Skip to content

fix(rt): preserve the sign bit of -0.0 - #37

Closed
vi2q wants to merge 1 commit into
pjankiewicz:mainfrom
vi2q:fix/neg-zero-sign-preservation
Closed

fix(rt): preserve the sign bit of -0.0#37
vi2q wants to merge 1 commit into
pjankiewicz:mainfrom
vi2q:fix/neg-zero-sign-preservation

Conversation

@vi2q

@vi2q vi2q commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

value_from_stack normalizes whole-number floats to Value::Integer. -0.0 passes is_exact_integer (fract() == 0.0, finite, in i64 range), so it folds into Value::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_conversion even pinned this as a DEVIATION from mlua/upstream, with an explicit assert!(!negative_zero.is_sign_negative()).

Fix

Skip the Integer normalization for zero:

if is_exact_integer(n) && n != 0.0 {
    Value::Integer(n as i64)
} else {
    Value::Number(n)
}

FromLua for i64 already accepts whole-number floats (same as mlua), so integer call sites are unaffected — only the Value representation of zero changes, from Integer(0) to Number(0.0).

Testing

  • test_num_conversion: the DEVIATION note and !is_sign_negative() assertion are replaced with mlua-parity assertions (is_sign_negative()).
  • New test_negative_zero_round_trips_as_number in mlua_conversion.rs: -0.0 stays Value::Number with an intact sign bit through both into_lua and a script round trip; return 0 and return 42 behavior unchanged.
  • cargo test -p luaur-rt: 247 passed, 0 failed (was 246 passed + 1 pinned-deviation test asserting the bug).
  • Full workspace: no new failures (the 22 pre-existing CLI integration failures on clean main are unchanged with and without this patch).

Found while embedding luaur-rt in a game engine (gameplay checksums compare f64::to_bits of script-owned state; -0.0 folding changed the checksum).

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 pjankiewicz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Numbervisit_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.

@vi2q

vi2q commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — you're right on both points. The serde regression was a clear mistake on my part: I only ran cargo test -p luaur-rt without --features serde, so I never saw the deserialize_anyvisit_f64 breakage, and the n != 0.0 guard indeed folds plain 0 into Value::Number(0.0) too. Withdrawing the value_from_stack change.

On the diagnosis: agreed, the DEVIATION note was wrong at the Value layer — mlua's stack_value drops the sign bit the same way. Follow-up PR: #40, adding the from_stack fast path for f32/f64 (mirroring lua_convert_float!) with the round-trip tests asserting on eval::<f64>(), plus from_stack_multi on FromLuaMulti so Function::call/exec_raw can hand stack indices to the conversion without materializing Values first (that materialization was where the sign bit was lost). Your serde case passes with the patch. Feedback welcome.

@vi2q vi2q closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants