Housing: Fix 0x20 crash root cause — Stationary flag on MeshObjects

Retail MeshObjects have HasMeshObject=true, Stationary=false. Our
MeshObject constructor had BOTH Stationary=true AND MeshObject=true.
The extra Stationary block (16 bytes: X,Y,Z,O) shifted all subsequent
parsing of the MeshObject block and fragment data, corrupting the
client's entity data and causing NULL+0x20 crashes.

Sniff-verified: retail interior components have Stationary=false and
HasMeshObject=true. Also restored IsRoom=true on components (sniff
confirms ALL retail room components have IsRoom=true).
This commit is contained in:
luis
2026-04-06 13:40:53 -03:00
parent 06af862469
commit b09b8f5d37
4 changed files with 266 additions and 294 deletions
@@ -24,15 +24,6 @@
#include "UpdateData.h"
#include "WowCSEntityDefinitions.h"
namespace
{
inline void BuildHousingEntityFragments(ByteBuffer& data, std::span<WowCS::EntityFragment const> fragments)
{
data.append(fragments.data(), fragments.size());
data << uint8(WowCS::EntityFragment::End);
}
}
HousingRoomEntity::HousingRoomEntity()
: WorldObject(false)
{
@@ -106,7 +97,7 @@ void HousingRoomEntity::BuildCreateUpdateBlockForPlayer(UpdateData* data, Player
std::size_t sizePos = buf.wpos();
buf << uint32(0);
buf << uint8(fieldFlags);
BuildHousingEntityFragments(buf, m_entityFragments.GetIds());
BuildEntityFragments(buf, m_entityFragments.GetIds());
for (std::size_t i = 0; i < m_entityFragments.UpdateableCount; ++i)
{
@@ -25,7 +25,10 @@ MeshObject::MeshObject() : WorldObject(false), MapObject()
{
m_objectTypeId = TYPEID_MESH_OBJECT;
m_updateFlag.Stationary = true;
// Retail MeshObjects: HasPositionFragment=true (from Object), Stationary=false,
// HasMeshObject=true. The MeshObject block writes AttachParentGUID + local pos/rot.
// Stationary MUST be false ? it writes extra position data that shifts parsing.
m_updateFlag.Stationary = false;
m_updateFlag.MeshObject = true;
m_entityFragments.Add(WowCS::EntityFragment::Tag_MeshObject, false);
@@ -281,9 +284,14 @@ void MeshObject::InitHousingRoomData(ObjectGuid houseGuid, int32 houseRoomID,
if (m_housingRoomData.has_value())
return;
// Set IsRoom=true ? this MeshObject has FHousingRoom_C, so the client's
// MeshObjectSystem can safely read FHousingRoom_C.Flags from it.
{
auto meshData = m_values.ModifyValue(&MeshObject::m_meshObjectData);
SetUpdateFieldValue(meshData.ModifyValue(&UF::MeshObjectData::IsRoom), true);
}
// Populate HousingRoomData (FHousingRoom_C fragment data).
// Sniff-verified: Room entities carry HouseGUID, HouseRoomID, Flags, FloorIndex,
// and a MeshObjects array referencing attached room component MeshObjects.
auto roomData = m_values.ModifyValue(&Object::m_housingRoomData, 0);
SetUpdateFieldValue(roomData.ModifyValue(&UF::HousingRoomData::HouseGUID), houseGuid);
SetUpdateFieldValue(roomData.ModifyValue(&UF::HousingRoomData::HouseRoomID), houseRoomID);
@@ -352,7 +360,7 @@ void MeshObject::InitHousingRoomComponentData(ObjectGuid roomGuid,
if (m_housingRoomComponentMeshData.has_value())
return;
// Set IsRoom = true on MeshObjectData so the client treats this as a room mesh
// Sniff-verified: ALL retail room component MeshObjects have IsRoom=true.
{
auto meshData = m_values.ModifyValue(&MeshObject::m_meshObjectData);
SetUpdateFieldValue(meshData.ModifyValue(&UF::MeshObjectData::IsRoom), true);
@@ -188,7 +188,7 @@ void BaseEntity::BuildValuesUpdateBlockForPlayer(UpdateData* data, Player const*
data->AddUpdateBlock();
}
inline void BaseEntity::BuildEntityFragments(ByteBuffer& data, std::span<WowCS::EntityFragment const> fragments)
void BaseEntity::BuildEntityFragments(ByteBuffer& data, std::span<WowCS::EntityFragment const> fragments)
{
data.append(fragments.data(), fragments.size());
data << uint8(WowCS::EntityFragment::End);
+252 -279
View File
@@ -45,11 +45,11 @@
HouseInteriorMap::HouseInteriorMap(uint32 id, time_t expiry, uint32 instanceId, ObjectGuid const& owner)
: Map(id, expiry, instanceId, DIFFICULTY_NORMAL),
_owner(owner),
_loadingPlayer(nullptr),
_sourceNeighborhoodMapId(0),
_sourcePlotIndex(0),
_roomsSpawned(false)
_owner(owner),
_loadingPlayer(nullptr),
_sourceNeighborhoodMapId(0),
_sourcePlotIndex(0),
_roomsSpawned(false)
{
HouseInteriorMap::InitVisibilityDistance();
@@ -152,7 +152,7 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
// 2. RoomWmoData ? Geobox bounds (bounding box for OutsidePlotBounds check)
float geoMinX = -35.0f, geoMinY = -30.0f, geoMinZ = -1.01f;
float geoMaxX = 35.0f, geoMaxY = 30.0f, geoMaxZ = 125.01f;
float geoMaxX = 35.0f, geoMaxY = 30.0f, geoMaxZ = 125.01f;
RoomWmoDataEntry const* wmoData = roomWmoDataID ? sRoomWmoDataStore.LookupEntry(roomWmoDataID) : nullptr;
if (wmoData)
{
@@ -206,20 +206,33 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
GridCoord roomGrid = Trinity::ComputeGridCoord(roomX, roomY);
GridMarkNoUnload(roomGrid.x_coord, roomGrid.y_coord);
// --- Phase 1: Create component MeshObjects ---
// Retail has TWO entity types per room:
// 1. Component MeshObjects (objectType=56, FHousingRoomComponentMesh_C) ? visual meshes
// 2. HousingRoomEntity (objectType=18, Housing/2 GUID, FHousingRoom_C) ? room data + doors
// The MeshObjects attach to the HousingRoomEntity via AttachParentGUID = room->Guid.
// We do NOT create a separate "root" MeshObject with FHousingRoom_C ? that causes
// duplicate FHousingRoom_C in the client's archetype query, and the MeshObject GUID
// (not Housing type) makes GetRoomGUID() return nil in the layout editor.
int32 roomFlags = roomData->IsBaseRoom() ? 1 : 0;
int32 floorIndex = 0;
ObjectGuid roomHousingGuid = room->Guid; // Housing/2 GUID for attach parent
// --- Phase 1b: Create all component MeshObjects ---
// --- Phase 1: Create HousingRoomEntity FIRST ---
// Must exist on the map BEFORE component MeshObjects because components use
// AttachParentGUID = roomHousingGuid. The client resolves this GUID during
// entity creation ? if the parent doesn't exist, it crashes (NULL+0x20).
// Door data is added after components are created (Phase 3).
HousingRoomEntity* housingRoom = new HousingRoomEntity();
PhasingHandler::InitDbPhaseShift(housingRoom->GetPhaseShift(), PHASE_USE_FLAGS_ALWAYS_VISIBLE, 0, 0);
housingRoom->SetHouseGUID(housing->GetHouseGuid());
housingRoom->SetHouseRoomID(room->RoomEntryId);
housingRoom->SetFlags(roomFlags);
housingRoom->SetFloorIndex(floorIndex);
housingRoom->SetMirroredPosition(roomPos, roomRot, 1.0f);
if (!housingRoom->Create(roomHousingGuid, this, roomPos))
{
TC_LOG_ERROR("housing", "HouseInteriorMap: Failed to add HousingRoomEntity to map (roomEntry={})",
room->RoomEntryId);
delete housingRoom;
continue;
}
_roomEntities.push_back(housingRoom);
// --- Phase 2: Create all component MeshObjects ---
std::vector<MeshObject*> componentMeshes;
uint32 wallCount = 0, floorCount = 0, ceilingCount = 0, doorwayCount = 0;
@@ -270,10 +283,10 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
// Hardcoded fallback (sniff-verified: walls=24, floors=40, ceilings=54)
switch (comp.Type)
{
case 1: roomComponentTextureID = 24; break;
case 2: roomComponentTextureID = 40; break;
case 3: roomComponentTextureID = 54; break;
default: break;
case 1: roomComponentTextureID = 24; break;
case 2: roomComponentTextureID = 40; break;
case 3: roomComponentTextureID = 54; break;
default: break;
}
}
}
@@ -324,14 +337,14 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
// Count by component type for diagnostic summary
switch (comp.Type)
{
case HOUSING_ROOM_COMPONENT_WALL: ++wallCount; break;
case HOUSING_ROOM_COMPONENT_FLOOR: ++floorCount; break;
case HOUSING_ROOM_COMPONENT_CEILING: ++ceilingCount; break;
case HOUSING_ROOM_COMPONENT_STAIRS: ++stairsCount; break;
case HOUSING_ROOM_COMPONENT_PILLAR: ++pillarCount; break;
case HOUSING_ROOM_COMPONENT_DOORWAY_WALL: ++doorwayWallCount; break;
case HOUSING_ROOM_COMPONENT_DOORWAY: ++doorwayCount; break;
default: ++otherCount; break;
case HOUSING_ROOM_COMPONENT_WALL: ++wallCount; break;
case HOUSING_ROOM_COMPONENT_FLOOR: ++floorCount; break;
case HOUSING_ROOM_COMPONENT_CEILING: ++ceilingCount; break;
case HOUSING_ROOM_COMPONENT_STAIRS: ++stairsCount; break;
case HOUSING_ROOM_COMPONENT_PILLAR: ++pillarCount; break;
case HOUSING_ROOM_COMPONENT_DOORWAY_WALL: ++doorwayWallCount; break;
case HOUSING_ROOM_COMPONENT_DOORWAY: ++doorwayCount; break;
default: ++otherCount; break;
}
TC_LOG_ERROR("housing", " Component: compID={} type={} fileDataID={} optionID={} themeID={} "
@@ -369,36 +382,17 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
}
}
// --- Phase 3: Create HousingRoomEntity (objectType=18, Housing/2 GUID) ---
// This is the ONLY entity with FHousingRoom_C ? the client's archetype query uses it
// to create layout editor pins. GetRoomGUID() validates the Housing GUID type.
// --- Phase 4: Populate HousingRoomEntity with component GUIDs + door data ---
// The entity was created in Phase 1 (before components) so it exists when
// components reference it via AttachParentGUID. Now add the child list + doors.
{
HousingRoomEntity* housingRoom = new HousingRoomEntity();
PhasingHandler::InitDbPhaseShift(housingRoom->GetPhaseShift(), PHASE_USE_FLAGS_ALWAYS_VISIBLE, 0, 0);
housingRoom->SetHouseGUID(housing->GetHouseGuid());
housingRoom->SetHouseRoomID(room->RoomEntryId);
housingRoom->SetFlags(roomFlags);
housingRoom->SetFloorIndex(floorIndex);
housingRoom->SetMirroredPosition(roomPos, roomRot, 1.0f);
// Add component MeshObject GUIDs to the room entity's MeshObjects array.
// Only component MeshObjects (with FHousingRoomComponentMesh_C) belong here ?
// the root room MeshObject has FHousingRoom_C instead, and the client crashes
// if it encounters a child without FHousingRoomComponentMesh_C (NULL+0x21).
for (MeshObject* comp : componentMeshes)
{
if (comp->IsInWorld())
housingRoom->AddMeshObject(comp->GetGUID());
}
// Add door data from components that serve as connection points.
// DB2 RoomComponent.ConnectionType=1 (All) marks door-capable components.
// For walls, also require axis-aligned position (one of X,Y non-zero) to
// exclude corner pieces ? Room 1 has CT=1 on ALL walls but corners (30-33)
// at diagonal positions are NOT doors in the sniff.
// Sniff-verified: Room 46 has 1 door (comp 206 CT=1), Room 1 has 4 doors
// (comp 26-29 CT=1, axis-aligned). Corner pieces 30-33 have CT=1 but are
// filtered out by the axis-aligned check.
// Add door data from door-capable wall components
uint32 doorCount = 0;
for (RoomComponentData const& comp : *components)
{
@@ -406,15 +400,9 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
if (comp.Type == HOUSING_ROOM_COMPONENT_DOORWAY ||
comp.Type == HOUSING_ROOM_COMPONENT_DOORWAY_WALL)
{
// Explicit doorway component (Type=6/7) ? custom geometry rooms
isDoor = true;
}
else if (comp.Type == HOUSING_ROOM_COMPONENT_WALL && comp.ConnectionType != 0)
{
// Wall with ConnectionType=1 (All) ? potential door connection point.
// Additional filter: must be axis-aligned (flat wall, not corner piece).
// Flat walls have only one of X,Y non-zero; corners have both.
bool hasX = std::abs(comp.OffsetPos[0]) > 0.5f;
bool hasY = std::abs(comp.OffsetPos[1]) > 0.5f;
if (hasX != hasY)
@@ -424,21 +412,19 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
if (!isDoor)
continue;
// Determine which adjacent room this door faces (for AttachedRoomGUID)
ObjectGuid attachedRoomGuid;
if (comp.OffsetPos[0] > 0.5f) // positive X ? faces next slot
if (comp.OffsetPos[0] > 0.5f)
{
auto itr = slotToRoomGuid.find(room->SlotIndex + 1);
if (itr != slotToRoomGuid.end())
attachedRoomGuid = itr->second;
}
else if (comp.OffsetPos[0] < -0.5f) // negative X ? faces previous slot
else if (comp.OffsetPos[0] < -0.5f)
{
auto itr = slotToRoomGuid.find(room->SlotIndex - 1);
if (itr != slotToRoomGuid.end())
attachedRoomGuid = itr->second;
}
// Y-axis doors are potential future connection points (AttachedRoomGUID stays empty)
Position doorOffset(comp.OffsetPos[0], comp.OffsetPos[1], comp.OffsetPos[2]);
uint8 connType = comp.ConnectionType > 0 ? comp.ConnectionType : 1;
@@ -446,19 +432,6 @@ void HouseInteriorMap::SpawnRoomMeshObjects(Housing* housing, int32 factionRestr
++doorCount;
}
// WorldObject: AddToMap makes it visible through the visibility system,
// sent in the SAME UPDATE_OBJECT as room MeshObjects ? matching retail.
if (!housingRoom->Create(roomHousingGuid, this, roomPos))
{
TC_LOG_ERROR("housing", "HouseInteriorMap: Failed to add HousingRoomEntity to map (roomEntry={})",
room->RoomEntryId);
delete housingRoom;
housingRoom = nullptr;
}
if (housingRoom)
_roomEntities.push_back(housingRoom);
TC_LOG_ERROR("housing", "HouseInteriorMap::SpawnRoomMeshObjects: Created HousingRoomEntity "
"guid={} roomEntry={} slot={} meshObjects={} doors={}",
roomHousingGuid.ToString(), room->RoomEntryId, room->SlotIndex,
@@ -803,7 +776,7 @@ void HouseInteriorMap::SpawnSingleInteriorDecor(Housing::PlacedDecor const& deco
float roomFacing = roomWorldPos.GetOrientation();
float cosF = std::cos(roomFacing);
float sinF = std::sin(roomFacing);
localX = cosF * dx + sinF * dy;
localX = cosF * dx + sinF * dy;
localY = -sinF * dx + cosF * dy;
localZ = worldZ - roomWorldPos.GetPositionZ();
}
@@ -962,11 +935,17 @@ bool HouseInteriorMap::AddPlayerToMap(Player* player, bool initPlayer /*= true*/
housing->SetInInterior(true);
// Spawn room meshes on first entry
if (!_roomsSpawned && player->GetGUID() == _owner)
if (player->GetGUID() == _owner)
{
// Always force a fresh spawn on login ? old entities from a previous binary/session
// may have stale fragment formats (e.g., root MeshObjects with FHousingRoom_C that
// no longer exist in the current code). DespawnAll is safe here because the player
// hasn't received any entities yet (no DESTROY goes to the client).
if (_roomsSpawned)
DespawnAllRoomMeshObjects();
TC_LOG_ERROR("housing", "HouseInteriorMap::AddPlayerToMap: === SPAWNING ROOMS ===");
// Log each room for debugging
for (Housing::Room const* room : housing->GetRooms())
{
TC_LOG_ERROR("housing", " Room: guid={} entryId={} slot={} orientation={} mirrored={}",
@@ -979,12 +958,6 @@ bool HouseInteriorMap::AddPlayerToMap(Player* player, bool initPlayer /*= true*/
SpawnRoomMeshObjects(housing, faction);
_roomsSpawned = true;
}
else
{
TC_LOG_ERROR("housing", "HouseInteriorMap::AddPlayerToMap: Rooms already spawned "
"(_roomsSpawned={}, isOwner={})",
_roomsSpawned, player->GetGUID() == _owner);
}
// Always spawn interior decor (handles both first entry and re-entry).
// SpawnInteriorDecor skips already-spawned decor via _decorGuidToObjGuid check.
@@ -1031,215 +1004,215 @@ bool HouseInteriorMap::AddPlayerToMap(Player* player, bool initPlayer /*= true*/
uint32 settingsFlags = housing->GetSettingsFlags();
player->m_Events.AddEventAtOffset([this, playerGuid, houseGuid, neighborhoodGuid, accountGuid, plotIndex, settingsFlags]()
{
Player* p = ObjectAccessor::FindPlayer(playerGuid);
if (!p || !p->IsInWorld())
return;
Housing* housing = p->GetHousing();
if (!housing)
return;
// ENTER_PLOT is sent AFTER the AT CREATE in step 8 below.
// The sequence is: AT CREATE ? ENTER_PLOT ? re-send Status+Perms.
// ENTER_PLOT fires HOUSE_PLOT_ENTERED (FrameScript event 1073) which
// loads Blizzard_HousingControls. The handler resets editor state, so
// Status+Perms are re-sent afterward to re-establish context.
// Steps 1-3 (HouseInfo, Status, Permissions) moved to AFTER
// ENTER_PLOT below. ENTER_PLOT resets editor state, so sending
// Status+Permissions before it is wasteful. The exterior AT handler
// (at_housing_plot.cpp) also sends Status+Permissions AFTER ENTER_PLOT.
// 1) PostTutorialAuras (slots 8, 9, 50)
SendPostTutorialAuras(p);
// 5) Account CREATE + HousingPlayerHouseEntity + decor
{
housing->PopulateCatalogStorageEntries();
housing->SyncUpdateFields();
Player* p = ObjectAccessor::FindPlayer(playerGuid);
if (!p || !p->IsInWorld())
return;
WorldSession* session = p->GetSession();
UpdateData storageUpdate(p->GetMapId());
WorldPacket storagePacket;
Housing* housing = p->GetHousing();
if (!housing)
return;
session->GetBattlenetAccount().BuildCreateUpdateBlockForPlayer(&storageUpdate, p);
p->m_clientGUIDs.insert(session->GetBattlenetAccount().GetGUID());
// ENTER_PLOT is sent AFTER the AT CREATE in step 8 below.
// The sequence is: AT CREATE ? ENTER_PLOT ? re-send Status+Perms.
// ENTER_PLOT fires HOUSE_PLOT_ENTERED (FrameScript event 1073) which
// loads Blizzard_HousingControls. The handler resets editor state, so
// Status+Perms are re-sent afterward to re-establish context.
if (p->HaveAtClient(&session->GetHousingPlayerHouseEntity()))
session->GetHousingPlayerHouseEntity().BuildValuesUpdateBlockForPlayer(&storageUpdate, p);
else
// Steps 1-3 (HouseInfo, Status, Permissions) moved to AFTER
// ENTER_PLOT below. ENTER_PLOT resets editor state, so sending
// Status+Permissions before it is wasteful. The exterior AT handler
// (at_housing_plot.cpp) also sends Status+Permissions AFTER ENTER_PLOT.
// 1) PostTutorialAuras (slots 8, 9, 50)
SendPostTutorialAuras(p);
// 5) Account CREATE + HousingPlayerHouseEntity + decor
{
session->GetHousingPlayerHouseEntity().BuildCreateUpdateBlockForPlayer(&storageUpdate, p);
p->m_clientGUIDs.insert(session->GetHousingPlayerHouseEntity().GetGUID());
}
housing->PopulateCatalogStorageEntries();
housing->SyncUpdateFields();
uint32 meshCreateCount = 0;
for (auto const& [decorGuid, meshObjGuid] : _decorGuidToObjGuid)
{
MeshObject* meshObj = GetMeshObject(meshObjGuid);
if (!meshObj || !meshObj->IsInWorld())
continue;
meshObj->BuildCreateUpdateBlockForPlayer(&storageUpdate, p);
p->m_clientGUIDs.insert(meshObjGuid);
++meshCreateCount;
}
WorldSession* session = p->GetSession();
UpdateData storageUpdate(p->GetMapId());
WorldPacket storagePacket;
// HousingRoomEntity CREATEs are now sent via the map visibility
// system (AddToMap in SpawnRoomMeshObjects). No manual send needed.
session->GetBattlenetAccount().BuildCreateUpdateBlockForPlayer(&storageUpdate, p);
p->m_clientGUIDs.insert(session->GetBattlenetAccount().GetGUID());
storageUpdate.BuildPacket(&storagePacket);
p->SendDirectMessage(&storagePacket);
session->GetBattlenetAccount().ClearUpdateMask(true);
session->GetHousingPlayerHouseEntity().ClearUpdateMask(true);
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Sent Account+budget+{} decor for {}",
meshCreateCount, playerGuid.ToString());
}
// Map-level Housing/3 entity (objectType=18) is now included in
// the initial UPDATE_OBJECT via Player::BuildCreateUpdateBlockForPlayer.
// It must be in the initial batch for the client's type-18 render init.
// 7) InitiativeServiceStatus
{
WorldPackets::Housing::InitiativeServiceStatus initStatus;
initStatus.ServiceEnabled = true;
p->SendDirectMessage(initStatus.Write());
}
// 7) Create AND send the interior plot AreaTrigger LAST.
// The AT must be created here (not in pre-spawn) because if it's
// on the map during AddPlayerToMap, the visibility system includes
// it in the initial UPDATE_OBJECT ? before Status+Permissions.
// Retail sends the AT in a separate UPDATE_OBJECT (#11752) AFTER
// the main entity data. The client fires HOUSE_PLOT_ENTERED on AT
// receipt and immediately checks IsHouseEditorStatusAvailable(),
// which requires permissions to already be set.
if (_interiorPlotAT.IsEmpty())
{
float atX = _originX;
float atY = _originY;
float atZ = _originZ;
LoadGrid(atX, atY);
Position atPos(atX, atY, atZ, 0.0f);
AreaTrigger* plotAt = AreaTrigger::CreateStaticAreaTrigger(
{ .Id = 37358, .IsCustom = false }, this, atPos, -1);
if (plotAt)
{
PhasingHandler::InitDbPhaseShift(plotAt->GetPhaseShift(), PHASE_USE_FLAGS_ALWAYS_VISIBLE, 0, 0);
plotAt->InitHousingPlotData(
housing->GetPlotIndex(),
playerGuid,
houseGuid,
accountGuid);
if (AddToMap(plotAt))
{
_interiorPlotAT = plotAt->GetGUID();
// Send CREATE directly to the player (visibility system
// won't send it since the player is already on the map)
UpdateData atUpdate(p->GetMapId());
plotAt->BuildCreateUpdateBlockForPlayer(&atUpdate, p);
p->m_clientGUIDs.insert(plotAt->GetGUID());
WorldPacket atPacket;
atUpdate.BuildPacket(&atPacket);
p->SendDirectMessage(&atPacket);
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Created+sent interior plot AT "
"guid={} at ({:.1f},{:.1f},{:.1f}) plotIndex={} for {}",
plotAt->GetGUID().ToString(), atX, atY, atZ,
housing->GetPlotIndex(), playerGuid.ToString());
// 8) Plot enter spell packets ? same as exterior AT overlap.
// Sniff-verified: exterior sends 3 spell+aura sequences
// (1239847@slot50, 469226@slot56, 1266699@slot9) before
// ENTER_PLOT. These trigger editor availability on the client.
{
plotAt->SetAreaTriggerFlag(AreaTriggerFieldFlags::HasPlayers);
// Spell 1239847 at slot 50 (plot enter tracking)
{
ObjectGuid castId = ObjectGuid::Create<HighGuid::Cast>(
SPELL_CAST_SOURCE_NORMAL, p->GetMapId(), SPELL_HOUSING_PLOT_ENTER,
GenerateLowGuid<HighGuid::Cast>());
WorldPackets::Spells::AuraUpdate au;
au.UpdateAll = false;
au.UnitGUID = p->GetGUID();
WorldPackets::Spells::AuraInfo ai;
ai.Slot = 55; // sniff-verified: retail uses slot 55
ai.AuraData.emplace();
ai.AuraData->CastID = castId;
ai.AuraData->SpellID = SPELL_HOUSING_PLOT_ENTER;
ai.AuraData->Flags = AFLAG_NOCASTER;
ai.AuraData->ActiveFlags = 1; // sniff-verified: retail uses 1
ai.AuraData->CastLevel = 36;
au.Auras.push_back(std::move(ai));
p->SendDirectMessage(au.Write());
}
// Spell 469226 at slot 56 (plot context)
{
ObjectGuid castId = ObjectGuid::Create<HighGuid::Cast>(
SPELL_CAST_SOURCE_NORMAL, p->GetMapId(), SPELL_HOUSING_PLOT_PRESENCE,
GenerateLowGuid<HighGuid::Cast>());
WorldPackets::Spells::AuraUpdate au;
au.UpdateAll = false;
au.UnitGUID = p->GetGUID();
WorldPackets::Spells::AuraInfo ai;
ai.Slot = 56;
ai.AuraData.emplace();
ai.AuraData->CastID = castId;
ai.AuraData->SpellID = SPELL_HOUSING_PLOT_PRESENCE;
ai.AuraData->Flags = AFLAG_NOCASTER;
ai.AuraData->ActiveFlags = 1;
ai.AuraData->CastLevel = 36;
au.Auras.push_back(std::move(ai));
p->SendDirectMessage(au.Write());
}
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Sent plot enter spells (slots 50+56) for {}",
playerGuid.ToString());
}
// 9) ENTER_PLOT ? fires HOUSE_PLOT_ENTERED on the client.
// The client handler (NeighborhoodSystem vtable[22]) signals
// FrameScript event 1073, which dispatches to all UI frames
// with OnEvent handlers, triggering the HOUSE_PLOT_ENTERED
// Lua event that loads Blizzard_HousingControls.
// MUST come AFTER the AT CREATE so the client can look up
// the AT GUID in its entity table.
{
WorldPackets::Neighborhood::NeighborhoodPlayerEnterPlot enterPlot;
enterPlot.NeighborhoodEntityGuid = plotAt->GetGUID();
p->SendDirectMessage(enterPlot.Write());
}
// Sniff-verified: retail does NOT proactively send Status+Perms.
// The client polls via CMSGs after receiving ENTER_PLOT, and
// our CMSG handlers (HandleHousingHouseStatus, HandleHousingGetPlayerPermissions)
// respond correctly with FlagByte=0xE0 and PermissionFlags=0xE0.
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Sent ENTER_PLOT for {} (Status+Perms reactive via CMSG handlers)",
playerGuid.ToString());
}
if (p->HaveAtClient(&session->GetHousingPlayerHouseEntity()))
session->GetHousingPlayerHouseEntity().BuildValuesUpdateBlockForPlayer(&storageUpdate, p);
else
{
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: AddToMap failed for interior plot AT");
delete plotAt;
session->GetHousingPlayerHouseEntity().BuildCreateUpdateBlockForPlayer(&storageUpdate, p);
p->m_clientGUIDs.insert(session->GetHousingPlayerHouseEntity().GetGUID());
}
uint32 meshCreateCount = 0;
for (auto const& [decorGuid, meshObjGuid] : _decorGuidToObjGuid)
{
MeshObject* meshObj = GetMeshObject(meshObjGuid);
if (!meshObj || !meshObj->IsInWorld())
continue;
meshObj->BuildCreateUpdateBlockForPlayer(&storageUpdate, p);
p->m_clientGUIDs.insert(meshObjGuid);
++meshCreateCount;
}
// HousingRoomEntity CREATEs are now sent via the map visibility
// system (AddToMap in SpawnRoomMeshObjects). No manual send needed.
storageUpdate.BuildPacket(&storagePacket);
p->SendDirectMessage(&storagePacket);
session->GetBattlenetAccount().ClearUpdateMask(true);
session->GetHousingPlayerHouseEntity().ClearUpdateMask(true);
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Sent Account+budget+{} decor for {}",
meshCreateCount, playerGuid.ToString());
}
// Map-level Housing/3 entity (objectType=18) is now included in
// the initial UPDATE_OBJECT via Player::BuildCreateUpdateBlockForPlayer.
// It must be in the initial batch for the client's type-18 render init.
// 7) InitiativeServiceStatus
{
WorldPackets::Housing::InitiativeServiceStatus initStatus;
initStatus.ServiceEnabled = true;
p->SendDirectMessage(initStatus.Write());
}
// 7) Create AND send the interior plot AreaTrigger LAST.
// The AT must be created here (not in pre-spawn) because if it's
// on the map during AddPlayerToMap, the visibility system includes
// it in the initial UPDATE_OBJECT ? before Status+Permissions.
// Retail sends the AT in a separate UPDATE_OBJECT (#11752) AFTER
// the main entity data. The client fires HOUSE_PLOT_ENTERED on AT
// receipt and immediately checks IsHouseEditorStatusAvailable(),
// which requires permissions to already be set.
if (_interiorPlotAT.IsEmpty())
{
float atX = _originX;
float atY = _originY;
float atZ = _originZ;
LoadGrid(atX, atY);
Position atPos(atX, atY, atZ, 0.0f);
AreaTrigger* plotAt = AreaTrigger::CreateStaticAreaTrigger(
{ .Id = 37358, .IsCustom = false }, this, atPos, -1);
if (plotAt)
{
PhasingHandler::InitDbPhaseShift(plotAt->GetPhaseShift(), PHASE_USE_FLAGS_ALWAYS_VISIBLE, 0, 0);
plotAt->InitHousingPlotData(
housing->GetPlotIndex(),
playerGuid,
houseGuid,
accountGuid);
if (AddToMap(plotAt))
{
_interiorPlotAT = plotAt->GetGUID();
// Send CREATE directly to the player (visibility system
// won't send it since the player is already on the map)
UpdateData atUpdate(p->GetMapId());
plotAt->BuildCreateUpdateBlockForPlayer(&atUpdate, p);
p->m_clientGUIDs.insert(plotAt->GetGUID());
WorldPacket atPacket;
atUpdate.BuildPacket(&atPacket);
p->SendDirectMessage(&atPacket);
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Created+sent interior plot AT "
"guid={} at ({:.1f},{:.1f},{:.1f}) plotIndex={} for {}",
plotAt->GetGUID().ToString(), atX, atY, atZ,
housing->GetPlotIndex(), playerGuid.ToString());
// 8) Plot enter spell packets ? same as exterior AT overlap.
// Sniff-verified: exterior sends 3 spell+aura sequences
// (1239847@slot50, 469226@slot56, 1266699@slot9) before
// ENTER_PLOT. These trigger editor availability on the client.
{
plotAt->SetAreaTriggerFlag(AreaTriggerFieldFlags::HasPlayers);
// Spell 1239847 at slot 50 (plot enter tracking)
{
ObjectGuid castId = ObjectGuid::Create<HighGuid::Cast>(
SPELL_CAST_SOURCE_NORMAL, p->GetMapId(), SPELL_HOUSING_PLOT_ENTER,
GenerateLowGuid<HighGuid::Cast>());
WorldPackets::Spells::AuraUpdate au;
au.UpdateAll = false;
au.UnitGUID = p->GetGUID();
WorldPackets::Spells::AuraInfo ai;
ai.Slot = 55; // sniff-verified: retail uses slot 55
ai.AuraData.emplace();
ai.AuraData->CastID = castId;
ai.AuraData->SpellID = SPELL_HOUSING_PLOT_ENTER;
ai.AuraData->Flags = AFLAG_NOCASTER;
ai.AuraData->ActiveFlags = 1; // sniff-verified: retail uses 1
ai.AuraData->CastLevel = 36;
au.Auras.push_back(std::move(ai));
p->SendDirectMessage(au.Write());
}
// Spell 469226 at slot 56 (plot context)
{
ObjectGuid castId = ObjectGuid::Create<HighGuid::Cast>(
SPELL_CAST_SOURCE_NORMAL, p->GetMapId(), SPELL_HOUSING_PLOT_PRESENCE,
GenerateLowGuid<HighGuid::Cast>());
WorldPackets::Spells::AuraUpdate au;
au.UpdateAll = false;
au.UnitGUID = p->GetGUID();
WorldPackets::Spells::AuraInfo ai;
ai.Slot = 56;
ai.AuraData.emplace();
ai.AuraData->CastID = castId;
ai.AuraData->SpellID = SPELL_HOUSING_PLOT_PRESENCE;
ai.AuraData->Flags = AFLAG_NOCASTER;
ai.AuraData->ActiveFlags = 1;
ai.AuraData->CastLevel = 36;
au.Auras.push_back(std::move(ai));
p->SendDirectMessage(au.Write());
}
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Sent plot enter spells (slots 50+56) for {}",
playerGuid.ToString());
}
// 9) ENTER_PLOT ? fires HOUSE_PLOT_ENTERED on the client.
// The client handler (NeighborhoodSystem vtable[22]) signals
// FrameScript event 1073, which dispatches to all UI frames
// with OnEvent handlers, triggering the HOUSE_PLOT_ENTERED
// Lua event that loads Blizzard_HousingControls.
// MUST come AFTER the AT CREATE so the client can look up
// the AT GUID in its entity table.
{
WorldPackets::Neighborhood::NeighborhoodPlayerEnterPlot enterPlot;
enterPlot.NeighborhoodEntityGuid = plotAt->GetGUID();
p->SendDirectMessage(enterPlot.Write());
}
// Sniff-verified: retail does NOT proactively send Status+Perms.
// The client polls via CMSGs after receiving ENTER_PLOT, and
// our CMSG handlers (HandleHousingHouseStatus, HandleHousingGetPlayerPermissions)
// respond correctly with FlagByte=0xE0 and PermissionFlags=0xE0.
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Sent ENTER_PLOT for {} (Status+Perms reactive via CMSG handlers)",
playerGuid.ToString());
}
else
{
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: AddToMap failed for interior plot AT");
delete plotAt;
}
}
}
}
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Complete ? "
"HouseInfo+Status+Perms+Auras+Account+Initiative+PlotAT+ENTER_PLOT for {}",
playerGuid.ToString());
}, Milliseconds(500));
TC_LOG_ERROR("housing", "HouseInteriorMap deferred: Complete ? "
"HouseInfo+Status+Perms+Auras+Account+Initiative+PlotAT+ENTER_PLOT for {}",
playerGuid.ToString());
}, Milliseconds(500));
}
}
else