Commit graph

34 commits

Author SHA1 Message Date
Stephen Dennis
8808b4c375 feat(#2136): flip fargs to const UTF8 * const — and convert every site the compiler surfaced
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>
2026-08-06 14:34:23 -06:00
Stephen Dennis
cb883caae5 fix review: stale-blob signature and list_copy coverage (#2158)
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.
2026-08-06 18:34:18 +00:00
Stephen Dennis
34ef8750b9 docs+chore: the fallback ladder written down; one way to copy a list (#2155, #2157)
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>
2026-08-06 12:16:56 -06:00
Stephen Dennis
114495b4d8 docs(functions): pay the #2146 review debts — invariant at every site, honest CHANGES
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>
2026-08-06 09:28:47 -06:00
Stephen Dennis
c0155c51a0 perf(functions): stop zeroing 128-256 KB of pointer table per list call (#2145)
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>
2026-08-06 08:42:38 -06:00
Stephen Dennis
be4bd0c7fa feat(functions): list2arr_nd — non-destructive list split, first conversions (#2136)
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>
2026-08-06 07:35:25 -06:00
Stephen Dennis
5a203bfb39 docs(engine): say what the shuffle clamp actually guards (#2057)
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>
2026-08-04 18:13:28 -06:00
Stephen Dennis
9056c2f32c perf(engine): shuffle() locates each word once instead of re-walking (#2057)
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>
2026-08-04 17:02:46 -06:00
Stephen Dennis
43bca16e22 perf(color): make scramble() linear by building the cluster table once (#2045)
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>
2026-08-04 13:25:43 -06:00
Stephen Dennis
ddff5b45a5 chore(int64): softcode mux_atoi64 sinks use int64_t (#1402)
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.
2026-07-28 19:40:41 -06:00
Stephen Dennis
7dea5b97ce nls: mark remaining small engine notify piles with M_ (#1419)
Sweep the leftover constant notify/raw_notify sites outside the large
already-sliced files: mguests listing chrome, @function denials,
boolexp lock errors, login/page-lock notes, dump progress, @cron usage,
attrcache chrome, @dbclean error, funceval rummaging/step size (~20
sites). Leave blank-line T("") alone (#1443) and tprintf formats as T().
Half-mark pass clean. Regenerate pot/xx without fuzzy entries.
2026-07-27 12:40:51 +00:00
Stephen Dennis
e7eb6ec76d nls: route literal #-1 softcode tokens through S_ (#1475)
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.
2026-07-27 01:00:55 +00:00
Stephen Dennis
2f106f200f fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373)
Completes the sweep the issue called for.  mux_atol returns long, which
is 32-bit on LLP64, so every caller silently truncated on Windows.  Two
of those were real defects (the truthiness family and cf_size, fixed in
the preceding commits); the rest were latent, waiting for a value large
enough to matter.

Rather than audit 290 sites for whether each can reach 2^31 today, use
the 64-bit parser everywhere and remove the class.  A dbref cannot
overflow now, but nothing stops a later caller passing that same site a
timestamp or a byte count.

Pure 1:1 substitution: 285 lines changed, and every removed line
contained mux_atol while every added line contains mux_atoi64.  No
control flow, no types, no behaviour beyond the wider parse.

This is a NO-OP on LP64 -- long is already 64-bit on Linux and macOS, so
the generated code there is unchanged.  It only widens the parse on
Windows.  Narrowing destinations are unaffected either way: `int x =
mux_atoi64(s)` truncates exactly as `int x = mux_atol(s)` did, on both
models.

Left alone: mux_atol itself in mathutil, its declaration, and three
comments that name it.  Callers that genuinely want 32-bit semantics can
still ask for them; none appear to.

Verified on Windows: full solution builds clean with no new warnings,
smoke is 1418 passed / 16 failed / 0 crashes / 306 of 306 dispatched --
identical to before the sweep, with the same 16 build-configuration
failures (exp3 module not loaded, hmac/digest behind UNIX_DIGEST).
Spot checks after the change: the boolean family returns 1 for multiples
of 2^32, cf_size round-trips 3000000000 and still reads -1 as unlimited,
and arithmetic, string and list functions are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:12:35 -06:00
Stephen Dennis
6c54d5972e fix(win32): use mux_atoi64 for softcode truthiness (#1373)
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>
2026-07-26 09:43:38 -06:00
Stephen Dennis
1e1f066b62 fix(softcode): hastype() matches full type names; error no longer grows a stray 0
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>
2026-07-25 07:19:07 -06:00
Stephen Dennis
f0189548f7 fix(softcode): finish #1112 — clear on no-capture, and fix the %0..%9 twin
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>
2026-07-24 19:58:00 -06:00
Stephen Dennis
fe10b1211e fix: resolve Pass 5 Medium softcode defects #1111–#1115, #1117–#1119 + #1122
- #1111: wrapcolumns caps colWidth/nCols to LBUF-scale bounds
- #1112: regmatch pcre2_substring_copy capacity = LBUF_SIZE-1
- #1113: regrep_util null-checks match_data (fail closed)
- #1114: iabs(INT64_MIN) returns magnitude string (no negation UB)
- #1115: sql/mapsql drop second mux_exec; hard Wizard(); sql() invk/alarm
- #1117: sandbox reverse uses dynamic entry table (no 512 fail-open)
- #1118: trim_space_sep_LEN early-out when nStr==0
- #1119: lmath/limath median uses std::sort + alarm check
- #1122: mailreview body rejects nObjEvalNest (sent-mail twin of #1106)

engine.so builds clean. #1116 remains not-a-bug (closed).
2026-07-24 19:42:21 -06:00
Stephen Dennis
b1a9a196bd fix: resolve Pass 5 High softcode defects #1106–#1110
- #1106: mail() body access rejects self under nObjEvalNest (objeval nest)
- #1107: pose() only runs SPEECHMOD/SAYSTRING when Controls(target)
- #1108: lrooms BFS requires Good_obj+isRoom before Examinable/enqueue
- #1109: shl/shr require shift count in [0, 63] (UB for >= 64)
- #1110: heap-allocate limath vals and LBUF-scale list scratch (stack margin)

Claude confirmed all five; #1110 framed as recursion-margin erosion (leaf
handlers), not nest-to-multiply. engine.so builds clean.
2026-07-24 19:19:40 -06:00
Stephen Dennis
f22f848720 fix: resolve Pass 2 audit defects #1063–#1071
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).
2026-07-24 09:58:53 -06:00
Stephen Dennis
c34df4be01 Fix correctness bugs in xor(), wrapcolumns(), step(), tr()
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>
2026-06-19 05:56:27 -06:00
Stephen Dennis
a42d69e95a Fix memory-safety bugs in softcode functions, examine, @reference, @cron
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>
2026-06-19 05:12:28 -06:00
Stephen Dennis
9b4c2946c2 Migrate ~55 alloc_lbuf/free_lbuf sites in large engine files to LBuf RAII
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>
2026-04-05 18:14:49 -06:00
Stephen Dennis
737eadb8ee Implement Phase 3a routing: query-time lock validation
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>
2026-03-29 08:36:15 -06:00
Stephen Dennis
a348e27f33 Implement Phase 1 routing: static unconditional next-hop tables
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>
2026-03-29 03:18:37 -06:00
Stephen Dennis
68d014b46f Fix sortby with #lambda — bypass JIT in u_comp for correct cargs
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>
2026-03-24 13:22:33 -06:00
Stephen Dennis
fa18bdae47 Replace stack-allocated LBUF arrays with pool-backed LBuf RAII wrapper
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>
2026-03-23 21:52:30 -06:00
Stephen Dennis
f90334432f Add regrep()/regrepi() — regex-based attribute value searching
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>
2026-03-19 12:21:28 -06:00
Stephen Dennis
ff59ac8551 Eliminate mux_string from funceval.cpp, funceval2.cpp; reduce functions.cpp to 2
New co_* primitives:
- co_split_words: multi-char delimiter word splitting (PUA-aware)
- co_trim_pattern: multi-byte cyclic pattern trim
- co_compress_str: multi-char separator compression
- co_search_with_end: substring search returning match end position

Replaced mux_string/mux_words in:
- fun_trim (pattern), fun_squish (multi-char) → co_trim_pattern, co_compress_str
- fun_extract, fun_revwords, fun_last, fun_lrest, fun_pickrand, fun_shuffle,
  fun_elements, fun_columns → co_split_words
- do_itemfuns (fun_ldelete/fun_replace/fun_insert) → co_split_words
- fun_remove → co_split_words + co_search + co_strip_color
- fun_printf/sprintf → co_visual_width + co_copy_columns

funceval.cpp: 0 mux_string references (was ~10)
funceval2.cpp: 0 mux_string references (was ~20)
functions.cpp: 4 remaining (linewrap_general only)

593/593 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:53:59 -06:00
Stephen Dennis
8fc73473b2 Grapheme cluster co_* primitives: eliminate mux_string from 7 functions
New in color_ops.rl:
- next_grapheme_plain(): pure C UAX #29 grapheme segmentation using
  GCB/ExtPict DFA tables directly (same rules as utf8_next_grapheme)
- co_cluster_count(): count grapheme clusters in PUA-encoded strings
- co_cluster_advance(): advance past N clusters, skipping PUA color
- co_mid_cluster(): substring by cluster position, preserving color
- co_delete_cluster(): delete clusters by position, preserving color

Converted functions:
- fun_mid: co_mid_cluster (was cursor_from_cluster + export_TextColor)
- fun_right: co_cluster_count + co_mid_cluster
- fun_delete: co_delete_cluster
- fun_pos: co_search + co_cluster_count (cluster position of match)
- fun_lpos: co_search loop + co_cluster_count per match
- fun_strtrunc: co_cluster_count + co_mid_cluster
- fun_scramble: co_cluster_count + Fisher-Yates + co_mid_cluster

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:46:21 -06:00
Stephen Dennis
e39121f164 Replace fun_shuffle/fun_foreach/fun_escape with co_* primitives
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>
2026-03-12 18:36:55 -06:00
Stephen Dennis
87adf375f5 Replace fun_revwords/fun_pickrand/fun_elements with co_* for single-char
- 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>
2026-03-12 18:10:06 -06:00
Stephen Dennis
ac982526f9 Dual-mode co_* word functions: space compresses, non-space exact
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>
2026-03-12 18:06:23 -06:00
Stephen Dennis
bdd197bd25 Replace fun_extract/fun_last/fun_lrest with co_* for space delimiter
- 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>
2026-03-12 17:51:30 -06:00
Stephen Dennis
4ff1398de1 Restructure mux/ directory: component-based layout with proper build root
Move from flat mux/src/ layout to clean component hierarchy:
- mux/ is now the autoconf/automake build root (configure.ac lives here)
- mux/include/ — shared headers used by multiple components
- mux/lib/ — libmux.so (core utilities, no game state)
- mux/src/ — netmux driver only (thin networking shell)
- mux/modules/engine/ — engine.so (game logic)
- mux/modules/{comsys,mail,exp3,sqlproxy,sqlslave}/ — external modules
- mux/ganl/ — GANL networking library
- mux/sqlite/ — SQLite amalgamation (builds libsqlite3.a)
- mux/announce/ — announce tool (was mux/src/tools/)

Build changes:
- SUBDIRS ordering: ganl sqlite lib src modules announce
- libmux.so gets -Wl,-soname,libmux.so; netmux links via -L -lmux
- engine.so links libsqlite3.a and libmux.so with -Wl,--no-undefined
- RPATH uses $ORIGIN for portable .so resolution
- Install hooks use absolute paths for game/bin symlinks

Bug fixes:
- engine.so mux_Register() now passes nullptr to mux_RegisterClassObjects
  (matches all other modules; libmux already has the factory via dlsym)
- DbConvert() now calls pcache_init() before db_write, fixing a latent
  crash (free(): invalid pointer) when exporting from SQLite databases

411/411 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:38:37 -06:00
Renamed from mux/src/funceval2.cpp (Browse further)