The flip: FUNCTION/XFUNCTION/FUN::fun/delim_check and the module
interfaces take `const UTF8 * const fargs[]`. Double-const is
load-bearing: C++ qualification conversion needs const at both pointer
levels, so builder-side `UTF8 *[]` arrays convert implicitly — the
evaluator, the JIT marshaller, and every owner site need zero casts,
and slot reassignment inside bodies becomes a compile error for free.
The conversions: the flip landed first so the compiler enumerated every
violation; this commit is that inventory worked to zero — ~250 sites
across funceval, funceval2, functions, funmath, help, mail, session,
powers, levels, predicates, conf, walkdb, stringutil, timeutil/
date_scan (regenerated, one-line diff), exp3, and mux_main, each
classified per docs/campaign-2136-const-fargs.md's four recipes.
New idioms (functions.h): trim_space_sep_n() — non-destructive trim for
(pointer, length) consumers, so trim-then-scan sites need no copy at
all; FargVec — the argv counterpart of FargCopy for CS_ARGV handlers.
countwords() and DecodeListOfIntegers() rewritten non-destructive.
The flip deleted more than it added: #2157's fun_munge list1 copy, the
engine_com help-topic copy, fun_index's in-place NUL write, and five
const_casts (process_sex x4, sha1_helper). const_cast budget: zero
added.
Trap recorded in the brief: an old-signature definition doesn't fail
the build — it becomes a C++ overload, and the new-signature symbol
stays undefined until dlopen(RTLD_NOW). delim_check, the conn_bridge
bridges, the dbt_spike stub, and exp3::Call were all silently shadowed;
muxscript was the only host that noticed, because netmux's own net.cpp
resolved the flat-namespace lookup. After any signature flip, grep the
old spelling.
Verified: make test EXPECT_CONFIG="jit=yes" (35 passed / 0 failed) and
make test-scenario, including the new tests/scenario/sidefx_fargs.py
that live-probes the class-3 wrappers smoke never touches (pemit/
trigger/link/tel/wipe/destroy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correct the diagnostic: old-blob M/F/F collapses ecalls and tier2 to 1
(not ecalls up). Route sort/sortby/munge/sortkey through
list_copy_for_split so the one-primitive claim holds. Null-tolerant
list_copy_for_split. Fix ITER's typos in arm pointers.
Two landing items from the #2154/#2144 reviews.
#2155 — the per-arm fallback asymmetry is now documented where a reader
will look. ITER's arm carries the canonical comment: its three-rung
ladder (SPLIT_STEP -> SPLIT_TOKEN, which is LIVE blob-compat -> EXTRACT)
versus MAP/FILTER/FOLD's entry-gated two rungs (integer trio -> generic
ECALL fallback), why that asymmetry is deliberate (ITER is the core loop
everything reaches; the M/F/F arms decline wholesale rather than carry
an unexercised second emission path apiece), and the observable
signature of "new engine, old blob" — M/F/F correct but ~4x slower with
ecalls up and tier2 near zero, meaning stale blob, not broken lowering.
The three arm gates point back to it, and there is a note for the
`grep SPLIT_TOKEN` reader that eight of its nine hits are commentary.
#2157 — list_copy_for_split() is now the file's single answer to how a
borrowed list gets copied ahead of destructive tokenization:
list2arr_nd, fun_ledit's walk, and both handle_sets copies route
through it (the adjacent byte-at-a-time mux_strncpy loops are gone —
a consistency fix, not a performance one). real_regmatch's `registers`
parameter is const: since #2144 routed it through list2arr_nd nothing
writes through it, and the compiler now holds what a comment asserted —
the first parameter formally down-paid against #2136's const-fargs
contract flip.
Probes exact (ledit, setunion, setinter, regmatchi captures, map, iter);
full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped (stubslave,
not configured), 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three items from Kagura's review, none behavioural:
- Every `uninit (#2145)` marker now states the invariant the safety
rests on ("past-count reads are UB now, not nullptr") instead of only
the what; the canonical version lives with the splitter contracts in
functions.h, which also picks up the #2144 nit that list2arr_nd
truncates beyond LBUF_SIZE-1 where list2arr does not.
- CHANGES no longer implies a measured win for the set family. Their
identical memsets measured near-free on BOTH architectures before the
change (x86-64: setunion 0.95us vs vadd 7.70us with the same two
memset instructions confirmed in disassembly; arm64 corroborates),
which neither box can explain. Tracked separately rather than
guessed at.
- CHANGES gains the measured x86-64 numbers, where the conditional
word-index sites turned out to be the largest wins (last 23x,
lrest 18x) — the "lower priority" class from the issue was the
opposite.
Smoke: 1660 passed, 1 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
std::vector<T>(n) value-initializes, so every call to a list builtin
memset a quarter-megabyte of pointer table before looking at a single
token — ~6 of vadd()'s ~7 us on the issue's x86-64 box, 85% of the
function, and 4x worse since #1990 grew LBUF_SIZE 8000 -> 32768 with
nothing measuring the constant overhead (it is identical at every N,
the shape test-growth is blind to by design).
list2arr writes arr[i] only for i < its return value and every caller
reads only that far, so the tables never needed initializing. They are
now uninitialized unique_ptr<T[]> allocations under the same RAII
lifetimes.
Converted:
handle_vectors 2x (vadd/vsub/vmul/vdot/vcross)
fun_vmag/vunit 1x each
fun_choose 2x
fun_ledit 2x
fun_sortby 1x
handle_sets 2x (setunion/setinter/setdiff — the issue's table
attributed this pair to fun_sortkey, whose own
allocation is already sized to strlen+1)
shuffle/pickrand/last/lrest — the conditional multi-char-delimiter
co_split_words index tables (2x 256 KB each), same
shape, filled to nWords and read no further
Left alone, deliberately: sites already sized to the real token bound
(fun_sortkey's strlen+1 vector, cluster-offset tables) — proportional
zeroing is not the defect.
Measured (macOS arm64, benchmark() 10k iterations, us/call):
vadd 1.89 -> 0.74 choose 1.70 -> 0.58
ledit 2.00 -> 0.63 vmag 1.39 -> 1.03
Apple Silicon's memset made the before milder than the issue's x86-64
numbers (75x on the microbench there); the gradient-by-vector-count is
gone on both.
Full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped
(stubslave, not configured), 0 failed. Spot checks exact: vadd,
setunion, shuffle, sortby with a live comparator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1 of the non-destructive argument contract. Builtins have always
been allowed to tokenize fargs in place — split_token NULs every
separator in the CALLER's buffer — which is invisible while the
interpreter hands out fresh evaluation buffers and silent corruption the
moment the compiled route hands out cached memory: #2128 was a cached
program's own constant edited permanently by words(map(...)), and #2135
now copies every ordinary ECALL argument defensively to contain it.
That copy has two hand-found special cases and every new builtin is a
potential repeat; the way out is builtins that do not scribble, after
which the copy can be deleted rather than maintained.
list2arr_nd() tokenizes a PRIVATE copy: the caller's buffer is never
written, arr[] points into a caller-supplied LBUF-sized scratch whose
lifetime brackets the tokens, and tokens stay writable so consumers that
edit them in place remain legal. One memcpy per split, pool-allocated.
Converted in this pass — every list2arr call site whose input is
borrowed memory (fargs or a parameter aliasing fargs):
handle_vectors both lists (vadd/vsub/vmul/vdot/vcross family)
fun_vmag fargs[0]
fun_vunit fargs[0]
fun_choose fargs[0], fargs[1]
fun_ledit fargs[1], fargs[2], and its inline trim/split walk
over fargs[0]
real_regmatch the register list (fargs[2] of regmatch/regmatchi)
Sites already tokenizing their own copies (fun_sort, fun_sortby,
handle_sets, do_asort_finish, fun_shuffle) are correct as-is and were
left untouched — the conversion targets the contract violation, not the
idiom.
Remaining phases, tracked in #2136: the ~13 FUNCTION bodies that
trim/split fargs directly without list2arr; then a const-qualified
argument contract so the compiler enforces what this establishes by
convention; then the #2135 ECALL copy is deleted, not defended.
No behavioural change intended: full make test EXPECT_CONFIG="jit=yes"
is 35 passed / 1 skipped (stubslave, not configured) / 0 failed, and the
smoke suite's golden outputs cover every converted builtin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nEmit clamp is described as being there "because the cost of being wrong
is reading past the table." That is not what it does.
It bounds the LOOP COUNT, not the index. indices[] is a shuffled permutation
over [0, n), so its first nEmit entries are nEmit distinct indices -- not the
indices [0, nFound). Were co_split_words and co_words_count ever to disagree,
w >= nFound would still be reached.
What actually keeps that read in bounds is the allocation: wstarts/wends are
sized n rather than nFound and are value-initialised, so an entry
co_split_words never wrote reads 0 - 0 and emits an empty word.
Unreachable today -- tests/color_ops' split_extract_parity asserts the two
functions agree, so nFound == n. Worth correcting anyway, because the comment
named the wrong guard, and the obvious later tightening (sizing the tables to
nFound, since they are oversized in exactly the case the clamp exists for)
would turn this into an out-of-bounds read with the stated guard still
apparently in place.
Comment only; no change to emitted code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fun_shuffle's single-char-delimiter branch called
co_extract(word, p, slen, indices[i] + 1, 1, delim, delim)
once per word. co_extract addresses word i by scanning forward from the
start of the string counting delimiters, so it costs O(slen) whichever word
is asked for -- and asking for every word in turn is O(slen^2). shuffle()
is CA_PUBLIC, and at LBUF length one call occupied the single-threaded
server for ~3.4s.
Build the word table once with co_split_words and index it. The multi-char
branch of the same function already worked this way; this makes the two
branches agree. Same defect and same repair as fun_scramble (#2045), which
was re-walking with co_mid_cluster.
Measured on Darwin/arm64, astbench, word counts asserted with words() at
every size:
N before after speedup
2000 13 769us 58.6us 235x
4000 45 916us 91.0us 505x
8000 181 208us 191.2us 948x
16000 731 822us 384.2us 1905x
Fitted exponent 1.92 -> 0.92: quadratic to linear. (The multiplier is what
the class change looks like at the top end, not a claim about typical
softcode -- the point is that a public function could be driven there at
all.)
The substitution is only correct if co_split_words and co_extract agree
about what a word IS, and they were written independently with their
delimiter handling described in different words in different places. So
tests/color_ops gains a split_extract_parity suite that settles it by
comparison rather than by reading: for every input, the i'th word according
to co_extract must be byte-identical to the i'th range according to
co_split_words, and both must agree with co_words_count. Fixed cases cover
space compression, non-space delimiters where empty words are real, UTF-8,
and colour codes; then 4000 fuzz iterations over both delimiter kinds.
Validated by perturbation: an off-by-one in the extract index turns the
suite red with 7 failures, so it is capable of catching a real divergence.
Fuzz input comes from gen_random_colored, which emits COMPLETE colour
sequences -- a battery emitting bare PUA lead bytes generates input the
network boundary cannot deliver and pins whatever the code happens to do
with it, which cost a full cycle in #2002.
`make test`: 36 targets, 35 passed, 1 skipped (test-stubslave-teardown,
stubslave=no), 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scramble() emitted clusters in shuffled order by calling co_mid_cluster()
once per cluster. Each of those calls co_cluster_advance() twice, and each
of THOSE strips colour into a 32 KB buffer and re-walks from the start --
so the cost is O(slen) whichever cluster is wanted, and the loop is
O(slen^2). scramble() is CA_PUBLIC, so any player could occupy the
single-threaded server for seconds, and max_cmdsecs/lag_limit cannot
preempt a call already running.
Measured on this box, same build either side, strlen(scramble(repeat(a,n))):
n before ratio after ratio
4000 0.087s - 0.001s -
8000 0.286s 3.29x 0.001s 0.71x
16000 1.128s 3.95x 0.001s 1.42x
32000 4.672s 4.14x 0.002x 1.50x
~4x per doubling is quadratic; ~1.5x is linear dominated by fixed cost.
At 32000 clusters that is 4.672s -> 0.002s. The issue reports ~19s at LBUF
length on Kagura's box; same phenomenon, slower hardware.
The saving is that advance_pua_by_plain_bytes() walks FORWARD from wherever
it starts. So co_cluster_offsets() takes the colour-stripped copy once and
carries the PUA pointer across iterations, advancing it by only the current
cluster's plain byte count, instead of recomputing from the beginning each
time. It fills offsets[0..n] -- the start of each cluster plus an end
sentinel -- and cluster i is data[offsets[i] .. offsets[i+1]).
That range is byte-identical to what co_mid_cluster(out, data, len, i, 1)
copied, colour included: advance_pua_by_plain_bytes() skips colour while
consuming visible bytes, so a PUA run sitting between a base character and
its combining mark lands INSIDE the range, and trailing colour after the
last cluster stays outside. Both behaviours are now pinned by the codiff
cases added in the previous commit -- cl/mid/colcomb/0 is exactly that case
-- and the transcript is unchanged at 1914 lines.
nEmit clamps the loop to what co_cluster_offsets() actually found. The two
functions walk identically so it should never bind, but indices[] was built
from co_cluster_count() and is about to index a different function's output.
Blob regenerated in the same commit, both copies: color_ops.c is compiled
into the freestanding rv64 blob, so adding a function to it makes the
committed artifacts stale. test-blob caught the omission, as designed.
make test 35 passed / 1 skipped / 0 failed
(jit=yes stubslave=no nls=yes realitylvls=yes wodrealms=yes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Convert remaining engine `int x = mux_atoi64(...)` (and explicit
static_cast<int> forms) to int64_t so parse width is not discarded on
any platform. Includes functions/funceval/funmath, mail folder numbers,
comsys charge parse, JIT register indices, and HIR lower mid/left/right.
dbref parse keeps full-width parse then rejects values outside int
range. Channel charge still stores int after an in-range check.
Mechanical hygiene under the opt-in M_() design: replace T("#-1…") and
T("#-2…") with S_() so softcode ABI tokens are obvious in source and
cannot enter a player catalog. ~400 call sites across engine, exp3,
mail, and driver. Assembled/library-spliced diagnostics (plan §4.2)
are unchanged where they are not a single literal.
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>
mux_atol() returns long: 64-bit on LP64, 32-bit on Windows. Thirteen
sites evaluate truthiness as isTRUE(mux_atol(...)), so on Windows any
value whose low 32 bits are zero read as false.
xor(4294967296) 0 should be 1
lxor(4294967296) 0 should be 1
and(4294967296) 0 should be 1
or(4294967296) 0 should be 1
t(4294967296) 1 correct -- goes through xlate()
The last line is the tell: on Windows the server contradicted itself,
with t() calling a value true while and() called the same value false.
correctness_fn.mux TC001 and the comment in fun_xor both already say the
intent is "the full 64-bit value, not a 32-bit-truncated copy". The fix
simply never worked on LLP64, because long is the wrong type to say it
in. Two of the thirteen are in ast.cpp, the evaluator's own boolean
handling, so this was not confined to a few list functions.
mux_atoi64() already exists beside mux_atol() and returns int64_t. This
is a no-op on LP64 -- long is already 64-bit there -- and corrects LLP64.
isTRUE(x) is ((x) != 0), so nothing else changes.
Deliberately NOT switched to xlate(), even though that is the single
definition of a softcode boolean: doing so would change behaviour on
Linux too, since xlate treats #- errors and non-numeric text differently
from isTRUE(atol). This is a portability defect, not a semantics
decision.
Found by the first smoke run ever performed on Windows (#1347). Full
smoke there went from 1415 passed / 19 failed to 1418 / 16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fun_hastype tested only fargs[1][0], so any string starting with
R/E/P/T passed as that type — hastype(<thing>,TRUMPET) confidently
returned 1, the worst shape for a predicate gating conditionals — and
the invalid-type arm broke into the common tail, appending the boolean
to the error: '#-1 NO SUCH TYPE0'.
Match the full type name case-insensitively (ROOM, EXIT, PLAYER,
THING), exactly as 'help hastype()' has always documented, and return
the bare error for anything else. The interpreter is the only
implementation (no fold/tier2 wrapper), so both eval routes are fixed
by construction. No shipped softcode or testcase calls hastype() with
an abbreviated type (swept), and the help text needs no change.
hastype_fn.mux TC004 pins both behaviours: TRUMPET/T/RUBBISH/ZEBRA are
exactly '#-1 NO SUCH TYPE' (strmatch with no wildcard also proves the
0 is gone) and lowercase/mixed-case full names still match.
Verified: probe matrix matches the issue's expected column; full make
test green — smoke 1400/1400, 0 crashes.
Fixes#1168
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two residuals found while cross-reviewing the #1112 fix. Both are the
same root cause and were invisible while every capture came back empty.
1. The "clear the register" path did not clear. funceval2.cpp passed
RegAssign(..., 0, nullptr) for an unset group or a register list longer
than the capture count, but RegAssign early-returns on a null ptr
(eval.cpp), so the register kept its PREVIOUS value. Harmless when
nothing ever filled; now that captures work, `setq(2,secret)` followed
by a regmatch whose group 2 does not participate reads `secret` back
out of %q2 and looks like a capture. Pass T("") — non-null, length 0 —
which is exactly what the named-register branch alongside already does.
2. engine.cpp regexp_match() still had `PCRE2_SIZE outlen = 0;`. This is
the %0..%9 path for REGEXP-flagged $-commands and ^-listens (callers in
engine.cpp atr_match1, command.cpp, look.cpp) — not the regmatch()
function the PR fixed. Same in/out capacity semantics, same
PCRE2_ERROR_NOMEMORY, and the rc<0 branch then frees the arg to
nullptr, so every regexp $-command capture was empty. Arguably wider
blast radius than regmatch() itself.
Verified with a new REGEXP $-command case in the wild-capture scenario
(smoke cannot reach $-commands — muxscript does not wire match_mine).
With engine.cpp reverted the new case reports CAP5<><> (both captures
empty) while the four wildcard cases still pass, isolating the defect;
with the fix, CAP5<alice><bob>.
smoke 1324/1324; scenario 5/5 wild-capture + 6/6 site-threshold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Softcode: elock returns after !locatable (#1063); lastcreate frees empty
A_NEWOBJS via LBuf_Adopt (#1064).
mail_mod: MAIL_DB_LIMIT grow guards (#1065, #843 parity); make_tolist no
longer advances by snprintf's untruncated return (#1066).
Windows: IOCP uses completion-model descriptor cap, not FD_SETSIZE (#1069);
wselect closes untracked accepts at FD_SETSIZE (#1070).
Schannel: contextHandleInitialized only on real ASC context; EXTRA on
CONTINUE_NEEDED; decrypt loops multi-record EXTRA (#1067/#1068).
JIT: guest_strnlen/guest_cstr for Lua ECALL and softlib bridges (#1071).
Correctness sweep of the softcode function library, evaluator, and lock/
wildcard semantics. Four confirmed bugs, each empirically validated on a
live server before fixing:
- xor(): tested truthiness on a 32-bit-truncated copy of each argument,
so values whose low 32 bits are zero (e.g. xor(4294967296)) were wrongly
false and disagreed with lxor(). Now uses isTRUE(mux_atol()). (#850)
- wrapcolumns(): dropped the character at the break column on every hard
(no-space) line break. Segments are now copied out with their own
terminator instead of NUL-overwriting input in place. (#851)
- step(): evaluated the attribute as the calling executor rather than the
attribute-owning object, unlike map()/mix()/foreach(). Behavior change
for cross-object step(). (#852)
- tr(): registered with a 1-arg minimum but unconditionally reads
fargs[1]/fargs[2], so tr(abc) dereferenced NULL. Now min == 3. (#853)
Adds regression tests TC001-TC005 (correctness_fn.mux), including a guard
for switch()/#$ pattern scoping (a flagged finding that the live engine
already handles correctly). Build clean, all 1263 smoke tests pass.
unique()'s unimplemented <sorttype> is documented as an open item in
docs/survey-correctness-pass-2026-06.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Memory-safety pass over previously unsurveyed engine subsystems. Six
confirmed, adversarially-verified bugs, all reachable from untrusted
player input:
- scramble()/shuffle(): Fisher-Yates index arrays were sized LBUF_SIZE/2
but the cluster/word count can reach LBUF_SIZE, so a ~32 KB argument
overflowed the stack array. Both are CA_PUBLIC. (#845)
- view_atr()/flags(): decode_attr_flags() can emit NUM_ATTRIBUTE_CODES+1
bytes but two callers gave it an 11-byte buffer. (#846)
- do_reference()/absolute_named_reference(): used snprintf's return value
(intended length) directly as a copy/compare length, reading past the
buffer on truncation; @reference could leak the adjacent heap bytes back
via @reference/list. Clamped to min(n, LBUF_SIZE-1). (#847)
- @cron value/step parsing accumulated digits into a signed int without an
overflow guard (UB). (#848)
- index(): trailing-space trim decremented before testing, reading the byte
before the lbuf when an item begins at the delimiter. (#849)
Adds regression tests TC012-TC014 to overflow_inject_fn.mux. Build clean,
all 1258 smoke tests pass. Coverage recorded in
docs/survey-memsafety-pass-2026-06.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fourth wave: ast (11), funceval (10), funceval2 (7), comsys (3),
speech (10), set (6), engine (7), command (10). Covers the NOEVAL
handlers (cand/cor/if/switch/iter), function evaluators (ifelse,
letq, objeval, sortby, munge, while, sandbox), notify_check message
buffers, and command dispatch paths. Buffers stored in fargs[]
arrays, returned through output pointers, or used in ping-pong swap
patterns are left manual.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add route(..., locked) option that validates the next-hop exit against
the executor via could_doit(). Returns #-1 EXIT IMPASSABLE if the exit
is locked against the executor. Works with distance and path options.
This is a thin Tier 2 layer: no lock-equivalence classes, no TTL, no
uncached BFS fallback. The Tier 1 table is reused as-is; only the
returned next-hop exit is validated at query time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add route() softcode function with BFS-based shortest-path routing
over rooms marked NAVIGABLE. The routing table stores only the next-hop
exit for each (source, dest) pair, compressed via diagonal elimination,
adjacent marking, and row redundancy. Lazy rebuild on generation-counter
mismatch triggered by topology changes (@dig, @destroy, @link, @open,
@unlink) and NAVIGABLE flag changes. SQLite schema v10 adds route_nodes,
route_table, route_meta tables for future persistence phases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The JIT compiler uses the outer caller's cargs instead of the
dynamically-provided comparison elements, causing sortby with
#lambda to leave lists unsorted (all comparisons return 0).
Use ast_exec() to bypass JIT, matching the ulambda fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add LBuf class to alloc.h: an RAII wrapper around alloc_lbuf/free_lbuf
that moves LBUF_SIZE buffers from the stack to the heap pool. Convert
all 108 non-static UTF8 xxx[LBUF_SIZE] stack arrays across 25 source
files. Static BSS buffers (24) are unchanged.
This eliminates LBUF_SIZE from recursive stack frames, making it safe
to increase LBUF_SIZE without risking stack overflow in the evaluation
pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Like grep()/grepi() but uses PCRE2 regular expressions instead of
wildcard patterns for matching attribute values.
regrep(object, attr-pattern, regexp) — case-sensitive regex search
regrepi(object, attr-pattern, regexp) — case-insensitive regex search
Registered in function table, help text added.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fun_shuffle: co_words_count + co_extract with Fisher-Yates index
shuffle for single-char delimiters; multi-char falls back to mux_string.
fun_foreach: eliminate mux_string entirely — direct UTF-8 buffer
iteration with inline code point length detection.
fun_escape: new co_escape() function in color_ops.rl — single-pass
backslash insertion preserving PUA color, using co_skip_color and a
static escape character table matching mux_isescape[].
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- fun_revwords: co_words_count + co_extract in reverse order
- fun_pickrand: co_words_count + co_extract for random word selection
- fun_elements: co_words_count + co_extract by position indices
- All fall back to mux_string for multi-char delimiters
All 593/593 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
co_words_count, co_first, co_rest, co_extract now handle two modes
based on delimiter value:
- Space (0x20): compress consecutive delimiters (MUX space semantics)
- Non-space: each delimiter is significant, empty words are valid
This unlocks co_* fast path for ALL single-char delimiters in
fun_extract, fun_last, and fun_lrest — not just space.
co_last unchanged (already correct for both modes).
All 593/593 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- fun_extract: co_extract for space delimiter, mux_string for multi-char
- fun_last: co_last with trim_space_sep for space, mux_string fallback
- fun_lrest: co_extract(1, N-1) for space, mux_string fallback
- Added color_ops.h include to funceval2.cpp
- co_extract/co_last compress consecutive delimiters, which matches MUX
space semantics but not non-space (each delimiter is significant).
Restrict co_* path to space delimiter for correctness.
All 593/593 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>