tinymux/mux/modules/engine/hir_codegen.cpp

2947 lines
126 KiB
C++
Raw Permalink Normal View History

/*! \file hir_codegen.cpp
* \brief HIR to RV64 code generation.
*
* RV64 instruction encoding, register allocation (linear scan),
* output buffer allocation (liveness-based), and the hir_codegen()
* function that walks HIR and emits RV64 machine code.
*/
#include "copyright.h"
#include "autoconf.h"
#include "config.h"
#include "externs.h"
#include "dbt_compile.h"
#include "dbt_decoder.h"
#include "engine_api.h"
#include <cstring>
#include <cstdlib>
#include <algorithm>
#include <vector>
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
#include <bitset>
#include <string>
fix(jit): invalidate the persisted code_cache on any tier 1 change (#2061) The SQLite code_cache is keyed on blob_hash (s_blob_version), whose tier 1 leg was a single __DATE__/__TIME__ in jit_compiler.cpp. Its comment stated the assumption: "Because the JIT requires a clean rebuild to take effect, this file is recompiled every such build, so its __DATE__/__TIME__ stamp changes and folds into the hash, invalidating every previously persisted entry automatically." Under incremental make that is false, and false in exactly the case the stamp was added to defend against. Its own comment names "a codegen change in another TU (e.g. hir_codegen.cpp) with no version bump" -- and a change in another TU is precisely when jit_compiler.cpp is NOT recompiled. Measured before the fix: touching hir_lower.cpp rebuilt hir_lower.eo and left jit_compiler.eo untouched, so blob_hash stayed byte-identical at ad598f6b... and every previously persisted entry still matched. The cache then served the previous build's compiled output -- which contaminated a #2052 retest, where TINYMUX_DUMP_HIR counted zero compilations while the old behaviour still ran. On a live game it means attributes keep running the old compiler's output after an upgrade until something evicts them. Each tier 1 unit now carries its own stamp and all of them fold into the hash, so the key moves when any of them is recompiled. Folded into BOTH s_blob_version computations; the no-blob fallback had the identical hole and is the leg a blob-less build runs on. The set is deliberately explicit rather than a glob, because the invalidation model is three-legged and only two legs belong: softlib.rv64 MUST invalidate -- cached RV64 calls into it via a PC-relative JAL to a resolved blob address (hir_codegen.cpp:2262), so moving a blob function makes cached code jump into the middle of something else. Already covered. tier 1 JIT MUST invalidate -- it emits different RV64 for the same softcode. This commit. the DBT MUST NOT invalidate -- it only executes the stored RV64 and is not baked into it (the cache holds guest RV64, never host code). Invalidating for a dbt_*.cpp change would discard the cache for no gain. Verified by rebuilding after touching each unit in turn and reading blob_hash from a fresh database. All six tier 1 units move the key; dbt.cpp and dbt_interp.cpp leave it unchanged -- the negative control matters, since a fix that simply invalidated on everything would pass the positive half and be wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:46:46 -06:00
#include "jit_tier1_stamp.h"
// Tier 1 build stamp for this unit (#2061). Folded into the persisted
// code_cache's staleness key so a codegen change here invalidates entries
// compiled by the previous build. Updates when THIS unit is recompiled,
// which is what makes it work under incremental make.
TIER1_STAMP_DEFINE(TIER1_STAMP_HIR_CODEGEN);
// RV64 instruction encoding
// ---------------------------------------------------------------
static uint32_t rv_i_type(uint8_t opcode, uint8_t rd, uint8_t funct3,
uint8_t rs1, int32_t imm) {
return opcode | (rd << 7) | (funct3 << 12) | (rs1 << 15)
| ((static_cast<uint32_t>(imm) & 0xFFF) << 20);
}
static uint32_t rv_u_type(uint8_t opcode, uint8_t rd, int32_t imm) {
return opcode | (rd << 7) | (static_cast<uint32_t>(imm) & 0xFFFFF000);
}
static uint32_t rv_ADDI(uint8_t rd, uint8_t rs1, int32_t imm) {
return rv_i_type(OP_IMM, rd, ALU_ADDI, rs1, imm);
}
static uint32_t rv_LUI(uint8_t rd, int32_t imm) {
return rv_u_type(OP_LUI, rd, imm);
}
static uint32_t rv_SLLI(uint8_t rd, uint8_t rs1, int32_t shamt) {
return rv_i_type(OP_IMM, rd, ALU_SLLI, rs1, shamt);
}
static uint32_t rv_SRLI(uint8_t rd, uint8_t rs1, int32_t shamt) {
return rv_i_type(OP_IMM, rd, ALU_SRLI, rs1, shamt);
}
static uint32_t rv_ECALL() {
return rv_i_type(OP_SYSTEM, 0, 0, 0, 0);
}
// R-type encoding for register-register ALU ops.
//
static uint32_t rv_r_type(uint8_t opcode, uint8_t rd, uint8_t funct3,
uint8_t rs1, uint8_t rs2, uint8_t funct7) {
return opcode | (rd << 7) | (funct3 << 12) | (rs1 << 15)
| (rs2 << 20) | (static_cast<uint32_t>(funct7) << 25);
}
static uint32_t rv_ADD(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_ADD, rs1, rs2, 0x00);
}
static uint32_t rv_SUB(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_ADD, rs1, rs2, 0x20);
}
// B-type encoding (branches).
//
static uint32_t rv_b_type(uint8_t funct3, uint8_t rs1, uint8_t rs2,
int32_t imm) {
uint32_t u = static_cast<uint32_t>(imm);
return OP_BRANCH
| (((u >> 11) & 1) << 7)
| (((u >> 1) & 0xF) << 8)
| (static_cast<uint32_t>(funct3) << 12)
| (static_cast<uint32_t>(rs1) << 15)
| (static_cast<uint32_t>(rs2) << 20)
| (((u >> 5) & 0x3F) << 25)
| (((u >> 12) & 1) << 31);
}
static uint32_t rv_BEQ(uint8_t rs1, uint8_t rs2, int32_t off) {
return rv_b_type(BR_BEQ, rs1, rs2, off);
}
static uint32_t rv_BNE(uint8_t rs1, uint8_t rs2, int32_t off) {
return rv_b_type(BR_BNE, rs1, rs2, off);
}
static uint32_t rv_BGE(uint8_t rs1, uint8_t rs2, int32_t off) {
return rv_b_type(BR_BGE, rs1, rs2, off);
}
static uint32_t rv_BGEU(uint8_t rs1, uint8_t rs2, int32_t off) {
return rv_b_type(BR_BGEU, rs1, rs2, off);
}
// S-type encoding (stores).
//
static uint32_t rv_SB(uint8_t base, uint8_t src, int32_t off) {
uint32_t u = static_cast<uint32_t>(off);
return OP_STORE
| ((u & 0x1F) << 7)
| (static_cast<uint32_t>(ST_SB) << 12)
| (static_cast<uint32_t>(base) << 15)
| (static_cast<uint32_t>(src) << 20)
| (((u >> 5) & 0x7F) << 25);
}
// Load byte unsigned.
//
static uint32_t rv_LBU(uint8_t rd, uint8_t base, int32_t off) {
return rv_i_type(OP_LOAD, rd, LD_LBU, base, off);
}
// Store doubleword.
//
static uint32_t rv_SD(uint8_t base, uint8_t src, int32_t off) {
uint32_t u = static_cast<uint32_t>(off);
return OP_STORE
| ((u & 0x1F) << 7)
| (static_cast<uint32_t>(ST_SD) << 12)
| (static_cast<uint32_t>(base) << 15)
| (static_cast<uint32_t>(src) << 20)
| (((u >> 5) & 0x7F) << 25);
}
// Load doubleword.
//
static uint32_t rv_LD(uint8_t rd, uint8_t base, int32_t off) {
return rv_i_type(OP_LOAD, rd, LD_LD, base, off);
}
// J-type encoding (JAL).
//
static uint32_t rv_JAL(uint8_t rd, int32_t imm) {
uint32_t u = static_cast<uint32_t>(imm);
return OP_JAL
| (static_cast<uint32_t>(rd) << 7)
| (((u >> 12) & 0xFF) << 12)
| (((u >> 11) & 1) << 20)
| (((u >> 1) & 0x3FF) << 21)
| (((u >> 20) & 1) << 31);
}
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
// Range checks for PC-relative immediates. The B-type and J-type encoders
// silently drop bits that do not fit, so callers must verify the byte
// offset is representable before emitting. Both immediates are even (the
// implicit low bit is 0): JAL is 21-bit signed ([-2^20, +2^20-2]) and the
// B-type branch is 13-bit signed ([-2^12, +2^12-2]).
//
static inline bool rv_jal_offset_ok(int32_t off) {
return off >= -(1 << 20) && off <= ((1 << 20) - 2);
}
static inline bool rv_branch_offset_ok(int32_t off) {
return off >= -(1 << 12) && off <= ((1 << 12) - 2);
}
// Emit a JAL, or mark the compilation out-of-range (forcing the AST
// evaluator to handle the expression) if the byte offset does not fit
// RV64's 21-bit signed immediate. A placeholder is still pushed so the
// surrounding code layout is unchanged; the whole blob is discarded once
// rc.out_exhausted is observed by the compiler driver.
//
static void rv_push_jal(rv_compiler &rc, uint8_t rd, int32_t off) {
if (!rv_jal_offset_ok(off)) {
rc.out_exhausted = true;
}
rc.code.push_back(rv_JAL(rd, off));
}
// Inline string copy: copy NUL-terminated string from src_reg to dest_reg.
// Clobbers t0 (x5). 5 instructions (byte-by-byte loop).
//
static void rv_emit_strcpy(std::vector<uint32_t> &code,
uint8_t dest_reg, uint8_t src_reg) {
constexpr uint8_t t0 = 5;
// loop:
size_t loop = code.size();
code.push_back(rv_LBU(t0, src_reg, 0)); // LBU t0, 0(src)
code.push_back(rv_SB(dest_reg, t0, 0)); // SB t0, 0(dest)
code.push_back(rv_ADDI(src_reg, src_reg, 1)); // src++
code.push_back(rv_ADDI(dest_reg, dest_reg, 1)); // dest++
int32_t off = -static_cast<int32_t>((code.size() - loop) * 4);
code.push_back(rv_BNE(t0, 0, off)); // BNE t0, x0, loop
}
// M extension: MUL, DIV, REM.
//
static uint32_t rv_MUL(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, 0, rs1, rs2, 0x01);
}
static uint32_t rv_DIV(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, 4, rs1, rs2, 0x01);
}
static uint32_t rv_REM(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, 6, rs1, rs2, 0x01);
}
// Bitwise operations.
//
static uint32_t rv_AND(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_AND, rs1, rs2, 0x00);
}
static uint32_t rv_OR(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_OR, rs1, rs2, 0x00);
}
static uint32_t rv_XOR(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_XOR, rs1, rs2, 0x00);
}
static uint32_t rv_SLT(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_SLT, rs1, rs2, 0x00);
}
static uint32_t rv_SLL(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_SLL, rs1, rs2, 0x00);
}
static uint32_t rv_SRL(uint8_t rd, uint8_t rs1, uint8_t rs2) {
return rv_r_type(OP_REG, rd, ALU_SRL, rs1, rs2, 0x00);
}
// D extension: double-precision floating point.
// RV64D uses R-type with opcode=OP_FP, funct7 encodes the operation,
// and rm (funct3) = 0 (RNE) or 7 (dynamic) for arithmetic.
// FLD/FSD use I/S-type with opcode OP_FP_LOAD/OP_FP_STORE, funct3=3.
//
static uint32_t rv_FADD_D(uint8_t fd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, fd, 7, fs1, fs2, 0x01); // funct7=0000001
}
static uint32_t rv_FSUB_D(uint8_t fd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, fd, 7, fs1, fs2, 0x05); // funct7=0000101
}
static uint32_t rv_FMUL_D(uint8_t fd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, fd, 7, fs1, fs2, 0x09); // funct7=0001001
}
static uint32_t rv_FDIV_D(uint8_t fd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, fd, 7, fs1, fs2, 0x0D); // funct7=0001101
}
static uint32_t rv_FSQRT_D(uint8_t fd, uint8_t fs1) {
return rv_r_type(OP_FP, fd, 7, fs1, 0, 0x2D); // funct7=0101101, rs2=0
}
static uint32_t rv_FSGNJN_D(uint8_t fd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, fd, 1, fs1, fs2, 0x11); // funct7=0010001, funct3=1 (FSGNJN)
}
// FNEG.D is a pseudo: FSGNJN.D fd, fs, fs
static uint32_t rv_FNEG_D(uint8_t fd, uint8_t fs) {
return rv_FSGNJN_D(fd, fs, fs);
}
// FCVT.D.L: int64 → double (rs2=2 for L)
static uint32_t rv_FCVT_D_L(uint8_t fd, uint8_t rs1) {
return rv_r_type(OP_FP, fd, 7, rs1, 2, 0x69); // funct7=1101001
}
// FCVT.L.D: double → int64 (rs2=0 for L, rm=1 for RTZ)
static uint32_t rv_FCVT_L_D(uint8_t rd, uint8_t fs1) {
return rv_r_type(OP_FP, rd, 1, fs1, 2, 0x61); // funct7=1100001, rm=RTZ
}
// FMV.X.D: move FP bits to integer register
static uint32_t rv_FMV_X_D(uint8_t rd, uint8_t fs1) {
return rv_r_type(OP_FP, rd, 0, fs1, 0, 0x71); // funct7=1110001
}
// FMV.D.X: move integer bits to FP register
static uint32_t rv_FMV_D_X(uint8_t fd, uint8_t rs1) {
return rv_r_type(OP_FP, fd, 0, rs1, 0, 0x79); // funct7=1111001
}
// FEQ.D: float equality → integer rd
static uint32_t rv_FEQ_D(uint8_t rd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, rd, 2, fs1, fs2, 0x51); // funct7=1010001, funct3=2
}
// FLT.D: float less-than → integer rd
static uint32_t rv_FLT_D(uint8_t rd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, rd, 1, fs1, fs2, 0x51); // funct7=1010001, funct3=1
}
// FLE.D: float less-or-equal → integer rd
static uint32_t rv_FLE_D(uint8_t rd, uint8_t fs1, uint8_t fs2) {
return rv_r_type(OP_FP, rd, 0, fs1, fs2, 0x51); // funct7=1010001, funct3=0
}
// FLD: load double from memory
static uint32_t rv_FLD(uint8_t fd, uint8_t base, int32_t off) {
return rv_i_type(OP_FP_LOAD, fd, 3, base, off);
}
// FSD: store double to memory
static uint32_t rv_FSD(uint8_t base, uint8_t fs, int32_t off) {
// S-type encoding, same as rv_SD but with OP_FP_STORE
return OP_FP_STORE
| ((off & 0x1F) << 7)
| (3 << 12)
| (base << 15)
| (fs << 20)
| (((off >> 5) & 0x7F) << 25);
}
// ---------------------------------------------------------------
// Inline RISC-V atoi: parse decimal string → signed integer.
//
// Input: addr_reg = guest address of NUL-terminated string
// Output: out_reg = signed 64-bit integer
// Clobbers: t0(x5), t1(x6), t2(x7), t3(x28), t4(x29), addr_reg
// 19 instructions.
// ---------------------------------------------------------------
static void rv_emit_atoi(std::vector<uint32_t> &code,
uint8_t addr_reg, uint8_t out_reg) {
constexpr uint8_t t0=5, t1=6, t2=7, t3=28, t4=29;
code.push_back(rv_ADDI(t1, 0, 0)); // 0: acc = 0
code.push_back(rv_ADDI(t2, 0, 0)); // 1: sign = 0
code.push_back(rv_LBU(t0, addr_reg, 0)); // 2: load byte
code.push_back(rv_ADDI(t3, 0, 45)); // 3: t3 = '-'
size_t bne_sign = code.size();
code.push_back(0); // 4: BNE → skip_sign (patch)
code.push_back(rv_ADDI(t2, 0, 1)); // 5: sign = 1
code.push_back(rv_ADDI(addr_reg, addr_reg, 1)); // 6: advance past '-'
// skip_sign:
size_t skip_sign = code.size();
code[bne_sign] = rv_BNE(t0, t3,
static_cast<int32_t>((skip_sign - bne_sign) * 4));
code.push_back(rv_LBU(t0, addr_reg, 0)); // 7: (re)load byte
// digit_loop:
size_t digit_loop = code.size();
code.push_back(rv_ADDI(t4, t0, -48)); // 8: digit = byte - '0'
code.push_back(rv_ADDI(t3, 0, 10)); // 9: t3 = 10
size_t bgeu_done = code.size();
code.push_back(0); // 10: BGEU → done (patch)
code.push_back(rv_MUL(t1, t1, t3)); // 11: acc *= 10
code.push_back(rv_ADD(t1, t1, t4)); // 12: acc += digit
code.push_back(rv_ADDI(addr_reg, addr_reg, 1)); // 13: advance
code.push_back(rv_LBU(t0, addr_reg, 0)); // 14: load next byte
size_t bk = code.size();
code.push_back(rv_BEQ(0, 0, // 15: j digit_loop
static_cast<int32_t>((digit_loop - bk) * 4)));
// done:
size_t done = code.size();
code[bgeu_done] = rv_BGEU(t4, t3,
static_cast<int32_t>((done - bgeu_done) * 4));
size_t beq_pos = code.size();
code.push_back(0); // 16: BEQ → skip_neg (patch)
code.push_back(rv_SUB(t1, 0, t1)); // 17: negate
size_t skip_neg = code.size();
code[beq_pos] = rv_BEQ(t2, 0,
static_cast<int32_t>((skip_neg - beq_pos) * 4));
code.push_back(rv_ADDI(out_reg, t1, 0)); // 18: mv out, acc
}
// ---------------------------------------------------------------
// Inline RISC-V strcmp: compare two NUL-terminated strings.
//
// Input: addr_a, addr_b = guest addresses of the two strings
// Output: out_reg = -1 (a<b), 0 (a==b), 1 (a>b)
// Clobbers: t0(x5), t1(x6), addr_a, addr_b
// ---------------------------------------------------------------
static void rv_emit_strcmp(std::vector<uint32_t> &code,
uint8_t addr_a, uint8_t addr_b,
uint8_t out_reg) {
constexpr uint8_t t0 = 5, t1 = 6;
// loop:
size_t loop = code.size();
code.push_back(rv_LBU(t0, addr_a, 0)); // t0 = *a
code.push_back(rv_LBU(t1, addr_b, 0)); // t1 = *b
size_t bne_differ = code.size();
code.push_back(0); // BNE t0, t1 → differ (patch)
size_t beq_equal = code.size();
code.push_back(0); // BEQ t0, x0 → equal (patch)
code.push_back(rv_ADDI(addr_a, addr_a, 1)); // a++
code.push_back(rv_ADDI(addr_b, addr_b, 1)); // b++
size_t j_loop = code.size();
code.push_back(rv_BEQ(0, 0, // j loop
static_cast<int32_t>((loop - j_loop) * 4)));
// differ: t0 != t1
size_t differ = code.size();
code[bne_differ] = rv_BNE(t0, t1,
static_cast<int32_t>((differ - bne_differ) * 4));
code.push_back(rv_SLT(out_reg, t0, t1)); // out = (a < b) ? 1 : 0
size_t bne_done = code.size();
code.push_back(0); // BNE out, x0 → neg (patch)
code.push_back(rv_ADDI(out_reg, 0, 1)); // out = 1 (a > b)
size_t j_done = code.size();
code.push_back(0); // J → done (patch)
// neg: a < b → out = -1
size_t neg = code.size();
code[bne_done] = rv_BNE(out_reg, 0,
static_cast<int32_t>((neg - bne_done) * 4));
code.push_back(rv_SUB(out_reg, 0, out_reg)); // out = -1 (negate the 1 from SLT... wait, SLT gave 1, so -1 is correct)
// Actually: SLT out, t0, t1 → out=1 if a<b. We want -1 for a<b.
// SUB out, x0, out → out = -1. Correct.
size_t j_done2 = code.size();
code.push_back(0); // J → done (patch)
// equal: both NUL
size_t equal = code.size();
code[beq_equal] = rv_BEQ(t0, 0,
static_cast<int32_t>((equal - beq_equal) * 4));
code.push_back(rv_ADDI(out_reg, 0, 0)); // out = 0
// done:
size_t done = code.size();
code[j_done] = rv_BEQ(0, 0,
static_cast<int32_t>((done - j_done) * 4));
code[j_done2] = rv_BEQ(0, 0,
static_cast<int32_t>((done - j_done2) * 4));
}
// ---------------------------------------------------------------
// Inline RISC-V itoa: signed integer → decimal string.
//
// Input: val_reg = signed 64-bit integer
// buf_reg = guest address of output buffer (≥21 bytes)
// Output: NUL-terminated string at buf_reg
// Clobbers: t0(x5), t1(x6), t2(x7), t3(x28), t4(x29),
// t5(x30), t6(x31), buf_reg
// 30 instructions.
// ---------------------------------------------------------------
static void rv_emit_itoa(std::vector<uint32_t> &code,
uint8_t val_reg, uint8_t buf_reg) {
constexpr uint8_t t0=5, t1=6, t2=7, t3=28, t4=29, t5=30, t6=31;
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
// Digits are accumulated in NEGATIVE space (#1326 / TC059).
//
// The obvious shape -- write '-', negate, then emit '0' + (v % 10) -- is
// wrong for exactly one input. -INT64_MIN is not representable, so the
// negation wraps and leaves the value negative; REM then yields negative
// digits and '0' + (-d) writes the character d places BELOW '0'.
// -9223372036854775808 came out as -'..--).0-*(+,))+(0(, each byte off
// by twice its digit.
//
// Negating a POSITIVE value can never overflow, so normalize the other
// way: make the value non-positive and negate each digit instead, where
// the magnitude is at most 9. INT64_MIN then needs no special case.
//
code.push_back(rv_ADDI(t0, buf_reg, 0)); // 0: wr = buf
code.push_back(rv_ADDI(t1, val_reg, 0)); // 1: t1 = val
size_t bge_pos = code.size();
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
code.push_back(0); // 2: BGE → nonneg (patch)
code.push_back(rv_ADDI(t4, 0, 45)); // 3: t4 = '-'
code.push_back(rv_SB(t0, t4, 0)); // 4: write '-'
code.push_back(rv_ADDI(t0, t0, 1)); // 5: advance wr
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
size_t j_norm = code.size();
code.push_back(0); // 6: J → skip_neg (patch)
// nonneg: value is >= 0, so negating it is safe and makes both paths
// agree that t1 <= 0 from here on.
size_t nonneg = code.size();
code[bge_pos] = rv_BGE(t1, 0,
static_cast<int32_t>((nonneg - bge_pos) * 4));
code.push_back(rv_SUB(t1, 0, t1)); // 7: t1 = -t1
// skip_neg:
size_t skip_neg = code.size();
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
code[j_norm] = rv_BEQ(0, 0,
static_cast<int32_t>((skip_neg - j_norm) * 4));
code.push_back(rv_ADDI(t5, t0, 0)); // 7: digit_start = wr
size_t bne_nz = code.size();
code.push_back(0); // 8: BNE → digit_loop (patch)
code.push_back(rv_ADDI(t4, 0, 48)); // 9: '0'
code.push_back(rv_SB(t0, t4, 0)); // 10: write '0'
code.push_back(rv_ADDI(t0, t0, 1)); // 11: advance
size_t beq_nul = code.size();
code.push_back(0); // 12: BEQ → nul_term (patch)
// digit_loop:
size_t digit_loop = code.size();
code[bne_nz] = rv_BNE(t1, 0,
static_cast<int32_t>((digit_loop - bne_nz) * 4));
code.push_back(rv_ADDI(t3, 0, 10)); // 13: t3 = 10
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
code.push_back(rv_REM(t2, t1, t3)); // 14: t2 = val % 10 (<= 0)
code.push_back(rv_DIV(t1, t1, t3)); // 15: t1 = val / 10 (<= 0)
code.push_back(rv_SUB(t2, 0, t2)); // 15a: digit = -t2, 0..9
code.push_back(rv_ADDI(t2, t2, 48)); // 16: '0' + digit
code.push_back(rv_SB(t0, t2, 0)); // 17: write digit
code.push_back(rv_ADDI(t0, t0, 1)); // 18: advance
code.push_back(rv_BNE(t1, 0, // 19: loop if more
static_cast<int32_t>((digit_loop - (code.size())) * 4)));
// nul_term:
size_t nul_term = code.size();
code[beq_nul] = rv_BEQ(0, 0,
static_cast<int32_t>((nul_term - beq_nul) * 4));
code.push_back(rv_SB(t0, 0, 0)); // 20: write '\0'
code.push_back(rv_ADDI(t6, t0, -1)); // 21: end = wr - 1
// reverse_loop:
size_t rev_loop = code.size();
size_t bge_rev = code.size();
code.push_back(0); // 22: BGE → done (patch)
code.push_back(rv_LBU(t3, t5, 0)); // 23: t3 = *start
code.push_back(rv_LBU(t4, t6, 0)); // 24: t4 = *end
code.push_back(rv_SB(t5, t4, 0)); // 25: *start = t4
code.push_back(rv_SB(t6, t3, 0)); // 26: *end = t3
code.push_back(rv_ADDI(t5, t5, 1)); // 27: start++
code.push_back(rv_ADDI(t6, t6, -1)); // 28: end--
code.push_back(rv_BEQ(0, 0, // 29: j reverse_loop
static_cast<int32_t>((rev_loop - (code.size())) * 4)));
// done:
size_t done = code.size();
code[bge_rev] = rv_BGE(t5, t6,
static_cast<int32_t>((done - bge_rev) * 4));
}
// Emit LUI+ADDI (or ADDI alone) that materializes the low 32 bits of
// `bits` into rd. On RV64 the result is sign-extended from bit 31.
//
static void rv_load_i32_bits(std::vector<uint32_t> &code, uint8_t rd,
uint32_t bits) {
int32_t sval = static_cast<int32_t>(bits);
if (sval >= -2048 && sval <= 2047) {
code.push_back(rv_ADDI(rd, 0, sval));
return;
}
uint32_t hi = bits & 0xFFFFF000u;
int32_t lo = static_cast<int32_t>(bits & 0xFFFu);
if (lo & 0x800) {
hi += 0x1000u;
lo -= 0x1000;
}
code.push_back(rv_LUI(rd, static_cast<int32_t>(hi)));
if (lo) {
code.push_back(rv_ADDI(rd, rd, lo));
}
}
// Load a signed 64-bit value into a register.
//
// Fits-in-12 → ADDI; fits-in-signed-32 → LUI+ADDI; otherwise a two-half
// sequence (load high, slli 32, OR zero-extended low). Uses t0 (x5) as a
// temporary, or t1 (x6) when rd is t0.
//
static void rv_load_i64(std::vector<uint32_t> &code, uint8_t rd, int64_t val) {
if (val >= -2048 && val <= 2047) {
code.push_back(rv_ADDI(rd, 0, static_cast<int32_t>(val)));
return;
}
if (val >= -2147483648LL && val <= 2147483647LL) {
rv_load_i32_bits(code, rd, static_cast<uint32_t>(static_cast<int32_t>(val)));
return;
}
// Full 64-bit immediate: hi << 32 | lo.
const uint64_t u = static_cast<uint64_t>(val);
const uint32_t lo = static_cast<uint32_t>(u);
const uint32_t hi = static_cast<uint32_t>(u >> 32);
const uint8_t tmp = (rd == 5) ? 6 : 5; // t0, else t1 if rd is t0
rv_load_i32_bits(code, rd, hi);
code.push_back(rv_SLLI(rd, rd, 32));
if (lo != 0) {
rv_load_i32_bits(code, tmp, lo);
// LUI+ADDI sign-extends; clear upper 32 so OR cannot smear into hi.
if (lo & 0x80000000u) {
code.push_back(rv_SLLI(tmp, tmp, 32));
code.push_back(rv_SRLI(tmp, tmp, 32));
}
code.push_back(rv_OR(rd, rd, tmp));
}
}
// Load a value into a register using LUI + ADDI.
//
static void rv_load_val(std::vector<uint32_t> &code, uint8_t rd,
uint64_t val) {
if (val == 0) {
code.push_back(rv_ADDI(rd, 0, 0));
return;
}
int32_t sval = static_cast<int32_t>(val);
if (sval >= -2048 && sval <= 2047 && val == static_cast<uint64_t>(static_cast<uint32_t>(sval))) {
code.push_back(rv_ADDI(rd, 0, sval));
return;
}
uint32_t hi = static_cast<uint32_t>(val) & 0xFFFFF000;
int32_t lo = static_cast<int32_t>(val & 0xFFF);
if (lo & 0x800) {
hi += 0x1000;
lo = lo - 0x1000;
}
code.push_back(rv_LUI(rd, hi));
if (lo) code.push_back(rv_ADDI(rd, rd, lo));
}
static void rv_load_guest_addr(std::vector<uint32_t> &code, uint8_t rd,
uint64_t addr) {
if (!rv_compiler::is_output_frame_ref(addr)) {
rv_load_val(code, rd, addr);
return;
}
rv_load_val(code, rd, rv_compiler::output_frame_delta(addr));
code.push_back(rv_SUB(rd, 8, rd)); // rd = frame_top - delta
}
// Emit runtime patching of fargs entries that contain frame-relative
// output references. For each tagged entry, resolves it using s0
// (frame pointer) and stores the resolved address back into the
// fargs array in guest memory.
//
// Uses t0 (x5) and t1 (x6) as temporaries.
//
static void rv_patch_fargs(std::vector<uint32_t> &code,
uint64_t fargs_addr,
const std::vector<uint64_t> &farg_addrs) {
for (size_t j = 0; j < farg_addrs.size(); j++) {
if (rv_compiler::is_output_frame_ref(farg_addrs[j])) {
// t0 = resolved address (s0 - delta)
rv_load_val(code, 5, rv_compiler::output_frame_delta(farg_addrs[j]));
code.push_back(rv_SUB(5, 8, 5)); // t0 = s0 - delta
// Store resolved address into fargs[j]
rv_load_val(code, 6, fargs_addr + j * 8); // t1 = &fargs[j]
code.push_back(rv_SD(6, 5, 0)); // *t1 = t0
}
}
}
// Emit ECALL to call a function.
//
// If func_idx > 0, uses indexed dispatch (ECALL_CALL_INDEX, a0 = index).
// Otherwise, uses string dispatch (ECALL_CALL_FUNC, a0 = name_addr).
//
static void rv_emit_call(std::vector<uint32_t> &code,
uint64_t name_addr, uint64_t fargs_addr,
int nfargs, uint64_t out_addr, int out_size,
int func_idx = 0) {
if (func_idx > 0) {
// Indexed dispatch — no string lookup at runtime.
code.push_back(rv_ADDI(17, 0, 0x101)); // a7 = ECALL_CALL_INDEX
rv_load_val(code, 10, func_idx); // a0 = function index
} else {
// String-based dispatch (fallback).
code.push_back(rv_ADDI(17, 0, 0x100)); // a7 = ECALL_CALL_FUNC
rv_load_val(code, 10, name_addr); // a0 = name
}
rv_load_val(code, 11, fargs_addr); // a1 = fargs
code.push_back(rv_ADDI(12, 0, nfargs)); // a2 = nfargs
rv_load_guest_addr(code, 13, out_addr); // a3 = output
rv_load_val(code, 14, out_size); // a4 = outsize
code.push_back(rv_ECALL());
}
static void rv_emit_exit(std::vector<uint32_t> &code) {
code.push_back(rv_ADDI(17, 0, ECALL_EXIT));
code.push_back(rv_ADDI(10, 0, 0));
code.push_back(rv_ECALL());
}
// Emit a Tier 2 call: JAL to pre-compiled blob function.
// Calling convention: a0=output, a1=fargs, a2=nfargs.
// Return value in a0 (pointer to output buffer).
//
static void rv_emit_tier2_call(rv_compiler &rc,
uint64_t fargs_addr, int nfargs,
uint64_t out_addr, uint64_t func_guest_addr) {
rv_load_guest_addr(rc.code, 10, out_addr); // a0 = output
rv_load_val(rc.code, 11, fargs_addr); // a1 = fargs
rc.code.push_back(rv_ADDI(12, 0, nfargs)); // a2 = nfargs
// JAL ra, target — offset relative to current PC.
uint64_t cur_pc = rc.current_pc();
int32_t offset = static_cast<int32_t>(func_guest_addr - cur_pc);
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
rv_push_jal(rc, 1, offset); // JAL ra, blob_func
}
// ---------------------------------------------------------------
// Walks the HIR instruction array and emits RV64 instructions.
// Each HIR instruction gets a "location" — either a guest memory
// address (TY_STRING) or an RV64 register (TY_INT).
// ===============================================================
struct hir_loc {
uint64_t addr; // guest memory address (for strings)
uint8_t reg; // RV64 register (for integers)
bool in_reg; // true if value is in a register
int spill_slot; // -1 = not spilled, >=0 = stack slot index
};
// Branch patch record for backpatching.
struct branch_patch {
int code_idx; // index into rc.code
int target_blk; // target block number
};
// ---------------------------------------------------------------
// Register allocation: linear scan over SSA live ranges
//
// Poletto-Sarkar algorithm. Computes live intervals for all
// integer-typed SSA values, then assigns the 11 saved registers
// (s1-s11). When register pressure exceeds 11, the interval
// ending furthest in the future is spilled to the RV64 stack.
// ---------------------------------------------------------------
// Allocatable integer registers: s1-s10 (x9, x18-x26).
// s11 (x27) is reserved as RA_SCRATCH.
static constexpr int RA_NUM_REGS = 10;
static constexpr uint8_t RA_REGS[RA_NUM_REGS] = {
9, 18, 19, 20, 21, 22, 23, 24, 25, 26
};
// Scratch register for spill/reload (s11 = x27, callee-saved).
// Must be callee-saved to survive across ECALL and Tier 2 JAL calls.
// Must not conflict with rv_emit_itoa/strcpy/atoi helpers (which use t0-t2).
static constexpr uint8_t RA_SCRATCH = 27;
// Frame-top register: incoming SP captured in the prologue (s0 = x8).
static constexpr uint8_t RA_FRAME_TOP = 8;
// Second scratch for two-operand instructions (t3 = x28).
// Safe because arithmetic ops don't call atoi/itoa.
static constexpr uint8_t RA_SCRATCH2 = 28;
struct live_interval {
int value; // HIR instruction index (SSA value number)
int start; // program point of definition
int end; // program point of last use (inclusive)
};
struct reg_alloc_result {
uint8_t reg[HIR_MAX_INSNS]; // assigned register (0 = spilled/none)
int spill_slot[HIR_MAX_INSNS]; // -1 = not spilled
int n_spill_slots; // total spill slots used
};
static bool needs_output_buffer(hir_program &h, int i) {
perf(jit): integers cross the tier-2 boundary as integers (#2132) The compiled ITER loop lost to the interpreter's C loop per element, and its per-element cost climbed with N while the interpreter's stayed flat. Profiling (guest->host block map + ELF symbolization of anonymous JIT frames) found the mechanism the issue asked for: the tier-2 string ABI. Every argument crosses as decimal text, so each element paid SIX integer<->string conversions — ITOA(cursor) caller-side, satoi(cursor) callee-side twice (elem + next modes), sitoa/ATOI for the next offset, ITOA/satoi for the append's length and iteration number, sitoa/ATOI for the new length — digit-proportional loops, executed under DBT expansion, on numbers (byte cursors, accumulated lengths) whose digit counts grow with the list. That is both the bulk of per-element cost and the whole of the residual superlinearity: cost/element ~ a + b*digits(N). The fix is the calling convention the machine already has. HIR_CALL_T2I loads TY_INT arguments straight into a0.. from their registers and TY_STRING arguments as guest addresses, JALs to the blob, and takes the callee's long return from a0; arguments ride carg[] so liveness, DCE and copy propagation see them (hir_is_carg_call). val[i]=1 allocates an output slot passed ahead of the args; HIR_T2I_STR aliases it as the element string. Two int-native blob entrypoints replace the string pair: rv64_split_step (ONE call per element instead of two — element written to out, next cursor returned in a0) and rv64_append_i. The next cursor stays an SSA value stored in the latch, preserving the nested-iter safety the string route had. Measured (macOS arm64, min-of-5, ast=/cached= per CLAUDE.md): iter(lnum(N),1) us/element ratio vs interpreter N before after before after 200 0.117 0.030 0.75 0.19 1000 0.125 0.030 0.81 0.20 4000 0.145 0.033 0.89 0.21 4x on the compiled path; the loop that opened the issue LOSING 1.42x on Linux is 5x FASTER than the interpreter here. Fixed-width N-pair probes no longer climb (median ratio 0.90, was 1.09 at 10/10 above 1.0), and the per-char slope halves (one scan per element, not two). MAP/FILTER still compose the string route; converting them is the follow-up. Two silent-fallback traps found en route, each now carrying a warning: - tier2_allowed()'s allowlist quietly vetoed the new names: lookup returned 0, the lowering kept its graceful string fallback, and every test passed while the fix did not run. Ground truth came from disassembling the compiled program out of the SQLite code cache — the JAL targets do not lie. The allowlist comment now names this failure shape. - A same-mtime-second edit after a build produced an engine that had the new symbols in source and nowhere in the binary (#2118's genre). Also rides along: the env-gated profiling diagnostics that found this — TINYMUX_DBT_MAP (guest-pc -> host-address lines at translate time) and TINYMUX_DBT_CODEDUMP (code_buf + block-cache table at cleanup), which together let a sampling profiler's anonymous JIT frames be symbolized against the blob ELF. Full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped (stubslave, not configured), 0 failed — including smoke's 1660 goldens, the jit parity suites, and the format guard. Functional probes cover nested iter, #@/##, custom in/out separators, runtime leading spaces, and empty lists, all exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:58:55 -06:00
// CALL_T2I is TY_INT (the callee's return) but SPLIT_STEP-shaped calls
// still write their string result into an output slot passed as a0 —
// val[i] carries that flag (#2132). HIR_T2I_STR deliberately does NOT
// allocate: it aliases its call's slot.
if (h.kind[i] == HIR_CALL_T2I) return h.val[i] != 0;
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 represented as a string buffer (#1579); only its
// semantics differ, and those are enforced in the Lua lowerer.
if (h.ty[i] != TY_STRING && h.ty[i] != TY_LUA_HANDLE) return false;
switch (h.kind[i]) {
case HIR_CALL:
case HIR_STRCAT:
case HIR_ITOA:
case HIR_FTOA:
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 HIR_LUA_FTOA:
// CALL_STR writes the library result into this slot and passes
// OUT_SLOT as the bound (#1519 / #1679). Omitting it leaves
// loc[i].addr at 0 so every CALL_STR aliases guest address 0 —
// sequential single-result tests still pass by luck.
//
case HIR_LUA_CALL_STR:
case HIR_LUA_MARSHAL:
case HIR_PHI:
case HIR_COPY:
return true;
default:
return false;
}
}
struct output_alloc_result {
uint64_t addr[HIR_MAX_INSNS];
};
static output_alloc_result allocate_output_buffers(rv_compiler &rc,
std::vector<live_interval> &intervals) {
output_alloc_result result;
memset(result.addr, 0, sizeof(result.addr));
if (intervals.empty()) return result;
std::sort(intervals.begin(), intervals.end(),
[](const live_interval &a, const live_interval &b) {
return a.start < b.start;
});
struct active_entry {
int end;
int value;
uint64_t addr;
};
std::vector<active_entry> active;
std::vector<uint64_t> free_pool;
for (auto &iv : intervals) {
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
// Defensive: every result.* array is sized HIR_MAX_INSNS and
// indexed by the HIR value number. The HIR builder caps n_insns
// at HIR_MAX_INSNS, so this never fires today, but a future pass
// that synthesizes virtuals out of band must not silently corrupt
// adjacent allocator state — bail the compile instead.
if (iv.value < 0 || iv.value >= HIR_MAX_INSNS) {
rc.out_exhausted = true;
continue;
}
size_t j = 0;
while (j < active.size()) {
if (active[j].end >= iv.start) break;
free_pool.push_back(active[j].addr);
active.erase(active.begin() + j);
}
uint64_t addr;
if (!free_pool.empty()) {
addr = free_pool.back();
free_pool.pop_back();
} else {
addr = rc.alloc_output();
if (addr == 0) break;
}
result.addr[iv.value] = addr;
active_entry ae = {iv.end, iv.value, addr};
auto pos = std::lower_bound(active.begin(), active.end(), ae,
[](const active_entry &a, const active_entry &b) {
return a.end < b.end;
});
active.insert(pos, ae);
}
return result;
}
// Returns true if HIR instruction i produces an integer that needs
// a register.
//
static bool needs_int_reg(hir_program &h, int i) {
switch (h.kind[i]) {
case HIR_ICONST:
case HIR_ATOI:
case HIR_STRCMP:
perf(jit): integers cross the tier-2 boundary as integers (#2132) The compiled ITER loop lost to the interpreter's C loop per element, and its per-element cost climbed with N while the interpreter's stayed flat. Profiling (guest->host block map + ELF symbolization of anonymous JIT frames) found the mechanism the issue asked for: the tier-2 string ABI. Every argument crosses as decimal text, so each element paid SIX integer<->string conversions — ITOA(cursor) caller-side, satoi(cursor) callee-side twice (elem + next modes), sitoa/ATOI for the next offset, ITOA/satoi for the append's length and iteration number, sitoa/ATOI for the new length — digit-proportional loops, executed under DBT expansion, on numbers (byte cursors, accumulated lengths) whose digit counts grow with the list. That is both the bulk of per-element cost and the whole of the residual superlinearity: cost/element ~ a + b*digits(N). The fix is the calling convention the machine already has. HIR_CALL_T2I loads TY_INT arguments straight into a0.. from their registers and TY_STRING arguments as guest addresses, JALs to the blob, and takes the callee's long return from a0; arguments ride carg[] so liveness, DCE and copy propagation see them (hir_is_carg_call). val[i]=1 allocates an output slot passed ahead of the args; HIR_T2I_STR aliases it as the element string. Two int-native blob entrypoints replace the string pair: rv64_split_step (ONE call per element instead of two — element written to out, next cursor returned in a0) and rv64_append_i. The next cursor stays an SSA value stored in the latch, preserving the nested-iter safety the string route had. Measured (macOS arm64, min-of-5, ast=/cached= per CLAUDE.md): iter(lnum(N),1) us/element ratio vs interpreter N before after before after 200 0.117 0.030 0.75 0.19 1000 0.125 0.030 0.81 0.20 4000 0.145 0.033 0.89 0.21 4x on the compiled path; the loop that opened the issue LOSING 1.42x on Linux is 5x FASTER than the interpreter here. Fixed-width N-pair probes no longer climb (median ratio 0.90, was 1.09 at 10/10 above 1.0), and the per-char slope halves (one scan per element, not two). MAP/FILTER still compose the string route; converting them is the follow-up. Two silent-fallback traps found en route, each now carrying a warning: - tier2_allowed()'s allowlist quietly vetoed the new names: lookup returned 0, the lowering kept its graceful string fallback, and every test passed while the fix did not run. Ground truth came from disassembling the compiled program out of the SQLite code cache — the JAL targets do not lie. The allowlist comment now names this failure shape. - A same-mtime-second edit after a build produced an engine that had the new symbols in source and nowhere in the binary (#2118's genre). Also rides along: the env-gated profiling diagnostics that found this — TINYMUX_DBT_MAP (guest-pc -> host-address lines at translate time) and TINYMUX_DBT_CODEDUMP (code_buf + block-cache table at cleanup), which together let a sampling profiler's anonymous JIT frames be symbolized against the blob ELF. Full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped (stubslave, not configured), 0 failed — including smoke's 1660 goldens, the jit parity suites, and the format guard. Functional probes cover nested iter, #@/##, custom in/out separators, runtime leading spaces, and empty lists, all exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:58:55 -06:00
case HIR_CALL_T2I: // the callee's long return, in a0 (#2132)
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
// NEWTABLE's result is a Lua stack index -- an integer as far as the
// machine is concerned, and it must live in a register for SETI/GETI to
// consume. Omitting it here does not fail loudly: the codegen's
// `if (!dest) break;` emits nothing, so the ECALL silently never runs
// and the consumer reads a garbage index (measured: SETI got idx=0).
case HIR_LUA_NEWTABLE:
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
case HIR_LUA_LEN:
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
case HIR_LUA_INSN_BUDGET:
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
case HIR_LUA_GETGLOBAL:
case HIR_LUA_GETFIELD_REF:
case HIR_LUA_CALL_INT:
case HIR_LUA_CALL_VAL:
case HIR_LUA_TOBOOL:
case HIR_LUA_EQ:
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
case HIR_LUA_GETFIELD:
case HIR_LUA_GETI:
case HIR_LUA_ALOAD:
case HIR_ADD: case HIR_SUB: case HIR_MUL: case HIR_DIV: case HIR_REM:
case HIR_NEG: case HIR_SIGN:
case HIR_MAX: case HIR_MIN:
case HIR_EQ: case HIR_NE: case HIR_GT: case HIR_LT:
case HIR_GE: case HIR_LE:
case HIR_NOT: case HIR_BOOL:
case HIR_INC: case HIR_DEC:
case HIR_BAND: case HIR_BOR: case HIR_BXOR: case HIR_BNOT:
case HIR_SHL: case HIR_SHR:
case HIR_FTOI: // float → int produces integer
case HIR_FEQ: case HIR_FLT: case HIR_FLE: // float cmp → int 0/1
return true;
case HIR_PHI:
return h.ty[i] == TY_INT;
case HIR_COPY:
return h.ty[i] == TY_INT;
default:
return false;
}
}
// Returns true if HIR instruction i produces a float that needs
// an FP register (spilled to guest memory).
//
static bool needs_fp_reg(hir_program &h, int i) {
switch (h.kind[i]) {
case HIR_FCONST:
case HIR_FADD: case HIR_FSUB: case HIR_FMUL: case HIR_FDIV:
case HIR_FNEG: case HIR_FSQRT:
case HIR_ITOF:
case HIR_ATOF:
case HIR_FCALL1: case HIR_FCALL2:
2026-07-28 16:44:16 -06:00
// The first Lua opcode producing TY_FLOAT. Omitting a float producer
// here is the FP twin of the needs_int_reg() trap above: no slot is
// allocated, loc[].addr stays 0, and the FSD after the ECALL silently
// writes guest address 0 (#1159's failure shape).
case HIR_LUA_GETFIELD_FLT:
return true;
case HIR_PHI:
return h.ty[i] == TY_FLOAT;
case HIR_COPY:
return h.ty[i] == TY_FLOAT;
default:
return false;
}
}
// Compute live intervals for values passing the filter.
//
static void compute_live_ranges(hir_program &h,
std::vector<live_interval> &intervals,
bool (*filter)(hir_program &, int)) {
// Assign program points in codegen order (blocks in layout order,
// instructions within each block in order).
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
//
// Layout order is NOT execution order: the lowering can allocate an
// outer loop's latch block before the blocks of an inner loop it
// encloses (nested iter() does), and RPO is no help — a reverse
// postorder only topologically orders the acyclic part and may also
// place the latch before the loop body. The natural-loop closure
// below is what makes intervals over this order sound.
int prog_point[HIR_MAX_INSNS];
int block_end_pp[HIR_MAX_BLOCKS];
memset(prog_point, -1, sizeof(int) * h.n_insns);
int pp = 0;
for (int b = 0; b < h.n_blocks; b++) {
if (h.block_first[b] <= h.block_last[b]) {
for (int i = h.block_first[b]; i <= h.block_last[b]; i++) {
if (h.blk[i] == b) {
prog_point[i] = pp++;
}
}
}
block_end_pp[b] = pp++; // virtual point at end of block
}
int max_pp = pp;
// Find last use program point for each value.
int last_use[HIR_MAX_INSNS];
memset(last_use, -1, sizeof(int) * h.n_insns);
for (int i = 0; i < h.n_insns; i++) {
if (prog_point[i] < 0) continue;
int pp_i = prog_point[i];
refactor(jit): one operand walk instead of four (#1519) The call path in #1713 proved the IR could not express what the upper layer needed, and named it: an operand list every pass can walk without knowing the opcode. This is that, at the access layer rather than the storage layer. FOUR copies of one walk existed -- src1, src2 with a BRC exception, val[], then the carg[] loop: hir_opt.cpp copy propagation hir_opt.cpp dead code elimination hir_codegen.cpp liveness / last-use hir_codegen.cpp loop live-range extension Each new operand shape had to be added to all four, and missing one does not fail loudly: the live range ends at the definition and the register allocator hands the register to something else while the ECALL still expects it. CALL_INT's second argument was invisible to some of them until 20d39472f. Now: hir_operand_count / hir_operand_get / hir_operand_set, and the four walks become for (int sl = 0; sl < hir_operand_count(h, i); sl++) ... hir_operand_get(h, i, sl) ... Three pieces of scattered knowledge move into those functions: BRC keeps a BLOCK NUMBER in src2, not an operand. DCE knew and skipped it; the liveness walks each re-derived it; nothing enforced it. val[] is a plain index for SETI/SETFIELD and PACKED with nargs for CALL_INT. copy propagation wrote `h.val[i] = r` unconditionally, which preserves neither. Latent rather than observed -- I could not construct a chunk where resolve_copy fires on a call argument -- and exactly the class a hand-rolled walk keeps re-introducing. hir_operand_set now re-packs. Argument lists live in carg[] for CALL/STRCAT and pval[] for PHI. What deliberately did NOT change: hir_codegen's per-opcode case blocks still read src1/src2/val[] directly. Codegen for HIR_LUA_SETI should know where SETI keeps things. The rule is that GENERIC passes use the accessor and OPCODE-SPECIFIC code knows its own opcode. Access layer, not storage. The failure mode -- a pass silently missing an operand -- is gone without moving any data, so the existing suite validates it cheaply. If storage does need to change later, every generic pass now goes through one interface and that change becomes mechanical. Behaviour identical: 8 executing / 26 declining before and after, no wrong answers, JIT q-register and ifelse oracles agree. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:16:48 -06:00
// Every operand extends its definition's live range to here.
//
// This was three hand-rolled cases -- src1, src2 with a BRC
// exception, val[], then the carg[] loop -- duplicated from the two
// walks in hir_opt.cpp. An operand shape the walk missed did not
// fail loudly: the live range ended at the definition and the
// allocator handed the register to something else while the ECALL
// still expected it. The accessor knows every slot, including that
// BRC's src2 is a block number.
for (int sl = 0; sl < hir_operand_count(h, i); sl++) {
int a = hir_operand_get(h, i, sl);
if (a >= 0 && a < h.n_insns && pp_i > last_use[a]) {
last_use[a] = pp_i;
}
}
// PHI arguments: value is used at end of predecessor block.
if (h.kind[i] == HIR_PHI) {
for (int j = 0; j < h.pnargs[i]; j++) {
int val = h.pval[h.pbase[i] + j];
int pred_blk = h.pblk[h.pbase[i] + j];
if (val >= 0 && val < h.n_insns &&
pred_blk >= 0 && pred_blk < h.n_blocks) {
int end_pp = block_end_pp[pred_blk];
if (end_pp > last_use[val])
last_use[val] = end_pp;
}
}
}
}
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
// Execution-reachability liveness closure (#2161).
//
// Linear scan needs every interval to be a superset of the value's
// true live range under the chosen linearization — and BLOCK LAYOUT
// ORDER IS NOT EXECUTION ORDER. The lowering allocates an outer
// loop's latch before the blocks of an inner loop it encloses, and
// allocates the program's result-assembly block before later loops
// (this whole program's final STRCAT sat in block 27 while the last
// iter's loop blocks were 28-33). Two observed corruptions, one
// cause:
//
// - a value defined in an outer iter body and used in its latch had
// an interval HOLE where the inner loop's points sit; the
// allocator handed its register to inner-loop values, the latch
// stored inner_final+1, and a triple-nested iter lost a level.
// - a bracket's words() result died at the final STRCAT's layout
// point; loops with HIGHER layout points but EARLIER execution
// recycled its output slot, and the result read a later lnum's
// leftovers.
//
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
// (RPO cannot fix the ordering either: a reverse postorder may also
// place a latch before the body it follows at runtime.)
//
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
// The uniform sound rule: for every use of v, v must be live in every
// block that can execute between the def and that use — every B with
// def-block -> B reachable and B -> use-block reachable, back edges
// included. Cycles make a loop's blocks mutually reachable, so this
// subsumes the loop case; straight-line out-of-order blocks are just
// the acyclic instance. Conservative — some intervals get longer and
// some values spill that did not before — and that is the correct
// direction.
{
// reach[b] = blocks reachable from b, reflexive.
static thread_local std::bitset<HIR_MAX_BLOCKS> reach[HIR_MAX_BLOCKS];
for (int b = 0; b < h.n_blocks; b++) {
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
reach[b].reset();
reach[b].set(b);
}
bool changed = true;
while (changed) {
changed = false;
for (int b = 0; b < h.n_blocks; b++) {
for (int s = 0; s < h.block_nsucc[b]; s++) {
int t = h.block_succ[b][s];
if (t < 0 || t >= h.n_blocks) continue;
std::bitset<HIR_MAX_BLOCKS> merged = reach[b] | reach[t];
if (merged != reach[b]) {
reach[b] = merged;
changed = true;
}
}
}
fix(#2161): nested iter() loses a level — liveness intervals had layout-order holes Block LAYOUT order is not EXECUTION order. The lowering allocates an outer iter's latch block before the blocks of an inner loop it encloses, and the program's result-assembly block before later loops. Linear-scan intervals were computed over layout points, so a value defined in an outer body and used in its latch was considered dead exactly where the inner loop's points sit — the allocator handed its register to inner-loop values, the latch stored inner_final+1 into the shared counter q-slot, and a triple-nested iter exited after one trip: N^2 results where N^3 is correct, silently, on the default configuration. Two-deep nests worked only because their latch happened to land after the inner loop in layout order. The same hole in the string output-buffer allocator let LATER loops recycle EARLIER fragments' result slots (a words() result came back as a later lnum's "0 1 2"). The fix is an execution-reachability liveness closure in compute_live_ranges: for every use of v, extend v's interval to every block B with def->B and B->use both reachable (back edges included). Cycles make a loop's blocks mutually reachable, so the loop case falls out; the out-of-order straight-line case is the acyclic instance. RPO was tried and rejected — a reverse postorder may also place a latch before the body it follows at runtime (measured: it moved the bug to the middle loop). The closure replaces the old rpo-range extension, which both under-extended (values defined inside the loop got nothing) and mis-tested membership (rpo_pos ranges are not loop membership). Verified live at depths 2/3/4 with per-level markers (o#@/m#@/i#@ asserts every level's element and position), the mixed-consumer single-program shape, and lnum + literal lists: all byte-identical to the AST route. Warm perf unchanged: iter(lnum(100),[ifelse(...)]) min-of-5 0.00043-0.00055s/30 iters vs the same box's 0.00046-0.00056 baseline before the fix; jit_handled 30/30 throughout. New smoke file iter_nest_fn.mux: TC001 pins depth-3/4 structure and counts, TC002 pins the cross-fragment slot-survival shape. The corpus previously contained zero triple-nested iters — this class was invisible to every existing net, and only VALUE assertions can see it (jit_handled reported success while wrong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:46:54 -06:00
}
auto extend_use = [&](int v, int use_blk) {
if (v < 0 || v >= h.n_insns || prog_point[v] < 0) return;
if (use_blk < 0 || use_blk >= h.n_blocks) return;
int d = h.blk[v];
if (d < 0 || d >= h.n_blocks) return;
int m = last_use[v];
for (int B = 0; B < h.n_blocks; B++) {
if (reach[d][B] && reach[B][use_blk]
&& block_end_pp[B] > m) {
m = block_end_pp[B];
}
}
if (m > last_use[v]) last_use[v] = m;
};
for (int i = 0; i < h.n_insns; i++) {
if (prog_point[i] < 0) continue;
for (int sl = 0; sl < hir_operand_count(h, i); sl++) {
extend_use(hir_operand_get(h, i, sl), h.blk[i]);
}
// PHI operands are uses at the end of their predecessor.
if (h.kind[i] == HIR_PHI) {
for (int j = 0; j < h.pnargs[i]; j++) {
extend_use(h.pval[h.pbase[i] + j],
h.pblk[h.pbase[i] + j]);
}
}
}
}
// The final result must survive to the end of the program.
if (h.result >= 0 && h.result < h.n_insns && filter(h, h.result)) {
last_use[h.result] = max_pp - 1;
}
// Build intervals for values passing the filter.
intervals.clear();
for (int i = 0; i < h.n_insns; i++) {
if (!filter(h, i)) continue;
if (prog_point[i] < 0) continue; // unreachable
int def = prog_point[i];
// PHI: start at earliest predecessor's block end point.
if (h.kind[i] == HIR_PHI && h.pnargs[i] > 0) {
for (int j = 0; j < h.pnargs[i]; j++) {
int pred_blk = h.pblk[h.pbase[i] + j];
if (pred_blk >= 0 && pred_blk < h.n_blocks) {
int ep = block_end_pp[pred_blk];
if (ep < def) def = ep;
}
}
}
int end = (last_use[i] >= def) ? last_use[i] : def;
intervals.push_back({i, def, end});
}
}
// Poletto-Sarkar linear scan register allocation.
//
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
static reg_alloc_result linear_scan(rv_compiler &rc,
std::vector<live_interval> &intervals) {
reg_alloc_result result;
memset(result.reg, 0, sizeof(result.reg));
memset(result.spill_slot, -1, sizeof(result.spill_slot));
result.n_spill_slots = 0;
if (intervals.empty()) return result;
// Sort intervals by start point.
std::sort(intervals.begin(), intervals.end(),
[](const live_interval &a, const live_interval &b) {
return a.start < b.start;
});
// Free register pool (stack-based for fast alloc/free).
uint8_t free_regs[RA_NUM_REGS];
int n_free = RA_NUM_REGS;
for (int i = 0; i < RA_NUM_REGS; i++) {
free_regs[i] = RA_REGS[RA_NUM_REGS - 1 - i]; // s11 at bottom
}
// Active intervals, sorted by end point ascending.
// Small-N (max 11 entries), so linear insertion is fine.
struct active_entry {
int end;
int value;
uint8_t reg;
};
std::vector<active_entry> active;
for (auto &iv : intervals) {
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
// Defensive bounds check: result.reg[]/spill_slot[] are sized
// HIR_MAX_INSNS and indexed by iv.value. See allocate_output_buffers.
if (iv.value < 0 || iv.value >= HIR_MAX_INSNS) {
rc.out_exhausted = true;
continue;
}
// ExpireOldIntervals: remove intervals that ended before iv.start.
size_t j = 0;
while (j < active.size()) {
if (active[j].end >= iv.start) break; // sorted: rest are live
// Return register to free pool.
free_regs[n_free++] = active[j].reg;
active.erase(active.begin() + j);
// Don't increment j — next element shifted down.
}
if (n_free > 0) {
// Assign a register.
uint8_t reg = free_regs[--n_free];
result.reg[iv.value] = reg;
// Insert into active, maintaining sort by end.
active_entry ae = {iv.end, iv.value, reg};
auto pos = std::lower_bound(active.begin(), active.end(), ae,
[](const active_entry &a, const active_entry &b) {
return a.end < b.end;
});
active.insert(pos, ae);
} else {
// Spill: evict the interval ending furthest in the future.
auto &spill = active.back(); // largest end
if (spill.end > iv.end) {
// Spill the active interval, give its register to iv.
result.reg[iv.value] = spill.reg;
result.reg[spill.value] = 0;
result.spill_slot[spill.value] = result.n_spill_slots++;
// Remove spilled interval from active.
active.pop_back();
// Insert iv into active.
active_entry ae = {iv.end, iv.value, result.reg[iv.value]};
auto pos = std::lower_bound(active.begin(), active.end(), ae,
[](const active_entry &a, const active_entry &b) {
return a.end < b.end;
});
active.insert(pos, ae);
} else {
// Spill the new interval (it ends later than everything).
result.spill_slot[iv.value] = result.n_spill_slots++;
}
}
}
return result;
}
fix(jit): move integer spill slots inside the stack frame (#2052) Spill slots lived at SP-8, SP-16, ... -- BELOW the stack pointer. That kept them clear of the output buffers, but RV64 has no red zone: the first JAL into a blob function let the gcc-compiled callee build its frame right on top of them. A spilled value reloaded after any tier2 call read the callee's dead locals. Nothing ever noticed because nothing ever both spilled AND called. The allocator has 10 registers, and no compiled program had held more than 10 integers live across a call until the ITER cursor rework added one int PHI and one ATOI per loop. Then: [iter(A,%i0)][iter(B,%i0)] gave "A B" (expected "AB") parser_fn TC020 (four loops) gave "X Y Z31" (lost elements) The pinning observation: in the same loop iteration, #@ (inum+1, register operand) printed 1 2 3 correctly while is_first = EQ(inum, 0) (spilled operand, reloaded after two split_token calls) read garbage. Same SSA value, right at one use, wrong at the other -- with tier2 calls in between. More loops meant more spills meant grosser corruption, which is why the 4-loop smoke expression failed harder than anything typed at `think`, and why every isolated test in the WIP handoff was correct: none of them spilled. Slots now sit at +8*slot from the post-prologue SP, inside the frame the prologue reserves; the backpatch adds a 16-byte-aligned spill area below the output slots and emits the SUB even when there are no output slots. Callee frames start below SP and cannot reach either region. Validated by perturbation both ways: with only this hunk reverted, the two-loop expression reads "A B"; restored, "AB". Full suite: 36 targets, 35 passed, 1 skipped (stubslave=no), 0 failed. Latent on master for any spilled program that makes calls; the ITER work is merely the first to compile one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:37:10 -06:00
// Spill slot stack offset: +8*slot from the post-prologue SP.
//
// These MUST be at non-negative offsets — i.e. inside the frame the
// prologue reserves — not below SP. They used to live at -8*(slot+1),
// which kept them clear of the output buffers but put them in the one
// region of the stack that belongs to nobody: RV64 has no red zone, so
// the first JAL into a blob function let gcc-compiled callee code build
// its frame right on top of them. A spilled value reloaded after any
// tier2 call read the callee's dead locals.
//
// Nothing ever noticed because nothing ever both spilled AND called:
// the ITER cursor rework (#2052) was the first program with more than
// 10 live ints around a tier2 call, and the symptom was an is_first
// comparison whose spilled operand read garbage — while the same SSA
// value, read from a register two instructions earlier, was correct
// (iter(a,%i0) twice in one expression: second loop lost its
// accumulator). The prologue backpatch sizes the frame to cover
// these slots; see "spill_area" below.
static int32_t spill_offset(int slot) {
fix(jit): move integer spill slots inside the stack frame (#2052) Spill slots lived at SP-8, SP-16, ... -- BELOW the stack pointer. That kept them clear of the output buffers, but RV64 has no red zone: the first JAL into a blob function let the gcc-compiled callee build its frame right on top of them. A spilled value reloaded after any tier2 call read the callee's dead locals. Nothing ever noticed because nothing ever both spilled AND called. The allocator has 10 registers, and no compiled program had held more than 10 integers live across a call until the ITER cursor rework added one int PHI and one ATOI per loop. Then: [iter(A,%i0)][iter(B,%i0)] gave "A B" (expected "AB") parser_fn TC020 (four loops) gave "X Y Z31" (lost elements) The pinning observation: in the same loop iteration, #@ (inum+1, register operand) printed 1 2 3 correctly while is_first = EQ(inum, 0) (spilled operand, reloaded after two split_token calls) read garbage. Same SSA value, right at one use, wrong at the other -- with tier2 calls in between. More loops meant more spills meant grosser corruption, which is why the 4-loop smoke expression failed harder than anything typed at `think`, and why every isolated test in the WIP handoff was correct: none of them spilled. Slots now sit at +8*slot from the post-prologue SP, inside the frame the prologue reserves; the backpatch adds a 16-byte-aligned spill area below the output slots and emits the SUB even when there are no output slots. Callee frames start below SP and cannot reach either region. Validated by perturbation both ways: with only this hunk reverted, the two-loop expression reads "A B"; restored, "AB". Full suite: 36 targets, 35 passed, 1 skipped (stubslave=no), 0 failed. Latent on master for any spilled program that makes calls; the ITER work is merely the first to compile one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:37:10 -06:00
return 8 * slot;
}
// Emit SD reg, off(sp) — store integer register to spill slot.
static void emit_spill_store(std::vector<uint32_t> &code, uint8_t reg, int slot) {
code.push_back(rv_SD(2, reg, spill_offset(slot)));
}
// Emit LD rd, off(sp) — reload integer register from spill slot.
static void emit_spill_load(std::vector<uint32_t> &code, uint8_t rd, int slot) {
code.push_back(rv_LD(rd, 2, spill_offset(slot)));
}
// Get the register holding integer value v, reloading from spill
// slot if necessary. scratch = register to reload into if spilled.
//
static uint8_t ra_get_reg(rv_compiler &rc, hir_loc *loc, int v,
uint8_t scratch) {
if (v < 0) return 0;
if (loc[v].spill_slot >= 0 && !loc[v].in_reg) {
emit_spill_load(rc.code, scratch, loc[v].spill_slot);
return scratch;
}
return loc[v].reg;
}
// Set loc[i] from allocation result and optionally emit spill.
// dest = the register the value was computed into.
// Returns the destination register.
//
static void ra_set_loc(rv_compiler &rc, hir_loc *loc,
reg_alloc_result &alloc, int i, uint8_t computed_in) {
uint8_t assigned = alloc.reg[i];
int slot = alloc.spill_slot[i];
if (assigned != 0) {
// Value lives in a register.
loc[i].reg = assigned;
loc[i].in_reg = true;
loc[i].spill_slot = -1;
} else if (slot >= 0) {
// Value is spilled — emit store.
emit_spill_store(rc.code, computed_in, slot);
loc[i].reg = 0;
loc[i].in_reg = false;
loc[i].spill_slot = slot;
}
}
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
// Emit the argument setup shared by the three HIR_LUA_CALL_* variants.
// The arguments come off the instruction's carg[] list; the kind bits in
// val[] say what each register carries (see ecall_lua_push_call_args in
// jit_compiler.cpp): 0 is an integer via ra_get_reg into x12+j, 1 is an
// SCONST's guest address into x12+j, 2 is a double loaded from its FP
// slot and moved as raw bits into x12+j over the FMV.X.D lane
// ECALL_LUA_FTOA already proved on both execution routes. The kind bits
// the lowering set are the single source of truth here; re-deriving them
// from h.kind/h.ty would be a second opinion that could disagree with
// what the handler will decode.
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
//
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
// Returns nargs | kinds<<8, the a1 payload every call variant sends.
//
static int emit_lua_call_args(rv_compiler &rc, hir_program &h,
hir_loc *loc, int i) {
const int nargs = h.cnargs[i];
const int kinds = static_cast<int>(h.val[i]) & 0xFF;
for (int j = 0; j < nargs && j < 3; j++) {
const int v = h.carg[h.cbase[i] + j];
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
if (v < 0) continue;
const uint8_t xd = static_cast<uint8_t>(12 + j);
switch ((kinds >> (2 * j)) & 3) {
case 1:
rv_load_guest_addr(rc.code, xd, loc[v].addr);
break;
case 2:
rv_load_guest_addr(rc.code, RA_SCRATCH, loc[v].addr);
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0));
rc.code.push_back(rv_FMV_X_D(xd, 0));
break;
default: {
uint8_t r = ra_get_reg(rc, loc, v,
static_cast<uint8_t>(28 + j));
rc.code.push_back(rv_ADDI(xd, r, 0));
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
return (nargs & 0xFF) | (kinds << 8);
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
}
// Emit PHI copies: when branching from from_blk to to_blk,
// emit moves for any PHI nodes at the target block.
//
static void emit_phi_copies(hir_program &h, rv_compiler &rc,
hir_loc *loc, int from_blk, int to_blk) {
// A branch target outside [0, n_blocks) is an unpatched placeholder
// or an overflowed new_block() — indexing block_first[] with it reads
// wild memory (#858). Flag it and skip; the branch emits no copies.
if (to_blk < 0 || to_blk >= h.n_blocks) {
fprintf(stderr, "hir_codegen: emit_phi_copies invalid to_blk=%d "
"(from_blk=%d n_blocks=%d)\n", to_blk, from_blk, h.n_blocks);
return;
}
if (h.block_first[to_blk] > h.block_last[to_blk]) return;
for (int i = h.block_first[to_blk]; i <= h.block_last[to_blk]; i++) {
if (h.blk[i] != to_blk || h.kind[i] != HIR_PHI) continue;
// Find the PHI argument for from_blk.
int base = h.pbase[i];
for (int j = 0; j < h.pnargs[i]; j++) {
if (h.pblk[base + j] != from_blk) continue;
int val = h.pval[base + j];
if (val < 0) break;
// Float PHI: both slots are 8-byte guest doubles — FLD/FSD.
// Must not fall into the string path (strcpy of float bits).
if (h.ty[i] == TY_FLOAT) {
rv_load_guest_addr(rc.code, RA_SCRATCH, loc[val].addr);
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0));
rv_load_guest_addr(rc.code, RA_SCRATCH, loc[i].addr);
rc.code.push_back(rv_FSD(RA_SCRATCH, 0, 0));
break;
}
bool phi_is_int = (loc[i].in_reg || loc[i].spill_slot >= 0);
if (phi_is_int) {
// Integer PHI (registered or spilled).
uint8_t phi_dest = loc[i].in_reg ? loc[i].reg : RA_SCRATCH;
uint8_t val_reg;
if (loc[val].in_reg) {
val_reg = loc[val].reg;
} else if (loc[val].spill_slot >= 0) {
// Spilled integer operand: reload.
emit_spill_load(rc.code, RA_SCRATCH2, loc[val].spill_slot);
val_reg = RA_SCRATCH2;
} else {
// String value used as int PHI — load addr and atoi.
rv_load_guest_addr(rc.code, 10, loc[val].addr);
rv_emit_atoi(rc.code, 10, phi_dest);
if (loc[i].spill_slot >= 0 && !loc[i].in_reg) {
emit_spill_store(rc.code, phi_dest, loc[i].spill_slot);
}
break;
}
rc.code.push_back(rv_ADD(phi_dest, val_reg, 0));
if (loc[i].spill_slot >= 0 && !loc[i].in_reg) {
emit_spill_store(rc.code, phi_dest, loc[i].spill_slot);
}
} else {
// String PHI: copy string to PHI's output buffer.
if (loc[val].in_reg) {
// Integer val → ITOA to PHI buffer.
rv_load_guest_addr(rc.code, 10, loc[i].addr);
rv_emit_itoa(rc.code, loc[val].reg, 10);
} else if (loc[val].spill_slot >= 0) {
// Spilled integer val → reload, then ITOA.
emit_spill_load(rc.code, RA_SCRATCH, loc[val].spill_slot);
rv_load_guest_addr(rc.code, 10, loc[i].addr);
rv_emit_itoa(rc.code, RA_SCRATCH, 10);
} else {
// String → string: byte copy.
rv_load_guest_addr(rc.code, 7, loc[i].addr); // t2 = dest
rv_load_guest_addr(rc.code, 6, loc[val].addr); // t1 = src
rv_emit_strcpy(rc.code, 7, 6);
}
}
break;
}
}
}
void hir_codegen(hir_program &h, rv_compiler &rc) {
// Location map: where each instruction's result lives.
hir_loc loc[HIR_MAX_INSNS];
memset(loc, 0, sizeof(loc));
for (int i = 0; i < h.n_insns; i++) loc[i].spill_slot = -1;
// Block code offsets for branch backpatching.
int block_offset[HIR_MAX_BLOCKS];
memset(block_offset, 0, sizeof(block_offset));
std::vector<branch_patch> patches;
// 1. Run register allocation for integers.
std::vector<live_interval> int_intervals;
compute_live_ranges(h, int_intervals, needs_int_reg);
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
reg_alloc_result int_alloc = linear_scan(rc, int_intervals);
rc.spills = int_alloc.n_spill_slots;
// 2. Run liveness-based allocation for output buffers.
std::vector<live_interval> str_intervals;
compute_live_ranges(h, str_intervals, needs_output_buffer);
output_alloc_result str_alloc = allocate_output_buffers(rc, str_intervals);
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
// 2b. A program with branches can reach more than one HIR_RET, and which
// one runs is only known at runtime. The result location, though, is
// fixed at compile time (h.result, set from the *first* return), so the
// answer used to come from the first return site no matter which one
// executed -- the branch was taken correctly and the value ignored
// (#1486). Give every return one shared slot to write into.
//
// 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 keeping the compile-time result preserves constant folding
// for straight-line chunks like `return 42`.
//
if (h.n_blocks > 1) {
for (int i = 0; i < h.n_insns; i++) {
if (h.kind[i] == HIR_RET && h.src1[i] >= 0) {
rc.ret_out = rc.alloc_output();
break;
}
}
}
// 3. Allocate 8-byte guest memory slots for FP values.
// Simple bump allocation — no register caching for FP in HIR.
// Each FP value gets a slot; operations load/store via FLD/FSD.
// The DBT's x86-64 translator will optimize these into XMM regs.
uint64_t fp_pool = (rc.str_pool + 7) & ~7ULL; // align to 8
for (int i = 0; i < h.n_insns; i++) {
if (needs_fp_reg(h, i)) {
fix(jit): bound FP slot allocation by this compile's pool limit (#1159) hir_codegen's FP slot bump allocator tested `fp_pool + 8 > rv_compiler::STR_LIMIT` -- the one-shot compiler's static 0x4000 -- instead of `rc.str_pool_limit`, the limit this particular compile was constructed with. The shared heap (used for every nested evaluation, s_jit_depth > 1) lays its string pool at 0x40000..0x60000. Its str_pool therefore starts above the static STR_LIMIT, the bound test failed on the very first FP value, the loop broke, and every FP slot address stayed 0 -- `loc` is memset to zero. All float temporaries then aliased guest address 0. Nothing reported an error: FCONST wrote its double to address 0, ATOF overwrote it, and FMUL loaded both operands from 0, so mul(12.75,N) returned N*N and fdiv(x,y) returned x/x == 1. Strings were unaffected because pool_str() already used the per-instance str_pool_limit; only floats went through the static constant. Any expression containing runtime (non-folded) float arithmetic that is evaluated inside another compiled expression was affected -- u() and ulocal() bodies, and anything wrapping them. A bare call at the prompt compiles through the one-shot compiler and was always correct, which is why this survived manual spot-checks. Genuine pool exhaustion now sets out_exhausted to fail the compile rather than silently handing back address 0. testcases/nested_float_fn.mux is new. Reverting the one-line bound and rebuilding fails all four of its cases (cat=<9> add=10 len=1 first=9, div35=<1> div2=<1>, iter=9 switch=9 ulocal=<9>) while the other 1330 smoke cases still pass, so it isolates this defect. Verified on x86-64 with --enable-jit: smoke 1334/1334, make test green (q-register and ifelse oracles OK), jit_diff 300 expressions 0 LOGIC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 01:20:32 -06:00
// Bound against THIS compile's pool limit, not the one-shot
// compiler's static STR_LIMIT. The shared heap lays its
// string pool at 0x40000..0x60000, well above the static
// 0x4000, so the old test failed on the first FP value and
// left every slot addr at 0 — all float temporaries then
// aliased guest address 0 and silently computed garbage
// (#1159). Genuine exhaustion must fail the compile, not
// hand back address 0.
if (fp_pool + 8 > rc.str_pool_limit) {
rc.out_exhausted = true;
break;
}
loc[i].addr = fp_pool;
loc[i].in_reg = false;
loc[i].spill_slot = -1;
fp_pool += 8;
}
}
rc.str_pool = fp_pool;
// Pre-populate loc map from allocation results.
for (int i = 0; i < h.n_insns; i++) {
if (needs_int_reg(h, i)) {
loc[i].reg = int_alloc.reg[i];
loc[i].in_reg = (loc[i].reg != 0);
loc[i].spill_slot = int_alloc.spill_slot[i];
}
if (needs_output_buffer(h, i)) {
loc[i].addr = str_alloc.addr[i];
loc[i].in_reg = false;
}
}
// Reserve prologue slots for stack frame setup.
// First instruction snapshots the incoming SP into s0 so
// stack-allocated output buffers can be addressed relative to
// the function's entry stack pointer even across nested calls.
// Output buffers are stack-allocated; the prologue decrements SP
// by the total frame size. We don't know the final count yet
// (the itoa path may allocate 1 more slot), so reserve 3 NOPs
// and backpatch after all code is emitted.
//
// 4 instructions = mv s0, sp + LUI t0, upper + ADDI t0, t0, lower
// + SUB sp, sp, t0
//
rc.code.push_back(rv_ADDI(RA_FRAME_TOP, 2, 0)); // mv s0, sp
size_t prologue_pos = rc.code.size();
static constexpr int PROLOGUE_SLOTS = 3;
for (int p = 0; p < PROLOGUE_SLOTS; p++) {
rc.code.push_back(rv_ADDI(0, 0, 0)); // NOP placeholder
}
// Process blocks in layout order.
for (int b = 0; b < h.n_blocks; b++) {
block_offset[b] = static_cast<int>(rc.code.size());
if (h.block_first[b] > h.block_last[b]) continue;
for (int i = h.block_first[b]; i <= h.block_last[b]; i++) {
if (h.blk[i] != b) continue;
switch (h.kind[i]) {
case HIR_SCONST:
loc[i].addr = static_cast<uint64_t>(h.val[i]);
loc[i].in_reg = false;
break;
case HIR_ICONST: {
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rv_load_i64(rc.code, dest, h.val[i]);
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_ATOI: {
int s1 = h.src1[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
// Compile-time only for true constants. runtime_ref SCONSTs
// (CARGS/SUBST, mux.args) have empty sval but a live guest
// address — atoi must run at runtime (#1309).
//
if ( h.kind[s1] == HIR_SCONST
&& !h.runtime_ref[s1]) {
int64_t v = static_cast<int64_t>(
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) Completes the sweep the issue called for. mux_atol returns long, which is 32-bit on LLP64, so every caller silently truncated on Windows. Two of those were real defects (the truthiness family and cf_size, fixed in the preceding commits); the rest were latent, waiting for a value large enough to matter. Rather than audit 290 sites for whether each can reach 2^31 today, use the 64-bit parser everywhere and remove the class. A dbref cannot overflow now, but nothing stops a later caller passing that same site a timestamp or a byte count. Pure 1:1 substitution: 285 lines changed, and every removed line contained mux_atol while every added line contains mux_atoi64. No control flow, no types, no behaviour beyond the wider parse. This is a NO-OP on LP64 -- long is already 64-bit on Linux and macOS, so the generated code there is unchanged. It only widens the parse on Windows. Narrowing destinations are unaffected either way: `int x = mux_atoi64(s)` truncates exactly as `int x = mux_atol(s)` did, on both models. Left alone: mux_atol itself in mathutil, its declaration, and three comments that name it. Callers that genuinely want 32-bit semantics can still ask for them; none appear to. Verified on Windows: full solution builds clean with no new warnings, smoke is 1418 passed / 16 failed / 0 crashes / 306 of 306 dispatched -- identical to before the sweep, with the same 16 build-configuration failures (exp3 module not loaded, hmac/digest behind UNIX_DIGEST). Spot checks after the change: the boolean family returns 1 for multiples of 2^32, cf_size round-trips 3000000000 and still reads -1 as unlimited, and arithmetic, string and list functions are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:03:45 -06:00
mux_atoi64(u8(h.sval[s1])));
rv_load_i64(rc.code, dest, v);
} else {
// SCONST runtime_ref: loc.addr was set from h.val (guest
// CARGS/SUBST slot). Other sources use normal loc.
//
uint64_t addr = (h.kind[s1] == HIR_SCONST)
? static_cast<uint64_t>(h.val[s1])
: loc[s1].addr;
rv_load_guest_addr(rc.code, 10, addr);
rv_emit_atoi(rc.code, 10, dest);
}
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_STRCMP: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
if ( h.kind[s1] == HIR_SCONST && h.kind[s2] == HIR_SCONST
&& !h.runtime_ref[s1] && !h.runtime_ref[s2]) {
int r = strcmp(h.sval[s1].c_str(), h.sval[s2].c_str());
rv_load_i64(rc.code, dest, r < 0 ? -1 : r > 0 ? 1 : 0);
} else {
uint64_t a1 = (h.kind[s1] == HIR_SCONST)
? static_cast<uint64_t>(h.val[s1]) : loc[s1].addr;
uint64_t a2 = (h.kind[s2] == HIR_SCONST)
? static_cast<uint64_t>(h.val[s2]) : loc[s2].addr;
rv_load_guest_addr(rc.code, 10, a1);
rv_load_guest_addr(rc.code, 11, a2);
rv_emit_strcmp(rc.code, 10, 11, dest);
}
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
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
case HIR_LUA_GETGLOBAL: {
// a0 = key addr -> a0 = stack idx.
int s1 = h.src1[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
if (h.kind[s1] != HIR_SCONST) break;
rv_load_guest_addr(rc.code, 10, loc[s1].addr);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_GETGLOBAL)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LUA_GETFIELD_REF: {
// a0=tbl_idx, a1=key addr -> a0=stack idx.
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
if (h.kind[s2] != HIR_SCONST) break;
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0));
rv_load_guest_addr(rc.code, 11, loc[s2].addr);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_GETFIELD_REF)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
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
case HIR_LUA_CALL_STR: {
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
// a0=fn, a1=nargs|kinds, a2..a4=args, a5=out addr,
// a6=out size. Result is TY_STRING, so its guest buffer
// is the output slot the allocator already assigned --
// OUT_SLOT bytes, passed explicitly.
uint8_t fn_r = ra_get_reg(rc, loc, h.src1[i], RA_SCRATCH);
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
rc.code.push_back(rv_ADDI(10, fn_r, 0));
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
int a1val = emit_lua_call_args(rc, h, loc, i);
rv_load_i64(rc.code, 11, a1val);
rv_load_guest_addr(rc.code, 15, loc[i].addr);
rv_load_i64(rc.code, 16, rv_compiler::OUT_SLOT);
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
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_CALL_STR)));
rc.code.push_back(rv_ECALL());
break;
}
case HIR_LUA_CALL_VAL: {
// a0=fn, a1=nargs|kinds, a2..a4=args → a0=stack index.
// Result is a live Lua value (handle), not text.
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t fn_r = ra_get_reg(rc, loc, h.src1[i], RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, fn_r, 0));
int a1val = emit_lua_call_args(rc, h, loc, i);
rv_load_i64(rc.code, 11, a1val);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_CALL_VAL)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LUA_MARSHAL: {
// a0=stack idx, a1=out addr, a2=out size. Result TY_STRING
// lives in the output slot.
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, r1, 0));
rv_load_guest_addr(rc.code, 11, loc[i].addr);
rv_load_i64(rc.code, 12, rv_compiler::OUT_SLOT);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_MARSHAL)));
rc.code.push_back(rv_ECALL());
break;
}
case HIR_LUA_TOBOOL: {
// a0=stack idx → a0=0/1 Lua truthiness.
int s1 = h.src1[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, r1, 0));
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_TOBOOL)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LUA_EQ: {
// a0=lhs handle, a1=kind, a2=rhs → a0=0/1 under Lua ==.
int s1 = h.src1[i], s2 = h.src2[i];
int kind = static_cast<int>(h.val[i]);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, r1, 0));
rv_load_i64(rc.code, 11, kind);
if (kind == 1) {
// String constant: guest address of the pool string.
if (h.kind[s2] != HIR_SCONST) break;
rv_load_guest_addr(rc.code, 12, loc[s2].addr);
} else if (kind == 0 || kind == 4) {
// Integer / bool payload in ICONST val, or register.
if (h.kind[s2] == HIR_ICONST) {
rv_load_i64(rc.code, 12, h.val[s2]);
} else {
uint8_t r2 = ra_get_reg(rc, loc, s2, 29);
rc.code.push_back(rv_ADDI(12, r2, 0));
}
} else if (kind == 2) {
uint8_t r2 = ra_get_reg(rc, loc, s2, 29);
rc.code.push_back(rv_ADDI(12, r2, 0));
} else if (kind == 3) {
rv_load_i64(rc.code, 12, 0);
} else {
break;
}
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_EQ)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
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
case HIR_LUA_CALL_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
// a0=fn idx, a1=nargs|kinds, a2..a4=args -> a0=int result.
// Arguments come off the carg[] list; see emit_lua_call in
// hir.h for why there is no packed operand any more.
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
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) 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
uint8_t fn_r = ra_get_reg(rc, loc, h.src1[i], RA_SCRATCH);
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
rc.code.push_back(rv_ADDI(10, fn_r, 0));
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
// Argument setup is identical to CALL_STR -- one emitter;
// only the result handling below differs.
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
int a1val = emit_lua_call_args(rc, h, loc, i);
rv_load_i64(rc.code, 11, a1val);
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
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_CALL_INT)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
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
case HIR_LUA_CALL_VOID: {
// a0=fn idx, a1=nargs|kinds, a2..a4=args; no result. The
// call is FOR its side effect -- table.insert -- which is
// why the opcode sits in has_side_effects(): an
// unused-result call DCE may NOP is precisely the one this
// exists to keep (#1145's SETI lesson).
uint8_t fn_r = ra_get_reg(rc, loc, h.src1[i], RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, fn_r, 0));
int a1val = emit_lua_call_args(rc, h, loc, i);
rv_load_i64(rc.code, 11, a1val);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_CALL_VOID)));
rc.code.push_back(rv_ECALL());
break;
}
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
case HIR_LUA_GETFIELD: {
// a0=tbl_idx, a1=key addr -> a0=value.
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0));
// The key is an SCONST: it lives as loc[].addr with
// in_reg=false, so ra_get_reg would hand back a register
// that was never loaded. Materialize the address. Reading
// it through ra_get_reg made every field read return the
// LAST value written, because a1 held the same stale
// address on every call.
if (h.kind[s2] != HIR_SCONST) break;
rv_load_guest_addr(rc.code, 11, loc[s2].addr);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_GETFIELD_INT)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
2026-07-28 16:44:16 -06:00
case HIR_LUA_GETFIELD_FLT: {
// a0=tbl_idx, a1=key addr -> a0=double BITS, a1=ok. The
// result's home is its FP slot, so store the bits there
// directly; no integer register is allocated or needed.
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0));
// SCONST key: materialize the pool address, as GETFIELD
// does and for the same reason (stale-register trap).
if (h.kind[s2] != HIR_SCONST) break;
rv_load_guest_addr(rc.code, 11, loc[s2].addr);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_GETFIELD_FLT)));
rc.code.push_back(rv_ECALL());
rv_load_guest_addr(rc.code, RA_SCRATCH, loc[i].addr);
rc.code.push_back(rv_SD(RA_SCRATCH, 10, 0));
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
case HIR_LUA_LIMITED: {
// Back-edge budget exhausted (#1732). The handler declines
// the whole run; nothing after this executes, so there are
// no operands and no result.
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_LIMITED)));
rc.code.push_back(rv_ECALL());
break;
}
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
case HIR_LUA_SETFIELD: {
// a0=tbl_idx, a1=key addr, a2=value (third operand in val[]).
int s1 = h.src1[i], s2 = h.src2[i];
int s3 = static_cast<int>(h.val[i]);
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0));
// SCONST key: address, not a register. See GETFIELD above.
if (h.kind[s2] != HIR_SCONST) break;
uint8_t val_r = ra_get_reg(rc, loc, s3, 29);
rc.code.push_back(rv_ADDI(12, val_r, 0));
rv_load_guest_addr(rc.code, 11, loc[s2].addr);
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_SETFIELD_INT)));
rc.code.push_back(rv_ECALL());
break;
}
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
case HIR_LUA_INSN_BUDGET: {
// No-arg ECALL: a0 = the CURRENT lua_instruction_limit.
// This exists so the back-edge budget is seeded per run
// rather than baked as an ICONST at lowering -- a compiled
// program must not contain a config value (#1745, the
// #1613 shape on the compiled path).
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_INSN_BUDGET)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0)); // dest = a0
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
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
case HIR_LUA_LEN: {
// Dedicated ECALL: a0=tbl_idx -> a0=length.
int s1 = h.src1[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0)); // a0 = tbl_idx
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_LEN_INT)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0)); // dest = a0
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LUA_GETI: {
// Dedicated ECALL: a0=tbl_idx, a1=key → a0=value (int64)
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
// s1 = table stack index (known_int), s2 = integer key
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0)); // a0 = tbl_idx
uint8_t key_r = ra_get_reg(rc, loc, s2, 28); // t3 as scratch
rc.code.push_back(rv_ADDI(11, key_r, 0)); // a1 = key
rc.code.push_back(rv_ADDI(17, 0, static_cast<int32_t>(ECALL_LUA_GETI_INT)));
rc.code.push_back(rv_ECALL());
// Result in a0 (x10). Success flag in a1 (x11) — ignored for now.
rc.code.push_back(rv_ADDI(dest, 10, 0)); // dest = a0
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
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
case HIR_LUA_NEWTABLE: {
// Dedicated ECALL: a0=narr, a1=nrec -> a0=stack_idx.
//
// The result IS a stack index, so table creation has no
// value form -- but it rides in a register as a typed
// TY_LUA_HANDLE rather than as a decimal string in guest
// memory, which is what keeps it distinguishable from an
// integer downstream (#1519).
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t narr_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, narr_r, 0)); // a0 = narr
uint8_t nrec_r = ra_get_reg(rc, loc, s2, 28);
rc.code.push_back(rv_ADDI(11, nrec_r, 0)); // a1 = nrec
rc.code.push_back(rv_ADDI(17, 0,
static_cast<int32_t>(ECALL_LUA_NEWTABLE)));
rc.code.push_back(rv_ECALL());
rc.code.push_back(rv_ADDI(dest, 10, 0)); // dest = a0
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LUA_SETI: {
// Dedicated ECALL: a0=tbl_idx, a1=key, a2=value
int s1 = h.src1[i], s2 = h.src2[i];
int s3 = static_cast<int>(h.val[i]); // 3rd operand stored in val
uint8_t tbl_r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
rc.code.push_back(rv_ADDI(10, tbl_r, 0)); // a0 = tbl_idx
uint8_t key_r = ra_get_reg(rc, loc, s2, 28);
rc.code.push_back(rv_ADDI(11, key_r, 0)); // a1 = key
uint8_t val_r = ra_get_reg(rc, loc, s3, 29);
rc.code.push_back(rv_ADDI(12, val_r, 0)); // a2 = value
rc.code.push_back(rv_ADDI(17, 0, static_cast<int32_t>(ECALL_LUA_SETI_INT)));
rc.code.push_back(rv_ECALL());
break;
}
case HIR_LUA_ALOAD: {
// Native array load: result = *(int64*)(base + (key-1)*8)
// src1 = key (TY_INT), val = guest base address
int s1 = h.src1[i];
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
uint8_t key_r = ra_get_reg(rc, loc, s1, RA_SCRATCH2);
uint64_t base_addr = static_cast<uint64_t>(h.val[i]);
// t0 = key - 1 (0-based index)
rc.code.push_back(rv_ADDI(5, key_r, -1));
// t0 = t0 << 3 (multiply by 8)
rc.code.push_back(rv_SLLI(5, 5, 3));
// Load base address into t1
rv_load_val(rc.code, 6, base_addr);
// t0 = base + offset
rc.code.push_back(rv_ADD(5, 5, 6));
// dest = *(int64*)t0
rc.code.push_back(rv_LD(dest, 5, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
h.native_ops++;
break;
}
case HIR_ITOA: {
int s1 = h.src1[i];
uint8_t s1r = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint64_t out_addr = loc[i].addr;
rv_load_guest_addr(rc.code, 10, out_addr);
rv_emit_itoa(rc.code, s1r, 10);
break;
}
case HIR_ADD: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_ADD(dest, r1, r2));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_SUB: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_SUB(dest, r1, r2));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_MUL: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_MUL(dest, r1, r2));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_REM: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_REM(dest, r1, r2));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_DIV: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_DIV(dest, r1, r2));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// Bitwise operations.
#define BITOP_RR(RV_INSN) \
{ \
int s1 = h.src1[i], s2 = h.src2[i]; \
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH); \
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2); \
uint8_t reg = int_alloc.reg[i]; \
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0); \
uint8_t dest = spilled ? RA_SCRATCH : reg; \
if (!dest) break; \
rc.code.push_back(RV_INSN(dest, r1, r2)); \
ra_set_loc(rc, loc, int_alloc, i, dest); \
break; \
}
case HIR_BAND: BITOP_RR(rv_AND)
case HIR_BOR: BITOP_RR(rv_OR)
case HIR_BXOR: BITOP_RR(rv_XOR)
case HIR_SHL: BITOP_RR(rv_SLL)
case HIR_SHR: BITOP_RR(rv_SRL)
#undef BITOP_RR
case HIR_BNOT: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
// XORI rd, rs, -1 (all-ones immediate = bitwise NOT)
rc.code.push_back(rv_i_type(OP_IMM, dest, ALU_XORI, r1, -1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// NEG: integer negate as SUB dest, x0, rs.
// Two's-complement wrap: -INT64_MIN stays INT64_MIN (matches
// RV64 SUB and the const-fold guard in hir_opt). (#1258)
case HIR_NEG: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_SUB(dest, 0, r1)); // dest = 0 - r1
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// SIGN: returns -1, 0, or 1.
// SLT t0, rs, x0 (t0 = 1 if rs < 0)
// SLT dest, x0, rs (dest = 1 if rs > 0, i.e., 0 < rs)
// SUB dest, dest, t0
case HIR_SIGN: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
constexpr uint8_t t0 = 5;
rc.code.push_back(rv_r_type(OP_REG, t0, ALU_SLT, r1, 0, 0));
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLT, 0, r1, 0));
rc.code.push_back(rv_SUB(dest, dest, t0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// MAX: max(a, b) — branchless via SLT + conditional select.
// SLT t0, r1, r2 (t0 = 1 if r1 < r2)
// BEQ t0, x0, +8 (skip if r1 >= r2, i.e., r1 is already max)
// MV dest, r2 (r2 is larger)
// Otherwise dest = r1.
// Actually simpler: compute both, select.
// SUB t0, r1, r2
// SRA t0, t0, 63 (sign mask: all 1s if r1 < r2)
// AND t0, t0, SUB → use the mask to select
// Better: just branch.
// BLT r1, r2, +12; MV dest, r1; JAL x0, +8; MV dest, r2
case HIR_MAX: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
// BGE r1, r2, +12 (skip to dest=r1 case when r1 >= r2)
rc.code.push_back(rv_BGE(r1, r2, 12));
// r1 < r2: dest = r2
rc.code.push_back(rv_ADD(dest, r2, 0)); // MV dest, r2
rc.code.push_back(rv_JAL(0, 8)); // skip next
// r1 >= r2: dest = r1
rc.code.push_back(rv_ADD(dest, r1, 0)); // MV dest, r1
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// MIN: min(a, b) — mirror of MAX.
case HIR_MIN: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
// BLT r1, r2, +12 (skip to dest=r1 case when r1 < r2)
rc.code.push_back(rv_b_type(BR_BLT, r1, r2, 12));
// r1 >= r2: dest = r2
rc.code.push_back(rv_ADD(dest, r2, 0)); // MV dest, r2
rc.code.push_back(rv_JAL(0, 8)); // skip next
// r1 < r2: dest = r1
rc.code.push_back(rv_ADD(dest, r1, 0)); // MV dest, r1
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_EQ: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_SUB(5, r1, r2));
rc.code.push_back(rv_i_type(OP_IMM, dest, ALU_SLTIU, 5, 1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_NE: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_SUB(5, r1, r2));
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLTU, 0, 5, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_GT: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLT, r2, r1, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LT: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLT, r1, r2, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_GE: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLT, r1, r2, 0));
rc.code.push_back(rv_i_type(OP_IMM, dest, ALU_XORI, dest, 1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_LE: {
int s1 = h.src1[i], s2 = h.src2[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t r2 = ra_get_reg(rc, loc, s2, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLT, r2, r1, 0));
rc.code.push_back(rv_i_type(OP_IMM, dest, ALU_XORI, dest, 1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_NOT: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_i_type(OP_IMM, dest, ALU_SLTIU, r1, 1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// BOOL (t function): SNEZ — set if not equal to zero.
// SLTU dest, x0, r1 → dest = (0 < r1) unsigned = (r1 != 0)
case HIR_BOOL: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_r_type(OP_REG, dest, ALU_SLTU, 0, r1, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_INC: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_ADDI(dest, r1, 1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
case HIR_DEC: {
int s1 = h.src1[i];
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_ADDI(dest, r1, -1));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// ---- Float arithmetic (RV64D) ----
//
// FP values are spilled to guest memory (8-byte aligned).
// We load into f0/f1, compute into f0, store result.
// The DBT's x86-64 translator handles the rest.
case HIR_FCONST: {
// Write the double constant into guest memory at the
// allocated FP slot, then no codegen needed — the value
// is already there for subsequent FLD instructions.
uint64_t addr = loc[i].addr;
double v = h.fval[i];
memcpy(rc.memory.data() + addr, &v, 8);
break;
}
#define FP_BINOP(RV_INSN) \
{ \
uint64_t a1 = loc[h.src1[i]].addr; \
uint64_t a2 = loc[h.src2[i]].addr; \
uint64_t dst = loc[i].addr; \
rv_load_guest_addr(rc.code, RA_SCRATCH, a1); \
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0)); \
rv_load_guest_addr(rc.code, RA_SCRATCH, a2); \
rc.code.push_back(rv_FLD(1, RA_SCRATCH, 0)); \
rc.code.push_back(RV_INSN(0, 0, 1)); \
rv_load_guest_addr(rc.code, RA_SCRATCH, dst); \
rc.code.push_back(rv_FSD(RA_SCRATCH, 0, 0)); \
break; \
}
case HIR_FADD: FP_BINOP(rv_FADD_D)
case HIR_FSUB: FP_BINOP(rv_FSUB_D)
case HIR_FMUL: FP_BINOP(rv_FMUL_D)
case HIR_FDIV: FP_BINOP(rv_FDIV_D)
#undef FP_BINOP
case HIR_FNEG: {
uint64_t a1 = loc[h.src1[i]].addr;
uint64_t dst = loc[i].addr;
rv_load_guest_addr(rc.code, RA_SCRATCH, a1);
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0));
rc.code.push_back(rv_FNEG_D(0, 0));
rv_load_guest_addr(rc.code, RA_SCRATCH, dst);
rc.code.push_back(rv_FSD(RA_SCRATCH, 0, 0));
break;
}
case HIR_FSQRT: {
uint64_t a1 = loc[h.src1[i]].addr;
uint64_t dst = loc[i].addr;
rv_load_guest_addr(rc.code, RA_SCRATCH, a1);
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0));
rc.code.push_back(rv_FSQRT_D(0, 0));
rv_load_guest_addr(rc.code, RA_SCRATCH, dst);
rc.code.push_back(rv_FSD(RA_SCRATCH, 0, 0));
break;
}
// ITOF: int64 → double. Load int reg, FCVT.D.L, store to FP slot.
case HIR_ITOF: {
uint8_t r1 = ra_get_reg(rc, loc, h.src1[i], RA_SCRATCH);
uint64_t dst = loc[i].addr;
rc.code.push_back(rv_FCVT_D_L(0, r1));
rv_load_guest_addr(rc.code, RA_SCRATCH, dst);
rc.code.push_back(rv_FSD(RA_SCRATCH, 0, 0));
break;
}
// FTOI: double → int64 (truncate toward zero).
case HIR_FTOI: {
uint64_t a1 = loc[h.src1[i]].addr;
rv_load_guest_addr(rc.code, RA_SCRATCH, a1);
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0));
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) break;
rc.code.push_back(rv_FCVT_L_D(dest, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
break;
}
// FTOA: double → string. Use ECALL to format.
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
// HIR_LUA_FTOA is the same sequence against a host formatter
// that follows Lua's tostring rules instead of MUX's (#1488).
case HIR_FTOA:
case HIR_LUA_FTOA: {
uint64_t a1 = loc[h.src1[i]].addr;
uint64_t out_addr = loc[i].addr;
// Load double bits into a0 via FMV.X.D.
rv_load_guest_addr(rc.code, RA_SCRATCH, a1);
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0));
rc.code.push_back(rv_FMV_X_D(10, 0)); // a0 = double bits
rv_load_guest_addr(rc.code, 11, out_addr); // a1 = output buffer
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
rv_load_val(rc.code, 17,
(h.kind[i] == HIR_LUA_FTOA) ? ECALL_LUA_FTOA
: ECALL_FTOA);
rc.code.push_back(rv_ECALL());
break;
}
// ATOF: string → double.
// Fast path: JAL to blob rv64_strtod (DBT intercepts as
// native intrinsic). Fallback: ECALL_ATOF.
case HIR_ATOF: {
uint64_t str_addr = loc[h.src1[i]].addr;
uint64_t dst = loc[i].addr;
uint64_t blob_addr = static_cast<uint64_t>(h.val[i]);
rv_load_guest_addr(rc.code, 10, str_addr); // a0 = string
if (blob_addr) {
// JAL to rv64_strtod — result in fa0.
uint64_t pc = rc.current_pc();
int32_t offset = static_cast<int32_t>(blob_addr - pc);
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
rv_push_jal(rc, 1, offset);
} else {
// ECALL fallback.
rv_load_val(rc.code, 17, 0x141); // a7 = ECALL_ATOF
rc.code.push_back(rv_ECALL());
}
// Result in fa0 (f10). Store to FP slot.
rv_load_guest_addr(rc.code, RA_SCRATCH, dst);
rc.code.push_back(rv_FSD(RA_SCRATCH, 10, 0)); // *dst = fa0
break;
}
// FCALL1: unary FP intrinsic call (sin, cos, etc.).
// arg in fa0, result in fa0. JAL to blob stub.
case HIR_FCALL1: {
uint64_t a1 = loc[h.src1[i]].addr;
uint64_t dst = loc[i].addr;
uint64_t func_addr = static_cast<uint64_t>(h.val[i]);
// Load argument into fa0 (f10).
rv_load_guest_addr(rc.code, RA_SCRATCH, a1);
rc.code.push_back(rv_FLD(10, RA_SCRATCH, 0)); // fa0 = *a1
// JAL to blob function.
uint64_t pc = rc.current_pc();
int32_t offset = static_cast<int32_t>(func_addr - pc);
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
rv_push_jal(rc, 1, offset); // JAL ra, func
// Store result from fa0 to FP slot.
rv_load_guest_addr(rc.code, RA_SCRATCH, dst);
rc.code.push_back(rv_FSD(RA_SCRATCH, 10, 0)); // *dst = fa0
break;
}
// FCALL2: binary FP intrinsic call (pow, atan2, fmod).
// args in fa0, fa1; result in fa0.
case HIR_FCALL2: {
uint64_t a1 = loc[h.src1[i]].addr;
uint64_t a2 = loc[h.src2[i]].addr;
uint64_t dst = loc[i].addr;
uint64_t func_addr = static_cast<uint64_t>(h.val[i]);
// Load arguments into fa0, fa1.
rv_load_guest_addr(rc.code, RA_SCRATCH, a1);
rc.code.push_back(rv_FLD(10, RA_SCRATCH, 0)); // fa0 = *a1
rv_load_guest_addr(rc.code, RA_SCRATCH, a2);
rc.code.push_back(rv_FLD(11, RA_SCRATCH, 0)); // fa1 = *a2
// JAL to blob function.
uint64_t pc = rc.current_pc();
int32_t offset = static_cast<int32_t>(func_addr - pc);
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
rv_push_jal(rc, 1, offset); // JAL ra, func
// Store result from fa0 to FP slot.
rv_load_guest_addr(rc.code, RA_SCRATCH, dst);
rc.code.push_back(rv_FSD(RA_SCRATCH, 10, 0)); // *dst = fa0
break;
}
// Float comparisons: result is integer 0/1.
#define FP_CMP(RV_INSN) \
{ \
uint64_t a1 = loc[h.src1[i]].addr; \
uint64_t a2 = loc[h.src2[i]].addr; \
rv_load_guest_addr(rc.code, RA_SCRATCH, a1); \
rc.code.push_back(rv_FLD(0, RA_SCRATCH, 0)); \
rv_load_guest_addr(rc.code, RA_SCRATCH, a2); \
rc.code.push_back(rv_FLD(1, RA_SCRATCH, 0)); \
uint8_t reg = int_alloc.reg[i]; \
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0); \
uint8_t dest = spilled ? RA_SCRATCH : reg; \
if (!dest) break; \
rc.code.push_back(RV_INSN(dest, 0, 1)); \
ra_set_loc(rc, loc, int_alloc, i, dest); \
break; \
}
case HIR_FEQ: FP_CMP(rv_FEQ_D)
case HIR_FLT: FP_CMP(rv_FLT_D)
case HIR_FLE: FP_CMP(rv_FLE_D)
#undef FP_CMP
case HIR_CALL: {
uint64_t out_addr = loc[i].addr;
int na = h.cnargs[i];
int base = h.cbase[i];
std::vector<uint64_t> farg_addrs;
for (int j = 0; j < na; j++) {
int ai = h.carg[base + j];
farg_addrs.push_back(loc[ai].addr);
}
uint64_t fargs_addr = rc.alloc_fargs(farg_addrs);
if (h.tier2_addr[i]) {
// Tier 2: JAL to pre-compiled blob function.
// Patch any frame-relative fargs at runtime.
rv_patch_fargs(rc.code, fargs_addr, farg_addrs);
rv_emit_tier2_call(rc, fargs_addr, na,
out_addr, h.tier2_addr[i]);
} else {
// ECALL to engine function.
int fidx = h.func_idx[i];
uint64_t name_addr = 0;
if (fidx == 0 && !h.call_name[i].empty()) {
name_addr = rc.pool_str(h.call_name[i]);
}
rv_emit_call(rc.code, name_addr, fargs_addr, na,
out_addr, rv_compiler::OUT_SLOT, fidx);
}
break;
}
perf(jit): integers cross the tier-2 boundary as integers (#2132) The compiled ITER loop lost to the interpreter's C loop per element, and its per-element cost climbed with N while the interpreter's stayed flat. Profiling (guest->host block map + ELF symbolization of anonymous JIT frames) found the mechanism the issue asked for: the tier-2 string ABI. Every argument crosses as decimal text, so each element paid SIX integer<->string conversions — ITOA(cursor) caller-side, satoi(cursor) callee-side twice (elem + next modes), sitoa/ATOI for the next offset, ITOA/satoi for the append's length and iteration number, sitoa/ATOI for the new length — digit-proportional loops, executed under DBT expansion, on numbers (byte cursors, accumulated lengths) whose digit counts grow with the list. That is both the bulk of per-element cost and the whole of the residual superlinearity: cost/element ~ a + b*digits(N). The fix is the calling convention the machine already has. HIR_CALL_T2I loads TY_INT arguments straight into a0.. from their registers and TY_STRING arguments as guest addresses, JALs to the blob, and takes the callee's long return from a0; arguments ride carg[] so liveness, DCE and copy propagation see them (hir_is_carg_call). val[i]=1 allocates an output slot passed ahead of the args; HIR_T2I_STR aliases it as the element string. Two int-native blob entrypoints replace the string pair: rv64_split_step (ONE call per element instead of two — element written to out, next cursor returned in a0) and rv64_append_i. The next cursor stays an SSA value stored in the latch, preserving the nested-iter safety the string route had. Measured (macOS arm64, min-of-5, ast=/cached= per CLAUDE.md): iter(lnum(N),1) us/element ratio vs interpreter N before after before after 200 0.117 0.030 0.75 0.19 1000 0.125 0.030 0.81 0.20 4000 0.145 0.033 0.89 0.21 4x on the compiled path; the loop that opened the issue LOSING 1.42x on Linux is 5x FASTER than the interpreter here. Fixed-width N-pair probes no longer climb (median ratio 0.90, was 1.09 at 10/10 above 1.0), and the per-char slope halves (one scan per element, not two). MAP/FILTER still compose the string route; converting them is the follow-up. Two silent-fallback traps found en route, each now carrying a warning: - tier2_allowed()'s allowlist quietly vetoed the new names: lookup returned 0, the lowering kept its graceful string fallback, and every test passed while the fix did not run. Ground truth came from disassembling the compiled program out of the SQLite code cache — the JAL targets do not lie. The allowlist comment now names this failure shape. - A same-mtime-second edit after a build produced an engine that had the new symbols in source and nowhere in the binary (#2118's genre). Also rides along: the env-gated profiling diagnostics that found this — TINYMUX_DBT_MAP (guest-pc -> host-address lines at translate time) and TINYMUX_DBT_CODEDUMP (code_buf + block-cache table at cleanup), which together let a sampling profiler's anonymous JIT frames be symbolized against the blob ELF. Full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped (stubslave, not configured), 0 failed — including smoke's 1660 goldens, the jit parity suites, and the format guard. Functional probes cover nested iter, #@/##, custom in/out separators, runtime leading spaces, and empty lists, all exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:58:55 -06:00
case HIR_CALL_T2I: {
// Integer-ABI tier-2 call (#2132): args straight into
// a0.., JAL, result back from a0. No fargs array, no
// decimal marshalling. RA values live in s-regs, so the
// moves below cannot clobber their sources, and the JAL
// clobbers only a*/t* — the same contract every string
// tier-2 call already relies on.
int na = h.cnargs[i];
int base = h.cbase[i];
uint8_t areg = 10; // a0
if (h.val[i]) {
// Output slot rides ahead of the declared args.
rv_load_guest_addr(rc.code, areg++, loc[i].addr);
}
for (int j = 0; j < na; j++) {
int ai = h.carg[base + j];
if (h.ty[ai] == TY_INT) {
// From its register (reloading a spill into the
// scratch is fine: the value is moved into its
// a-reg before the next argument touches it).
uint8_t r = ra_get_reg(rc, loc, ai, RA_SCRATCH);
rc.code.push_back(rv_ADD(areg, r, 0));
} else {
rv_load_guest_addr(rc.code, areg, loc[ai].addr);
}
areg++;
}
uint64_t cur_pc = rc.current_pc();
int32_t offset =
static_cast<int32_t>(h.tier2_addr[i] - cur_pc);
rv_push_jal(rc, 1, offset); // JAL ra, blob
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (dest) {
rc.code.push_back(rv_ADD(dest, 10, 0)); // mv dest, a0
ra_set_loc(rc, loc, int_alloc, i, dest);
}
break;
}
case HIR_T2I_STR: {
// Alias of the call's output slot; no code.
int s1 = h.src1[i];
if (s1 >= 0) {
loc[i].addr = loc[s1].addr;
loc[i].in_reg = false;
}
break;
}
case HIR_STRCAT: {
uint64_t out_addr = loc[i].addr;
int na = h.cnargs[i];
int base = h.cbase[i];
std::vector<uint64_t> farg_addrs;
for (int j = 0; j < na; j++) {
int ai = h.carg[base + j];
farg_addrs.push_back(loc[ai].addr);
}
uint64_t fargs_addr = rc.alloc_fargs(farg_addrs);
uint64_t t2addr = tier2_lookup("STRCAT");
if (t2addr) {
rv_patch_fargs(rc.code, fargs_addr, farg_addrs);
rv_emit_tier2_call(rc, fargs_addr, na,
out_addr, t2addr);
} else {
int fidx = h.func_idx[i];
uint64_t name_addr = fidx ? 0 : rc.pool_str("strcat");
rv_emit_call(rc.code, name_addr, fargs_addr, na,
out_addr, rv_compiler::OUT_SLOT, fidx);
}
break;
}
case HIR_COPY: {
int s1 = h.src1[i];
if (s1 < 0) break;
if (needs_int_reg(h, i)) {
uint8_t r1 = ra_get_reg(rc, loc, s1, RA_SCRATCH2);
uint8_t reg = int_alloc.reg[i];
bool spilled = (reg == 0 && int_alloc.spill_slot[i] >= 0);
uint8_t dest = spilled ? RA_SCRATCH : reg;
if (!dest) { loc[i] = loc[s1]; break; }
rc.code.push_back(rv_ADD(dest, r1, 0));
ra_set_loc(rc, loc, int_alloc, i, dest);
} else {
loc[i] = loc[s1];
}
break;
}
case HIR_PHI:
// Location already allocated above.
break;
case HIR_BRC: {
// Conditional branch: if cond != 0, go to true_blk.
int cond_insn = h.src1[i];
int true_blk = static_cast<int>(h.val[i]);
int false_blk = h.src2[i];
uint8_t cond_reg = ra_get_reg(rc, loc, cond_insn, RA_SCRATCH);
// Emit PHI copies for true path, then BNE.
emit_phi_copies(h, rc, loc, b, true_blk);
int bne_idx = static_cast<int>(rc.code.size());
rc.code.push_back(rv_BNE(cond_reg, 0, 0));
patches.push_back({bne_idx, true_blk});
// Emit PHI copies for false path.
emit_phi_copies(h, rc, loc, b, false_blk);
// If false block is not the next in layout, emit JAL.
if (false_blk != b + 1) {
int jal_idx = static_cast<int>(rc.code.size());
rc.code.push_back(rv_JAL(0, 0));
patches.push_back({jal_idx, false_blk});
}
break;
}
case HIR_BR: {
int target = static_cast<int>(h.val[i]);
// Emit PHI copies for target.
emit_phi_copies(h, rc, loc, b, target);
// If target is not the next block, emit JAL.
if (target != b + 1) {
int jal_idx = static_cast<int>(rc.code.size());
rc.code.push_back(rv_JAL(0, 0));
patches.push_back({jal_idx, target});
}
break;
}
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
case HIR_RET: {
// Materialize this return's value into the shared slot before
// exiting, so the value follows the path actually taken
// (#1486). Mirrors the string-PHI copy above.
int rv = h.src1[i];
if (rc.ret_out != 0 && rv >= 0) {
if (loc[rv].in_reg) {
rv_load_guest_addr(rc.code, 10, rc.ret_out);
rv_emit_itoa(rc.code, loc[rv].reg, 10);
} else if (loc[rv].spill_slot >= 0) {
emit_spill_load(rc.code, RA_SCRATCH, loc[rv].spill_slot);
rv_load_guest_addr(rc.code, 10, rc.ret_out);
rv_emit_itoa(rc.code, RA_SCRATCH, 10);
} else {
rv_load_guest_addr(rc.code, 7, rc.ret_out); // t2 = dest
rv_load_guest_addr(rc.code, 6, loc[rv].addr); // t1 = src
rv_emit_strcpy(rc.code, 7, 6);
}
// The answer is now produced by executing this code, so
// the program can no longer be served by a compile-time
// fold.
rc.needs_jit = true;
}
rv_emit_exit(rc.code);
break;
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
}
case HIR_SETQ_SYNC: {
// Emit ECALL_SETQ_PACK: a0 = reg_num, a1 = value_addr, a2 = length.
// We pass 0 for length to tell the host to use strlen() for now.
int regnum = static_cast<int>(h.val[i]);
int val_idx = h.src1[i];
rc.code.push_back(rv_ADDI(17, 0, 0x130)); // a7 = ECALL_SETQ_PACK
rv_load_val(rc.code, 10, static_cast<uint64_t>(regnum)); // a0 = regnum
if (val_idx >= 0) {
rv_load_guest_addr(rc.code, 11, loc[val_idx].addr); // a1 = value addr
} else {
rv_load_val(rc.code, 11, 0);
}
rv_load_val(rc.code, 12, 0); // a2 = 0 (use strlen)
rc.code.push_back(rv_ECALL());
break;
}
fix(#2171): loop context crosses the ECALL program boundary Compiled iter() levels are now published in a guest-side loop-context table (rv_compiler::LOOPCTX_BASE): depth, plus each live level's element buffer address and 1-based iteration number. At ECALL time an RAII GuestLoopContext pushes those levels onto the interpreter's itext[]/inum[]/in_loop stack around the callee, so anything that evaluates softcode — fun_u's mux_exec, fun_itext, fun_ilev — sees the composed stack instead of an empty one. %i0 inside u() called from a compiled iter now answers, and nested programs compose correctly because each nesting depth owns its own s_vm buffer while the caller's RAII push stays active for the callee's whole run. The marshal also retires #2170's remaining compile bails: ilev() and dynamic-depth itext()/inum() inside compiled levels now lower to the plain ECALL and are correct at runtime, and constant depths naming enclosing interpreted iters no longer need the depth adjusted down by the compiled levels. Constant depths naming levels in THIS program still resolve at compile time. A nest deeper than the table (LOOPCTX_MAX_LEVELS = 10) declines the compile rather than publish a partial stack. Two codegen subtleties found by live probes, not review: - The element's table payload must go through rv_load_guest_addr: its buffer can be an output-frame slot, and storing the tagged constant raw handed the host an out-of-range guest address (the push then aborted, and ilev() read an empty stack). - HIR_LCTX_KEEP (emits no code) references the element at the END of the level's body, extending its live interval so the slot allocator cannot recycle the buffer for an inner loop's element or a body temporary while a callee could still read it through the table. run_cached_program zeroes the table's depth each run — the VM buffer is shared across programs, so a loop-free program must publish no levels. Stale-cache compatibility is not a concern: the code cache is flushed after a rebuild because the program hashes regenerate. Verified live on direct-#1 stdin (the JIT-gated context), jit_handled=5/5: u() callees see %i0/%i1/itext/inum across one and two compiled levels; ilev() composes; dynamic-depth itext/inum resolve outer levels including across three-level nests and interpreted enclosing iters. Full suite 35/0; iter_nest_fn TC004 pins the shapes. Note for posterity: the first probe battery used word(), which does not exist in TinyMUX — itext(#-1 ...) atoi'd to 0 and both routes agreed on the "wrong" answer. extract() is the equivalent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:23:36 -06:00
// Loop-context table maintenance (#2171). All table
// addresses are compile-time constants; the DBT maps the
// guest addresses at store time like every other SD.
case HIR_LCTX_DEPTH: {
rv_load_val(rc.code, RA_SCRATCH2,
static_cast<uint64_t>(h.val[i]));
rv_load_guest_addr(rc.code, RA_SCRATCH,
rv_compiler::LOOPCTX_BASE);
rc.code.push_back(rv_SD(RA_SCRATCH, RA_SCRATCH2, 0));
break;
}
case HIR_LCTX_ELEM: {
int v = h.src1[i];
if (v < 0) break;
uint64_t slot = rv_compiler::LOOPCTX_BASE
+ (1 + 2 * static_cast<uint64_t>(h.val[i])) * 8;
// The element's slot ADDRESS is the payload — the host
// dereferences it at ECALL time to read the current
// iteration's element. It can be an output-frame
// reference, so resolve it (rv_load_guest_addr) rather
// than storing the tagged constant raw: the tag bits
// would read as an out-of-range guest address.
rv_load_guest_addr(rc.code, RA_SCRATCH2, loc[v].addr);
rv_load_guest_addr(rc.code, RA_SCRATCH, slot);
rc.code.push_back(rv_SD(RA_SCRATCH, RA_SCRATCH2, 0));
break;
}
case HIR_LCTX_INUM: {
int v = h.src1[i];
if (v < 0) break;
uint64_t slot = rv_compiler::LOOPCTX_BASE
+ (2 + 2 * static_cast<uint64_t>(h.val[i])) * 8;
uint8_t r;
if (loc[v].in_reg) {
r = loc[v].reg;
} else if (loc[v].spill_slot >= 0) {
emit_spill_load(rc.code, RA_SCRATCH2,
loc[v].spill_slot);
r = RA_SCRATCH2;
} else {
break;
}
rv_load_guest_addr(rc.code, RA_SCRATCH, slot);
rc.code.push_back(rv_SD(RA_SCRATCH, r, 0));
break;
}
case HIR_LCTX_KEEP: // interval-only: no code (see hir.h)
case HIR_NOP:
case HIR_STORE_Q: // consumed by SSA construction
case HIR_LOAD_Q: // should be COPY after SSA; harmless NOP
break;
default:
break;
}
}
}
// Backpatch branch offsets.
for (auto &p : patches) {
int target_off = block_offset[p.target_blk];
int branch_off = p.code_idx;
int32_t rel = static_cast<int32_t>((target_off - branch_off) * 4);
uint32_t insn = rc.code[branch_off];
uint8_t opcode = insn & 0x7F;
if (opcode == OP_BRANCH) {
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
// B-type: re-encode with correct offset. Bail if the branch
// target is beyond the 13-bit signed reach rather than emit a
// truncated (wrong) offset.
if (!rv_branch_offset_ok(rel)) {
rc.out_exhausted = true;
}
uint8_t funct3 = (insn >> 12) & 7;
uint8_t rs1 = (insn >> 15) & 0x1F;
uint8_t rs2 = (insn >> 20) & 0x1F;
rc.code[branch_off] = rv_b_type(funct3, rs1, rs2, rel);
} else if (opcode == OP_JAL) {
jit: range-check RV64 PC-relative offsets and allocator indices The RV64 codegen handed raw byte offsets to the B-type/J-type encoders, which silently drop the bits that do not fit. Three places could emit corrupt instructions (or corrupt allocator state) if the JIT arena ever grew past the encodable reach: - #719: the four Tier-2 JAL call sites (blob dispatch, rv64_strtod fast path, HIR_FCALL1/HIR_FCALL2 intrinsic stubs) computed offset = func - pc and passed it straight to rv_JAL. JAL's immediate is 21-bit signed (+/-1 MiB); a larger offset jumped to a wrong address. - #722: the branch backpatch loop re-encoded B-type (13-bit, +/-4 KiB) and JAL (21-bit) offsets with no range check. - #720: the linear-scan and output-buffer allocators wrote result.reg/addr/spill_slot[iv.value] without verifying iv.value < HIR_MAX_INSNS. The HIR builder caps n_insns, so this is defensive against a future pass that synthesizes virtuals out of band. Add rv_jal_offset_ok()/rv_branch_offset_ok() and route every computed offset through them; on overflow set rc.out_exhausted so the compiler driver discards the blob and the AST evaluator handles the expression -- the same bail path already used elsewhere. The allocators gain explicit HIR_MAX_INSNS bounds guards that bail the same way. All checks pass trivially for in-range offsets, so normal compilation is unchanged. Smoke: JIT 1061/0, non-JIT 1050/0. Closes #719 Closes #720 Closes #722 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:45:42 -05:00
// J-type: re-encode with correct offset. Bail if beyond the
// 21-bit signed reach.
if (!rv_jal_offset_ok(rel)) {
rc.out_exhausted = true;
}
uint8_t rd = (insn >> 7) & 0x1F;
rc.code[branch_off] = rv_JAL(rd, rel);
}
}
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
// Set the result location in the rv_compiler. When the returns write to
// a shared slot, that slot is the answer and h.result -- which names only
// the first return site -- must not override it (#1486).
int ri = h.result;
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 (rc.ret_out != 0) {
rc.final_out = rc.ret_out;
} else if (ri >= 0) {
if (loc[ri].in_reg) {
if (!rc.needs_jit && h.kind[ri] == HIR_ICONST) {
// Constant integer with no runtime code — convert to
// string at compile time instead of emitting ITOA.
// This keeps the result in the string pool (low memory)
// so it survives SQLite cache persistence.
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"),
static_cast<long long>(h.val[ri]));
uint64_t addr = rc.pool_str(buf, strlen(buf));
rc.final_out = addr;
} else {
// Final result is in a register — need ITOA at runtime.
uint64_t out_addr = rc.alloc_output();
rv_load_guest_addr(rc.code, 10, out_addr);
rv_emit_itoa(rc.code, loc[ri].reg, 10);
rc.final_out = out_addr;
rc.needs_jit = true;
}
} else if (loc[ri].spill_slot >= 0) {
// Final result is spilled — reload and ITOA.
uint64_t out_addr = rc.alloc_output();
emit_spill_load(rc.code, RA_SCRATCH, loc[ri].spill_slot);
rv_load_guest_addr(rc.code, 10, out_addr);
rv_emit_itoa(rc.code, RA_SCRATCH, 10);
rc.final_out = out_addr;
rc.needs_jit = true;
} else {
rc.final_out = loc[ri].addr;
}
}
fix(jit): move integer spill slots inside the stack frame (#2052) Spill slots lived at SP-8, SP-16, ... -- BELOW the stack pointer. That kept them clear of the output buffers, but RV64 has no red zone: the first JAL into a blob function let the gcc-compiled callee build its frame right on top of them. A spilled value reloaded after any tier2 call read the callee's dead locals. Nothing ever noticed because nothing ever both spilled AND called. The allocator has 10 registers, and no compiled program had held more than 10 integers live across a call until the ITER cursor rework added one int PHI and one ATOI per loop. Then: [iter(A,%i0)][iter(B,%i0)] gave "A B" (expected "AB") parser_fn TC020 (four loops) gave "X Y Z31" (lost elements) The pinning observation: in the same loop iteration, #@ (inum+1, register operand) printed 1 2 3 correctly while is_first = EQ(inum, 0) (spilled operand, reloaded after two split_token calls) read garbage. Same SSA value, right at one use, wrong at the other -- with tier2 calls in between. More loops meant more spills meant grosser corruption, which is why the 4-loop smoke expression failed harder than anything typed at `think`, and why every isolated test in the WIP handoff was correct: none of them spilled. Slots now sit at +8*slot from the post-prologue SP, inside the frame the prologue reserves; the backpatch adds a 16-byte-aligned spill area below the output slots and emits the SUB even when there are no output slots. Callee frames start below SP and cannot reach either region. Validated by perturbation both ways: with only this hunk reverted, the two-loop expression reads "A B"; restored, "AB". Full suite: 36 targets, 35 passed, 1 skipped (stubslave=no), 0 failed. Latent on master for any spilled program that makes calls; the ITER work is merely the first to compile one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:37:10 -06:00
// Backpatch prologue: set SP to accommodate output frame + spill area.
//
// The spill area sits at [SP, SP + spill_area) AFTER the SUB — inside
// the frame, so callee frames (which start below SP) cannot touch it.
// See spill_offset() for the failure this replaces. Rounded to 16 so
// the SP alignment the callees see is unchanged from before.
//
// The SUB must be emitted when there are spills EVEN IF there are no
// output slots: positive spill offsets address the frame the SUB
// creates, and without it they would read the caller's stack.
uint64_t spill_area = (static_cast<uint64_t>(rc.spills) * 8 + 15)
& ~static_cast<uint64_t>(15);
if (rc.n_output_slots > 0 || spill_area > 0) {
// Frame includes 8-byte alignment pad + all output slots.
uint64_t frame_size = 8 + static_cast<uint64_t>(rc.n_output_slots)
fix(jit): move integer spill slots inside the stack frame (#2052) Spill slots lived at SP-8, SP-16, ... -- BELOW the stack pointer. That kept them clear of the output buffers, but RV64 has no red zone: the first JAL into a blob function let the gcc-compiled callee build its frame right on top of them. A spilled value reloaded after any tier2 call read the callee's dead locals. Nothing ever noticed because nothing ever both spilled AND called. The allocator has 10 registers, and no compiled program had held more than 10 integers live across a call until the ITER cursor rework added one int PHI and one ATOI per loop. Then: [iter(A,%i0)][iter(B,%i0)] gave "A B" (expected "AB") parser_fn TC020 (four loops) gave "X Y Z31" (lost elements) The pinning observation: in the same loop iteration, #@ (inum+1, register operand) printed 1 2 3 correctly while is_first = EQ(inum, 0) (spilled operand, reloaded after two split_token calls) read garbage. Same SSA value, right at one use, wrong at the other -- with tier2 calls in between. More loops meant more spills meant grosser corruption, which is why the 4-loop smoke expression failed harder than anything typed at `think`, and why every isolated test in the WIP handoff was correct: none of them spilled. Slots now sit at +8*slot from the post-prologue SP, inside the frame the prologue reserves; the backpatch adds a 16-byte-aligned spill area below the output slots and emits the SUB even when there are no output slots. Callee frames start below SP and cannot reach either region. Validated by perturbation both ways: with only this hunk reverted, the two-loop expression reads "A B"; restored, "AB". Full suite: 36 targets, 35 passed, 1 skipped (stubslave=no), 0 failed. Latent on master for any spilled program that makes calls; the ITER work is merely the first to compile one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:37:10 -06:00
* rv_compiler::OUT_SLOT
+ spill_area;
// LUI t0, upper20
uint32_t hi = static_cast<uint32_t>(frame_size) & 0xFFFFF000;
int32_t lo = static_cast<int32_t>(frame_size & 0xFFF);
if (lo & 0x800) { hi += 0x1000; lo -= 0x1000; }
rc.code[prologue_pos + 0] = rv_LUI(5, hi); // LUI t0, upper
rc.code[prologue_pos + 1] = rv_ADDI(5, 5, lo); // ADDI t0, t0, lower
rc.code[prologue_pos + 2] = rv_SUB(2, 2, 5); // SUB SP, SP, t0
}
// If n_output_slots == 0, the NOPs remain (harmless).
// Emit exit.
rv_emit_exit(rc.code);
}
const char *hir_kind_name(hir_kind k) {
switch (k) {
case HIR_NOP: return "NOP";
case HIR_ICONST: return "ICONST";
case HIR_SCONST: return "SCONST";
case HIR_ADD: return "ADD";
case HIR_SUB: return "SUB";
case HIR_MUL: return "MUL";
case HIR_DIV: return "DIV";
case HIR_REM: return "REM";
case HIR_NEG: return "NEG";
case HIR_SIGN: return "SIGN";
case HIR_MAX: return "MAX";
case HIR_MIN: return "MIN";
case HIR_BAND: return "BAND";
case HIR_BOR: return "BOR";
case HIR_BXOR: return "BXOR";
case HIR_BNOT: return "BNOT";
case HIR_SHL: return "SHL";
case HIR_SHR: return "SHR";
case HIR_EQ: return "EQ";
case HIR_NE: return "NE";
case HIR_LT: return "LT";
case HIR_LE: return "LE";
case HIR_GT: return "GT";
case HIR_GE: return "GE";
case HIR_NOT: return "NOT";
case HIR_BOOL: return "BOOL";
case HIR_INC: return "INC";
case HIR_DEC: return "DEC";
case HIR_ATOI: return "ATOI";
case HIR_STRCMP: return "STRCMP";
case HIR_LUA_NEWTABLE: return "LUA_NEWTABLE";
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
case HIR_LUA_LEN: return "LUA_LEN";
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
case HIR_LUA_INSN_BUDGET: return "LUA_INSN_BUDGET";
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
case HIR_LUA_GETGLOBAL: return "LUA_GETGLOBAL";
case HIR_LUA_GETFIELD_REF: return "LUA_GETFIELD_REF";
case HIR_LUA_CALL_INT: return "LUA_CALL_INT";
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
case HIR_LUA_CALL_STR: return "LUA_CALL_STR";
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
case HIR_LUA_CALL_VOID: return "LUA_CALL_VOID";
case HIR_LUA_CALL_VAL: return "LUA_CALL_VAL";
case HIR_LUA_MARSHAL: return "LUA_MARSHAL";
case HIR_LUA_TOBOOL: return "LUA_TOBOOL";
case HIR_LUA_EQ: return "LUA_EQ";
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
case HIR_LUA_LIMITED: return "LUA_LIMITED";
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
case HIR_LUA_GETFIELD: return "LUA_GETFIELD";
2026-07-28 16:44:16 -06:00
case HIR_LUA_GETFIELD_FLT: return "LUA_GETFIELD_FLT";
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
case HIR_LUA_SETFIELD: return "LUA_SETFIELD";
case HIR_LUA_GETI: return "LUA_GETI";
case HIR_LUA_SETI: return "LUA_SETI";
case HIR_LUA_ALOAD: return "LUA_ALOAD";
case HIR_ITOA: return "ITOA";
case HIR_ITOF: return "ITOF";
case HIR_FTOI: return "FTOI";
case HIR_FTOA: return "FTOA";
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 HIR_LUA_FTOA: return "LUA_FTOA";
case HIR_ATOF: return "ATOF";
case HIR_FCONST: return "FCONST";
case HIR_FADD: return "FADD";
case HIR_FSUB: return "FSUB";
case HIR_FMUL: return "FMUL";
case HIR_FDIV: return "FDIV";
case HIR_FNEG: return "FNEG";
case HIR_FSQRT: return "FSQRT";
case HIR_FEQ: return "FEQ";
case HIR_FLT: return "FLT";
case HIR_FLE: return "FLE";
case HIR_CALL: return "CALL";
perf(jit): integers cross the tier-2 boundary as integers (#2132) The compiled ITER loop lost to the interpreter's C loop per element, and its per-element cost climbed with N while the interpreter's stayed flat. Profiling (guest->host block map + ELF symbolization of anonymous JIT frames) found the mechanism the issue asked for: the tier-2 string ABI. Every argument crosses as decimal text, so each element paid SIX integer<->string conversions — ITOA(cursor) caller-side, satoi(cursor) callee-side twice (elem + next modes), sitoa/ATOI for the next offset, ITOA/satoi for the append's length and iteration number, sitoa/ATOI for the new length — digit-proportional loops, executed under DBT expansion, on numbers (byte cursors, accumulated lengths) whose digit counts grow with the list. That is both the bulk of per-element cost and the whole of the residual superlinearity: cost/element ~ a + b*digits(N). The fix is the calling convention the machine already has. HIR_CALL_T2I loads TY_INT arguments straight into a0.. from their registers and TY_STRING arguments as guest addresses, JALs to the blob, and takes the callee's long return from a0; arguments ride carg[] so liveness, DCE and copy propagation see them (hir_is_carg_call). val[i]=1 allocates an output slot passed ahead of the args; HIR_T2I_STR aliases it as the element string. Two int-native blob entrypoints replace the string pair: rv64_split_step (ONE call per element instead of two — element written to out, next cursor returned in a0) and rv64_append_i. The next cursor stays an SSA value stored in the latch, preserving the nested-iter safety the string route had. Measured (macOS arm64, min-of-5, ast=/cached= per CLAUDE.md): iter(lnum(N),1) us/element ratio vs interpreter N before after before after 200 0.117 0.030 0.75 0.19 1000 0.125 0.030 0.81 0.20 4000 0.145 0.033 0.89 0.21 4x on the compiled path; the loop that opened the issue LOSING 1.42x on Linux is 5x FASTER than the interpreter here. Fixed-width N-pair probes no longer climb (median ratio 0.90, was 1.09 at 10/10 above 1.0), and the per-char slope halves (one scan per element, not two). MAP/FILTER still compose the string route; converting them is the follow-up. Two silent-fallback traps found en route, each now carrying a warning: - tier2_allowed()'s allowlist quietly vetoed the new names: lookup returned 0, the lowering kept its graceful string fallback, and every test passed while the fix did not run. Ground truth came from disassembling the compiled program out of the SQLite code cache — the JAL targets do not lie. The allowlist comment now names this failure shape. - A same-mtime-second edit after a build produced an engine that had the new symbols in source and nowhere in the binary (#2118's genre). Also rides along: the env-gated profiling diagnostics that found this — TINYMUX_DBT_MAP (guest-pc -> host-address lines at translate time) and TINYMUX_DBT_CODEDUMP (code_buf + block-cache table at cleanup), which together let a sampling profiler's anonymous JIT frames be symbolized against the blob ELF. Full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped (stubslave, not configured), 0 failed — including smoke's 1660 goldens, the jit parity suites, and the format guard. Functional probes cover nested iter, #@/##, custom in/out separators, runtime leading spaces, and empty lists, all exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:58:55 -06:00
case HIR_CALL_T2I: return "CALL_T2I";
case HIR_T2I_STR: return "T2I_STR";
case HIR_STRCAT: return "STRCAT";
case HIR_FCALL1: return "FCALL1";
case HIR_FCALL2: return "FCALL2";
case HIR_RET: return "RET";
case HIR_COPY: return "COPY";
case HIR_PHI: return "PHI";
case HIR_LOAD_Q: return "LOAD_Q";
case HIR_STORE_Q: return "STORE_Q";
case HIR_SETQ_SYNC: return "SETQ_SYNC";
fix(#2171): loop context crosses the ECALL program boundary Compiled iter() levels are now published in a guest-side loop-context table (rv_compiler::LOOPCTX_BASE): depth, plus each live level's element buffer address and 1-based iteration number. At ECALL time an RAII GuestLoopContext pushes those levels onto the interpreter's itext[]/inum[]/in_loop stack around the callee, so anything that evaluates softcode — fun_u's mux_exec, fun_itext, fun_ilev — sees the composed stack instead of an empty one. %i0 inside u() called from a compiled iter now answers, and nested programs compose correctly because each nesting depth owns its own s_vm buffer while the caller's RAII push stays active for the callee's whole run. The marshal also retires #2170's remaining compile bails: ilev() and dynamic-depth itext()/inum() inside compiled levels now lower to the plain ECALL and are correct at runtime, and constant depths naming enclosing interpreted iters no longer need the depth adjusted down by the compiled levels. Constant depths naming levels in THIS program still resolve at compile time. A nest deeper than the table (LOOPCTX_MAX_LEVELS = 10) declines the compile rather than publish a partial stack. Two codegen subtleties found by live probes, not review: - The element's table payload must go through rv_load_guest_addr: its buffer can be an output-frame slot, and storing the tagged constant raw handed the host an out-of-range guest address (the push then aborted, and ilev() read an empty stack). - HIR_LCTX_KEEP (emits no code) references the element at the END of the level's body, extending its live interval so the slot allocator cannot recycle the buffer for an inner loop's element or a body temporary while a callee could still read it through the table. run_cached_program zeroes the table's depth each run — the VM buffer is shared across programs, so a loop-free program must publish no levels. Stale-cache compatibility is not a concern: the code cache is flushed after a rebuild because the program hashes regenerate. Verified live on direct-#1 stdin (the JIT-gated context), jit_handled=5/5: u() callees see %i0/%i1/itext/inum across one and two compiled levels; ilev() composes; dynamic-depth itext/inum resolve outer levels including across three-level nests and interpreted enclosing iters. Full suite 35/0; iter_nest_fn TC004 pins the shapes. Note for posterity: the first probe battery used word(), which does not exist in TinyMUX — itext(#-1 ...) atoi'd to 0 and both routes agreed on the "wrong" answer. extract() is the equivalent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:23:36 -06:00
case HIR_LCTX_DEPTH: return "LCTX_DEPTH";
case HIR_LCTX_ELEM: return "LCTX_ELEM";
case HIR_LCTX_INUM: return "LCTX_INUM";
case HIR_LCTX_KEEP: return "LCTX_KEEP";
case HIR_BR: return "BR";
case HIR_BRC: return "BRC";
default: return "UNKNOWN";
}
}
static const char *hir_type_name(hir_type t) {
switch (t) {
case TY_VOID: return "void";
case TY_INT: return "int";
case TY_FLOAT: return "flt";
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
case TY_LUA_HANDLE: return "lhnd";
case TY_STRING: return "str";
default: return "???";
}
}
void hir_dump(const hir_program &h) {
printf("HIR Program: %d instructions, %d blocks\n", h.n_insns, h.n_blocks);
printf("Result: v%d\n", h.result);
for (int b = 0; b < h.n_blocks; b++) {
printf("\nBLOCK %d:\n", b);
printf(" Range: [%d, %d]\n", h.block_first[b], h.block_last[b]);
printf(" Preds: ");
for (int i = 0; i < h.n_pred[b]; i++) {
printf("%d ", h.pblk[h.pred_base[b] + i]);
}
printf("\n Succs: ");
for (int i = 0; i < h.block_nsucc[b]; i++) {
printf("%d ", h.block_succ[b][i]);
}
printf("\n IDom: %d\n", h.idom[b]);
if (h.block_first[b] >= 0
&& h.block_first[b] <= h.block_last[b]
&& h.block_last[b] < h.n_insns) {
for (int i = h.block_first[b]; i <= h.block_last[b]; i++) {
if (h.blk[i] != b) continue;
printf(" v%-3d = %-10s %-4s", i, hir_kind_name(h.kind[i]), hir_type_name(h.ty[i]));
if (h.kind[i] == HIR_ICONST) {
printf(" %lld", (long long)h.val[i]);
} else if (h.kind[i] == HIR_SCONST) {
const char *sv = (i < static_cast<int>(h.sval.size()))
? h.sval[i].c_str() : "<unset>";
printf(" \"%s\" (0x%llX)", sv, (unsigned long long)h.val[i]);
} else if (h.kind[i] == HIR_BR) {
printf(" -> BLOCK %d", (int)h.val[i]);
} else if (h.kind[i] == HIR_BRC) {
printf(" v%d ? -> BLOCK %d : BLOCK %d", h.src1[i], (int)h.val[i], h.src2[i]);
} else if (h.kind[i] == HIR_PHI) {
printf(" Q%d { ", (int)h.val[i]);
for (int j = 0; j < h.pnargs[i]; j++) {
printf("B%d:v%d ", h.pblk[h.pbase[i] + j], h.pval[h.pbase[i] + j]);
}
printf("}");
} else if (h.kind[i] == HIR_CALL || h.kind[i] == HIR_STRCAT) {
if (i < static_cast<int>(h.call_name.size()) && !h.call_name[i].empty())
printf(" %s", h.call_name[i].c_str());
printf(" ( ");
for (int j = 0; j < h.cnargs[i]; j++) {
printf("v%d ", h.carg[h.cbase[i] + j]);
}
printf(")");
if (h.tier2_addr[i]) printf(" [T2:0x%llX]", (unsigned long long)h.tier2_addr[i]);
} else {
if (h.src1[i] >= 0) printf(" v%d", h.src1[i]);
if (h.src2[i] >= 0) printf(", v%d", h.src2[i]);
if (h.val[i] != 0 && h.kind[i] != HIR_COPY) printf(" imm:%lld", (long long)h.val[i]);
}
printf("\n");
}
}
}
printf("--- end dump ---\n");
}