2026-03-18 09:59:36 -06:00
|
|
|
/*! \file hir_lower_lua.cpp
|
|
|
|
|
* \brief Lua 5.4 bytecode → HIR lowering.
|
|
|
|
|
*
|
|
|
|
|
* Two-pass approach:
|
|
|
|
|
* Pass 1: scan for basic block boundaries (branch targets).
|
|
|
|
|
* Pass 2: walk opcodes, emit HIR instructions.
|
|
|
|
|
*
|
|
|
|
|
* Lua register map: lua_reg[i] holds the current HIR value number
|
|
|
|
|
* for Lua register i. Updated on each register write.
|
|
|
|
|
*
|
|
|
|
|
* Unsupported opcodes → return -1 (caller falls back to Lua VM).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#include "copyright.h"
|
|
|
|
|
#include "autoconf.h"
|
|
|
|
|
#include "config.h"
|
|
|
|
|
#include "externs.h"
|
|
|
|
|
|
|
|
|
|
#include "dbt_compile.h"
|
|
|
|
|
#include "engine_api.h"
|
|
|
|
|
#include "lua_bytecode.h"
|
|
|
|
|
#include "hir_lower_lua.h"
|
|
|
|
|
|
|
|
|
|
#include <cstring>
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <cctype>
|
2026-03-18 13:17:12 -06:00
|
|
|
#include <cmath>
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
#include <map>
|
2026-07-29 09:08:10 -06:00
|
|
|
#include <set>
|
2026-03-18 09:59:36 -06:00
|
|
|
#include <vector>
|
|
|
|
|
#include <string>
|
|
|
|
|
|
|
|
|
|
// Maximum Lua registers we track.
|
|
|
|
|
static constexpr int MAX_LUA_REGS = 256;
|
|
|
|
|
|
2026-03-18 12:21:02 -06:00
|
|
|
// Q-register slots for Lua for-loop variables.
|
|
|
|
|
// Reuses compiler-internal slots 10-12 (same as softcode iter()).
|
|
|
|
|
// Safe because Lua lowering never calls the softcode iter() path.
|
|
|
|
|
//
|
2026-03-27 14:17:18 -06:00
|
|
|
static constexpr int QREG_LUA_IDX = 10; // loop index variable
|
|
|
|
|
static constexpr int QREG_LUA_BUDGET = 12; // back-edge iteration budget
|
2026-03-18 12:21:02 -06:00
|
|
|
|
2026-03-21 18:02:17 -06:00
|
|
|
// Maximum inline depth for nested lowering.
|
|
|
|
|
static constexpr int MAX_INLINE_DEPTH = 4;
|
|
|
|
|
|
2026-03-18 12:15:42 -06:00
|
|
|
// Maximum code size we attempt to JIT (prevents runaway compile time).
|
|
|
|
|
static constexpr int MAX_LUA_CODE_SIZE = 1024;
|
|
|
|
|
|
|
|
|
|
// Maximum Lua stack size (register pressure bound).
|
|
|
|
|
static constexpr int MAX_LUA_STACK = 64;
|
|
|
|
|
|
|
|
|
|
// Maximum parameters.
|
|
|
|
|
static constexpr int MAX_LUA_PARAMS = 8;
|
|
|
|
|
|
2026-03-27 14:17:18 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Back-edge iteration budget.
|
|
|
|
|
//
|
|
|
|
|
// Emits HIR to decrement QREG_LUA_BUDGET at each back-edge.
|
|
|
|
|
// When the counter reaches zero, the combined condition forces
|
|
|
|
|
// loop exit — same model as MUSHcode's func_invk_lim. The
|
|
|
|
|
// budget is initialized to lua_instruction_limit (default 100K)
|
|
|
|
|
// at function entry.
|
|
|
|
|
//
|
|
|
|
|
// The per-iteration cost is: LOAD_Q, SUB, STORE_Q, GT, BAND —
|
|
|
|
|
// five integer ops, no ECALL. At GHz speed this is negligible.
|
|
|
|
|
//
|
|
|
|
|
// cond: the original loop condition (>=0 for FORLOOP/TFORLOOP),
|
|
|
|
|
// or -1 for unconditional back-edges (JMP).
|
|
|
|
|
// Returns: combined condition value.
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
static int emit_budget_check(hir_program &h, int cond, int amount) {
|
2026-03-27 14:17:18 -06:00
|
|
|
int budget = h.emit(HIR_LOAD_Q, TY_INT, -1, -1, QREG_LUA_BUDGET);
|
|
|
|
|
if (budget < 0) return cond;
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// Decrement by the loop body's bytecode length, not by 1: the
|
|
|
|
|
// interpreter's hook counts INSTRUCTIONS, and a per-edge tick would
|
|
|
|
|
// let a compiled loop run body-length times longer than the VM
|
|
|
|
|
// before tripping the same limit.
|
|
|
|
|
if (amount < 1) amount = 1;
|
|
|
|
|
int amt = h.emit(HIR_ICONST, TY_INT, -1, -1, amount);
|
|
|
|
|
int new_budget = h.emit(HIR_SUB, TY_INT, budget, amt);
|
2026-03-27 14:17:18 -06:00
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, new_budget, -1, QREG_LUA_BUDGET);
|
|
|
|
|
|
|
|
|
|
int zero_val = h.emit(HIR_ICONST, TY_INT, -1, -1, 0);
|
|
|
|
|
int budget_ok = h.emit(HIR_GT, TY_INT, new_budget, zero_val);
|
|
|
|
|
|
|
|
|
|
if (cond >= 0) {
|
|
|
|
|
return h.emit(HIR_BAND, TY_INT, cond, budget_ok);
|
|
|
|
|
}
|
|
|
|
|
return budget_ok;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 12:15:42 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Rejection reason names (for diagnostics).
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const char *lua_bc_reject_name(lua_bc_reject reason) {
|
|
|
|
|
switch (reason) {
|
|
|
|
|
case LUA_BC_ELIGIBLE: return "eligible";
|
|
|
|
|
case LUA_BC_EMPTY: return "empty proto";
|
|
|
|
|
case LUA_BC_TOO_LARGE: return "code or stack too large";
|
|
|
|
|
case LUA_BC_TOO_MANY_PARAMS: return "too many parameters";
|
|
|
|
|
case LUA_BC_HAS_CLOSURE: return "contains OP_CLOSURE";
|
|
|
|
|
case LUA_BC_HAS_VARARG: return "contains OP_VARARG";
|
|
|
|
|
case LUA_BC_HAS_TBC: return "contains OP_TBC";
|
|
|
|
|
case LUA_BC_HAS_TAILCALL: return "contains OP_TAILCALL";
|
|
|
|
|
case LUA_BC_HAS_NESTED_PROTOS: return "has nested protos";
|
|
|
|
|
case LUA_BC_UNSUPPORTED_OP: return "unsupported opcode";
|
2026-03-18 13:17:12 -06:00
|
|
|
case LUA_BC_HAS_NON_INT_CONST: return "non-integer float constant";
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
case LUA_BC_HAS_LOOP: return "backward branch (unbounded on host)";
|
2026-03-18 12:15:42 -06:00
|
|
|
}
|
|
|
|
|
return "unknown";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Eligibility pre-filter.
|
|
|
|
|
//
|
|
|
|
|
// This is a fast O(n) scan over the proto's instruction stream
|
|
|
|
|
// that rejects protos we know we cannot compile. It runs before
|
|
|
|
|
// any HIR allocation so the reject path is cheap.
|
|
|
|
|
//
|
|
|
|
|
// The supported opcode set must exactly match what
|
|
|
|
|
// hir_lower_lua_proto() handles in its switch statement.
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
lua_bc_reject lua_bc_eligible(const lua_bc_proto *proto) {
|
|
|
|
|
if (nullptr == proto) return LUA_BC_EMPTY;
|
|
|
|
|
|
|
|
|
|
int n = static_cast<int>(proto->code.size());
|
|
|
|
|
if (n == 0) return LUA_BC_EMPTY;
|
|
|
|
|
|
|
|
|
|
// --- Header checks ---
|
|
|
|
|
|
|
|
|
|
if (n > MAX_LUA_CODE_SIZE) return LUA_BC_TOO_LARGE;
|
|
|
|
|
if (proto->maxstacksize > MAX_LUA_STACK) return LUA_BC_TOO_LARGE;
|
|
|
|
|
if (proto->numparams > MAX_LUA_PARAMS) return LUA_BC_TOO_MANY_PARAMS;
|
|
|
|
|
|
|
|
|
|
// Nested protos mean OP_CLOSURE will appear. Reject early without
|
|
|
|
|
// scanning — the bytecode can't reference protos that don't exist.
|
|
|
|
|
if (!proto->protos.empty()) return LUA_BC_HAS_NESTED_PROTOS;
|
|
|
|
|
|
|
|
|
|
// --- Opcode scan ---
|
|
|
|
|
//
|
|
|
|
|
// We maintain a whitelist of opcodes the lowering handles.
|
|
|
|
|
// Anything outside the whitelist → reject.
|
|
|
|
|
//
|
|
|
|
|
// Note: VARARGPREP is harmless (adjusts stack for main chunks)
|
|
|
|
|
// and is treated as a no-op. OP_VARARG is the actual vararg
|
|
|
|
|
// access instruction and is rejected.
|
|
|
|
|
|
|
|
|
|
for (int pc = 0; pc < n; pc++) {
|
|
|
|
|
int op = proto->code[pc].opcode();
|
|
|
|
|
switch (op) {
|
|
|
|
|
|
|
|
|
|
// Data movement — handled.
|
|
|
|
|
case OP_LUA_MOVE:
|
|
|
|
|
case OP_LUA_LOADI:
|
|
|
|
|
case OP_LUA_LOADF:
|
|
|
|
|
case OP_LUA_LOADK:
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_LOADKX:
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_LOADFALSE:
|
|
|
|
|
case OP_LUA_LFALSESKIP:
|
|
|
|
|
case OP_LUA_LOADTRUE:
|
|
|
|
|
case OP_LUA_LOADNIL:
|
|
|
|
|
break;
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
// Arithmetic — handled (integer and float).
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_ADD:
|
|
|
|
|
case OP_LUA_SUB:
|
|
|
|
|
case OP_LUA_MUL:
|
2026-03-18 14:53:23 -06:00
|
|
|
case OP_LUA_DIV:
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_IDIV:
|
|
|
|
|
case OP_LUA_MOD:
|
|
|
|
|
case OP_LUA_UNM:
|
2026-03-18 15:28:35 -06:00
|
|
|
case OP_LUA_NOT:
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
case OP_LUA_BNOT:
|
2026-03-18 15:28:35 -06:00
|
|
|
case OP_LUA_LEN:
|
|
|
|
|
case OP_LUA_CONCAT:
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_ADDI:
|
|
|
|
|
case OP_LUA_ADDK:
|
|
|
|
|
case OP_LUA_SUBK:
|
|
|
|
|
case OP_LUA_MULK:
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
case OP_LUA_DIVK:
|
|
|
|
|
case OP_LUA_IDIVK:
|
|
|
|
|
case OP_LUA_MODK:
|
|
|
|
|
case OP_LUA_BAND:
|
|
|
|
|
case OP_LUA_BOR:
|
|
|
|
|
case OP_LUA_BXOR:
|
|
|
|
|
case OP_LUA_SHL:
|
|
|
|
|
case OP_LUA_SHR:
|
|
|
|
|
case OP_LUA_SHRI:
|
|
|
|
|
case OP_LUA_SHLI:
|
|
|
|
|
case OP_LUA_BANDK:
|
|
|
|
|
case OP_LUA_BORK:
|
|
|
|
|
case OP_LUA_BXORK:
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_POW:
|
|
|
|
|
case OP_LUA_POWK:
|
2026-03-18 12:15:42 -06:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// Metamethod companions — skipped as no-ops.
|
|
|
|
|
case OP_LUA_MMBIN:
|
|
|
|
|
case OP_LUA_MMBINI:
|
|
|
|
|
case OP_LUA_MMBINK:
|
|
|
|
|
break;
|
|
|
|
|
|
2026-03-18 15:37:23 -06:00
|
|
|
// Table operations — handled via ECALL back to Lua VM.
|
|
|
|
|
case OP_LUA_NEWTABLE:
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_GETTABLE:
|
2026-03-18 15:37:23 -06:00
|
|
|
case OP_LUA_GETTABI:
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_GETFIELD:
|
|
|
|
|
case OP_LUA_SETTABLE:
|
2026-03-18 15:37:23 -06:00
|
|
|
case OP_LUA_SETTABI:
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_SETFIELD:
|
2026-03-18 15:37:23 -06:00
|
|
|
case OP_LUA_SETLIST:
|
|
|
|
|
break;
|
|
|
|
|
|
2026-03-18 12:15:42 -06:00
|
|
|
// Comparisons — handled.
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
case OP_LUA_EQK:
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_EQ:
|
|
|
|
|
case OP_LUA_LT:
|
|
|
|
|
case OP_LUA_LE:
|
|
|
|
|
case OP_LUA_EQI:
|
|
|
|
|
case OP_LUA_LTI:
|
|
|
|
|
case OP_LUA_LEI:
|
|
|
|
|
case OP_LUA_GTI:
|
|
|
|
|
case OP_LUA_GEI:
|
|
|
|
|
case OP_LUA_TEST:
|
|
|
|
|
case OP_LUA_TESTSET:
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// Control flow — handled.
|
|
|
|
|
case OP_LUA_JMP:
|
|
|
|
|
case OP_LUA_FORPREP:
|
|
|
|
|
case OP_LUA_FORLOOP:
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// Return — handled.
|
|
|
|
|
case OP_LUA_RETURN:
|
|
|
|
|
case OP_LUA_RETURN0:
|
|
|
|
|
case OP_LUA_RETURN1:
|
|
|
|
|
break;
|
|
|
|
|
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
// Table/global access and function calls — handled. TAILCALL is
|
|
|
|
|
// lowered as CALL-then-RETURN: the frame-reuse the real mechanism
|
|
|
|
|
// exists for does not apply when the callee runs via an ECALL doing
|
|
|
|
|
// its own pcall, and the chunk-level pcall takes one result either
|
|
|
|
|
// way. A k flag (upvalues to close) declines in the lowering.
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_GETTABUP:
|
2026-03-18 16:26:24 -06:00
|
|
|
case OP_LUA_SETTABUP:
|
2026-03-18 16:45:17 -06:00
|
|
|
case OP_LUA_GETUPVAL:
|
|
|
|
|
case OP_LUA_SETUPVAL:
|
2026-03-18 16:26:24 -06:00
|
|
|
case OP_LUA_SELF:
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_CALL:
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
case OP_LUA_TAILCALL:
|
2026-03-18 12:15:42 -06:00
|
|
|
break;
|
|
|
|
|
|
Add generic for-loop (TFORPREP/TFORCALL/TFORLOOP) to Lua JIT — 79/83
Generic for-loops (for k,v in pairs(t) do...end) are now JIT-eligible:
- TFORPREP: jumps to TFORLOOP for initial nil check (same as FORPREP)
- TFORCALL: calls iterator(state, control) via __lua_tfor_call ECALL,
marshals multiple results to guest memory. Uses lua_pcall with the
iterator function reference from the Lua stack.
- TFORLOOP: checks if first result is empty (nil), sets control variable,
branches back or exits.
Multi-return CALL support: when nresults > 1, emit __lua_get_result
ECALLs to fetch additional return values from the Lua stack. This
enables the pairs()/ipairs() setup call which returns 3 values
(iterator, state, initial control).
Block boundary detection updated for TFORPREP/TFORLOOP jump targets.
New ECALL handlers: __lua_tfor_call, __lua_get_result, __lua_getenv.
79 of 83 opcodes handled. Only 4 permanently rejected: CLOSURE,
VARARG, TBC, TAILCALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:54:22 -06:00
|
|
|
// Generic for-loop — handled via ECALL.
|
|
|
|
|
case OP_LUA_TFORPREP:
|
|
|
|
|
case OP_LUA_TFORCALL:
|
|
|
|
|
case OP_LUA_TFORLOOP:
|
|
|
|
|
break;
|
|
|
|
|
|
2026-03-18 15:37:23 -06:00
|
|
|
// Harmless no-ops.
|
2026-03-18 12:15:42 -06:00
|
|
|
case OP_LUA_VARARGPREP:
|
2026-03-18 15:37:23 -06:00
|
|
|
case OP_LUA_EXTRAARG:
|
2026-03-18 16:45:17 -06:00
|
|
|
case OP_LUA_CLOSE: // no open upvalues without closures
|
2026-03-18 12:15:42 -06:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// --- Hard rejects ---
|
|
|
|
|
|
|
|
|
|
case OP_LUA_CLOSURE:
|
|
|
|
|
return LUA_BC_HAS_CLOSURE;
|
|
|
|
|
|
|
|
|
|
case OP_LUA_VARARG:
|
|
|
|
|
return LUA_BC_HAS_VARARG;
|
|
|
|
|
|
|
|
|
|
case OP_LUA_TBC:
|
|
|
|
|
return LUA_BC_HAS_TBC;
|
|
|
|
|
|
|
|
|
|
// --- Everything else is unsupported ---
|
|
|
|
|
default:
|
|
|
|
|
return LUA_BC_UNSUPPORTED_OP;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Backward branches (#1326, partially lifted by #1732).
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
//
|
|
|
|
|
// Instruction and memory limits are enforced by CLuaMod::InsnCountHook,
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// which is a Lua VM hook: it does not exist on the compiled path, so an
|
|
|
|
|
// unbudgeted compiled loop runs unbounded (TC009's original symptom).
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
//
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Numeric for loops (OP_FORLOOP) now compile under a back-edge budget
|
|
|
|
|
// whose exhaustion ABORTS the run via ECALL_LUA_LIMITED -- the runner
|
|
|
|
|
// fails over to the interpreter, which re-runs the chunk and raises its
|
|
|
|
|
// own "instruction limit exceeded" through the hook, so the error
|
|
|
|
|
// surface is the interpreter's verbatim. The re-run is what shapes the
|
|
|
|
|
// restrictions below: a loop proto must be RERUN-SAFE, so anything that
|
|
|
|
|
// could reach outside the chunk is rejected -- calls (a rebound global
|
|
|
|
|
// is arbitrary effectful code), SETTABUP (global writes), SELF (method
|
|
|
|
|
// dispatch). Chunk-local table stores are fine: TryJIT's stack
|
|
|
|
|
// save/restore discards them with the chunk. TESTSET is rejected
|
|
|
|
|
// because it writes a register from inside a terminator, which the
|
|
|
|
|
// store-at-write q-register routing in the lowering cannot see. The
|
|
|
|
|
// stack cap is the q-register file: loop-carried Lua registers map onto
|
|
|
|
|
// q-regs 0..9.
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
//
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// while/repeat (backward OP_JMP) and generic for (TFORLOOP) still
|
|
|
|
|
// reject: the JMP shape needs the same budget wiring on a less regular
|
|
|
|
|
// structure, and TFOR's iterator call is the dead named bridge.
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
//
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
bool has_back_edge = false;
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
for (int pc = 0; pc < n; pc++) {
|
|
|
|
|
const lua_bc_instruction &insn = proto->code[pc];
|
|
|
|
|
switch (insn.opcode()) {
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// while/repeat loop back through a backward JMP; same budget,
|
|
|
|
|
// same rerun-safety restrictions below as the numeric for.
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
case OP_LUA_JMP:
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
if (pc + 1 + insn.sJ() <= pc) has_back_edge = true;
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case OP_LUA_FORLOOP:
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
has_back_edge = true;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// Backward by construction; the iterator protocol is unsupported.
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
case OP_LUA_TFORLOOP:
|
|
|
|
|
return LUA_BC_HAS_LOOP;
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
if (has_back_edge) {
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
if (proto->maxstacksize > 10) return LUA_BC_HAS_LOOP;
|
|
|
|
|
for (int pc = 0; pc < n; pc++) {
|
|
|
|
|
switch (proto->code[pc].opcode()) {
|
|
|
|
|
case OP_LUA_CALL:
|
|
|
|
|
case OP_LUA_TAILCALL:
|
|
|
|
|
case OP_LUA_SELF:
|
|
|
|
|
case OP_LUA_SETTABUP:
|
|
|
|
|
case OP_LUA_TESTSET:
|
|
|
|
|
return LUA_BC_HAS_LOOP;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 12:15:42 -06:00
|
|
|
return LUA_BC_ELIGIBLE;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Pass 1: find basic block boundaries
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
// The block leaders a single instruction induces, written into out[] (at most
|
|
|
|
|
// two). find_block_starts() and lua_bool_fuse_at() both consult this, so the
|
|
|
|
|
// two passes cannot disagree about where the blocks are -- a disagreement is
|
|
|
|
|
// what #1421 was: the lowering skipped past a leader the CFG had recorded.
|
|
|
|
|
static int insn_leaders(const lua_bc_proto *proto, int pc, int n, int out[2]) {
|
|
|
|
|
const lua_bc_instruction &insn = proto->code[pc];
|
|
|
|
|
int cnt = 0;
|
|
|
|
|
|
|
|
|
|
switch (insn.opcode()) {
|
|
|
|
|
case OP_LUA_JMP:
|
|
|
|
|
out[cnt++] = pc + 1 + insn.sJ();
|
|
|
|
|
out[cnt++] = pc + 1;
|
|
|
|
|
break;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Lua 5.4 numeric for: both operands are UNSIGNED Bx with an implicit
|
|
|
|
|
// direction -- FORPREP skips FORWARD past the whole loop (Bx+1) when
|
|
|
|
|
// the trip count is zero and otherwise falls into the body; FORLOOP
|
|
|
|
|
// jumps BACK by Bx to the body. The 5.3-era sBx read produced targets
|
|
|
|
|
// tens of thousands of instructions away, which is why the (then
|
|
|
|
|
// unreachable) lowering declined the moment the eligibility reject was
|
|
|
|
|
// lifted (#1732).
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
case OP_LUA_FORPREP:
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
out[cnt++] = pc + 1 + insn.Bx() + 1; // zero-trip skip target
|
|
|
|
|
out[cnt++] = pc + 1; // body
|
|
|
|
|
break;
|
|
|
|
|
case OP_LUA_FORLOOP:
|
|
|
|
|
out[cnt++] = pc + 1 - insn.Bx(); // body (back edge)
|
|
|
|
|
out[cnt++] = pc + 1; // exit
|
|
|
|
|
break;
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
case OP_LUA_TFORPREP:
|
|
|
|
|
case OP_LUA_TFORLOOP:
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Rejected by eligibility; offsets kept only for the leader map.
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
out[cnt++] = pc + 1 + insn.sBx();
|
|
|
|
|
out[cnt++] = pc + 1;
|
|
|
|
|
break;
|
|
|
|
|
case OP_LUA_EQ:
|
|
|
|
|
case OP_LUA_LT:
|
|
|
|
|
case OP_LUA_LE:
|
|
|
|
|
case OP_LUA_EQI:
|
|
|
|
|
case OP_LUA_LTI:
|
|
|
|
|
case OP_LUA_LEI:
|
|
|
|
|
case OP_LUA_GTI:
|
|
|
|
|
case OP_LUA_GEI:
|
|
|
|
|
case OP_LUA_TEST:
|
|
|
|
|
case OP_LUA_TESTSET:
|
|
|
|
|
// "if (cond ~= k) then pc++" -- the skip lands at pc+2, and the JMP
|
|
|
|
|
// it skipped is its own block.
|
|
|
|
|
out[cnt++] = pc + 1;
|
|
|
|
|
out[cnt++] = pc + 2;
|
|
|
|
|
break;
|
2026-07-29 07:21:03 -06:00
|
|
|
case OP_LUA_EQK:
|
|
|
|
|
// #1761: EQK is "if ((R[A]==K[B]) ~= k) then pc++" -- one-instruction
|
|
|
|
|
// skip, NOT the EQ+JMP fuse. pc+1 is the skipped insn (often JMP),
|
|
|
|
|
// pc+2 is the fall-through after the skip. Omitting these leaders
|
|
|
|
|
// left false_target in the SAME block as the EQK, so BRC's false
|
|
|
|
|
// edge was a self-loop (dispatch-limit hang) and the fall-through
|
|
|
|
|
// return was unreachable. Masked by interpreter re-run until
|
|
|
|
|
// Phase 4 removed it.
|
|
|
|
|
out[cnt++] = pc + 1;
|
|
|
|
|
out[cnt++] = pc + 2;
|
|
|
|
|
break;
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
case OP_LUA_LFALSESKIP:
|
|
|
|
|
// "R[A] := false; pc++". The skip is control flow, not a linear
|
|
|
|
|
// step: the instruction it jumps over belongs to the other path.
|
|
|
|
|
// Lowering it as a bare pc++ swallowed that leader whole (#1421).
|
|
|
|
|
out[cnt++] = pc + 1;
|
|
|
|
|
out[cnt++] = pc + 2;
|
|
|
|
|
break;
|
|
|
|
|
case OP_LUA_RETURN:
|
|
|
|
|
case OP_LUA_RETURN0:
|
|
|
|
|
case OP_LUA_RETURN1:
|
|
|
|
|
// Do NOT mark pc+1 as a leader. Lua always appends a trailing
|
|
|
|
|
// return after an explicit one; treating it as a new block made
|
|
|
|
|
// every returning chunk multi_block (budget STORE_Q + SSA) and
|
|
|
|
|
// contributed to the #1309 hang class on otherwise linear code.
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drop out-of-range targets; malformed bytecode is declined elsewhere.
|
|
|
|
|
int keep = 0;
|
|
|
|
|
for (int i = 0; i < cnt; i++) {
|
|
|
|
|
if (out[i] > 0 && out[i] < n) out[keep++] = out[i];
|
|
|
|
|
}
|
|
|
|
|
return keep;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Is `pc` the head of the four-instruction idiom Lua emits to materialize a
|
|
|
|
|
// condition as a value (lcode.c exp2reg / code_loadbool)?
|
|
|
|
|
//
|
|
|
|
|
// pc : <test> if (cond ~= k) then pc++
|
|
|
|
|
// pc+1 : JMP -> pc+3
|
|
|
|
|
// pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
|
|
|
|
|
// pc+3 : LOADTRUE A R[A] := true
|
|
|
|
|
//
|
|
|
|
|
// The whole run is just R[A] = (cond == k) with no control flow, so fusing
|
|
|
|
|
// it to a bare comparison is both correct and branchless. A compound
|
|
|
|
|
// condition (`a<b and c<d`) patches its own jump list into pc+2/pc+3, and
|
|
|
|
|
// then the run is a real join and must not be fused -- so require that
|
|
|
|
|
// nothing outside the run enters it.
|
|
|
|
|
//
|
|
|
|
|
// Only the comparison opcodes are fused. TEST/TESTSET are excluded: TESTSET
|
|
|
|
|
// also copies R[B] into R[A] on the taken path, so its value is not simply
|
|
|
|
|
// the branch condition.
|
|
|
|
|
static bool lua_bool_fuse_at(const lua_bc_proto *proto, int pc, int n,
|
|
|
|
|
int *dst_reg) {
|
|
|
|
|
switch (proto->code[pc].opcode()) {
|
|
|
|
|
case OP_LUA_EQ: case OP_LUA_LT: case OP_LUA_LE:
|
2026-07-29 13:22:14 -06:00
|
|
|
case OP_LUA_EQK: // same condjump+LFALSESKIP/LOADTRUE shape as EQ (#1764)
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
case OP_LUA_EQI: case OP_LUA_LTI: case OP_LUA_LEI:
|
|
|
|
|
case OP_LUA_GTI: case OP_LUA_GEI:
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (pc + 3 >= n) return false;
|
|
|
|
|
|
|
|
|
|
const lua_bc_instruction &jmp = proto->code[pc + 1];
|
|
|
|
|
const lua_bc_instruction &lfs = proto->code[pc + 2];
|
|
|
|
|
const lua_bc_instruction <r = proto->code[pc + 3];
|
|
|
|
|
if (jmp.opcode() != OP_LUA_JMP) return false;
|
|
|
|
|
if (pc + 2 + jmp.sJ() != pc + 3) return false;
|
|
|
|
|
if (lfs.opcode() != OP_LUA_LFALSESKIP) return false;
|
|
|
|
|
if (ltr.opcode() != OP_LUA_LOADTRUE) return false;
|
|
|
|
|
if (lfs.A() != ltr.A()) return false;
|
|
|
|
|
|
|
|
|
|
// No entry into pc+1 .. pc+3 from outside the run itself.
|
|
|
|
|
for (int j = 0; j < n; j++) {
|
|
|
|
|
if (j >= pc && j <= pc + 2) continue; // the run's own branches
|
|
|
|
|
int tgt[2];
|
|
|
|
|
int cnt = insn_leaders(proto, j, n, tgt);
|
|
|
|
|
for (int i = 0; i < cnt; i++) {
|
|
|
|
|
if (tgt[i] >= pc + 1 && tgt[i] <= pc + 3) return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*dst_reg = lfs.A();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
static void find_block_starts(const lua_bc_proto *proto,
|
|
|
|
|
std::vector<bool> &is_leader) {
|
|
|
|
|
int n = static_cast<int>(proto->code.size());
|
|
|
|
|
is_leader.assign(n, false);
|
|
|
|
|
if (n > 0) is_leader[0] = true;
|
|
|
|
|
|
|
|
|
|
for (int pc = 0; pc < n; pc++) {
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
int dst;
|
|
|
|
|
if (lua_bool_fuse_at(proto, pc, n, &dst)) {
|
|
|
|
|
// Fused to a value in pass 2 -- the run has no control flow, so
|
|
|
|
|
// it must not induce leaders here either.
|
|
|
|
|
pc += 3;
|
|
|
|
|
continue;
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
int tgt[2];
|
|
|
|
|
int cnt = insn_leaders(proto, pc, n, tgt);
|
|
|
|
|
for (int i = 0; i < cnt; i++) is_leader[tgt[i]] = true;
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Block mapping
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static int assign_blocks(const std::vector<bool> &is_leader,
|
|
|
|
|
std::vector<int> &pc_to_block, int n) {
|
|
|
|
|
int block_count = 0;
|
|
|
|
|
pc_to_block.resize(n, -1);
|
|
|
|
|
for (int pc = 0; pc < n; pc++) {
|
|
|
|
|
if (is_leader[pc]) block_count++;
|
|
|
|
|
pc_to_block[pc] = block_count - 1;
|
|
|
|
|
}
|
|
|
|
|
return block_count;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:19:43 -06:00
|
|
|
// The mux.* SENTINEL: `mux` and `mux.args` are lowered as SCONSTs holding
|
|
|
|
|
// their own NAMES, not as Lua values. The separate provenance bit is
|
|
|
|
|
// essential: `"mux.args"` is also perfectly ordinary Lua string text and
|
|
|
|
|
// must never enter the CARGS fast path just because its bytes match.
|
|
|
|
|
//
|
|
|
|
|
static inline bool lua_is_mux_sentinel(const hir_program &h, int v) {
|
|
|
|
|
return v >= 0 && h.kind[v] == HIR_SCONST && h.lua_mux_sentinel[v];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 23:08:40 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Helper: coerce a return value to TY_STRING for HIR_RET.
|
|
|
|
|
//
|
|
|
|
|
// Known ICONST/FCONST fold to SCONST digit strings so pure `return 42`
|
|
|
|
|
// can take the folded (needs_jit=false) path. Runtime ITOA/FTOA is only
|
|
|
|
|
// used when the value is not a compile-time constant (#1309).
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
// Render a double the way Lua's own tostring does (lobject.c tostringbuff).
|
|
|
|
|
// Lua formats with LUA_NUMBER_FMT -- "%.14g" for the double build -- and then
|
|
|
|
|
// appends ".0" to anything that came out looking like an integer, so a float
|
|
|
|
|
// whose value happens to be integral prints as "3.0" and stays distinguishable
|
|
|
|
|
// from the integer 3. The compiled path used "%.17g" and never appended,
|
|
|
|
|
// so every integral float lost its subtype and every other float printed more
|
|
|
|
|
// digits than the interpreter (#1488).
|
|
|
|
|
//
|
|
|
|
|
void lua_format_double(double d, char *buf, size_t sz) {
|
chore(jit): finish the JIT-owned raw printf sites (#1653)
lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With
jit_compiler in the previous commit that is every JIT-owned site that CAN be
converted; legacy total 79 -> 41.
Two checks rather than assumptions, either of which would have been a silent
behaviour change:
%.14g in hir_lower_lua is a float rendered onto the compiled path, and
mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and
interpreter would print different numbers for the same value. Compared
across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form
boundary, and 9007199254740993 (past exact integer representation): zero
differences.
Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp.
Those are not redundant, they are dead: snprintf returns what it WOULD have
written, mux_snprintf what it DID. lua_mod's five result writes lose the
explicit pResult[n] = '\0' for the same reason. That difference is why each
site was re-read rather than renamed.
dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt
builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so
mux_snprintf is not reachable without changing that build. Frozen with the
reason recorded rather than exempted -- an exemption would let new sites in,
a frozen count still may not grow, and neither file writes player-facing
text.
The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2,
match 2, predicates 2, and six singles.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
|
|
|
mux_snprintf(reinterpret_cast<UTF8 *>(buf), sz, T("%.14g"), d);
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
if (buf[strspn(buf, "-0123456789")] == '\0') {
|
|
|
|
|
size_t len = strlen(buf);
|
|
|
|
|
if (len + 3 <= sz) {
|
|
|
|
|
buf[len] = '.';
|
|
|
|
|
buf[len + 1] = '0';
|
|
|
|
|
buf[len + 2] = '\0';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 23:08:40 -06:00
|
|
|
static int return_as_string(hir_program &h, rv_compiler &rc, int rv) {
|
|
|
|
|
if (rv < 0) return -1;
|
2026-07-29 15:19:43 -06:00
|
|
|
// A mux table sentinel is not the string containing its name.
|
|
|
|
|
if (lua_is_mux_sentinel(h, rv)) return -1;
|
2026-07-29 12:45:59 -06:00
|
|
|
// A CALL_VAL result is a live Lua value on the stack: marshal with
|
|
|
|
|
// fun_lua rules at the softcode boundary (#1764 shape 2). Other
|
|
|
|
|
// handles (tables, callables) still must not escape as decimal indices.
|
|
|
|
|
if (h.ty[rv] == TY_LUA_HANDLE) {
|
|
|
|
|
if (h.kind[rv] == HIR_LUA_CALL_VAL) {
|
|
|
|
|
h.needs_jit = true;
|
|
|
|
|
return h.emit(HIR_LUA_MARSHAL, TY_STRING, rv);
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-07-25 23:08:40 -06:00
|
|
|
if (h.ty[rv] == TY_STRING) {
|
|
|
|
|
return rv;
|
|
|
|
|
}
|
|
|
|
|
if (h.ty[rv] == TY_INT) {
|
|
|
|
|
if (h.kind[rv] == HIR_ICONST) {
|
|
|
|
|
char buf[32];
|
chore(jit): finish the JIT-owned raw printf sites (#1653)
lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With
jit_compiler in the previous commit that is every JIT-owned site that CAN be
converted; legacy total 79 -> 41.
Two checks rather than assumptions, either of which would have been a silent
behaviour change:
%.14g in hir_lower_lua is a float rendered onto the compiled path, and
mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and
interpreter would print different numbers for the same value. Compared
across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form
boundary, and 9007199254740993 (past exact integer representation): zero
differences.
Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp.
Those are not redundant, they are dead: snprintf returns what it WOULD have
written, mux_snprintf what it DID. lua_mod's five result writes lose the
explicit pResult[n] = '\0' for the same reason. That difference is why each
site was re-read rather than renamed.
dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt
builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so
mux_snprintf is not reachable without changing that build. Frozen with the
reason recorded rather than exempted -- an exemption would let new sites in,
a frozen count still may not grow, and neither file writes player-facing
text.
The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2,
match 2, predicates 2, and six singles.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
|
|
|
mux_snprintf(reinterpret_cast<UTF8 *>(buf), sizeof(buf), T("%lld"),
|
2026-07-25 23:08:40 -06:00
|
|
|
static_cast<long long>(h.val[rv]));
|
|
|
|
|
uint64_t addr = rc.pool_str(buf, strlen(buf));
|
|
|
|
|
return h.emit_sconst(addr, buf);
|
|
|
|
|
}
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
// Runtime ITOA has no sval. Without needs_jit the folded path
|
|
|
|
|
// would return that empty string -- `return not a` answered ""
|
|
|
|
|
// while the interpreter answered "0"/"1".
|
|
|
|
|
h.needs_jit = true;
|
2026-07-25 23:08:40 -06:00
|
|
|
return h.emit(HIR_ITOA, TY_STRING, rv);
|
|
|
|
|
}
|
|
|
|
|
if (h.ty[rv] == TY_FLOAT) {
|
|
|
|
|
if (h.kind[rv] == HIR_FCONST) {
|
|
|
|
|
char buf[64];
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
lua_format_double(h.fval[rv], buf, sizeof(buf));
|
2026-07-25 23:08:40 -06:00
|
|
|
uint64_t addr = rc.pool_str(buf, strlen(buf));
|
|
|
|
|
return h.emit_sconst(addr, buf);
|
|
|
|
|
}
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
// Lua float, so Lua's rendering -- not HIR_FTOA, which formats the
|
|
|
|
|
// MUX way and would drop the ".0" at run time just as the fold used
|
|
|
|
|
// to at compile time (#1488).
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
h.needs_jit = true;
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
return h.emit(HIR_LUA_FTOA, TY_STRING, rv);
|
2026-07-25 23:08:40 -06:00
|
|
|
}
|
|
|
|
|
return rv;
|
|
|
|
|
}
|
|
|
|
|
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
// The return half of a lowered OP_TAILCALL: `return f(...)` is the call the
|
|
|
|
|
// OP_LUA_CALL case just emitted, then this. One helper because the call
|
|
|
|
|
// body has two successful exits (the direct ECALL path and the general
|
|
|
|
|
// path) and both must finish the same way.
|
|
|
|
|
//
|
|
|
|
|
static int lua_tailcall_ret(hir_program &h, rv_compiler &rc, int v,
|
|
|
|
|
int &result_val) {
|
|
|
|
|
int rv = return_as_string(h, rc, v);
|
|
|
|
|
if (rv < 0) return -1;
|
|
|
|
|
h.emit(HIR_RET, TY_VOID, rv);
|
|
|
|
|
if (result_val < 0) {
|
|
|
|
|
result_val = rv;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Helper: load a Lua constant into HIR.
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static int emit_lua_constant(hir_program &h, rv_compiler &rc,
|
|
|
|
|
const lua_bc_constant &k) {
|
|
|
|
|
switch (k.type) {
|
|
|
|
|
case LUA_BC_TNIL:
|
|
|
|
|
return h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
case LUA_BC_TFALSE:
|
|
|
|
|
return h.emit_iconst(0);
|
|
|
|
|
case LUA_BC_TTRUE:
|
|
|
|
|
return h.emit_iconst(1);
|
|
|
|
|
case LUA_BC_TINT:
|
|
|
|
|
return h.emit_iconst(k.ival);
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
case LUA_BC_TFLOAT:
|
|
|
|
|
// A Lua float stays a float even when its value is integral. This
|
|
|
|
|
// used to demote 2.0 to ICONST "for compatibility with integer
|
|
|
|
|
// arithmetic", but Lua 5.4's integer/float distinction is observable
|
|
|
|
|
// -- tostring(2.0) is "2.0", math.type(2.0) is "float", and a float
|
|
|
|
|
// operand makes the whole expression float. Demoting it made
|
|
|
|
|
// `a * 1.0` an integer multiply and `a + 0.0` print "3" (#1488).
|
|
|
|
|
return h.emit_fconst(k.fval);
|
2026-03-18 09:59:36 -06:00
|
|
|
case LUA_BC_TSHRSTR:
|
|
|
|
|
case LUA_BC_TLNGSTR: {
|
|
|
|
|
uint64_t addr = rc.pool_str(k.sval.c_str(), k.sval.size());
|
|
|
|
|
return h.emit_sconst(addr, k.sval);
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Helper: promote an operand to TY_FLOAT if needed.
|
|
|
|
|
// Returns the (possibly new) HIR value index, or -1 on error.
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
2026-07-29 08:08:14 -06:00
|
|
|
// A CALL_STR result is a Lua value marshalled to text the way fun_lua
|
|
|
|
|
// renders a chunk return (nil→"", bool→"0"/"1", else tolstring). That is
|
|
|
|
|
// correct only where the value leaves Lua. Inside the chunk the Lua type
|
|
|
|
|
// is gone: truthiness, ==, and arithmetic then run under softcode / string
|
|
|
|
|
// rules and answer wrongly for values that are truthy in Lua but MUSH-falsy
|
|
|
|
|
// as text ("0", "", integer 0), or for bool/nil that only look right by
|
|
|
|
|
// coincidence of the marshal. #1764: treat the producer as provenance and
|
|
|
|
|
// refuse to consume it under Lua semantics -- the interpreter answers.
|
|
|
|
|
//
|
2026-07-29 15:19:43 -06:00
|
|
|
// The mux.* sentinel fiction is what buys the
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
// native CARGS fast path -- GETTABI on the "mux.args" sentinel becomes an
|
|
|
|
|
// ALOAD with no ECALL -- and it is sound only while the sentinel is
|
|
|
|
|
// consumed AS a sentinel. The moment one reaches an operation that treats
|
|
|
|
|
// it as a value, the program is working with the string "mux.args".
|
|
|
|
|
//
|
|
|
|
|
// It had leaked into five of them (#1795), every one executing and silent:
|
|
|
|
|
// type() said "string", tostring() said "mux.args", concat spliced the
|
|
|
|
|
// name in where Lua raises, == compared it to the literal text, and #mux
|
|
|
|
|
// answered 3 -- #1424's shape (a length taken over the NAME of a thing)
|
|
|
|
|
// on a different value class.
|
|
|
|
|
//
|
|
|
|
|
// Same rule as lua_is_marshalled_str and lua_is_nil below: a
|
|
|
|
|
// representation that lies about its type must be refused by every
|
|
|
|
|
// consumer that would believe it.
|
2026-07-29 08:08:14 -06:00
|
|
|
static inline bool lua_is_marshalled_str(const hir_program &h, int v) {
|
|
|
|
|
return v >= 0 && h.kind[v] == HIR_LUA_CALL_STR;
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
// Lua truthiness provenance. HIR collapses false and integer 0 to the
|
|
|
|
|
// same ICONST 0, and nil and "" to the same empty SCONST. HIR_BOOL is
|
|
|
|
|
// integer SNEZ (softcode truthiness), so `local a=0 if a` answered falsy
|
|
|
|
|
// while Lua requires truthy. Tags restore the distinction at TEST/NOT:
|
|
|
|
|
//
|
2026-07-29 09:24:24 -06:00
|
|
|
// VALUE — numbers, real strings, floats: always truthy in Lua
|
|
|
|
|
// BOOL — 0/1 with boolean semantics (LOADTRUE/FALSE, comparisons)
|
2026-07-29 09:08:10 -06:00
|
|
|
// NIL — always falsy (LOADNIL, or a known-absent plain-table read)
|
2026-07-29 09:24:24 -06:00
|
|
|
// UNKNOWN — tag lost or never known (loop-carried LOAD_Q, …). TEST/NOT
|
|
|
|
|
// must decline rather than default to VALUE: the VALUE default
|
|
|
|
|
// is the unsafe direction (lost BOOL/NIL → silent truthy, the
|
|
|
|
|
// bug class #1765 fixed, re-entering through the back door).
|
|
|
|
|
// #1768.
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
//
|
|
|
|
|
enum lua_truth : uint8_t {
|
2026-07-29 09:24:24 -06:00
|
|
|
LUA_TRUTH_VALUE = 0,
|
|
|
|
|
LUA_TRUTH_BOOL = 1,
|
|
|
|
|
LUA_TRUTH_NIL = 2,
|
|
|
|
|
LUA_TRUTH_UNKNOWN = 3,
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
typedef std::map<int, lua_truth> lua_truth_map;
|
|
|
|
|
|
|
|
|
|
static void lua_truth_set(lua_truth_map &m, int v, lua_truth t) {
|
|
|
|
|
if (v >= 0) {
|
|
|
|
|
m[v] = t;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 12:22:12 -06:00
|
|
|
// Tag a HIR value produced from a Lua pool constant. TNIL/TFALSE/TTRUE
|
|
|
|
|
// share HIR representations with "" / 0 / 1; without the tag, EQK and
|
|
|
|
|
// TEST invent softcode semantics (nil == "" was true compiled).
|
|
|
|
|
//
|
|
|
|
|
static void lua_truth_tag_constant(lua_truth_map &truth,
|
|
|
|
|
const lua_bc_constant &k, int v) {
|
|
|
|
|
if (v < 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
switch (k.type) {
|
|
|
|
|
case LUA_BC_TNIL:
|
|
|
|
|
lua_truth_set(truth, v, LUA_TRUTH_NIL);
|
|
|
|
|
break;
|
|
|
|
|
case LUA_BC_TFALSE:
|
|
|
|
|
case LUA_BC_TTRUE:
|
|
|
|
|
lua_truth_set(truth, v, LUA_TRUTH_BOOL);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): equality compares Lua TYPES, not representations (#1770 review)
The EQK nil fix is right, and its lesson generalizes further than it was
applied. Lua's == is false across TYPES, and HIR erases every
distinction that decides it: false and 0 are the same ICONST, nil and ""
the same empty SCONST, and the numeric path coerces "5" to 5. Measured
on the PR as submitted -- all executing, all silently wrong:
local a=0 if a == false jit "y" interp "n"
local a=1 if a == true jit "y" interp "n"
local a=false if a == 0 jit "y" interp "n"
local a="5" if a == 5 jit "y" interp "n"
local a=5 if a == "5" jit "y" interp "n"
Generalizes the author's nil branch into a type-class test
(lua_type_class_of_value / _of_const, from the truth tag plus the HIR
type): mismatched classes are constant false whatever the
representations say; nil == nil stays true.
EQI needed the same treatment and is now its own case rather than
sharing CMP_RI: `a == 0` lowers to EQI and `a == ""` to EQK, so fixing
either alone leaves the other wrong -- the opcode-twin trap this PR's
own doc note names, one level deeper. Order comparisons keep declining
instead: Lua RAISES on mismatched types there, so answering false would
be its own wrong answer.
Five AGREE pins across both opcode forms; all EXECUTE and answer like
the interpreter. Plan gains the rule as item 5.
Also records the softcode no-post-entry-decline assumption that #1767's
exactly-once argument rests on -- derived today from the #1002 pre-entry
watermarks plus Phase 4 leaving one defensive ECALL_DECLINE that nothing
emits into, and therefore worth writing where it can be checked.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:33 -06:00
|
|
|
// Lua TYPE classes, for equality. Lua's == is false across types --
|
|
|
|
|
// 0 ~= false, "5" ~= 5, nil ~= "" -- but HIR represents false and 0 as
|
|
|
|
|
// the same ICONST, nil and "" as the same empty SCONST, and coerces
|
|
|
|
|
// strings to numbers when comparing. Equality must therefore compare
|
|
|
|
|
// types before representations.
|
|
|
|
|
//
|
|
|
|
|
enum lua_type_class {
|
|
|
|
|
LUA_TC_NIL,
|
|
|
|
|
LUA_TC_BOOL,
|
|
|
|
|
LUA_TC_NUMBER,
|
|
|
|
|
LUA_TC_STRING,
|
|
|
|
|
LUA_TC_OTHER, // handles etc. -- refused before this matters
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static lua_type_class lua_type_class_of_const(const lua_bc_constant &k) {
|
|
|
|
|
switch (k.type) {
|
|
|
|
|
case LUA_BC_TNIL: return LUA_TC_NIL;
|
|
|
|
|
case LUA_BC_TFALSE:
|
|
|
|
|
case LUA_BC_TTRUE: return LUA_TC_BOOL;
|
|
|
|
|
case LUA_BC_TINT:
|
|
|
|
|
case LUA_BC_TFLOAT: return LUA_TC_NUMBER;
|
|
|
|
|
case LUA_BC_TSHRSTR:
|
|
|
|
|
case LUA_BC_TLNGSTR: return LUA_TC_STRING;
|
|
|
|
|
default: return LUA_TC_OTHER;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
static lua_truth lua_truth_of(const hir_program &h, const lua_truth_map &m,
|
|
|
|
|
int v) {
|
|
|
|
|
if (v < 0) {
|
2026-07-29 09:24:24 -06:00
|
|
|
return LUA_TRUTH_UNKNOWN;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
}
|
|
|
|
|
lua_truth_map::const_iterator it = m.find(v);
|
|
|
|
|
if (it != m.end()) {
|
|
|
|
|
return it->second;
|
|
|
|
|
}
|
|
|
|
|
// Comparison / NOT / BOOL results are 0/1 booleans even without a mark.
|
|
|
|
|
switch (h.kind[v]) {
|
|
|
|
|
case HIR_EQ: case HIR_NE: case HIR_LT: case HIR_LE:
|
|
|
|
|
case HIR_GT: case HIR_GE:
|
|
|
|
|
case HIR_FEQ: case HIR_FLT: case HIR_FLE:
|
|
|
|
|
case HIR_BOOL: case HIR_NOT:
|
|
|
|
|
return LUA_TRUTH_BOOL;
|
2026-07-29 09:24:24 -06:00
|
|
|
// Loop-carried q-register reloads (#1732): the value came from a
|
|
|
|
|
// store in another block; its truth class was not carried with it.
|
|
|
|
|
case HIR_LOAD_Q:
|
|
|
|
|
return LUA_TRUTH_UNKNOWN;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
default:
|
2026-07-29 09:24:24 -06:00
|
|
|
// Untagged ICONST/arith/SCONST: VALUE is correct for numbers and
|
|
|
|
|
// real strings. Producers of BOOL/NIL must tag explicitly.
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
return LUA_TRUTH_VALUE;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): audit nil's consumers, not just its producers (#1766 review)
The closed-key-set analysis is sound and the producers are right, but
lowering a known-absent read to nil makes nil a value that FLOWS -- and
the consumers had not been audited. Measured on the first cut, all
silent wrong answers where master had been loudly declining:
"a" .. t[2] jit "a" interp raises "concatenate a nil value"
#t[2] jit 0 interp raises "length of a nil value"
tostring(t[2]) jit "" interp "nil"
t[2] == "" jit 1 interp 0 (found while fixing)
nil is representable in HIR only as the empty SCONST, which is also a
real "" -- the truth tag is the only thing that tells them apart, so
every operation Lua rejects on nil must consult it. One predicate
(lua_is_nil, mirroring lua_is_marshalled_str) applied at CONCAT, LEN,
the call-argument walk, and both comparison macros; promote_to_int and
promote_to_float already had it.
Five AGREE pins, one per shape above plus concat-through-a-local, so
the class cannot regress silently. Budget 10 -> 15: nil has no
compiled consumer yet, and every one of those five is a decline by
design until it does.
This is the fourth PR in the campaign where emptying a loud bin
introduced a silent wrong answer (#1755, #1756, #1763, this). The
pattern is worth naming: a new VALUE CLASS needs a consumer audit, not
just correct producers.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:56:20 -06:00
|
|
|
// A value KNOWN to be Lua nil at lowering: LOADNIL, or a known-absent
|
|
|
|
|
// read of a plain table with a closed key set. nil is representable in
|
|
|
|
|
// HIR only as the empty SCONST, which is also a real "" -- the truth tag
|
|
|
|
|
// is what tells them apart, so every consumer Lua would reject on nil
|
|
|
|
|
// must consult it here.
|
|
|
|
|
//
|
|
|
|
|
// Producers are easy to get right and consumers are easy to forget: the
|
|
|
|
|
// first cut of this change taught `"a" .. t[2]` to answer "a" where Lua
|
|
|
|
|
// raises, `#t[2]` to answer 0, and tostring(t[2]) to answer "" instead of
|
|
|
|
|
// "nil". A new value class needs a consumer audit, not just correct
|
|
|
|
|
// producers (#1751 review note).
|
|
|
|
|
//
|
|
|
|
|
static inline bool lua_is_nil(const hir_program &h, const lua_truth_map &m,
|
|
|
|
|
int v) {
|
|
|
|
|
return lua_truth_of(h, m, v) == LUA_TRUTH_NIL;
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): equality compares Lua TYPES, not representations (#1770 review)
The EQK nil fix is right, and its lesson generalizes further than it was
applied. Lua's == is false across TYPES, and HIR erases every
distinction that decides it: false and 0 are the same ICONST, nil and ""
the same empty SCONST, and the numeric path coerces "5" to 5. Measured
on the PR as submitted -- all executing, all silently wrong:
local a=0 if a == false jit "y" interp "n"
local a=1 if a == true jit "y" interp "n"
local a=false if a == 0 jit "y" interp "n"
local a="5" if a == 5 jit "y" interp "n"
local a=5 if a == "5" jit "y" interp "n"
Generalizes the author's nil branch into a type-class test
(lua_type_class_of_value / _of_const, from the truth tag plus the HIR
type): mismatched classes are constant false whatever the
representations say; nil == nil stays true.
EQI needed the same treatment and is now its own case rather than
sharing CMP_RI: `a == 0` lowers to EQI and `a == ""` to EQK, so fixing
either alone leaves the other wrong -- the opcode-twin trap this PR's
own doc note names, one level deeper. Order comparisons keep declining
instead: Lua RAISES on mismatched types there, so answering false would
be its own wrong answer.
Five AGREE pins across both opcode forms; all EXECUTE and answer like
the interpreter. Plan gains the rule as item 5.
Also records the softcode no-post-entry-decline assumption that #1767's
exactly-once argument rests on -- derived today from the #1002 pre-entry
watermarks plus Phase 4 leaving one defensive ECALL_DECLINE that nothing
emits into, and therefore worth writing where it can be checked.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:33 -06:00
|
|
|
// Lua type class of a lowered VALUE. The truth tag carries exactly the
|
|
|
|
|
// distinctions HIR erases; everything else follows the HIR type.
|
|
|
|
|
//
|
|
|
|
|
static lua_type_class lua_type_class_of_value(const hir_program &h,
|
|
|
|
|
const lua_truth_map &m,
|
|
|
|
|
int v) {
|
|
|
|
|
if (v < 0) return LUA_TC_OTHER;
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
// A mux.* sentinel is really a TABLE; classifying it by its SCONST
|
|
|
|
|
// representation would compare it against the literal "mux.args".
|
|
|
|
|
if (lua_is_mux_sentinel(h, v)) return LUA_TC_OTHER;
|
fix(lua/jit): equality compares Lua TYPES, not representations (#1770 review)
The EQK nil fix is right, and its lesson generalizes further than it was
applied. Lua's == is false across TYPES, and HIR erases every
distinction that decides it: false and 0 are the same ICONST, nil and ""
the same empty SCONST, and the numeric path coerces "5" to 5. Measured
on the PR as submitted -- all executing, all silently wrong:
local a=0 if a == false jit "y" interp "n"
local a=1 if a == true jit "y" interp "n"
local a=false if a == 0 jit "y" interp "n"
local a="5" if a == 5 jit "y" interp "n"
local a=5 if a == "5" jit "y" interp "n"
Generalizes the author's nil branch into a type-class test
(lua_type_class_of_value / _of_const, from the truth tag plus the HIR
type): mismatched classes are constant false whatever the
representations say; nil == nil stays true.
EQI needed the same treatment and is now its own case rather than
sharing CMP_RI: `a == 0` lowers to EQI and `a == ""` to EQK, so fixing
either alone leaves the other wrong -- the opcode-twin trap this PR's
own doc note names, one level deeper. Order comparisons keep declining
instead: Lua RAISES on mismatched types there, so answering false would
be its own wrong answer.
Five AGREE pins across both opcode forms; all EXECUTE and answer like
the interpreter. Plan gains the rule as item 5.
Also records the softcode no-post-entry-decline assumption that #1767's
exactly-once argument rests on -- derived today from the #1002 pre-entry
watermarks plus Phase 4 leaving one defensive ECALL_DECLINE that nothing
emits into, and therefore worth writing where it can be checked.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:33 -06:00
|
|
|
const lua_truth t = lua_truth_of(h, m, v);
|
|
|
|
|
if (t == LUA_TRUTH_NIL) return LUA_TC_NIL;
|
|
|
|
|
if (t == LUA_TRUTH_BOOL) return LUA_TC_BOOL;
|
|
|
|
|
if (t == LUA_TRUTH_UNKNOWN) return LUA_TC_OTHER; // caller declines
|
|
|
|
|
switch (h.ty[v]) {
|
|
|
|
|
case TY_INT:
|
|
|
|
|
case TY_FLOAT: return LUA_TC_NUMBER;
|
|
|
|
|
case TY_STRING: return LUA_TC_STRING;
|
|
|
|
|
default: return LUA_TC_OTHER;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 07:12:16 -06:00
|
|
|
// Encode a non-handle operand for HIR_LUA_EQ (kind in val): 0=int, 1=str,
|
|
|
|
|
// 3=nil, 4=bool. Returns the rhs HIR value index, or -1 if unencodable.
|
|
|
|
|
// #1835: NIL/BOOL must not collapse to kind 1/0 via empty SCONST / ICONST.
|
|
|
|
|
//
|
|
|
|
|
static int lua_eq_kind_of_value(hir_program &h, const lua_truth_map &m,
|
|
|
|
|
int v, int *kind_out) {
|
|
|
|
|
const lua_truth tr = lua_truth_of(h, m, v);
|
|
|
|
|
if (tr == LUA_TRUTH_NIL) {
|
|
|
|
|
*kind_out = 3;
|
|
|
|
|
return h.emit_iconst(0);
|
|
|
|
|
}
|
|
|
|
|
if (tr == LUA_TRUTH_BOOL) {
|
|
|
|
|
*kind_out = 4;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
if (h.kind[v] == HIR_SCONST) {
|
|
|
|
|
*kind_out = 1;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
if (h.ty[v] == TY_INT) {
|
|
|
|
|
*kind_out = 0;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:08:10 -06:00
|
|
|
static int promote_to_float(hir_program &h, const lua_truth_map &truth, int v) {
|
2026-03-18 14:53:23 -06:00
|
|
|
if (v < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
if (lua_is_marshalled_str(h, v)) return -1;
|
2026-07-29 15:19:43 -06:00
|
|
|
if (lua_is_mux_sentinel(h, v)) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
// nil is not a number; ATOI("") would invent 0.
|
|
|
|
|
if (lua_truth_of(h, truth, v) == LUA_TRUTH_NIL) return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
if (h.ty[v] == TY_FLOAT) return v;
|
|
|
|
|
if (h.ty[v] == TY_INT) {
|
|
|
|
|
return h.emit(HIR_ITOF, TY_FLOAT, v);
|
|
|
|
|
}
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (h.ty[v] == TY_STRING) {
|
|
|
|
|
int as_int = h.emit(HIR_ATOI, TY_INT, v);
|
|
|
|
|
if (as_int < 0) return -1;
|
|
|
|
|
return h.emit(HIR_ITOF, TY_FLOAT, as_int);
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Helper: promote an operand to TY_INT if needed.
|
|
|
|
|
// TY_STRING → HIR_ATOI. TY_INT passes through.
|
|
|
|
|
// TY_FLOAT returns -1 (use promote_to_float instead).
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
2026-07-29 09:08:10 -06:00
|
|
|
static int promote_to_int(hir_program &h, const lua_truth_map &truth, int v) {
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (v < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
if (lua_is_marshalled_str(h, v)) return -1;
|
2026-07-29 15:19:43 -06:00
|
|
|
if (lua_is_mux_sentinel(h, v)) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
if (lua_truth_of(h, truth, v) == LUA_TRUTH_NIL) return -1;
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (h.ty[v] == TY_INT) return v;
|
|
|
|
|
if (h.ty[v] == TY_STRING) return h.emit(HIR_ATOI, TY_INT, v);
|
|
|
|
|
return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Returns true if either operand is TY_FLOAT (i.e., need float arithmetic).
|
|
|
|
|
//
|
|
|
|
|
static bool either_float(hir_program &h, int a, int b) {
|
|
|
|
|
return (a >= 0 && h.ty[a] == TY_FLOAT)
|
|
|
|
|
|| (b >= 0 && h.ty[b] == TY_FLOAT);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Helper: emit a comparison + branch pattern.
|
|
|
|
|
// Many Lua comparison opcodes share the same structure:
|
|
|
|
|
// compare → optional negate (k bit) → read JMP → emit BRC
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
static inline bool lua_reg_in_range(int idx); // defined with pass 2
|
|
|
|
|
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
// A Lua handle is a reference into the VM, not a value (#1579). Arithmetic,
|
|
|
|
|
// comparison, length, concatenation and returning are all illegal on one, so
|
|
|
|
|
// decline the chunk rather than let a stack index flow on as though it were
|
|
|
|
|
// the thing it points at -- which is what made `#t` answer 22 (#1424).
|
|
|
|
|
//
|
|
|
|
|
// Declining here is strictly better than the run-time bail that #1518's
|
|
|
|
|
// fail-closed intercept produces today: it costs no compile-and-run, and it
|
|
|
|
|
// does not depend on the bridge ECALL names staying unimplemented.
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
//
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// What a handle refers to -- its REFERENT -- tracked per HIR value while
|
|
|
|
|
// lowering. The type system says "handle"; this says handle to WHAT, which
|
|
|
|
|
// is the question two decision sites had each been answering by inspecting
|
|
|
|
|
// provenance:
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
//
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// * OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT by whether the
|
|
|
|
|
// table handle's producing instruction was GETGLOBAL, and
|
|
|
|
|
// * OP_LUA_CALL chose CALL_INT vs CALL_STR by walking back to the SCONST
|
|
|
|
|
// key that named the callee and consulting a whitelist -- which then had
|
|
|
|
|
// to gate BOTH branches (d5e5e86e0), or a name skipping one could fall
|
|
|
|
|
// through and claim the other's result type.
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
//
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// Now the claim is made once, where the handle is created, and the decision
|
|
|
|
|
// sites read it. A claim is ELIGIBILITY, not soundness: every ECALL
|
|
|
|
|
// verifies at runtime (lua_isfunction before calling, lua_isinteger and
|
|
|
|
|
// LUA_TSTRING on results) and declines to the interpreter on a miss, so a
|
|
|
|
|
// wrong claim costs a bail, never a wrong answer. That is what lets
|
|
|
|
|
// TY_STRING be the open default for a name nothing here knows -- a
|
|
|
|
|
// game-defined global that does return a string compiles and runs, and one
|
|
|
|
|
// that does not declines exactly where it always did.
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
//
|
feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume. The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.
Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once. And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).
HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape). Noted in both places.
EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1. AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.
luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
|
|
|
// A library member that is a VALUE rather than a function -- math.pi,
|
|
|
|
|
// math.maxinteger. Reading one takes the value directly (GETFIELD_INT /
|
|
|
|
|
// GETFIELD_FLT on the library table) instead of a reference nothing could
|
|
|
|
|
// consume. Function members deliberately do NOT appear here: their return
|
|
|
|
|
// claims stay in lua_call_claim, so each fact lives once.
|
|
|
|
|
//
|
|
|
|
|
struct lua_lib_value {
|
|
|
|
|
const char *name;
|
|
|
|
|
hir_type ty; // TY_INT or TY_FLOAT
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static const lua_lib_value k_lua_math_values[] = {
|
|
|
|
|
{"maxinteger", TY_INT},
|
|
|
|
|
{"mininteger", TY_INT},
|
|
|
|
|
{"pi", TY_FLOAT},
|
|
|
|
|
{"huge", TY_FLOAT},
|
|
|
|
|
{nullptr, TY_VOID},
|
|
|
|
|
};
|
|
|
|
|
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
struct lua_referent {
|
|
|
|
|
// Field reads: false means members are values (GETFIELD_INT -- the
|
|
|
|
|
// NEWTABLE shape), true means members are references (GETFIELD_REF --
|
|
|
|
|
// the library shape). GETGLOBAL results are the only handles whose
|
|
|
|
|
// fields are taken by reference, same as the provenance test chose.
|
|
|
|
|
bool fields_are_refs = false;
|
|
|
|
|
|
|
|
|
|
// Calls: may one be attempted, and what does it claim to return?
|
|
|
|
|
// TY_INT and TY_STRING are the two marshallings that exist; a handle
|
|
|
|
|
// that never received a callable claim declines the call at compile
|
|
|
|
|
// time, which is where a NEWTABLE or a nested-field handle lands.
|
|
|
|
|
bool callable = false;
|
|
|
|
|
hir_type returns = TY_VOID;
|
feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume. The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.
Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once. And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).
HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape). Noted in both places.
EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1. AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.
luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
|
|
|
|
2026-07-31 09:08:44 -06:00
|
|
|
// Callee name when known at the GETGLOBAL/GETFIELD site (e.g. "tonumber").
|
|
|
|
|
// Used for call-site result specialisation (#1866 fast path): a fixed
|
|
|
|
|
// returns claim cannot express "int or float depending on the arg".
|
|
|
|
|
//
|
|
|
|
|
std::string call_name;
|
|
|
|
|
|
2026-07-29 09:12:24 -06:00
|
|
|
// The callee may have side effects the player can observe
|
|
|
|
|
// (mux.notify/pemit/set/eval). Recorded for diagnostics and for
|
|
|
|
|
// any future purity analysis; it is NOT a compile-time refuse.
|
|
|
|
|
// #1751 Phase 4 deleted post-entry re-run, so an effect delivered
|
|
|
|
|
// compiled is not re-delivered by a silent interpreter retry — the
|
|
|
|
|
// reason #1750 made these ineligible. Both routes share the same
|
|
|
|
|
// bridge C functions under the same permissions now.
|
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.
The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path). Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction. The
name-mapped branch and its is_bridge plumbing are deleted.
Two consequences handled:
* Execution context. The bridge functions read executor/caller/
enactor from the registry, and per-run mux fields are injected into
the table -- previously only in ExecuteChunk, the interpreter leg,
so a compiled run offered the bridge a cleared registry and the
PREVIOUS run's mux table. The setup/teardown is now one shared pair
(lua_setup_exec_context / lua_clear_exec_context) staged by both
ExecuteChunk and TryJIT's two RunCompiled sites.
* Effectful members stay exactly-once. mux.notify/pemit/set in a
result-consuming form would pcall -- effect delivered -- then
decline on the result type, and the interpreter re-run would deliver
the effect twice. The referent now carries an effectful claim:
statement form compiles (CALL_VOID has no result check), result-
consuming forms decline at COMPILE time and run once, interpreted.
Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.
smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
|
|
|
bool effectful = false;
|
|
|
|
|
|
feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume. The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.
Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once. And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).
HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape). Noted in both places.
EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1. AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.
luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
|
|
|
// Known VALUE members, for a recognized standard-library table; null
|
|
|
|
|
// for everything else. Like every claim here it is eligibility only:
|
|
|
|
|
// a game that rebinds math.pi to a string declines at the runtime
|
|
|
|
|
// check, not answers wrongly.
|
|
|
|
|
const lua_lib_value *values = nullptr;
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
|
|
|
|
|
// PROVEN plain: created by NEWTABLE in this chunk and never passed as
|
|
|
|
|
// a call argument since. Only such a table may take the typed
|
|
|
|
|
// integer-keyed fast reads (GETI / GETFIELD_INT / GETFIELD_FLT):
|
|
|
|
|
// their ok=0 claim-miss path continues with 0, which is sound only
|
|
|
|
|
// when no metamethod can fire and no non-integer value can have been
|
|
|
|
|
// stored -- both guaranteed by construction here (compiled stores are
|
|
|
|
|
// integer-only, plain tables have no __index) and by NOTHING for a
|
|
|
|
|
// handle from anywhere else. #1751's Phase 1 review proved the miss:
|
|
|
|
|
// an interpreter-installed __index on a global made compiled `T[1]`
|
|
|
|
|
// answer 0 where the interpreter answered "s", silently. Escaping
|
|
|
|
|
// as a call argument clears the proof: the callee can setmetatable.
|
|
|
|
|
bool plain_proven = false;
|
2026-07-29 09:08:10 -06:00
|
|
|
|
|
|
|
|
// Closed integer-key set for a plain table: every compiled store used
|
|
|
|
|
// a constant key recorded in int_keys. A dynamic-key store clears
|
|
|
|
|
// keys_closed. When closed, a GETI of a constant key not in the set
|
|
|
|
|
// is known-absent at lowering — emit nil, no post-entry GETI_INT
|
|
|
|
|
// miss (the residual loud site for `return t[2]` after `t[1]=5`).
|
|
|
|
|
bool keys_closed = false;
|
|
|
|
|
std::set<int64_t> int_keys;
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
};
|
|
|
|
|
|
feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume. The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.
Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once. And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).
HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape). Noted in both places.
EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1. AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.
luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
|
|
|
static hir_type lua_lib_value_type(const lua_referent &t,
|
|
|
|
|
const std::string &key) {
|
|
|
|
|
if (nullptr == t.values) return TY_VOID;
|
|
|
|
|
for (const lua_lib_value *v = t.values; v->name != nullptr; v++) {
|
|
|
|
|
if (key == v->name) return v->ty;
|
|
|
|
|
}
|
|
|
|
|
return TY_VOID;
|
|
|
|
|
}
|
|
|
|
|
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
typedef std::map<int, lua_referent> lua_ref_map;
|
|
|
|
|
|
|
|
|
|
static lua_referent lua_referent_of(const lua_ref_map &m, int v) {
|
|
|
|
|
lua_ref_map::const_iterator it = m.find(v);
|
|
|
|
|
return (it == m.end()) ? lua_referent() : it->second;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:08:10 -06:00
|
|
|
// Record a constant integer-key store on a plain table. No-op if the
|
|
|
|
|
// handle is not plain or keys are already open.
|
|
|
|
|
//
|
|
|
|
|
static void lua_note_int_key_store(lua_ref_map &m, int tbl, int64_t key) {
|
|
|
|
|
lua_ref_map::iterator it = m.find(tbl);
|
|
|
|
|
if (it == m.end() || !it->second.plain_proven) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!it->second.keys_closed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
it->second.int_keys.insert(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A non-constant integer key store: the closed set is no longer complete.
|
|
|
|
|
//
|
|
|
|
|
static void lua_note_dynamic_int_key_store(lua_ref_map &m, int tbl) {
|
|
|
|
|
lua_ref_map::iterator it = m.find(tbl);
|
|
|
|
|
if (it == m.end() || !it->second.plain_proven) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
it->second.keys_closed = false;
|
|
|
|
|
it->second.int_keys.clear();
|
|
|
|
|
}
|
|
|
|
|
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// The standard-library knowledge, in one place: what does calling NAME
|
|
|
|
|
// return? HIR result types are static and Lua's are not -- math.max(3,9)
|
|
|
|
|
// and tostring(42) take the same argument shapes and return different
|
|
|
|
|
// types -- so the name is the only thing that carries it, and this list is
|
|
|
|
|
// a claim about the standard library. Names claiming TY_INT stay few and
|
|
|
|
|
// deliberate; everything else claims TY_STRING, the open default the
|
|
|
|
|
// runtime check makes safe.
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
//
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
static hir_type lua_call_claim(const std::string &name) {
|
2026-07-31 09:06:07 -06:00
|
|
|
// #1866: tonumber is NOT on this list. Lua 5.4 returns an integer
|
|
|
|
|
// for integral inputs and a float for non-integral ones; claiming
|
|
|
|
|
// TY_INT always emitted CALL_INT, and ECALL_LUA_CALL_INT declines
|
|
|
|
|
// when lua_isinteger is false -- a post-entry residual after the
|
2026-07-31 09:08:44 -06:00
|
|
|
// function has already run. Default claim routes CALL_VAL; the
|
|
|
|
|
// call site upgrades to CALL_INT when the argument is a proven
|
|
|
|
|
// integer (HIR INT or integral string constant) so the hot path
|
|
|
|
|
// stays native.
|
2026-07-31 09:06:07 -06:00
|
|
|
//
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
static const char *kIntReturning[] = {
|
|
|
|
|
"floor", "ceil", "max", "min", "abs", "tointeger",
|
|
|
|
|
"len", "byte", "maxinteger", "mininteger",
|
|
|
|
|
};
|
|
|
|
|
for (const char *n : kIntReturning) {
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
if (name == n) return TY_INT;
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
}
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// FLOAT-returning stdlib names. No CALL_FLT marshalling exists, so
|
|
|
|
|
// a FLOAT claim makes the call ineligible at lowering and the
|
|
|
|
|
// interpreter answers -- silently, with the right subtype. Before
|
|
|
|
|
// this list, sqrt claimed the STRING default and its miss was a
|
|
|
|
|
// post-entry fail (smoke TC046 under Phase 0).
|
2026-07-31 09:06:07 -06:00
|
|
|
//
|
|
|
|
|
// tonumber is also not here: a FLOAT claim would decline the whole
|
2026-07-31 09:08:44 -06:00
|
|
|
// call, including the common tonumber("17") integer case.
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
static const char *kFloatReturning[] = {
|
|
|
|
|
"sqrt", "exp", "log", "sin", "cos", "tan",
|
|
|
|
|
"asin", "acos", "atan", "fmod", "rad", "deg",
|
|
|
|
|
"random", // float in the no-argument form
|
|
|
|
|
};
|
|
|
|
|
for (const char *n : kFloatReturning) {
|
|
|
|
|
if (name == n) return TY_FLOAT;
|
|
|
|
|
}
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
return TY_STRING;
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
}
|
|
|
|
|
|
2026-07-31 09:08:44 -06:00
|
|
|
// #1866: when is tonumber(arg) guaranteed to return a Lua integer?
|
|
|
|
|
// - HIR integer (tonumber(3) → integer)
|
2026-07-31 09:20:59 -06:00
|
|
|
// - SCONST of optional '-' + digits only, small enough to fit int64
|
|
|
|
|
// (tonumber("17") → integer; tonumber("3.0") / "3.5" / "1e2" stay on
|
|
|
|
|
// CALL_VAL, and an overflowing literal must too -- see below)
|
2026-07-31 09:08:44 -06:00
|
|
|
// Runtime strings and floats take CALL_VAL.
|
|
|
|
|
//
|
|
|
|
|
static bool lua_tonumber_arg_is_integral(const hir_program &h, int areg) {
|
|
|
|
|
if (areg < 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (h.ty[areg] == TY_INT) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (h.kind[areg] != HIR_SCONST) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const std::string &s = h.sval[areg];
|
|
|
|
|
if (s.empty()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
size_t i = 0;
|
|
|
|
|
if (s[0] == '-' || s[0] == '+') {
|
|
|
|
|
i = 1;
|
|
|
|
|
if (i >= s.size()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 09:20:59 -06:00
|
|
|
const size_t start = i;
|
2026-07-31 09:08:44 -06:00
|
|
|
for (; i < s.size(); i++) {
|
|
|
|
|
if (s[i] < '0' || s[i] > '9') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 09:20:59 -06:00
|
|
|
// All digits -- but Lua 5.4 returns a FLOAT when an all-digit literal
|
|
|
|
|
// overflows int64 (tonumber("9223372036854775808") -> 9.2e18), so
|
|
|
|
|
// claiming CALL_INT here would post-entry decline where the interpreter
|
|
|
|
|
// answers a float. INT64_MAX has 19 digits; <= 18 significant digits
|
|
|
|
|
// always fits. Bound conservatively rather than parse (#1866 review).
|
|
|
|
|
//
|
|
|
|
|
size_t z = start;
|
|
|
|
|
while (z < s.size() && s[z] == '0') {
|
|
|
|
|
z++;
|
|
|
|
|
}
|
|
|
|
|
if (s.size() - z > 18) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-31 09:08:44 -06:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
static inline bool lua_is_handle(const hir_program &h, int v) {
|
|
|
|
|
return v >= 0 && h.ty[v] == TY_LUA_HANDLE;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
static int emit_cmp_branch(hir_program &h, int cmp, int k_bit,
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
const lua_bc_proto *proto, int &pc,
|
2026-03-18 09:59:36 -06:00
|
|
|
const std::vector<int> &pc_to_block,
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
int cur_hir_block, int n,
|
|
|
|
|
int *lua_reg, bool multi_block) {
|
2026-03-18 09:59:36 -06:00
|
|
|
if (cmp < 0) return -1;
|
fix(lua/jit): the branch value must follow the branch taken (#1486)
Two defects, and the second hid the first.
1. The returned value was pinned to the first RET.
HIR_RET only emits an exit; the value reaches the caller through
rc.final_out, a single address derived at compile time from h.result.
h.result is set from the *first* return site -- deliberately, because Lua
appends a trailing dead RETURN0 whose empty SCONST would otherwise clobber
the real value (#1309).
That heuristic holds only while one return is reachable. With a branch, both
are, and the answer came from the first no matter which executed:
local x=1 if x>3 then return 777 else return 888 end jit 777 interp 888
local x=9 if x>3 then return 777 else return 888 end jit 777 interp 777
local x=1 if x<3 then return 777 else return 888 end jit 777 interp 777
local x=9 if x<3 then return 777 else return 888 end jit 777 interp 888
Always 777, correct only when 777 happened to be right. Any chunk with more
than one reachable return is affected; this is not specific to if/else.
Now every HIR_RET materializes its value into one shared output slot before
exiting, and final_out names that slot, so the value follows the path taken.
The copy mirrors the string-PHI materialization already in hir_codegen.
Only for multi-block programs. A single block runs top to bottom and exits at
its first HIR_RET, so first-return is the executed return there, and the
compile-time result is kept -- straight-line chunks like `return 42` keep
their constant folding rather than being forced to run.
2. emit_cmp_branch had the branch polarity inverted.
Lua's conditional ops are "if (cond ~= k) then pc++", and the pc++ skips the
JMP that follows, so the JMP is taken exactly when cond == k. true_target is
that JMP's destination, so the branch condition must be (cond == k): negate
when k is 0. It negated when k is 1. OP_EQK carries the opposite convention
-- no JMP to fuse, so its true_target is the skip -- and negating on k is
right there. The two look alike and mean opposite things.
Fixing either alone looks like nothing happened, which is why this sat:
neither always 777 (value pinned)
RET only 777/888 inverted (polarity now visible)
both 888/777/777/888 (matches the interpreter)
Verified against the interpreter with lua_jit 0 vs 1, classifying each case by
whether a code_cache row appeared -- a declined chunk agrees trivially and
reads as a pass. All the above compile. Also correct for three-way elseif
chains, string returns, and genuine runtime conditions (`mux.args[1]+0`) in
both directions. Straight-line chunks still compile and fold. Smoke
1505/1505 on both routes.
Smoke cannot cover this: lua_jit is default-off, so the suite exercises the
interpreter (#1426). The differential table above is the acceptance test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:51:52 -06:00
|
|
|
// Lua's conditional ops are "if (cond ~= k) then pc++", and that pc++
|
|
|
|
|
// skips the JMP which follows. So the JMP is taken exactly when
|
|
|
|
|
// cond == k, and falling through to pc+2 is the cond != k case.
|
|
|
|
|
// true_target below is the JMP's destination, so the branch condition
|
|
|
|
|
// must be (cond == k): negate when k is 0, not when it is 1 (#1486).
|
|
|
|
|
//
|
2026-07-29 13:22:14 -06:00
|
|
|
// EQK uses the same condjump+JMP shape as EQ/EQI (luaK_codeeq →
|
|
|
|
|
// condjump), so it shares this polarity. An older lowering treated
|
|
|
|
|
// EQK as a bare skip to pc+1/pc+2 with inverted k; that path is gone.
|
fix(lua/jit): the branch value must follow the branch taken (#1486)
Two defects, and the second hid the first.
1. The returned value was pinned to the first RET.
HIR_RET only emits an exit; the value reaches the caller through
rc.final_out, a single address derived at compile time from h.result.
h.result is set from the *first* return site -- deliberately, because Lua
appends a trailing dead RETURN0 whose empty SCONST would otherwise clobber
the real value (#1309).
That heuristic holds only while one return is reachable. With a branch, both
are, and the answer came from the first no matter which executed:
local x=1 if x>3 then return 777 else return 888 end jit 777 interp 888
local x=9 if x>3 then return 777 else return 888 end jit 777 interp 777
local x=1 if x<3 then return 777 else return 888 end jit 777 interp 777
local x=9 if x<3 then return 777 else return 888 end jit 777 interp 888
Always 777, correct only when 777 happened to be right. Any chunk with more
than one reachable return is affected; this is not specific to if/else.
Now every HIR_RET materializes its value into one shared output slot before
exiting, and final_out names that slot, so the value follows the path taken.
The copy mirrors the string-PHI materialization already in hir_codegen.
Only for multi-block programs. A single block runs top to bottom and exits at
its first HIR_RET, so first-return is the executed return there, and the
compile-time result is kept -- straight-line chunks like `return 42` keep
their constant folding rather than being forced to run.
2. emit_cmp_branch had the branch polarity inverted.
Lua's conditional ops are "if (cond ~= k) then pc++", and the pc++ skips the
JMP that follows, so the JMP is taken exactly when cond == k. true_target is
that JMP's destination, so the branch condition must be (cond == k): negate
when k is 0. It negated when k is 1. OP_EQK carries the opposite convention
-- no JMP to fuse, so its true_target is the skip -- and negating on k is
right there. The two look alike and mean opposite things.
Fixing either alone looks like nothing happened, which is why this sat:
neither always 777 (value pinned)
RET only 777/888 inverted (polarity now visible)
both 888/777/777/888 (matches the interpreter)
Verified against the interpreter with lua_jit 0 vs 1, classifying each case by
whether a code_cache row appeared -- a declined chunk agrees trivially and
reads as a pass. All the above compile. Also correct for three-way elseif
chains, string returns, and genuine runtime conditions (`mux.args[1]+0`) in
both directions. Straight-line chunks still compile and fold. Smoke
1505/1505 on both routes.
Smoke cannot cover this: lua_jit is default-off, so the suite exercises the
interpreter (#1426). The differential table above is the acceptance test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:51:52 -06:00
|
|
|
if (!k_bit) {
|
2026-03-18 09:59:36 -06:00
|
|
|
cmp = h.emit(HIR_NOT, TY_INT, cmp);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
}
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
|
|
|
|
|
// The condition used as a *value* rather than as a branch: `cmp` already
|
|
|
|
|
// is (cond == k), which is exactly what the LFALSESKIP/LOADTRUE pair
|
|
|
|
|
// computes, so drop the whole run and keep the comparison (#1421).
|
|
|
|
|
int dst;
|
|
|
|
|
if (lua_reg != nullptr && lua_bool_fuse_at(proto, pc, n, &dst)) {
|
|
|
|
|
if (!lua_reg_in_range(dst)) return -1;
|
|
|
|
|
lua_reg[dst] = cmp;
|
|
|
|
|
pc += 2; // LFALSESKIP and LOADTRUE; the caller steps over the JMP
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only a real branch needs more than one block. This guard sits after the
|
|
|
|
|
// fuse on purpose: fusing removes the chunk's only branch, so `return a<b`
|
|
|
|
|
// is single-block by construction and would otherwise decline here.
|
|
|
|
|
if (!multi_block) return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
if (pc + 1 >= n) return -1;
|
|
|
|
|
const lua_bc_instruction &jmp_insn = proto->code[pc + 1];
|
|
|
|
|
if (jmp_insn.opcode() != OP_LUA_JMP) return -1;
|
|
|
|
|
int true_target = pc + 2 + jmp_insn.sJ();
|
|
|
|
|
int false_target = pc + 2;
|
|
|
|
|
int true_blk = (true_target >= 0 && true_target < n) ? pc_to_block[true_target] : -1;
|
|
|
|
|
int false_blk = (false_target >= 0 && false_target < n) ? pc_to_block[false_target] : -1;
|
|
|
|
|
if (true_blk < 0 || false_blk < 0) return -1;
|
|
|
|
|
h.emit(HIR_BRC, TY_VOID, cmp, false_blk, true_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, true_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, false_blk);
|
|
|
|
|
return 0; // success
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Pass 2: emit HIR
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
2026-06-14 09:52:36 -06:00
|
|
|
// Defensive bound for composite register indices (A + offset). Bare A/B/C
|
|
|
|
|
// operands are 8-bit (< MAX_LUA_REGS == 256) and always index lua_reg[]
|
|
|
|
|
// safely, but ranges — LOADNIL's R(A)..R(A+B), CONCAT/SETLIST/CALL argument
|
|
|
|
|
// and result runs, and the TFOR result registers R(A+4)..R(A+3+C) — can
|
|
|
|
|
// reach ~R(A+4+255) with crafted operands, past the end of lua_reg[].
|
|
|
|
|
// Well-formed Lua 5.4 compiler output keeps every register < maxstacksize
|
|
|
|
|
// (<= MAX_LUA_STACK == 64), so this guard only fires on malformed bytecode;
|
|
|
|
|
// bail to the Lua VM (return -1) rather than overrun the map. (Crafted
|
|
|
|
|
// bytecode can't reach this lowering through the text-only sandbox today,
|
|
|
|
|
// but the translation must stay memory-safe regardless.)
|
|
|
|
|
//
|
|
|
|
|
static inline bool lua_reg_in_range(int idx) {
|
|
|
|
|
return idx >= 0 && idx < MAX_LUA_REGS;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
int hir_lower_lua_proto(hir_program &h, rv_compiler &rc,
|
|
|
|
|
const lua_bc_proto *proto) {
|
|
|
|
|
if (nullptr == proto) return -1;
|
|
|
|
|
|
|
|
|
|
int n = static_cast<int>(proto->code.size());
|
|
|
|
|
if (n == 0) return -1;
|
|
|
|
|
|
|
|
|
|
// Pass 1: find block boundaries.
|
|
|
|
|
std::vector<bool> is_leader;
|
|
|
|
|
find_block_starts(proto, is_leader);
|
|
|
|
|
std::vector<int> pc_to_block;
|
|
|
|
|
int num_blocks = assign_blocks(is_leader, pc_to_block, n);
|
|
|
|
|
|
|
|
|
|
bool multi_block = (num_blocks > 1);
|
|
|
|
|
|
|
|
|
|
// Allocate HIR blocks.
|
|
|
|
|
if (multi_block) {
|
|
|
|
|
for (int b = 1; b < num_blocks; b++) {
|
|
|
|
|
int nb = h.new_block();
|
|
|
|
|
if (nb < 0) return -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lua register → HIR value map.
|
|
|
|
|
int lua_reg[MAX_LUA_REGS];
|
|
|
|
|
memset(lua_reg, -1, sizeof(lua_reg));
|
|
|
|
|
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// HIR value → referent claim, for TY_LUA_HANDLE values. Keyed by HIR
|
|
|
|
|
// value id, so it is indifferent to blocks and to which Lua register a
|
|
|
|
|
// handle currently sits in. A handle with no entry gets the default:
|
|
|
|
|
// fields are values, calls decline.
|
|
|
|
|
lua_ref_map lua_ref;
|
|
|
|
|
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
// HIR value → Lua truth class (see lua_truth). Distinguishes false
|
|
|
|
|
// from integer 0 and nil from "" so TEST/NOT follow Lua, not HIR_BOOL.
|
|
|
|
|
lua_truth_map truth_tag;
|
|
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Loop-carried value routing (#1732). A plain HIR value crosses a
|
|
|
|
|
// block boundary only under dominance, and the transition below drops
|
|
|
|
|
// everything else -- which is correct for diamonds and fatal for
|
|
|
|
|
// loops: the accumulator in `for i=1,4 do s=s+i end` is written in the
|
|
|
|
|
// body and read by the next iteration. Loop protos therefore route
|
|
|
|
|
// Lua registers through q-registers (reg r → qreg r), the one kind of
|
|
|
|
|
// traffic hir_ssa_construct PHI-converts -- the same road the numeric
|
|
|
|
|
// for's own index already takes via QREG_LUA_IDX.
|
|
|
|
|
//
|
|
|
|
|
// Backing rule: a register joins the backed set only when the ENTRY
|
|
|
|
|
// block stores it (every path executes the entry block, so every later
|
|
|
|
|
// LOAD_Q is dominated by a store), or when FORLOOP itself stores the
|
|
|
|
|
// loop variable (every reader is dominated by the latch). A register
|
|
|
|
|
// first written elsewhere stays plain and keeps today's drop-then-
|
|
|
|
|
// decline behavior: reloading it would read whatever the surrounding
|
|
|
|
|
// command left in the MUSH %q register on paths that never stored it.
|
|
|
|
|
// Backed stores are integers only; a backed register going non-int
|
|
|
|
|
// declines the chunk rather than leaving a stale int in the qreg.
|
|
|
|
|
// The loop VARIABLE needs its backing declared up front: the body is
|
|
|
|
|
// lowered BEFORE the FORLOOP that writes R(A)/R(A+3) (linear pc
|
|
|
|
|
// order), so without the pre-scan the body's read of the loop var
|
|
|
|
|
// found -1 and declined. Sound because every path into the body --
|
|
|
|
|
// and into the exit block -- passes the latch, whose STORE_Qs
|
|
|
|
|
// dominate every reload.
|
|
|
|
|
bool proto_has_loop = false;
|
|
|
|
|
bool forloop_backed[10] = { false, false, false, false, false,
|
|
|
|
|
false, false, false, false, false };
|
|
|
|
|
for (int i = 0; i < n; i++) {
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
const int sop = proto->code[i].opcode();
|
|
|
|
|
if (sop == OP_LUA_FORLOOP) {
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
proto_has_loop = true;
|
|
|
|
|
int fa = proto->code[i].A();
|
|
|
|
|
// Only the VISIBLE index (A+3) is materialized; R(A) is 5.4's
|
|
|
|
|
// internal counter and nothing here ever produces it.
|
|
|
|
|
if (fa + 3 < 10) {
|
|
|
|
|
forloop_backed[fa + 3] = true;
|
|
|
|
|
}
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
} else if (sop == OP_LUA_JMP
|
|
|
|
|
&& i + 1 + proto->code[i].sJ() <= i) {
|
|
|
|
|
// while/repeat: a backward JMP makes this a loop proto too.
|
|
|
|
|
proto_has_loop = true;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
bool qreg_backed[10] = { false, false, false, false, false,
|
|
|
|
|
false, false, false, false, false };
|
|
|
|
|
bool entry_backing_sealed = false;
|
|
|
|
|
int limited_blk = -1;
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// Register state at the moment the entry block was left. FORLOOP's
|
|
|
|
|
// static-bounds test reads its ICONSTs from here: by the latch,
|
|
|
|
|
// lua_reg[] holds LOAD_Q reloads, and a reload is one iteration
|
|
|
|
|
// fresher than an entry constant but buries the constness.
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
int entry_final[MAX_LUA_REGS];
|
|
|
|
|
memset(entry_final, -1, sizeof(entry_final));
|
|
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
|
fix(lua/jit): decline chunks whose values cross a control-flow join (#1422)
Nothing merges Lua register values at a join. hir_ssa_construct() inserts
PHIs only for q-register traffic -- which is precisely why the numeric for
loop routes its index through STORE_Q/LOAD_Q -- so a plain HIR value is
usable across blocks only where its defining block dominates the use.
lua_reg[] is a single linear map, so in practice the last-lowered write
simply won:
local x=1 local t=1 if x>2 then t=2 end return t -> 2, want 1
local x=3 local t=1 if x>2 then t=2 else t=3 end -> 3, want 2
local i=0 while i<10 do i=i+1 end return i -> 0, want 10
local i=0 repeat i=i+1 until i>=5 return i -> 1, want 5
A not-taken branch's assignment leaked past the join, and a loop body's
update was invisible to the next iteration.
The entry block dominates every reachable block; no other block here is
known to. So on leaving a non-entry block, drop any register that block
wrote. Every read site is guarded by "< 0" (return_as_string() checks its
argument too), so the chunk declines cleanly to the interpreter instead of
compiling a wrong answer.
This defect is latent on master only because the operand-bias bug fixed in
the previous commit gives OP_JMP the wrong displacement, so loop and branch
targets miss their block and lowering bails for the wrong reason. Correct
the decoder without this guard and the wrong answers above go live.
Verified with the interpreter as oracle (lua_jit 0 vs lua_jit 1), using
code_cache row counts to tell "compiled correctly" from "never compiled":
all four cases now decline and agree, while straight-line chunks still
compile (`t=t+5`, `a*b+1`, `s.."cd"`, `t[1]`, `#"hello"`). make test:
1497/1497 on both the JIT and interpreted routes.
Making these chunks actually compile needs value merging for Lua locals at
joins, which is a larger piece of work; #1422 stays open for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:34:02 -06:00
|
|
|
// Snapshot of lua_reg as it stood on entry to the current block, so a
|
|
|
|
|
// block transition can tell which registers this block wrote. See the
|
|
|
|
|
// dominance note at the transition below (#1422).
|
|
|
|
|
int blk_entry_reg[MAX_LUA_REGS];
|
|
|
|
|
memcpy(blk_entry_reg, lua_reg, sizeof(blk_entry_reg));
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
int cur_hir_block = 0;
|
|
|
|
|
h.cur_block = 0;
|
|
|
|
|
int result_val = -1;
|
|
|
|
|
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
// The shared bail block: its ECALL aborts the whole run to the
|
|
|
|
|
// interpreter. Two producers branch here -- back-edge budget
|
|
|
|
|
// exhaustion, and FORPREP's runtime bounds guard -- and both bails
|
|
|
|
|
// are rerun-safe: loop protos exclude persistent effects, and the
|
|
|
|
|
// bounds guard runs before any body effect exists at all.
|
|
|
|
|
auto ensure_limited_blk = [&]() -> bool {
|
|
|
|
|
if (limited_blk >= 0) return true;
|
|
|
|
|
limited_blk = h.new_block();
|
|
|
|
|
if (limited_blk < 0) return false;
|
|
|
|
|
int save_blk = h.cur_block;
|
|
|
|
|
h.cur_block = limited_blk;
|
|
|
|
|
h.emit(HIR_LUA_LIMITED, TY_VOID);
|
|
|
|
|
int dead = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (dead < 0) return false;
|
|
|
|
|
h.emit(HIR_RET, TY_VOID, dead);
|
|
|
|
|
h.cur_block = save_blk;
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// Back-edge budget guard, shared by FORLOOP and backward JMP so the
|
|
|
|
|
// two cannot drift (#1457's lesson): decrement by the loop body's
|
|
|
|
|
// instruction count, and branch to the shared limited block on
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
// exhaustion rather than exiting the loop with a wrong partial
|
|
|
|
|
// result (#1732). Leaves the current block set to a fresh
|
|
|
|
|
// continuation block for the caller's own terminator. Returns
|
|
|
|
|
// false on emission failure.
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
auto emit_backedge_guard = [&](int body_len) -> bool {
|
|
|
|
|
int budget_ok = emit_budget_check(h, -1, body_len);
|
|
|
|
|
if (budget_ok < 0) return false;
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
if (!ensure_limited_blk()) return false;
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
int cont_blk = h.new_block();
|
|
|
|
|
if (cont_blk < 0) return false;
|
|
|
|
|
h.emit(HIR_BRC, TY_VOID, budget_ok, limited_blk, cont_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, limited_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, cont_blk);
|
|
|
|
|
h.cur_block = cont_blk;
|
|
|
|
|
cur_hir_block = cont_blk;
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
// Entry-backing seal: decide, once, which registers the q-reg
|
|
|
|
|
// machinery may reload (see the backing rule above), and freeze
|
|
|
|
|
// entry_final. Called from the first block transition -- or from
|
|
|
|
|
// FORPREP's runtime-bounds path, which SPLITS the entry block with
|
|
|
|
|
// branches and must finalize entry state before the split so the
|
|
|
|
|
// next transition's drop-compare does not mistake entry writes for
|
|
|
|
|
// block-local ones.
|
|
|
|
|
auto seal_entry_backing = [&]() {
|
|
|
|
|
if (entry_backing_sealed) return;
|
|
|
|
|
for (int r = 0; r < 10 && r < MAX_LUA_REGS; r++) {
|
|
|
|
|
if (qreg_backed[r]
|
|
|
|
|
&& (lua_reg[r] < 0 || h.ty[lua_reg[r]] != TY_INT)) {
|
|
|
|
|
qreg_backed[r] = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
memcpy(entry_final, lua_reg, sizeof(entry_final));
|
|
|
|
|
entry_backing_sealed = true;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-27 14:17:18 -06:00
|
|
|
// Initialize back-edge budget counter for loop DoS protection.
|
|
|
|
|
// Uses the same limit as the Lua interpreter's instruction hook.
|
|
|
|
|
// Only needed for multi-block programs (which can have loops).
|
fix(lua/jit): read the instruction budget at run time, not compile time (#1745)
Default-on (#1745) exposed that the compiled path baked the back-edge
budget into each program as a constant:
int budget_init = h.emit(HIR_ICONST, TY_INT, -1, -1,
static_cast<int64_t>(mudconf.lua_instruction_limit));
A compiled program is cached in memory and persisted in code_cache, so the
limit in force was whatever was configured when the chunk happened to
compile -- @admin lua_instruction_limit reported Set. and changed nothing,
which is #1613's bug arriving on the compiled path. test-config's
runtime-bounds case caught it the moment the flip put the compiled path in
its way; on default-configure trees --enable-jit is off and everything
stayed green, which is why the flip validated cleanly elsewhere.
## Fix
A dedicated no-arg ECALL, following the LUA_LEN pattern end to end:
HIR_LUA_INSN_BUDGET -> ECALL_LUA_INSN_BUDGET (0x314)
-> a0 = mudconf.lua_instruction_limit, read per run
The entry seed becomes ECALL + STORE_Q, so the program carries no config
value at all. That fixes the in-memory cache and makes the persisted
code_cache safe by construction rather than by flush discipline; blobs from
before this change carry the old entry-store, and JIT_BUILD_STAMP
(__DATE__ __TIME__) already invalidates them on rebuild.
The handler clamps the limit to >= 1: a zero or negative limit must abort
loops, not arm an effectively unbounded unsigned countdown.
The op is listed in needs_int_reg() -- whose own comment documents that
omitting an int-producing ECALL fails silently (codegen emits nothing and
the consumer reads garbage), which is the trap this listing avoids.
## Verified
FAIL: lowering lua_instruction_limit at runtime had no effect before
ok: lua limits apply at runtime, both directions, and read back after
## Second layer found under this one: #1748
Full smoke under default-on now completes and fails exactly two cases
(TC013 mux.name, TC014 mux.eval -- compiled path answers wrongly instead of
declining). On the UNFIXED merge commit those cases are unreachable: the
smoke chain stalls at 164/320 dispatched with 800+ lost verdicts. So this
fix converts a catastrophic stall into two known failures, filed as #1748
with the pre/post evidence. make test on JIT trees stays red at those two
until #1748 resolves; lua_jit 0 remains 1561/1561.
Also amends plan-lua-jit-product.md, replacing "residual optional polish
only" with the post-flip regression record -- as promised in the #1747
review.
Refs #1325, #1613, #1732, #1747, #1748.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:45:42 -06:00
|
|
|
//
|
|
|
|
|
// Read at RUN time via a dedicated ECALL, never baked as an ICONST of
|
|
|
|
|
// mudconf.lua_instruction_limit (#1745). A compiled program is cached
|
|
|
|
|
// in memory and persisted in code_cache, so a baked value is the limit
|
|
|
|
|
// that happened to be configured at compile time, forever -- @admin
|
|
|
|
|
// changes reported Set. and changed nothing, which is #1613's bug
|
|
|
|
|
// arriving on the compiled path. The test-config runtime-bounds case
|
|
|
|
|
// is what caught it, the first time the default-on flip put the
|
|
|
|
|
// compiled path in its way.
|
2026-03-27 14:17:18 -06:00
|
|
|
if (multi_block) {
|
fix(lua/jit): read the instruction budget at run time, not compile time (#1745)
Default-on (#1745) exposed that the compiled path baked the back-edge
budget into each program as a constant:
int budget_init = h.emit(HIR_ICONST, TY_INT, -1, -1,
static_cast<int64_t>(mudconf.lua_instruction_limit));
A compiled program is cached in memory and persisted in code_cache, so the
limit in force was whatever was configured when the chunk happened to
compile -- @admin lua_instruction_limit reported Set. and changed nothing,
which is #1613's bug arriving on the compiled path. test-config's
runtime-bounds case caught it the moment the flip put the compiled path in
its way; on default-configure trees --enable-jit is off and everything
stayed green, which is why the flip validated cleanly elsewhere.
## Fix
A dedicated no-arg ECALL, following the LUA_LEN pattern end to end:
HIR_LUA_INSN_BUDGET -> ECALL_LUA_INSN_BUDGET (0x314)
-> a0 = mudconf.lua_instruction_limit, read per run
The entry seed becomes ECALL + STORE_Q, so the program carries no config
value at all. That fixes the in-memory cache and makes the persisted
code_cache safe by construction rather than by flush discipline; blobs from
before this change carry the old entry-store, and JIT_BUILD_STAMP
(__DATE__ __TIME__) already invalidates them on rebuild.
The handler clamps the limit to >= 1: a zero or negative limit must abort
loops, not arm an effectively unbounded unsigned countdown.
The op is listed in needs_int_reg() -- whose own comment documents that
omitting an int-producing ECALL fails silently (codegen emits nothing and
the consumer reads garbage), which is the trap this listing avoids.
## Verified
FAIL: lowering lua_instruction_limit at runtime had no effect before
ok: lua limits apply at runtime, both directions, and read back after
## Second layer found under this one: #1748
Full smoke under default-on now completes and fails exactly two cases
(TC013 mux.name, TC014 mux.eval -- compiled path answers wrongly instead of
declining). On the UNFIXED merge commit those cases are unreachable: the
smoke chain stalls at 164/320 dispatched with 800+ lost verdicts. So this
fix converts a catastrophic stall into two known failures, filed as #1748
with the pre/post evidence. make test on JIT trees stays red at those two
until #1748 resolves; lua_jit 0 remains 1561/1561.
Also amends plan-lua-jit-product.md, replacing "residual optional polish
only" with the post-flip regression record -- as promised in the #1747
review.
Refs #1325, #1613, #1732, #1747, #1748.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:45:42 -06:00
|
|
|
int budget_init = h.emit(HIR_LUA_INSN_BUDGET, TY_INT);
|
2026-03-27 14:17:18 -06:00
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, budget_init, -1, QREG_LUA_BUDGET);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 18:02:17 -06:00
|
|
|
// Pinned table tracking for array optimization.
|
|
|
|
|
// When a for-loop body accesses t[i] where i is the loop variable,
|
|
|
|
|
// we pin the table's array into guest memory before the loop.
|
|
|
|
|
int pinned_tbl_reg = -1; // Lua register of pinned table (-1 = none)
|
|
|
|
|
int pinned_count_val = -1; // HIR value holding element count
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
for (int pc = 0; pc < n; pc++) {
|
|
|
|
|
// Switch blocks if this PC is a leader.
|
|
|
|
|
if (is_leader[pc] && pc > 0) {
|
|
|
|
|
int new_block = pc_to_block[pc];
|
|
|
|
|
if (new_block != cur_hir_block) {
|
|
|
|
|
if (h.n_insns > 0) {
|
|
|
|
|
hir_kind last = h.kind[h.n_insns - 1];
|
|
|
|
|
if (last != HIR_BR && last != HIR_BRC && last != HIR_RET) {
|
|
|
|
|
h.emit(HIR_BR, TY_VOID, -1, -1, new_block);
|
|
|
|
|
h.add_edge(cur_hir_block, new_block);
|
|
|
|
|
}
|
|
|
|
|
}
|
fix(lua/jit): decline chunks whose values cross a control-flow join (#1422)
Nothing merges Lua register values at a join. hir_ssa_construct() inserts
PHIs only for q-register traffic -- which is precisely why the numeric for
loop routes its index through STORE_Q/LOAD_Q -- so a plain HIR value is
usable across blocks only where its defining block dominates the use.
lua_reg[] is a single linear map, so in practice the last-lowered write
simply won:
local x=1 local t=1 if x>2 then t=2 end return t -> 2, want 1
local x=3 local t=1 if x>2 then t=2 else t=3 end -> 3, want 2
local i=0 while i<10 do i=i+1 end return i -> 0, want 10
local i=0 repeat i=i+1 until i>=5 return i -> 1, want 5
A not-taken branch's assignment leaked past the join, and a loop body's
update was invisible to the next iteration.
The entry block dominates every reachable block; no other block here is
known to. So on leaving a non-entry block, drop any register that block
wrote. Every read site is guarded by "< 0" (return_as_string() checks its
argument too), so the chunk declines cleanly to the interpreter instead of
compiling a wrong answer.
This defect is latent on master only because the operand-bias bug fixed in
the previous commit gives OP_JMP the wrong displacement, so loop and branch
targets miss their block and lowering bails for the wrong reason. Correct
the decoder without this guard and the wrong answers above go live.
Verified with the interpreter as oracle (lua_jit 0 vs lua_jit 1), using
code_cache row counts to tell "compiled correctly" from "never compiled":
all four cases now decline and agree, while straight-line chunks still
compile (`t=t+5`, `a*b+1`, `s.."cd"`, `t[1]`, `#"hello"`). make test:
1497/1497 on both the JIT and interpreted routes.
Making these chunks actually compile needs value merging for Lua locals at
joins, which is a larger piece of work; #1422 stays open for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:34:02 -06:00
|
|
|
// A plain HIR value is usable across blocks only where its
|
|
|
|
|
// defining block dominates the use. Nothing merges values
|
|
|
|
|
// at a join: hir_ssa_construct() inserts PHIs only for
|
|
|
|
|
// q-register traffic, which is exactly why the numeric for
|
|
|
|
|
// loop routes its index through STORE_Q/LOAD_Q (see the
|
|
|
|
|
// FORPREP comment below). The entry block dominates every
|
|
|
|
|
// reachable block; no other block here is known to. So
|
|
|
|
|
// drop any register this block wrote before leaving it --
|
|
|
|
|
// the "< 0" guards then decline the chunk instead of
|
|
|
|
|
// compiling a wrong answer (#1422).
|
|
|
|
|
//
|
|
|
|
|
// Without this, the last-lowered write simply won. A
|
|
|
|
|
// not-taken branch's assignment leaked past the join
|
|
|
|
|
// (`if x>2 then t=2 end` yielded 2 for x=1), and a loop
|
|
|
|
|
// body's update was invisible to the next iteration
|
|
|
|
|
// (`while i<10 do i=i+1 end` yielded 0, not 10).
|
|
|
|
|
if (cur_hir_block != 0) {
|
|
|
|
|
for (int r = 0; r < MAX_LUA_REGS; r++) {
|
|
|
|
|
if (lua_reg[r] != blk_entry_reg[r]) {
|
|
|
|
|
lua_reg[r] = -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
if (proto_has_loop) {
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
// Leaving the entry block for the first time: seal
|
|
|
|
|
// the backed set (see seal_entry_backing; FORPREP's
|
|
|
|
|
// runtime-bounds path may have sealed already).
|
|
|
|
|
seal_entry_backing();
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Every backed register enters the new block through
|
|
|
|
|
// its qreg; SSA turns the loads into PHIs over the
|
|
|
|
|
// stores on each incoming path. ALWAYS -- an entry
|
|
|
|
|
// value dominates every block, but dominance is
|
|
|
|
|
// availability, not currency: inside the loop the
|
|
|
|
|
// entry constant is one iteration stale, which made
|
|
|
|
|
// `s=s+i` compute 0+i forever. FORLOOP gets the
|
|
|
|
|
// ICONSTs its static-bounds test needs from
|
|
|
|
|
// entry_final[], not from these reloads.
|
|
|
|
|
cur_hir_block = new_block;
|
|
|
|
|
h.cur_block = new_block;
|
|
|
|
|
for (int r = 0; r < 10 && r < MAX_LUA_REGS; r++) {
|
|
|
|
|
if (!qreg_backed[r] && !forloop_backed[r]) continue;
|
|
|
|
|
int lv = h.emit(HIR_LOAD_Q, TY_INT, -1, -1, r);
|
|
|
|
|
if (lv < 0) return -1;
|
|
|
|
|
h.known_int[lv] = true;
|
2026-07-29 09:24:24 -06:00
|
|
|
// Truth class did not cross the block boundary
|
|
|
|
|
// with the integer payload (#1768).
|
|
|
|
|
lua_truth_set(truth_tag, lv, LUA_TRUTH_UNKNOWN);
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
lua_reg[r] = lv;
|
|
|
|
|
}
|
|
|
|
|
memcpy(blk_entry_reg, lua_reg, sizeof(blk_entry_reg));
|
|
|
|
|
} else {
|
|
|
|
|
memcpy(blk_entry_reg, lua_reg, sizeof(blk_entry_reg));
|
|
|
|
|
cur_hir_block = new_block;
|
|
|
|
|
h.cur_block = new_block;
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lua_bc_instruction &insn = proto->code[pc];
|
|
|
|
|
int op = insn.opcode();
|
|
|
|
|
int A = insn.A();
|
|
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Snapshot for the store-at-write hook below (loop protos only).
|
|
|
|
|
int pre_reg[MAX_LUA_REGS];
|
|
|
|
|
if (proto_has_loop) {
|
|
|
|
|
memcpy(pre_reg, lua_reg, sizeof(pre_reg));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
switch (op) {
|
|
|
|
|
|
|
|
|
|
// ---- Data movement ----
|
|
|
|
|
|
|
|
|
|
case OP_LUA_MOVE:
|
|
|
|
|
if (lua_reg[insn.B()] < 0) return -1;
|
|
|
|
|
lua_reg[A] = lua_reg[insn.B()];
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case OP_LUA_LOADI:
|
|
|
|
|
lua_reg[A] = h.emit_iconst(insn.sBx());
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
break;
|
|
|
|
|
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
// LOADF loads a *float* whose value is the signed immediate. It
|
|
|
|
|
// carried an integer immediate, and the lowering took it at face
|
|
|
|
|
// value and emitted an integer constant -- so `return 3.0` produced
|
|
|
|
|
// the integer 3, and every Lua constant expression that folds to an
|
|
|
|
|
// integral float (`4/2`, `2^3`, `7.0//2.0`, `1e3`, `-3.0`) lost its
|
|
|
|
|
// float subtype before the JIT ever did any arithmetic. It also made
|
|
|
|
|
// `a * 1.0` an integer multiply (#1488).
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_LOADF:
|
fix(lua/jit): a Lua float stops being a float on the compiled path (#1488)
Lua 5.4 distinguishes integers from floats, and the distinction is
observable: tostring(3.0) is "3.0", math.type(3.0) is "float", and one
float operand makes the whole expression float. The compiled path threw
that away in three separate places.
- OP_LOADF loads a *float* whose value is the signed immediate. The
lowering read the immediate and emitted an integer constant, so
`return 3.0` produced the integer 3. Lua constant-folds arithmetic on
literals, so this also caught `4/2`, `2^3`, `7.0//2.0`, `1e3` and
`-3.0`, all of which reach the JIT already folded into a LOADF.
`return 4 / 2` lowered to a bare ICONST 2 with no FDIV anywhere.
- emit_lua_constant() deliberately demoted an integral float constant to
ICONST "for compatibility with integer arithmetic". That made
`a * 1.0` an integer multiply and `a + 0.0` print "3".
- return_as_string() formatted floats with "%.17g" and never appended
".0". Lua uses LUA_NUMBER_FMT ("%.14g") and appends ".0" when the
result looks like an integer (lobject.c tostringbuff), so the compiled
path disagreed with the interpreter on every float: integral ones lost
the subtype, and the rest printed more digits.
The fold now renders floats Lua's way, and the runtime path gets
HIR_LUA_FTOA / ECALL_LUA_FTOA, which is ECALL_FTOA's sequence against a
host formatter that follows Lua's rules rather than MUX's. Both share
lua_format_double(), so the compile-time fold and the run-time ECALL
cannot drift.
Verified against the interpreter as oracle, reading jitstats()
lua_run_ok/lua_run_fail per chunk rather than compile counts -- a chunk
that declines agrees trivially, and one that compiles can still bail at
run time and let the interpreter answer. 28 chunks, 11 divergences
before, 0 after, with 20 confirmed executing compiled code.
Smoke 1509/1509 on both routes, 316/316 dispatched, make test green.
Also re-measured the string->number half of #1425 (`return "3" + 4` → 6):
already correct on master, and covered by four cases here.
The `^` half of this work landed independently as #1548 while it was in
flight; this branch keeps master's version of that hunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:29:35 -06:00
|
|
|
lua_reg[A] = h.emit_fconst(static_cast<double>(insn.sBx()));
|
2026-03-18 09:59:36 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case OP_LUA_LOADK: {
|
|
|
|
|
int kidx = insn.Bx();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
const lua_bc_constant &kc = proto->constants[kidx];
|
|
|
|
|
lua_reg[A] = emit_lua_constant(h, rc, kc);
|
2026-03-18 09:59:36 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
lua_truth_tag_constant(truth_tag, kc, lua_reg[A]);
|
2026-03-18 09:59:36 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_LOADKX: {
|
|
|
|
|
// Extended constant: index is in the following EXTRAARG instruction.
|
|
|
|
|
if (pc + 1 >= n) return -1;
|
|
|
|
|
int kidx = proto->code[pc + 1].Ax();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
const lua_bc_constant &kc = proto->constants[kidx];
|
|
|
|
|
lua_reg[A] = emit_lua_constant(h, rc, kc);
|
2026-03-18 15:54:13 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
lua_truth_tag_constant(truth_tag, kc, lua_reg[A]);
|
2026-03-18 15:54:13 -06:00
|
|
|
pc++; // skip EXTRAARG
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_LOADFALSE:
|
|
|
|
|
lua_reg[A] = h.emit_iconst(0);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
// Boolean false, not integer 0 — same ICONST, different TEST.
|
|
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_BOOL);
|
2026-03-18 09:59:36 -06:00
|
|
|
break;
|
|
|
|
|
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
// "R[A] := false; pc++". When this pairs with LOADTRUE purely to
|
|
|
|
|
// turn a condition into a value, the run is fused away before we get
|
|
|
|
|
// here (lua_bool_fuse_at). What is left is a genuine two-way join,
|
|
|
|
|
// so the skip has to be an explicit branch. Lowering it as a linear
|
|
|
|
|
// pc++ stepped over a block leader: the skipped path's entire body
|
|
|
|
|
// was emitted into this block and the other block was left empty, so
|
|
|
|
|
// the chunk returned the false arm's value -- or, once every RET got
|
|
|
|
|
// its own output slot, nothing at all (#1421).
|
|
|
|
|
case OP_LUA_LFALSESKIP: {
|
|
|
|
|
lua_reg[A] = h.emit_iconst(0);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_BOOL);
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
if (!multi_block) return -1;
|
|
|
|
|
int target = pc + 2;
|
|
|
|
|
int target_blk = (target > 0 && target < n) ? pc_to_block[target] : -1;
|
|
|
|
|
if (target_blk < 0) return -1;
|
|
|
|
|
h.emit(HIR_BR, TY_VOID, -1, -1, target_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, target_blk);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_LOADTRUE:
|
|
|
|
|
lua_reg[A] = h.emit_iconst(1);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_BOOL);
|
2026-03-18 09:59:36 -06:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case OP_LUA_LOADNIL:
|
|
|
|
|
for (int i = A; i <= A + insn.B(); i++) {
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(i)) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
// Empty SCONST is also the representation of "": only the
|
|
|
|
|
// NIL tag makes TEST falsy here and leaves literal "" truthy.
|
2026-03-18 09:59:36 -06:00
|
|
|
lua_reg[i] = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (lua_reg[i] < 0) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
lua_truth_set(truth_tag, lua_reg[i], LUA_TRUTH_NIL);
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// ---- Integer arithmetic ----
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
#define ARITH_RR(HIR_INT_OP, HIR_FP_OP, MMOP) \
|
2026-03-18 09:59:36 -06:00
|
|
|
{ \
|
|
|
|
|
int rb = lua_reg[insn.B()]; \
|
|
|
|
|
int rc_val = lua_reg[insn.C()]; \
|
|
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
if (lua_is_handle(h, rb) || lua_is_handle(h, rc_val)) return -1; \
|
2026-03-18 14:53:23 -06:00
|
|
|
if (either_float(h, rb, rc_val)) { \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb); \
|
|
|
|
|
rc_val = promote_to_float(h, truth_tag, rc_val); \
|
2026-03-18 14:53:23 -06:00
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FP_OP, TY_FLOAT, rb, rc_val); \
|
|
|
|
|
} else if (h.ty[rb] == TY_INT && h.ty[rc_val] == TY_INT) { \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_INT_OP, TY_INT, rb, rc_val); \
|
|
|
|
|
} else { \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb); \
|
|
|
|
|
rc_val = promote_to_int(h, truth_tag, rc_val); \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_INT_OP, TY_INT, rb, rc_val); \
|
2026-03-18 14:53:23 -06:00
|
|
|
} \
|
2026-03-18 09:59:36 -06:00
|
|
|
if (lua_reg[A] < 0) return -1; \
|
|
|
|
|
h.native_ops++; \
|
2026-07-25 23:08:40 -06:00
|
|
|
/* Do NOT pc++ past MMBIN here: the for-loop already advances
|
|
|
|
|
* pc, so an extra increment skips the following RETURN and
|
|
|
|
|
* leaves the chunk without a proper HIR_RET (#1309 hang).
|
|
|
|
|
* MMBIN* cases below are intentional no-ops. */ \
|
|
|
|
|
(void)MMOP; \
|
2026-03-18 09:59:36 -06:00
|
|
|
break; \
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
case OP_LUA_ADD: ARITH_RR(HIR_ADD, HIR_FADD, OP_LUA_MMBIN)
|
|
|
|
|
case OP_LUA_SUB: ARITH_RR(HIR_SUB, HIR_FSUB, OP_LUA_MMBIN)
|
|
|
|
|
case OP_LUA_MUL: ARITH_RR(HIR_MUL, HIR_FMUL, OP_LUA_MMBIN)
|
|
|
|
|
case OP_LUA_IDIV: ARITH_RR(HIR_DIV, HIR_DIV, OP_LUA_MMBIN) // IDIV always integer
|
|
|
|
|
case OP_LUA_MOD: ARITH_RR(HIR_REM, HIR_REM, OP_LUA_MMBIN) // MOD always integer
|
2026-03-18 09:59:36 -06:00
|
|
|
#undef ARITH_RR
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
// Lua `/` (OP_DIV) always produces a float result.
|
|
|
|
|
case OP_LUA_DIV: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
int rc_val = lua_reg[insn.C()];
|
|
|
|
|
if (rb < 0 || rc_val < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb);
|
|
|
|
|
rc_val = promote_to_float(h, truth_tag, rc_val);
|
2026-03-18 14:53:23 -06:00
|
|
|
if (rb < 0 || rc_val < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FDIV, TY_FLOAT, rb, rc_val);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
2026-07-25 23:08:40 -06:00
|
|
|
// MMBIN follows as a no-op case — do not double-advance pc.
|
2026-03-18 14:53:23 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:54:40 +00:00
|
|
|
// Lua `^` (OP_POW) always produces a float. Native FCALL2 to the
|
docs(lua/jit): Lua ^ must not mirror softcode's IEEE domain guard (#1556)
#1556 asked which of two things is right: mirror softcode POWER's
HAVE_IEEE_FP_SNAN guard in the Lua lowering, or document why Lua does not
need it. It is the second, and mirroring would introduce a bug.
Softcode POWER declines the native FCALL2 path when the macro is undefined
because fun_power has a MUX *output convention* on those builds: a negative
base yields the literal string "Ind" rather than whatever the FP library
produces. That is softcode's answer format, not trap avoidance.
Lua has no such convention. luai_numpow (lua54/llimits.h) is
`(b == 2) ? a*a : pow(a, b)` on every platform, so the Lua interpreter
calls pow() directly whether or not the macro is defined. Mirroring the
guard would make the compiled path diverge from the Lua interpreter --
exactly backwards from what the guard achieves for softcode.
Measured on a build with HAVE_IEEE_FP_SNAN forced off in autoconf.h, no
JIT involved:
power(-2,0.5) softcode Ind
(-2) ^ 0.5 Lua interp nan
The two languages disagree by design, and the compiled path has to follow
Lua. Comment only; no behaviour change.
Also found while measuring this, reported separately rather than fixed
here: `^` never compiles at all today, so the FCALL2 path #1556 is about
is currently unreachable and the asymmetry is latent. tier2_lazy_init()
is called only from jit_eval(), the softcode entry point; nothing on the
Lua path loads the blob, so tier2_sym_addr("pow") returns 0 and both
OP_LUA_POW and OP_LUA_POWK take their `if (!addr) return -1` decline. The
configuration needed to run the Lua JIT at all is jit_eval_brackets 0,
which is precisely the setting that keeps brackets out of jit_eval.
Confirmed by calling the loader from the Lua path as a diagnostic: `^`
then compiles and runs (lua_compile_ok and lua_run_ok both move) and
returns 729 where the interpreter returns 729.0 -- i.e. it works
numerically and lands straight on #1488. That is why the loader hook is
NOT included here: on its own it would convert a decline into a
float-ness regression. #1488 first.
make test passes, both smoke routes 1542/1542.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:28:51 -06:00
|
|
|
// No HAVE_IEEE_FP_SNAN guard here, deliberately (#1556).
|
|
|
|
|
//
|
|
|
|
|
// Softcode POWER declines the native path when that macro is undefined
|
|
|
|
|
// (hir_lower.cpp) because fun_power has a *MUX output convention* on
|
|
|
|
|
// such builds: a negative base yields the literal string "Ind" rather
|
|
|
|
|
// than whatever the FP library produces. That is softcode's answer
|
|
|
|
|
// format, not a trap-avoidance measure.
|
|
|
|
|
//
|
|
|
|
|
// Lua has no such convention. luai_numpow (lua54/llimits.h) is
|
|
|
|
|
// `(b == 2) ? a*a : pow(a, b)` on every platform, so the Lua
|
|
|
|
|
// *interpreter* calls pow() directly whether or not the macro is
|
|
|
|
|
// defined. Mirroring softcode's guard here would therefore make the
|
|
|
|
|
// compiled path diverge from the Lua interpreter, which is the opposite
|
|
|
|
|
// of what the guard achieves for softcode.
|
|
|
|
|
//
|
|
|
|
|
// Measured on a build with HAVE_IEEE_FP_SNAN forced off: softcode
|
|
|
|
|
// power(-2,0.5) answers "Ind" while Lua (-2)^0.5 answers "nan", in the
|
|
|
|
|
// interpreter, with no JIT involved. The two languages disagree by
|
|
|
|
|
// design and the compiled path must follow Lua, not softcode.
|
|
|
|
|
//
|
2026-07-27 12:54:40 +00:00
|
|
|
// tier-2 `pow` blob — same path softcode power() uses — not the
|
|
|
|
|
// string-bridge ECALL __LUA_POW. That ECALL read both args with
|
|
|
|
|
// atof(farg_cstr()), but HIR_CALL marshals TY_FLOAT as the raw
|
|
|
|
|
// double storage address; low bytes are 0x00 so atof sees "" → 0
|
|
|
|
|
// and every runtime ^ became pow(0,0) == 1 (#1538). Constant ^
|
|
|
|
|
// never hit it (Lua folds those to LOADF).
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_POW: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
int rc_val = lua_reg[insn.C()];
|
|
|
|
|
if (rb < 0 || rc_val < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb);
|
|
|
|
|
rc_val = promote_to_float(h, truth_tag, rc_val);
|
2026-03-18 15:54:13 -06:00
|
|
|
if (rb < 0 || rc_val < 0) return -1;
|
2026-07-27 12:54:40 +00:00
|
|
|
uint64_t addr = tier2_sym_addr("pow");
|
|
|
|
|
if (!addr) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FCALL2, TY_FLOAT, rb, rc_val,
|
|
|
|
|
static_cast<int64_t>(addr));
|
2026-03-18 15:54:13 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
2026-07-27 12:54:40 +00:00
|
|
|
h.func_idx[lua_reg[A]] = FMATH_POW;
|
|
|
|
|
h.native_ops++;
|
2026-03-18 15:54:13 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_UNM: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
if (h.ty[rb] == TY_FLOAT) {
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FNEG, TY_FLOAT, rb);
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
lua_reg[A] = h.emit(HIR_NEG, TY_INT, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING) {
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_NEG, TY_INT, rb);
|
2026-03-18 14:53:23 -06:00
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
// ---- Bitwise operations ----
|
|
|
|
|
|
|
|
|
|
#define BITOP_RR(HIR_OP) \
|
|
|
|
|
{ \
|
|
|
|
|
int rb = lua_reg[insn.B()]; \
|
|
|
|
|
int rc_val = lua_reg[insn.C()]; \
|
|
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
if (lua_is_handle(h, rb) || lua_is_handle(h, rc_val)) return -1; \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb); \
|
|
|
|
|
rc_val = promote_to_int(h, truth_tag, rc_val); \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
lua_reg[A] = h.emit(HIR_OP, TY_INT, rb, rc_val); \
|
|
|
|
|
if (lua_reg[A] < 0) return -1; \
|
|
|
|
|
h.native_ops++; \
|
2026-07-25 23:08:40 -06:00
|
|
|
/* MMBIN is a no-op case — do not double-advance pc (#1309). */ \
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
break; \
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_BAND: BITOP_RR(HIR_BAND)
|
|
|
|
|
case OP_LUA_BOR: BITOP_RR(HIR_BOR)
|
|
|
|
|
case OP_LUA_BXOR: BITOP_RR(HIR_BXOR)
|
|
|
|
|
case OP_LUA_SHL: BITOP_RR(HIR_SHL)
|
|
|
|
|
case OP_LUA_SHR: BITOP_RR(HIR_SHR)
|
|
|
|
|
#undef BITOP_RR
|
|
|
|
|
|
|
|
|
|
case OP_LUA_BNOT: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
lua_reg[A] = h.emit(HIR_BNOT, TY_INT, rb);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SHRI/SHLI: shift by immediate (sC field).
|
|
|
|
|
case OP_LUA_SHRI: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
int imm = h.emit_iconst(insn.sC());
|
|
|
|
|
if (imm < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_SHR, TY_INT, rb, imm);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_SHLI: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
int imm = h.emit_iconst(insn.sC());
|
|
|
|
|
if (imm < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_SHL, TY_INT, rb, imm);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bitwise with constant (BANDK/BORK/BXORK).
|
|
|
|
|
#define BITOP_RK(HIR_OP) \
|
|
|
|
|
{ \
|
|
|
|
|
int rb = lua_reg[insn.B()]; \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1; \
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
if (lua_is_handle(h, rb)) return -1; \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb); \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1; \
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
int kidx = insn.C(); \
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size())) \
|
|
|
|
|
return -1; \
|
|
|
|
|
int kval = emit_lua_constant(h, rc, proto->constants[kidx]); \
|
|
|
|
|
if (kval < 0 || h.ty[kval] != TY_INT) return -1; \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_OP, TY_INT, rb, kval); \
|
|
|
|
|
if (lua_reg[A] < 0) return -1; \
|
|
|
|
|
h.native_ops++; \
|
|
|
|
|
break; \
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_BANDK: BITOP_RK(HIR_BAND)
|
|
|
|
|
case OP_LUA_BORK: BITOP_RK(HIR_BOR)
|
|
|
|
|
case OP_LUA_BXORK: BITOP_RK(HIR_BXOR)
|
|
|
|
|
#undef BITOP_RK
|
|
|
|
|
|
2026-03-18 15:28:35 -06:00
|
|
|
// ---- Logical NOT ----
|
|
|
|
|
|
|
|
|
|
case OP_LUA_NOT: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
// Marshalled CALL_STR text is not a Lua value: not("0") and
|
|
|
|
|
// not(false→"0") disagree, and the type is gone (#1764).
|
|
|
|
|
if (lua_is_marshalled_str(h, rb)) return -1;
|
2026-07-29 12:45:59 -06:00
|
|
|
// CALL_VAL handle: ask the VM (only nil/false are falsy).
|
|
|
|
|
if (lua_is_handle(h, rb) && h.kind[rb] == HIR_LUA_CALL_VAL) {
|
|
|
|
|
int tb = h.emit(HIR_LUA_TOBOOL, TY_INT, rb);
|
|
|
|
|
if (tb < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_NOT, TY_INT, tb);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_BOOL);
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (lua_is_handle(h, rb)) return -1;
|
2026-03-18 15:28:35 -06:00
|
|
|
// NOT in Lua: false and nil → true (1), everything else → false (0).
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
// HIR_NOT is integer zero-test — correct only for BOOL tags.
|
|
|
|
|
// VALUE (including integer 0 and "") is always truthy → NOT 0.
|
2026-07-29 09:24:24 -06:00
|
|
|
// NIL → NOT 1. UNKNOWN (tag lost) declines rather than lie (#1768).
|
|
|
|
|
// Result is itself a boolean.
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
const lua_truth tr = lua_truth_of(h, truth_tag, rb);
|
2026-07-29 09:24:24 -06:00
|
|
|
if (tr == LUA_TRUTH_UNKNOWN) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
if (tr == LUA_TRUTH_NIL) {
|
|
|
|
|
lua_reg[A] = h.emit_iconst(1);
|
|
|
|
|
} else if (tr == LUA_TRUTH_VALUE) {
|
2026-03-18 15:28:35 -06:00
|
|
|
lua_reg[A] = h.emit_iconst(0);
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
// BOOL: 0 → 1, nonzero → 0.
|
|
|
|
|
if (h.kind[rb] == HIR_ICONST) {
|
|
|
|
|
lua_reg[A] = h.emit_iconst(h.val[rb] == 0 ? 1 : 0);
|
|
|
|
|
} else {
|
|
|
|
|
lua_reg[A] = h.emit(HIR_NOT, TY_INT, rb);
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
}
|
2026-03-18 15:28:35 -06:00
|
|
|
} else {
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
return -1;
|
2026-03-18 15:28:35 -06:00
|
|
|
}
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_BOOL);
|
2026-03-18 15:28:35 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- String length (ECALL back to Lua VM) ----
|
|
|
|
|
|
|
|
|
|
case OP_LUA_LEN: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
fix(lua/jit): audit nil's consumers, not just its producers (#1766 review)
The closed-key-set analysis is sound and the producers are right, but
lowering a known-absent read to nil makes nil a value that FLOWS -- and
the consumers had not been audited. Measured on the first cut, all
silent wrong answers where master had been loudly declining:
"a" .. t[2] jit "a" interp raises "concatenate a nil value"
#t[2] jit 0 interp raises "length of a nil value"
tostring(t[2]) jit "" interp "nil"
t[2] == "" jit 1 interp 0 (found while fixing)
nil is representable in HIR only as the empty SCONST, which is also a
real "" -- the truth tag is the only thing that tells them apart, so
every operation Lua rejects on nil must consult it. One predicate
(lua_is_nil, mirroring lua_is_marshalled_str) applied at CONCAT, LEN,
the call-argument walk, and both comparison macros; promote_to_int and
promote_to_float already had it.
Five AGREE pins, one per shape above plus concat-through-a-local, so
the class cannot regress silently. Budget 10 -> 15: nil has no
compiled consumer yet, and every one of those five is a decline by
design until it does.
This is the fourth PR in the campaign where emptying a loud bin
introduced a silent wrong answer (#1755, #1756, #1763, this). The
pattern is worth naming: a new VALUE CLASS needs a consumer audit, not
just correct producers.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:56:20 -06:00
|
|
|
// Lua raises "attempt to get length of a nil value"; the
|
|
|
|
|
// empty SCONST would measure 0 instead.
|
|
|
|
|
if (lua_is_nil(h, truth_tag, rb)) return -1;
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
// #mux answered 3 -- strlen of the sentinel's NAME. #1424's
|
|
|
|
|
// shape on a different value class (#1795). The mux.args
|
|
|
|
|
// sentinel has its own arity path further down; this refuses
|
|
|
|
|
// the rest.
|
|
|
|
|
if (lua_is_mux_sentinel(h, rb) && h.sval[rb] != "mux.args") {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
// `#` on a VM reference is what returned 22 for a three-element
|
|
|
|
|
// table: the stack index, measured as though it were the value
|
feat(lua/jit): #t on a table, which is #1424 fixed at the root (#1519)
local t={1,2,3} return #t -> 3 run_ok, no fallback
3 is the number that used to come back as 22.
The lowering measured a stack INDEX that the named bridge had marshalled out
as a decimal string -- strlen of the text, not the length of the table.
#1579 contained it by typing handles and declining `#` on one, which kept
the answer correct at the cost of never compiling it. ECALL_LUA_LEN_INT
asks the VM instead, and the index never leaves a register where anything
could measure it as text. Correct AND compiled.
lua_rawlen is only equivalent to `#` for a table with no __len metamethod,
and that is exactly what ecall_lua_plain_table already refuses -- range,
istable, and no metatable. The guard is load-bearing here, not incidental.
Decline count 31 -> 30, the ratchet's second fire in the improving
direction. It failed the build until AGREE_DECLINE_BUDGET moved with the
change, which is the point of it.
All five registration points, hit deliberately rather than discovered:
1. hir_kind enum hir.h
2. lowering hir_lower_lua.cpp
3. codegen case hir_codegen.cpp
4. needs_int_reg() hir_codegen.cpp
5. hir_kind_name() hir_codegen.cpp
plus the ECALL constant and its handler. 4 and 5 are the ones that bite:
omitting 4 fails SILENTLY, because codegen's `if (!dest) break;` emits
nothing and the consumer reads a stale register (measured on NEWTABLE as
`SETI idx=0 type=function`); omitting 5 only shows up in a dump and was
caught after merge by someone else. This list is here so the next increment
does not rediscover either.
EXEC cases for both shapes; on master both report "lua_run_ok=0 (compiled
path did not execute)".
table.insert(t,4) still declines -- a library CALL needs the
global-lookup-and-call path, not a table primitive. Different shape.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:59:32 -06:00
|
|
|
// (#1424, #1579). Declining kept it correct; asking the VM
|
|
|
|
|
// makes it fast as well, and the index never leaves a register
|
|
|
|
|
// where something could measure it as text.
|
|
|
|
|
if (lua_is_handle(h, rb)) {
|
|
|
|
|
lua_reg[A] = h.emit(HIR_LUA_LEN, TY_INT, rb);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.known_int[lua_reg[A]] = true;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
|
|
|
|
|
// A mux.* table is carried through lowering as an SCONST holding
|
|
|
|
|
// its NAME -- "mux.args" is a sentinel, not text the program can
|
|
|
|
|
// see. It is not a handle, and it IS TY_STRING, so both guards
|
2026-07-29 13:08:17 -06:00
|
|
|
// above wave it through and a naive STRLEN would measure the
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
// sentinel: `#mux.args` answered 8 (strlen "mux.args") where the
|
2026-07-29 13:08:17 -06:00
|
|
|
// interpreter answers the argument count (TC020 under #1326).
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
//
|
2026-07-29 13:08:17 -06:00
|
|
|
// `#mux.args` is the call's ncargs. Softcode already parks that
|
|
|
|
|
// count in SUBST_NCARGS for `%+`; reuse it so nested production
|
|
|
|
|
// brackets can compile this shape instead of declining forever.
|
|
|
|
|
//
|
2026-07-29 15:19:43 -06:00
|
|
|
if ( lua_is_mux_sentinel(h, rb)
|
|
|
|
|
&& h.kind[rb] == HIR_SCONST
|
2026-07-29 13:08:17 -06:00
|
|
|
&& h.sval[rb] == "mux.args") {
|
|
|
|
|
uint64_t addr = rv_compiler::SUBST_BASE
|
|
|
|
|
+ static_cast<uint64_t>(rv_compiler::SUBST_NCARGS)
|
|
|
|
|
* rv_compiler::SUBST_SLOT;
|
|
|
|
|
h.needs_jit = true;
|
|
|
|
|
int sref = h.emit_sref(addr);
|
|
|
|
|
if (sref < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
lua_reg[A] = h.emit(HIR_ATOI, TY_INT, sref);
|
|
|
|
|
if (lua_reg[A] < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
h.known_int[lua_reg[A]] = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
// Other mux.* sentinels still have no length semantics here.
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
//
|
2026-07-29 15:19:43 -06:00
|
|
|
if (lua_is_mux_sentinel(h, rb)) {
|
fix(lua/jit): the three shapes nesting made reachable, and tests that see them
Review of #1664 found that lifting the reentrancy guard is not safe on its
own. `make test` excludes `test-lua-jit` -- it is opt-in -- so the target
named for the configuration the PR changes was never run:
master Succeeded: 1561 Failed: 0
nesting, no fixes Succeeded: 1558 Failed: 3
All three are wrong answers, not crashes, and all three were previously
unreachable only because the refusal sent every nested lua() to the
interpreter.
TC020, `#mux.args` answered 8. A mux.* table is carried through lowering as
an SCONST holding its own NAME, so OP_LUA_LEN's TY_STRING branch measured the
sentinel: strlen("mux.args"). It is not a handle, so the #1424 guard above
does not catch it. Decline; resolving to the call's ncargs is #1519's work.
TC009, the instruction limit stopped applying. Limits live in
CLuaMod::InsnCountHook, a Lua VM hook the compiled path does not have --
RunCompiled has only max_dispatch and the wall alarm, and a loop spinning
inside one translated block issues no dispatches. `while true do end`
answered an empty string where the documented result is
"#-1 LUA ERROR: instruction limit exceeded". New LUA_BC_HAS_LOOP rejects any
backward branch. Bounded loops go too: trip counts are not known here, and
guessing on the unsafe side is how this was reachable at all.
TC059, INT64_MIN rendered as -'..--).0-*(+,))+(0(). rv_emit_itoa negated the
value to get a magnitude, but -INT64_MIN wraps and stays negative, so REM
produced negative digits and '0' + (-d) wrote below '0' -- every byte off by
twice its digit. Negating a POSITIVE value cannot overflow, so accumulate in
negative space and negate each digit instead, where the magnitude is at most
9. INT64_MIN then needs no special case. Swept 0, +/-1, +/-10, +/-100,
INT64_MAX, -INT64_MAX and INT64_MIN as both identity and negation.
TC020 and TC059 also fix pre-existing wrong answers under
`lua_jit 1, jit_eval_brackets 0`, which is how they were reachable before
nesting.
The tier that should have caught all three was mine, and did not: it ran
three pure-arithmetic chunks, so it stayed green while smoke regressed. It
now carries all three shapes -- but the first attempt at that was no better,
because two of them passed against the broken build:
- a loop WITH a body declines for unrelated reasons; only TC009's bare
`while true do end` compiles
- run_one passes (2,3), so `-mux.args[1]` renders -2 and never reaches the
one input in 2^64 that fails -- reach INT64_MIN by overflow instead
Verified in both directions rather than assumed: pre-fix nested_wrong=3,
post-fix nested_wrong=0.
Two tiers, because two of the shapes now DECLINE and declining is the fix:
NESTED requires lua_run_ok > 0, NESTED_AGREE requires only agreement. A
decline that returned the wrong answer would still be a bug and only a
comparison sees it.
Also moves AGREE_DECLINE_BUDGET out from under the EXEC header and gives
NESTED its own banner, per review.
test-lua-jit 1561/0 matching master; make test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:04:26 -06:00
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 15:28:35 -06:00
|
|
|
// For TY_STRING: emit strlen-like ECALL.
|
|
|
|
|
// For other types: would need lua_State to call __len metamethod.
|
|
|
|
|
if (h.ty[rb] == TY_STRING) {
|
|
|
|
|
// Use engine API STRLEN function if available.
|
|
|
|
|
int fidx = engine_api_lookup("STRLEN");
|
|
|
|
|
if (fidx > 0) {
|
|
|
|
|
int args[] = { rb };
|
|
|
|
|
lua_reg[A] = h.emit_call(TY_STRING, fidx, args, 1);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.known_int[lua_reg[A]] = true;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return -1; // Table/userdata length needs lua_State.
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- String concatenation ----
|
|
|
|
|
|
|
|
|
|
case OP_LUA_CONCAT: {
|
|
|
|
|
// OP_CONCAT A B: concatenate B values starting at R(A),
|
|
|
|
|
// result in R(A).
|
|
|
|
|
int nvals = insn.B();
|
|
|
|
|
if (nvals < 1) return -1;
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
// Concatenating a handle would splice a stack index into the
|
|
|
|
|
// text (#1579).
|
|
|
|
|
for (int ci = 0; ci < nvals; ci++) {
|
|
|
|
|
if (!lua_reg_in_range(A + ci)) return -1;
|
|
|
|
|
if (lua_is_handle(h, lua_reg[A + ci])) return -1;
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
// A sentinel is a table in Lua; concatenating it raises,
|
|
|
|
|
// and splicing its NAME in would answer "xmux.args".
|
|
|
|
|
if (lua_is_mux_sentinel(h, lua_reg[A + ci])) return -1;
|
fix(lua/jit): audit nil's consumers, not just its producers (#1766 review)
The closed-key-set analysis is sound and the producers are right, but
lowering a known-absent read to nil makes nil a value that FLOWS -- and
the consumers had not been audited. Measured on the first cut, all
silent wrong answers where master had been loudly declining:
"a" .. t[2] jit "a" interp raises "concatenate a nil value"
#t[2] jit 0 interp raises "length of a nil value"
tostring(t[2]) jit "" interp "nil"
t[2] == "" jit 1 interp 0 (found while fixing)
nil is representable in HIR only as the empty SCONST, which is also a
real "" -- the truth tag is the only thing that tells them apart, so
every operation Lua rejects on nil must consult it. One predicate
(lua_is_nil, mirroring lua_is_marshalled_str) applied at CONCAT, LEN,
the call-argument walk, and both comparison macros; promote_to_int and
promote_to_float already had it.
Five AGREE pins, one per shape above plus concat-through-a-local, so
the class cannot regress silently. Budget 10 -> 15: nil has no
compiled consumer yet, and every one of those five is a decline by
design until it does.
This is the fourth PR in the campaign where emptying a loud bin
introduced a silent wrong answer (#1755, #1756, #1763, this). The
pattern is worth naming: a new VALUE CLASS needs a consumer audit, not
just correct producers.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:56:20 -06:00
|
|
|
// Lua raises "attempt to concatenate a nil value"; the
|
|
|
|
|
// empty SCONST would splice in as "".
|
|
|
|
|
if (lua_is_nil(h, truth_tag, lua_reg[A + ci])) return -1;
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
}
|
2026-03-18 15:28:35 -06:00
|
|
|
if (nvals == 1) {
|
|
|
|
|
// Single value — no-op (just ensure it's a string).
|
|
|
|
|
int rv = lua_reg[A];
|
|
|
|
|
if (rv < 0) return -1;
|
|
|
|
|
if (h.ty[rv] == TY_INT) {
|
|
|
|
|
lua_reg[A] = h.emit(HIR_ITOA, TY_STRING, rv);
|
|
|
|
|
} else if (h.ty[rv] == TY_FLOAT) {
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FTOA, TY_STRING, rv);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert all operands to strings, then emit HIR_STRCAT.
|
|
|
|
|
std::vector<int> str_args;
|
|
|
|
|
for (int j = 0; j < nvals; j++) {
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + j)) return -1;
|
2026-03-18 15:28:35 -06:00
|
|
|
int rv = lua_reg[A + j];
|
|
|
|
|
if (rv < 0) return -1;
|
|
|
|
|
if (h.ty[rv] == TY_INT) {
|
|
|
|
|
rv = h.emit(HIR_ITOA, TY_STRING, rv);
|
|
|
|
|
if (rv < 0) return -1;
|
|
|
|
|
} else if (h.ty[rv] == TY_FLOAT) {
|
|
|
|
|
rv = h.emit(HIR_FTOA, TY_STRING, rv);
|
|
|
|
|
if (rv < 0) return -1;
|
|
|
|
|
}
|
|
|
|
|
str_args.push_back(rv);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lua_reg[A] = h.emit_strcat(str_args.data(),
|
|
|
|
|
static_cast<int>(str_args.size()));
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 15:37:23 -06:00
|
|
|
// ---- Table operations (ECALL back to Lua VM) ----
|
|
|
|
|
//
|
|
|
|
|
// Tables live on the Lua stack, referenced by stack index.
|
|
|
|
|
// NEWTABLE creates a table and returns its stack index (TY_INT).
|
|
|
|
|
// GETI/SETI/GETFIELD/SETFIELD operate via ECALL, marshalling
|
|
|
|
|
// values between guest memory and the Lua stack.
|
|
|
|
|
|
|
|
|
|
case OP_LUA_NEWTABLE: {
|
|
|
|
|
// A = dest register, B = array hint, C = hash hint.
|
|
|
|
|
// Extra size info may be in a following EXTRAARG instruction.
|
|
|
|
|
int narr = insn.B();
|
|
|
|
|
int nrec = insn.C();
|
|
|
|
|
// Emit ECALL_LUA_NEWTABLE: a0=narr, a1=nrec → a0=stack_idx.
|
|
|
|
|
int v_narr = h.emit_iconst(narr);
|
|
|
|
|
int v_nrec = h.emit_iconst(nrec);
|
|
|
|
|
if (v_narr < 0 || v_nrec < 0) return -1;
|
|
|
|
|
|
feat(lua/jit): integer-keyed table store on the dedicated-opcode path (#1519)
First table chunks to EXECUTE rather than decline:
local t={} t[1]=5 return t[1] -> 5 run_ok, no fallback
local t={} t[1]=7 t[2]=9 return t[1]+t[2] -> 16 run_ok, no fallback
Computed by the JIT, not by the interpreter covering for a failed run.
Three pieces. HIR_LUA_NEWTABLE is new: table creation had only the named
HIR_CALL form, which marshalled the resulting stack index through guest
memory as a decimal string and never completed. HIR_LUA_SETI already had
codegen and nothing lowered to it, so OP_LUA_SETTABI now emits it instead of
the named call. And SETTABI's value can be a CONSTANT rather than a
register when the k flag is set -- reading lua_reg[C] there yields -1, which
is why `t[1]=5` declined even once the rest was wired.
Integer values only. The dedicated ECALL carries the value in a register,
so there is nowhere for a string to ride; anything else declines and the
interpreter answers, correctly. That is the direction #1309 settled: an
index typed TY_LUA_HANDLE and passed in a register, which lua_is_handle can
refuse to let escape into arithmetic, rather than an untyped index passed as
text where "22" is indistinguishable from 22 (#1424).
A fourth registration point, which is the part worth knowing: a
value-producing opcode must also appear in needs_int_reg() in
hir_codegen.cpp. Enum, lowering and codegen are the obvious three; omitting
the fourth fails SILENTLY, because codegen's `if (!dest) break;` emits
nothing at all, the ECALL never runs, and the consumer reads whatever was in
the register. Measured as `SETI idx=0 type=function` -- found by tracing the
index across the ECALL boundary, which reading the code would not have shown.
Tests go in EXEC rather than AGREE deliberately. Agreement is also what a
decline produces, so only lua_run_ok > 0 separates "the JIT computed this"
from "the interpreter did" (#1426). Verified both directions: on master both
cases report "lua_run_ok=0 (compiled path did not execute)".
agree_declined stays 32. The harness's other table chunks use CONSTRUCTORS,
which lower through SETLIST -- a different path, and the next increment.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:15:39 -06:00
|
|
|
// Dedicated opcode, not a named HIR_CALL. The named form went
|
|
|
|
|
// through an ECALL that marshalled the stack index as a decimal
|
|
|
|
|
// string; nothing ever completed through it and it is gone
|
|
|
|
|
// (#1519). This keeps the index in a register, typed.
|
|
|
|
|
lua_reg[A] = h.emit(HIR_LUA_NEWTABLE, TY_LUA_HANDLE,
|
|
|
|
|
v_narr, v_nrec);
|
2026-03-18 15:37:23 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
// Mark this as known-integer (it's a stack index).
|
|
|
|
|
h.known_int[lua_reg[A]] = true;
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
// A table born here is PROVEN plain until it escapes as a
|
2026-07-29 09:08:10 -06:00
|
|
|
// call argument; see lua_referent::plain_proven. keys_closed
|
|
|
|
|
// starts true with an empty int_keys set — every constant-key
|
|
|
|
|
// store is recorded until a dynamic-key store opens it.
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
{
|
|
|
|
|
lua_referent nt;
|
|
|
|
|
nt.plain_proven = true;
|
2026-07-29 09:08:10 -06:00
|
|
|
nt.keys_closed = true;
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
lua_ref[lua_reg[A]] = nt;
|
|
|
|
|
}
|
2026-03-18 15:37:23 -06:00
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_GETTABI: {
|
|
|
|
|
// A = dest, B = table register, C = integer key.
|
|
|
|
|
int tbl = lua_reg[insn.B()];
|
|
|
|
|
if (tbl < 0) return -1;
|
2026-07-25 23:08:40 -06:00
|
|
|
|
|
|
|
|
// mux.args[N] (1-based) → softcode CARGS slot N-1. The mux.*
|
|
|
|
|
// bridge lowers `mux`/`args` to SCONST sentinels rather than a
|
|
|
|
|
// live Lua table; treating those as stack indices for
|
|
|
|
|
// HIR_LUA_GETI caused runaway DBT dispatch (#1309).
|
|
|
|
|
//
|
2026-07-29 15:19:43 -06:00
|
|
|
if (lua_is_mux_sentinel(h, tbl)) {
|
2026-07-25 23:08:40 -06:00
|
|
|
if (h.sval[tbl] == "mux.args") {
|
|
|
|
|
int key = insn.C();
|
|
|
|
|
if (key < 1 || key > rv_compiler::MAX_CARGS) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
uint64_t carg_addr = rv_compiler::CARGS_BASE
|
|
|
|
|
+ static_cast<uint64_t>(key - 1)
|
|
|
|
|
* rv_compiler::CARGS_SLOT;
|
|
|
|
|
h.needs_jit = true;
|
|
|
|
|
lua_reg[A] = h.emit_sref(carg_addr);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
// Other mux.* tables are not indexable on the JIT path yet.
|
|
|
|
|
if (h.sval[tbl].rfind("mux.", 0) == 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
// Typed fast read requires the PLAIN PROOF (#1751 Phase 1):
|
|
|
|
|
// GETI's integer claim is sound only for a table this chunk
|
|
|
|
|
// built and never let escape. Any other handle -- a global,
|
|
|
|
|
// a member, an escaped local -- may carry a metatable or
|
|
|
|
|
// non-integer values, and the claim-miss path would continue
|
|
|
|
|
// with 0: a silent wrong answer, proved in review. Chunk is
|
|
|
|
|
// ineligible instead; the interpreter answers.
|
2026-07-29 09:08:10 -06:00
|
|
|
const lua_referent tref = lua_referent_of(lua_ref, tbl);
|
|
|
|
|
if (!tref.plain_proven) {
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:08:10 -06:00
|
|
|
const int64_t ikey = static_cast<int64_t>(insn.C());
|
|
|
|
|
// Known-absent under a closed key set: the integer slot cannot
|
|
|
|
|
// carry nil, and continuing with 0 was a silent wrong answer.
|
|
|
|
|
// Emit LOADNIL's representation at lowering — no post-entry
|
|
|
|
|
// GETI_INT miss.
|
|
|
|
|
if (tref.keys_closed
|
|
|
|
|
&& tref.int_keys.find(ikey) == tref.int_keys.end()) {
|
|
|
|
|
lua_reg[A] = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_NIL);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 15:37:23 -06:00
|
|
|
int key = h.emit_iconst(insn.C());
|
|
|
|
|
if (key < 0) return -1;
|
|
|
|
|
|
2026-03-21 17:30:29 -06:00
|
|
|
// Use integer fast-path: returns TY_INT directly, no string.
|
|
|
|
|
lua_reg[A] = h.emit(HIR_LUA_GETI, TY_INT, tbl, key);
|
2026-03-18 15:37:23 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_SETTABI: {
|
feat(lua/jit): integer-keyed table store on the dedicated-opcode path (#1519)
First table chunks to EXECUTE rather than decline:
local t={} t[1]=5 return t[1] -> 5 run_ok, no fallback
local t={} t[1]=7 t[2]=9 return t[1]+t[2] -> 16 run_ok, no fallback
Computed by the JIT, not by the interpreter covering for a failed run.
Three pieces. HIR_LUA_NEWTABLE is new: table creation had only the named
HIR_CALL form, which marshalled the resulting stack index through guest
memory as a decimal string and never completed. HIR_LUA_SETI already had
codegen and nothing lowered to it, so OP_LUA_SETTABI now emits it instead of
the named call. And SETTABI's value can be a CONSTANT rather than a
register when the k flag is set -- reading lua_reg[C] there yields -1, which
is why `t[1]=5` declined even once the rest was wired.
Integer values only. The dedicated ECALL carries the value in a register,
so there is nowhere for a string to ride; anything else declines and the
interpreter answers, correctly. That is the direction #1309 settled: an
index typed TY_LUA_HANDLE and passed in a register, which lua_is_handle can
refuse to let escape into arithmetic, rather than an untyped index passed as
text where "22" is indistinguishable from 22 (#1424).
A fourth registration point, which is the part worth knowing: a
value-producing opcode must also appear in needs_int_reg() in
hir_codegen.cpp. Enum, lowering and codegen are the obvious three; omitting
the fourth fails SILENTLY, because codegen's `if (!dest) break;` emits
nothing at all, the ECALL never runs, and the consumer reads whatever was in
the register. Measured as `SETI idx=0 type=function` -- found by tracing the
index across the ECALL boundary, which reading the code would not have shown.
Tests go in EXEC rather than AGREE deliberately. Agreement is also what a
decline produces, so only lua_run_ok > 0 separates "the JIT computed this"
from "the interpreter did" (#1426). Verified both directions: on master both
cases report "lua_run_ok=0 (compiled path did not execute)".
agree_declined stays 32. The harness's other table chunks use CONSTRUCTORS,
which lower through SETLIST -- a different path, and the next increment.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:15:39 -06:00
|
|
|
// A = table register, B = integer key, C = value register --
|
|
|
|
|
// or, when k is set, a CONSTANT index rather than a register.
|
|
|
|
|
// Reading lua_reg[C] in that case yields -1 and the chunk
|
|
|
|
|
// declines, which is why `t[1]=5` did: the 5 is a constant.
|
2026-03-18 15:37:23 -06:00
|
|
|
int tbl = lua_reg[A];
|
feat(lua/jit): integer-keyed table store on the dedicated-opcode path (#1519)
First table chunks to EXECUTE rather than decline:
local t={} t[1]=5 return t[1] -> 5 run_ok, no fallback
local t={} t[1]=7 t[2]=9 return t[1]+t[2] -> 16 run_ok, no fallback
Computed by the JIT, not by the interpreter covering for a failed run.
Three pieces. HIR_LUA_NEWTABLE is new: table creation had only the named
HIR_CALL form, which marshalled the resulting stack index through guest
memory as a decimal string and never completed. HIR_LUA_SETI already had
codegen and nothing lowered to it, so OP_LUA_SETTABI now emits it instead of
the named call. And SETTABI's value can be a CONSTANT rather than a
register when the k flag is set -- reading lua_reg[C] there yields -1, which
is why `t[1]=5` declined even once the rest was wired.
Integer values only. The dedicated ECALL carries the value in a register,
so there is nowhere for a string to ride; anything else declines and the
interpreter answers, correctly. That is the direction #1309 settled: an
index typed TY_LUA_HANDLE and passed in a register, which lua_is_handle can
refuse to let escape into arithmetic, rather than an untyped index passed as
text where "22" is indistinguishable from 22 (#1424).
A fourth registration point, which is the part worth knowing: a
value-producing opcode must also appear in needs_int_reg() in
hir_codegen.cpp. Enum, lowering and codegen are the obvious three; omitting
the fourth fails SILENTLY, because codegen's `if (!dest) break;` emits
nothing at all, the ECALL never runs, and the consumer reads whatever was in
the register. Measured as `SETI idx=0 type=function` -- found by tracing the
index across the ECALL boundary, which reading the code would not have shown.
Tests go in EXEC rather than AGREE deliberately. Agreement is also what a
decline produces, so only lua_run_ok > 0 separates "the JIT computed this"
from "the interpreter did" (#1426). Verified both directions: on master both
cases report "lua_run_ok=0 (compiled path did not execute)".
agree_declined stays 32. The harness's other table chunks use CONSTRUCTORS,
which lower through SETLIST -- a different path, and the next increment.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:15:39 -06:00
|
|
|
if (tbl < 0) return -1;
|
|
|
|
|
int val;
|
|
|
|
|
if (insn.k()) {
|
|
|
|
|
if (insn.C() < 0
|
|
|
|
|
|| insn.C() >= static_cast<int>(proto->constants.size())) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
const lua_bc_constant &kv = proto->constants[insn.C()];
|
|
|
|
|
if (kv.type != LUA_BC_TINT) return -1; // ints only, as below
|
|
|
|
|
val = h.emit_iconst(kv.ival);
|
|
|
|
|
} else {
|
|
|
|
|
val = lua_reg[insn.C()];
|
2026-03-18 15:37:23 -06:00
|
|
|
}
|
feat(lua/jit): integer-keyed table store on the dedicated-opcode path (#1519)
First table chunks to EXECUTE rather than decline:
local t={} t[1]=5 return t[1] -> 5 run_ok, no fallback
local t={} t[1]=7 t[2]=9 return t[1]+t[2] -> 16 run_ok, no fallback
Computed by the JIT, not by the interpreter covering for a failed run.
Three pieces. HIR_LUA_NEWTABLE is new: table creation had only the named
HIR_CALL form, which marshalled the resulting stack index through guest
memory as a decimal string and never completed. HIR_LUA_SETI already had
codegen and nothing lowered to it, so OP_LUA_SETTABI now emits it instead of
the named call. And SETTABI's value can be a CONSTANT rather than a
register when the k flag is set -- reading lua_reg[C] there yields -1, which
is why `t[1]=5` declined even once the rest was wired.
Integer values only. The dedicated ECALL carries the value in a register,
so there is nowhere for a string to ride; anything else declines and the
interpreter answers, correctly. That is the direction #1309 settled: an
index typed TY_LUA_HANDLE and passed in a register, which lua_is_handle can
refuse to let escape into arithmetic, rather than an untyped index passed as
text where "22" is indistinguishable from 22 (#1424).
A fourth registration point, which is the part worth knowing: a
value-producing opcode must also appear in needs_int_reg() in
hir_codegen.cpp. Enum, lowering and codegen are the obvious three; omitting
the fourth fails SILENTLY, because codegen's `if (!dest) break;` emits
nothing at all, the ECALL never runs, and the consumer reads whatever was in
the register. Measured as `SETI idx=0 type=function` -- found by tracing the
index across the ECALL boundary, which reading the code would not have shown.
Tests go in EXEC rather than AGREE deliberately. Agreement is also what a
decline produces, so only lua_run_ok > 0 separates "the JIT computed this"
from "the interpreter did" (#1426). Verified both directions: on master both
cases report "lua_run_ok=0 (compiled path did not execute)".
agree_declined stays 32. The harness's other table chunks use CONSTRUCTORS,
which lower through SETLIST -- a different path, and the next increment.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:15:39 -06:00
|
|
|
if (val < 0) return -1;
|
|
|
|
|
|
|
|
|
|
// Integer values only. The dedicated ECALL carries the value
|
|
|
|
|
// in a register (a2), so there is nowhere for a string to ride;
|
|
|
|
|
// the named form it replaces stringified everything and never
|
|
|
|
|
// completed (#1519). Decline the rest rather than invent a
|
|
|
|
|
// marshalling for it -- the interpreter answers, correctly.
|
|
|
|
|
if (h.ty[val] != TY_INT) return -1;
|
|
|
|
|
if (lua_is_handle(h, val)) return -1;
|
2026-03-18 15:37:23 -06:00
|
|
|
|
|
|
|
|
int key = h.emit_iconst(insn.B());
|
|
|
|
|
if (key < 0) return -1;
|
|
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// In a loop proto the run may be re-run on the interpreter
|
|
|
|
|
// after budget exhaustion, so stores must be chunk-local: a
|
|
|
|
|
// store into a global-shaped table would happen twice (#1732).
|
|
|
|
|
if (proto_has_loop
|
|
|
|
|
&& lua_referent_of(lua_ref, tbl).fields_are_refs) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
feat(lua/jit): integer-keyed table store on the dedicated-opcode path (#1519)
First table chunks to EXECUTE rather than decline:
local t={} t[1]=5 return t[1] -> 5 run_ok, no fallback
local t={} t[1]=7 t[2]=9 return t[1]+t[2] -> 16 run_ok, no fallback
Computed by the JIT, not by the interpreter covering for a failed run.
Three pieces. HIR_LUA_NEWTABLE is new: table creation had only the named
HIR_CALL form, which marshalled the resulting stack index through guest
memory as a decimal string and never completed. HIR_LUA_SETI already had
codegen and nothing lowered to it, so OP_LUA_SETTABI now emits it instead of
the named call. And SETTABI's value can be a CONSTANT rather than a
register when the k flag is set -- reading lua_reg[C] there yields -1, which
is why `t[1]=5` declined even once the rest was wired.
Integer values only. The dedicated ECALL carries the value in a register,
so there is nowhere for a string to ride; anything else declines and the
interpreter answers, correctly. That is the direction #1309 settled: an
index typed TY_LUA_HANDLE and passed in a register, which lua_is_handle can
refuse to let escape into arithmetic, rather than an untyped index passed as
text where "22" is indistinguishable from 22 (#1424).
A fourth registration point, which is the part worth knowing: a
value-producing opcode must also appear in needs_int_reg() in
hir_codegen.cpp. Enum, lowering and codegen are the obvious three; omitting
the fourth fails SILENTLY, because codegen's `if (!dest) break;` emits
nothing at all, the ECALL never runs, and the consumer reads whatever was in
the register. Measured as `SETI idx=0 type=function` -- found by tracing the
index across the ECALL boundary, which reading the code would not have shown.
Tests go in EXEC rather than AGREE deliberately. Agreement is also what a
decline produces, so only lua_run_ok > 0 separates "the JIT computed this"
from "the interpreter did" (#1426). Verified both directions: on master both
cases report "lua_run_ok=0 (compiled path did not execute)".
agree_declined stays 32. The harness's other table chunks use CONSTRUCTORS,
which lower through SETLIST -- a different path, and the next increment.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:15:39 -06:00
|
|
|
// Third operand rides in val[]; see hir_val_operand() in hir.h,
|
|
|
|
|
// which the liveness walker consults so the register holding the
|
|
|
|
|
// stored value is not recycled before the ECALL reads it.
|
|
|
|
|
if (h.emit(HIR_LUA_SETI, TY_VOID, tbl, key, val) < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
// SETTABI's key is always the constant insn.B().
|
|
|
|
|
lua_note_int_key_store(lua_ref, tbl, static_cast<int64_t>(insn.B()));
|
2026-03-18 15:37:23 -06:00
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_SETLIST: {
|
|
|
|
|
// A = table register, B = number of values, k+C = offset.
|
|
|
|
|
// Values are in R(A+1)..R(A+B).
|
|
|
|
|
int tbl = lua_reg[A];
|
|
|
|
|
if (tbl < 0) return -1;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Same rerun-safety rule as SETTABI (#1732).
|
|
|
|
|
if (proto_has_loop
|
|
|
|
|
&& lua_referent_of(lua_ref, tbl).fields_are_refs) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-03-18 15:37:23 -06:00
|
|
|
int nvals = insn.B();
|
|
|
|
|
int offset = insn.C();
|
|
|
|
|
// k flag indicates extra offset from following EXTRAARG.
|
|
|
|
|
if (insn.k() && pc + 1 < n) {
|
|
|
|
|
offset += proto->code[pc + 1].Ax() * (1 << 8);
|
|
|
|
|
// Don't skip EXTRAARG here — it will be skipped as
|
|
|
|
|
// unsupported if we don't handle it, but SETLIST
|
|
|
|
|
// consumed the info.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (int j = 1; j <= nvals; j++) {
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + j)) return -1;
|
2026-03-18 15:37:23 -06:00
|
|
|
int val = lua_reg[A + j];
|
|
|
|
|
if (val < 0) return -1;
|
feat(lua/jit): table constructors on the dedicated-opcode path (#1519)
The decline count moves for the first time: 32 -> 31.
local t={10,20,30} return t[2] -> 20 run_ok=1, run_fail=0
Constructors lower through SETLIST, a different path from the t[1]= stores
in #1705, and it was still emitting the named ECALL -- so a constructor
compiled and then failed on every call, paying the compile cost to reach an
answer the interpreter supplied anyway.
Same shape as OP_LUA_SETTABI: integer elements only, since the dedicated
ECALL carries the value in a register and there is nowhere for a string to
ride. A constructor holding anything else declines and the interpreter
answers. The stringify-then-call that used to sit here is what the named
mechanism required and is exactly why it could not complete.
The ratchet did its job. It FAILED on this change with
DECLINE BUDGET: down to 31 from 32 -- good.
Lower AGREE_DECLINE_BUDGET in this file to 31
so an improvement cannot land without the number moving with it -- the first
time it has fired in the improving direction since #1326 added it.
EXEC cases cover both constructor shapes. Verified in both directions: on
master they report "lua_run_ok=0 (compiled path did not execute)" and the
lowered budget reports "32 declined, budget 31".
`local t={1,2,3} return #t` still declines -- that is `#t` on a handle,
which lua_is_handle correctly refuses. Length-of-table needs a real ECALL
rather than STRLEN on a sentinel; next increment.
test-lua-jit 1561/0. `make test` is red on master itself for an unrelated
reason (tests/nls runtime oracle, 3 vs 4) -- fixed separately in #1706, and
reproduced on a clean master checkout with this branch stashed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:39:28 -06:00
|
|
|
|
|
|
|
|
// Integer elements only, as for OP_LUA_SETTABI: the
|
|
|
|
|
// dedicated ECALL carries the value in a register, so there
|
|
|
|
|
// is nowhere for a string to ride. A constructor holding
|
|
|
|
|
// anything else declines and the interpreter answers.
|
|
|
|
|
if (h.ty[val] != TY_INT) return -1;
|
|
|
|
|
if (lua_is_handle(h, val)) return -1;
|
|
|
|
|
|
2026-03-18 15:37:23 -06:00
|
|
|
int key = h.emit_iconst(offset + j);
|
|
|
|
|
if (key < 0) return -1;
|
|
|
|
|
|
feat(lua/jit): table constructors on the dedicated-opcode path (#1519)
The decline count moves for the first time: 32 -> 31.
local t={10,20,30} return t[2] -> 20 run_ok=1, run_fail=0
Constructors lower through SETLIST, a different path from the t[1]= stores
in #1705, and it was still emitting the named ECALL -- so a constructor
compiled and then failed on every call, paying the compile cost to reach an
answer the interpreter supplied anyway.
Same shape as OP_LUA_SETTABI: integer elements only, since the dedicated
ECALL carries the value in a register and there is nowhere for a string to
ride. A constructor holding anything else declines and the interpreter
answers. The stringify-then-call that used to sit here is what the named
mechanism required and is exactly why it could not complete.
The ratchet did its job. It FAILED on this change with
DECLINE BUDGET: down to 31 from 32 -- good.
Lower AGREE_DECLINE_BUDGET in this file to 31
so an improvement cannot land without the number moving with it -- the first
time it has fired in the improving direction since #1326 added it.
EXEC cases cover both constructor shapes. Verified in both directions: on
master they report "lua_run_ok=0 (compiled path did not execute)" and the
lowered budget reports "32 declined, budget 31".
`local t={1,2,3} return #t` still declines -- that is `#t` on a handle,
which lua_is_handle correctly refuses. Length-of-table needs a real ECALL
rather than STRLEN on a sentinel; next increment.
test-lua-jit 1561/0. `make test` is red on master itself for an unrelated
reason (tests/nls runtime oracle, 3 vs 4) -- fixed separately in #1706, and
reproduced on a clean master checkout with this branch stashed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 12:39:28 -06:00
|
|
|
// Third operand rides in val[]; hir_val_operand() in hir.h
|
|
|
|
|
// is what keeps the liveness walker from recycling the
|
|
|
|
|
// register before the ECALL reads it.
|
|
|
|
|
if (h.emit(HIR_LUA_SETI, TY_VOID, tbl, key, val) < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-07-29 09:08:10 -06:00
|
|
|
lua_note_int_key_store(lua_ref, tbl,
|
|
|
|
|
static_cast<int64_t>(offset + j));
|
2026-03-18 15:37:23 -06:00
|
|
|
h.ecalls++;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 15:54:13 -06:00
|
|
|
// GETTABLE: A = dest, B = table register, C = key register.
|
|
|
|
|
case OP_LUA_GETTABLE: {
|
|
|
|
|
int tbl = lua_reg[insn.B()];
|
|
|
|
|
int key = lua_reg[insn.C()];
|
|
|
|
|
if (tbl < 0 || key < 0) return -1;
|
2026-03-21 17:30:29 -06:00
|
|
|
|
2026-07-25 23:08:40 -06:00
|
|
|
// mux.args[k] with compile-time integer key → CARGS (see GETTABI).
|
|
|
|
|
//
|
2026-07-29 15:19:43 -06:00
|
|
|
if ( lua_is_mux_sentinel(h, tbl)
|
|
|
|
|
&& h.kind[tbl] == HIR_SCONST
|
2026-07-25 23:08:40 -06:00
|
|
|
&& h.sval[tbl] == "mux.args"
|
|
|
|
|
&& h.kind[key] == HIR_ICONST) {
|
|
|
|
|
int k = static_cast<int>(h.val[key]);
|
|
|
|
|
if (k < 1 || k > rv_compiler::MAX_CARGS) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
uint64_t carg_addr = rv_compiler::CARGS_BASE
|
|
|
|
|
+ static_cast<uint64_t>(k - 1) * rv_compiler::CARGS_SLOT;
|
|
|
|
|
h.needs_jit = true;
|
|
|
|
|
lua_reg[A] = h.emit_sref(carg_addr);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 18:02:17 -06:00
|
|
|
if (h.ty[key] == TY_INT && pinned_tbl_reg >= 0
|
|
|
|
|
&& insn.B() == pinned_tbl_reg) {
|
|
|
|
|
// Pinned array: native memory load, no ECALL.
|
|
|
|
|
lua_reg[A] = h.emit(HIR_LUA_ALOAD, TY_INT, key, -1,
|
|
|
|
|
static_cast<int64_t>(rv_compiler::LUA_ARRAY_BASE));
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
h.ecalls--; // replaces an ECALL
|
|
|
|
|
} else if (h.ty[key] == TY_INT) {
|
2026-07-29 09:08:10 -06:00
|
|
|
// Same plain-proof rule as GETTABI.
|
|
|
|
|
const lua_referent tref = lua_referent_of(lua_ref, tbl);
|
|
|
|
|
if (!tref.plain_proven) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
// Constant key under a closed set: known-absent → nil.
|
|
|
|
|
if (h.kind[key] == HIR_ICONST
|
|
|
|
|
&& tref.keys_closed
|
|
|
|
|
&& tref.int_keys.find(h.val[key]) == tref.int_keys.end()) {
|
|
|
|
|
lua_reg[A] = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
lua_truth_set(truth_tag, lua_reg[A], LUA_TRUTH_NIL);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-03-21 17:30:29 -06:00
|
|
|
// Integer key: use fast-path ECALL, returns TY_INT.
|
|
|
|
|
lua_reg[A] = h.emit(HIR_LUA_GETI, TY_INT, tbl, key);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
} else {
|
|
|
|
|
// String/float key: use general ECALL path.
|
|
|
|
|
if (h.ty[key] == TY_FLOAT) {
|
|
|
|
|
key = h.emit(HIR_FTOA, TY_STRING, key);
|
|
|
|
|
if (key < 0) return -1;
|
|
|
|
|
}
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// No handler exists for a general (string/float key)
|
|
|
|
|
// table read; emitting the named ECALL could only fail
|
|
|
|
|
// loudly post-entry. Ineligible at lowering instead
|
|
|
|
|
// (#1751 rule 1): the interpreter answers.
|
|
|
|
|
return -1;
|
2026-03-18 15:54:13 -06:00
|
|
|
}
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GETFIELD: A = dest, B = table register, C = key constant index.
|
|
|
|
|
// (General case — not the mux.* bridge pattern, which is handled
|
|
|
|
|
// separately via GETTABUP+GETFIELD.)
|
|
|
|
|
case OP_LUA_GETFIELD: {
|
|
|
|
|
int table_reg = lua_reg[insn.B()];
|
|
|
|
|
if (table_reg < 0) return -1;
|
|
|
|
|
int kidx = insn.C();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
const lua_bc_constant &k = proto->constants[kidx];
|
|
|
|
|
if (k.type != LUA_BC_TSHRSTR && k.type != LUA_BC_TLNGSTR)
|
|
|
|
|
return -1;
|
|
|
|
|
|
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.
The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path). Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction. The
name-mapped branch and its is_bridge plumbing are deleted.
Two consequences handled:
* Execution context. The bridge functions read executor/caller/
enactor from the registry, and per-run mux fields are injected into
the table -- previously only in ExecuteChunk, the interpreter leg,
so a compiled run offered the bridge a cleared registry and the
PREVIOUS run's mux table. The setup/teardown is now one shared pair
(lua_setup_exec_context / lua_clear_exec_context) staged by both
ExecuteChunk and TryJIT's two RunCompiled sites.
* Effectful members stay exactly-once. mux.notify/pemit/set in a
result-consuming form would pcall -- effect delivered -- then
decline on the result type, and the interpreter re-run would deliver
the effect twice. The referent now carries an effectful claim:
statement form compiles (CALL_VOID has no result check), result-
consuming forms decline at COMPILE time and run once, interpreted.
Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.
smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
|
|
|
// mux.* routing. ONLY mux.args stays on the SCONST sentinel:
|
|
|
|
|
// GETTABI on the "mux.args" marker is the native CARGS/ALOAD
|
|
|
|
|
// fast path. Every other mux member goes through the REAL
|
|
|
|
|
// global table, so a compiled mux.eval(...) pcalls the same
|
|
|
|
|
// bridge C function the interpreter calls -- semantics correct
|
|
|
|
|
// by construction. The old path mapped the NAME onto the
|
|
|
|
|
// softcode function table instead (mux.eval -> softcode
|
|
|
|
|
// eval(obj,attr), mux.name -> name() wanting a "#dbref"), which
|
|
|
|
|
// default-on exposed the day smoke first ran it compiled
|
|
|
|
|
// (#1745: TC013/TC014).
|
2026-07-29 15:19:43 -06:00
|
|
|
const bool mux_sentinel =
|
|
|
|
|
lua_is_mux_sentinel(h, table_reg)
|
|
|
|
|
&& h.sval[table_reg] == "mux";
|
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.
The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path). Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction. The
name-mapped branch and its is_bridge plumbing are deleted.
Two consequences handled:
* Execution context. The bridge functions read executor/caller/
enactor from the registry, and per-run mux fields are injected into
the table -- previously only in ExecuteChunk, the interpreter leg,
so a compiled run offered the bridge a cleared registry and the
PREVIOUS run's mux table. The setup/teardown is now one shared pair
(lua_setup_exec_context / lua_clear_exec_context) staged by both
ExecuteChunk and TryJIT's two RunCompiled sites.
* Effectful members stay exactly-once. mux.notify/pemit/set in a
result-consuming form would pcall -- effect delivered -- then
decline on the result type, and the interpreter re-run would deliver
the effect twice. The referent now carries an effectful claim:
statement form compiles (CALL_VOID has no result check), result-
consuming forms decline at COMPILE time and run once, interpreted.
Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.
smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
|
|
|
if (mux_sentinel && k.sval == "args") {
|
2026-03-18 15:54:13 -06:00
|
|
|
std::string name = "mux." + k.sval;
|
|
|
|
|
uint64_t addr = rc.pool_str(name.c_str(), name.size());
|
|
|
|
|
lua_reg[A] = h.emit_sconst(addr, name);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
2026-07-29 15:19:43 -06:00
|
|
|
h.lua_mux_sentinel[lua_reg[A]] = true;
|
2026-03-18 15:54:13 -06:00
|
|
|
} else {
|
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.
The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path). Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction. The
name-mapped branch and its is_bridge plumbing are deleted.
Two consequences handled:
* Execution context. The bridge functions read executor/caller/
enactor from the registry, and per-run mux fields are injected into
the table -- previously only in ExecuteChunk, the interpreter leg,
so a compiled run offered the bridge a cleared registry and the
PREVIOUS run's mux table. The setup/teardown is now one shared pair
(lua_setup_exec_context / lua_clear_exec_context) staged by both
ExecuteChunk and TryJIT's two RunCompiled sites.
* Effectful members stay exactly-once. mux.notify/pemit/set in a
result-consuming form would pcall -- effect delivered -- then
decline on the result type, and the interpreter re-run would deliver
the effect twice. The referent now carries an effectful claim:
statement form compiles (CALL_VOID has no result check), result-
consuming forms decline at COMPILE time and run once, interpreted.
Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.
smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
|
|
|
if (mux_sentinel) {
|
|
|
|
|
// Materialize the real global in place of the marker.
|
|
|
|
|
uint64_t mk = rc.pool_str("mux", 3);
|
|
|
|
|
int mkey = h.emit_sconst(mk, "mux");
|
|
|
|
|
if (mkey < 0) return -1;
|
|
|
|
|
int mh = h.emit(HIR_LUA_GETGLOBAL, TY_LUA_HANDLE, mkey);
|
|
|
|
|
if (mh < 0) return -1;
|
|
|
|
|
lua_referent g;
|
|
|
|
|
g.fields_are_refs = true; // members are functions
|
|
|
|
|
g.callable = false; // the table itself is not
|
|
|
|
|
lua_ref[mh] = g;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
table_reg = mh;
|
|
|
|
|
}
|
feat(lua/jit): string-keyed table fields on the dedicated-opcode path (#1519)
local t={a=3,b=4} return t.a+t.b -> 7 run_ok, no fallback
local t={} t.x=5 t.y=6 return t.x*10+t.y -> 56
Decline count 30 -> 29.
The key travels as an ADDRESS into the program's own string pool, never as
marshalled text, so nothing downstream can mistake it for a value. Only the
integer result comes back, in a register; a non-integer field declines
inside the handler rather than guessing a marshalling.
Two more registration points, both silent if missed, and both found by
READING rather than by debugging -- hir.h's comment about val[] operands is
what prompted the check:
hir_val_operand() was gated strictly on HIR_LUA_SETI. SETFIELD parks
its value there too, so the liveness walker would not
have seen it and the register could be recycled before
the ECALL read it.
has_side_effects() SETFIELD is a store with no result. DCE deletes it.
That makes seven places a new opcode may need to appear, four of which fail
with no diagnostic: enum, lowering, codegen case, needs_int_reg,
hir_kind_name, hir_val_operand, has_side_effects.
Also the k flag again: OP_LUA_SETFIELD's C is a CONSTANT index when k is
set, exactly as OP_LUA_SETTABI's was, so `t.x=5` declined until it was
handled.
A WRONG ANSWER, caught before merge and worth recording
local t={a=3,b=4} return t.a+t.b answered 8, not 7
HIR_SCONST lives as loc[].addr with in_reg=false. Passing the key through
ra_get_reg returned a register that was never loaded, so a1 held the same
stale address on every call and every field read returned the LAST value
written -- 4+4.
The harness was fully green while that was true: agree_wrong 0, exec_wrong
0, and my own first EXEC case `local t={a=7} return t.a` PASSED, because
with one key "always return the last write" is indistinguishable from
correct. A probe with two distinct keys is what exposed it.
So the EXEC cases here read TWO DISTINCT KEYS deliberately. For any keyed
operation a single-key test proves almost nothing: stale key register, key
ignored, and all-keys-alias are each invisible unless two keys are read back
independently.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:24:11 -06:00
|
|
|
// General table field access via the dedicated ECALL. The
|
|
|
|
|
// key travels as an ADDRESS into the program's own string
|
|
|
|
|
// pool; only the integer value comes back, in a register.
|
|
|
|
|
// Non-integer fields decline inside the handler.
|
|
|
|
|
if (!lua_is_handle(h, table_reg)) return -1;
|
2026-03-18 15:54:13 -06:00
|
|
|
uint64_t key_addr = rc.pool_str(k.sval.c_str(), k.sval.size());
|
|
|
|
|
int key_val = h.emit_sconst(key_addr, k.sval);
|
|
|
|
|
if (key_val < 0) return -1;
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// Which variant depends on what the table IS -- its
|
|
|
|
|
// referent. A library table's members are functions, so
|
|
|
|
|
// take a reference; a data table's members are values, so
|
|
|
|
|
// take the value. The claim was recorded where the handle
|
|
|
|
|
// was created; a member reference gets its own claim here,
|
|
|
|
|
// from the member's name, so a later call reads it instead
|
|
|
|
|
// of walking back to this key.
|
|
|
|
|
const lua_referent tref = lua_referent_of(lua_ref, table_reg);
|
feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume. The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.
Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once. And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).
HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape). Noted in both places.
EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1. AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.
luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
|
|
|
const hir_type vty = lua_lib_value_type(tref, k.sval);
|
|
|
|
|
if (TY_VOID != vty) {
|
|
|
|
|
// A known VALUE member of a library table -- math.pi,
|
|
|
|
|
// math.maxinteger -- so take the value itself; a
|
|
|
|
|
// reference would be a handle nothing downstream can
|
|
|
|
|
// consume. The runtime check keeps a rebound member
|
|
|
|
|
// honest: wrong type, decline.
|
|
|
|
|
lua_reg[A] = h.emit(
|
|
|
|
|
(TY_FLOAT == vty) ? HIR_LUA_GETFIELD_FLT
|
|
|
|
|
: HIR_LUA_GETFIELD,
|
|
|
|
|
vty, table_reg, key_val);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
if (TY_INT == vty) {
|
|
|
|
|
h.known_int[lua_reg[A]] = true;
|
|
|
|
|
}
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
// The typed value read (GETFIELD_INT) needs the PLAIN
|
|
|
|
|
// PROOF exactly as GETI does; a reference read does not
|
|
|
|
|
// (a handle result carries no value claim to miss).
|
|
|
|
|
if (!tref.fields_are_refs
|
|
|
|
|
&& !tref.plain_proven) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
feat(lua/jit): library calls, integer in and integer out (#1519)
math.max(3,9) -> 9 math.min(3,9) -> 3 math.abs(-7) -> 7
Decline count 29 -> 26, the largest single step so far, and the first that
crosses THREE ECALLs in one compiled run:
GETGLOBAL name -> stack index of the library table
GETFIELD_REF table + name -> stack index of the function
CALL_INT function + up to two integer args -> integer result
#1519 flagged stack discipline across ECALLs as needing proof rather than
assumption. It holds: a handle from one ECALL stays valid across the next
two inside a run, bounded by TryJIT's settop around the whole thing.
Narrow on purpose. Integer args, integer result, nothing marshalled --
math.floor(3.7) still declines because its argument is a float. That keeps
this increment about STRUCTURE and leaves the string-result convention to be
decided on its own, where a bad choice would be expensive (see ECALL_ORD's
unbounded write, #1679).
WHAT THIS BUILD PROVED THE IR CANNOT DO
Two seams, neither of which I would have written down from taste:
1. An instruction needs N OPERANDS. A call has three or four; the IR has
src1, src2 and val[]. So nargs is bit-packed with an instruction index
into val[]:
int64_t packed = nargs | (int64_t)(a1 + 1) << 8;
That is val[]'s FOURTH meaning after immediate, guest address, and
SETI's third operand -- and hir_val_operand() cannot see the arg1 index
at all. Nothing breaks today only because two-argument calls are simple
enough that liveness incidentally holds. That is luck, and it is the
same shape that produced #1711's wrong answer.
What the upper layer needs, plainly: an operand list every pass can walk
without knowing the opcode.
2. The type system knows "handle" but not "handle to WHAT". Choosing
GETFIELD_REF over GETFIELD_INT is a guess from provenance -- did this
handle come from GETGLOBAL or from NEWTABLE -- because a library member
is a function and a data-table member is a value, and TY_LUA_HANDLE
cannot tell them apart. Second place this pass has had to guess.
Both are recorded rather than worked around silently, because the refactor
they argue for should be driven by what the upper layer demonstrably needs.
Tests use asymmetric two-argument calls, max(3,9) and min(3,9), for the same
reason #1711's use two distinct keys: one argument cannot distinguish
correct passing from an argument being ignored or the pair being swapped.
All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:55:37 -06:00
|
|
|
lua_reg[A] = h.emit(
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
tref.fields_are_refs ? HIR_LUA_GETFIELD_REF
|
|
|
|
|
: HIR_LUA_GETFIELD,
|
|
|
|
|
tref.fields_are_refs ? TY_LUA_HANDLE : TY_INT,
|
feat(lua/jit): library calls, integer in and integer out (#1519)
math.max(3,9) -> 9 math.min(3,9) -> 3 math.abs(-7) -> 7
Decline count 29 -> 26, the largest single step so far, and the first that
crosses THREE ECALLs in one compiled run:
GETGLOBAL name -> stack index of the library table
GETFIELD_REF table + name -> stack index of the function
CALL_INT function + up to two integer args -> integer result
#1519 flagged stack discipline across ECALLs as needing proof rather than
assumption. It holds: a handle from one ECALL stays valid across the next
two inside a run, bounded by TryJIT's settop around the whole thing.
Narrow on purpose. Integer args, integer result, nothing marshalled --
math.floor(3.7) still declines because its argument is a float. That keeps
this increment about STRUCTURE and leaves the string-result convention to be
decided on its own, where a bad choice would be expensive (see ECALL_ORD's
unbounded write, #1679).
WHAT THIS BUILD PROVED THE IR CANNOT DO
Two seams, neither of which I would have written down from taste:
1. An instruction needs N OPERANDS. A call has three or four; the IR has
src1, src2 and val[]. So nargs is bit-packed with an instruction index
into val[]:
int64_t packed = nargs | (int64_t)(a1 + 1) << 8;
That is val[]'s FOURTH meaning after immediate, guest address, and
SETI's third operand -- and hir_val_operand() cannot see the arg1 index
at all. Nothing breaks today only because two-argument calls are simple
enough that liveness incidentally holds. That is luck, and it is the
same shape that produced #1711's wrong answer.
What the upper layer needs, plainly: an operand list every pass can walk
without knowing the opcode.
2. The type system knows "handle" but not "handle to WHAT". Choosing
GETFIELD_REF over GETFIELD_INT is a guess from provenance -- did this
handle come from GETGLOBAL or from NEWTABLE -- because a library member
is a function and a data-table member is a value, and TY_LUA_HANDLE
cannot tell them apart. Second place this pass has had to guess.
Both are recorded rather than worked around silently, because the refactor
they argue for should be driven by what the upper layer demonstrably needs.
Tests use asymmetric two-argument calls, max(3,9) and min(3,9), for the same
reason #1711's use two distinct keys: one argument cannot distinguish
correct passing from an argument being ignored or the pair being swapped.
All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:55:37 -06:00
|
|
|
table_reg, key_val);
|
2026-03-18 15:54:13 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
if (tref.fields_are_refs) {
|
|
|
|
|
lua_referent m;
|
|
|
|
|
m.callable = true;
|
|
|
|
|
m.returns = lua_call_claim(k.sval);
|
2026-07-31 09:08:44 -06:00
|
|
|
m.call_name = k.sval;
|
fix(lua/jit): effectful bridge members never compile; ctx save/restore
Addresses both blocking objections from #1750's adversarial review.
Chunk-granularity exactly-once. The review's probe proved the first
version's reasoning wrong at the chunk level: statement-form
mux.pemit(1,"X") followed by a DECLINING read delivered the message
twice -- the effect ran compiled, the later decline failed the run, and
the interpreter re-ran the whole chunk. The chunk is the rerun unit,
so effectful members (notify, pemit, set, and eval -- arbitrary
softcode, doubled identically in the probe) now decline at COMPILE
time in every form. Chunks containing them run interpreted, once.
Pure members (name, get, type, owner, ...) still compile and pcall the
real bridge functions. Cost is nothing real: ECALL-bound shapes bench
at parity (#1741).
Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's
exec-ctx instead of clearing to nil, at all three sites including the
interpreter leg -- the review measured the pre-existing clear as a
route-dependent divergence under the production default (inner run's
teardown nil'd the outer run's context).
Harness: the mux.eval pins move to AGREE, asserting the ANSWER while
the decline is the required behavior -- with a comment that an eval
case starting to execute means the exactly-once argument must be
re-made, not waved through. A mux.name EXEC pin proves the pure-member
compiled path is real (lua_run_ok advances; equality with the
interpreter is the assertion). Decline budget 4 -> 6, both newcomers
permanent by design.
make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143
chunks 0 wrong; test-config green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
|
|
|
// Bridge members that act on the world; see the
|
|
|
|
|
// effectful field's comment. eval is on the list
|
|
|
|
|
// because it runs arbitrary softcode -- pemit inside
|
|
|
|
|
// a mux.eval doubled exactly like a direct pemit in
|
|
|
|
|
// the adversarial probe.
|
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.
The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path). Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction. The
name-mapped branch and its is_bridge plumbing are deleted.
Two consequences handled:
* Execution context. The bridge functions read executor/caller/
enactor from the registry, and per-run mux fields are injected into
the table -- previously only in ExecuteChunk, the interpreter leg,
so a compiled run offered the bridge a cleared registry and the
PREVIOUS run's mux table. The setup/teardown is now one shared pair
(lua_setup_exec_context / lua_clear_exec_context) staged by both
ExecuteChunk and TryJIT's two RunCompiled sites.
* Effectful members stay exactly-once. mux.notify/pemit/set in a
result-consuming form would pcall -- effect delivered -- then
decline on the result type, and the interpreter re-run would deliver
the effect twice. The referent now carries an effectful claim:
statement form compiles (CALL_VOID has no result check), result-
consuming forms decline at COMPILE time and run once, interpreted.
Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.
smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
|
|
|
m.effectful = (k.sval == "notify" || k.sval == "pemit"
|
fix(lua/jit): effectful bridge members never compile; ctx save/restore
Addresses both blocking objections from #1750's adversarial review.
Chunk-granularity exactly-once. The review's probe proved the first
version's reasoning wrong at the chunk level: statement-form
mux.pemit(1,"X") followed by a DECLINING read delivered the message
twice -- the effect ran compiled, the later decline failed the run, and
the interpreter re-ran the whole chunk. The chunk is the rerun unit,
so effectful members (notify, pemit, set, and eval -- arbitrary
softcode, doubled identically in the probe) now decline at COMPILE
time in every form. Chunks containing them run interpreted, once.
Pure members (name, get, type, owner, ...) still compile and pcall the
real bridge functions. Cost is nothing real: ECALL-bound shapes bench
at parity (#1741).
Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's
exec-ctx instead of clearing to nil, at all three sites including the
interpreter leg -- the review measured the pre-existing clear as a
route-dependent divergence under the production default (inner run's
teardown nil'd the outer run's context).
Harness: the mux.eval pins move to AGREE, asserting the ANSWER while
the decline is the required behavior -- with a comment that an eval
case starting to execute means the exactly-once argument must be
re-made, not waved through. A mux.name EXEC pin proves the pure-member
compiled path is real (lua_run_ok advances; equality with the
interpreter is the assertion). Decline budget 4 -> 6, both newcomers
permanent by design.
make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143
chunks 0 wrong; test-config green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
|
|
|
|| k.sval == "set" || k.sval == "eval");
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
lua_ref[lua_reg[A]] = m;
|
|
|
|
|
}
|
feat(lua/jit): string-keyed table fields on the dedicated-opcode path (#1519)
local t={a=3,b=4} return t.a+t.b -> 7 run_ok, no fallback
local t={} t.x=5 t.y=6 return t.x*10+t.y -> 56
Decline count 30 -> 29.
The key travels as an ADDRESS into the program's own string pool, never as
marshalled text, so nothing downstream can mistake it for a value. Only the
integer result comes back, in a register; a non-integer field declines
inside the handler rather than guessing a marshalling.
Two more registration points, both silent if missed, and both found by
READING rather than by debugging -- hir.h's comment about val[] operands is
what prompted the check:
hir_val_operand() was gated strictly on HIR_LUA_SETI. SETFIELD parks
its value there too, so the liveness walker would not
have seen it and the register could be recycled before
the ECALL read it.
has_side_effects() SETFIELD is a store with no result. DCE deletes it.
That makes seven places a new opcode may need to appear, four of which fail
with no diagnostic: enum, lowering, codegen case, needs_int_reg,
hir_kind_name, hir_val_operand, has_side_effects.
Also the k flag again: OP_LUA_SETFIELD's C is a CONSTANT index when k is
set, exactly as OP_LUA_SETTABI's was, so `t.x=5` declined until it was
handled.
A WRONG ANSWER, caught before merge and worth recording
local t={a=3,b=4} return t.a+t.b answered 8, not 7
HIR_SCONST lives as loc[].addr with in_reg=false. Passing the key through
ra_get_reg returned a register that was never loaded, so a1 held the same
stale address on every call and every field read returned the LAST value
written -- 4+4.
The harness was fully green while that was true: agree_wrong 0, exec_wrong
0, and my own first EXEC case `local t={a=7} return t.a` PASSED, because
with one key "always return the last write" is indistinguishable from
correct. A probe with two distinct keys is what exposed it.
So the EXEC cases here read TWO DISTINCT KEYS deliberately. For any keyed
operation a single-key test proves almost nothing: stale key register, key
ignored, and all-keys-alias are each invisible unless two keys are read back
independently.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:24:11 -06:00
|
|
|
h.known_int[lua_reg[A]] = true;
|
2026-03-18 15:54:13 -06:00
|
|
|
h.ecalls++;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SETTABLE: A = table register, B = key register, C = value register.
|
|
|
|
|
case OP_LUA_SETTABLE: {
|
|
|
|
|
int tbl = lua_reg[A];
|
|
|
|
|
int key = lua_reg[insn.B()];
|
|
|
|
|
int val = lua_reg[insn.C()];
|
|
|
|
|
if (tbl < 0 || key < 0 || val < 0) return -1;
|
|
|
|
|
if (h.ty[key] == TY_INT) {
|
|
|
|
|
key = h.emit(HIR_ITOA, TY_STRING, key);
|
|
|
|
|
if (key < 0) return -1;
|
|
|
|
|
}
|
|
|
|
|
if (h.ty[val] == TY_INT) {
|
|
|
|
|
val = h.emit(HIR_ITOA, TY_STRING, val);
|
|
|
|
|
if (val < 0) return -1;
|
|
|
|
|
} else if (h.ty[val] == TY_FLOAT) {
|
|
|
|
|
val = h.emit(HIR_FTOA, TY_STRING, val);
|
|
|
|
|
if (val < 0) return -1;
|
|
|
|
|
}
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// No handler exists for a runtime-keyed store; the named
|
|
|
|
|
// ECALL could only fail loudly post-entry -- which is what
|
|
|
|
|
// turned the plain `t[i]=v` loop into a loud diverge under
|
|
|
|
|
// Phase 0. Ineligible at lowering instead (#1751 rule 1).
|
|
|
|
|
return -1;
|
2026-03-18 15:54:13 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SETFIELD: A = table register, B = key constant index, C = value register.
|
|
|
|
|
case OP_LUA_SETFIELD: {
|
|
|
|
|
int tbl = lua_reg[A];
|
feat(lua/jit): string-keyed table fields on the dedicated-opcode path (#1519)
local t={a=3,b=4} return t.a+t.b -> 7 run_ok, no fallback
local t={} t.x=5 t.y=6 return t.x*10+t.y -> 56
Decline count 30 -> 29.
The key travels as an ADDRESS into the program's own string pool, never as
marshalled text, so nothing downstream can mistake it for a value. Only the
integer result comes back, in a register; a non-integer field declines
inside the handler rather than guessing a marshalling.
Two more registration points, both silent if missed, and both found by
READING rather than by debugging -- hir.h's comment about val[] operands is
what prompted the check:
hir_val_operand() was gated strictly on HIR_LUA_SETI. SETFIELD parks
its value there too, so the liveness walker would not
have seen it and the register could be recycled before
the ECALL read it.
has_side_effects() SETFIELD is a store with no result. DCE deletes it.
That makes seven places a new opcode may need to appear, four of which fail
with no diagnostic: enum, lowering, codegen case, needs_int_reg,
hir_kind_name, hir_val_operand, has_side_effects.
Also the k flag again: OP_LUA_SETFIELD's C is a CONSTANT index when k is
set, exactly as OP_LUA_SETTABI's was, so `t.x=5` declined until it was
handled.
A WRONG ANSWER, caught before merge and worth recording
local t={a=3,b=4} return t.a+t.b answered 8, not 7
HIR_SCONST lives as loc[].addr with in_reg=false. Passing the key through
ra_get_reg returned a register that was never loaded, so a1 held the same
stale address on every call and every field read returned the LAST value
written -- 4+4.
The harness was fully green while that was true: agree_wrong 0, exec_wrong
0, and my own first EXEC case `local t={a=7} return t.a` PASSED, because
with one key "always return the last write" is indistinguishable from
correct. A probe with two distinct keys is what exposed it.
So the EXEC cases here read TWO DISTINCT KEYS deliberately. For any keyed
operation a single-key test proves almost nothing: stale key register, key
ignored, and all-keys-alias are each invisible unless two keys are read back
independently.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:24:11 -06:00
|
|
|
if (tbl < 0) return -1;
|
|
|
|
|
// C is a CONSTANT index, not a register, when k is set -- the
|
|
|
|
|
// same shape that made `t[1]=5` decline on OP_LUA_SETTABI.
|
|
|
|
|
int val;
|
|
|
|
|
if (insn.k()) {
|
|
|
|
|
if (insn.C() < 0
|
|
|
|
|
|| insn.C() >= static_cast<int>(proto->constants.size())) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
const lua_bc_constant &kv = proto->constants[insn.C()];
|
|
|
|
|
if (kv.type != LUA_BC_TINT) return -1;
|
|
|
|
|
val = h.emit_iconst(kv.ival);
|
|
|
|
|
} else {
|
|
|
|
|
val = lua_reg[insn.C()];
|
|
|
|
|
}
|
|
|
|
|
if (val < 0) return -1;
|
2026-03-18 15:54:13 -06:00
|
|
|
int kidx = insn.B();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
const lua_bc_constant &k = proto->constants[kidx];
|
|
|
|
|
if (k.type != LUA_BC_TSHRSTR && k.type != LUA_BC_TLNGSTR)
|
|
|
|
|
return -1;
|
feat(lua/jit): string-keyed table fields on the dedicated-opcode path (#1519)
local t={a=3,b=4} return t.a+t.b -> 7 run_ok, no fallback
local t={} t.x=5 t.y=6 return t.x*10+t.y -> 56
Decline count 30 -> 29.
The key travels as an ADDRESS into the program's own string pool, never as
marshalled text, so nothing downstream can mistake it for a value. Only the
integer result comes back, in a register; a non-integer field declines
inside the handler rather than guessing a marshalling.
Two more registration points, both silent if missed, and both found by
READING rather than by debugging -- hir.h's comment about val[] operands is
what prompted the check:
hir_val_operand() was gated strictly on HIR_LUA_SETI. SETFIELD parks
its value there too, so the liveness walker would not
have seen it and the register could be recycled before
the ECALL read it.
has_side_effects() SETFIELD is a store with no result. DCE deletes it.
That makes seven places a new opcode may need to appear, four of which fail
with no diagnostic: enum, lowering, codegen case, needs_int_reg,
hir_kind_name, hir_val_operand, has_side_effects.
Also the k flag again: OP_LUA_SETFIELD's C is a CONSTANT index when k is
set, exactly as OP_LUA_SETTABI's was, so `t.x=5` declined until it was
handled.
A WRONG ANSWER, caught before merge and worth recording
local t={a=3,b=4} return t.a+t.b answered 8, not 7
HIR_SCONST lives as loc[].addr with in_reg=false. Passing the key through
ra_get_reg returned a register that was never loaded, so a1 held the same
stale address on every call and every field read returned the LAST value
written -- 4+4.
The harness was fully green while that was true: agree_wrong 0, exec_wrong
0, and my own first EXEC case `local t={a=7} return t.a` PASSED, because
with one key "always return the last write" is indistinguishable from
correct. A probe with two distinct keys is what exposed it.
So the EXEC cases here read TWO DISTINCT KEYS deliberately. For any keyed
operation a single-key test proves almost nothing: stale key register, key
ignored, and all-keys-alias are each invisible unless two keys are read back
independently.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:24:11 -06:00
|
|
|
// Integer values only, as for the integer-keyed stores: the
|
|
|
|
|
// ECALL carries the value in a register.
|
|
|
|
|
if (h.ty[val] != TY_INT) return -1;
|
|
|
|
|
if (lua_is_handle(h, val)) return -1;
|
|
|
|
|
if (!lua_is_handle(h, tbl)) return -1;
|
|
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Same rerun-safety rule as SETTABI (#1732).
|
|
|
|
|
if (proto_has_loop
|
|
|
|
|
&& lua_referent_of(lua_ref, tbl).fields_are_refs) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 15:54:13 -06:00
|
|
|
uint64_t key_addr = rc.pool_str(k.sval.c_str(), k.sval.size());
|
|
|
|
|
int key_val = h.emit_sconst(key_addr, k.sval);
|
|
|
|
|
if (key_val < 0) return -1;
|
feat(lua/jit): string-keyed table fields on the dedicated-opcode path (#1519)
local t={a=3,b=4} return t.a+t.b -> 7 run_ok, no fallback
local t={} t.x=5 t.y=6 return t.x*10+t.y -> 56
Decline count 30 -> 29.
The key travels as an ADDRESS into the program's own string pool, never as
marshalled text, so nothing downstream can mistake it for a value. Only the
integer result comes back, in a register; a non-integer field declines
inside the handler rather than guessing a marshalling.
Two more registration points, both silent if missed, and both found by
READING rather than by debugging -- hir.h's comment about val[] operands is
what prompted the check:
hir_val_operand() was gated strictly on HIR_LUA_SETI. SETFIELD parks
its value there too, so the liveness walker would not
have seen it and the register could be recycled before
the ECALL read it.
has_side_effects() SETFIELD is a store with no result. DCE deletes it.
That makes seven places a new opcode may need to appear, four of which fail
with no diagnostic: enum, lowering, codegen case, needs_int_reg,
hir_kind_name, hir_val_operand, has_side_effects.
Also the k flag again: OP_LUA_SETFIELD's C is a CONSTANT index when k is
set, exactly as OP_LUA_SETTABI's was, so `t.x=5` declined until it was
handled.
A WRONG ANSWER, caught before merge and worth recording
local t={a=3,b=4} return t.a+t.b answered 8, not 7
HIR_SCONST lives as loc[].addr with in_reg=false. Passing the key through
ra_get_reg returned a register that was never loaded, so a1 held the same
stale address on every call and every field read returned the LAST value
written -- 4+4.
The harness was fully green while that was true: agree_wrong 0, exec_wrong
0, and my own first EXEC case `local t={a=7} return t.a` PASSED, because
with one key "always return the last write" is indistinguishable from
correct. A probe with two distinct keys is what exposed it.
So the EXEC cases here read TWO DISTINCT KEYS deliberately. For any keyed
operation a single-key test proves almost nothing: stale key register, key
ignored, and all-keys-alias are each invisible unless two keys are read back
independently.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:24:11 -06:00
|
|
|
// Value rides in val[]; hir_val_operand() knows about SETFIELD
|
|
|
|
|
// as well as SETI, which is what keeps the register alive.
|
|
|
|
|
if (h.emit(HIR_LUA_SETFIELD, TY_VOID, tbl, key_val, val) < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-03-18 15:54:13 -06:00
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
// ---- Immediate arithmetic ----
|
|
|
|
|
|
|
|
|
|
case OP_LUA_ADDI: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
// promote_to_int already refuses CALL_STR; guard the float/int
|
|
|
|
|
// arms too so a marshalled result cannot fall into ITOF/ADD.
|
|
|
|
|
if (lua_is_marshalled_str(h, rb)) return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
if (h.ty[rb] == TY_FLOAT) {
|
|
|
|
|
int imm_val = h.emit_fconst(static_cast<double>(insn.sC()));
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FADD, TY_FLOAT, rb, imm_val);
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sC());
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_ADD, TY_INT, rb, imm_val);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING) {
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sC());
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_ADD, TY_INT, rb, imm_val);
|
2026-03-18 14:53:23 -06:00
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Constant arithmetic ----
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
#define ARITH_RK(HIR_INT_OP, HIR_FP_OP) \
|
2026-03-18 09:59:36 -06:00
|
|
|
{ \
|
|
|
|
|
int rb = lua_reg[insn.B()]; \
|
|
|
|
|
if (rb < 0) return -1; \
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
if (lua_is_handle(h, rb)) return -1; \
|
2026-03-18 09:59:36 -06:00
|
|
|
int kidx = insn.C(); \
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size())) \
|
|
|
|
|
return -1; \
|
|
|
|
|
int kval = emit_lua_constant(h, rc, proto->constants[kidx]); \
|
|
|
|
|
if (kval < 0) return -1; \
|
2026-03-18 14:53:23 -06:00
|
|
|
if (either_float(h, rb, kval)) { \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb); \
|
|
|
|
|
kval = promote_to_float(h, truth_tag, kval); \
|
2026-03-18 14:53:23 -06:00
|
|
|
if (rb < 0 || kval < 0) return -1; \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FP_OP, TY_FLOAT, rb, kval); \
|
|
|
|
|
} else if (h.ty[rb] == TY_INT && h.ty[kval] == TY_INT) { \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_INT_OP, TY_INT, rb, kval); \
|
|
|
|
|
} else { \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb); \
|
|
|
|
|
kval = promote_to_int(h, truth_tag, kval); \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0 || kval < 0) return -1; \
|
|
|
|
|
lua_reg[A] = h.emit(HIR_INT_OP, TY_INT, rb, kval); \
|
2026-03-18 14:53:23 -06:00
|
|
|
} \
|
2026-03-18 09:59:36 -06:00
|
|
|
if (lua_reg[A] < 0) return -1; \
|
|
|
|
|
h.native_ops++; \
|
|
|
|
|
break; \
|
|
|
|
|
}
|
|
|
|
|
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
case OP_LUA_ADDK: ARITH_RK(HIR_ADD, HIR_FADD)
|
|
|
|
|
case OP_LUA_SUBK: ARITH_RK(HIR_SUB, HIR_FSUB)
|
|
|
|
|
case OP_LUA_MULK: ARITH_RK(HIR_MUL, HIR_FMUL)
|
|
|
|
|
case OP_LUA_IDIVK: ARITH_RK(HIR_DIV, HIR_DIV)
|
|
|
|
|
case OP_LUA_MODK: ARITH_RK(HIR_REM, HIR_REM)
|
2026-03-18 09:59:36 -06:00
|
|
|
#undef ARITH_RK
|
|
|
|
|
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
// DIVK: Lua `/` with constant — always float.
|
|
|
|
|
case OP_LUA_DIVK: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
int kidx = insn.C();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
int kval = emit_lua_constant(h, rc, proto->constants[kidx]);
|
|
|
|
|
if (kval < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb);
|
|
|
|
|
kval = promote_to_float(h, truth_tag, kval);
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
if (rb < 0 || kval < 0) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FDIV, TY_FLOAT, rb, kval);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:54:40 +00:00
|
|
|
// POWK: exponentiation with constant K — always float. Same
|
|
|
|
|
// native FCALL2 as OP_POW (#1538); do not string-bridge.
|
docs(lua/jit): Lua ^ must not mirror softcode's IEEE domain guard (#1556)
#1556 asked which of two things is right: mirror softcode POWER's
HAVE_IEEE_FP_SNAN guard in the Lua lowering, or document why Lua does not
need it. It is the second, and mirroring would introduce a bug.
Softcode POWER declines the native FCALL2 path when the macro is undefined
because fun_power has a MUX *output convention* on those builds: a negative
base yields the literal string "Ind" rather than whatever the FP library
produces. That is softcode's answer format, not trap avoidance.
Lua has no such convention. luai_numpow (lua54/llimits.h) is
`(b == 2) ? a*a : pow(a, b)` on every platform, so the Lua interpreter
calls pow() directly whether or not the macro is defined. Mirroring the
guard would make the compiled path diverge from the Lua interpreter --
exactly backwards from what the guard achieves for softcode.
Measured on a build with HAVE_IEEE_FP_SNAN forced off in autoconf.h, no
JIT involved:
power(-2,0.5) softcode Ind
(-2) ^ 0.5 Lua interp nan
The two languages disagree by design, and the compiled path has to follow
Lua. Comment only; no behaviour change.
Also found while measuring this, reported separately rather than fixed
here: `^` never compiles at all today, so the FCALL2 path #1556 is about
is currently unreachable and the asymmetry is latent. tier2_lazy_init()
is called only from jit_eval(), the softcode entry point; nothing on the
Lua path loads the blob, so tier2_sym_addr("pow") returns 0 and both
OP_LUA_POW and OP_LUA_POWK take their `if (!addr) return -1` decline. The
configuration needed to run the Lua JIT at all is jit_eval_brackets 0,
which is precisely the setting that keeps brackets out of jit_eval.
Confirmed by calling the loader from the Lua path as a diagnostic: `^`
then compiles and runs (lua_compile_ok and lua_run_ok both move) and
returns 729 where the interpreter returns 729.0 -- i.e. it works
numerically and lands straight on #1488. That is why the loader hook is
NOT included here: on its own it would convert a decline into a
float-ness regression. #1488 first.
make test passes, both smoke routes 1542/1542.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:28:51 -06:00
|
|
|
// Same reasoning as OP_LUA_POW above: no HAVE_IEEE_FP_SNAN guard,
|
|
|
|
|
// because Lua's interpreter has no "Ind" convention to match (#1556).
|
2026-03-18 15:54:13 -06:00
|
|
|
case OP_LUA_POWK: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
int kidx = insn.C();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
int kval = emit_lua_constant(h, rc, proto->constants[kidx]);
|
|
|
|
|
if (kval < 0) return -1;
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb);
|
|
|
|
|
kval = promote_to_float(h, truth_tag, kval);
|
2026-03-18 15:54:13 -06:00
|
|
|
if (rb < 0 || kval < 0) return -1;
|
2026-07-27 12:54:40 +00:00
|
|
|
uint64_t addr = tier2_sym_addr("pow");
|
|
|
|
|
if (!addr) return -1;
|
|
|
|
|
lua_reg[A] = h.emit(HIR_FCALL2, TY_FLOAT, rb, kval,
|
|
|
|
|
static_cast<int64_t>(addr));
|
2026-03-18 15:54:13 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
2026-07-27 12:54:40 +00:00
|
|
|
h.func_idx[lua_reg[A]] = FMATH_POW;
|
|
|
|
|
h.native_ops++;
|
2026-03-18 15:54:13 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
// ---- Comparisons ----
|
|
|
|
|
// All share: compare → optional negate → JMP → BRC
|
|
|
|
|
|
2026-07-29 08:08:14 -06:00
|
|
|
// CMP_RR: == / order on a marshalled CALL_STR result compares text, not
|
|
|
|
|
// the Lua value (0 == "0", false == "0", ...). Refuse those (#1764).
|
2026-07-29 13:22:14 -06:00
|
|
|
// Stack handles (CALL_VAL and any TY_LUA_HANDLE) use the VM for EQ only
|
|
|
|
|
// (HIR_LUA_EQ); order comparisons still decline.
|
2026-07-31 07:12:16 -06:00
|
|
|
//
|
|
|
|
|
// #1835: EQK/EQI already type-class gate and encode NIL/BOOL kinds; the
|
|
|
|
|
// register-register path did neither — mixed types ATOI-coerced to true,
|
|
|
|
|
// and a NIL-tagged empty SCONST was sent as kind 1 (string "").
|
|
|
|
|
//
|
2026-03-18 14:53:23 -06:00
|
|
|
#define CMP_RR(HIR_INT_OP, HIR_FP_OP) \
|
2026-03-18 09:59:36 -06:00
|
|
|
{ \
|
|
|
|
|
int rb = lua_reg[A]; \
|
|
|
|
|
int rc_val = lua_reg[insn.B()]; \
|
|
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
2026-07-29 13:22:14 -06:00
|
|
|
if (lua_is_handle(h, rb) || lua_is_handle(h, rc_val)) { \
|
|
|
|
|
if ((HIR_INT_OP) != HIR_EQ) return -1; \
|
|
|
|
|
int lhs = rb, rhs = rc_val; \
|
|
|
|
|
int kind = 2; \
|
|
|
|
|
if (lua_is_handle(h, rb) && lua_is_handle(h, rc_val)) { \
|
|
|
|
|
kind = 2; \
|
|
|
|
|
} else if (lua_is_handle(h, rb)) { \
|
2026-07-31 07:12:16 -06:00
|
|
|
lhs = rb; \
|
|
|
|
|
rhs = lua_eq_kind_of_value(h, truth_tag, rc_val, &kind); \
|
|
|
|
|
if (rhs < 0) return -1; \
|
2026-07-29 13:22:14 -06:00
|
|
|
} else if (lua_is_handle(h, rc_val)) { \
|
2026-07-31 07:12:16 -06:00
|
|
|
lhs = rc_val; \
|
|
|
|
|
rhs = lua_eq_kind_of_value(h, truth_tag, rb, &kind); \
|
|
|
|
|
if (rhs < 0) return -1; \
|
2026-07-29 13:22:14 -06:00
|
|
|
} else { \
|
|
|
|
|
return -1; \
|
|
|
|
|
} \
|
|
|
|
|
int cmp = h.emit(HIR_LUA_EQ, TY_INT, lhs, rhs, kind); \
|
|
|
|
|
if (cmp < 0) return -1; \
|
|
|
|
|
h.ecalls++; \
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block, \
|
|
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0) \
|
|
|
|
|
return -1; \
|
|
|
|
|
pc++; \
|
|
|
|
|
break; \
|
|
|
|
|
} \
|
2026-07-29 08:08:14 -06:00
|
|
|
if (lua_is_marshalled_str(h, rb) || lua_is_marshalled_str(h, rc_val)) \
|
|
|
|
|
return -1; \
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
if (lua_is_mux_sentinel(h, rb) || lua_is_mux_sentinel(h, rc_val)) \
|
|
|
|
|
return -1; \
|
2026-07-31 07:12:16 -06:00
|
|
|
/* Type-class gate (#1835 / #1770): Lua == is false across */ \
|
|
|
|
|
/* types; order raises on mixed types. HIR erases BOOL/0 and */ \
|
|
|
|
|
/* NIL/"" and the old promote_to_int path made "5"==5 true. */ \
|
|
|
|
|
const lua_type_class ltc = \
|
|
|
|
|
lua_type_class_of_value(h, truth_tag, rb); \
|
|
|
|
|
const lua_type_class rtc = \
|
|
|
|
|
lua_type_class_of_value(h, truth_tag, rc_val); \
|
|
|
|
|
if (ltc == LUA_TC_OTHER || rtc == LUA_TC_OTHER) { \
|
|
|
|
|
return -1; \
|
|
|
|
|
} \
|
|
|
|
|
if ((HIR_INT_OP) == HIR_EQ) { \
|
|
|
|
|
if (ltc != rtc || ltc == LUA_TC_NIL) { \
|
|
|
|
|
int cmp = h.emit_iconst( \
|
|
|
|
|
(ltc == LUA_TC_NIL && rtc == LUA_TC_NIL) ? 1 : 0); \
|
|
|
|
|
if (cmp < 0) return -1; \
|
|
|
|
|
h.native_ops++; \
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, \
|
|
|
|
|
pc_to_block, cur_hir_block, n, \
|
|
|
|
|
lua_reg, multi_block) < 0) \
|
|
|
|
|
return -1; \
|
|
|
|
|
pc++; \
|
|
|
|
|
break; \
|
|
|
|
|
} \
|
|
|
|
|
} else { \
|
|
|
|
|
/* Order: interpreter raises on mixed / non-orderable. */ \
|
|
|
|
|
if (ltc != rtc) return -1; \
|
|
|
|
|
if (ltc != LUA_TC_NUMBER && ltc != LUA_TC_STRING) return -1; \
|
|
|
|
|
} \
|
2026-03-18 14:53:23 -06:00
|
|
|
int cmp; \
|
|
|
|
|
if (either_float(h, rb, rc_val)) { \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb); \
|
|
|
|
|
rc_val = promote_to_float(h, truth_tag, rc_val); \
|
2026-03-18 14:53:23 -06:00
|
|
|
if (rb < 0 || rc_val < 0) return -1; \
|
|
|
|
|
cmp = h.emit(HIR_FP_OP, TY_INT, rb, rc_val); \
|
|
|
|
|
} else if (h.ty[rb] == TY_INT && h.ty[rc_val] == TY_INT) { \
|
|
|
|
|
cmp = h.emit(HIR_INT_OP, TY_INT, rb, rc_val); \
|
2026-03-21 17:03:27 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING && h.ty[rc_val] == TY_STRING) { \
|
|
|
|
|
int sc = h.emit(HIR_STRCMP, TY_INT, rb, rc_val); \
|
|
|
|
|
if (sc < 0) return -1; \
|
|
|
|
|
int zero = h.emit_iconst(0); \
|
|
|
|
|
cmp = h.emit(HIR_INT_OP, TY_INT, sc, zero); \
|
2026-03-18 14:53:23 -06:00
|
|
|
} else { \
|
2026-07-31 07:12:16 -06:00
|
|
|
/* Same type class but HIR types differ (e.g. int vs float */ \
|
|
|
|
|
/* already handled). Decline rather than cross-coerce. */ \
|
|
|
|
|
return -1; \
|
2026-03-18 14:53:23 -06:00
|
|
|
} \
|
2026-03-18 09:59:36 -06:00
|
|
|
h.native_ops++; \
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block, \
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0) \
|
|
|
|
|
return -1; \
|
2026-03-18 09:59:36 -06:00
|
|
|
pc++; \
|
|
|
|
|
break; \
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
#define CMP_RI(HIR_INT_OP, HIR_FP_OP) \
|
2026-03-18 09:59:36 -06:00
|
|
|
{ \
|
|
|
|
|
int rb = lua_reg[A]; \
|
|
|
|
|
if (rb < 0) return -1; \
|
feat(jit): give Lua a handle type so VM references stop passing as values (#1579)
The HIR type lattice names where a value lives -- integer register, FP
register, guest memory -- which is the right shape for MUSHCode, where
everything is semantically a string. Lua is typed, and forcing it through a
representation lattice is what let a global holding the integer 22 and a
table at stack index 22 come back byte-identical: both were TY_STRING. `#t`
answering 22 was that, not a length bug (#1424).
TY_LUA_HANDLE carries both facts. Representationally it is still a string
buffer, so codegen needs no new machinery -- one line in
needs_output_buffer() and a name in the dumper. Semantically it is opaque:
the eight bridge calls that return a VM reference (__lua_getglobal,
__lua_getfield, __lua_geti, __lua_newtable, __lua_call, __lua_get_result)
now produce it, and arithmetic, bit ops, comparison, length, concatenation
and returning all reject it.
hir_lower.cpp is untouched, as predicted: MUSHCode never produces a handle.
Measured, interpreter as oracle, per-chunk disposition from jitstats:
chunk before after
local t={1,2,3} return #t bailed declined
local t={1,2,3} table.insert(t,4) ... bailed declined
local t={1,2,3} return #t + 1 bailed declined
local t={1,2,3} local n=#t return n*2 bailed declined
local x=math.floor(3.7) return x bailed declined
return math.huge bailed declined
local t={a=7} return t.a declined declined
local t={10,20,30} return t[2] bailed bailed
Six of eight move from a run-time bail to a lowering-time decline, answers
unchanged and still correct. `t[2]` is untouched because it routes through
ECALL_LUA_GETI_INT, one of the two bridge ECALLs that is actually live, and
returns a value rather than a reference -- so it is correctly not a handle.
Worth being plain about what this does and does not buy today. It is not a
correctness fix: #1518 already prevents the same wrong answers by failing
closed on unimplemented bridge names, and `#t` returning 22 is not currently
reproducible. What it buys is that the rejection stops being incidental.
#1518's protection holds only while the bridge names stay unmatched, and
evaporates the moment #1519 implements them -- at which point the ambiguity
is unanswerable, because a stack index and an integer are the same bytes.
This makes the rejection a property of the type instead of an accident of
what is unimplemented, which is what stops #1519 from re-introducing the
class as it lands.
make test green: smoke 1559/1559 both routes, tests/luajit PASSED with
agree_wrong 0, exec_wrong 0, exec_no_run 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:26 -06:00
|
|
|
if (lua_is_handle(h, rb)) return -1; \
|
fix(lua/jit): audit nil's consumers, not just its producers (#1766 review)
The closed-key-set analysis is sound and the producers are right, but
lowering a known-absent read to nil makes nil a value that FLOWS -- and
the consumers had not been audited. Measured on the first cut, all
silent wrong answers where master had been loudly declining:
"a" .. t[2] jit "a" interp raises "concatenate a nil value"
#t[2] jit 0 interp raises "length of a nil value"
tostring(t[2]) jit "" interp "nil"
t[2] == "" jit 1 interp 0 (found while fixing)
nil is representable in HIR only as the empty SCONST, which is also a
real "" -- the truth tag is the only thing that tells them apart, so
every operation Lua rejects on nil must consult it. One predicate
(lua_is_nil, mirroring lua_is_marshalled_str) applied at CONCAT, LEN,
the call-argument walk, and both comparison macros; promote_to_int and
promote_to_float already had it.
Five AGREE pins, one per shape above plus concat-through-a-local, so
the class cannot regress silently. Budget 10 -> 15: nil has no
compiled consumer yet, and every one of those five is a decline by
design until it does.
This is the fourth PR in the campaign where emptying a loud bin
introduced a silent wrong answer (#1755, #1756, #1763, this). The
pattern is worth naming: a new VALUE CLASS needs a consumer audit, not
just correct producers.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:56:20 -06:00
|
|
|
if (lua_is_nil(h, truth_tag, rb)) return -1; \
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
if (lua_is_mux_sentinel(h, rb)) return -1; \
|
2026-03-18 14:53:23 -06:00
|
|
|
int cmp; \
|
|
|
|
|
if (h.ty[rb] == TY_FLOAT) { \
|
|
|
|
|
int fimm = h.emit_fconst(static_cast<double>(insn.sB())); \
|
|
|
|
|
if (fimm < 0) return -1; \
|
|
|
|
|
cmp = h.emit(HIR_FP_OP, TY_INT, rb, fimm); \
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) { \
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sB()); \
|
|
|
|
|
if (imm_val < 0) return -1; \
|
|
|
|
|
cmp = h.emit(HIR_INT_OP, TY_INT, rb, imm_val); \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING) { \
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb); \
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1; \
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sB()); \
|
|
|
|
|
if (imm_val < 0) return -1; \
|
|
|
|
|
cmp = h.emit(HIR_INT_OP, TY_INT, rb, imm_val); \
|
2026-03-18 14:53:23 -06:00
|
|
|
} else { \
|
|
|
|
|
return -1; \
|
|
|
|
|
} \
|
2026-03-18 09:59:36 -06:00
|
|
|
h.native_ops++; \
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block, \
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0) \
|
|
|
|
|
return -1; \
|
2026-03-18 09:59:36 -06:00
|
|
|
pc++; \
|
|
|
|
|
break; \
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:53:23 -06:00
|
|
|
case OP_LUA_EQ: CMP_RR(HIR_EQ, HIR_FEQ)
|
|
|
|
|
case OP_LUA_LT: CMP_RR(HIR_LT, HIR_FLT)
|
|
|
|
|
case OP_LUA_LE: CMP_RR(HIR_LE, HIR_FLE)
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
|
|
|
|
|
// EQK: equality with constant from pool.
|
2026-07-29 13:22:14 -06:00
|
|
|
//
|
|
|
|
|
// Lua's condjump always pairs EQK with a following JMP (same shape
|
|
|
|
|
// as EQ/EQI). Value materialisation is EQK+JMP+LFALSESKIP+LOADTRUE
|
|
|
|
|
// and is fused via emit_cmp_branch / lua_bool_fuse_at. Older code
|
|
|
|
|
// treated EQK as a bare one-instruction skip to pc+1/pc+2 and
|
|
|
|
|
// declined the fuse shape, so `return x == "0"` never compiled
|
|
|
|
|
// once x was a CALL_VAL handle (#1764 residual).
|
|
|
|
|
//
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
case OP_LUA_EQK: {
|
|
|
|
|
int rb = lua_reg[A];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
// See CMP_RR: marshalled CALL_STR is text, not a Lua value (#1764).
|
|
|
|
|
if (lua_is_marshalled_str(h, rb)) return -1;
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
int kidx = insn.B();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
const lua_bc_constant &kconst = proto->constants[kidx];
|
2026-07-29 13:22:14 -06:00
|
|
|
// Stack-handle left-hand side (CALL_VAL etc.): ask the VM so type
|
|
|
|
|
// distinctions survive (tostring(0) == "0" true; tonumber("17")
|
|
|
|
|
// == "17" false). Match on TY_LUA_HANDLE, not kind==CALL_VAL:
|
|
|
|
|
// SSA copies / reloads keep the type but not the producer kind.
|
|
|
|
|
//
|
|
|
|
|
if (lua_is_handle(h, rb)) {
|
|
|
|
|
int kind = -1;
|
|
|
|
|
int rhs = -1;
|
|
|
|
|
if (kconst.type == LUA_BC_TNIL) {
|
|
|
|
|
kind = 3;
|
|
|
|
|
rhs = h.emit_iconst(0);
|
|
|
|
|
} else if (kconst.type == LUA_BC_TFALSE) {
|
|
|
|
|
kind = 4;
|
|
|
|
|
rhs = h.emit_iconst(0);
|
|
|
|
|
} else if (kconst.type == LUA_BC_TTRUE) {
|
|
|
|
|
kind = 4;
|
|
|
|
|
rhs = h.emit_iconst(1);
|
|
|
|
|
} else if (kconst.type == LUA_BC_TINT) {
|
|
|
|
|
kind = 0;
|
|
|
|
|
rhs = h.emit_iconst(kconst.ival);
|
|
|
|
|
} else if (kconst.type == LUA_BC_TFLOAT) {
|
|
|
|
|
// Float constant kind not wired for HIR_LUA_EQ yet.
|
|
|
|
|
return -1;
|
|
|
|
|
} else if ( kconst.type == LUA_BC_TSHRSTR
|
|
|
|
|
|| kconst.type == LUA_BC_TLNGSTR) {
|
|
|
|
|
kind = 1;
|
|
|
|
|
rhs = emit_lua_constant(h, rc, kconst);
|
|
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
if (rhs < 0) return -1;
|
|
|
|
|
int cmp = h.emit(HIR_LUA_EQ, TY_INT, rb, rhs, kind);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
// emit_cmp_branch owns k-bit polarity (EQ convention) and
|
|
|
|
|
// the LFALSESKIP/LOADTRUE fuse for `return x == K`.
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
|
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0)
|
|
|
|
|
return -1;
|
|
|
|
|
pc++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-07-29 12:22:12 -06:00
|
|
|
// nil is the empty SCONST in HIR, which is also a real "".
|
|
|
|
|
// EQK's string path would make nil == "" true. Resolve against
|
|
|
|
|
// the pool type (and the lhs tag) before emitting STRCMP:
|
|
|
|
|
// nil == nil → true
|
|
|
|
|
// nil == anything else (incl. "") → false
|
|
|
|
|
// Same for false vs integer 0 once BOOL is tagged on constants.
|
fix(lua/jit): equality compares Lua TYPES, not representations (#1770 review)
The EQK nil fix is right, and its lesson generalizes further than it was
applied. Lua's == is false across TYPES, and HIR erases every
distinction that decides it: false and 0 are the same ICONST, nil and ""
the same empty SCONST, and the numeric path coerces "5" to 5. Measured
on the PR as submitted -- all executing, all silently wrong:
local a=0 if a == false jit "y" interp "n"
local a=1 if a == true jit "y" interp "n"
local a=false if a == 0 jit "y" interp "n"
local a="5" if a == 5 jit "y" interp "n"
local a=5 if a == "5" jit "y" interp "n"
Generalizes the author's nil branch into a type-class test
(lua_type_class_of_value / _of_const, from the truth tag plus the HIR
type): mismatched classes are constant false whatever the
representations say; nil == nil stays true.
EQI needed the same treatment and is now its own case rather than
sharing CMP_RI: `a == 0` lowers to EQI and `a == ""` to EQK, so fixing
either alone leaves the other wrong -- the opcode-twin trap this PR's
own doc note names, one level deeper. Order comparisons keep declining
instead: Lua RAISES on mismatched types there, so answering false would
be its own wrong answer.
Five AGREE pins across both opcode forms; all EXECUTE and answer like
the interpreter. Plan gains the rule as item 5.
Also records the softcode no-post-entry-decline assumption that #1767's
exactly-once argument rests on -- derived today from the #1002 pre-entry
watermarks plus Phase 4 leaving one defensive ECALL_DECLINE that nothing
emits into, and therefore worth writing where it can be checked.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:33 -06:00
|
|
|
// Lua == is false across TYPES, and HIR erases the ones that
|
|
|
|
|
// matter: false and 0 are the same ICONST, nil and "" the same
|
|
|
|
|
// empty SCONST, and the numeric path would coerce "5" to 5.
|
|
|
|
|
// So compare type classes first; a mismatch is constant false
|
|
|
|
|
// whatever the representations say. (The nil-only version of
|
|
|
|
|
// this check still let `0 == false`, `1 == true` and
|
|
|
|
|
// `"5" == 5` answer true -- measured.)
|
|
|
|
|
const lua_type_class lhs_tc =
|
|
|
|
|
lua_type_class_of_value(h, truth_tag, rb);
|
|
|
|
|
const lua_type_class rhs_tc = lua_type_class_of_const(kconst);
|
|
|
|
|
if (lhs_tc == LUA_TC_OTHER || rhs_tc == LUA_TC_OTHER) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
const bool lhs_nil = (lhs_tc == LUA_TC_NIL);
|
|
|
|
|
const bool rhs_nil = (rhs_tc == LUA_TC_NIL);
|
|
|
|
|
if (lhs_tc != rhs_tc || lhs_nil) {
|
|
|
|
|
// Different types -> false. Same type and both nil ->
|
|
|
|
|
// true (nil has exactly one value).
|
2026-07-29 12:22:12 -06:00
|
|
|
int cmp = h.emit_iconst((lhs_nil && rhs_nil) ? 1 : 0);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
2026-07-29 13:22:14 -06:00
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
|
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0)
|
|
|
|
|
return -1;
|
|
|
|
|
pc++;
|
2026-07-29 12:22:12 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
int kval = emit_lua_constant(h, rc, kconst);
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
if (kval < 0) return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
lua_truth_tag_constant(truth_tag, kconst, kval);
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
int cmp;
|
|
|
|
|
if (either_float(h, rb, kval)) {
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_float(h, truth_tag, rb);
|
|
|
|
|
kval = promote_to_float(h, truth_tag, kval);
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
if (rb < 0 || kval < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_FEQ, TY_INT, rb, kval);
|
|
|
|
|
} else if (h.ty[rb] == TY_INT && h.ty[kval] == TY_INT) {
|
|
|
|
|
cmp = h.emit(HIR_EQ, TY_INT, rb, kval);
|
2026-03-21 17:03:27 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING && h.ty[kval] == TY_STRING) {
|
|
|
|
|
int sc = h.emit(HIR_STRCMP, TY_INT, rb, kval);
|
|
|
|
|
if (sc < 0) return -1;
|
|
|
|
|
int zero = h.emit_iconst(0);
|
|
|
|
|
cmp = h.emit(HIR_EQ, TY_INT, sc, zero);
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
} else {
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
|
|
|
|
kval = promote_to_int(h, truth_tag, kval);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0 || kval < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_EQ, TY_INT, rb, kval);
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
}
|
2026-07-29 13:22:14 -06:00
|
|
|
if (cmp < 0) return -1;
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
h.native_ops++;
|
2026-07-29 13:22:14 -06:00
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
|
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0)
|
|
|
|
|
return -1;
|
|
|
|
|
pc++;
|
Add bitwise ops, constant-arithmetic variants, and EQK to Lua JIT
Bitwise (6 opcodes, all native RV64):
- BAND/BOR/BXOR → HIR_BAND/BOR/BXOR → AND/OR/XOR
- BNOT → HIR_BNOT → XORI rd, rs, -1
- SHL/SHR → HIR_SHL/SHR → SLL/SRL
- SHRI/SHLI (shift by immediate)
- BANDK/BORK/BXORK (bitwise with constant from pool)
Constant-arithmetic variants (3 opcodes):
- DIVK: float division with constant → HIR_FDIV
- IDIVK: integer division with constant → HIR_DIV
- MODK: modulo with constant → HIR_REM
Comparison:
- EQK: equality with constant from pool, with float promotion
6 new HIR instructions: HIR_BAND, HIR_BOR, HIR_BXOR, HIR_BNOT,
HIR_SHL, HIR_SHR. 547/547 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:46:00 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): equality compares Lua TYPES, not representations (#1770 review)
The EQK nil fix is right, and its lesson generalizes further than it was
applied. Lua's == is false across TYPES, and HIR erases every
distinction that decides it: false and 0 are the same ICONST, nil and ""
the same empty SCONST, and the numeric path coerces "5" to 5. Measured
on the PR as submitted -- all executing, all silently wrong:
local a=0 if a == false jit "y" interp "n"
local a=1 if a == true jit "y" interp "n"
local a=false if a == 0 jit "y" interp "n"
local a="5" if a == 5 jit "y" interp "n"
local a=5 if a == "5" jit "y" interp "n"
Generalizes the author's nil branch into a type-class test
(lua_type_class_of_value / _of_const, from the truth tag plus the HIR
type): mismatched classes are constant false whatever the
representations say; nil == nil stays true.
EQI needed the same treatment and is now its own case rather than
sharing CMP_RI: `a == 0` lowers to EQI and `a == ""` to EQK, so fixing
either alone leaves the other wrong -- the opcode-twin trap this PR's
own doc note names, one level deeper. Order comparisons keep declining
instead: Lua RAISES on mismatched types there, so answering false would
be its own wrong answer.
Five AGREE pins across both opcode forms; all EXECUTE and answer like
the interpreter. Plan gains the rule as item 5.
Also records the softcode no-post-entry-decline assumption that #1767's
exactly-once argument rests on -- derived today from the #1002 pre-entry
watermarks plus Phase 4 leaving one defensive ECALL_DECLINE that nothing
emits into, and therefore worth writing where it can be checked.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:33 -06:00
|
|
|
// EQI is equality against a small integer immediate -- a NUMBER.
|
|
|
|
|
// Lua == is false across types, so a non-number lhs answers false
|
|
|
|
|
// rather than coercing (`local a=false if a==0` answered true;
|
|
|
|
|
// `local a="5" if a==5` likewise). Order comparisons (LTI/LEI/
|
|
|
|
|
// GTI/GEI) are different: Lua RAISES on mismatched types, so they
|
|
|
|
|
// keep declining via CMP_RI's guards.
|
|
|
|
|
case OP_LUA_EQI: {
|
|
|
|
|
int rb = lua_reg[A];
|
|
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
if (lua_is_handle(h, rb)) return -1;
|
|
|
|
|
if (lua_is_marshalled_str(h, rb)) return -1;
|
|
|
|
|
const lua_type_class lhs_tc =
|
|
|
|
|
lua_type_class_of_value(h, truth_tag, rb);
|
|
|
|
|
if (lhs_tc == LUA_TC_OTHER) return -1;
|
|
|
|
|
if (lhs_tc != LUA_TC_NUMBER) {
|
|
|
|
|
int cmp = h.emit_iconst(0);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
|
|
|
|
cur_hir_block, n, nullptr,
|
|
|
|
|
multi_block) < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
pc++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
CMP_RI(HIR_EQ, HIR_FEQ)
|
|
|
|
|
}
|
2026-03-18 14:53:23 -06:00
|
|
|
case OP_LUA_LTI: CMP_RI(HIR_LT, HIR_FLT)
|
|
|
|
|
case OP_LUA_LEI: CMP_RI(HIR_LE, HIR_FLE)
|
|
|
|
|
|
|
|
|
|
// For GT/GE with floats, we only have FLT/FLE.
|
|
|
|
|
// GT(a, b) = FLT(b, a), GE(a, b) = FLE(b, a) — swap operands.
|
|
|
|
|
case OP_LUA_GTI: {
|
|
|
|
|
int rb = lua_reg[A];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
// nil has no order; empty SCONST would promote to 0.
|
|
|
|
|
if (lua_is_nil(h, truth_tag, rb)) return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
int cmp;
|
|
|
|
|
if (h.ty[rb] == TY_FLOAT) {
|
|
|
|
|
int fimm = h.emit_fconst(static_cast<double>(insn.sB()));
|
|
|
|
|
if (fimm < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_FLT, TY_INT, fimm, rb); // swapped
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sB());
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_GT, TY_INT, rb, imm_val);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING) {
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sB());
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_GT, TY_INT, rb, imm_val);
|
2026-03-18 14:53:23 -06:00
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0)
|
|
|
|
|
return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
pc++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case OP_LUA_GEI: {
|
|
|
|
|
int rb = lua_reg[A];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 12:22:12 -06:00
|
|
|
if (lua_is_nil(h, truth_tag, rb)) return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
int cmp;
|
|
|
|
|
if (h.ty[rb] == TY_FLOAT) {
|
|
|
|
|
int fimm = h.emit_fconst(static_cast<double>(insn.sB()));
|
|
|
|
|
if (fimm < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_FLE, TY_INT, fimm, rb); // swapped
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sB());
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_GE, TY_INT, rb, imm_val);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
} else if (h.ty[rb] == TY_STRING) {
|
2026-07-29 09:08:10 -06:00
|
|
|
rb = promote_to_int(h, truth_tag, rb);
|
Add Lua JIT runtime type guards: string-to-numeric promotion at all arithmetic/comparison sites
MUX Lua scripts pass arguments as strings via mux.args[N]. When these
TY_STRING values from ECALLs flow into arithmetic or comparison opcodes,
the HIR lowering previously rejected the proto. Now, promote_to_int()
emits HIR_ATOI and promote_to_float() chains ATOI+ITOF to coerce string
operands at use sites, enabling JIT compilation for the most common Lua
pattern: read args, do math, return result.
Sites updated: ARITH_RR, ARITH_RK, ADDI, UNM, CMP_RR, CMP_RI, GTI,
GEI, EQK, BITOP_RR, BITOP_RK, BNOT, SHRI, SHLI.
6 new smoke tests (TC047-TC052) covering string-arg subtraction, add
immediate, negation, comparison, integer division, and modulo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:28:06 -06:00
|
|
|
if (rb < 0) return -1;
|
|
|
|
|
int imm_val = h.emit_iconst(insn.sB());
|
|
|
|
|
if (imm_val < 0) return -1;
|
|
|
|
|
cmp = h.emit(HIR_GE, TY_INT, rb, imm_val);
|
2026-03-18 14:53:23 -06:00
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
cur_hir_block, n, lua_reg, multi_block) < 0)
|
|
|
|
|
return -1;
|
2026-03-18 14:53:23 -06:00
|
|
|
pc++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
#undef CMP_RR
|
|
|
|
|
#undef CMP_RI
|
|
|
|
|
|
|
|
|
|
case OP_LUA_TEST: {
|
|
|
|
|
int rb = lua_reg[A];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
// HIR_BOOL is integer SNEZ / softcode truthiness. A CALL_STR
|
|
|
|
|
// result is type-erased text: "0", "" and integer-0-as-text are
|
|
|
|
|
// all truthy in Lua and falsy under that test (#1764). Decline
|
2026-07-29 12:45:59 -06:00
|
|
|
// rather than lie; CALL_VAL uses the VM's truthiness instead.
|
2026-07-29 08:08:14 -06:00
|
|
|
if (lua_is_marshalled_str(h, rb)) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
int cmp;
|
2026-07-29 12:45:59 -06:00
|
|
|
if (lua_is_handle(h, rb) && h.kind[rb] == HIR_LUA_CALL_VAL) {
|
|
|
|
|
cmp = h.emit(HIR_LUA_TOBOOL, TY_INT, rb);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
} else if (lua_is_handle(h, rb)) {
|
2026-07-29 09:24:24 -06:00
|
|
|
return -1;
|
2026-07-29 12:45:59 -06:00
|
|
|
} else {
|
|
|
|
|
// Lua: only nil and false are falsy. VALUE (0, 0.0, "") is
|
|
|
|
|
// always truthy; BOOL uses HIR_BOOL; NIL is always falsy.
|
|
|
|
|
// UNKNOWN declines — do not invent VALUE (#1768).
|
|
|
|
|
const lua_truth tr = lua_truth_of(h, truth_tag, rb);
|
|
|
|
|
if (tr == LUA_TRUTH_UNKNOWN) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
if (tr == LUA_TRUTH_NIL) {
|
|
|
|
|
cmp = h.emit_iconst(0);
|
|
|
|
|
} else if (tr == LUA_TRUTH_VALUE) {
|
|
|
|
|
cmp = h.emit_iconst(1);
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
if (h.kind[rb] == HIR_ICONST) {
|
|
|
|
|
cmp = h.emit_iconst(h.val[rb] != 0 ? 1 : 0);
|
|
|
|
|
} else {
|
|
|
|
|
cmp = h.emit(HIR_BOOL, TY_INT, rb);
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
}
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
} else {
|
2026-07-29 12:45:59 -06:00
|
|
|
return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (cmp < 0) return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
cur_hir_block, n, nullptr, multi_block) < 0)
|
|
|
|
|
return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
pc++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_TESTSET: {
|
|
|
|
|
int rb = lua_reg[insn.B()];
|
|
|
|
|
if (rb < 0) return -1;
|
2026-07-29 08:08:14 -06:00
|
|
|
if (lua_is_marshalled_str(h, rb)) return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
int cmp;
|
2026-07-29 12:45:59 -06:00
|
|
|
if (lua_is_handle(h, rb) && h.kind[rb] == HIR_LUA_CALL_VAL) {
|
|
|
|
|
cmp = h.emit(HIR_LUA_TOBOOL, TY_INT, rb);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
} else if (lua_is_handle(h, rb)) {
|
2026-07-29 09:24:24 -06:00
|
|
|
return -1;
|
2026-07-29 12:45:59 -06:00
|
|
|
} else {
|
|
|
|
|
const lua_truth tr = lua_truth_of(h, truth_tag, rb);
|
|
|
|
|
if (tr == LUA_TRUTH_UNKNOWN) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
if (tr == LUA_TRUTH_NIL) {
|
|
|
|
|
cmp = h.emit_iconst(0);
|
|
|
|
|
} else if (tr == LUA_TRUTH_VALUE) {
|
|
|
|
|
cmp = h.emit_iconst(1);
|
|
|
|
|
} else if (h.ty[rb] == TY_INT) {
|
|
|
|
|
if (h.kind[rb] == HIR_ICONST) {
|
|
|
|
|
cmp = h.emit_iconst(h.val[rb] != 0 ? 1 : 0);
|
|
|
|
|
} else {
|
|
|
|
|
cmp = h.emit(HIR_BOOL, TY_INT, rb);
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
}
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
} else {
|
2026-07-29 12:45:59 -06:00
|
|
|
return -1;
|
fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.
Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.
EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (cmp < 0) return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
lua_reg[A] = rb; // Simplified: always copy.
|
|
|
|
|
if (emit_cmp_branch(h, cmp, insn.k(), proto, pc, pc_to_block,
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
cur_hir_block, n, nullptr, multi_block) < 0)
|
|
|
|
|
return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
pc++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Control flow ----
|
|
|
|
|
|
|
|
|
|
case OP_LUA_JMP: {
|
|
|
|
|
int target = pc + 1 + insn.sJ();
|
|
|
|
|
if (!multi_block) return -1;
|
|
|
|
|
int target_blk = (target >= 0 && target < n) ? pc_to_block[target] : -1;
|
|
|
|
|
if (target_blk < 0) return -1;
|
2026-03-27 14:17:18 -06:00
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// Back edge (while/repeat): the budget guard, then the jump.
|
|
|
|
|
// The shape this replaces folded exhaustion into an exit to
|
|
|
|
|
// the fall-through -- leaving the loop early and CONTINUING
|
|
|
|
|
// with a wrong partial result, the exact defect the FORLOOP
|
|
|
|
|
// budget had (#1732). The guard aborts to the interpreter
|
|
|
|
|
// instead.
|
2026-03-27 14:17:18 -06:00
|
|
|
if (target <= pc) {
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
if (!emit_backedge_guard(pc - target + 1)) return -1;
|
2026-03-27 14:17:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
h.emit(HIR_BR, TY_VOID, -1, -1, target_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, target_blk);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Numeric for loop ----
|
2026-03-18 12:21:02 -06:00
|
|
|
//
|
|
|
|
|
// Lua 5.4 for-loop registers:
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// R(A) = internal counter (never materialized here)
|
2026-03-18 12:21:02 -06:00
|
|
|
// R(A+1) = limit
|
|
|
|
|
// R(A+2) = step
|
|
|
|
|
// R(A+3) = exposed index (visible in body)
|
|
|
|
|
//
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// 5.4 semantics, not 5.3's: FORPREP sets R(A+3)=init and FALLS
|
|
|
|
|
// INTO the body (jumping forward past FORLOOP only when the trip
|
|
|
|
|
// count is zero); FORLOOP steps the index and jumps BACK on
|
|
|
|
|
// continue. The 5.3-shaped lowering this replaces (init-step
|
|
|
|
|
// pre-subtraction, sBx offsets) was written against the wrong VM
|
|
|
|
|
// and had never executed behind the #1326 reject.
|
2026-03-18 12:21:02 -06:00
|
|
|
//
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// STATIC BOUNDS ONLY in this first cut: init, limit and step must
|
|
|
|
|
// all be integer constants, so the trip direction, the zero-trip
|
|
|
|
|
// decision, and freedom from wraparound are compile-time facts --
|
|
|
|
|
// 5.4's own counter model exists precisely because a naive
|
|
|
|
|
// idx<=limit test misbehaves at the integer edge, and declining
|
|
|
|
|
// the edge is cheaper than reproducing the counter. The index
|
|
|
|
|
// rides QREG_LUA_IDX so hir_ssa_construct() gives it a real PHI.
|
2026-03-18 09:59:36 -06:00
|
|
|
|
|
|
|
|
case OP_LUA_FORPREP: {
|
|
|
|
|
if (!multi_block) return -1;
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + 3)) return -1;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
int init = lua_reg[A];
|
|
|
|
|
int limit = lua_reg[A + 1];
|
|
|
|
|
int step = lua_reg[A + 2];
|
|
|
|
|
if (init < 0 || limit < 0 || step < 0) return -1;
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
// The STEP must be a constant either way: it fixes the trip
|
|
|
|
|
// direction, and with it which comparison FORLOOP emits.
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// step 0 is a runtime error; the interpreter raises it.
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
if (h.kind[step] != HIR_ICONST) return -1;
|
|
|
|
|
const int64_t vs = h.val[step];
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
if (0 == vs) return -1;
|
|
|
|
|
// Stay far from the int64 edge so idx+step cannot wrap.
|
|
|
|
|
const int64_t kEdge = INT64_C(1) << 62;
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
if (vs > kEdge || vs < -kEdge) return -1;
|
2026-03-21 18:02:17 -06:00
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
int body_target = pc + 1;
|
|
|
|
|
int skip_target = pc + 1 + insn.Bx() + 1;
|
|
|
|
|
int body_blk = (body_target < n) ? pc_to_block[body_target] : -1;
|
|
|
|
|
int skip_blk = (skip_target < n) ? pc_to_block[skip_target] : -1;
|
|
|
|
|
if (body_blk < 0 || skip_blk < 0) return -1;
|
feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers. The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.
What was static becomes branches. The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone. FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.
The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.
EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.
luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
|
|
|
if (A + 3 >= 10) return -1;
|
|
|
|
|
|
|
|
|
|
if (h.kind[init] == HIR_ICONST
|
|
|
|
|
&& h.kind[limit] == HIR_ICONST) {
|
|
|
|
|
// STATIC bounds: trip direction, zero-trip, and freedom
|
|
|
|
|
// from wraparound are compile-time facts, and the entry
|
|
|
|
|
// block stays whole.
|
|
|
|
|
const int64_t vi = h.val[init];
|
|
|
|
|
const int64_t vl = h.val[limit];
|
|
|
|
|
if (vi > kEdge || vi < -kEdge || vl > kEdge
|
|
|
|
|
|| vl < -kEdge) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
// The body's first pass reads the index before any
|
|
|
|
|
// FORLOOP runs, so it must be stored on the entry side.
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, init, -1, QREG_LUA_IDX);
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, init, -1, A + 3);
|
|
|
|
|
const bool zero_trip = (vs > 0) ? (vi > vl) : (vi < vl);
|
|
|
|
|
int target_blk = zero_trip ? skip_blk : body_blk;
|
|
|
|
|
h.emit(HIR_BR, TY_VOID, -1, -1, target_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, target_blk);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RUNTIME bounds (#1732): `for i=1,n`. The zero-trip test
|
|
|
|
|
// and the wraparound guard become branches. A value outside
|
|
|
|
|
// the int64 safety margin bails to the limited block -- a
|
|
|
|
|
// decline, not an error: the interpreter re-runs the chunk
|
|
|
|
|
// with its own counter model, and nothing has executed yet,
|
|
|
|
|
// so the bail is rerun-safe by position alone.
|
|
|
|
|
if (h.ty[init] != TY_INT || lua_is_handle(h, init)) return -1;
|
|
|
|
|
if (h.ty[limit] != TY_INT || lua_is_handle(h, limit)) return -1;
|
|
|
|
|
|
|
|
|
|
// This path SPLITS the entry block, so entry state must be
|
|
|
|
|
// finalized first: seal the backing, and re-snapshot
|
|
|
|
|
// blk_entry_reg so the next transition's drop-compare sees
|
|
|
|
|
// the split blocks as the same logical entry -- its values
|
|
|
|
|
// dominate the body exactly as block 0's do.
|
|
|
|
|
seal_entry_backing();
|
|
|
|
|
memcpy(blk_entry_reg, lua_reg, sizeof(blk_entry_reg));
|
|
|
|
|
|
|
|
|
|
int e_hi = h.emit_iconst(kEdge);
|
|
|
|
|
int e_lo = h.emit_iconst(-kEdge);
|
|
|
|
|
if (e_hi < 0 || e_lo < 0) return -1;
|
|
|
|
|
int b1 = h.emit(HIR_LT, TY_INT, init, e_hi);
|
|
|
|
|
int b2 = h.emit(HIR_GT, TY_INT, init, e_lo);
|
|
|
|
|
int b3 = h.emit(HIR_LT, TY_INT, limit, e_hi);
|
|
|
|
|
int b4 = h.emit(HIR_GT, TY_INT, limit, e_lo);
|
|
|
|
|
int b12 = h.emit(HIR_BAND, TY_INT, b1, b2);
|
|
|
|
|
int b34 = h.emit(HIR_BAND, TY_INT, b3, b4);
|
|
|
|
|
int bounds_ok = h.emit(HIR_BAND, TY_INT, b12, b34);
|
|
|
|
|
if (bounds_ok < 0) return -1;
|
|
|
|
|
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, init, -1, QREG_LUA_IDX);
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, init, -1, A + 3);
|
|
|
|
|
|
|
|
|
|
if (!ensure_limited_blk()) return -1;
|
|
|
|
|
int cont_blk = h.new_block();
|
|
|
|
|
if (cont_blk < 0) return -1;
|
|
|
|
|
h.emit(HIR_BRC, TY_VOID, bounds_ok, limited_blk, cont_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, limited_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, cont_blk);
|
|
|
|
|
h.cur_block = cont_blk;
|
|
|
|
|
cur_hir_block = cont_blk;
|
|
|
|
|
|
|
|
|
|
// Runtime zero-trip: run the body iff init is on the limit's
|
|
|
|
|
// side of the direction the constant step fixes.
|
|
|
|
|
int cond_run = h.emit((vs > 0) ? HIR_LE : HIR_GE,
|
|
|
|
|
TY_INT, init, limit);
|
|
|
|
|
if (cond_run < 0) return -1;
|
|
|
|
|
h.emit(HIR_BRC, TY_VOID, cond_run, skip_blk, body_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, body_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, skip_blk);
|
2026-03-18 09:59:36 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_FORLOOP: {
|
|
|
|
|
if (!multi_block) return -1;
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + 3)) return -1;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Bounds come from entry_final[], the register state frozen
|
|
|
|
|
// at the entry block's exit: lua_reg[] holds LOAD_Q reloads
|
|
|
|
|
// by now, and the static-bounds test below needs to SEE the
|
|
|
|
|
// ICONSTs FORPREP already vetted.
|
|
|
|
|
int step = entry_final[A + 2];
|
|
|
|
|
int limit = entry_final[A + 1];
|
2026-03-18 12:21:02 -06:00
|
|
|
if (step < 0 || limit < 0) return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
|
2026-03-18 12:21:02 -06:00
|
|
|
// Load current index from q-register (becomes PHI after SSA).
|
|
|
|
|
int idx = h.emit(HIR_LOAD_Q, TY_INT, -1, -1, QREG_LUA_IDX);
|
2026-07-29 09:24:24 -06:00
|
|
|
// Loop index is always a number, but the reload has no truth
|
|
|
|
|
// tag by construction — mark UNKNOWN so a future if-on-idx
|
|
|
|
|
// cannot invent VALUE after a lost BOOL (#1768).
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
lua_truth_set(truth_tag, idx, LUA_TRUTH_UNKNOWN);
|
|
|
|
|
}
|
2026-03-18 12:21:02 -06:00
|
|
|
if (idx < 0) return -1;
|
|
|
|
|
|
|
|
|
|
// Increment: index = index + step.
|
2026-03-18 09:59:36 -06:00
|
|
|
int new_idx = h.emit(HIR_ADD, TY_INT, idx, step);
|
|
|
|
|
if (new_idx < 0) return -1;
|
2026-03-18 12:21:02 -06:00
|
|
|
h.native_ops++;
|
|
|
|
|
|
|
|
|
|
// Store updated index back to q-register.
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, new_idx, -1, QREG_LUA_IDX);
|
|
|
|
|
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
// Expose the new index to subsequent instructions and back it
|
|
|
|
|
// in the visible register's own qreg. FORLOOP is the only
|
|
|
|
|
// terminator that writes a register, so the store-at-write
|
|
|
|
|
// hook cannot see this; every reader -- the body, the exit
|
|
|
|
|
// block -- is dominated by this latch, and the first pass
|
|
|
|
|
// reads the STORE_Q FORPREP emitted, so the backing the
|
|
|
|
|
// pre-scan declared is stored on every path (#1732).
|
2026-03-18 09:59:36 -06:00
|
|
|
lua_reg[A + 3] = new_idx;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
if (A + 3 >= 10) return -1;
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, new_idx, -1, A + 3);
|
|
|
|
|
|
|
|
|
|
// Continue test. The step is an ICONST -- FORPREP declined
|
|
|
|
|
// anything else -- so the direction is static; FORPREP's edge
|
|
|
|
|
// bound is what keeps new_idx from wrapping first.
|
|
|
|
|
if (h.kind[step] != HIR_ICONST) return -1;
|
|
|
|
|
int cmp = h.emit((h.val[step] > 0) ? HIR_LE : HIR_GE,
|
|
|
|
|
TY_INT, new_idx, limit);
|
2026-03-18 09:59:36 -06:00
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
h.native_ops++;
|
|
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// Back-edge budget (#1732); see emit_backedge_guard. The
|
|
|
|
|
// body is Bx instructions plus this FORLOOP.
|
|
|
|
|
if (!emit_backedge_guard(insn.Bx() + 1)) return -1;
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
|
|
|
|
|
// Branch: if true, loop back; else fall through. 5.4 encodes
|
|
|
|
|
// the back edge as an unsigned Bx: pc -= Bx.
|
|
|
|
|
int loop_target = pc + 1 - insn.Bx();
|
2026-03-18 09:59:36 -06:00
|
|
|
int exit_target = pc + 1;
|
|
|
|
|
int loop_blk = (loop_target >= 0 && loop_target < n) ? pc_to_block[loop_target] : -1;
|
|
|
|
|
int exit_blk = (exit_target >= 0 && exit_target < n) ? pc_to_block[exit_target] : -1;
|
|
|
|
|
if (loop_blk < 0 || exit_blk < 0) return -1;
|
|
|
|
|
h.emit(HIR_BRC, TY_VOID, cmp, exit_blk, loop_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, loop_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, exit_blk);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Return ----
|
2026-07-25 23:08:40 -06:00
|
|
|
//
|
|
|
|
|
// Lua always appends a trailing OP_RETURN / RETURN0 after an
|
|
|
|
|
// explicit return (and after the last statement of a chunk).
|
|
|
|
|
// That second return is dead once we have already emitted HIR_RET
|
|
|
|
|
// for the real value. Updating result_val from it overwrote a
|
|
|
|
|
// correct "hello" / 42 with an empty SCONST, so folded Lua JIT
|
|
|
|
|
// programs always produced empty strings (#1309).
|
|
|
|
|
//
|
|
|
|
|
// Emit HIR_RET for every opcode (keeps block structure), but only
|
|
|
|
|
// the first return value becomes h.result.
|
2026-03-18 09:59:36 -06:00
|
|
|
|
2026-07-25 23:08:40 -06:00
|
|
|
case OP_LUA_RETURN0: {
|
|
|
|
|
int rv = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (rv < 0) return -1;
|
|
|
|
|
h.emit(HIR_RET, TY_VOID, rv);
|
|
|
|
|
if (result_val < 0) {
|
|
|
|
|
result_val = rv;
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
break;
|
2026-07-25 23:08:40 -06:00
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
|
|
|
|
|
case OP_LUA_RETURN1: {
|
2026-07-25 23:08:40 -06:00
|
|
|
int rv = return_as_string(h, rc, lua_reg[A]);
|
2026-03-18 09:59:36 -06:00
|
|
|
if (rv < 0) return -1;
|
2026-07-25 23:08:40 -06:00
|
|
|
h.emit(HIR_RET, TY_VOID, rv);
|
|
|
|
|
if (result_val < 0) {
|
|
|
|
|
result_val = rv;
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_RETURN: {
|
|
|
|
|
int nret = insn.B() - 1;
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
if (nret < 0) {
|
|
|
|
|
// B == 0 is "return all values from A up" (in-top). The
|
|
|
|
|
// one shape that produces it here is the dead trailing
|
|
|
|
|
// return Lua appends after OP_TAILCALL, whose lowering
|
|
|
|
|
// already emitted the real HIR_RET and claimed result_val;
|
|
|
|
|
// give it RETURN0's shape so the block still terminates.
|
|
|
|
|
// Any other multret return stays declined.
|
|
|
|
|
if (0 == pc
|
|
|
|
|
|| OP_LUA_TAILCALL != proto->code[pc - 1].opcode()) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
int dead = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (dead < 0) return -1;
|
|
|
|
|
h.emit(HIR_RET, TY_VOID, dead);
|
|
|
|
|
if (result_val < 0) {
|
|
|
|
|
result_val = dead;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-07-25 23:08:40 -06:00
|
|
|
int rv;
|
2026-03-18 09:59:36 -06:00
|
|
|
if (nret == 0) {
|
2026-07-25 23:08:40 -06:00
|
|
|
rv = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (rv < 0) return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
} else {
|
2026-07-25 23:08:40 -06:00
|
|
|
rv = return_as_string(h, rc, lua_reg[A]);
|
2026-03-18 09:59:36 -06:00
|
|
|
if (rv < 0) return -1;
|
2026-07-25 23:08:40 -06:00
|
|
|
}
|
|
|
|
|
h.emit(HIR_RET, TY_VOID, rv);
|
|
|
|
|
if (result_val < 0) {
|
2026-03-18 09:59:36 -06:00
|
|
|
result_val = rv;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 16:45:17 -06:00
|
|
|
// ---- Upvalue access ----
|
|
|
|
|
// We reject nested protos, so the only upvalue is _ENV (index 0).
|
|
|
|
|
|
|
|
|
|
case OP_LUA_GETUPVAL: {
|
|
|
|
|
// A = dest, B = upvalue index.
|
|
|
|
|
// For the main chunk, upvalue 0 = _ENV (global table).
|
|
|
|
|
if (insn.B() != 0) return -1; // Non-_ENV upvalue.
|
|
|
|
|
// Push _ENV onto Lua stack via __lua_getglobal equivalent.
|
|
|
|
|
// Actually, just reject — GETUPVAL on _ENV is rare; scripts
|
|
|
|
|
// use GETTABUP for _ENV[key] access which is already handled.
|
|
|
|
|
// If someone does `local g = _ENV`, they get GETUPVAL.
|
|
|
|
|
std::string name("__lua_getenv");
|
|
|
|
|
int args[1];
|
|
|
|
|
int dummy = h.emit_iconst(0);
|
|
|
|
|
args[0] = dummy;
|
|
|
|
|
lua_reg[A] = h.emit_call(TY_STRING, 0, args, 1, &name);
|
|
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.ecalls++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_SETUPVAL: {
|
|
|
|
|
// A = source register, B = upvalue index.
|
|
|
|
|
// Setting _ENV is unusual and dangerous. Reject.
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 16:26:24 -06:00
|
|
|
// ---- Global access, method calls, and function calls ----
|
2026-03-18 09:59:36 -06:00
|
|
|
|
|
|
|
|
case OP_LUA_GETTABUP: {
|
2026-03-18 16:26:24 -06:00
|
|
|
if (insn.B() != 0) return -1; // Only _ENV (upvalue 0).
|
|
|
|
|
int kidx = insn.C();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
const lua_bc_constant &k = proto->constants[kidx];
|
|
|
|
|
if (k.type != LUA_BC_TSHRSTR && k.type != LUA_BC_TLNGSTR)
|
|
|
|
|
return -1;
|
|
|
|
|
|
|
|
|
|
if (k.sval == "mux") {
|
|
|
|
|
// mux.* bridge pattern — sentinel for GETFIELD+CALL.
|
|
|
|
|
uint64_t addr = rc.pool_str("mux", 3);
|
|
|
|
|
lua_reg[A] = h.emit_sconst(addr, "mux");
|
2026-07-29 15:19:43 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.lua_mux_sentinel[lua_reg[A]] = true;
|
2026-03-18 16:26:24 -06:00
|
|
|
} else {
|
Enable generic Lua function calls (math.floor, string.upper, etc.)
The big unlock: GETTABUP now handles ALL _ENV globals (not just "mux"),
and CALL now handles both mux.* bridge calls and general Lua function
calls via __lua_call ECALL. Scripts using math.floor(), string.upper(),
tostring(), or any global function are now JIT-eligible.
Key fix: lua_settop() save/restore in TryJIT around RunCompiled.
ECALL handlers for __lua_getglobal push tables/functions onto the Lua
stack, and __lua_call pushes/pops during lua_pcall. Without cleanup,
the stack overflows on repeated calls. The save/restore ensures every
JIT execution leaves the Lua stack in its original state.
4 new smoke tests (TC043-TC046): math.floor, string.upper, tostring,
math.sqrt — verifying the generic global→field→call path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:35:29 -06:00
|
|
|
// General global access: _ENV[key] via ECALL.
|
|
|
|
|
// Pushes table/function onto Lua stack, returns stack
|
|
|
|
|
// index as string. Lua stack cleanup is handled by
|
|
|
|
|
// TryJIT's save/restore around RunCompiled.
|
|
|
|
|
uint64_t key_addr = rc.pool_str(k.sval.c_str(), k.sval.size());
|
|
|
|
|
int key_val = h.emit_sconst(key_addr, k.sval);
|
|
|
|
|
if (key_val < 0) return -1;
|
feat(lua/jit): library calls, integer in and integer out (#1519)
math.max(3,9) -> 9 math.min(3,9) -> 3 math.abs(-7) -> 7
Decline count 29 -> 26, the largest single step so far, and the first that
crosses THREE ECALLs in one compiled run:
GETGLOBAL name -> stack index of the library table
GETFIELD_REF table + name -> stack index of the function
CALL_INT function + up to two integer args -> integer result
#1519 flagged stack discipline across ECALLs as needing proof rather than
assumption. It holds: a handle from one ECALL stays valid across the next
two inside a run, bounded by TryJIT's settop around the whole thing.
Narrow on purpose. Integer args, integer result, nothing marshalled --
math.floor(3.7) still declines because its argument is a float. That keeps
this increment about STRUCTURE and leaves the string-result convention to be
decided on its own, where a bad choice would be expensive (see ECALL_ORD's
unbounded write, #1679).
WHAT THIS BUILD PROVED THE IR CANNOT DO
Two seams, neither of which I would have written down from taste:
1. An instruction needs N OPERANDS. A call has three or four; the IR has
src1, src2 and val[]. So nargs is bit-packed with an instruction index
into val[]:
int64_t packed = nargs | (int64_t)(a1 + 1) << 8;
That is val[]'s FOURTH meaning after immediate, guest address, and
SETI's third operand -- and hir_val_operand() cannot see the arg1 index
at all. Nothing breaks today only because two-argument calls are simple
enough that liveness incidentally holds. That is luck, and it is the
same shape that produced #1711's wrong answer.
What the upper layer needs, plainly: an operand list every pass can walk
without knowing the opcode.
2. The type system knows "handle" but not "handle to WHAT". Choosing
GETFIELD_REF over GETFIELD_INT is a guess from provenance -- did this
handle come from GETGLOBAL or from NEWTABLE -- because a library member
is a function and a data-table member is a value, and TY_LUA_HANDLE
cannot tell them apart. Second place this pass has had to guess.
Both are recorded rather than worked around silently, because the refactor
they argue for should be driven by what the upper layer demonstrably needs.
Tests use asymmetric two-argument calls, max(3,9) and min(3,9), for the same
reason #1711's use two distinct keys: one argument cannot distinguish
correct passing from an argument being ignored or the pair being swapped.
All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:55:37 -06:00
|
|
|
lua_reg[A] = h.emit(HIR_LUA_GETGLOBAL, TY_LUA_HANDLE,
|
|
|
|
|
key_val);
|
Enable generic Lua function calls (math.floor, string.upper, etc.)
The big unlock: GETTABUP now handles ALL _ENV globals (not just "mux"),
and CALL now handles both mux.* bridge calls and general Lua function
calls via __lua_call ECALL. Scripts using math.floor(), string.upper(),
tostring(), or any global function are now JIT-eligible.
Key fix: lua_settop() save/restore in TryJIT around RunCompiled.
ECALL handlers for __lua_getglobal push tables/functions onto the Lua
stack, and __lua_call pushes/pops during lua_pcall. Without cleanup,
the stack overflows on repeated calls. The save/restore ensures every
JIT execution leaves the Lua stack in its original state.
4 new smoke tests (TC043-TC046): math.floor, string.upper, tostring,
math.sqrt — verifying the generic global→field→call path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:35:29 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// A global's referent is not knowable here -- `math` is a
|
|
|
|
|
// table, `tostring` is a function, and a game can rebind
|
|
|
|
|
// either -- so claim both capabilities and let each use's
|
|
|
|
|
// runtime check settle it: field reads take references,
|
|
|
|
|
// calls are allowed with the result type the name claims.
|
|
|
|
|
lua_referent g;
|
|
|
|
|
g.fields_are_refs = true;
|
|
|
|
|
g.callable = true;
|
|
|
|
|
g.returns = lua_call_claim(k.sval);
|
2026-07-31 09:08:44 -06:00
|
|
|
g.call_name = k.sval;
|
feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume. The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.
Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once. And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).
HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape). Noted in both places.
EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1. AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.
luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
|
|
|
if (k.sval == "math") {
|
|
|
|
|
g.values = k_lua_math_values;
|
|
|
|
|
}
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
lua_ref[lua_reg[A]] = g;
|
feat(lua/jit): library calls, integer in and integer out (#1519)
math.max(3,9) -> 9 math.min(3,9) -> 3 math.abs(-7) -> 7
Decline count 29 -> 26, the largest single step so far, and the first that
crosses THREE ECALLs in one compiled run:
GETGLOBAL name -> stack index of the library table
GETFIELD_REF table + name -> stack index of the function
CALL_INT function + up to two integer args -> integer result
#1519 flagged stack discipline across ECALLs as needing proof rather than
assumption. It holds: a handle from one ECALL stays valid across the next
two inside a run, bounded by TryJIT's settop around the whole thing.
Narrow on purpose. Integer args, integer result, nothing marshalled --
math.floor(3.7) still declines because its argument is a float. That keeps
this increment about STRUCTURE and leaves the string-result convention to be
decided on its own, where a bad choice would be expensive (see ECALL_ORD's
unbounded write, #1679).
WHAT THIS BUILD PROVED THE IR CANNOT DO
Two seams, neither of which I would have written down from taste:
1. An instruction needs N OPERANDS. A call has three or four; the IR has
src1, src2 and val[]. So nargs is bit-packed with an instruction index
into val[]:
int64_t packed = nargs | (int64_t)(a1 + 1) << 8;
That is val[]'s FOURTH meaning after immediate, guest address, and
SETI's third operand -- and hir_val_operand() cannot see the arg1 index
at all. Nothing breaks today only because two-argument calls are simple
enough that liveness incidentally holds. That is luck, and it is the
same shape that produced #1711's wrong answer.
What the upper layer needs, plainly: an operand list every pass can walk
without knowing the opcode.
2. The type system knows "handle" but not "handle to WHAT". Choosing
GETFIELD_REF over GETFIELD_INT is a guess from provenance -- did this
handle come from GETGLOBAL or from NEWTABLE -- because a library member
is a function and a data-table member is a value, and TY_LUA_HANDLE
cannot tell them apart. Second place this pass has had to guess.
Both are recorded rather than worked around silently, because the refactor
they argue for should be driven by what the upper layer demonstrably needs.
Tests use asymmetric two-argument calls, max(3,9) and min(3,9), for the same
reason #1711's use two distinct keys: one argument cannot distinguish
correct passing from an argument being ignored or the pair being swapped.
All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:55:37 -06:00
|
|
|
h.known_int[lua_reg[A]] = true;
|
Enable generic Lua function calls (math.floor, string.upper, etc.)
The big unlock: GETTABUP now handles ALL _ENV globals (not just "mux"),
and CALL now handles both mux.* bridge calls and general Lua function
calls via __lua_call ECALL. Scripts using math.floor(), string.upper(),
tostring(), or any global function are now JIT-eligible.
Key fix: lua_settop() save/restore in TryJIT around RunCompiled.
ECALL handlers for __lua_getglobal push tables/functions onto the Lua
stack, and __lua_call pushes/pops during lua_pcall. Without cleanup,
the stack overflows on repeated calls. The save/restore ensures every
JIT execution leaves the Lua stack in its original state.
4 new smoke tests (TC043-TC046): math.floor, string.upper, tostring,
math.sqrt — verifying the generic global→field→call path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:35:29 -06:00
|
|
|
h.ecalls++;
|
2026-03-18 16:26:24 -06:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SETTABUP: set _ENV[key] = value.
|
|
|
|
|
case OP_LUA_SETTABUP: {
|
|
|
|
|
if (insn.A() != 0) return -1; // Only _ENV.
|
|
|
|
|
int kidx = insn.B();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
const lua_bc_constant &k = proto->constants[kidx];
|
|
|
|
|
if (k.type != LUA_BC_TSHRSTR && k.type != LUA_BC_TLNGSTR)
|
|
|
|
|
return -1;
|
|
|
|
|
int val = lua_reg[insn.C()];
|
|
|
|
|
if (val < 0) return -1;
|
|
|
|
|
if (h.ty[val] == TY_INT) {
|
|
|
|
|
val = h.emit(HIR_ITOA, TY_STRING, val);
|
|
|
|
|
if (val < 0) return -1;
|
|
|
|
|
} else if (h.ty[val] == TY_FLOAT) {
|
|
|
|
|
val = h.emit(HIR_FTOA, TY_STRING, val);
|
|
|
|
|
if (val < 0) return -1;
|
|
|
|
|
}
|
|
|
|
|
uint64_t key_addr = rc.pool_str(k.sval.c_str(), k.sval.size());
|
|
|
|
|
int key_val = h.emit_sconst(key_addr, k.sval);
|
|
|
|
|
if (key_val < 0) return -1;
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// Global writes have no handler; ineligible at lowering
|
|
|
|
|
// (#1751 rule 1) rather than a guaranteed post-entry fail.
|
|
|
|
|
return -1;
|
2026-03-18 16:26:24 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SELF: A = dest, B = table register, C = method key constant.
|
|
|
|
|
// R(A+1) := R(B); R(A) := R(B)[K(C)]
|
|
|
|
|
case OP_LUA_SELF: {
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + 1)) return -1;
|
2026-03-18 16:26:24 -06:00
|
|
|
int tbl = lua_reg[insn.B()];
|
|
|
|
|
if (tbl < 0) return -1;
|
|
|
|
|
// Copy table to R(A+1) for method call.
|
|
|
|
|
lua_reg[A + 1] = tbl;
|
|
|
|
|
// Load method: t[key].
|
2026-03-18 09:59:36 -06:00
|
|
|
int kidx = insn.C();
|
|
|
|
|
if (kidx < 0 || kidx >= static_cast<int>(proto->constants.size()))
|
|
|
|
|
return -1;
|
|
|
|
|
const lua_bc_constant &k = proto->constants[kidx];
|
|
|
|
|
if (k.type != LUA_BC_TSHRSTR && k.type != LUA_BC_TLNGSTR)
|
|
|
|
|
return -1;
|
2026-03-18 16:26:24 -06:00
|
|
|
uint64_t key_addr = rc.pool_str(k.sval.c_str(), k.sval.size());
|
|
|
|
|
int key_val = h.emit_sconst(key_addr, k.sval);
|
|
|
|
|
if (key_val < 0) return -1;
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// Method dispatch has no handler; ineligible at lowering
|
|
|
|
|
// (#1751 rule 1).
|
|
|
|
|
return -1;
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
|
|
|
|
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
case OP_LUA_TAILCALL:
|
|
|
|
|
// `return f(...)`: the call below, then the return the helper
|
|
|
|
|
// emits at each successful exit. The real tail-call mechanism
|
|
|
|
|
// reuses the caller's frame; nothing here does -- the callee
|
|
|
|
|
// runs via an ECALL doing its own pcall -- and the chunk-level
|
|
|
|
|
// pcall asks for one result either way, so a plain call
|
|
|
|
|
// observes the same thing. k set means upvalues to close,
|
|
|
|
|
// which the CLOSURE reject should make impossible; decline
|
|
|
|
|
// rather than assume. C is frame correction for the frame
|
|
|
|
|
// reuse that is not happening.
|
|
|
|
|
if (insn.k()) return -1;
|
|
|
|
|
// fall through
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_CALL: {
|
|
|
|
|
int func_reg = lua_reg[A];
|
|
|
|
|
if (func_reg < 0) return -1;
|
|
|
|
|
|
|
|
|
|
int nargs = insn.B() - 1;
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
// TAILCALL has no C-encoded result count: it returns what the
|
|
|
|
|
// callee returns, of which the chunk boundary keeps one.
|
|
|
|
|
int nresults = (OP_LUA_TAILCALL == op) ? 1 : insn.C() - 1;
|
Enable generic Lua function calls (math.floor, string.upper, etc.)
The big unlock: GETTABUP now handles ALL _ENV globals (not just "mux"),
and CALL now handles both mux.* bridge calls and general Lua function
calls via __lua_call ECALL. Scripts using math.floor(), string.upper(),
tostring(), or any global function are now JIT-eligible.
Key fix: lua_settop() save/restore in TryJIT around RunCompiled.
ECALL handlers for __lua_getglobal push tables/functions onto the Lua
stack, and __lua_call pushes/pops during lua_pcall. Without cleanup,
the stack overflows on repeated calls. The save/restore ensures every
JIT execution leaves the Lua stack in its original state.
4 new smoke tests (TC043-TC046): math.floor, string.upper, tostring,
math.sqrt — verifying the generic global→field→call path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:35:29 -06:00
|
|
|
if (nargs < 0) return -1; // Variable args not supported.
|
2026-03-18 09:59:36 -06:00
|
|
|
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// Direct call on a handle with a callable claim. CALL_INT and
|
|
|
|
|
// CALL_STR share one argument encoding (nargs, argkind bits,
|
|
|
|
|
// arg registers) and differ only in how the result comes back:
|
|
|
|
|
// in a register, or marshalled into an output slot whose SIZE
|
|
|
|
|
// the ECALL is told rather than assumes (#1679). Which one to
|
|
|
|
|
// emit is the handle's claimed result type, recorded where the
|
|
|
|
|
// handle was created -- one claim, so the two variants cannot
|
|
|
|
|
// disagree about a name the way the twin gated branches this
|
|
|
|
|
// replaces could (d5e5e86e0).
|
feat(lua/jit): string results from library calls (#1519)
string.upper("ab") -> AB string.lower("AB") -> ab
string.rep("ab",2) -> abab
Decline count 26 -> 23. This answers the convention #1713 deliberately
deferred: where does a string result go, and who bounds it.
It goes in the output slot the register allocator already gives any
TY_STRING value, and the ECALL is told that slot's SIZE rather than assuming
it. ECALL_ORD is why: its bound read like one and was not -- 64 bytes of
headroom against a loop writing per codepoint -- and it wrote 15k (#1679).
Two choices made against the lazier option:
Decline on overflow, do not truncate. A silently shortened string is a
wrong answer, and the interpreter can produce the whole thing.
Accept LUA_TSTRING only, rather than lua_tolstring on anything. That
function coerces a number AND mutates the stack slot in place, which would
disturb a live handle and make number->string conversion the JIT's rules
instead of Lua's. ITOA/FTOA already carry the interpreter's rules.
Arguments may be integers or constant strings; a kind bit per argument tells
the handler which register holds which. A runtime string argument needs its
own guest buffer and waits for something that needs it.
#1715 EARNED ITSELF HERE. CALL_STR packs three fields into val[] --
nargs | kinds<<8 | arg1<<16 -- and teaching the operand accessor about it was
ONE edit. Before that refactor it was four separate walks, three of which
fail silently when missed, and all four would have needed it while the
string convention was also being designed.
A test that could not fail, caught in the minute it was written: I added
string.sub("hello",2,3) as an EXEC case with a comment claiming it covered
mixed argument kinds. It takes THREE arguments against a ceiling of two, so
it declines and can never execute -- the comment asserted coverage that did
not exist. string.rep("ab",2) is genuinely string-plus-integer and does
cover it. The EXEC contract caught this, because a declining chunk there is
a hard error rather than a pass.
All three cases report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:46:37 -06:00
|
|
|
//
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
// Arguments may be integers, CONSTANT strings, floats --
|
|
|
|
|
// constant or runtime -- or Lua HANDLES, with TWO kind bits
|
|
|
|
|
// per argument telling codegen and the handler what each
|
|
|
|
|
// register carries (0 integer, 1 string address, 2 double as
|
|
|
|
|
// raw bits over the FMV.X.D lane, 3 stack reference). Floats
|
|
|
|
|
// travel honestly rather than as rendered text because
|
|
|
|
|
// coercion would lie to a type-sensitive callee:
|
|
|
|
|
// math.type("3.0") is nil, not "float". A handle argument is
|
|
|
|
|
// the index for a lua_pushvalue -- the one use of a handle
|
|
|
|
|
// that is ABOUT the thing it points at (#1579), which is what
|
|
|
|
|
// table.insert(t,4) needs. A runtime string argument would
|
|
|
|
|
// need its own guest buffer and is left for when something
|
|
|
|
|
// needs it.
|
|
|
|
|
//
|
|
|
|
|
// nresults == 0 is a call FOR the effect -- table.insert --
|
|
|
|
|
// and takes CALL_VOID: no result register, no result-type
|
|
|
|
|
// claim to check, and the destination Lua registers become
|
|
|
|
|
// dead exactly as the VM's would.
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
const lua_referent fref = lua_referent_of(lua_ref, func_reg);
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// A FLOAT-returning claim has no marshalling; ineligible at
|
|
|
|
|
// lowering, interpreter answers with the right subtype.
|
|
|
|
|
if (fref.callable && TY_FLOAT == fref.returns) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-07-29 09:12:24 -06:00
|
|
|
// Effectful callees (mux.pemit/set/eval/…) are eligible: the
|
|
|
|
|
// compiled path runs the same bridge C functions as the
|
|
|
|
|
// interpreter. Pre-Phase-4 they were ineligible because a
|
|
|
|
|
// later runtime decline re-ran the chunk and doubled effects.
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
// The string form keeps its historical one-argument floor; the
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
// integer form and the effect-only form allow zero.
|
|
|
|
|
const int min_args =
|
|
|
|
|
(0 == nresults || TY_INT == fref.returns) ? 0 : 1;
|
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.
The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path). Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction. The
name-mapped branch and its is_bridge plumbing are deleted.
Two consequences handled:
* Execution context. The bridge functions read executor/caller/
enactor from the registry, and per-run mux fields are injected into
the table -- previously only in ExecuteChunk, the interpreter leg,
so a compiled run offered the bridge a cleared registry and the
PREVIOUS run's mux table. The setup/teardown is now one shared pair
(lua_setup_exec_context / lua_clear_exec_context) staged by both
ExecuteChunk and TryJIT's two RunCompiled sites.
* Effectful members stay exactly-once. mux.notify/pemit/set in a
result-consuming form would pcall -- effect delivered -- then
decline on the result type, and the interpreter re-run would deliver
the effect twice. The referent now carries an effectful claim:
statement form compiles (CALL_VOID has no result check), result-
consuming forms decline at COMPILE time and run once, interpreted.
Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.
smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
|
|
|
if (fref.callable
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
&& (0 == nresults || 1 == nresults)
|
|
|
|
|
&& nargs >= min_args && nargs <= 3) {
|
|
|
|
|
int cargs[3] = { -1, -1, -1 };
|
|
|
|
|
int kinds = 0;
|
feat(lua/jit): string results from library calls (#1519)
string.upper("ab") -> AB string.lower("AB") -> ab
string.rep("ab",2) -> abab
Decline count 26 -> 23. This answers the convention #1713 deliberately
deferred: where does a string result go, and who bounds it.
It goes in the output slot the register allocator already gives any
TY_STRING value, and the ECALL is told that slot's SIZE rather than assuming
it. ECALL_ORD is why: its bound read like one and was not -- 64 bytes of
headroom against a loop writing per codepoint -- and it wrote 15k (#1679).
Two choices made against the lazier option:
Decline on overflow, do not truncate. A silently shortened string is a
wrong answer, and the interpreter can produce the whole thing.
Accept LUA_TSTRING only, rather than lua_tolstring on anything. That
function coerces a number AND mutates the stack slot in place, which would
disturb a live handle and make number->string conversion the JIT's rules
instead of Lua's. ITOA/FTOA already carry the interpreter's rules.
Arguments may be integers or constant strings; a kind bit per argument tells
the handler which register holds which. A runtime string argument needs its
own guest buffer and waits for something that needs it.
#1715 EARNED ITSELF HERE. CALL_STR packs three fields into val[] --
nargs | kinds<<8 | arg1<<16 -- and teaching the operand accessor about it was
ONE edit. Before that refactor it was four separate walks, three of which
fail silently when missed, and all four would have needed it while the
string convention was also being designed.
A test that could not fail, caught in the minute it was written: I added
string.sub("hello",2,3) as an EXEC case with a comment claiming it covered
mixed argument kinds. It takes THREE arguments against a ceiling of two, so
it declines and can never execute -- the comment asserted coverage that did
not exist. string.rep("ab",2) is genuinely string-plus-integer and does
cover it. The EXEC contract caught this, because a declining chunk there is
a hard error rather than a pass.
All three cases report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:46:37 -06:00
|
|
|
bool ok = true;
|
|
|
|
|
for (int i = 0; i < nargs && ok; i++) {
|
|
|
|
|
if (!lua_reg_in_range(A + 1 + i)) { ok = false; break; }
|
|
|
|
|
int areg = lua_reg[A + 1 + i];
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
if (areg < 0) { ok = false; break; }
|
fix(lua/jit): audit nil's consumers, not just its producers (#1766 review)
The closed-key-set analysis is sound and the producers are right, but
lowering a known-absent read to nil makes nil a value that FLOWS -- and
the consumers had not been audited. Measured on the first cut, all
silent wrong answers where master had been loudly declining:
"a" .. t[2] jit "a" interp raises "concatenate a nil value"
#t[2] jit 0 interp raises "length of a nil value"
tostring(t[2]) jit "" interp "nil"
t[2] == "" jit 1 interp 0 (found while fixing)
nil is representable in HIR only as the empty SCONST, which is also a
real "" -- the truth tag is the only thing that tells them apart, so
every operation Lua rejects on nil must consult it. One predicate
(lua_is_nil, mirroring lua_is_marshalled_str) applied at CONCAT, LEN,
the call-argument walk, and both comparison macros; promote_to_int and
promote_to_float already had it.
Five AGREE pins, one per shape above plus concat-through-a-local, so
the class cannot regress silently. Budget 10 -> 15: nil has no
compiled consumer yet, and every one of those five is a decline by
design until it does.
This is the fourth PR in the campaign where emptying a loud bin
introduced a silent wrong answer (#1755, #1756, #1763, this). The
pattern is worth naming: a new VALUE CLASS needs a consumer audit, not
just correct producers.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:56:20 -06:00
|
|
|
// nil as an argument would arrive as "" -- tostring(nil)
|
|
|
|
|
// is "nil", not "". No kind encodes nil, so decline.
|
|
|
|
|
if (lua_is_nil(h, truth_tag, areg)) { ok = false; break; }
|
fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL. That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete. Five consumers believed the
representation, every one executing and silent:
return type(mux.args) jit "string" interp "table"
return tostring(mux.args) jit "mux.args" interp "table: 0x..."
return "x" .. mux.args jit "xmux.args" interp raises
if mux.args == "mux.args" jit "y" interp "n"
return #mux jit 3 interp 0
The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.
One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.
Five AGREE pins for the stable shapes. tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".
The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).
Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it. This one predates the rule by years.
make test exit 0; smoke 1561/0; luajit PASSED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
|
|
|
// A sentinel would travel as the string "mux.args";
|
|
|
|
|
// type() answered "string", tostring() the name.
|
|
|
|
|
if (lua_is_mux_sentinel(h, areg)) { ok = false; break; }
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
if (lua_is_handle(h, areg)) {
|
|
|
|
|
kinds |= (3 << (2 * i)); // stack reference
|
fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.
INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0. Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.
PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued. Nothing pinned it.
The revision makes the typed claims PROVABLE or ineligible:
* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
CLEARED when the handle escapes as a call argument (the callee can
setmetatable it). GETI / GETFIELD_INT / GETFIELD_FLT emissions
require the proof; anything else is ineligible at lowering and the
interpreter answers. Exhibit: the metatabled-global read now agrees
("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
integer, so the one honest miss left is an absent key -- Lua nil,
which a typed integer slot cannot carry. That is now a loud
post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
SET delegate to the real VM ops and raise what the interpreter
raises; a nil global stays on the stack for its consumer to raise on.
Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.
make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
|
|
|
// The callee may setmetatable the table: the
|
|
|
|
|
// plain proof does not survive an escape.
|
|
|
|
|
lua_ref[areg].plain_proven = false;
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
} else if (h.ty[areg] == TY_INT) {
|
feat(lua/jit): float call arguments over the FMV.X.D lane (#1519)
math.floor(3.7), math.type(3.0) and math.type(2^3) all declined because
the call argument walk accepted only integers and constant strings.
Floats could not be smuggled as rendered text -- coercion lies to a
type-sensitive callee: math.type("3.0") is nil, not "float" -- so they
travel honestly, as raw double bits through the integer argument
register, the same FMV.X.D lane ECALL_LUA_FTOA already proved on both
execution routes. Runtime floats work identically to constants: the
value FLDs from its FP slot at call time.
The argument encoding widens from one kind bit to two per argument
(0 integer, 1 string address, 2 double bits), and the widening forced
the factoring the duplication deserved anyway: one emitter in codegen
(emit_lua_call_args) and one decoder in the handler
(ecall_lua_push_call_args) replace two near-identical copies of each --
the same #1457 drift shape the lowering's twin call branches were merged
out of. The packed kind bits are the single source of truth end to end;
codegen no longer re-derives argument shapes from h.kind.
Four EXEC cases pin the lane: a float constant to each result variant,
two floor calls with different fractions (catches a reused argument
slot), and a RUNTIME float a constant-folding accident cannot fake.
AGREE declines fall 15 -> 11; ratchet tightened in this commit as the
harness requires.
luajit: 124 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:29:26 -06:00
|
|
|
// integer: kind 0
|
feat(lua/jit): string results from library calls (#1519)
string.upper("ab") -> AB string.lower("AB") -> ab
string.rep("ab",2) -> abab
Decline count 26 -> 23. This answers the convention #1713 deliberately
deferred: where does a string result go, and who bounds it.
It goes in the output slot the register allocator already gives any
TY_STRING value, and the ECALL is told that slot's SIZE rather than assuming
it. ECALL_ORD is why: its bound read like one and was not -- 64 bytes of
headroom against a loop writing per codepoint -- and it wrote 15k (#1679).
Two choices made against the lazier option:
Decline on overflow, do not truncate. A silently shortened string is a
wrong answer, and the interpreter can produce the whole thing.
Accept LUA_TSTRING only, rather than lua_tolstring on anything. That
function coerces a number AND mutates the stack slot in place, which would
disturb a live handle and make number->string conversion the JIT's rules
instead of Lua's. ITOA/FTOA already carry the interpreter's rules.
Arguments may be integers or constant strings; a kind bit per argument tells
the handler which register holds which. A runtime string argument needs its
own guest buffer and waits for something that needs it.
#1715 EARNED ITSELF HERE. CALL_STR packs three fields into val[] --
nargs | kinds<<8 | arg1<<16 -- and teaching the operand accessor about it was
ONE edit. Before that refactor it was four separate walks, three of which
fail silently when missed, and all four would have needed it while the
string convention was also being designed.
A test that could not fail, caught in the minute it was written: I added
string.sub("hello",2,3) as an EXEC case with a comment claiming it covered
mixed argument kinds. It takes THREE arguments against a ceiling of two, so
it declines and can never execute -- the comment asserted coverage that did
not exist. string.rep("ab",2) is genuinely string-plus-integer and does
cover it. The EXEC contract caught this, because a declining chunk there is
a hard error rather than a pass.
All three cases report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:46:37 -06:00
|
|
|
} else if (h.kind[areg] == HIR_SCONST) {
|
feat(lua/jit): float call arguments over the FMV.X.D lane (#1519)
math.floor(3.7), math.type(3.0) and math.type(2^3) all declined because
the call argument walk accepted only integers and constant strings.
Floats could not be smuggled as rendered text -- coercion lies to a
type-sensitive callee: math.type("3.0") is nil, not "float" -- so they
travel honestly, as raw double bits through the integer argument
register, the same FMV.X.D lane ECALL_LUA_FTOA already proved on both
execution routes. Runtime floats work identically to constants: the
value FLDs from its FP slot at call time.
The argument encoding widens from one kind bit to two per argument
(0 integer, 1 string address, 2 double bits), and the widening forced
the factoring the duplication deserved anyway: one emitter in codegen
(emit_lua_call_args) and one decoder in the handler
(ecall_lua_push_call_args) replace two near-identical copies of each --
the same #1457 drift shape the lowering's twin call branches were merged
out of. The packed kind bits are the single source of truth end to end;
codegen no longer re-derives argument shapes from h.kind.
Four EXEC cases pin the lane: a float constant to each result variant,
two floor calls with different fractions (catches a reused argument
slot), and a RUNTIME float a constant-folding accident cannot fake.
AGREE declines fall 15 -> 11; ratchet tightened in this commit as the
harness requires.
luajit: 124 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:29:26 -06:00
|
|
|
kinds |= (1 << (2 * i)); // string address
|
|
|
|
|
} else if (h.ty[areg] == TY_FLOAT) {
|
|
|
|
|
kinds |= (2 << (2 * i)); // double, raw bits
|
feat(lua/jit): string results from library calls (#1519)
string.upper("ab") -> AB string.lower("AB") -> ab
string.rep("ab",2) -> abab
Decline count 26 -> 23. This answers the convention #1713 deliberately
deferred: where does a string result go, and who bounds it.
It goes in the output slot the register allocator already gives any
TY_STRING value, and the ECALL is told that slot's SIZE rather than assuming
it. ECALL_ORD is why: its bound read like one and was not -- 64 bytes of
headroom against a loop writing per codepoint -- and it wrote 15k (#1679).
Two choices made against the lazier option:
Decline on overflow, do not truncate. A silently shortened string is a
wrong answer, and the interpreter can produce the whole thing.
Accept LUA_TSTRING only, rather than lua_tolstring on anything. That
function coerces a number AND mutates the stack slot in place, which would
disturb a live handle and make number->string conversion the JIT's rules
instead of Lua's. ITOA/FTOA already carry the interpreter's rules.
Arguments may be integers or constant strings; a kind bit per argument tells
the handler which register holds which. A runtime string argument needs its
own guest buffer and waits for something that needs it.
#1715 EARNED ITSELF HERE. CALL_STR packs three fields into val[] --
nargs | kinds<<8 | arg1<<16 -- and teaching the operand accessor about it was
ONE edit. Before that refactor it was four separate walks, three of which
fail silently when missed, and all four would have needed it while the
string convention was also being designed.
A test that could not fail, caught in the minute it was written: I added
string.sub("hello",2,3) as an EXEC case with a comment claiming it covered
mixed argument kinds. It takes THREE arguments against a ceiling of two, so
it declines and can never execute -- the comment asserted coverage that did
not exist. string.rep("ab",2) is genuinely string-plus-integer and does
cover it. The EXEC contract caught this, because a declining chunk there is
a hard error rather than a pass.
All three cases report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:46:37 -06:00
|
|
|
} else {
|
|
|
|
|
ok = false; break;
|
|
|
|
|
}
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
cargs[i] = areg;
|
feat(lua/jit): string results from library calls (#1519)
string.upper("ab") -> AB string.lower("AB") -> ab
string.rep("ab",2) -> abab
Decline count 26 -> 23. This answers the convention #1713 deliberately
deferred: where does a string result go, and who bounds it.
It goes in the output slot the register allocator already gives any
TY_STRING value, and the ECALL is told that slot's SIZE rather than assuming
it. ECALL_ORD is why: its bound read like one and was not -- 64 bytes of
headroom against a loop writing per codepoint -- and it wrote 15k (#1679).
Two choices made against the lazier option:
Decline on overflow, do not truncate. A silently shortened string is a
wrong answer, and the interpreter can produce the whole thing.
Accept LUA_TSTRING only, rather than lua_tolstring on anything. That
function coerces a number AND mutates the stack slot in place, which would
disturb a live handle and make number->string conversion the JIT's rules
instead of Lua's. ITOA/FTOA already carry the interpreter's rules.
Arguments may be integers or constant strings; a kind bit per argument tells
the handler which register holds which. A runtime string argument needs its
own guest buffer and waits for something that needs it.
#1715 EARNED ITSELF HERE. CALL_STR packs three fields into val[] --
nargs | kinds<<8 | arg1<<16 -- and teaching the operand accessor about it was
ONE edit. Before that refactor it was four separate walks, three of which
fail silently when missed, and all four would have needed it while the
string convention was also being designed.
A test that could not fail, caught in the minute it was written: I added
string.sub("hello",2,3) as an EXEC case with a comment claiming it covered
mixed argument kinds. It takes THREE arguments against a ceiling of two, so
it declines and can never execute -- the comment asserted coverage that did
not exist. string.rep("ab",2) is genuinely string-plus-integer and does
cover it. The EXEC contract caught this, because a declining chunk there is
a hard error rather than a pass.
All three cases report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:46:37 -06:00
|
|
|
}
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
if (ok) {
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
// Arguments ride the carg[] list; val[] carries only
|
|
|
|
|
// the kind bits.
|
2026-07-31 09:08:44 -06:00
|
|
|
//
|
|
|
|
|
// #1866: tonumber is dynamic int|float. Upgrade to
|
|
|
|
|
// CALL_INT only when the arg proves integral so the
|
|
|
|
|
// common tonumber("17") / tonumber(n) path stays
|
|
|
|
|
// native; non-integral and runtime strings keep
|
|
|
|
|
// CALL_VAL (no post-entry residual on floats).
|
|
|
|
|
//
|
|
|
|
|
hir_type result_ty = fref.returns;
|
|
|
|
|
if (fref.call_name == "tonumber" && 1 == nargs
|
|
|
|
|
&& lua_tonumber_arg_is_integral(h, cargs[0])) {
|
|
|
|
|
result_ty = TY_INT;
|
|
|
|
|
}
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
if (0 == nresults) {
|
|
|
|
|
if (h.emit_lua_call(HIR_LUA_CALL_VOID, TY_VOID,
|
|
|
|
|
func_reg, cargs, nargs, kinds) < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
lua_reg[A] = -1;
|
2026-07-31 09:08:44 -06:00
|
|
|
} else if (TY_INT == result_ty) {
|
refactor(lua/jit): call args on the carg[] list; 3 args, handle args, void calls (#1519)
The third argument is what finally graduates CALL_INT/CALL_STR off the
packed val[] encoding: fn stays src1, the arguments ride the carg[] list
exactly as HIR_CALL's do (emit_lua_call), and val[] keeps only the
two-bit argument kinds. hir_val_operand's CALL branch and
hir_operand_set's re-packing hack are DELETED rather than grown a third
shape -- every operand-walking pass now sees call arguments through the
ARG slots, closing the seam that made CALL_INT's second argument
invisible to liveness (20d39472f) and that the codegen comment had been
naming since the packing landed.
On that footing, three call-surface features:
* Three arguments (string.sub). The third rides x14, so CALL_STR's
out addr/size shift to x15/x16 -- an internal encoding, changed
everywhere in this commit.
* Handle arguments, kind 3: the register carries the stack index and
the handler does lua_pushvalue -- the one use of a handle that is
ABOUT the thing it points at (#1579), which table.concat({...},",")
needs. Codegen's register move for kind 0 was already exactly right.
* HIR_LUA_CALL_VOID for nresults == 0 (table.insert): pcall asked for
zero results, nothing to type-check. It exists only for its side
effect and produces no value, so nothing downstream can keep it
alive -- it joins has_side_effects(), without which DCE NOPs it
(#1145's SETI lesson).
EXEC pins each: three args, a handle arg with a string result, and the
void call read back through t[2] -- #t alone stays plausible when an
insert silently never ran, but the inserted element cannot. AGREE
declines fall 8 -> 5; the survivors are all deliberate (loops x2,
os.time, string.find's result-count pin, select's four arguments).
luajit: 129 chunks, 0 wrong, 0 crashes; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:03:51 -06:00
|
|
|
lua_reg[A] = h.emit_lua_call(HIR_LUA_CALL_INT,
|
|
|
|
|
TY_INT, func_reg, cargs, nargs, kinds);
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
|
|
|
|
h.known_int[lua_reg[A]] = true;
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
} else {
|
2026-07-29 12:45:59 -06:00
|
|
|
// Default claim: keep the Lua value on the VM stack
|
|
|
|
|
// as a handle. Marshal only at chunk return
|
|
|
|
|
// (HIR_LUA_MARSHAL). Consumers use Lua semantics
|
|
|
|
|
// (TOBOOL, pushvalue args) instead of softcode text.
|
|
|
|
|
lua_reg[A] = h.emit_lua_call(HIR_LUA_CALL_VAL,
|
|
|
|
|
TY_LUA_HANDLE, func_reg, cargs, nargs, kinds);
|
refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.
Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it. The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.
lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.
No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
|
|
|
if (lua_reg[A] < 0) return -1;
|
feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42 type(42) -> number tonumber("17") -> 17
Decline count 23 -> 15. Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.
Two changes.
A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table. The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.
And CALL_INT and CALL_STR now share ONE argument encoding. CALL_INT took
integers only; CALL_STR took integers or constant strings. That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive. tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves. The
tonumber(mux.args[1]) family fell out for free and was not on the list.
WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION
math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types. HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.
That is the second place the lowering guesses what a handle points at. The
first is GETFIELD_REF vs GETFIELD_INT by provenance. Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type. Two
independent pressure points now argue for it rather than one.
Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both. All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:20:09 -06:00
|
|
|
}
|
feat(lua/jit): library calls, integer in and integer out (#1519)
math.max(3,9) -> 9 math.min(3,9) -> 3 math.abs(-7) -> 7
Decline count 29 -> 26, the largest single step so far, and the first that
crosses THREE ECALLs in one compiled run:
GETGLOBAL name -> stack index of the library table
GETFIELD_REF table + name -> stack index of the function
CALL_INT function + up to two integer args -> integer result
#1519 flagged stack discipline across ECALLs as needing proof rather than
assumption. It holds: a handle from one ECALL stays valid across the next
two inside a run, bounded by TryJIT's settop around the whole thing.
Narrow on purpose. Integer args, integer result, nothing marshalled --
math.floor(3.7) still declines because its argument is a float. That keeps
this increment about STRUCTURE and leaves the string-result convention to be
decided on its own, where a bad choice would be expensive (see ECALL_ORD's
unbounded write, #1679).
WHAT THIS BUILD PROVED THE IR CANNOT DO
Two seams, neither of which I would have written down from taste:
1. An instruction needs N OPERANDS. A call has three or four; the IR has
src1, src2 and val[]. So nargs is bit-packed with an instruction index
into val[]:
int64_t packed = nargs | (int64_t)(a1 + 1) << 8;
That is val[]'s FOURTH meaning after immediate, guest address, and
SETI's third operand -- and hir_val_operand() cannot see the arg1 index
at all. Nothing breaks today only because two-argument calls are simple
enough that liveness incidentally holds. That is luck, and it is the
same shape that produced #1711's wrong answer.
What the upper layer needs, plainly: an operand list every pass can walk
without knowing the opcode.
2. The type system knows "handle" but not "handle to WHAT". Choosing
GETFIELD_REF over GETFIELD_INT is a guess from provenance -- did this
handle come from GETGLOBAL or from NEWTABLE -- because a library member
is a function and a data-table member is a value, and TY_LUA_HANDLE
cannot tell them apart. Second place this pass has had to guess.
Both are recorded rather than worked around silently, because the refactor
they argue for should be driven by what the upper layer demonstrably needs.
Tests use asymmetric two-argument calls, max(3,9) and min(3,9), for the same
reason #1711's use two distinct keys: one argument cannot distinguish
correct passing from an argument being ignored or the pair being swapped.
All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:55:37 -06:00
|
|
|
h.ecalls++;
|
|
|
|
|
for (int i = A + 1; i < A + 1 + nargs; i++) {
|
|
|
|
|
if (lua_reg_in_range(i)) lua_reg[i] = -1;
|
|
|
|
|
}
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
if (OP_LUA_TAILCALL == op
|
|
|
|
|
&& lua_tailcall_ret(h, rc, lua_reg[A],
|
|
|
|
|
result_val) < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
feat(lua/jit): library calls, integer in and integer out (#1519)
math.max(3,9) -> 9 math.min(3,9) -> 3 math.abs(-7) -> 7
Decline count 29 -> 26, the largest single step so far, and the first that
crosses THREE ECALLs in one compiled run:
GETGLOBAL name -> stack index of the library table
GETFIELD_REF table + name -> stack index of the function
CALL_INT function + up to two integer args -> integer result
#1519 flagged stack discipline across ECALLs as needing proof rather than
assumption. It holds: a handle from one ECALL stays valid across the next
two inside a run, bounded by TryJIT's settop around the whole thing.
Narrow on purpose. Integer args, integer result, nothing marshalled --
math.floor(3.7) still declines because its argument is a float. That keeps
this increment about STRUCTURE and leaves the string-result convention to be
decided on its own, where a bad choice would be expensive (see ECALL_ORD's
unbounded write, #1679).
WHAT THIS BUILD PROVED THE IR CANNOT DO
Two seams, neither of which I would have written down from taste:
1. An instruction needs N OPERANDS. A call has three or four; the IR has
src1, src2 and val[]. So nargs is bit-packed with an instruction index
into val[]:
int64_t packed = nargs | (int64_t)(a1 + 1) << 8;
That is val[]'s FOURTH meaning after immediate, guest address, and
SETI's third operand -- and hir_val_operand() cannot see the arg1 index
at all. Nothing breaks today only because two-argument calls are simple
enough that liveness incidentally holds. That is luck, and it is the
same shape that produced #1711's wrong answer.
What the upper layer needs, plainly: an operand list every pass can walk
without knowing the opcode.
2. The type system knows "handle" but not "handle to WHAT". Choosing
GETFIELD_REF over GETFIELD_INT is a guess from provenance -- did this
handle come from GETGLOBAL or from NEWTABLE -- because a library member
is a function and a data-table member is a value, and TY_LUA_HANDLE
cannot tell them apart. Second place this pass has had to guess.
Both are recorded rather than worked around silently, because the refactor
they argue for should be driven by what the upper layer demonstrably needs.
Tests use asymmetric two-argument calls, max(3,9) and min(3,9), for the same
reason #1711's use two distinct keys: one argument cannot distinguish
correct passing from an argument being ignored or the pair being swapped.
All three report lua_run_ok=0 on master.
make test green; test-lua-jit 1561/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:55:37 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// A call the typed CALL_INT/STR/VOID path cannot encode --
|
|
|
|
|
// arity above three, argument shapes outside the kind
|
|
|
|
|
// encoding, an unclaimed callee -- has no honest compiled
|
|
|
|
|
// form. The original Phase 2 draft resurrected the named
|
|
|
|
|
// __lua_call here with the handle ITOA'd through guest
|
|
|
|
|
// memory and every argument STRINGIFIED; review measured a
|
|
|
|
|
// table argument arriving in string.format as its stack
|
|
|
|
|
// index rendered in decimal (#1424's exact resurrection) and
|
|
|
|
|
// the plan itself names string lies a non-goal. Until a
|
|
|
|
|
// TYPED general-call encoding exists, these shapes are
|
|
|
|
|
// ineligible at lowering (#1751 rule 1): the interpreter
|
|
|
|
|
// answers, silently and correctly.
|
|
|
|
|
return -1;
|
feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale. The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.
The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits. The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined. A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.
Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline. `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.
luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
|
|
|
if (OP_LUA_TAILCALL == op
|
|
|
|
|
&& lua_tailcall_ret(h, rc, lua_reg[A], result_val) < 0) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
Add generic for-loop (TFORPREP/TFORCALL/TFORLOOP) to Lua JIT — 79/83
Generic for-loops (for k,v in pairs(t) do...end) are now JIT-eligible:
- TFORPREP: jumps to TFORLOOP for initial nil check (same as FORPREP)
- TFORCALL: calls iterator(state, control) via __lua_tfor_call ECALL,
marshals multiple results to guest memory. Uses lua_pcall with the
iterator function reference from the Lua stack.
- TFORLOOP: checks if first result is empty (nil), sets control variable,
branches back or exits.
Multi-return CALL support: when nresults > 1, emit __lua_get_result
ECALLs to fetch additional return values from the Lua stack. This
enables the pairs()/ipairs() setup call which returns 3 values
(iterator, state, initial control).
Block boundary detection updated for TFORPREP/TFORLOOP jump targets.
New ECALL handlers: __lua_tfor_call, __lua_get_result, __lua_getenv.
79 of 83 opcodes handled. Only 4 permanently rejected: CLOSURE,
VARARG, TBC, TAILCALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:54:22 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Generic for-loop (TFOR) ----
|
|
|
|
|
//
|
|
|
|
|
// R(A) = iterator function (Lua stack ref)
|
|
|
|
|
// R(A+1) = invariant state (Lua stack ref)
|
|
|
|
|
// R(A+2) = control variable
|
|
|
|
|
// R(A+3) = to-be-closed (not used without TBC)
|
|
|
|
|
// R(A+4)... = iterator results (key, value, ...)
|
|
|
|
|
|
|
|
|
|
case OP_LUA_TFORPREP: {
|
|
|
|
|
// Jump forward to TFORLOOP for initial nil check.
|
|
|
|
|
if (!multi_block) return -1;
|
|
|
|
|
int target = pc + 1 + insn.sBx();
|
|
|
|
|
int target_blk = (target >= 0 && target < n) ? pc_to_block[target] : -1;
|
|
|
|
|
if (target_blk < 0) return -1;
|
|
|
|
|
h.emit(HIR_BR, TY_VOID, -1, -1, target_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, target_blk);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_TFORCALL: {
|
|
|
|
|
// Call iterator: R(A+4),...,R(A+3+C) = R(A)(R(A+1), R(A+2))
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + 4)) return -1;
|
Add generic for-loop (TFORPREP/TFORCALL/TFORLOOP) to Lua JIT — 79/83
Generic for-loops (for k,v in pairs(t) do...end) are now JIT-eligible:
- TFORPREP: jumps to TFORLOOP for initial nil check (same as FORPREP)
- TFORCALL: calls iterator(state, control) via __lua_tfor_call ECALL,
marshals multiple results to guest memory. Uses lua_pcall with the
iterator function reference from the Lua stack.
- TFORLOOP: checks if first result is empty (nil), sets control variable,
branches back or exits.
Multi-return CALL support: when nresults > 1, emit __lua_get_result
ECALLs to fetch additional return values from the Lua stack. This
enables the pairs()/ipairs() setup call which returns 3 values
(iterator, state, initial control).
Block boundary detection updated for TFORPREP/TFORLOOP jump targets.
New ECALL handlers: __lua_tfor_call, __lua_get_result, __lua_getenv.
79 of 83 opcodes handled. Only 4 permanently rejected: CLOSURE,
VARARG, TBC, TAILCALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:54:22 -06:00
|
|
|
int iter_func = lua_reg[A];
|
|
|
|
|
int iter_state = lua_reg[A + 1];
|
|
|
|
|
int iter_control = lua_reg[A + 2];
|
|
|
|
|
if (iter_func < 0 || iter_state < 0) return -1;
|
|
|
|
|
|
|
|
|
|
// Control variable might be nil (empty string) on first call.
|
|
|
|
|
if (iter_control < 0) {
|
|
|
|
|
iter_control = h.emit_sconst(rc.pool_str("", 0), "");
|
|
|
|
|
if (iter_control < 0) return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert control to string if needed.
|
|
|
|
|
if (h.ty[iter_control] == TY_INT) {
|
|
|
|
|
iter_control = h.emit(HIR_ITOA, TY_STRING, iter_control);
|
|
|
|
|
if (iter_control < 0) return -1;
|
|
|
|
|
}
|
|
|
|
|
|
fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)
Replaces the original Phase 2 draft rather than amending it. That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED. Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.
The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers. So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic. The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.
lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype. Smoke TC046
tightens back to asserting 12.0's answer alone.
Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.
make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
|
|
|
// TFOR is rejected at eligibility; if that ever lifts, the
|
|
|
|
|
// iterator call needs a real typed encoding, not the dead
|
|
|
|
|
// named bridge. Ineligible (#1751 rule 1).
|
|
|
|
|
return -1;
|
Add generic for-loop (TFORPREP/TFORCALL/TFORLOOP) to Lua JIT — 79/83
Generic for-loops (for k,v in pairs(t) do...end) are now JIT-eligible:
- TFORPREP: jumps to TFORLOOP for initial nil check (same as FORPREP)
- TFORCALL: calls iterator(state, control) via __lua_tfor_call ECALL,
marshals multiple results to guest memory. Uses lua_pcall with the
iterator function reference from the Lua stack.
- TFORLOOP: checks if first result is empty (nil), sets control variable,
branches back or exits.
Multi-return CALL support: when nresults > 1, emit __lua_get_result
ECALLs to fetch additional return values from the Lua stack. This
enables the pairs()/ipairs() setup call which returns 3 values
(iterator, state, initial control).
Block boundary detection updated for TFORPREP/TFORLOOP jump targets.
New ECALL handlers: __lua_tfor_call, __lua_get_result, __lua_getenv.
79 of 83 opcodes handled. Only 4 permanently rejected: CLOSURE,
VARARG, TBC, TAILCALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:54:22 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case OP_LUA_TFORLOOP: {
|
|
|
|
|
// if R(A+4) ~= nil then R(A+2) = R(A+4); jump back
|
|
|
|
|
if (!multi_block) return -1;
|
2026-06-14 09:52:36 -06:00
|
|
|
if (!lua_reg_in_range(A + 4)) return -1;
|
Add generic for-loop (TFORPREP/TFORCALL/TFORLOOP) to Lua JIT — 79/83
Generic for-loops (for k,v in pairs(t) do...end) are now JIT-eligible:
- TFORPREP: jumps to TFORLOOP for initial nil check (same as FORPREP)
- TFORCALL: calls iterator(state, control) via __lua_tfor_call ECALL,
marshals multiple results to guest memory. Uses lua_pcall with the
iterator function reference from the Lua stack.
- TFORLOOP: checks if first result is empty (nil), sets control variable,
branches back or exits.
Multi-return CALL support: when nresults > 1, emit __lua_get_result
ECALLs to fetch additional return values from the Lua stack. This
enables the pairs()/ipairs() setup call which returns 3 values
(iterator, state, initial control).
Block boundary detection updated for TFORPREP/TFORLOOP jump targets.
New ECALL handlers: __lua_tfor_call, __lua_get_result, __lua_getenv.
79 of 83 opcodes handled. Only 4 permanently rejected: CLOSURE,
VARARG, TBC, TAILCALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:54:22 -06:00
|
|
|
int first_result = lua_reg[A + 4];
|
|
|
|
|
if (first_result < 0) return -1;
|
|
|
|
|
|
|
|
|
|
// Check if first result is empty (nil in string form).
|
|
|
|
|
// Use STRLEN-like check: if the string is empty, done.
|
|
|
|
|
int fidx = engine_api_lookup("STRLEN");
|
|
|
|
|
int len_val;
|
|
|
|
|
if (fidx > 0) {
|
|
|
|
|
int args[] = { first_result };
|
|
|
|
|
len_val = h.emit_call(TY_STRING, fidx, args, 1);
|
|
|
|
|
if (len_val < 0) return -1;
|
|
|
|
|
h.known_int[len_val] = true;
|
|
|
|
|
} else {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ATOI the length, check if > 0.
|
|
|
|
|
int len_int = h.emit(HIR_ATOI, TY_INT, len_val);
|
|
|
|
|
if (len_int < 0) return -1;
|
|
|
|
|
int zero = h.emit_iconst(0);
|
|
|
|
|
int cmp = h.emit(HIR_GT, TY_INT, len_int, zero);
|
|
|
|
|
if (cmp < 0) return -1;
|
|
|
|
|
|
feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.
The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.
The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.
`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".
EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly. AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select). This closes the loop half of
#1732 entirely.
luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
|
|
|
// Back-edge budget check. (TFOR protos are rejected at
|
|
|
|
|
// eligibility; this fold-into-the-condition shape is the one
|
|
|
|
|
// #1732 replaced elsewhere and must be reworked like FORLOOP
|
|
|
|
|
// if TFOR is ever lifted.)
|
|
|
|
|
cmp = emit_budget_check(h, cmp, 1);
|
2026-03-27 14:17:18 -06:00
|
|
|
|
Add generic for-loop (TFORPREP/TFORCALL/TFORLOOP) to Lua JIT — 79/83
Generic for-loops (for k,v in pairs(t) do...end) are now JIT-eligible:
- TFORPREP: jumps to TFORLOOP for initial nil check (same as FORPREP)
- TFORCALL: calls iterator(state, control) via __lua_tfor_call ECALL,
marshals multiple results to guest memory. Uses lua_pcall with the
iterator function reference from the Lua stack.
- TFORLOOP: checks if first result is empty (nil), sets control variable,
branches back or exits.
Multi-return CALL support: when nresults > 1, emit __lua_get_result
ECALLs to fetch additional return values from the Lua stack. This
enables the pairs()/ipairs() setup call which returns 3 values
(iterator, state, initial control).
Block boundary detection updated for TFORPREP/TFORLOOP jump targets.
New ECALL handlers: __lua_tfor_call, __lua_get_result, __lua_getenv.
79 of 83 opcodes handled. Only 4 permanently rejected: CLOSURE,
VARARG, TBC, TAILCALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:54:22 -06:00
|
|
|
// If non-nil: set control = first_result, loop back.
|
|
|
|
|
int loop_target = pc + 1 + insn.sBx();
|
|
|
|
|
int exit_target = pc + 1;
|
|
|
|
|
int loop_blk = (loop_target >= 0 && loop_target < n) ? pc_to_block[loop_target] : -1;
|
|
|
|
|
int exit_blk = (exit_target >= 0 && exit_target < n) ? pc_to_block[exit_target] : -1;
|
|
|
|
|
if (loop_blk < 0 || exit_blk < 0) return -1;
|
|
|
|
|
|
|
|
|
|
// Update control variable.
|
|
|
|
|
lua_reg[A + 2] = first_result;
|
|
|
|
|
|
|
|
|
|
h.emit(HIR_BRC, TY_VOID, cmp, exit_blk, loop_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, loop_blk);
|
|
|
|
|
h.add_edge(cur_hir_block, exit_blk);
|
2026-03-18 09:59:36 -06:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- No-op instructions ----
|
2026-03-18 16:45:17 -06:00
|
|
|
case OP_LUA_CLOSE: // No open upvalues without closures.
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_VARARGPREP:
|
2026-03-18 15:37:23 -06:00
|
|
|
case OP_LUA_EXTRAARG:
|
2026-03-18 09:59:36 -06:00
|
|
|
case OP_LUA_MMBIN:
|
|
|
|
|
case OP_LUA_MMBINI:
|
|
|
|
|
case OP_LUA_MMBINK:
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// ---- Unsupported opcodes ----
|
|
|
|
|
default:
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
feat(lua/jit): numeric for loops under an aborting back-edge budget (#1732)
Numeric `for` compiles and runs; `while`/`repeat` (backward JMP) and
generic for (TFOR) still decline. Three things had to be true at once:
RIGHT SEMANTICS. The FORPREP/FORLOOP lowering behind #1326's reject
implemented Lua 5.3 -- signed sBx offsets, init-step pre-subtraction --
against a 5.4 VM, and had never executed. 5.4's FORPREP falls INTO the
body (jumping forward past FORLOOP only on a zero trip count) and
FORLOOP jumps BACK by an unsigned Bx. First cut takes STATIC BOUNDS
only: init/limit/step must be integer constants, so trip direction,
zero-trip, and freedom from wraparound are compile-time facts -- 5.4's
counter model exists precisely because a naive idx<=limit test misses
at the integer edge, and declining the edge is cheaper than reproducing
the counter.
LOOP-CARRIED VALUES. A plain HIR value crosses blocks only under
dominance, and the #1422 transition drops the rest -- fatal for the
accumulator in `for i=1,4 do s=s+i end`. Loop protos now route Lua
registers through q-registers (reg r -> qreg r), the one traffic
hir_ssa_construct PHI-converts: store-at-write after every
non-terminator instruction, reload at every block entry. Backing is
claimed only where every path stores first -- the entry block, or
FORLOOP for its visible index, whose readers the latch dominates. The
first draft skipped reloads for registers still holding entry
CONSTANTS; the harness answered `return s` with the loop INDEX --
dominance is availability, not currency, and inside a loop the entry
value is one iteration stale. Reloads are unconditional now, and
FORLOOP reads its ICONST bounds from entry_final[], the register state
frozen at the entry block's exit.
EXHAUSTION THAT ABORTS. The old budget folded exhaustion into the
loop condition -- an early exit with a WRONG PARTIAL SUM, which is what
#1326 refused to ship. Each back edge now branches to a shared block
whose ECALL_LUA_LIMITED declines the entire run; the caller fails over
to the interpreter, which re-runs the chunk into its own hook and
raises "#-1 LUA ERROR: instruction limit exceeded" -- the player sees
the interpreter's error verbatim, from one budget. The re-run is why
loop protos must be RERUN-SAFE: eligibility rejects calls, SELF and
SETTABUP inside them, and the referent (#1725) declines stores into
global-shaped tables while chunk-local NEWTABLE stores stay compiled.
EXEC pins the accumulator, an order-sensitive a*10+i, and the
zero-iteration path (where a reload of a never-stored qreg would read
the surrounding command's %q). AGREE pins budget exhaustion against
the interpreter's error text. AGREE declines: 5 of 35, every one
deliberate.
luajit: 133 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:09 -06:00
|
|
|
|
|
|
|
|
// Store-at-write (#1732): mirror this instruction's register
|
|
|
|
|
// writes into the q-registers, so the value crosses the next
|
|
|
|
|
// block boundary as PHI-convertible q-reg traffic. Terminator
|
|
|
|
|
// opcodes are skipped -- their block is already closed, and the
|
|
|
|
|
// only terminator that writes a register, FORLOOP, stores its own
|
|
|
|
|
// writes inside its case. Comparisons that FUSE (lua_bool_fuse)
|
|
|
|
|
// also skip; their write stays plain and declines on a later
|
|
|
|
|
// cross-block read rather than answering wrongly.
|
|
|
|
|
if (proto_has_loop) {
|
|
|
|
|
bool is_terminator;
|
|
|
|
|
switch (op) {
|
|
|
|
|
case OP_LUA_JMP: case OP_LUA_FORPREP: case OP_LUA_FORLOOP:
|
|
|
|
|
case OP_LUA_RETURN: case OP_LUA_RETURN0: case OP_LUA_RETURN1:
|
|
|
|
|
case OP_LUA_EQ: case OP_LUA_EQK: case OP_LUA_EQI:
|
|
|
|
|
case OP_LUA_LT: case OP_LUA_LE: case OP_LUA_LTI:
|
|
|
|
|
case OP_LUA_LEI: case OP_LUA_GTI: case OP_LUA_GEI:
|
|
|
|
|
case OP_LUA_TEST:
|
|
|
|
|
is_terminator = true;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
is_terminator = false;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (!is_terminator) {
|
|
|
|
|
for (int r = 0; r < 10 && r < MAX_LUA_REGS; r++) {
|
|
|
|
|
if (lua_reg[r] == pre_reg[r] || lua_reg[r] < 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const bool is_int =
|
|
|
|
|
(h.ty[lua_reg[r]] == TY_INT)
|
|
|
|
|
&& !lua_is_handle(h, lua_reg[r]);
|
|
|
|
|
if (0 == cur_hir_block && !entry_backing_sealed) {
|
|
|
|
|
if (is_int) {
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, lua_reg[r], -1, r);
|
|
|
|
|
qreg_backed[r] = true;
|
|
|
|
|
} else if (forloop_backed[r]) {
|
|
|
|
|
// The loop variable's register holding a
|
|
|
|
|
// non-int before the loop would leave the
|
|
|
|
|
// reload machinery a stale value; decline.
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
} else if (qreg_backed[r] || forloop_backed[r]) {
|
|
|
|
|
// A backed register going non-int would leave a
|
|
|
|
|
// stale integer for the next reload to resurrect.
|
|
|
|
|
if (!is_int) return -1;
|
|
|
|
|
h.emit(HIR_STORE_Q, TY_VOID, lua_reg[r], -1, r);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result_val < 0) return -1;
|
fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):
pc : <test> if (cond ~= k) then pc++
pc+1 : JMP -> pc+3
pc+2 : LFALSESKIP A R[A] := false; pc++ (skips pc+3)
pc+3 : LOADTRUE A R[A] := true
OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk. That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty. `return 1 < 2` compiled to a program whose true arm was a hole.
The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs. Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports). Once every RET got its own output slot the hole
stopped writing anything and they came back empty. False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.
Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.
Three changes:
- insn_leaders() now owns the "which leaders does this instruction
induce" question, and find_block_starts() is expressed in terms of
it, so pass 1 and pass 2 cannot drift about where the blocks are.
OP_LFALSESKIP is added to it, and its lowering emits a real branch to
pc+2 instead of stepping over the leader.
- The four-instruction run is recognised (lua_bool_fuse_at) and fused
to the comparison itself: it computes exactly (cond == k), so no
branch is needed at all. This is what makes `x = a < b` compile
rather than merely stop being wrong -- and it compiles branchless.
A compound condition patches its own jump list into pc+2/pc+3, so the
fuse requires that nothing outside the run enters it; TEST/TESTSET
are excluded because TESTSET also copies R[B] into R[A].
- Lowering now declines any program with a reachable but empty block.
That is the shape of this bug in general, available to any future
opcode that advances pc by hand, and the guard turns the whole class
into a decline instead of a wrong answer (#1501).
Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially). 23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.
Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.
No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:00:53 -06:00
|
|
|
|
|
|
|
|
// Every block a branch can reach must have been lowered into. A
|
|
|
|
|
// reachable-but-empty block means some opcode's control flow was modelled
|
|
|
|
|
// as a linear step and swallowed a block leader whole: the skipped path's
|
|
|
|
|
// body ends up in the wrong block, and the block the branch actually
|
|
|
|
|
// targets is a hole. That was #1421 (OP_LFALSESKIP), and the same shape
|
|
|
|
|
// is available to any future opcode that advances pc by hand. Catching
|
|
|
|
|
// it here makes the whole class decline instead of silently answering
|
|
|
|
|
// with the other arm's value (#1501).
|
|
|
|
|
{
|
|
|
|
|
bool has_insn[HIR_MAX_BLOCKS];
|
|
|
|
|
memset(has_insn, 0, sizeof(has_insn));
|
|
|
|
|
for (int i = 0; i < h.n_insns; i++) {
|
|
|
|
|
int b = h.blk[i];
|
|
|
|
|
if (b >= 0 && b < HIR_MAX_BLOCKS) has_insn[b] = true;
|
|
|
|
|
}
|
|
|
|
|
for (int b = 0; b < h.n_blocks && b < HIR_MAX_BLOCKS; b++) {
|
|
|
|
|
if (!has_insn[b]) continue; // b unreachable itself; harmless
|
|
|
|
|
for (int s = 0; s < h.block_nsucc[b]; s++) {
|
|
|
|
|
int t = h.block_succ[b][s];
|
|
|
|
|
if (t >= 0 && t < h.n_blocks && !has_insn[t]) return -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 09:59:36 -06:00
|
|
|
h.result = result_val;
|
2026-07-25 23:17:08 -06:00
|
|
|
// ecalls/native_ops force a runtime path. Also keep needs_jit if
|
|
|
|
|
// lowering already set it (mux.args → CARGS srefs have no ecall/native
|
|
|
|
|
// count but must not take the folded path with empty sval) (#1309).
|
|
|
|
|
//
|
|
|
|
|
if (h.ecalls > 0 || h.native_ops > 0) {
|
|
|
|
|
h.needs_jit = true;
|
|
|
|
|
} else if (!h.sref_addrs.empty()) {
|
|
|
|
|
h.needs_jit = true;
|
|
|
|
|
}
|
2026-03-18 09:59:36 -06:00
|
|
|
|
|
|
|
|
return result_val;
|
|
|
|
|
}
|