Commit graph

30 commits

Author SHA1 Message Date
Stephen Dennis
e13364a3e0 perf(engine): invalidate the name index at db_free, not per load path
The index's staleness argument rested on "every load path that writes
location/contents/next directly calls db_validate_refs() afterwards, and
that invalidates."  Checking it rather than asserting it: db_free() has
eight call sites and db_validate_refs() has one, so the argument does not
hold in general.

It happens to hold at each non-SELFCHECK site today -- db_read(),
db_make_minimal() and the SQLite open path each either run with an empty
index or have the following setters re-invalidate every container they
touch -- so this is a latent gap rather than a live defect.  But it is
latent by coincidence, and the reasoning that establishes it is exactly
the kind that rots on the next refactor.

db_free() is the one point all eight paths share, and it is where the
containers the index was built from cease to exist.  Invalidating there
retires the argument instead of restating it.

Also hoists the sqlite_load_game() invalidation out of the per-object
loop, where it was clearing the whole map once per object rather than
once per load.

make test: 36 targets, 34 passed, 2 skipped, 0 failed
(jit=yes stubslave=yes realitylvls=yes wodrealms=yes)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:05:12 -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
d1eca03dc2 perf(engine): cache the per-object attribute-number list (#2077)
collect_attrnums_from_storage() answered every atr_head/atr_next with a
GetAll() query. $-command matching reaches it once per object in scope on every
line a player types, so a master room with 200 global commands cost one SQLite
round trip per object per command -- ~67% of $-dispatch was inside sqlite3.c,
its WAL frame lookups and its fcntl locking. Attribute VALUES were cached; the
attribute LIST never was.

Cache the storage half per object. The pending overlay
(cache_collect_pending_attrnums) still runs on every lookup and is deliberately
NOT cached with it, because it changes as the write queue drains.

Invalidation is in two places and the second is not redundant:

  * cache_put/cache_del, when an attribute may appear or disappear. This also
    covers the bStandAlone branches, which write through to SQLite directly and
    never reach a flush.
  * cache_flush_writes, once the queue has landed in SQLite. Without this, a
    list cached BETWEEN queue and flush stays correct only while the overlay
    still reports the pending attribute; the moment the flush clears the dirty
    flag the overlay goes quiet and the stale list silently loses it.

Attribute value changes do not affect the list, so they do not invalidate.

MEASURED, live server, typed commands matching nothing, master room populated
with objects carrying one $-command each. Both columns from the same build:

  objects   master    with cache          us/object master -> fix
        -    120us         80us
       25    270us         90us  3.0x           6.0 -> 0.4
       50    430us        110us  3.9x           6.2 -> 0.6
      100    750us        170us  4.4x           6.3 -> 0.9
      200   1580us        290us  5.4x           7.3 -> 1.1

The empty-master-room baseline improves too (120us -> 80us): atr_head serves
far more than $-matching.

This retires the $-pattern automaton idea for now. Profiling the same workload
put wild() + wild_lit_eq() at 0.48% of user time with 200 patterns tested per
command -- getting to the patterns was the cost, not comparing them.

Correctness, beyond `make test`:
  * pending ADD visible before flush.
  * a list cached BEFORE a flush still reports the attribute AFTER it
    (KEEP moves from position 3 to 73 as 70 pads land) -- the case the
    flush-side invalidation exists for.
  * tombstone suppressed immediately (#1045), and still suppressed after the
    delete itself flushes, rather than resurrecting from a stale list.

`make test` -- 34 passed, 2 skipped, 0 failed
(jit=yes stubslave=yes nls=no realitylvls=yes wodrealms=yes).

Sibling of #2073, which fixed the pending-merge half of the same function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:30:45 -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
8e904de0cc nls: mark GAME/connect/Suspect/boot system notifies with M_() (#1419)
Residual Phase 3 player/staff prose that still used T() on live paths:
connect/reconnect/disconnect room and monitor lines, [Suspect] flags,
GAME: restart/shutdown/signal/log-full, dual-path channel boot, and
@email hostname resolution. pot + xx complete + ko msgmerge append;
tools/merge_nls_markings.py for Windows agents without xgettext
(canonical pot remains make -C mux/po pot on Linux).
2026-07-28 12:07:45 -06:00
Stephen Dennis
1862b248d7 nls: mark speech pose formats, look UI, engine/db set notifies (#1685)
Phase 3 coverage:

  speech   pose/semipose composition (%s %s / %s%s), shout glue
  look     exit destination and command-scan lines (not decompile)
  engine   @timecheck counted-objects summary (screen + log)
  db       @stats field sets, forward/permission errors

Decompile @create/@set templates, HTML, and softcode #dbref args stay T().
Regenerates pot; rebuilds xx.po for complete catalogue.
2026-07-28 15:16:04 +00:00
Stephen Dennis
301a7664d0 nls: mark db/cron/powers/session misc notify prose with M_ (#1419)
Remaining small engine notify piles (~45 sites across db, cron, powers,
session, object, conf, help, log). Leave tprintf formats as T().
Half-mark pass clean. Regenerate pot/xx without fuzzy entries.
2026-07-27 11:50:31 +00: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
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
4d6fac656c fix: resolve 2.14 audit defects #1039–#1061
Restart (#1039–#1043): zero process-local DESC fields after load,
restore peer via getpeername, strip non-survivable TLS/WS flags,
drop TLS and WebSocket before exec, tear down adopt failures with
shutdownsock, abort partial restart loads cleanly.

DB (#1044–#1047): cache_flush_writes returns success only after
Commit; leave dirty queue on failure; atr_head union SQLite with
pending cache/queue; set mudstate.bStandAlone in DbConvert; flush
attr queue before import Commit.

Queue (#1048–#1052): setup_que refunds on OOM; sql_que frees BQUE
on Query fail and inits waittime; do_wait rolls back semaphore if
wait_que fails; include nest depth limit 50; CPU halt_que uses
Owner+object for machines.

JIT (#1053–#1057): full 64-bit rv_load_i64; setq/r single-char
RegisterSet; decline long CARGS; accumulate inline u() watermarks;
bounded guest_strnlen on ECALL paths.

Conf/convert (#1058–#1061): @enable/@disable sync g_dc; live
driver knobs for output/retry/max_players/timeouts/quota; safe
heap encode for t5x/t6h/r7h attributes.
2026-07-24 08:26:59 -06:00
Stephen Dennis
497e36778a fix: db_validate_refs clamped legal HOME exit destinations to NOTHING
For an exit, the location field holds the destination, and @link
exit=home stores the HOME sentinel there ("HOME is always linkable",
parse_linkable_room; move.cpp handles dest==HOME throughout; decompile
emits "@link <exit>=home" for it).  db_validate_refs (#810) clamped
location with the plain objref() check, so every home-linked exit was
rewritten to an unlinked exit on load -- and the repair persists on the
next dump.  Give location the same HOME-permitting treatment link
already gets.

Also corrects the comment cross-references in db.cpp/engine_com.cpp,
which cited #809 (the unrelated a64 DBT issue) instead of #810.

Adds a smoke regression test (db_validate_refs.mux): an exit opened
with @open smoke_home_exit=home must still have loc() == #-3 after the
flat-export -> SQLite-import -> load round-trip.  Against the previous
clamp it fails with loc=#-1; with the fix the suite passes 1116/1116.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:07:10 -05:00
Stephen Dennis
739cc30f9b fix: validate object field dbrefs after load (db_validate_refs) — chain-walk OOB on malformed DB (#810)
Object field dbrefs (location/contents/exits/next/link/owner/parent/zone) were
stored straight from the DB with no range validation. Object indices (#806) and
attribute numbers (#808) were validated, but the field VALUES were not, and the
engine has no db_check. A wild value crashes at use: DOLIST (db.h:218) walks
contents/exits via Next(thing)=db[thing].next, guarding only NOTHING/self-loops,
so a wild next -> db[wild].next is an OOB read; location/parent/zone/owner index
db[] elsewhere. db_read clamped only zone; sqlite_load_game clamped nothing.

Add db_validate_refs(), called once after a load completes (engine_com.cpp
LoadGame, covering warm-SQLite and cold-flatfile paths). It clamps each object's
location/contents/exits/next/parent/zone to a real object or NOTHING, link
additionally to HOME, and a wild owner to GOD. For a valid DB every field is
already in range, so it is a no-op there; only a corrupt DB is repaired (the
repair persists on the next dump).

Verified: a server loaded from SQLite with next=999999999 on object 0 starts
cleanly and logs "db_validate_refs: clamped 1 out-of-range object reference(s)";
smoke 1115/1115 (valid DBs unaffected). Same incomplete-hardening family as
#806/#807/#808.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:33:36 -06:00
Stephen Dennis
7e3da01e3b fix: validate user-attribute number before anum_table indexing — OOB write / OOM on malformed DB (#808)
Loading a corrupt/malicious DB with an out-of-range user-attribute number
corrupted memory or OOMed: the number indexes anum_table via anum_set (a bare
`anum_table[x]=v` macro) and anum_extend (allocates a dense (x+1) table), with
no validation. The read path atr_num validates anum<0||>top; the write path
(vattr_define_LEN -> anum_extend/anum_set) did not (incomplete hardening,
family of #805/#806/#807).

Live-verified via flatfile import (+A record, novel attr name):
- +A-10000000 -> anum_table[-10000000] write -> SIGSEGV.
- +A-5 -> anum_table[-5] write in valid heap -> silent corruption.
- +A999999999 -> anum_extend allocs ~8GB dense table -> OOM kill.

File-derived numbers reach this from the flatfile +A handler (getref) and the
SQLite attr-name load (db.cpp:3626). A huge number also seeds attr_next enormous
via g_max_nam_atr/max_attrnum_loaded -> later runtime OOM.

Fix:
- New A_USER_MAX (0x01000000 = 16M) constant in attrs.h beside A_USER_START.
- vattr_define_LEN rejects number < A_USER_START || > A_USER_MAX (central backstop).
- The flatfile +A handler and the SQLite attr-name callback validate and skip
  the bad record (graceful degradation; flatfile path logs it).

Verified bite-then-not-bite: malformed +A flatfiles SIGSEGV/silent-corrupt/
OOM-kill on the old build, load cleanly (bad record skipped) on the fixed build;
valid DB still imports; smoke 1115/1115.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:08:44 -06:00
Stephen Dennis
900e3e3175 Convert ~75 atr_get/atr_pget sites to LBuf_Adopt across 17 files
Use the new LBuf_Adopt() macro to take RAII ownership of caller-owned
pool buffers returned by atr_get, atr_pget, and atr_get_LEN.  This
eliminates ~82 explicit free_lbuf calls and automatically covers
early-return paths that previously required careful manual placement
of the free.

Heaviest conversions: player.cpp (13), comsys.cpp (14), command.cpp (9).
Complex interleaved patterns (did_it charge/runout swaps, PureName
reassignment, process_cmdent loops) left manual for now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:34:51 -06:00
Stephen Dennis
3b62628772 Migrate 47 more alloc_lbuf/free_lbuf sites to LBuf RAII
Second wave: convert manual alloc/free pairs in cque (1), db_rw (4),
conf (5), db (2), engine_com (6), help (7), levels (2), predicates (5),
player (7), funmath (8).  Eliminates ~80 explicit free_lbuf calls
including multi-exit error paths in getboolexp1 (5 frees → 0),
get_list, AnnounceConnect/Disconnect, and the eight NOEVAL function
variants (cand/cor/firstof/allof and bool variants).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:55:40 -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
c84a24dae0 Guard bootstrap attribute owner checks 2026-04-04 10:12:00 -06:00
Stephen Dennis
a2d65b1f7d Complete Stage 2: backend interface audit
Story 2a: Extend IStorageBackend with Count(), GetModCount(), and
GetAllModCounts(). Implement in CSQLiteBackend. Route all attribute
access in attrcache.cpp and db.cpp through the interface, eliminating
GetDB() bypasses for attribute-specific operations.

Story 2b: New docs/attribute-metadata.md defines the per-attribute
metadata contract: (object, attrnum) -> (value, owner, flags, mod_count),
encoding requirements, mod_count semantics, and iteration guarantees.

Story 2c: Confirmed @search never queries the attributes table directly.
SQL fast-paths use object metadata only; eval= predicates go through
cache_get() -> IStorageBackend. No changes needed for backend swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:22:22 -06:00
Stephen Dennis
369f92495c Remove ISOUTOFMEMORY macro; add per-site OOM recovery and fix g_dump_child_pid
Eliminate the ISOUTOFMEMORY macro that unconditionally aborted on allocation
failure. Each of the 23 call sites now handles OOM appropriately:
- Fatal sites (buffer pools, db array, anum table): mux_assert or OutOfMemory
- Recoverable sites (queue, mail, commands, guests, vattrs, config, restart,
  forward lists): log the failure and return gracefully

Also fix g_dump_child_pid portability: volatile pid_t -> volatile sig_atomic_t
with explicit casts in ganl_adapter.cpp.

Close integer overflow issue as false alarm (getstring_noalloc uses a bounded
static buffer, so nBuffer+1 cannot wrap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:31:20 -06:00
Stephen Dennis
82d0ff1728 Fix Phase 1 routing correctness gaps 2026-03-29 03:28:10 -06:00
Stephen Dennis
0b28265577 Remove dead code: LogStatBuf() and GAME_DOOFERMUX
LogStatBuf() was defined but never called. GAME_DOOFERMUX was never
defined, making all four #ifdef blocks dead code (A_REGINFO attribute,
RegInfo table entry, registration stamp on player create, and alternate
execl argv[0]). Update smoke test expectations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 02:07:41 -06:00
Stephen Dennis
8529bb68f2 Add vlimit config parameter for per-object attribute count limit
Default 1000, 0 = unlimited. Wizard-owned objects are exempt.
Enforced in atr_add_raw_LEN with a fast cache_get existence check
(updates are free) and SQLite COUNT(*) for new attributes only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 05:09:02 -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
a57b039e78 Fix bulk deletion mod_count: two-phase transactional invalidation
The previous sentinel-based approach (UINT32_MAX) wrapped to 0 on
the next write, breaking monotonicity.  The before-transaction
eager mutation approach diverged on rollback.

New design: two-phase collect/apply.

Phase 1 (before transaction): attr_mod_count_collect_object()
reads all attrs from SQLite and the in-memory map, returning a
vector of {key, current_value} pairs.  No in-memory mutation.

Phase 2 (after successful commit only):
attr_mod_count_apply_increments() writes current_value+1 to the
in-memory map for each collected entry.

On rollback: no in-memory mutation occurred, so the map stays
consistent with SQLite.  Monotonicity is preserved because the
increment is always current+1, never a sentinel or reset.

Smoke tests: 610/610 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:33:33 -06:00
Stephen Dennis
7d7ea2ea79 Fix bulk deletion mod_count: monotonic increment, no sentinel
The UINT32_MAX sentinel wrapped to 0 on next write, breaking
monotonicity. Replace with:

- attr_mod_count_invalidate_object() now seeds all attr counters
  from SQLite BEFORE deletion (via GetAllAttrModCounts), then
  increments each by 1. Counters are strictly monotonic — no
  sentinel, no wrapping, no reuse across delete/recreate cycles.

- Call sites in object.cpp moved BEFORE DelAllAttributes so the
  SQLite rows are still readable when seeding.

- ClearAttributes() clears the in-memory map entirely (correct
  for full database reload where JIT cache is also cleared).

Smoke tests: 610/610 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:25:50 -06:00
Stephen Dennis
0f3d233616 Fix mod_count failure-path divergence and bulk deletion bypass
Two bugs from code review:

1. attr_mod_count_inc ran before cache_put/cache_del, so failed
   writes bumped the in-memory counter without changing SQLite.
   Fix: increment AFTER successful storage. PutAttribute reads
   the current counter and passes counter+1 to the upsert
   independently.

2. DelAllAttributes and ClearAttributes bypassed mod_count tracking.
   Fix: attr_mod_count_invalidate_object() sets all entries for a
   destroyed object to UINT32_MAX (always stale). ClearAttributes()
   calls attr_mod_count_invalidate_all() to clear the entire map.

Smoke tests: 610/610 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:20:16 -06:00
Stephen Dennis
acae612fea Fix mod_count divergence and delete/recreate reuse
Two bugs from code review:

1. In-memory counter started at 0 regardless of persisted value.
   After restart, first write to an attr with mod_count=17 in SQLite
   would set in-memory to 1 while SQLite advanced to 18.
   Fix: attr_mod_count_inc() seeds from SQLite on first access.

2. Delete + recreate reused mod_count=1, making stale cache entries
   look fresh.  Fix: increment in-memory counter BEFORE deletion
   (survives the SQLite row removal), and PutAttribute uses
   MAX(excluded.mod_count, mod_count+1) so the SQLite value is
   always >= the in-memory value.

Smoke tests: 610/610 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:11:02 -06:00
Stephen Dennis
2be7d9a1b5 Add per-attr mod_count for JIT cache invalidation (Step 1/7)
Foundation for Tier 3 u() inlining cache staleness detection.
Each attribute now tracks a modification counter that increments
on every atr_add/atr_clr.  The JIT compiler will record mod_count
at compile time and check for staleness at runtime.

Schema changes:
- Bump to v8: ALTER TABLE attributes ADD COLUMN mod_count
- PutAttribute uses INSERT...ON CONFLICT DO UPDATE to atomically
  increment mod_count on each write
- New GetAttrModCount() method for SQLite lookup

In-memory tracking:
- attr_mod_count_inc() incremented from atr_add_raw_LEN / atr_clr
- attr_mod_count_get() reads from in-memory map with SQLite fallback
- Survives LRU cache eviction (separate map, not in AttrCacheEntry)

Also fixes CURRENT_SCHEMA_VERSION (was 6, now 8).

Smoke tests: 610/610 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:02:46 -06:00
Stephen Dennis
68e0c673e7 Feature: Add MSSP, GMCP, @protect, and benchmark()
Four new features identified from the TinyMUSH/PennMUSH/RhostMUSH survey:

MSSP (MUD Server Status Protocol, telnet option 70):
- Server sends structured key-value data (NAME, PLAYERS, UPTIME, PORT,
  CODEBASE, FAMILY) to MU* directory crawlers on IAC DO MSSP
- Stateless response via send_mssp() in telnet.cpp using g_dc config basket
- start_time_utc added to DRIVER_CONFIG for uptime calculation

GMCP (Generic MUD Communication Protocol, telnet option 201):
- Protocol negotiation: server offers WILL GMCP, tracks gmcp_enabled per DESC
- Inbound: GMCP subneg queued as synthetic "\x01GMCP" command, dispatched
  to handle_gmcp() which fires A_GMCP attribute with %0=package %1=json
- Outbound: gmcp(<player>, <package>, <json>) softcode function sends
  GMCP frames to all GMCP-enabled descriptors via SendGmcp COM method
- Full COM architecture: mux_IConnectionManager::SendGmcp in driver,
  send_gmcp() bridge in engine, CConnectionManager impl in modules.cpp

@protect (player name reservation):
- @protect[/add] <name>, @protect/del <name>, @protect/list [<player>]
- A_PROTECTNAME attribute (234) stores space-separated protected names
- protectname_check() hooked into create_player() and do_name()
- max_name_protect config param (default 5)

benchmark(<expression>, <iterations>):
- FN_NOEVAL, CA_PUBLIC, 10000 iteration cap
- Uses clock_gettime(CLOCK_MONOTONIC) / QueryPerformanceCounter
- Returns elapsed seconds as floating point

Also fixes: add unicode_tables.c to libmux.so LIBMUX_C_SRC (resolves
pre-existing tr_tolower_sbt etc. link errors).

505/505 smoke tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:08:55 -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/db.cpp (Browse further)