diff --git a/Client/game_sa/CModelInfoSA.cpp b/Client/game_sa/CModelInfoSA.cpp index 94a3316bc85..3944c657946 100644 --- a/Client/game_sa/CModelInfoSA.cpp +++ b/Client/game_sa/CModelInfoSA.cpp @@ -1603,8 +1603,14 @@ void CModelInfoSA::SetColModel(CColModel* pColModel) if (!pColModelInterface) return; - // Skip setting if already done - if (m_pCustomColModel == pColModel) + // Skip only if this col is both already recorded as our custom one AND still actually applied to + // the live model interface. We must NOT early-out merely because m_pCustomColModel matches: + // MakeCustomModel() re-invokes us right after the model streams in specifically to re-apply the + // custom col over whatever the reload reset the interface's pColModel back to, and that re-apply + // has to actually run. (Without this, a freshly streamed model - e.g. a scaled-collision clone - + // keeps the original disk collision the reload restored, ignoring our custom one.) + CBaseModelInfoSAInterface* pLiveInterface = ppModelInfo[m_dwModelID]; + if (m_pCustomColModel == pColModel && pLiveInterface && pLiveInterface->pColModel == pColModelInterface) return; // Store the col model we set @@ -1744,6 +1750,12 @@ void CModelInfoSA::RestoreColModel() m_originalFlags = 0; } +CColModelSAInterface* CModelInfoSA::GetColModelInterface() +{ + m_pInterface = ppModelInfo[m_dwModelID]; + return m_pInterface ? m_pInterface->pColModel : nullptr; +} + void CModelInfoSA::MakeCustomModel() { // We have a custom model? diff --git a/Client/game_sa/CModelInfoSA.h b/Client/game_sa/CModelInfoSA.h index 230a898a5da..13580e417b2 100644 --- a/Client/game_sa/CModelInfoSA.h +++ b/Client/game_sa/CModelInfoSA.h @@ -453,11 +453,12 @@ class CModelInfoSA : public CModelInfo void SetVoice(const char* szVoiceType, const char* szVoice); // Custom collision related functions - bool SetCustomModel(RpClump* pClump) override; - void RestoreOriginalModel() override; - void SetColModel(CColModel* pColModel) override; - void RestoreColModel() override; - void MakeCustomModel() override; + bool SetCustomModel(RpClump* pClump) override; + void RestoreOriginalModel() override; + void SetColModel(CColModel* pColModel) override; + void RestoreColModel() override; + void MakeCustomModel() override; + CColModelSAInterface* GetColModelInterface() override; // Increases the collision slot reference counter for the original collision model void AddColRef() override; diff --git a/Client/game_sa/CRenderWareSA.cpp b/Client/game_sa/CRenderWareSA.cpp index 3bd46f70249..a1aeea215ab 100644 --- a/Client/game_sa/CRenderWareSA.cpp +++ b/Client/game_sa/CRenderWareSA.cpp @@ -12,6 +12,9 @@ *****************************************************************************/ #include "StdInc.h" +#include +#include +#include #include #include #define RWFUNC_IMPLEMENT @@ -486,6 +489,221 @@ CColModel* CRenderWareSA::ReadCOL(const SString& buffer) return NULL; } +namespace +{ + // Mirrors the on-disk COL3 version-specific header (gtamods.com/wiki/Collision_File). + // Field order/sizes must match exactly: natural struct alignment produces the correct + // 88 byte layout (verified by the static_assert below), so no #pragma pack is used here. + struct SColV3HeaderSA + { + CBoundingBoxSA m_bounds; + CSphereSA m_boundSphere; + std::uint16_t m_numSpheres; + std::uint16_t m_numBoxes; + std::uint16_t m_numFaces; + std::uint8_t m_numLines; + std::uint32_t m_flags; + std::uint32_t m_offSpheres; + std::uint32_t m_offBoxes; + std::uint32_t m_offLines; + std::uint32_t m_offVerts; + std::uint32_t m_offFaces; + std::uint32_t m_offPlanes; + std::uint32_t m_numShadowFaces; + std::uint32_t m_offShadowVerts; + std::uint32_t m_offShadowFaces; + }; + static_assert(sizeof(SColV3HeaderSA) == 88, "Invalid size for SColV3HeaderSA"); + + constexpr std::uint8_t COLFLAG_USESDISKS = 1; + constexpr std::uint8_t COLFLAG_NOTEMPTY = 2; + + // LoadCollisionModelVer3's pointer-fixup math expects offsets relative to a buffer that + // includes a 32 byte file header + 4 byte fourcc that we never actually build (we call the + // parser directly with just the V3 header + arrays). This constant compensates for that + // missing preamble so our payload-relative offsets resolve to the right addresses. + constexpr std::uint32_t COL_OFFSET_FIXUP = 116; + + constexpr float COL_VERTEX_SCALE = 128.0f; + + CCompressedVectorSA ScaleCompressedVertex(const CCompressedVectorSA& vertex, const CVector& vecScale) + { + auto scaleAxis = [](short comp, float scale) -> short + { + float fValue = (static_cast(comp) / COL_VERTEX_SCALE) * scale; + float fScaled = std::round(fValue * COL_VERTEX_SCALE); + fScaled = std::clamp(fScaled, -32768.0f, 32767.0f); + return static_cast(fScaled); + }; + return {scaleAxis(vertex.x, vecScale.fX), scaleAxis(vertex.y, vecScale.fY), scaleAxis(vertex.z, vecScale.fZ)}; + } + + CVector ScaleVector(const CVector& vec, const CVector& vecScale) + { + return CVector(vec.fX * vecScale.fX, vec.fY * vecScale.fY, vec.fZ * vecScale.fZ); + } + + CBoxSA ScaleBox(const CBoxSA& box, const CVector& vecScale) + { + CVector vecA = ScaleVector(box.m_vecMin, vecScale); + CVector vecB = ScaleVector(box.m_vecMax, vecScale); + + // A negative scale component can flip which corner is the min/max on that axis + CVector vecMin(std::min(vecA.fX, vecB.fX), std::min(vecA.fY, vecB.fY), std::min(vecA.fZ, vecB.fZ)); + CVector vecMax(std::max(vecA.fX, vecB.fX), std::max(vecA.fY, vecB.fY), std::max(vecA.fZ, vecB.fZ)); + + CBoxSA result; + result.m_vecMin = vecMin; + result.m_vecMax = vecMax; + return result; + } + + // CColDataSA has no explicit vertex count - like the engine's own shadow-mesh loader + // (see GetNoOfShdwVerts), the number of vertices is derived from the highest index any + // triangle references. + std::uint32_t CountReferencedVertices(const CColDataSA* pData) + { + if (!pData->m_triangles || !pData->m_vertices) + return 0; + + std::uint32_t maxIndex = 0; + for (std::uint32_t i = 0; i < pData->m_numTriangles; i++) + { + const CColTriangleSA& triangle = pData->m_triangles[i]; + maxIndex = std::max({maxIndex, static_cast(triangle.m_indices[0]), static_cast(triangle.m_indices[1]), + static_cast(triangle.m_indices[2])}); + } + return pData->m_numTriangles > 0 ? maxIndex + 1 : 0; + } +} // namespace + +// Builds a new CColModel with the same geometry as pOriginalInterface, scaled by vecScale. +// Used to give scaled objects (setObjectScale with scaleCollision=true) their own +// per-scale collision instead of sharing (and corrupting) the original model's collision. +CColModel* CRenderWareSA::CreateScaledColModel(CColModelSAInterface* pOriginalInterface, const CVector& vecScale) +{ + if (!pOriginalInterface) + return nullptr; + + CColDataSA* pOriginalData = pOriginalInterface->m_data; + if (!pOriginalData) + { + // The collision interface exists, but its actual data arrays (spheres/boxes/faces) aren't + // resident yet: the collision slot hasn't been streamed in. This is exactly what happens + // when an object is scaled the same frame it's created - it (and therefore its collision) + // hasn't streamed in. Force-load the collision slot now, the same way the engine streams + // collision, then re-read. Without this we'd fail and silently leave the object unscaled. + constexpr unsigned int RESOURCE_ID_COL = 25000; + const unsigned int colStreamId = RESOURCE_ID_COL + pOriginalInterface->m_sphere.m_collisionSlot; + pGame->GetStreaming()->RequestModel(colStreamId, 0x16); + pGame->GetStreaming()->LoadAllRequestedModels(true, "CRenderWareSA::CreateScaledColModel"); + + pOriginalData = pOriginalInterface->m_data; + if (!pOriginalData) + { + // Genuinely no collision volumes (e.g. a purely visual/LOD model) - nothing to scale + return nullptr; + } + } + + const bool bUsesDisks = pOriginalData->m_usesDisks; + + // Compute each array's byte size and offset (relative to right after the 88 byte header) + const std::uint32_t sphereBytes = pOriginalData->m_numSpheres * sizeof(CColSphereSA); + const std::uint32_t boxBytes = pOriginalData->m_numBoxes * sizeof(CColBoxSA); + const std::uint32_t lineBytes = pOriginalData->m_numSuspensionLines * (bUsesDisks ? sizeof(CColDiskSA) : sizeof(CColLineSA)); + const std::uint32_t numVertices = CountReferencedVertices(pOriginalData); + const std::uint32_t vertBytes = numVertices * sizeof(CCompressedVectorSA); + const std::uint32_t faceBytes = pOriginalData->m_numTriangles * sizeof(CColTriangleSA); + + const std::uint32_t sphereOffset = 0; + const std::uint32_t boxOffset = sphereOffset + sphereBytes; + const std::uint32_t lineOffset = boxOffset + boxBytes; + const std::uint32_t vertOffset = lineOffset + lineBytes; + const std::uint32_t faceOffset = vertOffset + vertBytes; + const std::uint32_t totalArrayBytes = faceOffset + faceBytes; + + std::vector buffer(sizeof(SColV3HeaderSA) + totalArrayBytes, 0); + SColV3HeaderSA* pHeader = reinterpret_cast(buffer.data()); + + static_cast(pHeader->m_bounds) = ScaleBox(pOriginalInterface->m_bounds, vecScale); + + // The bounding sphere is only used for fast broad-phase rejection, so under non-uniform + // scale we conservatively grow it using the largest scale axis rather than trying to + // represent a squashed sphere exactly. + const float fMaxScale = std::max({std::fabs(vecScale.fX), std::fabs(vecScale.fY), std::fabs(vecScale.fZ)}); + pHeader->m_boundSphere.m_center = ScaleVector(pOriginalInterface->m_sphere.m_center, vecScale); + pHeader->m_boundSphere.m_radius = pOriginalInterface->m_sphere.m_radius * fMaxScale; + + pHeader->m_numSpheres = pOriginalData->m_numSpheres; + pHeader->m_numBoxes = pOriginalData->m_numBoxes; + pHeader->m_numFaces = pOriginalData->m_numTriangles; + pHeader->m_numLines = pOriginalData->m_numSuspensionLines; + pHeader->m_flags = COLFLAG_NOTEMPTY | (bUsesDisks ? COLFLAG_USESDISKS : 0); + pHeader->m_offSpheres = sphereBytes ? (sphereOffset + COL_OFFSET_FIXUP) : 0; + pHeader->m_offBoxes = boxBytes ? (boxOffset + COL_OFFSET_FIXUP) : 0; + pHeader->m_offLines = lineBytes ? (lineOffset + COL_OFFSET_FIXUP) : 0; + pHeader->m_offVerts = vertBytes ? (vertOffset + COL_OFFSET_FIXUP) : 0; + pHeader->m_offFaces = faceBytes ? (faceOffset + COL_OFFSET_FIXUP) : 0; + pHeader->m_offPlanes = 0; + pHeader->m_numShadowFaces = 0; + pHeader->m_offShadowVerts = 0; + pHeader->m_offShadowFaces = 0; + + unsigned char* pArrays = buffer.data() + sizeof(SColV3HeaderSA); + + for (std::uint32_t i = 0; i < pOriginalData->m_numSpheres; i++) + { + CColSphereSA sphere = pOriginalData->m_spheres[i]; + sphere.m_center = ScaleVector(sphere.m_center, vecScale); + sphere.m_radius *= fMaxScale; + std::memcpy(pArrays + sphereOffset + i * sizeof(CColSphereSA), &sphere, sizeof(CColSphereSA)); + } + + for (std::uint32_t i = 0; i < pOriginalData->m_numBoxes; i++) + { + CColBoxSA box = pOriginalData->m_boxes[i]; + static_cast(box) = ScaleBox(box, vecScale); + std::memcpy(pArrays + boxOffset + i * sizeof(CColBoxSA), &box, sizeof(CColBoxSA)); + } + + for (std::uint32_t i = 0; i < pOriginalData->m_numSuspensionLines; i++) + { + if (bUsesDisks) + { + CColDiskSA disk = pOriginalData->m_disks[i]; + disk.m_startPosition = ScaleVector(disk.m_startPosition, vecScale); + disk.m_stopPosition = ScaleVector(disk.m_stopPosition, vecScale); + disk.m_startRadius *= fMaxScale; + disk.m_stopRadius *= fMaxScale; + std::memcpy(pArrays + lineOffset + i * sizeof(CColDiskSA), &disk, sizeof(CColDiskSA)); + } + else + { + CColLineSA line = pOriginalData->m_suspensionLines[i]; + line.m_vecStart = ScaleVector(line.m_vecStart, vecScale); + line.m_vecStop = ScaleVector(line.m_vecStop, vecScale); + line.m_startSize *= fMaxScale; + line.m_stopSize *= fMaxScale; + std::memcpy(pArrays + lineOffset + i * sizeof(CColLineSA), &line, sizeof(CColLineSA)); + } + } + + for (std::uint32_t i = 0; i < numVertices; i++) + { + CCompressedVectorSA scaled = ScaleCompressedVertex(pOriginalData->m_vertices[i], vecScale); + std::memcpy(pArrays + vertOffset + i * sizeof(CCompressedVectorSA), &scaled, sizeof(CCompressedVectorSA)); + } + + for (std::uint32_t i = 0; i < pOriginalData->m_numTriangles; i++) + std::memcpy(pArrays + faceOffset + i * sizeof(CColTriangleSA), &pOriginalData->m_triangles[i], sizeof(CColTriangleSA)); + + CColModelSA* pScaledColModel = new CColModelSA(); + LoadCollisionModelVer3(buffer.data(), static_cast(buffer.size()), pScaledColModel->GetInterface(), NULL); + + return pScaledColModel; +} + // Loads all atomics from a clump into a container struct and returns the number of atomics it loaded unsigned int CRenderWareSA::LoadAtomics(RpClump* pClump, RpAtomicContainer* pAtomics) { diff --git a/Client/game_sa/CRenderWareSA.h b/Client/game_sa/CRenderWareSA.h index 1fc217ad967..49191f2d237 100644 --- a/Client/game_sa/CRenderWareSA.h +++ b/Client/game_sa/CRenderWareSA.h @@ -57,6 +57,10 @@ class CRenderWareSA : public CRenderWare // Reads and parses a COL3 file with an optional collision key name CColModel* ReadCOL(const SString& buffer); + // Builds a new CColModel with the same geometry as pOriginalInterface, scaled by vecScale. + // Returns nullptr if pOriginalInterface has collision spheres/disks/lines and vecScale is not uniform. + CColModel* CreateScaledColModel(CColModelSAInterface* pOriginalInterface, const CVector& vecScale); + // Replaces a CColModel for a specific object identified by the object id (usModelID) void ReplaceCollisions(CColModel* pColModel, unsigned short usModelID); diff --git a/Client/mods/deathmatch/logic/CClientModel.cpp b/Client/mods/deathmatch/logic/CClientModel.cpp index 8565ef76dbf..f0c7c36b04c 100644 --- a/Client/mods/deathmatch/logic/CClientModel.cpp +++ b/Client/mods/deathmatch/logic/CClientModel.cpp @@ -198,16 +198,22 @@ void CClientModel::RestoreDFF(CModelInfo* pModelInfo) // Restore pickups with custom model CClientPickupManager* pPickupManager = g_pClientGame->GetManager()->GetPickupManager(); - - unloadModelsAndCallEvents(pPickupManager->IterBegin(), pPickupManager->IterEnd(), usParentID, [=](auto& element) { element.SetModel(usParentID); }); + if (pPickupManager) + unloadModelsAndCallEvents(pPickupManager->IterBegin(), pPickupManager->IterEnd(), usParentID, + [=](auto& element) { element.SetModel(usParentID); }); // Restore buildings CClientBuildingManager* pBuildingsManager = g_pClientGame->GetManager()->GetBuildingManager(); - auto& buildingsList = pBuildingsManager->GetBuildings(); - unloadModelsAndCallEventsNonStreamed(buildingsList.begin(), buildingsList.end(), usParentID, [=](auto& element) { element.SetModel(usParentID); }); + if (pBuildingsManager) + { + auto& buildingsList = pBuildingsManager->GetBuildings(); + unloadModelsAndCallEventsNonStreamed(buildingsList.begin(), buildingsList.end(), usParentID, + [=](auto& element) { element.SetModel(usParentID); }); + } // Restore COL - g_pClientGame->GetManager()->GetColModelManager()->RestoreModel(static_cast(m_iModelID)); + if (CClientColModelManager* pColModelManager = g_pClientGame->GetManager()->GetColModelManager()) + pColModelManager->RestoreModel(static_cast(m_iModelID)); break; } case eClientModelType::VEHICLE: @@ -215,14 +221,16 @@ void CClientModel::RestoreDFF(CModelInfo* pModelInfo) CClientVehicleManager* pVehicleManager = g_pClientGame->GetManager()->GetVehicleManager(); const auto usParentID = static_cast(g_pGame->GetModelInfo(m_iModelID)->GetParentID()); - unloadModelsAndCallEvents(pVehicleManager->IterBegin(), pVehicleManager->IterEnd(), usParentID, - [=](auto& element) { element.SetModelBlocking(usParentID, 255, 255); }); + if (pVehicleManager) + unloadModelsAndCallEvents(pVehicleManager->IterBegin(), pVehicleManager->IterEnd(), usParentID, + [=](auto& element) { element.SetModelBlocking(usParentID, 255, 255); }); break; } } // Restore DFF/TXD - g_pClientGame->GetManager()->GetDFFManager()->RestoreModel(static_cast(m_iModelID)); + if (CClientDFFManager* pDFFManager = g_pClientGame->GetManager()->GetDFFManager()) + pDFFManager->RestoreModel(static_cast(m_iModelID)); } bool CClientModel::AllocateTXD(std::string& strTxdName) diff --git a/Client/mods/deathmatch/logic/CClientModelManager.cpp b/Client/mods/deathmatch/logic/CClientModelManager.cpp index 75975f24d33..8bc29710d36 100644 --- a/Client/mods/deathmatch/logic/CClientModelManager.cpp +++ b/Client/mods/deathmatch/logic/CClientModelManager.cpp @@ -9,6 +9,10 @@ *****************************************************************************/ #include "StdInc.h" +#include +#include +#include + CClientModelManager::CClientModelManager() : m_Models(std::make_unique[]>(g_pGame->GetBaseIDforCOL())) { const unsigned int uiMaxModelID = g_pGame->GetBaseIDforCOL(); @@ -31,6 +35,19 @@ void CClientModelManager::RemoveAll(void) m_Models[i] = nullptr; } m_modelCount = 0; + + // The loop above already drops every clone's CClientModel slot, but our own scaled-collision + // cache isn't aware of that - without clearing it too, a later AcquireScaledCollisionModel() + // call (e.g. reapplying scale on reconnect) would think it can reuse a clone that no longer + // really exists, handing out a model ID with the visual scale applied but no scaled collision + // actually attached to it. + for (auto& [key, entry] : m_ScaledColModels) + { + if (entry.pScaledColModel) + entry.pScaledColModel->Destroy(); + } + m_ScaledColModels.clear(); + m_ScaledColModelKeyByID.clear(); } void CClientModelManager::Add(const std::shared_ptr& pModel) @@ -136,3 +153,105 @@ void CClientModelManager::DeallocateModelsAllocatedByResource(CResource* pResour Remove(m_Models[i]); } } + +namespace +{ + int QuantizeScaleComponent(float fValue) + { + return static_cast(std::lround(fValue * 1000.0f)); + } +} // namespace + +int CClientModelManager::AcquireScaledCollisionModel(unsigned short usBaseModelID, const CVector& vecScale) +{ + const SScaledColModelKey key{usBaseModelID, QuantizeScaleComponent(vecScale.fX), QuantizeScaleComponent(vecScale.fY), QuantizeScaleComponent(vecScale.fZ)}; + + auto it = m_ScaledColModels.find(key); + if (it != m_ScaledColModels.end()) + { + it->second.uiRefCount++; + return it->second.pClonedModel->GetModelID(); + } + + CModelInfo* pBaseModelInfo = g_pGame->GetModelInfo(usBaseModelID, true); + if (!pBaseModelInfo || !pBaseModelInfo->IsValid()) + return -1; + + // GetColModelInterface() only returns whatever's already resident - it doesn't stream + // anything in. If this is called right after creating an object of this model (before the + // model's own streaming request has finished), the collision data may not be loaded yet and + // we'd silently fail here. Force a blocking load so it's guaranteed to be ready, then drop + // our temporary reference - whatever already (or will) reference this model keeps it loaded. + pBaseModelInfo->ModelAddRef(BLOCKING, "AcquireScaledCollisionModel"); + CColModelSAInterface* pOriginalColModelInterface = pBaseModelInfo->GetColModelInterface(); + pBaseModelInfo->RemoveRef(); + if (!pOriginalColModelInterface) + return -1; + + CColModel* pScaledColModel = g_pGame->GetRenderWare()->CreateScaledColModel(pOriginalColModelInterface, vecScale); + if (!pScaledColModel) + return -1; + + const int iCloneID = GetFirstFreeModelID(); + if (iCloneID == INVALID_MODEL_ID) + { + pScaledColModel->Destroy(); + return -1; + } + + auto pClonedModel = std::make_shared(g_pClientGame->GetManager(), iCloneID, eClientModelType::OBJECT); + if (!pClonedModel->Allocate(static_cast(usBaseModelID))) + { + pScaledColModel->Destroy(); + return -1; + } + + Add(pClonedModel); + + CModelInfo* pCloneModelInfo = g_pGame->GetModelInfo(iCloneID, true); + pCloneModelInfo->SetColModel(pScaledColModel); + + SScaledColModelEntry entry; + entry.pClonedModel = pClonedModel; + entry.pScaledColModel = pScaledColModel; + entry.uiRefCount = 1; + + m_ScaledColModels[key] = entry; + m_ScaledColModelKeyByID[iCloneID] = key; + + return iCloneID; +} + +void CClientModelManager::ReleaseScaledCollisionModel(int iClonedModelID) +{ + auto keyIt = m_ScaledColModelKeyByID.find(iClonedModelID); + if (keyIt == m_ScaledColModelKeyByID.end()) + return; + + auto entryIt = m_ScaledColModels.find(keyIt->second); + if (entryIt == m_ScaledColModels.end()) + { + m_ScaledColModelKeyByID.erase(keyIt); + return; + } + + SScaledColModelEntry& entry = entryIt->second; + if (--entry.uiRefCount > 0) + return; + + // Last user gone - detach our collision from the model info first (same order + // engineReplaceCOL/CClientColModel use), THEN free it, THEN free the model slot. + // Detaching first matters: CModelInfoSA::Remove() refuses to actually unload the + // model while it still thinks a custom col model is assigned. + CModelInfo* pCloneModelInfo = g_pGame->GetModelInfo(iClonedModelID, true); + if (pCloneModelInfo) + pCloneModelInfo->RestoreColModel(); + + if (entry.pScaledColModel) + entry.pScaledColModel->Destroy(); + + Remove(entry.pClonedModel); + + m_ScaledColModels.erase(entryIt); + m_ScaledColModelKeyByID.erase(keyIt); +} diff --git a/Client/mods/deathmatch/logic/CClientModelManager.h b/Client/mods/deathmatch/logic/CClientModelManager.h index 15fff298ca8..7ef819f0eac 100644 --- a/Client/mods/deathmatch/logic/CClientModelManager.h +++ b/Client/mods/deathmatch/logic/CClientModelManager.h @@ -13,14 +13,38 @@ class CClientModelManager; #pragma once #include +#include #include #include +#include #include "CClientModel.h" #define MAX_MODEL_DFF_ID 20000 #define MAX_MODEL_TXD_ID 25000 #define MAX_MODEL_ID 25000 +class CColModel; + +// Identifies a (base model, scale) combination so identical scale requests can share one clone +struct SScaledColModelKey +{ + unsigned short usBaseModelID; + int iScaleX; // Scale components quantized to 1/1000th to keep the cache key stable + int iScaleY; + int iScaleZ; + + bool operator<(const SScaledColModelKey& other) const + { + if (usBaseModelID != other.usBaseModelID) + return usBaseModelID < other.usBaseModelID; + if (iScaleX != other.iScaleX) + return iScaleX < other.iScaleX; + if (iScaleY != other.iScaleY) + return iScaleY < other.iScaleY; + return iScaleZ < other.iScaleZ; + } +}; + class CClientModelManager { friend class CClientModel; @@ -44,7 +68,27 @@ class CClientModelManager void DeallocateModelsAllocatedByResource(CResource* pResource); + // Returns a model ID cloned from usBaseModelID whose collision is scaled by vecScale. + // Identical (model, scale) requests share the same clone (refcounted). Returns -1 on + // failure (no free model slot, or non-uniform scale with a collision that can't support it). + // Each successful call must be paired with exactly one ReleaseScaledCollisionModel call. + int AcquireScaledCollisionModel(unsigned short usBaseModelID, const CVector& vecScale); + + // Releases a reference acquired via AcquireScaledCollisionModel. Frees the clone once its + // refcount reaches zero. + void ReleaseScaledCollisionModel(int iClonedModelID); + private: + struct SScaledColModelEntry + { + std::shared_ptr pClonedModel; + CColModel* pScaledColModel = nullptr; + unsigned int uiRefCount = 0; + }; + std::unique_ptr[]> m_Models; unsigned int m_modelCount = 0; + + std::map m_ScaledColModels; + std::map m_ScaledColModelKeyByID; }; diff --git a/Client/mods/deathmatch/logic/CClientObject.cpp b/Client/mods/deathmatch/logic/CClientObject.cpp index 933a01f2816..9d851de57fd 100644 --- a/Client/mods/deathmatch/logic/CClientObject.cpp +++ b/Client/mods/deathmatch/logic/CClientObject.cpp @@ -9,6 +9,8 @@ *****************************************************************************/ #include +#include +#include #define MTA_BUILDINGS #define CCLIENTOBJECT_MAX 250 @@ -64,9 +66,18 @@ CClientObject::~CClientObject() // Detach us from anything AttachTo(NULL); - // Destroy the object + // Destroy the object (this releases our reference to whatever model we're currently using, + // clone or not - must happen before we release the clone below, otherwise the clone's model + // info could be freed while m_pObject is still referencing it) Destroy(); + // Release any scaled-collision model clone we were using + if (m_iScaleCollisionModelID != -1) + { + g_pClientGame->GetManager()->GetModelManager()->ReleaseScaledCollisionModel(m_iScaleCollisionModelID); + m_iScaleCollisionModelID = -1; + } + // Remove us from the list Unlink(); @@ -407,13 +418,78 @@ void CClientObject::GetScale(CVector& vecScale) const } } -void CClientObject::SetScale(const CVector& vecScale) +void CClientObject::SetScale(const CVector& vecScale, std::optional scaleCollision) { + constexpr float kUnitScaleEpsilon = 0.0001f; + const bool bIsUnitScale = std::fabs(vecScale.fX - 1.0f) < kUnitScaleEpsilon && std::fabs(vecScale.fY - 1.0f) < kUnitScaleEpsilon && + std::fabs(vecScale.fZ - 1.0f) < kUnitScaleEpsilon; + // If the caller didn't specify, keep whatever collision-scaling state this object already has, + // instead of silently turning it off whenever someone just wants to nudge the visual scale. + const bool bScaleCollision = scaleCollision.value_or(m_iScaleCollisionModelID != -1); + // Scaling collision to (1,1,1) would just be a wasteful clone of the original - skip it + const bool bWantScaledCollision = bScaleCollision && !bIsUnitScale; + + // Set this before any SetModel() call below, since that can synchronously (or, once the model + // streams in, asynchronously) re-run Create(), which applies m_vecScale to the new object before + // registering it for collision/visibility. If m_vecScale were updated only at the end of this + // function, Create() would still see the old scale and register the object at the wrong size. + m_vecScale = vecScale; + + CClientModelManager* pModelManager = g_pClientGame->GetManager()->GetModelManager(); + + if (bWantScaledCollision) + { + // Capture the true base model the first time collision scaling is enabled, so + // re-scaling (or later disabling it) can always find its way back to it. + const unsigned short usBaseModel = (m_iScaleCollisionModelID != -1) ? m_usScaleCollisionBaseModel : m_usModel; + + const int iNewCloneID = pModelManager->AcquireScaledCollisionModel(usBaseModel, vecScale); + if (iNewCloneID != -1) + { + const int iOldCloneID = m_iScaleCollisionModelID; + + m_usScaleCollisionBaseModel = usBaseModel; + m_iScaleCollisionModelID = iNewCloneID; + + // The clone is a freshly minted model slot, so CClientModelRequestManager::Request() + // (called inside SetModel() below) would normally see it as "not loaded" and only + // queue an async callback to Create() once it streams in. That callback never actually + // fires for these clones (they don't reference new disk data, just the parent's already- + // loaded geometry, so nothing ever marks them "loaded"), leaving the object stuck with + // its old, unscaled collision until something else happens to retry. Force a blocking + // load first so Request() sees it as already loaded and Create() runs synchronously. + if (CModelInfo* pCloneModelInfo = g_pGame->GetModelInfo(iNewCloneID, true)) + { + pCloneModelInfo->ModelAddRef(BLOCKING, "CClientObject::SetScale"); + pCloneModelInfo->RemoveRef(); + } + + SetModel(static_cast(iNewCloneID)); + + if (iOldCloneID != -1 && iOldCloneID != iNewCloneID) + pModelManager->ReleaseScaledCollisionModel(iOldCloneID); + } + // Else: couldn't get scaled collision (no free model slot, or unsupported geometry + // for this scale - e.g. non-uniform scale on a model with collision spheres). Leave + // whatever collision state we already had and just fall through to the visual scale. + } + else if (m_iScaleCollisionModelID != -1) + { + // Turning collision scaling back off - restore the real model and release our clone + const int iOldCloneID = m_iScaleCollisionModelID; + const unsigned short usBaseModel = m_usScaleCollisionBaseModel; + + m_iScaleCollisionModelID = -1; + m_usScaleCollisionBaseModel = 0; + SetModel(usBaseModel); + + pModelManager->ReleaseScaledCollisionModel(iOldCloneID); + } + if (m_pObject) { m_pObject->SetScale(vecScale.fX, vecScale.fY, vecScale.fZ); } - m_vecScale = vecScale; } void CClientObject::SetCollisionEnabled(bool bCollisionEnabled) @@ -535,6 +611,20 @@ void CClientObject::Create() // Put our pointer in its stored pointer m_pObject->SetStoredPointer(this); + // Apply the visual scale directly on the freshly created game object, instead of going + // through CClientObject::SetScale(). That method can acquire or release a scaled + // collision clone and call SetModel(), which destroys and recursively re-creates this + // very object, so if the resulting model needs to stream in asynchronously, m_pObject + // ends up null here and everything below crashes on a null pointer. The collision + // clone bookkeeping is already settled by the time Create() runs, since it's what got + // us streaming m_usModel in the first place, so only the visual scale is needed here. + // This must happen before ProcessCollision()/UpdateVisibility() below, since those use + // the object's current size to register it for collision and visibility - scaling + // afterwards leaves it registered at its old (usually default 1,1,1) size, which can + // make it flicker in and out of view at some camera angles. + if (m_vecScale.fX != 1.0f || m_vecScale.fY != 1.0f || m_vecScale.fZ != 1.0f) + m_pObject->SetScale(m_vecScale.fX, m_vecScale.fY, m_vecScale.fZ); + // Apply our data to the object m_pObject->Teleport(m_vecPosition.fX, m_vecPosition.fY, m_vecPosition.fZ); m_pObject->SetOrientation(m_vecRotation.fX, m_vecRotation.fY, m_vecRotation.fZ); @@ -547,8 +637,6 @@ void CClientObject::Create() UpdateVisibility(); if (!m_bUsesCollision) SetCollisionEnabled(false); - if (m_vecScale.fX != 1.0f || m_vecScale.fY != 1.0f || m_vecScale.fZ != 1.0f) - SetScale(m_vecScale); m_pObject->SetAreaCode(m_ucInterior); SetAlpha(m_ucAlpha); m_pObject->SetHealth(m_fHealth); diff --git a/Client/mods/deathmatch/logic/CClientObject.h b/Client/mods/deathmatch/logic/CClientObject.h index dce385eb6a1..de8e2f5e0b9 100644 --- a/Client/mods/deathmatch/logic/CClientObject.h +++ b/Client/mods/deathmatch/logic/CClientObject.h @@ -12,6 +12,7 @@ class CClientObject; #pragma once +#include #include #include "CClientStreamElement.h" #include "CClientModel.h" @@ -83,7 +84,10 @@ class CClientObject : public CClientStreamElement unsigned char GetAlpha() { return m_ucAlpha; } void SetAlpha(unsigned char ucAlpha); void GetScale(CVector& vecScale) const; - void SetScale(const CVector& vecScale); + // scaleCollision left unspecified (nullopt) preserves whatever collision-scaling state this + // object already has, instead of silently turning it off. + void SetScale(const CVector& vecScale, std::optional scaleCollision = std::nullopt); + bool IsCollisionScaled() const { return m_iScaleCollisionModelID != -1; } bool IsCollisionEnabled() { return m_bUsesCollision; }; void SetCollisionEnabled(bool bCollisionEnabled); @@ -158,6 +162,12 @@ class CClientObject : public CClientStreamElement CVector m_vecCenterOfMass; bool m_bVisibleInAllDimensions = false; + // Tracks the per-scale collision clone acquired from CClientModelManager when SetScale is + // called with bScaleCollision=true. -1 means no clone is currently in use (normal shared + // collision). m_usScaleCollisionBaseModel remembers the real model so it can be restored. + int m_iScaleCollisionModelID = -1; + unsigned short m_usScaleCollisionBaseModel = 0; + CVector m_vecMoveSpeed; CVector m_vecTurnSpeed; diff --git a/Client/mods/deathmatch/logic/CPacketHandler.cpp b/Client/mods/deathmatch/logic/CPacketHandler.cpp index 1fb1a3d8fa7..868b23c7687 100644 --- a/Client/mods/deathmatch/logic/CPacketHandler.cpp +++ b/Client/mods/deathmatch/logic/CPacketHandler.cpp @@ -3087,7 +3087,9 @@ void CPacketHandler::Packet_EntityAdd(NetBitStreamInterface& bitStream) bitStream.Read(vecScale.fY); bitStream.Read(vecScale.fZ); } - pObject->SetScale(vecScale); + bool bScaleCollision = false; + bitStream.ReadBit(bScaleCollision); + pObject->SetScale(vecScale, bScaleCollision); bool bFrozen; if (bitStream.ReadBit(bFrozen)) diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp index dcd11a4e984..0a45c9ced04 100644 --- a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp +++ b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp @@ -4175,14 +4175,14 @@ bool CStaticFunctionDefinitions::StopObject(CClientEntity& Entity) return false; } -bool CStaticFunctionDefinitions::SetObjectScale(CClientEntity& Entity, const CVector& vecScale) +bool CStaticFunctionDefinitions::SetObjectScale(CClientEntity& Entity, const CVector& vecScale, std::optional scaleCollision) { - RUN_CHILDREN(SetObjectScale(**iter, vecScale)) + RUN_CHILDREN(SetObjectScale(**iter, vecScale, scaleCollision)) if (IS_OBJECT(&Entity)) { CDeathmatchObject& Object = static_cast(Entity); - Object.SetScale(vecScale); + Object.SetScale(vecScale, scaleCollision); return true; } diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h index d1e019758e9..aa6f6ec4183 100644 --- a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h +++ b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h @@ -294,7 +294,7 @@ class CStaticFunctionDefinitions static bool MoveObject(CClientEntity& Entity, unsigned long ulTime, const CVector& vecPosition, const CVector& vecDeltaRotation, CEasingCurve::eType a_eEasingType, double a_fEasingPeriod, double a_fEasingAmplitude, double a_fEasingOvershoot); static bool StopObject(CClientEntity& Entity); - static bool SetObjectScale(CClientEntity& Entity, const CVector& vecScale); + static bool SetObjectScale(CClientEntity& Entity, const CVector& vecScale, std::optional scaleCollision = std::nullopt); static bool SetObjectStatic(CClientEntity& Entity, bool bStatic); static bool SetObjectBreakable(CClientEntity& Entity, bool bBreakable); static bool BreakObject(CClientEntity& Entity); diff --git a/Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp b/Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp index 714d2a9eec2..2d9771de102 100644 --- a/Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp +++ b/Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp @@ -10,6 +10,7 @@ *****************************************************************************/ #include "StdInc.h" +#include #include void CLuaObjectDefs::LoadFunctions() @@ -424,9 +425,10 @@ int CLuaObjectDefs::StopObject(lua_State* luaVM) int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) { - // bool setObjectScale ( object theObject, float scale ) - CClientEntity* pEntity; - CVector vecScale; + // bool setObjectScale ( object theObject, float scale [, float scaleY = scale, float scaleZ = scale, bool scaleCollision ] ) + CClientEntity* pEntity; + CVector vecScale; + std::optional scaleCollision; CScriptArgReader argStream(luaVM); argStream.ReadUserData(pEntity); @@ -447,9 +449,18 @@ int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) argStream.ReadNumber(vecScale.fY, vecScale.fX); argStream.ReadNumber(vecScale.fZ, vecScale.fX); } + // Leaving scaleCollision unspecified preserves whatever collision-scaling state the object + // already has, instead of silently turning it off every time the scale is just nudged. + if (argStream.NextIsBool()) + { + bool bValue; + argStream.ReadBool(bValue); + scaleCollision = bValue; + } + if (!argStream.HasErrors()) { - if (CStaticFunctionDefinitions::SetObjectScale(*pEntity, vecScale)) + if (CStaticFunctionDefinitions::SetObjectScale(*pEntity, vecScale, scaleCollision)) { lua_pushboolean(luaVM, true); return 1; diff --git a/Client/mods/deathmatch/logic/rpc/CObjectRPCs.cpp b/Client/mods/deathmatch/logic/rpc/CObjectRPCs.cpp index 15ff752b6a3..baa01cc554f 100644 --- a/Client/mods/deathmatch/logic/rpc/CObjectRPCs.cpp +++ b/Client/mods/deathmatch/logic/rpc/CObjectRPCs.cpp @@ -97,7 +97,11 @@ void CObjectRPCs::SetObjectScale(CClientEntity* pSource, NetBitStreamInterface& vecScale.fZ = vecScale.fX; bitStream.Read(vecScale.fY); bitStream.Read(vecScale.fZ); - pObject->SetScale(vecScale); + + bool bScaleCollision = false; + bitStream.ReadBit(bScaleCollision); + + pObject->SetScale(vecScale, bScaleCollision); } } diff --git a/Client/sdk/game/CModelInfo.h b/Client/sdk/game/CModelInfo.h index 16cde2727ff..53ffd9bcfee 100644 --- a/Client/sdk/game/CModelInfo.h +++ b/Client/sdk/game/CModelInfo.h @@ -22,6 +22,7 @@ constexpr std::uint16_t MODEL_PROPERTIES_GROUP_STATIC = 0xFFFF; class CBaseModelInfoSAInterface; class CColModel; class CPedModelInfo; +struct CColModelSAInterface; struct RpClump; struct RwObject; @@ -227,6 +228,9 @@ class CModelInfo virtual void SetColModel(CColModel* pColModel) = 0; virtual void RestoreColModel() = 0; + // Raw collision interface currently assigned to this model (custom or original). May be nullptr. + virtual CColModelSAInterface* GetColModelInterface() = 0; + // Increases the collision slot reference counter for this model virtual void AddColRef() = 0; diff --git a/Client/sdk/game/CRenderWare.h b/Client/sdk/game/CRenderWare.h index 760fe299253..53ea4bd82a5 100644 --- a/Client/sdk/game/CRenderWare.h +++ b/Client/sdk/game/CRenderWare.h @@ -20,6 +20,8 @@ class CPixels; class CShaderItem; class SString; class CColModel; +class CVector; +struct CColModelSAInterface; struct RpAtomicContainer; struct RwFrame; struct RwMatrix; @@ -85,6 +87,7 @@ class CRenderWare virtual RwTexDictionary* ReadTXD(const SString& strFilename, const SString& buffer) = 0; virtual RpClump* ReadDFF(const SString& strFilename, const SString& buffer, unsigned short usModelID, bool bLoadEmbeddedCollisions) = 0; virtual CColModel* ReadCOL(const SString& buffer) = 0; + virtual CColModel* CreateScaledColModel(CColModelSAInterface* pOriginalInterface, const CVector& vecScale) = 0; virtual void DestroyDFF(RpClump* pClump) = 0; virtual void DestroyTXD(RwTexDictionary* pTXD) = 0; virtual void DestroyTexture(RwTexture* pTex) = 0; diff --git a/Server/mods/deathmatch/logic/CObject.cpp b/Server/mods/deathmatch/logic/CObject.cpp index 4c5e7d30ec2..5effa81ba4e 100644 --- a/Server/mods/deathmatch/logic/CObject.cpp +++ b/Server/mods/deathmatch/logic/CObject.cpp @@ -54,6 +54,7 @@ CObject::CObject(const CObject& Copy) : CElement(Copy.m_pParent), m_bIsLowLod(Co m_usModel = Copy.m_usModel; m_ucAlpha = Copy.m_ucAlpha; m_vecScale = CVector(Copy.m_vecScale.fX, Copy.m_vecScale.fY, Copy.m_vecScale.fZ); + m_bScaleCollision = Copy.m_bScaleCollision; m_fHealth = Copy.m_fHealth; m_bSyncable = Copy.m_bSyncable; m_pSyncer = Copy.m_pSyncer; diff --git a/Server/mods/deathmatch/logic/CObject.h b/Server/mods/deathmatch/logic/CObject.h index 23a0cf91907..3590009a01f 100644 --- a/Server/mods/deathmatch/logic/CObject.h +++ b/Server/mods/deathmatch/logic/CObject.h @@ -56,6 +56,9 @@ class CObject : public CElement const CVector& GetScale() { return m_vecScale; } void SetScale(const CVector& vecScale) { m_vecScale = vecScale; } + bool IsScaleCollisionEnabled() { return m_bScaleCollision; } + void SetScaleCollisionEnabled(bool bScaleCollision) { m_bScaleCollision = bScaleCollision; } + bool GetCollisionEnabled() { return m_bCollisionsEnabled; } void SetCollisionEnabled(bool bCollisionEnabled) { m_bCollisionsEnabled = bCollisionEnabled; } @@ -93,6 +96,7 @@ class CObject : public CElement unsigned char m_ucAlpha; unsigned short m_usModel; CVector m_vecScale; + bool m_bScaleCollision = false; bool m_bIsFrozen; float m_fHealth; bool m_bBreakable; diff --git a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp index 7bcd5afe321..cecbf94a997 100644 --- a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp +++ b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp @@ -8385,20 +8385,27 @@ bool CStaticFunctionDefinitions::SetObjectRotation(CElement* pElement, const CVe return false; } -bool CStaticFunctionDefinitions::SetObjectScale(CElement* pElement, const CVector& vecScale) +bool CStaticFunctionDefinitions::SetObjectScale(CElement* pElement, const CVector& vecScale, std::optional scaleCollision) { - RUN_CHILDREN(SetObjectScale(*iter, vecScale)) + RUN_CHILDREN(SetObjectScale(*iter, vecScale, scaleCollision)) if (IS_OBJECT(pElement)) { CObject* pObject = static_cast(pElement); pObject->SetScale(vecScale); + // Leaving scaleCollision unspecified preserves whatever collision-scaling state the object + // already has, instead of silently turning it off every time the scale is just nudged. + if (scaleCollision.has_value()) + pObject->SetScaleCollisionEnabled(*scaleCollision); + + const bool bScaleCollision = pObject->IsScaleCollisionEnabled(); CBitStream BitStream; BitStream.pBitStream->Write(vecScale.fX); BitStream.pBitStream->Write(vecScale.fY); // Ignored by clients with bitstream version < 0x41 BitStream.pBitStream->Write(vecScale.fZ); // Ignored by clients with bitstream version < 0x41 + BitStream.pBitStream->WriteBit(bScaleCollision); m_pPlayerManager->BroadcastOnlyJoined(CElementRPCPacket(pObject, SET_OBJECT_SCALE, *BitStream.pBitStream)); return true; } diff --git a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h index 911710b06b4..bdce92794fc 100644 --- a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h +++ b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h @@ -427,7 +427,7 @@ class CStaticFunctionDefinitions // Object set functions static bool SetObjectRotation(CElement* pElement, const CVector& vecRotation); - static bool SetObjectScale(CElement* pElement, const CVector& vecScale); + static bool SetObjectScale(CElement* pElement, const CVector& vecScale, std::optional scaleCollision = std::nullopt); static bool MoveObject(CResource* pResource, CElement* pElement, unsigned long ulTime, const CVector& vecPosition, const CVector& vecRotation, CEasingCurve::eType a_easingType, double a_fEasingPeriod, double a_fEasingAmplitude, double a_fEasingOvershoot); static bool StopObject(CElement* pElement); diff --git a/Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp b/Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp index a64a33fe498..baad1e81242 100644 --- a/Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp +++ b/Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp @@ -10,6 +10,7 @@ *****************************************************************************/ #include "StdInc.h" +#include #include "CLuaObjectDefs.h" #include "CStaticFunctionDefinitions.h" #include "CScriptArgReader.h" @@ -195,8 +196,10 @@ int CLuaObjectDefs::SetObjectRotation(lua_State* luaVM) int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) { - CObject* pObject; - CVector vecScale; + // bool setObjectScale ( object theObject, float scale [, float scaleY = scale, float scaleZ = scale, bool scaleCollision ] ) + CObject* pObject; + CVector vecScale; + std::optional scaleCollision; CScriptArgReader argStream(luaVM); argStream.ReadUserData(pObject); @@ -217,10 +220,18 @@ int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) argStream.ReadNumber(vecScale.fY, vecScale.fX); argStream.ReadNumber(vecScale.fZ, vecScale.fX); } + // Leaving scaleCollision unspecified preserves whatever collision-scaling state the object + // already has, instead of silently turning it off every time the scale is just nudged. + if (argStream.NextIsBool()) + { + bool bValue; + argStream.ReadBool(bValue); + scaleCollision = bValue; + } if (!argStream.HasErrors()) { - if (CStaticFunctionDefinitions::SetObjectScale(pObject, vecScale)) + if (CStaticFunctionDefinitions::SetObjectScale(pObject, vecScale, scaleCollision)) { lua_pushboolean(luaVM, true); return 1; diff --git a/Server/mods/deathmatch/logic/packets/CEntityAddPacket.cpp b/Server/mods/deathmatch/logic/packets/CEntityAddPacket.cpp index 0a92e763762..e56ba2d68ea 100644 --- a/Server/mods/deathmatch/logic/packets/CEntityAddPacket.cpp +++ b/Server/mods/deathmatch/logic/packets/CEntityAddPacket.cpp @@ -281,6 +281,7 @@ bool CEntityAddPacket::Write(NetBitStreamInterface& BitStream) const BitStream.Write(vecScale.fY); BitStream.Write(vecScale.fZ); } + BitStream.WriteBit(pObject->IsScaleCollisionEnabled()); // Frozen bool bFrozen = pObject->IsFrozen();