Xania Builder

Research, proof and review notes for changes to Xania, the MUD. Source: github.com/abraidotti/xania-builder

#1 · PR on the fork · PR on upstream

do_get reads past the end of "all": get all / autoloot crash debug builds, skip the undroppable guard in release

gate passed · researched at Xania 82171fa443 · branch fix/1-do-get-all at 515b3001ab

Written by whoever made the change (here, Claude Code working from the dossier), answering the dossier's questions. The facts it cites are in dossier.md; the proof is in gate.md.

In one paragraph

do_get decides between get all and get all.<name> by looking at arg1[3], the character after "all". That worked when arg1 was a NUL-terminated char[]: arg1[3] was the terminator or the dot. In December 2021 arg1 became a std::string_view, which has no terminator, so for the 3-character view "all" the read is past the end: undefined behaviour. Release builds read the next byte of the command line (a space, in get all corpse), so the guard that keeps autoloot from taking undroppable items has never worked in release since then. Debug builds didn't notice either, until the project moved to GCC 15 on 2026-10-31, whose standard library checks operator[] in unoptimised builds: from then on every autoloot of a non-empty corpse aborts a debug MUD. The fix decides "all" or "all.name" once, as named booleans, and passes the intent to the guard instead of a character.

Root cause

The timeline, from section 8 of the dossier and the commits it points at:

WhenCommitWhat happened
2021-04-165e72201The guard is added: "'get all corpse' including the autoloot feature no longer auto loots NODROP or NOREMOVE items (NPC corpses only)... to prevent accidental looting". arg1 is a char[MAX_INPUT_LENGTH]; arg1[3] is '\0' for all and '.' for all.x. Correct.
2021-12-0335c8f3a"get, put, freeze & gain commands upgraded to ArgParser or string_view". arg1 becomes args.shift(), a view. The container branch's own checks are converted to is_all / is_all_named, but the room branch (lines 641, 648, 651) and the guard's argument (line 707) keep reading arg1[3]. From here on it's undefined behaviour.
2026-10-3182823ff, 7bd8c82g++-15 becomes the default compiler, locally and in CI. GCC 15's libstdc++ defines _GLIBCXX_ASSERTIONS whenever optimisation is off (GCC bug 112808, "Done for GCC 15"), so Debug builds start aborting on the out-of-range read.
2026-11-08Xania Sim's bots, playing a debug build with autoloot on, crash it within minutes. Filed as #1.

Why debug builds abort and release builds don't

Checked directly with g++ 15.2 on a 10-line program that indexes std::string_view("all corpse", 3) at 3:

FlagsResult
-O0 (what BUILD_TYPE=Debug compiles with)Assertion '__pos < this->_M_len' failed, abort
-O0 -fsanitize=address,undefined (Xania's Debug flags)the same assertion: it's libstdc++, not the sanitizers, that catches it
-O0 -D_GLIBCXX_NO_ASSERTIONS -fsanitize=address,undefinedprints 32 (a space). ASan and UBSan don't see it: the byte is inside the command-line buffer, so it isn't an out-of-bounds access as far as memory goes
-O2 (Release)prints 32

So it is undefined behaviour in every build, as suspected; the assertion only makes it visible. The /usr/include/x86_64-linux-gnu/c++/15/bits/c++config.h lines that do it:

# elif ! defined(__OPTIMIZE__) && ! defined(_GLIBCXX_NO_ASSERTIONS)
// Enable assertions for unoptimized builds.
#  define _GLIBCXX_ASSERTIONS 1

What each faulty line did, in release

The fix

// "all" takes everything; "all.<name>" takes everything called <name>, and arg1 becomes just the <name>.
const auto is_all = matches(arg1, "all");
const auto is_all_named = matches_start("all.", arg1);
if (is_all_named)
    arg1.remove_prefix(4);

hoisted above the room/container split, so both branches use the same two booleans; the room branch then uses is_all and arg1 (the name) exactly as the container branch already did after 35c8f3a. The guard takes the intent instead of a character:

bool is_mass_looting_npc_undroppable_obj(const Object *obj, const Object *container, const bool is_unnamed_all)
...
if (is_mass_looting_npc_undroppable_obj(obj, container, is_all)) {

That finishes the conversion 35c8f3a started, in the idioms it chose (section 3 of the dossier: do_get already had is_all = matches(arg1, "all") and is_all_named = matches_start("all.", arg1); the fix moves them up rather than adding a third way). src/act_obj.cpp changes by 14 lines added and 13 removed, two of the added lines being comments that say what "all" and the guard mean.

Alternatives considered

Answers to the dossier's questions

1. Explorer. No Explorer check covers this. One is proposed (code.get-all-past-end) so Explorer's Bugs page shows it as fixed once this lands; it isn't part of this PR (Explorer is a separate tool).

2. Architecture. The change is local to do_get and its file-local helper. The only automatic caller, loot_and_sacrifice_corpse (autoloot), passes "all corpse" and now gets the behaviour 5e72201 intended. No other caller of the helper exists.

3. Design patterns. It follows the idiom already in the function (named booleans from matches / matches_start, remove_prefix). No new helper was needed.

4. Technical debt. Pays down all five C-string reads in the focus (the gate's detector step: 5 before, 0 after) and removes a duplicated is_all computation. Leaves do_get's length alone. The same pattern elsewhere in src/ (dossier section 4), place by place:

5. Gameplay. Debug builds: no more aborts on get all or autoloot. Release builds (the live game): players will notice a change: get all corpse and autoloot now leave NODROP/NOREMOVE items on NPC corpses, with "You refrain from looting $p as it appears to be undroppable. You can still 'get' it using its name.", as the original feature meant to. Named looting (get ring corpse, get all.ring corpse) still takes them. Nothing to abuse: the change only takes away an unintended pickup. Explorer's balance models don't read act_obj.cpp.

6. Documentation. The help entry DROP GET GIVE PUT TAKE doesn't describe the guard; the in-game message explains it when it happens. No help change. The PR says the release behaviour changes.

7. Tests. New src/test/GetTest.cpp, 2 test cases and 10 sections, driving do_get itself on MockMud: empty room (all, all.sword, all.), picking up, the corpse guard (NODROP, NOREMOVE, a plain item), named looting, a short name, and a container (not a corpse) that isn't guarded. On the base they abort the Debug build and fail in Release (the gate's before/after table); on the branch they pass in both. From the player's side: Sim's known-bug scenario (a bot types get all in an empty room) crashes the base and no longer crashes the branch, and a 5-minute hunt (four bots killing cave beasts with autoloot on) runs clean on the branch. The hunt isn't used as proof on the base: the guard's read only happens for a corpse with something in it, and cave beasts rarely carry anything (the first gate run saw 16 kills on the base without a crash). The corpse path's proof is the unit test.

8. History. Answered under Root cause. Upstream has no issue or PR about it (the search finds only upstream #72, a different autoloot bug, closed).

Follow-ups (not in this PR)

Branch fix/1-do-get-all at 515b3001ab against base 82171fa443: passed in 7 min, 2026-11-11 10:56.

Environment: gcc g++-15 (Ubuntu 15.2.0-16ubuntu1) 15.2.0; cmake cmake version 4.2.3; os Ubuntu 26.04.1 LTS; distro Ubuntu-26.04

Changes: 2 files changed, 170 insertions(+), 13 deletions(-)

StepExpectedResultTime
✅clang-format (make check-format, as CI)no formatting differencesclean2 s
✅Debug build of the branchit compiles (-Werror)built in 0 s0 s
✅Unit tests, Debug, the branchevery test passes1083 test cases and sections, all passed3 s
✅Release build of the branchit compiles (-Werror)built in 0 s0 s
✅Unit tests, Release, the branchevery test passes1083 test cases and sections, all passed0 s
✅Debug build of the base with the branch's testsit compiles (-Werror)built in 10 s10 s
➖Unit tests, Debug, the base with the branch's teststhe new tests fail here (they pin the bug)aborted: Assertion '__pos < this->_M_len' failed1 s
✅Release build of the base with the branch's testsit compiles (-Werror)built in 7 s7 s
➖Unit tests, Release, the base with the branch's teststhe new tests fail here (they pin the bug)1 of 1083 failed: get all from an NPC corpse leaves undroppable items unless named/get all corpse (autoloot sends the same) takes the bread, refrains from the nodrop and noremove items0 s
✅The new tests, before and after the changeeach new test fails or aborts on the base in at least one build, and passes on the branch2 of 2 new tests pin the fix0 s
✅Sim smoke on the branchit runs and finds no crashno crash in 21 s22 s
✅Sim pytest scenarios/test_live_bugs.py -k get_all on the basexania crashes when a bot types 'get all' in an empty room (the known-bug check sees the crash)pytest exit 0: 1 passed, 1 deselected in 38.81s40 s
✅Sim pytest scenarios/test_live_bugs.py -k get_all on the branchxania survives it (the known-bug check reports the bug gone and asks to be retired)pytest exit 1: 1 failed, 1 deselected in 34.89s36 s
✅Sim hunt --minutes 5 on the branchit runs and finds no crashno crash in 308 s (fights 16, bot errors 0, crashed False)309 s
✅Builder's detectors on the focus, base against branchno string_view indexed like a C string is left in the focus5 before, 0 after0 s

The new tests, before and after

TestBase, DebugBase, ReleaseBranch, DebugBranch, Release
get all and get all.<name> in a roomabortedpasspasspasspins the fix
get all from an NPC corpse leaves undroppable items unless namedabortedfailpasspasspins the fix

Detectors, base against branch

FunctionBaseBranch
loot_and_sacrifice_corpsenonenone
do_getarg1[3] at src/act_obj.cpp:641, &arg1[4] at src/act_obj.cpp:641, arg1[3] at src/act_obj.cpp:648, &arg1[4] at src/act_obj.cpp:651, arg1[3] at src/act_obj.cpp:707none
is_mass_looting_npc_undroppable_objnonenone

Logs

➖ Unit tests, Debug, the base with the branch's tests
/usr/include/c++/15/string_view:260: constexpr const std::basic_string_view<_CharT, _Traits>::value_type& std::basic_string_view<_CharT, _Traits>::operator[](size_type) const [with _CharT = char; _Traits = std::char_traits<char>; const_reference = const char&; size_type = long unsigned int]: Assertion '__pos < this->_M_len' failed.
✅ Sim smoke on the branch
smoke: Smoke: create a bot, look, quit (21 s, Xania 515b3001ab)
  transcript: runs\20261111-105703\Smokea.log
  result: clean
✅ Sim pytest scenarios/test_live_bugs.py -k get_all on the base
.                                                                        [100%]
1 passed, 1 deselected in 38.81s
✅ Sim pytest scenarios/test_live_bugs.py -k get_all on the branch
F                                                                        [100%]
================================== FAILURES ===================================
_____________ test_get_all_in_an_empty_room_crashes_debug_builds ______________
mud = <xania_sim.core.server.TestMud object at 0x000001957D641550>
names = <function names.<locals>.<lambda> at 0x000001957F65C3B0>, found = []
    async def test_get_all_in_an_empty_room_crashes_debug_builds(mud, names, found):
        """act_obj.cpp do_get: 'get all' reads arg1[3] where arg1 is "all" (3 chars) when nothing is found (line 648).
        Each run that still sees it adds an occurrence to the finding (so the dispatch shows it's still there)."""
        bot = Bot(names(), "simpass1", host=mud.host, port=mud.port)
        await bot.enter(Profile())
        crashed = await _crashes_after(mud, bot, "get all")
        report = mud.crash_report() if crashed else ""
        await bot.close()
        if crashed:
            found.append(crash_finding(report, 'python -m pytest scenarios/test_live_bugs.py -k get_all',
                                       "A bot typed 'get all' in an empty room (a known-bug check)."))
            await mud.restart()
>       assert crashed, "'get all' no longer crashes xania: the bug looks fixed, retire this test"
E       AssertionError: 'get all' no longer crashes xania: the bug looks fixed, retire this test
E       assert False
scenarios\test_live_bugs.py:41: AssertionError
=========================== short test summary info ===========================
FAILED scenarios/test_live_bugs.py::test_get_all_in_an_empty_room_crashes_debug_builds
1 failed, 1 deselected in 34.89s
✅ Sim hunt --minutes 5 on the branch
hunt: Hunt: angry cave beast, 4 bots (308 s, Xania 515b3001ab)
  fights 16, bot errors 0, crashed False
  result: clean

#1 · Xania 82171fa443 · 2026-11-11 10:11

The idea as given

Summary

do_get (src/act_obj.cpp) still indexes its first argument the way the old C code indexed a NUL-terminated buffer: arg1[3] to tell get all from get all.<name>, and &arg1[4] for the name. arg1 is now a std::string_view, so for the plain "all" (3 characters) arg1[3] is out of range.

  • Debug builds abort on the libstdc++ bounds assertion, taking the whole MUD down: any get all that finds nothing, and every autoloot of a non-empty corpse.
  • Release builds don't crash, but arg1[3] reads the next byte of the command line instead of a terminator:
  • is_mass_looting_npc_undroppable_obj gets ' ' instead of '\0' for get all corpse, so the guard against mass-looting undroppable items from NPC corpses (looting_all_item_dot == '\0') never applies: get all corpse and autoloot take undroppable (NoDrop/NoRemove) items.
  • Lines 641/651 work only when the byte after all happens to be a NUL, which depends on how the command line is stored.

Present at 82171fa (current main of both this fork and mattgodbolt/xania).

How to reproduce

Either of these crashes a debug build (make, the default BUILD_TYPE=Debug):

1. Type get all in a room with nothing in it. 2. Kill a mob that carries something, with autoloot on (the default for new characters). Autoloot calls do_get(looter, ArgParser("all corpse")) from loot_and_sacrifice_corpse (src/fight.cpp:565). A level-1 character killing an angry cave beast in Mud School (room 3713) does it within a couple of minutes.

/usr/include/c++/15/string_view:260: ... operator[](size_type) const ...: Assertion '__pos < this->_M_len' failed.
Program received signal SIGABRT, Aborted.

Backtrace for case 2:

#6  std::basic_string_view<char>::operator[] (__pos=3) at /usr/include/c++/15/string_view:260
#7  do_get (ch=..., args=...) at src/act_obj.cpp:707
#8  loot_and_sacrifice_corpse (looter=..., victim=..., victim_room_vnum=3713) at src/fight.cpp:565
#9  damage (ch=..., victim=..., raw_damage=2, ...) at src/fight.cpp:813
#10 one_hit (...) at src/fight.cpp:535
#11 multi_hit (...) at src/fight.cpp:263
#13 violence_update () at src/fight.cpp:110
#14 update_handler (mud=...) at src/update.cpp:874
#15 MudImpl::game_loop (...) at src/MudImpl.cpp:332

Case 1 stops at do_get ... at src/act_obj.cpp:648.

Faulty lines

All in src/act_obj.cpp:

LineCodeProblem when arg1 == "all"
641arg1[3] == '\0' || is_name(&arg1[4], obj->name)arg1[3] out of range; &arg1[4] too (and for "all.", 4 chars)
648if (arg1[3] == '\0')out of range; runs whenever nothing was picked up, so an empty room always hits it
651act("I see no $T here.", ..., &arg1[4], ...)&arg1[4] is a const char* read up to the next NUL, past the end of the view
707is_mass_looting_npc_undroppable_obj(obj, container, arg1[3])out of range for get all <container>; for get all.<name> <container> the prefix has already been removed (line 688), so arg1[3] is the 4th character of the name

Suggested fix

Decide "all" vs "all.name" once, and pass that intent instead of a character:

// 'get all' or 'get all.obj' (in the room)
const auto is_all = matches(arg1, "all");
const auto named = is_all ? std::string_view{} : arg1.substr(4);  // after "all."
found = false;
for (auto *obj : ch->in_room->contents) {
    if ((is_all || is_name(named, obj->name)) && ch->can_see(*obj)) {
        found = true;
        get_obj(ch, obj, nullptr);
    }
}
if (!found) {
    if (is_all)
        ch->send_line("I see nothing here.");
    else
        act("I see no $T here.", ch, nullptr, named, To::Char);
}
bool is_mass_looting_npc_undroppable_obj(const Object *obj, const Object *container, const bool unnamed_all) {
    return container->type == ObjectType::Npccorpse && unnamed_all && (obj->is_no_drop() || obj->is_no_remove());
}
// line 707:
if (is_mass_looting_npc_undroppable_obj(obj, container, is_all)) {

Tests worth adding (there's MockMud in src/test/):

  • get all in an empty room says "I see nothing here." (and doesn't abort a debug build);
  • get all corpse on an NPC corpse holding a NoDrop item leaves that item behind, while get <item> corpse takes it;
  • get all.sword corpse takes a NoDrop sword (named looting is allowed).

How this was found

Bots from xania-sim playing a debug build: its test MUD runs xania under gdb, and the crash reproduces with python -m xania_sim hunt --mob "angry cave beast". Case 1 was spotted in the code next to it and confirmed the same way (a bot typing get all in an empty room). A check for it (code.get-all-past-end) is on its way to Xania Explorer's Bugs page, where it will turn to "fixed" once these lines change.

Focus

1. Explorer insights

To decide

2. Architecture

To decide

3. Design patterns

To decide

4. Technical debt

To decide

5. Gameplay

To decide

6. Documentation

To decide

7. Test coverage

To decide

8. History

To decide

Gate plan

What prototype runs on the fork before this can ship: