From 6e61396e8ed86b89e0fd93686b7463481ff5a83a Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 16:47:07 -0300 Subject: [PATCH 01/12] Add scaled collision model cloning to CRenderWareSA Builds a new CColModel by rescaling a model's collision geometry (spheres, boxes, vertices, suspension lines/disks) and feeding it through the engine's own COL3 parser, so memory ownership ends up identical to a normal custom .col load. Non-uniform scale gets rejected when the source has collision spheres, disks or lines, since those only carry a single radius and can't be scaled correctly per axis. Also adds CModelInfoSA::GetColModelInterface() to read a model's current collision before scaling it. --- Client/game_sa/CModelInfoSA.cpp | 6 + Client/game_sa/CModelInfoSA.h | 1 + Client/game_sa/CRenderWareSA.cpp | 218 +++++++++++++++++++++++++++++++ Client/game_sa/CRenderWareSA.h | 4 + Client/sdk/game/CModelInfo.h | 4 + Client/sdk/game/CRenderWare.h | 3 + 6 files changed, 236 insertions(+) diff --git a/Client/game_sa/CModelInfoSA.cpp b/Client/game_sa/CModelInfoSA.cpp index 94a3316bc85..651a6247698 100644 --- a/Client/game_sa/CModelInfoSA.cpp +++ b/Client/game_sa/CModelInfoSA.cpp @@ -1744,6 +1744,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..424b0848635 100644 --- a/Client/game_sa/CModelInfoSA.h +++ b/Client/game_sa/CModelInfoSA.h @@ -458,6 +458,7 @@ class CModelInfoSA : public CModelInfo 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..9fff1bd22f7 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; + } + + bool IsScaleUniform(const CVector& vecScale) + { + constexpr float kEpsilon = 0.0001f; + return std::fabs(vecScale.fX - vecScale.fY) < kEpsilon && std::fabs(vecScale.fY - vecScale.fZ) < kEpsilon; + } + + // 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) + { + // No collision volumes at all (e.g. a purely visual/LOD model) - nothing to scale + return nullptr; + } + + const bool bUsesDisks = pOriginalData->m_usesDisks; + const bool bHasNonUniformScaleHazard = + (pOriginalData->m_numSpheres > 0 || pOriginalData->m_numSuspensionLines > 0) && !IsScaleUniform(vecScale); + if (bHasNonUniformScaleHazard) + { + // Spheres/disks/lines carry a single radius value that can't be represented correctly + // under a non-uniform scale. Reject rather than silently producing wrong collision. + return nullptr; + } + + // 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/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; From 4c58fd9d0179a9ad4b31de8081f4d8bf3621f825 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 16:47:20 -0300 Subject: [PATCH 02/12] Add refcounted cache for per-scale collision model clones AcquireScaledCollisionModel clones a base model into a free custom model ID with scaled collision, sharing that clone with any other caller asking for the same model and scale instead of duplicating it. ReleaseScaledCollisionModel drops a reference and frees the clone's collision and model slot once nobody's using it anymore. Releasing detaches the collision first, same order engineReplaceCOL already uses, then destroys it and frees the model slot. Freeing the slot first would make CModelInfoSA::Remove skip the actual unload while it still thinks a custom col model is assigned. --- .../deathmatch/logic/CClientModelManager.cpp | 97 +++++++++++++++++++ .../deathmatch/logic/CClientModelManager.h | 44 +++++++++ 2 files changed, 141 insertions(+) diff --git a/Client/mods/deathmatch/logic/CClientModelManager.cpp b/Client/mods/deathmatch/logic/CClientModelManager.cpp index 75975f24d33..b2beba7c52d 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(); @@ -136,3 +140,96 @@ 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; + + CColModelSAInterface* pOriginalColModelInterface = pBaseModelInfo->GetColModelInterface(); + 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; }; From f8dc096a23b5903a06ffe1907166ade2e58b14ee Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 16:47:32 -0300 Subject: [PATCH 03/12] Wire CClientObject::SetScale to optional collision scaling SetScale now takes a bScaleCollision flag, default false so nothing existing changes. When it's on, the object switches to a scaled collision clone from CClientModelManager; turning it off (or destroying the object) switches back to the real model and releases the clone. The real base model is kept separately so re-scaling or disabling it later always starts from the original, not a clone. The clone only gets released after Destroy()/SetModel() already dropped this object's own reference to it, not before, otherwise its model info could get freed while still in use. --- .../mods/deathmatch/logic/CClientObject.cpp | 57 ++++++++++++++++++- Client/mods/deathmatch/logic/CClientObject.h | 9 ++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/Client/mods/deathmatch/logic/CClientObject.cpp b/Client/mods/deathmatch/logic/CClientObject.cpp index 933a01f2816..facf336ef38 100644 --- a/Client/mods/deathmatch/logic/CClientObject.cpp +++ b/Client/mods/deathmatch/logic/CClientObject.cpp @@ -9,6 +9,7 @@ *****************************************************************************/ #include +#include #define MTA_BUILDINGS #define CCLIENTOBJECT_MAX 250 @@ -64,9 +65,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,8 +417,51 @@ void CClientObject::GetScale(CVector& vecScale) const } } -void CClientObject::SetScale(const CVector& vecScale) +void CClientObject::SetScale(const CVector& vecScale, bool bScaleCollision) { + 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; + // Scaling collision to (1,1,1) would just be a wasteful clone of the original - skip it + const bool bWantScaledCollision = bScaleCollision && !bIsUnitScale; + + 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; + 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); diff --git a/Client/mods/deathmatch/logic/CClientObject.h b/Client/mods/deathmatch/logic/CClientObject.h index dce385eb6a1..ec3e2e5f2ba 100644 --- a/Client/mods/deathmatch/logic/CClientObject.h +++ b/Client/mods/deathmatch/logic/CClientObject.h @@ -83,7 +83,8 @@ 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); + void SetScale(const CVector& vecScale, bool bScaleCollision = false); + bool IsCollisionScaled() const { return m_iScaleCollisionModelID != -1; } bool IsCollisionEnabled() { return m_bUsesCollision; }; void SetCollisionEnabled(bool bCollisionEnabled); @@ -158,6 +159,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; From 932fc1d381dbe065d0b26f2f22dd1cda3fd50037 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 16:47:44 -0300 Subject: [PATCH 04/12] Expose scaleCollision argument on client setObjectScale setObjectScale(object, scale[, scaleY, scaleZ, scaleCollision=false]), defaulting to false so every existing script keeps its current visual-only scaling behaviour. --- .../mods/deathmatch/logic/CStaticFunctionDefinitions.cpp | 6 +++--- Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h | 2 +- Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp index dcd11a4e984..fac90075f9a 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, bool bScaleCollision) { - RUN_CHILDREN(SetObjectScale(**iter, vecScale)) + RUN_CHILDREN(SetObjectScale(**iter, vecScale, bScaleCollision)) if (IS_OBJECT(&Entity)) { CDeathmatchObject& Object = static_cast(Entity); - Object.SetScale(vecScale); + Object.SetScale(vecScale, bScaleCollision); return true; } diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h index d1e019758e9..a0f481d4c08 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, bool bScaleCollision = false); 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..27f8f29eed5 100644 --- a/Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp +++ b/Client/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp @@ -424,9 +424,10 @@ int CLuaObjectDefs::StopObject(lua_State* luaVM) int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) { - // bool setObjectScale ( object theObject, float scale ) + // bool setObjectScale ( object theObject, float scale [, float scaleY = scale, float scaleZ = scale, bool scaleCollision = false ] ) CClientEntity* pEntity; CVector vecScale; + bool bScaleCollision = false; CScriptArgReader argStream(luaVM); argStream.ReadUserData(pEntity); @@ -447,9 +448,11 @@ int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) argStream.ReadNumber(vecScale.fY, vecScale.fX); argStream.ReadNumber(vecScale.fZ, vecScale.fX); } + argStream.ReadBool(bScaleCollision, false); + if (!argStream.HasErrors()) { - if (CStaticFunctionDefinitions::SetObjectScale(*pEntity, vecScale)) + if (CStaticFunctionDefinitions::SetObjectScale(*pEntity, vecScale, bScaleCollision)) { lua_pushboolean(luaVM, true); return 1; From 4de00630d68225a7fb65e6a3b0cec8062beb2062 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 16:47:56 -0300 Subject: [PATCH 05/12] Add server-side scaleCollision support and sync to clients CObject now stores the scaleCollision flag alongside its scale and passes it through setObjectScale on the server's Lua API. It's synced to clients both on live changes (the SET_OBJECT_SCALE RPC) and on initial entity creation, so players who join later still apply the same collision scaling as everyone else. --- Client/mods/deathmatch/logic/CPacketHandler.cpp | 4 +++- Client/mods/deathmatch/logic/rpc/CObjectRPCs.cpp | 6 +++++- Server/mods/deathmatch/logic/CObject.cpp | 1 + Server/mods/deathmatch/logic/CObject.h | 4 ++++ Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp | 6 ++++-- Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h | 2 +- Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp | 5 ++++- Server/mods/deathmatch/logic/packets/CEntityAddPacket.cpp | 1 + 8 files changed, 23 insertions(+), 6 deletions(-) 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/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/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..35ee13f7e6c 100644 --- a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp +++ b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp @@ -8385,20 +8385,22 @@ 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, bool bScaleCollision) { - RUN_CHILDREN(SetObjectScale(*iter, vecScale)) + RUN_CHILDREN(SetObjectScale(*iter, vecScale, bScaleCollision)) if (IS_OBJECT(pElement)) { CObject* pObject = static_cast(pElement); pObject->SetScale(vecScale); + pObject->SetScaleCollisionEnabled(bScaleCollision); 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..9a15fbaa751 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, bool bScaleCollision = false); 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..ee0d3be7c86 100644 --- a/Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp +++ b/Server/mods/deathmatch/logic/luadefs/CLuaObjectDefs.cpp @@ -195,8 +195,10 @@ int CLuaObjectDefs::SetObjectRotation(lua_State* luaVM) int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) { + // bool setObjectScale ( object theObject, float scale [, float scaleY = scale, float scaleZ = scale, bool scaleCollision = false ] ) CObject* pObject; CVector vecScale; + bool bScaleCollision = false; CScriptArgReader argStream(luaVM); argStream.ReadUserData(pObject); @@ -217,10 +219,11 @@ int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) argStream.ReadNumber(vecScale.fY, vecScale.fX); argStream.ReadNumber(vecScale.fZ, vecScale.fX); } + argStream.ReadBool(bScaleCollision, false); if (!argStream.HasErrors()) { - if (CStaticFunctionDefinitions::SetObjectScale(pObject, vecScale)) + if (CStaticFunctionDefinitions::SetObjectScale(pObject, vecScale, bScaleCollision)) { 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(); From ff503a04e4ebcbb16a652e1b44ac6533de1407b8 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 20:53:38 -0300 Subject: [PATCH 06/12] Fix crash when creating an object with scaled collision already active CClientObject::Create() applied scale through the full CClientObject::SetScale(), which can acquire or release a scaled collision clone and call SetModel(). That destroys and recursively re-creates the very object currently being constructed, and if the resulting model needs to stream in asynchronously, m_pObject is left null for the rest of Create(), crashing on the next access to it (for example SetAreaCode). By the time Create() runs, the collision clone bookkeeping is already settled, since it's what's streaming m_usModel in, so only the visual scale needs to be applied here. Skip CClientObject::SetScale() and call m_pObject->SetScale() directly instead. --- Client/mods/deathmatch/logic/CClientObject.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Client/mods/deathmatch/logic/CClientObject.cpp b/Client/mods/deathmatch/logic/CClientObject.cpp index facf336ef38..aadf0fe4c4e 100644 --- a/Client/mods/deathmatch/logic/CClientObject.cpp +++ b/Client/mods/deathmatch/logic/CClientObject.cpp @@ -600,8 +600,15 @@ void CClientObject::Create() UpdateVisibility(); if (!m_bUsesCollision) SetCollisionEnabled(false); + // 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. if (m_vecScale.fX != 1.0f || m_vecScale.fY != 1.0f || m_vecScale.fZ != 1.0f) - SetScale(m_vecScale); + m_pObject->SetScale(m_vecScale.fX, m_vecScale.fY, m_vecScale.fZ); m_pObject->SetAreaCode(m_ucInterior); SetAlpha(m_ucAlpha); m_pObject->SetHealth(m_fHealth); From f4888d1d84368b662a9e82c42c82b0c9735e2044 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 21:17:06 -0300 Subject: [PATCH 07/12] Don't silently disable scaled collision when scale is just nudged setObjectScale(obj, x, y, z) without the scaleCollision argument defaulted it to false, which meant calling it again to tweak the visual scale alone would turn off any collision scaling that was already active, on both the client and the server. scaleCollision is now optional end to end (Lua argument, static function definitions, and CClientObject::SetScale). Leaving it unspecified preserves whatever collision-scaling state the object already has, instead of resetting it. Explicitly passing true or false still works as before. --- .../mods/deathmatch/logic/CClientObject.cpp | 6 +++++- Client/mods/deathmatch/logic/CClientObject.h | 5 ++++- .../logic/CStaticFunctionDefinitions.cpp | 6 +++--- .../logic/CStaticFunctionDefinitions.h | 2 +- .../logic/luadefs/CLuaObjectDefs.cpp | 20 +++++++++++++------ .../logic/CStaticFunctionDefinitions.cpp | 11 +++++++--- .../logic/CStaticFunctionDefinitions.h | 2 +- .../logic/luadefs/CLuaObjectDefs.cpp | 20 +++++++++++++------ 8 files changed, 50 insertions(+), 22 deletions(-) diff --git a/Client/mods/deathmatch/logic/CClientObject.cpp b/Client/mods/deathmatch/logic/CClientObject.cpp index aadf0fe4c4e..0c6b1071a72 100644 --- a/Client/mods/deathmatch/logic/CClientObject.cpp +++ b/Client/mods/deathmatch/logic/CClientObject.cpp @@ -10,6 +10,7 @@ #include #include +#include #define MTA_BUILDINGS #define CCLIENTOBJECT_MAX 250 @@ -417,11 +418,14 @@ void CClientObject::GetScale(CVector& vecScale) const } } -void CClientObject::SetScale(const CVector& vecScale, bool bScaleCollision) +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; diff --git a/Client/mods/deathmatch/logic/CClientObject.h b/Client/mods/deathmatch/logic/CClientObject.h index ec3e2e5f2ba..69e46d7f225 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,9 @@ 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, bool bScaleCollision = false); + // 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; }; diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp index fac90075f9a..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 bScaleCollision) +bool CStaticFunctionDefinitions::SetObjectScale(CClientEntity& Entity, const CVector& vecScale, std::optional scaleCollision) { - RUN_CHILDREN(SetObjectScale(**iter, vecScale, bScaleCollision)) + RUN_CHILDREN(SetObjectScale(**iter, vecScale, scaleCollision)) if (IS_OBJECT(&Entity)) { CDeathmatchObject& Object = static_cast(Entity); - Object.SetScale(vecScale, bScaleCollision); + Object.SetScale(vecScale, scaleCollision); return true; } diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.h index a0f481d4c08..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, bool bScaleCollision = false); + 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 27f8f29eed5..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,10 +425,10 @@ int CLuaObjectDefs::StopObject(lua_State* luaVM) int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) { - // bool setObjectScale ( object theObject, float scale [, float scaleY = scale, float scaleZ = scale, bool scaleCollision = false ] ) - CClientEntity* pEntity; - CVector vecScale; - bool bScaleCollision = false; + // 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); @@ -448,11 +449,18 @@ int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) argStream.ReadNumber(vecScale.fY, vecScale.fX); argStream.ReadNumber(vecScale.fZ, vecScale.fX); } - argStream.ReadBool(bScaleCollision, false); + // 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, bScaleCollision)) + if (CStaticFunctionDefinitions::SetObjectScale(*pEntity, vecScale, scaleCollision)) { lua_pushboolean(luaVM, true); return 1; diff --git a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp index 35ee13f7e6c..cecbf94a997 100644 --- a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp +++ b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp @@ -8385,16 +8385,21 @@ bool CStaticFunctionDefinitions::SetObjectRotation(CElement* pElement, const CVe return false; } -bool CStaticFunctionDefinitions::SetObjectScale(CElement* pElement, const CVector& vecScale, bool bScaleCollision) +bool CStaticFunctionDefinitions::SetObjectScale(CElement* pElement, const CVector& vecScale, std::optional scaleCollision) { - RUN_CHILDREN(SetObjectScale(*iter, vecScale, bScaleCollision)) + RUN_CHILDREN(SetObjectScale(*iter, vecScale, scaleCollision)) if (IS_OBJECT(pElement)) { CObject* pObject = static_cast(pElement); pObject->SetScale(vecScale); - pObject->SetScaleCollisionEnabled(bScaleCollision); + // 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); diff --git a/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h b/Server/mods/deathmatch/logic/CStaticFunctionDefinitions.h index 9a15fbaa751..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, bool bScaleCollision = false); + 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 ee0d3be7c86..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,10 +196,10 @@ int CLuaObjectDefs::SetObjectRotation(lua_State* luaVM) int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) { - // bool setObjectScale ( object theObject, float scale [, float scaleY = scale, float scaleZ = scale, bool scaleCollision = false ] ) - CObject* pObject; - CVector vecScale; - bool bScaleCollision = false; + // 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); @@ -219,11 +220,18 @@ int CLuaObjectDefs::SetObjectScale(lua_State* luaVM) argStream.ReadNumber(vecScale.fY, vecScale.fX); argStream.ReadNumber(vecScale.fZ, vecScale.fX); } - argStream.ReadBool(bScaleCollision, false); + // 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, bScaleCollision)) + if (CStaticFunctionDefinitions::SetObjectScale(pObject, vecScale, scaleCollision)) { lua_pushboolean(luaVM, true); return 1; From 984325ccc6fe7c2e6e8dd9d51fa106b4985151c1 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 21:23:52 -0300 Subject: [PATCH 08/12] Fix crash restoring pickups/buildings/etc on shutdown after scaled collision CClientManager's destructor deletes and nulls each manager in a fixed order (pickups, then objects, among others). Releasing a scaled collision clone in CClientObject's destructor can cascade into CClientModel::RestoreDFF, which restores every entity type that could be using the model, including pickups and buildings, with no null checks on those manager pointers. During full client shutdown, the pickup manager (and potentially others) is already gone by the time the object manager destroys its objects, so this crashed. RestoreDFF now skips each restore step whose manager isn't available instead of assuming it always is. --- Client/mods/deathmatch/logic/CClientModel.cpp | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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) From 7affa8ca21c30b40b77120ce0a3085b53b9a5202 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 21:27:12 -0300 Subject: [PATCH 09/12] Apply scale before collision/visibility registration to fix flicker Both CClientObject::Create() and SetScale() applied the visual scale after ProcessCollision()/UpdateVisibility() had already run (or, for SetScale(), after a model swap that re-runs Create() with the old scale, since m_vecScale was only updated at the very end of the function). That registers the object's collision and visibility bounds at its old, usually unscaled, size, so a scaled object could flicker in and out of view at some camera angles even after a single scale call. m_vecScale is now updated before SetModel() can trigger a recreation, and Create() applies the scale before processing collision and visibility, so both always reflect the object's real size from the moment it's registered. --- .../mods/deathmatch/logic/CClientObject.cpp | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/Client/mods/deathmatch/logic/CClientObject.cpp b/Client/mods/deathmatch/logic/CClientObject.cpp index 0c6b1071a72..d52822eb903 100644 --- a/Client/mods/deathmatch/logic/CClientObject.cpp +++ b/Client/mods/deathmatch/logic/CClientObject.cpp @@ -429,6 +429,12 @@ void CClientObject::SetScale(const CVector& vecScale, std::optional scaleC // 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) @@ -470,7 +476,6 @@ void CClientObject::SetScale(const CVector& vecScale, std::optional scaleC { m_pObject->SetScale(vecScale.fX, vecScale.fY, vecScale.fZ); } - m_vecScale = vecScale; } void CClientObject::SetCollisionEnabled(bool bCollisionEnabled) @@ -592,6 +597,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); @@ -604,15 +623,6 @@ void CClientObject::Create() UpdateVisibility(); if (!m_bUsesCollision) SetCollisionEnabled(false); - // 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. - 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); m_pObject->SetAreaCode(m_ucInterior); SetAlpha(m_ucAlpha); m_pObject->SetHealth(m_fHealth); From fddc4482950f7ca4c50abbc125211ca3bc300c13 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 21:32:27 -0300 Subject: [PATCH 10/12] Apply clang-format (v21.1.7) to files touched by this branch --- Client/game_sa/CModelInfoSA.h | 10 +++++----- Client/game_sa/CRenderWareSA.cpp | 7 +++---- Client/mods/deathmatch/logic/CClientModelManager.cpp | 10 ++++++---- Client/mods/deathmatch/logic/CClientObject.h | 4 ++-- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Client/game_sa/CModelInfoSA.h b/Client/game_sa/CModelInfoSA.h index 424b0848635..13580e417b2 100644 --- a/Client/game_sa/CModelInfoSA.h +++ b/Client/game_sa/CModelInfoSA.h @@ -453,11 +453,11 @@ 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 diff --git a/Client/game_sa/CRenderWareSA.cpp b/Client/game_sa/CRenderWareSA.cpp index 9fff1bd22f7..d13bae8d1ac 100644 --- a/Client/game_sa/CRenderWareSA.cpp +++ b/Client/game_sa/CRenderWareSA.cpp @@ -577,11 +577,11 @@ namespace { 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])}); + static_cast(triangle.m_indices[2])}); } return pData->m_numTriangles > 0 ? maxIndex + 1 : 0; } -} // namespace +} // 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 @@ -599,8 +599,7 @@ CColModel* CRenderWareSA::CreateScaledColModel(CColModelSAInterface* pOriginalIn } const bool bUsesDisks = pOriginalData->m_usesDisks; - const bool bHasNonUniformScaleHazard = - (pOriginalData->m_numSpheres > 0 || pOriginalData->m_numSuspensionLines > 0) && !IsScaleUniform(vecScale); + const bool bHasNonUniformScaleHazard = (pOriginalData->m_numSpheres > 0 || pOriginalData->m_numSuspensionLines > 0) && !IsScaleUniform(vecScale); if (bHasNonUniformScaleHazard) { // Spheres/disks/lines carry a single radius value that can't be represented correctly diff --git a/Client/mods/deathmatch/logic/CClientModelManager.cpp b/Client/mods/deathmatch/logic/CClientModelManager.cpp index b2beba7c52d..8105ee3ade8 100644 --- a/Client/mods/deathmatch/logic/CClientModelManager.cpp +++ b/Client/mods/deathmatch/logic/CClientModelManager.cpp @@ -143,13 +143,15 @@ void CClientModelManager::DeallocateModelsAllocatedByResource(CResource* pResour namespace { - int QuantizeScaleComponent(float fValue) { return static_cast(std::lround(fValue * 1000.0f)); } -} // 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)}; + const SScaledColModelKey key{usBaseModelID, QuantizeScaleComponent(vecScale.fX), QuantizeScaleComponent(vecScale.fY), QuantizeScaleComponent(vecScale.fZ)}; auto it = m_ScaledColModels.find(key); if (it != m_ScaledColModels.end()) diff --git a/Client/mods/deathmatch/logic/CClientObject.h b/Client/mods/deathmatch/logic/CClientObject.h index 69e46d7f225..de8e2f5e0b9 100644 --- a/Client/mods/deathmatch/logic/CClientObject.h +++ b/Client/mods/deathmatch/logic/CClientObject.h @@ -86,8 +86,8 @@ class CClientObject : public CClientStreamElement void GetScale(CVector& vecScale) const; // 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; } + 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); From dd23abb9a1448a9fbb1ae8abdd5f45eed49fdd7a Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 22:21:33 -0300 Subject: [PATCH 11/12] Fix scaled object collision not applying (first call, and on reconnect) The scaled-collision feature gives a scaled object its own cloned model whose collision is a scaled copy of the base model's. Several issues kept that scaled collision from actually being used: - Root cause: CModelInfoSA::SetColModel() early-returned whenever the requested col model was already recorded as the custom one (m_pCustomColModel == pColModel). MakeCustomModel() re-invokes SetColModel() right after a model streams in, precisely to re-apply the custom collision over whatever the reload reset the interface's pColModel back to. The early-return turned that re-apply into a no-op, so a freshly streamed clone kept the original disk collision and ignored the scaled one. It only "fixed itself" on a second setObjectScale call because of leftover interface state. The guard now also checks the live interface still has the custom col actually applied, so the re-apply runs when (and only when) it's needed. - CreateScaledColModel rejected any non-uniform scale on collisions with spheres/disks/lines, even though the rest of the function already approximates those radii with the largest scale axis (as it does for the bounding sphere). Removed the rejection so the approximation is actually used instead of silently producing no scaled collision. - Force a blocking load of the base model's collision before cloning it, so scaling immediately after createObject() doesn't race the model's own streaming and find no collision to clone. - Force a blocking load of the clone model before switching to it, so Create() runs synchronously and applies the scaled collision on the first call instead of waiting for an async callback that never fired. - Clear the scaled-collision clone cache in RemoveAll(), so reconnecting can't hand back a cache entry whose clone no longer exists (which left objects visually scaled but with unscaled collision after a reconnect). --- Client/game_sa/CModelInfoSA.cpp | 10 ++++++++-- Client/game_sa/CRenderWareSA.cpp | 13 ------------ .../deathmatch/logic/CClientModelManager.cpp | 20 +++++++++++++++++++ .../mods/deathmatch/logic/CClientObject.cpp | 14 +++++++++++++ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/Client/game_sa/CModelInfoSA.cpp b/Client/game_sa/CModelInfoSA.cpp index 651a6247698..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 diff --git a/Client/game_sa/CRenderWareSA.cpp b/Client/game_sa/CRenderWareSA.cpp index d13bae8d1ac..98669134951 100644 --- a/Client/game_sa/CRenderWareSA.cpp +++ b/Client/game_sa/CRenderWareSA.cpp @@ -558,12 +558,6 @@ namespace return result; } - bool IsScaleUniform(const CVector& vecScale) - { - constexpr float kEpsilon = 0.0001f; - return std::fabs(vecScale.fX - vecScale.fY) < kEpsilon && std::fabs(vecScale.fY - vecScale.fZ) < kEpsilon; - } - // 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. @@ -599,13 +593,6 @@ CColModel* CRenderWareSA::CreateScaledColModel(CColModelSAInterface* pOriginalIn } const bool bUsesDisks = pOriginalData->m_usesDisks; - const bool bHasNonUniformScaleHazard = (pOriginalData->m_numSpheres > 0 || pOriginalData->m_numSuspensionLines > 0) && !IsScaleUniform(vecScale); - if (bHasNonUniformScaleHazard) - { - // Spheres/disks/lines carry a single radius value that can't be represented correctly - // under a non-uniform scale. Reject rather than silently producing wrong collision. - return nullptr; - } // 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); diff --git a/Client/mods/deathmatch/logic/CClientModelManager.cpp b/Client/mods/deathmatch/logic/CClientModelManager.cpp index 8105ee3ade8..8bc29710d36 100644 --- a/Client/mods/deathmatch/logic/CClientModelManager.cpp +++ b/Client/mods/deathmatch/logic/CClientModelManager.cpp @@ -35,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) @@ -164,7 +177,14 @@ int CClientModelManager::AcquireScaledCollisionModel(unsigned short usBaseModelI 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; diff --git a/Client/mods/deathmatch/logic/CClientObject.cpp b/Client/mods/deathmatch/logic/CClientObject.cpp index d52822eb903..9d851de57fd 100644 --- a/Client/mods/deathmatch/logic/CClientObject.cpp +++ b/Client/mods/deathmatch/logic/CClientObject.cpp @@ -450,6 +450,20 @@ void CClientObject::SetScale(const CVector& vecScale, std::optional scaleC 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) From 767f4147481221f02c6822c266e2a05e835393f4 Mon Sep 17 00:00:00 2001 From: Federico Romero Date: Fri, 26 Jun 2026 22:47:15 -0300 Subject: [PATCH 12/12] Force-load collision slot before scaling if data is not resident When scaling an object in the same frame it is created, its collision slot may not have been streamed in yet, causing m_data to be nullptr. Previously, this was incorrectly treated as a "no collision" case (visual/LOD model), silently leaving the object unscaled. Fix: request and force-load the collision stream slot via the engine's streaming system before giving up. Only return nullptr if data is still absent after the force-load, which is the genuine "no collision" case. --- Client/game_sa/CRenderWareSA.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Client/game_sa/CRenderWareSA.cpp b/Client/game_sa/CRenderWareSA.cpp index 98669134951..a1aeea215ab 100644 --- a/Client/game_sa/CRenderWareSA.cpp +++ b/Client/game_sa/CRenderWareSA.cpp @@ -588,8 +588,22 @@ CColModel* CRenderWareSA::CreateScaledColModel(CColModelSAInterface* pOriginalIn CColDataSA* pOriginalData = pOriginalInterface->m_data; if (!pOriginalData) { - // No collision volumes at all (e.g. a purely visual/LOD model) - nothing to scale - return nullptr; + // 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;