Commit graph

71 commits

Author SHA1 Message Date
Stephen Dennis
5b497df7c1 fix(lua/jit): tonumber fast path must reject int64-overflowing literals (#1866)
#1866's proven-integral fast path upgrades tonumber(arg) to CALL_INT when
the argument is an all-digit SCONST.  But Lua 5.4 returns a FLOAT, not an
integer, when a decimal literal overflows int64:

  tonumber("9223372036854775808")    -> 9.2e18   (INT64_MAX + 1)
  tonumber("99999999999999999999999999") -> 1e26

For those, CALL_INT sees lua_isinteger == false and post-entry declines
loud (#-1 LUA ERROR) where the interpreter answers the float — a
compiled-vs-interpreter divergence, exactly the class the seam corpus
guards, newly introduced by the fast path.

lua_tonumber_arg_is_integral now bounds the significant-digit count:
INT64_MAX has 19 digits, so <= 18 significant digits always fits and is
guaranteed integral; 19+ falls back to CALL_VAL, which preserves the
typed float.  Conservative (some in-range 19-digit values take the slower
CALL_VAL path) but sound — same significant-digit bound as the CIDR
prefix guard.  tonumber("17") still executes native.

tests/luajit gains two EXEC pins (INT64_MAX+1 and a 26-digit literal);
both FAIL against the pre-fix engine (post-entry decline) and pass after.
Harness: 228 chunks, 0 divergences, 0 exec_post_entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 09:20:59 -06:00
Stephen Dennis
4ca2f9fe01 fix(lua/jit): tonumber CALL_INT fast path for proven integers (#1866)
Keep CALL_VAL for non-integral/runtime args so floats do not post-entry
decline, but upgrade to CALL_INT when the argument is a HIR integer or
an integral string constant.  Restores native throughput for
tonumber("17") and tonumber(n) while preserving correct float results.
2026-07-31 09:08:44 -06:00
Stephen Dennis
7abc78e781 fix(lua/jit,tests): tonumber CALL_VAL; tests/db LBUF_SIZE (#1866 #1868)
tonumber can return int or float; claiming TY_INT always used CALL_INT and
post-entry declined on float results.  Drop it from the int-return claim
so CALL_VAL preserves the typed stack value.  EXEC pins "3.5"/"3.0"/"17".

Standalone tests/db never saw LBUF_SIZE (no alloc.h).  Define it in the
Makefile to match alloc.h, and include <cstring> in sqlite_backend for
the same TINYMUX_TYPES_DEFINED compile path.
2026-07-31 09:06:07 -06:00
Stephen Dennis
eb12a97f68 fix(lua/jit): pre-entry fallback, protect GETGLOBAL/EQ, CMP_RR types (#1835 #1836 #1837)
Phase 4 over-committed pre-entry setup failures (carg/depth/get_dbt) as
#-1 LUA JIT RUN FAIL; fall back to the interpreter when nothing ran.
Protect lua_getglobal and lua_compare via pcall so raising metamethods
cannot abort the server. Apply EQK-class type gates and NIL/BOOL kinds
to register-register CMP_RR equality, and decline mixed-type order.
2026-07-31 07:12:16 -06:00
Stephen Dennis
1e8fb01a0d Merge remote-tracking branch 'origin/master' into fix/1795-sentinel-escape
# Conflicts:
#	tests/luajit/run.sh
2026-07-29 15:48:02 -06:00
Stephen Dennis
b8ea2d80eb fix(lua/jit): compile CALL_VAL handle equality via HIR_LUA_EQ
Value-producing `return x == "0"` is EQK+JMP+LFALSESKIP+LOADTRUE, but
EQK was never in the bool fuse and always declined on TY_LUA_HANDLE.
Add ECALL_LUA_EQ / HIR_LUA_EQ, fuse EQK like EQ/EQI, and route handle
and non-handle EQK through emit_cmp_branch.  tostring(0)=="0" and
return nil=="" now execute; AGREE_DECLINE_BUDGET 13→11 on current master.
2026-07-29 15:38:19 -06:00
Stephen Dennis
b3e1d2dc92 Fix Lua mux sentinel provenance 2026-07-29 15:19:43 -06:00
Stephen Dennis
2f8542591f fix(lua/jit): the mux.* sentinel must not escape as a value (#1795)
`mux` and `mux.args` are lowered as SCONSTs holding their own NAMES,
which is what buys the native CARGS fast path: GETTABI on the
"mux.args" sentinel becomes an ALOAD with no ECALL.  That fiction is
sound only while the sentinel is consumed AS a sentinel, and the guards
for it were per-consumer and incomplete.  Five consumers believed the
representation, every one executing and silent:

  return type(mux.args)         jit "string"     interp "table"
  return tostring(mux.args)     jit "mux.args"   interp "table: 0x..."
  return "x" .. mux.args        jit "xmux.args"  interp raises
  if mux.args == "mux.args"     jit "y"          interp "n"
  return #mux                   jit 3            interp 0

The last is #1424's shape on a different value class: a length taken
over the NAME of a thing instead of the thing.

One predicate (lua_is_mux_sentinel, beside lua_is_marshalled_str and
lua_is_nil) applied at CONCAT, the call-argument walk, both comparison
macros, and LEN; plus LUA_TC_OTHER in lua_type_class_of_value, which
covers EQK and EQI in one place because a sentinel is really a table.
LEN keeps the mux.args arity path -- that consumer reads the sentinel
as a sentinel, which is exactly the distinction being drawn.

Five AGREE pins for the stable shapes.  tostring(mux.args) is
deliberately not pinned: its answer embeds a heap address, so only a
shape comparison could assert it; verified by hand that both routes now
produce "table: 0x...".

The CARGS fast path is untouched: mux.args[N] and #mux.args still
EXECUTE (run_ok=1).

Third instance of the same rule (plan section 6): a representation that
lies about its type must be refused by every consumer that would
believe it.  This one predates the rule by years.

make test exit 0; smoke 1561/0; luajit PASSED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:39 -06:00
Stephen Dennis
a9f28f9062 fix(lua/jit): compile #mux.args as SUBST_NCARGS, not strlen of the sentinel
The mux.args table is carried through lowering as the SCONST "mux.args".
OP_LUA_LEN treated it as TY_STRING and would measure strlen("mux.args")
== 8, so the shape declined and the interpreter answered.  Under
production brackets that was the wrong default for every nested lua().

Softcode already parks the call's ncargs in SUBST_NCARGS for %+.  Use
that slot: emit_sref + ATOI.  NESTED now requires lua_run_ok>0 for
return #mux.args; NESTED_AGREE keeps only the unbounded-loop pin.
2026-07-29 13:08:17 -06:00
Stephen Dennis
414ea0ecca feat(lua/jit): CALL_VAL keeps call results as typed stack values
#1764 shape 2: default STRING-claim calls no longer marshal to TY_STRING
text immediately.  ECALL_LUA_CALL_VAL leaves the first pcall result on the
Lua stack and returns a TY_LUA_HANDLE.  Softcode boundary marshals via
ECALL_LUA_MARSHAL (fun_lua rules).  TEST/NOT use ECALL_LUA_TOBOOL so
tostring(0)/"" / string.find stay truthy under Lua semantics.

CALL_INT / CALL_VOID unchanged.  Equality of a value handle to a string
constant still declines (needs VM compare).

AGREE_DECLINE 16→12: the four #1764 if/not pins now execute.
2026-07-29 13:02:01 -06:00
Stephen Dennis
6f38d62b7b fix(lua/jit): equality compares Lua TYPES, not representations (#1770 review)
The EQK nil fix is right, and its lesson generalizes further than it was
applied.  Lua's == is false across TYPES, and HIR erases every
distinction that decides it: false and 0 are the same ICONST, nil and ""
the same empty SCONST, and the numeric path coerces "5" to 5.  Measured
on the PR as submitted -- all executing, all silently wrong:

  local a=0     if a == false   jit "y"  interp "n"
  local a=1     if a == true    jit "y"  interp "n"
  local a=false if a == 0       jit "y"  interp "n"
  local a="5"   if a == 5       jit "y"  interp "n"
  local a=5     if a == "5"     jit "y"  interp "n"

Generalizes the author's nil branch into a type-class test
(lua_type_class_of_value / _of_const, from the truth tag plus the HIR
type): mismatched classes are constant false whatever the
representations say; nil == nil stays true.

EQI needed the same treatment and is now its own case rather than
sharing CMP_RI: `a == 0` lowers to EQI and `a == ""` to EQK, so fixing
either alone leaves the other wrong -- the opcode-twin trap this PR's
own doc note names, one level deeper.  Order comparisons keep declining
instead: Lua RAISES on mismatched types there, so answering false would
be its own wrong answer.

Five AGREE pins across both opcode forms; all EXECUTE and answer like
the interpreter.  Plan gains the rule as item 5.

Also records the softcode no-post-entry-decline assumption that #1767's
exactly-once argument rests on -- derived today from the #1002 pre-entry
watermarks plus Phase 4 leaving one defensive ECALL_DECLINE that nothing
emits into, and therefore worth writing where it can be checked.

make test exit 0; smoke 1561/0; luajit PASSED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:33 -06:00
Stephen Dennis
0efe4956ca fix(lua/jit): EQK must not treat nil as empty string
#1766's consumer audit covered CMP_RR (return t[2]=="") but not EQK
(if t[2]=="" then).  nil is the empty SCONST in HIR; EQK's STRCMP path
made nil == "" true compiled while the interpreter answers false.

Resolve EQK against the pool constant type and the lhs NIL tag before
STRCMP: nil==nil is true, nil vs anything else (including "") is false.
Tag pool TNIL/TFALSE/TTRUE on LOADK/LOADKX and on EQK's constant side.
Also refuse GTI/GEI on nil (order of nil).

Three EXEC-friendly AGREE pins for the if-EQK shapes.  Document the
"new value class ⇒ consumer audit" lesson (including opcode twins) in
the post-entry plan.
2026-07-29 12:22:12 -06:00
Stephen Dennis
20fe115a32 fix(lua/jit): allow effects on compiled path; close post-entry loud
Phase 4 deleted silent re-run, so #1750's effect-free corridor is obsolete:
bridge notify/pemit/set/eval run on both routes under the same permissions.
Remove EFFECT_REFUSED and compile-time effectful ineligibility.

STATE e2 delivers PING once on both legs. POST_ENTRY_LOUD 1→0;
AGREE_DECLINE 10→8 (mux.eval executes).

Docs: rewrite plan-lua-post-entry-contract as campaign-complete + residual
design goals; product plan points residual quality at §3.
2026-07-29 11:08:22 -06:00
Stephen Dennis
75f8a07a75 fix(lua/jit): audit nil's consumers, not just its producers (#1766 review)
The closed-key-set analysis is sound and the producers are right, but
lowering a known-absent read to nil makes nil a value that FLOWS -- and
the consumers had not been audited.  Measured on the first cut, all
silent wrong answers where master had been loudly declining:

  "a" .. t[2]        jit "a"    interp raises "concatenate a nil value"
  #t[2]              jit 0      interp raises "length of a nil value"
  tostring(t[2])     jit ""     interp "nil"
  t[2] == ""         jit 1      interp 0   (found while fixing)

nil is representable in HIR only as the empty SCONST, which is also a
real "" -- the truth tag is the only thing that tells them apart, so
every operation Lua rejects on nil must consult it.  One predicate
(lua_is_nil, mirroring lua_is_marshalled_str) applied at CONCAT, LEN,
the call-argument walk, and both comparison macros; promote_to_int and
promote_to_float already had it.

Five AGREE pins, one per shape above plus concat-through-a-local, so
the class cannot regress silently.  Budget 10 -> 15: nil has no
compiled consumer yet, and every one of those five is a decline by
design until it does.

This is the fourth PR in the campaign where emptying a loud bin
introduced a silent wrong answer (#1755, #1756, #1763, this).  The
pattern is worth naming: a new VALUE CLASS needs a consumer audit, not
just correct producers.

make test exit 0; smoke 1561/0; luajit PASSED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:57:18 -06:00
Stephen Dennis
b24ba0d385 fix(lua/jit): known-absent plain GETI lowers to nil
GETI_INT cannot carry Lua nil; an absent key on a plain-proven table was
a post-entry residual decline. Track a closed integer-key set on plain
tables (constant SETTABI/SETLIST stores). When keys_closed and the GETI
key is not in the set, emit LOADNIL's representation (empty SCONST + NIL
tag) at lowering — no ECALL, no loud miss.

promote_to_int/float refuse NIL so arithmetic on a known-absent read
declines to the interpreter (matching Lua's error).

POST_ENTRY_LOUD 2→1 (only STATE e2 EFFECT_REFUSED remains). EXEC pins
for return t[2], constructor miss, and if on absent key.
2026-07-29 09:57:30 -06:00
Stephen Dennis
7edf44b2ef hardening(lua/jit): UNKNOWN truth class for tag loss (#1768)
lua_truth_of defaulted to VALUE (always truthy) for untagged HIR. That
is correct for numbers/strings, but if a BOOL or NIL tag is ever lost
the value silently becomes truthy — #1765's bug class through the back
door.

Add LUA_TRUTH_UNKNOWN. Tag HIR_LOAD_Q reloads (loop-carried qregs and
FORLOOP index). TEST/NOT/TESTSET decline on UNKNOWN rather than invent
VALUE. Also treat negative HIR indices and the LOAD_Q kind as UNKNOWN.

AGREE pins for the three shapes that already decline by luck (loop-
carried false, table false store, a or 5); decline budget 10→13.
2026-07-29 09:24:24 -06:00
Stephen Dennis
7f18e20b28 fix(lua/jit): Lua truthiness for 0, "", nil, and false
HIR collapses false and integer 0 to the same ICONST 0, and nil and ""
to the same empty SCONST. HIR_BOOL is integer SNEZ, so `local a=0 if a`
answered falsy compiled while Lua requires truthy.

Tag HIR values at lowering (VALUE / BOOL / NIL). TEST and NOT use the
tag: only nil and false are falsy; numbers and real strings are always
truthy. Also force needs_jit when return_as_string emits runtime ITOA
so `return not a` no longer takes the folded path with an empty sval.

EXEC pins for if/not on 0, false, nil, "", "0", 0.0, and 1-1.
2026-07-29 08:59:24 -06:00
Stephen Dennis
aacdebd042 fix(lua/jit): decline Lua ops on marshalled CALL_STR results (#1764)
CALL_STR erases the Lua type into text (fun_lua-style marshal). Consuming
that text with if/not/==/arithmetic is a string lie: "0", "" and integer 0
are truthy in Lua but MUSH-falsy as softcode. Mark HIR_LUA_CALL_STR as
provenance and refuse those consumers at lowering; the interpreter answers.
Chunk return of the same value still executes (softcode boundary).

AGREE pins for tostring(0)/""/string.find under if, plus not/==; decline
budget 5→10.
2026-07-29 08:08:14 -06:00
Stephen Dennis
2e2a23973f fix(lua/jit): EQK must mark skip and fall-through as block leaders (#1761)
OP_LUA_EQK was missing from insn_leaders while EQ/TEST already marked
pc+1 and pc+2.  Without those leaders, EQK's false edge targeted the
comparison's own block — a self-loop that burned the DBT dispatch limit.
The taken branch still worked; fall-through/else returns failed with
lua_run_fail, and the interpreter re-run hid the bug until Phase 4.

Pin both spellings in EXEC (taken and miss, if/else and fall-through).
2026-07-29 07:21:03 -06:00
Stephen Dennis
5c9ce602d5 fix(lua/jit): Phase 2 revision -- unencodable calls are ineligible, not
string-coerced (#1751)

Replaces the original Phase 2 draft rather than amending it.  That
draft resurrected the named __lua_call with the function handle ITOA'd
through guest memory and every argument STRINGIFIED.  Review measured
the consequence: a table argument reached string.format as its stack
index rendered in decimal (interp 72 chars of "table: 0x...", compiled
12 chars of digits, run_ok=1, nothing loud) -- #1424's exact
resurrection, and the plan names string lies a non-goal in its own
text.

The honest form of "no post-entry decline" for shapes the typed
CALL_INT/STR/VOID encoding cannot carry is rule 1: ineligible at
lowering, interpreter answers.  So every named __lua_* emission is now
a lowering refusal -- the general call, runtime-keyed stores
(__lua_seti: the plain `t[i]=v` loop's SURVIVE divergence disappears,
pre-entry instead of loud), global writes, SELF dispatch, string-keyed
general reads, and the TFOR iterator relic.  The fail-closed named
dispatch in the handler remains as the belt; nothing emits into it.

lua_call_claim gains the FLOAT-returning stdlib names (sqrt, exp, trig,
...): no CALL_FLT marshalling exists, so a FLOAT claim is ineligible
and the interpreter answers with the right subtype.  Smoke TC046
tightens back to asserting 12.0's answer alone.

Re-baselines: POST_ENTRY_LOUD 7 -> 6 (select and os.time moved to
pre-entry; remaining loud is Phase 3's budget/while-true pair,
string.find's numeric-result gap, the absent-key pin, and STATE e1/e2).
AGREE_DECLINE 3 -> 5 for those two, silent by design.

make test exit 0; smoke 1561/0; luajit 146 chunks 0 wrong, SURVIVE
divergences 0; STATE oracle green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:44:02 -06:00
Stephen Dennis
cd1914a677 fix(lua/jit): Phase 1 revision -- typed reads need the plain proof
Review of the Phase 1 totalization found a silent wrong answer it
introduced, and the fix exposed a second one that predates it.

INTRODUCED: totalized lua_gettable runs metamethods, but the typed
claim-miss path continued with 0.  Measured: an interpreter-installed
__index on a global made compiled `return T[1]` answer 0 where the
interpreter answered "s" -- run_ok=1, decline counter 0, nothing loud.
The campaign's own worst category, from its own Phase 1.

PRE-EXISTING: the same continue-with-0 shape made an ABSENT key read
silently wrong since the typed GETI first landed: `local t={} t[1]=5
return t[2]` answered "0" compiled where the interpreter answers "".
GETFIELD_INT's twin declined; GETI's continued.  Nothing pinned it.

The revision makes the typed claims PROVABLE or ineligible:

* lua_referent gains plain_proven -- set only by this chunk's NEWTABLE,
  CLEARED when the handle escapes as a call argument (the callee can
  setmetatable it).  GETI / GETFIELD_INT / GETFIELD_FLT emissions
  require the proof; anything else is ineligible at lowering and the
  interpreter answers.  Exhibit: the metatabled-global read now agrees
  ("s" on both legs), pinned as STATE case e3.
* Under the proof no metamethod can fire and every compiled store is an
  integer, so the one honest miss left is an absent key -- Lua nil,
  which a typed integer slot cannot carry.  That is now a loud
  post-entry fail (named bins GETI_INT_NONINT / _BADIDX) instead of a
  silent 0, pinned as an AGREE loud case.
* Total LEN / SET / GETGLOBAL from the original Phase 1 stand: LEN and
  SET delegate to the real VM ops and raise what the interpreter
  raises; a nil global stays on the stack for its consumer to raise on.

Harness re-baselines: AGREE_DECLINE 2->3 (escaped-read pin, silent by
design), POST_ENTRY_LOUD 6->7 (absent-key pin, loud by design); the
escaped-read EXEC case moves to AGREE with a pre-escape EXEC
replacement, so CALL_VOID+proven-read coverage survives.

make test exit 0; luajit 146 chunks 0 wrong; STATE oracle green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:30:29 -06:00
Stephen Dennis
eabdd8e666 fix(lua/jit): effectful bridge members never compile; ctx save/restore
Addresses both blocking objections from #1750's adversarial review.

Chunk-granularity exactly-once.  The review's probe proved the first
version's reasoning wrong at the chunk level: statement-form
mux.pemit(1,"X") followed by a DECLINING read delivered the message
twice -- the effect ran compiled, the later decline failed the run, and
the interpreter re-ran the whole chunk.  The chunk is the rerun unit,
so effectful members (notify, pemit, set, and eval -- arbitrary
softcode, doubled identically in the probe) now decline at COMPILE
time in every form.  Chunks containing them run interpreted, once.
Pure members (name, get, type, owner, ...) still compile and pcall the
real bridge functions.  Cost is nothing real: ECALL-bound shapes bench
at parity (#1741).

Nested context stomp.  Setup/teardown now SAVE/RESTORE the registry's
exec-ctx instead of clearing to nil, at all three sites including the
interpreter leg -- the review measured the pre-existing clear as a
route-dependent divergence under the production default (inner run's
teardown nil'd the outer run's context).

Harness: the mux.eval pins move to AGREE, asserting the ANSWER while
the decline is the required behavior -- with a comment that an eval
case starting to execute means the exactly-once argument must be
re-made, not waved through.  A mux.name EXEC pin proves the pure-member
compiled path is real (lua_run_ok advances; equality with the
interpreter is the assertion).  Decline budget 4 -> 6, both newcomers
permanent by design.

make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143
chunks 0 wrong; test-config green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
Stephen Dennis
6fb0860a99 fix(lua/jit): compiled mux.* calls the real bridge functions (#1745)
Default-on's second exposure (smoke TC013/TC014, the day it first ran
them compiled): the compiled mux.* path mapped the member NAME onto the
softcode function table -- mux.eval("add(10,20)") reached softcode
eval(obj, attr) and echoed its argument; mux.name(1) reached name()
wanting "#1" and answered #-1 NOT FOUND.  Name coincidence, not
semantics, and mux.set mapping to softcode set() was the same trap
armed.

The unified fix: only mux.args keeps the SCONST sentinel (the
CARGS/ALOAD fast path).  Every other mux member is a field on the REAL
global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same
bridge C function the interpreter calls, correct by construction.  The
name-mapped branch and its is_bridge plumbing are deleted.

Two consequences handled:

* Execution context.  The bridge functions read executor/caller/
  enactor from the registry, and per-run mux fields are injected into
  the table -- previously only in ExecuteChunk, the interpreter leg,
  so a compiled run offered the bridge a cleared registry and the
  PREVIOUS run's mux table.  The setup/teardown is now one shared pair
  (lua_setup_exec_context / lua_clear_exec_context) staged by both
  ExecuteChunk and TryJIT's two RunCompiled sites.

* Effectful members stay exactly-once.  mux.notify/pemit/set in a
  result-consuming form would pcall -- effect delivered -- then
  decline on the result type, and the interpreter re-run would deliver
  the effect twice.  The referent now carries an effectful claim:
  statement form compiles (CALL_VOID has no result check), result-
  consuming forms decline at COMPILE time and run once, interpreted.

Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests;
harness pins mux.eval in both spellings (local-then-return and tail
call), executing compiled.

smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green;
make test clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
Stephen Dennis
2a85210ad5 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
Stephen Dennis
34592439a3 feat(lua/jit): runtime-bounded for loops (#1732)
`for i=1,n` -- the shape real softcode writes -- compiles when init and
limit are runtime integers.  The step stays constant: it fixes the trip
direction, and with it which comparison FORLOOP emits.

What was static becomes branches.  The zero-trip decision is a runtime
compare on the direction the step fixes; the wraparound safety margin
is a runtime bounds test whose failure bails to the shared limited
block -- a decline, not an error: the interpreter re-runs the chunk
with 5.4's own counter model, and since FORPREP runs before any body
effect exists, the bail is rerun-safe by position alone.  FORLOOP
needed nothing: entry_final[] already hands it the limit under entry
dominance, constant or not.

The runtime path SPLITS the entry block, which the transition machinery
must not mistake for leaving it: entry backing seals early (the seal is
now one lambda shared with the transition) and blk_entry_reg re-
snapshots, so entry values -- including unbackable ones like table
handles -- survive into the body under the dominance the split blocks
genuinely have.

EXEC pins runtime limit, runtime init, runtime init with negative
step, and the runtime ZERO-TRIP path (init 3 > limit 1), which no
constant-fold accident can fake.

luajit: 140 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:01:19 -06:00
Stephen Dennis
a0c5cf40e8 feat(lua/jit): while and repeat loops; body-scaled budget (#1732)
The second half of #1732, on the rails the numeric for laid down.
while/repeat loop through a backward OP_JMP, so the lowering needed
exactly two things: treat a backward-JMP proto as a loop proto (same
q-register routing, same rerun-safety eligibility rules), and put the
budget guard on the jump.

The guard itself is now shared: emit_backedge_guard serves FORLOOP and
backward JMP both, so the two cannot drift (#1457's lesson) -- which
retired the second copy of the fold-into-the-condition defect on the
spot: the dead JMP budget code exited the loop to the fall-through on
exhaustion and CONTINUED, wrong partial result and all, exactly the
shape #1734 removed from FORLOOP.

The budget now decrements by the loop body's bytecode length instead
of 1 per edge: the interpreter's hook counts instructions, and a
per-edge tick let a compiled loop run body-length times longer than
the VM before tripping the same limit.

`while true do end` -- TC009's original shape, the chunk #1326 was
opened about -- now compiles, exhausts, aborts, and answers with the
interpreter's own "#-1 LUA ERROR: instruction limit exceeded".

EXEC pins while, repeat/until, and a geometric while whose condition
tests the CARRIED value (s<100), not a counter -- the case a stale
reload answers wrongly.  AGREE declines: 4 of 35, every one permanent
by design (os.time, two pins, select).  This closes the loop half of
#1732 entirely.

luajit: 136 chunks, 0 wrong, 0 crashes; smoke 1561/0; make test clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:16:14 -06:00
Stephen Dennis
36b49b0034 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
Stephen Dennis
56b91f4bf5 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
Stephen Dennis
cbb68a86e0 feat(lua/jit): library VALUE members -- math.pi, math.maxinteger (#1519)
math.pi, math.huge and math.maxinteger declined: a field read on a
library table takes a reference, and a handle to a number is something
nothing downstream can consume.  The referent now carries a table of
known VALUE members, and the field read takes the value itself --
GETFIELD (INT) for maxinteger/mininteger, the new GETFIELD_FLT for
pi/huge, whose double rides the same raw-bits lane the call arguments
use and lands directly in its FP slot.

Function members deliberately do NOT join the table: their return
claims stay in lua_call_claim, so each fact lives exactly once.  And as
with every referent claim, this is eligibility, not soundness -- a game
that rebinds math.pi to a string declines at the handler's type check
(a genuine float only; lua_isnumber alone would coerce "3.7" and
integers, which have their own routes).

HIR_LUA_GETFIELD_FLT is the first Lua opcode producing TY_FLOAT, which
found the FP twin of the needs_int_reg() silent-registration trap:
a float producer missing from needs_fp_reg() gets no slot, loc[].addr
stays 0, and the post-ECALL store writes guest address 0 (#1159's
shape).  Noted in both places.

EXEC pins each value USED in arithmetic, not just returned -- an
unwritten slot cannot survive x*2 or x-1.  AGREE declines fall 11 -> 8;
ratchet tightened as the harness requires.

luajit: 126 chunks, 0 wrong, 0 crashes; smoke 1561/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:44:16 -06:00
Stephen Dennis
6739395410 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
Stephen Dennis
48087a003c feat(lua/jit): lower OP_TAILCALL as call-then-return (#1519)
`return f(x)` -- the most common one-liner shape -- compiles to
OP_TAILCALL, which the eligibility scan rejected wholesale.  The real
tail-call mechanism exists to reuse the caller's frame; nothing here does
(the callee runs via an ECALL doing its own pcall), and the chunk-level
entry is lua_pcall(L, 0, 1, 0) on the interpreter route too, so a plain
call observing one result matches the interpreter exactly -- including
for multi-value callees like string.find, where both routes keep the
first value.

The lowering shares OP_LUA_CALL's body by fall-through with nresults
forced to 1, then emits the return half via one helper at both of the
call's successful exits.  The dead trailing `RETURN A 0` Lua appends
after a tail call (in-top, B==0) is recognized only in that position and
given RETURN0's shape; any other multret return stays declined.  A k
flag (upvalues to close) declines defensively -- the CLOSURE reject
should make it impossible.

Six EXEC cases pin the shape: the call half without the return half
answers empty, mishandling the dead return declines the chunk, and
asymmetric max/min plus the tostring/tonumber pair keep their original
discipline.  `return math.type(3)` moves from AGREE (declining) to EXEC
(executing); string.find joins AGREE to pin the result-count question.

luajit harness: 120 chunks, 0 wrong, EXEC all advancing; smoke 1561/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:13:53 -06:00
Stephen Dennis
3fa2c4b113 refactor(lua/jit): handles carry their referent; decision sites read it (#1519)
Two decision sites had each been guessing what a handle points at by
inspecting provenance: OP_LUA_GETFIELD chose GETFIELD_REF vs GETFIELD_INT
by whether the table came from GETGLOBAL, and OP_LUA_CALL chose CALL_INT
vs CALL_STR by walking back to the SCONST that named the callee and
consulting a whitelist -- which then had to gate BOTH call branches
(d5e5e86e0) or a name skipping one could claim the other's result type.

Now the claim is recorded once, where the handle is created (GETGLOBAL,
and the member reference GETFIELD_REF produces), and both decision sites
read it.  The twin near-identical CALL branches -- the #1457 drift shape
-- merge into one that branches only on the claimed result type, so the
two variants cannot disagree about a name by construction.

lua_callable_source, lua_callee_name and lua_callee_returns_int are gone;
the standard-library knowledge lives in one function, lua_call_claim.
The claims stay eligibility, not soundness: every ECALL verifies at
runtime and declines on a miss, which is what lets TY_STRING remain the
open default for names nothing here knows.

No behavior change: the luajit harness profile is identical
(agree_executed: 19, agree_declined: 15), smoke 1561/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:57:27 -06:00
Stephen Dennis
d5e5e86e08 fix(lua/jit): gate CALL_INT on the int-returning callee whitelist
CALL_STR is chosen when the name is not on the list; CALL_INT must be
the dual — only names that claim an integer result — so a string-returning
global cannot fall through and emit TY_INT by accident.
2026-07-28 21:23:03 +00:00
Stephen Dennis
5c53532d68 feat(lua/jit): bare-global calls, and one argument encoding for both (#1519)
tostring(42) -> 42    type(42) -> number    tonumber("17") -> 17

Decline count 23 -> 15.  Eight chunks, the largest step so far, and most of
it came from REMOVING things rather than adding.

Two changes.

A callable handle may now come from GETGLOBAL as well as GETFIELD_REF.
tostring is the global itself, a function rather than a library table.  The
lowering does not try to know which globals are functions -- the ECALL
already checks lua_isfunction, so math called rather than indexed declines
there.

And CALL_INT and CALL_STR now share ONE argument encoding.  CALL_INT took
integers only; CALL_STR took integers or constant strings.  That difference
was never justified: the two differ in RESULT type, so they have no business
differing in how arguments arrive.  tonumber("17") is the case that proves
it -- string argument, integer result, needs both halves.  The
tonumber(mux.args[1]) family fell out for free and was not on the list.

WHAT THE LOWERING NOW GUESSES, AND WHY IT IS ONE QUESTION

math.max(3,9) and tostring(42) have identical argument shapes and opposite
result types.  HIR result types are STATIC and Lua's are not, so nothing at
the call site distinguishes them and the callee's NAME is the only carrier.
lua_callee_returns_int is a short whitelist; a name not on it declines, and
declining is always correct.

That is the second place the lowering guesses what a handle points at.  The
first is GETFIELD_REF vs GETFIELD_INT by provenance.  Both are the same
question -- "handle to WHAT" -- and both want the same fix: a handle type
that carries its referent, including a function's return type.  Two
independent pressure points now argue for it rather than one.

Tests pair tostring with tonumber deliberately: same argument shapes,
opposite result types, so an implementation inferring the result type from
the arguments cannot pass both.  All three report lua_run_ok=0 on master.

make test green; test-lua-jit 1561/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:22:18 +00:00
Stephen Dennis
e132508126 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 20:47:57 +00:00
Stephen Dennis
d76bdf90fb 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 19:57:31 +00:00
Stephen Dennis
2151534437 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 19:25:53 +00:00
Stephen Dennis
8416cc411e 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
Stephen Dennis
758e3d132f feat(lua/jit): table constructors on the dedicated-opcode path (#1519)
The decline count moves for the first time: 32 -> 31.

  local t={10,20,30} return t[2]   -> 20   run_ok=1, run_fail=0

Constructors lower through SETLIST, a different path from the t[1]= stores
in #1705, and it was still emitting the named ECALL -- so a constructor
compiled and then failed on every call, paying the compile cost to reach an
answer the interpreter supplied anyway.

Same shape as OP_LUA_SETTABI: integer elements only, since the dedicated
ECALL carries the value in a register and there is nowhere for a string to
ride.  A constructor holding anything else declines and the interpreter
answers.  The stringify-then-call that used to sit here is what the named
mechanism required and is exactly why it could not complete.

The ratchet did its job.  It FAILED on this change with

    DECLINE BUDGET: down to 31 from 32 -- good.
    Lower AGREE_DECLINE_BUDGET in this file to 31

so an improvement cannot land without the number moving with it -- the first
time it has fired in the improving direction since #1326 added it.

EXEC cases cover both constructor shapes.  Verified in both directions: on
master they report "lua_run_ok=0 (compiled path did not execute)" and the
lowered budget reports "32 declined, budget 31".

`local t={1,2,3} return #t` still declines -- that is `#t` on a handle,
which lua_is_handle correctly refuses.  Length-of-table needs a real ECALL
rather than STRLEN on a sentinel; next increment.

test-lua-jit 1561/0.  `make test` is red on master itself for an unrelated
reason (tests/nls runtime oracle, 3 vs 4) -- fixed separately in #1706, and
reproduced on a clean master checkout with this branch stashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:43:19 +00:00
Stephen Dennis
a47431316d 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:24:51 -06:00
Stephen Dennis
66de787605 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 14:35:25 +00:00
Stephen Dennis
ebea52a81b 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 06:06:24 +00:00
Stephen Dennis
cc45f1d931 fix(lua/jit): the named bridge had never once executed (#1519)
Four defects, found by asking why a chunk that calls a stdlib function
declines, and measured at each step rather than reasoned about.

1. __lua_call's HIR type described the wrong thing.

   The handler marshals result 1 into the output buffer and pops the Lua
   stack -- it returns a *value*, exactly as the mux.* bridge path beside it
   does -- but it was typed TY_LUA_HANDLE while that path was typed
   TY_STRING.  return_as_string declines a handle (#1579), so every chunk
   that consumed a call result declined with the answer already marshalled
   in hand.

   `local v = math.floor(3.7) return v` went from lua_compile_fail=1 to
   lua_compile_ok=1 on this change alone, and the HIR now reads
   `v5 = CALL str __lua_call(v3 v4)` / `RET v5`.

   Retyping it means a call result is no longer a valid callee, so the
   general-call path now requires func_reg to actually be a handle;
   otherwise `f()()` would hand the marshalled text of the inner result to
   the ECALL as a stack index.

2. Every bridge name failed to dispatch, on every call, always.

   is_lua_bridge_name gates the block case-insensitively (tolower against
   "__lua_"), but all thirteen comparisons inside it are case-sensitive
   strcmp against upper-case literals -- and hir_lower_lua emits them in
   lower case.  Names passed the gate, matched nothing, and fell out the
   bottom.

   jit_compiler.cpp already said so in a comment ("the entire named bridge
   ... has never once been reached"), as the motivation for #1512's
   fail-closed exit; the mismatch itself was left in place.  Normalising the
   name once lets the two halves agree.  With it, TINYMUX_TRACE_LUA_ECALL=1
   shows __LUA_GETGLOBAL and __LUA_CALL executing for the first time.

3. __LUA_CALL answered "" on failure and reported success.

   Fixing 1 and 2 made this reachable, and it immediately cost correctness:
   seven harness chunks went from declining to *running* and returning empty
   -- tostring(42) gave "", tonumber(a)*2 gave 0 -- with run_ok incremented,
   because all three failure exits wrote an empty string and returned
   "handled".  An empty result is indistinguishable from a call that
   genuinely returned "".  They now decline, as does an unmarshallable
   result type: nil is a routine Lua answer (tonumber("abc")) and must not
   become "".  That is the containment #1512 established for this block.

   Caught by tests/luajit, which went agree_wrong 0 -> 7 -> 0 across the
   three states.  Without step 3 this change would have shipped silent
   corruption on every one of those shapes.

4. The multi-return lowering fetched results that were never produced.

   __LUA_CALL runs lua_pcall(L, nargs, 1, 0) -- one result regardless of
   nresults -- and then pops it.  The lowering nevertheless emitted
   __lua_get_result for r >= 2 against a stack holding nothing of the
   call's, and the handler read the top of stack unconditionally, so
   `local a,b = f()` gave b whatever unrelated value was there.  It now
   declines until pcall is told how many results the caller wants.

What this does NOT do: make these chunks run compiled.  They now compile,
reach the bridge, and decline inside it -- the interpreter still answers.
Two distinct blockers remain, both located with the trace:

  * __LUA_GETFIELD has no LUA_TFUNCTION branch at all.  It marshals
    integers, numbers and strings and pops, so it can never hand a callable
    to __LUA_CALL; math.floor and string.upper stop there.  Its plain-table
    guard also rejects `string`, whose table carries a metatable.
  * __LUA_CALL itself declines for a global function (tostring, tonumber,
    select) even though __LUA_GETGLOBAL hands it a real stack index for a
    LUA_TFUNCTION.  Not yet diagnosed.

So this moves #1519 from "declines at lowering" to "compiles, reaches the
bridge, declines inside it", which costs a compile-and-run per chunk that
the earlier decline did not.  That is the intended direction, but it is not
free, and it is not the bridge working.

The trace is kept, env-gated: which ECALLs a chunk reaches is otherwise
invisible, since a decline in any handler surfaces only as lua_run_fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:47:05 -06:00
Stephen Dennis
6e34d55366 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
Stephen Dennis
2333156108 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:35:13 -06:00
Stephen Dennis
b89e73c837 docs(lua/jit): Lua ^ must not mirror softcode's IEEE domain guard (#1556)
#1556 asked which of two things is right: mirror softcode POWER's
HAVE_IEEE_FP_SNAN guard in the Lua lowering, or document why Lua does not
need it.  It is the second, and mirroring would introduce a bug.

Softcode POWER declines the native FCALL2 path when the macro is undefined
because fun_power has a MUX *output convention* on those builds: a negative
base yields the literal string "Ind" rather than whatever the FP library
produces.  That is softcode's answer format, not trap avoidance.

Lua has no such convention.  luai_numpow (lua54/llimits.h) is
`(b == 2) ? a*a : pow(a, b)` on every platform, so the Lua interpreter
calls pow() directly whether or not the macro is defined.  Mirroring the
guard would make the compiled path diverge from the Lua interpreter --
exactly backwards from what the guard achieves for softcode.

Measured on a build with HAVE_IEEE_FP_SNAN forced off in autoconf.h, no
JIT involved:

    power(-2,0.5)   softcode     Ind
    (-2) ^ 0.5      Lua interp   nan

The two languages disagree by design, and the compiled path has to follow
Lua.  Comment only; no behaviour change.

Also found while measuring this, reported separately rather than fixed
here: `^` never compiles at all today, so the FCALL2 path #1556 is about
is currently unreachable and the asymmetry is latent.  tier2_lazy_init()
is called only from jit_eval(), the softcode entry point; nothing on the
Lua path loads the blob, so tier2_sym_addr("pow") returns 0 and both
OP_LUA_POW and OP_LUA_POWK take their `if (!addr) return -1` decline.  The
configuration needed to run the Lua JIT at all is jit_eval_brackets 0,
which is precisely the setting that keeps brackets out of jit_eval.

Confirmed by calling the loader from the Lua path as a diagnostic: `^`
then compiles and runs (lua_compile_ok and lua_run_ok both move) and
returns 729 where the interpreter returns 729.0 -- i.e. it works
numerically and lands straight on #1488.  That is why the loader hook is
NOT included here: on its own it would convert a decline into a
float-ness regression.  #1488 first.

make test passes, both smoke routes 1542/1542.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:28:51 -06:00
Stephen Dennis
b7511d5b4a fix(lua/jit): lower runtime ^ to native FCALL2 pow, not string ECALL (#1538)
OP_POW/OP_POWK promoted both operands to TY_FLOAT then emitted a named
string-bridge ECALL (__LUA_POW).  HIR_CALL marshals TY_FLOAT as the raw
double storage address; the low byte is 0x00, so farg_cstr/atof saw empty
strings and every runtime ^ became pow(0,0) == 1.  Constant ^ never hit it
because Lua folds those to LOADF.

Use the same HIR_FCALL2 + tier-2 `pow` path softcode power() already uses:
native doubles in fa0/fa1, TY_FLOAT result.  Decline if the blob symbol is
missing.

tests/luajit: two agreement cases with runtime operands (and == so #1488
float formatting cannot mask the value).  Requires jit_eval_brackets 0 so
the compiled path actually runs.
2026-07-27 12:54:40 +00:00
Stephen Dennis
b97885497d fix(lua/jit): a condition used as a value swallowed a block leader (#1421)
Lua materializes a condition as a value with a fixed four-instruction run
(lcode.c exp2reg / code_loadbool):

    pc   : <test>          if (cond ~= k) then pc++
    pc+1 : JMP -> pc+3
    pc+2 : LFALSESKIP A    R[A] := false; pc++   (skips pc+3)
    pc+3 : LOADTRUE  A     R[A] := true

OP_LFALSESKIP's "pc++" is control flow -- the instruction it steps over
belongs to the other path -- but the lowering implemented it as a linear
`pc++` in the instruction walk.  That walked straight past a block leader
the CFG had already recorded: everything after the skip was emitted into
the false arm's block, and the block the branch actually targets was left
empty.  `return 1 < 2` compiled to a program whose true arm was a hole.

The visible symptom moved as the surrounding code was fixed, which is why
it read as several separate bugs.  Before #1511 the chunk returned the
first RET's compile-time value, so every true comparison came back `0`
(what #1421 reports).  Once every RET got its own output slot the hole
stopped writing anything and they came back empty.  False comparisons were
right by accident throughout, because the false arm is the block that kept
the body.

Nor was the damage limited to the boolean: in
`local t=(a<b and b<3) return 5` the swallowed leader took the `return 5`
with it, so a chunk whose result has nothing to do with the comparison
also answered wrong.

Three changes:

  - insn_leaders() now owns the "which leaders does this instruction
    induce" question, and find_block_starts() is expressed in terms of
    it, so pass 1 and pass 2 cannot drift about where the blocks are.
    OP_LFALSESKIP is added to it, and its lowering emits a real branch to
    pc+2 instead of stepping over the leader.

  - The four-instruction run is recognised (lua_bool_fuse_at) and fused
    to the comparison itself: it computes exactly (cond == k), so no
    branch is needed at all.  This is what makes `x = a < b` compile
    rather than merely stop being wrong -- and it compiles branchless.
    A compound condition patches its own jump list into pc+2/pc+3, so the
    fuse requires that nothing outside the run enters it; TEST/TESTSET
    are excluded because TESTSET also copies R[B] into R[A].

  - Lowering now declines any program with a reachable but empty block.
    That is the shape of this bug in general, available to any future
    opcode that advances pc by hand, and the guard turns the whole class
    into a decline instead of a wrong answer (#1501).

Verified against the interpreter as oracle (lua_jit 0 vs lua_jit 1,
code_cache cleared between runs and row counts checked, since a chunk that
declines agrees trivially).  23 chunks: 9 wrong before, 0 after, and 13
that previously compiled to a wrong answer now compile to the right one.
Reverting only the OP_LFALSESKIP branch, with the guard left in, turns
those wrong answers into declines rather than wrong answers -- so the
guard is live and the branch is what recovers the compile.

Smoke 1505/1505 on both routes, 316/316 dispatched, make test green.

No smoke case: lua_jit is default-off (#1426/#1325), so a smoke test for
this would pass without the compiled path ever running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:02:09 -06:00
Stephen Dennis
e85280e3f0 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-27 04:50:57 -06:00
Stephen Dennis
e903413d02 fix(lua/jit): decline chunks whose values cross a control-flow join (#1422)
Nothing merges Lua register values at a join.  hir_ssa_construct() inserts
PHIs only for q-register traffic -- which is precisely why the numeric for
loop routes its index through STORE_Q/LOAD_Q -- so a plain HIR value is
usable across blocks only where its defining block dominates the use.
lua_reg[] is a single linear map, so in practice the last-lowered write
simply won:

  local x=1 local t=1 if x>2 then t=2 end return t    -> 2, want 1
  local x=3 local t=1 if x>2 then t=2 else t=3 end    -> 3, want 2
  local i=0 while i<10 do i=i+1 end return i          -> 0, want 10
  local i=0 repeat i=i+1 until i>=5 return i          -> 1, want 5

A not-taken branch's assignment leaked past the join, and a loop body's
update was invisible to the next iteration.

The entry block dominates every reachable block; no other block here is
known to.  So on leaving a non-entry block, drop any register that block
wrote.  Every read site is guarded by "< 0" (return_as_string() checks its
argument too), so the chunk declines cleanly to the interpreter instead of
compiling a wrong answer.

This defect is latent on master only because the operand-bias bug fixed in
the previous commit gives OP_JMP the wrong displacement, so loop and branch
targets miss their block and lowering bails for the wrong reason.  Correct
the decoder without this guard and the wrong answers above go live.

Verified with the interpreter as oracle (lua_jit 0 vs lua_jit 1), using
code_cache row counts to tell "compiled correctly" from "never compiled":
all four cases now decline and agree, while straight-line chunks still
compile (`t=t+5`, `a*b+1`, `s.."cd"`, `t[1]`, `#"hello"`).  make test:
1497/1497 on both the JIT and interpreted routes.

Making these chunks actually compile needs value merging for Lua locals at
joins, which is a larger piece of work; #1422 stays open for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:34:02 -06:00