Commit graph

70 commits

Author SHA1 Message Date
Stephen Dennis
95977dc7d4 feat(net): proto_detect_window — make the 500ms connect wait configurable (#2193)
Every classic telnet connect waited a hardcoded 500ms in total silence
before the server sent its first byte.  Measured over 30 connects to an
idle server: min 500.6ms, p50 501.0ms, max 504.5ms -- the distribution is
the grace window itself.  Base command latency on the same setup is ~51us.

The window exists so telnet negotiation cannot corrupt a WebSocket
handshake (#1074), and the incentives are inverted: WebSocket and TLS
clients speak first and are served immediately, while the classic MUD
client -- which waits for the server to speak, and is the primary audience
-- is the only kind that always pays in full.

Now `proto_detect_window`, in milliseconds, default 500.  Nothing changes
for a site that does not touch it.  0 disables detection: the banner goes
out at accept, as in 2.13.  That is the correct setting for a port that
never serves WebSocket -- it has nothing to detect and no reason to wait.

On keeping 500 as the default rather than shaving it: the window is NOT
covering a round trip, which is what the original issue text assumed.  A
WebSocket client's GET rides directly behind the handshake's final ACK, so
the healthy case needs ~0ms however distant the client -- the RTT is
already spent by the time the window opens.  What the window must survive
is that first packet being LOST, where the retransmit arrives on an RTO
that Linux floors at 200ms.  500 covers one retransmit with headroom.  100
would sit in the dead zone -- past every healthy client, short of every
retransmit -- and would break real WebSocket handshakes intermittently on
a lossy link.  A slow banner is a far better failure mode than that.

The value was a literal in two places (the age-out sweep and the main-loop
timeout clamp) with nothing tying them together, so they could drift and
the clamp would silently stop bounding the sweep.  One accessor now.

0 is expressed as arithmetic rather than a second finalize call site: with
the window at 0, `age >= 0` is true on the first sweep and the clamp drives
processEvents to a 0ms timeout, so the connection finalizes in the same
main-loop iteration it was accepted in.  A shortcut around the sweep would
have had to duplicate both the #2018 exception barrier and the #1800
partial-preface replay.

IID_IGameEngine bumped C9D2 -> C9D3.  The vtable is unchanged, but
GetConfig() memsets and fills sizeof(DRIVER_CONFIG) as the ENGINE sees it
into storage the DRIVER sized, so a size disagreement is an out-of-bounds
write rather than a wrong answer.

tests/scenario/proto_detect.py asserts it against a live server:

    ok 1 - silent client waits the configured proto-detect window  # 500 ms
    ok 2 - client that speaks first is served without waiting  # 0 ms
    ok 3 - engine accepted the runtime @admin to 0  # config='0'
    ok 4 - #2193 window 0 serves a silent client at accept  # 0 ms
    ok 5 - restoring the window restores the wait (live push, both ways)

Catch-verified by reverting only the driver's USE of the knob, leaving the
config plumbing intact -- the #1222 shape, where the engine reports the
change and the driver ignores it until restart:

    ok 3 - engine accepted the runtime @admin to 0  # config='0'
    not ok 4 - #2193 window 0 serves a silent client at accept (500 ms)

Clean rebuild, since DRIVER_CONFIG's layout changed (#2107).

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:01:47 -06:00
Stephen Dennis
a906a66af6 chore(#2136): post-merge nits from the PR review
- engine_com: drop the two const_casts into now-const APIs
  (lookup_player, select_channel) — harmless re-const the campaign made
  unnecessary; the remaining casts there target still-mutable APIs
  (load/save_comsys, connect_player) and stay.
- campaign brief: the merge-gate paragraph was written as an instruction
  and had become history — rewritten to record the closed gate and keep
  the pointer to landing Kagura's 35-shape probe in-tree.

Smoke: ALL 1661 PASSED on jit=yes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:24:55 -06:00
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
5465b2fbeb fix(com,mail,sql): bound CResultsSet fields, channel names, malias caps (#1878 #1874 #1875)
CResultsSet constructor and NextField require length headers and payloads
to fit the result blob.  CreateChannel rejects names longer than
MAX_CHANNEL_LEN before duplicate checks.  Mail aliases cap at 50 per
owner and 100 members, including on load.
2026-07-31 09:12:46 -06:00
Stephen Dennis
9a4de32dcb fix(queue,db): scheduler enqueue success and flatfile write failures (#1871 #1869)
DeferTask/DeferImmediateTask return bool (nothrow OOM); wait_que and
sql_que free the BQUE and refund quota/waitcost when enqueue fails.
db_write_object reports stream status via ferror; db_write returns -1
on I/O failure after header/object/end/flush checks, and dump/dbconvert
callers refuse to publish a truncated flatfile.
2026-07-31 09:09:18 -06:00
Stephen Dennis
cf008e2bc7 chore(int64): ConnectionInfo fields and conn* softcode use int64_t (#1402)
Times and connect counts are attribute-backed integers; store and expose
them as int64_t on every platform instead of long (32-bit on LLP64).

Bump IID_IObjectInfo for the mux_IObjectInfo method signatures. Persist
with mux_i64toa; softcode conn* helpers print with safe_i64toa.
2026-07-28 19:40:41 -06:00
Stephen Dennis
8d7999c70b feat(modules): GAME_CONFIG -- game-policy config reaches modules (#1654)
The parking condition on #1654 was "a second consumer"; the tally reached
three, all documented divergences:

    searchcost       mail    non-wizard self @mail/stats was free
    eval_comtitle    comsys  comtitles always evaluated under the module
    money_name_*     mail    the charge-refusal text could not be composed

## Shape

A new engine-registered class rather than an Initialize signature change:

    mux_IGameConfig::GetGameConfig(GAME_CONFIG *)    CID_GameConfig

No existing IID moves, so there is no ABI break in either direction: an old
module never asks, and a new module against an old engine gets
CLASSNOTAVAILABLE, soft-fails to nullptr, and keeps its prior behaviour.

Two design rules baked in rather than documented and hoped for:

  * Queried per CALL, never cached at Initialize.  A boot-time snapshot is
    #1613's bug -- @admin reports Set. while the module keeps stale values.
    @admin search_cost=50 now takes effect on the next @mail/stats.

  * Versioned by cbSize: caller zeroes the struct and sets cbSize; the
    engine fills what fits.  The struct can grow without a new interface,
    and zero must stay a safe default for every future field.

## Both consumers, verified as a mortal

The gap was wizard-invisible -- payfor() exempts wizards and every harness
runs as God -- so verification used muxscript -p with a @pcreate'd mortal:

    search_cost 5, rich       engine 150->145   module 150->145   IDENTICAL
    search_cost 99999, poor   both: "Finding mail stats costs 99999
                              Pennies.", money untouched

    eval_comtitle default     [ec] 3 Wizard says ...            both sides
    eval_comtitle 0           [ec] [strlen(abc)] Wizard says    both sides

The eval_comtitle rows close the divergence documented at
channel_speaker_name since #1640/#1647.

Conformance and smoke are unchanged by construction (wizard runs), and were
run anyway.

make test: Smoke 1561 x3, conformance PASSED, handoff 13.  TESTEXIT=0.

Refs #1613, #1614, #1631, #1640, #1647.  Closes #1654.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:57:35 -06:00
Stephen Dennis
a474dc0a88 feat(nls): language in netmux.conf, and one catalogue reader everywhere (#1702)
Selecting a language was the one server-wide setting that did not live in
netmux.conf, and the documented way to do it did not work on every platform.

    LANGUAGE=ko ./bin/netmux      # mux/po/README.md

libintl honours LANGUAGE.  The built-in reader -- the MSVC path, since the
Windows SDK ships no <libintl.h> -- reads only LC_ALL, LC_MESSAGES and LANG,
so on Windows that selected nothing and the server ran English with no
diagnostic.  Two readers, two answers, one documented recipe.

## `language <catalogue>`

    language ko

names game/locale/ko/LC_MESSAGES/tinymux.mo.  Empty (every existing config)
keeps the environment behaviour exactly.  It is passed to mux_nls_init() as a
parameter rather than exported into the environment, so selection cannot mean
different things on different platforms.

## One reader

libintl is no longer used for lookup: mux_nls.cpp does not include
<libintl.h> and calls neither gettext nor ngettext.  The built-in MO reader
serves every platform.

That removes gettext's rule that the C/POSIX locale suppresses translation
outright.  Via libintl, `language ko` in a bare service environment could
only print "cannot take effect" and continue in English; opening a catalogue
by path has no such rule, so it now simply works:

    LANG=C LC_ALL=C, language ko  ->  kowidget을(를) 사물 #12(으)로 만들었습니다

The format is unchanged -- .po/.mo, msgfmt, xgettext, msgmerge, Plural-Forms.
Only the runtime is ours now, so a translator's workflow is untouched.

## Plural-Forms, which is what libintl was really providing

The built-in reader returned English plural forms regardless of catalogue, so
Windows already got "1 vs many" for all 18 msgid_plural entries -- wrong for
ko, which declares nplurals=1.  Making it the only reader meant implementing
the rule properly: a recursive-descent evaluator over the grammar gettext
uses (?: || && == != < > <= >= + - * / % ! and parens, over n).

Total and bounded by construction: no allocation, division and modulo by zero
yield 0, an unparseable rule or an out-of-range form falls back to form 0.

tests/nls/test_plural.cpp covers it directly -- 43 cases over en, ko, fr, ru,
pl and ar rules plus div-by-zero, unbalanced parens, truncated ternaries,
garbage and out-of-range forms.  Reaching plural_eval() means including
mux_nls.cpp, the same way tests/dbt/test_interp.cpp reaches its file-static
mem_check.  Wired as `make test-nls-plural`.

Mutation-checked rather than assumed: flipping % to / in the evaluator fails
13 cases across ru/pl/ar while en and ko still pass, so the suite measures
the thing it is named after.

## A regression the matrix caught

Dropping libintl dropped its LANGUAGE support, and mo_language() had never
read that variable -- so LANGUAGE=ko, the recipe in mux/po/README.md, would
have silently stopped working on Unix.  mo_language() now reads gettext's
documented order (LANGUAGE, LC_ALL, LC_MESSAGES, LANG) and takes the first
entry of a colon list, which also gives Windows LANGUAGE support it never
had.

Verified, each row distinguishing a fix from a no-op:

    language ko, no LANGUAGE            Korean     <- the case that failed
    no directive, nothing set           English    <- no regression
    no directive, LANGUAGE=ko           Korean     <- env still works
    no directive, LANGUAGE=ko:fr        Korean     <- priority list
    language ko vs LANGUAGE=xx          Korean     <- directive outranks env
    language ko under LANG=C            Korean     <- impossible via libintl

tests/nls/run.sh gains the directive cases (7 total, from 5); confirmed both
fail when the directive is disabled while the pre-existing cases pass.

make test: Smoke 1561 x3, tests/nls 7 cases, ko 4 cases, plural 43 cases.
TESTEXIT=0.

Documented in wizhelp (& LANGUAGE) and mux/po/README.md.

Per-player locale is deliberately not addressed; the parameter form leaves
room for it without committing to a design.

Refs #1419, #1444, #1473, #1523, #1580, #1622, #1702.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:29:13 +00: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
2b2566bd60 nls: mark mail module + engine_com delivery notify prose with M_()
#1689 marked engine/mail remaining MAIL: sentences; the shipped mail
module (dual-impl default) and CMailDelivery in engine_com still spoke
those msgids via T(), so LANGUAGE=xx never hit the live path.

Mark player-facing MAIL:/URGENT MAIL: notifies and matching
fwd/re/alias prose already extractable. pot regenerated; xx complete;
ko msgmerge only.
2026-07-28 15:47:20 +00:00
Stephen Dennis
890d7f302d fix(comsys): the module's command surface, compared against the engine (#1640)
Four divergences from the issue, plus three more the probe found once the
fixtures stopped being plain ASCII.

From #1640:

  * @clist/full ignored the switch and printed the default listing, losing
    Header, Access, Users and Msgs -- the four columns the switch exists for.
  * @cwho printed a bare name where the engine prints unparse_object(), so
    staff lost the dbref and flags that identify WHICH object is on a channel.
  * comtitles reached no message at all.  The module stored them and
    round-tripped them through SyncChannelUser, then never read them back when
    composing one: not speech, not poses, not join/leave, and not spoof
    channels, where the comtitle is supposed to REPLACE the speaker's name.
  * delcom emitted the channel broadcast instead of the leaver's own
    confirmation -- which the leaver cannot even see, since clearing
    bConnected first is what suppresses it, so delcom looked like it had done
    nothing and any trigger matching "^You have left channel" stopped firing.

Comtitles are one fix, not four: BuildSpeakerPrefix mirrors the engine's
BuildChannelMessage, including that the LISTENER decides whether a comtitle
is shown, so SendChannelMessage now takes both variants and picks per
recipient.

Found while verifying, none of which an ASCII fixture can see:

  * The ENGINE conflated byte offset with column offset in @clist/full
    (comsys.cpp:2877), resetting iPos.m_column to the byte offset after
    writing JXR.  Those are equal only while every preceding field is plain
    ASCII; a colored channel header makes the byte count larger, PadField
    believes it is already past column 56, and the Users column shifts left.
  * The module stored channel headers with ANSI uncollapsed -- a raw strncpy
    where do_cheader runs StripTabsAndTruncate -- so `[ansi(r,RED)][ansi(b,BLU)]`
    persisted an extra reset between the codes.  A divergence in SQLite, not
    on screen, so it survived a handoff.  The same strncpy truncated at
    MAX_HEADER_LEN *bytes*, which can split a codepoint or a color code.
  * @clist/headers was missing the engine's pad to column 79.

Column layout in the module now goes through StripTabsAndTruncate/PadField
rather than printf field widths.  A printf precision counts codepoints and
knows nothing about PUA color; StripTabsAndTruncate carries a byte limit and
a column limit as a pair and reserves budget for the closing color sequence.
My first version of this fix hand-rolled the column stops with %-14.13s and
verified byte-identical output against the engine -- for ASCII only, which is
exactly the blind spot that produced two of the three findings above.  #1649
covers the general problem; nothing here waits on it.

@cwho deliberately reproduces the engine's composition including
strip_color(): @cwho discards color so its width arithmetic is honest while
@clist two functions away preserves it.  Both are coping strategies for the
primitive #1649 proposes, and this file's job is parity, not unilaterally
improving one side into a fresh divergence.

mux_IObjectInfo gains UnparseObject: the visibility rule is Examinable() plus
the CHOWN_OK/JUMP_OK/LINK_OK/DESTROY_OK/ABODE exceptions, which a module
cannot compute from GetFlags/DecodeFlags.  Caller-supplied buffer, matching
AtrGet, so nothing crosses the DLL boundary needing to be freed on the far
side.  CID_ObjectInfo is UseSameProcess only, so there is no proxy/stub to
update.

New harness, tests/comsys_cmdparity: one command stream, two fresh databases,
output diffed.  The two existing comsys harnesses compare state handoff and
delivery hooks; neither compares plain command output, and both scored green
against all seven of these.  Fixtures are deliberately colored and CJK.
Verified it goes red: reinstating the engine's PadField conflation fails case
3 while cases 1 and 2 still pass, which is the ASCII blind spot reproduced on
demand.

Windows Server 2022, MSVC 14.51, Release x64: three parity streams identical;
comsys_handoff 13/13; comsys_mogrify 5/5; smoke 1555 succeeded / 5 failed /
0 crashes, unchanged, the five being the pre-existing no-OpenSSL gap (#1641).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:36:25 -06:00
Stephen Dennis
4edea3c4d8 fix(comsys): let the module write engine-owned channel attributes (#1585, #1620)
Both bugs are one refusal.  The engine writes HISTORY_%d, MAX_LOG and
LOG_TIMESTAMPS on the channel object as GOD with AF_CONST, and
bCanSetAttr denies AF_CONST in every branch -- God included, which is
unlike every other flag it checks.  So the module's permission-checked
SetAttribute is refused, and whichever implementation did not write a
value first can never change it.

  #1585  a channel whose timestamps the engine enabled could not be
         turned off from the module, and before #1624 the module
         reported success anyway.

  #1620  the same refusal reaching HISTORY_%d once the ring wraps onto
         an engine-written slot.  num_messages is a column and keeps
         counting, so the counter and the history diverge -- #1564's
         symptom arriving by a second route.

mux_IComsysStorage gains SetChannelAttr: the module asks the ENGINE to
perform the write.  The engine owns the attribute layer and the
permission check, and here it is writing its own data rather than a
player's, so an attribute add is its operation to make.

Two alternatives were weighed and rejected:

  - Dropping AF_CONST from the five comsys writes is the smallest
    change, but then anyone controlling the channel object can forge
    recall history with a plain @set.  Channel logs stop being
    tamper-evident, which is what AF_CONST was buying.

  - Giving God an escape from AF_CONST in bCanSetAttr is one line and
    restores an invariant that holds for every other flag there, but it
    is a global permission change to fix one subsystem's problem.

Scope is deliberately narrow.  The channel is passed by NAME, so the
engine resolves the object itself and this cannot become "write any
attribute anywhere as GOD".  Player-owned attributes are untouched --
mail's Mailcurf/Mailfolders keep going through the permission-checked
mux_IAttributeAccess, which is the right path for anything a player
owns.

tests/comsys_handoff now passes with no TODO markers:

  ok 4 - module can clear a flag the engine set (#1585)
  ok 6 - module overwrites an engine-written history slot (#1620)
  === comsys handoff: 8 passed, 0 failed, 0 known-failing ===

The harness demanded this edit rather than allowing it: with the fix in
and the markers still present it reported "2 unexpectedly passing" and
failed the run, which is what those markers are for.

One assertion changed direction rather than being deleted.  #1624 added
a check that a REFUSED write was reported honestly instead of as
success; there is no refusal left to report, so it now checks that the
clear reports success -- because it now is one.  Asserting on the old
failure text would have failed for the right reason in the wrong
direction.

make test green: 1561/1561 on all three smoke routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:35:33 +00:00
Stephen Dennis
b1a20f1359 merge master: resolve LoadGame cf_read conflict with #1605
Keep both stderr (visible under muxscript) and STARTLOG, plus the
directory/fclose fixes and tests/config harness from this branch.
2026-07-27 18:01:51 +00:00
Stephen Dennis
305e576b2d fix(conf): make an unreadable configuration file fatal (#1601)
cf_read() returns -1 when the top-level config cannot be read, and its only
call site -- CGameEngine::LoadGame -- discarded that. Both callers therefore
took the success branch and the game came up on compiled-in defaults.

On netmux a mistyped -c path produced a live server: it bound the default
port 2860 and served a two-object database while the real one sat untouched,
having logged one CNF/NFND line between two INI/LOAD lines that read like
success. On muxscript it exited 0 and printed "loaded game from ...", so a
harness could not distinguish a green run against the intended database from
one against an empty default.

LoadGame now returns MUX_E_NOTFOUND, and both callers name the config file
rather than reporting only "LoadGame failed (-9)" -- this is the startup
failure most likely to be a simple typo.

Two cases stay deliberately non-fatal. An unrecognized directive still only
logs: games carry config files forward across releases, and cf_include
discards cf_set()'s per-line return, which is load-bearing rather than a
matching oversight. An empty file still succeeds, and is the supported way
to ask for the compiled-in defaults on purpose.

Also in cf_include, where fgets returns nothing:

  - fopen() succeeds on a directory on Linux and macOS and only the read
    fails, so `-c somedir` was indistinguishable from an empty file and was
    silently accepted. Checking ferror() separates the two.
  - the early return skipped the fclose the normal path does, leaking the
    handle on every empty include.

tests/config/run.sh covers all six corners, wired in as `make test-config`.
The good-config case asserts mud_name actually took effect rather than just
that the process exited 0 -- exit status alone cannot tell "read the config"
from "silently used defaults", which is the bug itself. Verified to fail
against the unfixed engine (2 of 6, the two non-fatal cases still passing).

make test green: 1560/1560 on both smoke routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:20:21 -06:00
Stephen Dennis
ae3e92eb07 fix(conf): a config file that cannot be opened is fatal (#1601)
cf_read() has always returned cf_include()'s -1 when the config file cannot
be opened, and its only call site -- in CGameEngine::LoadGame -- discarded
it.  LoadGame returned MUX_S_OK, and both callers took the success branch.

netmux is the serious victim: it booted and *listened*, on whatever
database the compiled-in defaults name rather than the one the operator
asked for.  Measured before this change, `netmux -c nosuch.conf` ran until
a 20-second timeout killed it.  After, it exits 2 in 30ms with no listener
opened.  muxscript printed "loaded game from" and exited 0, so a harness
could not distinguish a green run against the intended database from a
green run against an empty default one; it now exits 2 and prints no such
line.

-1 means exactly "could not open the file".  cf_include returns it only
for that and for being called outside configuration reading; an
unparseable *directive* inside a file that does exist returns 0 and stays
non-fatal.  Keeping that distinction is deliberate -- games carry old
config files naming directives this build no longer knows, and refusing to
boot over one would be a compatibility break, while failing to find the
file at all is never what anyone wanted.

Reported on stderr as well as through the log.  cf_log_notfound's STARTLOG
is not visible at this point in LoadGame for muxscript, which is half of
why this stayed hidden -- the diagnostic existed and nobody saw it.

Both callers already tested MUX_FAILED and refused, so propagating the
failure was the only missing link; neither caller needed changing.

  netmux   -c nosuch.conf   before: rc=124 (still listening at 20s)
                            after:  rc=2 in 30ms, no listener
  muxscript -c nosuch.conf  before: rc=0, "loaded game from"
                            after:  rc=2, no such line
  muxscript -c p.conf       unchanged: rc=0, "loaded game from"

Negative control: with the return value discarded again, netmux boots on
the nonexistent config and keeps running.

make test green, both smoke routes 1560/1560.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:17:01 -06:00
Stephen Dennis
7fc45bdcf5 fix(modules): say which comsys/mail implementation is live (#1581)
#1581's item 1: the silent fallback in discover_comsys_mail_modules.  Both
subsystems have two implementations that demonstrably disagree -- #1564 (the
module never wrote HISTORY_n), #1585 (LOG_TIMESTAMPS), #1572 (four MOGRIFY
hooks), #1587 (mail message size) -- and nothing said which one a server got.

Now, at startup:

    Comsys: using module implementation.
    Mail: using built-in engine implementation.

Both outcomes, not only the failure, because "which one am I running" is the
question and a silence that means success is still a silence.

## The log call is fprintf(stderr), and that is not laziness

I tried STARTLOG first, matching the failure paths already in that function.
No output.  Then Log.tinyprintf, matching the surrounding LoadGame code.  Also
no output -- and the control settles it: the neighbouring

    Log.tinyprintf(T("LOADING: %s" ENDLINE), mudconf.mail_db);

produces nothing either, in muxscript or through the smoke harness.

So there is no working log channel at this point in LoadGame, and the
consequence is worth recording: **the STARTLOG failure paths already in
discover_comsys_mail_modules have never been visible to anyone.**  Three
existing branches that report "using built-in" on module-init failure emit
nothing at all.  That is a second, quieter instance of the same defect #1581
is about.

fprintf(stderr) is what CSQLiteDB::MigrateSchema already uses from this same
phase, and it is what the harness captures.

## Answering the question #1581 could not

The issue establishes Windows behaviour and says: "What I could not determine
is what Linux does -- and that is the question this issue needs answered
first."  I cannot answer for Linux either, but macOS arm64 now reports, and
the answer is configuration-driven rather than platform-driven:

    bare config, no module directives   -> built-in engine, both subsystems
    module comsys_mod / module mail_mod -> module, both subsystems

So the modules do NOT load by default from game/bin; they load when asked for.
tools/Smoke:145-147 generates those directives, so the suite exercises the
module path here -- and tools/Makesmoke does not, so the upload phase
exercises the built-in.  The harness has been running both implementations all
along, in different phases.

Note the Windows finding in #1581 predates #1569, which fixed the missing
DCL_EXPORT on the module entry points -- the reason the DLLs exported nothing
and could not load.  Whether Windows now matches this wants re-measuring
there; this change makes that a one-line check.

Suite: 1560 passed / 0 failed / 320 of 320.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:21:54 -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
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
7a9bcea85d
Merge pull request #1415 from brazilofmux/fix/1411-sqlite-path-overflow
fix(engine): bound the .sqlite path derivation (#1411)
2026-07-26 19:26:52 -06: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
776dab8d1a fix(engine): bound the .sqlite path derivation (#1411)
Two sites derived the ".sqlite" sibling of mudconf.indb by open-coding
the same replace-or-append with strcpy/strcat, neither checking the
remaining capacity:

  mux/modules/engine/engine_com.cpp  (bMinDB RemoveFile)  SIZEOF_PATHNAME
  mux/modules/engine/attrcache.cpp   (cache_init)         LBUF_SIZE

input_database is cf_string_dyn with a maximum of SIZEOF_PATHNAME, so its
content can be SIZEOF_PATHNAME-1 characters.  ".sqlite" is seven more plus
a terminator, so the engine_com buffer overflows in both branches: the
append writes eight bytes past a full buffer, and even the ".db" branch
that replaces three characters with seven needs four bytes it may not
have.  attrcache was safe only because indb is capped well below
LBUF_SIZE -- an implicit cross-module invariant, not a check.

Replaced with one bounds-checked derive_sqlite_path() in sqlite_backend,
which both TUs already include.  It returns false and leaves the buffer
untouched when the result will not fit; callers log and take a defined
path -- cache_init returns HF_OPEN_STATUS_ERROR after releasing the
backend it had just allocated, and the bMinDB case skips the removal,
since a path that cannot be formed names no file to remove.  No new
abort: the failure is reported, not asserted.

Behaviour is unchanged for every input that fits, including the edge
cases the old `n > 3` test produced -- ".db" appends rather than
replaces, "a.dbx" and "db" append -- so this is not a silent change of
the derived name.

Verified with ASan on a standalone harness carrying both versions, an
input_database at exactly the configured maximum:

  old, no .db suffix  heap-buffer-overflow, WRITE of size 8
  old, .db suffix     heap-buffer-overflow, WRITE of size 8
  new, both           returns false, no diagnostic

and the normal cases: netmux.db -> netmux.sqlite, netmux ->
netmux.sqlite, data/netmux.db -> data/netmux.sqlite, plus the exact-fit
boundary accepted and one byte over rejected.

smoke 1489/1489 on both routes, 0 crashes, 314/314 dispatched.

Closes #1411.
2026-07-26 13:59:30 -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
bd8b1ba376 fix(c6/g2/f4): boolexp NUL, COM guards, muxescape/muxscript (#1294–#1296)
C6: always NUL-terminate oversize lock keys in parse_boolexp.
F4: null out-param and nValueMax==0 guards on IObjectInfo/IAttributeAccess.
G2: 16 MiB muxescape input cap; muxscript rejects invalid -p after MarkConnected.
Update audit-coverage and survey-boolexp for the residual scout.
2026-07-25 20:21:38 -06:00
Stephen Dennis
9649651ae5 fix(comsys): module gag/charge/BLOCK and join/who/dbck parity
#1194 transmit path:
- reject Gagged non-wizards
- collect channel charge via new IObjectInfo PayFor/GiveTo (engine payfor)
- evaluate MOGRIFY`BLOCK via IEvaluator EvalWithArgs before send

#1195 membership/visibility:
- MAX_USERS_PER_CHANNEL on join; bConnected from UNDEAD semantics
- suppress join/leave announce when Hidden
- comwho filters Hidden (WizardWho/SeeHidden) and lists objects
- load skips invalid who; dbck prunes invalid only (keep object listeners)

COM: bump IID_IObjectInfo and IID_IEvaluator for the new vtable slots.
Rebuild exp3/mail/comsys modules against the new IIDs.
2026-07-25 13:43:00 -06:00
Stephen Dennis
938ff1baa3 Merge master into fix/1191-dual-store (take master's #1192/#1203 text, keep bump_revision) 2026-07-25 09:23:04 -06:00
Stephen Dennis
fb6362eb61 fix(comsys/mail): activate modules and sync softcode with module store (#1191)
Root cause: comsys_mod/mail_mod lacked mux_CanUnloadNow, so ModuleLoad
never set bLoaded and CreateInstance always returned CLASSNOTAVAILABLE —
commands and softcode both used the engine path by accident.

With modules actually loaded:
- Bump module revision on every write-through; softcode reloads engine
  maps from SQLite when the revision advances (select_channel, mail_fetch,
  MailList, etc.).
- CComsysStorage::SyncChannel sets has_comsys so softcode reload works.
- Channel Sync uses ON CONFLICT DO UPDATE so updates do not CASCADE-wipe
  channel_users/player_channels.
- mailsend() goes through IMailControl::SoftcodeSend; bodies and
  mail_db_top are write-through for softcode mail_* fields.
2026-07-25 08:33:42 -06:00
Stephen Dennis
37203f637c Merge master; adopt sqlite_storage_db() for PutMeta; bump IID_IMailStorage
Review follow-through for #1203:
- Resolve the GetMeta conflict in master's favor (the #1202
  sqlite_storage_db() null-guard refactor) and convert the new
  PutMeta to the same shape instead of an inline backend check.
- Bump IID_IMailStorage (..F809 -> ..F80A): PutMeta is a mid-interface
  vtable insertion, so a stale mail_mod.so against a newer engine would
  mis-dispatch every later slot (InsertMailHeader landing in PutMeta)
  rather than fail cleanly.  With the bump, a mismatched component
  fails mux_CreateInstance discovery instead -- the #817 pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 08:31:08 -06:00
Stephen Dennis
bf1146156c fix(mail): write-through module mail bodies and mail_db_top (#1192)
Call sqlite_wt_mail_body from new_mail_message so live module mail
persists bodies. Add PutMeta to IMailStorage and update mail_db_top so
the loader gate matches engine MessageAdd (#783 parity). Load path still
skips WT via m_bLoading.
2026-07-25 08:04:59 -06:00
Stephen Dennis
d50890bb3d fix(engine): init comsys/mail modules after SQLite is open (#1190)
Discover and Initialize comsys_mod/mail_mod only at the end of LoadGame,
after init_dbfile and aux loads. On storage or Initialize failure, Release
the control pointer so built-in paths remain active. Null-guard all
CComsysStorage/CMailStorage methods against a closed backend.
2026-07-25 08:00:15 -06:00
Stephen Dennis
47c30b64d7 fix: resolve remaining Pass 6 High and Medium defects
Highs:
- #1126: zero gmcp_enabled (and charset_request_pending) on DESC alloc
  and freeqs so pooled DESCs cannot retain residual GMCP
- #1128: enable_us/disable_us consult us_state (RFC 1143), not him_state

Mediums:
- #1130: install SIGXFSZ under #ifdef SIGXFSZ (not SIGFSZ)
- #1131: SB full resets NVT to Normal; allow complete SB when q==qend;
  preserve nOption across reads when buffer is full
- #1132: CHARSET last-token length omits -1 only when ending at buffer end
- #1133: refuse SiteMon passes DESC so HI_NOSITEMON applies
- #1134: queue_write_LEN skips drop-oldest when output_limit <= 0 (WS parity)
- #1135: refuse accept when peer sockaddr cannot be established
- #1136: DumpChildExited ignores non-dumper PIDs once dumper is known

netmux + engine.so build clean.
2026-07-24 21:19:45 -06:00
Stephen Dennis
4f99e6b63f fix: resolve Pass 3 Medium audit defects #1081–#1085
WebSocket (#1081–#1083):
- Require client mask bit (RFC 6455 §5.1); unit test added
- Handshake reject flushes + closes; SHA-1 failure is fatal 500
- ws_queue_frame enforces output_limit drop-oldest on framed size

comsys_mod (#1084):
- IPermissions::CouldDoit; join/transmit/receive OR channel locks

conf (#1085):
- cf_bool/cf_option/cf_string live-sync g_dc; site_chars live int

Smoke 1319/1319; websocket_test 14/14.
2026-07-24 13:18:24 -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
197095b778 feat(net): per-source connection-rate limit (connect/disconnect churn)
The Rhost item our own follow-up list skipped, and the vector was genuinely
uncovered. max_preauth_sitecons bounds how many pre-auth connections are held
AT ONCE; login_fail_limit bounds FAILED LOGINS. An attacker who connects and
immediately disconnects -- never logging in, never failing a login -- is
touched by neither, while every cycle still costs an accept, a DESC, the
welcome screen's file dump, the site checks and a log line.

  max_lastsite_cnt  default 40, 0 = off  -- connections per source per window
  min_con_attempt   default 60 seconds   -- window over which it refills

Both names are Rhost's. Checked in the GANL accept path before the pre-auth
cap (it is the cheaper test). Only ACCEPTED connections are charged: a refused
attempt costs the attacker nothing extra, but charging it would hold a shared
address at zero for as long as one attacker kept trying, starving the
legitimate users behind it of the refill.

Two deliberate differences from Rhost:

  * The response is a transient refusal, not their lastsite_paranoia
    auto-register/auto-forbid. A permanent sitelock earned by a burst is
    precisely the wrong answer on a shared address: one abuser in a dorm would
    lock out the whole building until an admin undid it by hand. A refusal
    that heals as the bucket refills costs a legitimate player seconds. Admins
    wanting the permanent form already have forbid_site, now with graduated
    thresholds. We therefore ship no lastsite_paranoia knob at all rather than
    one whose values mean something different from theirs.

  * The counting is a true per-source bucket. Rhost keeps a single "last site"
    slot, so only CONSECUTIVE connections from one address count and one
    interleaved connection from anywhere else resets the counter -- trivially
    walked around by alternating two addresses. Keyed per source (v6 by /64),
    ours cannot be reset that way.

Default is 40/60 rather than Rhost's 20/60 because ours counts strictly harder
AND is on by default where theirs is gated behind lastsite_paranoia 0. 40/60
still cuts churn from unlimited to 40/minute -- three orders of magnitude --
while clearing the burst that matters: a whole dorm reconnecting after a
reboot. Verified 30 connect-and-login cycles from one address at the shipping
default: 30 accepted, 0 refused.

The failed-login bucket was generalized rather than copied: one source_bucket
mechanism (fixed 512-slot table, lazy refill, least-suspicious eviction, /64
v6 keying) now backs both g_login_fail and g_connect_rate.

Verified at max_lastsite_cnt 10 / min_con_attempt 60 with the pre-auth cap and
login-fail throttle both DISABLED -- proving this defense alone catches it --
pure connect/disconnect churn ran 11 cycles then was refused, NET/RATE damped
by nospam_connect to one line. At 5/10s, 5 of 12 rapid cycles refused and a
connection 8s later succeeded. Regression at the shipping default: smoke
1319/1319, stress 8/8, netaddr 57/57, ganl 14/14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:43:52 -06:00
Stephen Dennis
68331075a4 feat(net): damp connection-refusal logging (nospam_connect); adopt Rhost naming
Surveyed RhostMUSH at ce5226ff for prior art on connection/login DoS defenses.
Two results change this work.

1. Refusal logging is itself a DoS vector, and we had missed it.

Refusing a connection still costs a log write, so a flood we successfully
refuse fills a disk instead. Rhost's nospam_connect exists solely for this and
their help text names it outright ("Real twinkish players may try multiple
connects to overload a log file"). Both of our new defenses -- the pre-auth
cap and the failed-login throttle -- logged every single refusal, converting a
connection flood into a disk flood.

Adopt their parameter by name and semantics:

  nospam_connect  0 = log every refusal
                  1 = log the first of a consecutive run from one address,
                      then one summary line when the run ends (default)
                  2 = do not log refusals

Two deliberate differences from Rhost:

  * Default 1, not their 0. The collapse loses no signal -- first line plus an
    exact count -- so there is no reason to ship the hole open.

  * Flushed on the periodic idle sweep, not only when a run ends. Rhost's
    refusals happen at accept, so an accepted connection ends the run. Ours
    also refuse at LOGIN, and every login attempt arrives on a freshly
    accepted socket -- flushing on accept would end the run before every
    single refusal and defeat the damping entirely. Flushing on the sweep also
    bounds refusal logging by TIME rather than by the attacker's rate, which
    is the property actually wanted: a sustained attacker produces neither a
    different address nor a successful login, so the count would otherwise sit
    unreported for the whole attack.

State is one address slot plus a counter, as Rhost does it -- the anti-flood
measure cannot itself be flooded.

2. Match Rhost's configuration vocabulary where the knob is the same thing.

nospam_connect is adopted verbatim. max_preauth_per_site is renamed to
max_preauth_sitecons to sit in Rhost's max_sitecons family -- but deliberately
NOT named max_sitecons, because theirs caps ALL connections from a site and
ours caps only unauthenticated ones. An identical name would be a false friend
that reads as configured while behaving differently.

The survey also validates two earlier decisions: Rhost has no per-account
lockout anywhere and no per-IP failed-login throttle either, and every Rhost
auto-generated ACL entry keys IPv6 on the full /128 address against a list with
no cap or expiry -- exactly the evade-and-flood hole our /64 source_key avoids.

Verified: 9 consecutive refusals collapse to 1 full line plus a periodic
summary ("[127.0.0.1] Connection refused [total 4 more times]."), and the
renamed knob still refuses correctly. Regression: smoke 1319/1319, stress 8/8,
netaddr 46/46, ganl 14/14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 12:20:11 -06:00
Stephen Dennis
429a7eb61b feat(net): per-source failed-login throttle
retry_limit (3) is per-SOCKET: after three bad passwords the connection
closes and the attacker reconnects for three more. Nothing remembered
anything across connections, so brute-force-by-reconnect was unbounded.
Add that memory as a token bucket per source address, consumed only by
failed connect attempts:

  login_fail_limit   default 10, 0 = off  -- burst of failed logins per source
  login_fail_period  default 60 seconds   -- interval over which it refills

Sustained rate is limit/period, so the default is 10/minute against a
previously unlimited rate. Checked in check_connect BEFORE ConnectPlayer, so
a throttled source also stops costing a password hash per guess. Guests are
exempt (fixed password, separately bounded by the guest pool). On refusal the
socket is left open and retries_left untouched -- the attempt never reached a
password check, so it is not a failed login -- and conn_timeout still reaps an
idle one.

Two shapes of this defense are actively harmful in a MUSH and are not used:

  * Per-ACCOUNT lockout. Player names are public (WHO, in-game, the
    directory), so anyone could lock any player -- including a wizard -- out
    of their own game by spamming failures at their name. That trades a
    brute-force risk for a guaranteed griefing tool.

  * A delay before answering a failed login. This server is single-threaded;
    sleeping to slow one attacker stops the world for every other player. The
    throttle must be non-blocking, so it refuses rather than stalls.

Keying: IPv6 by /64, not by address. One IPv6 customer normally holds a whole
/64, so a single host can source 2**64 addresses -- keying on the full address
would let one attacker both evade the throttle and flood the table with
single-use entries. mux_sockaddr::source_key() returns the 4-byte v4 address
or the 8-byte v6 /64 prefix; differing lengths keep the families from
colliding.

The table must not become the resource it protects: a fixed 512-slot array
scanned linearly, no allocation and no growth, consulted only on login
attempts (already bounded by max_preauth_per_site). When full, eviction takes
the LEAST suspicious entry (fullest bucket, oldest as tie-break) so table
pressure never costs us the record of an active attacker.

The dorm/NAT cost is real and deliberate: an exhausted bucket briefly refuses
legitimate players from a shared address, including ones typing the correct
password. It is bounded (continuous refill; seconds, not a lockout), the
default is generous relative to how often real players mistype, and admins can
widen or disable it. There is deliberately no "this source already has an
authenticated session" exemption -- it would read as dorm-friendly while
handing a full bypass to an existing player going after someone else's account.

Verified on a live netmux at limit 3 / period 600s: guesses 1-3 rejected
normally, 4-6 refused with the wait message, each from a FRESH connection --
reconnecting no longer buys a fresh batch. At limit 5 / period 20s the budget
demonstrably refills. Both the new CON/THR line and the earlier NET/SITE
pre-auth line confirmed to emit. Regression: smoke 1319/1319, stress 8/8,
netaddr 46/46 (+7 source_key tests), ganl 14/14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:46:41 -06:00
Stephen Dennis
04fb016340 feat(net): per-source cap on pre-authenticated connections
Multi-connection is normal in MUSH -- a dorm or NAT puts many unrelated
players behind one address, households share one, and a single player
commonly sits on five or more alts at once -- so a per-IP cap on *total*
connections would break real play. That is why 36 years of TinyMUX shipped
only allow/deny site ACLs and never a per-IP count.

What is not normal is many connections from one address sitting at the login
prompt. Legitimate multi-play authenticates promptly; a slowloris holding
half-open sockets to exhaust the descriptor table never authenticates at all.
So count only connections without DS_CONNECTED.

  max_preauth_per_site  (default 2, 0 = unlimited, CA_GOD / CA_WIZARD)

Checked in the GANL accept path just before the new DESC joins
g_descriptors_list, so a refused connection never occupies a descriptor slot.
Refusal writes a short "try again in a moment" line raw via SOCKET_WRITE --
the route fcache_rawdump uses -- because the DESC is torn down immediately
and the normal output queue would never flush; it logs under
LOG_NET|LOG_SECURITY.

mux_sockaddr::operator== includes the source port, which differs for every
connection from one peer, so add mux_sockaddr::same_address() for family +
address-bytes equality. Cross-family (v4 vs v4-mapped v6) deliberately does
not unify: it can at most double a hostile client's allowance, and both forms
of one peer cannot arrive on the same listener.

Self-healing by construction -- a slot frees the instant a peer authenticates
or conn_timeout (120s) reaps it -- so a legitimate collision costs one retry,
never a lockout.

Verified with the default of 2: a third pre-auth socket from 127.0.0.1 is
refused with the explanatory message; authenticating a pending connection
immediately frees a slot; and 12 authenticated sessions from that same single
address were all accepted, leaving the dorm / household / five-alts case
untouched. Regression: smoke 1319/1319, stress 8/8 (its 16 simultaneous
logins from one address still pass), netaddr 39/39 (+6 new same_address
tests), ganl 14/14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:14:51 -06:00
Stephen Dennis
ad613478d4 feat(net): own input_size in save_command + anti-runaway input backlog cap
Hardening: the per-connection pending-input backlog (docs/survey-
resource-defenses.md).  Two parts, informed by measurement.

Correctness (the real fix): save_command now owns d->input_size,
incrementing it per enqueued line by the exact byte length it queues.
Previously telnet.cpp incremented it in a batch (nInputBytes) while
the dequeue decremented by cmd.size() — a latent drift — and the
websocket save_command path never touched it at all.  Ownership in
the single choke point keeps the counter exactly in step with the
dequeue and covers telnet + websocket uniformly.

Anti-runaway backstop: new input_limit config (default 16*LBUF =
512KB, <=0 disables) caps per-connection pending-input bytes; excess
is dropped with input_lost accounting and hysteresis on
d->input_throttled to keep a flood from spamming the log (which would
just move the amplification into logging).

Deliberately a HIGH backstop, not a primary throttle: input is
drop-sensitive (a dropped line silently corrupts a user's paste — a
code attribute is one legit ~32KB line; @edit/multi-attribute uploads
are legit bursts), unlike output.  Measurement shows the app queue
does not grow unbounded anyway — the single-threaded read/drain
cadence + TCP flow control bound it (input_size peaked at ~44 bytes
under a sustained flood with the drain throttled to command_quota 1).
The proper form is read-side backpressure (stop reading when
backlogged; TCP holds the excess with zero loss), deferred.

Verified: two back-to-back 30KB attribute pastes store intact with
zero drops; smoke 1319/1319; stress harness 8/8; server responsive
throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:57:22 -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
cfd9cd0f76 dbload: clear comsys/mail on a forced game load (#783)
A flatfile game load replaces the database's game, but the comsys and
mail tables were only cleared when -C/-m were passed -- so
"db_load -f netmux new.flat" produced a database whose channels and
mail belonged to the PREVIOUS game, referencing dbrefs in the new one.
This scenario was impossible before -f existed (the only supported
path was deleting the whole .sqlite, which removed comsys/mail too).

Clear both table sets after any flatfile game load; the -C/-m imports
that follow repopulate them when flatfiles are supplied (verified:
export + forced reload with -C/-m round-trips channels and mail).

Adjacent fixes from the same review:

- The "already contains a game" refusal fired on ANY pre-existing
  file (HF_OPEN_STATUS_OLD only means the file existed) -- including
  the empty shell that running db_unload against a never-imported
  game creates as a side effect.  Probe the db_top metadata and only
  refuse when the file actually holds a game; loading into an empty
  shell proceeds without -f.

- db_load now accepts -f anywhere among the options, not just as the
  first argument.

- The every-warm-boot log line advising "Remove the SQLite database
  to reload from a flatfile" invited destroying all changes since the
  seed flatfile (the .sqlite is authoritative; the flatfile is never
  refreshed).  It now recommends exporting with db_unload first, then
  db_load -f.

Verified end-to-end: refusal with a real game and no -f; forced load
clears channels/channel_users/mail_bodies/mail_headers and the new
game's marker replaces the old one; empty-shell load proceeds without
-f; -C/-m repopulate after the clear.  smoke 1115/0/0, 200 jit_diff
clean, tests/libmux 51/0.

Closes #783.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:00:33 -06:00
Stephen Dennis
ecae4df9de dbload: mistake-proof the SQLite import path (#766)
Migrating an existing game to the SQLite backend had two silent traps that
both surfaced as "old passwords/characters don't work":

  1. dbconvert resolves <basename>.sqlite relative to cwd, but the server
     reads data/<name>.sqlite. Running db_load from the wrong directory put
     the database where the server never looks, so old characters appeared
     to vanish while a fresh login still worked against the stock db.
  2. A populated netmux.sqlite silently shadows the netmux.db flatfile at
     boot, so dropping in an old flatfile did nothing.

Changes:
  - db_load/db_unload are now cwd-independent: the .sqlite always lands in
    the game's data/ dir (next to the script), file args are resolved
    against the caller's dir, and the scripts echo the absolute path. Arg
    handling is space-safe (set --) and POSIX sh.
  - dbconvert prints the exact database file it opened (CSQLiteDB::GetPath).
  - The "would overwrite" guard now names the file and gives two ways
    forward; a new -f/force option lets a load replace an existing db (the
    load already clears attributes/objects/attr-names cleanly).
  - The server logs a line when it warm-starts from SQLite and the flatfile
    was not consulted, making the precedence visible.
  - New docs/importing-a-game.md documents the two-file model and import
    steps.

Verified: db_load from an unrelated dir lands the .sqlite in data/; the
guard refuses without -f and replaces with -f; db_unload round-trip is
byte-identical (passwords preserved). Smoke: 1078 ok / 0 new failures
(TC001/TC009 pre-existing on master).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:27:43 -05:00
Stephen Dennis
9aa4566647 Make smoke harness self-terminate and detect dropped tests
The suite runs under CLI muxscript via a serial semaphore chain, but
relied on a leftover @shutdown to stop the process.  @shutdown raced the
command queue and silently dropped a nondeterministic, platform-sensitive
tail of tests behind an "ALL PASSED" banner.

- CGameEngine::WhenNext now returns MUX_E_NOTFOUND when the scheduler is
  empty instead of always MUX_S_OK (also fixes an unset-timeout misread
  in netmux's ganl idle loop, which had used a zero-initialized time).
- New CScheduler::HasPendingUserTasks(): muxscript exits on stdin EOF once
  only recurring system maintenance (dump/idle/keepalive) and parked
  semaphore tasks remain, while still honoring delayed @wait tasks.
- shutdown.mux no longer calls @shutdown; muxscript self-terminates.
- mux_main attempts the stdin read regardless of poll()'s verdict, since
  macOS poll() never flags /dev/null readable and EOF went undetected.
- smoke.mux logs SUITE-EXPECTED/SUITE-DISPATCH and tools/Smoke asserts
  every expected test dispatched, naming any that did not.

This surfaced ~140 tests that were being silently skipped, including the
strlen CJK grapheme cases fixed in the previous commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:11:13 -05: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
890470f03b Implement tiered storage Stage 1: configurable cache size and depth preload
Story 1a — cache_max_size with K/M/G suffixes, -1 for unlimited, default
raised from 1 MB to 256 MB. @list cache shows human-readable sizes.

Story 1b — cache_preload_depth controls BFS depth for preloading adjacent
rooms. Unlimited mode: GetAll for player/location, GetBuiltin across BFS
neighbors. Bounded mode: GetBuiltin for player/location only, no BFS.
Move path preloads destination synchronously, defers BFS neighbors via
scheduler to avoid latency on look_in().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:51:01 -06:00
Stephen Dennis
03d017fc20 Implement Phase 4 routing: @walk and @patrol NPC primitives
Add server-side @walk <npc>=<destination> that moves an object one hop
per second along the routed path until arrival, and @patrol <npc>=<rooms>
for continuous waypoint loop movement. Both use the system scheduler
(DeferTask self-rescheduling, same pattern as @cron) and call move_exit()
for full movement side effects.

New route_next_exit() C-level API returns the next-hop exit dbref
directly, avoiding text format/parse overhead for the walk subsystem.

Switches: /stop cancels, /quiet suppresses notifications, /locked
validates exit locks during movement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:54:51 -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
1814aabf1e Harden restart-file loading, signal globals, and COM error paths
- load_restart_db(): clamp all memcpy into fixed-size fields (addr,
  doing, username, output_prefix, output_suffix) to destination size;
  validate num_main_game_ports against MAX_LISTEN_PORTS; replace
  mux_assert(0) with graceful close-and-return; check fgets() return.
- Signal globals: g_shutdown_flag and g_panicking changed from bool to
  volatile sig_atomic_t; slave.cpp counters likewise.
- COM modules: mail, comsys, and exp3 FinalConstruct() now check every
  mux_CreateInstance() return value and bail early on failure.
- engine_com.cpp: log storage interface creation failures for comsys
  and mail instead of silently continuing.
- dowin32.sh: quote dirname arguments to prevent word splitting.
- Update ISSUES.md trackers to reflect fixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:23:30 -06:00
Stephen Dennis
ceca4a53dc Sync function aliases into JIT lookup after config load
function_alias entries from alias.conf (parse→iter, ufun→u, etc.)
were invisible to engine_api_lookup, causing the JIT to bail to AST
for any aliased function name. engine_api_sync_aliases() now walks
mudstate.builtin_functions after cf_read() and adds alias names to
the hash map, sharing the same index as their canonical function.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:35:21 -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