tinymux/mux/modules/engine/speech.cpp

1884 lines
48 KiB
C++
Raw Permalink Normal View History

/*! \file speech.cpp
* \brief Commands which involve speaking.
*
*/
#include "copyright.h"
#include "autoconf.h"
#include "config.h"
#include "externs.h"
2022-03-13 14:06:30 -06:00
UTF8 *modSpeech(dbref player, const UTF8 *message, bool bWhich, const UTF8 *command)
{
dbref aowner;
int aflags;
LBuf mod = LBuf_Adopt(atr_get("modSpeech.25", player, bWhich ? A_SPEECHMOD : A_SAYSTRING,
&aowner, &aflags));
if ( mod[0] == '\0'
|| alarm_clock.alarmed)
{
2018-10-03 17:54:51 +00:00
return nullptr;
}
UTF8 *new_message = alloc_lbuf("modspeech");
UTF8 *t_ptr = new_message;
if ((aflags & AF_NOEVAL) || NoEval(player))
{
mux_strncpy(new_message, mod, LBUF_SIZE-1);
t_ptr = new_message + strlen((const char *)new_message);
}
else
{
const UTF8 *args[2];
args[0] = message;
args[1] = command;
mux_exec(mod, LBUF_SIZE-1, new_message, &t_ptr, player, player, player,
AttrTrace(aflags, EV_FCHECK|EV_EVAL|EV_TOP), args, 2);
}
*t_ptr = '\0';
return new_message;
}
static int64_t idle_timeout_val(dbref player)
{
// If IDLETIMEOUT attribute is not present, the value
// returned will be zero.
//
// int64_t, not int (#1402): attribute-sourced seconds stay full width.
//
dbref aowner;
int aflags;
LBuf ITbuffer = LBuf_Adopt(atr_get("idle_timeout_val.53", player, A_IDLETMOUT, &aowner, &aflags));
return mux_atoi64(ITbuffer);
}
static bool sp_ok(dbref player)
{
if ( Gagged(player)
&& !Wizard(player))
{
notify(player, M_("Sorry. Gagged players cannot speak."));
return false;
}
if (!mudconf.robot_speak)
{
if (Robot(player) && !Controls(player, Location(player)))
{
notify(player, M_("Sorry, robots may not speak in public."));
return false;
}
}
if (Auditorium(Location(player)))
{
if (!could_doit(player, Location(player), A_LSPEECH))
{
notify(player, M_("Sorry, you may not speak in this place."));
return false;
}
}
return true;
}
void do_think(dbref executor, dbref caller, dbref enactor, int eval, int key,
UTF8 *message, const UTF8 *cargs[], int ncargs)
{
UNUSED_PARAMETER(key);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
UTF8 *bp;
LBuf buf = LBuf_Src("do_think");
bp = buf;
mux_exec(message, LBUF_SIZE-1, buf, &bp, executor, caller, enactor, eval|EV_FCHECK|EV_EVAL|EV_TOP,
2018-10-03 17:54:51 +00:00
nullptr, 0);
*bp = '\0';
notify(executor, buf);
}
void do_say(dbref executor, dbref caller, dbref enactor, int eval, int key, UTF8 *message, const UTF8 *cargs[], int ncargs)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
// Make sure speaker is somewhere if speaking in a place
//
dbref loc = where_is(executor);
if ( !( Good_obj(loc)
&& sp_ok(executor)))
{
return;
}
int say_flags, depth;
// Convert prefix-coded messages into the normal type
//
say_flags = key & (SAY_NOEVAL | SAY_HERE | SAY_ROOM | SAY_HTML);
key &= ~(SAY_NOEVAL | SAY_HERE | SAY_ROOM | SAY_HTML);
if (key == SAY_PREFIX)
{
switch (message[0])
{
case '"':
message++;
key = SAY_SAY;
break;
case ':':
message++;
if (*message == ' ')
{
message++;
key = SAY_POSE_NOSPC;
}
else
{
key = SAY_POSE;
}
break;
case ';':
message++;
key = SAY_POSE_NOSPC;
break;
case 0xE2:
if ( 0x80 == message[1]
&& 0x9C == message[2])
{
// U+201C - Unicode version of opening double quote.
//
message += 3;
key = SAY_SAY;
}
else
{
key = SAY_EMIT;
}
break;
case '\\':
message++;
// FALLTHROUGH
//
default:
key = SAY_EMIT;
break;
}
}
const UTF8 *command = T("");
if (SAY_SAY == key)
{
command = T("say");
}
else if (SAY_POSE == key || SAY_POSE_NOSPC == key)
{
command = T("pose");
}
else if (SAY_EMIT == key)
{
command = T("@emit");
}
// Parse speechmod if present.
//
UTF8 *messageOrig = message;
2018-10-03 17:54:51 +00:00
UTF8 *messageNew = nullptr;
if (!(say_flags & SAY_NOEVAL))
{
messageNew = modSpeech(executor, message, true, command);
if (messageNew)
{
message = messageNew;
}
}
// Send the message on its way
//
UTF8 *saystring;
switch (key)
{
case SAY_SAY:
saystring = modSpeech(executor, messageOrig, false, command);
if (saystring)
{
notify_saypose(executor, tprintf(M_("%s %s “%s”"),
Moniker(executor), saystring, message));
#ifdef REALITY_LVLS
notify_except_rlevel(loc, executor, executor, tprintf(M_("%s %s “%s”"), Moniker(executor), saystring, message), MSG_SAYPOSE);
#else
notify_except(loc, executor, executor, tprintf(M_("%s %s “%s”"), Moniker(executor), saystring, message), MSG_SAYPOSE);
#endif
free_lbuf(saystring);
}
else
{
notify_saypose(executor, tprintf(M_("You say, “%s”"), message));
#ifdef REALITY_LVLS
notify_except_rlevel(loc, executor, executor, tprintf(M_("%s says, “%s”"), Moniker(executor), message), MSG_SAYPOSE);
#else
notify_except(loc, executor, executor, tprintf(M_("%s says, “%s”"), Moniker(executor), message), MSG_SAYPOSE);
#endif
}
break;
case SAY_POSE:
#ifdef REALITY_LVLS
notify_except_rlevel(loc, executor, -1, tprintf(M_("%s %s"), Moniker(executor), message), MSG_SAYPOSE);
#else
notify_all_from_inside_saypose(loc, executor, tprintf(M_("%s %s"), Moniker(executor), message));
#endif
break;
case SAY_POSE_NOSPC:
#ifdef REALITY_LVLS
notify_except_rlevel(loc, executor, -1, tprintf(M_("%s%s"), Moniker(executor), message), MSG_SAYPOSE);
#else
notify_all_from_inside_saypose(loc, executor, tprintf(M_("%s%s"), Moniker(executor), message));
#endif
break;
case SAY_EMIT:
if ( (say_flags & SAY_HERE)
|| (say_flags & SAY_HTML)
|| !say_flags)
{
if (say_flags & SAY_HTML)
{
notify_all_from_inside_html(loc, executor, message);
}
else
{
#ifdef REALITY_LVLS
notify_except_rlevel(loc, executor, -1, message, SAY_EMIT);
#else
notify_all_from_inside(loc, executor, message);
#endif
}
}
if (say_flags & SAY_ROOM)
{
if ( isRoom(loc)
&& (say_flags & SAY_HERE))
{
if (messageNew)
{
free_lbuf(messageNew);
}
return;
}
for (depth = 0; !isRoom(loc) && (depth < 20); depth++)
{
loc = Location(loc);
if ( !Good_obj(loc)
|| (loc == Location(loc)))
{
if (messageNew)
{
free_lbuf(messageNew);
}
return;
}
}
if (isRoom(loc))
{
#ifdef REALITY_LVLS
notify_except_rlevel(loc, executor, -1, message, -1);
#else
notify_all_from_inside(loc, executor, message);
#endif
}
}
break;
}
if (messageNew)
{
free_lbuf(messageNew);
}
}
struct wall_broadcast_context
{
int target;
dbref player;
UTF8 *message;
};
static void wall_broadcast_callback(dbref connected_player, void *ctx)
{
wall_broadcast_context *wbc = static_cast<wall_broadcast_context *>(ctx);
switch (wbc->target)
{
case SHOUT_WIZARD:
if (Wizard(connected_player))
{
notify_with_cause(connected_player, wbc->player, wbc->message);
}
break;
case SHOUT_ADMIN:
if (WizRoy(connected_player))
{
notify_with_cause(connected_player, wbc->player, wbc->message);
}
break;
default:
notify_with_cause(connected_player, wbc->player, wbc->message);
break;
}
}
static void wall_broadcast(int target, dbref player, UTF8 *message)
{
wall_broadcast_context ctx = { target, player, message };
for_each_connected_player(wall_broadcast_callback, &ctx);
}
static const UTF8 *announce_msg = T("Announcement: ");
static const UTF8 *broadcast_msg = T("Broadcast: ");
static const UTF8 *admin_msg = T("Admin: ");
void do_shout(dbref executor, dbref caller, dbref enactor, int eval, int key,
UTF8 *message, const UTF8 *cargs[], int ncargs)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
2018-10-03 17:54:51 +00:00
UTF8 *p = nullptr, *messageNew = nullptr, *buf2 = nullptr, *bp = nullptr;
bool bNoTag = (key & SHOUT_NOTAG) ? true : false;
bool bEmit = (key & SHOUT_EMIT) ? true : false;
bool bPose = (key & SHOUT_POSE) ? true : bEmit;
bool bSpace = !bEmit;
key &= ~(SHOUT_NOTAG | SHOUT_POSE | SHOUT_EMIT);
static const UTF8 *prefix, *loghead, *logtext1, *logsay, *saystring;
if (key & SHOUT_ADMIN)
{
key = SHOUT_ADMIN; // @wall/wiz/admin is treated as @wall/admin
prefix = admin_msg;
loghead = T("ASHOUT");
logtext1 = T(" ADMIN");
logsay = T(" yells: ");
saystring = T("says, “");
}
else if (key & SHOUT_WIZARD)
{
prefix = broadcast_msg;
loghead = T("BCAST");
logtext1 = T(" WIZ");
logsay = T(" broadcasts: ");
saystring = T("says, “");
}
else
{
prefix = announce_msg;
loghead = T("SHOUT");
logtext1 = T(" WALL");
logsay = T(" shouts: ");
saystring = T("shouts, “");
}
if (bNoTag)
{
prefix = T("");
}
if (!bPose)
{
switch (*message)
{
case ';':
bSpace = false;
// FALL THROUGH
case ':':
bPose = true;
// FALL THROUGH
case '"':
message++;
break;
}
}
// Parse speechmod if present.
//
messageNew = modSpeech(executor, message, true, T("@wall"));
if (messageNew)
{
message = messageNew;
}
if (!bPose)
{
buf2 = alloc_lbuf("do_shout");
bp = buf2;
safe_str(saystring, buf2, &bp);
safe_str(message, buf2, &bp);
safe_str(T(""), buf2, &bp);
*bp = '\0';
}
p = tprintf(M_("%s%s%s%s"), prefix, bEmit ? T("") : Moniker(executor),
bSpace ? T(" ") : T(""), bPose ? reinterpret_cast<UTF8 *>(message) : reinterpret_cast<UTF8 *>(buf2));
wall_broadcast(key, executor, p);
if (!bPose)
{
free_lbuf(buf2);
}
STARTLOG(LOG_SHOUTS, "WIZ", loghead);
log_name(executor);
if (bEmit)
{
log_text(logtext1);
log_text(T("emits: "));
}
else if (bPose)
{
log_text(logtext1);
log_text(T("poses: "));
}
else
{
log_text(logsay);
}
log_text(message);
ENDLOG;
if (messageNew)
{
free_lbuf(messageNew);
}
}
/* ---------------------------------------------------------------------------
* do_page: Handle the page command.
* Page-pose code from shadow@prelude.cc.purdue.
*/
static void page_return(dbref player, dbref target, const UTF8 *tag,
int anum, const UTF8 *dflt)
{
if (alarm_clock.alarmed)
{
return;
}
dbref aowner;
int aflags;
UTF8 *bp;
LBuf str = LBuf_Adopt(atr_pget(target, anum, &aowner, &aflags));
if (*str.get())
{
LBuf str2 = LBuf_Src("page_return");
bp = str2;
mux_exec(str, LBUF_SIZE-1, str2, &bp, target, player, player,
AttrTrace(aflags, EV_FCHECK|EV_EVAL|EV_TOP|EV_NO_LOCATION),
2018-10-03 17:54:51 +00:00
nullptr, 0);
*bp = '\0';
if (*str2.get())
{
CLinearTimeAbsolute ltaNow;
ltaNow.GetLocal();
FIELDEDTIME ft;
ltaNow.ReturnFields(&ft);
UTF8 *p = tprintf(M_("%s message from %s: %s"), tag,
Moniker(target), str2.get());
notify_with_cause_ooc(player, target, p, MSG_SRC_PAGE);
p = tprintf(M_("[%d:%02d] %s message sent to %s."), ft.iHour,
ft.iMinute, tag, Moniker(player));
notify_with_cause_ooc(target, player, p, MSG_SRC_PAGE);
}
}
else if (dflt && *dflt)
{
notify_with_cause_ooc(player, target, dflt, MSG_SRC_PAGE);
}
}
static bool page_check(dbref player, dbref target)
{
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
// Validate first and charge last. payfor() used to run before any of
// these checks and nothing refunded it when they failed, so paging an
// offline player -- or one whose PAGE-lock rejected the sender -- still
// cost pagecost and delivered nothing. do_page() calls this once per
// recipient, so a multi-target page multiplied the loss (#1187).
//
// payfor() deducts only on success, so moving it last needs no refund
// path.
//
if (!Connected(target))
{
page_return(player, target, T("Away"), A_AWAY,
tprintf(M_("Sorry, %s is not connected."), Moniker(target)));
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
return false;
}
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
if (!could_doit(player, target, A_LPAGE))
{
if ( Can_Hide(target)
&& Hidden(target)
&& !See_Hidden(player))
{
page_return(player, target, T("Away"), A_AWAY,
tprintf(M_("Sorry, %s is not connected."), Moniker(target)));
}
else
{
page_return(player, target, T("Reject"), A_REJECT,
tprintf(M_("Sorry, %s is not accepting pages."), Moniker(target)));
}
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
return false;
}
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
// A wizard may page someone who cannot page back; everyone else may not.
// Hold the warning until the page is actually paid for, so a sender who
// cannot afford it does not get told about a page that never happens.
//
bool bCannotReturn = false;
if (!could_doit(target, player, A_LPAGE))
{
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
if (!Wizard(player))
{
notify(player, tprintf(M_("Sorry, %s cant return your page."),
Moniker(target)));
return false;
}
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
bCannotReturn = true;
}
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
if (!payfor(player, Guest(player) ? 0 : mudconf.pagecost))
{
notify(player, tprintf(M_("You dont have enough %s."), mudconf.many_coins));
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
return false;
}
if (bCannotReturn)
{
notify(player, tprintf(M_("Warning: %s cant return your page."),
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
Moniker(target)));
}
fix: resolve Pass 8 user-facing defects #1182, #1184, #1186, #1187 Four unclaimed Pass 8 findings in four separate files, chosen to avoid the files the open PR queue is already touching. Each premise was re-verified against current source before fixing. #1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex digits + separator). Any A_PASS carrying that prefix with a total length of 13..53 passed the check and then ran safe_str off the end of the attribute value. Reachable because mux_crypt classifies anything starting "$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS -- raw attribute write or a damaged import, not @password -- reaches it on the login path. Require the whole fixed layout including the separator, and fail closed to szFail. #1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and flag_description() both hide the 'c' letter on Hidden(target) && !See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so every dark non-wizard -- royalty, staff, any mortal able to set itself DARK -- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK), and decode_flags takes a FLAGSET rather than a dbref, so the aligned test is DARK on the caller's flagset (unparse_object passes the target's). #1186 moniker injection in look_contents (look.cpp). look_exits() html_escape()s exit names inside xch_cmd="...", and the anchor text in look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED inserted Moniker() raw into the attribute. A moniker containing a double quote closed the attribute early and let the rest become further Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already safe (it emits #dbref). #1187 page_check charged before validating (speech.cpp). payfor() ran first; the not-connected and both A_LPAGE lock failures then returned false with no refund, so a page that was never delivered still cost page_cost -- once per recipient, since do_page() calls page_check() per target. Reordered to validate first and charge last; payfor() deducts only on success, so no refund path is needed. The wizard "can't return your page" warning is now held until after payment, so a sender who cannot afford the page is not told about one that never happens. Behaviour change worth noting: when a sender both lacks funds and the target is offline, the message is now "Sorry, X is not connected." rather than "You don't have enough coins." -- the actual reason rather than the one that happened to be checked first. Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only one of the four reachable without an HTML client or manufactured connection state. It needs a mortal sender, since payfor() exempts wizards outright and a Wizard-only test would pass against any implementation. Test validated against the unfixed build: cases 2 and 4 fail there (offline page charged 5; two offline recipients charged 10, showing the per-recipient amplification) while case 3 passes in both -- so it pins the charge, not merely the absence of one. The other three are not smoke-reachable: #1182 needs a crafted A_PASS, #1184 needs live CONNECTED state on a dark player, #1186 needs an HTML client. Their normal paths are covered -- every scenario driver logs in through mux_crypt, and all four suites pass. Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers (wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new page_cost 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:35:29 -06:00
return true;
}
// The combinations are:
//
// nargs arg1[0] arg2[0]
// '' 1 '\0' '\0' Report LastPaged to player.
// 'a' 1 'a' '\0' Page LastPaged with A
// 'a=' 2 'a' '\0' Page A. LastPaged <- A
// '=b' 2 '\0' 'b' Page LastPaged with B
// 'a=b' 2 'a' 'b' Page A with B. LastPaged <- A
// 'a=b1=[b2=]*...' All treated the same as 'a=b'.
//
void do_page
(
dbref executor,
dbref caller,
dbref enactor,
int eval,
int key,
int nargs,
UTF8 *arg1,
UTF8 *arg2,
const UTF8 *cargs[],
int ncargs
)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
bool bBlind = (key & PAGE_BLIND) != 0;
int nPlayers = 0;
dbref aPlayers[(LBUF_SIZE+1)/2];
// Either we have been given a recipient list, or we are relying on an
// existing A_LASTPAGE.
//
bool bModified = false;
if ( nargs == 2
&& arg1[0] != '\0')
{
bModified = true;
UTF8 *p = arg1;
while (*p != '\0')
{
UTF8 *q = reinterpret_cast<UTF8 *>(strchr(reinterpret_cast<char *>(p), '"'));
if (q)
{
*q = '\0';
}
// Decode space-delimited or comma-delimited recipients.
//
string_token st(p, T(", "));
UTF8 *r;
for (r = st.parse(); r; r = st.parse())
{
dbref target = lookup_player(executor, r, true);
if (target != NOTHING)
{
aPlayers[nPlayers++] = target;
}
else
{
notify(executor, tprintf(M_("I dont recognize “%s”."), r));
}
}
if (q)
{
p = q + 1;
// Handle quoted named.
//
q = reinterpret_cast<UTF8 *>(strchr(reinterpret_cast<char *>(p), '"'));
if (q)
{
*q = '\0';
}
dbref target = lookup_player(executor, p, true);
if (target != NOTHING)
{
aPlayers[nPlayers++] = target;
}
else
{
notify(executor, tprintf(M_("I dont recognize “%s”."), p));
}
if (q)
{
p = q + 1;
}
else
{
break;
}
}
else
{
break;
}
}
}
else
{
// Need to decode the A_LASTPAGE.
//
dbref aowner;
int aflags;
LBuf pLastPage = LBuf_Adopt(atr_get("do_page.645", executor, A_LASTPAGE, &aowner, &aflags));
string_token st(pLastPage, T(" "));
UTF8 *p;
for (p = st.parse(); p; p = st.parse())
{
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:03:45 -06:00
dbref target = mux_atoi64(p);
if ( Good_obj(target)
&& isPlayer(target))
{
aPlayers[nPlayers++] = target;
}
else
{
notify(executor, tprintf(M_("I dont recognize #%d."), target));
bModified = true;
}
}
}
int nValid = nPlayers;
// Remove duplicate dbrefs.
//
int i;
for (i = 0; i < nPlayers-1; i++)
{
if (aPlayers[i] != NOTHING)
{
int j;
for (j = i+1; j < nPlayers; j++)
{
if (aPlayers[j] == aPlayers[i])
{
aPlayers[j] = NOTHING;
bModified = true;
nValid--;
}
}
}
}
// If we are doing more than reporting, we have some other dbref
// validation to do.
//
if ( nargs == 2
|| arg1[0] != '\0')
{
for (i = 0; i < nPlayers; i++)
{
if ( Good_obj(aPlayers[i])
&& !page_check(executor, aPlayers[i]))
{
aPlayers[i] = NOTHING;
bModified = true;
nValid--;
}
}
}
if (bModified)
{
// Our aPlayers could be different than the one encoded on A_LASTPAGE.
// Update the database.
//
ITL itl;
LBuf pBuff = LBuf_Src("do_page.lastpage");
UTF8 *pBufc = pBuff;
ItemToList_Init(&itl, pBuff, &pBufc);
for (i = 0; i < nPlayers; i++)
{
if ( Good_obj(aPlayers[i])
&& !ItemToList_AddInteger(&itl, aPlayers[i]))
{
break;
}
}
ItemToList_Final(&itl);
atr_add_raw(executor, A_LASTPAGE, pBuff);
}
// Verify that the recipient list isn't empty.
//
if (nValid == 0)
{
if ( nargs == 1
&& arg1[0] == '\0')
{
notify(executor, M_("You have not paged anyone."));
}
else
{
notify(executor, M_("No one to page."));
}
return;
}
// Build a friendly representation of the recipient list.
//
LBuf aFriendly = LBuf_Src("do_page.friendly");
UTF8 *pFriendly = aFriendly;
if (nValid > 1)
{
safe_chr('(', aFriendly, &pFriendly);
}
bool bFirst = true;
for (i = 0; i < nPlayers; i++)
{
if (aPlayers[i] != NOTHING)
{
if (bFirst)
{
bFirst = false;
}
else
{
safe_copy_buf(T(", "), 2, aFriendly, &pFriendly);
}
safe_str(Moniker(aPlayers[i]), aFriendly, &pFriendly);
}
}
if (nValid > 1)
{
safe_chr(')', aFriendly, &pFriendly);
}
*pFriendly = '\0';
// We may be able to proceed directly to the reporting case.
//
if ( nargs == 1
&& arg1[0] == '\0')
{
notify(executor, tprintf(M_("You last paged %s."), aFriendly.get()));
return;
}
// Build messages.
//
LBuf omessage = LBuf_Src("do_page.omessage");
LBuf imessage = LBuf_Src("do_page.imessage");
UTF8 *omp = omessage;
UTF8 *imp = imessage;
UTF8 *pMessage;
if (nargs == 1)
{
// 'page A' form.
//
pMessage = arg1;
}
else
{
// 'page A=', 'page =B', and 'page A=B' forms.
//
pMessage = arg2;
}
int pageMode;
switch (*pMessage)
{
case '\0':
pageMode = 1;
break;
case ':':
pMessage++;
2008-03-02 22:19:57 -08:00
if (' ' != *pMessage)
{
pageMode = 2;
break;
}
// FALL THROUGH
case ';':
pageMode = 3;
pMessage++;
break;
case '"':
pMessage++;
// FALL THROUGH
default:
pageMode = 0;
}
UTF8 *newMessage = modSpeech(executor, pMessage, true, T("page"));
if (newMessage)
{
pMessage = newMessage;
}
switch (pageMode)
{
case 1:
// 'page A=' form.
//
if (bBlind || nValid == 1)
{
safe_tprintf_str(omessage, &omp, M_("From afar, %s pages you."),
Moniker(executor));
}
else
{
safe_tprintf_str(omessage, &omp, M_("From afar, %s pages %s."),
Moniker(executor), aFriendly.get());
}
safe_tprintf_str(imessage, &imp, M_("You page %s."), aFriendly.get());
break;
case 2:
// Whole sentences: the recipient list is conditional, so two
// msgids rather than T/M_ fragment assembly (see #1575 / #1588).
//
if (!bBlind && nValid > 1)
{
safe_tprintf_str(omessage, &omp, M_("From afar, to %s: %s %s"),
aFriendly.get(), Moniker(executor), pMessage);
}
else
{
safe_tprintf_str(omessage, &omp, M_("From afar, %s %s"),
Moniker(executor), pMessage);
}
safe_tprintf_str(imessage, &imp, M_("Long distance to %s: %s %s"),
aFriendly.get(), Moniker(executor), pMessage);
break;
case 3:
if (!bBlind && nValid > 1)
{
safe_tprintf_str(omessage, &omp, M_("From afar, to %s: %s%s"),
aFriendly.get(), Moniker(executor), pMessage);
}
else
{
safe_tprintf_str(omessage, &omp, M_("From afar, %s%s"),
Moniker(executor), pMessage);
}
safe_tprintf_str(imessage, &imp, M_("Long distance to %s: %s%s"),
aFriendly.get(), Moniker(executor), pMessage);
break;
default:
if (!bBlind && nValid > 1)
{
safe_tprintf_str(omessage, &omp, M_("To %s, %s pages: %s"),
aFriendly.get(), Moniker(executor), pMessage);
}
else
{
safe_tprintf_str(omessage, &omp, M_("%s pages: %s"),
Moniker(executor), pMessage);
}
safe_tprintf_str(imessage, &imp, M_("You paged %s with %s"),
aFriendly.get(), pMessage);
break;
}
// Send message to recipients.
//
for (i = 0; i < nPlayers; i++)
{
dbref target = aPlayers[i];
if (target != NOTHING)
{
notify_with_cause_ooc(target, executor, omessage, MSG_SRC_PAGE);
int target_idle = fetch_idle(target);
int64_t target_idle_timeout_val = idle_timeout_val(target);
if (target_idle >= target_idle_timeout_val)
{
2018-10-03 17:54:51 +00:00
page_return(executor, target, T("Idle"), A_IDLE, nullptr);
}
}
}
// Send message to sender.
//
notify(executor, imessage);
if (newMessage)
{
free_lbuf(newMessage);
}
}
/* ---------------------------------------------------------------------------
* do_pemit: Messages to specific players, or to all but specific players.
*/
static void whisper_pose(dbref player, dbref target, UTF8 *message, bool bSpace)
{
UTF8 *newMessage = modSpeech(player, message, true, T("whisper"));
if (newMessage)
{
message = newMessage;
}
LBuf buff = LBuf_Src("do_pemit.whisper.pose");
mux_strncpy(buff, Moniker(player), LBUF_SIZE-1);
notify_with_cause(target, player, tprintf(M_("You sense %s%s%s"), buff.get(),
bSpace ? " " : "", message));
if (newMessage)
{
free_lbuf(newMessage);
}
}
static dbref FindPemitTarget(dbref player, int key, UTF8 *recipient)
{
dbref target = NOTHING;
switch (key)
{
case PEMIT_FSAY:
case PEMIT_FPOSE:
case PEMIT_FPOSE_NS:
case PEMIT_FEMIT:
target = match_controlled(player, recipient);
break;
default:
init_match(player, recipient, TYPE_PLAYER);
match_everything(0);
target = match_result();
}
return target;
}
void do_pemit_single
(
dbref player,
int key,
bool bDoContents,
int pemit_flags,
2008-09-20 07:29:53 -07:00
dbref target,
int chPoseType,
UTF8 *message
)
{
dbref loc;
UTF8 *bp;
int depth;
bool ok_to_do = false;
switch (key)
{
case PEMIT_FSAY:
case PEMIT_FPOSE:
case PEMIT_FPOSE_NS:
case PEMIT_FEMIT:
if (target == NOTHING)
{
return;
}
ok_to_do = true;
break;
}
2018-10-03 17:54:51 +00:00
UTF8 *newMessage = nullptr;
UTF8 *saystring = nullptr;
UTF8 *p;
switch (target)
{
case NOTHING:
switch (key)
{
case PEMIT_WHISPER:
notify(player, M_("Whisper to whom?"));
break;
case PEMIT_PEMIT:
notify(player, M_("Emit to whom?"));
break;
case PEMIT_OEMIT:
notify(player, M_("Emit except to whom?"));
break;
default:
notify(player, M_("Sorry."));
break;
}
break;
case AMBIGUOUS:
notify(player, M_("I dont know who you mean!"));
break;
default:
// Enforce locality constraints.
//
if ( !ok_to_do
&& ( nearby(player, target)
|| Long_Fingers(player)
|| Controls(player, target)))
{
ok_to_do = true;
}
if ( !ok_to_do
&& key == PEMIT_PEMIT
&& isPlayer(target)
&& mudconf.pemit_players)
{
if (!page_check(player, target))
{
return;
}
ok_to_do = true;
}
if ( !ok_to_do
&& ( !mudconf.pemit_any
|| key != PEMIT_PEMIT))
{
notify(player, M_("You are too far away to do that."));
return;
}
if ( bDoContents
&& !Controls(player, target)
&& !mudconf.pemit_any)
{
notify(player, NOPERM_MESSAGE);
return;
}
loc = where_is(target);
switch (key)
{
case PEMIT_PEMIT:
if (bDoContents)
{
if (Has_contents(target))
{
notify_all_from_inside(target, player, message);
}
}
else
{
if (pemit_flags & PEMIT_HTML)
{
notify_with_cause_html(target, player, message);
}
else
{
notify_with_cause(target, player, message);
}
}
break;
case PEMIT_OEMIT:
notify_except(Location(target), player, target, message, 0);
break;
case PEMIT_WHISPER:
if ( isPlayer(target)
&& !Connected(target))
{
page_return(player, target, T("Away"), A_AWAY,
tprintf(M_("Sorry, %s is not connected."), Moniker(target)));
return;
}
switch (chPoseType)
{
case ':':
message++;
if (' ' == *message)
{
message++;
whisper_pose(player, target, message, false);
}
else
{
whisper_pose(player, target, message, true);
}
break;
case ';':
message++;
whisper_pose(player, target, message, false);
break;
case '"':
message++;
default:
newMessage = modSpeech(player, message, true, T("whisper"));
if (newMessage)
{
message = newMessage;
}
notify_with_cause(target, player,
tprintf(M_("%s whispers “%s”"), Moniker(player), message));
if (newMessage)
{
free_lbuf(newMessage);
}
}
if ( !mudconf.quiet_whisper
&& !Wizard(player))
{
loc = where_is(player);
if (loc != NOTHING)
{
LBuf buf2 = LBuf_Src("do_pemit.whisper.buzz");
bp = buf2;
safe_str(Moniker(player), buf2, &bp);
safe_str(T(" whispers something to "), buf2, &bp);
safe_str(Moniker(target), buf2, &bp);
*bp = '\0';
notify_except2(loc, player, player, target, buf2);
}
}
break;
case PEMIT_FSAY:
newMessage = modSpeech(target, message, true, T("@fsay"));
if (newMessage)
{
message = newMessage;
}
notify(target, tprintf(M_("You say, “%s”"), message));
if (loc != NOTHING)
{
saystring = modSpeech(target, message, false, T("@fsay"));
if (saystring)
{
p = tprintf(M_("%s %s “%s”"), Moniker(target),
saystring, message);
notify_except(loc, player, target, p, 0);
}
else
{
p = tprintf(M_("%s says, “%s”"), Moniker(target),
message);
notify_except(loc, player, target, p, 0);
}
}
if (saystring)
{
free_lbuf(saystring);
}
if (newMessage)
{
free_lbuf(newMessage);
}
break;
case PEMIT_FPOSE:
newMessage = modSpeech(target, message, true, T("@fpose"));
if (newMessage)
{
message = newMessage;
}
p = tprintf(M_("%s %s"), Moniker(target), message);
notify_all_from_inside(loc, player, p);
if (newMessage)
{
free_lbuf(newMessage);
}
break;
case PEMIT_FPOSE_NS:
newMessage = modSpeech(target, message, true, T("@fpose"));
if (newMessage)
{
message = newMessage;
}
p = tprintf(M_("%s%s"), Moniker(target), message);
notify_all_from_inside(loc, player, p);
if (newMessage)
{
free_lbuf(newMessage);
}
break;
case PEMIT_FEMIT:
if ( (pemit_flags & PEMIT_HERE)
|| !pemit_flags)
{
notify_all_from_inside(loc, player, message);
}
if (pemit_flags & PEMIT_ROOM)
{
if ( isRoom(loc)
&& (pemit_flags & PEMIT_HERE))
{
return;
}
depth = 0;
while ( !isRoom(loc)
&& depth++ < 20)
{
loc = Location(loc);
if ( loc == NOTHING
|| loc == Location(loc))
{
return;
}
}
if (isRoom(loc))
{
notify_all_from_inside(loc, player, message);
}
}
break;
}
}
}
void do_pemit_single
(
dbref player,
int key,
bool bDoContents,
int pemit_flags,
UTF8 *recipient,
int chPoseType,
UTF8 *message
)
{
dbref target = FindPemitTarget(player, key, recipient);
2008-09-20 07:29:53 -07:00
do_pemit_single(player, key, bDoContents, pemit_flags, target, chPoseType, message);
}
void do_pemit_list
(
dbref player,
int key,
bool bDoContents,
int pemit_flags,
UTF8 *list,
int chPoseType,
UTF8 *message
)
{
2007-08-21 20:35:51 -07:00
if ( '\0' == message[0]
|| '\0' == list[0])
{
return;
}
2018-10-03 17:54:51 +00:00
UTF8 *error_message = nullptr;
UTF8 *error_ptr = nullptr;
dbref aPlayers[(LBUF_SIZE+1)/2];
2008-09-20 07:29:53 -07:00
int nPlayers = 0;
string_token st(list, T(", "));
for (UTF8 *p = st.parse(); p; p = st.parse())
{
dbref target = FindPemitTarget(player, key, p);
2008-09-20 07:29:53 -07:00
if (Good_obj(target))
{
aPlayers[nPlayers++] = target;
}
2008-09-20 07:29:53 -07:00
else
{
2018-10-03 17:54:51 +00:00
if (nullptr == error_message)
{
error_message = alloc_lbuf("do_pemit_list.error");
error_ptr = error_message;
safe_str(T("Emit error(s): "), error_message, &error_ptr);
}
else
{
safe_str(T(", "), error_message, &error_ptr);
}
safe_str(p, error_message, &error_ptr);
switch (target)
{
case NOTHING:
safe_str(T(" (unknown)"), error_message, &error_ptr);
break;
case AMBIGUOUS:
safe_str(T(" (ambiguous)"), error_message, &error_ptr);
break;
}
}
}
// Remove duplicate dbrefs.
//
int i;
for (i = 0; i < nPlayers-1; i++)
{
if (aPlayers[i] != NOTHING)
{
int j;
for (j = i+1; j < nPlayers; j++)
{
if (aPlayers[j] == aPlayers[i])
{
aPlayers[j] = NOTHING;
}
}
}
}
// For PEMIT_OEMIT, emit to the room of the first valid target,
// excluding all listed targets.
//
if (PEMIT_OEMIT == key)
2008-09-20 07:29:53 -07:00
{
// Find first valid target.
//
dbref first_target = NOTHING;
for (int i = 0; i < nPlayers; i++)
{
if (NOTHING != aPlayers[i])
{
first_target = aPlayers[i];
break;
}
}
if (NOTHING != first_target)
{
// Enforce locality constraints.
//
if ( nearby(player, first_target)
|| Long_Fingers(player)
|| Controls(player, first_target))
{
dbref loc = Location(first_target);
if (Good_obj(loc))
{
notify_except_N(loc, player, aPlayers, nPlayers, message, 0);
}
}
else
{
notify(player, M_("You are too far away to do that."));
}
}
}
else
{
for (int i = 0; i < nPlayers; i++)
2008-09-20 07:29:53 -07:00
{
dbref target = aPlayers[i];
if (NOTHING != target)
{
do_pemit_single(player, key, bDoContents, pemit_flags, target, chPoseType, message);
}
2008-09-20 07:29:53 -07:00
}
}
2018-10-03 17:54:51 +00:00
if (nullptr != error_message)
{
*error_ptr = '\0';
notify(player, error_message);
free_lbuf(error_message);
2018-10-03 17:54:51 +00:00
error_message = nullptr;
error_ptr = nullptr;
}
}
// Check if the target can be contacted by executor. If not, issue an
// error message to the executor and return false. If checks pass, return
// true.
//
static bool noisy_check_whisper_target(dbref executor, dbref target, int key)
{
bool ok_to_do = false;
if ( nearby(executor, target)
|| Long_Fingers(executor)
|| Controls(executor, target))
{
ok_to_do = true;
}
if ( !ok_to_do
&& ( !mudconf.pemit_any
|| PEMIT_PEMIT != key))
{
notify(executor,
tprintf(M_("Sorry, you are too far away to contact %s."),
Moniker(target)));
return false;
}
if ( isPlayer(target)
&& !Connected(target))
{
page_return(executor, target, T("Away"), A_AWAY,
tprintf(M_("Sorry, %s is not connected."),
Moniker(target)));
return false;
}
return true;
}
void do_pemit_whisper
(
dbref executor,
dbref caller,
dbref enactor,
int key,
int nargs,
UTF8 *recipient,
UTF8 *message
)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
2007-08-21 20:35:51 -07:00
// If the argument count is not 2, pull recipients from A_LASTWHISPER.
//
if (nargs < 2)
{
if ('\0' == recipient[0])
{
notify(executor, M_("No one to whisper to."));
return;
}
message = recipient;
2018-10-03 17:54:51 +00:00
recipient = nullptr;
}
2007-08-21 20:35:51 -07:00
bool bModified = true;
int nPlayers = 0;
dbref aPlayers[(LBUF_SIZE+1)/2];
2007-08-21 20:35:51 -07:00
// Read the A_LASTWHISPER attribute and use that recipient list.
//
if ( PEMIT_WHISPER == key
2018-10-03 17:54:51 +00:00
&& ( nullptr == recipient
2007-08-21 20:35:51 -07:00
|| '\0' == recipient[0]))
{
dbref aowner;
int aflags;
LBuf stored_recipient = LBuf_Adopt(
atr_get("do_whisper.1316", executor, A_LASTWHISPER,
&aowner, &aflags));
bModified = false;
string_token st(stored_recipient, T(", "));
UTF8 *r;
for (r = st.parse(); r; r = st.parse())
{
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:03:45 -06:00
dbref target = mux_atoi64(r);
if (Good_obj(target))
{
if (!noisy_check_whisper_target(executor, target, key))
{
continue;
}
else
{
aPlayers[nPlayers++] = target;
}
}
}
}
2007-08-21 20:35:51 -07:00
if (bModified)
{
UTF8 *p = recipient;
2007-08-21 20:35:51 -07:00
while ('\0' != *p)
{
UTF8 *q = reinterpret_cast<UTF8 *>(strchr(reinterpret_cast<char *>(p), '"'));
if (q)
{
*q = '\0';
}
// Decode space-delimited or comma-delimited recipients.
//
string_token st(p, T(", "));
UTF8 *r;
for (r = st.parse(); r; r = st.parse())
{
dbref target = lookup_player(executor, r, true);
if (NOTHING == target)
{
init_match(executor, r, NOTYPE);
match_neighbor();
target = match_result();
}
if (NOTHING != target)
{
if (!noisy_check_whisper_target(executor, target, key))
{
continue;
}
else
{
aPlayers[nPlayers++] = target;
}
}
}
2007-08-21 20:35:51 -07:00
if (q)
{
p = q + 1;
// Handle quoted named.
//
q = reinterpret_cast<UTF8 *>(strchr(reinterpret_cast<char *>(p), '"'));
if (q)
{
*q = '\0';
}
dbref target = lookup_player(executor, p, true);
2007-08-21 20:35:51 -07:00
if (NOTHING != target)
{
Fix command-side verb correctness bugs (@clone, @ps, whisper, @flag, @mark) Correctness sweep of the command-side verb handlers. Five confirmed bugs plus a help-text correction, verified by dual-lens review and code reading (the read-only smoke harness can't exercise these verbs directly): - @clone/cost on an exit bypassed the "must control current location" check (it lived only on the non-/cost path), letting a builder splice a cloned exit into a room they do not control. (#855) - The @mark/@mark_all/@apply_marked DB-cleaning refusal cited @unmark_all, which does not exist (produces "Huh?"); corrected to @mark_all/clear. (#856) - @ps <object> reported nothing for a controlled object owned by another player; do_ps was missing the non-player-target else clause that the sibling @halt has (clear the owner filter). (#857) - whisper "<quoted name>" skipped the locality/connected gate the unquoted form applies, giving a success confirmation plus a delivery error and polluting A_LASTWHISPER; also fixed an adjacent quoted-name continue that did not advance the parser. (#858) - @flag/remove of an unknown/empty flag name was silent; now reports an error like the other flag-name failure paths. (#859) - report help said 8-hour segments but the code uses 4 (deliberately, per 4a845139f); corrected the help. (#860) Also restores the "## JIT / DBT Engine" CHANGES heading dropped during an earlier 2.14.0.8 edit. Build clean, all 1264 smoke tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 06:29:11 -06:00
// Apply the same locality/connected gate as the
// non-quoted branch; do not 'continue' here, which would
// skip the p advance below and loop forever.
//
if (noisy_check_whisper_target(executor, target, key))
{
aPlayers[nPlayers++] = target;
}
}
else
{
init_match(executor, p, NOTYPE);
match_neighbor();
target = match_result();
2007-08-21 20:35:51 -07:00
if (NOTHING != target)
{
Fix command-side verb correctness bugs (@clone, @ps, whisper, @flag, @mark) Correctness sweep of the command-side verb handlers. Five confirmed bugs plus a help-text correction, verified by dual-lens review and code reading (the read-only smoke harness can't exercise these verbs directly): - @clone/cost on an exit bypassed the "must control current location" check (it lived only on the non-/cost path), letting a builder splice a cloned exit into a room they do not control. (#855) - The @mark/@mark_all/@apply_marked DB-cleaning refusal cited @unmark_all, which does not exist (produces "Huh?"); corrected to @mark_all/clear. (#856) - @ps <object> reported nothing for a controlled object owned by another player; do_ps was missing the non-player-target else clause that the sibling @halt has (clear the owner filter). (#857) - whisper "<quoted name>" skipped the locality/connected gate the unquoted form applies, giving a success confirmation plus a delivery error and polluting A_LASTWHISPER; also fixed an adjacent quoted-name continue that did not advance the parser. (#858) - @flag/remove of an unknown/empty flag name was silent; now reports an error like the other flag-name failure paths. (#859) - report help said 8-hour segments but the code uses 4 (deliberately, per 4a845139f); corrected the help. (#860) Also restores the "## JIT / DBT Engine" CHANGES heading dropped during an earlier 2.14.0.8 edit. Build clean, all 1264 smoke tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 06:29:11 -06:00
if (noisy_check_whisper_target(executor, target, key))
{
aPlayers[nPlayers++] = target;
}
}
}
if (q)
{
p = q + 1;
}
else
{
break;
}
}
else
{
break;
}
}
int nValid = nPlayers;
// Remove duplicate dbrefs.
//
int i;
for (i = 0; i < nPlayers-1; i++)
{
2007-08-21 20:35:51 -07:00
if (NOTHING != aPlayers[i])
{
int j;
for (j = i+1; j < nPlayers; j++)
{
if (aPlayers[j] == aPlayers[i])
{
aPlayers[j] = NOTHING;
bModified = true;
nValid--;
}
}
}
}
2007-08-21 20:35:51 -07:00
}
if ( bModified
&& 0 < nPlayers)
{
2007-08-21 20:35:51 -07:00
// Our aPlayers could be different than the one encoded on A_LASTWHISPER
// Update the database.
//
ITL itl;
LBuf pBuff = LBuf_Src("do_pemit_whisper.lastwhisper");
UTF8 *pBufc = pBuff;
ItemToList_Init(&itl, pBuff, &pBufc);
for (int i = 0; i < nPlayers; i++)
{
if ( Good_obj(aPlayers[i])
&& !ItemToList_AddInteger(&itl, aPlayers[i]))
{
break;
}
}
ItemToList_Final(&itl);
atr_add_raw(executor, A_LASTWHISPER, pBuff);
}
// Decode PEMIT_HERE, PEMIT_ROOM, PEMIT_HTML and remove from key.
//
int mask = PEMIT_HERE | PEMIT_ROOM | PEMIT_HTML;
int pemit_flags = key & mask;
key &= ~mask;
int chPoseType = *message;
bool bPoseSpace = true;
2007-08-21 20:35:51 -07:00
if (':' == chPoseType)
{
message[0] = ' ';
if (' ' == message[1])
{
// ": test" → skip the extra space, no space between name and text.
//
bPoseSpace = false;
}
}
2007-08-21 20:35:51 -07:00
if ( 1 == nPlayers
&& Good_obj(aPlayers[0]))
{
2007-08-21 20:57:27 -07:00
switch (chPoseType)
{
2007-08-21 20:57:27 -07:00
case ';':
notify(executor, tprintf(M_("%s senses “%s%s”"),
Moniker(aPlayers[0]), Moniker(executor), &message[1]));
2007-08-21 20:57:27 -07:00
break;
2007-08-21 20:35:51 -07:00
2007-08-21 20:57:27 -07:00
case ':':
if (bPoseSpace)
{
notify(executor, tprintf(M_("%s senses “%s %s”"),
Moniker(aPlayers[0]), Moniker(executor), &message[1]));
}
else
{
notify(executor, tprintf(M_("%s senses “%s%s”"),
Moniker(aPlayers[0]), Moniker(executor), &message[1]));
}
2007-08-21 20:57:27 -07:00
break;
2007-08-21 20:35:51 -07:00
2007-08-21 20:57:27 -07:00
default:
notify(executor, tprintf(M_("You whisper “%s” to %s."), message,
2007-08-21 20:57:27 -07:00
Moniker(aPlayers[0])));
break;
}
}
2007-08-21 20:35:51 -07:00
else if (1 < nPlayers)
{
LBuf aFriendly = LBuf_Src("do_pemit_whisper.friendly");
UTF8 *pFriendly = aFriendly;
bool bFirst = true;
for (int i = 0; i < nPlayers; i++)
{
2007-08-21 20:35:51 -07:00
if (NOTHING != aPlayers[i])
{
if (bFirst)
{
bFirst = false;
}
2007-09-26 12:04:34 -07:00
else if (nPlayers-1 == i)
{
2007-08-21 20:35:51 -07:00
if (2 == nPlayers)
{
safe_copy_buf(T(" and "), 5, aFriendly, &pFriendly);
}
else
{
safe_copy_buf(T(", and "), 6, aFriendly, &pFriendly);
}
}
else
{
safe_copy_buf(T(", "), 2, aFriendly, &pFriendly);
}
safe_str(Moniker(aPlayers[i]), aFriendly, &pFriendly);
}
}
*pFriendly = '\0';
2007-08-21 20:35:51 -07:00
switch (chPoseType)
{
2007-08-21 20:57:27 -07:00
case ';':
notify(executor, tprintf(M_("%s sense “%s%s”"),
aFriendly.get(), Moniker(executor), &message[1]));
2007-08-21 20:57:27 -07:00
break;
2007-08-21 20:35:51 -07:00
2007-08-21 20:57:27 -07:00
case ':':
if (bPoseSpace)
{
notify(executor, tprintf(M_("%s sense “%s %s”"),
aFriendly.get(), Moniker(executor), &message[1]));
}
else
{
notify(executor, tprintf(M_("%s sense “%s%s”"),
aFriendly.get(), Moniker(executor), &message[1]));
}
2007-08-21 20:57:27 -07:00
break;
2007-08-21 20:35:51 -07:00
2007-08-21 20:57:27 -07:00
default:
notify(executor, tprintf(M_("You whisper “%s” to %s."), message,
aFriendly.get()));
2007-08-21 20:57:27 -07:00
break;
}
}
2007-08-21 20:35:51 -07:00
for (int i = 0; i < nPlayers; i++)
{
2007-08-21 20:35:51 -07:00
if (Good_obj(aPlayers[i]))
{
2007-09-26 12:04:34 -07:00
do_pemit_single(executor, key, false, pemit_flags,
tprintf(T("#%d"), aPlayers[i]), chPoseType, message);
}
}
}
void do_pemit
(
dbref executor,
dbref caller,
dbref enactor,
int eval,
int key,
int nargs,
UTF8 *recipient,
UTF8 *message,
const UTF8 *cargs[],
int ncargs
)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
2007-08-21 20:35:51 -07:00
if ( nargs < 2
&& key != PEMIT_WHISPER)
{
return;
}
2007-08-21 20:35:51 -07:00
if (PEMIT_WHISPER == key)
{
do_pemit_whisper(executor, caller, enactor, key, nargs, recipient, message);
return;
}
2007-08-21 20:57:27 -07:00
// Decode PEMIT_CONTENTS and PEMIT_LIST and remove from key.
//
bool bDoContents = false;
if (key & PEMIT_CONTENTS)
{
bDoContents = true;
}
bool bDoList = false;
2007-08-21 20:35:51 -07:00
if (key & (PEMIT_LIST|PEMIT_WHISPER))
{
bDoList = true;
}
key &= ~(PEMIT_CONTENTS | PEMIT_LIST);
// Decode PEMIT_HERE, PEMIT_ROOM, PEMIT_HTML and remove from key.
//
int mask = PEMIT_HERE | PEMIT_ROOM | PEMIT_HTML;
int pemit_flags = key & mask;
key &= ~mask;
int chPoseType = *message;
2007-08-21 20:35:51 -07:00
if ( PEMIT_WHISPER == key
&& ':' == chPoseType)
{
message[0] = ' ';
}
if (bDoList)
{
do_pemit_list(executor, key, bDoContents, pemit_flags, recipient,
chPoseType, message);
}
else
{
do_pemit_single(executor, key, bDoContents, pemit_flags, recipient,
chPoseType, message);
}
}