Commit graph

13 commits

Author SHA1 Message Date
Stephen Dennis
843e46c6ec fix(match): the name index must observe the promote, not the compare (#2110)
match_list()'s exact-name index short-circuited the rest of the container
whenever string_compare() matched a bucket candidate.  That is only sound
if the match actually promoted, and promote_match() returns WITHOUT
promoting when REALITY_LVLS hides the candidate from the looker:

    #ifdef REALITY_LVLS
        if (Good_obj(what) && (confidence & CON_LOCAL)
            && !IsReal(md.player, what) && what != Location(md.player))
        {
            return;
        }
    #endif

match_list() always passes CON_LOCAL, so the guard is live on exactly the
path the index accelerates.  The old walk had no early exit -- it declined
that candidate and kept scanning, which is how a VISIBLE partial match
further down the contents list still won.  With the index, a query that
exactly names a reality-hidden object returned #-1 where 2.13 and
pre-#2058 master return an object.

Reproduced on a throwaway server with def_thing_tx 2 against
def_player_rx 1, one room holding THING "sword" (hidden) and PLAYER
"swordsman" (visible):

    num(sword)      pre-#2058  #13     with index  #-1     fixed  #13
    num(swordsman)             #13                 #13            #13
    num(#12)                   #12                 #12            #12

Snapshot count/confidence/match and short-circuit only when one of them
moved.  Three fields rather than md.count alone: a higher-confidence
promote replaces md.match while leaving the count at 1.

Deliberately not duplicating the IsReal test inside the loop --
promote_match() owns that policy, a second copy is what rots, and
observing the outcome also covers any future early return added there.

The PR's soundness argument (exact scores 0x09 minimum, partial tops out
at 0x07, so exact always outranks partial) is correct about confidence.
It does not cover the case where the exact match is never scored at all.

tests/scenario/reality_name_index.py pins it.  This needs a NON-DEFAULT
reality configuration -- at the shipped defaults every rx/tx is 1, IsReal
is 1 & 1, and promote_match never declines -- which is why `make test`
was 36/36 green on the commit that introduced this.  run.sh starts a
second server for it, because the setting is global and would change what
every other driver is testing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:41:34 -06:00
Stephen Dennis
63a09f4f49 perf(engine): index exact names per container (#2058)
match_list() compared the query against every object in scope and could not
stop at the first hit, because ambiguity has to be detected.  Name resolution
was 66.8% of attrread and 74.8% of attrwrite once the storage costs were out
of the attribute path (#2073, #2084).

THE INDEX IS A FILTER, NOT THE DECIDER.  string_compare() folds Unicode case
through mux_tolower(), whose output is not a byte-for-byte map; reimplementing
that to build a hash key would be an approximation that silently disagrees
with the walk on some name.  So the bucket key is an ASCII normalisation only,
every candidate it yields is still confirmed with the real string_compare(),
and any name carrying a byte >= 0x80 is not bucketed -- it goes on a must-scan
list that suppresses the short-circuit for that container.  A wrong key costs
a fallback, never a wrong answer.

Skipping the remaining walk on a hit is sound because an exact match scores
CON_COMPLETE|CON_LOCAL (0x09 minimum) while a partial tops out at
CON_LOCAL|CON_TYPE|CON_LOCK (0x07): exact always outranks partial.

Only used when md.check_keys is false.  With it set, promote_match() evaluates
could_doit() per candidate -- softcode -- so skipping the walk would skip those
evaluations.  Exit and movement matching, the only check_keys users, keep the
existing behaviour exactly.

INVALIDATION.  s_Location/s_Contents/s_Next/s_Name carry hooks, which covers
the 45 call sites that mutate contents lists through them.  Three sites bypass
the setters and write the fields directly; grepping for those found them, and
they invalidate too:

  * db.cpp objref() fixup loop and the SQLite load path -- whole-index clear.
  * object.cpp force_reclaim_failed_create_slot(), which turns an object into
    GARBAGE while clearing its fields.  That one is in normal operation and
    would have left a container's index naming a destroyed object.

MEASURED.  Per lookup at 1000 objects, the tax that scaled with scope is gone
-- name lookup now costs what a dbref lookup costs:

  lookup            master     with index
  by dbref (ctrl)    26.7us       30.0us
  name at head       53.3us       26.7us
  name at middle     60.0us       23.3us
  name at tail       66.7us       26.7us

Per command, two runs, with three non-resolving controls flat:

  phase            before    run1    run2
  attrwrite         143us    46us    41us
  attrread          160us    51us    55us
  dispatch (ctrl)    31us    30us    32us
  softcode_arith     27us    28us    26us

Behaviour verified identical to master across fifteen cases: exact match,
case-insensitivity, partial-match fallthrough, space compression, true
ambiguity (two objects sharing a name still give #-2), rename, movement and
destruction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:04:37 -06:00
Stephen Dennis
d4fba89566 chore: convert remaining player-facing snprintf to mux_sprintf (#1653)
BAN_LEGACY falls 41 → 23. Convert every ratchet site that can reach
mux_sprintf/mux_snprintf:

  nls path assembly, AST bench floats, attrcache sizes, mail folders,
  named references, softcode %f (fixed-precision table; no %.*f),
  exp3 safe_ltoa, ganl port, websocket 101 handshake.

Left frozen: dbt_test.cpp (22) and dbt_x64_div_harness.c (1) — standalone
test binaries that do not link libmux. Neither is player-facing text.
2026-07-28 19:59:35 -06:00
Stephen Dennis
b5ee3b6f81 refactor: use UTF-8 text in user-facing string literals (#1513)
Replace ~900 typographic \xE2\x80\x.. escapes (curly quotes, en dashes)
and a few \xE2\x80\230 octal workarounds with real UTF-8 in message
strings under mux/modules and mux/src. Leave stringutil and convert
charset mapping tables as explicit byte sequences.
2026-07-26 21:52:48 -06:00
Stephen Dennis
963fbceb7a nls: opt-in M_() for prose; keep T() as UTF-8 cast (#1444)
Translating every T() literal made the catalog surface ~7k strings
(identifiers, formats, softcode) and forced static tables through
gettext. Leave T() as the cast it always was; mark player/staff prose
with M_() (and N_() for static msgid storage). Catalogs only grow when
someone deliberately opts in — cheaper to maintain, safer if a string
is missed.

Spike seed: match messages rebind via M_() after mux_nls_init. Empty
msgid guard remains in mux_gettext for M_() (#1443).
2026-07-27 00:03:01 +00:00
Stephen Dennis
30bc04c732 Add optional gettext NLS plumbing for server messages (#1419)
Phase 1 Linux spike: --enable-nls wires libintl, T()/S_()/N_() split
softcode ABI from player notifies, bindtextdomain under game/locale, and
an xx pseudo-locale for manual checks. English-only remains the default.
2026-07-27 00:03:01 +00: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
87485bc401 Migrate 22 alloc_lbuf/free_lbuf sites to LBuf RAII
Replace manual alloc_lbuf/free_lbuf pairs with LBuf RAII wrappers
in 12 engine source files: rob, walk, quota, wiz, session, object,
log, match, move, create, flags, boolexp.  This eliminates ~40
explicit free_lbuf calls on error paths that are now handled by
destructors, removing leak risk in early-return and multi-exit
functions (boolexp alone had 13 exit-path frees across two
functions).  Buffers returned by atr_get/atr_pget or to callers
are left manual since LBuf cannot adopt externally-allocated
buffers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:46:22 -06:00
Stephen Dennis
974964719c Convert all static scratch buffers to thread_local
43 static scratch-buffer arrays across 21 files (5 in mux/src, 38 in
mux/modules/) changed from `static` to `thread_local`. Under the
current single-threaded evaluator this is a zero-behavior-change swap
— `thread_local` storage has the same lifetime and zero-allocation
properties as `static` — but each thread gets its own copy, which
makes these functions safe for a future multi-threaded evaluator
without any locking.

Read-only constant tables (`aRadix64`, `aRadixPenn36`,
`aRadixPenn64`, `Empty`) left as `static` because they are immutable
shared data.

Affected areas:
  net.cpp        — queue_string co_buf, trimmed_site, dump_users NameField
  signals.cpp    — signal_desc
  stubslave.cpp  — Stub_PipePump
  attrcache.cpp  — sqlite_attr_buf
  boolexp.cpp    — parsestore
  command.cpp    — preserve_cmd, SpaceCompressCommand, LowerCaseCommand
  comsys.cpp     — NewTitle, Buffer, temp
  db.cpp         — tbuff, Buffer (x2)
  flags.cpp      — buff
  funceval.cpp   — textbuff
  functions.cpp  — TimeBuffer64, TimeBuffer80, Buffer
  help.cpp       — Line, Buffer
  mail.cpp       — aFolders, Buffer, res, szFittedMailAliasDesc
  match.cpp      — buffer
  player.cpp     — szSalt, buf (x2), buff
  plusemail.cpp   — buf
  predicates.cpp — Buf (x2), pName
  session.cpp    — szFittedDoing
  set.cpp        — pRestrictedKeyText
  unparse.cpp    — buf, boolexp_buf
  mail_mod.cpp   — result, res, buf

All 21 files verified with g++ -std=c++17 -fsyntax-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:21:14 -06:00
Stephen Dennis
b03638ba84 Fix match prefilter and tier2 lookup 2026-04-04 10:28:28 -06:00
Stephen Dennis
876a578123 Skip obvious non-matches in match_list 2026-04-04 10:23:46 -06:00
Stephen Dennis
48777504e4 Eliminate mux_string from notify pipeline and trim remaining small uses
Rewrite notify_check() from mux_string to raw PUA-encoded UTF-8 lbufs.
The engine now passes PUA strings through; the network layer converts
PUA to ANSI/XTERM/HTML per client.  html_escape() replaces encode_Html(),
co_strip_color() replaces export_TextPlain() for @listen matching.

Remove mux_string overloads: raw_notify, raw_notify_html,
send_text_to_player (session.cpp, conn_bridge.cpp, net.cpp).
Convert handle_ears, look_for_exits (predicates.cpp), notify_comsys
(comsys.cpp), and small uses in mail.cpp/match.cpp.
Add extern "C" guards to color_ops.h for C++ inclusion.

593/593 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:31:08 -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/match.cpp (Browse further)