match_list() compared the query against every object in scope and could not
stop at the first hit, because ambiguity has to be detected. Name resolution
was 66.8% of attrread and 74.8% of attrwrite once the storage costs were out
of the attribute path (#2073, #2084).
THE INDEX IS A FILTER, NOT THE DECIDER. string_compare() folds Unicode case
through mux_tolower(), whose output is not a byte-for-byte map; reimplementing
that to build a hash key would be an approximation that silently disagrees
with the walk on some name. So the bucket key is an ASCII normalisation only,
every candidate it yields is still confirmed with the real string_compare(),
and any name carrying a byte >= 0x80 is not bucketed -- it goes on a must-scan
list that suppresses the short-circuit for that container. A wrong key costs
a fallback, never a wrong answer.
Skipping the remaining walk on a hit is sound because an exact match scores
CON_COMPLETE|CON_LOCAL (0x09 minimum) while a partial tops out at
CON_LOCAL|CON_TYPE|CON_LOCK (0x07): exact always outranks partial.
Only used when md.check_keys is false. With it set, promote_match() evaluates
could_doit() per candidate -- softcode -- so skipping the walk would skip those
evaluations. Exit and movement matching, the only check_keys users, keep the
existing behaviour exactly.
INVALIDATION. s_Location/s_Contents/s_Next/s_Name carry hooks, which covers
the 45 call sites that mutate contents lists through them. Three sites bypass
the setters and write the fields directly; grepping for those found them, and
they invalidate too:
* db.cpp objref() fixup loop and the SQLite load path -- whole-index clear.
* object.cpp force_reclaim_failed_create_slot(), which turns an object into
GARBAGE while clearing its fields. That one is in normal operation and
would have left a container's index naming a destroyed object.
MEASURED. Per lookup at 1000 objects, the tax that scaled with scope is gone
-- name lookup now costs what a dbref lookup costs:
lookup master with index
by dbref (ctrl) 26.7us 30.0us
name at head 53.3us 26.7us
name at middle 60.0us 23.3us
name at tail 66.7us 26.7us
Per command, two runs, with three non-resolving controls flat:
phase before run1 run2
attrwrite 143us 46us 41us
attrread 160us 51us 55us
dispatch (ctrl) 31us 30us 32us
softcode_arith 27us 28us 26us
Behaviour verified identical to master across fifteen cases: exact match,
case-insensitivity, partial-match fallthrough, space compression, true
ambiguity (two objects sharing a name still give #-2), rename, movement and
destruction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while translating mail (#1419). The tell was a line wrong in English
before translation is even involved:
MAIL: 1 messages in folder 0 [unnamed] (1 unread, 0 cleared).
MN_() exists for this (#1622) and #1655 converted the @mail/stats family;
this set was missed. A translator cannot work around it -- the msgid has no
msgid_plural, so there is no second form for a catalogue to supply, and es
must pick one wording for all counts while ru and pl need three and can
express none of them. The fix is in the C++, not the .po.
## Converted, 11 sites
mail.cpp, mail_mod.cpp mailbox is full; URGENT MAIL urgent messages
walkdb.cpp universe contains; objects @chowned; objects marked
object.cpp objects @chowned to you
wiz.cpp You toaded ... objects @chowned
command.cpp at most N players logged in
The mail module duplicates are converted alongside the engine's so the two
implementations do not drift (#1614).
## One was a different bug than the issue described
walkdb.cpp built its message as M_("%d objects %smarked") with %s = "" or
"un". That is fragment assembly, not a missing plural: a translator handed
that %s cannot use it, because the prefix is English morphology rather than
a word. Split into two whole sentences, each pluralised -- the #1575 /
#1588 rule. Now renders "1 object marked" / "8 objects unmarked" instead of
a %s a catalogue has no way to fill.
## Two deliberately NOT converted
MAIL: %d messages in folder %d [%s] (%d unread, %d cleared).
%d objects = %d rooms, %d exits, %d things, %d players. (%d garbage)
MN_(s, p, n) selects one form from one n. Four independent counts are four
independent plural decisions, and in Russian each noun needs its own form.
These want rewording, which is a design call rather than a mechanical
conversion, so they are left and recorded rather than half-fixed.
## Catalogue fallout
.pot 18 -> 27 plural entries; all three catalogues msgmerge'd.
xx is `complete` policy, so its nine new entries had to be filled and
unfuzzied or the guard fails -- which it duly did, first try. msgmerge had
copied the old singular-only text into BOTH forms, so msgstr[0] said
"messages"; each form is now prefixed from its own msgid.
es and ko received real translations for all nine.
es.po 190 -> 198/976 (20.3%)
ko.po 229 -> 236/976 (24.2%)
## Verified end to end, at n=1 and n>1
en 1 object marked 8 objects marked
es 1 objeto marcado 8 objetos marcados
ko 사물 1개를 표시했습니다 사물 8개를 표시했습니다
xx [xx] 1 object marked [xx] 8 objects marked
Korean renders identically for both, which is correct: it declares
nplurals=1, so the catalogue's own rule is being honoured rather than
English's imposed on it.
The unconverted multi-count line still reads "1 messages", as documented.
make test: Smoke 1561 x3, tests/nls 7, ko 4, plural 43. TESTEXIT=0.
Refs #1419, #1575, #1588, #1614, #1622, #1655, #1702.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3 coverage slice. Player and staff notifies that still used T()
(cast-only) now use M_() so they extract into the catalogue:
object name taken/silly, deposit refund, @chown summary, parent/zone/
home/dropto clears, floating room
flags Flags: header, type/flag parse errors, @set decompile, flag
name removed
boolexp match “don’t see / which”
wiz password changed by %s
player @protect all listing line
Left alone: log messages, HTML, softcode/machine tokens, concatenated
fragments (set @chown owner line, rob give pieces), and punctuation
separators. pot 704 -> 722; xx filled; ko msgmerge only.
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.
Mechanical hygiene under the opt-in M_() design: replace T("#-1…") and
T("#-2…") with S_() so softcode ABI tokens are obvious in source and
cannot enter a player catalog. ~400 call sites across engine, exp3,
mail, and driver. Assembled/library-spliced diagnostics (plan §4.2)
are unchanged where they are not a single literal.
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>
- #1185: zero Powers/Powers2 on create_obj; require zero powers in IS_CLEAN
so freelist recycle cannot inherit privilege from corrupt garbage slots
- #1183: validate A_DESTROYER with Good_owner before destroy_player/chown_all;
fall back to GOD and log when the attribute is stale or corrupt
- #1188: refuse reverse @open when the destination is HOME with an explicit
message instead of a silent open_exit bail
Add pcache_delete() and call it from destroy_obj/destroy_bad_obj before
TYPE_GARBAGE so freelist reuse cannot inherit a prior player's QueueMax
or residual queue depth.
Remove sqlite_clear_after_reconcile_failure, which wiped all attributes,
objects, and meta (db_top=0) when a failed create_obj cleanup could not
resync. Log loudly and leave SQLite unchanged so RAM/backend divergence
does not become mass data loss on restart.
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>
Replace manual alloc_lbuf/free_lbuf pairs with LBuf RAII wrappers
in 12 engine source files: rob, walk, quota, wiz, session, object,
log, match, move, create, flags, boolexp. This eliminates ~40
explicit free_lbuf calls on error paths that are now handled by
destructors, removing leak risk in early-return and multi-exit
functions (boolexp alone had 13 exit-path frees across two
functions). Buffers returned by atr_get/atr_pget or to callers
are left manual since LBuf cannot adopt externally-allocated
buffers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous sentinel-based approach (UINT32_MAX) wrapped to 0 on
the next write, breaking monotonicity. The before-transaction
eager mutation approach diverged on rollback.
New design: two-phase collect/apply.
Phase 1 (before transaction): attr_mod_count_collect_object()
reads all attrs from SQLite and the in-memory map, returning a
vector of {key, current_value} pairs. No in-memory mutation.
Phase 2 (after successful commit only):
attr_mod_count_apply_increments() writes current_value+1 to the
in-memory map for each collected entry.
On rollback: no in-memory mutation occurred, so the map stays
consistent with SQLite. Monotonicity is preserved because the
increment is always current+1, never a sentinel or reset.
Smoke tests: 610/610 passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The UINT32_MAX sentinel wrapped to 0 on next write, breaking
monotonicity. Replace with:
- attr_mod_count_invalidate_object() now seeds all attr counters
from SQLite BEFORE deletion (via GetAllAttrModCounts), then
increments each by 1. Counters are strictly monotonic — no
sentinel, no wrapping, no reuse across delete/recreate cycles.
- Call sites in object.cpp moved BEFORE DelAllAttributes so the
SQLite rows are still readable when seeding.
- ClearAttributes() clears the in-memory map entirely (correct
for full database reload where JIT cache is also cleared).
Smoke tests: 610/610 passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs from code review:
1. attr_mod_count_inc ran before cache_put/cache_del, so failed
writes bumped the in-memory counter without changing SQLite.
Fix: increment AFTER successful storage. PutAttribute reads
the current counter and passes counter+1 to the upsert
independently.
2. DelAllAttributes and ClearAttributes bypassed mod_count tracking.
Fix: attr_mod_count_invalidate_object() sets all entries for a
destroyed object to UINT32_MAX (always stale). ClearAttributes()
calls attr_mod_count_invalidate_all() to clear the entire map.
Smoke tests: 610/610 passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mudstate lives in engine.so (loaded via dlopen at runtime), so the
driver binary cannot reference it at link time — stricter linkers
reject the unresolved symbol. Move the StubSlave lifetime pointer
to a driver-side global in driverstate.h. Engine-side code that
needs mux_ISlaveControl now obtains its own proxy on demand through
mux_CreateInstance via libmux, matching the existing pattern used
for CID_QueryServer.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>