From b30afb80fb9a0350c326ffffa6b0e24ee9572d94 Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:56:03 +0900 Subject: [PATCH 1/8] fix integer numeric promotion Apply C# numeric promotion before selecting and calling Udon operators, so small integer operands are converted to the types expected by the externs. Share unary and binary promotion rules between checking and code generation. Promote small integer unary operands to int, uint negation to long, and mixed signed integer/uint operands to long. Reject incompatible numeric pairs and invalid shift operands instead of emitting unusable calls. Convert char through int for floating-point and decimal conversions, since the direct Convert overloads do not support those conversions. Add code-generation checks and SDK smoke coverage for promoted result types and signed/uint comparisons in both operand orders. Extend the emulator's UInt32-to-Int64 conversion support. Honor DOTNET_ROOT in the test reference lookup so these tests can also run with a configured Windows .NET runtime. --- src/men-sharp-asm/src/emulator.rs | 2 + .../src/generator/expressions.rs | 102 +++++++++++----- src/men-sharp-compiler/tests/codegen.rs | 68 ++++++++++- .../src/semantics/check/expressions.rs | 44 +++++++ .../src/semantics/check/operators.rs | 113 ++++++++++-------- .../src/types/conversions.rs | 43 +++++++ .../Assets/MenSharp/MenSharpRuntimeSmoke.cs | 20 ++++ .../Tests/Editor/MenSharpIntegrationTests.cs | 12 ++ 8 files changed, 318 insertions(+), 86 deletions(-) diff --git a/src/men-sharp-asm/src/emulator.rs b/src/men-sharp-asm/src/emulator.rs index c6db58b..43442e7 100644 --- a/src/men-sharp-asm/src/emulator.rs +++ b/src/men-sharp-asm/src/emulator.rs @@ -1250,10 +1250,12 @@ impl Emulator { Ok(()) } "SystemConvert.__ToInt64__SystemInt32__SystemInt64" + | "SystemConvert.__ToInt64__SystemUInt32__SystemInt64" | "SystemConvert.__ToInt64__SystemObject__SystemInt64" => { let args = self.pop_arguments(2)?; let value = match &self.heap[args[0]] { Value::Int64(value) => *value, + Value::UInt32(value) => i64::from(*value), other => i64::from(other.as_i32()?), }; self.heap[args[1]] = Value::Int64(value); diff --git a/src/men-sharp-codegen/src/generator/expressions.rs b/src/men-sharp-codegen/src/generator/expressions.rs index dc5ff93..684cd6e 100644 --- a/src/men-sharp-codegen/src/generator/expressions.rs +++ b/src/men-sharp-codegen/src/generator/expressions.rs @@ -1106,6 +1106,17 @@ impl<'a, 'ast> Generator<'a, 'ast> { { self.wrap_int32_to_small(ctx, source, &to_name, span) } + (Some(from_name), Some(to_name)) + if from_name == "SystemChar" + && matches!( + to_name.as_str(), + "SystemSingle" | "SystemDouble" | "SystemDecimal" + ) => + { + let int = self.corlib_type("Int32"); + let value = self.convert(ctx, source, from, &int, span.clone()); + self.convert(ctx, value, &int, to, span) + } (Some(from_name), Some(to_name)) if from_name != to_name => { let method = match to_name.as_str() { "SystemInt32" => Some("ToInt32"), @@ -2032,40 +2043,45 @@ impl<'a, 'ast> Generator<'a, 'ast> { span: Range, ) -> ((DataId, Type), (DataId, Type)) { use BinaryOperator::*; - let as_is = ((left.0, left.1.clone()), (right.0, right.1.clone())); - let (Some(left_rank), Some(right_rank)) = - (self.numeric_rank(left.1), self.numeric_rank(right.1)) - else { - return as_is; + let system = self.type_system(); + let (Some(l), Some(r)) = (system.numeric_kind(left.1), system.numeric_kind(right.1)) else { + return ((left.0, left.1.clone()), (right.0, right.1.clone())); }; - if matches!(operator, LeftShift | RightShift) { - return as_is; - } - // one type on both sides: its own operator applies (Udon has - // `char == char`, and no promotion is needed) - if self.extern_type_name(left.1) == self.extern_type_name(right.1) { - return as_is; - } - let comparison = matches!( - operator, - Equal | NotEqual | LessThan | GreaterThan | LessThanEqual | GreaterThanEqual - ); - let target = if !comparison && self.numeric_rank(result_type).is_some() { - result_type.clone() - } else if left_rank >= right_rank { - left.1.clone() + let shift = matches!(operator, LeftShift | RightShift | UnsignedRightShift); + let (l, r) = if shift { + (l, r) } else { - right.1.clone() + use men_sharp_semantics::types::conversions::NumericKind::*; + let promote_constant = |slot: DataId, kind, other| { + let fits = matches!(self.program.data[slot.0].init, HeapInit::Int32(v) if v >= 0) + || matches!(self.program.data[slot.0].init, HeapInit::Int64(v) if v >= 0); + if fits && matches!((kind, other), (Int32, UInt32 | UInt64) | (Int64, UInt64)) { + other + } else { + kind + } + }; + ( + promote_constant(left.0, l, r), + promote_constant(right.0, r, l), + ) }; - // a small type (byte, short, char) computes as an int - let target = if self.numeric_rank(&target) == Some(0) { + let kind = if shift { + Some(l.unary_promoted()) + } else { + l.binary_promoted(r) + }; + let target = kind + .map(|k| self.corlib_type(k.corlib_name())) + .unwrap_or_else(|| result_type.clone()); + let right_target = if shift { self.corlib_type("Int32") } else { - target + target.clone() }; let promoted_left = self.convert(ctx, left.0, left.1, &target, span.clone()); - let promoted_right = self.convert(ctx, right.0, right.1, &target, span); - ((promoted_left, target.clone()), (promoted_right, target)) + let promoted_right = self.convert(ctx, right.0, right.1, &right_target, span); + ((promoted_left, target), (promoted_right, right_target)) } /// A value as a `string`, via the type's own `ToString` extern. @@ -2254,6 +2270,11 @@ impl<'a, 'ast> Generator<'a, 'ast> { { return result; } + let operand = if self.numeric_rank(operand_type).is_some() { + self.convert(ctx, operand, operand_type, result_type, span.clone()) + } else { + operand + }; match operator { UnaryOperator::Not => { let out = self.temp("SystemBoolean"); @@ -2320,6 +2341,11 @@ impl<'a, 'ast> Generator<'a, 'ast> { "SystemUInt32" => { self.constant("SystemUInt32", "4294967295", HeapInit::UInt32(u32::MAX)) } + "SystemUInt64" => self.constant( + "SystemUInt64", + &u64::MAX.to_string(), + HeapInit::UInt64(u64::MAX), + ), _ => { self.error( ctx, @@ -2606,13 +2632,23 @@ impl<'a, 'ast> Generator<'a, 'ast> { node: Option, ) -> Option { let target = current.1.clone(); - let computed = if self.numeric_rank(&target) == Some(0) { - match self.numeric_rank(value.1) { - Some(rank) if rank > 1 => value.1.clone(), - _ => self.corlib_type("Int32"), + let system = self.type_system(); + let computed = match (system.numeric_kind(&target), system.numeric_kind(value.1)) { + (Some(l), Some(r)) => { + let kind = if matches!( + operator, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift + ) { + Some(l.unary_promoted()) + } else { + l.binary_promoted(r) + }; + kind.map(|k| self.corlib_type(k.corlib_name())) + .unwrap_or_else(|| target.clone()) } - } else { - target.clone() + _ => target.clone(), }; let result = self.emit_binary_operator( ctx, diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index 2f873ba..11e93be 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -6,7 +6,12 @@ use men_sharp_asm::{Emulator, Value}; use men_sharp_compiler::{Compiler, CompilerSettings, SourceCode}; fn dotnet_shared_dir() -> Option { - for root in ["/usr/share/dotnet", "/usr/lib/dotnet"] { + let configured = std::env::var("DOTNET_ROOT").ok(); + for root in configured + .as_deref() + .into_iter() + .chain(["/usr/share/dotnet", "/usr/lib/dotnet"]) + { let base = std::path::Path::new(root).join("shared/Microsoft.NETCore.App"); if let Ok(entries) = std::fs::read_dir(base) { let mut versions: Vec<_> = entries.flatten().collect(); @@ -14103,3 +14108,64 @@ fn an_initializer_udon_can_run_is_not_handed_to_the_proxy() { "{uasm}" ); } + +#[test] +fn integer_operands_are_promoted_before_calling_externs() { + // Small operands must be converted before calling Int32 externs; merely + // choosing the promoted result type leaves incompatible values on the heap. + for ty in ["sbyte", "byte", "short", "ushort", "char"] { + let source = format!( + r#" + namespace Game {{ public class Program {{ + public static object plus, minus, complement, remainder, shifted; + public static void Main() {{ + {ty} a = ({ty})13, b = ({ty})3; + plus = +a; minus = -a; complement = ~a; + remainder = a % b; shifted = a << b; + a %= b; a >>= b; + }} + }} }} + "# + ); + let Some((program, _)) = build(vec![SourceCode::new("test.cs", source.as_str())]) else { + return; + }; + let dump = program.dump(); + assert!(dump.contains("SystemConvert.__ToInt32__"), "{ty}: {dump}"); + assert!( + dump.contains("SystemInt32.__op_Remainder__SystemInt32_SystemInt32__SystemInt32"), + "{ty}: {dump}" + ); + assert!( + dump.contains("SystemInt32.__op_UnaryMinus__SystemInt32__SystemInt32"), + "{ty}: {dump}" + ); + } +} + +#[test] +fn signed_uint_comparisons_use_long_in_both_orders() { + // A negative signed operand cannot be converted to uint. Both operand + // orders must select long, as must unary minus on a uint operand. + let Some(emulator) = run( + r#" + namespace Game { public class Program { + public static int result; + public static long negated; + public static void Main() { + int a = -13; uint b = 3u; + if (a < b && b > a && a != b && b != a) result = 1; + negated = -b; + } + } } + "#, + "Main", + ) else { + return; + }; + assert_eq!(int_of(&emulator, "result"), 1); + assert!(matches!( + emulator.value_of("negated"), + Some(Value::Int64(-3)) + )); +} diff --git a/src/men-sharp-semantics/src/semantics/check/expressions.rs b/src/men-sharp-semantics/src/semantics/check/expressions.rs index 08cf76f..acf338d 100644 --- a/src/men-sharp-semantics/src/semantics/check/expressions.rs +++ b/src/men-sharp-semantics/src/semantics/check/expressions.rs @@ -207,6 +207,50 @@ impl<'a, 'ast> Checker<'a, 'ast> { other => other, }; } + // Non-negative int constants convert to uint/ulong before + // overload resolution; long constants may convert to ulong. + let promote_constant = |ty: Type, other: &Type, expression: &Expression| { + use crate::types::conversions::NumericKind::*; + let system = self.system(); + let pair = (system.numeric_kind(&ty), system.numeric_kind(other)); + let fits = super::exhaustive::integer_literal_value(expression) + .is_some_and(|v| v >= 0); + if fits + && matches!( + pair, + (Some(Int32), Some(UInt32 | UInt64)) | (Some(Int64), Some(UInt64)) + ) + { + other.clone() + } else { + ty + } + }; + let original_left = left.clone(); + let left = if !matches!( + binary.operator.value, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift + ) { + promote_constant(left, &right, &binary.left) + } else { + left + }; + let right = if let Ok(expression) = &binary.right { + if !matches!( + binary.operator.value, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift + ) { + promote_constant(right, &original_left, expression) + } else { + right + } + } else { + right + }; self.binary_type( binary.operator.value, left, diff --git a/src/men-sharp-semantics/src/semantics/check/operators.rs b/src/men-sharp-semantics/src/semantics/check/operators.rs index ff89d52..6e2f462 100644 --- a/src/men-sharp-semantics/src/semantics/check/operators.rs +++ b/src/men-sharp-semantics/src/semantics/check/operators.rs @@ -49,14 +49,19 @@ impl<'a, 'ast> Checker<'a, 'ast> { } UnaryOperator::Plus | UnaryOperator::Minus => { if let Some(kind) = system.numeric_kind(&operand) { - // small operands promote to int - let promoted = match kind { - NumericKind::SByte - | NumericKind::Byte - | NumericKind::Int16 - | NumericKind::UInt16 - | NumericKind::Char => NumericKind::Int32, - other => other, + let promoted = match (operator, kind) { + (UnaryOperator::Minus, NumericKind::UInt32) => NumericKind::Int64, + (UnaryOperator::Minus, NumericKind::UInt64) => { + self.error( + SemanticErrorKind::InvalidOperator { + left: self.describe(&operand), + right: None, + }, + span, + ); + return Type::Error; + } + _ => kind.unary_promoted(), }; self.corlib(promoted.corlib_name()) } else if let Some(result) = @@ -79,7 +84,11 @@ impl<'a, 'ast> Checker<'a, 'ast> { .unwrap_or(false) || system.is_enum_type(&operand) { - operand + if let Some(kind) = system.numeric_kind(&operand) { + self.corlib(kind.unary_promoted().corlib_name()) + } else { + operand + } } else if let Some(result) = self.user_defined_unary(operator, &operand, &span, node) { @@ -219,6 +228,12 @@ impl<'a, 'ast> Checker<'a, 'ast> { }; } Equal | NotEqual => { + if let (Some(l), Some(r)) = + (system.numeric_kind(&left), system.numeric_kind(&right)) + { + return self.numeric_binary(operator, l, r, span); + } + let comparable = matches!(left, Type::Null) || matches!(right, Type::Null) || (system.numeric_kind(&left).is_some() @@ -331,51 +346,45 @@ impl<'a, 'ast> Checker<'a, 'ast> { use BinaryOperator::*; use NumericKind::*; - let promoted = if left == Decimal || right == Decimal { - Decimal - } else if left == Double || right == Double { - Double - } else if left == Single || right == Single { - Single - } else if left == UInt64 || right == UInt64 { - UInt64 - } else if left == Int64 || right == Int64 { - Int64 - } else if left == UInt32 || right == UInt32 { - // uint with a signed operand goes to long - let signed = |kind: NumericKind| matches!(kind, SByte | Int16 | Int32); - if signed(left) || signed(right) { - Int64 - } else { - UInt32 - } + let shift = matches!(operator, LeftShift | RightShift | UnsignedRightShift); + let promoted = if shift { + (left.is_integral() && right.unary_promoted() == Int32).then_some(left.unary_promoted()) } else { - Int32 + left.binary_promoted(right) }; - - match operator { - LessThan | GreaterThan | LessThanEqual | GreaterThanEqual | Equal | NotEqual => { - self.corlib("Boolean") - } - LeftShift | RightShift | UnsignedRightShift => { - // the left operand alone decides, promoted to at least int - let shifted = match left { - SByte | Byte | Int16 | UInt16 | Char => Int32, - other => other, - }; - self.corlib(shifted.corlib_name()) - } - BitwiseAnd | BitwiseOr | BitwiseXor - if matches!(promoted, Single | Double | Decimal) => - { - let kind = SemanticErrorKind::InvalidOperator { - left: promoted.corlib_name().to_string(), - right: Some(promoted.corlib_name().to_string()), - }; - self.error(kind, span); - Type::Error - } - _ => self.corlib(promoted.corlib_name()), + let valid_operator = matches!( + operator, + Add | Subtract + | Multiply + | Divide + | Modulo + | LessThan + | GreaterThan + | LessThanEqual + | GreaterThanEqual + | Equal + | NotEqual + ) || (matches!(operator, BitwiseAnd | BitwiseOr | BitwiseXor) + && left.is_integral() + && right.is_integral()) + || shift; + let Some(promoted) = promoted.filter(|_| valid_operator) else { + self.error( + SemanticErrorKind::InvalidOperator { + left: left.corlib_name().to_string(), + right: Some(right.corlib_name().to_string()), + }, + span, + ); + return Type::Error; + }; + if matches!( + operator, + LessThan | GreaterThan | LessThanEqual | GreaterThanEqual | Equal | NotEqual + ) { + self.corlib("Boolean") + } else { + self.corlib(promoted.corlib_name()) } } diff --git a/src/men-sharp-semantics/src/types/conversions.rs b/src/men-sharp-semantics/src/types/conversions.rs index 9201359..fe6ee11 100644 --- a/src/men-sharp-semantics/src/types/conversions.rs +++ b/src/men-sharp-semantics/src/types/conversions.rs @@ -95,6 +95,49 @@ impl NumericKind { } } + /// Unary numeric promotion (§12.4.7.1), also used for shift operands. + pub fn unary_promoted(self) -> Self { + match self { + Self::SByte | Self::Byte | Self::Int16 | Self::UInt16 | Self::Char => Self::Int32, + other => other, + } + } + + /// Binary numeric promotion (§12.4.7.2). `None` means that the built-in + /// operator has no common operand type (for example, long with ulong). + pub fn binary_promoted(self, other: Self) -> Option { + use NumericKind::*; + let pair = [self, other]; + Some(if pair.contains(&Decimal) { + if pair.contains(&Single) || pair.contains(&Double) { + return None; + } + Decimal + } else if pair.contains(&Double) { + Double + } else if pair.contains(&Single) { + Single + } else if pair.contains(&UInt64) { + if pair + .iter() + .any(|k| matches!(k, SByte | Int16 | Int32 | Int64)) + { + return None; + } + UInt64 + } else if pair.contains(&Int64) { + Int64 + } else if pair.contains(&UInt32) { + if pair.iter().any(|k| matches!(k, SByte | Int16 | Int32)) { + Int64 + } else { + UInt32 + } + } else { + Int32 + }) + } + pub fn is_integral(&self) -> bool { !matches!( self, diff --git a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs index a567084..752b9bb 100644 --- a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs +++ b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs @@ -452,6 +452,26 @@ public void DefensiveCopies() public int smallUShort; public string smallChar; + // Check the actual boxed result types: a small heap slot cannot be + // passed straight to an Int32 extern even when its value fits. + public object[] promotedIntegers; + public bool signedUnsignedComparison; + public double charFloating; + public void IntegerPromotion() + { + sbyte sb = 13; byte b = 13; short sh = 13; ushort us = 13; char c = (char)13; + promotedIntegers = new object[] { + +sb, +b, +sh, +us, +c, + -sb, -b, -sh, -us, -c, + ~sb, ~b, ~sh, ~us, ~c, + sb % sb, b % b, sh % sh, us % us, c % c, + sb << b, b << sh, sh << us, us << c, c << 1 + }; + int negative = -13; uint positive = 3; + signedUnsignedComparison = negative < positive && positive > negative; + charFloating = c + 0.5; + } + public void SmallIntegers() { byte b = 1; diff --git a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs index 7a36018..66c22a1 100644 --- a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs +++ b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs @@ -298,6 +298,18 @@ public IEnumerator GeneratedProgramsExecuteInTheSdkUdonVm() Assert.AreEqual(3, smoke.GetProgramVariable("copyIn")); // small integral types: compound ops compute in int, cast back, wrap + smoke.RunProgram("IntegerPromotion"); + var promoted = (object[])smoke.GetProgramVariable("promotedIntegers"); + Assert.AreEqual(25, promoted.Length); + for (int i = 0; i < promoted.Length; i++) + { + Assert.IsInstanceOf(promoted[i], "promotion index " + i); + int expected = i < 5 ? 13 : i < 10 ? -13 : i < 15 ? -14 : i < 20 ? 0 : i == 24 ? 26 : 106496; + Assert.AreEqual(expected, promoted[i], "promotion index " + i); + } + Assert.AreEqual(true, smoke.GetProgramVariable("signedUnsignedComparison")); + Assert.AreEqual(13.5, smoke.GetProgramVariable("charFloating")); + smoke.RunProgram("SmallIntegers"); Assert.AreEqual(3, smoke.GetProgramVariable("smallByte")); Assert.AreEqual(1, smoke.GetProgramVariable("smallByteWrapped")); From 2e2546c747caf17e15c71617f1bcbed184f501e8 Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:58:56 +0900 Subject: [PATCH 2/8] impl wide integer remainder Lower uint, long and ulong remainder to division, multiplication and subtraction when a remainder extern is unavailable. Route division by zero through generated exception handling so user code can catch it. Keep this contribution within C# 9, as required by Unity 2022.3. Support wide remainder compound assignments and ulong complement using XOR. Retain ordinary Int32 shift emulation for C# 9 operator tests. Add codegen and SDK smoke cases for negative remainders, wide values and caught exceptions. The long.MinValue overflow case is covered by the separate fix long remainder overflow commit. --- src/men-sharp-asm/src/emulator.rs | 6 ++ .../src/generator/exceptions.rs | 22 +++++-- .../src/generator/expressions.rs | 65 ++++++++++++++++++- src/men-sharp-compiler/tests/codegen.rs | 41 ++++++++++++ .../Assets/MenSharp/MenSharpRuntimeSmoke.cs | 14 ++++ .../Tests/Editor/MenSharpIntegrationTests.cs | 5 ++ 6 files changed, 143 insertions(+), 10 deletions(-) diff --git a/src/men-sharp-asm/src/emulator.rs b/src/men-sharp-asm/src/emulator.rs index 43442e7..100d4a6 100644 --- a/src/men-sharp-asm/src/emulator.rs +++ b/src/men-sharp-asm/src/emulator.rs @@ -563,6 +563,12 @@ impl Emulator { | "SystemInt32.__op_Remainder__SystemInt32_SystemInt32__SystemInt32" => { binary_i32!(|a: i32, b: i32| Value::Int32(a.wrapping_rem(b))) } + "SystemInt32.__op_LeftShift__SystemInt32_SystemInt32__SystemInt32" => { + binary_i32!(|a: i32, b: i32| Value::Int32(a.wrapping_shl(b as u32))) + } + "SystemInt32.__op_RightShift__SystemInt32_SystemInt32__SystemInt32" => { + binary_i32!(|a: i32, b: i32| Value::Int32(a.wrapping_shr(b as u32))) + } "SystemInt32.__op_UnaryMinus__SystemInt32__SystemInt32" => { let args = self.pop_arguments(2)?; let a = self.heap[args[0]].as_i32()?; diff --git a/src/men-sharp-codegen/src/generator/exceptions.rs b/src/men-sharp-codegen/src/generator/exceptions.rs index f814ad6..71f5c0a 100644 --- a/src/men-sharp-codegen/src/generator/exceptions.rs +++ b/src/men-sharp-codegen/src/generator/exceptions.rs @@ -887,19 +887,27 @@ impl<'a, 'ast> Generator<'a, 'ast> { divisor_type: &Type, span: Range, ) { - if self.heap_type(divisor_type) != "SystemInt32" { - return; - } - if let HeapInit::Int32(value) = self.program.data[divisor.0].init - && value != 0 + let name = self.heap_type(divisor_type); + let init = match name.as_str() { + "SystemInt32" => HeapInit::Int32(0), + "SystemInt64" => HeapInit::Int64(0), + "SystemUInt32" => HeapInit::UInt32(0), + "SystemUInt64" => HeapInit::UInt64(0), + _ => return, + }; + if matches!(self.program.data[divisor.0].init, + HeapInit::Int32(v) if v != 0) + || matches!(self.program.data[divisor.0].init, HeapInit::Int64(v) if v != 0) + || matches!(self.program.data[divisor.0].init, HeapInit::UInt32(v) if v != 0) + || matches!(self.program.data[divisor.0].init, HeapInit::UInt64(v) if v != 0) { return; } - let zero = self.int_constant(0); + let zero = self.constant(&name, "0", init); let is_zero = self.temp("SystemBoolean"); self.call_extern( ctx, - "SystemInt32.__op_Equality__SystemInt32_SystemInt32__SystemBoolean", + &format!("{name}.__op_Equality__{name}_{name}__SystemBoolean"), &[divisor, zero, is_zero], span.clone(), ); diff --git a/src/men-sharp-codegen/src/generator/expressions.rs b/src/men-sharp-codegen/src/generator/expressions.rs index 684cd6e..8207ab9 100644 --- a/src/men-sharp-codegen/src/generator/expressions.rs +++ b/src/men-sharp-codegen/src/generator/expressions.rs @@ -1737,9 +1737,6 @@ impl<'a, 'ast> Generator<'a, 'ast> { if let Some(result) = self.user_operator_call(ctx, node, &[left, right], span.clone()) { return result; } - if matches!(operator, Divide | Modulo) { - self.check_divisor(ctx, right.0, right.1, span.clone()); - } let system_is = |ty: &Type, name: &str| { self.extern_type_name(ty) @@ -1788,6 +1785,18 @@ impl<'a, 'ast> Generator<'a, 'ast> { let left = (left.0, &left.1); let right = (right.0, &right.1); + if matches!(operator, Divide | Modulo) { + self.check_divisor(ctx, right.0, right.1, span.clone()); + } + if operator == Modulo + && matches!( + self.extern_type_name(left.1).as_deref(), + Some("SystemUInt32" | "SystemInt64" | "SystemUInt64") + ) + { + return self.integer_remainder(ctx, left, right.0, span); + } + let name = match operator { Add => "op_Addition", Subtract => "op_Subtraction", @@ -2011,6 +2020,56 @@ impl<'a, 'ast> Generator<'a, 'ast> { } } + /// Udon lacks remainder externs for uint, long and ulong. The quotient + /// is truncated, so a - (a / b) * b gives the same remainder without + /// overflowing the product. Handle long.MinValue / -1 before dividing. + fn integer_remainder( + &mut self, + ctx: &mut Ctx<'ast>, + left: (DataId, &Type), + right: DataId, + span: Range, + ) -> Option { + let name = self.extern_type_name(left.1)?; + let out = self.temp(&name); + let done = self.fresh_label("remainder_done"); + if name == "SystemInt64" { + let ordinary = self.fresh_label("remainder_divide"); + let minus_one = self.constant("SystemInt64", "-1", HeapInit::Int64(-1)); + let is_minus_one = self.temp("SystemBoolean"); + self.call_extern( + ctx, + "SystemInt64.__op_Equality__SystemInt64_SystemInt64__SystemBoolean", + &[right, minus_one, is_minus_one], + span.clone(), + ); + self.program.code.push(Op::Push(is_minus_one)); + self.program + .code + .push(Op::JumpIfFalse(Target::Label(ordinary))); + let zero = self.constant("SystemInt64", "0", HeapInit::Int64(0)); + self.copy(zero, out); + self.program.code.push(Op::Jump(Target::Label(done))); + self.program.code.push(Op::Label(ordinary)); + } + let quotient = self.temp(&name); + let product = self.temp(&name); + for (op, args) in [ + ("op_Division", [left.0, right, quotient]), + ("op_Multiplication", [quotient, right, product]), + ("op_Subtraction", [left.0, product, out]), + ] { + self.call_extern( + ctx, + &format!("{name}.__{op}__{name}_{name}__{name}"), + &args, + span.clone(), + ); + } + self.program.code.push(Op::Label(done)); + Some(out) + } + /// The rank of a numeric type in binary promotion; `None` for anything /// that is not a primitive number. fn numeric_rank(&self, ty: &Type) -> Option { diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index 11e93be..bf855e2 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -14169,3 +14169,44 @@ fn signed_uint_comparisons_use_long_in_both_orders() { Some(Value::Int64(-3)) )); } + +#[test] +fn wide_remainder_handles_boundaries() { + // Lowered remainder must preserve the dividend's sign and catchable exceptions. + let Some(emulator) = run( + r#" + namespace Game { public class Program { + public static long negative, min; + public static ulong wide, complement; + public static int caught; + public static void Main() { + long a = -13L, b = 3L; + negative = a % b; + long lowest = long.MinValue, minusOne = -1L; + min = lowest % minusOne; + ulong top = ulong.MaxValue, divisor = 10UL; + wide = top % divisor; + complement = ~top; + long zero = 0L; + try { negative %= zero; } catch (System.DivideByZeroException) { caught = 1; } + } + } } + "#, + "Main", + ) else { + return; + }; + for (name, value) in [("negative", -1), ("min", 0)] { + assert!( + matches!(emulator.value_of(name), Some(Value::Int64(v)) if *v == value), + "{name}" + ); + } + assert!(matches!(emulator.value_of("wide"), Some(Value::UInt64(5)))); + assert!(matches!( + emulator.value_of("complement"), + Some(Value::UInt64(0)) + )); + + assert_eq!(int_of(&emulator, "caught"), 1); +} diff --git a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs index 752b9bb..7c00aab 100644 --- a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs +++ b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs @@ -472,6 +472,20 @@ public void IntegerPromotion() charFloating = c + 0.5; } + public object[] wideIntegerResults; + public int remainderZeroCaught; + public void WideIntegers() + { + uint u = uint.MaxValue, three = 3; + long l = long.MinValue, minusOne = -1; + ulong ul = ulong.MaxValue, ten = 10; + wideIntegerResults = new object[] { u % three, l % minusOne, ul % ten, ~ul }; + u %= three; ul %= ten; + wideIntegerResults[0] = u; wideIntegerResults[2] = ul; + ulong zero = 0; + try { ul %= zero; } catch (DivideByZeroException) { remainderZeroCaught = 1; } + } + public void SmallIntegers() { byte b = 1; diff --git a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs index 66c22a1..a314575 100644 --- a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs +++ b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs @@ -310,6 +310,11 @@ public IEnumerator GeneratedProgramsExecuteInTheSdkUdonVm() Assert.AreEqual(true, smoke.GetProgramVariable("signedUnsignedComparison")); Assert.AreEqual(13.5, smoke.GetProgramVariable("charFloating")); + smoke.RunProgram("WideIntegers"); + CollectionAssert.AreEqual(new object[] { 0u, 0L, 5UL, 0UL }, + (object[])smoke.GetProgramVariable("wideIntegerResults")); + Assert.AreEqual(1, smoke.GetProgramVariable("remainderZeroCaught")); + smoke.RunProgram("SmallIntegers"); Assert.AreEqual(3, smoke.GetProgramVariable("smallByte")); Assert.AreEqual(1, smoke.GetProgramVariable("smallByteWrapped")); From d80a44f20f5e931a3e177b93204166d8e7dd5874 Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:00:49 +0900 Subject: [PATCH 3/8] fix small integer metadata constants Preserve sbyte, byte, short and ushort metadata constants as their declared heap types instead of initializing their slots with boxed Int32 values. A numerically correct value with the wrong boxed type can fail at a Udon extern call or when the value is consumed through object. Add typed HeapInit variants, serialize their metadata kinds, and decode those kinds to the corresponding CLR types in the Unity importer. Keep the emulator's existing Int32 representation for small integer values. Inspect generated heap initializers directly in the compiler regression test, since emulator values alone cannot detect this type mismatch. Add SDK smoke assertions for both the values and their actual boxed types. --- src/men-sharp-asm/src/emulator.rs | 4 ++ src/men-sharp-asm/src/program.rs | 16 ++++++++ src/men-sharp-codegen/src/generator.rs | 20 ++++++++++ src/men-sharp-compiler/tests/codegen.rs | 38 +++++++++++++++++++ .../Assets/MenSharp/MenSharpRuntimeSmoke.cs | 11 ++++++ .../Tests/Editor/MenSharpIntegrationTests.cs | 13 +++++++ .../Editor/MenSharpProgramAsset.cs | 4 ++ 7 files changed, 106 insertions(+) diff --git a/src/men-sharp-asm/src/emulator.rs b/src/men-sharp-asm/src/emulator.rs index 100d4a6..eb8772b 100644 --- a/src/men-sharp-asm/src/emulator.rs +++ b/src/men-sharp-asm/src/emulator.rs @@ -303,6 +303,10 @@ impl Emulator { _ => Value::Null, }, HeapInit::Boolean(v) => Value::Boolean(*v), + HeapInit::SByte(v) => Value::Int32(i32::from(*v)), + HeapInit::Byte(v) => Value::Int32(i32::from(*v)), + HeapInit::Int16(v) => Value::Int32(i32::from(*v)), + HeapInit::UInt16(v) => Value::Int32(i32::from(*v)), HeapInit::Int32(v) => Value::Int32(*v), HeapInit::Int64(v) => Value::Int64(*v), HeapInit::UInt32(v) => Value::UInt32(*v), diff --git a/src/men-sharp-asm/src/program.rs b/src/men-sharp-asm/src/program.rs index 7795980..3356b30 100644 --- a/src/men-sharp-asm/src/program.rs +++ b/src/men-sharp-asm/src/program.rs @@ -50,6 +50,10 @@ pub type UdonType = String; pub enum HeapInit { Null, Boolean(bool), + SByte(i8), + Byte(u8), + Int16(i16), + UInt16(u16), Int32(i32), Int64(i64), UInt32(u32), @@ -632,6 +636,18 @@ impl Program { HeapInit::Boolean(v) => { let _ = write!(out, "Boolean\", \"value\": \"{v}\""); } + HeapInit::SByte(v) => { + let _ = write!(out, "SByte\", \"value\": \"{v}\""); + } + HeapInit::Byte(v) => { + let _ = write!(out, "Byte\", \"value\": \"{v}\""); + } + HeapInit::Int16(v) => { + let _ = write!(out, "Int16\", \"value\": \"{v}\""); + } + HeapInit::UInt16(v) => { + let _ = write!(out, "UInt16\", \"value\": \"{v}\""); + } HeapInit::Int32(v) => { let _ = write!(out, "Int32\", \"value\": \"{v}\""); } diff --git a/src/men-sharp-codegen/src/generator.rs b/src/men-sharp-codegen/src/generator.rs index 816b1aa..c29fbeb 100644 --- a/src/men-sharp-codegen/src/generator.rs +++ b/src/men-sharp-codegen/src/generator.rs @@ -3156,6 +3156,26 @@ impl<'a, 'ast> Generator<'a, 'ast> { ); return Some(slot); } + // Metadata stores all small integer constants as Int/UInt, + // but their boxed heap values must retain the declared type. + let integer = match constant { + ExternalConstant::Int(v) => Some(*v), + ExternalConstant::UInt(v) => i64::try_from(*v).ok(), + _ => None, + }; + if let Some(value) = integer { + let name = self.heap_type(ty); + let init = match name.as_str() { + "SystemSByte" => Some(HeapInit::SByte(value as i8)), + "SystemByte" => Some(HeapInit::Byte(value as u8)), + "SystemInt16" => Some(HeapInit::Int16(value as i16)), + "SystemUInt16" => Some(HeapInit::UInt16(value as u16)), + _ => None, + }; + if let Some(init) = init { + return Some(self.constant(&name, &value.to_string(), init)); + } + } let slot = match constant { ExternalConstant::Int(value) => match self.heap_type(ty).as_str() { "SystemInt64" => self.constant( diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index bf855e2..073bace 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -14210,3 +14210,41 @@ fn wide_remainder_handles_boundaries() { assert_eq!(int_of(&emulator, "caught"), 1); } + +#[test] +fn small_integer_metadata_constants_keep_their_heap_types() { + // The emulator represents small integers as Int32, so checking its values + // would miss an initializer with the right value but the wrong boxed type. + // Inspect the heap initializers that the Unity importer uses instead. + let Some((program, _)) = build(vec![SourceCode::new( + "test.cs", + r#" + namespace Game { public class Program { + public static object[] limits; + public static void Main() { + limits = new object[] { sbyte.MinValue, byte.MaxValue, short.MinValue, ushort.MaxValue }; + } + } } + "#, + )]) else { + return; + }; + for (name, value) in [ + ("SystemSByte", -128), + ("SystemByte", 255), + ("SystemInt16", -32768), + ("SystemUInt16", 65535), + ] { + let slot = program.data.iter().find(|s| { + s.udon_type == name + && match s.init { + men_sharp_asm::HeapInit::SByte(v) => i64::from(v) == value, + men_sharp_asm::HeapInit::Byte(v) => i64::from(v) == value, + men_sharp_asm::HeapInit::Int16(v) => i64::from(v) == value, + men_sharp_asm::HeapInit::UInt16(v) => i64::from(v) == value, + _ => false, + } + }); + assert!(slot.is_some(), "missing typed constant {name}"); + } +} diff --git a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs index 7c00aab..67390f4 100644 --- a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs +++ b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs @@ -486,6 +486,17 @@ public void WideIntegers() try { ul %= zero; } catch (DivideByZeroException) { remainderZeroCaught = 1; } } + public object[] smallLimits; + public object[] smallLimitsAfter; + public void SmallIntegerConstants() + { + sbyte sb = sbyte.MaxValue; byte b = byte.MaxValue; + short sh = short.MaxValue; ushort us = ushort.MaxValue; + smallLimits = new object[] { sb, b, sh, us }; + sb++; b++; sh++; us++; + smallLimitsAfter = new object[] { sb, b, sh, us }; + } + public void SmallIntegers() { byte b = 1; diff --git a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs index a314575..b6ca404 100644 --- a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs +++ b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs @@ -315,6 +315,19 @@ public IEnumerator GeneratedProgramsExecuteInTheSdkUdonVm() (object[])smoke.GetProgramVariable("wideIntegerResults")); Assert.AreEqual(1, smoke.GetProgramVariable("remainderZeroCaught")); + smoke.RunProgram("SmallIntegerConstants"); + var limits = (object[])smoke.GetProgramVariable("smallLimits"); + var limitsAfter = (object[])smoke.GetProgramVariable("smallLimitsAfter"); + object[] expectedLimits = { sbyte.MaxValue, byte.MaxValue, short.MaxValue, ushort.MaxValue }; + object[] expectedAfter = { sbyte.MinValue, (byte)0, short.MinValue, (ushort)0 }; + for (int i = 0; i < limits.Length; i++) + { + Assert.AreEqual(expectedLimits[i].GetType(), limits[i].GetType()); + Assert.AreEqual(expectedLimits[i], limits[i]); + Assert.AreEqual(expectedAfter[i].GetType(), limitsAfter[i].GetType()); + Assert.AreEqual(expectedAfter[i], limitsAfter[i]); + } + smoke.RunProgram("SmallIntegers"); Assert.AreEqual(3, smoke.GetProgramVariable("smallByte")); Assert.AreEqual(1, smoke.GetProgramVariable("smallByteWrapped")); diff --git a/unity/io.tesca.mensharp/Editor/MenSharpProgramAsset.cs b/unity/io.tesca.mensharp/Editor/MenSharpProgramAsset.cs index c33ad43..65c2c4a 100644 --- a/unity/io.tesca.mensharp/Editor/MenSharpProgramAsset.cs +++ b/unity/io.tesca.mensharp/Editor/MenSharpProgramAsset.cs @@ -529,6 +529,10 @@ private static object Decode(MenSharpHeapEntry entry) { switch (entry.kind) { + case "SByte": return sbyte.Parse(entry.value); + case "Byte": return byte.Parse(entry.value); + case "Int16": return short.Parse(entry.value); + case "UInt16": return ushort.Parse(entry.value); case "Int32": return int.Parse(entry.value); case "UInt32": return uint.Parse(entry.value); case "UInt64": return ulong.Parse(entry.value); From ccc55a512ece4203d83c431630c19a6210bd3e3e Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:02:34 +0900 Subject: [PATCH 4/8] fix numeric compound assignment validation Validate predefined numeric compound assignments instead of accepting any numeric target merely because the result can be narrowed back to it. When result narrowing is needed, require the right operand to convert implicitly to the target type, with the shift exception and fitting int constant conversions handled explicitly. Add separate compilation checks for invalid operator and operand pairs, including byte += long, byte += 300 and char += int. List each source body in an array so one shared checking path exercises every rejection case; the assertion includes the rejected body to identify a failure. Change the existing char test from c += 1 to c += (char)1. C# has no implicit int-to-char constant conversion, so the original expression was invalid. The explicit cast keeps the intended test: promote both operands to int for addition, then convert the compound assignment result to char. --- src/men-sharp-compiler/tests/codegen.rs | 34 ++++++++++++++- .../src/semantics/check/expressions.rs | 41 ++++++++++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index 073bace..316ba01 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -1787,7 +1787,7 @@ fn small_integer_compound_assignment_computes_in_int_and_casts_back() { char c = 'A'; c++; after = c.ToString(); // "B" - c += 1; + c += (char)1; code = c; // 67 char w = (char)65601; // 65601 & 0xFFFF = 65: an unchecked cast wraps wrapped = w.ToString(); // "A" @@ -14248,3 +14248,35 @@ fn small_integer_metadata_constants_keep_their_heap_types() { assert!(slot.is_some(), "missing typed constant {name}"); } } + +#[test] +fn invalid_numeric_operators_are_rejected() { + // A usable Udon conversion does not make an operator legal in C#. + // Reject invalid operand pairs during checking, including compound + // assignments whose right operand cannot implicitly convert to the left. + let Some(dir) = dotnet_shared_dir() else { + return; + }; + let compiler = Compiler::new(CompilerSettings::default()).unwrap(); + let bytes = vec![std::fs::read(dir.join("System.Private.CoreLib.dll")).unwrap()]; + let references = compiler.load_references(&bytes).unwrap(); + for body in [ + "long a = 1; ulong b = 1; var c = a + b;", + "long a = 1; ulong b = 1; var c = a == b;", + "int a = 1; uint b = 1; var c = a << b;", + "float a = 1; var c = a << 1;", + "decimal a = 1; double b = 1; var c = a + b;", + "int a = 1; var c = a && a;", + "ulong a = 1; var c = -a;", + "byte a = 1; long b = 1; a += b;", + "char a = 'a'; a += 1;", + "byte a = 1; a += 300;", + ] { + let source = format!("class Probe {{ void Run() {{ {body} }} }}"); + let files = compiler.parse(vec![SourceCode::new("test.cs", source)]); + let declarations = compiler.collect_declarations(&files); + let signatures = compiler.resolve_signatures(&declarations, &references); + let bodies = compiler.check_bodies(&declarations, &signatures, &references); + assert!(!bodies.errors.is_empty(), "accepted {body}"); + } +} diff --git a/src/men-sharp-semantics/src/semantics/check/expressions.rs b/src/men-sharp-semantics/src/semantics/check/expressions.rs index acf338d..601edf8 100644 --- a/src/men-sharp-semantics/src/semantics/check/expressions.rs +++ b/src/men-sharp-semantics/src/semantics/check/expressions.rs @@ -104,17 +104,48 @@ impl<'a, 'ast> Checker<'a, 'ast> { let result = self.binary_type( operator, target.clone(), - value_type, + value_type.clone(), literal, zero_literal, assignment.span.clone(), Some(EntityID::from(*assignment)), ); - // compound assignment narrows back implicitly (int += byte) + // A predefined compound operator may narrow its result, + // but the RHS must still convert implicitly to the LHS + // (except for shifts). `byte += byte` is valid; `byte += long` is not. if !matches!(result, Type::Error) { - let compatible = - self.system().is_implicitly_convertible(&result, &target) - || self.system().numeric_kind(&target).is_some(); + let system = self.system(); + let numeric = system.numeric_kind(&target).is_some() + && system.numeric_kind(&value_type).is_some(); + let shift = matches!( + operator, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift + ); + let constant_fits = if system.numeric_kind(&value_type) + == Some(crate::types::conversions::NumericKind::Int32) + { + super::exhaustive::integer_literal_value(value).is_some_and(|v| { + use crate::types::conversions::NumericKind::*; + match system.numeric_kind(&target) { + Some(SByte) => i8::try_from(v).is_ok(), + Some(Byte) => u8::try_from(v).is_ok(), + Some(Int16) => i16::try_from(v).is_ok(), + Some(UInt16) => u16::try_from(v).is_ok(), + Some(UInt32) => u32::try_from(v).is_ok(), + Some(UInt64) => v >= 0, + _ => false, + } + }) + } else { + false + }; + let compatible = system.is_implicitly_convertible(&result, &target) + || (numeric + && (shift + || system.is_implicitly_convertible(&value_type, &target) + || constant_fits)); if !compatible { let kind = SemanticErrorKind::TypeMismatch { expected: self.describe(&target), From fe54f3b50f815449cf9a4d78076679372ff6525d Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:09:51 +0900 Subject: [PATCH 5/8] fix constant operands in integer compound assignment Accept fitting signed integer constants as unsigned compound-assignment operands and perform the operation in the selected unsigned type. Avoid converting a large unsigned left operand to a signed type before arithmetic. Recognize unary signs and parentheses when reading integer literals, so a case such as sbyte a = 1; a += (-1) remains valid. Apply the corresponding constant-aware operand selection in checking and code generation. Add an unchecked wrapping regression starting at ulong.MaxValue with +=, -= and *=, plus a compilation check for the parenthesized negative constant. The boundary value exposes an erroneous signed conversion. --- .../src/generator/expressions.rs | 9 ++++++ src/men-sharp-compiler/tests/codegen.rs | 32 +++++++++++++++++++ .../src/semantics/check/exhaustive.rs | 11 +++++++ .../src/semantics/check/expressions.rs | 24 +++++++++++++- 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/men-sharp-codegen/src/generator/expressions.rs b/src/men-sharp-codegen/src/generator/expressions.rs index 8207ab9..abe5434 100644 --- a/src/men-sharp-codegen/src/generator/expressions.rs +++ b/src/men-sharp-codegen/src/generator/expressions.rs @@ -2694,6 +2694,15 @@ impl<'a, 'ast> Generator<'a, 'ast> { let system = self.type_system(); let computed = match (system.numeric_kind(&target), system.numeric_kind(value.1)) { (Some(l), Some(r)) => { + use men_sharp_semantics::types::conversions::NumericKind::*; + let fits = matches!(self.program.data[value.0.0].init, HeapInit::Int32(v) if v >= 0) + || matches!(self.program.data[value.0.0].init, HeapInit::Int64(v) if v >= 0); + let r = if fits && matches!((r, l), (Int32, UInt32 | UInt64) | (Int64, UInt64)) { + l + } else { + r + }; + let kind = if matches!( operator, BinaryOperator::LeftShift diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index 316ba01..43b1b86 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -14280,3 +14280,35 @@ fn invalid_numeric_operators_are_rejected() { assert!(!bodies.errors.is_empty(), "accepted {body}"); } } + +#[test] +fn unsigned_compound_assignment_accepts_constant_operands() { + // A fitting int constant can select an unsigned operator. Starting at + // ulong.MaxValue exposes an incorrect signed conversion before arithmetic. + let Some(emulator) = run( + r#" + namespace Game { public class Program { + public static ulong wrapped; + public static void Main() { + ulong a = ulong.MaxValue; + a += 2; a -= 2; a *= 2; + wrapped = a; + } + } } + "#, + "Main", + ) else { + return; + }; + assert!(matches!(emulator.value_of("wrapped"), Some(Value::UInt64(v)) if *v == u64::MAX - 1)); + let Some(_) = build(vec![SourceCode::new( + "test.cs", + r#" + namespace Game { public class Program { + public static void Main() { sbyte a = 1; a += (-1); } + } } + "#, + )]) else { + return; + }; +} diff --git a/src/men-sharp-semantics/src/semantics/check/exhaustive.rs b/src/men-sharp-semantics/src/semantics/check/exhaustive.rs index b639567..0aa0014 100644 --- a/src/men-sharp-semantics/src/semantics/check/exhaustive.rs +++ b/src/men-sharp-semantics/src/semantics/check/exhaustive.rs @@ -90,12 +90,23 @@ fn bool_literal(expression: &Expression) -> Option { /// allowed to be. Anything else is `None`: the member is then a case of /// its own, which is right unless it aliases another. pub(super) fn integer_literal_value(expression: &Expression) -> Option { + if let Expression::Unary(unary) = expression { + let value = integer_literal_value(unary.operand.as_ref().ok()?)?; + return match unary.operator.value { + men_sharp_parser::ast::UnaryOperator::Plus => Some(value), + men_sharp_parser::ast::UnaryOperator::Minus => value.checked_neg(), + _ => None, + }; + } let Expression::Primary(primary) = expression else { return None; }; if !primary.chain.is_empty() { return None; } + if let PrimaryLeft::Parenthesized { expression, .. } = &primary.left { + return integer_literal_value(expression); + } let PrimaryLeft::Literal(LiteralExpression::Integer(text)) = &primary.left else { return None; }; diff --git a/src/men-sharp-semantics/src/semantics/check/expressions.rs b/src/men-sharp-semantics/src/semantics/check/expressions.rs index 601edf8..9432347 100644 --- a/src/men-sharp-semantics/src/semantics/check/expressions.rs +++ b/src/men-sharp-semantics/src/semantics/check/expressions.rs @@ -101,10 +101,32 @@ impl<'a, 'ast> Checker<'a, 'ast> { .ok() .and_then(super::exhaustive::integer_literal_value) == Some(0); + let operator_value_type = { + use crate::types::conversions::NumericKind::*; + let pair = ( + self.system().numeric_kind(&value_type), + self.system().numeric_kind(&target), + ); + if !matches!( + operator, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift + ) && matches!( + pair, + (Some(Int32), Some(UInt32 | UInt64)) | (Some(Int64), Some(UInt64)) + ) && super::exhaustive::integer_literal_value(value) + .is_some_and(|v| v >= 0) + { + target.clone() + } else { + value_type.clone() + } + }; let result = self.binary_type( operator, target.clone(), - value_type.clone(), + operator_value_type, literal, zero_literal, assignment.span.clone(), From b53c7ddd59758f39a9f5d0b4d9e7ab8b410509d9 Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:28:27 +0900 Subject: [PATCH 6/8] fix long remainder overflow Raise a catchable OverflowException for long.MinValue % -1, matching Unity Mono, instead of returning zero from the lowered remainder operation. Keep this check separate from the DivideByZeroException path. Update the compiler and SDK smoke tests to catch the overflow and verify that execution reaches the catch body. Adjust the SDK expected result to match the new sentinel value rather than the previously expected zero. --- .../src/generator/expressions.rs | 22 ++++++++++++++----- src/men-sharp-compiler/tests/codegen.rs | 5 +++-- .../Assets/MenSharp/MenSharpRuntimeSmoke.cs | 4 +++- .../Tests/Editor/MenSharpIntegrationTests.cs | 3 ++- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/men-sharp-codegen/src/generator/expressions.rs b/src/men-sharp-codegen/src/generator/expressions.rs index abe5434..bceec5e 100644 --- a/src/men-sharp-codegen/src/generator/expressions.rs +++ b/src/men-sharp-codegen/src/generator/expressions.rs @@ -2032,7 +2032,6 @@ impl<'a, 'ast> Generator<'a, 'ast> { ) -> Option { let name = self.extern_type_name(left.1)?; let out = self.temp(&name); - let done = self.fresh_label("remainder_done"); if name == "SystemInt64" { let ordinary = self.fresh_label("remainder_divide"); let minus_one = self.constant("SystemInt64", "-1", HeapInit::Int64(-1)); @@ -2047,9 +2046,23 @@ impl<'a, 'ast> Generator<'a, 'ast> { self.program .code .push(Op::JumpIfFalse(Target::Label(ordinary))); - let zero = self.constant("SystemInt64", "0", HeapInit::Int64(0)); - self.copy(zero, out); - self.program.code.push(Op::Jump(Target::Label(done))); + let minimum = self.constant( + "SystemInt64", + &i64::MIN.to_string(), + HeapInit::Int64(i64::MIN), + ); + let is_minimum = self.temp("SystemBoolean"); + self.call_extern( + ctx, + "SystemInt64.__op_Equality__SystemInt64_SystemInt64__SystemBoolean", + &[left.0, minimum, is_minimum], + span.clone(), + ); + self.program.code.push(Op::Push(is_minimum)); + self.program + .code + .push(Op::JumpIfFalse(Target::Label(ordinary))); + self.throw_new(ctx, &["System", "OverflowException"], None, span.clone()); self.program.code.push(Op::Label(ordinary)); } let quotient = self.temp(&name); @@ -2066,7 +2079,6 @@ impl<'a, 'ast> Generator<'a, 'ast> { span.clone(), ); } - self.program.code.push(Op::Label(done)); Some(out) } diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index 43b1b86..29b285e 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -14183,7 +14183,8 @@ fn wide_remainder_handles_boundaries() { long a = -13L, b = 3L; negative = a % b; long lowest = long.MinValue, minusOne = -1L; - min = lowest % minusOne; + // Unity Mono throws here; the lowered remainder must remain catchable. + try { min = lowest % minusOne; } catch (System.OverflowException) { min = 7; } ulong top = ulong.MaxValue, divisor = 10UL; wide = top % divisor; complement = ~top; @@ -14196,7 +14197,7 @@ fn wide_remainder_handles_boundaries() { ) else { return; }; - for (name, value) in [("negative", -1), ("min", 0)] { + for (name, value) in [("negative", -1), ("min", 7)] { assert!( matches!(emulator.value_of(name), Some(Value::Int64(v)) if *v == value), "{name}" diff --git a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs index 67390f4..3e72a52 100644 --- a/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs +++ b/tests/unity-project/Assets/MenSharp/MenSharpRuntimeSmoke.cs @@ -474,12 +474,14 @@ public void IntegerPromotion() public object[] wideIntegerResults; public int remainderZeroCaught; + public int remainderOverflowCaught; public void WideIntegers() { uint u = uint.MaxValue, three = 3; long l = long.MinValue, minusOne = -1; ulong ul = ulong.MaxValue, ten = 10; - wideIntegerResults = new object[] { u % three, l % minusOne, ul % ten, ~ul }; + wideIntegerResults = new object[] { u % three, l % 3L, ul % ten, ~ul }; + try { l %= minusOne; } catch (OverflowException) { remainderOverflowCaught = 1; } u %= three; ul %= ten; wideIntegerResults[0] = u; wideIntegerResults[2] = ul; ulong zero = 0; diff --git a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs index b6ca404..d8e6559 100644 --- a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs +++ b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs @@ -311,9 +311,10 @@ public IEnumerator GeneratedProgramsExecuteInTheSdkUdonVm() Assert.AreEqual(13.5, smoke.GetProgramVariable("charFloating")); smoke.RunProgram("WideIntegers"); - CollectionAssert.AreEqual(new object[] { 0u, 0L, 5UL, 0UL }, + CollectionAssert.AreEqual(new object[] { 0u, -2L, 5UL, 0UL }, (object[])smoke.GetProgramVariable("wideIntegerResults")); Assert.AreEqual(1, smoke.GetProgramVariable("remainderZeroCaught")); + Assert.AreEqual(1, smoke.GetProgramVariable("remainderOverflowCaught")); smoke.RunProgram("SmallIntegerConstants"); var limits = (object[])smoke.GetProgramVariable("smallLimits"); From ad826a37d36045dd8dcec71dfdedaf50bc2da410 Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:28:27 +0900 Subject: [PATCH 7/8] fix constant expression operator binding Retain the numeric operand types selected by semantic checking and pass that information through to code generation. Re-inferring an operator from an Int32 constant heap slot can otherwise discard the constant conversion that made an unsigned operation valid. Evaluate integral constant expressions used for operator binding, including const locals and fields, parentheses, unary/binary operations and integral casts. Track const local values separately from ordinary initialized locals, bound recursive evaluation, and preserve integer width for shift evaluation. Merge the recorded promotions across checked files and retain fallback promotion for synthesized operations that have no checked expression. Add regressions using a const field, a const local and arithmetic constant expressions with ulong operations and comparisons. Also check byte compound assignment with a const local and a masked constant shift. These tests cover the supported binding cases, not every C# constant-expression form. --- .../src/generator/expressions.rs | 95 +++++----- src/men-sharp-compiler/tests/codegen.rs | 44 +++++ .../src/semantics/check.rs | 7 + .../src/semantics/check/checker.rs | 167 +++++++++++++++++- .../src/semantics/check/expressions.rs | 8 +- .../src/semantics/check/operators.rs | 14 +- .../src/semantics/check/statements.rs | 15 ++ 7 files changed, 291 insertions(+), 59 deletions(-) diff --git a/src/men-sharp-codegen/src/generator/expressions.rs b/src/men-sharp-codegen/src/generator/expressions.rs index bceec5e..fcbc12e 100644 --- a/src/men-sharp-codegen/src/generator/expressions.rs +++ b/src/men-sharp-codegen/src/generator/expressions.rs @@ -1780,8 +1780,23 @@ impl<'a, 'ast> Generator<'a, 'ast> { // binary numeric promotion (§12.4.7): `long == int`, `float * int` // — both operands are brought to the wider type first, as an extern // takes two slots of exactly its own type - let (left, right) = - self.promote_numeric_operands(ctx, operator, left, right, result_type, span.clone()); + let selected = node + .and_then(|id| self.bodies.numeric_promotions.get(&id)) + .cloned(); + let (left, right) = if let Some((left_type, right_type)) = selected { + ( + ( + self.convert(ctx, left.0, left.1, &left_type, span.clone()), + left_type, + ), + ( + self.convert(ctx, right.0, right.1, &right_type, span.clone()), + right_type, + ), + ) + } else { + self.promote_numeric_operands(ctx, operator, left, right, result_type, span.clone()) + }; let left = (left.0, &left.1); let right = (right.0, &right.1); @@ -2100,10 +2115,9 @@ impl<'a, 'ast> Generator<'a, 'ast> { }) } - /// Both operands of a numeric operator converted to the promoted type: - /// the result type for arithmetic, the wider operand for comparisons. - /// A shift keeps its `int` count. Operands that are not both numeric - /// come back as they were. + /// Promotion for synthesized numeric operations such as ++. Explicit + /// expressions use the operand types recorded by the checker above. + /// A shift keeps its `int` count; non-numeric operands stay as they were. fn promote_numeric_operands( &mut self, ctx: &mut Ctx<'ast>, @@ -2119,24 +2133,6 @@ impl<'a, 'ast> Generator<'a, 'ast> { return ((left.0, left.1.clone()), (right.0, right.1.clone())); }; let shift = matches!(operator, LeftShift | RightShift | UnsignedRightShift); - let (l, r) = if shift { - (l, r) - } else { - use men_sharp_semantics::types::conversions::NumericKind::*; - let promote_constant = |slot: DataId, kind, other| { - let fits = matches!(self.program.data[slot.0].init, HeapInit::Int32(v) if v >= 0) - || matches!(self.program.data[slot.0].init, HeapInit::Int64(v) if v >= 0); - if fits && matches!((kind, other), (Int32, UInt32 | UInt64) | (Int64, UInt64)) { - other - } else { - kind - } - }; - ( - promote_constant(left.0, l, r), - promote_constant(right.0, r, l), - ) - }; let kind = if shift { Some(l.unary_promoted()) } else { @@ -2691,7 +2687,8 @@ impl<'a, 'ast> Generator<'a, 'ast> { /// `x` (byte, sbyte, short, ushort, char) C# computes in `int` — or in /// `y`'s wider type — and casts back, `x = (byte)(x + y)`; Udon has no /// operators on the small types, so this is also the only way to - /// compute it. Anything else computes in `x`'s own type. + /// compute it. The checker supplies the operand types for explicit + /// compound assignments, including constant-expression conversions. #[allow(clippy::too_many_arguments)] fn compound_result( &mut self, @@ -2704,32 +2701,28 @@ impl<'a, 'ast> Generator<'a, 'ast> { ) -> Option { let target = current.1.clone(); let system = self.type_system(); - let computed = match (system.numeric_kind(&target), system.numeric_kind(value.1)) { - (Some(l), Some(r)) => { - use men_sharp_semantics::types::conversions::NumericKind::*; - let fits = matches!(self.program.data[value.0.0].init, HeapInit::Int32(v) if v >= 0) - || matches!(self.program.data[value.0.0].init, HeapInit::Int64(v) if v >= 0); - let r = if fits && matches!((r, l), (Int32, UInt32 | UInt64) | (Int64, UInt64)) { - l - } else { - r - }; - - let kind = if matches!( - operator, - BinaryOperator::LeftShift - | BinaryOperator::RightShift - | BinaryOperator::UnsignedRightShift - ) { - Some(l.unary_promoted()) - } else { - l.binary_promoted(r) - }; - kind.map(|k| self.corlib_type(k.corlib_name())) - .unwrap_or_else(|| target.clone()) - } - _ => target.clone(), - }; + let computed = + if let Some((left, _)) = node.and_then(|id| self.bodies.numeric_promotions.get(&id)) { + left.clone() + } else { + match (system.numeric_kind(&target), system.numeric_kind(value.1)) { + (Some(l), Some(r)) => { + let kind = if matches!( + operator, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift + ) { + Some(l.unary_promoted()) + } else { + l.binary_promoted(r) + }; + kind.map(|k| self.corlib_type(k.corlib_name())) + .unwrap_or_else(|| target.clone()) + } + _ => target.clone(), + } + }; let result = self.emit_binary_operator( ctx, operator, diff --git a/src/men-sharp-compiler/tests/codegen.rs b/src/men-sharp-compiler/tests/codegen.rs index 29b285e..bf4e75e 100644 --- a/src/men-sharp-compiler/tests/codegen.rs +++ b/src/men-sharp-compiler/tests/codegen.rs @@ -14313,3 +14313,47 @@ fn unsigned_compound_assignment_accepts_constant_operands() { return; }; } + +#[test] +fn constant_expressions_keep_the_selected_unsigned_operator() { + // Constant conversions also apply to const fields, locals and expressions. + // Code generation must retain the operator selected by the checker rather + // than infer it again from the constant's original Int32 heap type. + let Some(emulator) = run( + r#" + namespace Game { public class Program { + const int FieldDelta = 2; + public static ulong wrapped; + public static int comparison; + public static void Main() { + const int two = 2; + ulong a = ulong.MaxValue; + a += FieldDelta; + wrapped = a + (1 + 1); + if (a == (two - 1)) comparison = 1; + } + } } + "#, + "Main", + ) else { + return; + }; + assert!(matches!( + emulator.value_of("wrapped"), + Some(Value::UInt64(3)) + )); + assert_eq!(int_of(&emulator, "comparison"), 1); + let Some(_) = build(vec![SourceCode::new( + "test.cs", + r#" + namespace Game { public class Program { + public static void Main() { + const int delta = 2; + byte b = 1; b += delta; b += (1 << 32); + } + } } + "#, + )]) else { + return; + }; +} diff --git a/src/men-sharp-semantics/src/semantics/check.rs b/src/men-sharp-semantics/src/semantics/check.rs index 99953dd..64e88df 100644 --- a/src/men-sharp-semantics/src/semantics/check.rs +++ b/src/men-sharp-semantics/src/semantics/check.rs @@ -74,6 +74,8 @@ use crate::types::{FunctionSignature, Type}; pub struct BodyCheck { /// A type for every checked expression node, keyed by node identity. pub expression_types: HashMap, + /// Operand types selected for built-in numeric operators. + pub numeric_promotions: HashMap, /// Expression-level type references (casts, `new T`, locals, ...), resolved. pub resolved_types: HashMap, /// What each name/call/member node *bound to* — the code generator's map @@ -136,6 +138,7 @@ pub struct LocalFunctionSignature { impl BodyCheck { pub fn merge(&mut self, other: BodyCheck) { self.expression_types.extend(other.expression_types); + self.numeric_promotions.extend(other.numeric_promotions); self.resolved_types.extend(other.resolved_types); self.targets.extend(other.targets); self.attribute_types.extend(other.attribute_types); @@ -345,6 +348,7 @@ pub fn check_file( local_order: 0, local_function_order: HashMap::default(), expression_types: HashMap::default(), + numeric_promotions: HashMap::default(), attribute_types: HashMap::default(), targets: HashMap::default(), pattern_inputs: HashMap::default(), @@ -391,6 +395,7 @@ pub fn check_file( BodyCheck { expression_types: checker.expression_types, + numeric_promotions: checker.numeric_promotions, resolved_types: checker.resolver.out.type_of, targets: checker.targets, attribute_types: checker.attribute_types, @@ -538,6 +543,7 @@ struct Scope<'ast> { /// what a local function declared above it may not reach (CS0841). struct LocalVariable { ty: Type, + integer_constant: Option, order: usize, } @@ -591,6 +597,7 @@ struct Checker<'a, 'ast> { /// themselves are visible throughout their block, above and below. local_function_order: HashMap, expression_types: HashMap, + numeric_promotions: HashMap, targets: HashMap, /// See [`BodyCheck::attribute_types`]. attribute_types: HashMap, diff --git a/src/men-sharp-semantics/src/semantics/check/checker.rs b/src/men-sharp-semantics/src/semantics/check/checker.rs index 74e44fb..6b3ad8c 100644 --- a/src/men-sharp-semantics/src/semantics/check/checker.rs +++ b/src/men-sharp-semantics/src/semantics/check/checker.rs @@ -145,7 +145,14 @@ impl<'a, 'ast> Checker<'a, 'ast> { self.local_order += 1; let order = self.local_order; if let Some(scope) = self.locals.last_mut() { - scope.locals.insert(name, LocalVariable { ty, order }); + scope.locals.insert( + name, + LocalVariable { + ty, + order, + integer_constant: None, + }, + ); } } @@ -155,6 +162,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { self.local_order += 1; LocalVariable { ty, + integer_constant: None, order: self.local_order, } } @@ -291,6 +299,163 @@ impl<'a, 'ast> Checker<'a, 'ast> { } } + /// Integral constant expressions used by operator overload resolution. + /// Keep values only for const locals, never for ordinary variables whose + /// initializer happens to be a literal. + pub(super) fn integer_constant(&self, expression: &Expression<'ast, 'ast>) -> Option { + self.integer_constant_inner(expression, 64) + } + + fn integer_constant_inner( + &self, + expression: &Expression<'ast, 'ast>, + depth: usize, + ) -> Option { + use crate::types::conversions::NumericKind::*; + if depth == 0 { + return None; + } + let Some(ty) = self.expression_types.get(&EntityID::from(expression)) else { + return super::exhaustive::integer_literal_value(expression).map(i128::from); + }; + let kind = self.system().numeric_kind(ty)?; + if !kind.is_integral() { + return None; + } + let value = match expression { + Expression::Primary(primary) + if self + .targets + .get( + &primary + .chain + .last() + .map(EntityID::from) + .unwrap_or_else(|| EntityID::from(&primary.left)), + ) + .is_some_and(|t| matches!(t, super::ResolvedTarget::Member(_))) => + { + let node = primary + .chain + .last() + .map(EntityID::from) + .unwrap_or_else(|| EntityID::from(&primary.left)); + let super::ResolvedTarget::Member(member) = self.targets.get(&node)? else { + return None; + }; + match &member.origin { + crate::types::lookup::MemberOrigin::External { member, .. } => { + match member.constant.as_ref()? { + crate::ExternalConstant::Int(v) => i128::from(*v), + crate::ExternalConstant::UInt(v) => i128::from(*v), + _ => return None, + } + } + crate::types::lookup::MemberOrigin::Source(id) => { + let symbol = self.resolver.declarations.table.symbol(*id); + let value = symbol.declarations.iter().find_map(|site| { + if let crate::symbol::SyntaxRef::Field { field, declarator } = + site.syntax + && field + .modifiers + .iter() + .any(|m| m.value == men_sharp_parser::ast::Modifier::Const) + && let Some(men_sharp_parser::ast::InitializerValue::Expression( + value, + )) = &declarator.initializer + { + return Some(value); + } + None + })?; + self.integer_constant_inner(value, depth - 1)? + } + _ => return None, + } + } + Expression::Primary(primary) if primary.chain.is_empty() => match &primary.left { + PrimaryLeft::Parenthesized { expression, .. } => { + self.integer_constant_inner(expression, depth - 1)? + } + PrimaryLeft::Identifier { name, .. } => { + self.locals + .iter() + .rev() + .find_map(|scope| scope.locals.get(name.value))? + .integer_constant? + } + _ => i128::from(super::exhaustive::integer_literal_value(expression)?), + }, + Expression::Cast(cast) => { + self.integer_constant_inner(cast.value.as_ref().ok()?, depth - 1)? + } + Expression::Unary(unary) => { + let value = self.integer_constant_inner(unary.operand.as_ref().ok()?, depth - 1)?; + match unary.operator.value { + UnaryOperator::Plus => value, + UnaryOperator::Minus => value.checked_neg()?, + UnaryOperator::BitwiseNot => !value, + _ => return None, + } + } + Expression::Binary(binary) => { + let left = self.integer_constant_inner(&binary.left, depth - 1)?; + let right = self.integer_constant_inner(binary.right.as_ref().ok()?, depth - 1)?; + match binary.operator.value { + BinaryOperator::Add => left.checked_add(right)?, + BinaryOperator::Subtract => left.checked_sub(right)?, + BinaryOperator::Multiply => left.checked_mul(right)?, + BinaryOperator::Divide => left.checked_div(right)?, + BinaryOperator::Modulo => left.checked_rem(right)?, + BinaryOperator::BitwiseAnd => left & right, + BinaryOperator::BitwiseOr => left | right, + BinaryOperator::BitwiseXor => left ^ right, + BinaryOperator::LeftShift + | BinaryOperator::RightShift + | BinaryOperator::UnsignedRightShift => { + let bits = if matches!(kind, Int64 | UInt64) { + 64 + } else { + 32 + }; + let count = (right as u32) & (bits - 1); + let shifted = match binary.operator.value { + BinaryOperator::LeftShift => left << count, + BinaryOperator::UnsignedRightShift if bits == 32 => { + i128::from((left as u32) >> count) + } + BinaryOperator::UnsignedRightShift => { + i128::from((left as u64) >> count) + } + _ => left >> count, + }; + match kind { + Int32 => i128::from(shifted as i32), + UInt32 => i128::from(shifted as u32), + Int64 => i128::from(shifted as i64), + UInt64 => i128::from(shifted as u64), + _ => return None, + } + } + _ => return None, + } + } + _ => return None, + }; + let fits = match kind { + SByte => i8::try_from(value).is_ok(), + Byte => u8::try_from(value).is_ok(), + Int16 => i16::try_from(value).is_ok(), + UInt16 | Char => u16::try_from(value).is_ok(), + Int32 => i32::try_from(value).is_ok(), + UInt32 => u32::try_from(value).is_ok(), + Int64 => i64::try_from(value).is_ok(), + UInt64 => u64::try_from(value).is_ok(), + _ => false, + }; + fits.then_some(value) + } + /// The constant-expression allowance, without evaluating: an integer /// constant expression may sit in any integral slot (the C# LSP checks the /// actual range). diff --git a/src/men-sharp-semantics/src/semantics/check/expressions.rs b/src/men-sharp-semantics/src/semantics/check/expressions.rs index 9432347..85127a0 100644 --- a/src/men-sharp-semantics/src/semantics/check/expressions.rs +++ b/src/men-sharp-semantics/src/semantics/check/expressions.rs @@ -115,8 +115,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { ) && matches!( pair, (Some(Int32), Some(UInt32 | UInt64)) | (Some(Int64), Some(UInt64)) - ) && super::exhaustive::integer_literal_value(value) - .is_some_and(|v| v >= 0) + ) && self.integer_constant(value).is_some_and(|v| v >= 0) { target.clone() } else { @@ -148,7 +147,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { let constant_fits = if system.numeric_kind(&value_type) == Some(crate::types::conversions::NumericKind::Int32) { - super::exhaustive::integer_literal_value(value).is_some_and(|v| { + self.integer_constant(value).is_some_and(|v| { use crate::types::conversions::NumericKind::*; match system.numeric_kind(&target) { Some(SByte) => i8::try_from(v).is_ok(), @@ -266,8 +265,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { use crate::types::conversions::NumericKind::*; let system = self.system(); let pair = (system.numeric_kind(&ty), system.numeric_kind(other)); - let fits = super::exhaustive::integer_literal_value(expression) - .is_some_and(|v| v >= 0); + let fits = self.integer_constant(expression).is_some_and(|v| v >= 0); if fits && matches!( pair, diff --git a/src/men-sharp-semantics/src/semantics/check/operators.rs b/src/men-sharp-semantics/src/semantics/check/operators.rs index 6e2f462..8af6457 100644 --- a/src/men-sharp-semantics/src/semantics/check/operators.rs +++ b/src/men-sharp-semantics/src/semantics/check/operators.rs @@ -231,7 +231,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { if let (Some(l), Some(r)) = (system.numeric_kind(&left), system.numeric_kind(&right)) { - return self.numeric_binary(operator, l, r, span); + return self.numeric_binary(operator, l, r, span, node); } let comparable = matches!(left, Type::Null) @@ -283,7 +283,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { if let (Some(left_kind), Some(right_kind)) = (system.numeric_kind(&left), system.numeric_kind(&right)) { - return self.numeric_binary(operator, left_kind, right_kind, span); + return self.numeric_binary(operator, left_kind, right_kind, span, node); } // enums @@ -342,6 +342,7 @@ impl<'a, 'ast> Checker<'a, 'ast> { left: NumericKind, right: NumericKind, span: Range, + node: Option, ) -> Type { use BinaryOperator::*; use NumericKind::*; @@ -378,6 +379,15 @@ impl<'a, 'ast> Checker<'a, 'ast> { ); return Type::Error; }; + if let Some(node) = node { + let left = self.corlib(promoted.corlib_name()); + let right = if shift { + self.corlib("Int32") + } else { + left.clone() + }; + self.numeric_promotions.insert(node, (left, right)); + } if matches!( operator, LessThan | GreaterThan | LessThanEqual | GreaterThanEqual | Equal | NotEqual diff --git a/src/men-sharp-semantics/src/semantics/check/statements.rs b/src/men-sharp-semantics/src/semantics/check/statements.rs index 18f738c..02a9211 100644 --- a/src/men-sharp-semantics/src/semantics/check/statements.rs +++ b/src/men-sharp-semantics/src/semantics/check/statements.rs @@ -353,7 +353,22 @@ impl<'a, 'ast> Checker<'a, 'ast> { (declared, _) => declared.clone(), }; + let constant = if declaration.const_keyword.is_some() { + match &declarator.initializer { + Some(InitializerValue::Expression(value)) => self.integer_constant(value), + _ => None, + } + } else { + None + }; self.declare_local(declarator.name.value, ty); + if let Some(local) = self + .locals + .last_mut() + .and_then(|s| s.locals.get_mut(declarator.name.value)) + { + local.integer_constant = constant; + } } } From 519bfb730832e68d56228b7ae9a9870817da6e6d Mon Sep 17 00:00:00 2001 From: ureishi <57707826+ureishi@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:08:16 +0900 Subject: [PATCH 8/8] fix infinity metadata output Write positive and negative floating-point infinity as Infinity and -Infinity in compiler-generated heap metadata. Rust's Debug formatting uses inf and -inf, which Unity 2022.3 Mono's invariant-culture Parse rejects. The compiler owns this interchange format; keep the Unity importer unchanged. Preserve the existing finite-value and NaN formatting for both Single and Double. Previously generated inf metadata must be regenerated with the updated compiler. Add a serialization regression covering both types, both infinities, NaN, a finite value and signed zeros. Add eight Unity decoder cases checking the emitted spellings and exact boxed types through the existing importer. --- src/men-sharp-asm/src/lib.rs | 33 +++++++++++++++++++ src/men-sharp-asm/src/program.rs | 26 +++++++++++++-- .../Tests/Editor/MenSharpIntegrationTests.cs | 31 +++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/men-sharp-asm/src/lib.rs b/src/men-sharp-asm/src/lib.rs index 3e60005..41fffba 100644 --- a/src/men-sharp-asm/src/lib.rs +++ b/src/men-sharp-asm/src/lib.rs @@ -23,6 +23,39 @@ pub use world::World; mod tests { use super::*; + #[test] + fn floating_metadata_uses_dotnet_special_value_spellings() { + // Unity Mono parses Infinity/-Infinity, not Rust's inf/-inf. Keep + // finite formatting (including signed zero) and NaN unchanged. + let mut asm = Asm::new(); + for (name, value, expected) in [ + ("positive", f64::INFINITY, "Infinity"), + ("negative", f64::NEG_INFINITY, "-Infinity"), + ("nan", f64::NAN, "NaN"), + ("finite", 1.25, "1.25"), + ("zero", 0.0, "0.0"), + ("negative_zero", -0.0, "-0.0"), + ] { + asm.slot( + &format!("single_{name}"), + "SystemSingle", + HeapInit::Single(value as f32), + ); + asm.slot( + &format!("double_{name}"), + "SystemDouble", + HeapInit::Double(value), + ); + let meta = asm.program.to_meta_json().unwrap(); + for (prefix, kind) in [("single", "Single"), ("double", "Double")] { + let entry = format!( + "\"name\": \"{prefix}_{name}\", \"kind\": \"{kind}\", \"value\": \"{expected}\"" + ); + assert!(meta.contains(&entry), "{meta}"); + } + } + } + /// Small builder so tests read like assembly listings. struct Asm { program: Program, diff --git a/src/men-sharp-asm/src/program.rs b/src/men-sharp-asm/src/program.rs index 3356b30..5639930 100644 --- a/src/men-sharp-asm/src/program.rs +++ b/src/men-sharp-asm/src/program.rs @@ -661,10 +661,32 @@ impl Program { let _ = write!(out, "UInt64\", \"value\": \"{v}\""); } HeapInit::Single(v) => { - let _ = write!(out, "Single\", \"value\": \"{v:?}\""); + // Metadata is read by Unity Mono using invariant-culture Parse. + // Rust Debug spells infinity as inf, which that parser rejects. + if v.is_infinite() { + let text = if v.is_sign_negative() { + "-Infinity" + } else { + "Infinity" + }; + let _ = write!(out, "Single\", \"value\": \"{text}\""); + } else { + let _ = write!(out, "Single\", \"value\": \"{v:?}\""); + } } HeapInit::Double(v) => { - let _ = write!(out, "Double\", \"value\": \"{v:?}\""); + // Metadata is read by Unity Mono using invariant-culture Parse. + // Rust Debug spells infinity as inf, which that parser rejects. + if v.is_infinite() { + let text = if v.is_sign_negative() { + "-Infinity" + } else { + "Infinity" + }; + let _ = write!(out, "Double\", \"value\": \"{text}\""); + } else { + let _ = write!(out, "Double\", \"value\": \"{v:?}\""); + } } HeapInit::Decimal(v) => { out.push_str("Decimal\", \"value\": "); diff --git a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs index d8e6559..3046d13 100644 --- a/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs +++ b/tests/unity-project/Assets/Tests/Editor/MenSharpIntegrationTests.cs @@ -68,6 +68,37 @@ public class MenSharpIntegrationTests "VerifyWake", }; + [TestCase("Single", "NaN")] + [TestCase("Single", "1.25")] + [TestCase("Single", "Infinity")] + [TestCase("Single", "-Infinity")] + [TestCase("Double", "NaN")] + [TestCase("Double", "1.25")] + [TestCase("Double", "Infinity")] + [TestCase("Double", "-Infinity")] + public void FloatingMetadataPreservesSpecialValuesAndBoxedTypes(string kind, string text) + { + // Verify the metadata spellings emitted by the compiler on Unity Mono. + // Assert boxed types as well as values: Single slots must receive floats. + var decode = typeof(MenSharpProgramAsset).GetMethod("Decode", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(decode); + object actual = decode.Invoke(null, new object[] { + new MenSharpHeapEntry { kind = kind, value = text } + }); + + if (kind == "Single") + { + Assert.IsInstanceOf(actual); + Assert.AreEqual(float.Parse(text, System.Globalization.CultureInfo.InvariantCulture), actual); + } + else + { + Assert.IsInstanceOf(actual); + Assert.AreEqual(double.Parse(text, System.Globalization.CultureInfo.InvariantCulture), actual); + } + } + [Test] public void CompilerCreatesEveryManualFixtureAsAnSdkProgramAsset() {