feat(world-ptr): WorldPtr<T> / PolyPtr<T> affine pointer pair + migrate Mounts, Outfits, Tile hierarchy#3975
feat(world-ptr): WorldPtr<T> / PolyPtr<T> affine pointer pair + migrate Mounts, Outfits, Tile hierarchy#3975beats-dh wants to merge 11 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughMigrates ownership from std::shared_ptr to intrusive WorldPtr/PolyPtr (Owning/Borrowed/Shared/Weak), updates tile/map/game/creature/item/house/combat/Lua/protocol APIs to use borrowed views, adds dispatcher QSBR draining, and introduces extensive unit/integration tests and fixtures. ChangesWorldPointer / Pointer model migration
Estimated code review effort: 🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Code Review
This pull request introduces a high-performance memory management system using WorldPtr and PolyPtr with Quiescent State Based Reclamation (QSBR) to replace std::shared_ptr for core objects like Mount, Outfit, and Tile. The feedback identifies critical thread-safety risks in mapsector.hpp due to missing or disabled locks in setTile and setTileCache, which could result in torn reads or data races. Additionally, the use of offsetof in WorldPtr for non-standard-layout types is noted as a medium-severity issue that will likely trigger compilation errors.
Two findings from SonarCloud's analysis on PR #3975, both in `src/utils/worldpointer.hpp`: 1. cpp:S6458 MAJOR @ Block(Args&&...) — the variadic forwarding constructor lacked a constraint preventing it from outcompeting the implicitly-declared copy/move constructors when called with a single `Block` argument. Add a `requires` clause that excludes `Args` being exactly one `Block` (any cv/ref). In practice this is unreachable (Block is private to WorldPtr<T> and only ever built via `Owning::make` → `AllocTraits::construct`), but the constraint encodes the intent explicitly. 2. cpp:S3519 BLOCKER @ useCount() — false positive on the offsetof trick. SonarCloud tracks `block = getBlock()` as `&value - 16` and flags `block->reference_count` (at Block offset 0) as a "negative byte offset -16" access relative to `value`. That IS the intentional intrusive layout: Block is `{ refcount, next, value }` and `getBlock()` recovers the block start from `value` via `offsetof(Block, value)`. Mark with `// NOSONAR cpp:S3519` and document the rationale in-place — same pattern used in `src/utils/tools.cpp` for legitimate suppressions. No behavioural change.
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (6)
src/creatures/monsters/monster.cpp (1)
1583-1588:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winDon't enqueue a
Borrowedtile into the walk event.
posTileis only a borrowed handle. Capturing it inaddWalkEvent()lets the callback outlive the current tile epoch, so it can dereference a retired/replaced tile when the event finally runs.🐛 Proposed fix
if (canPushCreatures()) { if (g_dispatcher().context().getGroup() == TaskGroup::Walk) { Monster::pushCreatures(posTile); } else { - g_dispatcher().addWalkEvent([=] { - Monster::pushCreatures(posTile); - }); + const Position pushPos = pos; + g_dispatcher().addWalkEvent([pushPos] { + if (const auto tile = g_game().map.getTile(pushPos)) { + Monster::pushCreatures(tile); + } + }); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/creatures/monsters/monster.cpp` around lines 1583 - 1588, The callback currently captures the borrowed tile handle posTile and passes it to Monster::pushCreatures inside g_dispatcher().addWalkEvent, which can outlive the tile epoch; change the capture to only copy a stable identifier (e.g., tile coordinates or position) and in the added walk event re-resolve the tile from the world/map before calling Monster::pushCreatures. Specifically, update the lambda passed to g_dispatcher().addWalkEvent so it captures a primitive (like posTile->position() or x/y/z) instead of posTile, then inside the lambda obtain a fresh tile handle from the dispatcher/world and call Monster::pushCreatures with that fresh handle.src/items/weapons/weapons.cpp (1)
321-333:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGuard empty borrowed tiles before using them.
These overloads now accept
PolyPtr<Tile>::Borrowed, but both paths still assume the handle is always live. If a caller passes an empty borrow, this becomes an immediate null dereference ontile->getPosition()or the move path.Proposed fix
void Weapon::internalUseWeapon(const std::shared_ptr<Player> &player, const std::shared_ptr<Item> &item, PolyPtr<Tile>::Borrowed tile) const { + if (!tile) { + return; + } + if (isLoadedScriptId()) { LuaVariant var; var.type = VARIANT_TARGETPOSITION; @@ void Weapon::onUsedWeapon(const std::shared_ptr<Player> &player, const std::shared_ptr<Item> &item, PolyPtr<Tile>::Borrowed destTile) const { + if (!destTile) { + return; + } + if (!player->hasFlag(PlayerFlags_t::NotGainSkill)) {Also applies to: 335-387
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/items/weapons/weapons.cpp` around lines 321 - 333, The method Weapon::internalUseWeapon assumes the PolyPtr<Tile>::Borrowed tile is always valid and dereferences it (tile->getPosition()); add a guard at the start to handle an empty/expired borrow (e.g., if !tile) and bail out or handle the missing tile before calling isLoadedScriptId/executeUseWeapon, Combat::postCombatEffects, g_game().addMagicEffect, g_game().sendSingleSoundEffect, and onUsedWeapon. Apply the same null-check pattern to the other overload(s) referenced (lines 335-387) so no code path dereferences tile when the borrow is empty.src/creatures/combat/combat.cpp (2)
346-352:⚠️ Potential issue | 🟠 Major | ⚡ Quick winNull-check
target->getTile()before tile-based combat checks.After the tile migration,
getTile()can legitimately return a nullBorrowed. Lines 347 and 350 dereference it unconditionally, so a detached or already-retired target now turns this path into a crash.Suggested fix
if (target) { PolyPtr<Tile>::Borrowed tile = target->getTile(); - if (tile->hasProperty(CONST_PROP_BLOCKPROJECTILE)) { + if (tile && tile->hasProperty(CONST_PROP_BLOCKPROJECTILE)) { return RETURNVALUE_NOTENOUGHROOM; } - if (targetPlayer && tile->hasFlag(TILESTATE_PROTECTIONZONE)) { + if (targetPlayer && tile && tile->hasFlag(TILESTATE_PROTECTIONZONE)) { const auto permittedOnPz = targetPlayer->hasPermittedConditionInPZ(); return permittedOnPz ? RETURNVALUE_NOERROR : RETURNVALUE_ACTIONNOTPERMITTEDINPROTECTIONZONE; } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/creatures/combat/combat.cpp` around lines 346 - 352, The code dereferences the result of target->getTile() (assigned to PolyPtr<Tile>::Borrowed tile) without null-checking, causing crashes when getTile() returns a null Borrowed; add a guard after obtaining tile to return an appropriate value (e.g., RETURNVALUE_NOTPOSSIBLE or RETURNVALUE_NOTENOUGHROOM) when tile is null before calling tile->hasProperty(CONST_PROP_BLOCKPROJECTILE) or tile->hasFlag(TILESTATE_PROTECTIONZONE), and only call targetPlayer->hasPermittedConditionInPZ() when tile is non-null.
1048-1134:⚠️ Potential issue | 🟠 Major | ⚡ Quick winEarly-return when tile effects receive a null borrow.
combatTileEffects()now acceptsPolyPtr<Tile>::Borrowed, but every path assumestileis valid.doCombatDefault()can passtarget->getTile()directly, so the first detached target will crash here as soon as an item/effect/tile callback is processed.Suggested fix
void Combat::combatTileEffects(const CreatureVector &spectators, const std::shared_ptr<Creature> &caster, PolyPtr<Tile>::Borrowed tile, const CombatParams ¶ms) { + if (!tile) { + return; + } + if (params.itemId != 0) { uint16_t itemId = params.itemId;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/creatures/combat/combat.cpp` around lines 1048 - 1134, combatTileEffects assumes the borrowed tile is non-null and will dereference it unconditionally, causing a crash when doCombatDefault passes a null target->getTile(); fix by adding an early null check at the top of Combat::combatTileEffects (the function taking PolyPtr<Tile>::Borrowed tile) and return immediately if tile is null so no item creation, tileCallback->onTileCombat, sendCombatEffect, or sound calls run against a null tile.src/server/network/protocol/protocolgame.cpp (1)
4090-4105:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winSerialize mounts in a stable order.
Mounts::getMounts()now comes from an unordered hash-set-backed store, so iterating it directly makes these packets nondeterministic. That will reshuffle mount lists between runs/reloads and can make the UI and packet-order tests flaky. Snapshot the borrowed mounts and sort them by a stable key such asclientIdbefore encoding.Also applies to: 7878-7892, 7985-7999, 8108-8118
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/network/protocol/protocolgame.cpp` around lines 4090 - 4105, The loop iterates g_game().mounts->getMounts() (an unordered set) directly causing nondeterministic packet order; fix by copying the borrowed mounts into a std::vector, sort that vector by a stable key such as mount->clientId, then iterate the sorted vector when calling player->hasMount(mount.get()), reading mount->type and mount->clientId and serializing with msg.add/...; apply the same snapshot-and-sort change to the other mount-iteration sites referenced (the blocks around the other ranges).src/game/game.cpp (1)
1394-1405:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winDon’t capture
PolyPtr<Tile>::Borrowedacross dispatcher/task boundaries.These callbacks store a borrowed tile and dereference it later from
createPlayerTask(...)/addWalkEvent(...). With the new QSBR-style lifetime rules, that borrowed handle can be invalid by the time the callback runs, turning this into a use-after-free. CapturePositionand re-resolve the tile inside the deferred callback, or cross the boundary with an owning/shared facade instead.Suggested direction
- PolyPtr<Tile>::Borrowed tile = map.getTile(toPos); - if (!tile) { + PolyPtr<Tile>::Borrowed tile = map.getTile(toPos); + if (!tile) { player->sendCancelMessage(RETURNVALUE_NOTPOSSIBLE); return; } + const Position targetPos = tile->getPosition(); if (Position::areInRange<1, 1, 0>(movingCreature->getPosition(), player->getPosition())) { const auto &task = createPlayerTask( g_configManager().getNumber(PUSH_DELAY), - [this, player, movingCreature, tile] { - playerMoveCreatureByID(player->getID(), movingCreature->getID(), movingCreature->getPosition(), tile->getPosition()); + [this, player, movingCreature, targetPos] { + playerMoveCreatureByID(player->getID(), movingCreature->getID(), movingCreature->getPosition(), targetPos); }, __FUNCTION__ ); player->setNextActionPushTask(task); } else { - playerMoveCreature(player, movingCreature, movingCreature->getPosition(), tile); + playerMoveCreature(player, movingCreature, movingCreature->getPosition(), targetPos); } }-void Game::playerMoveCreature(const std::shared_ptr<Player> &player, const std::shared_ptr<Creature> &movingCreature, const Position &movingCreatureOrigPos, PolyPtr<Tile>::Borrowed toTile) { +void Game::playerMoveCreature(const std::shared_ptr<Player> &player, const std::shared_ptr<Creature> &movingCreature, const Position &movingCreatureOrigPos, const Position &targetPos) { metrics::method_latency measure(__METRICS_METHOD_NAME__); - g_dispatcher().addWalkEvent([=, this] { + g_dispatcher().addWalkEvent([=, this] { + const auto toTile = map.getTile(targetPos); + if (!toTile) { + player->sendCancelMessage(RETURNVALUE_NOTPOSSIBLE); + return; + } + if (!player->canDoAction()) { const auto &task = createPlayerTask( 600, - [this, player, movingCreature, toTile, movingCreatureOrigPos] { - playerMoveCreatureByID(player->getID(), movingCreature->getID(), movingCreatureOrigPos, toTile->getPosition()); + [this, player, movingCreature, movingCreatureOrigPos, targetPos] { + playerMoveCreatureByID(player->getID(), movingCreature->getID(), movingCreatureOrigPos, targetPos); }, __FUNCTION__ );Also mirror the signature change in
src/game/game.hpp.Also applies to: 1434-1471
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/game/game.cpp` around lines 1394 - 1405, The callback captures a borrowed PolyPtr<Tile>::Borrowed (from map.getTile(toPos)) which may be invalid when the deferred task created via createPlayerTask / addWalkEvent runs; instead capture the tile's Position (e.g., tile->getPosition()) and any IDs you need, then inside the deferred lambda re-resolve the tile via map.getTile(resolvedPos) before calling playerMoveCreatureByID(player->getID(), movingCreature->getID(), movingCreature->getPosition(), tilePos) (or bail out if the tile no longer exists), and mirror the changed lambda signature in the corresponding declaration in the header (game.hpp).
🧹 Nitpick comments (4)
src/items/containers/container.hpp (1)
162-162: ⚡ Quick winAlign
createBrowseFieldnaming/docs with the borrowed handle API.The signature now takes
PolyPtr<Tile>::Borrowed, but docs still describe a shared pointer and the parameter nametypeis ambiguous.Proposed cleanup
- * `@param` tile A shared pointer to the Tile from which items will be sourced. + * `@param` tile Borrowed handle to the Tile from which items will be sourced. ... - * `@note` This function will return nullptr if the newContainer could not be created or if the tile pointer is null. + * `@note` This function returns nullptr if the container cannot be created or the tile handle is empty. ... -static std::shared_ptr<Container> createBrowseField(PolyPtr<Tile>::Borrowed type); +static std::shared_ptr<Container> createBrowseField(PolyPtr<Tile>::Borrowed tile);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/items/containers/container.hpp` at line 162, The declaration for createBrowseField currently accepts PolyPtr<Tile>::Borrowed but the docs and parameter name imply a shared_ptr and are ambiguous; update the function signature comment and parameter name to reflect the borrowed handle API (e.g., change parameter name from type to tileHandle or borrowedTile) and revise the docblock to state it takes a PolyPtr<Tile>::Borrowed borrowed handle (not a shared_ptr), plus ensure any related factory/overload names match the borrowed-handle convention where createBrowseField is used.tests/unit/game/mounts_test.cpp (2)
56-69: ⚡ Quick winDrain the retired mount blocks in
MountsApiTesttoo.This fixture also creates and destroys
Mountsinstances, so it can leaveWorldPtr<Mount>retire-list state behind for later tests. MirroringGlobalMountsTest::TearDown()here will keep the suite isolated.Proposed fix
-class MountsApiTest : public test::mounts::MountsTestBase { }; +class MountsApiTest : public test::mounts::MountsTestBase { +protected: + void TearDown() override { + MountsTestBase::TearDown(); + drainMounts(); + } +};As per coding guidelines, C++ changes should "prefer focused tests or the smallest practical build/check that validates the touched code".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/game/mounts_test.cpp` around lines 56 - 69, Add a TearDown override to MountsApiTest (which inherits test::mounts::MountsTestBase) that mirrors GlobalMountsTest::TearDown: call MountsTestBase::TearDown() and then call drainMounts() to clear retired WorldPtr<Mount> blocks left by the fixture; this ensures MountsApiTest does not leave retire-list state for later tests.
224-237: ⚡ Quick win
Reload_DefersOldMountsToQSBRdoes not actually prove deferral.The only post-reload assertion is the new container size, so this still passes if old mounts are reclaimed eagerly. Keep a pre-reload borrow/shared handle and assert the old object survives until the quiescent drain boundary.
As per coding guidelines, C++ changes should "prefer focused tests or the smallest practical build/check that validates the touched code".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/game/mounts_test.cpp` around lines 224 - 237, The test Reload_DefersOldMountsToQSBR must retain a pre-reload borrow/handle to an old mount so we can prove reclamation is deferred: before calling mounts.reload(), obtain and hold a shared/borrowed reference to one of the existing mount objects (via mounts.getMounts() or the API that returns a shared_ptr/borrowed handle), then call reload() and run drainMounts(); assert that the held handle still points to a valid/expected old mount (i.e., the object was not destroyed), only then release the handle and run drainMounts() again and assert final cleanup/size changes; update assertions to reference Mounts::loadFromXml, Mounts::reload, Mounts::getMounts, and drainMounts to locate the changes.tests/unit/utils/worldpointer_test.cpp (1)
241-269: ⚡ Quick winAdd a regression case for post-retire
Borrowed::share().These tests cover the successful promotion path, but not the bug this PR is fixing: borrowing first, dropping the last strong ref, and then verifying that
share()returns null beforequiescentState(). Without that case, the resurrection fix can regress silently.As per coding guidelines, "For C++ changes, prefer focused tests or the smallest practical build/check that validates the touched code".
Also applies to: 585-604
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/utils/worldpointer_test.cpp` around lines 241 - 269, Add a focused regression test that exercises calling Borrowed::share() after the owner has been dropped but before WorldPtr<Probe>::quiescentState() to ensure it returns null; specifically create an Owning via Owning::make(value), obtain a Borrowed view = owner.borrow(), reset/drop the owner, then call auto promoted = view.share() and ASSERT_FALSE(promoted) (or EXPECT_EQ(nullptr, promoted.get())) before calling WorldPtr<Probe>::quiescentState(), finally verify that after quiescentState() promotion behaves as expected if needed; place this alongside the existing Borrowed->Shared tests and reference Borrowed::share, Owning::make, and WorldPtr::quiescentState to locate the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/game/game.hpp`:
- Around line 623-627: addTileToClean currently calls
tilesToClean.emplace(tile.share()) which can insert a null Shared when
Borrowed::share() fails; change addTileToClean to promote once into a
PolyPtr<Tile>::Shared local (e.g. auto shared = tile.share()) and only call
tilesToClean.emplace(shared) if shared is non-null, so null promotions are never
inserted and removeTileToClean can correctly match/erase entries.
In `@src/items/tile.cpp`:
- Around line 1977-2007: The safeCall overloads assume Borrowed::share() always
returns a valid pin and only assert, which disappears in release builds; change
both Tile::safeCall functions to perform a runtime check after calling
borrowedFromThis().share() (for the Borrowed overload check the pinned variable,
for the void overload check pin) and if the returned shared pin is null simply
return/do not call g_dispatcher().addEvent (i.e., drop the work) instead of
enqueueing; remove or keep the assert as a diagnostic but ensure the null-check
prevents scheduling when the tile is retired. Use the existing symbols
borrowedFromThis(), Borrowed::share(), g_dispatcher().addEvent and the lambda
captures (pinned/pin) when implementing the guard.
In `@src/map/map.cpp`:
- Around line 151-169: Reject out-of-range layer indices early in
Map::getOrCreateTile by checking z against MAP_MAX_LAYERS before constructing
any PolyPtr<Tile>::Owning or calling borrow(); if z is invalid, return an empty
PolyPtr<Tile>::Borrowed immediately instead of creating an Owning (so
getTile()/setTile() cannot drop a newly-created Owning and leave a dangling
borrowed). Move the z validation to the top of getOrCreateTile, and only create
the make_poly<DynamicTile>/make_poly<StaticTile>, call owning.borrow(), and
setTile(...) after the z check and after confirming getTile(...) did not already
return a tile.
In `@src/map/mapcache.cpp`:
- Around line 317-325: The code creates tile = make_poly<HouseTile>(...) only
when a house is found but then unconditionally calls
tile.borrow()->safeCall(...), which dereferences a null handle if
getHouse(cachedTile->houseId) missed; fix by checking that the house
pointer/result of getHouse(cachedTile->houseId) is non-null before
creating/using tile and move the tile.borrow()->safeCall(...) inside that branch
(or return/continue after logging the missing house), and apply the same guard
for the similar block around the code referenced at lines 333-347; reference
symbols: getHouse, HouseTile, make_poly<HouseTile>, tile,
PolyPtr<Tile>::Borrowed, safeCall, cachedTile->houseId.
In `@src/map/utils/mapsector.hpp`:
- Around line 40-45: Writes to tiles in setTile and setTileCache must be
protected by the same exclusive mutex used with readers (getTile/getTileCache
use shared_lock); restore an exclusive lock (e.g.
std::unique_lock<std::shared_mutex> or equivalent scoped exclusive lock on
mutex) at the start of both setTile and setTileCache before mutating tiles[x &
SECTOR_MASK][y & SECTOR_MASK].first/second so concurrent readers cannot observe
a torn PolyPtr<Tile>::Owning state.
In `@tests/shared/mounts/mounts_test_fixture.hpp`:
- Around line 47-50: In TearDown() ensure the config restoration cannot silently
fail by checking the return value of
g_configManager().setConfigFileLua(previousConfigFile_) and
g_configManager().reload() (or otherwise verifying reload succeeded) and failing
the test if either call fails; update the TearDown implementation in
mounts_test_fixture.hpp to assert or EXPECT_TRUE/ASSERT_TRUE on the results of
setConfigFileLua and reload (while still restoring
std::filesystem::current_path(previousPath_)) so any config restore failure
causes an immediate test failure and prevents state leakage into later tests.
---
Outside diff comments:
In `@src/creatures/combat/combat.cpp`:
- Around line 346-352: The code dereferences the result of target->getTile()
(assigned to PolyPtr<Tile>::Borrowed tile) without null-checking, causing
crashes when getTile() returns a null Borrowed; add a guard after obtaining tile
to return an appropriate value (e.g., RETURNVALUE_NOTPOSSIBLE or
RETURNVALUE_NOTENOUGHROOM) when tile is null before calling
tile->hasProperty(CONST_PROP_BLOCKPROJECTILE) or
tile->hasFlag(TILESTATE_PROTECTIONZONE), and only call
targetPlayer->hasPermittedConditionInPZ() when tile is non-null.
- Around line 1048-1134: combatTileEffects assumes the borrowed tile is non-null
and will dereference it unconditionally, causing a crash when doCombatDefault
passes a null target->getTile(); fix by adding an early null check at the top of
Combat::combatTileEffects (the function taking PolyPtr<Tile>::Borrowed tile) and
return immediately if tile is null so no item creation,
tileCallback->onTileCombat, sendCombatEffect, or sound calls run against a null
tile.
In `@src/creatures/monsters/monster.cpp`:
- Around line 1583-1588: The callback currently captures the borrowed tile
handle posTile and passes it to Monster::pushCreatures inside
g_dispatcher().addWalkEvent, which can outlive the tile epoch; change the
capture to only copy a stable identifier (e.g., tile coordinates or position)
and in the added walk event re-resolve the tile from the world/map before
calling Monster::pushCreatures. Specifically, update the lambda passed to
g_dispatcher().addWalkEvent so it captures a primitive (like posTile->position()
or x/y/z) instead of posTile, then inside the lambda obtain a fresh tile handle
from the dispatcher/world and call Monster::pushCreatures with that fresh
handle.
In `@src/game/game.cpp`:
- Around line 1394-1405: The callback captures a borrowed
PolyPtr<Tile>::Borrowed (from map.getTile(toPos)) which may be invalid when the
deferred task created via createPlayerTask / addWalkEvent runs; instead capture
the tile's Position (e.g., tile->getPosition()) and any IDs you need, then
inside the deferred lambda re-resolve the tile via map.getTile(resolvedPos)
before calling playerMoveCreatureByID(player->getID(), movingCreature->getID(),
movingCreature->getPosition(), tilePos) (or bail out if the tile no longer
exists), and mirror the changed lambda signature in the corresponding
declaration in the header (game.hpp).
In `@src/items/weapons/weapons.cpp`:
- Around line 321-333: The method Weapon::internalUseWeapon assumes the
PolyPtr<Tile>::Borrowed tile is always valid and dereferences it
(tile->getPosition()); add a guard at the start to handle an empty/expired
borrow (e.g., if !tile) and bail out or handle the missing tile before calling
isLoadedScriptId/executeUseWeapon, Combat::postCombatEffects,
g_game().addMagicEffect, g_game().sendSingleSoundEffect, and onUsedWeapon. Apply
the same null-check pattern to the other overload(s) referenced (lines 335-387)
so no code path dereferences tile when the borrow is empty.
In `@src/server/network/protocol/protocolgame.cpp`:
- Around line 4090-4105: The loop iterates g_game().mounts->getMounts() (an
unordered set) directly causing nondeterministic packet order; fix by copying
the borrowed mounts into a std::vector, sort that vector by a stable key such as
mount->clientId, then iterate the sorted vector when calling
player->hasMount(mount.get()), reading mount->type and mount->clientId and
serializing with msg.add/...; apply the same snapshot-and-sort change to the
other mount-iteration sites referenced (the blocks around the other ranges).
---
Nitpick comments:
In `@src/items/containers/container.hpp`:
- Line 162: The declaration for createBrowseField currently accepts
PolyPtr<Tile>::Borrowed but the docs and parameter name imply a shared_ptr and
are ambiguous; update the function signature comment and parameter name to
reflect the borrowed handle API (e.g., change parameter name from type to
tileHandle or borrowedTile) and revise the docblock to state it takes a
PolyPtr<Tile>::Borrowed borrowed handle (not a shared_ptr), plus ensure any
related factory/overload names match the borrowed-handle convention where
createBrowseField is used.
In `@tests/unit/game/mounts_test.cpp`:
- Around line 56-69: Add a TearDown override to MountsApiTest (which inherits
test::mounts::MountsTestBase) that mirrors GlobalMountsTest::TearDown: call
MountsTestBase::TearDown() and then call drainMounts() to clear retired
WorldPtr<Mount> blocks left by the fixture; this ensures MountsApiTest does not
leave retire-list state for later tests.
- Around line 224-237: The test Reload_DefersOldMountsToQSBR must retain a
pre-reload borrow/handle to an old mount so we can prove reclamation is
deferred: before calling mounts.reload(), obtain and hold a shared/borrowed
reference to one of the existing mount objects (via mounts.getMounts() or the
API that returns a shared_ptr/borrowed handle), then call reload() and run
drainMounts(); assert that the held handle still points to a valid/expected old
mount (i.e., the object was not destroyed), only then release the handle and run
drainMounts() again and assert final cleanup/size changes; update assertions to
reference Mounts::loadFromXml, Mounts::reload, Mounts::getMounts, and
drainMounts to locate the changes.
In `@tests/unit/utils/worldpointer_test.cpp`:
- Around line 241-269: Add a focused regression test that exercises calling
Borrowed::share() after the owner has been dropped but before
WorldPtr<Probe>::quiescentState() to ensure it returns null; specifically create
an Owning via Owning::make(value), obtain a Borrowed view = owner.borrow(),
reset/drop the owner, then call auto promoted = view.share() and
ASSERT_FALSE(promoted) (or EXPECT_EQ(nullptr, promoted.get())) before calling
WorldPtr<Probe>::quiescentState(), finally verify that after quiescentState()
promotion behaves as expected if needed; place this alongside the existing
Borrowed->Shared tests and reference Borrowed::share, Owning::make, and
WorldPtr::quiescentState to locate the code.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 58be47eb-4b2b-48c5-aba0-e10e823efa1a
📒 Files selected for processing (91)
CMakePresets.jsonsrc/creatures/appearance/mounts/mounts.cppsrc/creatures/appearance/mounts/mounts.hppsrc/creatures/appearance/outfit/outfit.cppsrc/creatures/appearance/outfit/outfit.hppsrc/creatures/combat/combat.cppsrc/creatures/combat/combat.hppsrc/creatures/creature.cppsrc/creatures/creature.hppsrc/creatures/monsters/monster.cppsrc/creatures/monsters/monster.hppsrc/creatures/npcs/npc.cppsrc/creatures/npcs/npc.hppsrc/creatures/players/components/player_title.cppsrc/creatures/players/player.cppsrc/creatures/players/player.hppsrc/game/game.cppsrc/game/game.hppsrc/game/movement/teleport.cppsrc/game/movement/teleport.hppsrc/game/scheduling/dispatcher.cppsrc/game/scheduling/dispatcher.hppsrc/io/iomapserialize.cppsrc/io/iomapserialize.hppsrc/items/containers/container.cppsrc/items/containers/container.hppsrc/items/containers/depot/depotchest.cppsrc/items/containers/depot/depotchest.hppsrc/items/containers/depot/depotlocker.cppsrc/items/containers/inbox/inbox.cppsrc/items/containers/inbox/inbox.hppsrc/items/containers/rewards/reward.cppsrc/items/containers/rewards/reward.hppsrc/items/containers/rewards/rewardchest.cppsrc/items/item.cppsrc/items/item.hppsrc/items/thing.cppsrc/items/thing.hppsrc/items/tile.cppsrc/items/tile.hppsrc/items/trashholder.cppsrc/items/weapons/weapons.cppsrc/items/weapons/weapons.hppsrc/lua/callbacks/event_callback.cppsrc/lua/callbacks/event_callback.hppsrc/lua/creature/actions.cppsrc/lua/creature/events.cppsrc/lua/creature/events.hppsrc/lua/creature/movement.cppsrc/lua/creature/movement.hppsrc/lua/functions/core/game/game_functions.cppsrc/lua/functions/creatures/creature_functions.cppsrc/lua/functions/creatures/npc/npc_functions.cppsrc/lua/functions/creatures/player/mount_functions.cppsrc/lua/functions/creatures/player/player_functions.cppsrc/lua/functions/creatures/player/player_functions.hppsrc/lua/functions/items/item_functions.cppsrc/lua/functions/lua_functions_loader.cppsrc/lua/functions/lua_functions_loader.hppsrc/lua/functions/map/house_functions.cppsrc/lua/functions/map/position_functions.cppsrc/lua/functions/map/tile_functions.cppsrc/map/house/house.cppsrc/map/house/house.hppsrc/map/house/housetile.cppsrc/map/map.cppsrc/map/map.hppsrc/map/mapcache.cppsrc/map/mapcache.hppsrc/map/utils/astarnodes.cppsrc/map/utils/astarnodes.hppsrc/map/utils/mapsector.hppsrc/server/network/protocol/protocolgame.cppsrc/server/network/protocol/protocolgame.hppsrc/utils/CMakeLists.txtsrc/utils/worldpointer.cppsrc/utils/worldpointer.hpptests/fixture/config/mounts_test.luatests/fixture/core/XML/mounts.xmltests/fixture/core/XML/outfits.xmltests/integration/event_callbacks/event_callbacks_it.cpptests/shared/mounts/mounts_test_fixture.hpptests/unit/game/CMakeLists.txttests/unit/game/dispatcher_qsbr_test.cpptests/unit/game/mounts_test.cpptests/unit/game/outfits_test.cpptests/unit/game/random_mount_test.cpptests/unit/lua/CMakeLists.txttests/unit/lua/mount_functions_test.cpptests/unit/utils/CMakeLists.txttests/unit/utils/worldpointer_test.cpp
Six concerns from the CodeRabbit review on PR #3975, all variations of "a `Borrowed` outliving its tile's epoch" or "`share()` can now return null after the CAS-loop fix": 1. game.hpp `addTileToClean`: previously inserted `tile.share()` even when promotion fails. Null Shared in `tilesToClean` would never be findable by `removeTileToClean` (which bails on null Borrows). Now promotes once into a local and only inserts on success. 2. items/tile.cpp `safeCall` (both overloads): the assert(pin) was the only check; release builds would silently enqueue an empty pin into the dispatcher. Added a runtime null check that drops the work if the tile was retired between borrowedFromThis() and share(). 3. map/map.cpp `getOrCreateTile`: out-of-range `z` made `getTile()` return empty AND `setTile()` silently drop the new Owning, leaving the local `borrowed` dangling. Early-return empty Borrowed before constructing anything. 4. map/mapcache.cpp tile-cache promotion: when `getHouse(houseId)` misses, `tile` stayed empty but the code below dereferenced `borrowed = tile.borrow()` (`internalAddThing`, `setFlag`, etc.). Bail out — clear the cache entry and return `oldTile`. 5. creatures/monsters/monster.cpp `getNextStep`: the async branch captured the `Borrowed posTile` into `addWalkEvent`'s lambda. The lambda fires next tick, by which point the tile may have been retired. Capture the `Position` and re-resolve via `g_game().map.getTile(pos)` inside the lambda; skip if empty. 6. game/game.cpp `playerMoveThing` + `playerMoveCreature`: same pattern. Both delayed `createPlayerTask` and the outer `addWalkEvent` of `playerMoveCreature` captured the Borrowed destination tile. Snapshot `toPos` on entry, capture position only, re-resolve inside the lambda, null-check before use. The inner `const Position &toPos = toTile->getPosition()` shadow was redundant after the change — removed. Tests: - tests/shared/mounts/mounts_test_fixture.hpp: TearDown now `EXPECT_TRUE`s `g_configManager().reload()` so a botched restore surfaces as a test failure instead of leaking dirty config state into the next test. Skipped from the review (intentionally): - mapsector.hpp setTile/setTileCache locks: the dispatcher is single-threaded by design (worldpointer.hpp:63-67); the shared_lock on the read path is forward-looking. Re-instating the exclusive lock pays an atomic on every tile write today for no benefit. Will be reinstated together with epoch-based MT dispatch. - combat.cpp / weapons.cpp null-`Borrowed` checks: this was the same scenario pre-migration (`shared_ptr<Tile>` could also be null on a detached creature). Not a regression introduced by this PR — defer to a dedicated null-safety pass. - protocolgame.cpp deterministic mount serialization: cosmetic stability concern, defer. - test cosmetic nits (container.hpp docs, drainMounts in extra fixtures, additional regression test for post-retire share): deferrable.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/utils/worldpointer.hpp (3)
402-424: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftMake nullable
Borrowed -> Sharedpromotion explicit.These conversions now have
lock()-style failure semantics, but they still happen implicitly. That makes the new null path easy to miss at call sites that acceptShared, and the resulting bugs show up later as null dereferences instead of compile-time friction.Suggested fix
- [[nodiscard]] operator Shared<Allocator>() const noexcept { + [[nodiscard]] explicit operator Shared<Allocator>() const noexcept { if (!this->tryIncrementReferenceCount()) { return Shared<Allocator> {}; } return Shared<Allocator>(this->get()); }- [[nodiscard]] operator Shared() const noexcept { + [[nodiscard]] explicit operator Shared() const noexcept { if (!world_ptr_poly_detail::tryIncrementStrong(this->header_)) { return Shared {}; } return Shared(this->header_, this->value_); }Also applies to: 1090-1113
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/worldpointer.hpp` around lines 402 - 424, The implicit conversion from Borrowed to Shared (operator Shared<Allocator>() and share()) can now fail and return a null Shared but remains implicit, causing silent nulls at call sites expecting a non-null Shared; update the API to make nullable promotion explicit by removing or making operator Shared<Allocator>() explicit (or marking it as explicit operator Shared<Allocator>() noexcept) and ensure share() is clearly named (e.g., lock() or try_share()) and documents/returns an optional-like result so callers must handle nulls; change usages that relied on implicit conversion to call the explicit method (try_share()/lock()) and add checks for null before dereference, referencing Borrowed::operator Shared<Allocator>(), Borrowed::share(), and tryIncrementReferenceCount() to locate and update all conversions (also apply same change to the other occurrence at the noted range).
869-874:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHide the raw control-block fields.
header_andvalue_are inherited by every public pointer type here, so external code can retarget them without the matching refcount transition and corrupt ownership state.Suggested fix
class Base { + template <typename> + friend struct ::PolyPtr; + public: // Public to allow cross-type (PolyPtr<Derived> → PolyPtr<Base>) // constructors to read the source pair without a maze of friend // declarations across nested templates. Treat as internal — direct // mutation breaks invariants. - Header* header_; - T* value_; +protected: + Header* header_; + T* value_;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/worldpointer.hpp` around lines 869 - 874, Make the raw control-block fields header_ and value_ non-public to prevent external retargeting: move Header* header_ and T* value_ to private, add protected read-only accessors (e.g. Header* header() const and T* value() const) and a single protected helper (e.g. set_control(Header*, T*) or a protected conversion-constructor) that performs the required refcount transition; update PolyPtr conversion constructors to use those helpers so only controlled code can change the control pointers.
1739-1745:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftAnyPtrSelector reintroduces incomplete-type hazard via
std::is_polymorphic_v<T>
AnyPtrSelector<T>probesstd::is_polymorphic_v<T>at alias instantiation time (src/utils/worldpointer.hpp~1739-1745). That runs the trait even whenTmay be forward-declared/incomplete, which the header earlier explicitly avoided forPolyPtr(the code comment calls out that MSVC rejects incomplete records as arguments to__is_polymorphic). The standard/library wording treatsstd::is_polymorphicon an incomplete non-union class type as undefined behavior, so this is a portability/design boundary.Fix by guarding completeness at the selector boundary with a clear diagnostic (e.g. require
sizeof(T)/complete type before evaluatingis_polymorphic_v), or by restructuring so polymorphic detection only happens at a point whereTis complete (similar toPolyPtr<T>::Owning::make).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/worldpointer.hpp` around lines 1739 - 1745, AnyPtrSelector currently evaluates std::is_polymorphic_v<T> even for incomplete/forward-declared T, reintroducing the incomplete-type UB; replace the direct use with a completeness-guarded trait (e.g. add a helper template is_polymorphic_if_complete<T> that defaults to false and uses std::void_t<decltype(sizeof(T))> specialization to evaluate std::is_polymorphic_v<T> only when sizeof(T) is well-formed) and then change AnyPtrSelector to use that helper (keep symbols AnyPtrSelector, AnyPtr, PolyPtr, WorldPtr unchanged so selection logic is identical but safe on incomplete types).
🧹 Nitpick comments (1)
src/utils/worldpointer.hpp (1)
173-196: ⚡ Quick winAdd a focused check around the non-standard-layout
offsetofpath.This logic now intentionally depends on compiler-specific
offsetofbehavior for payloads likestd::string. A tiny compile/runtime test that instantiatesWorldPtrwith a non-standard-layoutTwould lock in the exact contract this change relies on.As per coding guidelines,
**/*.{cpp,hpp,h,cc,cxx}: For C++ changes, prefer focused tests or the smallest practical build/check that validates the touched code.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/worldpointer.hpp` around lines 173 - 196, Add a focused unit/compile-time test that instantiates WorldPtr with a non-standard-layout payload (e.g., T = std::string) and forces the offsetof path to execute (create a Block-like wrapper, set inner_ non-null and call the conversion that uses offsetof(Block, value)) so the code path in WorldPtr, Block, inner_, and the offsetof usage is compiled and exercised; ensure the test is minimal (single TU) and runs under GCC to validate the pragma suppression and that both compilation and a simple runtime check (returned pointer/ref is correct) succeed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/utils/worldpointer.hpp`:
- Around line 402-424: The implicit conversion from Borrowed to Shared (operator
Shared<Allocator>() and share()) can now fail and return a null Shared but
remains implicit, causing silent nulls at call sites expecting a non-null
Shared; update the API to make nullable promotion explicit by removing or making
operator Shared<Allocator>() explicit (or marking it as explicit operator
Shared<Allocator>() noexcept) and ensure share() is clearly named (e.g., lock()
or try_share()) and documents/returns an optional-like result so callers must
handle nulls; change usages that relied on implicit conversion to call the
explicit method (try_share()/lock()) and add checks for null before dereference,
referencing Borrowed::operator Shared<Allocator>(), Borrowed::share(), and
tryIncrementReferenceCount() to locate and update all conversions (also apply
same change to the other occurrence at the noted range).
- Around line 869-874: Make the raw control-block fields header_ and value_
non-public to prevent external retargeting: move Header* header_ and T* value_
to private, add protected read-only accessors (e.g. Header* header() const and
T* value() const) and a single protected helper (e.g. set_control(Header*, T*)
or a protected conversion-constructor) that performs the required refcount
transition; update PolyPtr conversion constructors to use those helpers so only
controlled code can change the control pointers.
- Around line 1739-1745: AnyPtrSelector currently evaluates
std::is_polymorphic_v<T> even for incomplete/forward-declared T, reintroducing
the incomplete-type UB; replace the direct use with a completeness-guarded trait
(e.g. add a helper template is_polymorphic_if_complete<T> that defaults to false
and uses std::void_t<decltype(sizeof(T))> specialization to evaluate
std::is_polymorphic_v<T> only when sizeof(T) is well-formed) and then change
AnyPtrSelector to use that helper (keep symbols AnyPtrSelector, AnyPtr, PolyPtr,
WorldPtr unchanged so selection logic is identical but safe on incomplete
types).
---
Nitpick comments:
In `@src/utils/worldpointer.hpp`:
- Around line 173-196: Add a focused unit/compile-time test that instantiates
WorldPtr with a non-standard-layout payload (e.g., T = std::string) and forces
the offsetof path to execute (create a Block-like wrapper, set inner_ non-null
and call the conversion that uses offsetof(Block, value)) so the code path in
WorldPtr, Block, inner_, and the offsetof usage is compiled and exercised;
ensure the test is minimal (single TU) and runs under GCC to validate the pragma
suppression and that both compilation and a simple runtime check (returned
pointer/ref is correct) succeed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bca1d882-3717-4bb5-bcb2-54c85055ed83
📒 Files selected for processing (1)
src/utils/worldpointer.hpp
|
@coderabbitai re: the outside-diff findings in your latest review — addressing each here since they're not inline-replyable:
|
|
Tip For best results, initiate chat on the files or code changes.
✏️ Learnings added
|
There was a problem hiding this comment.
♻️ Duplicate comments (1)
tests/shared/mounts/mounts_test_fixture.hpp (1)
47-52:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAlso assert
setConfigFileLua()succeeds during teardown.If Line 48 fails, Line 51 can still reload the mounts test config and leak global state into later suites.
Suggested fix
void TearDown() override { - (void)g_configManager().setConfigFileLua(previousConfigFile_); + EXPECT_TRUE(g_configManager().setConfigFileLua(previousConfigFile_)) << "Failed to restore previous Lua config path"; // Surface restore failures — otherwise broken config can leak // into subsequent tests and produce flaky cross-test errors. EXPECT_TRUE(g_configManager().reload()) << "Failed to restore previous Lua config"; std::filesystem::current_path(previousPath_); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/shared/mounts/mounts_test_fixture.hpp` around lines 47 - 52, In TearDown(), assert that g_configManager().setConfigFileLua(previousConfigFile_) returns true before calling reload and restoring current_path: replace the void cast with an EXPECT_TRUE or equivalent assertion on the result of g_configManager().setConfigFileLua(previousConfigFile_) (inside the TearDown() method) so failures are surfaced immediately for setConfigFileLua, then proceed to call g_configManager().reload() and std::filesystem::current_path(previousPath_).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@tests/shared/mounts/mounts_test_fixture.hpp`:
- Around line 47-52: In TearDown(), assert that
g_configManager().setConfigFileLua(previousConfigFile_) returns true before
calling reload and restoring current_path: replace the void cast with an
EXPECT_TRUE or equivalent assertion on the result of
g_configManager().setConfigFileLua(previousConfigFile_) (inside the TearDown()
method) so failures are surfaced immediately for setConfigFileLua, then proceed
to call g_configManager().reload() and
std::filesystem::current_path(previousPath_).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: aaa85c11-01d1-46ee-b9bd-5a17fa275943
📒 Files selected for processing (7)
src/creatures/monsters/monster.cppsrc/game/game.cppsrc/game/game.hppsrc/items/tile.cppsrc/map/map.cppsrc/map/mapcache.cpptests/shared/mounts/mounts_test_fixture.hpp
|
@coderabbitai fixed in 7753211 — TearDown now EXPECT_TRUE's both setConfigFileLua() and reload() so a failed path restore surfaces immediately instead of letting the subsequent reload re-apply the fixture config. |
|
Tip For best results, initiate chat on the files or code changes.
(ᵔ ᗢ ᵔ)🐇✨ If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
Two findings from SonarCloud's analysis on PR #3975, both in `src/utils/worldpointer.hpp`: 1. cpp:S6458 MAJOR @ Block(Args&&...) — the variadic forwarding constructor lacked a constraint preventing it from outcompeting the implicitly-declared copy/move constructors when called with a single `Block` argument. Add a `requires` clause that excludes `Args` being exactly one `Block` (any cv/ref). In practice this is unreachable (Block is private to WorldPtr<T> and only ever built via `Owning::make` → `AllocTraits::construct`), but the constraint encodes the intent explicitly. 2. cpp:S3519 BLOCKER @ useCount() — false positive on the offsetof trick. SonarCloud tracks `block = getBlock()` as `&value - 16` and flags `block->reference_count` (at Block offset 0) as a "negative byte offset -16" access relative to `value`. That IS the intentional intrusive layout: Block is `{ refcount, next, value }` and `getBlock()` recovers the block start from `value` via `offsetof(Block, value)`. Mark with `// NOSONAR cpp:S3519` and document the rationale in-place — same pattern used in `src/utils/tools.cpp` for legitimate suppressions. No behavioural change.
Six concerns from the CodeRabbit review on PR #3975, all variations of "a `Borrowed` outliving its tile's epoch" or "`share()` can now return null after the CAS-loop fix": 1. game.hpp `addTileToClean`: previously inserted `tile.share()` even when promotion fails. Null Shared in `tilesToClean` would never be findable by `removeTileToClean` (which bails on null Borrows). Now promotes once into a local and only inserts on success. 2. items/tile.cpp `safeCall` (both overloads): the assert(pin) was the only check; release builds would silently enqueue an empty pin into the dispatcher. Added a runtime null check that drops the work if the tile was retired between borrowedFromThis() and share(). 3. map/map.cpp `getOrCreateTile`: out-of-range `z` made `getTile()` return empty AND `setTile()` silently drop the new Owning, leaving the local `borrowed` dangling. Early-return empty Borrowed before constructing anything. 4. map/mapcache.cpp tile-cache promotion: when `getHouse(houseId)` misses, `tile` stayed empty but the code below dereferenced `borrowed = tile.borrow()` (`internalAddThing`, `setFlag`, etc.). Bail out — clear the cache entry and return `oldTile`. 5. creatures/monsters/monster.cpp `getNextStep`: the async branch captured the `Borrowed posTile` into `addWalkEvent`'s lambda. The lambda fires next tick, by which point the tile may have been retired. Capture the `Position` and re-resolve via `g_game().map.getTile(pos)` inside the lambda; skip if empty. 6. game/game.cpp `playerMoveThing` + `playerMoveCreature`: same pattern. Both delayed `createPlayerTask` and the outer `addWalkEvent` of `playerMoveCreature` captured the Borrowed destination tile. Snapshot `toPos` on entry, capture position only, re-resolve inside the lambda, null-check before use. The inner `const Position &toPos = toTile->getPosition()` shadow was redundant after the change — removed. Tests: - tests/shared/mounts/mounts_test_fixture.hpp: TearDown now `EXPECT_TRUE`s `g_configManager().reload()` so a botched restore surfaces as a test failure instead of leaking dirty config state into the next test. Skipped from the review (intentionally): - mapsector.hpp setTile/setTileCache locks: the dispatcher is single-threaded by design (worldpointer.hpp:63-67); the shared_lock on the read path is forward-looking. Re-instating the exclusive lock pays an atomic on every tile write today for no benefit. Will be reinstated together with epoch-based MT dispatch. - combat.cpp / weapons.cpp null-`Borrowed` checks: this was the same scenario pre-migration (`shared_ptr<Tile>` could also be null on a detached creature). Not a regression introduced by this PR — defer to a dedicated null-safety pass. - protocolgame.cpp deterministic mount serialization: cosmetic stability concern, defer. - test cosmetic nits (container.hpp docs, drainMounts in extra fixtures, additional regression test for post-retire share): deferrable.
8909918 to
d0d082c
Compare
Two findings from SonarCloud's analysis on PR #3975, both in `src/utils/worldpointer.hpp`: 1. cpp:S6458 MAJOR @ Block(Args&&...) — the variadic forwarding constructor lacked a constraint preventing it from outcompeting the implicitly-declared copy/move constructors when called with a single `Block` argument. Add a `requires` clause that excludes `Args` being exactly one `Block` (any cv/ref). In practice this is unreachable (Block is private to WorldPtr<T> and only ever built via `Owning::make` → `AllocTraits::construct`), but the constraint encodes the intent explicitly. 2. cpp:S3519 BLOCKER @ useCount() — false positive on the offsetof trick. SonarCloud tracks `block = getBlock()` as `&value - 16` and flags `block->reference_count` (at Block offset 0) as a "negative byte offset -16" access relative to `value`. That IS the intentional intrusive layout: Block is `{ refcount, next, value }` and `getBlock()` recovers the block start from `value` via `offsetof(Block, value)`. Mark with `// NOSONAR cpp:S3519` and document the rationale in-place — same pattern used in `src/utils/tools.cpp` for legitimate suppressions. No behavioural change.
Six concerns from the CodeRabbit review on PR #3975, all variations of "a `Borrowed` outliving its tile's epoch" or "`share()` can now return null after the CAS-loop fix": 1. game.hpp `addTileToClean`: previously inserted `tile.share()` even when promotion fails. Null Shared in `tilesToClean` would never be findable by `removeTileToClean` (which bails on null Borrows). Now promotes once into a local and only inserts on success. 2. items/tile.cpp `safeCall` (both overloads): the assert(pin) was the only check; release builds would silently enqueue an empty pin into the dispatcher. Added a runtime null check that drops the work if the tile was retired between borrowedFromThis() and share(). 3. map/map.cpp `getOrCreateTile`: out-of-range `z` made `getTile()` return empty AND `setTile()` silently drop the new Owning, leaving the local `borrowed` dangling. Early-return empty Borrowed before constructing anything. 4. map/mapcache.cpp tile-cache promotion: when `getHouse(houseId)` misses, `tile` stayed empty but the code below dereferenced `borrowed = tile.borrow()` (`internalAddThing`, `setFlag`, etc.). Bail out — clear the cache entry and return `oldTile`. 5. creatures/monsters/monster.cpp `getNextStep`: the async branch captured the `Borrowed posTile` into `addWalkEvent`'s lambda. The lambda fires next tick, by which point the tile may have been retired. Capture the `Position` and re-resolve via `g_game().map.getTile(pos)` inside the lambda; skip if empty. 6. game/game.cpp `playerMoveThing` + `playerMoveCreature`: same pattern. Both delayed `createPlayerTask` and the outer `addWalkEvent` of `playerMoveCreature` captured the Borrowed destination tile. Snapshot `toPos` on entry, capture position only, re-resolve inside the lambda, null-check before use. The inner `const Position &toPos = toTile->getPosition()` shadow was redundant after the change — removed. Tests: - tests/shared/mounts/mounts_test_fixture.hpp: TearDown now `EXPECT_TRUE`s `g_configManager().reload()` so a botched restore surfaces as a test failure instead of leaking dirty config state into the next test. Skipped from the review (intentionally): - mapsector.hpp setTile/setTileCache locks: the dispatcher is single-threaded by design (worldpointer.hpp:63-67); the shared_lock on the read path is forward-looking. Re-instating the exclusive lock pays an atomic on every tile write today for no benefit. Will be reinstated together with epoch-based MT dispatch. - combat.cpp / weapons.cpp null-`Borrowed` checks: this was the same scenario pre-migration (`shared_ptr<Tile>` could also be null on a detached creature). Not a regression introduced by this PR — defer to a dedicated null-safety pass. - protocolgame.cpp deterministic mount serialization: cosmetic stability concern, defer. - test cosmetic nits (container.hpp docs, drainMounts in extra fixtures, additional regression test for post-retire share): deferrable.
d0d082c to
d546d47
Compare
Two findings from SonarCloud's analysis on PR #3975, both in `src/utils/worldpointer.hpp`: 1. cpp:S6458 MAJOR @ Block(Args&&...) — the variadic forwarding constructor lacked a constraint preventing it from outcompeting the implicitly-declared copy/move constructors when called with a single `Block` argument. Add a `requires` clause that excludes `Args` being exactly one `Block` (any cv/ref). In practice this is unreachable (Block is private to WorldPtr<T> and only ever built via `Owning::make` → `AllocTraits::construct`), but the constraint encodes the intent explicitly. 2. cpp:S3519 BLOCKER @ useCount() — false positive on the offsetof trick. SonarCloud tracks `block = getBlock()` as `&value - 16` and flags `block->reference_count` (at Block offset 0) as a "negative byte offset -16" access relative to `value`. That IS the intentional intrusive layout: Block is `{ refcount, next, value }` and `getBlock()` recovers the block start from `value` via `offsetof(Block, value)`. Mark with `// NOSONAR cpp:S3519` and document the rationale in-place — same pattern used in `src/utils/tools.cpp` for legitimate suppressions. No behavioural change.
Six concerns from the CodeRabbit review on PR #3975, all variations of "a `Borrowed` outliving its tile's epoch" or "`share()` can now return null after the CAS-loop fix": 1. game.hpp `addTileToClean`: previously inserted `tile.share()` even when promotion fails. Null Shared in `tilesToClean` would never be findable by `removeTileToClean` (which bails on null Borrows). Now promotes once into a local and only inserts on success. 2. items/tile.cpp `safeCall` (both overloads): the assert(pin) was the only check; release builds would silently enqueue an empty pin into the dispatcher. Added a runtime null check that drops the work if the tile was retired between borrowedFromThis() and share(). 3. map/map.cpp `getOrCreateTile`: out-of-range `z` made `getTile()` return empty AND `setTile()` silently drop the new Owning, leaving the local `borrowed` dangling. Early-return empty Borrowed before constructing anything. 4. map/mapcache.cpp tile-cache promotion: when `getHouse(houseId)` misses, `tile` stayed empty but the code below dereferenced `borrowed = tile.borrow()` (`internalAddThing`, `setFlag`, etc.). Bail out — clear the cache entry and return `oldTile`. 5. creatures/monsters/monster.cpp `getNextStep`: the async branch captured the `Borrowed posTile` into `addWalkEvent`'s lambda. The lambda fires next tick, by which point the tile may have been retired. Capture the `Position` and re-resolve via `g_game().map.getTile(pos)` inside the lambda; skip if empty. 6. game/game.cpp `playerMoveThing` + `playerMoveCreature`: same pattern. Both delayed `createPlayerTask` and the outer `addWalkEvent` of `playerMoveCreature` captured the Borrowed destination tile. Snapshot `toPos` on entry, capture position only, re-resolve inside the lambda, null-check before use. The inner `const Position &toPos = toTile->getPosition()` shadow was redundant after the change — removed. Tests: - tests/shared/mounts/mounts_test_fixture.hpp: TearDown now `EXPECT_TRUE`s `g_configManager().reload()` so a botched restore surfaces as a test failure instead of leaking dirty config state into the next test. Skipped from the review (intentionally): - mapsector.hpp setTile/setTileCache locks: the dispatcher is single-threaded by design (worldpointer.hpp:63-67); the shared_lock on the read path is forward-looking. Re-instating the exclusive lock pays an atomic on every tile write today for no benefit. Will be reinstated together with epoch-based MT dispatch. - combat.cpp / weapons.cpp null-`Borrowed` checks: this was the same scenario pre-migration (`shared_ptr<Tile>` could also be null on a detached creature). Not a regression introduced by this PR — defer to a dedicated null-safety pass. - protocolgame.cpp deterministic mount serialization: cosmetic stability concern, defer. - test cosmetic nits (container.hpp docs, drainMounts in extra fixtures, additional regression test for post-retire share): deferrable.
548f3cc to
71680d0
Compare
Two findings from SonarCloud's analysis on PR #3975, both in `src/utils/worldpointer.hpp`: 1. cpp:S6458 MAJOR @ Block(Args&&...) — the variadic forwarding constructor lacked a constraint preventing it from outcompeting the implicitly-declared copy/move constructors when called with a single `Block` argument. Add a `requires` clause that excludes `Args` being exactly one `Block` (any cv/ref). In practice this is unreachable (Block is private to WorldPtr<T> and only ever built via `Owning::make` → `AllocTraits::construct`), but the constraint encodes the intent explicitly. 2. cpp:S3519 BLOCKER @ useCount() — false positive on the offsetof trick. SonarCloud tracks `block = getBlock()` as `&value - 16` and flags `block->reference_count` (at Block offset 0) as a "negative byte offset -16" access relative to `value`. That IS the intentional intrusive layout: Block is `{ refcount, next, value }` and `getBlock()` recovers the block start from `value` via `offsetof(Block, value)`. Mark with `// NOSONAR cpp:S3519` and document the rationale in-place — same pattern used in `src/utils/tools.cpp` for legitimate suppressions. No behavioural change.
Six concerns from the CodeRabbit review on PR #3975, all variations of "a `Borrowed` outliving its tile's epoch" or "`share()` can now return null after the CAS-loop fix": 1. game.hpp `addTileToClean`: previously inserted `tile.share()` even when promotion fails. Null Shared in `tilesToClean` would never be findable by `removeTileToClean` (which bails on null Borrows). Now promotes once into a local and only inserts on success. 2. items/tile.cpp `safeCall` (both overloads): the assert(pin) was the only check; release builds would silently enqueue an empty pin into the dispatcher. Added a runtime null check that drops the work if the tile was retired between borrowedFromThis() and share(). 3. map/map.cpp `getOrCreateTile`: out-of-range `z` made `getTile()` return empty AND `setTile()` silently drop the new Owning, leaving the local `borrowed` dangling. Early-return empty Borrowed before constructing anything. 4. map/mapcache.cpp tile-cache promotion: when `getHouse(houseId)` misses, `tile` stayed empty but the code below dereferenced `borrowed = tile.borrow()` (`internalAddThing`, `setFlag`, etc.). Bail out — clear the cache entry and return `oldTile`. 5. creatures/monsters/monster.cpp `getNextStep`: the async branch captured the `Borrowed posTile` into `addWalkEvent`'s lambda. The lambda fires next tick, by which point the tile may have been retired. Capture the `Position` and re-resolve via `g_game().map.getTile(pos)` inside the lambda; skip if empty. 6. game/game.cpp `playerMoveThing` + `playerMoveCreature`: same pattern. Both delayed `createPlayerTask` and the outer `addWalkEvent` of `playerMoveCreature` captured the Borrowed destination tile. Snapshot `toPos` on entry, capture position only, re-resolve inside the lambda, null-check before use. The inner `const Position &toPos = toTile->getPosition()` shadow was redundant after the change — removed. Tests: - tests/shared/mounts/mounts_test_fixture.hpp: TearDown now `EXPECT_TRUE`s `g_configManager().reload()` so a botched restore surfaces as a test failure instead of leaking dirty config state into the next test. Skipped from the review (intentionally): - mapsector.hpp setTile/setTileCache locks: the dispatcher is single-threaded by design (worldpointer.hpp:63-67); the shared_lock on the read path is forward-looking. Re-instating the exclusive lock pays an atomic on every tile write today for no benefit. Will be reinstated together with epoch-based MT dispatch. - combat.cpp / weapons.cpp null-`Borrowed` checks: this was the same scenario pre-migration (`shared_ptr<Tile>` could also be null on a detached creature). Not a regression introduced by this PR — defer to a dedicated null-safety pass. - protocolgame.cpp deterministic mount serialization: cosmetic stability concern, defer. - test cosmetic nits (container.hpp docs, drainMounts in extra fixtures, additional regression test for post-retire share): deferrable.
a67cd9a to
c5abc59
Compare
Add an environment block to the base configure preset setting VSLANG=1033 and tidy field ordering (generator, hidden, name moved). This enforces a consistent Visual Studio language/locale for builds and CI, while preserving the existing Ninja generator and other cache variables.
Adds an affine pointer pair plus a polymorphic variant on top, designed
to replace shared_ptr on hot read paths in the world model. The legacy
shared_ptr stays at the boundary; the affine layer sits above it so
reference-counting work is paid only where the value LEAVES the world
(Lua, network), not on every traversal.
Two type families in `src/utils/worldpointer.hpp`:
WorldPtr<T> (intrusive, single-allocation per object)
- Block { atomic refcount; next; T value } — recovered from `T*` via
offsetof(Block, value). One allocation per object: the control block
is co-located with the value, no separate heap node.
- Owning::make(args...) is the only construction entry point; Shared
has no public T* ctor (only Owning/Borrowed friends build it), so the
affine invariant — at most one Owning per object — cannot be violated
by user code.
- Borrowed: T* cache + observer; zero atomic ops on accessors. Implicit
conversion to Shared uses a CAS-loop (`tryIncrementReferenceCount`)
that REFUSES to resurrect a block whose refcount has already reached
0 — would otherwise be a UAF when the QSBR drain destroys the value
on the next pass.
- Per-(T, Allocator) statics declared `inline static` so each
instantiation has one definition (C++17 ODR), deduplicated by the
linker. Sidesteps the explicit-specialization form that failed to
link under MSVC + unity builds in canary_core.lib.
- Block forwarding ctor guarded by `requires` clause that rejects
`Block(Block&)` so the variadic ctor cannot outcompete the implicit
copy/move ctors (closes cpp:S6458 from SonarCloud).
- is_layout_compatible_v asserts guarded by `__cpp_lib_is_layout_compatible`
feature-test macro — Apple Clang's libc++ on older macOS images
doesn't ship it yet.
- Memory ordering: acq_rel on fetch_sub so the last decrement
synchronises-with the destructor's reads of the value.
- MSVC C++20 rewriter quirks handled — `using Base::operator==;` in
each derived class so derived `operator==(const Derived&)` doesn't
hide the inherited `operator==(nullptr_t)`; `explicit` on nullptr_t
ctors so `wp != nullptr` isn't rewritten through a synthesised
conversion.
PolyPtr<T> (type-erased header, supports polymorphism + Weak)
- Same refcount model as std::shared_ptr / weak_ptr — strong group
destroys the value at strong=0; block freed at weak=0 (strong group
counts as +1 weak).
- Weak<T>: 1 atomic LOAD for expired() / borrowIfAlive(); 1 CAS for
lock() via shared `tryIncrementStrong` helper. Borrow/observe with
no allocator coupling — needed for Item↔Tile and Creature↔Tile where
the legacy shared_ptr cycle pinned both sides forever.
- Owning/Shared destructors NEVER destroy inline: they push the block
to a single global atomic-head retire list. polyPtrQuiescentState()
drains every retired polymorphic block in one call (vs WorldPtr's
per-(T, Allocator) drain). Lock-free CAS push on the hot path.
- sharedDowncast_ / sharedDynamicDowncast_ / enable_borrowed_from_this::
sharedFromThis all use the CAS-loop helper so a Borrowed-derived
Shared promotion can't resurrect a retired block — same protection
as Borrowed::operator Shared().
- weakDowncast_ / weakDynamicDowncast_ do NOT call `fetch_add` on the
weak refcount explicitly — the `Weak(Header*, T*)` ctor already
increments. The explicit add would double-bump and leak the block.
- PolyPtrTransparentHash + Equal (is_transparent) lets hash containers
accept Borrowed in find() without paying a .share() atomic. The
`const T*` overload hashes through `std::hash<const T*>` (GCC's
strict mode rejects `std::hash<T*>{}(const T*&)`).
- static_pointer_cast_poly / dynamic_pointer_cast_poly for typed
downcasts (Tile -> HouseTile, etc.).
NOSONAR cpp:S3519 on `Base::useCount()` documents the intentional
negative-offset access — SonarCloud's static analyzer tracks `block`
as `&value - 16` and flags `reference_count` (Block offset 0) as a
"negative byte offset -16" relative to `value`. That IS the intrusive
layout, not an OOB.
Lua boundary helpers (`src/lua/functions/lua_functions_loader.hpp`)
- pushUserdataAffine<T> / getUserdataAffine<T>: userdata stores
`WorldPtr<T>::Shared` (8 bytes — bare T*), Lua's __gc releases it.
- pushUserdataPoly<T> / getUserdataPoly<T>: userdata stores
`PolyPtr<T>::Shared` (16 bytes — header* + value*), same lifecycle.
- registerAffineClass<T> / registerPolyClass<T>: wire __gc once per
metatable so a single per-type registration covers every userdata of
that type.
No callers yet — the type-by-type migrations (Mounts, Outfits, Tile
hierarchy) and the dispatcher's per-tick `quiescentState` calls land in
follow-up commits.
c5abc59 to
adcfba5
Compare
adcfba5 to
fe8570e
Compare
…tr<T>
Companion to the API commit. ~110 tests covering both type families,
their move/copy semantics, the QSBR drain protocol, cross-type
upcast/downcast, the enable_borrowed_from_this mixin, and the
heterogeneous (transparent) hashers/comparators.
Coverage by category:
WorldPtr<T>
- Default / null / make construction; move ctor and chained moves
with zero atomic ops; move-assign retires old to QSBR.
- Borrowed copies are non-bumping; 1000-access stays at zero refcount
bumps. Implicit Borrowed→Shared via CAS-loop (post-fix from the API
commit) returns null after the strong group has dropped to zero.
- Shared copy bumps refcount; move ctor transfers without bump.
- Reset() defers drop to QSBR. Empty drain is idempotent.
- 100x make/destroy cycle returns liveCount to 0 after drain.
- Over-aligned T (alignas(64)) survives a long move-assign chain —
offsetof recovery works regardless of padding choices.
- Hundred-cycle stress; pending retire list grows then drains.
PolyPtr<T>
- Same construction / move / drain coverage applied to the
polymorphic variant.
- Cross-type upcast (Derived → Base) for Owning/Borrowed/Shared/Weak.
- static_pointer_cast_poly / dynamic_pointer_cast_poly: round-trip,
failure returns null, full lifecycle without leaks.
- Mixed lifetime — Owning + Borrowed + Shared + Weak all observe the
same value; Shared can outlive Owning across QSBR drain.
- Diamond virtual inheritance — offsetof recovery + cross-type cast
survive the standard-layout-incompatible layout.
Weak<T>
- expired() / borrowIfAlive() (1 atomic LOAD); lock() (1 CAS).
- From Shared (does not pin); from Borrowed (explicit ctor — caller
must opt in to extending observability past the current tick).
- Cycle-breaker test: Item-like type owning a child via Weak, drop
parent, child's Weak expires cleanly without holding the cycle.
- Deferred destruction — Shared.~Shared does NOT destroy inline.
- Self-copy/move assign preserves the observation.
Regression tests for the API fixes folded into feat(utils):
- Borrowed::share() returns null after the strong group hits 0
(CAS-loop refuses to resurrect a retired block) — for both
WorldPtr and PolyPtr.
- sharedFromThis() (enable_borrowed_from_this mixin) returns null
after the object's strong group is gone.
- Weak downcast full lifecycle completes without leak (smoke for
the "don't double-bump weak_refcount in weakDowncast_" fix).
- Floor race smoke test under a real shared_mutex + 200 reader
threads, exercises the read path's tolerance for concurrent
writers (compile sanity — production single-thread).
Compile-time GUARD tests (`static_assert`) document the API
invariants so a future refactor can't silently relax them:
- Owning is move-only (copy ctor/assign deleted); Borrowed/Shared/
Weak are copyable.
- No public construction of Shared/Borrowed/Owning from a raw T*
(preserves the affine "only Owning::make creates" pipeline).
- No conversion from Shared/Borrowed/Weak back to Owning.
- Cross-type Owning copy (lvalue) is REJECTED — only rvalue move
allowed (preserves affinity across upcasts).
- Weak(Borrowed) is `explicit` — implicit conversion would let a
tick-bound Borrowed silently extend observability past QSBR.
- Block forwarding ctor rejects Block(B&)/Block(B&&)/Block(const B&)
via `requires` (closes the SonarCloud cpp:S6458 finding).
- All WorldPtr wrappers are standard-layout, 1 pointer wide.
- Mixin `poly_header_` is private (not externally observable).
Runtime DEATH tests (debug-only, guarded by `#ifndef NDEBUG`):
- Dereferencing null Owning/Borrowed/Shared via `*` or `->` aborts
on the internal `assert(get() != nullptr)`. `EXPECT_DEATH_IF_SUPPORTED`
quietly skips on platforms without fork-based death testing.
- Same coverage for PolyPtr variants.
- `Shared::useCount()` on null aborts (would otherwise read a wild
block pointer).
- In release (NDEBUG), `assert()` is a no-op and the death tests are
`#ifdef`'d out — the runtime guard simply doesn't exist there, so
testing it would be testing UB.
Test fixtures introduce probes with liveCount tracking so retirement
and destruction are observable end-to-end. Internal helpers
`pendingQBSR()` / `pendingPolyQBSR()` walk the retire lists directly
to validate deferral semantics — not exposed from the production API.
First migration of a world-owned type to the affine pointer pair.
- Storage: `phmap::parallel_flat_hash_set<OwningMount,
WorldPtrOwningHash<Mount>>` (was `std::shared_ptr<Mount>`). The
Owning destructor handles QSBR uniformly; `reload()` simplifies to
a plain `mounts.clear()`.
- loadFromXml uses `OwningMount::make(args...)` — single allocation,
refcount block co-located with the Mount.
- Getters return `BorrowedMount`: zero atomic ops on lookup.
- `Player::hasMount` signature: `(const Mount*)` — works for both
`.borrow().get()` and `.get()` from any other source. Read-only
membership check; no refcount op needed.
Lua boundary (`mount_functions.cpp`, `player_functions.cpp`
`luaPlayerHasMount`) goes through the affine helpers
(`pushUserdataAffine<Mount>` / `getUserdataAffine<Mount>`). The
userdata holds a `WorldPtr<Mount>::Shared` that participates in the
storage's refcount, so a Lua-held Mount reference survives past a
`mounts.reload()` until Lua collects the userdata.
Call sites that copied `getMounts()` switched to `const auto &` (the
storage's value type is now move-only).
Dispatcher main loop calls `WorldPtr<Mount>::quiescentState` once per
tick, after `mergeEvents()` — the boundary at which no logic thread
is mid-task, safe to drain Mount's deferred drops. Outfit and Tile
each add their own `quiescentState` line in their migration commits;
PolyPtr's single global drain lands with the Tile migration.
`PlayerFunctions::init` exposed as public to match `MountFunctions` and
the rest of the `*Functions` classes (was friend-only via
`CreatureFunctions`; the test suite needs it directly).
Tests targeting the Mount storage's affine boundary and the Lua /
dispatcher integration. Lands in the same logical step as the Mount
refactor so the behavioural contracts are checked at the point they
are introduced, not several commits later.
tests/unit/game/mounts_test.cpp
- Empty / populated Mounts loaded from `tests/fixture/core/XML/
mounts.xml`; case-insensitive name lookup; correct id / clientId
/ speed / premium / type fields.
- Strict zero-bump contract on the lookup path: 100 lookups don't
change the storage refcount.
- Boundary materialisation: a Borrow → Shared bumps the EXISTING
block (no separate refcount); baseline `share()` and the
boundary Shared see the same use_count.
- Lifetime extension: a boundary Shared held across
`mounts.reload()` + QSBR drain keeps the original Mount alive;
reload populates the storage with a freshly constructed Mount
whose address differs.
- Reload defers previous owners to QSBR (3 retires drained on QS);
raw borrows captured before reload remain valid until the next
quiescentState.
- `PlayerHasMountTest` (4 tests, `TEST_F` over `MountsTestBase`):
false-by-default, true after taming, premium gate, storage word
boundary at id 32.
- `GlobalMountsTest` (6 tests): `hasAnyMount`, `getRandomMountId`,
`PlayerTitle::checkMount` end-to-end with `g_game().mounts`
populated from the fixture XML.
tests/unit/lua/mount_functions_test.cpp (10 tests)
- `Mount(id)` and `Mount(name)` factories produce correct userdata
with `getName` / `getId` / `getSpeed` / `getClientId` methods;
nil for missing.
- Boundary share bump: dropping the Lua userdata releases the
boundary refcount; storage's `OwningMount` keeps the Mount
alive.
- `player:hasMount(id)` and `player:hasMount(name)`: false by
default, true after taming, nil for missing, premium gate
blocks non-premium player.
- Fixture initialises Lua state via `CreatureFunctions::init`,
which transitively wires Mount and Player bindings the way
`lua_loader.cpp` does at runtime.
tests/unit/game/dispatcher_qsbr_test.cpp (3 tests)
- End-of-tick drain integration. Spins up the real Dispatcher
singleton (one-time, leaked at process exit; cannot be shut
down safely without affecting later tests).
- Generic sentinel path: retire from the test thread, schedule a
no-op event, poll for the drain. 10 retires drained in a
single tick. `WorldPtr<Mount>::quiescentState` integration —
retire an `OwningMount` from inside the dispatcher's own event
body; the next tick's drain runs the destructor.
- `TearDownTestSuite` calls `std::_Exit` to bypass the static
thread-pool destructor that would deadlock joining the still-
blocked dispatcher worker. ctest spawns one process per
`--gtest_filter`, so this is safe — each fixture process is
done after its test completes.
- Requires `friend class DispatcherQsbrIntegrationTest` on
`src/game/scheduling/dispatcher.hpp`. Production code
unaffected.
`tests/unit/game/random_mount_test.cpp` (existing) converted from
`TEST` to `TEST_F(MountsTestBase)`. The fixture's
`g_configManager().reload()` silences the `ConfigManager::getBoolean`
"invalid index" warnings that used to fire because the config wasn't
initialised on the unit-test path.
Shared infrastructure
- `tests/shared/mounts/mounts_test_fixture.hpp` — `MountsTestBase`
swaps config to the fixture, cd's to repo root, restores both
on `TearDown` (best-effort — `previousConfigFile_` is the
production config which doesn't exist in the test wd).
- `tests/fixture/config/mounts_test.lua` — coreDirectory pointing
at the fixture, `warnUnsafeScripts=false`.
- `tests/fixture/core/XML/mounts.xml` — 3-mount fixture (id 1
premium, id 2 non-premium, id 3 premium) chosen so the premium
gate is exercised.
Local helpers in the new test files
- `makeTestPlayer()` — Player with non-access Group so
`hasMount`'s `isAccessPlayer()` doesn't deref a null group.
- `tameMountBit()` — writes the storage bitmask directly with
`isLogin=true` to bypass the dispatcher event path (unwired in
unit tests).
Mirror of the Mounts migration applied to the per-sex outfit registry.
Same storage shape (a flat container of `OwningOutfit`), same boundary
contract, same QSBR drain integration.
- Storage: `std::vector<OwningOutfit>` per sex (was
`std::vector<std::shared_ptr<Outfit>>`).
- loadFromXml uses `OwningOutfit::make(args...)` — single
allocation, block co-located with the Outfit.
- Getters return a const-ref to the vector (callers iterate over
Owning entries and call `.borrow()` / `.get()` as needed).
- `Player::getOutfitAddons` signature: `(const Outfit*, uint8_t&)`.
Read-only addons lookup; no refcount op needed for the parameter.
Call sites in `protocolgame.cpp` (`sendCyclopediaCharacterOutfitsMounts`,
`sendOutfitWindow`, `sendPodiumWindow`) switched the local copies of
`getOutfits()` from `const auto` (which would deep-copy the now
move-only vector) to `const auto &`, and pass `outfit.get()` to
`getOutfitAddons`.
Dispatcher main loop gains a per-tick `WorldPtr<Outfit>::quiescentState`
call alongside `Mount`'s. Each affine type pays one drain per tick;
types share the same per-tick boundary.
Mirror of the Mounts test suite applied to the per-sex Outfit
registry. Same affine-boundary contracts (move-only Owning, zero-bump
Borrowed lookups, retire-until-QS reload), exercised against the new
`std::vector<OwningOutfit>` storage.
tests/unit/game/outfits_test.cpp (12 tests, fixture
`tests/fixture/core/XML/outfits.xml`)
- Empty / populated Outfits per sex; reload defers previous owners
to QSBR.
- Strict zero-bump lookup contract; boundary materialisation from a
Borrow → Shared bumps the EXISTING block.
- Lifetime extension across reload + QSBR drain.
- `Player::getOutfitAddons(const Outfit*)` end-to-end with
`g_game()` populated from the fixture XML.
The fixture reuses `MountsTestBase` from the test(mounts) commit —
same config bootstrap (cd to repo root, swap config to the test
directory, restore on TearDown) is needed for any test that
constructs a Player.
Move Tile and its derivatives off `std::shared_ptr<Tile>` and onto the
polymorphic affine pointer (`PolyPtr<T>`). Two motivations:
- Unblock the world-pointer migration for the rest of the game graph
(Cylinder, Thing, Item all flow through Tile).
- Break a real reference cycle that had been pinning Tiles past their
useful lifetime: Tile owns Item via the `items` vector
(`shared_ptr<Item>`); Item.m_parent previously stored
`Shared<Tile>`, pinning the Tile back. Same on Creature.m_tile. Net
effect: refcount never reached zero, Tiles outlived map reloads
and house transfers.
PolyPtr breaks the cycle by giving us a Weak. Item.m_parent and
Creature.m_tile now store `Weak<Tile>` — observes without pinning.
Borrow on demand via `m_parent.borrowIfAlive()` / `m_tile.borrowIfAlive()`,
1 atomic LOAD per read.
Tile / HouseTile / StaticTile / DynamicTile are PolyPtr-managed. Floor
owns its tiles via `PolyPtr<Tile>::Owning`; `getTile` returns Borrowed
for read paths. The legacy `shared_ptr<Cylinder>` boundary (still
required by Cylinder/Thing callers that haven't migrated yet) is
bridged through `Tile::getCylinder()`, which returns a `shared_ptr`
with a custom deleter that captures a `PolyPtr<Tile>::Shared` — pins
the tile for the entire shared_ptr lifetime. Cost: 1 heap alloc + 1
atomic ADD per call; only paid at boundaries to the un-migrated APIs.
Cross-cutting changes
- `Player::sendAddTileItem`, `sendUpdateTileItem`, `sendUpdateTile`,
`onUpdateTileItem`, `onRemoveTileItem`, `onCreatureMove` switched
to `PolyPtr<Tile>::Borrowed` parameters — same in
`ProtocolGame::GetTileDescription` / `sendUpdateTile`.
- `Game::internalMoveCreature`, `internalMoveItem`, `internalAddItem`,
`findItemOfType`, `playerMoveCreature` add `PolyPtr<Tile>::Borrowed`
overloads that promote to `getCylinder()` internally. Avoids
rewriting every Cylinder caller in this commit.
- `Game::browseFields` and `tilesToClean` switched to
`PolyPtr<Tile>::Shared` (pins the tile across ticks) with
`PolyPtrTransparentHash` / `Equal` so `find()` / `erase()` accept
`Borrowed` without paying `share()` just to query.
`addTileToClean` promotes the share once into a local and only
inserts on success — `tile.share()` can return null after the
CAS-loop fix, and a null in the set would never be erased.
- `HouseTile` casts switched from `std::dynamic_pointer_cast<HouseTile>`
on Shared to `dynamic_cast<HouseTile*>(borrowed.get())` — read-only,
no refcount op.
- `House::addTile` uses `dynamic_pointer_cast_poly` (checked) instead
of the unchecked static cast that previously stored junk on a non-
HouseTile slipping through.
- `HouseTile::queryDestination` preserves the legacy "never returns
null" invariant in the degenerate (no entry, no temple) path by
self-referencing via `getCylinder()`. Callers chain
`queryDestination(...)->getTile()` without null-check; a null here
would crash.
- `Map::moveCreature` async branch (TaskGroup::Walk path) captures
`oldTile` / `newTile` as Shared (`.share()`) before the deferred
lambda fires. Without the pin, a concurrent `Floor::setTile`
retiring either tile would have it destroyed by the next-tick QSBR
drain before the lambda ran — UAF. Sync branch inlines and is safe
without the bump. Lambda null-guards both pins (Shared may be null
if retire happened between observation and `.share()`).
- `Tile::safeCall` (both overloads) does a runtime null-check on the
`share()` result; in release builds the `assert` evaporates, so a
raw retire/share race would silently enqueue an event with an
empty pin. The runtime check drops the work instead.
- `Map::getOrCreateTile` bails out early when `z >= MAP_MAX_LAYERS`
so `setTile`'s silent OOB-drop can't leave us returning a Borrowed
of a locally-destroyed Owning.
- `MapCache::getOrCreateTileFromCache` clears the cache entry and
returns `oldTile` when `getHouse(houseId)` misses — recoverable
bad houseId path; the code below would otherwise dereference an
empty `tile`.
- `Monster::pushCreatures` / `Game::playerMoveThing` / `Game::
playerMoveCreature` callbacks that cross the dispatcher boundary
capture a `Position` and re-resolve the tile inside the lambda
instead of capturing the `Borrowed` (which can become stale by
the time the deferred event fires).
- `Map::placeCreature` signature migrated to
`PolyPtr<Tile>::Borrowed centerTile = {}` (Unity-build miss in the
initial draft).
- `addItemToCylinder` local helper in `game.cpp` bridged via
`getCylinder()` at the one Tile-typed call site; non-null already
guaranteed by the surrounding `FLAG_DROPONMAP` check.
- `astarnodes.hpp` direct-includes `utils/worldpointer.hpp`; Unity
bundling exposed the latent transitive-inclusion fragility.
- `Floor::setTileCache` storage `.second` reverted from
`shared_ptr<BasicTile>` to `const BasicTile*` — the BasicTile is
owned by `MapCache::retainedBasicTiles`; Floor was always meant
to observe.
- `Thing::getPosition() const` overload uses a `static const
Position` sentinel `{0xFFFF, 0xFFFF, 0xFF}` (the legacy
`Tile::nullptr_tile` static was removed by the migration).
Dispatcher main loop adds `polyPtrQuiescentState()` after the Mount /
Outfit drains. PolyPtr shares a single global retire list across every
polymorphic type (Tile, future Item, Cylinder, …), so one drain
handles them all. After the worker loop exits, a final trio of drains
runs so destructors don't get skipped on shutdown (the in-loop drain
is end-of-tick; if a handler retires in the LAST iteration, the value
dtor would otherwise never fire — masked by process exit reclaiming
memory, but corrupts test runs that rely on global tear-down).
Header overhead: +16 bytes per PolyPtr block (`weak_refcount` +
`DeleterTable*`). On a fully populated map (~10M tiles) that's
~160 MB. Per-call costs match legacy `shared_ptr` on the slow path
and beat it on the read path (Borrowed access is zero atomic ops).
Style: dropped the `typename` keyword on ~170 occurrences across these
files where the inner argument of `PolyPtr<T>::Borrowed` is a
concrete type (Tile, HouseTile, etc.) — C++20 (P0634R3) made
`typename` optional in non-dependent contexts. Kept the ~58 uses
where the inner argument is genuinely dependent (`T`, `Derived`,
`Self`, `Base`, `Concrete`). Zero semantic change; identical codegen.
Two test files needed signature updates after the Tile→PolyPtr
migration:
- `tests/integration/event_callbacks/event_callbacks_it.cpp`:
`EventCallbacksIntegrationTest::ReturnEventsReturnNoErrorAndCallOnce`
dispatches `creatureOnAreaCombat`, whose Tile parameter is now
`PolyPtr<Tile>::Borrowed`. Update the empty-Tile sentinel
(`PolyPtr<Tile>::Borrowed {}`) so the call compiles against the
new callback API.
- `tests/unit/players/condition/player_paralyze_walk_exhaust_test.cpp`:
pre-existing test (added in `eb9e6808d fix: correct paralyze walk
exhaust (#3943)`) that constructed `std::make_shared<DynamicTile>`
and passed them to `Creature::onCreatureMove` — both
incompatible with the new signatures. Migrated helpers to
`PolyPtr<Tile>::Borrowed` parameters and test bodies to
`make_poly<DynamicTile>(pos)` upcast into a `PolyPtr<Tile>::Owning`
local; the implicit Owning→Borrowed conversion handles the
helper calls. Same coverage; corrected types under GCC's
stricter conversion rules.
No semantic change in either test — same scenarios, same coverage.
Five new docs under `docs/systems/world-ptr/`:
- `README.md` — overview, motivation, which family to pick
(`WorldPtr<T>` vs `PolyPtr<T>`), quick example, navigation hub.
- `api-reference.md` — every public type, member function, and
free function with signatures, semantics, and worked examples.
Covers `Owning` / `Borrowed` / `Shared` / `Weak`, `make` /
`make_poly`, `static_pointer_cast_poly` /
`dynamic_pointer_cast_poly`, `enable_borrowed_from_this`,
transparent hashers/comparators, `AnyPtr`, Lua boundary
helpers, and the compile-time invariants.
- `usage-guide.md` — patterns, best practices, anti-patterns,
boundary-crossing rules (within-tick, dispatcher events,
threads), Lua integration, performance characteristics
table.
- `migration-guide.md` — step-by-step recipe for porting a
`shared_ptr<T>`-based type, the decision tree for picking
`WorldPtr` vs `PolyPtr`, worked diffs against the Mount /
Outfit / Tile migrations in this PR, revert protocol.
- `internals.md` — control-block layout (intrusive offsetof for
WorldPtr, type-erased Header for PolyPtr), refcount protocol
(single counter vs strong/weak split), the QSBR drain
algorithm, multi-threading model and what still needs to
change to support MT dispatch, why offsetof works for STL
types whose standard-layout-ness is technically
implementation-defined, the rationale for `#ifndef NDEBUG`
death tests.
Follows the layout convention of `docs/systems/livestream/`
(README hub + topic-specific files). All five are cross-linked.
Resolves conflict in src/lua/functions/creatures/player/mount_functions.cpp by keeping the branch-side migration to the PolyPtr/Affine API (`registerAffineClass<Mount>`, `Mounts::BorrowedMount`, `pushUserdataAffine`, `getUserdataAffine`). Main's #3987 introduced a typed `registerSharedClass<T>` helper and migrated Mount to that path; our branch eliminates `shared_ptr<Mount>` from the Lua boundary entirely, so the affine variant supersedes the typed-shared variant for this binding. The other 17 files migrated by #3987 auto-merged cleanly because they don't touch Mount/Outfit/Tile.
fe8570e to
b059d32
Compare
|



Summary
Introduces
WorldPtr<T>andPolyPtr<T>— an affine pointer pair on top ofstd::shared_ptrthat pays the atomic refcount only at the world boundary (Lua, network), not on every read traversal. Three storage migrations land on top: Mounts, Outfits, and the Tile hierarchy.What's in here
API —
src/utils/worldpointer.hppWorldPtr<T>(intrusive, 8 BBorrowed):Owning(move-only, single per object) /Borrowed(non-pinning view, zero atomic ops) /Shared(boundary refcount).Blockis recovered from theT*viaoffsetof(Block, value). Per-(T, Allocator)quiescentState()drain.PolyPtr<T>(type-erased header, 16 B): polymorphic variant for hierarchies (Tile, Item, Cylinder, Thing). AddsWeak<T>so Item↔Tile cycles can be broken. Single global retire list — onepolyPtrQuiescentState()drains every type.pushUserdataAffine/pushUserdataPoly,registerAffineClass/registerPolyClass, matching__gcmetamethods.Migrations
refactor(mounts):phmap::parallel_flat_hash_set<OwningMount>;Player::hasMount(const Mount*); Lua boundary usesWorldPtr<Mount>::Shared.refactor(outfits):std::vector<OwningOutfit>per sex;Player::getOutfitAddons(const Outfit*, uint8_t&).refactor(tile): full hierarchy migrated toPolyPtr<Tile>::Owning(Floor) /Borrowed(read paths) /Weak(Item.m_parent, Creature.m_tile to break refcount cycles).Tile::getCylinder()bridges to legacyshared_ptr<Cylinder>callers with a custom-deleter facade that pins viaShared(1 alloc + 1 atomic per call — boundary cost only).Dispatcher —
quiescentStatecalled per migrated type at end-of-tick, plus a final drain after the worker loop exits so destructors don't get skipped on shutdown.Tests — per-migration suites
test(utils): 19+ tests covering both type families, move semantics, zero-bump contracts, retire-until-QS, Weak ↔ Shared,offsetofon over-aligned T.test(mounts): 28+ tests includingMountsTestBasefixture, strict zero-bump lookup, lifetime extension across reload, Lua boundary suite, dispatcher QSBR integration.test(outfits): 12 tests mirroring Mounts.test(tile): integration callback signature adaptation.Bug fixes folded in (caught by review + regression-hunter audit)
fix(world-ptr)Borrowed→Shared resurrection: 5 call sites used a barefetch_addon strong_refcount — could silently bump from 0, resurrecting a retired block. Replaced with CAS-loop (tryIncrementStrong/Block::tryIncrementReferenceCount) that returns null Shared when expired, same asweak_ptr::lock().fix(map): null-guard atMap::moveCreatureasync branch to handle the new nullable-Shared contract.fix(world-ptr)Weak downcast:weakDowncast_andweakDynamicDowncast_didfetch_addAND theWeak(Header*, T*)ctor also incremented — every cross-type Weak cast leaked the block. Removed the explicit add.fix(dispatcher)shutdown drain.fix(tile): five migration misses exposed by Unity build (Map::placeCreaturesignature,addItemToCylinderbridge,astarnodes.hppmissing include,Floor::setTileCachestorage reverted toconst BasicTile*since MapCache owns the cache viaretainedBasicTiles,Thing::getPosition() conststatic Position fallback).Performance impact
getTile()/ Borrowed propagation: −2 atomics per call vsshared_ptr<Tile>return by value.Weak<Tile>::borrowIfAlive(): 1 atomic LOAD vsweak_ptr::lock()'s CAS.Tile::getCylinder(): +1 alloc + 1 atomic per call vs legacyshared_ptr<Tile>return — accepted bridge cost for un-migratedshared_ptr<Cylinder>APIs.PolyPtrheader overhead: +16 B per block (weak_refcount+DeleterTable*). For a fully populated map (~10M tiles) that's ~160 MB.Reviewer guide
fix(...)commits at the end are the post-review hardening.world_ptr_poly_detail::tryIncrementStrong(PolyPtr) andBlock::tryIncrementReferenceCount(WorldPtr) — single audit point.reload()callers (sighupHandler,GameReload, tests) run on the dispatcher thread. Multi-thread dispatch would need atomic retire push for WorldPtr (PolyPtr already lock-free).Test plan
ctest --test-dir build/windows-debug—canary_unittestgreen.Summary by CodeRabbit
Refactor
Tests