From d1242df0067206d474e2b959e586b166cf1cefa1 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Wed, 5 Aug 2026 08:55:30 +0200 Subject: [PATCH 1/3] carry Lua numbers as doubles A Lua number is a C double: luaconf.h sets LUA_FLOAT_DEFAULT to LUA_FLOAT_DOUBLE, so lua_Number is double. push-float/get-float were registered against lua_pushnumber/lua_tonumber with Float, and because lua.h declares the prototypes C narrowed at every call site. Measured against a real 5.4 state: 3.141592653589793 came back as 3.1415927410125732, 16777217 came back as 16777216, and 1e300 came back as inf. All silent. Register push-double/get-double at the honest C type and generate the parallel Luax family (maybe-get-double, set/get-double-global, set/get-double-field) through the existing macros. push-float/get-float keep their signatures and become thin conversions over the double pair, so every current caller still compiles and behaves identically -- the float family is now documented as lossy rather than being the only way to reach a Lua number. Making them Carp functions also removes a latent hazard: a registered extern with a mismatched signature is only safe when called directly, since taking its address yields a function pointer of the wrong type. Tests: 19 new assertions across test/lua.carp and test/midlevel.carp. Verified they detect the bug by routing push-double/get-double back through Float -- 11 of them fail against that, the remaining 8 are type-guard and float-family pins that are meant to hold either way. --- README.md | 7 ++++- lua.carp | 57 ++++++++++++++++++++++++++------- test/lua.carp | 53 +++++++++++++++++++++++++++++++ test/midlevel.carp | 78 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 996467b..b62a486 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ state and closes it when the block exits: ``` The `Lua` module wraps the Lua C API directly: stack operations -(`push-int`, `push-float`, `push-bool`, `push-string`, `get-int`, etc.), +(`push-int`, `push-double`, `push-bool`, `push-string`, `get-int`, etc.), globals (`get-global`, `set-global`), tables (`create-table`, `set-field`, `get-field`, `next`), code execution (`do-string`, `do-file`, `call`), and type checking (`type-of`, `TYPE_NIL`, `TYPE_NUMBER`, etc.). It also provides a @@ -44,6 +44,11 @@ few conveniences: `Lua.fun` for defining Lua functions inline, `Lua.val` for evaluating Lua expressions into globals, and `Lua.eval-file` for loading Lua files with error handling. +A Lua number is a C double, so `push-double` and `get-double` (and the matching +`Luax.set-double-global`, `Luax.get-double-field`, …) move numbers across +without losing anything. The `float` versions still work, but they round to +single precision and turn anything above the float range into an infinity. + The `Luax` module provides safe wrappers that return `Maybe` and `Result` types instead of requiring manual type checks: diff --git a/lua.carp b/lua.carp index a772c1e..6f72aa5 100644 --- a/lua.carp +++ b/lua.carp @@ -163,8 +163,13 @@ automatically.") (register push-light-user-data (Fn [&Lua (Ptr ())] ()) "lua_pushlightuserdata") (doc push-nil "Push nil onto the stack.") (register push-nil (Fn [&Lua] ()) "lua_pushnil") - (doc push-float "Push a float (Lua number) onto the stack.") - (register push-float (Fn [&Lua Float] ()) "lua_pushnumber") + (doc push-double "Push a double (Lua number) onto the stack. Lua numbers are +C doubles, so this is the lossless way to hand Lua a number.") + (register push-double (Fn [&Lua Double] ()) "lua_pushnumber") + (doc push-float "Push a float onto the stack as a Lua number. Lua numbers are +doubles, so the value arrives carrying only single precision; use +[`push-double`](#push-double) to push the full value.") + (defn push-float [lua f] (push-double lua (Double.from-float f))) (doc push-string "Push a C string onto the stack. For Carp strings, use [`push-carp-str`](#push-carp-str) instead.") (deftemplate push-string @@ -189,9 +194,15 @@ elements on the stack.") (doc get-int "Read the value at `index` as an integer. Does not check the type—use [`Luax.maybe-get-int`](#maybe-get-int) for a safe version.") (register get-int (Fn [&Lua Int] Int) "lua_tointeger") - (doc get-float "Read the value at `index` as a float. Does not check the -type—use [`Luax.maybe-get-float`](#maybe-get-float) for a safe version.") - (register get-float (Fn [&Lua Int] Float) "lua_tonumber") + (doc get-double "Read the value at `index` as a double. Does not check the +type—use [`Luax.maybe-get-double`](#maybe-get-double) for a safe version.") + (register get-double (Fn [&Lua Int] Double) "lua_tonumber") + (doc get-float "Read the value at `index` as a float. Lua numbers are doubles, +so everything past single precision is dropped and anything above the float range +comes back as an infinity; use [`get-double`](#get-double) to read the value +intact. Does not check the type—use [`Luax.maybe-get-float`](#maybe-get-float) +for a safe version.") + (defn get-float [lua index] (Double.to-float (get-double lua index))) (doc get-string "Read the value at `index` as a C string pointer. Returns a raw pointer; prefer [`Luax.get-carp-str`](#get-carp-str) or [`Luax.maybe-get-string`](#maybe-get-string) for safe access.") @@ -595,20 +606,25 @@ the Lua standard library available. ``` Values are passed between Carp and Lua through the stack. Push values with -[`push-int`](#push-int), [`push-float`](#push-float), +[`push-int`](#push-int), [`push-double`](#push-double), [`push-bool`](#push-bool), [`push-carp-str`](#push-carp-str), etc. Read them -back with [`get-int`](#get-int), [`get-float`](#get-float), and so on, using +back with [`get-int`](#get-int), [`get-double`](#get-double), and so on, using negative indices to address from the top of the stack (-1 is the top element). ``` ; push two values, read the top one (Lua.push-int lua 42) -(Lua.push-float lua 3.14f) -(let [f (Lua.get-float lua -1) ; 3.14 - i (Lua.get-int lua -2)] ; 42 +(Lua.push-double lua 3.14) +(let [d (Lua.get-double lua -1) ; 3.14 + i (Lua.get-int lua -2)] ; 42 (Lua.pop lua 2)) ``` +A Lua number is a C double. [`push-float`](#push-float) and +[`get-float`](#get-float) still work, but they round to single precision and +turn anything above the float range into an infinity, so prefer the `double` +pair for numbers you care about. + To call a Lua function at the low level, push the function, then its arguments, then use [`call`](#call) with the argument and result counts. The result replaces the function and arguments on the stack. @@ -616,9 +632,9 @@ replaces the function and arguments on the stack. ``` (Lua.get-global lua (cstr \"math\")) (ignore (Lua.get-field lua -1 (cstr \"sqrt\"))) -(Lua.push-float lua 9.0f) +(Lua.push-double lua 9.0) (ignore (Lua.call lua 1 1 0)) -(IO.println &(str (Lua.get-float lua -1))) ; 3.0 +(IO.println &(str (Lua.get-double lua -1))) ; 3.0 (Lua.pop lua 2) ; pop result and math table ``` @@ -782,6 +798,11 @@ and assigns it to a global in one expression: (String.from-cstr-or (Lua.to-string lua index) @"")) (luax--def-maybe-get int Lua.TYPE_NUMBER Lua.get-int "an integer" "a number") + (luax--def-maybe-get double + Lua.TYPE_NUMBER + Lua.get-double + "a double" + "a number") (luax--def-maybe-get float Lua.TYPE_NUMBER Lua.get-float "a float" "a number") (luax--def-maybe-get bool Lua.TYPE_BOOLEAN @@ -804,6 +825,7 @@ unchanged.") (Maybe.Nothing)))) (luax--def-set-global int Lua.push-int) + (luax--def-set-global double Lua.push-double) (luax--def-set-global float Lua.push-float) (luax--def-set-global bool Lua.push-bool) ; `string` is defined explicitly rather than via luax--def-set-global: the @@ -814,6 +836,11 @@ unchanged.") (do (Lua.push-carp-str lua value) (Lua.set-global lua (cstr name)))) (luax--def-get-global int Lua.TYPE_NUMBER Lua.get-int "an integer" "a number") + (luax--def-get-global double + Lua.TYPE_NUMBER + Lua.get-double + "a double" + "a number") (luax--def-get-global float Lua.TYPE_NUMBER Lua.get-float "a float" "a number") (luax--def-get-global bool Lua.TYPE_BOOLEAN @@ -833,11 +860,17 @@ unchanged.") result))) (luax--def-set-field int Lua.push-int) + (luax--def-set-field double Lua.push-double) (luax--def-set-field float Lua.push-float) (luax--def-set-field bool Lua.push-bool) (luax--def-set-field string Lua.push-carp-str) (luax--def-get-field int Lua.TYPE_NUMBER Lua.get-int "an integer" "a number") + (luax--def-get-field double + Lua.TYPE_NUMBER + Lua.get-double + "a double" + "a number") (luax--def-get-field float Lua.TYPE_NUMBER Lua.get-float "a float" "a number") (luax--def-get-field bool Lua.TYPE_BOOLEAN diff --git a/test/lua.carp b/test/lua.carp index d246ebd..d57baa4 100644 --- a/test/lua.carp +++ b/test/lua.carp @@ -48,6 +48,59 @@ 2.5f (Lua.with-lua-do (Lua.push-float lua 2.5f) (Lua.get-float lua -1)) "push-float / get-float round-trip") + (assert-equal test + 3.1415926535897931 + (Lua.with-lua-do (Lua.push-double lua 3.1415926535897931) + (Lua.get-double lua -1)) + "push-double / get-double round-trip keeps all 17 significant digits") + (assert-equal test + 16777217.0 + (Lua.with-lua-do (Lua.libs lua) + (ignore (Luax.do-in lua "n = 16777217")) + (Lua.get-global lua (cstr "n")) + (Lua.get-double lua -1)) + "get-double reads a Lua integer above 2^24 intact") + (assert-true test + (let [n (Lua.with-lua-do (Lua.libs lua) + (ignore (Luax.do-in lua "n = 1e300")) + (Lua.get-global lua (cstr "n")) + (Lua.get-double lua -1))] + (and (Double.> n 0.0) (Double.< n Double.MAX))) + "get-double reads 1e300 as a finite value") + (assert-true test + (let [n (Lua.with-lua-do (Lua.libs lua) + (ignore (Luax.do-in lua "n = -1e300")) + (Lua.get-global lua (cstr "n")) + (Lua.get-double lua -1))] + (and (Double.< n 0.0) (Double.> n (Double.neg Double.MAX)))) + "get-double reads -1e300 as a finite value") + (assert-equal test + 3.1415926535897931 + (Lua.with-lua-do (Lua.libs lua) + (Lua.push-double lua 3.1415926535897931) + (Lua.set-global lua (cstr "n")) + (ignore (Luax.do-in lua "n = n * 2 / 2")) + (Lua.get-global lua (cstr "n")) + (Lua.get-double lua -1)) + "push-double hands Lua the full value") + + (assert-equal test + (Double.to-float 3.1415926535897931) + (Lua.with-lua-do (Lua.push-double lua 3.1415926535897931) + (Lua.get-float lua -1)) + "get-float still narrows to single precision") + (assert-true test + (Float.> + (Lua.with-lua-do (Lua.libs lua) + (ignore (Luax.do-in lua "n = 1e300")) + (Lua.get-global lua (cstr "n")) + (Lua.get-float lua -1)) + Float.MAX) + "get-float still overflows past the float range") + (assert-equal test + (Double.from-float 2.5f) + (Lua.with-lua-do (Lua.push-float lua 2.5f) (Lua.get-double lua -1)) + "push-float still accepts a Float") (assert-true test (Lua.with-lua-do (Lua.push-bool lua true) (Lua.get-bool lua -1)) "push-bool true round-trip") diff --git a/test/midlevel.carp b/test/midlevel.carp index 261b8e4..9850430 100644 --- a/test/midlevel.carp +++ b/test/midlevel.carp @@ -31,6 +31,22 @@ &(Lua.with-lua-do (Lua.push-bool lua true) (Luax.maybe-get-float lua -1))) "maybe-get-float returns Nothing for boolean value") + (assert-true test + (= &(Maybe.Just 3.1415926535897931) + &(Lua.with-lua-do (Lua.push-double lua 3.1415926535897931) + (Luax.maybe-get-double lua -1))) + "maybe-get-double returns Just for number value") + (assert-true test + (Maybe.nothing? + &(Lua.with-lua-do (Lua.push-bool lua true) (Luax.maybe-get-double lua -1))) + "maybe-get-double returns Nothing for boolean value") + (assert-true test + (Maybe.nothing? + &(Lua.with-lua-do + (ignore (Lua.push-string lua (cstr "hello"))) + (Luax.maybe-get-double lua -1))) + "maybe-get-double returns Nothing for string value") + (assert-true test (= &(Maybe.Just true) &(Lua.with-lua-do (Lua.push-bool lua true) (Luax.maybe-get-bool lua -1))) @@ -191,6 +207,22 @@ (Lua.get-float lua -1)) "set-float-global sets a float global") + (assert-equal test + 3.1415926535897931 + (Lua.with-lua-do (Lua.libs lua) + (Luax.set-double-global lua "d" 3.1415926535897931) + (Lua.get-global lua (cstr "d")) + (Lua.get-double lua -1)) + "set-double-global sets a double global without losing precision") + (assert-true test + (let [n (Lua.with-lua-do (Lua.libs lua) + (ignore (Luax.do-in lua "d = 1e300")) + (ignore (Luax.do-in lua "d = d + 0")) + (Lua.get-global lua (cstr "d")) + (Lua.get-double lua -1))] + (and (Double.> n 0.0) (Double.< n Double.MAX))) + "double globals survive a round trip through Lua arithmetic") + (assert-true test (Lua.with-lua-do (Lua.libs lua) (Luax.set-bool-global lua "flag" true) @@ -238,6 +270,23 @@ (Luax.get-float-global lua "f"))) "get-float-global returns Just for existing float") + (assert-true test + (= &(Maybe.Just 3.1415926535897931) + &(Lua.with-lua-do (Lua.libs lua) + (Luax.set-double-global lua "d" 3.1415926535897931) + (Luax.get-double-global lua "d"))) + "get-double-global returns Just for existing number") + (assert-true test + (Maybe.nothing? + &(Lua.with-lua-do (Lua.libs lua) (Luax.get-double-global lua "nope"))) + "get-double-global returns Nothing for missing global") + (assert-true test + (Maybe.nothing? + &(Lua.with-lua-do (Lua.libs lua) + (Luax.set-string-global lua "s" "hello") + (Luax.get-double-global lua "s"))) + "get-double-global returns Nothing for wrong type") + (assert-true test (= &(Maybe.Just true) &(Lua.with-lua-do (Lua.libs lua) @@ -302,6 +351,24 @@ (Luax.get-float-field lua -1 "val"))) "get-float-field returns Just for existing float field") + (assert-true test + (= &(Maybe.Just 3.1415926535897931) + &(Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr "t = {val = 3.141592653589793}"))) + (Lua.get-global lua (cstr "t")) + (Luax.get-double-field lua -1 "val"))) + "get-double-field returns Just for existing number field") + (assert-true test + (Maybe.nothing? + &(Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua (cstr "t = {name = 'Alice'}"))) + (Lua.get-global lua (cstr "t")) + (Luax.get-double-field lua -1 "name"))) + "get-double-field returns Nothing on type mismatch") + (assert-true test (= &(Maybe.Just true) &(Lua.with-lua-do (Lua.libs lua) @@ -344,6 +411,17 @@ (Lua.get-float lua -1)) "set-float-field sets a float field on a table") + (assert-equal test + 3.1415926535897931 + (Lua.with-lua-do (Lua.libs lua) + (Lua.create-table lua 0 1) + (Luax.set-double-field lua -1 "val" 3.1415926535897931) + (Lua.set-global lua (cstr "t")) + (Lua.get-global lua (cstr "t")) + (ignore (Lua.get-field lua -1 (cstr "val"))) + (Lua.get-double lua -1)) + "set-double-field sets a double field without losing precision") + (assert-true test (Lua.with-lua-do (Lua.libs lua) (Lua.create-table lua 0 1) From 11561c7362df2c946f15385d13a7b68e1c89205a Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Wed, 5 Aug 2026 14:40:10 +0200 Subject: [PATCH 2/3] say what the float pair actually does push-float widens a Float into a double, which is exact. The loss is entirely on the way back out, where get-float narrows. The two push docstrings, the module doc block and the README all framed the push side as lossy. Also narrows the README's claim that the double pair moves numbers "without losing anything": lua_tonumber converts Lua's 64-bit integer subtype to a double, so integers above 2^53 come back rounded (9007199254740993 reads as 9007199254740992, math.maxinteger as 9223372036854775808). And switches the make-table sample to push-double, so the README stops demonstrating the float pair right under the paragraph recommending the double one. --- README.md | 12 +++++++----- lua.carp | 21 ++++++++------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b62a486..163a822 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ evaluating Lua expressions into globals, and `Lua.eval-file` for loading Lua files with error handling. A Lua number is a C double, so `push-double` and `get-double` (and the matching -`Luax.set-double-global`, `Luax.get-double-field`, …) move numbers across -without losing anything. The `float` versions still work, but they round to -single precision and turn anything above the float range into an infinity. +`Luax.set-double-global`, `Luax.get-double-field`, …) carry Lua floats at full +precision. The `float` versions still work — a float widens into a double +exactly — but `get-float` narrows on the way back, so reading through it drops +everything past single precision. Lua integers are a separate 64-bit subtype; +`get-double` converts them to doubles, so integers above 2^53 come back rounded. The `Luax` module provides safe wrappers that return `Maybe` and `Result` types instead of requiring manual type checks: @@ -63,8 +65,8 @@ types instead of requiring manual type checks: (Luax.make-table lua player (name (Lua.push-carp-str "Ada")) (hp (Lua.push-int 100)) - (x (Lua.push-float 3.5f)) - (y (Lua.push-float 7.0f))) + (x (Lua.push-double 3.5)) + (y (Lua.push-double 7.0))) ; read table fields — returns Maybe, keeps the stack clean (Lua.get-global lua (cstr "player")) diff --git a/lua.carp b/lua.carp index 6f72aa5..7ebb715 100644 --- a/lua.carp +++ b/lua.carp @@ -163,12 +163,9 @@ automatically.") (register push-light-user-data (Fn [&Lua (Ptr ())] ()) "lua_pushlightuserdata") (doc push-nil "Push nil onto the stack.") (register push-nil (Fn [&Lua] ()) "lua_pushnil") - (doc push-double "Push a double (Lua number) onto the stack. Lua numbers are -C doubles, so this is the lossless way to hand Lua a number.") + (doc push-double "Push a double (Lua number) onto the stack.") (register push-double (Fn [&Lua Double] ()) "lua_pushnumber") - (doc push-float "Push a float onto the stack as a Lua number. Lua numbers are -doubles, so the value arrives carrying only single precision; use -[`push-double`](#push-double) to push the full value.") + (doc push-float "Push a float onto the stack as a Lua number.") (defn push-float [lua f] (push-double lua (Double.from-float f))) (doc push-string "Push a C string onto the stack. For Carp strings, use [`push-carp-str`](#push-carp-str) instead.") @@ -198,10 +195,8 @@ type—use [`Luax.maybe-get-int`](#maybe-get-int) for a safe version.") type—use [`Luax.maybe-get-double`](#maybe-get-double) for a safe version.") (register get-double (Fn [&Lua Int] Double) "lua_tonumber") (doc get-float "Read the value at `index` as a float. Lua numbers are doubles, -so everything past single precision is dropped and anything above the float range -comes back as an infinity; use [`get-double`](#get-double) to read the value -intact. Does not check the type—use [`Luax.maybe-get-float`](#maybe-get-float) -for a safe version.") +so this narrows; use [`get-double`](#get-double) instead. Does not check the +type—use [`Luax.maybe-get-float`](#maybe-get-float) for a safe version.") (defn get-float [lua index] (Double.to-float (get-double lua index))) (doc get-string "Read the value at `index` as a C string pointer. Returns a raw pointer; prefer [`Luax.get-carp-str`](#get-carp-str) or @@ -620,10 +615,10 @@ negative indices to address from the top of the stack (-1 is the top element). (Lua.pop lua 2)) ``` -A Lua number is a C double. [`push-float`](#push-float) and -[`get-float`](#get-float) still work, but they round to single precision and -turn anything above the float range into an infinity, so prefer the `double` -pair for numbers you care about. +A Lua number is a C double. [`push-float`](#push-float) widens exactly, but +[`get-float`](#get-float) narrows on the way back, so a number read through it +loses everything past single precision and anything above the float range comes +back as an infinity. To call a Lua function at the low level, push the function, then its arguments, then use [`call`](#call) with the argument and result counts. The result From 18dc3b11a1b726d563c7049f6967e0cefb7cf73e Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Wed, 5 Aug 2026 20:13:32 +0200 Subject: [PATCH 3/3] say when Lua integers actually round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit measured against Lua 5.4 on this box: 2^53+1 comes back 9007199254740992 (rounded), but 2^54 comes back 18014398509481984 (exact), and so does math.mininteger. above 2^53 the spacing between doubles grows, so an integer survives only when it lands on a representable value — "come back rounded" claimed more than the hardware does. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 163a822..ff67ecc 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ A Lua number is a C double, so `push-double` and `get-double` (and the matching precision. The `float` versions still work — a float widens into a double exactly — but `get-float` narrows on the way back, so reading through it drops everything past single precision. Lua integers are a separate 64-bit subtype; -`get-double` converts them to doubles, so integers above 2^53 come back rounded. +`get-double` converts them to doubles, so integers above 2^53 are no longer all +exactly representable and may come back rounded. The `Luax` module provides safe wrappers that return `Maybe` and `Result` types instead of requiring manual type checks: