fix(rt): preserve the sign bit of -0.0 - #37
Conversation
Value::Integer cannot represent -0.0: the whole-number normalization in value_from_stack folded -0.0 into Value::Integer(0), losing the sign bit. mlua and upstream Luau both keep -0.0 as a float. Skip the Integer normalization for zero. FromLua for i64 still accepts whole-number floats, so integer call sites are unaffected. The DEVIATION note in test_num_conversion is replaced by the correct mlua-parity assertion, and a dedicated round-trip regression test is added to mlua_conversion.
pjankiewicz
left a comment
There was a problem hiding this comment.
Thanks for the report — the symptom is real, but the diagnosis points at the wrong layer, and the patch as written regresses serde. Details below; I'd rather not merge this as-is.
1. The mlua-parity claim doesn't hold for Value
mlua 0.10.5, src/state/raw.rs (the luau / lua51 / lua52 / luajit arm of stack_value):
#[cfg(any(feature = "lua52", feature = "lua51", feature = "luajit", feature = "luau"))]
ffi::LUA_TNUMBER => {
let n = ffi::lua_tonumber(state, idx);
match num_traits::cast(n) {
Some(i) if (n - (i as Number)).abs() < Number::EPSILON => Value::Integer(i),
_ => Value::Number(n),
}
}For -0.0: num_traits::cast(-0.0f64) == Some(0i64), and (-0.0 - 0.0).abs() == 0.0 < EPSILON, so mlua produces Value::Integer(0) — the sign bit is dropped there too, exactly like luaur. So the DEVIATION note this PR removes is, at the Value layer, not a deviation at all.
2. Where mlua actually differs (the real gap)
mlua's lua_convert_float! gives f64/f32 a from_stack fast path that never builds a Value:
unsafe fn from_stack(idx: c_int, lua: &RawLua) -> Result<Self> {
if ffi::lua_type(state, idx) == ffi::LUA_TNUMBER {
let i = ffi::lua_tonumberx(state, idx, &mut ok);
if ok != 0 { return cast(i)...; }
}
...
}luaur-rt has no from_stack hook at all (traits.rs only mentions it in a comment) — every extraction round-trips through Value, which is why eval::<f64>() loses the sign here and not in mlua. That's the layer to fix: a stack fast path for the float conversions, leaving Value's integer normalization alone. That also fixes f32, and it fixes it for the same reason mlua does.
3. The patch regresses serde
is_exact_integer(n) && n != 0.0 is false for both zeros (-0.0 == 0.0), so every Lua 0 becomes Value::Number(0.0), not just the negative one. serde/de.rs forwards all integer types to deserialize_any, which dispatches Value::Number → visit_f64. Verified on this branch:
Error: DeserializeError("invalid type: floating point `0.0`, expected i64")
for lua.from_value::<Cfg>(...) where Cfg { count: u32, idx: i64, ratio: f64 } comes from { count = 0, idx = 0, ratio = 0 }. Same run on main: passes.
And the JSON shape changes:
main: {"m":1,"n":0}
this branch: {"m":1,"n":0.0}
Plus Value::is_integer() now returns false for a plain 0.
The existing suite doesn't catch either — cargo test -p luaur-rt without --features serde (the 247/248 runs quoted in these PRs) never builds the serde module, so these need the feature flags to reproduce.
Suggested path
Drop the value_from_stack change and add a from_stack fast path for f32/f64 instead (mirroring lua_convert_float!), with the round-trip test asserting on eval::<f64>() rather than on the Value variant. Happy to look at that as a follow-up PR — the checksum problem you hit is worth fixing properly.
|
Thanks for the thorough review — you're right on both points. The serde regression was a clear mistake on my part: I only ran On the diagnosis: agreed, the |
Problem
value_from_stacknormalizes whole-number floats toValue::Integer.-0.0passesis_exact_integer(fract() == 0.0, finite, in i64 range), so it folds intoValue::Integer((-0.0f64) as i64)=Value::Integer(0)— the sign bit is lost.mlua and upstream Luau both preserve
-0.0. This breaks round-tripping signed zeros through scripts (signed angles, normalization math, checksums over bit patterns).The existing
test_num_conversioneven pinned this as a DEVIATION from mlua/upstream, with an explicitassert!(!negative_zero.is_sign_negative()).Fix
Skip the Integer normalization for zero:
FromLua for i64already accepts whole-number floats (same as mlua), so integer call sites are unaffected — only theValuerepresentation of zero changes, fromInteger(0)toNumber(0.0).Testing
test_num_conversion: the DEVIATION note and!is_sign_negative()assertion are replaced with mlua-parity assertions (is_sign_negative()).test_negative_zero_round_trips_as_numberinmlua_conversion.rs:-0.0staysValue::Numberwith an intact sign bit through bothinto_luaand a script round trip;return 0andreturn 42behavior unchanged.cargo test -p luaur-rt: 247 passed, 0 failed (was 246 passed + 1 pinned-deviation test asserting the bug).mainare unchanged with and without this patch).Found while embedding luaur-rt in a game engine (gameplay checksums compare
f64::to_bitsof script-owned state;-0.0folding changed the checksum).