From d126390e1db6f1cfbd828d21a751b0749ed021fe Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:53:13 -0500 Subject: [PATCH 1/2] fix: bind string.pack only when it speaks the 5.3 dialect The byte helpers took their string.pack/unpack fast path whenever the functions existed. Control4's LuaJIT has both as lpack, which has no size suffixes and returns the position first from unpack, so every helper raised on a controller where the manual path would have been right. _compat now probes both functions once at load and binds them only if the answers match; the runner gains an lpack pass that installs that shape before every selftest. Refs FL-20. --- run_tests.sh | 19 +++++++++++++-- src/bitn/_compat.lua | 21 ++++++++++++++++ src/bitn/bit32.lua | 4 ++-- src/bitn/bit64.lua | 4 ++-- test/lpack_stub.lua | 57 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 test/lpack_stub.lua diff --git a/run_tests.sh b/run_tests.sh index 9a6a0d6..6c3858f 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -46,8 +46,8 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) lua_path="$script_dir/?.lua;$script_dir/?/init.lua;$script_dir/src/?.lua;$script_dir/src/?/init.lua;$script_dir/vendor/?.lua;$LUA_PATH" # Parse command line arguments to determine which modules to run -default_modules=("bit16" "bit32" "bit64") -all_modules=("bit16" "bit32" "bit64") +default_modules=("bit16" "bit32" "bit64" "lpack") +all_modules=("bit16" "bit32" "bit64" "lpack") modules_to_run=("$@") # Validate modules if specified @@ -131,6 +131,21 @@ run_selftest "16-bit operations" "bit16" "bitn.bit16" run_selftest "32-bit operations" "bit32" "bitn.bit32" run_selftest "64-bit operations" "bit64" "bitn.bit64" +# Control4's LuaJIT has string.pack/unpack as lpack, a different dialect: every +# selftest again with that shape installed first, and the 5.3 fast path must stay unbound. +run_test "All operations with lpack-shaped string.pack" "lpack" " + dofile('$script_dir/test/lpack_stub.lua') + if require('bitn._compat').string_pack ~= nil then + print('lpack-shaped string.pack was bound as the 5.3 dialect') + os.exit(1) + end + for _, name in ipairs({ 'bitn.bit16', 'bitn.bit32', 'bitn.bit64' }) do + if not require(name).selftest() then + os.exit(1) + end + end + " + passed_count=${#passed_modules[@]} failed_count=${#failed_modules[@]} total_count=$((passed_count + failed_count)) diff --git a/src/bitn/_compat.lua b/src/bitn/_compat.lua index bbc655a..17049b3 100644 --- a/src/bitn/_compat.lua +++ b/src/bitn/_compat.lua @@ -29,6 +29,27 @@ _compat.to_unsigned = to_unsigned -- Constants local MASK32 = 0xFFFFFFFF +-- string.pack/unpack are bound only if they answer in the 5.3 dialect. Control4's +-- LuaJIT ships lpack under the same names: no size suffixes, and unpack takes +-- (data, fmt, pos) and returns the position first, so existence proves nothing. +local function probe_string_pack() + local pack, unpack = rawget(string, "pack"), rawget(string, "unpack") + if not (pack and unpack) then + return nil, nil + end + local packed_ok, packed = pcall(pack, "" or c == "=" then + -- lpack: endian markers are accepted and ignored + elseif SIZE[c] then + i = i + 1 + out[#out + 1] = pack_int(args[i], SIZE[c]) + else + error("lpack stub: unsupported code '" .. c .. "'") + end + end + return table.concat(out) +end + +function string.unpack(data, fmt, pos) + pos = pos or 1 + local out = {} + for c in fmt:gmatch(".") do + if c == "<" or c == ">" or c == "=" then + -- lpack: endian markers are accepted and ignored + elseif SIZE[c] then + local value + pos, value = unpack_int(data, pos, SIZE[c]) + out[#out + 1] = value + else + error("lpack stub: unsupported code '" .. c .. "'") + end + end + return pos, (table.unpack or unpack)(out) +end From 2cc62ec1d8c02ee8943950075fa84359f28bdccb Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:24:59 -0500 Subject: [PATCH 2/2] review: assert the probe binds on 5.3+, model lpack as measured A probe module in the runner fails when string.pack is present but unbound, so a probe that fails closed on a real 5.3 interpreter cannot silently revert the fast path. The stub now honours '<', '=' and '>', stops at a NUL in the format, and returns nil for a value the data cannot supply, all as measured on a controller. The probe also checks the big-endian pack. --- run_tests.sh | 15 ++++++++-- src/bitn/_compat.lua | 4 +++ test/lpack_stub.lua | 68 ++++++++++++++++++++++++++++++-------------- 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/run_tests.sh b/run_tests.sh index 6c3858f..e7faf08 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -46,8 +46,8 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) lua_path="$script_dir/?.lua;$script_dir/?/init.lua;$script_dir/src/?.lua;$script_dir/src/?/init.lua;$script_dir/vendor/?.lua;$LUA_PATH" # Parse command line arguments to determine which modules to run -default_modules=("bit16" "bit32" "bit64" "lpack") -all_modules=("bit16" "bit32" "bit64" "lpack") +default_modules=("bit16" "bit32" "bit64" "probe" "lpack") +all_modules=("bit16" "bit32" "bit64" "probe" "lpack") modules_to_run=("$@") # Validate modules if specified @@ -131,6 +131,17 @@ run_selftest "16-bit operations" "bit16" "bitn.bit16" run_selftest "32-bit operations" "bit32" "bitn.bit32" run_selftest "64-bit operations" "bit64" "bitn.bit64" +# The probe must bind a real 5.3-dialect string.pack, or the byte helpers silently +# lose their fast path; only the 5.3+ legs can prove this. +run_test "string.pack binds when it is the 5.3 dialect" "probe" " + local _compat = require('bitn._compat') + if rawget(string, 'pack') and _compat.string_pack == nil then + print('string.pack is present but the probe declined it') + os.exit(1) + end + print(_compat.string_pack and 'string.pack bound' or 'no string.pack on this interpreter') + " + # Control4's LuaJIT has string.pack/unpack as lpack, a different dialect: every # selftest again with that shape installed first, and the 5.3 fast path must stay unbound. run_test "All operations with lpack-shaped string.pack" "lpack" " diff --git a/src/bitn/_compat.lua b/src/bitn/_compat.lua index 17049b3..fbeebc1 100644 --- a/src/bitn/_compat.lua +++ b/src/bitn/_compat.lua @@ -41,6 +41,10 @@ local function probe_string_pack() if not packed_ok or packed ~= "\1\2\3\4" then return nil, nil end + packed_ok, packed = pcall(pack, ">I4", 0x04030201) + if not packed_ok or packed ~= "\4\3\2\1" then + return nil, nil + end local unpacked_ok, value, next_pos = pcall(unpack, "' big-endian, a NUL ends the format, a digit or unknown +-- code raises, and unpack(data, fmt, pos) returns the next position before the +-- values, with nil for a value the data cannot supply. Loaded ahead of bitn so a +-- byte helper that assumes the 5.3 dialect fails here rather than on a controller. local SIZE = { b = 1, B = 1, c = 1, h = 2, H = 2, i = 4, I = 4, l = 4, L = 4 } -local function pack_int(value, size) +local function pack_int(value, size, big_endian) value = value % 2 ^ (8 * size) local out = {} for _ = 1, size do out[#out + 1] = string.char(value % 256) value = math.floor(value / 256) end + if big_endian then + local reversed = {} + for i = size, 1, -1 do + reversed[#reversed + 1] = out[i] + end + out = reversed + end return table.concat(out) end -local function unpack_int(data, pos, size) +local function unpack_int(data, pos, size, big_endian) + if #data < pos + size - 1 then + return pos, nil + end local value = 0 for i = 0, size - 1 do - value = value + string.byte(data, pos + i) * 2 ^ (8 * i) + local weight = big_endian and (size - 1 - i) or i + value = value + string.byte(data, pos + i) * 2 ^ (8 * weight) end return pos + size, value end -function string.pack(fmt, ...) - local args, i, out = { ... }, 0, {} +local function codes(fmt) + local out = {} for c in fmt:gmatch(".") do - if c == "<" or c == ">" or c == "=" then - -- lpack: endian markers are accepted and ignored + if c == "\0" then + break + end + out[#out + 1] = c + end + return out +end + +function string.pack(fmt, ...) + local args, i, out, big_endian = { ... }, 0, {}, false + for _, c in ipairs(codes(fmt)) do + if c == "<" or c == "=" then + big_endian = false + elseif c == ">" then + big_endian = true elseif SIZE[c] then i = i + 1 - out[#out + 1] = pack_int(args[i], SIZE[c]) + out[#out + 1] = pack_int(args[i], SIZE[c], big_endian) else error("lpack stub: unsupported code '" .. c .. "'") end @@ -41,17 +66,18 @@ end function string.unpack(data, fmt, pos) pos = pos or 1 - local out = {} - for c in fmt:gmatch(".") do - if c == "<" or c == ">" or c == "=" then - -- lpack: endian markers are accepted and ignored + local out, n, big_endian = {}, 0, false + for _, c in ipairs(codes(fmt)) do + if c == "<" or c == "=" then + big_endian = false + elseif c == ">" then + big_endian = true elseif SIZE[c] then - local value - pos, value = unpack_int(data, pos, SIZE[c]) - out[#out + 1] = value + n = n + 1 + pos, out[n] = unpack_int(data, pos, SIZE[c], big_endian) else error("lpack stub: unsupported code '" .. c .. "'") end end - return pos, (table.unpack or unpack)(out) + return pos, (table.unpack or unpack)(out, 1, n) end