From 882c9c269e63cc18cabcb9018c595be78775daf5 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 13:03:45 +0700 Subject: [PATCH 001/140] Refactor SerialSimulation --- src/SerialSimulation.luau | 2062 ++++++++++++++++++++++++++----------- 1 file changed, 1466 insertions(+), 596 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index f8b74dc8..b51365a3 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1,110 +1,197 @@ --[[ - - Author: Mawin CK - - Date: 2026 + SerialSimulation.luau + FastCast2 + + Refactored preview. + + Main goals: + - Keep simulation flow readable. + - Isolate collision resolution. + - Isolate high-fidelity resimulation. + - Centralize Hit/Pierced/Terminating events. + - Avoid deeply nested if/else chains. + - Keep SerialSimulation API compatible. ]] --!native --!optimize 2 --- Services - -local RS = game:GetService("RunService") +local RunService = game:GetService("RunService") local TypeDef = require(script.Parent:WaitForChild("TypeDefinitions")) local FastCastEnums = require(script.Parent:WaitForChild("FastCastEnums")) local Config = require(script.Parent:WaitForChild("Config")) -local DebugLogging = Config.DebugLogging --- Constants +local DebugLogging = Config.DebugLogging local EnumCastTypes = FastCastEnums.CastType + local DEFAULT_MAX_DISTANCE = 1000 local FC_VIS_OBJ_NAME = "FastCastVisualizationObjects" --- Variables +--------------------------------------------------------------------- +-- Types +--------------------------------------------------------------------- + +type BlockcastVariant = { + CastType: number, + Size: Vector3, +} + +type SpherecastVariant = { + CastType: number, + Radius: number, +} + +type CastVariants = + BlockcastVariant + | SpherecastVariant + +type RayVisualizerVariant = { + castLength: number, +} + +type BlockVisualizerVariant = { + size: Vector3, +} + +type SphereVisualizerVariant = { + radius: number, +} + +type CastVisualizerVariants = + RayVisualizerVariant + | BlockVisualizerVariant + | SphereVisualizerVariant + +type QueuedEventData = { + eventType: string, + args: { any }, +} + +type QueuedVisualizeCastData = { + castStartCFrame: CFrame, + sub: boolean, +} + +type QueuedVisualizeHitData = { + atCF: CFrame, + wasPierce: boolean, + sub: boolean, +} + +type SegmentData = { + lastPoint: Vector3, + currentPoint: Vector3, + + lastTime: number, + currentTime: number, + + velocity: Vector3, + displacement: Vector3, +} + +--------------------------------------------------------------------- +-- SoA State +--------------------------------------------------------------------- local casts_TotalRunTime = {} :: { [number]: number } local casts_DistanceCovered = {} :: { [number]: number } + local casts_HighFidelitySegmentSize = {} :: { [number]: number } local casts_HighFidelityBehavior = {} :: { [number]: number } + local casts_IsActivelySimulatingPierce = {} :: { [number]: boolean } local casts_IsActivelyResimulating = {} :: { [number]: boolean } local casts_CancelHighResCast = {} :: { [number]: boolean } + local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } local casts_FastCastEventsConfig = {} :: { [number]: TypeDef.FastCastEventsConfig } + local casts_VisualizeCasts = {} :: { [number]: boolean } -local casts_VisualizeCastSettings = {} :: { [number]: TypeDef.VisualizeCastSettings } +local casts_VisualizeCastSettings = {} :: { + [number]: TypeDef.VisualizeCastSettings +} + local casts_RayInfo = {} :: { [number]: TypeDef.CastRayInfo } local casts_UserData = {} :: { [number]: any } + local casts_CFrame = {} :: { [number]: CFrame } + local casts_CastType = {} :: { [number]: number } local casts_CastVariant = {} :: { [number]: CastVariants } + local casts_MaxDistance = {} :: { [number]: number } local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } -local casts_RayDisplacement = {} :: { [number]: number } --- Types - -type BlockcastVariant = { CastType: number, Size: Vector3 } -type SpherecastVariant = { CastType: number, Radius: number } -type CastVariants = BlockcastVariant | SpherecastVariant - - -type RayVisualizerVariant = { castLength: number} -type BlockVisualizerVariant = { size: Vector3 } -type SphereVisualizerVariant = { radius: number } -type CastVisualizerVariants = RayVisualizerVariant | BlockVisualizerVariant | SphereVisualizerVariant - -type QueuedEventData = { - eventType: string, - args: { any } -} +local casts_RayDisplacement = {} :: { [number]: number } -type QueuedVisualizeCastData = { - castStartCFrame: CFrame, - sub: boolean -} +--------------------------------------------------------------------- +-- Runtime State +--------------------------------------------------------------------- -type QueuedVisualizeHitData = { - atCF: CFrame, - wasPierce: boolean, - sub: boolean -} +local queuedEvents: { + [number]: { QueuedEventData }, +} = {} --- queued things +local queuedVisualizes: { + [number]: { + QueuedVisualizeCastData | QueuedVisualizeHitData + }, +} = {} -local queuedEvents: { [number]: { QueuedEventData } } = {} -local queuedVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData }} = {} +--------------------------------------------------------------------- +-- Cast Handlers +--------------------------------------------------------------------- local castHandlers = { + [EnumCastTypes.Raycast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams ) - return targetWorldRoot:Raycast(origin, direction, params) + return worldRoot:Raycast( + origin, + direction, + params + ) end, + [EnumCastTypes.Blockcast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: BlockcastVariant ) - return targetWorldRoot:Blockcast(CFrame.new(origin), variant.Size, direction, params) + return worldRoot:Blockcast( + CFrame.new(origin), + variant.Size, + direction, + params + ) end, + [EnumCastTypes.Spherecast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: SpherecastVariant ) - return targetWorldRoot:Spherecast(origin, variant.Radius, direction, params) - end + return worldRoot:Spherecast( + origin, + variant.Radius, + direction, + params + ) + end, } --- Utils +--------------------------------------------------------------------- +-- Math +--------------------------------------------------------------------- local function GetPositionAtTime( t: number, @@ -112,799 +199,1582 @@ local function GetPositionAtTime( initialVelocity: Vector3, acceleration: Vector3 ): Vector3 - local force = Vector3.new( - (acceleration.X * t ^ 2) / 2, - (acceleration.Y * t ^ 2) / 2, - (acceleration.Z * t ^ 2) / 2 - ) - return origin + (initialVelocity * t) + force + + return origin + + initialVelocity * t + + acceleration * (t * t * 0.5) end -local function GetVelocityAtTime(time: number, initialVelocity: Vector3, acceleration: Vector3): Vector3 - return initialVelocity + acceleration * time +local function GetVelocityAtTime( + t: number, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + + return initialVelocity + acceleration * t end -local function DebrisAdd(obj: Instance, Lifetime: number) - if not obj then - return - end - if Lifetime <= 0 then - obj:Destroy() +--------------------------------------------------------------------- +-- Debug Visualization +--------------------------------------------------------------------- + +local function DebrisAdd( + object: Instance, + lifetime: number +) + if lifetime <= 0 then + object:Destroy() return end - task.delay(Lifetime, function() - obj:Destroy() + task.delay(lifetime, function() + if object.Parent then + object:Destroy() + end end) end -local function GetFastCastVisualizationContainer(): Instance - local fcVisualizationObjects = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) - if fcVisualizationObjects then - return fcVisualizationObjects +local function GetVisualizationContainer(): Instance + + local container = workspace.Terrain:FindFirstChild( + FC_VIS_OBJ_NAME + ) + + if container then + return container end - fcVisualizationObjects = Instance.new("Folder") - fcVisualizationObjects.Name = FC_VIS_OBJ_NAME - fcVisualizationObjects.Archivable = false - fcVisualizationObjects.Parent = workspace.Terrain - return fcVisualizationObjects + container = Instance.new("Folder") + container.Name = FC_VIS_OBJ_NAME + container.Archivable = false + container.Parent = workspace.Terrain + + return container end -local function DbgVisualizeRaySegment( - castStartCFrame: CFrame, - VisualizeCastSettings: TypeDef.VisualizeCastSettings, +local function VisualizeRaySegment( + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: RayVisualizerVariant -): ConeHandleAdornment? +) + local adornment = Instance.new("ConeHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame + adornment.CFrame = startCFrame adornment.Height = variant.castLength - adornment.Color3 = VisualizeCastSettings.Debug_SegmentColor - adornment.Radius = VisualizeCastSettings.Debug_SegmentSize - adornment.Transparency = VisualizeCastSettings.Debug_SegmentTransparency - adornment.Parent = GetFastCastVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSettings.Debug_RayLifetime) + adornment.Color3 = settings.Debug_SegmentColor + adornment.Radius = settings.Debug_SegmentSize + adornment.Transparency = settings.Debug_SegmentTransparency + + adornment.Parent = GetVisualizationContainer() + + DebrisAdd( + adornment, + settings.Debug_RayLifetime + ) + return adornment end -local function DbgVisualizeBlockSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, +local function VisualizeBlockSegment( + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: BlockVisualizerVariant -): BoxHandleAdornment? +) + local adornment = Instance.new("BoxHandleAdornment") - adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.Adornee = workspace.Terrain + adornment.CFrame = startCFrame adornment.Size = variant.size - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + + adornment.Parent = GetVisualizationContainer() + + DebrisAdd( + adornment, + settings.Debug_RayLifetime + ) - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) return adornment end -local function DbgVisualizeSphereSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, +local function VisualizeSphereSegment( + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: SphereVisualizerVariant -): SphereHandleAdornment? +) + local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.CFrame = startCFrame adornment.Radius = variant.radius - --adornment.Size = Vector3.new(size.X, size.Y, size.Z + castLength) - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + + adornment.Parent = GetVisualizationContainer() + + DebrisAdd( + adornment, + settings.Debug_RayLifetime + ) - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) return adornment end -local function DbgVisualizeHit( - atCF: CFrame, +local function VisualizeHit( + atCFrame: CFrame, wasPierce: boolean, - VisualizeCastSettings: TypeDef.VisualizeCastSettings -): SphereHandleAdornment? + settings: TypeDef.VisualizeCastSettings +) + local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = atCF - -- Alert! someone is Mawining it!!!!! - adornment.Radius = (wasPierce == false) and VisualizeCastSettings.Debug_HitSize - or VisualizeCastSettings.Debug_RayPierceSize - adornment.Transparency = (wasPierce == false) and VisualizeCastSettings.Debug_HitTransparency - or VisualizeCastSettings.Debug_RayPierceTransparency - adornment.Color3 = (wasPierce == false) and VisualizeCastSettings.Debug_HitColor - or VisualizeCastSettings.Debug_RayPierceColor - adornment.Parent = GetFastCastVisualizationContainer() - - DebrisAdd(adornment, VisualizeCastSettings.Debug_HitLifetime) + adornment.CFrame = atCFrame + + if wasPierce then + adornment.Radius = settings.Debug_RayPierceSize + adornment.Transparency = settings.Debug_RayPierceTransparency + adornment.Color3 = settings.Debug_RayPierceColor + else + adornment.Radius = settings.Debug_HitSize + adornment.Transparency = settings.Debug_HitTransparency + adornment.Color3 = settings.Debug_HitColor + end + + adornment.Parent = GetVisualizationContainer() + + DebrisAdd( + adornment, + settings.Debug_HitLifetime + ) + return adornment end local Visualizers = { - [EnumCastTypes.Raycast] = DbgVisualizeRaySegment, - [EnumCastTypes.Blockcast] = DbgVisualizeBlockSegment, - [EnumCastTypes.Spherecast] = DbgVisualizeSphereSegment + [EnumCastTypes.Raycast] = VisualizeRaySegment, + [EnumCastTypes.Blockcast] = VisualizeBlockSegment, + [EnumCastTypes.Spherecast] = VisualizeSphereSegment, } +--------------------------------------------------------------------- -- SerialSimulation +--------------------------------------------------------------------- local SerialSimulation = {} SerialSimulation.__index = SerialSimulation SerialSimulation.__type = "SerialSimulation" -function SerialSimulation:Register(cast: any) - local id = cast.ID +--------------------------------------------------------------------- +-- Queue Helpers +--------------------------------------------------------------------- + +function SerialSimulation:QueueEvent( + castID: number, + eventType: string, + ...: any +) + + if not queuedEvents[castID] then + queuedEvents[castID] = {} + end + + table.insert( + queuedEvents[castID], + { + eventType = eventType, + args = { ... }, + } + ) +end + +function SerialSimulation:QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData +) + + if not casts_VisualizeCasts[castID] then + return + end + + if not queuedVisualizes[castID] then + queuedVisualizes[castID] = {} + end + + table.insert( + queuedVisualizes[castID], + data + ) +end + +--------------------------------------------------------------------- +-- State Helpers +--------------------------------------------------------------------- + +function SerialSimulation:IsCastAlive(id: number): boolean + return self.ActivesRef[id] ~= nil +end + +function SerialSimulation:ShouldUseHighFidelity(id: number): boolean + + return casts_HighFidelityBehavior[id] + == FastCastEnums.HighFidelityBehavior.Automatic + and casts_HighFidelitySegmentSize[id] > 0 +end + +function SerialSimulation:ShouldPierce( + id: number, + result: RaycastResult, + velocity: Vector3, + canPierce: TypeDef.CanPierceFunction? +): boolean + + if not canPierce then + return false + end + + local cast = self.ActivesRef[id] + local cosmeticBullet = casts_RayInfo[id].CosmeticBulletObject + + return canPierce( + cast, + result, + velocity, + cosmeticBullet + ) == true +end + +--------------------------------------------------------------------- +-- Segment Calculation +--------------------------------------------------------------------- + +function SerialSimulation:CalculateSegment( + id: number, + delta: number +): SegmentData + + local trajectory = casts_Trajectory[id] + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration + + local lastTime = + casts_TotalRunTime[id] + - trajectory.StartTime + + local currentTime = + lastTime + delta + + local lastPoint = GetPositionAtTime( + lastTime, + origin, + initialVelocity, + acceleration + ) + + local currentPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) + + local velocity = GetVelocityAtTime( + currentTime, + initialVelocity, + acceleration + ) + + return { + lastPoint = lastPoint, + currentPoint = currentPoint, + + lastTime = lastTime, + currentTime = currentTime, + + velocity = velocity, + displacement = currentPoint - lastPoint, + } +end + +--------------------------------------------------------------------- +-- Movement +--------------------------------------------------------------------- + +function SerialSimulation:UpdateCastCFrame( + id: number, + lastPoint: Vector3, + displacement: Vector3, + distance: number +) + + local direction + + if displacement.Magnitude > 0 then + direction = displacement.Unit + else + direction = Vector3.zAxis + end + + casts_CFrame[id] = + CFrame.lookAt( + lastPoint, + lastPoint + direction + ) + * CFrame.new( + 0, + 0, + -distance / 2 + ) +end + +--------------------------------------------------------------------- +-- Event Helpers +--------------------------------------------------------------------- + +function SerialSimulation:QueueLengthChanged( + id: number, + segment: SegmentData, + distance: number +) + + local rayDirection + + if segment.displacement.Magnitude > 0 then + rayDirection = segment.displacement.Unit + else + rayDirection = Vector3.zero + end + + local cosmeticBullet = + casts_RayInfo[id].CosmeticBulletObject + + self:QueueEvent( + id, + "LengthChanged", + segment.lastPoint, + rayDirection, + distance, + segment.velocity, + cosmeticBullet + ) +end + +function SerialSimulation:QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + + self:QueueEvent( + id, + "Hit", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) + + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = false, + sub = sub, + } + ) +end + +function SerialSimulation:QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + + self:QueueEvent( + id, + "Pierced", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) + + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = true, + sub = sub, + } + ) +end + +function SerialSimulation:QueueTermination(id: number) + self:QueueEvent( + id, + "CastTerminating" + ) +end + +--------------------------------------------------------------------- +-- High Fidelity +--------------------------------------------------------------------- + +function SerialSimulation:BeginResimulation(id: number): boolean + + if casts_IsActivelyResimulating[id] then + + casts_CancelHighResCast[id] = true + + warn( + "[FastCast2] Cascading cast lag encountered. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return false + end + + casts_IsActivelyResimulating[id] = true + + return true +end + +function SerialSimulation:EndResimulation(id: number) - casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 - casts_DistanceCovered[id] = 0 - casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - casts_IsActivelySimulatingPierce[id] = false casts_IsActivelyResimulating[id] = false - casts_CancelHighResCast[id] = false - casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = cast.RayInfo - casts_UserData[id] = cast.UserData - casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = cast.CastVariant - casts_MaxDistance[id] = cast.RayInfo.MaxDistance or DEFAULT_MAX_DISTANCE - table.insert(self.casts_ID, id) - self.casts_ID_Index[id] = #self.casts_ID - - local position = GetPositionAtTime( - casts_TotalRunTime[id], - casts_Trajectory[id].Origin, - casts_Trajectory[id].InitialVelocity, - casts_Trajectory[id].Acceleration + +end + +function SerialSimulation:CalculateHighFidelitySegments( + distance: number, + segmentSize: number +): number + + local count = math.floor( + distance / segmentSize + ) + + return math.max( + count, + 1 ) - casts_CFrame[id] = CFrame.new(position) +end + +function SerialSimulation:ResolveHighFidelityCollision( + id: number, + delta: number, + segment: SegmentData, + initialResult: RaycastResult, + canPierce: TypeDef.CanPierceFunction? +): boolean + + if not self:BeginResimulation(id) then + self:QueueTermination(id) + return true + end + + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + + local castHandler = + castHandlers[casts_CastVariant[id].CastType] + + local distance = + (initialResult.Position - segment.lastPoint).Magnitude + + local segmentCount = + self:CalculateHighFidelitySegments( + distance, + casts_HighFidelitySegmentSize[id] + ) + + local timeIncrement = + delta / segmentCount + + if DebugLogging.Calculation then + print( + "[FastCast2] High fidelity:", + segmentCount, + "segments," + , + timeIncrement, + "seconds each" + ) + end + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration + + for index = 1, segmentCount do + + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break + end + + if not self:IsCastAlive(id) then + self:EndResimulation(id) + return true + end + + local startTime = + segment.lastTime + + timeIncrement * (index - 1) + + local endTime = + segment.lastTime + + timeIncrement * index + + local subStart = + GetPositionAtTime( + startTime, + origin, + initialVelocity, + acceleration + ) + + local subEnd = + GetPositionAtTime( + endTime, + origin, + initialVelocity, + acceleration + ) + + local subVelocity = + GetVelocityAtTime( + endTime, + initialVelocity, + acceleration + ) + + local subDirection = + subEnd - subStart + + if DebugLogging.Segment then + print( + "[FastCast2] Subcast", + index, + "/", + segmentCount + ) + end - cast.CFrame = casts_CFrame[id] + local subResult = + castHandler( + rayInfo.WorldRoot, + subStart, + subDirection, + rayInfo.Parameters, + casts_CastVariant[id] + ) - if self.CurrentMovementMode == "Motor6D" and self.MovementEnabled then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and self.BaseCastRef and self.BaseCastRef._GetMotor6D then - local motor6d = self.BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d + self:QueueVisualize( + id, + { + castStartCFrame = + if subDirection.Magnitude > 0 + then CFrame.lookAt( + subStart, + subStart + subDirection + ) + else CFrame.new(subStart), + + sub = true, + } + ) + + if not subResult then + continue end + + local isPierced = + self:ShouldPierce( + id, + subResult, + subVelocity, + canPierce + ) + + if isPierced then + + self:QueuePierce( + id, + subResult, + subVelocity, + true + ) + + continue + end + + self:QueueHit( + id, + subResult, + subVelocity, + true + ) + + self:QueueTermination(id) + + self:EndResimulation(id) + + return true end - queuedEvents[id] = {} + self:EndResimulation(id) + + -- No valid sub-hit was found. + -- Fall back to the original collision. + + self:QueueHit( + id, + initialResult, + segment.velocity, + false + ) + + self:QueueTermination(id) + + return true end -function SerialSimulation:Unregister(castID: number) - casts_TotalRunTime[castID] = nil - casts_DistanceCovered[castID] = nil - casts_HighFidelitySegmentSize[castID] = nil - casts_HighFidelityBehavior[castID] = nil - casts_IsActivelySimulatingPierce[castID] = nil - casts_IsActivelyResimulating[castID] = nil - casts_CancelHighResCast[castID] = nil - casts_Trajectory[castID] = nil - casts_FastCastEventsConfig[castID] = nil - casts_VisualizeCasts[castID] = nil - casts_VisualizeCastSettings[castID] = nil - casts_RayInfo[castID] = nil - casts_UserData[castID] = nil - casts_CFrame[castID] = nil - casts_CastType[castID] = nil - casts_CastVariant[castID] = nil - casts_RayDisplacement[castID] = nil +--------------------------------------------------------------------- +-- Collision Resolution +--------------------------------------------------------------------- - if casts_ActiveMotor6Ds[castID] then - if self.BaseCastRef and self.BaseCastRef._ReturnMotor6D then - self.BaseCastRef:_ReturnMotor6D(casts_ActiveMotor6Ds[castID]) - end - casts_ActiveMotor6Ds[castID] = nil +function SerialSimulation:ResolveCollision( + id: number, + result: RaycastResult, + segment: SegmentData, + delta: number, + canPierce: TypeDef.CanPierceFunction? +): boolean + + local isPierced = + self:ShouldPierce( + id, + result, + segment.velocity, + canPierce + ) + + ------------------------------------------------------------- + -- Pierced + ------------------------------------------------------------- + + if isPierced then + + self:QueuePierce( + id, + result, + segment.velocity, + false + ) + + return false end - local idx = self.casts_ID_Index[castID] - if idx then - local lastID = self.casts_ID[#self.casts_ID] - self.casts_ID[idx] = lastID - self.casts_ID_Index[lastID] = idx - self.casts_ID[#self.casts_ID] = nil - self.casts_ID_Index[castID] = nil + ------------------------------------------------------------- + -- Non-piercing hit + ------------------------------------------------------------- + + if not self:ShouldUseHighFidelity(id) then + + self:QueueHit( + id, + result, + segment.velocity, + false + ) + + self:QueueTermination(id) + + return true end - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil + ------------------------------------------------------------- + -- High fidelity + ------------------------------------------------------------- + + return self:ResolveHighFidelityCollision( + id, + delta, + segment, + result, + canPierce + ) +end + +--------------------------------------------------------------------- +-- Max Distance +--------------------------------------------------------------------- + +function SerialSimulation:CheckMaxDistance( + id: number, + result: RaycastResult?, + segment: SegmentData +): boolean + + if casts_DistanceCovered[id] < casts_MaxDistance[id] then + return false + end + + if result then + self:QueueHit( + id, + result, + segment.velocity, + false + ) + end + + self:QueueTermination(id) + + return true +end + +--------------------------------------------------------------------- +-- Visualization +--------------------------------------------------------------------- + +function SerialSimulation:QueueSegmentVisualization( + id: number, + segment: SegmentData +) + + if segment.displacement.Magnitude <= 0 then + return + end + + self:QueueVisualize( + id, + { + castStartCFrame = + CFrame.lookAt( + segment.lastPoint, + segment.lastPoint + segment.displacement + ), + + sub = false, + } + ) end -function SerialSimulation:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - local oldMode = self.CurrentMovementMode - self.CurrentMovementMode = mode - self.MovementEnabled = enabled +--------------------------------------------------------------------- +-- Main Simulation +--------------------------------------------------------------------- + +function SerialSimulation:SimulateCast( + id: number, + delta: number, + canPierce: TypeDef.CanPierceFunction? +) + + if DebugLogging.Casting then + print( + "[FastCast2] Simulating cast", + id + ) + end + + ------------------------------------------------------------- + -- Calculate movement + ------------------------------------------------------------- + + local segment = + self:CalculateSegment( + id, + delta + ) + + ------------------------------------------------------------- + -- Advance simulation time + ------------------------------------------------------------- + + casts_TotalRunTime[id] += delta + + ------------------------------------------------------------- + -- Perform collision query + ------------------------------------------------------------- + + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] + + local castHandler = + castHandlers[variant.CastType] + + local result = + castHandler( + rayInfo.WorldRoot, + segment.lastPoint, + segment.displacement, + rayInfo.Parameters, + variant + ) + + ------------------------------------------------------------- + -- Determine visible endpoint + ------------------------------------------------------------- + + local endpoint = + result + and result.Position + or segment.currentPoint + + local displacement = + (endpoint - segment.lastPoint).Magnitude + + casts_RayDisplacement[id] = + displacement + + casts_DistanceCovered[id] += + displacement + + ------------------------------------------------------------- + -- Update cosmetic position + ------------------------------------------------------------- + + self:UpdateCastCFrame( + id, + segment.lastPoint, + segment.displacement, + displacement + ) + + ------------------------------------------------------------- + -- LengthChanged + ------------------------------------------------------------- + + self:QueueLengthChanged( + id, + segment, + displacement + ) + + ------------------------------------------------------------- + -- Debug visualization + ------------------------------------------------------------- + + if delta > 0 then + self:QueueSegmentVisualization( + id, + segment + ) + end + + ------------------------------------------------------------- + -- No collision + ------------------------------------------------------------- + + if not result then + self:CheckMaxDistance( + id, + nil, + segment + ) + + return + end + + ------------------------------------------------------------- + -- Ignore cosmetic projectile + ------------------------------------------------------------- + + if result.Instance + == rayInfo.CosmeticBulletObject + then + + self:CheckMaxDistance( + id, + nil, + segment + ) - if oldMode == "Motor6D" and mode ~= "Motor6D" then - for id, motor6d in casts_ActiveMotor6Ds do - if self.BaseCastRef and self.BaseCastRef._ReturnMotor6D then - self.BaseCastRef:_ReturnMotor6D(motor6d) - end - casts_ActiveMotor6Ds[id] = nil - end + return end - if mode == "Motor6D" and enabled and oldMode ~= "Motor6D" then - for _, id in self.casts_ID do - if not casts_ActiveMotor6Ds[id] then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and self.BaseCastRef and self.BaseCastRef._GetMotor6D then - local motor6d = self.BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end - end - end - end -end + ------------------------------------------------------------- + -- Resolve collision + ------------------------------------------------------------- -function SerialSimulation:QueueVisualize(castID: number, visualizeData: QueuedVisualizeCastData | QueuedVisualizeHitData) - if not casts_VisualizeCasts[castID] then + local terminated = + self:ResolveCollision( + id, + result, + segment, + delta, + canPierce + ) + + if terminated then return end - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} - end + ------------------------------------------------------------- + -- Max distance after piercing + ------------------------------------------------------------- - table.insert(queuedVisualizes[castID], visualizeData) + self:CheckMaxDistance( + id, + nil, + segment + ) end -function SerialSimulation:FireQueuedVisualizes(unFiredVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData }}) +--------------------------------------------------------------------- +-- Event Dispatch +--------------------------------------------------------------------- + +function SerialSimulation:FireQueuedVisualizes( + unFiredVisualizes +) + local sortedIDs = {} + for id in unFiredVisualizes do - table.insert(sortedIDs, id) + table.insert( + sortedIDs, + id + ) end + table.sort(sortedIDs) for _, castID in sortedIDs do - local visualizesList = unFiredVisualizes[castID] - if not visualizesList or not next(visualizesList) then + + local list = + unFiredVisualizes[castID] + + if not list then continue end - for _, visualizeData in visualizesList do - -- VisualizeHit - if visualizeData.atCF then - if DebugLogging.Hit and not visualizeData.wasPierce and not visualizeData.sub then - print("Hit was successful. Terminating.") + for _, data in list do + + if data.atCF then + + if DebugLogging.Hit + and not data.wasPierce + and not data.sub + then + print( + "Hit was successful. Terminating." + ) end - if DebugLogging.Pierced and visualizeData.wasPierce and not visualizeData.sub then - print("Piercing function returned TRUE to pierce this part.") + + if DebugLogging.Pierced + and data.wasPierce + and not data.sub + then + print( + "Piercing function returned TRUE." + ) end - DbgVisualizeHit(visualizeData.atCF, visualizeData.wasPierce, casts_VisualizeCastSettings[castID]) + + VisualizeHit( + data.atCF, + data.wasPierce, + casts_VisualizeCastSettings[castID] + ) + continue end - -- VisualizeCasts - if DebugLogging.Segment and visualizeData.sub then - print("Subcast visualization") + + if DebugLogging.Segment + and data.sub + then + print( + "Subcast visualization" + ) end - local castType = casts_CastType[castID] - local castVisualizer = Visualizers[castType] - if not castVisualizer then + + local castType = + casts_CastType[castID] + + local visualizer = + Visualizers[castType] + + if not visualizer then continue end - local visualizerVariant: CastVisualizerVariants - if castType == EnumCastTypes.Raycast then - visualizerVariant = { castLength = casts_RayDisplacement[castID] or 1 } - elseif castType == EnumCastTypes.Blockcast then - visualizerVariant = { size = (casts_CastVariant[castID] :: BlockcastVariant).Size } + + local variant: CastVisualizerVariants + + if castType + == EnumCastTypes.Raycast + then + + variant = { + castLength = + casts_RayDisplacement[castID] + or 1, + } + + elseif castType + == EnumCastTypes.Blockcast + then + + variant = { + size = + (casts_CastVariant[castID] + :: BlockcastVariant).Size, + } + else - visualizerVariant = { radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius } + + variant = { + radius = + (casts_CastVariant[castID] + :: SpherecastVariant).Radius, + } end - castVisualizer(visualizeData.castStartCFrame, casts_VisualizeCastSettings[castID], visualizerVariant) + + visualizer( + data.castStartCFrame, + casts_VisualizeCastSettings[castID], + variant + ) end end end -function SerialSimulation:QueueEvent(castID: number, eventType: string, ...: any) - local args = { ... } - if not queuedEvents[castID] then - queuedEvents[castID] = {} - end - table.insert(queuedEvents[castID], { - eventType = eventType, - args = args - }) -end +function SerialSimulation:FireQueuedEvents( + unFiredEvents +) -function SerialSimulation:FireQueuedEvents(unFiredEvents: { [number]: { QueuedEventData } }) local sortedIDs = {} + for id in unFiredEvents do - table.insert(sortedIDs, id) + table.insert( + sortedIDs, + id + ) end - table.sort(sortedIDs) - local eventsFunction = self.Events + table.sort(sortedIDs) for _, castID in sortedIDs do - local eventList = unFiredEvents[castID] - if not eventList or not next(eventList) then + + local events = + unFiredEvents[castID] + + if not events then continue end - for _, event in eventList do - local cast = self.ActivesRef[castID] + for _, event in events do + + local cast = + self.ActivesRef[castID] + if not cast then continue end - local eventType: string = event.eventType - local args: { any } = event.args + local config = + casts_FastCastEventsConfig[castID] - local eventConfig = casts_FastCastEventsConfig[castID] + if event.eventType + == "LengthChanged" + then - if eventType == "LengthChanged" then - local lastPoint = args[1] - local rayDir = args[2] - local rayDisplacement = args[3] - local segmentVelocity, CosmeticBulletObject = args[4], args[5] + if config + and config.UseLengthChanged + and self.Events.LengthChanged + then - if eventConfig and eventConfig.UseLengthChanged and eventsFunction.LengthChanged then - eventsFunction.LengthChanged(cast, lastPoint, rayDir, rayDisplacement, segmentVelocity, CosmeticBulletObject) + self.Events.LengthChanged( + cast, + table.unpack(event.args) + ) end - elseif eventType == "Hit" then - local result = args[1] - local velocity = args[2] - local cosmeticBulletObject = args[3] + elseif event.eventType + == "Hit" + then + + if config + and config.UseHit + and self.Events.Hit + then - if eventConfig and eventConfig.UseHit and eventsFunction.Hit then - eventsFunction.Hit(cast, result, velocity, cosmeticBulletObject) + self.Events.Hit( + cast, + table.unpack(event.args) + ) end - elseif eventType == "Pierced" then - local result = args[1] - local velocity = args[2] - local cosmeticBulletObject = args[3] + elseif event.eventType + == "Pierced" + then + + if config + and config.UsePierced + and self.Events.Pierced + then - if eventConfig and eventConfig.UsePierced and eventsFunction.Pierced then - eventsFunction.Pierced(cast, result, velocity, cosmeticBulletObject) + self.Events.Pierced( + cast, + table.unpack(event.args) + ) end - elseif eventType == "CastTerminating" then - self.BaseCastRef:_TerminateCast(castID, eventsFunction.CastTerminating) - self:Unregister(castID) + elseif event.eventType + == "CastTerminating" + then + + self.BaseCastRef:_TerminateCast( + castID, + self.Events.CastTerminating + ) + + self:Unregister( + castID + ) end end end end -function SerialSimulation:BulkMoveTo() - if self.CurrentMovementMode ~= "BulkMoveTo" or not self.MovementEnabled then - return - end +--------------------------------------------------------------------- +-- Registration +--------------------------------------------------------------------- - local parts = table.create(#self.casts_ID) - local cframes = table.create(#self.casts_ID) +function SerialSimulation:Register( + cast: any +) - for _, id in self.casts_ID do - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and cosmeticPart.Parent then - table.insert(parts, cosmeticPart) - table.insert(cframes, casts_CFrame[id]) - end - end + local id = cast.ID - if #parts > 0 then - workspace:BulkMoveTo(parts, cframes, Enum.BulkMoveMode.FireCFrameChanged) - end -end + casts_TotalRunTime[id] = + cast.StateInfo.TotalRuntime or 0 -function SerialSimulation:UpdateMotor6Ds() - if self.CurrentMovementMode ~= "Motor6D" or not self.MovementEnabled then - return - end + casts_DistanceCovered[id] = 0 - for id, motor6d in casts_ActiveMotor6Ds do - if motor6d and motor6d.Parent then - motor6d.Transform = casts_CFrame[id] - end - end -end + casts_HighFidelitySegmentSize[id] = + cast.StateInfo.HighFidelitySegmentSize + or 0.1 --- RS -function SerialSimulation:SimulateCast( - id: number, - delta: number, - CanPiercefn: TypeDef.CanPierceFunction? -) - if DebugLogging.Casting then - print("Casting for frame - SimulateCast") - end - local trajectory = casts_Trajectory[id] + casts_HighFidelityBehavior[id] = + cast.StateInfo.HighFidelityBehavior + or 0 - local origin = trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - trajectory.StartTime - local initialVelocity = trajectory.InitialVelocity - local acceleration = trajectory.Acceleration + casts_IsActivelySimulatingPierce[id] = false + casts_IsActivelyResimulating[id] = false + casts_CancelHighResCast[id] = false - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + casts_Trajectory[id] = + cast.StateInfo.Trajectory - local lastDelta = casts_TotalRunTime[id] - trajectory.StartTime + casts_FastCastEventsConfig[id] = + cast.StateInfo.FastCastEventsConfig - casts_TotalRunTime[id] += delta + casts_VisualizeCasts[id] = + cast.StateInfo.VisualizeCasts - totalDelta = casts_TotalRunTime[id] - trajectory.StartTime + casts_VisualizeCastSettings[id] = + cast.StateInfo.VisualizeCastSettings - local currentTarget = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local segmentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentTarget - lastPoint + casts_RayInfo[id] = + cast.RayInfo - local rayDir = totalDisplacement + casts_UserData[id] = + cast.UserData - local targetWorldRoot = casts_RayInfo[id].WorldRoot + casts_CastType[id] = + cast.CastVariant.CastType - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, casts_RayInfo[id].Parameters, casts_CastVariant[id]) + casts_CastVariant[id] = + cast.CastVariant - local point = currentTarget - local part: Instance? = nil + casts_MaxDistance[id] = + cast.RayInfo.MaxDistance + or DEFAULT_MAX_DISTANCE - if resultOfCast ~= nil then - point = resultOfCast.Position - part = resultOfCast.Instance - end + table.insert( + self.casts_ID, + id + ) - local rayDisplacement = (point - lastPoint).Magnitude - casts_RayDisplacement[id] = rayDisplacement + self.casts_ID_Index[id] = + #self.casts_ID - casts_CFrame[id] = (if rayDir ~= Vector3.new() then CFrame.new(lastPoint, lastPoint + rayDir) else CFrame.new(lastPoint)) * CFrame.new(0, 0, -rayDisplacement / 2) + local trajectory = + casts_Trajectory[id] - self:QueueEvent(id, "LengthChanged", lastPoint, rayDir.Unit, rayDisplacement, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) + local position = + GetPositionAtTime( + casts_TotalRunTime[id], + trajectory.Origin, + trajectory.InitialVelocity, + trajectory.Acceleration + ) - casts_DistanceCovered[id] += rayDisplacement + casts_CFrame[id] = + CFrame.new(position) - if delta > 0 then - self:QueueVisualize( - id, - { - castStartCFrame = CFrame.new(lastPoint, lastPoint+rayDir), - sub = false - } :: any - ) - end + cast.CFrame = + casts_CFrame[id] - -- NOTE: Please dont remove "part and" - -- Why? basically when part doesn't exist it will do nothing, but removing "part and" will break the logic - -- You can't do anything about it - if part and part ~= casts_RayInfo[id].CosmeticBulletObject then - if - CanPiercefn == nil - or CanPiercefn(self.ActivesRef[id], resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then + queuedEvents[id] = {} +end - casts_IsActivelyResimulating[id] = false +--------------------------------------------------------------------- +-- Unregister +--------------------------------------------------------------------- - if - casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Automatic - and casts_HighFidelitySegmentSize[id] > 0 - then - casts_CancelHighResCast[id] = false +function SerialSimulation:Unregister( + castID: number +) + + casts_TotalRunTime[castID] = nil + casts_DistanceCovered[castID] = nil - if casts_IsActivelyResimulating[id] then - self:QueueEvent(id, "CastTerminating") + casts_HighFidelitySegmentSize[castID] = nil + casts_HighFidelityBehavior[castID] = nil - warn( - "Cascading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize." - ) - return - end + casts_IsActivelySimulatingPierce[castID] = nil + casts_IsActivelyResimulating[castID] = nil + casts_CancelHighResCast[castID] = nil - casts_IsActivelyResimulating[id] = true + casts_Trajectory[castID] = nil + casts_FastCastEventsConfig[castID] = nil - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - --local realSegmentLength = rayDisplacement / numSegmentsReal + casts_VisualizeCasts[castID] = nil + casts_VisualizeCastSettings[castID] = nil - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + casts_RayInfo[castID] = nil + casts_UserData[castID] = nil - local timeIncrement = delta / numSegmentsReal - local subHitFound = false + casts_CFrame[castID] = nil + casts_CastType[castID] = nil + casts_CastVariant[castID] = nil - for segmentIndex = 1, numSegmentsReal do - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end + casts_MaxDistance[castID] = nil + casts_RayDisplacement[castID] = nil - local subPosition = GetPositionAtTime( - lastDelta + (timeIncrement * segmentIndex), - origin, - initialVelocity, - acceleration - ) - local subVelocity = GetVelocityAtTime( - lastDelta + (timeIncrement * segmentIndex), - initialVelocity, - acceleration - ) - local subRayDir = subVelocity * timeIncrement - local subResult = castHandler( - targetWorldRoot, - subPosition, - subRayDir, - casts_RayInfo[id].Parameters, - casts_CastVariant[id] - ) + if casts_ActiveMotor6Ds[castID] then - --local subDisplacement = (subPosition - (subPosition + subVelocity)).Magnitude - - if subResult ~= nil then - subHitFound = true - - self:QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition + subVelocity), - sub = true - } :: any) - - --subDispalcement = (subPosition - subResult.Position).Magnitude - - if - CanPiercefn == nil - or CanPiercefn(self.ActivesRef[id], subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then - casts_IsActivelyResimulating[id] = false - self:QueueEvent(id, "Hit", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueEvent(id, "CastTerminating") - self:QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = false, - sub = true - }:: any) - - return - else - self:QueueEvent(id, "Pierced", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = true, - sub = true - }:: any) - end - else - self:QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition+subVelocity), - sub = true - }:: any) - end - end - casts_IsActivelyResimulating[id] = false - if not subHitFound then - self:QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) - self:QueueEvent(id, "CastTerminating") - return - end - else - self:QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) - self:QueueEvent(id, "CastTerminating") - - return - end - else - self:QueueEvent(id, "Pierced", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = true - }:: any) + if self.BaseCastRef + and self.BaseCastRef._ReturnMotor6D + then + + self.BaseCastRef:_ReturnMotor6D( + casts_ActiveMotor6Ds[castID] + ) end + + casts_ActiveMotor6Ds[castID] = nil end - if casts_DistanceCovered[id] >= casts_MaxDistance[id] then - if resultOfCast then - self:QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - self:QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) - end - self:QueueEvent(id, "CastTerminating") + local index = + self.casts_ID_Index[castID] + + if index then + + local lastID = + self.casts_ID[#self.casts_ID] + + self.casts_ID[index] = + lastID + + self.casts_ID_Index[lastID] = + index + + self.casts_ID[#self.casts_ID] = nil + self.casts_ID_Index[castID] = nil end + + queuedEvents[castID] = nil + queuedVisualizes[castID] = nil end -function SerialSimulation:UpdateCasts(delta: number) - for _, id in self.casts_ID do - if DebugLogging.Casting then - print("Casting for frame - UpdateCasts") - end +--------------------------------------------------------------------- +-- Movement +--------------------------------------------------------------------- - if self.ActivesRef[id] and self.ActivesRef[id]._Destroying then - continue - end +function SerialSimulation:BulkMoveTo() - local Trajectory: TypeDef.CastTrajectory = casts_Trajectory[id] + if self.CurrentMovementMode + ~= "BulkMoveTo" + or not self.MovementEnabled + then + return + end - if casts_HighFidelitySegmentSize[id] <= 0 then - casts_HighFidelitySegmentSize[id] = 0.1 - end + local parts = + table.create(#self.casts_ID) - if casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Always then + local cframes = + table.create(#self.casts_ID) - if casts_IsActivelyResimulating[id] then - self:QueueEvent(id, "CastTerminating") - warn("Casading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize.") - continue - end - casts_IsActivelyResimulating[id] = true + for _, id in self.casts_ID do - local origin = Trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime - local initialVelocity = Trajectory.InitialVelocity - local acceleration = Trajectory.Acceleration + local cosmetic = + casts_RayInfo[id] + and casts_RayInfo[id].CosmeticBulletObject - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + if cosmetic + and cosmetic.Parent + then - casts_TotalRunTime[id] += delta + table.insert( + parts, + cosmetic + ) - totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime + table.insert( + cframes, + casts_CFrame[id] + ) + end + end - local currentPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local currentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentPoint - lastPoint + if #parts > 0 then - local rayDir = totalDisplacement.Unit * currentVelocity.Magnitude * delta + workspace:BulkMoveTo( + parts, + cframes, + Enum.BulkMoveMode.FireCFrameChanged + ) + end +end - local RayInfo = casts_RayInfo[id] - local targetWorldRoot = RayInfo.WorldRoot +function SerialSimulation:UpdateMotor6Ds() - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, RayInfo.Parameters, casts_CastVariant[id]) + if self.CurrentMovementMode + ~= "Motor6D" + or not self.MovementEnabled + then + return + end - local point = currentPoint - if resultOfCast ~= nil then - point = resultOfCast.Position - end + for id, motor6d in casts_ActiveMotor6Ds do - local rayDisplacement = (point - lastPoint).Magnitude - casts_TotalRunTime[id] -= delta + if motor6d + and motor6d.Parent + then - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + motor6d.Transform = + casts_CFrame[id] + end + end +end - local timeIncrement = delta / numSegmentsReal +--------------------------------------------------------------------- +-- Movement Mode +--------------------------------------------------------------------- - if DebugLogging.Calculation then - print("Performing subcast! Time increment: " .. timeIncrement .. ", num segments: " .. numSegmentsReal) - end +function SerialSimulation:SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) - local cast_nil = false - for segmentIndex = 1, numSegmentsReal do + self.CurrentMovementMode = + mode - -- In case when cast Destroyed or not exist - if self.ActivesRef[id] == nil then - cast_nil = true - end + self.MovementEnabled = + enabled +end +--------------------------------------------------------------------- +-- Update +--------------------------------------------------------------------- - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end +function SerialSimulation:UpdateCasts( + delta: number +) - if DebugLogging.Segment then - print("[" .. segmentIndex .. "] Subcast of time increment " .. timeIncrement) - end + for _, id in self.casts_ID do - self:SimulateCast(id, timeIncrement, self.Events.CanPierce) - end + local cast = + self.ActivesRef[id] - if cast_nil then - casts_IsActivelyResimulating[id] = false - continue - end + if cast + and cast._Destroying + then + continue + end - -- Double check again - if self.ActivesRef[id] == nil then - casts_IsActivelyResimulating[id] = false - continue - end - casts_IsActivelyResimulating[id] = false - else - self:SimulateCast(id, delta, self.Events.CanPierce) + if casts_HighFidelitySegmentSize[id] <= 0 then + casts_HighFidelitySegmentSize[id] = 0.1 end + + self:SimulateCast( + id, + delta, + self.Events.CanPierce + ) end - -- Write-back SoA -> ActiveCast so ActivesRef[id] has current values + ------------------------------------------------------------- + -- Write state back + ------------------------------------------------------------- + for _, id in self.casts_ID do - local cast = self.ActivesRef[id] + + local cast = + self.ActivesRef[id] + if cast then - cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] - cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] - cast.CFrame = casts_CFrame[id] + + cast.StateInfo.TotalRuntime = + casts_TotalRunTime[id] + + cast.StateInfo.DistanceCovered = + casts_DistanceCovered[id] + + cast.CFrame = + casts_CFrame[id] end end - -- Motor6D, BulkMoveTo, FireQueuedVisualizes, FireQueuedEvents - -- NOTE: Do not change these if you don't knows how it works + ------------------------------------------------------------- + -- Movement + ------------------------------------------------------------- self:UpdateMotor6Ds() self:BulkMoveTo() - local eventsToProcess = queuedEvents + ------------------------------------------------------------- + -- Swap queues + ------------------------------------------------------------- + + local events = + queuedEvents + queuedEvents = {} - local visualizesToProcess = queuedVisualizes + + local visualizes = + queuedVisualizes + queuedVisualizes = {} - self:FireQueuedVisualizes(visualizesToProcess) - self:FireQueuedEvents(eventsToProcess) + ------------------------------------------------------------- + -- Dispatch + ------------------------------------------------------------- + + self:FireQueuedVisualizes( + visualizes + ) + + self:FireQueuedEvents( + events + ) end +--------------------------------------------------------------------- +-- Construction +--------------------------------------------------------------------- + function SerialSimulation.new() - local self = setmetatable({}, SerialSimulation) + + local self = + setmetatable( + {}, + SerialSimulation + ) + self.Connection = nil - self.CurrentMovementMode = "BulkMoveTo" - self.MovementEnabled = true + + self.CurrentMovementMode = + "BulkMoveTo" + + self.MovementEnabled = + true + self.Events = {} + self.BaseCastRef = nil self.ActivesRef = nil + self.casts_ID = {} self.casts_ID_Index = {} + return self end -function SerialSimulation:Init(baseCastRef: any, events: TypeDef.FastCastEvents) - self.BaseCastRef = baseCastRef - self.ActivesRef = baseCastRef.Actives - self.Events = events +function SerialSimulation:Init( + baseCastRef: any, + events: TypeDef.FastCastEvents +) + + self.BaseCastRef = + baseCastRef + + self.ActivesRef = + baseCastRef.Actives + + self.Events = + events end +--------------------------------------------------------------------- +-- RunService +--------------------------------------------------------------------- + function SerialSimulation:Start() - if self.Connection then - warn("Already started") + + if self.Connection then + warn( + "[FastCast2] SerialSimulation already started." + ) + return end - if RS:IsClient() then - self.Connection = RS.PreSimulation:Connect(function(delta: number) - self:UpdateCasts(delta) - end) + if RunService:IsClient() then + + self.Connection = + RunService.PreSimulation:Connect( + function(delta) + self:UpdateCasts(delta) + end + ) + else - self.Connection = RS.Heartbeat:Connect(function(delta: number) - self:UpdateCasts(delta) - end) + + self.Connection = + RunService.Heartbeat:Connect( + function(delta) + self:UpdateCasts(delta) + end + ) end end function SerialSimulation:Stop() + if self.Connection then + self.Connection:Disconnect() self.Connection = nil end end --- Utils -function SerialSimulation:_UpdateEvents(eventName: string, newEventfn: (...any) -> ()) - self.Events[eventName] = newEventfn +--------------------------------------------------------------------- +-- Events +--------------------------------------------------------------------- + +function SerialSimulation:_UpdateEvents( + eventName: string, + newEventfn: (...any) -> () +) + + self.Events[eventName] = + newEventfn end -return SerialSimulation \ No newline at end of file +return SerialSimulation From c7d08fbadd821b2a9e553b312ad0eca67263b597 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 13:04:54 +0700 Subject: [PATCH 002/140] Refactor ParallelSimulation --- src/ParallelSimulation.luau | 1750 +++++++++++++++++++++++------------ 1 file changed, 1181 insertions(+), 569 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 50d0827f..8f860a5f 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1,59 +1,58 @@ --[[ - Author: Mawin CK + - Refactored ParallelSimulation - Date: 2026 ]] --!native --!optimize 2 --- Services - local RS = game:GetService("RunService") local FastCastModule = script.Parent --- Requires - local TypeDef = require(FastCastModule:WaitForChild("TypeDefinitions")) local FastCastEnums = require(FastCastModule:WaitForChild("FastCastEnums")) -local Config = require(script.Parent:WaitForChild("Config")) -local DebugLogging = Config.DebugLogging - +local Config = require(FastCastModule:WaitForChild("Config")) --- Constants +local DebugLogging = Config.DebugLogging local EnumCastTypes = FastCastEnums.CastType + local DEFAULT_MAX_DISTANCE = 1000 local FC_VIS_OBJ_NAME = "FastCastVisualizationObjects" --- Variables +-- ============================================================================ +-- Types +-- ============================================================================ -local casts_TotalRunTime = {} :: { [number]: number } -local casts_DistanceCovered = {} :: { [number]: number } -local casts_HighFidelitySegmentSize = {} :: { [number]: number } -local casts_HighFidelityBehavior = {} :: { [number]: number } -local casts_IsActivelySimulatingPierce = {} :: { [number]: boolean } -local casts_IsActivelyResimulating = {} :: { [number]: boolean } -local casts_CancelHighResCast = {} :: { [number]: boolean } -local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } -local casts_FastCastEventsModuleConfig = {} :: { [number]: TypeDef.FastCastEventsModuleConfig } -local casts_FastCastEventsConfig = {} :: { [number]: TypeDef.FastCastEventsConfig } -local casts_VisualizeCasts = {} :: { [number]: boolean } -local casts_VisualizeCastSettings = {} :: { [number]: TypeDef.VisualizeCastSettings } -local casts_RayInfo = {} :: { [number]: TypeDef.CastRayInfo } -local casts_UserData = {} :: { [number]: any } -local casts_CFrame = {} :: { [number]: CFrame } -local casts_CastType = {} :: { [number]: number } -local casts_CastVariant = {} :: { [number]: CastVariants } -local casts_MaxDistance = {} :: { [number]: number } -local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } -local casts_RayDisplacement = {} :: { [number]: number } +type BlockcastVariant = { + CastType: number, + Size: Vector3 +} -local casts_ID = {} :: { number } -local casts_ID_Index = {} :: { [number]: number } +type SpherecastVariant = { + CastType: number, + Radius: number +} -local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } +type CastVariants = BlockcastVariant | SpherecastVariant --- Types +type RayVisualizerVariant = { + castLength: number +} + +type BlockVisualizerVariant = { + size: Vector3 +} + +type SphereVisualizerVariant = { + radius: number +} + +type CastVisualizerVariants = + RayVisualizerVariant +| BlockVisualizerVariant +| SphereVisualizerVariant type QueuedEventData = { eventType: string, @@ -71,194 +70,236 @@ type QueuedVisualizeHitData = { sub: boolean } -type BlockcastVariant = { CastType: number, Size: Vector3 } -type SpherecastVariant = { CastType: number, Radius: number } -type CastVariants = BlockcastVariant | SpherecastVariant +-- ============================================================================ +-- State +-- ============================================================================ + +local casts_TotalRunTime = {} :: { [number]: number } +local casts_DistanceCovered = {} :: { [number]: number } + +local casts_HighFidelitySegmentSize = {} :: { [number]: number } +local casts_HighFidelityBehavior = {} :: { [number]: number } + +local casts_IsActivelySimulatingPierce = {} :: { [number]: boolean } +local casts_IsActivelyResimulating = {} :: { [number]: boolean } +local casts_CancelHighResCast = {} :: { [number]: boolean } + +local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } + +local casts_FastCastEventsModuleConfig = + {} :: { [number]: TypeDef.FastCastEventsModuleConfig } + +local casts_FastCastEventsConfig = + {} :: { [number]: TypeDef.FastCastEventsConfig } + +local casts_VisualizeCasts = {} :: { [number]: boolean } +local casts_VisualizeCastSettings = + {} :: { [number]: TypeDef.VisualizeCastSettings } + +local casts_RayInfo = {} :: { [number]: TypeDef.CastRayInfo } +local casts_UserData = {} :: { [number]: any } + +local casts_CFrame = {} :: { [number]: CFrame } + +local casts_CastType = {} :: { [number]: number } +local casts_CastVariant = {} :: { [number]: CastVariants } + +local casts_MaxDistance = {} :: { [number]: number } +local casts_RayDisplacement = {} :: { [number]: number } + +local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } -type RayVisualizerVariant = { castLength: number } -type BlockVisualizerVariant = { size: Vector3 } -type SphereVisualizerVariant = { radius: number } -type CastVisualizerVariants = RayVisualizerVariant | BlockVisualizerVariant | SphereVisualizerVariant +local casts_ID = {} :: { number } +local casts_ID_Index = {} :: { [number]: number } --- queued things +local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } local queuedEvents: { [number]: { QueuedEventData } } = {} -local queuedVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } } = {} +local queuedVisualizes: { + [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } +} = {} local ActivesRef: any = nil -local BaseCastRef = nil :: any +local BaseCastRef: any = nil + local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" local MovementEnabled = false +-- ============================================================================ +-- Math +-- ============================================================================ + +local function GetPositionAtTime( + t: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return origin + + initialVelocity * t + + acceleration * (t ^ 2 / 2) +end + +local function GetVelocityAtTime( + t: number, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return initialVelocity + acceleration * t +end + +-- ============================================================================ +-- Cast handlers +-- ============================================================================ + local castHandlers = { [EnumCastTypes.Raycast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams ) - return targetWorldRoot:Raycast(origin, direction, params) + return worldRoot:Raycast(origin, direction, params) end, + [EnumCastTypes.Blockcast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: BlockcastVariant ) - return targetWorldRoot:Blockcast(CFrame.new(origin), variant.Size, direction, params) + return worldRoot:Blockcast( + CFrame.new(origin), + variant.Size, + direction, + params + ) end, + [EnumCastTypes.Spherecast] = function( - targetWorldRoot: WorldRoot, + worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams, variant: SpherecastVariant ) - return targetWorldRoot:Spherecast(origin, variant.Radius, direction, params) + return worldRoot:Spherecast( + origin, + variant.Radius, + direction, + params + ) end } --- Utils - -local function GetPositionAtTime( - t: number, - origin: Vector3, - initialVelocity: Vector3, - acceleration: Vector3 -): Vector3 - local force = Vector3.new( - (acceleration.X * t ^ 2) / 2, - (acceleration.Y * t ^ 2) / 2, - (acceleration.Z * t ^ 2) / 2 - ) - return origin + (initialVelocity * t) + force -end - -local function GetVelocityAtTime(time: number, initialVelocity: Vector3, acceleration: Vector3): Vector3 - return initialVelocity + acceleration * time -end - -local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastTerminatingFunction?) - if cast._Destroying then return end - cast._Destroying = true - - local FastCastEventsConfig = cast.StateInfo.FastCastEventsConfig - if FastCastEventsConfig and FastCastEventsConfig.UseCastTerminating then - cast.Caster.Output:Fire("CastTerminating", cast) - end - - if castTerminatingFunction then - castTerminatingFunction((cast :: any)) - end - - cast.Caster.ActiveCastCleaner:Fire(cast.ID) -end +-- ============================================================================ +-- Visualization +-- ============================================================================ -local function DebrisAdd(obj: Instance, Lifetime: number) - if not obj then - return - end - if Lifetime <= 0 then +local function DebrisAdd(obj: Instance, lifetime: number) + if lifetime <= 0 then obj:Destroy() return end - task.delay(Lifetime, function() - obj:Destroy() + task.delay(lifetime, function() + if obj.Parent then + obj:Destroy() + end end) end -local function GetFastCastVisualizationContainer(): Instance - local fcVisualizationObjects = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) - if fcVisualizationObjects then - return fcVisualizationObjects +local function GetVisualizationContainer(): Instance + local container = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) + + if container then + return container end - fcVisualizationObjects = Instance.new("Folder") - fcVisualizationObjects.Name = FC_VIS_OBJ_NAME - fcVisualizationObjects.Archivable = false - fcVisualizationObjects.Parent = workspace.Terrain - return fcVisualizationObjects + container = Instance.new("Folder") + container.Name = FC_VIS_OBJ_NAME + container.Archivable = false + container.Parent = workspace.Terrain + + return container end local function DbgVisualizeRaySegment( - castStartCFrame: CFrame, - VisualizeCastSettings: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: RayVisualizerVariant -): ConeHandleAdornment? +) local adornment = Instance.new("ConeHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame + adornment.CFrame = startCFrame adornment.Height = variant.castLength - adornment.Color3 = VisualizeCastSettings.Debug_SegmentColor - adornment.Radius = VisualizeCastSettings.Debug_SegmentSize - adornment.Transparency = VisualizeCastSettings.Debug_SegmentTransparency - adornment.Parent = GetFastCastVisualizationContainer() + adornment.Color3 = settings.Debug_SegmentColor + adornment.Radius = settings.Debug_SegmentSize + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - DebrisAdd(adornment, VisualizeCastSettings.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeBlockSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: BlockVisualizerVariant -): BoxHandleAdornment? +) local adornment = Instance.new("BoxHandleAdornment") - adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.Adornee = workspace.Terrain + adornment.CFrame = startCFrame adornment.Size = variant.size - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - adornment.Parent = GetFastCastVisualizationContainer() - - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeSphereSegment( - castStartCFrame: CFrame, - VisualizeCastSetting: TypeDef.VisualizeCastSettings, + startCFrame: CFrame, + settings: TypeDef.VisualizeCastSettings, variant: SphereVisualizerVariant -): SphereHandleAdornment? +) local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = castStartCFrame - --adornment.Height = castLength + adornment.CFrame = startCFrame adornment.Radius = variant.radius - --adornment.Size = Vector3.new(size.X, size.Y, size.Z + castLength) - adornment.Color3 = VisualizeCastSetting.Debug_SegmentColor - adornment.Transparency = VisualizeCastSetting.Debug_SegmentTransparency + adornment.Color3 = settings.Debug_SegmentColor + adornment.Transparency = settings.Debug_SegmentTransparency + adornment.Parent = GetVisualizationContainer() - adornment.Parent = GetFastCastVisualizationContainer() - - DebrisAdd(adornment, VisualizeCastSetting.Debug_RayLifetime) - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end local function DbgVisualizeHit( - atCF: CFrame, + atCFrame: CFrame, wasPierce: boolean, - VisualizeCastSettings: TypeDef.VisualizeCastSettings -): SphereHandleAdornment? + settings: TypeDef.VisualizeCastSettings +) local adornment = Instance.new("SphereHandleAdornment") + adornment.Adornee = workspace.Terrain - adornment.CFrame = atCF - -- Alert! someone is Mawining it!!!!! - adornment.Radius = (wasPierce == false) and VisualizeCastSettings.Debug_HitSize - or VisualizeCastSettings.Debug_RayPierceSize - adornment.Transparency = (wasPierce == false) and VisualizeCastSettings.Debug_HitTransparency - or VisualizeCastSettings.Debug_RayPierceTransparency - adornment.Color3 = (wasPierce == false) and VisualizeCastSettings.Debug_HitColor - or VisualizeCastSettings.Debug_RayPierceColor - adornment.Parent = GetFastCastVisualizationContainer() - - DebrisAdd(adornment, VisualizeCastSettings.Debug_HitLifetime) - return adornment + adornment.CFrame = atCFrame + + if wasPierce then + adornment.Radius = settings.Debug_RayPierceSize + adornment.Transparency = settings.Debug_RayPierceTransparency + adornment.Color3 = settings.Debug_RayPierceColor + else + adornment.Radius = settings.Debug_HitSize + adornment.Transparency = settings.Debug_HitTransparency + adornment.Color3 = settings.Debug_HitColor + end + + adornment.Parent = GetVisualizationContainer() + + DebrisAdd(adornment, settings.Debug_HitLifetime) end local Visualizers = { @@ -267,153 +308,298 @@ local Visualizers = { [EnumCastTypes.Spherecast] = DbgVisualizeSphereSegment } -local function QueueVisualize(castID: number, visualizeData: QueuedVisualizeCastData | QueuedVisualizeHitData) +-- ============================================================================ +-- Queued visualization +-- ============================================================================ + +local function QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData +) if not casts_VisualizeCasts[castID] then return end - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} + local queue = queuedVisualizes[castID] + + if not queue then + queue = {} + queuedVisualizes[castID] = queue end - table.insert(queuedVisualizes[castID], visualizeData) + table.insert(queue, data) end -local function FireQueuedVisualizes(unFiredVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData }}) +local function FireQueuedVisualizes( + visualizes: { + [number]: { + QueuedVisualizeCastData | QueuedVisualizeHitData + } + } +) local sortedIDs = {} - for id in unFiredVisualizes do + + for id in visualizes do table.insert(sortedIDs, id) end + table.sort(sortedIDs) for _, castID in sortedIDs do - local visualizesList = unFiredVisualizes[castID] - if not visualizesList or not next(visualizesList) then + local list = visualizes[castID] + + if not list then + continue + end + + local settings = casts_VisualizeCastSettings[castID] + + if not settings then continue end - for _, visualizeData in visualizesList do - -- VisualizeHit - if visualizeData.atCF then - if DebugLogging.Hit and not visualizeData.wasPierce and not visualizeData.sub then + for _, data in list do + -- Hit visualization + if data.atCF then + if DebugLogging.Hit and not data.wasPierce and not data.sub then print("Hit was successful. Terminating.") end - if DebugLogging.Pierced and visualizeData.wasPierce and not visualizeData.sub then + + if DebugLogging.Pierced and data.wasPierce and not data.sub then print("Piercing function returned TRUE to pierce this part.") end - DbgVisualizeHit(visualizeData.atCF, visualizeData.wasPierce, casts_VisualizeCastSettings[castID]) + + DbgVisualizeHit( + data.atCF, + data.wasPierce, + settings + ) + continue end - -- VisualizeCasts - if DebugLogging.Segment and visualizeData.sub then + + -- Segment visualization + if DebugLogging.Segment and data.sub then print("Subcast visualization") end + local castType = casts_CastType[castID] - local castVisualizer = Visualizers[castType] - if not castVisualizer then + local visualizer = Visualizers[castType] + + if not visualizer then continue end - local visualizerVariant: CastVisualizerVariants + + local variant: CastVisualizerVariants + if castType == EnumCastTypes.Raycast then - visualizerVariant = { castLength = casts_RayDisplacement[castID] or 1 } + variant = { + castLength = casts_RayDisplacement[castID] or 1 + } elseif castType == EnumCastTypes.Blockcast then - visualizerVariant = { size = (casts_CastVariant[castID] :: BlockcastVariant).Size } + variant = { + size = (casts_CastVariant[castID] :: BlockcastVariant).Size + } else - visualizerVariant = { radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius } + variant = { + radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius + } end - castVisualizer(visualizeData.castStartCFrame, casts_VisualizeCastSettings[castID], visualizerVariant) + + visualizer( + data.castStartCFrame, + settings, + variant + ) end end end -local function QueueEvent(castID: number, eventType: string, ...: any) - local args = { ... } - if not queuedEvents[castID] then - queuedEvents[castID] = {} +-- ============================================================================ +-- Queued events +-- ============================================================================ + +local function QueueEvent( + castID: number, + eventType: string, + ...: any +) + local queue = queuedEvents[castID] + + if not queue then + queue = {} + queuedEvents[castID] = queue end - table.insert(queuedEvents[castID], { + + table.insert(queue, { eventType = eventType, - args = args + args = { ... } }) end -local function FireQueuedEvents(events: { [number]: { QueuedEventData } }) +local function FireQueuedEvents( + events: { [number]: { QueuedEventData } } +) local sortedIDs = {} + for id in events do table.insert(sortedIDs, id) end + table.sort(sortedIDs) for _, castID in sortedIDs do local eventList = events[castID] - if not eventList or not next(eventList) then + + if not eventList then continue end for _, event in eventList do local cast = ActivesRef[castID] + if not cast then continue end - local eventType: string = event.eventType - local args: { any } = event.args - local caster = cast.Caster - local moduleConfig = casts_FastCastEventsModuleConfig[castID] - local eventConfig = casts_FastCastEventsConfig[castID] - local fastCastEvents = casts_FastCastEvents[castID] + local args = event.args + + local moduleConfig = + casts_FastCastEventsModuleConfig[castID] + + local eventConfig = + casts_FastCastEventsConfig[castID] - if eventType == "LengthChanged" then + local fastCastEvents = + casts_FastCastEvents[castID] + + if event.eventType == "LengthChanged" then local lastPoint = args[1] - local rayDir = args[2] - local rayDisplacement = args[3] - local segmentVelocity, CosmeticBulletObject = args[4], args[5] + local rayDirection = args[2] + local displacement = args[3] + local velocity = args[4] + local cosmetic = args[5] if eventConfig and eventConfig.UseLengthChanged then - caster.Output:Fire("LengthChanged", cast, lastPoint, rayDir, rayDisplacement, segmentVelocity, CosmeticBulletObject) + caster.Output:Fire( + "LengthChanged", + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) end - if moduleConfig and moduleConfig.UseLengthChanged and fastCastEvents and fastCastEvents.LengthChanged then - fastCastEvents.LengthChanged(cast, lastPoint, rayDir, rayDisplacement, segmentVelocity, CosmeticBulletObject) + if moduleConfig + and moduleConfig.UseLengthChanged + and fastCastEvents + and fastCastEvents.LengthChanged + then + fastCastEvents.LengthChanged( + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) end - elseif eventType == "Hit" then + elseif event.eventType == "Hit" then local result = args[1] local velocity = args[2] - local cosmeticBulletObject = args[3] + local cosmetic = args[3] if eventConfig and eventConfig.UseHit then - caster.Output:Fire("Hit", cast, result, velocity, cosmeticBulletObject) + caster.Output:Fire( + "Hit", + cast, + result, + velocity, + cosmetic + ) end - if moduleConfig and moduleConfig.UseHit and fastCastEvents and fastCastEvents.Hit then - fastCastEvents.Hit(cast, result, velocity, cosmeticBulletObject) + if moduleConfig + and moduleConfig.UseHit + and fastCastEvents + and fastCastEvents.Hit + then + fastCastEvents.Hit( + cast, + result, + velocity, + cosmetic + ) end - elseif eventType == "Pierced" then + elseif event.eventType == "Pierced" then local result = args[1] local velocity = args[2] - local cosmeticBulletObject = args[3] + local cosmetic = args[3] if eventConfig and eventConfig.UsePierced then - caster.Output:Fire("Pierced", cast, result, velocity, cosmeticBulletObject) + caster.Output:Fire( + "Pierced", + cast, + result, + velocity, + cosmetic + ) + end + + if moduleConfig + and moduleConfig.UsePierced + and fastCastEvents + and fastCastEvents.Pierced + then + fastCastEvents.Pierced( + cast, + result, + velocity, + cosmetic + ) + end + + elseif event.eventType == "CastTerminating" then + local callback = args[1] + + if cast._Destroying then + continue + end + + cast._Destroying = true + + if eventConfig + and eventConfig.UseCastTerminating + then + caster.Output:Fire( + "CastTerminating", + cast + ) end - if moduleConfig and moduleConfig.UsePierced and fastCastEvents and fastCastEvents.Pierced then - fastCastEvents.Pierced(cast, result, velocity, cosmeticBulletObject) + if callback then + callback(cast) end - elseif eventType == "CastTerminating" then - local castTerminatingfn = args[1] - TerminateCast(cast, castTerminatingfn) + caster.ActiveCastCleaner:Fire(cast.ID) end end end end +-- ============================================================================ +-- Movement +-- ============================================================================ + local function BulkMoveTo() - if CurrentMovementMode ~= "BulkMoveTo" or not MovementEnabled then + if CurrentMovementMode ~= "BulkMoveTo" + or not MovementEnabled + then return end @@ -421,20 +607,28 @@ local function BulkMoveTo() local cframes = table.create(#casts_ID) for _, id in casts_ID do - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and cosmeticPart.Parent then - table.insert(parts, cosmeticPart) + local rayInfo = casts_RayInfo[id] + local cosmetic = rayInfo and rayInfo.CosmeticBulletObject + + if cosmetic and cosmetic.Parent then + table.insert(parts, cosmetic) table.insert(cframes, casts_CFrame[id]) end end if #parts > 0 then - workspace:BulkMoveTo(parts, cframes, Enum.BulkMoveMode.FireCFrameChanged) + workspace:BulkMoveTo( + parts, + cframes, + Enum.BulkMoveMode.FireCFrameChanged + ) end end local function UpdateMotor6Ds() - if CurrentMovementMode ~= "Motor6D" or not MovementEnabled then + if CurrentMovementMode ~= "Motor6D" + or not MovementEnabled + then return end @@ -445,509 +639,927 @@ local function UpdateMotor6Ds() end end --- ParallelSimulation +-- ============================================================================ +-- Event callback helpers +-- ============================================================================ -local ParallelSimulation = {} -ParallelSimulation.Connection = nil :: RBXScriptConnection? +local function GetEventCallbacks(id: number) + local moduleConfig = casts_FastCastEventsModuleConfig[id] + local events = casts_FastCastEvents[id] -function ParallelSimulation.Init(baseCastRef: any) + if not moduleConfig or not events then + return nil, nil + end - if ActivesRef ~= nil then - warn("[FastCast2] ParallelSimulation already initialized. Skipping.") - return - end + local canPierce = + moduleConfig.UseCanPierce + and events.CanPierce + or nil - BaseCastRef = baseCastRef - ActivesRef = baseCastRef.Actives + local terminating = + moduleConfig.UseCastTerminating + and events.CastTerminating + or nil + + return canPierce, terminating end -function ParallelSimulation.Register(cast: any) - local id = cast.ID +-- ============================================================================ +-- Simulation helpers +-- ============================================================================ - casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 - casts_DistanceCovered[id] = 0 - casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - casts_IsActivelySimulatingPierce[id] = false - casts_IsActivelyResimulating[id] = false - casts_CancelHighResCast[id] = false - casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsModuleConfig[id] = cast.StateInfo.FastCastEventsModuleConfig - casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = cast.RayInfo - casts_UserData[id] = cast.UserData - casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = cast.CastVariant - casts_MaxDistance[id] = cast.RayInfo.MaxDistance or DEFAULT_MAX_DISTANCE - table.insert(casts_ID, id) - casts_ID_Index[id] = #casts_ID +local function QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean, + castTerminatingfn: TypeDef.OnCastTerminatingFunction? +) + QueueEvent( + id, + "Hit", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - local eventModule = cast.RayInfo.FastCastEventsModule - casts_FastCastEvents[id] = (typeof(eventModule) == "Instance" and eventModule:IsA("ModuleScript")) and require(eventModule) or nil + QueueVisualize(id, { + atCF = CFrame.new(result.Position), + wasPierce = false, + sub = sub + }) - local position = GetPositionAtTime( - casts_TotalRunTime[id], - casts_Trajectory[id].Origin, - casts_Trajectory[id].InitialVelocity, - casts_Trajectory[id].Acceleration + QueueEvent( + id, + "CastTerminating", + castTerminatingfn ) - casts_CFrame[id] = CFrame.new(position) +end - cast.CFrame = casts_CFrame[id] +local function QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + QueueEvent( + id, + "Pierced", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - if CurrentMovementMode == "Motor6D" and MovementEnabled then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and BaseCastRef and BaseCastRef._GetMotor6D then - local motor6d = BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end + QueueVisualize(id, { + atCF = CFrame.new(result.Position), + wasPierce = true, + sub = sub + }) +end + +local function ShouldPierce( + id: number, + result: RaycastResult, + velocity: Vector3, + canPierce: TypeDef.CanPierceFunction? +): boolean + if not canPierce then + return false end - queuedEvents[id] = {} + return canPierce( + ActivesRef[id], + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) end -function ParallelSimulation.Unregister(castID: number) - casts_TotalRunTime[castID] = nil - casts_DistanceCovered[castID] = nil - casts_HighFidelitySegmentSize[castID] = nil - casts_HighFidelityBehavior[castID] = nil - casts_IsActivelySimulatingPierce[castID] = nil - casts_IsActivelyResimulating[castID] = nil - casts_CancelHighResCast[castID] = nil - casts_Trajectory[castID] = nil - casts_FastCastEventsModuleConfig[castID] = nil - casts_FastCastEventsConfig[castID] = nil - casts_VisualizeCasts[castID] = nil - casts_VisualizeCastSettings[castID] = nil - casts_RayInfo[castID] = nil - casts_UserData[castID] = nil - casts_CFrame[castID] = nil - casts_CastType[castID] = nil - casts_CastVariant[castID] = nil - casts_MaxDistance[castID] = nil - casts_RayDisplacement[castID] = nil +-- ============================================================================ +-- Automatic high-fidelity resimulation +-- ============================================================================ - if casts_ActiveMotor6Ds[castID] then - if BaseCastRef and BaseCastRef._ReturnMotor6D then - BaseCastRef:_ReturnMotor6D(casts_ActiveMotor6Ds[castID]) - end - casts_ActiveMotor6Ds[castID] = nil - end +local function ResimulateHit( + id: number, + delta: number, + lastDelta: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3, + targetWorldRoot: WorldRoot, + castHandler: any, + canPierce: TypeDef.CanPierceFunction?, + castTerminatingfn: TypeDef.OnCastTerminatingFunction?, + fallbackResult: RaycastResult, + fallbackVelocity: Vector3 +): boolean + + casts_IsActivelyResimulating[id] = true + + local rayDisplacement = casts_RayDisplacement[id] + + local segmentCount = math.max( + 1, + math.floor( + rayDisplacement + / casts_HighFidelitySegmentSize[id] + ) + ) - local idx = casts_ID_Index[castID] - if idx then - local lastID = casts_ID[#casts_ID] - casts_ID[idx] = lastID - casts_ID_Index[lastID] = idx - casts_ID[#casts_ID] = nil - casts_ID_Index[castID] = nil + local timeIncrement = delta / segmentCount + + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) end - casts_FastCastEvents[castID] = nil - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil -end + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break + end -function ParallelSimulation.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - local oldMode = CurrentMovementMode - CurrentMovementMode = mode - MovementEnabled = enabled + local subTime = + lastDelta + timeIncrement * segmentIndex - if oldMode == "Motor6D" and mode ~= "Motor6D" then - for id, motor6d in casts_ActiveMotor6Ds do - if BaseCastRef and BaseCastRef._ReturnMotor6D then - BaseCastRef:_ReturnMotor6D(motor6d) - end - casts_ActiveMotor6Ds[id] = nil + local subPosition = GetPositionAtTime( + subTime, + origin, + initialVelocity, + acceleration + ) + + local subVelocity = GetVelocityAtTime( + subTime, + initialVelocity, + acceleration + ) + + local subDirection = + subVelocity * timeIncrement + + local subResult = castHandler( + targetWorldRoot, + subPosition, + subDirection, + casts_RayInfo[id].Parameters, + casts_CastVariant[id] + ) + + QueueVisualize(id, { + castStartCFrame = CFrame.new( + subPosition, + subPosition + subVelocity + ), + sub = true + }) + + if not subResult then + continue end - end - if mode == "Motor6D" and enabled and oldMode ~= "Motor6D" then - for _, id in casts_ID do - if not casts_ActiveMotor6Ds[id] then - local cosmeticPart = casts_RayInfo[id] and casts_RayInfo[id].CosmeticBulletObject - if cosmeticPart and BaseCastRef and BaseCastRef._GetMotor6D then - local motor6d = BaseCastRef:_GetMotor6D(cosmeticPart) - casts_ActiveMotor6Ds[id] = motor6d - end - end + if ShouldPierce( + id, + subResult, + subVelocity, + canPierce + ) then + QueuePierce( + id, + subResult, + subVelocity, + true + ) + + continue end + + casts_IsActivelyResimulating[id] = false + + QueueHit( + id, + subResult, + subVelocity, + true, + castTerminatingfn + ) + + return true end + + casts_IsActivelyResimulating[id] = false + + -- The original cast hit, but none of the + -- high-resolution subcasts did. + QueueHit( + id, + fallbackResult, + fallbackVelocity, + false, + castTerminatingfn + ) + + return true end --- RS +-- ============================================================================ +-- Single simulation step +-- ============================================================================ + local function SimulateCast( id: number, - delta: number, - FastCastEvents + delta: number, + fastCastEvents: TypeDef.FastCastEvents? ) if DebugLogging.Casting then print("Casting for frame - SimulateCast") end local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] local origin = trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - trajectory.StartTime local initialVelocity = trajectory.InitialVelocity local acceleration = trajectory.Acceleration - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + local previousTime = + casts_TotalRunTime[id] - trajectory.StartTime - local lastDelta = casts_TotalRunTime[id] - trajectory.StartTime + local lastPoint = GetPositionAtTime( + previousTime, + origin, + initialVelocity, + acceleration + ) casts_TotalRunTime[id] += delta - totalDelta = casts_TotalRunTime[id] - trajectory.StartTime + local currentTime = + casts_TotalRunTime[id] - trajectory.StartTime + + local currentPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) - local currentTarget = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local segmentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentTarget - lastPoint + local velocity = GetVelocityAtTime( + currentTime, + initialVelocity, + acceleration + ) - local rayDir = totalDisplacement + local displacement = currentPoint - lastPoint - local targetWorldRoot = casts_RayInfo[id].WorldRoot + local worldRoot = rayInfo.WorldRoot + local handler = castHandlers[variant.CastType] - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, casts_RayInfo[id].Parameters, casts_CastVariant[id]) + local result = handler( + worldRoot, + lastPoint, + displacement, + rayInfo.Parameters, + variant + ) - local point = currentTarget - local part: Instance? = nil + local hitPoint = result + and result.Position + or currentPoint - if resultOfCast ~= nil then - point = resultOfCast.Position - part = resultOfCast.Instance - end + local rayDisplacement = + (hitPoint - lastPoint).Magnitude - local rayDisplacement = (point - lastPoint).Magnitude casts_RayDisplacement[id] = rayDisplacement - casts_CFrame[id] = (if rayDir ~= Vector3.new() then CFrame.new(lastPoint, lastPoint + rayDir) else CFrame.new(lastPoint)) * CFrame.new(0, 0, -rayDisplacement / 2) + -- Update cosmetic transform. + local direction = displacement + + casts_CFrame[id] = + ( + if direction ~= Vector3.zero + then CFrame.new( + lastPoint, + lastPoint + direction + ) + else CFrame.new(lastPoint) + ) + * CFrame.new( + 0, + 0, + -rayDisplacement / 2 + ) - QueueEvent(id, "LengthChanged", lastPoint, rayDir.Unit, rayDisplacement, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) + QueueEvent( + id, + "LengthChanged", + lastPoint, + direction.Unit, + rayDisplacement, + velocity, + rayInfo.CosmeticBulletObject + ) casts_DistanceCovered[id] += rayDisplacement if delta > 0 then - QueueVisualize( + QueueVisualize(id, { + castStartCFrame = CFrame.new( + lastPoint, + lastPoint + direction + ), + sub = false + }) + end + + -- ------------------------------------------------------------------------ + -- No hit. + -- ------------------------------------------------------------------------ + + if not result then + if casts_DistanceCovered[id] >= casts_MaxDistance[id] then + QueueEvent( + id, + "CastTerminating", + GetEventCallbacks(id) + ) + end + + return + end + + -- ------------------------------------------------------------------------ + -- Cosmetic bullet collision. + -- ------------------------------------------------------------------------ + + if result.Instance == rayInfo.CosmeticBulletObject then + return + end + + local canPierce, castTerminatingfn = + GetEventCallbacks(id) + + -- ------------------------------------------------------------------------ + -- Pierce. + -- ------------------------------------------------------------------------ + + if ShouldPierce( + id, + result, + velocity, + canPierce + ) then + QueuePierce( id, - { - castStartCFrame = CFrame.new(lastPoint, lastPoint+rayDir), - sub = false - } :: any + result, + velocity, + false ) + + return end - local canPierceCheckfn: TypeDef.CanPierceFunction? = nil - local castTerminatingfn: TypeDef.OnCastTerminatingFunction? = nil + -- ------------------------------------------------------------------------ + -- Hit. + -- ------------------------------------------------------------------------ + + if casts_HighFidelityBehavior[id] + == FastCastEnums.HighFidelityBehavior.Automatic + and casts_HighFidelitySegmentSize[id] > 0 + then + if casts_IsActivelyResimulating[id] then + QueueEvent( + id, + "CastTerminating", + castTerminatingfn + ) + + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return + end - if FastCastEvents then - canPierceCheckfn = casts_FastCastEventsModuleConfig[id].UseCanPierce and FastCastEvents.CanPierce or nil - castTerminatingfn = casts_FastCastEventsModuleConfig[id].UseCastTerminating and FastCastEvents.CastTerminating or nil + local lastDelta = previousTime + + ResimulateHit( + id, + delta, + lastDelta, + origin, + initialVelocity, + acceleration, + worldRoot, + handler, + canPierce, + castTerminatingfn, + result, + velocity + ) + + return end - -- NOTE: Please dont remove "part and" - -- Why? basically when part doesn't exist it will do nothing, but removing "part and" will break the logic - -- You can't do anything about it - if part and part ~= casts_RayInfo[id].CosmeticBulletObject then - if - canPierceCheckfn == nil - or canPierceCheckfn(ActivesRef[id], resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then + QueueHit( + id, + result, + velocity, + false, + castTerminatingfn + ) +end - casts_IsActivelyResimulating[id] = false +-- ============================================================================ +-- Always high-fidelity simulation +-- ============================================================================ - if - casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Automatic - and casts_HighFidelitySegmentSize[id] > 0 - then - casts_CancelHighResCast[id] = false +local function SimulateAlwaysHighFidelity( + id: number, + delta: number, + fastCastEvents: TypeDef.FastCastEvents? +) + if casts_IsActivelyResimulating[id] then + local _, terminating = + GetEventCallbacks(id) - if casts_IsActivelyResimulating[id] then - QueueEvent(id, "CastTerminating", castTerminatingfn) + QueueEvent( + id, + "CastTerminating", + terminating + ) - warn( - "Cascading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize." - ) - return - end + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) - casts_IsActivelyResimulating[id] = true + return + end - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - --local realSegmentLength = rayDisplacement / numSegmentsReal + casts_IsActivelyResimulating[id] = true - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration - local timeIncrement = delta / numSegmentsReal - local subHitFound = false + local currentTime = + casts_TotalRunTime[id] - trajectory.StartTime - for segmentIndex = 1, numSegmentsReal do - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end + local lastPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) - local subPosition = GetPositionAtTime( - lastDelta + (timeIncrement * segmentIndex), - origin, - initialVelocity, - acceleration - ) - local subVelocity = GetVelocityAtTime( - lastDelta + (timeIncrement * segmentIndex), - initialVelocity, - acceleration - ) - local subRayDir = subVelocity * timeIncrement - local subResult = castHandler( - targetWorldRoot, - subPosition, - subRayDir, - casts_RayInfo[id].Parameters, - casts_CastVariant[id] - ) + -- Temporarily advance time to determine + -- the approximate distance for this frame. + casts_TotalRunTime[id] += delta + local nextTime = + casts_TotalRunTime[id] - trajectory.StartTime - --local subDisplacement = (subPosition - (subPosition + subVelocity)).Magnitude - - if subResult ~= nil then - subHitFound = true - - QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition + subVelocity), - sub = true - } :: any) - - if - canPierceCheckfn == nil - or canPierceCheckfn(ActivesRef[id], subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) == false - then - casts_IsActivelyResimulating[id] = false - QueueEvent(id, "Hit", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueEvent(id, "CastTerminating", castTerminatingfn) - QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = false, - sub = true - } :: any) - return - else - QueueEvent(id, "Pierced", subResult, subVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(subResult.Position), - wasPierce = true, - sub = true - } :: any) - end - else - QueueVisualize(id, { - castStartCFrame = CFrame.new(subPosition, subPosition+subVelocity), - sub = true - } :: any) - end - end - casts_IsActivelyResimulating[id] = false - if not subHitFound then - QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - } :: any) - QueueEvent(id, "CastTerminating", castTerminatingfn) - return - end - else - QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - } :: any) - QueueEvent(id, "CastTerminating", castTerminatingfn) - - return - end - else - QueueEvent(id, "Pierced", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = true - } :: any) - end + local currentPoint = GetPositionAtTime( + nextTime, + origin, + initialVelocity, + acceleration + ) + + local currentVelocity = GetVelocityAtTime( + nextTime, + initialVelocity, + acceleration + ) + + local totalDisplacement = + currentPoint - lastPoint + + local direction = + totalDisplacement.Unit + * currentVelocity.Magnitude + * delta + + local handler = castHandlers[variant.CastType] + + local broadResult = handler( + rayInfo.WorldRoot, + lastPoint, + direction, + rayInfo.Parameters, + variant + ) + + local endpoint = + broadResult + and broadResult.Position + or currentPoint + + local distance = + (endpoint - lastPoint).Magnitude + + casts_TotalRunTime[id] -= delta + + local segmentCount = math.max( + 1, + math.floor( + distance + / casts_HighFidelitySegmentSize[id] + ) + ) + + local timeIncrement = + delta / segmentCount + + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) end - if casts_DistanceCovered[id] >= casts_MaxDistance[id] then - if resultOfCast then - QueueEvent(id, "Hit", resultOfCast, segmentVelocity, casts_RayInfo[id].CosmeticBulletObject) - QueueVisualize(id, { - atCF = CFrame.new(point), - sub = false, - wasPierce = false - }:: any) + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break end - QueueEvent(id, "CastTerminating", castTerminatingfn) - end -end -local function UpdateCasts(delta: number) - for _, id in casts_ID do - if DebugLogging.Casting then - print("Casting for frame - UpdateCasts") + if not ActivesRef[id] then + casts_IsActivelyResimulating[id] = false + return end - if ActivesRef[id] and ActivesRef[id]._Destroying then - continue + if DebugLogging.Segment then + print( + "[" + .. segmentIndex + .. "] Subcast of time increment " + .. timeIncrement + ) end - local Trajectory: TypeDef.CastTrajectory = casts_Trajectory[id] + SimulateCast( + id, + timeIncrement, + fastCastEvents + ) + end + + casts_IsActivelyResimulating[id] = false +end - if casts_HighFidelitySegmentSize[id] <= 0 then - casts_HighFidelitySegmentSize[id] = 0.1 - end +-- ============================================================================ +-- Registration +-- ============================================================================ - local FastCastEvents: TypeDef.FastCastEvents = casts_FastCastEvents[id] +function ParallelSimulation_Register(cast: any) + local id = cast.ID - if casts_HighFidelityBehavior[id] == FastCastEnums.HighFidelityBehavior.Always then - local castTerminatingfn: TypeDef.OnCastTerminatingFunction? = nil - if FastCastEvents then - castTerminatingfn = casts_FastCastEventsModuleConfig[id].UseCastTerminating - and FastCastEvents.CastTerminating - or nil - end + casts_TotalRunTime[id] = + cast.StateInfo.TotalRuntime or 0 - if casts_IsActivelyResimulating[id] then - QueueEvent(id, "CastTerminating", castTerminatingfn) - warn("Casading cast lag encountered! The caster attempted to perform a high fidelity cast before the previous one completed, resulting in exponential cast lag. Consider increasing HighFidelitySegmentSize.") - continue - end - casts_IsActivelyResimulating[id] = true + casts_DistanceCovered[id] = 0 - local origin = Trajectory.Origin - local totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime - local initialVelocity = Trajectory.InitialVelocity - local acceleration = Trajectory.Acceleration + casts_HighFidelitySegmentSize[id] = + cast.StateInfo.HighFidelitySegmentSize or 0.1 - local lastPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) + casts_HighFidelityBehavior[id] = + cast.StateInfo.HighFidelityBehavior or 0 - casts_TotalRunTime[id] += delta + casts_IsActivelySimulatingPierce[id] = false + casts_IsActivelyResimulating[id] = false + casts_CancelHighResCast[id] = false - totalDelta = casts_TotalRunTime[id] - Trajectory.StartTime + casts_Trajectory[id] = + cast.StateInfo.Trajectory - local currentPoint = GetPositionAtTime(totalDelta, origin, initialVelocity, acceleration) - local currentVelocity = GetVelocityAtTime(totalDelta, initialVelocity, acceleration) - local totalDisplacement = currentPoint - lastPoint + casts_FastCastEventsModuleConfig[id] = + cast.StateInfo.FastCastEventsModuleConfig - local rayDir = totalDisplacement.Unit * currentVelocity.Magnitude * delta + casts_FastCastEventsConfig[id] = + cast.StateInfo.FastCastEventsConfig - local RayInfo = casts_RayInfo[id] - local targetWorldRoot = RayInfo.WorldRoot + casts_VisualizeCasts[id] = + cast.StateInfo.VisualizeCasts - local castHandler = castHandlers[casts_CastVariant[id].CastType] - local resultOfCast = castHandler(targetWorldRoot, lastPoint, rayDir, RayInfo.Parameters, casts_CastVariant[id]) + casts_VisualizeCastSettings[id] = + cast.StateInfo.VisualizeCastSettings - local point = currentPoint - if resultOfCast ~= nil then - point = resultOfCast.Position - end + casts_RayInfo[id] = cast.RayInfo + casts_UserData[id] = cast.UserData - local rayDisplacement = (point - lastPoint).Magnitude - casts_TotalRunTime[id] -= delta + casts_CastType[id] = + cast.CastVariant.CastType - local numSegmentsDecimal = rayDisplacement / casts_HighFidelitySegmentSize[id] - local numSegmentsReal = math.floor(numSegmentsDecimal) - if numSegmentsReal == 0 then - numSegmentsReal = 1 - end + casts_CastVariant[id] = + cast.CastVariant - local timeIncrement = delta / numSegmentsReal + casts_MaxDistance[id] = + cast.RayInfo.MaxDistance + or DEFAULT_MAX_DISTANCE - if DebugLogging.Calculation then - print("Performing subcast! Time increment: " .. timeIncrement .. ", num segments: " .. numSegmentsReal) - end + table.insert(casts_ID, id) + casts_ID_Index[id] = #casts_ID - local cast_nil = false + -- Load optional event module. + local eventModule = cast.RayInfo.FastCastEventsModule - for segmentIndex = 1, numSegmentsReal do + if typeof(eventModule) == "Instance" + and eventModule:IsA("ModuleScript") + then + casts_FastCastEvents[id] = require(eventModule) + else + casts_FastCastEvents[id] = nil + end - -- In case when cast Destroyed or not exist - if ActivesRef[id] == nil then - cast_nil = true - end + local trajectory = casts_Trajectory[id] + local position = GetPositionAtTime( + casts_TotalRunTime[id], + trajectory.Origin, + trajectory.InitialVelocity, + trajectory.Acceleration + ) - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end + casts_CFrame[id] = CFrame.new(position) + cast.CFrame = casts_CFrame[id] - if DebugLogging.Segment then - print("[" .. segmentIndex .. "] Subcast of time increment " .. timeIncrement) - end + if CurrentMovementMode == "Motor6D" + and MovementEnabled + then + local cosmetic = casts_RayInfo[id].CosmeticBulletObject - SimulateCast(id, timeIncrement, FastCastEvents) + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef:_GetMotor6D(cosmetic) + end + end + + queuedEvents[id] = {} +end + +function ParallelSimulation_Unregister(castID: number) + casts_TotalRunTime[castID] = nil + casts_DistanceCovered[castID] = nil + + casts_HighFidelitySegmentSize[castID] = nil + casts_HighFidelityBehavior[castID] = nil + + casts_IsActivelySimulatingPierce[castID] = nil + casts_IsActivelyResimulating[castID] = nil + casts_CancelHighResCast[castID] = nil + + casts_Trajectory[castID] = nil + + casts_FastCastEventsModuleConfig[castID] = nil + casts_FastCastEventsConfig[castID] = nil + casts_FastCastEvents[castID] = nil + + casts_VisualizeCasts[castID] = nil + casts_VisualizeCastSettings[castID] = nil + + casts_RayInfo[castID] = nil + casts_UserData[castID] = nil + + casts_CFrame[castID] = nil + casts_CastType[castID] = nil + casts_CastVariant[castID] = nil + + casts_MaxDistance[castID] = nil + casts_RayDisplacement[castID] = nil + + if casts_ActiveMotor6Ds[castID] then + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef:_ReturnMotor6D( + casts_ActiveMotor6Ds[castID] + ) + end + + casts_ActiveMotor6Ds[castID] = nil + end + + local index = casts_ID_Index[castID] + + if index then + local lastID = casts_ID[#casts_ID] + + casts_ID[index] = lastID + casts_ID_Index[lastID] = index + + table.remove(casts_ID, #casts_ID) + casts_ID_Index[castID] = nil + end + + queuedEvents[castID] = nil + queuedVisualizes[castID] = nil +end + +-- ============================================================================ +-- Movement configuration +-- ============================================================================ + +local function SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + local oldMode = CurrentMovementMode + + CurrentMovementMode = mode + MovementEnabled = enabled + + if oldMode == "Motor6D" + and mode ~= "Motor6D" + then + for id, motor6d in casts_ActiveMotor6Ds do + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef:_ReturnMotor6D(motor6d) end - if cast_nil then - casts_IsActivelyResimulating[id] = false + casts_ActiveMotor6Ds[id] = nil + end + end + + if mode == "Motor6D" + and enabled + and oldMode ~= "Motor6D" + then + for _, id in casts_ID do + if casts_ActiveMotor6Ds[id] then continue end - -- Double check again - if ActivesRef[id] == nil then - casts_IsActivelyResimulating[id] = false - continue + local cosmetic = + casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef:_GetMotor6D(cosmetic) end - casts_IsActivelyResimulating[id] = false + end + end +end + +-- ============================================================================ +-- Main update +-- ============================================================================ + +local function UpdateCasts(delta: number) + for _, id in casts_ID do + local cast = ActivesRef[id] + + if cast and cast._Destroying then + continue + end + + if not casts_Trajectory[id] then + continue + end + + if casts_HighFidelitySegmentSize[id] <= 0 then + casts_HighFidelitySegmentSize[id] = 0.1 + end + + local fastCastEvents = + casts_FastCastEvents[id] + + local behavior = + casts_HighFidelityBehavior[id] + + if behavior + == FastCastEnums.HighFidelityBehavior.Always + then + SimulateAlwaysHighFidelity( + id, + delta, + fastCastEvents + ) else - SimulateCast(id, delta, FastCastEvents) + SimulateCast( + id, + delta, + fastCastEvents + ) end end + -- ========================================================================= + -- Synchronize back to the serial scheduler. + -- ========================================================================= + task.synchronize() - -- Write-back SoA -> ActiveCast so ActivesRef[id] has current values for _, id in casts_ID do local cast = ActivesRef[id] + if cast then - cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] - cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] - cast.CFrame = casts_CFrame[id] + cast.StateInfo.TotalRuntime = + casts_TotalRunTime[id] + + cast.StateInfo.DistanceCovered = + casts_DistanceCovered[id] + + cast.CFrame = + casts_CFrame[id] end end - -- Motor6D, BulkMoveTo, FireQueuedVisualizes, FireQueuedEvents - -- NOTE: Do not change these if you don't knows how it works + -- ========================================================================= + -- Main-thread-only work. + -- ========================================================================= UpdateMotor6Ds() BulkMoveTo() local eventsToProcess = queuedEvents queuedEvents = {} + local visualizesToProcess = queuedVisualizes queuedVisualizes = {} - FireQueuedVisualizes(visualizesToProcess) - FireQueuedEvents(eventsToProcess) + FireQueuedVisualizes( + visualizesToProcess + ) + + FireQueuedEvents( + eventsToProcess + ) +end + +-- ============================================================================ +-- Public API +-- ============================================================================ + +local ParallelSimulation = {} + +ParallelSimulation.Connection = nil :: RBXScriptConnection? + +function ParallelSimulation.Init(baseCastRef: any) + if ActivesRef ~= nil then + warn( + "[FastCast2] ParallelSimulation already initialized. Skipping." + ) + + return + end + + BaseCastRef = baseCastRef + ActivesRef = baseCastRef.Actives +end + +function ParallelSimulation.Register(cast: any) + ParallelSimulation_Register(cast) +end + +function ParallelSimulation.Unregister(castID: number) + ParallelSimulation_Unregister(castID) +end + +function ParallelSimulation.SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + SetMovementModeEnabled(enabled, mode) end function ParallelSimulation.Start() if ParallelSimulation.Connection then - warn("Already started") + warn("[FastCast2] ParallelSimulation already started.") return end if RS:IsClient() then - ParallelSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) + ParallelSimulation.Connection = + RS.PreSimulation:ConnectParallel(UpdateCasts) else - ParallelSimulation.Connection = RS.Heartbeat:ConnectParallel(UpdateCasts) + ParallelSimulation.Connection = + RS.Heartbeat:ConnectParallel(UpdateCasts) end end From ee1bc16477ec1249ca8f7063ba138b95097c048a Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 11:54:05 +0000 Subject: [PATCH 003/140] Fix useless comments --- src/ParallelSimulation.luau | 1 - src/SerialSimulation.luau | 14 ++------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 8f860a5f..26f5b755 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1,6 +1,5 @@ --[[ - Author: Mawin CK - - Refactored ParallelSimulation - Date: 2026 ]] diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index b51365a3..21d31127 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1,16 +1,6 @@ --[[ - SerialSimulation.luau - FastCast2 - - Refactored preview. - - Main goals: - - Keep simulation flow readable. - - Isolate collision resolution. - - Isolate high-fidelity resimulation. - - Centralize Hit/Pierced/Terminating events. - - Avoid deeply nested if/else chains. - - Keep SerialSimulation API compatible. + - Author: Mawin_CK + - Date: 2026 ]] --!native From 670771bb90e593cde2da9a48d622ed59fb6b2587 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 12:00:45 +0000 Subject: [PATCH 004/140] make things a little better --- src/ParallelSimulation.luau | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 26f5b755..8cbd71de 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -50,8 +50,8 @@ type SphereVisualizerVariant = { type CastVisualizerVariants = RayVisualizerVariant -| BlockVisualizerVariant -| SphereVisualizerVariant + | BlockVisualizerVariant + | SphereVisualizerVariant type QueuedEventData = { eventType: string, @@ -464,16 +464,14 @@ local function FireQueuedEvents( local caster = cast.Caster local args = event.args - local moduleConfig = - casts_FastCastEventsModuleConfig[castID] + local moduleConfig = casts_FastCastEventsModuleConfig[castID] - local eventConfig = - casts_FastCastEventsConfig[castID] + local eventConfig = casts_FastCastEventsConfig[castID] - local fastCastEvents = - casts_FastCastEvents[castID] + local fastCastEvents = casts_FastCastEvents[castID] + local eventType = event.eventType - if event.eventType == "LengthChanged" then + if eventType == "LengthChanged" then local lastPoint = args[1] local rayDirection = args[2] local displacement = args[3] @@ -507,7 +505,7 @@ local function FireQueuedEvents( ) end - elseif event.eventType == "Hit" then + elseif eventType == "Hit" then local result = args[1] local velocity = args[2] local cosmetic = args[3] @@ -535,7 +533,7 @@ local function FireQueuedEvents( ) end - elseif event.eventType == "Pierced" then + elseif eventType == "Pierced" then local result = args[1] local velocity = args[2] local cosmetic = args[3] @@ -563,7 +561,7 @@ local function FireQueuedEvents( ) end - elseif event.eventType == "CastTerminating" then + elseif eventType == "CastTerminating" then local callback = args[1] if cast._Destroying then From 1c5c8e79bc18b35412053e77ad0e65780c0f128c Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 12:04:49 +0000 Subject: [PATCH 005/140] Making things better --- src/SerialSimulation.luau | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 21d31127..1194cfae 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1273,12 +1273,11 @@ function SerialSimulation:FireQueuedEvents( continue end - local config = - casts_FastCastEventsConfig[castID] + local config = casts_FastCastEventsConfig[castID] - if event.eventType - == "LengthChanged" - then + local eventType = event.eventType + + if eventType == "LengthChanged" then if config and config.UseLengthChanged @@ -1291,9 +1290,7 @@ function SerialSimulation:FireQueuedEvents( ) end - elseif event.eventType - == "Hit" - then + elseif eventType == "Hit" then if config and config.UseHit @@ -1306,9 +1303,7 @@ function SerialSimulation:FireQueuedEvents( ) end - elseif event.eventType - == "Pierced" - then + elseif eventType == "Pierced" then if config and config.UsePierced @@ -1321,9 +1316,7 @@ function SerialSimulation:FireQueuedEvents( ) end - elseif event.eventType - == "CastTerminating" - then + elseif eventType == "CastTerminating" then self.BaseCastRef:_TerminateCast( castID, From 709c9ee6f3e4da0e2b20ff0a9964894f49fa3d0b Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 22:37:05 +0000 Subject: [PATCH 006/140] edit smth --- src/ParallelSimulation.luau | 48 +++++++++++++------------------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 8cbd71de..91df7e9e 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -796,8 +796,7 @@ local function ResimulateHit( acceleration ) - local subDirection = - subVelocity * timeIncrement + local subDirection = subVelocity * timeIncrement local subResult = castHandler( targetWorldRoot, @@ -884,8 +883,7 @@ local function SimulateCast( local initialVelocity = trajectory.InitialVelocity local acceleration = trajectory.Acceleration - local previousTime = - casts_TotalRunTime[id] - trajectory.StartTime + local previousTime = casts_TotalRunTime[id] - trajectory.StartTime local lastPoint = GetPositionAtTime( previousTime, @@ -896,8 +894,7 @@ local function SimulateCast( casts_TotalRunTime[id] += delta - local currentTime = - casts_TotalRunTime[id] - trajectory.StartTime + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime local currentPoint = GetPositionAtTime( currentTime, @@ -929,8 +926,7 @@ local function SimulateCast( and result.Position or currentPoint - local rayDisplacement = - (hitPoint - lastPoint).Magnitude + local rayDisplacement = (hitPoint - lastPoint).Magnitude casts_RayDisplacement[id] = rayDisplacement @@ -998,8 +994,7 @@ local function SimulateCast( return end - local canPierce, castTerminatingfn = - GetEventCallbacks(id) + local canPierce, castTerminatingfn = GetEventCallbacks(id) -- ------------------------------------------------------------------------ -- Pierce. @@ -1085,8 +1080,7 @@ local function SimulateAlwaysHighFidelity( fastCastEvents: TypeDef.FastCastEvents? ) if casts_IsActivelyResimulating[id] then - local _, terminating = - GetEventCallbacks(id) + local _, terminating = GetEventCallbacks(id) QueueEvent( id, @@ -1114,8 +1108,7 @@ local function SimulateAlwaysHighFidelity( local initialVelocity = trajectory.InitialVelocity local acceleration = trajectory.Acceleration - local currentTime = - casts_TotalRunTime[id] - trajectory.StartTime + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime local lastPoint = GetPositionAtTime( currentTime, @@ -1128,8 +1121,7 @@ local function SimulateAlwaysHighFidelity( -- the approximate distance for this frame. casts_TotalRunTime[id] += delta - local nextTime = - casts_TotalRunTime[id] - trajectory.StartTime + local nextTime = casts_TotalRunTime[id] - trajectory.StartTime local currentPoint = GetPositionAtTime( nextTime, @@ -1144,8 +1136,7 @@ local function SimulateAlwaysHighFidelity( acceleration ) - local totalDisplacement = - currentPoint - lastPoint + local totalDisplacement = currentPoint - lastPoint local direction = totalDisplacement.Unit @@ -1154,7 +1145,7 @@ local function SimulateAlwaysHighFidelity( local handler = castHandlers[variant.CastType] - local broadResult = handler( + local resultOfCast = handler( rayInfo.WorldRoot, lastPoint, direction, @@ -1163,12 +1154,11 @@ local function SimulateAlwaysHighFidelity( ) local endpoint = - broadResult - and broadResult.Position + resultOfCast + and resultOfCast.Position or currentPoint - local distance = - (endpoint - lastPoint).Magnitude + local distance = (endpoint - lastPoint).Magnitude casts_TotalRunTime[id] -= delta @@ -1180,8 +1170,7 @@ local function SimulateAlwaysHighFidelity( ) ) - local timeIncrement = - delta / segmentCount + local timeIncrement = delta / segmentCount if DebugLogging.Calculation then print( @@ -1476,14 +1465,11 @@ local function UpdateCasts(delta: number) local cast = ActivesRef[id] if cast then - cast.StateInfo.TotalRuntime = - casts_TotalRunTime[id] + cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] - cast.StateInfo.DistanceCovered = - casts_DistanceCovered[id] + cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] - cast.CFrame = - casts_CFrame[id] + cast.CFrame = casts_CFrame[id] end end From 0d97ea6143dfb0e539f7d60d4aa1b2bf4afbe79d Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 22:38:24 +0000 Subject: [PATCH 007/140] edit --- src/ParallelSimulation.luau | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 91df7e9e..47704187 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1538,11 +1538,9 @@ function ParallelSimulation.Start() end if RS:IsClient() then - ParallelSimulation.Connection = - RS.PreSimulation:ConnectParallel(UpdateCasts) + ParallelSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) else - ParallelSimulation.Connection = - RS.Heartbeat:ConnectParallel(UpdateCasts) + ParallelSimulation.Connection = RS.Heartbeat:ConnectParallel(UpdateCasts) end end From c3b37945c63d900a0bd2cb81e787d09c0c89a43f Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 22:59:16 +0000 Subject: [PATCH 008/140] edit --- src/SerialSimulation.luau | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 1194cfae..e382f9cf 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1190,11 +1190,9 @@ function SerialSimulation:FireQueuedVisualizes( ) end - local castType = - casts_CastType[castID] + local castType = casts_CastType[castID] - local visualizer = - Visualizers[castType] + local visualizer = Visualizers[castType] if not visualizer then continue @@ -1341,8 +1339,7 @@ function SerialSimulation:Register( local id = cast.ID - casts_TotalRunTime[id] = - cast.StateInfo.TotalRuntime or 0 + casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 casts_DistanceCovered[id] = 0 @@ -1672,11 +1669,9 @@ function SerialSimulation.new() self.Connection = nil - self.CurrentMovementMode = - "BulkMoveTo" + self.CurrentMovementMode = "BulkMoveTo" - self.MovementEnabled = - true + self.MovementEnabled = true self.Events = {} @@ -1694,14 +1689,11 @@ function SerialSimulation:Init( events: TypeDef.FastCastEvents ) - self.BaseCastRef = - baseCastRef + self.BaseCastRef = baseCastRef - self.ActivesRef = - baseCastRef.Actives + self.ActivesRef = baseCastRef.Actives - self.Events = - events + self.Events = events end --------------------------------------------------------------------- From 3bce9be5bcb85192f29dce2f4740746c0e371939 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 23:00:02 +0000 Subject: [PATCH 009/140] edit --- src/SerialSimulation.luau | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index e382f9cf..56137d16 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1355,29 +1355,21 @@ function SerialSimulation:Register( casts_IsActivelyResimulating[id] = false casts_CancelHighResCast[id] = false - casts_Trajectory[id] = - cast.StateInfo.Trajectory + casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsConfig[id] = - cast.StateInfo.FastCastEventsConfig + casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - casts_VisualizeCasts[id] = - cast.StateInfo.VisualizeCasts + casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - casts_VisualizeCastSettings[id] = - cast.StateInfo.VisualizeCastSettings + casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = - cast.RayInfo + casts_RayInfo[id] = cast.RayInfo - casts_UserData[id] = - cast.UserData + casts_UserData[id] = cast.UserData - casts_CastType[id] = - cast.CastVariant.CastType + casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = - cast.CastVariant + casts_CastVariant[id] = cast.CastVariant casts_MaxDistance[id] = cast.RayInfo.MaxDistance @@ -1388,11 +1380,9 @@ function SerialSimulation:Register( id ) - self.casts_ID_Index[id] = - #self.casts_ID + self.casts_ID_Index[id] = #self.casts_ID - local trajectory = - casts_Trajectory[id] + local trajectory = casts_Trajectory[id] local position = GetPositionAtTime( @@ -1402,11 +1392,9 @@ function SerialSimulation:Register( trajectory.Acceleration ) - casts_CFrame[id] = - CFrame.new(position) + casts_CFrame[id] = CFrame.new(position) - cast.CFrame = - casts_CFrame[id] + cast.CFrame = casts_CFrame[id] queuedEvents[id] = {} end From 4db89f7e3de622decec6e861ceb4e1713035288f Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 23:09:02 +0000 Subject: [PATCH 010/140] Remove useless things --- src/SerialSimulation.luau | 633 -------------------------------------- 1 file changed, 633 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 56137d16..3e2ee5e3 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -115,10 +115,6 @@ local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_RayDisplacement = {} :: { [number]: number } ---------------------------------------------------------------------- --- Runtime State ---------------------------------------------------------------------- - local queuedEvents: { [number]: { QueuedEventData }, } = {} @@ -405,128 +401,6 @@ function SerialSimulation:QueueVisualize( ) end ---------------------------------------------------------------------- --- State Helpers ---------------------------------------------------------------------- - -function SerialSimulation:IsCastAlive(id: number): boolean - return self.ActivesRef[id] ~= nil -end - -function SerialSimulation:ShouldUseHighFidelity(id: number): boolean - - return casts_HighFidelityBehavior[id] - == FastCastEnums.HighFidelityBehavior.Automatic - and casts_HighFidelitySegmentSize[id] > 0 -end - -function SerialSimulation:ShouldPierce( - id: number, - result: RaycastResult, - velocity: Vector3, - canPierce: TypeDef.CanPierceFunction? -): boolean - - if not canPierce then - return false - end - - local cast = self.ActivesRef[id] - local cosmeticBullet = casts_RayInfo[id].CosmeticBulletObject - - return canPierce( - cast, - result, - velocity, - cosmeticBullet - ) == true -end - ---------------------------------------------------------------------- --- Segment Calculation ---------------------------------------------------------------------- - -function SerialSimulation:CalculateSegment( - id: number, - delta: number -): SegmentData - - local trajectory = casts_Trajectory[id] - - local origin = trajectory.Origin - local initialVelocity = trajectory.InitialVelocity - local acceleration = trajectory.Acceleration - - local lastTime = - casts_TotalRunTime[id] - - trajectory.StartTime - - local currentTime = - lastTime + delta - - local lastPoint = GetPositionAtTime( - lastTime, - origin, - initialVelocity, - acceleration - ) - - local currentPoint = GetPositionAtTime( - currentTime, - origin, - initialVelocity, - acceleration - ) - - local velocity = GetVelocityAtTime( - currentTime, - initialVelocity, - acceleration - ) - - return { - lastPoint = lastPoint, - currentPoint = currentPoint, - - lastTime = lastTime, - currentTime = currentTime, - - velocity = velocity, - displacement = currentPoint - lastPoint, - } -end - ---------------------------------------------------------------------- --- Movement ---------------------------------------------------------------------- - -function SerialSimulation:UpdateCastCFrame( - id: number, - lastPoint: Vector3, - displacement: Vector3, - distance: number -) - - local direction - - if displacement.Magnitude > 0 then - direction = displacement.Unit - else - direction = Vector3.zAxis - end - - casts_CFrame[id] = - CFrame.lookAt( - lastPoint, - lastPoint + direction - ) - * CFrame.new( - 0, - 0, - -distance / 2 - ) -end - --------------------------------------------------------------------- -- Event Helpers --------------------------------------------------------------------- @@ -616,513 +490,6 @@ function SerialSimulation:QueueTermination(id: number) ) end ---------------------------------------------------------------------- --- High Fidelity ---------------------------------------------------------------------- - -function SerialSimulation:BeginResimulation(id: number): boolean - - if casts_IsActivelyResimulating[id] then - - casts_CancelHighResCast[id] = true - - warn( - "[FastCast2] Cascading cast lag encountered. " - .. "Consider increasing HighFidelitySegmentSize." - ) - - return false - end - - casts_IsActivelyResimulating[id] = true - - return true -end - -function SerialSimulation:EndResimulation(id: number) - - casts_IsActivelyResimulating[id] = false - -end - -function SerialSimulation:CalculateHighFidelitySegments( - distance: number, - segmentSize: number -): number - - local count = math.floor( - distance / segmentSize - ) - - return math.max( - count, - 1 - ) -end - -function SerialSimulation:ResolveHighFidelityCollision( - id: number, - delta: number, - segment: SegmentData, - initialResult: RaycastResult, - canPierce: TypeDef.CanPierceFunction? -): boolean - - if not self:BeginResimulation(id) then - self:QueueTermination(id) - return true - end - - local trajectory = casts_Trajectory[id] - local rayInfo = casts_RayInfo[id] - - local castHandler = - castHandlers[casts_CastVariant[id].CastType] - - local distance = - (initialResult.Position - segment.lastPoint).Magnitude - - local segmentCount = - self:CalculateHighFidelitySegments( - distance, - casts_HighFidelitySegmentSize[id] - ) - - local timeIncrement = - delta / segmentCount - - if DebugLogging.Calculation then - print( - "[FastCast2] High fidelity:", - segmentCount, - "segments," - , - timeIncrement, - "seconds each" - ) - end - - local origin = trajectory.Origin - local initialVelocity = trajectory.InitialVelocity - local acceleration = trajectory.Acceleration - - for index = 1, segmentCount do - - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end - - if not self:IsCastAlive(id) then - self:EndResimulation(id) - return true - end - - local startTime = - segment.lastTime - + timeIncrement * (index - 1) - - local endTime = - segment.lastTime - + timeIncrement * index - - local subStart = - GetPositionAtTime( - startTime, - origin, - initialVelocity, - acceleration - ) - - local subEnd = - GetPositionAtTime( - endTime, - origin, - initialVelocity, - acceleration - ) - - local subVelocity = - GetVelocityAtTime( - endTime, - initialVelocity, - acceleration - ) - - local subDirection = - subEnd - subStart - - if DebugLogging.Segment then - print( - "[FastCast2] Subcast", - index, - "/", - segmentCount - ) - end - - local subResult = - castHandler( - rayInfo.WorldRoot, - subStart, - subDirection, - rayInfo.Parameters, - casts_CastVariant[id] - ) - - self:QueueVisualize( - id, - { - castStartCFrame = - if subDirection.Magnitude > 0 - then CFrame.lookAt( - subStart, - subStart + subDirection - ) - else CFrame.new(subStart), - - sub = true, - } - ) - - if not subResult then - continue - end - - local isPierced = - self:ShouldPierce( - id, - subResult, - subVelocity, - canPierce - ) - - if isPierced then - - self:QueuePierce( - id, - subResult, - subVelocity, - true - ) - - continue - end - - self:QueueHit( - id, - subResult, - subVelocity, - true - ) - - self:QueueTermination(id) - - self:EndResimulation(id) - - return true - end - - self:EndResimulation(id) - - -- No valid sub-hit was found. - -- Fall back to the original collision. - - self:QueueHit( - id, - initialResult, - segment.velocity, - false - ) - - self:QueueTermination(id) - - return true -end - ---------------------------------------------------------------------- --- Collision Resolution ---------------------------------------------------------------------- - -function SerialSimulation:ResolveCollision( - id: number, - result: RaycastResult, - segment: SegmentData, - delta: number, - canPierce: TypeDef.CanPierceFunction? -): boolean - - local isPierced = - self:ShouldPierce( - id, - result, - segment.velocity, - canPierce - ) - - ------------------------------------------------------------- - -- Pierced - ------------------------------------------------------------- - - if isPierced then - - self:QueuePierce( - id, - result, - segment.velocity, - false - ) - - return false - end - - ------------------------------------------------------------- - -- Non-piercing hit - ------------------------------------------------------------- - - if not self:ShouldUseHighFidelity(id) then - - self:QueueHit( - id, - result, - segment.velocity, - false - ) - - self:QueueTermination(id) - - return true - end - - ------------------------------------------------------------- - -- High fidelity - ------------------------------------------------------------- - - return self:ResolveHighFidelityCollision( - id, - delta, - segment, - result, - canPierce - ) -end - ---------------------------------------------------------------------- --- Max Distance ---------------------------------------------------------------------- - -function SerialSimulation:CheckMaxDistance( - id: number, - result: RaycastResult?, - segment: SegmentData -): boolean - - if casts_DistanceCovered[id] < casts_MaxDistance[id] then - return false - end - - if result then - self:QueueHit( - id, - result, - segment.velocity, - false - ) - end - - self:QueueTermination(id) - - return true -end - ---------------------------------------------------------------------- --- Visualization ---------------------------------------------------------------------- - -function SerialSimulation:QueueSegmentVisualization( - id: number, - segment: SegmentData -) - - if segment.displacement.Magnitude <= 0 then - return - end - - self:QueueVisualize( - id, - { - castStartCFrame = - CFrame.lookAt( - segment.lastPoint, - segment.lastPoint + segment.displacement - ), - - sub = false, - } - ) -end - ---------------------------------------------------------------------- --- Main Simulation ---------------------------------------------------------------------- - -function SerialSimulation:SimulateCast( - id: number, - delta: number, - canPierce: TypeDef.CanPierceFunction? -) - - if DebugLogging.Casting then - print( - "[FastCast2] Simulating cast", - id - ) - end - - ------------------------------------------------------------- - -- Calculate movement - ------------------------------------------------------------- - - local segment = - self:CalculateSegment( - id, - delta - ) - - ------------------------------------------------------------- - -- Advance simulation time - ------------------------------------------------------------- - - casts_TotalRunTime[id] += delta - - ------------------------------------------------------------- - -- Perform collision query - ------------------------------------------------------------- - - local rayInfo = casts_RayInfo[id] - local variant = casts_CastVariant[id] - - local castHandler = - castHandlers[variant.CastType] - - local result = - castHandler( - rayInfo.WorldRoot, - segment.lastPoint, - segment.displacement, - rayInfo.Parameters, - variant - ) - - ------------------------------------------------------------- - -- Determine visible endpoint - ------------------------------------------------------------- - - local endpoint = - result - and result.Position - or segment.currentPoint - - local displacement = - (endpoint - segment.lastPoint).Magnitude - - casts_RayDisplacement[id] = - displacement - - casts_DistanceCovered[id] += - displacement - - ------------------------------------------------------------- - -- Update cosmetic position - ------------------------------------------------------------- - - self:UpdateCastCFrame( - id, - segment.lastPoint, - segment.displacement, - displacement - ) - - ------------------------------------------------------------- - -- LengthChanged - ------------------------------------------------------------- - - self:QueueLengthChanged( - id, - segment, - displacement - ) - - ------------------------------------------------------------- - -- Debug visualization - ------------------------------------------------------------- - - if delta > 0 then - self:QueueSegmentVisualization( - id, - segment - ) - end - - ------------------------------------------------------------- - -- No collision - ------------------------------------------------------------- - - if not result then - self:CheckMaxDistance( - id, - nil, - segment - ) - - return - end - - ------------------------------------------------------------- - -- Ignore cosmetic projectile - ------------------------------------------------------------- - - if result.Instance - == rayInfo.CosmeticBulletObject - then - - self:CheckMaxDistance( - id, - nil, - segment - ) - - return - end - - ------------------------------------------------------------- - -- Resolve collision - ------------------------------------------------------------- - - local terminated = - self:ResolveCollision( - id, - result, - segment, - delta, - canPierce - ) - - if terminated then - return - end - - ------------------------------------------------------------- - -- Max distance after piercing - ------------------------------------------------------------- - - self:CheckMaxDistance( - id, - nil, - segment - ) -end - --------------------------------------------------------------------- -- Event Dispatch --------------------------------------------------------------------- From e59b0ff07c9a80d2a9fe019a8405dbd9283d7464 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 23:13:57 +0000 Subject: [PATCH 011/140] Update order --- src/SerialSimulation.luau | 330 +++++++++++++++++++------------------- 1 file changed, 165 insertions(+), 165 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 3e2ee5e3..af8b0a0b 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -382,118 +382,120 @@ function SerialSimulation:QueueEvent( ) end -function SerialSimulation:QueueVisualize( - castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData +function SerialSimulation:FireQueuedEvents( + unFiredEvents ) - if not casts_VisualizeCasts[castID] then - return - end + local sortedIDs = {} - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} + for id in unFiredEvents do + table.insert( + sortedIDs, + id + ) end - table.insert( - queuedVisualizes[castID], - data - ) -end + table.sort(sortedIDs) ---------------------------------------------------------------------- --- Event Helpers ---------------------------------------------------------------------- + for _, castID in sortedIDs do -function SerialSimulation:QueueLengthChanged( - id: number, - segment: SegmentData, - distance: number -) + local events = + unFiredEvents[castID] - local rayDirection + if not events then + continue + end - if segment.displacement.Magnitude > 0 then - rayDirection = segment.displacement.Unit - else - rayDirection = Vector3.zero - end + for _, event in events do - local cosmeticBullet = - casts_RayInfo[id].CosmeticBulletObject + local cast = + self.ActivesRef[castID] - self:QueueEvent( - id, - "LengthChanged", - segment.lastPoint, - rayDirection, - distance, - segment.velocity, - cosmeticBullet - ) -end + if not cast then + continue + end -function SerialSimulation:QueueHit( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean -) + local config = casts_FastCastEventsConfig[castID] - self:QueueEvent( - id, - "Hit", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) + local eventType = event.eventType - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = false, - sub = sub, - } - ) + if eventType == "LengthChanged" then + + if config + and config.UseLengthChanged + and self.Events.LengthChanged + then + + self.Events.LengthChanged( + cast, + table.unpack(event.args) + ) + end + + elseif eventType == "Hit" then + + if config + and config.UseHit + and self.Events.Hit + then + + self.Events.Hit( + cast, + table.unpack(event.args) + ) + end + + elseif eventType == "Pierced" then + + if config + and config.UsePierced + and self.Events.Pierced + then + + self.Events.Pierced( + cast, + table.unpack(event.args) + ) + end + + elseif eventType == "CastTerminating" then + + self.BaseCastRef:_TerminateCast( + castID, + self.Events.CastTerminating + ) + + self:Unregister( + castID + ) + end + end + end end -function SerialSimulation:QueuePierce( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean +-- ============================================================================ +-- Queued visualization +-- ============================================================================ + +function SerialSimulation:QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData ) - self:QueueEvent( - id, - "Pierced", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) + if not casts_VisualizeCasts[castID] then + return + end - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = true, - sub = sub, - } - ) -end + if not queuedVisualizes[castID] then + queuedVisualizes[castID] = {} + end -function SerialSimulation:QueueTermination(id: number) - self:QueueEvent( - id, - "CastTerminating" + table.insert( + queuedVisualizes[castID], + data ) end ---------------------------------------------------------------------- --- Event Dispatch ---------------------------------------------------------------------- - function SerialSimulation:FireQueuedVisualizes( unFiredVisualizes ) @@ -605,95 +607,93 @@ function SerialSimulation:FireQueuedVisualizes( end end -function SerialSimulation:FireQueuedEvents( - unFiredEvents +--------------------------------------------------------------------- +-- Event Helpers +--------------------------------------------------------------------- + +function SerialSimulation:QueueLengthChanged( + id: number, + segment: SegmentData, + distance: number ) - local sortedIDs = {} + local rayDirection - for id in unFiredEvents do - table.insert( - sortedIDs, - id - ) + if segment.displacement.Magnitude > 0 then + rayDirection = segment.displacement.Unit + else + rayDirection = Vector3.zero end - table.sort(sortedIDs) - - for _, castID in sortedIDs do - - local events = - unFiredEvents[castID] - - if not events then - continue - end - - for _, event in events do - - local cast = - self.ActivesRef[castID] - - if not cast then - continue - end - - local config = casts_FastCastEventsConfig[castID] - - local eventType = event.eventType - - if eventType == "LengthChanged" then - - if config - and config.UseLengthChanged - and self.Events.LengthChanged - then - - self.Events.LengthChanged( - cast, - table.unpack(event.args) - ) - end - - elseif eventType == "Hit" then + local cosmeticBullet = + casts_RayInfo[id].CosmeticBulletObject - if config - and config.UseHit - and self.Events.Hit - then + self:QueueEvent( + id, + "LengthChanged", + segment.lastPoint, + rayDirection, + distance, + segment.velocity, + cosmeticBullet + ) +end - self.Events.Hit( - cast, - table.unpack(event.args) - ) - end +function SerialSimulation:QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) - elseif eventType == "Pierced" then + self:QueueEvent( + id, + "Hit", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - if config - and config.UsePierced - and self.Events.Pierced - then + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = false, + sub = sub, + } + ) +end - self.Events.Pierced( - cast, - table.unpack(event.args) - ) - end +function SerialSimulation:QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) - elseif eventType == "CastTerminating" then + self:QueueEvent( + id, + "Pierced", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - self.BaseCastRef:_TerminateCast( - castID, - self.Events.CastTerminating - ) + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = true, + sub = sub, + } + ) +end - self:Unregister( - castID - ) - end - end - end +function SerialSimulation:QueueTermination(id: number) + self:QueueEvent( + id, + "CastTerminating" + ) end --------------------------------------------------------------------- From bc6112ca76fce5c5c4e2958fd63d7dbcfae5840e Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 14 Sep 2026 23:19:40 +0000 Subject: [PATCH 012/140] Fix --- src/ParallelSimulation.luau | 10 ++++++-- src/SerialSimulation.luau | 51 +++++++++++++------------------------ 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 47704187..01352206 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -360,11 +360,17 @@ local function FireQueuedVisualizes( for _, data in list do -- Hit visualization if data.atCF then - if DebugLogging.Hit and not data.wasPierce and not data.sub then + if DebugLogging.Hit + and not data.wasPierce + and not data.sub + then print("Hit was successful. Terminating.") end - if DebugLogging.Pierced and data.wasPierce and not data.sub then + if DebugLogging.Pierced + and data.wasPierce + and not data.sub + then print("Piercing function returned TRUE to pierce this part.") end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index af8b0a0b..e347bb64 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -520,6 +520,12 @@ function SerialSimulation:FireQueuedVisualizes( continue end + local settings = casts_VisualizeCastSettings[castID] + + if not settings then + return + end + for _, data in list do if data.atCF then @@ -528,35 +534,27 @@ function SerialSimulation:FireQueuedVisualizes( and not data.wasPierce and not data.sub then - print( - "Hit was successful. Terminating." - ) + print("Hit was successful. Terminating.") end if DebugLogging.Pierced and data.wasPierce and not data.sub then - print( - "Piercing function returned TRUE." - ) + print("Piercing function returned TRUE.") end VisualizeHit( data.atCF, data.wasPierce, - casts_VisualizeCastSettings[castID] + settings ) continue end - if DebugLogging.Segment - and data.sub - then - print( - "Subcast visualization" - ) + if DebugLogging.Segment and data.sub then + print("Subcast visualization") end local castType = casts_CastType[castID] @@ -569,38 +567,23 @@ function SerialSimulation:FireQueuedVisualizes( local variant: CastVisualizerVariants - if castType - == EnumCastTypes.Raycast - then - + if castType == EnumCastTypes.Raycast then variant = { - castLength = - casts_RayDisplacement[castID] - or 1, + castLength = casts_RayDisplacement[castID] or 1 } - - elseif castType - == EnumCastTypes.Blockcast - then - + elseif castType == EnumCastTypes.Blockcast then variant = { - size = - (casts_CastVariant[castID] - :: BlockcastVariant).Size, + size = (casts_CastVariant[castID] :: BlockcastVariant).Size } - else - variant = { - radius = - (casts_CastVariant[castID] - :: SpherecastVariant).Radius, + radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius } end visualizer( data.castStartCFrame, - casts_VisualizeCastSettings[castID], + settings, variant ) end From c735011538c22629419d75ae4e55717268cf51bc Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 05:15:28 +0000 Subject: [PATCH 013/140] Add shit back --- src/SerialSimulation.luau | 1012 ++++++++++++++++++++++++++++++------- 1 file changed, 837 insertions(+), 175 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index e347bb64..bd5182e5 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1,6 +1,16 @@ --[[ - - Author: Mawin_CK - - Date: 2026 + SerialSimulation.luau + FastCast2 + + Refactored preview. + + Main goals: + - Keep simulation flow readable. + - Isolate collision resolution. + - Isolate high-fidelity resimulation. + - Centralize Hit/Pierced/Terminating events. + - Avoid deeply nested if/else chains. + - Keep SerialSimulation API compatible. ]] --!native @@ -34,7 +44,7 @@ type SpherecastVariant = { type CastVariants = BlockcastVariant - | SpherecastVariant +| SpherecastVariant type RayVisualizerVariant = { castLength: number, @@ -50,8 +60,8 @@ type SphereVisualizerVariant = { type CastVisualizerVariants = RayVisualizerVariant - | BlockVisualizerVariant - | SphereVisualizerVariant +| BlockVisualizerVariant +| SphereVisualizerVariant type QueuedEventData = { eventType: string, @@ -115,6 +125,10 @@ local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_RayDisplacement = {} :: { [number]: number } +--------------------------------------------------------------------- +-- Runtime State +--------------------------------------------------------------------- + local queuedEvents: { [number]: { QueuedEventData }, } = {} @@ -382,120 +396,744 @@ function SerialSimulation:QueueEvent( ) end -function SerialSimulation:FireQueuedEvents( - unFiredEvents +function SerialSimulation:QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData ) - local sortedIDs = {} + if not casts_VisualizeCasts[castID] then + return + end - for id in unFiredEvents do - table.insert( - sortedIDs, + if not queuedVisualizes[castID] then + queuedVisualizes[castID] = {} + end + + table.insert( + queuedVisualizes[castID], + data + ) +end + +--------------------------------------------------------------------- +-- State Helpers +--------------------------------------------------------------------- + +function SerialSimulation:IsCastAlive(id: number): boolean + return self.ActivesRef[id] ~= nil +end + +function SerialSimulation:ShouldUseHighFidelity(id: number): boolean + + return casts_HighFidelityBehavior[id] + == FastCastEnums.HighFidelityBehavior.Automatic + and casts_HighFidelitySegmentSize[id] > 0 +end + +function SerialSimulation:ShouldPierce( + id: number, + result: RaycastResult, + velocity: Vector3, + canPierce: TypeDef.CanPierceFunction? +): boolean + + if not canPierce then + return false + end + + local cast = self.ActivesRef[id] + local cosmeticBullet = casts_RayInfo[id].CosmeticBulletObject + + return canPierce( + cast, + result, + velocity, + cosmeticBullet + ) == true +end + +--------------------------------------------------------------------- +-- Segment Calculation +--------------------------------------------------------------------- + +function SerialSimulation:CalculateSegment( + id: number, + delta: number +): SegmentData + + local trajectory = casts_Trajectory[id] + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration + + local lastTime = + casts_TotalRunTime[id] + - trajectory.StartTime + + local currentTime = + lastTime + delta + + local lastPoint = GetPositionAtTime( + lastTime, + origin, + initialVelocity, + acceleration + ) + + local currentPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) + + local velocity = GetVelocityAtTime( + currentTime, + initialVelocity, + acceleration + ) + + return { + lastPoint = lastPoint, + currentPoint = currentPoint, + + lastTime = lastTime, + currentTime = currentTime, + + velocity = velocity, + displacement = currentPoint - lastPoint, + } +end + +--------------------------------------------------------------------- +-- Movement +--------------------------------------------------------------------- + +function SerialSimulation:UpdateCastCFrame( + id: number, + lastPoint: Vector3, + displacement: Vector3, + distance: number +) + + local direction + + if displacement.Magnitude > 0 then + direction = displacement.Unit + else + direction = Vector3.zAxis + end + + casts_CFrame[id] = + CFrame.lookAt( + lastPoint, + lastPoint + direction + ) + * CFrame.new( + 0, + 0, + -distance / 2 + ) +end + +--------------------------------------------------------------------- +-- Event Helpers +--------------------------------------------------------------------- + +function SerialSimulation:QueueLengthChanged( + id: number, + segment: SegmentData, + distance: number +) + + local rayDirection + + if segment.displacement.Magnitude > 0 then + rayDirection = segment.displacement.Unit + else + rayDirection = Vector3.zero + end + + local cosmeticBullet = + casts_RayInfo[id].CosmeticBulletObject + + self:QueueEvent( + id, + "LengthChanged", + segment.lastPoint, + rayDirection, + distance, + segment.velocity, + cosmeticBullet + ) +end + +function SerialSimulation:QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + + self:QueueEvent( + id, + "Hit", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) + + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = false, + sub = sub, + } + ) +end + +function SerialSimulation:QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + + self:QueueEvent( + id, + "Pierced", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) + + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = true, + sub = sub, + } + ) +end + +function SerialSimulation:QueueTermination(id: number) + self:QueueEvent( + id, + "CastTerminating" + ) +end + +--------------------------------------------------------------------- +-- High Fidelity +--------------------------------------------------------------------- + +function SerialSimulation:BeginResimulation(id: number): boolean + + if casts_IsActivelyResimulating[id] then + + casts_CancelHighResCast[id] = true + + warn( + "[FastCast2] Cascading cast lag encountered. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return false + end + + casts_IsActivelyResimulating[id] = true + + return true +end + +function SerialSimulation:EndResimulation(id: number) + + casts_IsActivelyResimulating[id] = false + +end + +function SerialSimulation:CalculateHighFidelitySegments( + distance: number, + segmentSize: number +): number + + local count = math.floor( + distance / segmentSize + ) + + return math.max( + count, + 1 + ) +end + +function SerialSimulation:ResolveHighFidelityCollision( + id: number, + delta: number, + segment: SegmentData, + initialResult: RaycastResult, + canPierce: TypeDef.CanPierceFunction? +): boolean + + if not self:BeginResimulation(id) then + self:QueueTermination(id) + return true + end + + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + + local castHandler = + castHandlers[casts_CastVariant[id].CastType] + + local distance = + (initialResult.Position - segment.lastPoint).Magnitude + + local segmentCount = + self:CalculateHighFidelitySegments( + distance, + casts_HighFidelitySegmentSize[id] + ) + + local timeIncrement = + delta / segmentCount + + if DebugLogging.Calculation then + print( + "[FastCast2] High fidelity:", + segmentCount, + "segments," + , + timeIncrement, + "seconds each" + ) + end + + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration + + for index = 1, segmentCount do + + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break + end + + if not self:IsCastAlive(id) then + self:EndResimulation(id) + return true + end + + local startTime = + segment.lastTime + + timeIncrement * (index - 1) + + local endTime = + segment.lastTime + + timeIncrement * index + + local subStart = + GetPositionAtTime( + startTime, + origin, + initialVelocity, + acceleration + ) + + local subEnd = + GetPositionAtTime( + endTime, + origin, + initialVelocity, + acceleration + ) + + local subVelocity = + GetVelocityAtTime( + endTime, + initialVelocity, + acceleration + ) + + local subDirection = + subEnd - subStart + + if DebugLogging.Segment then + print( + "[FastCast2] Subcast", + index, + "/", + segmentCount + ) + end + + local subResult = + castHandler( + rayInfo.WorldRoot, + subStart, + subDirection, + rayInfo.Parameters, + casts_CastVariant[id] + ) + + self:QueueVisualize( + id, + { + castStartCFrame = + if subDirection.Magnitude > 0 + then CFrame.lookAt( + subStart, + subStart + subDirection + ) + else CFrame.new(subStart), + + sub = true, + } + ) + + if not subResult then + continue + end + + local isPierced = + self:ShouldPierce( + id, + subResult, + subVelocity, + canPierce + ) + + if isPierced then + + self:QueuePierce( + id, + subResult, + subVelocity, + true + ) + + continue + end + + self:QueueHit( + id, + subResult, + subVelocity, + true + ) + + self:QueueTermination(id) + + self:EndResimulation(id) + + return true + end + + self:EndResimulation(id) + + -- No valid sub-hit was found. + -- Fall back to the original collision. + + self:QueueHit( + id, + initialResult, + segment.velocity, + false + ) + + self:QueueTermination(id) + + return true +end + +--------------------------------------------------------------------- +-- Collision Resolution +--------------------------------------------------------------------- + +function SerialSimulation:ResolveCollision( + id: number, + result: RaycastResult, + segment: SegmentData, + delta: number, + canPierce: TypeDef.CanPierceFunction? +): boolean + + local isPierced = + self:ShouldPierce( + id, + result, + segment.velocity, + canPierce + ) + + ------------------------------------------------------------- + -- Pierced + ------------------------------------------------------------- + + if isPierced then + + self:QueuePierce( + id, + result, + segment.velocity, + false + ) + + return false + end + + ------------------------------------------------------------- + -- Non-piercing hit + ------------------------------------------------------------- + + if not self:ShouldUseHighFidelity(id) then + + self:QueueHit( + id, + result, + segment.velocity, + false + ) + + self:QueueTermination(id) + + return true + end + + ------------------------------------------------------------- + -- High fidelity + ------------------------------------------------------------- + + return self:ResolveHighFidelityCollision( + id, + delta, + segment, + result, + canPierce + ) +end + +--------------------------------------------------------------------- +-- Max Distance +--------------------------------------------------------------------- + +function SerialSimulation:CheckMaxDistance( + id: number, + result: RaycastResult?, + segment: SegmentData +): boolean + + if casts_DistanceCovered[id] < casts_MaxDistance[id] then + return false + end + + if result then + self:QueueHit( + id, + result, + segment.velocity, + false + ) + end + + self:QueueTermination(id) + + return true +end + +--------------------------------------------------------------------- +-- Visualization +--------------------------------------------------------------------- + +function SerialSimulation:QueueSegmentVisualization( + id: number, + segment: SegmentData +) + + if segment.displacement.Magnitude <= 0 then + return + end + + self:QueueVisualize( + id, + { + castStartCFrame = + CFrame.lookAt( + segment.lastPoint, + segment.lastPoint + segment.displacement + ), + + sub = false, + } + ) +end + +--------------------------------------------------------------------- +-- Main Simulation +--------------------------------------------------------------------- + +function SerialSimulation:SimulateCast( + id: number, + delta: number, + canPierce: TypeDef.CanPierceFunction? +) + + if DebugLogging.Casting then + print( + "[FastCast2] Simulating cast", id ) end - table.sort(sortedIDs) + ------------------------------------------------------------- + -- Calculate movement + ------------------------------------------------------------- + + local segment = + self:CalculateSegment( + id, + delta + ) + + ------------------------------------------------------------- + -- Advance simulation time + ------------------------------------------------------------- + + casts_TotalRunTime[id] += delta + + ------------------------------------------------------------- + -- Perform collision query + ------------------------------------------------------------- + + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] - for _, castID in sortedIDs do + local castHandler = + castHandlers[variant.CastType] - local events = - unFiredEvents[castID] + local result = + castHandler( + rayInfo.WorldRoot, + segment.lastPoint, + segment.displacement, + rayInfo.Parameters, + variant + ) - if not events then - continue - end + ------------------------------------------------------------- + -- Determine visible endpoint + ------------------------------------------------------------- - for _, event in events do + local endpoint = + result + and result.Position + or segment.currentPoint - local cast = - self.ActivesRef[castID] + local displacement = (endpoint - segment.lastPoint).Magnitude - if not cast then - continue - end + casts_RayDisplacement[id] = displacement - local config = casts_FastCastEventsConfig[castID] + casts_DistanceCovered[id] += displacement - local eventType = event.eventType + ------------------------------------------------------------- + -- Update cosmetic position + ------------------------------------------------------------- - if eventType == "LengthChanged" then + self:UpdateCastCFrame( + id, + segment.lastPoint, + segment.displacement, + displacement + ) - if config - and config.UseLengthChanged - and self.Events.LengthChanged - then + ------------------------------------------------------------- + -- LengthChanged + ------------------------------------------------------------- - self.Events.LengthChanged( - cast, - table.unpack(event.args) - ) - end + self:QueueLengthChanged( + id, + segment, + displacement + ) - elseif eventType == "Hit" then + ------------------------------------------------------------- + -- Debug visualization + ------------------------------------------------------------- - if config - and config.UseHit - and self.Events.Hit - then + if delta > 0 then + self:QueueSegmentVisualization( + id, + segment + ) + end - self.Events.Hit( - cast, - table.unpack(event.args) - ) - end + ------------------------------------------------------------- + -- No collision + ------------------------------------------------------------- - elseif eventType == "Pierced" then + if not result then + self:CheckMaxDistance( + id, + nil, + segment + ) - if config - and config.UsePierced - and self.Events.Pierced - then + return + end - self.Events.Pierced( - cast, - table.unpack(event.args) - ) - end + ------------------------------------------------------------- + -- Ignore cosmetic projectile + ------------------------------------------------------------- - elseif eventType == "CastTerminating" then + if result.Instance + == rayInfo.CosmeticBulletObject + then - self.BaseCastRef:_TerminateCast( - castID, - self.Events.CastTerminating - ) + self:CheckMaxDistance( + id, + nil, + segment + ) - self:Unregister( - castID - ) - end - end + return end -end --- ============================================================================ --- Queued visualization --- ============================================================================ + ------------------------------------------------------------- + -- Resolve collision + ------------------------------------------------------------- -function SerialSimulation:QueueVisualize( - castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData -) + local terminated = + self:ResolveCollision( + id, + result, + segment, + delta, + canPierce + ) - if not casts_VisualizeCasts[castID] then + if terminated then return end - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} - end + ------------------------------------------------------------- + -- Max distance after piercing + ------------------------------------------------------------- - table.insert( - queuedVisualizes[castID], - data + self:CheckMaxDistance( + id, + nil, + segment ) end +--------------------------------------------------------------------- +-- Event Dispatch +--------------------------------------------------------------------- + function SerialSimulation:FireQueuedVisualizes( unFiredVisualizes ) @@ -520,12 +1158,6 @@ function SerialSimulation:FireQueuedVisualizes( continue end - local settings = casts_VisualizeCastSettings[castID] - - if not settings then - return - end - for _, data in list do if data.atCF then @@ -534,7 +1166,9 @@ function SerialSimulation:FireQueuedVisualizes( and not data.wasPierce and not data.sub then - print("Hit was successful. Terminating.") + print( + "Hit was successful. Terminating." + ) end if DebugLogging.Pierced @@ -547,14 +1181,18 @@ function SerialSimulation:FireQueuedVisualizes( VisualizeHit( data.atCF, data.wasPierce, - settings + casts_VisualizeCastSettings[castID] ) continue end - if DebugLogging.Segment and data.sub then - print("Subcast visualization") + if DebugLogging.Segment + and data.sub + then + print( + "Subcast visualization" + ) end local castType = casts_CastType[castID] @@ -569,114 +1207,132 @@ function SerialSimulation:FireQueuedVisualizes( if castType == EnumCastTypes.Raycast then variant = { - castLength = casts_RayDisplacement[castID] or 1 + castLength = + casts_RayDisplacement[castID] + or 1, } + elseif castType == EnumCastTypes.Blockcast then variant = { - size = (casts_CastVariant[castID] :: BlockcastVariant).Size + size = + (casts_CastVariant[castID] + :: BlockcastVariant).Size, } + else + variant = { - radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius + radius = + (casts_CastVariant[castID] + :: SpherecastVariant).Radius, } end visualizer( data.castStartCFrame, - settings, + casts_VisualizeCastSettings[castID], variant ) end end end ---------------------------------------------------------------------- --- Event Helpers ---------------------------------------------------------------------- - -function SerialSimulation:QueueLengthChanged( - id: number, - segment: SegmentData, - distance: number +function SerialSimulation:FireQueuedEvents( + unFiredEvents ) - local rayDirection + local sortedIDs = {} - if segment.displacement.Magnitude > 0 then - rayDirection = segment.displacement.Unit - else - rayDirection = Vector3.zero + for id in unFiredEvents do + table.insert( + sortedIDs, + id + ) end - local cosmeticBullet = - casts_RayInfo[id].CosmeticBulletObject + table.sort(sortedIDs) - self:QueueEvent( - id, - "LengthChanged", - segment.lastPoint, - rayDirection, - distance, - segment.velocity, - cosmeticBullet - ) -end + for _, castID in sortedIDs do -function SerialSimulation:QueueHit( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean -) + local events = + unFiredEvents[castID] - self:QueueEvent( - id, - "Hit", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) + if not events then + continue + end - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = false, - sub = sub, - } - ) -end + for _, event in events do -function SerialSimulation:QueuePierce( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean -) + local cast = + self.ActivesRef[castID] - self:QueueEvent( - id, - "Pierced", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) + if not cast then + continue + end - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = true, - sub = sub, - } - ) -end + local config = + casts_FastCastEventsConfig[castID] -function SerialSimulation:QueueTermination(id: number) - self:QueueEvent( - id, - "CastTerminating" - ) + if event.eventType + == "LengthChanged" + then + + if config + and config.UseLengthChanged + and self.Events.LengthChanged + then + + self.Events.LengthChanged( + cast, + table.unpack(event.args) + ) + end + + elseif event.eventType + == "Hit" + then + + if config + and config.UseHit + and self.Events.Hit + then + + self.Events.Hit( + cast, + table.unpack(event.args) + ) + end + + elseif event.eventType + == "Pierced" + then + + if config + and config.UsePierced + and self.Events.Pierced + then + + self.Events.Pierced( + cast, + table.unpack(event.args) + ) + end + + elseif event.eventType + == "CastTerminating" + then + + self.BaseCastRef:_TerminateCast( + castID, + self.Events.CastTerminating + ) + + self:Unregister( + castID + ) + end + end + end end --------------------------------------------------------------------- @@ -713,13 +1369,17 @@ function SerialSimulation:Register( casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = cast.RayInfo + casts_RayInfo[id] = + cast.RayInfo - casts_UserData[id] = cast.UserData + casts_UserData[id] = + cast.UserData - casts_CastType[id] = cast.CastVariant.CastType + casts_CastType[id] = + cast.CastVariant.CastType - casts_CastVariant[id] = cast.CastVariant + casts_CastVariant[id] = + cast.CastVariant casts_MaxDistance[id] = cast.RayInfo.MaxDistance @@ -1007,9 +1667,11 @@ function SerialSimulation.new() self.Connection = nil - self.CurrentMovementMode = "BulkMoveTo" + self.CurrentMovementMode = + "BulkMoveTo" - self.MovementEnabled = true + self.MovementEnabled = + true self.Events = {} From 65625ce4d796047127bc231fafd30dc730b9544f Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 05:21:51 +0000 Subject: [PATCH 014/140] edit --- src/SerialSimulation.luau | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index bd5182e5..b4bf9d6e 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -686,11 +686,9 @@ function SerialSimulation:ResolveHighFidelityCollision( local trajectory = casts_Trajectory[id] local rayInfo = casts_RayInfo[id] - local castHandler = - castHandlers[casts_CastVariant[id].CastType] + local castHandler = castHandlers[casts_CastVariant[id].CastType] - local distance = - (initialResult.Position - segment.lastPoint).Magnitude + local distance = (initialResult.Position - segment.lastPoint).Magnitude local segmentCount = self:CalculateHighFidelitySegments( @@ -1012,8 +1010,7 @@ function SerialSimulation:SimulateCast( local rayInfo = casts_RayInfo[id] local variant = casts_CastVariant[id] - local castHandler = - castHandlers[variant.CastType] + local castHandler = castHandlers[variant.CastType] local result = castHandler( From 66e5fdfbe8836a784fc7dd650e719664307fd50b Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 05:48:38 +0000 Subject: [PATCH 015/140] Fix shit --- src/SerialSimulation.luau | 1013 +++++++------------------------------ 1 file changed, 177 insertions(+), 836 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index b4bf9d6e..e347bb64 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1,16 +1,6 @@ --[[ - SerialSimulation.luau - FastCast2 - - Refactored preview. - - Main goals: - - Keep simulation flow readable. - - Isolate collision resolution. - - Isolate high-fidelity resimulation. - - Centralize Hit/Pierced/Terminating events. - - Avoid deeply nested if/else chains. - - Keep SerialSimulation API compatible. + - Author: Mawin_CK + - Date: 2026 ]] --!native @@ -44,7 +34,7 @@ type SpherecastVariant = { type CastVariants = BlockcastVariant -| SpherecastVariant + | SpherecastVariant type RayVisualizerVariant = { castLength: number, @@ -60,8 +50,8 @@ type SphereVisualizerVariant = { type CastVisualizerVariants = RayVisualizerVariant -| BlockVisualizerVariant -| SphereVisualizerVariant + | BlockVisualizerVariant + | SphereVisualizerVariant type QueuedEventData = { eventType: string, @@ -125,10 +115,6 @@ local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_RayDisplacement = {} :: { [number]: number } ---------------------------------------------------------------------- --- Runtime State ---------------------------------------------------------------------- - local queuedEvents: { [number]: { QueuedEventData }, } = {} @@ -396,741 +382,120 @@ function SerialSimulation:QueueEvent( ) end -function SerialSimulation:QueueVisualize( - castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData -) - - if not casts_VisualizeCasts[castID] then - return - end - - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} - end - - table.insert( - queuedVisualizes[castID], - data - ) -end - ---------------------------------------------------------------------- --- State Helpers ---------------------------------------------------------------------- - -function SerialSimulation:IsCastAlive(id: number): boolean - return self.ActivesRef[id] ~= nil -end - -function SerialSimulation:ShouldUseHighFidelity(id: number): boolean - - return casts_HighFidelityBehavior[id] - == FastCastEnums.HighFidelityBehavior.Automatic - and casts_HighFidelitySegmentSize[id] > 0 -end - -function SerialSimulation:ShouldPierce( - id: number, - result: RaycastResult, - velocity: Vector3, - canPierce: TypeDef.CanPierceFunction? -): boolean - - if not canPierce then - return false - end - - local cast = self.ActivesRef[id] - local cosmeticBullet = casts_RayInfo[id].CosmeticBulletObject - - return canPierce( - cast, - result, - velocity, - cosmeticBullet - ) == true -end - ---------------------------------------------------------------------- --- Segment Calculation ---------------------------------------------------------------------- - -function SerialSimulation:CalculateSegment( - id: number, - delta: number -): SegmentData - - local trajectory = casts_Trajectory[id] - - local origin = trajectory.Origin - local initialVelocity = trajectory.InitialVelocity - local acceleration = trajectory.Acceleration - - local lastTime = - casts_TotalRunTime[id] - - trajectory.StartTime - - local currentTime = - lastTime + delta - - local lastPoint = GetPositionAtTime( - lastTime, - origin, - initialVelocity, - acceleration - ) - - local currentPoint = GetPositionAtTime( - currentTime, - origin, - initialVelocity, - acceleration - ) - - local velocity = GetVelocityAtTime( - currentTime, - initialVelocity, - acceleration - ) - - return { - lastPoint = lastPoint, - currentPoint = currentPoint, - - lastTime = lastTime, - currentTime = currentTime, - - velocity = velocity, - displacement = currentPoint - lastPoint, - } -end - ---------------------------------------------------------------------- --- Movement ---------------------------------------------------------------------- - -function SerialSimulation:UpdateCastCFrame( - id: number, - lastPoint: Vector3, - displacement: Vector3, - distance: number -) - - local direction - - if displacement.Magnitude > 0 then - direction = displacement.Unit - else - direction = Vector3.zAxis - end - - casts_CFrame[id] = - CFrame.lookAt( - lastPoint, - lastPoint + direction - ) - * CFrame.new( - 0, - 0, - -distance / 2 - ) -end - ---------------------------------------------------------------------- --- Event Helpers ---------------------------------------------------------------------- - -function SerialSimulation:QueueLengthChanged( - id: number, - segment: SegmentData, - distance: number -) - - local rayDirection - - if segment.displacement.Magnitude > 0 then - rayDirection = segment.displacement.Unit - else - rayDirection = Vector3.zero - end - - local cosmeticBullet = - casts_RayInfo[id].CosmeticBulletObject - - self:QueueEvent( - id, - "LengthChanged", - segment.lastPoint, - rayDirection, - distance, - segment.velocity, - cosmeticBullet - ) -end - -function SerialSimulation:QueueHit( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean -) - - self:QueueEvent( - id, - "Hit", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) - - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = false, - sub = sub, - } - ) -end - -function SerialSimulation:QueuePierce( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean -) - - self:QueueEvent( - id, - "Pierced", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) - - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = true, - sub = sub, - } - ) -end - -function SerialSimulation:QueueTermination(id: number) - self:QueueEvent( - id, - "CastTerminating" - ) -end - ---------------------------------------------------------------------- --- High Fidelity ---------------------------------------------------------------------- - -function SerialSimulation:BeginResimulation(id: number): boolean - - if casts_IsActivelyResimulating[id] then - - casts_CancelHighResCast[id] = true - - warn( - "[FastCast2] Cascading cast lag encountered. " - .. "Consider increasing HighFidelitySegmentSize." - ) - - return false - end - - casts_IsActivelyResimulating[id] = true - - return true -end - -function SerialSimulation:EndResimulation(id: number) - - casts_IsActivelyResimulating[id] = false - -end - -function SerialSimulation:CalculateHighFidelitySegments( - distance: number, - segmentSize: number -): number - - local count = math.floor( - distance / segmentSize - ) - - return math.max( - count, - 1 - ) -end - -function SerialSimulation:ResolveHighFidelityCollision( - id: number, - delta: number, - segment: SegmentData, - initialResult: RaycastResult, - canPierce: TypeDef.CanPierceFunction? -): boolean - - if not self:BeginResimulation(id) then - self:QueueTermination(id) - return true - end - - local trajectory = casts_Trajectory[id] - local rayInfo = casts_RayInfo[id] - - local castHandler = castHandlers[casts_CastVariant[id].CastType] - - local distance = (initialResult.Position - segment.lastPoint).Magnitude - - local segmentCount = - self:CalculateHighFidelitySegments( - distance, - casts_HighFidelitySegmentSize[id] - ) - - local timeIncrement = - delta / segmentCount - - if DebugLogging.Calculation then - print( - "[FastCast2] High fidelity:", - segmentCount, - "segments," - , - timeIncrement, - "seconds each" - ) - end - - local origin = trajectory.Origin - local initialVelocity = trajectory.InitialVelocity - local acceleration = trajectory.Acceleration - - for index = 1, segmentCount do - - if casts_CancelHighResCast[id] then - casts_CancelHighResCast[id] = false - break - end - - if not self:IsCastAlive(id) then - self:EndResimulation(id) - return true - end - - local startTime = - segment.lastTime - + timeIncrement * (index - 1) - - local endTime = - segment.lastTime - + timeIncrement * index - - local subStart = - GetPositionAtTime( - startTime, - origin, - initialVelocity, - acceleration - ) - - local subEnd = - GetPositionAtTime( - endTime, - origin, - initialVelocity, - acceleration - ) - - local subVelocity = - GetVelocityAtTime( - endTime, - initialVelocity, - acceleration - ) - - local subDirection = - subEnd - subStart - - if DebugLogging.Segment then - print( - "[FastCast2] Subcast", - index, - "/", - segmentCount - ) - end - - local subResult = - castHandler( - rayInfo.WorldRoot, - subStart, - subDirection, - rayInfo.Parameters, - casts_CastVariant[id] - ) - - self:QueueVisualize( - id, - { - castStartCFrame = - if subDirection.Magnitude > 0 - then CFrame.lookAt( - subStart, - subStart + subDirection - ) - else CFrame.new(subStart), - - sub = true, - } - ) - - if not subResult then - continue - end - - local isPierced = - self:ShouldPierce( - id, - subResult, - subVelocity, - canPierce - ) - - if isPierced then - - self:QueuePierce( - id, - subResult, - subVelocity, - true - ) - - continue - end - - self:QueueHit( - id, - subResult, - subVelocity, - true - ) - - self:QueueTermination(id) - - self:EndResimulation(id) - - return true - end - - self:EndResimulation(id) - - -- No valid sub-hit was found. - -- Fall back to the original collision. - - self:QueueHit( - id, - initialResult, - segment.velocity, - false - ) - - self:QueueTermination(id) - - return true -end - ---------------------------------------------------------------------- --- Collision Resolution ---------------------------------------------------------------------- - -function SerialSimulation:ResolveCollision( - id: number, - result: RaycastResult, - segment: SegmentData, - delta: number, - canPierce: TypeDef.CanPierceFunction? -): boolean - - local isPierced = - self:ShouldPierce( - id, - result, - segment.velocity, - canPierce - ) - - ------------------------------------------------------------- - -- Pierced - ------------------------------------------------------------- - - if isPierced then - - self:QueuePierce( - id, - result, - segment.velocity, - false - ) - - return false - end - - ------------------------------------------------------------- - -- Non-piercing hit - ------------------------------------------------------------- - - if not self:ShouldUseHighFidelity(id) then - - self:QueueHit( - id, - result, - segment.velocity, - false - ) - - self:QueueTermination(id) - - return true - end - - ------------------------------------------------------------- - -- High fidelity - ------------------------------------------------------------- - - return self:ResolveHighFidelityCollision( - id, - delta, - segment, - result, - canPierce - ) -end - ---------------------------------------------------------------------- --- Max Distance ---------------------------------------------------------------------- - -function SerialSimulation:CheckMaxDistance( - id: number, - result: RaycastResult?, - segment: SegmentData -): boolean - - if casts_DistanceCovered[id] < casts_MaxDistance[id] then - return false - end - - if result then - self:QueueHit( - id, - result, - segment.velocity, - false - ) - end - - self:QueueTermination(id) - - return true -end - ---------------------------------------------------------------------- --- Visualization ---------------------------------------------------------------------- - -function SerialSimulation:QueueSegmentVisualization( - id: number, - segment: SegmentData +function SerialSimulation:FireQueuedEvents( + unFiredEvents ) - if segment.displacement.Magnitude <= 0 then - return - end - - self:QueueVisualize( - id, - { - castStartCFrame = - CFrame.lookAt( - segment.lastPoint, - segment.lastPoint + segment.displacement - ), - - sub = false, - } - ) -end - ---------------------------------------------------------------------- --- Main Simulation ---------------------------------------------------------------------- - -function SerialSimulation:SimulateCast( - id: number, - delta: number, - canPierce: TypeDef.CanPierceFunction? -) + local sortedIDs = {} - if DebugLogging.Casting then - print( - "[FastCast2] Simulating cast", + for id in unFiredEvents do + table.insert( + sortedIDs, id - ) - end - - ------------------------------------------------------------- - -- Calculate movement - ------------------------------------------------------------- - - local segment = - self:CalculateSegment( - id, - delta - ) - - ------------------------------------------------------------- - -- Advance simulation time - ------------------------------------------------------------- - - casts_TotalRunTime[id] += delta - - ------------------------------------------------------------- - -- Perform collision query - ------------------------------------------------------------- + ) + end - local rayInfo = casts_RayInfo[id] - local variant = casts_CastVariant[id] + table.sort(sortedIDs) - local castHandler = castHandlers[variant.CastType] + for _, castID in sortedIDs do - local result = - castHandler( - rayInfo.WorldRoot, - segment.lastPoint, - segment.displacement, - rayInfo.Parameters, - variant - ) + local events = + unFiredEvents[castID] - ------------------------------------------------------------- - -- Determine visible endpoint - ------------------------------------------------------------- + if not events then + continue + end - local endpoint = - result - and result.Position - or segment.currentPoint + for _, event in events do - local displacement = (endpoint - segment.lastPoint).Magnitude + local cast = + self.ActivesRef[castID] - casts_RayDisplacement[id] = displacement + if not cast then + continue + end - casts_DistanceCovered[id] += displacement + local config = casts_FastCastEventsConfig[castID] - ------------------------------------------------------------- - -- Update cosmetic position - ------------------------------------------------------------- + local eventType = event.eventType - self:UpdateCastCFrame( - id, - segment.lastPoint, - segment.displacement, - displacement - ) + if eventType == "LengthChanged" then - ------------------------------------------------------------- - -- LengthChanged - ------------------------------------------------------------- + if config + and config.UseLengthChanged + and self.Events.LengthChanged + then - self:QueueLengthChanged( - id, - segment, - displacement - ) + self.Events.LengthChanged( + cast, + table.unpack(event.args) + ) + end - ------------------------------------------------------------- - -- Debug visualization - ------------------------------------------------------------- + elseif eventType == "Hit" then - if delta > 0 then - self:QueueSegmentVisualization( - id, - segment - ) - end + if config + and config.UseHit + and self.Events.Hit + then - ------------------------------------------------------------- - -- No collision - ------------------------------------------------------------- + self.Events.Hit( + cast, + table.unpack(event.args) + ) + end - if not result then - self:CheckMaxDistance( - id, - nil, - segment - ) + elseif eventType == "Pierced" then - return - end + if config + and config.UsePierced + and self.Events.Pierced + then - ------------------------------------------------------------- - -- Ignore cosmetic projectile - ------------------------------------------------------------- + self.Events.Pierced( + cast, + table.unpack(event.args) + ) + end - if result.Instance - == rayInfo.CosmeticBulletObject - then + elseif eventType == "CastTerminating" then - self:CheckMaxDistance( - id, - nil, - segment - ) + self.BaseCastRef:_TerminateCast( + castID, + self.Events.CastTerminating + ) - return + self:Unregister( + castID + ) + end + end end +end - ------------------------------------------------------------- - -- Resolve collision - ------------------------------------------------------------- +-- ============================================================================ +-- Queued visualization +-- ============================================================================ - local terminated = - self:ResolveCollision( - id, - result, - segment, - delta, - canPierce - ) +function SerialSimulation:QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData +) - if terminated then + if not casts_VisualizeCasts[castID] then return end - ------------------------------------------------------------- - -- Max distance after piercing - ------------------------------------------------------------- + if not queuedVisualizes[castID] then + queuedVisualizes[castID] = {} + end - self:CheckMaxDistance( - id, - nil, - segment + table.insert( + queuedVisualizes[castID], + data ) end ---------------------------------------------------------------------- --- Event Dispatch ---------------------------------------------------------------------- - function SerialSimulation:FireQueuedVisualizes( unFiredVisualizes ) @@ -1155,6 +520,12 @@ function SerialSimulation:FireQueuedVisualizes( continue end + local settings = casts_VisualizeCastSettings[castID] + + if not settings then + return + end + for _, data in list do if data.atCF then @@ -1163,9 +534,7 @@ function SerialSimulation:FireQueuedVisualizes( and not data.wasPierce and not data.sub then - print( - "Hit was successful. Terminating." - ) + print("Hit was successful. Terminating.") end if DebugLogging.Pierced @@ -1178,18 +547,14 @@ function SerialSimulation:FireQueuedVisualizes( VisualizeHit( data.atCF, data.wasPierce, - casts_VisualizeCastSettings[castID] + settings ) continue end - if DebugLogging.Segment - and data.sub - then - print( - "Subcast visualization" - ) + if DebugLogging.Segment and data.sub then + print("Subcast visualization") end local castType = casts_CastType[castID] @@ -1204,132 +569,114 @@ function SerialSimulation:FireQueuedVisualizes( if castType == EnumCastTypes.Raycast then variant = { - castLength = - casts_RayDisplacement[castID] - or 1, + castLength = casts_RayDisplacement[castID] or 1 } - elseif castType == EnumCastTypes.Blockcast then variant = { - size = - (casts_CastVariant[castID] - :: BlockcastVariant).Size, + size = (casts_CastVariant[castID] :: BlockcastVariant).Size } - else - variant = { - radius = - (casts_CastVariant[castID] - :: SpherecastVariant).Radius, + radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius } end visualizer( data.castStartCFrame, - casts_VisualizeCastSettings[castID], + settings, variant ) end end end -function SerialSimulation:FireQueuedEvents( - unFiredEvents +--------------------------------------------------------------------- +-- Event Helpers +--------------------------------------------------------------------- + +function SerialSimulation:QueueLengthChanged( + id: number, + segment: SegmentData, + distance: number ) - local sortedIDs = {} + local rayDirection - for id in unFiredEvents do - table.insert( - sortedIDs, - id - ) + if segment.displacement.Magnitude > 0 then + rayDirection = segment.displacement.Unit + else + rayDirection = Vector3.zero end - table.sort(sortedIDs) - - for _, castID in sortedIDs do - - local events = - unFiredEvents[castID] - - if not events then - continue - end - - for _, event in events do - - local cast = - self.ActivesRef[castID] - - if not cast then - continue - end - - local config = - casts_FastCastEventsConfig[castID] - - if event.eventType - == "LengthChanged" - then - - if config - and config.UseLengthChanged - and self.Events.LengthChanged - then - - self.Events.LengthChanged( - cast, - table.unpack(event.args) - ) - end - - elseif event.eventType - == "Hit" - then + local cosmeticBullet = + casts_RayInfo[id].CosmeticBulletObject - if config - and config.UseHit - and self.Events.Hit - then + self:QueueEvent( + id, + "LengthChanged", + segment.lastPoint, + rayDirection, + distance, + segment.velocity, + cosmeticBullet + ) +end - self.Events.Hit( - cast, - table.unpack(event.args) - ) - end +function SerialSimulation:QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) - elseif event.eventType - == "Pierced" - then + self:QueueEvent( + id, + "Hit", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - if config - and config.UsePierced - and self.Events.Pierced - then + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = false, + sub = sub, + } + ) +end - self.Events.Pierced( - cast, - table.unpack(event.args) - ) - end +function SerialSimulation:QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) - elseif event.eventType - == "CastTerminating" - then + self:QueueEvent( + id, + "Pierced", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - self.BaseCastRef:_TerminateCast( - castID, - self.Events.CastTerminating - ) + self:QueueVisualize( + id, + { + atCF = CFrame.new(result.Position), + wasPierce = true, + sub = sub, + } + ) +end - self:Unregister( - castID - ) - end - end - end +function SerialSimulation:QueueTermination(id: number) + self:QueueEvent( + id, + "CastTerminating" + ) end --------------------------------------------------------------------- @@ -1366,17 +713,13 @@ function SerialSimulation:Register( casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - casts_RayInfo[id] = - cast.RayInfo + casts_RayInfo[id] = cast.RayInfo - casts_UserData[id] = - cast.UserData + casts_UserData[id] = cast.UserData - casts_CastType[id] = - cast.CastVariant.CastType + casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = - cast.CastVariant + casts_CastVariant[id] = cast.CastVariant casts_MaxDistance[id] = cast.RayInfo.MaxDistance @@ -1664,11 +1007,9 @@ function SerialSimulation.new() self.Connection = nil - self.CurrentMovementMode = - "BulkMoveTo" + self.CurrentMovementMode = "BulkMoveTo" - self.MovementEnabled = - true + self.MovementEnabled = true self.Events = {} From 46ea88b84f27f04d221ff6c45190abd5e5839a22 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 05:51:41 +0000 Subject: [PATCH 016/140] edit --- src/SerialSimulation.luau | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index e347bb64..c218f3c5 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -900,11 +900,9 @@ function SerialSimulation:SetMovementModeEnabled( mode: "BulkMoveTo" | "Motor6D" ) - self.CurrentMovementMode = - mode + self.CurrentMovementMode = mode - self.MovementEnabled = - enabled + self.MovementEnabled = enabled end --------------------------------------------------------------------- @@ -930,11 +928,19 @@ function SerialSimulation:UpdateCasts( casts_HighFidelitySegmentSize[id] = 0.1 end - self:SimulateCast( - id, - delta, - self.Events.CanPierce - ) + local behavior = casts_HighFidelityBehavior[id] + + if behavior == FastCastEnums.HighFidelityBehavior.Always then + self:SimulateAlwaysHighFidelity( + id, + delta + ) + else + self:SimulateCast( + id, + delta + ) + end end ------------------------------------------------------------- From 14a18d74e7acaae5fbae17ec3d3a8b32f0792282 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 05:52:10 +0000 Subject: [PATCH 017/140] edit --- src/ParallelSimulation.luau | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 01352206..3b2cfb59 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1438,11 +1438,9 @@ local function UpdateCasts(delta: number) casts_HighFidelitySegmentSize[id] = 0.1 end - local fastCastEvents = - casts_FastCastEvents[id] + local fastCastEvents = casts_FastCastEvents[id] - local behavior = - casts_HighFidelityBehavior[id] + local behavior = casts_HighFidelityBehavior[id] if behavior == FastCastEnums.HighFidelityBehavior.Always From 498fe9924d391b36ae709c45c831805358bfd719 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 05:52:24 +0000 Subject: [PATCH 018/140] edit 2 --- src/ParallelSimulation.luau | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 3b2cfb59..c0a2fffc 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1442,9 +1442,7 @@ local function UpdateCasts(delta: number) local behavior = casts_HighFidelityBehavior[id] - if behavior - == FastCastEnums.HighFidelityBehavior.Always - then + if behavior == FastCastEnums.HighFidelityBehavior.Always then SimulateAlwaysHighFidelity( id, delta, From 60e7732582e443ec0e6f2eec503bd1219a7f6de5 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 06:17:40 +0000 Subject: [PATCH 019/140] fix stuffs --- src/BaseCastSerial.luau | 8 +- src/ParallelSimulation.luau | 30 +- src/SerialSimulation.luau | 1633 +++++++++++++++++++++-------------- 3 files changed, 1019 insertions(+), 652 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 431556d7..2614cfd1 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -43,8 +43,7 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT end end - -function BaseCast.Init(events: TypeDef.FastCastEvents, Data: any) +function BaseCast.Init(events: any, Data: any) local self = setmetatable({}, BaseCast) self.Actives = {} self.CastFirefn = events.CastFire @@ -70,12 +69,11 @@ function BaseCast.Init(events: TypeDef.FastCastEvents, Data: any) self.Motor6DCacheInstance = Motor6DCache.new() end - self.SerialSimulation = SerialSimulation.new() - self.SerialSimulation:Init(self, events) + SerialSimulation.Init(self, events) self.SerialSimulation:SetMovementModeEnabled(true, self.CurrentMovementMode) - self.SerialSimulation:Start() + SerialSimulation.Start() return self end diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index c0a2fffc..5f3e7182 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1224,44 +1224,34 @@ end function ParallelSimulation_Register(cast: any) local id = cast.ID - casts_TotalRunTime[id] = - cast.StateInfo.TotalRuntime or 0 + casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 casts_DistanceCovered[id] = 0 - casts_HighFidelitySegmentSize[id] = - cast.StateInfo.HighFidelitySegmentSize or 0.1 + casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - casts_HighFidelityBehavior[id] = - cast.StateInfo.HighFidelityBehavior or 0 + casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 casts_IsActivelySimulatingPierce[id] = false casts_IsActivelyResimulating[id] = false casts_CancelHighResCast[id] = false - casts_Trajectory[id] = - cast.StateInfo.Trajectory + casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsModuleConfig[id] = - cast.StateInfo.FastCastEventsModuleConfig + casts_FastCastEventsModuleConfig[id] = cast.StateInfo.FastCastEventsModuleConfig - casts_FastCastEventsConfig[id] = - cast.StateInfo.FastCastEventsConfig + casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - casts_VisualizeCasts[id] = - cast.StateInfo.VisualizeCasts + casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - casts_VisualizeCastSettings[id] = - cast.StateInfo.VisualizeCastSettings + casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings casts_RayInfo[id] = cast.RayInfo casts_UserData[id] = cast.UserData - casts_CastType[id] = - cast.CastVariant.CastType + casts_CastType[id] = cast.CastVariant.CastType - casts_CastVariant[id] = - cast.CastVariant + casts_CastVariant[id] = cast.CastVariant casts_MaxDistance[id] = cast.RayInfo.MaxDistance diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index c218f3c5..5e2ffaf8 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1,16 +1,18 @@ --[[ - - Author: Mawin_CK + - Author: Mawin CK - Date: 2026 ]] --!native --!optimize 2 -local RunService = game:GetService("RunService") +local RS = game:GetService("RunService") -local TypeDef = require(script.Parent:WaitForChild("TypeDefinitions")) -local FastCastEnums = require(script.Parent:WaitForChild("FastCastEnums")) -local Config = require(script.Parent:WaitForChild("Config")) +local FastCastModule = script.Parent + +local TypeDef = require(FastCastModule:WaitForChild("TypeDefinitions")) +local FastCastEnums = require(FastCastModule:WaitForChild("FastCastEnums")) +local Config = require(FastCastModule:WaitForChild("Config")) local DebugLogging = Config.DebugLogging local EnumCastTypes = FastCastEnums.CastType @@ -18,34 +20,32 @@ local EnumCastTypes = FastCastEnums.CastType local DEFAULT_MAX_DISTANCE = 1000 local FC_VIS_OBJ_NAME = "FastCastVisualizationObjects" ---------------------------------------------------------------------- +-- ============================================================================ -- Types ---------------------------------------------------------------------- +-- ============================================================================ type BlockcastVariant = { CastType: number, - Size: Vector3, + Size: Vector3 } type SpherecastVariant = { CastType: number, - Radius: number, + Radius: number } -type CastVariants = - BlockcastVariant - | SpherecastVariant +type CastVariants = BlockcastVariant | SpherecastVariant type RayVisualizerVariant = { - castLength: number, + castLength: number } type BlockVisualizerVariant = { - size: Vector3, + size: Vector3 } type SphereVisualizerVariant = { - radius: number, + radius: number } type CastVisualizerVariants = @@ -55,34 +55,23 @@ type CastVisualizerVariants = type QueuedEventData = { eventType: string, - args: { any }, + args: { any } } type QueuedVisualizeCastData = { castStartCFrame: CFrame, - sub: boolean, + sub: boolean } type QueuedVisualizeHitData = { atCF: CFrame, wasPierce: boolean, - sub: boolean, -} - -type SegmentData = { - lastPoint: Vector3, - currentPoint: Vector3, - - lastTime: number, - currentTime: number, - - velocity: Vector3, - displacement: Vector3, + sub: boolean } ---------------------------------------------------------------------- --- SoA State ---------------------------------------------------------------------- +-- ============================================================================ +-- State +-- ============================================================================ local casts_TotalRunTime = {} :: { [number]: number } local casts_DistanceCovered = {} :: { [number]: number } @@ -95,12 +84,16 @@ local casts_IsActivelyResimulating = {} :: { [number]: boolean } local casts_CancelHighResCast = {} :: { [number]: boolean } local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } -local casts_FastCastEventsConfig = {} :: { [number]: TypeDef.FastCastEventsConfig } + +local casts_FastCastEventsModuleConfig = + {} :: { [number]: TypeDef.FastCastEventsModuleConfig } + +local casts_FastCastEventsConfig = + {} :: { [number]: TypeDef.FastCastEventsConfig } local casts_VisualizeCasts = {} :: { [number]: boolean } -local casts_VisualizeCastSettings = {} :: { - [number]: TypeDef.VisualizeCastSettings -} +local casts_VisualizeCastSettings = + {} :: { [number]: TypeDef.VisualizeCastSettings } local casts_RayInfo = {} :: { [number]: TypeDef.CastRayInfo } local casts_UserData = {} :: { [number]: any } @@ -111,37 +104,61 @@ local casts_CastType = {} :: { [number]: number } local casts_CastVariant = {} :: { [number]: CastVariants } local casts_MaxDistance = {} :: { [number]: number } +local casts_RayDisplacement = {} :: { [number]: number } + local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } -local casts_RayDisplacement = {} :: { [number]: number } +local casts_ID = {} :: { number } +local casts_ID_Index = {} :: { [number]: number } -local queuedEvents: { - [number]: { QueuedEventData }, -} = {} +local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } +local queuedEvents: { [number]: { QueuedEventData } } = {} local queuedVisualizes: { - [number]: { - QueuedVisualizeCastData | QueuedVisualizeHitData - }, + [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } } = {} ---------------------------------------------------------------------- --- Cast Handlers ---------------------------------------------------------------------- +local ActivesRef: any = nil +local BaseCastRef: any = nil -local castHandlers = { +local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" +local MovementEnabled = false + +-- ============================================================================ +-- Math +-- ============================================================================ + +local function GetPositionAtTime( + t: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return origin + + initialVelocity * t + + acceleration * (t ^ 2 / 2) +end + +local function GetVelocityAtTime( + t: number, + initialVelocity: Vector3, + acceleration: Vector3 +): Vector3 + return initialVelocity + acceleration * t +end +-- ============================================================================ +-- Cast handlers +-- ============================================================================ + +local castHandlers = { [EnumCastTypes.Raycast] = function( worldRoot: WorldRoot, origin: Vector3, direction: Vector3, params: RaycastParams ) - return worldRoot:Raycast( - origin, - direction, - params - ) + return worldRoot:Raycast(origin, direction, params) end, [EnumCastTypes.Blockcast] = function( @@ -172,59 +189,28 @@ local castHandlers = { direction, params ) - end, + end } ---------------------------------------------------------------------- --- Math ---------------------------------------------------------------------- - -local function GetPositionAtTime( - t: number, - origin: Vector3, - initialVelocity: Vector3, - acceleration: Vector3 -): Vector3 - - return origin - + initialVelocity * t - + acceleration * (t * t * 0.5) -end - -local function GetVelocityAtTime( - t: number, - initialVelocity: Vector3, - acceleration: Vector3 -): Vector3 - - return initialVelocity + acceleration * t -end - ---------------------------------------------------------------------- --- Debug Visualization ---------------------------------------------------------------------- +-- ============================================================================ +-- Visualization +-- ============================================================================ -local function DebrisAdd( - object: Instance, - lifetime: number -) +local function DebrisAdd(obj: Instance, lifetime: number) if lifetime <= 0 then - object:Destroy() + obj:Destroy() return end task.delay(lifetime, function() - if object.Parent then - object:Destroy() + if obj.Parent then + obj:Destroy() end end) end local function GetVisualizationContainer(): Instance - - local container = workspace.Terrain:FindFirstChild( - FC_VIS_OBJ_NAME - ) + local container = workspace.Terrain:FindFirstChild(FC_VIS_OBJ_NAME) if container then return container @@ -238,88 +224,63 @@ local function GetVisualizationContainer(): Instance return container end -local function VisualizeRaySegment( +local function DbgVisualizeRaySegment( startCFrame: CFrame, settings: TypeDef.VisualizeCastSettings, variant: RayVisualizerVariant ) - local adornment = Instance.new("ConeHandleAdornment") adornment.Adornee = workspace.Terrain adornment.CFrame = startCFrame adornment.Height = variant.castLength - adornment.Color3 = settings.Debug_SegmentColor adornment.Radius = settings.Debug_SegmentSize adornment.Transparency = settings.Debug_SegmentTransparency - adornment.Parent = GetVisualizationContainer() - DebrisAdd( - adornment, - settings.Debug_RayLifetime - ) - - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end -local function VisualizeBlockSegment( +local function DbgVisualizeBlockSegment( startCFrame: CFrame, settings: TypeDef.VisualizeCastSettings, variant: BlockVisualizerVariant ) - local adornment = Instance.new("BoxHandleAdornment") adornment.Adornee = workspace.Terrain adornment.CFrame = startCFrame adornment.Size = variant.size - adornment.Color3 = settings.Debug_SegmentColor adornment.Transparency = settings.Debug_SegmentTransparency - adornment.Parent = GetVisualizationContainer() - DebrisAdd( - adornment, - settings.Debug_RayLifetime - ) - - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end -local function VisualizeSphereSegment( +local function DbgVisualizeSphereSegment( startCFrame: CFrame, settings: TypeDef.VisualizeCastSettings, variant: SphereVisualizerVariant ) - local adornment = Instance.new("SphereHandleAdornment") adornment.Adornee = workspace.Terrain adornment.CFrame = startCFrame adornment.Radius = variant.radius - adornment.Color3 = settings.Debug_SegmentColor adornment.Transparency = settings.Debug_SegmentTransparency - adornment.Parent = GetVisualizationContainer() - DebrisAdd( - adornment, - settings.Debug_RayLifetime - ) - - return adornment + DebrisAdd(adornment, settings.Debug_RayLifetime) end -local function VisualizeHit( +local function DbgVisualizeHit( atCFrame: CFrame, wasPierce: boolean, settings: TypeDef.VisualizeCastSettings ) - local adornment = Instance.new("SphereHandleAdornment") adornment.Adornee = workspace.Terrain @@ -337,184 +298,54 @@ local function VisualizeHit( adornment.Parent = GetVisualizationContainer() - DebrisAdd( - adornment, - settings.Debug_HitLifetime - ) - - return adornment + DebrisAdd(adornment, settings.Debug_HitLifetime) end local Visualizers = { - [EnumCastTypes.Raycast] = VisualizeRaySegment, - [EnumCastTypes.Blockcast] = VisualizeBlockSegment, - [EnumCastTypes.Spherecast] = VisualizeSphereSegment, + [EnumCastTypes.Raycast] = DbgVisualizeRaySegment, + [EnumCastTypes.Blockcast] = DbgVisualizeBlockSegment, + [EnumCastTypes.Spherecast] = DbgVisualizeSphereSegment } ---------------------------------------------------------------------- --- SerialSimulation ---------------------------------------------------------------------- - -local SerialSimulation = {} -SerialSimulation.__index = SerialSimulation -SerialSimulation.__type = "SerialSimulation" - ---------------------------------------------------------------------- --- Queue Helpers ---------------------------------------------------------------------- - -function SerialSimulation:QueueEvent( - castID: number, - eventType: string, - ...: any -) - - if not queuedEvents[castID] then - queuedEvents[castID] = {} - end - - table.insert( - queuedEvents[castID], - { - eventType = eventType, - args = { ... }, - } - ) -end - -function SerialSimulation:FireQueuedEvents( - unFiredEvents -) - - local sortedIDs = {} - - for id in unFiredEvents do - table.insert( - sortedIDs, - id - ) - end - - table.sort(sortedIDs) - - for _, castID in sortedIDs do - - local events = - unFiredEvents[castID] - - if not events then - continue - end - - for _, event in events do - - local cast = - self.ActivesRef[castID] - - if not cast then - continue - end - - local config = casts_FastCastEventsConfig[castID] - - local eventType = event.eventType - - if eventType == "LengthChanged" then - - if config - and config.UseLengthChanged - and self.Events.LengthChanged - then - - self.Events.LengthChanged( - cast, - table.unpack(event.args) - ) - end - - elseif eventType == "Hit" then - - if config - and config.UseHit - and self.Events.Hit - then - - self.Events.Hit( - cast, - table.unpack(event.args) - ) - end - - elseif eventType == "Pierced" then - - if config - and config.UsePierced - and self.Events.Pierced - then - - self.Events.Pierced( - cast, - table.unpack(event.args) - ) - end - - elseif eventType == "CastTerminating" then - - self.BaseCastRef:_TerminateCast( - castID, - self.Events.CastTerminating - ) - - self:Unregister( - castID - ) - end - end - end -end - -- ============================================================================ -- Queued visualization -- ============================================================================ -function SerialSimulation:QueueVisualize( +local function QueueVisualize( castID: number, data: QueuedVisualizeCastData | QueuedVisualizeHitData ) - if not casts_VisualizeCasts[castID] then return end - if not queuedVisualizes[castID] then - queuedVisualizes[castID] = {} + local queue = queuedVisualizes[castID] + + if not queue then + queue = {} + queuedVisualizes[castID] = queue end - table.insert( - queuedVisualizes[castID], - data - ) + table.insert(queue, data) end -function SerialSimulation:FireQueuedVisualizes( - unFiredVisualizes +local function FireQueuedVisualizes( + visualizes: { + [number]: { + QueuedVisualizeCastData | QueuedVisualizeHitData + } + } ) - local sortedIDs = {} - for id in unFiredVisualizes do - table.insert( - sortedIDs, - id - ) + for id in visualizes do + table.insert(sortedIDs, id) end table.sort(sortedIDs) for _, castID in sortedIDs do - - local list = - unFiredVisualizes[castID] + local list = visualizes[castID] if not list then continue @@ -523,28 +354,27 @@ function SerialSimulation:FireQueuedVisualizes( local settings = casts_VisualizeCastSettings[castID] if not settings then - return + continue end for _, data in list do - + -- Hit visualization if data.atCF then - - if DebugLogging.Hit - and not data.wasPierce - and not data.sub + if DebugLogging.Hit + and not data.wasPierce + and not data.sub then print("Hit was successful. Terminating.") end - if DebugLogging.Pierced - and data.wasPierce - and not data.sub + if DebugLogging.Pierced + and data.wasPierce + and not data.sub then - print("Piercing function returned TRUE.") + print("Piercing function returned TRUE to pierce this part.") end - VisualizeHit( + DbgVisualizeHit( data.atCF, data.wasPierce, settings @@ -553,12 +383,12 @@ function SerialSimulation:FireQueuedVisualizes( continue end + -- Segment visualization if DebugLogging.Segment and data.sub then print("Subcast visualization") end local castType = casts_CastType[castID] - local visualizer = Visualizers[castType] if not visualizer then @@ -590,510 +420,1059 @@ function SerialSimulation:FireQueuedVisualizes( end end ---------------------------------------------------------------------- --- Event Helpers ---------------------------------------------------------------------- +-- ============================================================================ +-- Queued events +-- ============================================================================ -function SerialSimulation:QueueLengthChanged( - id: number, - segment: SegmentData, - distance: number +local function QueueEvent( + castID: number, + eventType: string, + ...: any ) + local queue = queuedEvents[castID] - local rayDirection - - if segment.displacement.Magnitude > 0 then - rayDirection = segment.displacement.Unit - else - rayDirection = Vector3.zero + if not queue then + queue = {} + queuedEvents[castID] = queue end - local cosmeticBullet = - casts_RayInfo[id].CosmeticBulletObject - - self:QueueEvent( - id, - "LengthChanged", - segment.lastPoint, - rayDirection, - distance, - segment.velocity, - cosmeticBullet - ) + table.insert(queue, { + eventType = eventType, + args = { ... } + }) end -function SerialSimulation:QueueHit( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean +local function FireQueuedEvents( + events: { [number]: { QueuedEventData } } ) + local sortedIDs = {} - self:QueueEvent( - id, - "Hit", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) + for id in events do + table.insert(sortedIDs, id) + end - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = false, - sub = sub, - } - ) -end + table.sort(sortedIDs) -function SerialSimulation:QueuePierce( - id: number, - result: RaycastResult, - velocity: Vector3, - sub: boolean -) + for _, castID in sortedIDs do + local eventList = events[castID] - self:QueueEvent( - id, - "Pierced", - result, - velocity, - casts_RayInfo[id].CosmeticBulletObject - ) + if not eventList then + continue + end - self:QueueVisualize( - id, - { - atCF = CFrame.new(result.Position), - wasPierce = true, - sub = sub, - } - ) -end + for _, event in eventList do + local cast = ActivesRef[castID] -function SerialSimulation:QueueTermination(id: number) - self:QueueEvent( - id, - "CastTerminating" - ) -end + if not cast then + continue + end ---------------------------------------------------------------------- --- Registration ---------------------------------------------------------------------- + local caster = cast.Caster + local args = event.args -function SerialSimulation:Register( - cast: any -) + local moduleConfig = casts_FastCastEventsModuleConfig[castID] - local id = cast.ID + local eventConfig = casts_FastCastEventsConfig[castID] - casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 + local fastCastEvents = casts_FastCastEvents[castID] + local eventType = event.eventType - casts_DistanceCovered[id] = 0 + if eventType == "LengthChanged" then + local lastPoint = args[1] + local rayDirection = args[2] + local displacement = args[3] + local velocity = args[4] + local cosmetic = args[5] + + if eventConfig and eventConfig.UseLengthChanged then + caster.Output:Fire( + "LengthChanged", + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) + end - casts_HighFidelitySegmentSize[id] = - cast.StateInfo.HighFidelitySegmentSize - or 0.1 + if moduleConfig + and moduleConfig.UseLengthChanged + and fastCastEvents + and fastCastEvents.LengthChanged + then + fastCastEvents.LengthChanged( + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) + end - casts_HighFidelityBehavior[id] = - cast.StateInfo.HighFidelityBehavior - or 0 + elseif eventType == "Hit" then + local result = args[1] + local velocity = args[2] + local cosmetic = args[3] - casts_IsActivelySimulatingPierce[id] = false - casts_IsActivelyResimulating[id] = false - casts_CancelHighResCast[id] = false + if eventConfig and eventConfig.UseHit then + caster.Output:Fire( + "Hit", + cast, + result, + velocity, + cosmetic + ) + end - casts_Trajectory[id] = cast.StateInfo.Trajectory + if moduleConfig + and moduleConfig.UseHit + and fastCastEvents + and fastCastEvents.Hit + then + fastCastEvents.Hit( + cast, + result, + velocity, + cosmetic + ) + end - casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig + elseif eventType == "Pierced" then + local result = args[1] + local velocity = args[2] + local cosmetic = args[3] - casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts + if eventConfig and eventConfig.UsePierced then + caster.Output:Fire( + "Pierced", + cast, + result, + velocity, + cosmetic + ) + end - casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings + if moduleConfig + and moduleConfig.UsePierced + and fastCastEvents + and fastCastEvents.Pierced + then + fastCastEvents.Pierced( + cast, + result, + velocity, + cosmetic + ) + end - casts_RayInfo[id] = cast.RayInfo + elseif eventType == "CastTerminating" then + local callback = args[1] - casts_UserData[id] = cast.UserData + if cast._Destroying then + continue + end - casts_CastType[id] = cast.CastVariant.CastType + cast._Destroying = true - casts_CastVariant[id] = cast.CastVariant + if eventConfig + and eventConfig.UseCastTerminating + then + caster.Output:Fire( + "CastTerminating", + cast + ) + end - casts_MaxDistance[id] = - cast.RayInfo.MaxDistance - or DEFAULT_MAX_DISTANCE + if callback then + callback(cast) + end - table.insert( - self.casts_ID, - id - ) + caster.ActiveCastCleaner:Fire(cast.ID) + end + end + end +end - self.casts_ID_Index[id] = #self.casts_ID +-- ============================================================================ +-- Movement +-- ============================================================================ - local trajectory = casts_Trajectory[id] +local function BulkMoveTo() + if CurrentMovementMode ~= "BulkMoveTo" + or not MovementEnabled + then + return + end + + local parts = table.create(#casts_ID) + local cframes = table.create(#casts_ID) - local position = - GetPositionAtTime( - casts_TotalRunTime[id], - trajectory.Origin, - trajectory.InitialVelocity, - trajectory.Acceleration + for _, id in casts_ID do + local rayInfo = casts_RayInfo[id] + local cosmetic = rayInfo and rayInfo.CosmeticBulletObject + + if cosmetic and cosmetic.Parent then + table.insert(parts, cosmetic) + table.insert(cframes, casts_CFrame[id]) + end + end + + if #parts > 0 then + workspace:BulkMoveTo( + parts, + cframes, + Enum.BulkMoveMode.FireCFrameChanged ) + end +end - casts_CFrame[id] = CFrame.new(position) +local function UpdateMotor6Ds() + if CurrentMovementMode ~= "Motor6D" + or not MovementEnabled + then + return + end - cast.CFrame = casts_CFrame[id] + for id, motor6d in casts_ActiveMotor6Ds do + if motor6d and motor6d.Parent then + motor6d.Transform = casts_CFrame[id] + end + end +end - queuedEvents[id] = {} +-- ============================================================================ +-- Event callback helpers +-- ============================================================================ + +local function GetEventCallbacks(id: number) + local moduleConfig = casts_FastCastEventsModuleConfig[id] + local events = casts_FastCastEvents[id] + + if not moduleConfig or not events then + return nil, nil + end + + local canPierce = + moduleConfig.UseCanPierce + and events.CanPierce + or nil + + local terminating = + moduleConfig.UseCastTerminating + and events.CastTerminating + or nil + + return canPierce, terminating end ---------------------------------------------------------------------- --- Unregister ---------------------------------------------------------------------- +-- ============================================================================ +-- Simulation helpers +-- ============================================================================ -function SerialSimulation:Unregister( - castID: number +local function QueueHit( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean, + castTerminatingfn: TypeDef.OnCastTerminatingFunction? ) + QueueEvent( + id, + "Hit", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - casts_TotalRunTime[castID] = nil - casts_DistanceCovered[castID] = nil + QueueVisualize(id, { + atCF = CFrame.new(result.Position), + wasPierce = false, + sub = sub + }) - casts_HighFidelitySegmentSize[castID] = nil - casts_HighFidelityBehavior[castID] = nil + QueueEvent( + id, + "CastTerminating", + castTerminatingfn + ) +end - casts_IsActivelySimulatingPierce[castID] = nil - casts_IsActivelyResimulating[castID] = nil - casts_CancelHighResCast[castID] = nil +local function QueuePierce( + id: number, + result: RaycastResult, + velocity: Vector3, + sub: boolean +) + QueueEvent( + id, + "Pierced", + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) - casts_Trajectory[castID] = nil - casts_FastCastEventsConfig[castID] = nil + QueueVisualize(id, { + atCF = CFrame.new(result.Position), + wasPierce = true, + sub = sub + }) +end - casts_VisualizeCasts[castID] = nil - casts_VisualizeCastSettings[castID] = nil +local function ShouldPierce( + id: number, + result: RaycastResult, + velocity: Vector3, + canPierce: TypeDef.CanPierceFunction? +): boolean + if not canPierce then + return false + end - casts_RayInfo[castID] = nil - casts_UserData[castID] = nil + return canPierce( + ActivesRef[id], + result, + velocity, + casts_RayInfo[id].CosmeticBulletObject + ) +end - casts_CFrame[castID] = nil - casts_CastType[castID] = nil - casts_CastVariant[castID] = nil +-- ============================================================================ +-- Automatic high-fidelity resimulation +-- ============================================================================ - casts_MaxDistance[castID] = nil - casts_RayDisplacement[castID] = nil +local function ResimulateHit( + id: number, + delta: number, + lastDelta: number, + origin: Vector3, + initialVelocity: Vector3, + acceleration: Vector3, + targetWorldRoot: WorldRoot, + castHandler: any, + canPierce: TypeDef.CanPierceFunction?, + castTerminatingfn: TypeDef.OnCastTerminatingFunction?, + fallbackResult: RaycastResult, + fallbackVelocity: Vector3 +): boolean + + casts_IsActivelyResimulating[id] = true + + local rayDisplacement = casts_RayDisplacement[id] + + local segmentCount = math.max( + 1, + math.floor( + rayDisplacement + / casts_HighFidelitySegmentSize[id] + ) + ) - if casts_ActiveMotor6Ds[castID] then + local timeIncrement = delta / segmentCount - if self.BaseCastRef - and self.BaseCastRef._ReturnMotor6D - then + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) + end - self.BaseCastRef:_ReturnMotor6D( - casts_ActiveMotor6Ds[castID] - ) + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break end - casts_ActiveMotor6Ds[castID] = nil - end + local subTime = + lastDelta + timeIncrement * segmentIndex - local index = - self.casts_ID_Index[castID] + local subPosition = GetPositionAtTime( + subTime, + origin, + initialVelocity, + acceleration + ) - if index then + local subVelocity = GetVelocityAtTime( + subTime, + initialVelocity, + acceleration + ) + + local subDirection = subVelocity * timeIncrement + + local subResult = castHandler( + targetWorldRoot, + subPosition, + subDirection, + casts_RayInfo[id].Parameters, + casts_CastVariant[id] + ) - local lastID = - self.casts_ID[#self.casts_ID] + QueueVisualize(id, { + castStartCFrame = CFrame.new( + subPosition, + subPosition + subVelocity + ), + sub = true + }) - self.casts_ID[index] = - lastID + if not subResult then + continue + end - self.casts_ID_Index[lastID] = - index + if ShouldPierce( + id, + subResult, + subVelocity, + canPierce + ) then + QueuePierce( + id, + subResult, + subVelocity, + true + ) - self.casts_ID[#self.casts_ID] = nil - self.casts_ID_Index[castID] = nil + continue + end + + casts_IsActivelyResimulating[id] = false + + QueueHit( + id, + subResult, + subVelocity, + true, + castTerminatingfn + ) + + return true end - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil -end + casts_IsActivelyResimulating[id] = false ---------------------------------------------------------------------- --- Movement ---------------------------------------------------------------------- + -- The original cast hit, but none of the + -- high-resolution subcasts did. + QueueHit( + id, + fallbackResult, + fallbackVelocity, + false, + castTerminatingfn + ) -function SerialSimulation:BulkMoveTo() + return true +end - if self.CurrentMovementMode - ~= "BulkMoveTo" - or not self.MovementEnabled - then - return +-- ============================================================================ +-- Single simulation step +-- ============================================================================ + +local function SimulateCast( + id: number, + delta: number, + fastCastEvents: TypeDef.FastCastEvents? +) + if DebugLogging.Casting then + print("Casting for frame - SimulateCast") end - local parts = - table.create(#self.casts_ID) + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] - local cframes = - table.create(#self.casts_ID) + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration - for _, id in self.casts_ID do + local previousTime = casts_TotalRunTime[id] - trajectory.StartTime - local cosmetic = - casts_RayInfo[id] - and casts_RayInfo[id].CosmeticBulletObject + local lastPoint = GetPositionAtTime( + previousTime, + origin, + initialVelocity, + acceleration + ) - if cosmetic - and cosmetic.Parent - then + casts_TotalRunTime[id] += delta + + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime + + local currentPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) + + local velocity = GetVelocityAtTime( + currentTime, + initialVelocity, + acceleration + ) + + local displacement = currentPoint - lastPoint + + local worldRoot = rayInfo.WorldRoot + local handler = castHandlers[variant.CastType] + + local result = handler( + worldRoot, + lastPoint, + displacement, + rayInfo.Parameters, + variant + ) + + local hitPoint = result + and result.Position + or currentPoint + + local rayDisplacement = (hitPoint - lastPoint).Magnitude - table.insert( - parts, - cosmetic + casts_RayDisplacement[id] = rayDisplacement + + -- Update cosmetic transform. + local direction = displacement + + casts_CFrame[id] = + ( + if direction ~= Vector3.zero + then CFrame.new( + lastPoint, + lastPoint + direction ) + else CFrame.new(lastPoint) + ) + * CFrame.new( + 0, + 0, + -rayDisplacement / 2 + ) + + QueueEvent( + id, + "LengthChanged", + lastPoint, + direction.Unit, + rayDisplacement, + velocity, + rayInfo.CosmeticBulletObject + ) + + casts_DistanceCovered[id] += rayDisplacement + + if delta > 0 then + QueueVisualize(id, { + castStartCFrame = CFrame.new( + lastPoint, + lastPoint + direction + ), + sub = false + }) + end + + -- ------------------------------------------------------------------------ + -- No hit. + -- ------------------------------------------------------------------------ - table.insert( - cframes, - casts_CFrame[id] + if not result then + if casts_DistanceCovered[id] >= casts_MaxDistance[id] then + QueueEvent( + id, + "CastTerminating", + GetEventCallbacks(id) ) end + + return end - if #parts > 0 then + -- ------------------------------------------------------------------------ + -- Cosmetic bullet collision. + -- ------------------------------------------------------------------------ - workspace:BulkMoveTo( - parts, - cframes, - Enum.BulkMoveMode.FireCFrameChanged - ) + if result.Instance == rayInfo.CosmeticBulletObject then + return end -end -function SerialSimulation:UpdateMotor6Ds() + local canPierce, castTerminatingfn = GetEventCallbacks(id) + + -- ------------------------------------------------------------------------ + -- Pierce. + -- ------------------------------------------------------------------------ + + if ShouldPierce( + id, + result, + velocity, + canPierce + ) then + QueuePierce( + id, + result, + velocity, + false + ) - if self.CurrentMovementMode - ~= "Motor6D" - or not self.MovementEnabled - then return end - for id, motor6d in casts_ActiveMotor6Ds do + -- ------------------------------------------------------------------------ + -- Hit. + -- ------------------------------------------------------------------------ - if motor6d - and motor6d.Parent - then + if casts_HighFidelityBehavior[id] + == FastCastEnums.HighFidelityBehavior.Automatic + and casts_HighFidelitySegmentSize[id] > 0 + then + if casts_IsActivelyResimulating[id] then + QueueEvent( + id, + "CastTerminating", + castTerminatingfn + ) - motor6d.Transform = - casts_CFrame[id] + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) + + return end + + local lastDelta = previousTime + + ResimulateHit( + id, + delta, + lastDelta, + origin, + initialVelocity, + acceleration, + worldRoot, + handler, + canPierce, + castTerminatingfn, + result, + velocity + ) + + return end + + QueueHit( + id, + result, + velocity, + false, + castTerminatingfn + ) end ---------------------------------------------------------------------- --- Movement Mode ---------------------------------------------------------------------- +-- ============================================================================ +-- Always high-fidelity simulation +-- ============================================================================ -function SerialSimulation:SetMovementModeEnabled( - enabled: boolean, - mode: "BulkMoveTo" | "Motor6D" +local function SimulateAlwaysHighFidelity( + id: number, + delta: number, + fastCastEvents: TypeDef.FastCastEvents? ) + if casts_IsActivelyResimulating[id] then + local _, terminating = GetEventCallbacks(id) - self.CurrentMovementMode = mode + QueueEvent( + id, + "CastTerminating", + terminating + ) - self.MovementEnabled = enabled -end + warn( + "Cascading cast lag encountered! " + .. "The caster attempted to perform a high fidelity " + .. "cast before the previous one completed. " + .. "Consider increasing HighFidelitySegmentSize." + ) ---------------------------------------------------------------------- --- Update ---------------------------------------------------------------------- + return + end -function SerialSimulation:UpdateCasts( - delta: number -) + casts_IsActivelyResimulating[id] = true - for _, id in self.casts_ID do + local trajectory = casts_Trajectory[id] + local rayInfo = casts_RayInfo[id] + local variant = casts_CastVariant[id] - local cast = - self.ActivesRef[id] + local origin = trajectory.Origin + local initialVelocity = trajectory.InitialVelocity + local acceleration = trajectory.Acceleration - if cast - and cast._Destroying - then - continue - end + local currentTime = casts_TotalRunTime[id] - trajectory.StartTime - if casts_HighFidelitySegmentSize[id] <= 0 then - casts_HighFidelitySegmentSize[id] = 0.1 + local lastPoint = GetPositionAtTime( + currentTime, + origin, + initialVelocity, + acceleration + ) + + -- Temporarily advance time to determine + -- the approximate distance for this frame. + casts_TotalRunTime[id] += delta + + local nextTime = casts_TotalRunTime[id] - trajectory.StartTime + + local currentPoint = GetPositionAtTime( + nextTime, + origin, + initialVelocity, + acceleration + ) + + local currentVelocity = GetVelocityAtTime( + nextTime, + initialVelocity, + acceleration + ) + + local totalDisplacement = currentPoint - lastPoint + + local direction = + totalDisplacement.Unit + * currentVelocity.Magnitude + * delta + + local handler = castHandlers[variant.CastType] + + local resultOfCast = handler( + rayInfo.WorldRoot, + lastPoint, + direction, + rayInfo.Parameters, + variant + ) + + local endpoint = + resultOfCast + and resultOfCast.Position + or currentPoint + + local distance = (endpoint - lastPoint).Magnitude + + casts_TotalRunTime[id] -= delta + + local segmentCount = math.max( + 1, + math.floor( + distance + / casts_HighFidelitySegmentSize[id] + ) + ) + + local timeIncrement = delta / segmentCount + + if DebugLogging.Calculation then + print( + "Performing subcast! Time increment: " + .. timeIncrement + .. ", num segments: " + .. segmentCount + ) + end + + for segmentIndex = 1, segmentCount do + if casts_CancelHighResCast[id] then + casts_CancelHighResCast[id] = false + break end - local behavior = casts_HighFidelityBehavior[id] + if not ActivesRef[id] then + casts_IsActivelyResimulating[id] = false + return + end - if behavior == FastCastEnums.HighFidelityBehavior.Always then - self:SimulateAlwaysHighFidelity( - id, - delta - ) - else - self:SimulateCast( - id, - delta + if DebugLogging.Segment then + print( + "[" + .. segmentIndex + .. "] Subcast of time increment " + .. timeIncrement ) end + + SimulateCast( + id, + timeIncrement, + fastCastEvents + ) end - ------------------------------------------------------------- - -- Write state back - ------------------------------------------------------------- + casts_IsActivelyResimulating[id] = false +end - for _, id in self.casts_ID do +-- ============================================================================ +-- Registration +-- ============================================================================ - local cast = - self.ActivesRef[id] +function SerialSimulation_Register(cast: any) + local id = cast.ID - if cast then + casts_TotalRunTime[id] = cast.StateInfo.TotalRuntime or 0 - cast.StateInfo.TotalRuntime = - casts_TotalRunTime[id] + casts_DistanceCovered[id] = 0 - cast.StateInfo.DistanceCovered = - casts_DistanceCovered[id] + casts_HighFidelitySegmentSize[id] = cast.StateInfo.HighFidelitySegmentSize or 0.1 - cast.CFrame = - casts_CFrame[id] - end - end + casts_HighFidelityBehavior[id] = cast.StateInfo.HighFidelityBehavior or 0 - ------------------------------------------------------------- - -- Movement - ------------------------------------------------------------- + casts_IsActivelySimulatingPierce[id] = false + casts_IsActivelyResimulating[id] = false + casts_CancelHighResCast[id] = false - self:UpdateMotor6Ds() - self:BulkMoveTo() + casts_Trajectory[id] = cast.StateInfo.Trajectory - ------------------------------------------------------------- - -- Swap queues - ------------------------------------------------------------- + casts_FastCastEventsModuleConfig[id] = cast.StateInfo.FastCastEventsModuleConfig - local events = - queuedEvents + casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig - queuedEvents = {} + casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts - local visualizes = - queuedVisualizes + casts_VisualizeCastSettings[id] = cast.StateInfo.VisualizeCastSettings - queuedVisualizes = {} + casts_RayInfo[id] = cast.RayInfo + casts_UserData[id] = cast.UserData - ------------------------------------------------------------- - -- Dispatch - ------------------------------------------------------------- + casts_CastType[id] = + cast.CastVariant.CastType - self:FireQueuedVisualizes( - visualizes - ) + casts_CastVariant[id] = + cast.CastVariant + + casts_MaxDistance[id] = + cast.RayInfo.MaxDistance + or DEFAULT_MAX_DISTANCE + + table.insert(casts_ID, id) + casts_ID_Index[id] = #casts_ID + + -- Load optional event module. + local eventModule = cast.RayInfo.FastCastEventsModule + + if typeof(eventModule) == "Instance" + and eventModule:IsA("ModuleScript") + then + casts_FastCastEvents[id] = require(eventModule) + else + casts_FastCastEvents[id] = nil + end - self:FireQueuedEvents( - events + local trajectory = casts_Trajectory[id] + + local position = GetPositionAtTime( + casts_TotalRunTime[id], + trajectory.Origin, + trajectory.InitialVelocity, + trajectory.Acceleration ) + + casts_CFrame[id] = CFrame.new(position) + cast.CFrame = casts_CFrame[id] + + queuedEvents[id] = {} end ---------------------------------------------------------------------- --- Construction ---------------------------------------------------------------------- +function SerialSimulation_Unregister(castID: number) + casts_TotalRunTime[castID] = nil + casts_DistanceCovered[castID] = nil -function SerialSimulation.new() + casts_HighFidelitySegmentSize[castID] = nil + casts_HighFidelityBehavior[castID] = nil - local self = - setmetatable( - {}, - SerialSimulation - ) + casts_IsActivelySimulatingPierce[castID] = nil + casts_IsActivelyResimulating[castID] = nil + casts_CancelHighResCast[castID] = nil - self.Connection = nil + casts_Trajectory[castID] = nil - self.CurrentMovementMode = "BulkMoveTo" + casts_FastCastEventsModuleConfig[castID] = nil + casts_FastCastEventsConfig[castID] = nil + casts_FastCastEvents[castID] = nil - self.MovementEnabled = true + casts_VisualizeCasts[castID] = nil + casts_VisualizeCastSettings[castID] = nil - self.Events = {} + casts_RayInfo[castID] = nil + casts_UserData[castID] = nil - self.BaseCastRef = nil - self.ActivesRef = nil + casts_CFrame[castID] = nil + casts_CastType[castID] = nil + casts_CastVariant[castID] = nil - self.casts_ID = {} - self.casts_ID_Index = {} + casts_MaxDistance[castID] = nil + casts_RayDisplacement[castID] = nil - return self -end + if casts_ActiveMotor6Ds[castID] then + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef:_ReturnMotor6D( + casts_ActiveMotor6Ds[castID] + ) + end -function SerialSimulation:Init( - baseCastRef: any, - events: TypeDef.FastCastEvents -) + casts_ActiveMotor6Ds[castID] = nil + end + + local index = casts_ID_Index[castID] + + if index then + local lastID = casts_ID[#casts_ID] - self.BaseCastRef = baseCastRef + casts_ID[index] = lastID + casts_ID_Index[lastID] = index - self.ActivesRef = baseCastRef.Actives + table.remove(casts_ID, #casts_ID) + casts_ID_Index[castID] = nil + end - self.Events = events + queuedEvents[castID] = nil + queuedVisualizes[castID] = nil end ---------------------------------------------------------------------- --- RunService ---------------------------------------------------------------------- +-- ============================================================================ +-- Movement configuration +-- ============================================================================ -function SerialSimulation:Start() +local function SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" +) + CurrentMovementMode = mode + MovementEnabled = enabled +end - if self.Connection then - warn( - "[FastCast2] SerialSimulation already started." - ) +-- ============================================================================ +-- Main update +-- ============================================================================ - return - end +local function UpdateCasts(delta: number) + for _, id in casts_ID do + local cast = ActivesRef[id] - if RunService:IsClient() then + if cast and cast._Destroying then + continue + end - self.Connection = - RunService.PreSimulation:Connect( - function(delta) - self:UpdateCasts(delta) - end - ) + if not casts_Trajectory[id] then + continue + end - else + if casts_HighFidelitySegmentSize[id] <= 0 then + casts_HighFidelitySegmentSize[id] = 0.1 + end - self.Connection = - RunService.Heartbeat:Connect( - function(delta) - self:UpdateCasts(delta) - end + local fastCastEvents = casts_FastCastEvents[id] + + local behavior = casts_HighFidelityBehavior[id] + + if behavior == FastCastEnums.HighFidelityBehavior.Always then + SimulateAlwaysHighFidelity( + id, + delta, + fastCastEvents ) + else + SimulateCast( + id, + delta, + fastCastEvents + ) + end end + + -- ========================================================================= + -- Main-thread-only work. + -- ========================================================================= + + UpdateMotor6Ds() + BulkMoveTo() + + local eventsToProcess = queuedEvents + queuedEvents = {} + + local visualizesToProcess = queuedVisualizes + queuedVisualizes = {} + + FireQueuedVisualizes( + visualizesToProcess + ) + + FireQueuedEvents( + eventsToProcess + ) end -function SerialSimulation:Stop() +-- ============================================================================ +-- Public API +-- ============================================================================ + +local SerialSimulation = {} - if self.Connection then +SerialSimulation.Connection = nil :: RBXScriptConnection? - self.Connection:Disconnect() - self.Connection = nil +function SerialSimulation.Init(baseCastRef: any) + if ActivesRef ~= nil then + warn( + "[FastCast2] SerialSimulation already initialized. Skipping." + ) + + return end + + BaseCastRef = baseCastRef + ActivesRef = baseCastRef.Actives +end + +function SerialSimulation.Register(cast: any) + SerialSimulation_Register(cast) end ---------------------------------------------------------------------- --- Events ---------------------------------------------------------------------- +function SerialSimulation.Unregister(castID: number) + SerialSimulation_Unregister(castID) +end -function SerialSimulation:_UpdateEvents( - eventName: string, - newEventfn: (...any) -> () +function SerialSimulation.SetMovementModeEnabled( + enabled: boolean, + mode: "BulkMoveTo" | "Motor6D" ) + SetMovementModeEnabled(enabled, mode) +end - self.Events[eventName] = - newEventfn +function SerialSimulation.Start() + if SerialSimulation.Connection then + warn("[FastCast2] SerialSimulation already started.") + return + end + + if RS:IsClient() then + SerialSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) + else + SerialSimulation.Connection = RS.Heartbeat:ConnectParallel(UpdateCasts) + end +end + +function SerialSimulation.Stop() + if SerialSimulation.Connection then + SerialSimulation.Connection:Disconnect() + SerialSimulation.Connection = nil + end end return SerialSimulation From b9174c2f4f0cf9b875cb142a2c1663ec3500ab59 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 06:23:52 +0000 Subject: [PATCH 020/140] fix stuffs --- src/ParallelSimulation.luau | 17 ++++------- src/SerialSimulation.luau | 59 ++++--------------------------------- 2 files changed, 10 insertions(+), 66 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 5f3e7182..0123b745 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -874,8 +874,7 @@ end local function SimulateCast( id: number, - delta: number, - fastCastEvents: TypeDef.FastCastEvents? + delta: number ) if DebugLogging.Casting then print("Casting for frame - SimulateCast") @@ -1082,8 +1081,7 @@ end local function SimulateAlwaysHighFidelity( id: number, - delta: number, - fastCastEvents: TypeDef.FastCastEvents? + delta: number ) if casts_IsActivelyResimulating[id] then local _, terminating = GetEventCallbacks(id) @@ -1209,8 +1207,7 @@ local function SimulateAlwaysHighFidelity( SimulateCast( id, - timeIncrement, - fastCastEvents + timeIncrement ) end @@ -1428,21 +1425,17 @@ local function UpdateCasts(delta: number) casts_HighFidelitySegmentSize[id] = 0.1 end - local fastCastEvents = casts_FastCastEvents[id] - local behavior = casts_HighFidelityBehavior[id] if behavior == FastCastEnums.HighFidelityBehavior.Always then SimulateAlwaysHighFidelity( id, - delta, - fastCastEvents + delta ) else SimulateCast( id, - delta, - fastCastEvents + delta ) end end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 5e2ffaf8..fafea3d5 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -470,11 +470,8 @@ local function FireQueuedEvents( local caster = cast.Caster local args = event.args - local moduleConfig = casts_FastCastEventsModuleConfig[castID] - local eventConfig = casts_FastCastEventsConfig[castID] - local fastCastEvents = casts_FastCastEvents[castID] local eventType = event.eventType if eventType == "LengthChanged" then @@ -496,21 +493,6 @@ local function FireQueuedEvents( ) end - if moduleConfig - and moduleConfig.UseLengthChanged - and fastCastEvents - and fastCastEvents.LengthChanged - then - fastCastEvents.LengthChanged( - cast, - lastPoint, - rayDirection, - displacement, - velocity, - cosmetic - ) - end - elseif eventType == "Hit" then local result = args[1] local velocity = args[2] @@ -526,19 +508,6 @@ local function FireQueuedEvents( ) end - if moduleConfig - and moduleConfig.UseHit - and fastCastEvents - and fastCastEvents.Hit - then - fastCastEvents.Hit( - cast, - result, - velocity, - cosmetic - ) - end - elseif eventType == "Pierced" then local result = args[1] local velocity = args[2] @@ -554,19 +523,6 @@ local function FireQueuedEvents( ) end - if moduleConfig - and moduleConfig.UsePierced - and fastCastEvents - and fastCastEvents.Pierced - then - fastCastEvents.Pierced( - cast, - result, - velocity, - cosmetic - ) - end - elseif eventType == "CastTerminating" then local callback = args[1] @@ -874,8 +830,7 @@ end local function SimulateCast( id: number, - delta: number, - fastCastEvents: TypeDef.FastCastEvents? + delta: number ) if DebugLogging.Casting then print("Casting for frame - SimulateCast") @@ -1082,8 +1037,7 @@ end local function SimulateAlwaysHighFidelity( id: number, - delta: number, - fastCastEvents: TypeDef.FastCastEvents? + delta: number ) if casts_IsActivelyResimulating[id] then local _, terminating = GetEventCallbacks(id) @@ -1209,8 +1163,7 @@ local function SimulateAlwaysHighFidelity( SimulateCast( id, - timeIncrement, - fastCastEvents + timeIncrement ) end @@ -1385,14 +1338,12 @@ local function UpdateCasts(delta: number) if behavior == FastCastEnums.HighFidelityBehavior.Always then SimulateAlwaysHighFidelity( id, - delta, - fastCastEvents + delta ) else SimulateCast( id, - delta, - fastCastEvents + delta ) end end From 5c67e9780d76a7805b6471a70caeec2237010a7d Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 06:26:48 +0000 Subject: [PATCH 021/140] Fix setmovement for motor6D --- src/SerialSimulation.luau | 40 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index fafea3d5..26ef969e 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1307,8 +1307,46 @@ local function SetMovementModeEnabled( enabled: boolean, mode: "BulkMoveTo" | "Motor6D" ) + local oldMode = CurrentMovementMode + CurrentMovementMode = mode MovementEnabled = enabled + + if oldMode == "Motor6D" + and mode ~= "Motor6D" + then + for id, motor6d in casts_ActiveMotor6Ds do + if BaseCastRef + and BaseCastRef._ReturnMotor6D + then + BaseCastRef:_ReturnMotor6D(motor6d) + end + + casts_ActiveMotor6Ds[id] = nil + end + end + + if mode == "Motor6D" + and enabled + and oldMode ~= "Motor6D" + then + for _, id in casts_ID do + if casts_ActiveMotor6Ds[id] then + continue + end + + local cosmetic = + casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef:_GetMotor6D(cosmetic) + end + end + end end -- ============================================================================ @@ -1331,8 +1369,6 @@ local function UpdateCasts(delta: number) casts_HighFidelitySegmentSize[id] = 0.1 end - local fastCastEvents = casts_FastCastEvents[id] - local behavior = casts_HighFidelityBehavior[id] if behavior == FastCastEnums.HighFidelityBehavior.Always then From d0e269822cabd7f519d10c0be040f432b870dfbb Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 06:35:44 +0000 Subject: [PATCH 022/140] Fix some more things --- src/SerialSimulation.luau | 74 +++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 26ef969e..ddd3b477 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -124,6 +124,8 @@ local BaseCastRef: any = nil local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" local MovementEnabled = false +local FastCastEvents: {[string]: (any) -> ()} = {} + -- ============================================================================ -- Math -- ============================================================================ @@ -482,15 +484,18 @@ local function FireQueuedEvents( local cosmetic = args[5] if eventConfig and eventConfig.UseLengthChanged then - caster.Output:Fire( - "LengthChanged", - cast, - lastPoint, - rayDirection, - displacement, - velocity, - cosmetic - ) + + local lengthChanged = FastCastEvents.LengthChanged + if lengthChanged then + lengthChanged( + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) + end end elseif eventType == "Hit" then @@ -499,13 +504,16 @@ local function FireQueuedEvents( local cosmetic = args[3] if eventConfig and eventConfig.UseHit then - caster.Output:Fire( - "Hit", - cast, - result, - velocity, - cosmetic - ) + + local Hit = FastCastEvents.Hit + if Hit then + Hit( + cast, + result, + velocity, + cosmetic + ) + end end elseif eventType == "Pierced" then @@ -514,13 +522,16 @@ local function FireQueuedEvents( local cosmetic = args[3] if eventConfig and eventConfig.UsePierced then - caster.Output:Fire( - "Pierced", - cast, - result, - velocity, - cosmetic - ) + + local Pierced = FastCastEvents.Pierced + if Pierced then + Pierced( + cast, + result, + velocity, + cosmetic + ) + end end elseif eventType == "CastTerminating" then @@ -535,16 +546,17 @@ local function FireQueuedEvents( if eventConfig and eventConfig.UseCastTerminating then - caster.Output:Fire( - "CastTerminating", - cast - ) + local CastTerminating = FastCastEvents.CastTerminating + if CastTerminating then + CastTerminating(cast) + end end if callback then callback(cast) end + -- TODO: Fix caster.ActiveCastCleaner:Fire(cast.ID) end end @@ -1462,4 +1474,12 @@ function SerialSimulation.Stop() end end +function SerialSimulation:_UpdateEvent(name: string, newEventfn: (any) -> ()) + if type(newEventfn) ~= "function" then + return + end + + FastCastEvents[name] = newEventfn +end + return SerialSimulation From f17f4b62d62689165ba7c303ed7325ea5c5ab1e5 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 07:43:14 +0000 Subject: [PATCH 023/140] Fixes --- src/BaseCastSerial.luau | 16 ++++----- src/SerialSimulation.luau | 75 +++++++++------------------------------ 2 files changed, 23 insertions(+), 68 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 2614cfd1..c3690cf3 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -71,7 +71,7 @@ function BaseCast.Init(events: any, Data: any) SerialSimulation.Init(self, events) - self.SerialSimulation:SetMovementModeEnabled(true, self.CurrentMovementMode) + SerialSimulation.SetMovementModeEnabled(true, self.CurrentMovementMode) SerialSimulation.Start() @@ -103,7 +103,7 @@ function BaseCast:Raycast( CastType = EnumCastTypes.Raycast } :: any, self.ObjectCacheInstance) - self.SerialSimulation:Register(cast) + SerialSimulation.Register(cast) self.Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then @@ -139,7 +139,7 @@ function BaseCast:Blockcast( Size = Size } :: any, self.ObjectCacheInstance) - self.SerialSimulation:Register(cast) + SerialSimulation.Register(cast) self.Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then @@ -175,7 +175,7 @@ function BaseCast:Spherecast( Radius = Radius } :: any, self.ObjectCacheInstance) - self.SerialSimulation:Register(cast) + SerialSimulation.Register(cast) self.Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then @@ -208,7 +208,7 @@ function BaseCast:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | end end - self.SerialSimulation:SetMovementModeEnabled(enabled, mode) + SerialSimulation.SetMovementModeEnabled(enabled, mode) end function BaseCast:BindObjectCache( @@ -251,10 +251,6 @@ Destroys the BaseCast instance and cleans up resources. ]=] function BaseCast:Destroy() - if self.SerialSimulation then - self.SerialSimulation:Stop() - end - if self.ObjectCacheInstance then self.ObjectCacheInstance:Destroy() end @@ -318,7 +314,7 @@ function BaseCast:_UpdateEvents(eventName: string, newEventfn: (...any) -> ()) self.CastFirefn = newEventfn return end - self.SerialSimulation:_UpdateEvents(eventName, newEventfn) + SerialSimulation._UpdateEvent(eventName, newEventfn) end return BaseCast diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index ddd3b477..6655c15c 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -85,9 +85,6 @@ local casts_CancelHighResCast = {} :: { [number]: boolean } local casts_Trajectory = {} :: { [number]: TypeDef.CastTrajectory } -local casts_FastCastEventsModuleConfig = - {} :: { [number]: TypeDef.FastCastEventsModuleConfig } - local casts_FastCastEventsConfig = {} :: { [number]: TypeDef.FastCastEventsConfig } @@ -124,7 +121,7 @@ local BaseCastRef: any = nil local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" local MovementEnabled = false -local FastCastEvents: {[string]: (any) -> ()} = {} +local FastCastEvents = {} :: {[number]: (any) -> ()} -- ============================================================================ -- Math @@ -610,31 +607,6 @@ local function UpdateMotor6Ds() end end --- ============================================================================ --- Event callback helpers --- ============================================================================ - -local function GetEventCallbacks(id: number) - local moduleConfig = casts_FastCastEventsModuleConfig[id] - local events = casts_FastCastEvents[id] - - if not moduleConfig or not events then - return nil, nil - end - - local canPierce = - moduleConfig.UseCanPierce - and events.CanPierce - or nil - - local terminating = - moduleConfig.UseCastTerminating - and events.CastTerminating - or nil - - return canPierce, terminating -end - -- ============================================================================ -- Simulation helpers -- ============================================================================ @@ -643,8 +615,7 @@ local function QueueHit( id: number, result: RaycastResult, velocity: Vector3, - sub: boolean, - castTerminatingfn: TypeDef.OnCastTerminatingFunction? + sub: boolean ) QueueEvent( id, @@ -662,8 +633,7 @@ local function QueueHit( QueueEvent( id, - "CastTerminating", - castTerminatingfn + "CastTerminating" ) end @@ -720,7 +690,6 @@ local function ResimulateHit( targetWorldRoot: WorldRoot, castHandler: any, canPierce: TypeDef.CanPierceFunction?, - castTerminatingfn: TypeDef.OnCastTerminatingFunction?, fallbackResult: RaycastResult, fallbackVelocity: Vector3 ): boolean @@ -814,8 +783,7 @@ local function ResimulateHit( id, subResult, subVelocity, - true, - castTerminatingfn + true ) return true @@ -829,8 +797,7 @@ local function ResimulateHit( id, fallbackResult, fallbackVelocity, - false, - castTerminatingfn + false ) return true @@ -951,8 +918,7 @@ local function SimulateCast( if casts_DistanceCovered[id] >= casts_MaxDistance[id] then QueueEvent( id, - "CastTerminating", - GetEventCallbacks(id) + "CastTerminating" ) end @@ -967,7 +933,7 @@ local function SimulateCast( return end - local canPierce, castTerminatingfn = GetEventCallbacks(id) + local canPierce = FastCastEvents.CanPierce -- ------------------------------------------------------------------------ -- Pierce. @@ -1000,8 +966,7 @@ local function SimulateCast( if casts_IsActivelyResimulating[id] then QueueEvent( id, - "CastTerminating", - castTerminatingfn + "CastTerminating" ) warn( @@ -1026,7 +991,6 @@ local function SimulateCast( worldRoot, handler, canPierce, - castTerminatingfn, result, velocity ) @@ -1038,8 +1002,7 @@ local function SimulateCast( id, result, velocity, - false, - castTerminatingfn + false ) end @@ -1052,12 +1015,9 @@ local function SimulateAlwaysHighFidelity( delta: number ) if casts_IsActivelyResimulating[id] then - local _, terminating = GetEventCallbacks(id) - QueueEvent( id, - "CastTerminating", - terminating + "CastTerminating" ) warn( @@ -1203,8 +1163,6 @@ function SerialSimulation_Register(cast: any) casts_Trajectory[id] = cast.StateInfo.Trajectory - casts_FastCastEventsModuleConfig[id] = cast.StateInfo.FastCastEventsModuleConfig - casts_FastCastEventsConfig[id] = cast.StateInfo.FastCastEventsConfig casts_VisualizeCasts[id] = cast.StateInfo.VisualizeCasts @@ -1266,7 +1224,6 @@ function SerialSimulation_Unregister(castID: number) casts_Trajectory[castID] = nil - casts_FastCastEventsModuleConfig[castID] = nil casts_FastCastEventsConfig[castID] = nil casts_FastCastEvents[castID] = nil @@ -1426,7 +1383,7 @@ local SerialSimulation = {} SerialSimulation.Connection = nil :: RBXScriptConnection? -function SerialSimulation.Init(baseCastRef: any) +function SerialSimulation.Init(baseCastRef: any, events: TypeDef.FastCastEvents) if ActivesRef ~= nil then warn( "[FastCast2] SerialSimulation already initialized. Skipping." @@ -1437,6 +1394,8 @@ function SerialSimulation.Init(baseCastRef: any) BaseCastRef = baseCastRef ActivesRef = baseCastRef.Actives + + FastCastEvents = events end function SerialSimulation.Register(cast: any) @@ -1456,14 +1415,14 @@ end function SerialSimulation.Start() if SerialSimulation.Connection then - warn("[FastCast2] SerialSimulation already started.") + --warn("[FastCast2] SerialSimulation already started.") return end if RS:IsClient() then - SerialSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) + SerialSimulation.Connection = RS.PreSimulation:Connect(UpdateCasts) else - SerialSimulation.Connection = RS.Heartbeat:ConnectParallel(UpdateCasts) + SerialSimulation.Connection = RS.Heartbeat:Connect(UpdateCasts) end end @@ -1474,7 +1433,7 @@ function SerialSimulation.Stop() end end -function SerialSimulation:_UpdateEvent(name: string, newEventfn: (any) -> ()) +function SerialSimulation._UpdateEvent(name: string, newEventfn: (any) -> ()) if type(newEventfn) ~= "function" then return end From 711412840de955244f2a49add81a56f11d6710a0 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 07:44:03 +0000 Subject: [PATCH 024/140] Fix --- src/SerialSimulation.luau | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 6655c15c..21b9f60a 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -552,9 +552,6 @@ local function FireQueuedEvents( if callback then callback(cast) end - - -- TODO: Fix - caster.ActiveCastCleaner:Fire(cast.ID) end end end From c454bfddcac1b258c7241a22cf27f449d2e667ad Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 08:06:56 +0000 Subject: [PATCH 025/140] Fixes --- src/BaseCastSerial.luau | 146 ++++++++++++++++++-------------------- src/SerialSimulation.luau | 39 ++++++---- 2 files changed, 95 insertions(+), 90 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index c3690cf3..abaaa5c2 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -22,8 +22,6 @@ local EnumCastTypes = FastCastEnums.CastType It is used internally by FastCastSerial and FastCastParallel. ]=] local BaseCast = {} -BaseCast.__index = BaseCast -BaseCast.__type = "BaseCast" local DEFAULT_CACHE_SIZE = 500 local DEFAULT_CACHE_HOLDER = workspace @@ -43,13 +41,19 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT end end +local Actives = {} +local CastFirefn = nil +local Motor6DCacheInstance = nil +local ObjectCacheInstance = nil +local CurrentMovementMode = "BulkMoveTo" + function BaseCast.Init(events: any, Data: any) - local self = setmetatable({}, BaseCast) - self.Actives = {} - self.CastFirefn = events.CastFire - self.Motor6DCacheInstance = nil - self.ObjectCacheInstance = nil - self.CurrentMovementMode = "BulkMoveTo" + BaseCast.Destroy() + + CastFirefn = events.CastFire + Motor6DCacheInstance = nil + ObjectCacheInstance = nil + CurrentMovementMode = "BulkMoveTo" if Data.useObjectCache then local objectCacheArgs = Data.objectCacheArgs or {} @@ -61,21 +65,19 @@ function BaseCast.Init(events: any, Data: any) objectCacheArgs.CacheHolder = DEFAULT_CACHE_HOLDER end - self.ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any + ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any end - self.CurrentMovementMode = Data.movementMode or "BulkMoveTo" - if self.CurrentMovementMode == "Motor6D" then - self.Motor6DCacheInstance = Motor6DCache.new() + CurrentMovementMode = Data.movementMode or "BulkMoveTo" + if CurrentMovementMode == "Motor6D" then + Motor6DCacheInstance = Motor6DCache.new() end - SerialSimulation.Init(self, events) + SerialSimulation.Init(BaseCast, events) - SerialSimulation.SetMovementModeEnabled(true, self.CurrentMovementMode) + SerialSimulation.SetMovementModeEnabled(true, CurrentMovementMode) SerialSimulation.Start() - - return self end --[=[ @@ -92,22 +94,22 @@ end Create a raycast. ]=] -function BaseCast:Raycast( +function BaseCast.Raycast( Origin: Vector3, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior ) NextProjectileID += 1 - local cast = ActiveCast.createCastData(self, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { + local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Raycast - } :: any, self.ObjectCacheInstance) + } :: any, ObjectCacheInstance) SerialSimulation.Register(cast) - self.Actives[cast.ID] = cast + Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then - self.CastFirefn(cast, Origin, Direction, Velocity, Behavior) + if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then + CastFirefn(cast, Origin, Direction, Velocity, Behavior) end end @@ -125,7 +127,7 @@ end Create a Blockcast. ]=] -function BaseCast:Blockcast( +function BaseCast.Blockcast( Origin: Vector3, Size: Vector3, Direction: Vector3, @@ -134,16 +136,16 @@ function BaseCast:Blockcast( ) NextProjectileID += 1 - local cast = ActiveCast.createCastData(self, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { + local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Blockcast, Size = Size - } :: any, self.ObjectCacheInstance) + } :: any, ObjectCacheInstance) SerialSimulation.Register(cast) - self.Actives[cast.ID] = cast + Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then - self.CastFirefn(cast, Origin, Direction, Velocity, Behavior) + if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then + CastFirefn(cast, Origin, Direction, Velocity, Behavior) end end @@ -161,7 +163,7 @@ end Create a Spherecast. ]=] -function BaseCast:Spherecast( +function BaseCast.Spherecast( Origin: Vector3, Radius: number, Direction: Vector3, @@ -170,16 +172,16 @@ function BaseCast:Spherecast( ) NextProjectileID += 1 - local cast = ActiveCast.createCastData(self, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { + local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Spherecast, Radius = Radius - } :: any, self.ObjectCacheInstance) + } :: any, ObjectCacheInstance) SerialSimulation.Register(cast) - self.Actives[cast.ID] = cast + Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire and self.CastFirefn then - self.CastFirefn(cast, Origin, Direction, Velocity, Behavior) + if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then + CastFirefn(cast, Origin, Direction, Velocity, Behavior) end end @@ -194,31 +196,31 @@ end Sets the movement mode for the casts. This determines how the cast's position is updated during simulation. ]=] -function BaseCast:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") - self.CurrentMovementMode = mode +function BaseCast.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") + CurrentMovementMode = mode if mode == "Motor6D" and enabled then - if not self.Motor6DCacheInstance then - self.Motor6DCacheInstance = Motor6DCache.new() + if not Motor6DCacheInstance then + Motor6DCacheInstance = Motor6DCache.new() end else - if self.Motor6DCacheInstance then - self.Motor6DCacheInstance:Destroy() - self.Motor6DCacheInstance = nil + if Motor6DCacheInstance then + Motor6DCacheInstance:Destroy() + Motor6DCacheInstance = nil end end SerialSimulation.SetMovementModeEnabled(enabled, mode) end -function BaseCast:BindObjectCache( +function BaseCast.BindObjectCache( enabled: boolean, Template: BasePart?, CacheSize: number?, CacheHolder: Instance? ) if enabled then - if self.ObjectCacheInstance then + if ObjectCacheInstance then return end @@ -233,11 +235,11 @@ function BaseCast:BindObjectCache( if not CacheHolder then CacheHolder = DEFAULT_CACHE_HOLDER end - self.ObjectCacheInstance = ObjectCache.new(Template, CacheSize, CacheHolder) + ObjectCacheInstance = ObjectCache.new(Template, CacheSize, CacheHolder) else - if self.ObjectCacheInstance then - self.ObjectCacheInstance:Destroy() - self.ObjectCacheInstance = nil + if ObjectCacheInstance then + ObjectCacheInstance:Destroy() + ObjectCacheInstance = nil end end end @@ -250,39 +252,31 @@ end Destroys the BaseCast instance and cleans up resources. ]=] -function BaseCast:Destroy() - if self.ObjectCacheInstance then - self.ObjectCacheInstance:Destroy() +function BaseCast.Destroy() + if ObjectCacheInstance then + ObjectCacheInstance:Destroy() end - if self.Motor6DCacheInstance then - self.Motor6DCacheInstance:Destroy() + if Motor6DCacheInstance then + Motor6DCacheInstance:Destroy() end - for _, v in self.Actives do + for _, v in Actives do TerminateCast(v) end - self.Actives = {} - setmetatable(self, nil) + Actives = {} end -- Utils -function BaseCast:_TerminateCast(castID: number, castTerminatingfn: (...any) -> ()) - if self.Actives[castID] then - local cast = self.Actives[castID] - - if not cast._Destroying then - cast._Destroying = true - if castTerminatingfn then - castTerminatingfn(cast) - end - end +function BaseCast._TerminateCast(castID: number) + if Actives[castID] then + local cast = Actives[castID] if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if self.ObjectCacheInstance then - self.ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) + if ObjectCacheInstance then + ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) else cast.RayInfo.CosmeticBulletObject:Destroy() cast.RayInfo.CosmeticBulletObject = nil @@ -292,26 +286,26 @@ function BaseCast:_TerminateCast(castID: number, castTerminatingfn: (...any) -> for key, _ in (cast :: any) do cast[key] = nil end - self.Actives[castID] = nil + Actives[castID] = nil end end -function BaseCast:_GetMotor6D(projectilePart: BasePart?) - if self.Motor6DCacheInstance and projectilePart then - return self.Motor6DCacheInstance:Connect(projectilePart) +function BaseCast._GetMotor6D(projectilePart: BasePart?) + if Motor6DCacheInstance and projectilePart then + return Motor6DCacheInstance:Connect(projectilePart) end return nil end -function BaseCast:_ReturnMotor6D(motor6d: Motor6D?) - if self.Motor6DCacheInstance then - self.Motor6DCacheInstance:Disconnect(motor6d) +function BaseCast._ReturnMotor6D(motor6d: Motor6D?) + if Motor6DCacheInstance then + Motor6DCacheInstance:Disconnect(motor6d) end end -function BaseCast:_UpdateEvents(eventName: string, newEventfn: (...any) -> ()) +function BaseCast._UpdateEvents(eventName: string, newEventfn: (...any) -> ()) if eventName == "CastFire" then - self.CastFirefn = newEventfn + CastFirefn = newEventfn return end SerialSimulation._UpdateEvent(eventName, newEventfn) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 21b9f60a..b3e6cf97 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -466,7 +466,6 @@ local function FireQueuedEvents( continue end - local caster = cast.Caster local args = event.args local eventConfig = casts_FastCastEventsConfig[castID] @@ -538,15 +537,19 @@ local function FireQueuedEvents( continue end - cast._Destroying = true - if eventConfig and eventConfig.UseCastTerminating then + local _CastTerminate = BaseCastRef._TerminateCast local CastTerminating = FastCastEvents.CastTerminating + if CastTerminating then CastTerminating(cast) end + + if _CastTerminate then + _CastTerminate(castID) + end end if callback then @@ -612,7 +615,8 @@ local function QueueHit( id: number, result: RaycastResult, velocity: Vector3, - sub: boolean + sub: boolean, + castTerminatingfn: TypeDef.OnCastTerminatingFunction? ) QueueEvent( id, @@ -630,7 +634,8 @@ local function QueueHit( QueueEvent( id, - "CastTerminating" + "CastTerminating", + castTerminatingfn ) end @@ -687,6 +692,7 @@ local function ResimulateHit( targetWorldRoot: WorldRoot, castHandler: any, canPierce: TypeDef.CanPierceFunction?, + castTerminatingfn: TypeDef.OnCastTerminatingFunction?, fallbackResult: RaycastResult, fallbackVelocity: Vector3 ): boolean @@ -780,7 +786,8 @@ local function ResimulateHit( id, subResult, subVelocity, - true + true, + castTerminatingfn ) return true @@ -794,7 +801,8 @@ local function ResimulateHit( id, fallbackResult, fallbackVelocity, - false + false, + castTerminatingfn ) return true @@ -930,7 +938,8 @@ local function SimulateCast( return end - local canPierce = FastCastEvents.CanPierce + local canPiercefn = FastCastEvents.CanPierce + local castTerminatingfn = FastCastEvents.CastTerminating -- ------------------------------------------------------------------------ -- Pierce. @@ -940,7 +949,7 @@ local function SimulateCast( id, result, velocity, - canPierce + canPiercefn ) then QueuePierce( id, @@ -963,7 +972,8 @@ local function SimulateCast( if casts_IsActivelyResimulating[id] then QueueEvent( id, - "CastTerminating" + "CastTerminating", + castTerminatingfn ) warn( @@ -987,7 +997,8 @@ local function SimulateCast( acceleration, worldRoot, handler, - canPierce, + canPiercefn, + castTerminatingfn, result, velocity ) @@ -1241,7 +1252,7 @@ function SerialSimulation_Unregister(castID: number) if BaseCastRef and BaseCastRef._ReturnMotor6D then - BaseCastRef:_ReturnMotor6D( + BaseCastRef._ReturnMotor6D( casts_ActiveMotor6Ds[castID] ) end @@ -1285,7 +1296,7 @@ local function SetMovementModeEnabled( if BaseCastRef and BaseCastRef._ReturnMotor6D then - BaseCastRef:_ReturnMotor6D(motor6d) + BaseCastRef._ReturnMotor6D(motor6d) end casts_ActiveMotor6Ds[id] = nil @@ -1309,7 +1320,7 @@ local function SetMovementModeEnabled( and BaseCastRef._GetMotor6D then casts_ActiveMotor6Ds[id] = - BaseCastRef:_GetMotor6D(cosmetic) + BaseCastRef._GetMotor6D(cosmetic) end end end From 619037714962691d548b4cf512565376fbe99241 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 08:13:30 +0000 Subject: [PATCH 026/140] omg --- src/init.luau | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/init.luau b/src/init.luau index 22f3f8ed..25a6c1e5 100644 --- a/src/init.luau +++ b/src/init.luau @@ -515,7 +515,7 @@ function FastCastSerial:Init( CastTerminating = self.CastTerminating } - self.BaseCast = BaseCastSerial.Init(events, data) + BaseCastSerial.Init(events, data) self.MovementMode = movementMode or "BulkMoveTo" self.AlreadyInit = true @@ -529,7 +529,7 @@ function FastCastSerial:RaycastFire( origin: Vector3, direction: Vector3, velocity: Vector3 | number, - BehaviorData: TypeDef.FastCastBehavior? + BehaviorData: TypeDef.FastCastBehavior ) if not self.AlreadyInit then error("Please Init caster first") @@ -538,7 +538,7 @@ function FastCastSerial:RaycastFire( BehaviorData = FastCast.newBehavior() end - self.BaseCast:Raycast(origin, direction, velocity, BehaviorData) + BaseCastSerial.Raycast(origin, direction, velocity, BehaviorData) end --[=[ @@ -550,7 +550,7 @@ function FastCastSerial:BlockcastFire( Size: Vector3, direction: Vector3, velocity: Vector3 | number, - BehaviorData: TypeDef.FastCastBehavior? + BehaviorData: TypeDef.FastCastBehavior ) if not self.AlreadyInit then error("Please Init caster first") @@ -559,7 +559,7 @@ function FastCastSerial:BlockcastFire( BehaviorData = FastCast.newBehavior() end - self.BaseCast:Blockcast(origin, Size, direction, velocity, BehaviorData) + BaseCastSerial.Blockcast(origin, Size, direction, velocity, BehaviorData) end --[=[ @@ -571,7 +571,7 @@ function FastCastSerial:SpherecastFire( Radius: number, direction: Vector3, velocity: Vector3 | number, - BehaviorData: TypeDef.FastCastBehavior? + BehaviorData: TypeDef.FastCastBehavior ) if not self.AlreadyInit then error("Please Init caster first") @@ -580,7 +580,7 @@ function FastCastSerial:SpherecastFire( BehaviorData = FastCast.newBehavior() end - self.BaseCast:Spherecast(origin, Radius, direction, velocity, BehaviorData) + BaseCastSerial.Spherecast(origin, Radius, direction, velocity, BehaviorData) end --[[ @@ -594,7 +594,7 @@ end function FastCastSerial:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") if not self.BaseCast then return end - self.BaseCast:SetMovementModeEnabled(enabled, mode) + BaseCastSerial.SetMovementModeEnabled(enabled, mode) self.MovementMode = mode end @@ -604,13 +604,11 @@ end ]=] function FastCastSerial:SetObjectCacheEnabled( enabled: boolean, - Template: BasePart | Model, + Template: BasePart?, CacheSize: number, CacheHolder: Instance ) - if not self.BaseCast then return end - - self.BaseCast:BindObjectCache( + BaseCastSerial.BindObjectCache( enabled, Template, CacheSize, @@ -629,9 +627,7 @@ function FastCastSerial:Destroy() end setmetatable(self, nil) - if self.BaseCast then - self.BaseCast:Destroy() - end + BaseCastSerial.Destroy() self.LengthChanged = nil self.Hit = nil From 9c0a0e0ba3435ee74945da568ee556e1cfe8a2d1 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 08:16:48 +0000 Subject: [PATCH 027/140] Pass ActivesRef --- src/BaseCastSerial.luau | 2 +- src/SerialSimulation.luau | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index abaaa5c2..831a70e6 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -73,7 +73,7 @@ function BaseCast.Init(events: any, Data: any) Motor6DCacheInstance = Motor6DCache.new() end - SerialSimulation.Init(BaseCast, events) + SerialSimulation.Init(BaseCast, Actives, events) SerialSimulation.SetMovementModeEnabled(true, CurrentMovementMode) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index b3e6cf97..b05228ef 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1391,7 +1391,7 @@ local SerialSimulation = {} SerialSimulation.Connection = nil :: RBXScriptConnection? -function SerialSimulation.Init(baseCastRef: any, events: TypeDef.FastCastEvents) +function SerialSimulation.Init(baseCastRef: any, Actives: any, events: TypeDef.FastCastEvents) if ActivesRef ~= nil then warn( "[FastCast2] SerialSimulation already initialized. Skipping." @@ -1401,7 +1401,7 @@ function SerialSimulation.Init(baseCastRef: any, events: TypeDef.FastCastEvents) end BaseCastRef = baseCastRef - ActivesRef = baseCastRef.Actives + ActivesRef = Actives FastCastEvents = events end From 8e46f9e7faf87c05ba392eff15fcc1313b5db47a Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 14:43:46 +0000 Subject: [PATCH 028/140] Fix simulation logic bugs SerialSimulation: - Fix FireQueuedEvents CastTerminating: use BaseCastRef._TerminateCast + Unregister instead of broken gating behind UseCastTerminating - Remove dead casts_FastCastEvents state variable and module loading in Register - Add Motor6D setup in Register (missing from refactored version) - Add SoA write-back loop in UpdateCasts before main-thread work ParallelSimulation: - Fix GetEventCallbacks unpacking bug in max-distance CastTerminating path: args[1] was getting canPierce instead of terminating --- src/ParallelSimulation.luau | 3 +- src/SerialSimulation.luau | 68 ++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 0123b745..fae9dbad 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -981,10 +981,11 @@ local function SimulateCast( if not result then if casts_DistanceCovered[id] >= casts_MaxDistance[id] then + local _, castTerminatingfn = GetEventCallbacks(id) QueueEvent( id, "CastTerminating", - GetEventCallbacks(id) + castTerminatingfn ) end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index b05228ef..356f8299 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -108,8 +108,6 @@ local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_ID = {} :: { number } local casts_ID_Index = {} :: { [number]: number } -local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } - local queuedEvents: { [number]: { QueuedEventData } } = {} local queuedVisualizes: { [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } @@ -531,30 +529,8 @@ local function FireQueuedEvents( end elseif eventType == "CastTerminating" then - local callback = args[1] - - if cast._Destroying then - continue - end - - if eventConfig - and eventConfig.UseCastTerminating - then - local _CastTerminate = BaseCastRef._TerminateCast - local CastTerminating = FastCastEvents.CastTerminating - - if CastTerminating then - CastTerminating(cast) - end - - if _CastTerminate then - _CastTerminate(castID) - end - end - - if callback then - callback(cast) - end + BaseCastRef._TerminateCast(castID) + SerialSimulation_Unregister(castID) end end end @@ -1193,17 +1169,6 @@ function SerialSimulation_Register(cast: any) table.insert(casts_ID, id) casts_ID_Index[id] = #casts_ID - -- Load optional event module. - local eventModule = cast.RayInfo.FastCastEventsModule - - if typeof(eventModule) == "Instance" - and eventModule:IsA("ModuleScript") - then - casts_FastCastEvents[id] = require(eventModule) - else - casts_FastCastEvents[id] = nil - end - local trajectory = casts_Trajectory[id] local position = GetPositionAtTime( @@ -1216,6 +1181,20 @@ function SerialSimulation_Register(cast: any) casts_CFrame[id] = CFrame.new(position) cast.CFrame = casts_CFrame[id] + if CurrentMovementMode == "Motor6D" + and MovementEnabled + then + local cosmetic = casts_RayInfo[id].CosmeticBulletObject + + if cosmetic + and BaseCastRef + and BaseCastRef._GetMotor6D + then + casts_ActiveMotor6Ds[id] = + BaseCastRef._GetMotor6D(cosmetic) + end + end + queuedEvents[id] = {} end @@ -1233,7 +1212,6 @@ function SerialSimulation_Unregister(castID: number) casts_Trajectory[castID] = nil casts_FastCastEventsConfig[castID] = nil - casts_FastCastEvents[castID] = nil casts_VisualizeCasts[castID] = nil casts_VisualizeCastSettings[castID] = nil @@ -1361,6 +1339,20 @@ local function UpdateCasts(delta: number) end end + -- ========================================================================= + -- Write-back SoA -> ActiveCast so ActivesRef[id] has current values + -- ========================================================================= + + for _, id in casts_ID do + local cast = ActivesRef[id] + + if cast then + cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] + cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] + cast.CFrame = casts_CFrame[id] + end + end + -- ========================================================================= -- Main-thread-only work. -- ========================================================================= From b4cbaa3e69786018f51332ab3bb0b0ed64e8ac28 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 15:03:21 +0000 Subject: [PATCH 029/140] Add NOTE --- src/ParallelSimulation.luau | 3 +++ src/SerialSimulation.luau | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index fae9dbad..1dee67f7 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1523,6 +1523,9 @@ function ParallelSimulation.Start() return end + --[[ + You can edit RunService event, if you want + ]] if RS:IsClient() then ParallelSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) else diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 356f8299..249985b9 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1419,6 +1419,9 @@ function SerialSimulation.Start() return end + --[[ + You can edit RunService event, if you want + ]] if RS:IsClient() then SerialSimulation.Connection = RS.PreSimulation:Connect(UpdateCasts) else From d63b329a2643a421bd9534ceb209cb8a627c4d74 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 15 Sep 2026 16:35:55 +0000 Subject: [PATCH 030/140] Fix Serial CastTerminating: invoke user callback before cleanup The CastTerminating handler in SerialSimulation was dropping args[1] (the user's callback) entirely. Now calls it before _TerminateCast, matching ParallelSimulation's behavior. --- src/SerialSimulation.luau | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 249985b9..8e6e34ac 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -529,6 +529,10 @@ local function FireQueuedEvents( end elseif eventType == "CastTerminating" then + local callback = args[1] + if callback then + callback(cast) + end BaseCastRef._TerminateCast(castID) SerialSimulation_Unregister(castID) end From fe606b766b10f72a3adfd45eddfec386456f391b Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 05:40:47 +0000 Subject: [PATCH 031/140] RS:BindToSimulation --- src/ParallelSimulation.luau | 12 +++++------- src/SerialSimulation.luau | 11 +++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 1dee67f7..cb4ca792 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -1522,15 +1522,13 @@ function ParallelSimulation.Start() warn("[FastCast2] ParallelSimulation already started.") return end - --[[ - You can edit RunService event, if you want + You can edit Hz60, Hz30, Hz15, Hz7_5, Hz5, Hz3, Hz1 here, if you want ]] - if RS:IsClient() then - ParallelSimulation.Connection = RS.PreSimulation:ConnectParallel(UpdateCasts) - else - ParallelSimulation.Connection = RS.Heartbeat:ConnectParallel(UpdateCasts) - end + ParallelSimulation.Connection = RS:BindToSimulation(function(dt) + task.desynchronize() + UpdateCasts(dt) + end, Enum.StepFrequency.Hz60) end function ParallelSimulation.Stop() diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 8e6e34ac..66b4502f 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1424,13 +1424,12 @@ function SerialSimulation.Start() end --[[ - You can edit RunService event, if you want + You can edit Hz60, Hz30, Hz15, Hz7_5, Hz5, Hz3, Hz1 here, if you want ]] - if RS:IsClient() then - SerialSimulation.Connection = RS.PreSimulation:Connect(UpdateCasts) - else - SerialSimulation.Connection = RS.Heartbeat:Connect(UpdateCasts) - end + SerialSimulation.Connection = RS:BindToSimulation(function(dt) + task.desynchronize() + UpdateCasts(dt) + end, Enum.StepFrequency.Hz60) end function SerialSimulation.Stop() From 69081bb6302fa6eb4671621dc98e590d33d5206c Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 06:27:08 +0000 Subject: [PATCH 032/140] Improve benchmarks scripts --- benchmarks/BenchmarkClient.client.luau | 18 +++++++++--------- benchmarks/BenchmarkServer.server.luau | 14 ++++++++------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index 4b74c578..5d72b158 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -68,9 +68,7 @@ local BENCHMARK_COOLDOWN = 2.5 local FastCastModule = ReplicatedStorage:WaitForChild("FastCast2") local FastCast = require(FastCastModule) -local FastCastEnums = require( - FastCastModule:WaitForChild("FastCastEnums") -) +local FastCastEnums = require(FastCastModule:WaitForChild("FastCastEnums")) --// Objects @@ -361,7 +359,7 @@ end local function fireProjectiles() for _ = 1, Config.ProjectileAmount do - fireProjectile() + task.spawn(fireProjectile) end end @@ -369,13 +367,15 @@ end local function cleanupCasts() for i = #ActiveCasts, 1, -1 do - local cast = ActiveCasts[i] + task.spawn(function() + local cast = ActiveCasts[i] - if cast then - FastCast:TerminateCast(cast) - end + if cast then + FastCast:TerminateCast(cast) + end - ActiveCasts[i] = nil + ActiveCasts[i] = nil + end) end end diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index 799947e5..f6b5529b 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -255,13 +255,15 @@ end local function terminateActiveCasts() for i = #ActiveCasts, 1, -1 do - local cast = ActiveCasts[i] + task.spawn(function() + local cast = ActiveCasts[i] - if cast then - FastCast:TerminateCast(cast) - end + if cast then + FastCast:TerminateCast(cast) + end - ActiveCasts[i] = nil + ActiveCasts[i] = nil + end) end end @@ -373,7 +375,7 @@ end local function fireProjectiles() for _ = 1, Config.ProjectileAmount do - fireProjectile() + task.spawn(fireProjectile) end end From 59c2b69f73ec9eade168fa192a08070005bb7809 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 08:38:03 +0000 Subject: [PATCH 033/140] Performance: optimize SerialSimulation event system and allocations Key changes: - Flatten event queue from nested per-cast tables to flat array - Use numeric event IDs (EVT_HIT etc) instead of string comparison - Pool all allocations (event entries, args tables, vis entries) via pre-allocated pools - zero allocation after warmup - Eliminate per-frame table swap (no more queuedEvents = {}) - Eliminate sorted ID iteration (no more table.sort) - Pre-allocate BulkMoveTo arrays with capacity - Merge SoA write-back into simulation loop (one iteration instead of two) - Pre-allocate BulkMoveTo arrays to avoid dynamic growth These match the patterns used by SwiftCast which runs faster despite being single-threaded. --- src/SerialSimulation.luau | 462 +++++++++++++++++++------------------- 1 file changed, 237 insertions(+), 225 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 66b4502f..75b2f230 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -53,10 +53,15 @@ type CastVisualizerVariants = | BlockVisualizerVariant | SphereVisualizerVariant -type QueuedEventData = { - eventType: string, - args: { any } -} +-- Event type constants (numeric IDs avoid string comparison in hot path) +local EVT_LENGTH_CHANGED = 1 +local EVT_HIT = 2 +local EVT_PIERCED = 3 +local EVT_CAST_TERMINATING = 4 + +type QueuedEvent = { number | string | { any } } +-- Layout: { castID, eventType, args } +-- args is a reusable table { arg1, arg2, ... } type QueuedVisualizeCastData = { castStartCFrame: CFrame, @@ -69,6 +74,8 @@ type QueuedVisualizeHitData = { sub: boolean } +type QueuedVis = { any } + -- ============================================================================ -- State -- ============================================================================ @@ -108,10 +115,15 @@ local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_ID = {} :: { number } local casts_ID_Index = {} :: { [number]: number } -local queuedEvents: { [number]: { QueuedEventData } } = {} -local queuedVisualizes: { - [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } -} = {} +-- Flat event queue: { castID, eventType, argsTable } +-- Reused every frame via table.clear +local queuedEvents: { QueuedEvent } = {} +local queuedEventsCount: number = 0 + +-- Flat visualization queue: { castID, data } +-- Reused every frame via table.clear +local queuedVisualizes: { QueuedVis } = {} +local queuedVisCount: number = 0 local ActivesRef: any = nil local BaseCastRef: any = nil @@ -308,242 +320,247 @@ local Visualizers = { -- Queued visualization -- ============================================================================ -local function QueueVisualize( - castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData -) - if not casts_VisualizeCasts[castID] then - return - end +-- ============================================================================ +-- Pre-allocated pools (reused every frame, never freed) +-- ============================================================================ - local queue = queuedVisualizes[castID] +local argsPool: { { any } } = {} +local argsPoolCount: number = 0 - if not queue then - queue = {} - queuedVisualizes[castID] = queue +local eventPool: { QueuedEvent } = {} +local eventPoolCount: number = 0 + +local function GetArgs(): { any } + argsPoolCount += 1 + if argsPoolCount > #argsPool then + argsPool[argsPoolCount] = {} end + return argsPool[argsPoolCount] +end - table.insert(queue, data) +local function GetEvent(): QueuedEvent + eventPoolCount += 1 + if eventPoolCount > #eventPool then + eventPool[eventPoolCount] = { 0, 0, nil } + end + return eventPool[eventPoolCount] end -local function FireQueuedVisualizes( - visualizes: { - [number]: { - QueuedVisualizeCastData | QueuedVisualizeHitData - } - } -) - local sortedIDs = {} +local visPool: { QueuedVis } = {} +local visPoolCount: number = 0 - for id in visualizes do - table.insert(sortedIDs, id) +local function GetVis(): QueuedVis + visPoolCount += 1 + if visPoolCount > #visPool then + visPool[visPoolCount] = { 0, nil } end + return visPool[visPoolCount] +end - table.sort(sortedIDs) +local function ResetPools() + argsPoolCount = 0 + eventPoolCount = 0 + visPoolCount = 0 +end - for _, castID in sortedIDs do - local list = visualizes[castID] +-- ============================================================================ +-- Queued visualizations (flat, allocation-free) +-- ============================================================================ - if not list then - continue - end +local function QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData +) + if not casts_VisualizeCasts[castID] then + return + end - local settings = casts_VisualizeCastSettings[castID] + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + queuedVisualizes[queuedVisCount] = entry +end +local function FireQueuedVisualizes() + for i = 1, queuedVisCount do + local entry = queuedVisualizes[i] + local castID = entry[1] :: number + local data = entry[2] + + local settings = casts_VisualizeCastSettings[castID] if not settings then continue end - for _, data in list do - -- Hit visualization - if data.atCF then - if DebugLogging.Hit - and not data.wasPierce - and not data.sub - then - print("Hit was successful. Terminating.") - end + -- Hit visualization + if (data :: any).atCF then + if DebugLogging.Hit + and not (data :: QueuedVisualizeHitData).wasPierce + and not (data :: QueuedVisualizeHitData).sub + then + print("Hit was successful. Terminating.") + end - if DebugLogging.Pierced - and data.wasPierce - and not data.sub - then - print("Piercing function returned TRUE to pierce this part.") - end + if DebugLogging.Pierced + and (data :: QueuedVisualizeHitData).wasPierce + and not (data :: QueuedVisualizeHitData).sub + then + print("Piercing function returned TRUE to pierce this part.") + end - DbgVisualizeHit( - data.atCF, - data.wasPierce, - settings - ) + DbgVisualizeHit( + (data :: QueuedVisualizeHitData).atCF, + (data :: QueuedVisualizeHitData).wasPierce, + settings + ) - continue - end + continue + end - -- Segment visualization - if DebugLogging.Segment and data.sub then - print("Subcast visualization") - end + -- Segment visualization + if DebugLogging.Segment and (data :: QueuedVisualizeCastData).sub then + print("Subcast visualization") + end - local castType = casts_CastType[castID] - local visualizer = Visualizers[castType] + local castType = casts_CastType[castID] + local visualizer = Visualizers[castType] - if not visualizer then - continue - end + if not visualizer then + continue + end - local variant: CastVisualizerVariants - - if castType == EnumCastTypes.Raycast then - variant = { - castLength = casts_RayDisplacement[castID] or 1 - } - elseif castType == EnumCastTypes.Blockcast then - variant = { - size = (casts_CastVariant[castID] :: BlockcastVariant).Size - } - else - variant = { - radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius - } - end + local variant: CastVisualizerVariants - visualizer( - data.castStartCFrame, - settings, - variant - ) + if castType == EnumCastTypes.Raycast then + variant = { + castLength = casts_RayDisplacement[castID] or 1 + } + elseif castType == EnumCastTypes.Blockcast then + variant = { + size = (casts_CastVariant[castID] :: BlockcastVariant).Size + } + else + variant = { + radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius + } end + + visualizer( + (data :: QueuedVisualizeCastData).castStartCFrame, + settings, + variant + ) end + + queuedVisCount = 0 end -- ============================================================================ --- Queued events +-- Queued events (flat, allocation-free) -- ============================================================================ local function QueueEvent( castID: number, - eventType: string, + eventType: number, ...: any ) - local queue = queuedEvents[castID] - - if not queue then - queue = {} - queuedEvents[castID] = queue + local args = GetArgs() + local n = select("#", ...) + for i = 1, n do + args[i] = select(i, ...) end - - table.insert(queue, { - eventType = eventType, - args = { ... } - }) -end - -local function FireQueuedEvents( - events: { [number]: { QueuedEventData } } -) - local sortedIDs = {} - - for id in events do - table.insert(sortedIDs, id) + -- Clear leftover args from previous use + for i = n + 1, #args do + args[i] = nil end - table.sort(sortedIDs) + queuedEventsCount += 1 + local entry = GetEvent() + entry[1] = castID + entry[2] = eventType + entry[3] = args + queuedEvents[queuedEventsCount] = entry +end - for _, castID in sortedIDs do - local eventList = events[castID] +local function FireQueuedEvents() + for i = 1, queuedEventsCount do + local entry = queuedEvents[i] + local castID = entry[1] :: number + local eventType = entry[2] :: number + local args = entry[3] :: { any } - if not eventList then + local cast = ActivesRef[castID] + if not cast then continue end - for _, event in eventList do - local cast = ActivesRef[castID] - - if not cast then - continue - end - - local args = event.args - - local eventConfig = casts_FastCastEventsConfig[castID] - - local eventType = event.eventType - - if eventType == "LengthChanged" then - local lastPoint = args[1] - local rayDirection = args[2] - local displacement = args[3] - local velocity = args[4] - local cosmetic = args[5] - - if eventConfig and eventConfig.UseLengthChanged then - - local lengthChanged = FastCastEvents.LengthChanged - if lengthChanged then - lengthChanged( - cast, - lastPoint, - rayDirection, - displacement, - velocity, - cosmetic - ) - end + local eventConfig = casts_FastCastEventsConfig[castID] + + if eventType == EVT_LENGTH_CHANGED then + if eventConfig and eventConfig.UseLengthChanged then + local lengthChanged = FastCastEvents.LengthChanged + if lengthChanged then + lengthChanged( + cast, + args[1], + args[2], + args[3], + args[4], + args[5] + ) end + end - elseif eventType == "Hit" then - local result = args[1] - local velocity = args[2] - local cosmetic = args[3] - - if eventConfig and eventConfig.UseHit then - - local Hit = FastCastEvents.Hit - if Hit then - Hit( - cast, - result, - velocity, - cosmetic - ) - end + elseif eventType == EVT_HIT then + if eventConfig and eventConfig.UseHit then + local Hit = FastCastEvents.Hit + if Hit then + Hit( + cast, + args[1], + args[2], + args[3] + ) end + end - elseif eventType == "Pierced" then - local result = args[1] - local velocity = args[2] - local cosmetic = args[3] - - if eventConfig and eventConfig.UsePierced then - - local Pierced = FastCastEvents.Pierced - if Pierced then - Pierced( - cast, - result, - velocity, - cosmetic - ) - end + elseif eventType == EVT_PIERCED then + if eventConfig and eventConfig.UsePierced then + local Pierced = FastCastEvents.Pierced + if Pierced then + Pierced( + cast, + args[1], + args[2], + args[3] + ) end + end - elseif eventType == "CastTerminating" then - local callback = args[1] - if callback then - callback(cast) - end - BaseCastRef._TerminateCast(castID) - SerialSimulation_Unregister(castID) + elseif eventType == EVT_CAST_TERMINATING then + local callback = args[1] + if callback then + callback(cast) end + BaseCastRef._TerminateCast(castID) + SerialSimulation_Unregister(castID) end end + + ResetPools() + queuedEventsCount = 0 end -- ============================================================================ -- Movement -- ============================================================================ +-- Pre-allocated arrays for BulkMoveTo (reused every frame) +local bulkParts: { BasePart } = {} +local bulkCframes: { CFrame } = {} +local bulkCount: number = 0 + local function BulkMoveTo() if CurrentMovementMode ~= "BulkMoveTo" or not MovementEnabled @@ -551,23 +568,23 @@ local function BulkMoveTo() return end - local parts = table.create(#casts_ID) - local cframes = table.create(#casts_ID) + bulkCount = 0 for _, id in casts_ID do local rayInfo = casts_RayInfo[id] local cosmetic = rayInfo and rayInfo.CosmeticBulletObject if cosmetic and cosmetic.Parent then - table.insert(parts, cosmetic) - table.insert(cframes, casts_CFrame[id]) + bulkCount += 1 + bulkParts[bulkCount] = cosmetic + bulkCframes[bulkCount] = casts_CFrame[id] end end - if #parts > 0 then + if bulkCount > 0 then workspace:BulkMoveTo( - parts, - cframes, + bulkParts, + bulkCframes, Enum.BulkMoveMode.FireCFrameChanged ) end @@ -600,7 +617,7 @@ local function QueueHit( ) QueueEvent( id, - "Hit", + EVT_HIT, result, velocity, casts_RayInfo[id].CosmeticBulletObject @@ -614,7 +631,7 @@ local function QueueHit( QueueEvent( id, - "CastTerminating", + EVT_CAST_TERMINATING, castTerminatingfn ) end @@ -627,7 +644,7 @@ local function QueuePierce( ) QueueEvent( id, - "Pierced", + EVT_PIERCED, result, velocity, casts_RayInfo[id].CosmeticBulletObject @@ -875,7 +892,7 @@ local function SimulateCast( QueueEvent( id, - "LengthChanged", + EVT_LENGTH_CHANGED, lastPoint, direction.Unit, rayDisplacement, @@ -903,7 +920,7 @@ local function SimulateCast( if casts_DistanceCovered[id] >= casts_MaxDistance[id] then QueueEvent( id, - "CastTerminating" + EVT_CAST_TERMINATING ) end @@ -952,7 +969,7 @@ local function SimulateCast( if casts_IsActivelyResimulating[id] then QueueEvent( id, - "CastTerminating", + EVT_CAST_TERMINATING, castTerminatingfn ) @@ -1005,7 +1022,7 @@ local function SimulateAlwaysHighFidelity( if casts_IsActivelyResimulating[id] then QueueEvent( id, - "CastTerminating" + EVT_CAST_TERMINATING ) warn( @@ -1198,8 +1215,6 @@ function SerialSimulation_Register(cast: any) BaseCastRef._GetMotor6D(cosmetic) end end - - queuedEvents[id] = {} end function SerialSimulation_Unregister(castID: number) @@ -1253,9 +1268,6 @@ function SerialSimulation_Unregister(castID: number) table.remove(casts_ID, #casts_ID) casts_ID_Index[castID] = nil end - - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil end -- ============================================================================ @@ -1341,15 +1353,8 @@ local function UpdateCasts(delta: number) delta ) end - end - - -- ========================================================================= - -- Write-back SoA -> ActiveCast so ActivesRef[id] has current values - -- ========================================================================= - - for _, id in casts_ID do - local cast = ActivesRef[id] + -- Write-back SoA -> ActiveCast (merged to avoid second iteration) if cast then cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] @@ -1364,19 +1369,8 @@ local function UpdateCasts(delta: number) UpdateMotor6Ds() BulkMoveTo() - local eventsToProcess = queuedEvents - queuedEvents = {} - - local visualizesToProcess = queuedVisualizes - queuedVisualizes = {} - - FireQueuedVisualizes( - visualizesToProcess - ) - - FireQueuedEvents( - eventsToProcess - ) + FireQueuedVisualizes() + FireQueuedEvents() end -- ============================================================================ @@ -1447,4 +1441,22 @@ function SerialSimulation._UpdateEvent(name: string, newEventfn: (any) -> ()) FastCastEvents[name] = newEventfn end +function SerialSimulation.QueueEvent(castID: number, eventType: string, ...: any) + -- Public wrapper for external callers (e.g. init.luau TerminateCast) + -- Map string event type to numeric constant + local numericType: number + if eventType == "CastTerminating" then + numericType = EVT_CAST_TERMINATING + elseif eventType == "Hit" then + numericType = EVT_HIT + elseif eventType == "Pierced" then + numericType = EVT_PIERCED + elseif eventType == "LengthChanged" then + numericType = EVT_LENGTH_CHANGED + else + return + end + QueueEvent(castID, numericType, ...) +end + return SerialSimulation From 5cca89b2de3cbd2d38e166c0cce7400a2bd2be27 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 08:59:21 +0000 Subject: [PATCH 034/140] Performance: optimize ParallelSimulation event system and allocations Mirrors SerialSimulation optimizations: - Flatten event queue from nested per-cast tables to flat array - Use numeric event IDs (EVT_HIT etc) instead of string comparison - Pool all allocations (event entries, args tables, vis entries) via pre-allocated pools - zero allocation after warmup - Eliminate per-frame table swap (no more queuedEvents = {}) - Eliminate sorted ID iteration (no more table.sort) - Pre-allocate BulkMoveTo arrays with capacity - Merge SoA write-back into simulation loop (one iteration instead of two) --- src/ParallelSimulation.luau | 576 ++++++++++++++++++------------------ 1 file changed, 292 insertions(+), 284 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index cb4ca792..05b879b4 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -53,10 +53,15 @@ type CastVisualizerVariants = | BlockVisualizerVariant | SphereVisualizerVariant -type QueuedEventData = { - eventType: string, - args: { any } -} +-- Event type constants (numeric IDs avoid string comparison in hot path) +local EVT_LENGTH_CHANGED = 1 +local EVT_HIT = 2 +local EVT_PIERCED = 3 +local EVT_CAST_TERMINATING = 4 + +type QueuedEvent = { number | string | { any } } +-- Layout: { castID, eventType, args } +-- args is a reusable table { arg1, arg2, ... } type QueuedVisualizeCastData = { castStartCFrame: CFrame, @@ -69,6 +74,8 @@ type QueuedVisualizeHitData = { sub: boolean } +type QueuedVis = { any } + -- ============================================================================ -- State -- ============================================================================ @@ -113,10 +120,15 @@ local casts_ID_Index = {} :: { [number]: number } local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } -local queuedEvents: { [number]: { QueuedEventData } } = {} -local queuedVisualizes: { - [number]: { QueuedVisualizeCastData | QueuedVisualizeHitData } -} = {} +-- Flat event queue: { castID, eventType, argsTable } +-- Reused every frame via table.clear +local queuedEvents: { QueuedEvent } = {} +local queuedEventsCount: number = 0 + +-- Flat visualization queue: { castID, data } +-- Reused every frame via table.clear +local queuedVisualizes: { QueuedVis } = {} +local queuedVisCount: number = 0 local ActivesRef: any = nil local BaseCastRef: any = nil @@ -308,289 +320,296 @@ local Visualizers = { } -- ============================================================================ --- Queued visualization +-- Pre-allocated pools (reused every frame, never freed) -- ============================================================================ -local function QueueVisualize( - castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData -) - if not casts_VisualizeCasts[castID] then - return - end +local argsPool: { { any } } = {} +local argsPoolCount: number = 0 - local queue = queuedVisualizes[castID] +local eventPool: { QueuedEvent } = {} +local eventPoolCount: number = 0 - if not queue then - queue = {} - queuedVisualizes[castID] = queue +local function GetArgs(): { any } + argsPoolCount += 1 + if argsPoolCount > #argsPool then + argsPool[argsPoolCount] = {} end + return argsPool[argsPoolCount] +end - table.insert(queue, data) +local function GetEvent(): QueuedEvent + eventPoolCount += 1 + if eventPoolCount > #eventPool then + eventPool[eventPoolCount] = { 0, 0, nil } + end + return eventPool[eventPoolCount] end -local function FireQueuedVisualizes( - visualizes: { - [number]: { - QueuedVisualizeCastData | QueuedVisualizeHitData - } - } -) - local sortedIDs = {} +local visPool: { QueuedVis } = {} +local visPoolCount: number = 0 - for id in visualizes do - table.insert(sortedIDs, id) +local function GetVis(): QueuedVis + visPoolCount += 1 + if visPoolCount > #visPool then + visPool[visPoolCount] = { 0, nil } end + return visPool[visPoolCount] +end - table.sort(sortedIDs) +local function ResetPools() + argsPoolCount = 0 + eventPoolCount = 0 + visPoolCount = 0 +end - for _, castID in sortedIDs do - local list = visualizes[castID] +-- ============================================================================ +-- Queued visualizations (flat, allocation-free) +-- ============================================================================ - if not list then - continue - end +local function QueueVisualize( + castID: number, + data: QueuedVisualizeCastData | QueuedVisualizeHitData +) + if not casts_VisualizeCasts[castID] then + return + end - local settings = casts_VisualizeCastSettings[castID] + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + queuedVisualizes[queuedVisCount] = entry +end + +local function FireQueuedVisualizes() + for i = 1, queuedVisCount do + local entry = queuedVisualizes[i] + local castID = entry[1] :: number + local data = entry[2] + local settings = casts_VisualizeCastSettings[castID] if not settings then continue end - for _, data in list do - -- Hit visualization - if data.atCF then - if DebugLogging.Hit - and not data.wasPierce - and not data.sub - then - print("Hit was successful. Terminating.") - end - - if DebugLogging.Pierced - and data.wasPierce - and not data.sub - then - print("Piercing function returned TRUE to pierce this part.") - end - - DbgVisualizeHit( - data.atCF, - data.wasPierce, - settings - ) - - continue + -- Hit visualization + if (data :: any).atCF then + if DebugLogging.Hit + and not (data :: QueuedVisualizeHitData).wasPierce + and not (data :: QueuedVisualizeHitData).sub + then + print("Hit was successful. Terminating.") end - -- Segment visualization - if DebugLogging.Segment and data.sub then - print("Subcast visualization") + if DebugLogging.Pierced + and (data :: QueuedVisualizeHitData).wasPierce + and not (data :: QueuedVisualizeHitData).sub + then + print("Piercing function returned TRUE to pierce this part.") end - local castType = casts_CastType[castID] - local visualizer = Visualizers[castType] + DbgVisualizeHit( + (data :: QueuedVisualizeHitData).atCF, + (data :: QueuedVisualizeHitData).wasPierce, + settings + ) - if not visualizer then - continue - end + continue + end - local variant: CastVisualizerVariants - - if castType == EnumCastTypes.Raycast then - variant = { - castLength = casts_RayDisplacement[castID] or 1 - } - elseif castType == EnumCastTypes.Blockcast then - variant = { - size = (casts_CastVariant[castID] :: BlockcastVariant).Size - } - else - variant = { - radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius - } - end + -- Segment visualization + if DebugLogging.Segment and (data :: QueuedVisualizeCastData).sub then + print("Subcast visualization") + end - visualizer( - data.castStartCFrame, - settings, - variant - ) + local castType = casts_CastType[castID] + local visualizer = Visualizers[castType] + + if not visualizer then + continue + end + + local variant: CastVisualizerVariants + + if castType == EnumCastTypes.Raycast then + variant = { + castLength = casts_RayDisplacement[castID] or 1 + } + elseif castType == EnumCastTypes.Blockcast then + variant = { + size = (casts_CastVariant[castID] :: BlockcastVariant).Size + } + else + variant = { + radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius + } end + + visualizer( + (data :: QueuedVisualizeCastData).castStartCFrame, + settings, + variant + ) end end -- ============================================================================ --- Queued events +-- Queued events (flat, allocation-free) -- ============================================================================ local function QueueEvent( castID: number, - eventType: string, + eventType: number, ...: any ) - local queue = queuedEvents[castID] - - if not queue then - queue = {} - queuedEvents[castID] = queue + queuedEventsCount += 1 + local entry = GetEvent() + local args = GetArgs() + local n = select("#", ...) + for i = 1, n do + args[i] = select(i, ...) end - - table.insert(queue, { - eventType = eventType, - args = { ... } - }) -end - -local function FireQueuedEvents( - events: { [number]: { QueuedEventData } } -) - local sortedIDs = {} - - for id in events do - table.insert(sortedIDs, id) + for i = n + 1, #args do + args[i] = nil end + entry[1] = castID + entry[2] = eventType + entry[3] = args + queuedEvents[queuedEventsCount] = entry +end - table.sort(sortedIDs) +local function FireQueuedEvents() + for i = 1, queuedEventsCount do + local entry = queuedEvents[i] + local castID = entry[1] :: number + local numericType = entry[2] :: number + local args = entry[3] :: { any } - for _, castID in sortedIDs do - local eventList = events[castID] + local cast = ActivesRef[castID] - if not eventList then + if not cast then continue end - for _, event in eventList do - local cast = ActivesRef[castID] + local caster = cast.Caster + + local moduleConfig = casts_FastCastEventsModuleConfig[castID] + local eventConfig = casts_FastCastEventsConfig[castID] + local fastCastEvents = casts_FastCastEvents[castID] + + if numericType == EVT_LENGTH_CHANGED then + local lastPoint = args[1] + local rayDirection = args[2] + local displacement = args[3] + local velocity = args[4] + local cosmetic = args[5] + + if eventConfig and eventConfig.UseLengthChanged then + caster.Output:Fire( + "LengthChanged", + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) + end + + if moduleConfig + and moduleConfig.UseLengthChanged + and fastCastEvents + and fastCastEvents.LengthChanged + then + fastCastEvents.LengthChanged( + cast, + lastPoint, + rayDirection, + displacement, + velocity, + cosmetic + ) + end + + elseif numericType == EVT_HIT then + local result = args[1] + local velocity = args[2] + local cosmetic = args[3] + + if eventConfig and eventConfig.UseHit then + caster.Output:Fire( + "Hit", + cast, + result, + velocity, + cosmetic + ) + end + + if moduleConfig + and moduleConfig.UseHit + and fastCastEvents + and fastCastEvents.Hit + then + fastCastEvents.Hit( + cast, + result, + velocity, + cosmetic + ) + end + + elseif numericType == EVT_PIERCED then + local result = args[1] + local velocity = args[2] + local cosmetic = args[3] + + if eventConfig and eventConfig.UsePierced then + caster.Output:Fire( + "Pierced", + cast, + result, + velocity, + cosmetic + ) + end + + if moduleConfig + and moduleConfig.UsePierced + and fastCastEvents + and fastCastEvents.Pierced + then + fastCastEvents.Pierced( + cast, + result, + velocity, + cosmetic + ) + end + + elseif numericType == EVT_CAST_TERMINATING then + local callback = args[1] - if not cast then + if cast._Destroying then continue end - local caster = cast.Caster - local args = event.args - - local moduleConfig = casts_FastCastEventsModuleConfig[castID] - - local eventConfig = casts_FastCastEventsConfig[castID] - - local fastCastEvents = casts_FastCastEvents[castID] - local eventType = event.eventType - - if eventType == "LengthChanged" then - local lastPoint = args[1] - local rayDirection = args[2] - local displacement = args[3] - local velocity = args[4] - local cosmetic = args[5] - - if eventConfig and eventConfig.UseLengthChanged then - caster.Output:Fire( - "LengthChanged", - cast, - lastPoint, - rayDirection, - displacement, - velocity, - cosmetic - ) - end - - if moduleConfig - and moduleConfig.UseLengthChanged - and fastCastEvents - and fastCastEvents.LengthChanged - then - fastCastEvents.LengthChanged( - cast, - lastPoint, - rayDirection, - displacement, - velocity, - cosmetic - ) - end - - elseif eventType == "Hit" then - local result = args[1] - local velocity = args[2] - local cosmetic = args[3] - - if eventConfig and eventConfig.UseHit then - caster.Output:Fire( - "Hit", - cast, - result, - velocity, - cosmetic - ) - end - - if moduleConfig - and moduleConfig.UseHit - and fastCastEvents - and fastCastEvents.Hit - then - fastCastEvents.Hit( - cast, - result, - velocity, - cosmetic - ) - end - - elseif eventType == "Pierced" then - local result = args[1] - local velocity = args[2] - local cosmetic = args[3] - - if eventConfig and eventConfig.UsePierced then - caster.Output:Fire( - "Pierced", - cast, - result, - velocity, - cosmetic - ) - end - - if moduleConfig - and moduleConfig.UsePierced - and fastCastEvents - and fastCastEvents.Pierced - then - fastCastEvents.Pierced( - cast, - result, - velocity, - cosmetic - ) - end - - elseif eventType == "CastTerminating" then - local callback = args[1] - - if cast._Destroying then - continue - end - - cast._Destroying = true - - if eventConfig - and eventConfig.UseCastTerminating - then - caster.Output:Fire( - "CastTerminating", - cast - ) - end - - if callback then - callback(cast) - end - - caster.ActiveCastCleaner:Fire(cast.ID) + cast._Destroying = true + + if eventConfig + and eventConfig.UseCastTerminating + then + caster.Output:Fire( + "CastTerminating", + cast + ) + end + + if callback then + callback(cast) end + + caster.ActiveCastCleaner:Fire(cast.ID) end end end @@ -599,6 +618,11 @@ end -- Movement -- ============================================================================ +-- Pre-allocated BulkMoveTo arrays (reused every frame) +local bulkParts: { Instance } = {} +local bulkCframes: { CFrame } = {} +local bulkCount: number = 0 + local function BulkMoveTo() if CurrentMovementMode ~= "BulkMoveTo" or not MovementEnabled @@ -606,23 +630,23 @@ local function BulkMoveTo() return end - local parts = table.create(#casts_ID) - local cframes = table.create(#casts_ID) + bulkCount = 0 for _, id in casts_ID do local rayInfo = casts_RayInfo[id] local cosmetic = rayInfo and rayInfo.CosmeticBulletObject if cosmetic and cosmetic.Parent then - table.insert(parts, cosmetic) - table.insert(cframes, casts_CFrame[id]) + bulkCount += 1 + bulkParts[bulkCount] = cosmetic + bulkCframes[bulkCount] = casts_CFrame[id] end end - if #parts > 0 then + if bulkCount > 0 then workspace:BulkMoveTo( - parts, - cframes, + bulkParts, + bulkCframes, Enum.BulkMoveMode.FireCFrameChanged ) end @@ -680,7 +704,7 @@ local function QueueHit( ) QueueEvent( id, - "Hit", + EVT_HIT, result, velocity, casts_RayInfo[id].CosmeticBulletObject @@ -694,7 +718,7 @@ local function QueueHit( QueueEvent( id, - "CastTerminating", + EVT_CAST_TERMINATING, castTerminatingfn ) end @@ -707,7 +731,7 @@ local function QueuePierce( ) QueueEvent( id, - "Pierced", + EVT_PIERCED, result, velocity, casts_RayInfo[id].CosmeticBulletObject @@ -955,7 +979,7 @@ local function SimulateCast( QueueEvent( id, - "LengthChanged", + EVT_LENGTH_CHANGED, lastPoint, direction.Unit, rayDisplacement, @@ -984,7 +1008,7 @@ local function SimulateCast( local _, castTerminatingfn = GetEventCallbacks(id) QueueEvent( id, - "CastTerminating", + EVT_CAST_TERMINATING, castTerminatingfn ) end @@ -1033,7 +1057,7 @@ local function SimulateCast( if casts_IsActivelyResimulating[id] then QueueEvent( id, - "CastTerminating", + EVT_CAST_TERMINATING, castTerminatingfn ) @@ -1089,7 +1113,7 @@ local function SimulateAlwaysHighFidelity( QueueEvent( id, - "CastTerminating", + EVT_CAST_TERMINATING, terminating ) @@ -1294,8 +1318,6 @@ function ParallelSimulation_Register(cast: any) BaseCastRef:_GetMotor6D(cosmetic) end end - - queuedEvents[id] = {} end function ParallelSimulation_Unregister(castID: number) @@ -1351,9 +1373,6 @@ function ParallelSimulation_Unregister(castID: number) table.remove(casts_ID, #casts_ID) casts_ID_Index[castID] = nil end - - queuedEvents[castID] = nil - queuedVisualizes[castID] = nil end -- ============================================================================ @@ -1439,26 +1458,21 @@ local function UpdateCasts(delta: number) delta ) end - end - - -- ========================================================================= - -- Synchronize back to the serial scheduler. - -- ========================================================================= - - task.synchronize() - - for _, id in casts_ID do - local cast = ActivesRef[id] + -- Write-back SoA -> ActiveCast (merged to avoid second iteration) if cast then cast.StateInfo.TotalRuntime = casts_TotalRunTime[id] - cast.StateInfo.DistanceCovered = casts_DistanceCovered[id] - cast.CFrame = casts_CFrame[id] end end + -- ========================================================================= + -- Synchronize back to the serial scheduler. + -- ========================================================================= + + task.synchronize() + -- ========================================================================= -- Main-thread-only work. -- ========================================================================= @@ -1466,19 +1480,13 @@ local function UpdateCasts(delta: number) UpdateMotor6Ds() BulkMoveTo() - local eventsToProcess = queuedEvents - queuedEvents = {} - - local visualizesToProcess = queuedVisualizes - queuedVisualizes = {} + FireQueuedVisualizes() + FireQueuedEvents() - FireQueuedVisualizes( - visualizesToProcess - ) - - FireQueuedEvents( - eventsToProcess - ) + -- Reset pool counters (reuse tables next frame) + ResetPools() + queuedEventsCount = 0 + queuedVisCount = 0 end -- ============================================================================ From f76b645ab041f6477075fbe886c693c1cfbb82ba Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 09:47:18 +0000 Subject: [PATCH 035/140] Fix annoying warning --- src/ParallelSimulation.luau | 2 +- src/SerialSimulation.luau | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 05b879b4..78044d7e 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -340,7 +340,7 @@ end local function GetEvent(): QueuedEvent eventPoolCount += 1 if eventPoolCount > #eventPool then - eventPool[eventPoolCount] = { 0, 0, nil } + eventPool[eventPoolCount] = { 0, 0, nil :: any } end return eventPool[eventPoolCount] end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 75b2f230..364247aa 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -341,7 +341,7 @@ end local function GetEvent(): QueuedEvent eventPoolCount += 1 if eventPoolCount > #eventPool then - eventPool[eventPoolCount] = { 0, 0, nil } + eventPool[eventPoolCount] = { 0, 0, nil :: any } end return eventPool[eventPoolCount] end From 8a0da25adc96ed0d15aef1f66fbac9ecaf173751 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 10:45:30 +0000 Subject: [PATCH 036/140] Fix Serial architecture + add RAY_SEARCH_OFFSET for pierce prevention Serial fixes: - Remove task.desynchronize() - Serial runs on main thread - EVT_CAST_TERMINATING handler now calls FastCastEvents.CastTerminating directly instead of relying on args (matches global event architecture) Both: - Add RAY_SEARCH_OFFSET (1e-4) in ResimulateHit subcast loop to prevent re-hitting surfaces after a pierce - EPSILON guard to skip raycast when displacement is negligible - direction.Unit zero-vector NaN guard - Pool visualization data tables (hitVisPool, segVisPool) - Reusable variant table in FireQueuedVisualizes --- src/ParallelSimulation.luau | 174 ++++++++++++++++++++++++--------- src/SerialSimulation.luau | 187 ++++++++++++++++++++++++++---------- 2 files changed, 265 insertions(+), 96 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 78044d7e..4977c353 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -59,6 +59,12 @@ local EVT_HIT = 2 local EVT_PIERCED = 3 local EVT_CAST_TERMINATING = 4 +--[[ + Shoutout to DrSinek for some code +]] +local EPSILON = 1e-6 +local RAY_SEARCH_OFFSET = 1e-4 + type QueuedEvent = { number | string | { any } } -- Layout: { castID, eventType, args } -- args is a reusable table { arg1, arg2, ... } @@ -356,69 +362,140 @@ local function GetVis(): QueuedVis return visPool[visPoolCount] end +-- Pooled visualization data tables (avoids per-frame allocations) +-- Hit/pierce data layout: { atCF :: CFrame, wasPierce :: boolean, sub :: boolean } +-- Segment data layout: { castStartCFrame :: CFrame, sub :: boolean } +local hitVisPool: { { any } } = {} +local hitVisPoolCount: number = 0 + +local segVisPool: { { any } } = {} +local segVisPoolCount: number = 0 + +local function GetHitVisData(): { any } + hitVisPoolCount += 1 + if hitVisPoolCount > #hitVisPool then + -- NOTE: Do not change ":: any" I just hate annoying warning + hitVisPool[hitVisPoolCount] = { nil :: any, false, false } + end + return hitVisPool[hitVisPoolCount] +end + +local function GetSegVisData(): { any } + segVisPoolCount += 1 + if segVisPoolCount > #segVisPool then + -- NOTE: Do not change ":: any" I just hate annoying warning + segVisPool[segVisPoolCount] = { nil :: any, false } + end + return segVisPool[segVisPoolCount] +end + local function ResetPools() argsPoolCount = 0 eventPoolCount = 0 visPoolCount = 0 + hitVisPoolCount = 0 + segVisPoolCount = 0 end -- ============================================================================ -- Queued visualizations (flat, allocation-free) -- ============================================================================ -local function QueueVisualize( +-- Hit/pierce visualization: pass fields directly, data table is pooled +local function QueueVisualizeHit( + castID: number, + atCF: CFrame, + wasPierce: boolean, + sub: boolean +) + if not casts_VisualizeCasts[castID] then + return + end + + local data = GetHitVisData() + data[1] = atCF + data[2] = wasPierce + data[3] = sub + + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + entry[3] = 1 -- tag: 1 = hit data + queuedVisualizes[queuedVisCount] = entry +end + +-- Segment visualization: pass fields directly, data table is pooled +local function QueueVisualizeSegment( castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData + castStartCFrame: CFrame, + sub: boolean ) if not casts_VisualizeCasts[castID] then return end + local data = GetSegVisData() + data[1] = castStartCFrame + data[2] = sub + queuedVisCount += 1 local entry = GetVis() entry[1] = castID entry[2] = data + entry[3] = 2 -- tag: 2 = segment data queuedVisualizes[queuedVisCount] = entry end +-- Reusable variant table (consumed synchronously, safe to reuse) +local reusableVariant = {} :: CastVisualizerVariants + local function FireQueuedVisualizes() for i = 1, queuedVisCount do local entry = queuedVisualizes[i] local castID = entry[1] :: number local data = entry[2] + local dataTag = entry[3] :: number local settings = casts_VisualizeCastSettings[castID] if not settings then continue end - -- Hit visualization - if (data :: any).atCF then + -- Hit visualization (tag 1) + if dataTag == 1 then + local atCF = data[1] :: CFrame + local wasPierce = data[2] :: boolean + local sub = data[3] :: boolean + if DebugLogging.Hit - and not (data :: QueuedVisualizeHitData).wasPierce - and not (data :: QueuedVisualizeHitData).sub + and not wasPierce + and not sub then print("Hit was successful. Terminating.") end if DebugLogging.Pierced - and (data :: QueuedVisualizeHitData).wasPierce - and not (data :: QueuedVisualizeHitData).sub + and wasPierce + and not sub then print("Piercing function returned TRUE to pierce this part.") end DbgVisualizeHit( - (data :: QueuedVisualizeHitData).atCF, - (data :: QueuedVisualizeHitData).wasPierce, + atCF, + wasPierce, settings ) continue end - -- Segment visualization - if DebugLogging.Segment and (data :: QueuedVisualizeCastData).sub then + -- Segment visualization (tag 2) + local castStartCFrame = data[1] :: CFrame + local sub = data[2] :: boolean + + if DebugLogging.Segment and sub then print("Subcast visualization") end @@ -429,26 +506,19 @@ local function FireQueuedVisualizes() continue end - local variant: CastVisualizerVariants - + -- Reuse single variant table (consumed synchronously) if castType == EnumCastTypes.Raycast then - variant = { - castLength = casts_RayDisplacement[castID] or 1 - } + reusableVariant.castLength = casts_RayDisplacement[castID] or 1 elseif castType == EnumCastTypes.Blockcast then - variant = { - size = (casts_CastVariant[castID] :: BlockcastVariant).Size - } + reusableVariant.size = (casts_CastVariant[castID] :: BlockcastVariant).Size else - variant = { - radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius - } + reusableVariant.radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius end visualizer( - (data :: QueuedVisualizeCastData).castStartCFrame, + castStartCFrame, settings, - variant + reusableVariant ) end end @@ -710,11 +780,12 @@ local function QueueHit( casts_RayInfo[id].CosmeticBulletObject ) - QueueVisualize(id, { - atCF = CFrame.new(result.Position), - wasPierce = false, - sub = sub - }) + QueueVisualizeHit( + id, + CFrame.new(result.Position), + false, + sub + ) QueueEvent( id, @@ -737,11 +808,12 @@ local function QueuePierce( casts_RayInfo[id].CosmeticBulletObject ) - QueueVisualize(id, { - atCF = CFrame.new(result.Position), - wasPierce = true, - sub = sub - }) + QueueVisualizeHit( + id, + CFrame.new(result.Position), + true, + sub + ) end local function ShouldPierce( @@ -828,21 +900,27 @@ local function ResimulateHit( local subDirection = subVelocity * timeIncrement + -- Offset ray origin slightly in the direction of travel + -- to prevent re-hitting the same surface after a pierce + local subOrigin = subPosition + + subVelocity.Unit * RAY_SEARCH_OFFSET + local subResult = castHandler( targetWorldRoot, - subPosition, + subOrigin, subDirection, casts_RayInfo[id].Parameters, casts_CastVariant[id] ) - QueueVisualize(id, { - castStartCFrame = CFrame.new( + QueueVisualizeSegment( + id, + CFrame.new( subPosition, subPosition + subVelocity ), - sub = true - }) + true + ) if not subResult then continue @@ -939,6 +1017,11 @@ local function SimulateCast( ) local displacement = currentPoint - lastPoint + + if displacement.Magnitude <= EPSILON then + casts_DistanceCovered[id] += displacement.Magnitude + return + end local worldRoot = rayInfo.WorldRoot local handler = castHandlers[variant.CastType] @@ -981,7 +1064,7 @@ local function SimulateCast( id, EVT_LENGTH_CHANGED, lastPoint, - direction.Unit, + if direction ~= Vector3.zero then direction.Unit else direction, rayDisplacement, velocity, rayInfo.CosmeticBulletObject @@ -990,13 +1073,14 @@ local function SimulateCast( casts_DistanceCovered[id] += rayDisplacement if delta > 0 then - QueueVisualize(id, { - castStartCFrame = CFrame.new( + QueueVisualizeSegment( + id, + CFrame.new( lastPoint, lastPoint + direction ), - sub = false - }) + false + ) end -- ------------------------------------------------------------------------ diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 364247aa..95631bda 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -59,6 +59,12 @@ local EVT_HIT = 2 local EVT_PIERCED = 3 local EVT_CAST_TERMINATING = 4 +--[[ + Shoutout to DrSinek for some code +]] +local EPSILON = 1e-6 +local RAY_SEARCH_OFFSET = 1e-4 + type QueuedEvent = { number | string | { any } } -- Layout: { castID, eventType, args } -- args is a reusable table { arg1, arg2, ... } @@ -357,69 +363,142 @@ local function GetVis(): QueuedVis return visPool[visPoolCount] end +-- Pooled visualization data tables (avoids per-frame allocations) +-- Hit/pierce data layout: { atCF :: CFrame, wasPierce :: boolean, sub :: boolean } +-- Segment data layout: { castStartCFrame :: CFrame, sub :: boolean } +local hitVisPool: { { any } } = {} +local hitVisPoolCount: number = 0 + +local segVisPool: { { any } } = {} +local segVisPoolCount: number = 0 + +--[[ + Do not remove ":: any" it giving me annoying warning +]] + +local function GetHitVisData(): { any } + hitVisPoolCount += 1 + if hitVisPoolCount > #hitVisPool then + hitVisPool[hitVisPoolCount] = { nil :: any, false, false } + end + return hitVisPool[hitVisPoolCount] +end + +local function GetSegVisData(): { any } + segVisPoolCount += 1 + if segVisPoolCount > #segVisPool then + segVisPool[segVisPoolCount] = { nil :: any, false } + end + return segVisPool[segVisPoolCount] +end + local function ResetPools() argsPoolCount = 0 eventPoolCount = 0 visPoolCount = 0 + hitVisPoolCount = 0 + segVisPoolCount = 0 end -- ============================================================================ -- Queued visualizations (flat, allocation-free) -- ============================================================================ -local function QueueVisualize( +-- Hit/pierce visualization: pass fields directly, data table is pooled +local function QueueVisualizeHit( castID: number, - data: QueuedVisualizeCastData | QueuedVisualizeHitData + atCF: CFrame, + wasPierce: boolean, + sub: boolean ) if not casts_VisualizeCasts[castID] then return end + local data = GetHitVisData() + data[1] = atCF + data[2] = wasPierce + data[3] = sub + queuedVisCount += 1 local entry = GetVis() entry[1] = castID entry[2] = data + entry[3] = 1 -- tag: 1 = hit data queuedVisualizes[queuedVisCount] = entry end +-- Segment visualization: pass fields directly, data table is pooled +local function QueueVisualizeSegment( + castID: number, + castStartCFrame: CFrame, + sub: boolean +) + if not casts_VisualizeCasts[castID] then + return + end + + local data = GetSegVisData() + data[1] = castStartCFrame + data[2] = sub + + queuedVisCount += 1 + local entry = GetVis() + entry[1] = castID + entry[2] = data + entry[3] = 2 -- tag: 2 = segment data + queuedVisualizes[queuedVisCount] = entry +end + +-- Reusable variant table (consumed synchronously, safe to reuse) +local reusableVariant = {} :: CastVisualizerVariants + local function FireQueuedVisualizes() for i = 1, queuedVisCount do local entry = queuedVisualizes[i] local castID = entry[1] :: number local data = entry[2] + local dataTag = entry[3] :: number local settings = casts_VisualizeCastSettings[castID] if not settings then continue end - -- Hit visualization - if (data :: any).atCF then + -- Hit visualization (tag 1) + if dataTag == 1 then + local atCF = data[1] :: CFrame + local wasPierce = data[2] :: boolean + local sub = data[3] :: boolean + if DebugLogging.Hit - and not (data :: QueuedVisualizeHitData).wasPierce - and not (data :: QueuedVisualizeHitData).sub + and not wasPierce + and not sub then print("Hit was successful. Terminating.") end if DebugLogging.Pierced - and (data :: QueuedVisualizeHitData).wasPierce - and not (data :: QueuedVisualizeHitData).sub + and wasPierce + and not sub then print("Piercing function returned TRUE to pierce this part.") end DbgVisualizeHit( - (data :: QueuedVisualizeHitData).atCF, - (data :: QueuedVisualizeHitData).wasPierce, + atCF, + wasPierce, settings ) continue end - -- Segment visualization - if DebugLogging.Segment and (data :: QueuedVisualizeCastData).sub then + -- Segment visualization (tag 2) + local castStartCFrame = data[1] :: CFrame + local sub = data[2] :: boolean + + if DebugLogging.Segment and sub then print("Subcast visualization") end @@ -430,30 +509,21 @@ local function FireQueuedVisualizes() continue end - local variant: CastVisualizerVariants - + -- Reuse single variant table (consumed synchronously) if castType == EnumCastTypes.Raycast then - variant = { - castLength = casts_RayDisplacement[castID] or 1 - } + reusableVariant.castLength = casts_RayDisplacement[castID] or 1 elseif castType == EnumCastTypes.Blockcast then - variant = { - size = (casts_CastVariant[castID] :: BlockcastVariant).Size - } + reusableVariant.size = (casts_CastVariant[castID] :: BlockcastVariant).Size else - variant = { - radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius - } + reusableVariant.radius = (casts_CastVariant[castID] :: SpherecastVariant).Radius end visualizer( - (data :: QueuedVisualizeCastData).castStartCFrame, + castStartCFrame, settings, - variant + reusableVariant ) end - - queuedVisCount = 0 end -- ============================================================================ @@ -539,9 +609,9 @@ local function FireQueuedEvents() end elseif eventType == EVT_CAST_TERMINATING then - local callback = args[1] - if callback then - callback(cast) + local castTerminating = FastCastEvents.CastTerminating + if castTerminating then + castTerminating(cast) end BaseCastRef._TerminateCast(castID) SerialSimulation_Unregister(castID) @@ -550,6 +620,7 @@ local function FireQueuedEvents() ResetPools() queuedEventsCount = 0 + queuedVisCount = 0 end -- ============================================================================ @@ -623,11 +694,12 @@ local function QueueHit( casts_RayInfo[id].CosmeticBulletObject ) - QueueVisualize(id, { - atCF = CFrame.new(result.Position), - wasPierce = false, - sub = sub - }) + QueueVisualizeHit( + id, + CFrame.new(result.Position), + false, + sub + ) QueueEvent( id, @@ -650,11 +722,12 @@ local function QueuePierce( casts_RayInfo[id].CosmeticBulletObject ) - QueueVisualize(id, { - atCF = CFrame.new(result.Position), - wasPierce = true, - sub = sub - }) + QueueVisualizeHit( + id, + CFrame.new(result.Position), + true, + sub + ) end local function ShouldPierce( @@ -741,21 +814,27 @@ local function ResimulateHit( local subDirection = subVelocity * timeIncrement + -- Offset ray origin slightly in the direction of travel + -- to prevent re-hitting the same surface after a pierce + local subOrigin = subPosition + + subVelocity.Unit * RAY_SEARCH_OFFSET + local subResult = castHandler( targetWorldRoot, - subPosition, + subOrigin, subDirection, casts_RayInfo[id].Parameters, casts_CastVariant[id] ) - QueueVisualize(id, { - castStartCFrame = CFrame.new( + QueueVisualizeSegment( + id, + CFrame.new( subPosition, subPosition + subVelocity ), - sub = true - }) + true + ) if not subResult then continue @@ -853,6 +932,12 @@ local function SimulateCast( local displacement = currentPoint - lastPoint + -- Skip raycast when displacement is negligible (matches SwiftCast EPSILON) + if displacement.Magnitude <= EPSILON then + casts_DistanceCovered[id] += displacement.Magnitude + return + end + local worldRoot = rayInfo.WorldRoot local handler = castHandlers[variant.CastType] @@ -894,7 +979,7 @@ local function SimulateCast( id, EVT_LENGTH_CHANGED, lastPoint, - direction.Unit, + if direction ~= Vector3.zero then direction.Unit else direction, rayDisplacement, velocity, rayInfo.CosmeticBulletObject @@ -903,13 +988,14 @@ local function SimulateCast( casts_DistanceCovered[id] += rayDisplacement if delta > 0 then - QueueVisualize(id, { - castStartCFrame = CFrame.new( + QueueVisualizeSegment( + id, + CFrame.new( lastPoint, lastPoint + direction ), - sub = false - }) + false + ) end -- ------------------------------------------------------------------------ @@ -1421,7 +1507,6 @@ function SerialSimulation.Start() You can edit Hz60, Hz30, Hz15, Hz7_5, Hz5, Hz3, Hz1 here, if you want ]] SerialSimulation.Connection = RS:BindToSimulation(function(dt) - task.desynchronize() UpdateCasts(dt) end, Enum.StepFrequency.Hz60) end From 820ce8c7c0df4e767b1949f99fb4bbd6c09435dc Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 10:52:54 +0000 Subject: [PATCH 037/140] Fix RAY_SEARCH_OFFSET to only apply after a pierce Track lastPierced state in ResimulateHit subcast loop. First subcast starts from trajectory position with no offset. Only after a pierce does the next subcast offset its ray origin by RAY_SEARCH_OFFSET to prevent re-hitting the same surface. --- src/ParallelSimulation.luau | 13 +++++++++---- src/SerialSimulation.luau | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 4977c353..6630788c 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -876,6 +876,8 @@ local function ResimulateHit( ) end + local lastPierced = false + for segmentIndex = 1, segmentCount do if casts_CancelHighResCast[id] then casts_CancelHighResCast[id] = false @@ -900,10 +902,12 @@ local function ResimulateHit( local subDirection = subVelocity * timeIncrement - -- Offset ray origin slightly in the direction of travel - -- to prevent re-hitting the same surface after a pierce - local subOrigin = subPosition - + subVelocity.Unit * RAY_SEARCH_OFFSET + -- Only offset after the previous subcast pierced + local subOrigin = if lastPierced + then subPosition + subVelocity.Unit * RAY_SEARCH_OFFSET + else subPosition + + lastPierced = false local subResult = castHandler( targetWorldRoot, @@ -939,6 +943,7 @@ local function ResimulateHit( true ) + lastPierced = true continue end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 95631bda..27c5755b 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -790,6 +790,8 @@ local function ResimulateHit( ) end + local lastPierced = false + for segmentIndex = 1, segmentCount do if casts_CancelHighResCast[id] then casts_CancelHighResCast[id] = false @@ -814,10 +816,12 @@ local function ResimulateHit( local subDirection = subVelocity * timeIncrement - -- Offset ray origin slightly in the direction of travel - -- to prevent re-hitting the same surface after a pierce - local subOrigin = subPosition - + subVelocity.Unit * RAY_SEARCH_OFFSET + -- Only offset after the previous subcast pierced + local subOrigin = if lastPierced + then subPosition + subVelocity.Unit * RAY_SEARCH_OFFSET + else subPosition + + lastPierced = false local subResult = castHandler( targetWorldRoot, @@ -853,6 +857,7 @@ local function ResimulateHit( true ) + lastPierced = true continue end From fe6a0e159ab3a67eac13bb48230b9e4a91e18c3b Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 11:07:48 +0000 Subject: [PATCH 038/140] fix: consolidate TerminateCast, add cast.Mode, use table.clear - Add Mode field ('Serial'|'Parallel') to cast data in ActiveCast - Rewrite FastCast:TerminateCast to use cast.Mode instead of duck-typing - Serial path now calls SerialSimulation.QueueEvent directly (module-level) - Remove duplicate termination logic that double-fired callbacks - Remove unused local TerminateCast in BaseCastSerial - BaseCastSerial.Destroy now uses _TerminateCast per cast for proper cleanup - Replace manual key nil loops with table.clear --- src/ActiveCast.luau | 3 ++- src/BaseCastParallel.luau | 4 +--- src/BaseCastSerial.luau | 21 +++------------------ src/init.luau | 29 +++++++---------------------- 4 files changed, 13 insertions(+), 44 deletions(-) diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index 21bded75..d072f6d3 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -93,7 +93,8 @@ function ActiveCast.createCastData( Type = variant.CastType, CastVariant = variant, CFrame = CFrame.new(origin), - ID = activeCastID + ID = activeCastID, + Mode = if _parallel then "Parallel" else "Serial", } if _parallel then diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index f8dff981..bf11348d 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -59,9 +59,7 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT cast.Caster.ActiveCastCleaner:Fire(cast.ID) - for key, _ in (cast :: any) do - cast[key] = nil - end + table.clear(cast :: any) end local BaseCastInitialized = false diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 831a70e6..a961a6fc 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -28,19 +28,6 @@ local DEFAULT_CACHE_HOLDER = workspace local NextProjectileID = 0 -local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastTerminatingFunction?) - if cast._Destroying then return end - cast._Destroying = true - - if castTerminatingFunction then - castTerminatingFunction((cast :: any)) - end - - for key, _ in (cast :: any) do - cast[key] = nil - end -end - local Actives = {} local CastFirefn = nil local Motor6DCacheInstance = nil @@ -261,8 +248,8 @@ function BaseCast.Destroy() Motor6DCacheInstance:Destroy() end - for _, v in Actives do - TerminateCast(v) + for castID in Actives do + BaseCast._TerminateCast(castID) end Actives = {} @@ -283,9 +270,7 @@ function BaseCast._TerminateCast(castID: number) end end - for key, _ in (cast :: any) do - cast[key] = nil - end + table.clear(cast :: any) Actives[castID] = nil end end diff --git a/src/init.luau b/src/init.luau index 25a6c1e5..d03b15df 100644 --- a/src/init.luau +++ b/src/init.luau @@ -60,6 +60,7 @@ local TypeDef = require(script:WaitForChild("TypeDefinitions")) local DefaultConfigs = require(script:WaitForChild("DefaultConfigs")) local BaseCastSerial = require(script:WaitForChild("BaseCastSerial")) +local SerialSimulation = require(script:WaitForChild("SerialSimulation")) local DispatcherModule = script:WaitForChild("FastCastVMs") local Dispatcher = require(DispatcherModule) @@ -825,33 +826,17 @@ function FastCast:TerminateCast(cast: vaildcast) if cast._Destroying then return end (cast :: any)._Destroying = true - local caster = cast.Caster - if caster == nil then return end + if cast.Mode == "Serial" then + SerialSimulation.QueueEvent(cast.ID, "CastTerminating") + elseif cast.Mode == "Parallel" then + local caster = cast.Caster + if caster == nil then return end - local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig - - if caster.Output then - -- Parallel mode: fire event + cleaner, but don't nil cast keys + local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig if eventsCfg and eventsCfg.UseCastTerminating then caster.Output:Fire("CastTerminating", cast) end caster.ActiveCastCleaner:Fire(cast.ID) - elseif caster.SerialSimulation then - -- Serial mode: fire callback sync, destroy cosmetic immediately, queue cleanup - if eventsCfg and eventsCfg.UseCastTerminating and caster.SerialSimulation.Events.CastTerminating then - caster.SerialSimulation.Events.CastTerminating(cast) - end - - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if caster.ObjectCacheInstance then - caster.ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - end - (cast :: any).RayInfo.CosmeticBulletObject = nil - end - - caster.SerialSimulation:QueueEvent(cast.ID, "CastTerminating") end end From 11c3c9b7df1242e283488dfe7eadc2ced833b2c7 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 11:36:49 +0000 Subject: [PATCH 039/140] fix: audit fixes for TerminateCast consolidation - Fix table mutation during iteration in BaseCastSerial.Destroy (copy keys first) - Restore synchronous CastTerminating callback in serial external termination - Add SerialSimulation.Stop() to BaseCastSerial.Destroy (leaked BindToSimulation) - Add UseCastTerminating gate + _Destroying check in serial EVT_CAST_TERMINATING handler - Add SerialSimulation.GetEvent() public getter - Reset ActivesRef in SerialSimulation.Stop for re-init support - Add Mode field to all ActiveCastData type definitions - Fix ID type from number|string to number --- src/BaseCastSerial.luau | 6 ++++++ src/SerialSimulation.luau | 25 +++++++++++++++++++------ src/TypeDefinitions.luau | 9 ++++++--- src/init.luau | 7 +++++++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index a961a6fc..00f3c351 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -240,6 +240,8 @@ Destroys the BaseCast instance and cleans up resources. ]=] function BaseCast.Destroy() + SerialSimulation.Stop() + if ObjectCacheInstance then ObjectCacheInstance:Destroy() end @@ -248,7 +250,11 @@ function BaseCast.Destroy() Motor6DCacheInstance:Destroy() end + local castIDs = {} for castID in Actives do + table.insert(castIDs, castID) + end + for _, castID in castIDs do BaseCast._TerminateCast(castID) end diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 27c5755b..1f7de66c 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -567,8 +567,12 @@ local function FireQueuedEvents() local eventConfig = casts_FastCastEventsConfig[castID] + if not eventConfig then + return + end + if eventType == EVT_LENGTH_CHANGED then - if eventConfig and eventConfig.UseLengthChanged then + if eventConfig.UseLengthChanged then local lengthChanged = FastCastEvents.LengthChanged if lengthChanged then lengthChanged( @@ -583,7 +587,7 @@ local function FireQueuedEvents() end elseif eventType == EVT_HIT then - if eventConfig and eventConfig.UseHit then + if eventConfig.UseHit then local Hit = FastCastEvents.Hit if Hit then Hit( @@ -596,7 +600,7 @@ local function FireQueuedEvents() end elseif eventType == EVT_PIERCED then - if eventConfig and eventConfig.UsePierced then + if eventConfig.UsePierced then local Pierced = FastCastEvents.Pierced if Pierced then Pierced( @@ -609,9 +613,13 @@ local function FireQueuedEvents() end elseif eventType == EVT_CAST_TERMINATING then - local castTerminating = FastCastEvents.CastTerminating - if castTerminating then - castTerminating(cast) + if not cast._Destroying then + cast._Destroying = true + local castTerminating = FastCastEvents.CastTerminating + --local eventConfig = cast.StateInfo and cast.StateInfo.FastCastEventsConfig + if eventConfig.UseCastTerminating and castTerminating then + castTerminating(cast) + end end BaseCastRef._TerminateCast(castID) SerialSimulation_Unregister(castID) @@ -1521,6 +1529,7 @@ function SerialSimulation.Stop() SerialSimulation.Connection:Disconnect() SerialSimulation.Connection = nil end + ActivesRef = nil end function SerialSimulation._UpdateEvent(name: string, newEventfn: (any) -> ()) @@ -1531,6 +1540,10 @@ function SerialSimulation._UpdateEvent(name: string, newEventfn: (any) -> ()) FastCastEvents[name] = newEventfn end +function SerialSimulation.GetEvent(name: string): ((any) -> ())? + return FastCastEvents[name] +end + function SerialSimulation.QueueEvent(castID: number, eventType: string, ...: any) -- Public wrapper for external callers (e.g. init.luau TerminateCast) -- Map string event type to numeric constant diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index 2be2b9ca..611ee022 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -491,7 +491,8 @@ export type ActiveCastData = { _Destroying: boolean, Type : "Raycast", CFrame: CFrame, - ID: number | string + ID: number, + Mode: "Serial" | "Parallel", } --[=[ @@ -509,7 +510,8 @@ export type ActiveBlockcastData = { _Destroying: boolean, Type : "Blockcast", CFrame: CFrame, - ID: number | string + ID: number, + Mode: "Serial" | "Parallel", } --[=[ @@ -527,7 +529,8 @@ export type ActiveSpherecastData = { _Destroying: boolean, Type : "Spherecast", CFrame: CFrame, - ID: number | string + ID: number, + Mode: "Serial" | "Parallel", } return {} diff --git a/src/init.luau b/src/init.luau index d03b15df..edf36a0a 100644 --- a/src/init.luau +++ b/src/init.luau @@ -827,6 +827,13 @@ function FastCast:TerminateCast(cast: vaildcast) (cast :: any)._Destroying = true if cast.Mode == "Serial" then + local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig + if eventsCfg and eventsCfg.UseCastTerminating then + local castTerminating = SerialSimulation.GetEvent("CastTerminating") + if castTerminating then + castTerminating(cast) + end + end SerialSimulation.QueueEvent(cast.ID, "CastTerminating") elseif cast.Mode == "Parallel" then local caster = cast.Caster From bb570ec62b8171de98d78df87a8b79fabc5547de Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 16 Sep 2026 15:43:00 +0000 Subject: [PATCH 040/140] Optimizing shit --- src/BaseCastParallel.luau | 29 ++++++++++++----------------- src/FastCastVMs/init.luau | 3 +-- src/ParallelSimulation.luau | 16 +++++++++++++++- src/TypeDefinitions.luau | 4 ++-- src/init.luau | 2 +- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index bf11348d..f53a559f 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -25,7 +25,6 @@ local DEFAULT_CACHE_HOLDER = workspace local Actor = nil local Output = nil -local ActiveCastCleaner: BindableEvent = nil local ObjectCacheInstance: any = nil local Motor6DCacheInstance: any = nil local NextProjectileID = 0 @@ -57,8 +56,14 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT castTerminatingFunction((cast :: any)) end - cast.Caster.ActiveCastCleaner:Fire(cast.ID) - + -- Inline cleanup (we're inside the Actor) + if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then + if ObjectCacheInstance then + ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) + else + cast.RayInfo.CosmeticBulletObject:Destroy() + end + end table.clear(cast :: any) end @@ -75,12 +80,9 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) local self = setmetatable({}, BaseCast) Actor = BindableOutput.Parent self.Actives = {} + self.Actor = Actor Output = BindableOutput - local BindableCleaner = Instance.new("BindableEvent") - BindableCleaner.Name = "ActiveCastDestroyer" - BindableCleaner.Parent = Actor - if Data.useObjectCache then local objectCacheArgs = Data.objectCacheArgs or {} if not objectCacheArgs.CacheSize then @@ -94,14 +96,14 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any end + self.ObjectCacheInstance = ObjectCacheInstance + CurrentMovementMode = Data.movementMode or "BulkMoveTo" if CurrentMovementMode == "Motor6D" then Motor6DCacheInstance = Motor6DCache.new() end - ActiveCastCleaner = BindableCleaner - - ActiveCastCleaner.Event:Connect(function(activeCastID: number) + Actor:BindToMessage("Cleanup", function(activeCastID: number) if self.Actives[activeCastID] then local cast = self.Actives[activeCastID] if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then @@ -114,7 +116,6 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) end self.Actives[activeCastID] = nil ParallelSimulation.Unregister(activeCastID) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") - 1) end end) @@ -175,12 +176,10 @@ function BaseCast:Raycast( Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior ) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") + 1) NextProjectileID += 1 local cast = ActiveCast.createCastData({ Output = Output, - ActiveCastCleaner = ActiveCastCleaner, SyncChange = SyncChanges, CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { @@ -239,12 +238,10 @@ function BaseCast:Blockcast( Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior ) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") + 1) NextProjectileID += 1 local cast = ActiveCast.createCastData({ Output = Output, - ActiveCastCleaner = ActiveCastCleaner, SyncChange = SyncChanges, CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { @@ -284,12 +281,10 @@ function BaseCast:Spherecast( Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior ) - Actor:SetAttribute("Tasks", Actor:GetAttribute("Tasks") + 1) NextProjectileID += 1 local cast = ActiveCast.createCastData({ Output = Output, - ActiveCastCleaner = ActiveCastCleaner, SyncChange = SyncChanges, CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { diff --git a/src/FastCastVMs/init.luau b/src/FastCastVMs/init.luau index d729b7c8..6eccb38f 100644 --- a/src/FastCastVMs/init.luau +++ b/src/FastCastVMs/init.luau @@ -80,8 +80,7 @@ function Dispatcher.Init(newContainerParent : Instance, VMContainerName : string -- Init - local Actor = Instance.new("Actor") - Actor:SetAttribute("Tasks", 0) + local Actor = Instance.new("Actor") local Controller if IS_SERVER then diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 6630788c..9ae4eec6 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -679,7 +679,21 @@ local function FireQueuedEvents() callback(cast) end - caster.ActiveCastCleaner:Fire(cast.ID) + -- Inline cleanup (no BindableEvent needed, we're on main thread) + local rayInfo = casts_RayInfo[castID] + if rayInfo then + local cosmetic = rayInfo.CosmeticBulletObject + if cosmetic then + local cache = BaseCastRef.ObjectCacheInstance + if cache then + cache:ReturnPart(cosmetic) + else + cosmetic:Destroy() + end + end + end + ActivesRef[castID] = nil + ParallelSimulation_Unregister(castID) end end end diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index 611ee022..d43a0a5b 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -464,14 +464,14 @@ export type SphereCastRayInfo = { } --[=[ - @type BaseCastData { Output: BindableEvent, ActiveCastCleaner: BindableEvent, ObjectCache: BindableFunction?, CacheHolder: any?, SyncChange : BindableEvent } + @type BaseCastData { Output: BindableEvent, Actor: Actor, ObjectCache: BindableFunction?, CacheHolder: any?, SyncChange : BindableEvent } @within TypeDefinitions Data stored on the caster that ActiveCasts reference. ]=] export type BaseCastData = { Output: BindableEvent, - ActiveCastCleaner: BindableEvent, + Actor: Actor, CacheHolder: any?, SyncChange : BindableEvent } diff --git a/src/init.luau b/src/init.luau index edf36a0a..96377de4 100644 --- a/src/init.luau +++ b/src/init.luau @@ -843,7 +843,7 @@ function FastCast:TerminateCast(cast: vaildcast) if eventsCfg and eventsCfg.UseCastTerminating then caster.Output:Fire("CastTerminating", cast) end - caster.ActiveCastCleaner:Fire(cast.ID) + caster.Actor:SendMessage("Cleanup", cast.ID) end end From 9b52211a54b8772d0b5896af82ca8d843f0d3527 Mon Sep 17 00:00:00 2001 From: darwin Date: Thu, 17 Sep 2026 19:20:26 +1000 Subject: [PATCH 041/140] test: add automated Studio test and benchmark suite - Add Lune orchestrator that builds a Rojo place and runs it in Studio via run-in-roblox - Add functional tests plus a six-case Serial/Parallel x Raycast/Blockcast/Spherecast benchmark matrix - Report creation/cleanup throughput and frame-time percentiles, uploading results to paste.shellworks.dev - Extend benchmarks with cast-type cycling and add lune/run-in-roblox to the toolchain --- .gitignore | 3 +- CONTRIBUTING.md | 4 +- README.md | 9 + TESTS.md | 20 + benchmarks.project.json | 21 ++ benchmarks/BenchmarkClient.client.luau | 186 ++++++---- benchmarks/BenchmarkServer.server.luau | 182 +++++---- benchmarks/README.md | 84 +++-- package.json | 4 + rokit.toml | 2 + selene.toml | 3 +- tests.project.json | 16 + tests/Entry.luau | 67 ++++ tests/README.md | 75 ++++ tests/run.luau | 310 ++++++++++++++++ tests/src/Benchmarks.luau | 220 +++++++++++ tests/src/Framework.luau | 268 +++++++++++++ tests/src/FunctionalTests.luau | 495 +++++++++++++++++++++++++ tests/src/Harness.server.luau | 97 +++++ tests/src/TestEventsModule.luau | 27 ++ 20 files changed, 1907 insertions(+), 186 deletions(-) create mode 100644 benchmarks.project.json create mode 100644 tests.project.json create mode 100644 tests/Entry.luau create mode 100644 tests/README.md create mode 100644 tests/run.luau create mode 100644 tests/src/Benchmarks.luau create mode 100644 tests/src/Framework.luau create mode 100644 tests/src/FunctionalTests.luau create mode 100644 tests/src/Harness.server.luau create mode 100644 tests/src/TestEventsModule.luau diff --git a/.gitignore b/.gitignore index 0070e3cd..c19f1186 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ sourcemap.json /node_modules /package-lock.json + # FastCast2 related stuff /src/*.legacy.luau .aider* @@ -24,4 +25,4 @@ sourcemap.json # Development related stuff DevPackages -build \ No newline at end of file +build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1fe96dc..9b822387 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,9 @@ I'm not strict about changelogs, and I don't really bother writing explicit chan - Keep discussions technical and constructive ### Testing -- Read manual testing instructions: https://github.com/weenachuangkud/FastCast2/blob/main/TESTS.md +- Run the automated suite before opening a PR: `npm test` (or `lune run tests/run.luau`) +- It runs the functional tests and benchmark matrix in Studio and uploads the report to paste.shellworks.dev; see [`tests/README.md`](tests/README.md) +- Manual checklist / coverage reference: [`TESTS.md`](TESTS.md) ### Questions? diff --git a/README.md b/README.md index 2e6717fc..27610b27 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,15 @@ FastCast2:TerminateCast(cast) ### -> Get started with the [FastCast2 documentation](https://weenachuangkud.github.io/FastCast2/docs/api-reference) +## Benchmarks + +FastCast2 includes repeatable client and server benchmarks for raycasts, blockcasts, and spherecasts. They compare serial and parallel execution using creation/cleanup throughput and simulation frame-time percentiles. See the [benchmark guide](benchmarks/README.md) for controls and the ready-to-sync Rojo project. + +## Tests + +The automated suite runs the functional tests and the six-case benchmark matrix inside Roblox Studio, prints a Markdown results table, and uploads the report to [paste.shellworks.dev](https://paste.shellworks.dev). Run it with `npm test` (or `lune run tests/run.luau`). See the [test guide](tests/README.md) for prerequisites and flags. + + --- # People behind FastCast2(Contributors) diff --git a/TESTS.md b/TESTS.md index dd8562b6..7cc96a78 100644 --- a/TESTS.md +++ b/TESTS.md @@ -1,3 +1,23 @@ +# Testing FastCast2 + +The checklist below is covered by the automated suite. Run it with one command +from the repository root: + +```sh +npm test +# or +lune run tests/run.luau +``` + +The runner builds the test place, runs it in Roblox Studio via +`run-in-roblox`, prints a functional-test summary and a benchmark table, and +uploads the full report to [paste.shellworks.dev](https://paste.shellworks.dev). +See [`tests/README.md`](tests/README.md) for prerequisites, flags, and the +project layout. + +The same cases can still be verified by hand if Studio automation is not +available. + ### Caster 1. Initialization 2. Basic raycast/blockcast/spherecast firing diff --git a/benchmarks.project.json b/benchmarks.project.json new file mode 100644 index 00000000..cefa2907 --- /dev/null +++ b/benchmarks.project.json @@ -0,0 +1,21 @@ +{ + "name": "FastCast2Benchmarks", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "FastCast2": { + "$path": "src" + } + }, + "ReplicatedFirst": { + "BenchmarkClient": { + "$path": "benchmarks/BenchmarkClient.client.luau" + } + }, + "ServerScriptService": { + "BenchmarkServer": { + "$path": "benchmarks/BenchmarkServer.server.luau" + } + } + } +} diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index 4b74c578..9ad8856e 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -15,6 +15,7 @@ > Increase HighFidelitySegmentSize < Decrease HighFidelitySegmentSize B Toggle Serial / Parallel + R Cycle cast type [ Decrease workers ] Increase workers I Show benchmark information @@ -36,6 +37,10 @@ local Config = { ProjectileAmount = 500, Velocity = 6, + CastType = "Raycast", + RandomSeed = 2026, + BlockcastSize = Vector3.one, + SpherecastRadius = 0.5, Instanced = true, ObjectCacheEnabled = false, @@ -49,8 +54,6 @@ local Config = { VMContainerName = "CastVMContainer", VMTemplateName = "CastVM", - FPSUpdateRate = 0.5, - HighFidelitySegmentSizeStep = 0.05, MinHighFidelitySegmentSize = 0.05, @@ -124,51 +127,15 @@ local ActiveCasts = {} local IsBenchmarking = false local IsReconfiguring = false ---// FPS Statistics - -local FPS = { - Current = 0, - Average = 0, - Minimum = math.huge, - Maximum = 0, - - Accumulator = 0, - FrameCount = 0, -} - -local function resetFPS() - FPS.Current = 0 - FPS.Average = 0 - FPS.Minimum = math.huge - FPS.Maximum = 0 - - FPS.Accumulator = 0 - FPS.FrameCount = 0 -end - -local function updateFPS(dt) - if dt <= 0 then - return - end - - local current = 1 / dt +--// Frame Statistics - FPS.Current = current - FPS.Minimum = math.min(FPS.Minimum, current) - FPS.Maximum = math.max(FPS.Maximum, current) - - FPS.Accumulator += current - FPS.FrameCount += 1 - - if FPS.FrameCount >= 1 then - -- Update the rolling average periodically rather than - -- calculating it every frame. - FPS.Average = FPS.Accumulator / FPS.FrameCount - end -end +local FrameTimes = {} +local IsMeasuringFrames = false RunService.Heartbeat:Connect(function(dt) - updateFPS(dt) + if IsMeasuringFrames and dt > 0 then + FrameTimes[#FrameTimes + 1] = dt * 1000 + end end) --// Utility @@ -286,6 +253,7 @@ local function printBenchmarkInfo() print(("Projectiles: %d"):format(Config.ProjectileAmount)) print(("Velocity: %.2f"):format(Config.Velocity)) + print(("CastType: %s"):format(Config.CastType)) print( "ObjectCache: %s", @@ -319,49 +287,87 @@ local function printBenchmarkInfo() end end ---// FPS Output +--// Benchmark Output -local function printFPS() - local minimum = FPS.Minimum +local function percentile(sortedValues, fraction) + if #sortedValues == 0 then + return 0 + end - if minimum == math.huge then - minimum = 0 + local index = math.max(1, math.ceil(#sortedValues * fraction)) + return sortedValues[index] +end + +local function printFrameStats() + if #FrameTimes == 0 then + print("No frames sampled.") + return end - local delta = FPS.Current > 0 - and (1000 / FPS.Current) - or 0 + local sorted = table.clone(FrameTimes) + table.sort(sorted) + + local total = 0 + for _, frameTime in FrameTimes do + total += frameTime + end - print(("Delta: %.2f ms"):format(delta)) - print(("Average FPS: %.2f"):format(FPS.Average)) - print(("Max FPS: %.2f"):format(FPS.Maximum)) - print(("Min FPS: %.2f"):format(minimum)) + local average = total / #FrameTimes + local slowest = sorted[#sorted] + local fastest = sorted[1] + + print(("Frames sampled: %d"):format(#FrameTimes)) + print(("Average frame time: %.2f ms (%.2f FPS)"):format(average, 1000 / average)) + print(("P50 / P95 / P99: %.2f / %.2f / %.2f ms"):format( + percentile(sorted, 0.50), + percentile(sorted, 0.95), + percentile(sorted, 0.99) + )) + print(("FPS range: %.2f - %.2f"):format(1000 / slowest, 1000 / fastest)) +end + +local function printOperationStats(elapsed, operationCount) + local elapsedMilliseconds = elapsed * 1000 + local throughput = elapsed > 0 and operationCount / elapsed or 0 + + print(("Elapsed: %.2f ms"):format(elapsedMilliseconds)) + print(("Throughput: %.2f projectiles/s"):format(throughput)) end --// Projectile Generation -local function fireProjectile() - Caster:RaycastFire( - Vector3.new( - math.random(-100, 100), - math.random(0, 100), - math.random(-100, 100) - ), - - Vector3.new( - math.random() * 2 - 1, - math.random() * 2 - 1, - math.random() * 2 - 1 - ), - - Config.Velocity, - CastBehavior +local function fireProjectile(random) + local origin = Vector3.new( + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) ) + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) + ) + + if direction.Magnitude == 0 then + direction = Vector3.xAxis + else + direction = direction.Unit + end + + if Config.CastType == "Blockcast" then + Caster:BlockcastFire(origin, Config.BlockcastSize, direction, Config.Velocity, CastBehavior) + elseif Config.CastType == "Spherecast" then + Caster:SpherecastFire(origin, Config.SpherecastRadius, direction, Config.Velocity, CastBehavior) + else + Caster:RaycastFire(origin, direction, Config.Velocity, CastBehavior) + end end local function fireProjectiles() + local random = Random.new(Config.RandomSeed) + for _ = 1, Config.ProjectileAmount do - fireProjectile() + fireProjectile(random) end end @@ -388,37 +394,54 @@ local function benchmark() IsBenchmarking = true - resetFPS() - printBenchmarkInfo() print(("Firing %d projectiles..."):format(Config.ProjectileAmount)) + local creationStarted = os.clock() fireProjectiles() + local creationElapsed = os.clock() - creationStarted -- Allow the casts to begin processing. task.wait() printSeparator("CREATION COMPLETE") - printFPS() + printOperationStats(creationElapsed, Config.ProjectileAmount) + table.clear(FrameTimes) + IsMeasuringFrames = true task.wait(Config.BenchmarkDuration) + IsMeasuringFrames = false printSeparator("SIMULATION COMPLETE") - printFPS() + printFrameStats() printSeparator("CLEANUP") + local cleanupStarted = os.clock() cleanupCasts() + local cleanupElapsed = os.clock() - cleanupStarted + printOperationStats(cleanupElapsed, Config.ProjectileAmount) -- Give FastCast/object cache time to settle. task.wait(BENCHMARK_COOLDOWN) printSeparator("DONE") - printFPS() IsBenchmarking = false end +local function cycleCastType() + if Config.CastType == "Raycast" then + Config.CastType = "Blockcast" + elseif Config.CastType == "Blockcast" then + Config.CastType = "Spherecast" + else + Config.CastType = "Raycast" + end + + print(("CastType: %s"):format(Config.CastType)) +end + --// Configuration Changes local function toggleInstancing() @@ -593,6 +616,7 @@ local function showControls() print("> - Increase HighFidelitySegmentSize") print("< - Decrease HighFidelitySegmentSize") print("B - Toggle Serial / Parallel") + print("R - Cycle Raycast / Blockcast / Spherecast") print("[ - Decrease numWorkers") print("] - Increase numWorkers") print("I - Show benchmark info") @@ -648,6 +672,12 @@ local Keybinds = { [Enum.KeyCode.B] = switchMode, + [Enum.KeyCode.R] = function() + if not IsBenchmarking then + cycleCastType() + end + end, + [Enum.KeyCode.LeftBracket] = function() changeWorkerCount(-1) end, diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index 799947e5..3ee36727 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -14,6 +14,7 @@ j - Toggle BulkMoveTo / Motor6D y - Toggle ObjectCache v - Toggle Cast Visualization + r - Cycle cast type x - Cycle HighFidelityBehavior > - Increase HighFidelitySegmentSize < - Decrease HighFidelitySegmentSize @@ -40,6 +41,10 @@ local Config = { ObjectCacheEnabled = false, ProjectileAmount = 500, VisualizeCasts = false, + CastType = "Raycast", + RandomSeed = 2026, + BlockcastSize = Vector3.one, + SpherecastRadius = 0.5, Velocity = 6, @@ -125,48 +130,15 @@ local ActiveCasts = {} local IsBenchmarking = false local IsReconfiguring = false ---// FPS Tracking +--// Frame Statistics -local FPS = { - Current = 0, - Average = 0, - Minimum = math.huge, - Maximum = 0, - - Accumulator = 0, - FrameCount = 0, -} - -local function resetFPS() - FPS.Current = 0 - FPS.Average = 0 - FPS.Minimum = math.huge - FPS.Maximum = 0 - - FPS.Accumulator = 0 - FPS.FrameCount = 0 -end - -local function updateFPS(dt) - if dt <= 0 then - return - end - - local current = 1 / dt - - FPS.Current = current - FPS.Minimum = math.min(FPS.Minimum, current) - FPS.Maximum = math.max(FPS.Maximum, current) - - FPS.Accumulator += current - FPS.FrameCount += 1 - - -- Keep a continuously updated average. - FPS.Average = FPS.Accumulator / FPS.FrameCount -end +local FrameTimes = {} +local IsMeasuringFrames = false RunService.Heartbeat:Connect(function(dt) - updateFPS(dt) + if IsMeasuringFrames and dt > 0 then + FrameTimes[#FrameTimes + 1] = dt * 1000 + end end) --// Utility @@ -298,6 +270,8 @@ local function printBenchmarkInfo() Config.Velocity )) + print(("CastType: %s"):format(Config.CastType)) + print(("ObjectCache: %s"):format( getObjectCacheState() and "Enabled" or "Disabled" )) @@ -327,53 +301,87 @@ local function printBenchmarkInfo() )) end ---// FPS Output +--// Benchmark Output + +local function percentile(sortedValues, fraction) + if #sortedValues == 0 then + return 0 + end -local function printFPS() - local minimum = FPS.Minimum + local index = math.max(1, math.ceil(#sortedValues * fraction)) + return sortedValues[index] +end - if minimum == math.huge then - minimum = 0 +local function printFrameStats() + if #FrameTimes == 0 then + print("No frames sampled.") + return end - local delta = FPS.Current > 0 - and (1000 / FPS.Current) - or 0 + local sorted = table.clone(FrameTimes) + table.sort(sorted) + + local total = 0 + for _, frameTime in FrameTimes do + total += frameTime + end - print(("Delta: %.2f ms"):format(delta)) - print(("Average FPS: %.2f"):format(FPS.Average)) - print(("Max FPS: %.2f"):format(FPS.Maximum)) - print(("Min FPS: %.2f"):format(minimum)) + local average = total / #FrameTimes + local slowest = sorted[#sorted] + local fastest = sorted[1] + + print(("Frames sampled: %d"):format(#FrameTimes)) + print(("Average frame time: %.2f ms (%.2f FPS)"):format(average, 1000 / average)) + print(("P50 / P95 / P99: %.2f / %.2f / %.2f ms"):format( + percentile(sorted, 0.50), + percentile(sorted, 0.95), + percentile(sorted, 0.99) + )) + print(("FPS range: %.2f - %.2f"):format(1000 / slowest, 1000 / fastest)) end ---// Projectile Generation +local function printOperationStats(elapsed, operationCount) + local elapsedMilliseconds = elapsed * 1000 + local throughput = elapsed > 0 and operationCount / elapsed or 0 -local function randomDirection() - return Vector3.new( - math.random() * 2 - 1, - math.random() * 2 - 1, - math.random() * 2 - 1 - ).Unit + print(("Elapsed: %.2f ms"):format(elapsedMilliseconds)) + print(("Throughput: %.2f projectiles/s"):format(throughput)) end -local function fireProjectile() +--// Projectile Generation + +local function fireProjectile(random) local origin = Vector3.new( - math.random(-100, 100), - math.random(0, 100), - math.random(-100, 100) + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) ) - - Caster:RaycastFire( - origin, - randomDirection(), - Config.Velocity, - CastBehavior + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) ) + + if direction.Magnitude == 0 then + direction = Vector3.xAxis + else + direction = direction.Unit + end + + if Config.CastType == "Blockcast" then + Caster:BlockcastFire(origin, Config.BlockcastSize, direction, Config.Velocity, CastBehavior) + elseif Config.CastType == "Spherecast" then + Caster:SpherecastFire(origin, Config.SpherecastRadius, direction, Config.Velocity, CastBehavior) + else + Caster:RaycastFire(origin, direction, Config.Velocity, CastBehavior) + end end local function fireProjectiles() + local random = Random.new(Config.RandomSeed) + for _ = 1, Config.ProjectileAmount do - fireProjectile() + fireProjectile(random) end end @@ -386,39 +394,56 @@ local function runBenchmark() IsBenchmarking = true - resetFPS() - printBenchmarkInfo() print(("Firing %d projectiles..."):format( Config.ProjectileAmount )) + local creationStarted = os.clock() fireProjectiles() + local creationElapsed = os.clock() - creationStarted -- Let the first batch begin processing. task.wait() printSection("CREATION COMPLETE") - printFPS() + printOperationStats(creationElapsed, Config.ProjectileAmount) + table.clear(FrameTimes) + IsMeasuringFrames = true task.wait(Config.BenchmarkDuration) + IsMeasuringFrames = false printSection("SIMULATION COMPLETE") - printFPS() + printFrameStats() printSection("CLEANUP") + local cleanupStarted = os.clock() terminateActiveCasts() + local cleanupElapsed = os.clock() - cleanupStarted + printOperationStats(cleanupElapsed, Config.ProjectileAmount) task.wait(BENCHMARK_COOLDOWN) printSection("DONE") - printFPS() IsBenchmarking = false end +local function cycleCastType() + if Config.CastType == "Raycast" then + Config.CastType = "Blockcast" + elseif Config.CastType == "Blockcast" then + Config.CastType = "Spherecast" + else + Config.CastType = "Raycast" + end + + print(("CastType: %s"):format(Config.CastType)) +end + --// Configuration local function setInstanced(enabled) @@ -574,6 +599,7 @@ local function showHelp() print("j - Toggle BulkMoveTo / Motor6D") print("y - Toggle ObjectCache") print("v - Toggle VisualizeCasts") + print("r - Cycle Raycast / Blockcast / Spherecast") print("x - Cycle HighFidelityBehavior") print("> - Increase HighFidelitySegmentSize") print("< - Decrease HighFidelitySegmentSize") @@ -624,6 +650,12 @@ local Commands = { end end, + r = function() + if not IsBenchmarking then + cycleCastType() + end + end, + x = function() if not IsBenchmarking then cycleHighFidelity() @@ -673,7 +705,7 @@ local Commands = { --// Chat Handling -local function onChatMessage(player, message) +local function onChatMessage(_, message) local command = message:lower():gsub("^%s*(.-)%s*$", "%1") local handler = Commands[command] diff --git a/benchmarks/README.md b/benchmarks/README.md index a8378c3c..77914791 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -19,6 +19,7 @@ Fires projectiles and tracks FPS using keyboard controls. Supports both serial a | J | Toggle BulkMoveTo / Motor6D | | Y | Toggle ObjectCache | | V | Toggle VisualizeCasts | +| R | Cycle Raycast / Blockcast / Spherecast | | X | Cycle HighFidelityBehavior (Default > Automatic > Always) | | > | Increase HighFidelitySegmentSize by 0.05 | | < | Decrease HighFidelitySegmentSize by 0.05 (floor 0.05) | @@ -35,14 +36,17 @@ Fires projectiles and tracks FPS using keyboard controls. Supports both serial a Edit at the top of the file: ```lua -local Parallel = true -- true = parallel, false = serial -local numWorkers = 4 -- number of VM workers (parallel only) -local ProjectileAmount = 500 -- projectiles per benchmark run -local Instanced = true -- use cosmetic bullet instances -local VisualizeCasts = false -- show cast ray visualization -local BENCH_TIME = 6 -- seconds to simulate after creation -local MovementMode = "BulkMoveTo" -local ObjectCacheEnabled = false +local Config = { + Parallel = true, -- true = parallel, false = serial + NumWorkers = 4, -- number of VM workers (parallel only) + ProjectileAmount = 500, -- projectiles per benchmark run + CastType = "Raycast", -- Raycast, Blockcast, or Spherecast + Instanced = true, -- use cosmetic bullet instances + VisualizeCasts = false, -- show cast ray visualization + BenchmarkDuration = 6, -- seconds to simulate after creation + MovementMode = "BulkMoveTo", + ObjectCacheEnabled = false, +} ``` --- @@ -66,6 +70,7 @@ Type the letter in chat: | j | Toggle BulkMoveTo / Motor6D | | y | Toggle ObjectCache | | v | Toggle VisualizeCasts | +| r | Cycle Raycast / Blockcast / Spherecast | | x | Cycle HighFidelityBehavior | | > | Increase HighFidelitySegmentSize by 0.05 | | < | Decrease HighFidelitySegmentSize by 0.05 | @@ -79,13 +84,16 @@ Type the letter in chat: Edit at the top of the file: ```lua -local numWorkers = 4 -local Instanced = false -local MovementMode = "BulkMoveTo" -local UseObjectCache = false -local ProjectileAmount = 500 -local VisualizeCasts = false -local VELOCITY = 6 +local Config = { + NumWorkers = 4, + Instanced = false, + MovementMode = "BulkMoveTo", + ObjectCacheEnabled = false, + ProjectileAmount = 500, + CastType = "Raycast", + VisualizeCasts = false, + Velocity = 6, +} ``` --- @@ -100,29 +108,45 @@ ObjectCache Disabled VisualizeCasts: Disabled HighFidelityBehavior: Default MovementMode: BulkMoveTo +CastType: Raycast Instanceless numWorkers: 4 Firing 500 projectiles... === CREATION COMPLETE === -Delta: xx.xx ms -Average FPS: xx.xx -Max FPS: xx.xx -Min FPS: xx.xx +Elapsed: xx.xx ms +Throughput: xx.xx projectiles/s === SIMULATION COMPLETE === -... +Frames sampled: xxx +Average frame time: xx.xx ms (xx.xx FPS) +P50 / P95 / P99: xx.xx / xx.xx / xx.xx ms +FPS range: xx.xx - xx.xx +=== CLEANUP === +Elapsed: xx.xx ms +Throughput: xx.xx projectiles/s === DONE === -Delta: xx.xx ms -Average FPS: xx.xx -Max FPS: xx.xx -Min FPS: xx.xx ``` -- **Delta**: Frame time in milliseconds (lower is better) -- **FPS** values are collected per-frame and averaged over 0.5s windows -- `maxFps`/`minFps` reset at the start of each benchmark run +- **Elapsed / throughput** measure synchronous projectile creation and cleanup work. +- **Average frame time** is calculated from every heartbeat during the simulation window; FPS is derived from that average. +- **P50 / P95 / P99** expose frame-time spikes that an FPS average can hide (lower is better). +- Each run uses the same random seed, so different modes and settings receive identical projectile origins and directions. ## Quick Start -1. Insert BenchmarkClient into ReplicatedFirst (or StarterPlayerScripts) -2. Insert BenchmarkServer into ServerScriptService -3. Play in Studio -- press P on the client or type p in chat on the server \ No newline at end of file +1. Run `rojo serve benchmarks.project.json` from the repository root. +2. Connect an empty Studio place with the Rojo plugin. +3. Play in Studio -- press P on the client or type p in chat on the server. + +The benchmark project mounts FastCast2 in `ReplicatedStorage`, the client benchmark in `ReplicatedFirst`, and the server benchmark in `ServerScriptService`. You can also insert either script manually in those locations if you do not use Rojo. + +For useful comparisons, change one setting at a time and run each configuration several times. In particular, compare all three cast types in serial and parallel modes with visualization disabled; Studio rendering, open windows, and plugins can otherwise add significant noise. + +## Automated suite + +The repeatable six-case matrix (Serial/Parallel x Raycast/Blockcast/Spherecast) +now lives with the rest of the automated tests. It builds a Rojo place, runs it +in Studio with `run-in-roblox`, prints a Markdown results table, and uploads the +report to [paste.shellworks.dev](https://paste.shellworks.dev). See +[`tests/README.md`](../tests/README.md) for prerequisites and usage; run it with +`npm test` or `lune run tests/run.luau`. + diff --git a/package.json b/package.json index dfe17d20..9e7f5764 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,8 @@ { + "scripts": { + "test": "lune run tests/run.luau", + "test:no-upload": "lune run tests/run.luau --no-upload" + }, "dependencies": { "moonwave": "^1.2.0", "opencode-ai": "^1.18.3" diff --git a/rokit.toml b/rokit.toml index f9a0f232..b9715360 100644 --- a/rokit.toml +++ b/rokit.toml @@ -8,3 +8,5 @@ rojo = "rojo-rbx/rojo@7.6.1" wally = "UpliftGames/wally@0.3.2" luau-lsp = "JohnnyMorganz/luau-lsp@1.68.0" selene = "Kampfkarren/selene@0.31.0" +lune = "lune-org/lune@0.10.1" +run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" diff --git a/selene.toml b/selene.toml index 1f1e1703..d6fe9f5c 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,2 @@ -std = "roblox" \ No newline at end of file +std = "roblox" +exclude = ["tests/run.luau"] diff --git a/tests.project.json b/tests.project.json new file mode 100644 index 00000000..6d931801 --- /dev/null +++ b/tests.project.json @@ -0,0 +1,16 @@ +{ + "name": "FastCast2Tests", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "FastCast2": { + "$path": "src" + } + }, + "ServerScriptService": { + "FastCast2Tests": { + "$path": "tests/src" + } + } + } +} diff --git a/tests/Entry.luau b/tests/Entry.luau new file mode 100644 index 00000000..d593c2aa --- /dev/null +++ b/tests/Entry.luau @@ -0,0 +1,67 @@ +--[[ + run-in-roblox entry script. + + Starts the simulation (which boots the server harness in + ServerScriptService), waits for its JSON report, and re-emits it with a + stable sentinel so the Lune orchestrator can parse it from stdout. + + The report is picked up either from the ReplicatedStorage attribute the + harness sets or, as a fallback, from the sentinel the harness prints. +]] + +local HttpService = game:GetService("HttpService") +local LogService = game:GetService("LogService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local RESULT_ATTRIBUTE = "FastCast2TestResults" +local SENTINEL = "FASTCAST_SUITE_RESULTS" +local TIMEOUT_SECONDS = 600 + +local function extract(message: string): string? + local start = string.find(message, SENTINEL, 1, true) + if not start then + return nil + end + + local candidate = string.gsub(string.sub(message, start + #SENTINEL), "^%s+", "") + + -- Ignore log-relayed copies that were truncated in transit. + local ok = pcall(HttpService.JSONDecode, HttpService, candidate) + if not ok then + return nil + end + + return candidate +end + +if not RunService:IsRunning() then + RunService:Run() +end + +local encoded = ReplicatedStorage:GetAttribute(RESULT_ATTRIBUTE) + +local attributeConnection = ReplicatedStorage:GetAttributeChangedSignal(RESULT_ATTRIBUTE):Connect(function() + if encoded == nil then + encoded = ReplicatedStorage:GetAttribute(RESULT_ATTRIBUTE) + end +end) + +local logConnection = LogService.MessageOut:Connect(function(message) + if encoded == nil then + encoded = extract(message) + end +end) + +local deadline = os.clock() + TIMEOUT_SECONDS +while encoded == nil and os.clock() < deadline do + task.wait(0.25) + encoded = encoded or ReplicatedStorage:GetAttribute(RESULT_ATTRIBUTE) +end + +attributeConnection:Disconnect() +logConnection:Disconnect() + +assert(encoded ~= nil, "Timed out waiting for the FastCast2 test harness") + +print(SENTINEL .. " " .. encoded) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..f45bc6ff --- /dev/null +++ b/tests/README.md @@ -0,0 +1,75 @@ +# Tests + +Automated FastCast2 test + benchmark suite. One command builds a Rojo place, +runs it inside Roblox Studio with `run-in-roblox`, renders the results as a +Markdown table, and uploads the report to +[paste.shellworks.dev](https://paste.shellworks.dev). + +## Prerequisites + +FastCast2 exercises real Roblox APIs (raycasts, `BulkMoveTo`, Actor-based +parallel VMs), so the suite runs inside Studio rather than a standalone Luau +runtime. + +1. Install the toolchain (Rojo, Lune, `run-in-roblox`, Selene): + ```sh + rokit install + ``` + `run-in-roblox` can also be installed with `cargo install run-in-roblox`. +2. Make sure Roblox Studio is installed and signed in. The suite opens Studio + automatically and closes it when finished. + +## Run it + +```sh +npm test +# or +lune run tests/run.luau +``` + +Flags: + +| Flag | Description | +|------|-------------| +| `--no-upload` | Skip the `paste.shellworks.dev` upload and just print the report | +| `--keep` | Keep the built place in `./build` for manual inspection | + +Example: `npm test -- --no-upload` + +## What runs + +- **Functional tests** (`tests/src/FunctionalTests.luau`) follow the checklist + in [`TESTS.md`](../TESTS.md): initialization, raycast/blockcast/spherecast + firing, every public event, ObjectCache, movement modes, cast manipulation, + high-fidelity behavior, and the parallel-only extensions + (`SetFastCastEventsModule`, `SyncChangesToCast`). +- **Benchmarks** (`tests/src/Benchmarks.luau`) run a fixed six-case matrix of + Serial/Parallel x Raycast/Blockcast/Spherecast, reporting creation and + cleanup cost plus simulation frame-time percentiles. Adjust projectile count, + sample duration, and worker count in `Benchmarks.DefaultConfig`. + +> **Note:** "Avg frame"/"P95"/"P99" are Heartbeat intervals, so they are +> bounded by Studio's frame rate (about `16.7 ms` at the default 60 FPS cap). +> Use them to compare cases within a single run, not across machines. + +The console and the uploaded report both include this table: + +``` +| Mode | Cast | Create | Avg frame | P95 | P99 | Cleanup | +``` + +## How it works + +``` +tests/run.luau Lune orchestrator (build -> run -> render -> upload) +tests/Entry.luau run-in-roblox entry script (starts Run mode, relays results) +tests/src/Harness.server Server bootstrap; runs the suite and publishes JSON +tests/src/Framework Tiny assertion/runner library +tests/src/FunctionalTests Functional test cases +tests/src/Benchmarks Benchmark matrix +tests/src/TestEventsModule FastCastEventsModule used by the parallel event test +``` + +`tests.project.json` mounts `src/` into `ReplicatedStorage` and `tests/src/` +into `ServerScriptService`, so `rojo build tests.project.json` produces the +place used by the runner. diff --git a/tests/run.luau b/tests/run.luau new file mode 100644 index 00000000..7f216f45 --- /dev/null +++ b/tests/run.luau @@ -0,0 +1,310 @@ +--[[ + FastCast2 test runner. + + The single command that: + 1. builds the test place with rojo, + 2. runs it inside Roblox Studio with run-in-roblox, + 3. renders the functional-test summary and benchmark table as Markdown, + 4. uploads that report to paste.shellworks.dev and prints the URL. + + Usage (from the repository root): + lune run tests/run.luau + npm test + + Flags: + --no-upload Skip the paste.shellworks.dev upload. + --keep Keep the built place in ./build. +]] + +local fs = require("@lune/fs") +local net = require("@lune/net") +local process = require("@lune/process") +local serde = require("@lune/serde") + +local SENTINEL = "FASTCAST_SUITE_RESULTS" +local PASTE_ENDPOINT = "https://paste.shellworks.dev/api/pastes" + +-- Resolve the repository root from this file's location rather than the cwd, +-- so the runner works from anywhere. +local function resolveRoot(): string + local source = string.gsub(debug.info(1, "s"), "\\", "/") + local scriptDir = string.match(source, "^(.*)/[^/]+$") or "." + local base = string.match(scriptDir, "([^/]+)$") or scriptDir + + if base == "tests" then + return string.match(scriptDir, "^(.*)/tests$") or "." + end + + return scriptDir +end + +local ROOT = resolveRoot() +local PROJECT_FILE = ROOT .. "/tests.project.json" +local BUILD_DIR = ROOT .. "/build" +local PLACE_FILE = BUILD_DIR .. "/FastCast2Tests.rbxlx" +local ENTRY_FILE = ROOT .. "/tests/Entry.luau" + +local flags: { [string]: any } = {} +for _, value in process.args do + local key, raw = string.match(value, "^%-%-([%w%-]+)=?(.*)$") + if key then + flags[key] = if raw ~= "" then raw else true + end +end + +local function fail(message: string) + warn("[run] " .. message) + process.exit(1) +end + +local function run(program: string, args: { string }) + local ok, result = pcall(function() + return process.exec(program, args, { cwd = ROOT }) + end) + + if not ok then + return nil, tostring(result) + end + + return result, nil +end + +local function formatMs(value: any): string + if type(value) ~= "number" then + return "n/a" + end + return string.format("%.2f ms", value) +end + +local function benchmarkTable(rows: { any }): string + local lines = { + "| Mode | Cast | Create | Avg frame | P95 | P99 | Cleanup |", + "| --- | --- | --- | --- | --- | --- | --- |", + } + + for _, row in rows do + table.insert( + lines, + string.format( + "| %s | %s | %s | %s | %s | %s | %s |", + row.mode, + row.castType, + formatMs(row.creationMs), + formatMs(row.averageFrameMs), + formatMs(row.p95FrameMs), + formatMs(row.p99FrameMs), + formatMs(row.cleanupMs) + ) + ) + end + + return table.concat(lines, "\n") +end + +local function testSection(tests: any): string + local lines = { + string.format( + "**%d passed, %d failed** (%d total, %.1f ms)", + tests.passed or 0, + tests.failed or 0, + tests.total or 0, + tests.durationMs or 0 + ), + } + + if (tests.failed or 0) > 0 then + table.insert(lines, "") + table.insert(lines, "| Group | Case | Error |") + table.insert(lines, "| --- | --- | --- |") + + for _, group in tests.groups or {} do + for _, case in group.cases or {} do + if case.status ~= "passed" then + local message = string.gsub(case.error or "unknown error", "|", "\\|") + message = string.gsub(message, "[\r\n]+", " ") + table.insert( + lines, + string.format("| %s | %s | %s |", group.name, case.name, message) + ) + end + end + end + end + + return table.concat(lines, "\n") +end + +local function buildMarkdown(payload: any): string + local tests = payload.tests or { passed = 0, failed = 0, total = 0, durationMs = 0, groups = {} } + local benchmarks = payload.benchmarks or {} + + local parts = { + "# FastCast2 test report", + "", + "Generated: " .. (payload.generatedAt or "unknown"), + "Context: " .. (payload.runContext or "unknown"), + "", + "## Functional tests", + "", + testSection(tests), + "", + "## Benchmarks", + "", + benchmarkTable(benchmarks), + } + + if payload.error then + table.insert(parts, "") + table.insert(parts, "## Harness error") + table.insert(parts, "") + table.insert(parts, "```") + table.insert(parts, tostring(payload.error)) + table.insert(parts, "```") + end + + return table.concat(parts, "\n") .. "\n" +end + +local function parseResults(output: string): any? + for line in string.gmatch(output, "[^\r\n]+") do + local start = string.find(line, SENTINEL, 1, true) + if start then + local json = string.gsub(string.sub(line, start + #SENTINEL), "^%s+", "") + local ok, decoded = pcall(serde.decode, "json", json) + if ok and decoded then + return decoded + end + end + end + + return nil +end + +local function upload(markdown: string): (string?, string?) + local ok, response = pcall(function() + return net.request({ + url = PASTE_ENDPOINT, + method = "POST", + headers = { ["Content-Type"] = "application/json" }, + body = serde.encode("json", { + content = markdown, + title = "FastCast2 test report", + language = "markdown", + visibility = "unlisted", + expires_in = "2w", + }), + }) + end) + + if not ok then + return nil, tostring(response) + end + + if response.statusCode < 200 or response.statusCode >= 300 then + return nil, string.format("HTTP %d: %s", response.statusCode, response.body) + end + + local decoded = serde.decode("json", response.body) + return decoded.url, nil +end + +-- 1. Validate the project layout ------------------------------------------------- +if not fs.isFile(PROJECT_FILE) then + fail("could not find tests.project.json at " .. PROJECT_FILE) +end + +if not fs.isFile(ENTRY_FILE) then + fail("could not find tests/Entry.luau at " .. ENTRY_FILE) +end + +-- 2. Build the place ------------------------------------------------------------- +print("[run] building test place with rojo...") +if not fs.isDir(BUILD_DIR) then + fs.writeDir(BUILD_DIR) +end + +local buildResult, buildError = run("rojo", { "build", "tests.project.json", "-o", PLACE_FILE }) +if not buildResult then + fail( + "could not run rojo (" .. buildError .. "). Install it with `rokit install` or `cargo install rojo`." + ) +end + +if not buildResult.ok then + fail("rojo build failed:\n" .. buildResult.stdout .. buildResult.stderr) +end + +-- 3. Run the suite in Studio ----------------------------------------------------- +print("[run] launching Roblox Studio with run-in-roblox (this can take a minute)...") + +local runResult, runError = run( + "run-in-roblox", + { "--place", PLACE_FILE, "--script", ENTRY_FILE } +) + +if not runResult then + fail( + "could not run run-in-roblox (" + .. runError + .. "). Install it with `rokit install` or `cargo install run-in-roblox`, and make sure Roblox Studio is signed in." + ) +end + +local output = (runResult.stdout or "") .. "\n" .. (runResult.stderr or "") +local payload = parseResults(output) + +if not payload then + fail( + "no " .. SENTINEL .. " record found in run-in-roblox output.\n" + .. string.format("run-in-roblox exited with code %s.\n", tostring(runResult.code)) + .. "---- captured output (tail) ----\n" + .. string.sub(output, -4000) + ) +end + +-- 4. Report ---------------------------------------------------------------------- +local markdown = buildMarkdown(payload) +local tests = payload.tests or { passed = 0, failed = 0, total = 0, durationMs = 0, groups = {} } + +print("") +print(benchmarkTable(payload.benchmarks or {})) +print("") +print(string.format("Tests: %d passed, %d failed", tests.passed or 0, tests.failed or 0)) + +if (tests.failed or 0) > 0 then + for _, group in tests.groups or {} do + for _, case in group.cases or {} do + if case.status ~= "passed" then + print(string.format("FAIL %s / %s: %s", group.name, case.name, case.error or "unknown error")) + end + end + end +end + +if payload.error then + print("Harness error: " .. tostring(payload.error)) +end + +if flags["no-upload"] then + print("Upload: skipped (--no-upload)") +else + print("[run] uploading report to " .. PASTE_ENDPOINT .. "...") + local url, uploadError = upload(markdown) + if url then + print("Report: " .. url) + else + warn("[run] upload failed: " .. tostring(uploadError)) + end +end + +if not flags.keep then + pcall(function() + fs.removeDir(BUILD_DIR) + end) +end + +if payload.error or (tests.failed or 0) > 0 then + process.exit(1) +end + +print("OK") diff --git a/tests/src/Benchmarks.luau b/tests/src/Benchmarks.luau new file mode 100644 index 00000000..f91235e5 --- /dev/null +++ b/tests/src/Benchmarks.luau @@ -0,0 +1,220 @@ +--[[ + FastCast2 automated benchmark matrix. + + Runs a fixed set of (mode x cast type) cases on the server and returns one + record per case. Creation and cleanup throughput are measured with + os.clock, while simulation cost is reported as Heartbeat frame-time + percentiles. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local ServerScriptService = game:GetService("ServerScriptService") + +local FastCast = require(ReplicatedStorage:WaitForChild("FastCast2")) + +local Benchmarks = {} + +Benchmarks.DefaultConfig = { + projectileCount = 500, + sampleDuration = 4, + parallelWorkers = 4, + randomSeed = 2026, + velocity = 6, + castTypes = { "Raycast", "Blockcast", "Spherecast" }, +} + +local frameTimes = {} +local isMeasuringFrames = false +local parallelCounter = 0 + +RunService.Heartbeat:Connect(function(deltaTime) + if isMeasuringFrames and deltaTime > 0 then + table.insert(frameTimes, deltaTime * 1000) + end +end) + +local function percentile(sortedValues: { number }, fraction: number): number + if #sortedValues == 0 then + return 0 + end + return sortedValues[math.max(1, math.ceil(#sortedValues * fraction))] +end + +local function summarizeFrames() + if #frameTimes == 0 then + return { frames = 0, averageMs = 0, p50Ms = 0, p95Ms = 0, p99Ms = 0 } + end + + local sorted = table.clone(frameTimes) + table.sort(sorted) + + local total = 0 + for _, frameTime in frameTimes do + total += frameTime + end + + return { + frames = #frameTimes, + averageMs = total / #frameTimes, + p50Ms = percentile(sorted, 0.50), + p95Ms = percentile(sorted, 0.95), + p99Ms = percentile(sorted, 0.99), + } +end + +local function makeInput(random: Random): (Vector3, Vector3) + local origin = Vector3.new( + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) + ) + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) + ) + + if direction.Magnitude == 0 then + direction = Vector3.xAxis + else + direction = direction.Unit + end + + return origin, direction +end + +local function fire(caster: any, castType: string, origin: Vector3, direction: Vector3, velocity: number, behavior: any) + if castType == "Blockcast" then + caster:BlockcastFire(origin, Vector3.one, direction, velocity, behavior) + elseif castType == "Spherecast" then + caster:SpherecastFire(origin, 0.5, direction, velocity, behavior) + else + caster:RaycastFire(origin, direction, velocity, behavior) + end +end + +local function createCaster(mode: string, config: any, activeCasts: { any }): any + local caster + + if mode == "Parallel" then + parallelCounter += 1 + local suffix = tostring(parallelCounter) + caster = FastCast.newParallel() + caster:Init( + config.parallelWorkers, + ServerScriptService, + "FastCast2BenchmarkVMs" .. suffix, + ServerScriptService, + "FastCast2BenchmarkContainers" .. suffix, + "FastCast2BenchmarkVM" .. suffix, + "BulkMoveTo" + ) + else + caster = FastCast.new() + caster:Init("BulkMoveTo") + end + + caster.CastFire = function(cast: any) + table.insert(activeCasts, cast) + end + + return caster +end + +local function runCase(mode: string, castType: string, caster: any, activeCasts: { any }, config: any) + local random = Random.new(config.randomSeed) + local creationStarted = os.clock() + + for _ = 1, config.projectileCount do + local origin, direction = makeInput(random) + fire(caster, castType, origin, direction, config.velocity, config.behavior) + end + + local creationSeconds = os.clock() - creationStarted + + -- Let parallel workers and CastFire callbacks finish before sampling. + task.wait() + table.clear(frameTimes) + isMeasuringFrames = true + task.wait(config.sampleDuration) + isMeasuringFrames = false + + local frames = summarizeFrames() + local activeCount = #activeCasts + local cleanupStarted = os.clock() + + for index = #activeCasts, 1, -1 do + FastCast:TerminateCast(activeCasts[index]) + activeCasts[index] = nil + end + + local cleanupSeconds = os.clock() - cleanupStarted + + local result = { + mode = mode, + castType = castType, + projectiles = config.projectileCount, + activeCasts = activeCount, + creationMs = creationSeconds * 1000, + creationPerSecond = if creationSeconds > 0 then config.projectileCount / creationSeconds else 0, + cleanupMs = cleanupSeconds * 1000, + cleanupPerSecond = if cleanupSeconds > 0 then activeCount / cleanupSeconds else 0, + frames = frames.frames, + averageFrameMs = frames.averageMs, + p50FrameMs = frames.p50Ms, + p95FrameMs = frames.p95Ms, + p99FrameMs = frames.p99Ms, + } + + print(string.format( + "[bench] %s/%s: create %.2f ms, avg %.2f ms, p95 %.2f ms, p99 %.2f ms, cleanup %.2f ms", + mode, + castType, + result.creationMs, + result.averageFrameMs, + result.p95FrameMs, + result.p99FrameMs, + result.cleanupMs + )) + + task.wait(1) + return result +end + +--[=[ + Runs the full benchmark matrix. + + @param overrides any? -- Optional fields merged over DefaultConfig. + @return { any } -- One record per (mode, cast type) case. +]=] +function Benchmarks.run(overrides: any?): { any } + local config = table.clone(Benchmarks.DefaultConfig) + for key, value in overrides or {} do + config[key] = value + end + + local behavior = FastCast.newBehavior() + behavior.MaxDistance = 1e9 + behavior.Acceleration = Vector3.zero + behavior.AutoIgnoreContainer = true + config.behavior = behavior + + local results = {} + + for _, mode in { "Serial", "Parallel" } do + local activeCasts = {} + local caster = createCaster(mode, config, activeCasts) + + for _, castType in config.castTypes do + table.insert(results, runCase(mode, castType, caster, activeCasts, config)) + end + + caster:Destroy() + task.wait(1) + end + + return results +end + +return Benchmarks diff --git a/tests/src/Framework.luau b/tests/src/Framework.luau new file mode 100644 index 00000000..9d413eb1 --- /dev/null +++ b/tests/src/Framework.luau @@ -0,0 +1,268 @@ +--[[ + FastCast2 test framework. + + A small, dependency-free assertion and runner library. Tests are grouped, + run sequentially, and any raised error marks a case as failed. Tests may + yield (task.wait), which is required for cast simulation timing. +]] + +local Framework = {} + +export type TestFunction = (expect: any) -> () +export type TestCase = { name: string, fn: TestFunction } +export type TestGroup = { name: string, cases: { TestCase } } + +local Assert = {} +Framework.Assert = Assert + +local function describeValue(value: any): string + if typeof(value) == "Instance" then + return value:GetFullName() + end + return tostring(value) +end + +--[=[ + @class Assert + Assertion helpers passed to every test case as `expect`. +]=] + +function Assert.truthy(condition: any, message: string?) + if not condition then + error(message or "expected value to be truthy", 2) + end +end + +function Assert.falsy(condition: any, message: string?) + if condition then + error(message or "expected value to be falsy", 2) + end +end + +function Assert.equal(actual: any, expected: any, message: string?) + if actual ~= expected then + error( + string.format( + "%s (expected %s, got %s)", + message or "values are not equal", + describeValue(expected), + describeValue(actual) + ), + 2 + ) + end +end + +function Assert.notEqual(actual: any, expected: any, message: string?) + if actual == expected then + error( + string.format( + "%s (did not expect %s)", + message or "values should not be equal", + describeValue(expected) + ), + 2 + ) + end +end + +function Assert.near(actual: any, expected: any, tolerance: number?, message: string?) + tolerance = tolerance or 1e-4 + + local actualType = typeof(actual) + if actualType ~= typeof(expected) then + error("near() expects values of the same type", 2) + end + + local delta + if actualType == "Vector3" then + delta = (actual - expected).Magnitude + elseif actualType == "number" then + delta = math.abs(actual - expected) + else + error("near() only supports numbers and Vector3", 2) + end + + if delta > tolerance then + error( + string.format( + "%s (expected %s, got %s, delta %.6f)", + message or "values are not near", + describeValue(expected), + describeValue(actual), + delta + ), + 2 + ) + end +end + +function Assert.instance(value: any, className: string, message: string?) + if typeof(value) ~= "Instance" then + error( + string.format( + "%s (expected an Instance, got %s)", + message or "value is not an Instance", + typeof(value) + ), + 2 + ) + end + + if not value:IsA(className) then + error( + string.format( + "%s (expected %s, got %s)", + message or "instance class mismatch", + className, + value.ClassName + ), + 2 + ) + end +end + +function Assert.greaterThan(actual: any, expected: any, message: string?) + if typeof(actual) ~= "number" or typeof(expected) ~= "number" then + error("greaterThan() expects numbers", 2) + end + + if actual <= expected then + error( + string.format( + "%s (expected > %s, got %s)", + message or "value is not greater than expected", + expected, + actual + ), + 2 + ) + end +end + +--[=[ + Builds a group of test cases. + + @param name string -- The group name. + @param register (test: (name: string, fn: TestFunction) -> ()) -> () -- Registration callback. + @return TestGroup +]=] +function Framework.describe(name: string, register: (test: (string, TestFunction) -> ()) -> ()): TestGroup + local group: TestGroup = { name = name, cases = {} } + + register(function(caseName: string, fn: TestFunction) + table.insert(group.cases, { name = caseName, fn = fn }) + end) + + return group +end + +--[=[ + Returns true once `predicate` returns truthy, or false if `timeout` elapses. + + @param predicate () -> boolean + @param timeout number? -- Seconds to wait. Defaults to 5. + @param interval number? -- Seconds between polls. Defaults to 0.05. + @return boolean +]=] +function Framework.waitFor(predicate: () -> boolean, timeout: number?, interval: number?): boolean + timeout = timeout or 5 + interval = interval or 0.05 + local deadline = os.clock() + timeout + + while true do + local ok, result = pcall(predicate) + if ok and result then + return true + end + + if os.clock() >= deadline then + return false + end + + task.wait(interval) + end +end + +--[=[ + Runs every group and returns a JSON-serializable report. Each test gets its + own `expect` object; resources registered through `expect.cleanup` are torn + down after the case finishes, even when it fails. +]=] +function Framework.run(groups: { TestGroup }, afterEach: (() -> ())?) + local report = { + passed = 0, + failed = 0, + total = 0, + durationMs = 0, + groups = {} :: { any }, + } + + local suiteStarted = os.clock() + + for _, group in groups do + local groupReport = { + name = group.name, + passed = 0, + failed = 0, + cases = {} :: { any }, + } + + print(string.format("[tests] %s", group.name)) + + for _, case in group.cases do + local cleanups = {} + local expect = setmetatable({ + cleanup = function(callback: () -> ()) + table.insert(cleanups, callback) + end, + }, { __index = Assert }) + + local caseStarted = os.clock() + local ok, err = pcall(case.fn, expect) + + for index = #cleanups, 1, -1 do + local cleaned, cleanupError = pcall(cleanups[index]) + if not cleaned then + warn(string.format(" cleanup for %s failed: %s", case.name, tostring(cleanupError))) + end + end + + if afterEach then + local hooked, hookError = pcall(afterEach) + if not hooked then + warn(string.format(" afterEach for %s failed: %s", case.name, tostring(hookError))) + end + end + + local durationMs = (os.clock() - caseStarted) * 1000 + + local caseReport = { + name = case.name, + status = if ok then "passed" else "failed", + durationMs = durationMs, + error = if ok then nil else tostring(err), + } + + report.total += 1 + if ok then + report.passed += 1 + groupReport.passed += 1 + print(string.format(" ok %s (%.1f ms)", case.name, durationMs)) + else + report.failed += 1 + groupReport.failed += 1 + warn(string.format(" FAIL %s (%.1f ms): %s", case.name, durationMs, tostring(err))) + end + + table.insert(groupReport.cases, caseReport) + end + + table.insert(report.groups, groupReport) + end + + report.durationMs = (os.clock() - suiteStarted) * 1000 + return report +end + +return Framework diff --git a/tests/src/FunctionalTests.luau b/tests/src/FunctionalTests.luau new file mode 100644 index 00000000..940020c7 --- /dev/null +++ b/tests/src/FunctionalTests.luau @@ -0,0 +1,495 @@ +--[[ + FastCast2 functional tests. + + These cover the checklist in TESTS.md: initialization, the three cast + variants, every public event, ObjectCache, movement modes, cast + manipulation, and the parallel-only extensions. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerScriptService = game:GetService("ServerScriptService") + +local FastCast = require(ReplicatedStorage:WaitForChild("FastCast2")) +local FastCastEnums = require(ReplicatedStorage.FastCast2:WaitForChild("FastCastEnums")) + +local Framework = require(script.Parent.Framework) +local TestEventsModule = script.Parent:WaitForChild("TestEventsModule") + +local CAST_TYPES = { "Raycast", "Blockcast", "Spherecast" } +local HIT_TIMEOUT = 5 + +local arenaCounter = 0 + +local function createArena(): Folder + arenaCounter += 1 + local arena = Instance.new("Folder") + arena.Name = "FastCast2TestArena" .. arenaCounter + arena.Parent = workspace + return arena +end + +local function createWall(parent: Instance, position: Vector3, size: Vector3): Part + local wall = Instance.new("Part") + wall.Name = "Wall" + wall.Anchored = true + wall.CanCollide = true + wall.CanQuery = true + wall.Size = size + wall.Position = position + wall.Parent = parent + return wall +end + +local function createContainer(parent: Instance): Folder + local container = Instance.new("Folder") + container.Name = "Projectiles" + container.Parent = parent + return container +end + +local function makeBehavior(container: Instance): any + local behavior = FastCast.newBehavior() + behavior.MaxDistance = 1e6 + behavior.Acceleration = Vector3.zero + behavior.CosmeticBulletContainer = container + + local params = RaycastParams.new() + params.FilterType = Enum.RaycastFilterType.Exclude + params.IgnoreWater = true + params.FilterDescendantsInstances = { container } + behavior.RaycastParams = params + + return behavior +end + +local function fire(caster: any, castType: string, origin: Vector3, direction: Vector3, velocity: number, behavior: any) + if castType == "Blockcast" then + caster:BlockcastFire(origin, Vector3.one, direction, velocity, behavior) + elseif castType == "Spherecast" then + caster:SpherecastFire(origin, 0.5, direction, velocity, behavior) + else + caster:RaycastFire(origin, direction, velocity, behavior) + end +end + +local parallelCounter = 0 + +local function createParallelCaster(events: { [string]: any }?): any + parallelCounter += 1 + local suffix = tostring(parallelCounter) + local caster = FastCast.newParallel() + + for name, fn in events or {} do + caster[name] = fn + end + + caster:Init( + 2, + ServerScriptService, + "FastCast2TestVMs" .. suffix, + ServerScriptService, + "FastCast2TestContainers" .. suffix, + "FastCast2TestVM" .. suffix, + "BulkMoveTo" + ) + + return caster +end + +local function registerSerialCastTypes(test: (string, any) -> ()) + for _, castType in CAST_TYPES do + test("Serial " .. castType .. " reaches a wall", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local hitInstance = nil + local terminated = false + + local caster = FastCast.new() + caster.Hit = function(_, result) + hitInstance = result.Instance + end + caster.CastTerminating = function() + terminated = true + end + caster:Init("BulkMoveTo") + + fire(caster, castType, Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + castType .. " never registered a hit" + ) + expect.equal(hitInstance, wall, castType .. " hit the wrong instance") + expect.truthy( + Framework.waitFor(function() + return terminated + end, HIT_TIMEOUT), + castType .. " never terminated after the hit" + ) + + caster:Destroy() + arena:Destroy() + end) + end +end + +local function registerParallelCastTypes(test: (string, any) -> ()) + for _, castType in CAST_TYPES do + test("Parallel " .. castType .. " reaches a wall", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local hitInstance = nil + local terminated = false + + local caster = createParallelCaster({ + Hit = function(_, result) + hitInstance = result.Instance + end, + CastTerminating = function() + terminated = true + end, + }) + + fire(caster, castType, Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + "Parallel " .. castType .. " never registered a hit" + ) + expect.equal(hitInstance, wall, "Parallel " .. castType .. " hit the wrong instance") + expect.truthy( + Framework.waitFor(function() + return terminated + end, HIT_TIMEOUT), + "Parallel " .. castType .. " never terminated after the hit" + ) + + caster:Destroy() + arena:Destroy() + end) + end +end + +return function() + return { + Framework.describe("FastCastBehavior", function(test) + test("newBehavior returns the documented defaults", function(expect) + local behavior = FastCast.newBehavior() + + expect.equal(behavior.MaxDistance, 1000, "MaxDistance default changed") + expect.near(behavior.Acceleration, Vector3.zero, 1e-6, "Acceleration default changed") + expect.equal( + behavior.HighFidelityBehavior, + FastCastEnums.HighFidelityBehavior.Default, + "HighFidelityBehavior default changed" + ) + expect.equal(behavior.AutoIgnoreContainer, true, "AutoIgnoreContainer default changed") + end) + end), + + Framework.describe("FastCastSerial", function(test) + test("initializes and destroys", function(expect) + local caster = FastCast.new() + caster:Init("BulkMoveTo") + + expect.equal(caster.AlreadyInit, true, "caster was not marked initialized") + expect.equal(caster.MovementMode, "BulkMoveTo", "movement mode was not stored") + + caster:Destroy() + expect.equal(getmetatable(caster), nil, "destroy did not clear the metatable") + end) + + test("RaycastFire fires CastFire synchronously", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firedCast = nil + local caster = FastCast.new() + caster.CastFire = function(cast) + firedCast = cast + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 60, behavior) + + expect.truthy(firedCast, "CastFire did not run") + expect.equal(firedCast.Type, FastCastEnums.CastType.Raycast, "wrong cast type") + expect.equal(firedCast.CFrame.Position, Vector3.new(0, 0, 0), "cast origin is wrong") + + FastCast:TerminateCast(firedCast) + caster:Destroy() + arena:Destroy() + end) + + registerSerialCastTypes(test) + + test("Pierced and CanPierce run when a cast is allowed through", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + behavior.MaxDistance = 80 + + createWall(arena, Vector3.new(0, 0, -20), Vector3.new(50, 50, 2)) + + local piercedCount = 0 + local terminated = false + + local caster = FastCast.new() + caster.CanPierce = function() + return true + end + caster.Pierced = function() + piercedCount += 1 + end + caster.CastTerminating = function() + terminated = true + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return terminated + end, HIT_TIMEOUT), + "cast never terminated" + ) + expect.greaterThan(piercedCount, 0, "the cast never pierced the wall") + + caster:Destroy() + arena:Destroy() + end) + + test("LengthChanged runs when enabled", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + behavior.FastCastEventsConfig.UseLengthChanged = true + + local lengthChanges = 0 + local caster = FastCast.new() + caster.LengthChanged = function() + lengthChanges += 1 + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + Framework.waitFor(function() + return lengthChanges > 0 + end, HIT_TIMEOUT) + + expect.greaterThan(lengthChanges, 0, "LengthChanged never fired") + + caster:Destroy() + arena:Destroy() + end) + + test("ObjectCache reuses pooled projectile parts", function(expect) + local arena = createArena() + local holder = Instance.new("Folder") + holder.Name = "Cache" + holder.Parent = arena + + local template = Instance.new("Part") + template.Name = "Projectile" + template.Anchored = true + template.CanCollide = false + template.CanQuery = false + template.Parent = arena + + local behavior = FastCast.newBehavior() + behavior.MaxDistance = 1e6 + behavior.Acceleration = Vector3.zero + behavior.RaycastParams = RaycastParams.new() + + local firedCast = nil + local caster = FastCast.new() + caster.CastFire = function(cast) + firedCast = cast + end + caster:Init("BulkMoveTo", true, template, 8, holder) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 1, 0), 60, behavior) + + expect.truthy(firedCast, "CastFire did not run") + local projectile = firedCast.RayInfo.CosmeticBulletObject + expect.instance(projectile, "BasePart", "ObjectCache did not provide a projectile") + expect.truthy( + projectile:IsDescendantOf(holder), + "projectile was not parented under the cache holder" + ) + + FastCast:TerminateCast(firedCast) + caster:Destroy() + arena:Destroy() + end) + + test("movement mode can be switched", function(expect) + local caster = FastCast.new() + caster:Init("Motor6D") + expect.equal(caster.BaseCast.CurrentMovementMode, "Motor6D", "initial movement mode was not applied") + + caster:SetMovementModeEnabled(true, "BulkMoveTo") + expect.equal(caster.BaseCast.CurrentMovementMode, "BulkMoveTo", "movement mode was not switched") + expect.equal(caster.MovementMode, "BulkMoveTo", "caster did not record the new mode") + + caster:Destroy() + end) + + test("cast velocity, acceleration and position can be manipulated", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firedCast = nil + local caster = FastCast.new() + caster.CastFire = function(cast) + firedCast = cast + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 1, 0), 60, behavior) + expect.truthy(firedCast, "CastFire did not run") + + local cast = firedCast :: any + FastCast:SetVelocityCast(cast, Vector3.new(0, 0, -100)) + expect.near(FastCast:GetVelocityCast(cast), Vector3.new(0, 0, -100), 1e-3, "velocity was not set") + + FastCast:AddVelocityCast(cast, Vector3.new(10, 0, 0)) + expect.near(FastCast:GetVelocityCast(cast), Vector3.new(10, 0, -100), 1e-3, "velocity was not added") + + FastCast:SetAccelerationCast(cast, Vector3.new(0, -10, 0)) + expect.near(FastCast:GetAccelerationCast(cast), Vector3.new(0, -10, 0), 1e-3, "acceleration was not set") + + FastCast:AddAccelerationCast(cast, Vector3.new(0, -5, 0)) + expect.near(FastCast:GetAccelerationCast(cast), Vector3.new(0, -15, 0), 1e-3, "acceleration was not added") + + FastCast:SetPositionCast(cast, Vector3.new(5, 5, 5)) + expect.near(FastCast:GetPositionCast(cast), Vector3.new(5, 5, 5), 1e-3, "position was not set") + + FastCast:AddPositionCast(cast, Vector3.new(1, 0, 0)) + expect.near(FastCast:GetPositionCast(cast), Vector3.new(6, 5, 5), 1e-3, "position was not added") + + FastCast:TerminateCast(cast) + caster:Destroy() + arena:Destroy() + end) + + test("HighFidelity Always still resolves a hit", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + behavior.HighFidelityBehavior = FastCastEnums.HighFidelityBehavior.Always + behavior.HighFidelitySegmentSize = 1 + + local hitInstance = nil + local caster = FastCast.new() + caster.Hit = function(_, result) + hitInstance = result.Instance + end + caster:Init("BulkMoveTo") + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + "high fidelity cast never hit the wall" + ) + expect.equal(hitInstance, wall, "high fidelity cast hit the wrong instance") + + caster:Destroy() + arena:Destroy() + end) + end), + + Framework.describe("FastCastParallel", function(test) + test("initializes and destroys", function(expect) + local caster = createParallelCaster() + expect.equal(caster.AlreadyInit, true, "parallel caster was not marked initialized") + + caster:Destroy() + end) + + registerParallelCastTypes(test) + + test("SetFastCastEventsModule forwards module events", function(expect) + local arena = createArena() + createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local signal = ReplicatedStorage:WaitForChild("FastCast2TestSignal") :: BindableEvent + local received = {} + + local connection = signal.Event:Connect(function(name) + received[name] = true + end) + + local caster = createParallelCaster() + caster:SetFastCastEventsModule(TestEventsModule) + + -- Let the worker VMs receive the new events module. + task.wait(0.25) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return received.Hit == true + end, HIT_TIMEOUT), + "events module Hit never fired" + ) + + connection:Disconnect() + caster:Destroy() + arena:Destroy() + end) + + test("SyncChangesToCast does not error", function(expect) + local arena = createArena() + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firedCast = nil + local caster = createParallelCaster({ + CastFire = function(cast) + firedCast = cast + end, + }) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 1, 0), 60, behavior) + expect.truthy( + Framework.waitFor(function() + return firedCast ~= nil + end, HIT_TIMEOUT), + "parallel CastFire never ran" + ) + + local cast = firedCast :: any + FastCast:SetPositionCast(cast, Vector3.new(10, 10, 10)) + caster:SyncChangesToCast(cast) + task.wait(0.1) + + expect.near(FastCast:GetPositionCast(cast), Vector3.new(10, 10, 10), 1e-3, "sync changed the local cast") + + FastCast:TerminateCast(cast) + caster:Destroy() + arena:Destroy() + end) + end), + } +end diff --git a/tests/src/Harness.server.luau b/tests/src/Harness.server.luau new file mode 100644 index 00000000..6d6b8b18 --- /dev/null +++ b/tests/src/Harness.server.luau @@ -0,0 +1,97 @@ +--[[ + FastCast2 test harness (server). + + Runs the functional suite and the benchmark matrix, then publishes the + combined JSON report to a ReplicatedStorage attribute so the run-in-roblox + entry script can forward it to the Lune orchestrator. +]] + +local HttpService = game:GetService("HttpService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local ServerScriptService = game:GetService("ServerScriptService") + +if not RunService:IsRunning() then + return +end + +local RESULT_ATTRIBUTE = "FastCast2TestResults" + +local function ensureTestSignal() + if ReplicatedStorage:FindFirstChild("FastCast2TestSignal") == nil then + local signal = Instance.new("BindableEvent") + signal.Name = "FastCast2TestSignal" + signal.Parent = ReplicatedStorage + end +end + +-- Safety net: a failing test may abort before its own teardown, so scrub any +-- test arenas and worker containers between cases to keep them isolated. +local function cleanupLeftovers() + for _, child in workspace:GetChildren() do + if string.match(child.Name, "^FastCast2TestArena") then + child:Destroy() + end + end + + for _, child in ServerScriptService:GetChildren() do + if + string.match(child.Name, "^FastCast2TestContainers%d") + or string.match(child.Name, "^FastCast2TestVMs%d") + then + child:Destroy() + end + end +end + +local function runSuite() + local Framework = require(script.Parent.Framework) + local FunctionalTests = require(script.Parent.FunctionalTests) + local Benchmarks = require(script.Parent.Benchmarks) + + ensureTestSignal() + + local tests = Framework.run(FunctionalTests(), cleanupLeftovers) + local benchmarks = Benchmarks.run() + + return { + generatedAt = os.date("%Y-%m-%d %H:%M:%S"), + runContext = if RunService:IsStudio() then "studio" else "game", + tests = tests, + benchmarks = benchmarks, + } +end + +local ok, result = pcall(runSuite) + +local payload +if ok then + payload = result +else + warn("[FastCast2] suite crashed: " .. tostring(result)) + payload = { + generatedAt = os.date("%Y-%m-%d %H:%M:%S"), + runContext = if RunService:IsStudio() then "studio" else "game", + tests = { passed = 0, failed = 0, total = 0, durationMs = 0, groups = {} }, + benchmarks = {}, + error = tostring(result), + } +end + +local encoded = HttpService:JSONEncode(payload) +ReplicatedStorage:SetAttribute(RESULT_ATTRIBUTE, encoded) + +if payload.error then + warn("[FastCast2] published results with errors") +else + print(string.format( + "[FastCast2] suite complete: %d passed, %d failed, %d benchmark case(s)", + payload.tests.passed, + payload.tests.failed, + #payload.benchmarks + )) +end + +-- Also emit the sentinel directly so the runner can find it even if the +-- entry script's relay is unavailable. +print("FASTCAST_SUITE_RESULTS " .. encoded) diff --git a/tests/src/TestEventsModule.luau b/tests/src/TestEventsModule.luau new file mode 100644 index 00000000..65579d79 --- /dev/null +++ b/tests/src/TestEventsModule.luau @@ -0,0 +1,27 @@ +--[[ + FastCastEventsModule used by the parallel tests. + + It is required inside each worker Actor VM and forwards every event to a + BindableEvent in ReplicatedStorage so the main thread can observe it. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local signal = ReplicatedStorage:WaitForChild("FastCast2TestSignal") :: BindableEvent + +local function forward(name: string) + return function(...) + signal:Fire(name, ...) + end +end + +return { + CastFire = forward("CastFire"), + Hit = forward("Hit"), + Pierced = forward("Pierced"), + LengthChanged = forward("LengthChanged"), + CastTerminating = forward("CastTerminating"), + CanPierce = function() + return false + end, +} From df60ab96522b9f3e7b9cb00c55f5535c1837eea3 Mon Sep 17 00:00:00 2001 From: darwin Date: Thu, 17 Sep 2026 19:31:14 +1000 Subject: [PATCH 042/140] test: isolate benchmark failures and broaden serial coverage - Publish test results even when the benchmark run throws - Exercise movement-mode switching through the public API - Cover event callbacks reassigned after Init --- tests/run.luau | 11 +++++++++++ tests/src/FunctionalTests.luau | 33 ++++++++++++++++++++++++++++++--- tests/src/Harness.server.luau | 15 ++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/run.luau b/tests/run.luau index 7f216f45..8a14bcb3 100644 --- a/tests/run.luau +++ b/tests/run.luau @@ -153,6 +153,13 @@ local function buildMarkdown(payload: any): string benchmarkTable(benchmarks), } + if payload.benchmarkError then + table.insert(parts, "") + table.insert(parts, "```") + table.insert(parts, tostring(payload.benchmarkError)) + table.insert(parts, "```") + end + if payload.error then table.insert(parts, "") table.insert(parts, "## Harness error") @@ -285,6 +292,10 @@ if payload.error then print("Harness error: " .. tostring(payload.error)) end +if payload.benchmarkError then + print("Benchmark error: " .. tostring(payload.benchmarkError)) +end + if flags["no-upload"] then print("Upload: skipped (--no-upload)") else diff --git a/tests/src/FunctionalTests.luau b/tests/src/FunctionalTests.luau index 940020c7..c15399d1 100644 --- a/tests/src/FunctionalTests.luau +++ b/tests/src/FunctionalTests.luau @@ -234,6 +234,34 @@ return function() registerSerialCastTypes(test) + test("event callbacks assigned after Init still fire", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local caster = FastCast.new() + caster:Init("BulkMoveTo") + + local hitInstance = nil + caster.Hit = function(_, result) + hitInstance = result.Instance + end + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return hitInstance ~= nil + end, HIT_TIMEOUT), + "Hit callback assigned after Init never fired" + ) + expect.equal(hitInstance, wall, "Hit callback fired for the wrong instance") + + caster:Destroy() + arena:Destroy() + end) + test("Pierced and CanPierce run when a cast is allowed through", function(expect) local arena = createArena() local container = createContainer(arena) @@ -338,11 +366,10 @@ return function() test("movement mode can be switched", function(expect) local caster = FastCast.new() caster:Init("Motor6D") - expect.equal(caster.BaseCast.CurrentMovementMode, "Motor6D", "initial movement mode was not applied") + expect.equal(caster.MovementMode, "Motor6D", "initial movement mode was not applied") caster:SetMovementModeEnabled(true, "BulkMoveTo") - expect.equal(caster.BaseCast.CurrentMovementMode, "BulkMoveTo", "movement mode was not switched") - expect.equal(caster.MovementMode, "BulkMoveTo", "caster did not record the new mode") + expect.equal(caster.MovementMode, "BulkMoveTo", "movement mode was not switched") caster:Destroy() end) diff --git a/tests/src/Harness.server.luau b/tests/src/Harness.server.luau index 6d6b8b18..522b5fe2 100644 --- a/tests/src/Harness.server.luau +++ b/tests/src/Harness.server.luau @@ -52,13 +52,26 @@ local function runSuite() ensureTestSignal() local tests = Framework.run(FunctionalTests(), cleanupLeftovers) - local benchmarks = Benchmarks.run() + + local benchmarks = {} + local benchmarkError = nil + local benchmarkOk, benchmarkResult = pcall(function() + return Benchmarks.run() + end) + + if benchmarkOk then + benchmarks = benchmarkResult + else + benchmarkError = tostring(benchmarkResult) + warn("[FastCast2] benchmark run failed: " .. benchmarkError) + end return { generatedAt = os.date("%Y-%m-%d %H:%M:%S"), runContext = if RunService:IsStudio() then "studio" else "game", tests = tests, benchmarks = benchmarks, + benchmarkError = benchmarkError, } end From 10a6b5f07115dcb6a04f91306bfb06cb223371b7 Mon Sep 17 00:00:00 2001 From: darwin Date: Thu, 17 Sep 2026 19:34:13 +1000 Subject: [PATCH 043/140] fix: correct refactor regressions found by the suite - BaseCastParallel: pass Actor into cast data so parallel TerminateCast cleanup works - FastCastSerial: assign self.BaseCast (was never set, disabling event updates and movement switching) - FastCastSerial.__newindex: call _updateEvents with a dot; it is defined without self --- src/BaseCastParallel.luau | 3 +++ src/init.luau | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index f53a559f..d4a47a28 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -180,6 +180,7 @@ function BaseCast:Raycast( local cast = ActiveCast.createCastData({ Output = Output, + Actor = Actor, SyncChange = SyncChanges, CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { @@ -242,6 +243,7 @@ function BaseCast:Blockcast( local cast = ActiveCast.createCastData({ Output = Output, + Actor = Actor, SyncChange = SyncChanges, CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { @@ -285,6 +287,7 @@ function BaseCast:Spherecast( local cast = ActiveCast.createCastData({ Output = Output, + Actor = Actor, SyncChange = SyncChanges, CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { diff --git a/src/init.luau b/src/init.luau index 96377de4..08299808 100644 --- a/src/init.luau +++ b/src/init.luau @@ -151,7 +151,7 @@ FastCastSerial.__newindex = function(self, key, value) if VALID_EVENTS[key] then if type(value) == "function" then if self.BaseCast then - self.BaseCast:_UpdateEvents(key, value) + self.BaseCast._UpdateEvents(key, value) else rawset(self, key, value) end @@ -517,6 +517,7 @@ function FastCastSerial:Init( } BaseCastSerial.Init(events, data) + self.BaseCast = BaseCastSerial self.MovementMode = movementMode or "BulkMoveTo" self.AlreadyInit = true From 4c9c4c1c675dc7d9d056759cb4273bc03258a504 Mon Sep 17 00:00:00 2001 From: darwin Date: Thu, 17 Sep 2026 21:58:56 +1000 Subject: [PATCH 044/140] perf: optimize cast simulation hot paths and add benchmarks - Replace pooled event tables with a packed buffer metadata queue (f64 id + u8 type) that grows by doubling - Cache per-cast WantLengthChanged/HasCosmetic flags and skip queueing events with no listeners - Compute displacement from velocity instead of a second position eval, and build cosmetic CFrame via CFrame.lookAlong - Add Config.SimulationMode (PerFrame/Fixed) and CosmeticUpdateInterval with per-worker phase staggering - Add perfbench project and tests/hotpaths runner (npm run test:hotpaths) --- package.json | 3 +- perfbench.project.json | 16 ++ perfbench/Entry.luau | 20 +++ perfbench/PerfBench.server.luau | 187 ++++++++++++++++++++ src/Config.luau | 31 ++++ src/FastCastVMs/init.luau | 5 +- src/ParallelSimulation.luau | 262 ++++++++++++++++++----------- src/SerialSimulation.luau | 233 ++++++++++++++----------- tests/README.md | 3 + tests/hotpaths/Entry.luau | 7 + tests/hotpaths/Harness.server.luau | 223 ++++++++++++++++++++++++ tests/hotpaths/README.md | 63 +++++++ tests/hotpaths/run.luau | 69 ++++++++ 13 files changed, 925 insertions(+), 197 deletions(-) create mode 100644 perfbench.project.json create mode 100644 perfbench/Entry.luau create mode 100644 perfbench/PerfBench.server.luau create mode 100644 tests/hotpaths/Entry.luau create mode 100644 tests/hotpaths/Harness.server.luau create mode 100644 tests/hotpaths/README.md create mode 100644 tests/hotpaths/run.luau diff --git a/package.json b/package.json index 9e7f5764..0474a96b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "scripts": { "test": "lune run tests/run.luau", - "test:no-upload": "lune run tests/run.luau --no-upload" + "test:no-upload": "lune run tests/run.luau --no-upload", + "test:hotpaths": "lune run tests/hotpaths/run.luau" }, "dependencies": { "moonwave": "^1.2.0", diff --git a/perfbench.project.json b/perfbench.project.json new file mode 100644 index 00000000..26e95d22 --- /dev/null +++ b/perfbench.project.json @@ -0,0 +1,16 @@ +{ + "name": "FastCast2PerfBench", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "FastCast2": { + "$path": "src" + } + }, + "ServerScriptService": { + "PerfBench": { + "$path": "perfbench/PerfBench.server.luau" + } + } + } +} diff --git a/perfbench/Entry.luau b/perfbench/Entry.luau new file mode 100644 index 00000000..4f5e8961 --- /dev/null +++ b/perfbench/Entry.luau @@ -0,0 +1,20 @@ +--[[ + run-in-roblox entry for the FastCast2 perf benchmark. + + Starts Run mode so the PerfBench server script executes, then waits until + it signals completion. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +if not RunService:IsRunning() then + RunService:Run() +end + +local deadline = os.clock() + 300 +while not ReplicatedStorage:GetAttribute("PerfBenchDone") and os.clock() < deadline do + task.wait(0.5) +end + +print("PERFBENCH_ENTRY_DONE") diff --git a/perfbench/PerfBench.server.luau b/perfbench/PerfBench.server.luau new file mode 100644 index 00000000..3f93fce5 --- /dev/null +++ b/perfbench/PerfBench.server.luau @@ -0,0 +1,187 @@ +--[[ + FastCast2 A/B performance benchmark. + + Runs in Studio via run-in-roblox. Fires N raycasts, samples server + Heartbeat frame times, and prints one PERFBENCH_RESULT line per case so the + orchestrator can diff fixed-step (BindToSimulation) vs per-frame stepping. + + The simulation mode is baked into src/Config.luau at build time; this + script only reports whatever `Config.SimulationMode` currently is. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local ServerScriptService = game:GetService("ServerScriptService") + +local FastCast = require(ReplicatedStorage:WaitForChild("FastCast2")) +local FastCastConfig = require(ReplicatedStorage.FastCast2:WaitForChild("Config")) + +local SENTINEL = "PERFBENCH_RESULT" +local SAMPLE_SECONDS = 5 +local WORKERS = 4 +local COUNTS = { 6000 } +local CAST_MODES = { "Parallel", "Serial" } +local INSTANCED = { true } +local MOVEMENT = { true, false } + +local cosmeticContainer = Instance.new("Folder") +cosmeticContainer.Name = "PerfBenchCosmetics" +cosmeticContainer.Parent = workspace + +local cosmeticTemplate = Instance.new("Part") +cosmeticTemplate.Name = "PerfBenchProjectile" +cosmeticTemplate.Size = Vector3.one +cosmeticTemplate.Anchored = true +cosmeticTemplate.CanCollide = false +cosmeticTemplate.CanQuery = false +cosmeticTemplate.CanTouch = false +cosmeticTemplate.Massless = true +cosmeticTemplate.Parent = ReplicatedStorage + +local frameTimes: { number } = {} +local measuring = false + +RunService.Heartbeat:Connect(function(deltaTime) + if measuring and deltaTime > 0 then + table.insert(frameTimes, deltaTime * 1000) + end +end) + +local function summarize(): (number, number, number, number) + if #frameTimes == 0 then + return 0, 0, 0, 0 + end + + local sorted = table.clone(frameTimes) + table.sort(sorted) + + local total = 0 + for _, value in frameTimes do + total += value + end + + local average = total / #frameTimes + local function percentile(fraction: number): number + return sorted[math.max(1, math.ceil(#sorted * fraction))] + end + + return average, percentile(0.50), percentile(0.95), percentile(0.99) +end + +local behavior = FastCast.newBehavior() +behavior.MaxDistance = 1e9 +behavior.Acceleration = Vector3.zero +behavior.AutoIgnoreContainer = true +behavior.HighFidelitySegmentSize = 1 + +local caseIndex = 0 + +local function runCase(castMode: string, projectileCount: number, instanced: boolean, movement: boolean) + caseIndex += 1 + local suffix = tostring(caseIndex) + + behavior.CosmeticBulletTemplate = if instanced then cosmeticTemplate else nil + behavior.CosmeticBulletContainer = if instanced then cosmeticContainer else nil + + local caster + if castMode == "Parallel" then + caster = FastCast.newParallel() + caster:Init( + WORKERS, + ServerScriptService, + "PerfBenchVMs" .. suffix, + ServerScriptService, + "PerfBenchContainers" .. suffix, + "PerfBenchVM" .. suffix, + "BulkMoveTo" + ) + else + caster = FastCast.new() + caster:Init("BulkMoveTo") + end + + caster:SetMovementModeEnabled(movement, "BulkMoveTo") + + local random = Random.new(2026) + local creationStarted = os.clock() + + for _ = 1, projectileCount do + local origin = Vector3.new( + random:NextInteger(-100, 100), + random:NextInteger(0, 100), + random:NextInteger(-100, 100) + ) + local direction = Vector3.new( + random:NextNumber(-1, 1), + random:NextNumber(-1, 1), + random:NextNumber(-1, 1) + ) + direction = if direction.Magnitude == 0 then Vector3.xAxis else direction.Unit + + caster:RaycastFire(origin, direction, 6, behavior) + end + + local creationMs = (os.clock() - creationStarted) * 1000 + + -- Let parallel workers create their casts before sampling. + task.wait() + table.clear(frameTimes) + measuring = true + task.wait(SAMPLE_SECONDS) + measuring = false + + local average, p50, p95, p99 = summarize() + + local cleanupStarted = os.clock() + caster:Destroy() + local cleanupMs = (os.clock() - cleanupStarted) * 1000 + + print(string.format( + "%s %s|%s|%d|instanced=%s|movement=%s|create=%.1f|avg=%.2f|p50=%.2f|p95=%.2f|p99=%.2f|cleanup=%.1f", + SENTINEL, + FastCastConfig.SimulationMode, + castMode, + projectileCount, + tostring(instanced), + tostring(movement), + creationMs, + average, + p50, + p95, + p99, + cleanupMs + )) + + task.wait(2) +end + +task.spawn(function() + task.wait(2) + print(string.format("[perfbench] mode=%s", FastCastConfig.SimulationMode)) + + for _, castMode in CAST_MODES do + for _, projectileCount in COUNTS do + for _, instanced in INSTANCED do + for _, movement in MOVEMENT do + local ok, err = pcall(runCase, castMode, projectileCount, instanced, movement) + if not ok then + print(string.format( + "%s %s|%s|%d|instanced=%s|movement=%s|ERROR|%s", + SENTINEL, + FastCastConfig.SimulationMode, + castMode, + projectileCount, + tostring(instanced), + tostring(movement), + tostring(err) + )) + end + end + end + end + end + + print("[perfbench] done") + ReplicatedStorage:SetAttribute("PerfBenchDone", true) + print(SENTINEL .. " DONE") +end) diff --git a/src/Config.luau b/src/Config.luau index 35524a08..1c88cbe6 100644 --- a/src/Config.luau +++ b/src/Config.luau @@ -14,4 +14,35 @@ Configs.DebugLogging = { } Configs.VisualizeCasts = true +--[[ + How casts are stepped. + + "PerFrame" - One update per frame (PreSimulation on the client, Heartbeat + on the server). Cheapest, and the delta always matches the + real frame time. Recommended default. + + "Fixed" - RunService:BindToSimulation at FixedSimulationFrequency. The + engine keeps the simulation at that fixed rate, so when the + frame rate drops below the frequency it runs MULTIPLE steps + per frame to catch up. Under heavy load this multiplies the + per-frame cast cost (a death spiral), which is why it is not + the default. Only use it if you need deterministic fixed-step + simulation. +]] +Configs.SimulationMode = "PerFrame" +Configs.FixedSimulationFrequency = Enum.StepFrequency.Hz60 + +--[[ + How often cosmetic bullet parts are moved, in simulation frames. + + 1 = move every frame (smoothest). Higher values push cosmetic CFrames + every Nth frame. Moving thousands of parts with BulkMoveTo (and + replicating their CFrames to clients) is by far the most expensive part of + an instanced cast, so 2 or 3 roughly halves/thirds that cost. Roblox + replicates instance CFrames at roughly 20 Hz anyway, so 3 (~20 Hz at 60 + FPS) is the default; use 1 for very fast projectiles that need every-frame + smoothness. Hit detection is unaffected: it still runs every frame. +]] +Configs.CosmeticUpdateInterval = 3 + return Configs diff --git a/src/FastCastVMs/init.luau b/src/FastCastVMs/init.luau index 6eccb38f..449ba8a0 100644 --- a/src/FastCastVMs/init.luau +++ b/src/FastCastVMs/init.luau @@ -157,10 +157,13 @@ function Dispatcher:Allocate(Threads: number, Data: any?, Callback: (...any) -> local Actors = {} --> Create actors - for _ = 1, Threads do + for index = 1, Threads do local Actor = Template:Clone() Actor.Parent = Container + -- Stable per-worker index, used to phase-offset cosmetic updates. + Actor:SetAttribute("FastCastWorkerIndex", #self.Threads + index) + local controller = Actor:FindFirstChild(ControllerName) if Callback then diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 9ae4eec6..af4f5692 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -65,10 +65,6 @@ local EVT_CAST_TERMINATING = 4 local EPSILON = 1e-6 local RAY_SEARCH_OFFSET = 1e-4 -type QueuedEvent = { number | string | { any } } --- Layout: { castID, eventType, args } --- args is a reusable table { arg1, arg2, ... } - type QueuedVisualizeCastData = { castStartCFrame: CFrame, sub: boolean @@ -126,9 +122,19 @@ local casts_ID_Index = {} :: { [number]: number } local casts_FastCastEvents = {} :: { [number]: TypeDef.FastCastEvents } --- Flat event queue: { castID, eventType, argsTable } --- Reused every frame via table.clear -local queuedEvents: { QueuedEvent } = {} +-- Cached per-cast "does anyone care about this event?" flags. +-- Precomputed at registration so the hot path avoids config lookups and, +-- more importantly, avoids queueing events nobody listens to. +local casts_WantLengthChanged = {} :: { [number]: boolean } +local casts_HasCosmetic = {} :: { [number]: boolean } +local castsWithCosmetics: number = 0 + +-- Packed metadata: f64 cast ID + u8 event type. Keep IDs lossless (no u32 +-- wraparound); Roblox values stay in the reusable argument tables below. +-- Capacity doubles only at a new high-water mark, never during steady state. +local EVENT_STRIDE = 9 +local eventCapacity = 256 +local eventMetadata = buffer.create(eventCapacity * EVENT_STRIDE) local queuedEventsCount: number = 0 -- Flat visualization queue: { castID, data } @@ -154,7 +160,7 @@ local function GetPositionAtTime( ): Vector3 return origin + initialVelocity * t - + acceleration * (t ^ 2 / 2) + + acceleration * (t * t * 0.5) end local function GetVelocityAtTime( @@ -332,9 +338,6 @@ local Visualizers = { local argsPool: { { any } } = {} local argsPoolCount: number = 0 -local eventPool: { QueuedEvent } = {} -local eventPoolCount: number = 0 - local function GetArgs(): { any } argsPoolCount += 1 if argsPoolCount > #argsPool then @@ -343,14 +346,6 @@ local function GetArgs(): { any } return argsPool[argsPoolCount] end -local function GetEvent(): QueuedEvent - eventPoolCount += 1 - if eventPoolCount > #eventPool then - eventPool[eventPoolCount] = { 0, 0, nil :: any } - end - return eventPool[eventPoolCount] -end - local visPool: { QueuedVis } = {} local visPoolCount: number = 0 @@ -391,7 +386,6 @@ end local function ResetPools() argsPoolCount = 0 - eventPoolCount = 0 visPoolCount = 0 hitVisPoolCount = 0 segVisPoolCount = 0 @@ -527,33 +521,43 @@ end -- Queued events (flat, allocation-free) -- ============================================================================ +-- Event signatures have at most five payload values. Assign all slots so +-- nil holes cannot leave stale references behind (the # operator is undefined +-- for sparse arrays), and avoid select/vararg loops in the hot path. local function QueueEvent( castID: number, eventType: number, - ...: any + arg1: any, + arg2: any, + arg3: any, + arg4: any, + arg5: any ) - queuedEventsCount += 1 - local entry = GetEvent() local args = GetArgs() - local n = select("#", ...) - for i = 1, n do - args[i] = select(i, ...) - end - for i = n + 1, #args do - args[i] = nil + args[1] = arg1 + args[2] = arg2 + args[3] = arg3 + args[4] = arg4 + args[5] = arg5 + + queuedEventsCount += 1 + if queuedEventsCount > eventCapacity then + eventCapacity *= 2 + local expanded = buffer.create(eventCapacity * EVENT_STRIDE) + buffer.copy(expanded, 0, eventMetadata) + eventMetadata = expanded end - entry[1] = castID - entry[2] = eventType - entry[3] = args - queuedEvents[queuedEventsCount] = entry + local offset = (queuedEventsCount - 1) * EVENT_STRIDE + buffer.writef64(eventMetadata, offset, castID) + buffer.writeu8(eventMetadata, offset + 8, eventType) end local function FireQueuedEvents() for i = 1, queuedEventsCount do - local entry = queuedEvents[i] - local castID = entry[1] :: number - local numericType = entry[2] :: number - local args = entry[3] :: { any } + local offset = (i - 1) * EVENT_STRIDE + local castID = buffer.readf64(eventMetadata, offset) + local numericType = buffer.readu8(eventMetadata, offset + 8) + local args = argsPool[i] local cast = ActivesRef[castID] @@ -706,6 +710,20 @@ end local bulkParts: { Instance } = {} local bulkCframes: { CFrame } = {} local bulkCount: number = 0 +local cosmeticFrame: number = 0 + +-- Cosmetic parts can be pushed at a lower rate than the simulation. Moving +-- thousands of parts (and replicating their CFrames) is the single most +-- expensive per-frame cost once cosmetics exist. +local function ShouldUpdateCosmetics(): boolean + local interval = Config.CosmeticUpdateInterval or 1 + if interval <= 1 then + return true + end + + cosmeticFrame += 1 + return cosmeticFrame % interval == 0 +end local function BulkMoveTo() if CurrentMovementMode ~= "BulkMoveTo" @@ -714,6 +732,15 @@ local function BulkMoveTo() return end + -- Nothing to move if no cast has a cosmetic part. + if castsWithCosmetics == 0 then + return + end + + if not ShouldUpdateCosmetics() then + return + end + bulkCount = 0 for _, id in casts_ID do @@ -743,6 +770,10 @@ local function UpdateMotor6Ds() return end + if not ShouldUpdateCosmetics() then + return + end + for id, motor6d in casts_ActiveMotor6Ds do if motor6d and motor6d.Parent then motor6d.Transform = casts_CFrame[id] @@ -931,14 +962,16 @@ local function ResimulateHit( casts_CastVariant[id] ) - QueueVisualizeSegment( - id, - CFrame.new( - subPosition, - subPosition + subVelocity - ), - true - ) + if casts_VisualizeCasts[id] then + QueueVisualizeSegment( + id, + CFrame.new( + subPosition, + subPosition + subVelocity + ), + true + ) + end if not subResult then continue @@ -1022,23 +1055,21 @@ local function SimulateCast( local currentTime = casts_TotalRunTime[id] - trajectory.StartTime - local currentPoint = GetPositionAtTime( - currentTime, - origin, - initialVelocity, - acceleration - ) - local velocity = GetVelocityAtTime( currentTime, initialVelocity, acceleration ) - local displacement = currentPoint - lastPoint - - if displacement.Magnitude <= EPSILON then - casts_DistanceCovered[id] += displacement.Magnitude + -- p(t1)-p(t0) = (v0 + a*(t0+t1)/2)*(t1-t0). + -- Avoid a second position evaluation and cancellation of large world + -- coordinates. Use the actual elapsed interval after runtime rounding. + local displacement = (initialVelocity + acceleration * ((previousTime + currentTime) * 0.5)) + * (currentTime - previousTime) + local displacementMagnitude = displacement.Magnitude + + if displacementMagnitude <= EPSILON then + casts_DistanceCovered[id] += displacementMagnitude return end @@ -1053,51 +1084,39 @@ local function SimulateCast( variant ) - local hitPoint = result - and result.Position - or currentPoint - - local rayDisplacement = (hitPoint - lastPoint).Magnitude + local rayDisplacement = if result + then (result.Position - lastPoint).Magnitude + else displacementMagnitude casts_RayDisplacement[id] = rayDisplacement - -- Update cosmetic transform. - local direction = displacement + -- Build the midpoint transform directly instead of multiplying two + -- CFrames. lookAlong avoids losing direction when origin + direction + -- rounds back to origin at large world coordinates. + local unitDirection = displacement / displacementMagnitude + casts_CFrame[id] = CFrame.lookAlong( + lastPoint + unitDirection * (rayDisplacement * 0.5), + unitDirection + ) - casts_CFrame[id] = - ( - if direction ~= Vector3.zero - then CFrame.new( - lastPoint, - lastPoint + direction - ) - else CFrame.new(lastPoint) - ) - * CFrame.new( - 0, - 0, - -rayDisplacement / 2 + if casts_WantLengthChanged[id] then + QueueEvent( + id, + EVT_LENGTH_CHANGED, + lastPoint, + unitDirection, + rayDisplacement, + velocity, + rayInfo.CosmeticBulletObject ) - - QueueEvent( - id, - EVT_LENGTH_CHANGED, - lastPoint, - if direction ~= Vector3.zero then direction.Unit else direction, - rayDisplacement, - velocity, - rayInfo.CosmeticBulletObject - ) + end casts_DistanceCovered[id] += rayDisplacement - if delta > 0 then + if delta > 0 and casts_VisualizeCasts[id] then QueueVisualizeSegment( id, - CFrame.new( - lastPoint, - lastPoint + direction - ), + CFrame.lookAlong(lastPoint, unitDirection), false ) end @@ -1396,6 +1415,25 @@ function ParallelSimulation_Register(cast: any) casts_FastCastEvents[id] = nil end + local eventConfig = casts_FastCastEventsConfig[id] + local moduleConfig = casts_FastCastEventsModuleConfig[id] + local moduleEvents = casts_FastCastEvents[id] + + casts_WantLengthChanged[id] = + (eventConfig ~= nil and eventConfig.UseLengthChanged == true) + or ( + moduleConfig ~= nil + and moduleConfig.UseLengthChanged == true + and moduleEvents ~= nil + and moduleEvents.LengthChanged ~= nil + ) + + local hasCosmetic = cast.RayInfo.CosmeticBulletObject ~= nil + casts_HasCosmetic[id] = hasCosmetic + if hasCosmetic then + castsWithCosmetics += 1 + end + local trajectory = casts_Trajectory[id] local position = GetPositionAtTime( @@ -1440,6 +1478,12 @@ function ParallelSimulation_Unregister(castID: number) casts_FastCastEventsConfig[castID] = nil casts_FastCastEvents[castID] = nil + casts_WantLengthChanged[castID] = nil + if casts_HasCosmetic[castID] then + castsWithCosmetics -= 1 + end + casts_HasCosmetic[castID] = nil + casts_VisualizeCasts[castID] = nil casts_VisualizeCastSettings[castID] = nil @@ -1611,6 +1655,17 @@ function ParallelSimulation.Init(baseCastRef: any) BaseCastRef = baseCastRef ActivesRef = baseCastRef.Actives + + -- Stagger each worker's cosmetic update phase so the workers do not all + -- call BulkMoveTo on the same frame (which produces heavy spikes). + local interval = Config.CosmeticUpdateInterval or 1 + if interval > 1 and baseCastRef.Actor then + local workerIndex = baseCastRef.Actor:GetAttribute("FastCastWorkerIndex") + + if type(workerIndex) == "number" then + cosmeticFrame = workerIndex % interval + end + end end function ParallelSimulation.Register(cast: any) @@ -1633,13 +1688,24 @@ function ParallelSimulation.Start() warn("[FastCast2] ParallelSimulation already started.") return end - --[[ - You can edit Hz60, Hz30, Hz15, Hz7_5, Hz5, Hz3, Hz1 here, if you want - ]] - ParallelSimulation.Connection = RS:BindToSimulation(function(dt) - task.desynchronize() - UpdateCasts(dt) - end, Enum.StepFrequency.Hz60) + + if Config.SimulationMode == "Fixed" then + -- Fixed timestep: the engine may run several steps per frame to keep + -- up at this frequency, which multiplies work when the FPS is low. + ParallelSimulation.Connection = RS:BindToSimulation(function(dt) + task.desynchronize() + UpdateCasts(dt) + end, Config.FixedSimulationFrequency) + else + -- One step per frame. `UpdateCasts` synchronizes before main-thread work. + if RS:IsClient() then + ParallelSimulation.Connection = + RS.PreSimulation:ConnectParallel(UpdateCasts) + else + ParallelSimulation.Connection = + RS.Heartbeat:ConnectParallel(UpdateCasts) + end + end end function ParallelSimulation.Stop() diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 1f7de66c..4b529de4 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -65,10 +65,6 @@ local EVT_CAST_TERMINATING = 4 local EPSILON = 1e-6 local RAY_SEARCH_OFFSET = 1e-4 -type QueuedEvent = { number | string | { any } } --- Layout: { castID, eventType, args } --- args is a reusable table { arg1, arg2, ... } - type QueuedVisualizeCastData = { castStartCFrame: CFrame, sub: boolean @@ -121,9 +117,17 @@ local casts_ActiveMotor6Ds = {} :: { [number]: Motor6D } local casts_ID = {} :: { number } local casts_ID_Index = {} :: { [number]: number } --- Flat event queue: { castID, eventType, argsTable } --- Reused every frame via table.clear -local queuedEvents: { QueuedEvent } = {} +-- Cached per-cast "does anyone care about this event?" flags. +local casts_WantLengthChanged = {} :: { [number]: boolean } +local casts_HasCosmetic = {} :: { [number]: boolean } +local castsWithCosmetics: number = 0 + +-- Packed metadata: f64 cast ID + u8 event type. Keep IDs lossless (no u32 +-- wraparound); Roblox values stay in the reusable argument tables below. +-- Capacity doubles only at a new high-water mark, never during steady state. +local EVENT_STRIDE = 9 +local eventCapacity = 256 +local eventMetadata = buffer.create(eventCapacity * EVENT_STRIDE) local queuedEventsCount: number = 0 -- Flat visualization queue: { castID, data } @@ -151,7 +155,7 @@ local function GetPositionAtTime( ): Vector3 return origin + initialVelocity * t - + acceleration * (t ^ 2 / 2) + + acceleration * (t * t * 0.5) end local function GetVelocityAtTime( @@ -333,9 +337,6 @@ local Visualizers = { local argsPool: { { any } } = {} local argsPoolCount: number = 0 -local eventPool: { QueuedEvent } = {} -local eventPoolCount: number = 0 - local function GetArgs(): { any } argsPoolCount += 1 if argsPoolCount > #argsPool then @@ -344,14 +345,6 @@ local function GetArgs(): { any } return argsPool[argsPoolCount] end -local function GetEvent(): QueuedEvent - eventPoolCount += 1 - if eventPoolCount > #eventPool then - eventPool[eventPoolCount] = { 0, 0, nil :: any } - end - return eventPool[eventPoolCount] -end - local visPool: { QueuedVis } = {} local visPoolCount: number = 0 @@ -394,7 +387,6 @@ end local function ResetPools() argsPoolCount = 0 - eventPoolCount = 0 visPoolCount = 0 hitVisPoolCount = 0 segVisPoolCount = 0 @@ -530,35 +522,43 @@ end -- Queued events (flat, allocation-free) -- ============================================================================ +-- Event signatures have at most five payload values. Assign all slots so +-- nil holes cannot leave stale references behind (the # operator is undefined +-- for sparse arrays), and avoid select/vararg loops in the hot path. local function QueueEvent( castID: number, eventType: number, - ...: any + arg1: any, + arg2: any, + arg3: any, + arg4: any, + arg5: any ) local args = GetArgs() - local n = select("#", ...) - for i = 1, n do - args[i] = select(i, ...) - end - -- Clear leftover args from previous use - for i = n + 1, #args do - args[i] = nil - end + args[1] = arg1 + args[2] = arg2 + args[3] = arg3 + args[4] = arg4 + args[5] = arg5 queuedEventsCount += 1 - local entry = GetEvent() - entry[1] = castID - entry[2] = eventType - entry[3] = args - queuedEvents[queuedEventsCount] = entry + if queuedEventsCount > eventCapacity then + eventCapacity *= 2 + local expanded = buffer.create(eventCapacity * EVENT_STRIDE) + buffer.copy(expanded, 0, eventMetadata) + eventMetadata = expanded + end + local offset = (queuedEventsCount - 1) * EVENT_STRIDE + buffer.writef64(eventMetadata, offset, castID) + buffer.writeu8(eventMetadata, offset + 8, eventType) end local function FireQueuedEvents() for i = 1, queuedEventsCount do - local entry = queuedEvents[i] - local castID = entry[1] :: number - local eventType = entry[2] :: number - local args = entry[3] :: { any } + local offset = (i - 1) * EVENT_STRIDE + local castID = buffer.readf64(eventMetadata, offset) + local eventType = buffer.readu8(eventMetadata, offset + 8) + local args = argsPool[i] local cast = ActivesRef[castID] if not cast then @@ -639,6 +639,20 @@ end local bulkParts: { BasePart } = {} local bulkCframes: { CFrame } = {} local bulkCount: number = 0 +local cosmeticFrame: number = 0 + +-- Cosmetic parts can be pushed at a lower rate than the simulation. Moving +-- thousands of parts (and replicating their CFrames) is the single most +-- expensive per-frame cost once cosmetics exist. +local function ShouldUpdateCosmetics(): boolean + local interval = Config.CosmeticUpdateInterval or 1 + if interval <= 1 then + return true + end + + cosmeticFrame += 1 + return cosmeticFrame % interval == 0 +end local function BulkMoveTo() if CurrentMovementMode ~= "BulkMoveTo" @@ -647,6 +661,15 @@ local function BulkMoveTo() return end + -- Nothing to move if no cast has a cosmetic part. + if castsWithCosmetics == 0 then + return + end + + if not ShouldUpdateCosmetics() then + return + end + bulkCount = 0 for _, id in casts_ID do @@ -676,6 +699,10 @@ local function UpdateMotor6Ds() return end + if not ShouldUpdateCosmetics() then + return + end + for id, motor6d in casts_ActiveMotor6Ds do if motor6d and motor6d.Parent then motor6d.Transform = casts_CFrame[id] @@ -839,14 +866,16 @@ local function ResimulateHit( casts_CastVariant[id] ) - QueueVisualizeSegment( - id, - CFrame.new( - subPosition, - subPosition + subVelocity - ), - true - ) + if casts_VisualizeCasts[id] then + QueueVisualizeSegment( + id, + CFrame.new( + subPosition, + subPosition + subVelocity + ), + true + ) + end if not subResult then continue @@ -930,24 +959,22 @@ local function SimulateCast( local currentTime = casts_TotalRunTime[id] - trajectory.StartTime - local currentPoint = GetPositionAtTime( - currentTime, - origin, - initialVelocity, - acceleration - ) - local velocity = GetVelocityAtTime( currentTime, initialVelocity, acceleration ) - local displacement = currentPoint - lastPoint + -- p(t1)-p(t0) = (v0 + a*(t0+t1)/2)*(t1-t0). + -- Avoid a second position evaluation and cancellation of large world + -- coordinates. Use the actual elapsed interval after runtime rounding. + local displacement = (initialVelocity + acceleration * ((previousTime + currentTime) * 0.5)) + * (currentTime - previousTime) + local displacementMagnitude = displacement.Magnitude -- Skip raycast when displacement is negligible (matches SwiftCast EPSILON) - if displacement.Magnitude <= EPSILON then - casts_DistanceCovered[id] += displacement.Magnitude + if displacementMagnitude <= EPSILON then + casts_DistanceCovered[id] += displacementMagnitude return end @@ -962,51 +989,39 @@ local function SimulateCast( variant ) - local hitPoint = result - and result.Position - or currentPoint - - local rayDisplacement = (hitPoint - lastPoint).Magnitude + local rayDisplacement = if result + then (result.Position - lastPoint).Magnitude + else displacementMagnitude casts_RayDisplacement[id] = rayDisplacement - -- Update cosmetic transform. - local direction = displacement + -- Build the midpoint transform directly instead of multiplying two + -- CFrames. lookAlong avoids losing direction when origin + direction + -- rounds back to origin at large world coordinates. + local unitDirection = displacement / displacementMagnitude + casts_CFrame[id] = CFrame.lookAlong( + lastPoint + unitDirection * (rayDisplacement * 0.5), + unitDirection + ) - casts_CFrame[id] = - ( - if direction ~= Vector3.zero - then CFrame.new( - lastPoint, - lastPoint + direction - ) - else CFrame.new(lastPoint) - ) - * CFrame.new( - 0, - 0, - -rayDisplacement / 2 + if casts_WantLengthChanged[id] and FastCastEvents.LengthChanged then + QueueEvent( + id, + EVT_LENGTH_CHANGED, + lastPoint, + unitDirection, + rayDisplacement, + velocity, + rayInfo.CosmeticBulletObject ) - - QueueEvent( - id, - EVT_LENGTH_CHANGED, - lastPoint, - if direction ~= Vector3.zero then direction.Unit else direction, - rayDisplacement, - velocity, - rayInfo.CosmeticBulletObject - ) + end casts_DistanceCovered[id] += rayDisplacement - if delta > 0 then + if delta > 0 and casts_VisualizeCasts[id] then QueueVisualizeSegment( id, - CFrame.new( - lastPoint, - lastPoint + direction - ), + CFrame.lookAlong(lastPoint, unitDirection), false ) end @@ -1289,6 +1304,17 @@ function SerialSimulation_Register(cast: any) table.insert(casts_ID, id) casts_ID_Index[id] = #casts_ID + local eventConfig = casts_FastCastEventsConfig[id] + + casts_WantLengthChanged[id] = + eventConfig ~= nil and eventConfig.UseLengthChanged == true + + local hasCosmetic = cast.RayInfo.CosmeticBulletObject ~= nil + casts_HasCosmetic[id] = hasCosmetic + if hasCosmetic then + castsWithCosmetics += 1 + end + local trajectory = casts_Trajectory[id] local position = GetPositionAtTime( @@ -1331,6 +1357,12 @@ function SerialSimulation_Unregister(castID: number) casts_FastCastEventsConfig[castID] = nil + casts_WantLengthChanged[castID] = nil + if casts_HasCosmetic[castID] then + castsWithCosmetics -= 1 + end + casts_HasCosmetic[castID] = nil + casts_VisualizeCasts[castID] = nil casts_VisualizeCastSettings[castID] = nil @@ -1516,12 +1548,19 @@ function SerialSimulation.Start() return end - --[[ - You can edit Hz60, Hz30, Hz15, Hz7_5, Hz5, Hz3, Hz1 here, if you want - ]] - SerialSimulation.Connection = RS:BindToSimulation(function(dt) - UpdateCasts(dt) - end, Enum.StepFrequency.Hz60) + if Config.SimulationMode == "Fixed" then + SerialSimulation.Connection = RS:BindToSimulation(function(dt) + UpdateCasts(dt) + end, Config.FixedSimulationFrequency) + else + if RS:IsClient() then + SerialSimulation.Connection = + RS.PreSimulation:Connect(UpdateCasts) + else + SerialSimulation.Connection = + RS.Heartbeat:Connect(UpdateCasts) + end + end end function SerialSimulation.Stop() diff --git a/tests/README.md b/tests/README.md index f45bc6ff..ffc1ea1d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -38,6 +38,9 @@ Example: `npm test -- --no-upload` ## What runs +For focused buffer/math regressions and optional direct simulation A/B timing, +see [the hot-path runner](hotpaths/README.md) (`npm run test:hotpaths`). + - **Functional tests** (`tests/src/FunctionalTests.luau`) follow the checklist in [`TESTS.md`](../TESTS.md): initialization, raycast/blockcast/spherecast firing, every public event, ObjectCache, movement modes, cast manipulation, diff --git a/tests/hotpaths/Entry.luau b/tests/hotpaths/Entry.luau new file mode 100644 index 00000000..1a09b5cb --- /dev/null +++ b/tests/hotpaths/Entry.luau @@ -0,0 +1,7 @@ +local storage = game:GetService("ReplicatedStorage") +game:GetService("RunService"):Run() +local deadline = os.clock() + 180 +while storage:GetAttribute("HotPathResult") == nil and os.clock() < deadline do + task.wait(0.2) +end +print(storage:GetAttribute("HotPathResult") or "HOTPATH_TIMEOUT") diff --git a/tests/hotpaths/Harness.server.luau b/tests/hotpaths/Harness.server.luau new file mode 100644 index 00000000..6532de8a --- /dev/null +++ b/tests/hotpaths/Harness.server.luau @@ -0,0 +1,223 @@ +--!native +--!optimize 2 +local storage = game:GetService("ReplicatedStorage") +local modules = storage.Modules + +local function near(a, b, tolerance) + assert((a - b).Magnitude <= tolerance, tostring(a) .. " ~= " .. tostring(b)) +end + +local function queueTests(simulation) + local hooks = simulation._Test + -- Cross several growth boundaries; f64 IDs must survive beyond u32. + for i = 1, 2057 do + hooks.Queue(2 ^ 40 + i, i % 4 + 1, i, nil, Vector3.one, nil, "tail") + end + for i = 1, 2057 do + local id, kind, args = hooks.Peek(i) + assert(id == 2 ^ 40 + i and kind == i % 4 + 1, "event metadata corrupted during growth") + assert( + args[1] == i and args[2] == nil and args[3] == Vector3.one and args[5] == "tail", + "event payload corrupted" + ) + end + hooks.Reset() + for i = 1, 2057 do + hooks.Queue(i, 4) + end + for i = 1, 2057 do + local id, kind, args = hooks.Peek(i) + assert(id == i and kind == 4 and next(args) == nil, "stale event arguments") + end + hooks.Reset() +end + +local function setup(simulation, count, events, world, origin, velocity, acceleration) + local actives = {} + local base = { + _TerminateCast = function(id) + actives[id] = nil + end, + } + simulation.Init(base, actives, events) + for i = 1, count do + local cast = { + ID = 2 ^ 40 + i, + StateInfo = { + TotalRuntime = 0, + HighFidelityBehavior = 0, + HighFidelitySegmentSize = 1, + FastCastEventsConfig = { + UseLengthChanged = events.LengthChanged ~= nil, + UseHit = events.Hit ~= nil, + }, + Trajectory = { Origin = origin, InitialVelocity = velocity, Acceleration = acceleration, StartTime = 0 }, + }, + RayInfo = { WorldRoot = world, MaxDistance = 1e30, Parameters = RaycastParams.new() }, + CastVariant = { CastType = require(modules.FastCastEnums).CastType.Raycast }, + } + actives[cast.ID] = cast + simulation.Register(cast) + end + return actives +end + +local function fresh(name) + local clone = modules[name]:Clone() + clone.Parent = modules + return require(clone) +end + +local function correctness() + queueTests(fresh("Optimized")) + queueTests(fresh("Parallel")) + local sim = fresh("Optimized") + local origin, velocity, acceleration = Vector3.new(10, 20, 30), Vector3.new(80, 90, -100), Vector3.new(0, -196.2, 0) + local observedOrigin, observedDirection, observedVelocity + local callbacks = 0 + local actives = setup(sim, 600, { + LengthChanged = function(_, _, _, _, v) + callbacks += 1 + observedVelocity = v + end, + }, { + Raycast = function(_, o, d) + observedOrigin, observedDirection = o, d + end, + }, origin, velocity, acceleration) + local elapsed = 0 + for _, dt in { 0, 1 / 240, 1 / 60, 0.1, 0.5 } do + sim._Test.Step(dt) + if dt > 0 then + near(observedOrigin, origin + velocity * elapsed + acceleration * (elapsed * elapsed * 0.5), 0.0001) + near(observedDirection, (velocity + acceleration * (elapsed + dt * 0.5)) * dt, 0.0001) + near(observedVelocity, velocity + acceleration * (elapsed + dt), 0.0001) + local reference = CFrame.new(observedOrigin, observedOrigin + observedDirection) + * CFrame.new(0, 0, -observedDirection.Magnitude / 2) + local cast = actives[2 ^ 40 + 1] + near(cast.CFrame.Position, reference.Position, 0.0001) + near(cast.CFrame.LookVector, reference.LookVector, 0.0001) + near(cast.CFrame.RightVector, reference.RightVector, 0.0001) + near(cast.CFrame.UpVector, reference.UpVector, 0.0001) + end + elapsed += dt + end + assert(callbacks == 2400, "event growth/order lost callbacks") + -- Dense active-list swap after removal must not mix event IDs or state. + sim.Unregister(2 ^ 40 + 1) + actives[2 ^ 40 + 1] = nil + sim._Test.Step(1 / 60) + assert(callbacks == 2999, "swap removal lost or duplicated events") + -- Tiny motion at a large origin must retain a finite direction. + local far = fresh("Optimized") + local direction + local casts = setup(far, 1, {}, { + Raycast = function(_, _, d) + direction = d + end, + }, Vector3.new(1e8, 1e8, 1e8), Vector3.new(1, 0, 0), Vector3.zero) + far._Test.Step(1 / 60) + near(direction, Vector3.new(1 / 60, 0, 0), 1e-7) + near(casts[2 ^ 40 + 1].CFrame.LookVector, Vector3.xAxis, 1e-6) + -- Vertical directions use the engine's lookAlong fallback basis. + for _, v in { Vector3.yAxis, -Vector3.yAxis, Vector3.zero } do + local vertical = fresh("Optimized") + local c = setup(vertical, 1, {}, { Raycast = function() end }, Vector3.zero, v, Vector3.zero) + vertical._Test.Step(1) + local cf = c[2 ^ 40 + 1].CFrame + assert(cf.Position.X == cf.Position.X and cf.LookVector.X == cf.LookVector.X, "non-finite CFrame") + if v ~= Vector3.zero then + near(cf.LookVector, v, 1e-6) + end + end +end + +local function collisionTests() + local wall = Instance.new("Part") + wall.Anchored = true + wall.Size = Vector3.new(50, 50, 2) + wall.Position = Vector3.new(0, 10000, -40) + wall.Parent = workspace + task.wait() + for _, kind in { "Raycast", "Blockcast", "Spherecast" } do + local sim = fresh("Optimized") + local hits = 0 + local casts = setup(sim, 96, { + Hit = function(_, result) + assert(result.Instance == wall, "wrong collision target") + hits += 1 + end, + }, workspace, Vector3.new(0, 10000, 0), Vector3.new(0, 0, -120), Vector3.zero) + local random = Random.new(42) + for _, cast in casts do + cast.StateInfo.Trajectory.Origin += Vector3.new(0, 0, random:NextNumber(-5, 5)) + cast.CastVariant.CastType = require(modules.FastCastEnums).CastType[kind] + cast.CastVariant.Size = Vector3.one + cast.CastVariant.Radius = 0.5 + cast.RayInfo.Parameters.FilterType = Enum.RaycastFilterType.Include + cast.RayInfo.Parameters.FilterDescendantsInstances = { wall } + end + for _ = 1, 60 do + sim._Test.Step(random:NextNumber(1 / 240, 1 / 20)) + end + assert(hits == 96, kind .. " missed collisions: " .. hits .. "/96") + end + wall:Destroy() +end + +local function benchmark() + if not modules:FindFirstChild("Baseline") then + return + end + for _, withEvents in { false, true } do + local variants = {} + for _, name in { "Baseline", "Optimized" } do + local sim = fresh(name) + setup( + sim, + 6000, + if withEvents then { LengthChanged = function() end } else {}, + workspace, + Vector3.new(0, 100000, 0), + Vector3.new(100, 10, -50), + Vector3.new(0, -10, 0) + ) + for _ = 1, 20 do + sim._Test.Step(1 / 60) + end + variants[name] = { sim = sim, samples = {} } + end + for round = 1, 9 do + -- Alternate order to reduce warmup / thermal bias. + for _, name in if round % 2 == 0 then { "Optimized", "Baseline" } else { "Baseline", "Optimized" } do + local variant = variants[name] + local started = os.clock() + for _ = 1, 60 do + variant.sim._Test.Step(1 / 60) + end + table.insert(variant.samples, (os.clock() - started) * 1000 / 60) + end + task.wait() + end + for name, variant in variants do + table.sort(variant.samples) + print( + string.format( + "HOTPATH_BENCH %s events=%s casts=6000 median_step_ms=%.4f min=%.4f max=%.4f", + name, + tostring(withEvents), + variant.samples[5], + variant.samples[1], + variant.samples[9] + ) + ) + end + end +end + +local ok, err = xpcall(function() + correctness() + collisionTests() + benchmark() +end, debug.traceback) +storage:SetAttribute("HotPathResult", if ok then "HOTPATH_PASS" else "HOTPATH_FAIL " .. tostring(err)) diff --git a/tests/hotpaths/README.md b/tests/hotpaths/README.md new file mode 100644 index 00000000..0e27702d --- /dev/null +++ b/tests/hotpaths/README.md @@ -0,0 +1,63 @@ +# Simulation hot paths + +Run from the repository root with the toolchain on PATH: + +```sh +npm run test:hotpaths +# Optional A/B benchmark against a saved, pre-change simulation module: +lune run tests/hotpaths/run.luau path/to/baseline-directory +``` + +The baseline directory must contain `SerialSimulation.luau`. Save that file +before making changes to compare against the exact workspace state. Both +versions use the current Config and dependencies. Nothing is uploaded. + +The runner builds an isolated Studio place under `build/hotpaths`. It injects +private test hooks into disposable copies of the actual simulation modules; +the shipped modules gain no test API. The ordinary functional suite remains +`npm test -- --no-upload`. + +Regression checks cover both queues crossing several buffer growth boundaries, +IDs above 32 bits, nil holes and reuse with shorter payloads, event dispatch +after active-list removal, accelerated trajectories, midpoint position and +orientation, zero/vertical movement, and tiny motion at large coordinates. +It also checks 96 real collisions per cast type with randomized timestep +lengths and starting positions. + +The optional benchmark directly times serial simulation steps with 6,000 +headless, accelerated raycasts in an empty world, using sparse IDs above 2^40. +It includes real workspace raycast calls and public-state writeback, excludes +creation and cosmetic movement, and compares LengthChanged off/on. Each +variant gets 20 warmup steps, followed by nine alternating-order samples of +60 steps at dt=1/60. Reported times are the median of those per-step averages, +not frame intervals or per-frame tail latency. It does not measure parallel +scheduler throughput, collision-heavy workloads, or SwiftCast. + +Local Studio run on 2026-09-17, against the workspace snapshot immediately +before this optimization pass (including its existing performance edits): + +| LengthChanged | Before ms/step | After ms/step | Time reduction | +| --- | ---: | ---: | ---: | +| Off | 13.3614 | 11.3702 | 14.9% | +| On | 15.9684 | 12.9217 | 19.1% | + +These are one-machine measurements, not universal speed guarantees. Existing +Heartbeat benchmarks remained around the 60 FPS cap in both versions. +The final full-suite run passed all 19 tests. One intermediate run timed out +on the serial spherecast wall test; that failure did not reproduce in the +final run or the 288 deterministic collision checks. Its cause remains +unresolved. + +The implementation stores event metadata in nine bytes per capacity slot +(f64 ID and u8 event kind), removing one three-field table per queued event +and the two arrays of references to those tables. Argument tables remain +pooled because they contain Roblox values. Capacity doubles at growth +boundaries and is reused thereafter. Five direct argument assignments avoid +sparse-table length ambiguity and vararg copy loops. + +Ballistic displacement uses `(v0 + a*(t0+t1)/2)*(t1-t0)`, algebraically equal +to subtracting the two trajectory positions, without rounding large world +coordinates before subtraction. Each moving cast builds one midpoint +`CFrame.lookAlong` instead of constructing and multiplying two CFrames. +Floating-point results can differ slightly from the previous implementation; +the large-origin test specifically verifies the improved displacement. diff --git a/tests/hotpaths/run.luau b/tests/hotpaths/run.luau new file mode 100644 index 00000000..7494fade --- /dev/null +++ b/tests/hotpaths/run.luau @@ -0,0 +1,69 @@ +-- Run from the repository root: lune run tests/hotpaths/run.luau [baseline-directory] +-- The optional directory contains the pre-change SerialSimulation.luau. +-- Hooks are injected into disposable build copies, never the public API. +local fs = require("@lune/fs") +local process = require("@lune/process") +local serde = require("@lune/serde") + +local build = "build/hotpaths" +fs.writeDir(build) +local modules = { ["$path"] = "../../src" } +local function instrument(name, path, simulation) + local source = fs.readFile(path) + local peek = if string.find(source, "local eventMetadata", 1, true) + then "local o = (i-1)*EVENT_STRIDE; return buffer.readf64(eventMetadata,o), buffer.readu8(eventMetadata,o+8), argsPool[i]" + else "local e = queuedEvents[i]; return e[1], e[2], e[3]" + local hooks = simulation + .. [[._Test = { + Step = UpdateCasts, + Queue = QueueEvent, + Flush = FireQueuedEvents, + Reset = function() ResetPools(); queuedEventsCount = 0 end, + Peek = function(i) ]] + .. peek + .. [[ end, + } + return ]] + .. simulation + local result, replacements = string.gsub(source, "return " .. simulation .. "%s*$", function() + return hooks + end) + assert(replacements == 1, "simulation return not found") + fs.writeFile(build .. "/" .. name .. ".luau", result) + modules[name] = { ["$path"] = name .. ".luau" } +end +instrument("Optimized", "src/SerialSimulation.luau", "SerialSimulation") +instrument("Parallel", "src/ParallelSimulation.luau", "ParallelSimulation") +if process.args[1] then + instrument("Baseline", process.args[1] .. "/SerialSimulation.luau", "SerialSimulation") +end +fs.writeFile( + build .. "/default.project.json", + serde.encode("json", { + name = "FastCastHotPaths", + tree = { + ["$className"] = "DataModel", + ReplicatedStorage = { Modules = modules }, + ServerScriptService = { HotPaths = { ["$path"] = "../../tests/hotpaths/Harness.server.luau" } }, + }, + }) +) +local function run(command, args) + local result = process.exec(command, args) + local output = result.stdout .. "\n" .. result.stderr + print(output) + -- Studio plugins can make run-in-roblox exit nonzero even when the + -- isolated harness succeeds. Its explicit sentinel is authoritative, + -- as in tests/run.luau; missing or failing sentinels still fail below. + if not result.ok and not (command == "run-in-roblox" and string.find(output, "HOTPATH_PASS", 1, true)) then + warn(result.stderr) + process.exit(1) + end + return output +end +run("rojo", { "build", build .. "/default.project.json", "-o", build .. "/test.rbxlx" }) +local output = run("run-in-roblox", { "--place", build .. "/test.rbxlx", "--script", "tests/hotpaths/Entry.luau" }) +if not string.find(output, "HOTPATH_PASS", 1, true) then + warn("hot path tests failed or timed out") + process.exit(1) +end From 745c25d3724b2e37b6a53fcebd4ffaf942c1a647 Mon Sep 17 00:00:00 2001 From: Darwin Date: Fri, 18 Sep 2026 22:37:24 +1000 Subject: [PATCH 045/140] feat(events): replace BindableEvent caster events with Signal - Add src/Signal.luau lightweight synchronous event dispatcher - Expose Caster events as multi-listener Signals with Connect/Once/Wait/Disconnect - Keep function assignment as backwards-compatible shorthand for :Connect - Batch parallel worker events into a single Output message per frame - Add tests for multi-listener and Disconnect behavior --- docs/api-reference.md | 30 ++++- docs/changelog.md | 15 +++ src/BaseCastSerial.luau | 14 +-- src/ParallelSimulation.luau | 85 ++++++++----- src/SerialSimulation.luau | 22 ++-- src/Signal.luau | 189 +++++++++++++++++++++++++++++ src/TypeDefinitions.luau | 37 ++++-- src/init.luau | 214 +++++++++++++++++++++++++-------- tests/src/FunctionalTests.luau | 73 +++++++++++ 9 files changed, 558 insertions(+), 121 deletions(-) create mode 100644 src/Signal.luau diff --git a/docs/api-reference.md b/docs/api-reference.md index e7a9db65..eb2c438d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -23,17 +23,35 @@ caster:Init(movementMode, useObjectCache, template, cacheSize, cacheHolder) #### 1.1.2 Events -Events are assigned directly on the caster **before or after Init**: +Caster events are lightweight `Signal` objects (not `BindableEvent`s), so any +number of listeners can connect and disconnect independently. Events can be +connected **before or after Init**: + +```lua +local connection = caster.Hit:Connect(function(cast, result, velocity, cosmeticBullet) end) +connection:Disconnect() -- stop listening + +caster.Pierced:Connect(function(cast, result, velocity, cosmeticBullet) end) +caster.LengthChanged:Connect(function(cast, lastPoint, rayDir, rayDisplacement, velocity, cosmeticBullet) end) +caster.CastFire:Connect(function(cast, origin, direction, velocity, behavior) end) +caster.CastTerminating:Connect(function(cast) end) +``` + +`CanPierce` is a query that must return a boolean, so it stays a single function: ```lua -caster.Hit = function(cast, result, velocity, cosmeticBullet) end -caster.Pierced = function(cast, result, velocity, cosmeticBullet) end -caster.LengthChanged = function(cast, lastPoint, rayDir, rayDisplacement) end -caster.CastFire = function(cast, origin, direction, velocity, behavior) end -caster.CastTerminating = function(cast) end caster.CanPierce = function(cast, result, velocity, cosmeticBullet) -> boolean end ``` +For backwards compatibility, assigning a function to a signal event is shorthand +for `:Connect` and replaces any previous function assigned this way: + +```lua +caster.Hit = function(cast, result, velocity, cosmeticBullet) end +``` + +`Signal` supports `Connect`, `Once`, `Wait`, `Fire`, `DisconnectAll`, and `Destroy`. + #### 1.1.3 Movement Modes - **`"BulkMoveTo"`** — Uses `workspace:BulkMoveTo()` each frame. (General/Default) diff --git a/docs/changelog.md b/docs/changelog.md index e201b3da..93960694 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,21 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) --- +## [Unreleased] + +### Added +- **`Signal`** - lightweight synchronous event dispatcher (`src/Signal.luau`) used for Caster events instead of `BindableEvent` +- Caster events (`Hit`, `Pierced`, `LengthChanged`, `CastFire`, `CastTerminating`) are now Signals supporting multiple listeners, `Once`, `Wait`, `Disconnect`, `DisconnectAll`, and `Destroy` + +### Changed +- Assigning a function to a caster event is now shorthand for `:Connect` (still supported for backwards compatibility) +- `CanPierce` remains a single function because it has to return a boolean + +### Performance +- Parallel workers now batch all queued events into a single `Output` message per frame instead of firing one `BindableEvent` message per event, reducing Actor-boundary crossings + +--- + ## [0.1.0] — 2026-05-07 ### Added diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 00f3c351..07e19d37 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -96,7 +96,7 @@ function BaseCast.Raycast( Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn(cast, Origin, Direction, Velocity, Behavior) + CastFirefn:Fire(cast, Origin, Direction, Velocity, Behavior) end end @@ -132,7 +132,7 @@ function BaseCast.Blockcast( Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn(cast, Origin, Direction, Velocity, Behavior) + CastFirefn:Fire(cast, Origin, Direction, Velocity, Behavior) end end @@ -168,7 +168,7 @@ function BaseCast.Spherecast( Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn(cast, Origin, Direction, Velocity, Behavior) + CastFirefn:Fire(cast, Origin, Direction, Velocity, Behavior) end end @@ -294,12 +294,4 @@ function BaseCast._ReturnMotor6D(motor6d: Motor6D?) end end -function BaseCast._UpdateEvents(eventName: string, newEventfn: (...any) -> ()) - if eventName == "CastFire" then - CastFirefn = newEventfn - return - end - SerialSimulation._UpdateEvent(eventName, newEventfn) -end - return BaseCast diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index 9ae4eec6..0b5e7fee 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -351,6 +351,19 @@ local function GetEvent(): QueuedEvent return eventPool[eventPoolCount] end +-- Pooled records for the worker -> main event batch. +-- Layout: { eventType, cast, args, argCount } +local batchPool: { { any } } = {} +local batchPoolCount: number = 0 + +local function GetBatchRecord(): { any } + batchPoolCount += 1 + if batchPoolCount > #batchPool then + batchPool[batchPoolCount] = { 0, false, false, 0 } + end + return batchPool[batchPoolCount] +end + local visPool: { QueuedVis } = {} local visPoolCount: number = 0 @@ -392,6 +405,7 @@ end local function ResetPools() argsPoolCount = 0 eventPoolCount = 0 + batchPoolCount = 0 visPoolCount = 0 hitVisPoolCount = 0 segVisPoolCount = 0 @@ -545,15 +559,23 @@ local function QueueEvent( entry[1] = castID entry[2] = eventType entry[3] = args + entry[4] = n queuedEvents[queuedEventsCount] = entry end local function FireQueuedEvents() + -- Events are collected into a single per-frame batch so the worker only + -- crosses the Actor boundary once per frame instead of once per event. + local batch: { any } = {} + local batchCount = 0 + local batchCaster: any = nil + for i = 1, queuedEventsCount do local entry = queuedEvents[i] local castID = entry[1] :: number local numericType = entry[2] :: number local args = entry[3] :: { any } + local argCount = entry[4] :: number local cast = ActivesRef[castID] @@ -575,15 +597,14 @@ local function FireQueuedEvents() local cosmetic = args[5] if eventConfig and eventConfig.UseLengthChanged then - caster.Output:Fire( - "LengthChanged", - cast, - lastPoint, - rayDirection, - displacement, - velocity, - cosmetic - ) + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_LENGTH_CHANGED + record[2] = cast + record[3] = args + record[4] = argCount + batch[batchCount] = record + batchCaster = caster end if moduleConfig @@ -607,13 +628,14 @@ local function FireQueuedEvents() local cosmetic = args[3] if eventConfig and eventConfig.UseHit then - caster.Output:Fire( - "Hit", - cast, - result, - velocity, - cosmetic - ) + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_HIT + record[2] = cast + record[3] = args + record[4] = argCount + batch[batchCount] = record + batchCaster = caster end if moduleConfig @@ -635,13 +657,14 @@ local function FireQueuedEvents() local cosmetic = args[3] if eventConfig and eventConfig.UsePierced then - caster.Output:Fire( - "Pierced", - cast, - result, - velocity, - cosmetic - ) + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_PIERCED + record[2] = cast + record[3] = args + record[4] = argCount + batch[batchCount] = record + batchCaster = caster end if moduleConfig @@ -669,10 +692,14 @@ local function FireQueuedEvents() if eventConfig and eventConfig.UseCastTerminating then - caster.Output:Fire( - "CastTerminating", - cast - ) + batchCount += 1 + local record = GetBatchRecord() + record[1] = EVT_CAST_TERMINATING + record[2] = cast + record[3] = false + record[4] = 0 + batch[batchCount] = record + batchCaster = caster end if callback then @@ -696,6 +723,10 @@ local function FireQueuedEvents() ParallelSimulation_Unregister(castID) end end + + if batchCount > 0 then + batchCaster.Output:Fire("Batch", batch) + end end -- ============================================================================ diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 1f7de66c..26ff05df 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -137,7 +137,7 @@ local BaseCastRef: any = nil local CurrentMovementMode: "BulkMoveTo" | "Motor6D" = "BulkMoveTo" local MovementEnabled = false -local FastCastEvents = {} :: {[number]: (any) -> ()} +local FastCastEvents = {} :: any -- ============================================================================ -- Math @@ -575,7 +575,7 @@ local function FireQueuedEvents() if eventConfig.UseLengthChanged then local lengthChanged = FastCastEvents.LengthChanged if lengthChanged then - lengthChanged( + lengthChanged:Fire( cast, args[1], args[2], @@ -590,7 +590,7 @@ local function FireQueuedEvents() if eventConfig.UseHit then local Hit = FastCastEvents.Hit if Hit then - Hit( + Hit:Fire( cast, args[1], args[2], @@ -603,7 +603,7 @@ local function FireQueuedEvents() if eventConfig.UsePierced then local Pierced = FastCastEvents.Pierced if Pierced then - Pierced( + Pierced:Fire( cast, args[1], args[2], @@ -618,7 +618,7 @@ local function FireQueuedEvents() local castTerminating = FastCastEvents.CastTerminating --local eventConfig = cast.StateInfo and cast.StateInfo.FastCastEventsConfig if eventConfig.UseCastTerminating and castTerminating then - castTerminating(cast) + castTerminating:Fire(cast) end end BaseCastRef._TerminateCast(castID) @@ -1480,7 +1480,7 @@ local SerialSimulation = {} SerialSimulation.Connection = nil :: RBXScriptConnection? -function SerialSimulation.Init(baseCastRef: any, Actives: any, events: TypeDef.FastCastEvents) +function SerialSimulation.Init(baseCastRef: any, Actives: any, events: TypeDef.FastCastSignalEvents) if ActivesRef ~= nil then warn( "[FastCast2] SerialSimulation already initialized. Skipping." @@ -1532,15 +1532,7 @@ function SerialSimulation.Stop() ActivesRef = nil end -function SerialSimulation._UpdateEvent(name: string, newEventfn: (any) -> ()) - if type(newEventfn) ~= "function" then - return - end - - FastCastEvents[name] = newEventfn -end - -function SerialSimulation.GetEvent(name: string): ((any) -> ())? +function SerialSimulation.GetEvent(name: string): any return FastCastEvents[name] end diff --git a/src/Signal.luau b/src/Signal.luau new file mode 100644 index 00000000..116be180 --- /dev/null +++ b/src/Signal.luau @@ -0,0 +1,189 @@ +--[[ + Author: Darwin (darwin@shellworks.dev) + Date: 18/09/2026 + + Signal - a lightweight, synchronous event dispatcher. + + FastCast2 uses this instead of `BindableEvent` for Caster events. It has no + Instance overhead and, unlike a `BindableEvent`, supports any number of + independent listeners plus explicit `Disconnect`/`Reconnect`. + + This is a pure-Luau object and therefore cannot cross an Actor boundary; the + parallel worker VMs still use a `BindableEvent` as the transport and simply + `Fire` the Caster's signals on the main thread. +]] + +-- Types + +export type Connection = { + Connected: boolean, + + Disconnect: (self: Connection) -> (), + Reconnect: (self: Connection) -> (), +} + +export type Signal = { + Connect: (self: Signal, callback: (T...) -> ()) -> Connection, + Once: (self: Signal, callback: (T...) -> ()) -> Connection, + Wait: (self: Signal) -> T..., + Fire: (self: Signal, T...) -> (), + DisconnectAll: (self: Signal) -> (), + Destroy: (self: Signal) -> (), +} + +-- Connection + +local Connection = {} +Connection.__index = Connection +Connection.__type = "Connection" + +local function disconnect(self) + if not self.Connected then + return + end + + self.Connected = false + + local signal = self._signal + if signal == nil then + return + end + + local prev = self._prev + local next = self._next + + if prev then + prev._next = next + elseif signal._head == self then + signal._head = next + end + + if next then + next._prev = prev + end +end + +local function reconnect(self) + if self.Connected then + return + end + + local signal = self._signal + if signal == nil then + return + end + + self.Connected = true + + local head = signal._head + self._next = head + self._prev = nil + + if head then + head._prev = self + end + + signal._head = self +end + +Connection.Disconnect = disconnect +Connection.Reconnect = reconnect + +-- Signal + +local Signal = {} +Signal.__index = Signal +Signal.__type = "Signal" + +function Signal.new() + return setmetatable({ _head = nil, _destroyed = false }, Signal) +end + +function Signal.is(value: any): boolean + return type(value) == "table" and getmetatable(value) == Signal +end + +function Signal:Connect(callback) + assert(type(callback) == "function", "Signal:Connect expects a function") + + local connection = setmetatable({ + Connected = true, + _signal = self, + _fn = callback, + _prev = nil, + _next = self._head, + }, Connection) + + local head = self._head + if head then + head._prev = connection + end + self._head = connection + + return connection +end + +function Signal:Once(callback) + local connection + connection = self:Connect(function(...) + connection:Disconnect() + callback(...) + end) + return connection +end + +function Signal:Wait() + local thread = coroutine.running() + local connection + connection = self:Connect(function(...) + connection:Disconnect() + task.spawn(thread, ...) + end) + return coroutine.yield() +end + +function Signal:Fire(...) + if self._destroyed then + return + end + + local connection = self._head + while connection do + -- Capture the next link before firing so a listener that disconnects + -- itself (or another listener) cannot break traversal. + local nextConnection = connection._next + if connection.Connected then + connection._fn(...) + end + connection = nextConnection + end +end + +function Signal:DisconnectAll() + local connection = self._head + while connection do + local nextConnection = connection._next + connection:Disconnect() + connection = nextConnection + end + self._head = nil +end + +function Signal:Destroy() + self._destroyed = true + self:DisconnectAll() +end + +--[[ + Wrap an `RBXScriptSignal` so it can be used like a Signal. Useful when + building APIs that mix Roblox signals with FastCast2 signals. +]] +function Signal.wrap(rbxSignal) + local signal = Signal.new() + signal._rbxConnection = rbxSignal:Connect(function(...) + signal:Fire(...) + end) + return signal +end + +return Signal diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index d43a0a5b..3e463b1b 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -14,6 +14,7 @@ ]=] local Dispatcher = require(script.Parent:WaitForChild("FastCastVMs")) +local Signal = require(script.Parent:WaitForChild("Signal")) --[=[ @type vaildcast ActiveCastData | ActiveBlockcastData | ActiveSpherecastData @@ -47,6 +48,22 @@ export type FastCastEvents = { CastFire: OnCastFireFunction } +--[=[ + @type FastCastSignalEvents { CanPierce: CanPierceFunction, Hit: Signal.Signal, Pierced: Signal.Signal, LengthChanged: Signal.Signal, CastTerminating: Signal.Signal, CastFire: Signal.Signal } + @within TypeDefinitions + + The Signal-based event table used internally by SerialSimulation. Each event + is a Signal so any number of listeners can connect to it. +]=] +export type FastCastSignalEvents = { + CanPierce: CanPierceFunction, + Hit: Signal.Signal, + Pierced: Signal.Signal, + LengthChanged: Signal.Signal, + CastTerminating: Signal.Signal, + CastFire: Signal.Signal +} + --[=[ @type CanPierceFunction (cast: vaildcast, result: RaycastResult, segmentVelocity: Vector3, cosmeticBulletObject: Instance?) -> boolean @within TypeDefinitions @@ -157,11 +174,11 @@ export type VisualizeCastSettings = { ]=] export type CasterParallel = { WorldRoot: WorldRoot, - LengthChanged: OnLengthChangedFunction, - Hit: OnHitFunction, - Pierced: OnPiercedFunction, - CastTerminating: OnCastTerminatingFunction, - CastFire: OnCastFireFunction, + LengthChanged: Signal.Signal | OnLengthChangedFunction, + Hit: Signal.Signal | OnHitFunction, + Pierced: Signal.Signal | OnPiercedFunction, + CastTerminating: Signal.Signal | OnCastTerminatingFunction, + CastFire: Signal.Signal | OnCastFireFunction, Dispatcher: Dispatcher.Dispatcher, AlreadyInit: boolean, @@ -252,12 +269,12 @@ export type CasterParallel = { ]=] export type CasterSerial = { WorldRoot: WorldRoot, - LengthChanged: OnLengthChangedFunction, - Hit: OnHitFunction, + LengthChanged: Signal.Signal | OnLengthChangedFunction, + Hit: Signal.Signal | OnHitFunction, CanPierce: CanPierceFunction, - Pierced: OnPiercedFunction, - CastTerminating: OnCastTerminatingFunction, - CastFire: OnCastFireFunction, + Pierced: Signal.Signal | OnPiercedFunction, + CastTerminating: Signal.Signal | OnCastTerminatingFunction, + CastFire: Signal.Signal | OnCastFireFunction, Dispatcher: Dispatcher.Dispatcher, AlreadyInit: boolean, diff --git a/src/init.luau b/src/init.luau index 08299808..51c8a472 100644 --- a/src/init.luau +++ b/src/init.luau @@ -57,6 +57,7 @@ --local BaseCast = script:WaitForChild("BaseCast") -- Requires +local Signal = require(script:WaitForChild("Signal")) local TypeDef = require(script:WaitForChild("TypeDefinitions")) local DefaultConfigs = require(script:WaitForChild("DefaultConfigs")) local BaseCastSerial = require(script:WaitForChild("BaseCastSerial")) @@ -67,6 +68,7 @@ local Dispatcher = require(DispatcherModule) -- Type Exports export type FastCastEventsModule = TypeDef.FastCastEventsModule export type FastCastEvents = TypeDef.FastCastEvents +export type FastCastSignalEvents = TypeDef.FastCastSignalEvents export type CanPierceFunction = TypeDef.CanPierceFunction export type OnHitFunction = TypeDef.OnHitFunction @@ -101,15 +103,154 @@ type vaildcast = TypeDef.ActiveCastData | TypeDef.ActiveBlockcastData | TypeDef. -- CONSTANTS local DEFAULT_CACHE_SIZE = 500 local DEFAULT_CACHE_HOLDER = workspace -local VALID_EVENTS = { + +-- Events exposed on a Caster as a Signal. Assigning a function to one of these +-- names is equivalent to connecting that function to the underlying Signal. +local SIGNAL_EVENTS = { ["CastFire"] = true, ["CastTerminating"] = true, ["Hit"] = true, ["Pierced"] = true, - ["LengthChanged"] = true, + ["LengthChanged"] = true +} + +-- CanPierce is a query (it has to return a boolean), so it stays a plain function. +local QUERY_EVENTS = { ["CanPierce"] = true } +-- Mirrors the EVT_* numeric constants in ParallelSimulation. Parallel workers +-- send one "Batch" message per frame containing records tagged with these IDs; +-- the main thread demuxes them back into the Caster's signals. +local OUTPUT_EVENT_NAMES = { + [1] = "LengthChanged", + [2] = "Hit", + [3] = "Pierced", + [4] = "CastTerminating" +} + +local function NewCasterSignals() + return { + CastFire = Signal.new(), + CastTerminating = Signal.new(), + Hit = Signal.new(), + Pierced = Signal.new(), + LengthChanged = Signal.new() + } +end + +local function DestroyCasterSignals(self) + local legacy = rawget(self, "_legacy") + if legacy then + for _, connection in legacy do + connection:Disconnect() + end + rawset(self, "_legacy", nil) + end + + local events = rawget(self, "_events") + if events then + for _, signal in events do + signal:Destroy() + end + rawset(self, "_events", nil) + end + + rawset(self, "CanPierce", nil) +end + +local function IndexCaster(self, key) + local events = rawget(self, "_events") + if events then + local signal = events[key] + if signal ~= nil then + return signal + end + end + + local mt = getmetatable(self) + if mt then + return mt[key] + end + return nil +end + +local function NewIndexCaster(self, key, value) + if SIGNAL_EVENTS[key] then + local events = rawget(self, "_events") + if not events then + events = NewCasterSignals() + rawset(self, "_events", events) + end + + if type(value) == "function" then + -- Backwards compatible assignment: connect the function, replacing + -- any previous function assigned this way. + local legacy = rawget(self, "_legacy") + if not legacy then + legacy = {} + rawset(self, "_legacy", legacy) + end + + local previous = legacy[key] + if previous then + previous:Disconnect() + end + + legacy[key] = events[key]:Connect(value) + elseif Signal.is(value) then + local legacy = rawget(self, "_legacy") + if legacy and legacy[key] then + legacy[key]:Disconnect() + legacy[key] = nil + end + + events[key] = value + elseif value == nil then + local legacy = rawget(self, "_legacy") + if legacy and legacy[key] then + legacy[key]:Disconnect() + legacy[key] = nil + end + else + warn("[FastCast2] Cannot set event '" .. tostring(key) .. "', expected a function or Signal") + end + return + end + + if QUERY_EVENTS[key] then + if value == nil or type(value) == "function" then + rawset(self, key, value) + else + warn("[FastCast2] Cannot set CanPierce, expected a function") + end + return + end + + rawset(self, key, value) +end + +-- Demux a batch of worker events (see ParallelSimulation.FireQueuedEvents). +-- Record layout: { eventType, cast, args, argCount } +local function DispatchOutputBatch(self, batch) + for i = 1, #batch do + local record = batch[i] + local signalName = OUTPUT_EVENT_NAMES[record[1]] + + if signalName then + local signal = self[signalName] + if Signal.is(signal) then + local argCount = record[4] + if argCount > 0 then + signal:Fire(record[2], table.unpack(record[3], 1, argCount)) + else + signal:Fire(record[2]) + end + end + end + end +end + --[=[ @class FastCast @@ -146,25 +287,12 @@ If true, verbose debug logging will be used, printing detailed information about what's going on during processing to the output. ]] -FastCastSerial.__index = FastCastSerial -FastCastSerial.__newindex = function(self, key, value) - if VALID_EVENTS[key] then - if type(value) == "function" then - if self.BaseCast then - self.BaseCast._UpdateEvents(key, value) - else - rawset(self, key, value) - end - else - warn("Cannot set event, not a function") - end - else - rawset(self, key, value) - end -end +FastCastSerial.__index = IndexCaster +FastCastSerial.__newindex = NewIndexCaster FastCastSerial.__type = "FastCastSerial" -FastCastParallel.__index = FastCastParallel +FastCastParallel.__index = IndexCaster +FastCastParallel.__newindex = NewIndexCaster FastCastParallel.__type = "FastCastParallel" -- Local functions @@ -306,14 +434,15 @@ function FastCastParallel:Init( }, FastCastModule = script } - self.Dispatcher = newDispatcher.new(numWorkers, data, function(signalName: string, ...) - local f = self[signalName] - if not f then + self.Dispatcher = newDispatcher.new(numWorkers, data, function(eventName: string, ...) + if eventName == "Batch" then + DispatchOutputBatch(self, ...) return end - if type(f) == "function" then - f(...) + local signal = self[eventName] + if Signal.is(signal) then + signal:Fire(...) end end) @@ -507,7 +636,7 @@ function FastCastSerial:Init( } } - local events: TypeDef.FastCastEvents = { + local events: TypeDef.FastCastSignalEvents = { CastFire = self.CastFire, Pierced = self.Pierced, Hit = self.Hit, @@ -627,16 +756,10 @@ function FastCastSerial:Destroy() if getmetatable(self) ~= FastCastSerial then return end - setmetatable(self, nil) - - BaseCastSerial.Destroy() - self.LengthChanged = nil - self.Hit = nil - self.Pierced = nil - self.CanPierce = nil - self.CastTerminating = nil - self.CastFire = nil + BaseCastSerial.Destroy() + DestroyCasterSignals(self) + setmetatable(self, nil) self.Destroy = function() end end @@ -651,15 +774,11 @@ function FastCastParallel:Destroy() return end - self.LengthChanged = nil - self.Hit = nil - self.Pierced = nil - self.CastTerminating = nil - self.CastFire = nil - if self.Dispatcher then self.Dispatcher:Destroy() end + + DestroyCasterSignals(self) self.Destroy = function() end setmetatable(self, nil) end @@ -832,7 +951,7 @@ function FastCast:TerminateCast(cast: vaildcast) if eventsCfg and eventsCfg.UseCastTerminating then local castTerminating = SerialSimulation.GetEvent("CastTerminating") if castTerminating then - castTerminating(cast) + castTerminating:Fire(cast) end end SerialSimulation.QueueEvent(cast.ID, "CastTerminating") @@ -861,12 +980,7 @@ end ]=] function FastCast.new() local fs = { - LengthChanged = nil, - Hit = nil, - Pierced = nil, - CanPierce = nil, - CastTerminating = nil, - CastFire = nil, + _events = NewCasterSignals(), WorldRoot = workspace, } setmetatable(fs, FastCastSerial) @@ -888,11 +1002,7 @@ end ]=] function FastCast.newParallel() local fp = { - LengthChanged = nil, - Hit = nil, - Pierced = nil, - CastTerminating = nil, - CastFire = nil, + _events = NewCasterSignals(), WorldRoot = workspace, Dispatcher = nil, AlreadyInit = false diff --git a/tests/src/FunctionalTests.luau b/tests/src/FunctionalTests.luau index c15399d1..4f10a2a2 100644 --- a/tests/src/FunctionalTests.luau +++ b/tests/src/FunctionalTests.luau @@ -262,6 +262,45 @@ return function() arena:Destroy() end) + test("events are Signals that support multiple listeners and Disconnect", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firstCount = 0 + local secondCount = 0 + + local caster = FastCast.new() + local firstConnection = caster.Hit:Connect(function(_, result) + if result.Instance == wall then + firstCount += 1 + end + end) + caster.Hit:Connect(function(_, result) + if result.Instance == wall then + secondCount += 1 + end + end) + caster:Init("BulkMoveTo") + + -- A disconnected listener must not receive the event. + firstConnection:Disconnect() + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return secondCount > 0 + end, HIT_TIMEOUT), + "connected Signal listener never fired" + ) + expect.equal(firstCount, 0, "disconnected Signal listener still fired") + + caster:Destroy() + arena:Destroy() + end) + test("Pierced and CanPierce run when a cast is allowed through", function(expect) local arena = createArena() local container = createContainer(arena) @@ -486,6 +525,40 @@ return function() arena:Destroy() end) + test("events are Signals that support multiple listeners", function(expect) + local arena = createArena() + local wall = createWall(arena, Vector3.new(0, 0, -40), Vector3.new(50, 50, 2)) + local container = createContainer(arena) + local behavior = makeBehavior(container) + + local firstCount = 0 + local secondCount = 0 + + local caster = createParallelCaster() + caster.Hit:Connect(function(_, result) + if result.Instance == wall then + firstCount += 1 + end + end) + caster.Hit:Connect(function(_, result) + if result.Instance == wall then + secondCount += 1 + end + end) + + fire(caster, "Raycast", Vector3.new(0, 0, 0), Vector3.new(0, 0, -1), 120, behavior) + + expect.truthy( + Framework.waitFor(function() + return firstCount > 0 and secondCount > 0 + end, HIT_TIMEOUT), + "parallel Signal listeners never fired" + ) + + caster:Destroy() + arena:Destroy() + end) + test("SyncChangesToCast does not error", function(expect) local arena = createArena() local container = createContainer(arena) From 803f274de590089ea1bdc694cfcbccabbf2047b7 Mon Sep 17 00:00:00 2001 From: Darwin Date: Sat, 19 Sep 2026 00:59:00 +1000 Subject: [PATCH 046/140] fix: initialize parallel actors reliably on the client Dispatcher:Allocate sent 'Init' exactly once, so a client ClientVM that bound its message handlers a frame late never initialized. Every later 'Raycast' message was then dropped and the caster produced no casts. BaseCastParallel.Init now sets a FastCast2Ready attribute and the dispatcher retries 'Init' until every actor reports ready. --- src/BaseCastParallel.luau | 14 +++++++++----- src/FastCastVMs/init.luau | 26 +++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index d4a47a28..1f89c9f5 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -72,17 +72,21 @@ local BaseCastInstance = nil function BaseCast.Init(BindableOutput: BindableEvent, Data: any) if BaseCastInitialized then - warn("[FastCast2] BaseCast already initialized in this Actor. Skipping.") - return BaseCastInstance - end - BaseCastInitialized = true - + return BaseCastInstance + end + BaseCastInitialized = true + local self = setmetatable({}, BaseCast) Actor = BindableOutput.Parent self.Actives = {} self.Actor = Actor Output = BindableOutput + -- Signals the dispatcher that this actor's controller has initialized. + -- The dispatcher retries "Init" until every actor sets this, which matters + -- on the client where a controller script can start a few frames late. + Actor:SetAttribute("FastCast2Ready", true) + if Data.useObjectCache then local objectCacheArgs = Data.objectCacheArgs or {} if not objectCacheArgs.CacheSize then diff --git a/src/FastCastVMs/init.luau b/src/FastCastVMs/init.luau index 449ba8a0..2af2fed4 100644 --- a/src/FastCastVMs/init.luau +++ b/src/FastCastVMs/init.luau @@ -183,9 +183,29 @@ function Dispatcher:Allocate(Threads: number, Data: any?, Callback: (...any) -> --> Allow actors to start RunService.PostSimulation:Wait() - --> Initialize actors - for _, Actor in Actors do - Actor:SendMessage("Init", Data) + --> Initialize actors. A message is dropped if the controller script has + --> not bound its topics yet, and client controllers can start a few + --> frames late, so retry until every actor reports that it initialized. + for attempt = 1, 30 do + local pending = false + + for _, Actor in Actors do + if Actor:GetAttribute("FastCast2Ready") ~= true then + pending = true + Actor:SendMessage("Init", Data) + end + end + + if not pending then + break + end + + if attempt == 30 then + warn("[FastCastVMs] Some actors did not initialize. RunContext : " + .. (IS_SERVER and "Server" or "Client")) + end + + RunService.PostSimulation:Wait() end --> Merge actors into threads From 4dc7ec18a1c6799ef6cd951b7da9c5965c7d8001 Mon Sep 17 00:00:00 2001 From: Darwin Date: Sat, 19 Sep 2026 01:02:03 +1000 Subject: [PATCH 047/140] docs: note the client ContainerParent requirement for parallel casters --- src/init.luau | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/init.luau b/src/init.luau index 51c8a472..87d70e0b 100644 --- a/src/init.luau +++ b/src/init.luau @@ -393,6 +393,10 @@ end @param Template BasePart | Model -- The template object to use for the ObjectCache (if enabled) @param CacheSize number -- The size of the ObjectCache (if enabled) @param CacheHolder Instance -- The Instance in which to place cached objects (if enabled) + + Note: On the client, `ContainerParent` must be a script-enabled container + (for example `ReplicatedFirst`). Worker controllers placed in a container + that does not run scripts never initialize, and no casts are simulated. ]=] function FastCastParallel:Init( numWorkers: number, From d8d8a5c6edb78c2316482aac5f2ae1314df5b927 Mon Sep 17 00:00:00 2001 From: Darwin Date: Sat, 19 Sep 2026 01:09:10 +1000 Subject: [PATCH 048/140] perf: batch parallel fire requests into one message per worker per frame Parallel casters sent one Actor message per shot, so a high fire rate spent most of the main thread serializing and crossing the Actor boundary. Shots are now queued and flushed once per frame, distributed round-robin across workers. The behavior is snapshotted when queued so per-shot mutations such as the raycast filter are preserved. --- src/FastCastVMs/ClientVM.client.luau | 13 ++++ src/FastCastVMs/ServerVM.server.luau | 13 ++++ src/init.luau | 111 ++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/FastCastVMs/ClientVM.client.luau b/src/FastCastVMs/ClientVM.client.luau index bfb1473e..a5e3b40b 100644 --- a/src/FastCastVMs/ClientVM.client.luau +++ b/src/FastCastVMs/ClientVM.client.luau @@ -73,6 +73,19 @@ actor:BindToMessage("Spherecast", function( BaseCast:Spherecast(origin, radius, direction, velocity, behavior) end) +actor:BindToMessage("FireBatch", function(requests: { any }) + for _, request in requests do + local kind = request[1] + if kind == 1 then + BaseCast:Raycast(request[2], request[3], request[4], request[5]) + elseif kind == 2 then + BaseCast:Blockcast(request[2], request[3], request[4], request[5], request[6]) + elseif kind == 3 then + BaseCast:Spherecast(request[2], request[3], request[4], request[5], request[6]) + end + end +end) + actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") BaseCast:SetMovementModeEnabled(enabled, mode) end) diff --git a/src/FastCastVMs/ServerVM.server.luau b/src/FastCastVMs/ServerVM.server.luau index 1d9c55e9..109071bb 100644 --- a/src/FastCastVMs/ServerVM.server.luau +++ b/src/FastCastVMs/ServerVM.server.luau @@ -81,6 +81,19 @@ actor:BindToMessage("Spherecast", function( BaseCast:Spherecast(origin, radius, direction, velocity, behavior) end) +actor:BindToMessage("FireBatch", function(requests: { any }) + for _, request in requests do + local kind = request[1] + if kind == 1 then + BaseCast:Raycast(request[2], request[3], request[4], request[5]) + elseif kind == 2 then + BaseCast:Blockcast(request[2], request[3], request[4], request[5], request[6]) + elseif kind == 3 then + BaseCast:Spherecast(request[2], request[3], request[4], request[5], request[6]) + end + end +end) + actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") BaseCast:SetMovementModeEnabled(enabled, mode) end) diff --git a/src/init.luau b/src/init.luau index 51c8a472..d94e0c5e 100644 --- a/src/init.luau +++ b/src/init.luau @@ -56,6 +56,10 @@ -- Modules --local BaseCast = script:WaitForChild("BaseCast") +-- Services + +local RunService = game:GetService("RunService") + -- Requires local Signal = require(script:WaitForChild("Signal")) local TypeDef = require(script:WaitForChild("TypeDefinitions")) @@ -376,6 +380,75 @@ function FastCast.newBehavior(): TypeDef.FastCastBehavior return deepCopyTable(DefaultConfigs.FastCastBehavior) :: TypeDef.FastCastBehavior end +-- Batched parallel dispatch. Shots are queued and sent once per frame per +-- worker instead of one message per shot, removing the per-shot cost of +-- crossing the Actor boundary. The behavior is snapshotted when queued so +-- per-shot mutations (such as the raycast filter) are preserved. + +local function CloneCastParams(params: RaycastParams): RaycastParams + local clone = RaycastParams.new() + clone.CollisionGroup = params.CollisionGroup + clone.FilterType = params.FilterType + clone.FilterDescendantsInstances = table.clone(params.FilterDescendantsInstances) + clone.IgnoreWater = params.IgnoreWater + clone.RespectCanCollide = params.RespectCanCollide + return clone +end + +local function SnapshotBehavior(behavior: any): any + local copy = table.clone(behavior) + if behavior.RaycastParams then + copy.RaycastParams = CloneCastParams(behavior.RaycastParams) + end + return copy +end + +local function QueueFire(self: any, request: { any }) + local queue = self._fireQueue + queue[#queue + 1] = request +end + +local function FlushFires(self: any) + local queue = self._fireQueue + local count = #queue + if count == 0 then + return + end + + local dispatcher = self.Dispatcher + local threads = dispatcher and dispatcher.Threads + local workerCount = if threads then #threads else 0 + if workerCount == 0 then + table.clear(queue) + return + end + + local batches = self._fireBatches + local used = self._fireUsed + local usedCount = 0 + + for i = 1, count do + local worker = (i - 1) % workerCount + 1 + local batch = batches[worker] + if not batch then + batch = {} + batches[worker] = batch + usedCount += 1 + used[usedCount] = worker + end + batch[#batch + 1] = queue[i] + end + table.clear(queue) + + for i = 1, usedCount do + local worker = used[i] + local batch = batches[worker] + threads[worker]:SendMessage("FireBatch", batch) + table.clear(batch) + used[i] = nil + end +end + --[=[ Initializes the Caster with the given parameters. This is required before firing using Raycasts in the Caster or nothing will happen! @method Init @@ -446,6 +519,13 @@ function FastCastParallel:Init( end end) + -- Queue shots and flush them once per frame (see FlushFires). + self._fireQueue = {} + self._fireBatches = {} + self._fireUsed = {} + self._flushConnection = RunService.PreSimulation:Connect(function() + FlushFires(self) + end) self.AlreadyInit = true self.ObjectCacheEnabled = useObjectCache @@ -496,7 +576,13 @@ function FastCastParallel:RaycastFire( BehaviorData = FastCast.newBehavior() end - self.Dispatcher:Dispatch("Raycast", origin, direction, velocity, BehaviorData) + QueueFire(self, { + 1, + origin, + direction, + velocity, + SnapshotBehavior(BehaviorData) + }) end --[=[ @@ -524,7 +610,14 @@ function FastCastParallel:BlockcastFire( BehaviorData = FastCast.newBehavior() end - self.Dispatcher:Dispatch("Blockcast", origin, Size, direction, velocity, BehaviorData) + QueueFire(self, { + 2, + origin, + Size, + direction, + velocity, + SnapshotBehavior(BehaviorData) + }) end --[=[ @@ -552,7 +645,14 @@ function FastCastParallel:SpherecastFire( BehaviorData = FastCast.newBehavior() end - self.Dispatcher:Dispatch("Spherecast", origin, Radius, direction, velocity, BehaviorData) + QueueFire(self, { + 3, + origin, + Radius, + direction, + velocity, + SnapshotBehavior(BehaviorData) + }) end --[=[ @@ -774,6 +874,11 @@ function FastCastParallel:Destroy() return end + if self._flushConnection then + self._flushConnection:Disconnect() + self._flushConnection = nil + end + if self.Dispatcher then self.Dispatcher:Destroy() end From cc277d71d4682f0a3253f0ce4ccb7acc3d4add9f Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 22:55:55 +0700 Subject: [PATCH 049/140] Checkmark some shits --- TODO.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 45e37bf2..a19a22c8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,15 +1,15 @@ - [ ] Create multiple games, libraries, and frameworks using FastCast2, and showcase them in `docs/showcase.md` (DOCS) -- [ ] Create a Debugger GUI for benchmarking and testing (debuggergui) -- [ ] Add a manual test checklist MD -- [ ] Code improvements +- [ ] ~~Create a Debugger GUI for benchmarking and testing (debuggergui)~~ (Cancalled) +- [X] Add a manual test checklist MD +- [X] Code improvements - [ ] ~~Add dynamic RunService event configuration for Caster (feats)~~ (Cancelled) -- [ ] Add `BindToSimulation` and `UseFixedSimulation` (changes) +- [X] Add `BindToSimulation` (changes) - [ ] Migration guide from FastCast Redux/FastCast (DOCS) - [ ] Performance guide and best practices (DOCS) - [ ] Common pitfalls and FAQ (DOCS) -- [ ] Add benchmark pics to `docs/` and `README.md` (DOCS) +- [X] ~~Add benchmark pics to `docs/` and `README.md` (DOCS)~~ (Naymmmm works) - [ ] Add benchmark GIF demo to `README.md` showing projectiles in action (DOCS) -- [ ] troubleshooting guide (DOCS) +- [ ] ~~troubleshooting guide (DOCS)~~ (Cancelled) - [ ] advanced examples (DOCS) - [ ] Create a new FastCast2 cover image (DOCS) - [ ] Create a FastCast2 tutorial video (DOCS) From e153737cf1addfa3b9a5281e57bc982cacdff7ad Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:33:45 +0700 Subject: [PATCH 050/140] Simplify FastCastParallel initialization for parallel luau --- src/init.luau | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/init.luau b/src/init.luau index c3c49b21..f679de8c 100644 --- a/src/init.luau +++ b/src/init.luau @@ -133,6 +133,22 @@ local OUTPUT_EVENT_NAMES = { [4] = "CastTerminating" } +--[[ + Note: On the client, `PARALLEL_PARENT` must be a script-enabled container + (for example `ReplicatedFirst`). Worker controllers placed in a container + that does not run scripts never initialize, and no casts are simulated. +]] +-- Parallel luau related +local PARALLEL_PARENT +if RunService:IsServer() then + PARALLEL_PARENT = game:GetService("ServerScriptService") +else + PARALLEL_PARENT = game:GetService("ReplicatedFirst") +end +local VM_NAME = "FastCastVM" +local VM_CONTAINER_NAME = "FastCastVMsContainer" +local DISPATCHER_NAME = "FastCastVMs" + local function NewCasterSignals() return { CastFire = Signal.new(), @@ -453,31 +469,17 @@ end Initializes the Caster with the given parameters. This is required before firing using Raycasts in the Caster or nothing will happen! @method Init @within FastCastParallel - - @param numWorkers number -- The number of worker VMs to create for this Caster. Must be greater than 1. - @param newParent Folder -- The Folder in which to place the FastCastVMs Folder - @param newName string -- The name to give the FastCastVMs Folder containing worker scripts. - @param ContainerParent Folder -- The parent Folder in which to place the worker VM Containers. - @param VMContainerName Folder -- The name to give to the Containers housing each worker VM. - @param VMname string -- The name to give each worker VM. - @param useBulkMoveTo boolean -- Whether to enable BulkMoveTo for the [CosmeticBulletObjects](TypeDefinitions#CastRayInfo) + + @param numWorkers number -- Number of workers for parallel luau + @param movementMode "BulkMoveTo" | "Motor6D" -- MovementMode for projectiles @param FastCastEventsModule ModuleScript -- The ModuleScript containing the FastCastEvents, A table of callback functions (events/hooks) used by ActiveCast.. @param useObjectCache boolean -- Whether to use ObjectCache for the [Caster](TypeDefinitions#Caster) @param Template BasePart | Model -- The template object to use for the ObjectCache (if enabled) @param CacheSize number -- The size of the ObjectCache (if enabled) @param CacheHolder Instance -- The Instance in which to place cached objects (if enabled) - - Note: On the client, `ContainerParent` must be a script-enabled container - (for example `ReplicatedFirst`). Worker controllers placed in a container - that does not run scripts never initialize, and no casts are simulated. ]=] function FastCastParallel:Init( numWorkers: number, - newParent: Folder, - newName: string, - ContainerParent: Folder, - VMContainerName: string, - VMname: string, movementMode: "BulkMoveTo" | "Motor6D", FastCastEventsModule: ModuleScript, @@ -494,12 +496,12 @@ function FastCastParallel:Init( assert(numWorkers > 1, "numWorker must be more than 1") local DispatcherClone = DispatcherModule:Clone() - DispatcherClone.Parent = newParent - DispatcherClone.Name = newName or "FastCastVMs" + DispatcherClone.Parent = PARALLEL_PARENT + DispatcherClone.Name = DISPATCHER_NAME local newDispatcher: Dispatcher.Dispatcher = require(DispatcherClone) :: Dispatcher.Dispatcher - newDispatcher.Init(ContainerParent, VMContainerName, VMname) + newDispatcher.Init(PARALLEL_PARENT, VM_CONTAINER_NAME, VM_NAME) local data = { movementMode = movementMode, From b7639faf99f54eb49c2a0bbb8de484f24cfb716e Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:35:56 +0700 Subject: [PATCH 051/140] Update FastCastParallel:Init API --- benchmarks/BenchmarkClient.client.luau | 5 ----- 1 file changed, 5 deletions(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index c59e1855..26335903 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -190,11 +190,6 @@ local function createCaster() newCaster:Init( Config.NumWorkers, - ReplicatedFirst, - Config.VMName, - ReplicatedFirst, - Config.VMContainerName, - Config.VMTemplateName, Config.MovementMode ) else From dfd885ee4141558dd809f33466b3e6dfd6df6504 Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:37:45 +0700 Subject: [PATCH 052/140] Update BenchmarkClient.client.luau --- benchmarks/BenchmarkClient.client.luau | 4 ---- 1 file changed, 4 deletions(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index 26335903..a905cba6 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -50,10 +50,6 @@ local Config = { MovementMode = "BulkMoveTo", - VMName = "CastVMs", - VMContainerName = "CastVMContainer", - VMTemplateName = "CastVM", - HighFidelitySegmentSizeStep = 0.05, MinHighFidelitySegmentSize = 0.05, From de65422a723bd44ec71f5e6e8a499d86a20c803f Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:38:32 +0700 Subject: [PATCH 053/140] Update FastCastParallel:Init API --- benchmarks/BenchmarkServer.server.luau | 9 --------- 1 file changed, 9 deletions(-) diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index 8c797c6e..a1a90811 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -50,10 +50,6 @@ local Config = { BenchmarkDuration = 6, - VMName = "CastVMs", - VMContainerName = "CastVMContainer", - VMTemplateName = "CastVM", - ProjectileStep = 500, WorkerStep = 1, @@ -192,11 +188,6 @@ local function createCaster() caster:Init( Config.NumWorkers, - ServerScriptService, - Config.VMName, - ServerScriptService, - Config.VMContainerName, - Config.VMTemplateName, Config.MovementMode ) From a66d2b103d8a305e9bde20f987d71d717ca2a486 Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:39:40 +0700 Subject: [PATCH 054/140] Update FastCastParallel:Init API --- perfbench/PerfBench.server.luau | 5 ----- 1 file changed, 5 deletions(-) diff --git a/perfbench/PerfBench.server.luau b/perfbench/PerfBench.server.luau index 3f93fce5..2f0d0e80 100644 --- a/perfbench/PerfBench.server.luau +++ b/perfbench/PerfBench.server.luau @@ -88,11 +88,6 @@ local function runCase(castMode: string, projectileCount: number, instanced: boo caster = FastCast.newParallel() caster:Init( WORKERS, - ServerScriptService, - "PerfBenchVMs" .. suffix, - ServerScriptService, - "PerfBenchContainers" .. suffix, - "PerfBenchVM" .. suffix, "BulkMoveTo" ) else From 90c1200f601063c65588d74419c60cd660aaa2df Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:41:12 +0700 Subject: [PATCH 055/140] Update --- perfbench/PerfBench.server.luau | 1 - 1 file changed, 1 deletion(-) diff --git a/perfbench/PerfBench.server.luau b/perfbench/PerfBench.server.luau index 2f0d0e80..19c277f3 100644 --- a/perfbench/PerfBench.server.luau +++ b/perfbench/PerfBench.server.luau @@ -78,7 +78,6 @@ local caseIndex = 0 local function runCase(castMode: string, projectileCount: number, instanced: boolean, movement: boolean) caseIndex += 1 - local suffix = tostring(caseIndex) behavior.CosmeticBulletTemplate = if instanced then cosmeticTemplate else nil behavior.CosmeticBulletContainer = if instanced then cosmeticContainer else nil From 177552ca6e005421155a6378e6a314f18bedb5c9 Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:42:57 +0700 Subject: [PATCH 056/140] Update FastCastParallel:Init API --- tests/src/Benchmarks.luau | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/src/Benchmarks.luau b/tests/src/Benchmarks.luau index f91235e5..6b4c7be3 100644 --- a/tests/src/Benchmarks.luau +++ b/tests/src/Benchmarks.luau @@ -99,15 +99,9 @@ local function createCaster(mode: string, config: any, activeCasts: { any }): an if mode == "Parallel" then parallelCounter += 1 - local suffix = tostring(parallelCounter) caster = FastCast.newParallel() caster:Init( config.parallelWorkers, - ServerScriptService, - "FastCast2BenchmarkVMs" .. suffix, - ServerScriptService, - "FastCast2BenchmarkContainers" .. suffix, - "FastCast2BenchmarkVM" .. suffix, "BulkMoveTo" ) else From 650602ad836245d4ada6c9798c9d8336f8db06a1 Mon Sep 17 00:00:00 2001 From: CK06 Date: Fri, 18 Sep 2026 23:43:33 +0700 Subject: [PATCH 057/140] Update FastCastParallel:Init API --- tests/src/FunctionalTests.luau | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/src/FunctionalTests.luau b/tests/src/FunctionalTests.luau index 4f10a2a2..11d5bf81 100644 --- a/tests/src/FunctionalTests.luau +++ b/tests/src/FunctionalTests.luau @@ -76,7 +76,6 @@ local parallelCounter = 0 local function createParallelCaster(events: { [string]: any }?): any parallelCounter += 1 - local suffix = tostring(parallelCounter) local caster = FastCast.newParallel() for name, fn in events or {} do @@ -85,11 +84,6 @@ local function createParallelCaster(events: { [string]: any }?): any caster:Init( 2, - ServerScriptService, - "FastCast2TestVMs" .. suffix, - ServerScriptService, - "FastCast2TestContainers" .. suffix, - "FastCast2TestVM" .. suffix, "BulkMoveTo" ) From b4044bbbd4c932c6df142e08ab7fea99f6a047ea Mon Sep 17 00:00:00 2001 From: CK06 Date: Sat, 19 Sep 2026 18:33:48 +0700 Subject: [PATCH 058/140] docs: Update FastCastParallel:Init API --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index 7e6603bd..6ba7ce98 100644 --- a/README.md +++ b/README.md @@ -175,11 +175,6 @@ parts to a far-away CFrame via `BulkMoveTo` — no instance creation/destruction local Caster = FastCast2.newParallel() Caster:Init( 4, -- numWorkers - workspace, -- VM folder parent - "FastCastVMs", -- VM folder name - workspace, -- container parent - "VMContainer", -- container name - "VM", -- VM name "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (optional) false -- useObjectCache From 0f2a8bb52f0ca1106369e5e7f700a9fdff7f0175 Mon Sep 17 00:00:00 2001 From: CK06 Date: Sat, 19 Sep 2026 19:55:09 +0700 Subject: [PATCH 059/140] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ba7ce98..d3979300 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ The automated suite runs the functional tests and the six-case benchmark matrix # People behind FastCast2(Contributors) - [CK06](https://github.com/weenachuangkud): Main developer, Maintainer, Graphic designer -- [Naymmmm](https://github.com/Naymmmm): Maintainer (Inactive) +- [Naymmmm](https://github.com/Naymmmm): Maintainer - [EtiTheSpirit](https://github.com/EtiTheSpirit): Original developer - [Per2iako](https://github.com/Per2iako): Maintainer From a0a019adb969d1421692230dc10ed76db3e7e9be Mon Sep 17 00:00:00 2001 From: CK06 Date: Sun, 20 Sep 2026 18:21:33 +0700 Subject: [PATCH 060/140] Update CasterParallel --- src/TypeDefinitions.luau | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index 3e463b1b..cb88459e 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -189,11 +189,6 @@ export type CasterParallel = { Init: ( self: CasterParallel, numWorkers: number, - newParent: Folder, - newName: string, - ContainerParent: Folder, - VMContainerName: string, - VMname: string, MovementMode: "BulkMoveTo" | "Motor6D", FastCastEventsModule: ModuleScript, useObjectCache: boolean, From 2cc70ef4ea13e9c90b8cd61f90c9f5064e2c9562 Mon Sep 17 00:00:00 2001 From: CK06 Date: Sun, 20 Sep 2026 19:34:21 +0700 Subject: [PATCH 061/140] Change rojo version to v7.7.0 --- rokit.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rokit.toml b/rokit.toml index b9715360..c2dcc7f1 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,7 +4,7 @@ # New tools can be added by running `rokit add ` in a terminal. [tools] -rojo = "rojo-rbx/rojo@7.6.1" +rojo = "rojo-rbx/rojo@7.7.0" wally = "UpliftGames/wally@0.3.2" luau-lsp = "JohnnyMorganz/luau-lsp@1.68.0" selene = "Kampfkarren/selene@0.31.0" From f61521bcdae7d6721c41dafb4f745094d238b27b Mon Sep 17 00:00:00 2001 From: CK06 Date: Sun, 20 Sep 2026 23:37:40 +0700 Subject: [PATCH 062/140] Add GitHub Actions workflow to run tests This workflow is triggered manually to run tests using npm after installing Rokit and necessary tools. --- .github/workflows/run-tests.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..686a70f2 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,23 @@ +name: Run tests + +on: + workflow_dispatch: + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rokit + run: | + curl -sSf https://raw.githubusercontent.com/rojo-rbx/rokit/main/scripts/install.sh | sh + echo "$HOME/.rokit/bin" >> "$GITHUB_PATH" + + - name: Install tools + run: rokit install + + - name: Run tests + run: npm test From 19b7f075f1e7598f0d965b897add24013f89aaf2 Mon Sep 17 00:00:00 2001 From: CK06 Date: Sun, 20 Sep 2026 23:50:39 +0700 Subject: [PATCH 063/140] Delete .github/workflows/run-tests.yml --- .github/workflows/run-tests.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 686a70f2..00000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Run tests - -on: - workflow_dispatch: - -jobs: - run: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Rokit - run: | - curl -sSf https://raw.githubusercontent.com/rojo-rbx/rokit/main/scripts/install.sh | sh - echo "$HOME/.rokit/bin" >> "$GITHUB_PATH" - - - name: Install tools - run: rokit install - - - name: Run tests - run: npm test From c79dca6e9a07ce926922715100756298c0e97bb6 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 13:18:36 +0700 Subject: [PATCH 064/140] fix: can't benchmark after first benchmark --- benchmarks/BenchmarkClient.client.luau | 109 ++++++++++++++++++------- 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index a905cba6..971dfea8 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -366,18 +366,17 @@ end local function cleanupCasts() for i = #ActiveCasts, 1, -1 do - task.spawn(function() - local cast = ActiveCasts[i] + local cast = ActiveCasts[i] - if cast then - FastCast:TerminateCast(cast) - end + if cast then + FastCast:TerminateCast(cast) + end - ActiveCasts[i] = nil - end) + ActiveCasts[i] = nil end end + --// Benchmark local function benchmark() @@ -387,42 +386,92 @@ local function benchmark() IsBenchmarking = true - printBenchmarkInfo() - print(("Firing %d projectiles..."):format(Config.ProjectileAmount)) + print("=== BENCHMARK STARTED ===") - local creationStarted = os.clock() - fireProjectiles() - local creationElapsed = os.clock() - creationStarted + local success, err = xpcall(function() - -- Allow the casts to begin processing. - task.wait() + printBenchmarkInfo() + print(("Firing %d projectiles..."):format(Config.ProjectileAmount)) - printSeparator("CREATION COMPLETE") - printOperationStats(creationElapsed, Config.ProjectileAmount) + -- Fire + local creationStarted = os.clock() - table.clear(FrameTimes) - IsMeasuringFrames = true - task.wait(Config.BenchmarkDuration) - IsMeasuringFrames = false + fireProjectiles() + + local creationElapsed = os.clock() - creationStarted + + task.wait() + + printSeparator("CREATION COMPLETE") + printOperationStats( + creationElapsed, + Config.ProjectileAmount + ) + + -- Measure frames + table.clear(FrameTimes) + + IsMeasuringFrames = true + + task.wait(Config.BenchmarkDuration) + + IsMeasuringFrames = false + + printSeparator("SIMULATION COMPLETE") + printFrameStats() + + -- Cleanup + printSeparator("CLEANUP") - printSeparator("SIMULATION COMPLETE") - printFrameStats() + local cleanupStarted = os.clock() - printSeparator("CLEANUP") + cleanupCasts() - local cleanupStarted = os.clock() - cleanupCasts() - local cleanupElapsed = os.clock() - cleanupStarted - printOperationStats(cleanupElapsed, Config.ProjectileAmount) + local cleanupElapsed = os.clock() - cleanupStarted - -- Give FastCast/object cache time to settle. - task.wait(BENCHMARK_COOLDOWN) + printOperationStats( + cleanupElapsed, + Config.ProjectileAmount + ) + + -- Clear our references + table.clear(ActiveCasts) + + -- Wait for FastCast to settle + task.wait(BENCHMARK_COOLDOWN) + + -- IMPORTANT: + -- Destroy the old caster and create a fresh one. + print("Rebuilding caster...") + + rebuildCaster() + + print("Caster rebuilt.") + + printSeparator("DONE") - printSeparator("DONE") + end, debug.traceback) + -- Always reset benchmark state + IsMeasuringFrames = false IsBenchmarking = false + + if not success then + warn("Benchmark error:") + warn(err) + + -- Also recover from an error by creating a fresh caster + task.defer(function() + rebuildCaster() + print("Caster recovered after benchmark error.") + end) + end + + print("IsBenchmarking =", IsBenchmarking) + print("=== BENCHMARK READY ===") end + local function cycleCastType() if Config.CastType == "Raycast" then Config.CastType = "Blockcast" From 56f29c8a9038563bc49d569f3380c52a3a453279 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 13:22:29 +0700 Subject: [PATCH 065/140] Fix: benchmark for server --- benchmarks/BenchmarkServer.server.luau | 115 ++++++++++++++++++------- 1 file changed, 84 insertions(+), 31 deletions(-) diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index a1a90811..b312251e 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -218,18 +218,19 @@ end local function terminateActiveCasts() for i = #ActiveCasts, 1, -1 do - task.spawn(function() - local cast = ActiveCasts[i] + local cast = ActiveCasts[i] - if cast then - FastCast:TerminateCast(cast) - end + if cast then + FastCast:TerminateCast(cast) + end - ActiveCasts[i] = nil - end) + ActiveCasts[i] = nil end + + table.clear(ActiveCasts) end + local function destroyCaster() if not Caster then return @@ -386,45 +387,97 @@ local function runBenchmark() end IsBenchmarking = true + IsMeasuringFrames = false - printBenchmarkInfo() + local success, err = xpcall(function() - print(("Firing %d projectiles..."):format( - Config.ProjectileAmount - )) + printSection("BENCHMARK START") + printBenchmarkInfo() - local creationStarted = os.clock() - fireProjectiles() - local creationElapsed = os.clock() - creationStarted + print(("Firing %d projectiles..."):format( + Config.ProjectileAmount + )) - -- Let the first batch begin processing. - task.wait() + --// Creation + local creationStarted = os.clock() - printSection("CREATION COMPLETE") - printOperationStats(creationElapsed, Config.ProjectileAmount) + fireProjectiles() - table.clear(FrameTimes) - IsMeasuringFrames = true - task.wait(Config.BenchmarkDuration) - IsMeasuringFrames = false + local creationElapsed = os.clock() - creationStarted - printSection("SIMULATION COMPLETE") - printFrameStats() + -- Let Parallel FastCast begin processing. + task.wait() - printSection("CLEANUP") + printSection("CREATION COMPLETE") + printOperationStats( + creationElapsed, + Config.ProjectileAmount + ) - local cleanupStarted = os.clock() - terminateActiveCasts() - local cleanupElapsed = os.clock() - cleanupStarted - printOperationStats(cleanupElapsed, Config.ProjectileAmount) + --// Simulation + table.clear(FrameTimes) + + IsMeasuringFrames = true + + task.wait(Config.BenchmarkDuration) + + IsMeasuringFrames = false + + printSection("SIMULATION COMPLETE") + printFrameStats() + + --// Cleanup + printSection("CLEANUP") + + local cleanupStarted = os.clock() - task.wait(BENCHMARK_COOLDOWN) + terminateActiveCasts() - printSection("DONE") + local cleanupElapsed = os.clock() - cleanupStarted + printOperationStats( + cleanupElapsed, + Config.ProjectileAmount + ) + + -- Give FastCast2's parallel workers time to finish + -- processing the terminated casts. + task.wait(BENCHMARK_COOLDOWN) + + --// IMPORTANT + -- Destroy the old parallel caster and create a + -- completely fresh one for the next benchmark. + print("Rebuilding FastCast caster...") + + rebuildCaster() + + print("FastCast caster rebuilt.") + + printSection("DONE") + + end, debug.traceback) + + -- Always restore benchmark state. + IsMeasuringFrames = false IsBenchmarking = false + + if not success then + warn("FastCast2 benchmark failed:") + warn(err) + + -- Recover the caster if something failed during + -- the benchmark. + pcall(function() + rebuildCaster() + end) + + print("FastCast caster recovered.") + end + + print("Benchmark ready. Type 'p' to run again.") end + local function cycleCastType() if Config.CastType == "Raycast" then Config.CastType = "Blockcast" From 0338d308936a27c714a81ff2aa81e282876f7d65 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 22:31:03 +0700 Subject: [PATCH 066/140] Update benchmarks.project.json to include Packages path Added a new path for Packages in the project configuration. --- benchmarks.project.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/benchmarks.project.json b/benchmarks.project.json index cefa2907..a4d5315c 100644 --- a/benchmarks.project.json +++ b/benchmarks.project.json @@ -5,6 +5,9 @@ "ReplicatedStorage": { "FastCast2": { "$path": "src" + }, + "Packages": { + "$path": "benchmarks/Packages" } }, "ReplicatedFirst": { From 9904973a2a29b535f4a884c932cf53b7b3c5a0bf Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 22:33:18 +0700 Subject: [PATCH 067/140] Add wally.toml to benchmarks for ByteNet --- benchmarks/wally.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 benchmarks/wally.toml diff --git a/benchmarks/wally.toml b/benchmarks/wally.toml new file mode 100644 index 00000000..a2c03b05 --- /dev/null +++ b/benchmarks/wally.toml @@ -0,0 +1,2 @@ +[dependencies] +bytenet = "ffrostflame/bytenet@0.4.6" From 5bf6bb0906bc29722b17b527544fd394f8e0e1c6 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 22:39:24 +0700 Subject: [PATCH 068/140] implement: ByteNet input sender to server for client benchmark --- benchmarks/BenchmarkClient.client.luau | 41 ++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index 971dfea8..ceec779d 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -20,6 +20,7 @@ ] Increase workers I Show benchmark information H Show controls + N Toggle Client / Server input mode ]] --// Services @@ -61,6 +62,7 @@ local Config = { local NUM_WORKERS_CHANGE_COOLDOWN = 4.5 local MODE_CHANGE_COOLDOWN = 2.5 local BENCHMARK_COOLDOWN = 2.5 +local INPUT_MODE_TOGGLE_KEY = Enum.KeyCode.N --// Modules @@ -71,6 +73,10 @@ local FastCastEnums = require( FastCastModule:WaitForChild("FastCastEnums") ) +local BenchmarkPackets = require( + ReplicatedStorage:WaitForChild("BenchmarkPackets") +) + --// Objects local ProjectileContainer = Instance.new("Folder") @@ -123,6 +129,8 @@ local ActiveCasts = {} local IsBenchmarking = false local IsReconfiguring = false +local InputMode = "Client" + --// Frame Statistics local FrameTimes = {} @@ -272,6 +280,7 @@ local function printBenchmarkInfo() print(("MovementMode: %s"):format(Config.MovementMode)) print(("Mode: %s"):format(getMode())) + print(("InputMode: %s"):format(InputMode)) if Config.Parallel then print(("numWorkers: %d"):format(Config.NumWorkers)) @@ -590,6 +599,16 @@ end --// Mode Switching +local function switchInputMode() + if IsBenchmarking or IsReconfiguring then + return + end + + InputMode = if InputMode == "Client" then "Server" else "Client" + + print(("InputMode: %s"):format(InputMode)) +end + local function switchMode() if IsBenchmarking or IsReconfiguring then return @@ -658,6 +677,7 @@ local function showControls() print("> - Increase HighFidelitySegmentSize") print("< - Decrease HighFidelitySegmentSize") print("B - Toggle Serial / Parallel") + print("N - Toggle Client / Server input mode") print("R - Cycle Raycast / Blockcast / Spherecast") print("[ - Decrease numWorkers") print("] - Increase numWorkers") @@ -714,6 +734,7 @@ local Keybinds = { [Enum.KeyCode.B] = switchMode, + [Enum.KeyCode.R] = function() if not IsBenchmarking then cycleCastType() @@ -755,16 +776,32 @@ local Keybinds = { --// Input +local function sendInputToServer(keyCode) + BenchmarkPackets.Input.send(keyCode.Name) +end + UserInputService.InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end + if input.KeyCode == INPUT_MODE_TOGGLE_KEY then + switchInputMode() + return + end + local callback = Keybinds[input.KeyCode] - if callback then - callback() + if not callback then + return end + + if InputMode == "Server" then + sendInputToServer(input.KeyCode) + return + end + + callback() end) --// Startup From 93d64b2eac11505fc90927f685b08a7558d5187a Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 22:41:07 +0700 Subject: [PATCH 069/140] Create BenchmarkPackets.luau --- benchmarks/BenchmarkPackets.luau | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 benchmarks/BenchmarkPackets.luau diff --git a/benchmarks/BenchmarkPackets.luau b/benchmarks/BenchmarkPackets.luau new file mode 100644 index 00000000..4cd33d10 --- /dev/null +++ b/benchmarks/BenchmarkPackets.luau @@ -0,0 +1,17 @@ +--// Services + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +--// Modules + +local ByteNet = require(ReplicatedStorage:WaitForChild("Packages"):WaitForChild("bytenet")) + +--// Packets + +return ByteNet.defineNamespace("FastCast2Benchmarks", function() + return { + Input = ByteNet.definePacket({ + value = ByteNet.string, + }), + } +end) From 63cdddc0f8959897a770f6b531a6b4f739cf8121 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 22:41:31 +0700 Subject: [PATCH 070/140] Add BenchmarkPackets path to benchmarks.project.json --- benchmarks.project.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/benchmarks.project.json b/benchmarks.project.json index a4d5315c..c4e0af73 100644 --- a/benchmarks.project.json +++ b/benchmarks.project.json @@ -8,6 +8,9 @@ }, "Packages": { "$path": "benchmarks/Packages" + }, + "BenchmarkPackets": { + "$path": "benchmarks/BenchmarkPackets.luau" } }, "ReplicatedFirst": { From 31548548a8623bfe4fc505b9ca96e06d09c3e586 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 22:48:32 +0700 Subject: [PATCH 071/140] Refactor chat handling and input mapping --- benchmarks/BenchmarkServer.server.luau | 55 ++++++++++++++++++++------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index b312251e..aba5d284 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -72,6 +72,10 @@ local FastCastEnums = require( FastCastModule:WaitForChild("FastCastEnums") ) +local BenchmarkPackets = require( + ReplicatedStorage:WaitForChild("BenchmarkPackets") +) + --// Projectile Objects local ProjectileContainer = Instance.new("Folder") @@ -749,11 +753,30 @@ local Commands = { h = showHelp, } ---// Chat Handling -local function onChatMessage(_, message) - local command = message:lower():gsub("^%s*(.-)%s*$", "%1") +--// Input Handling + +-- Maps Enum.KeyCode names (sent by the client) to Commands keys. +local KeyNameToCommand = { + P = "p", + L = "l", + K = "k", + U = "u", + J = "j", + Y = "y", + V = "v", + X = "x", + R = "r", + I = "i", + H = "h", + + Period = ">", + Comma = "<", + LeftBracket = "[", + RightBracket = "]", +} +local function runCommand(command) local handler = Commands[command] if handler then @@ -761,17 +784,25 @@ local function onChatMessage(_, message) end end -local function connectPlayer(player) - player.Chatted:Connect(function(message) - onChatMessage(player, message) - end) -end +--// ByteNet (client key input) -Players.PlayerAdded:Connect(connectPlayer) +BenchmarkPackets.Input.listen(function(keyName) + if typeof(keyName) ~= "string" then + return + end -for _, player in Players:GetPlayers() do - connectPlayer(player) -end + -- The server benchmark is parallel-only, so B has no effect here. + if keyName == "B" then + print("Serial / Parallel toggle isn't available on the server (Parallel only).") + return + end + + local command = KeyNameToCommand[keyName] + + if command then + runCommand(command) + end +end) --// Initialization From e01f70cdf0f29b234cb449a57ccfc537235f61e1 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 23:00:37 +0700 Subject: [PATCH 072/140] Add missing [package] --- benchmarks/wally.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/benchmarks/wally.toml b/benchmarks/wally.toml index a2c03b05..a1cb7e98 100644 --- a/benchmarks/wally.toml +++ b/benchmarks/wally.toml @@ -1,2 +1,4 @@ +[package] + [dependencies] bytenet = "ffrostflame/bytenet@0.4.6" From 7de960b2fecc52da29b68266b02d5b65827ecfb2 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 23:02:14 +0700 Subject: [PATCH 073/140] Add missing fields --- benchmarks/wally.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/benchmarks/wally.toml b/benchmarks/wally.toml index a1cb7e98..b90d7e29 100644 --- a/benchmarks/wally.toml +++ b/benchmarks/wally.toml @@ -1,4 +1,9 @@ [package] +name = "your_username/your_project_name" +description = "A short description of your Roblox game or package" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" [dependencies] bytenet = "ffrostflame/bytenet@0.4.6" From 7f57d0060971f418a513100cf3b19808547b22d9 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 23:04:19 +0700 Subject: [PATCH 074/140] fix: wally.toml --- benchmarks/wally.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/wally.toml b/benchmarks/wally.toml index b90d7e29..7c2b8064 100644 --- a/benchmarks/wally.toml +++ b/benchmarks/wally.toml @@ -1,9 +1,9 @@ [package] -name = "your_username/your_project_name" -description = "A short description of your Roblox game or package" +name = "mawin/benchmark" +description = "Some benchmarking for FastCast2" version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -bytenet = "ffrostflame/bytenet@0.4.6" +ByteNet = "ffrostflame/bytenet@0.4.6" From fbeb774d138d93b6cb53395d35c27dddd50eff34 Mon Sep 17 00:00:00 2001 From: CK06 Date: Mon, 21 Sep 2026 23:09:15 +0700 Subject: [PATCH 075/140] Fix: typo --- benchmarks/BenchmarkPackets.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/BenchmarkPackets.luau b/benchmarks/BenchmarkPackets.luau index 4cd33d10..022ebd6a 100644 --- a/benchmarks/BenchmarkPackets.luau +++ b/benchmarks/BenchmarkPackets.luau @@ -4,7 +4,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") --// Modules -local ByteNet = require(ReplicatedStorage:WaitForChild("Packages"):WaitForChild("bytenet")) +local ByteNet = require(ReplicatedStorage:WaitForChild("Packages"):WaitForChild("ByteNet")) --// Packets From bdc095169ba9ac09c8b81bd82e65e9bb625bccb7 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 22 Sep 2026 20:58:00 +0700 Subject: [PATCH 076/140] Update README.md --- README.md | 90 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d3979300..d4ba0212 100644 --- a/README.md +++ b/README.md @@ -47,67 +47,69 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Built-in castVisualization - Built-in ObjectCache - Built-in HighFidelitySegment control -- Flexible and extensible design +- Flexible, extensible, and easy to integrate - High Performance -FastCast2 is an open-source project, and contributions from the community are welcome.
-Read more on [FastCast2 devforum](https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed/4093890) +FastCast2 is an open-source project, and contributions from the community are welcome — see [Contributing](#contributing) below. --- -# Installation guide +## Installation guide -1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and install the `.rbxm` file from the latest release. +### 1. Model file (.rbxm) + +1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and download the `.rbxm` file from the latest release. 2. Open Roblox Studio and open any project. 3. Go to **File → Import Roblox Model** and import the `.rbxm` file. 4. After importing, **FastCast2** will appear in your Workspace. 5. Drag **FastCast2** into **ReplicatedStorage**. -6. Done, and you’re ready to use FastCast2. - ---- - -## Install with [Wally](https://wally.run/) - -1. Make you have installed Wally: https://wally.run/install -2. After you've installed Wally, make sure to `wally init` -3. Then inside `wally.toml` below `[dependencies]` copy and paste `FastCast2 = "weenachuangkud/fastcast2@0.1.4"` -4. Run `wally install`, and you're ready to use - -NOTE: This does not include rojo setup +6. Done — you're ready to use FastCast2. + +### 2. Install with [Wally](https://wally.run/) + +1. Make sure you have [Wally installed](https://wally.run/install). +2. Run `wally init` if you haven't already. +3. Add FastCast2 under `[dependencies]` in your `wally.toml`: +```toml + FastCast2 = "weenachuangkud/fastcast2@0.1.4" +``` + +4. Run `wally install` and you're ready to use it. +> **Note:** This does not include a Rojo setup, you'll need your own `default.project.json` to sync `Packages` into your place. --- -# Code example - -Shooting projectiles from your head (Serial mode - simpler, main thread): - +## Quick start + +Shooting projectiles from your head (Serial mode, simpler, runs on the main thread): + ```lua -- Services local Rep = game:GetService("ReplicatedStorage") local Players = game:GetService("Players") - + -- Requires local FastCast2 = require(Rep:WaitForChild("FastCast2")) local FastCastEnums = require(Rep:WaitForChild("FastCast2"):WaitForChild("FastCastEnums")) - + -- CONSTANTS local SPEED = 500 - + -- Variables local player = Players.LocalPlayer local character = player.Character or player.CharacterAdded:Wait() local Head = character:WaitForChild("Head") local Mouse = player:GetMouse() - + local ProjectileContainer = workspace:WaitForChild("Projectiles") local ProjectileTemplate = Rep:WaitForChild("Projectile") - + -- CastParams local CastParams = RaycastParams.new() CastParams.FilterDescendantsInstances = {character} CastParams.FilterType = Enum.RaycastFilterType.Exclude CastParams.IgnoreWater = true - + -- Behavior (FastCastBehavior) local behavior = FastCast2.newBehavior() behavior.MaxDistance = 1000 @@ -117,24 +119,23 @@ behavior.HighFidelitySegmentSize = 1 behavior.Acceleration = Vector3.new(0, -workspace.Gravity/2.3, 0) behavior.CosmeticBulletContainer = ProjectileContainer behavior.CosmeticBulletTemplate = ProjectileTemplate - - + -- Serial Caster (runs on main thread, simpler) local Caster = FastCast2.new() Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache - + -- Events (can be set before Init) Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + -- Fire local function fire() local origin = Head.Position local direction = (Mouse.Hit.Position - origin).Unit Caster:RaycastFire(origin, direction, SPEED, behavior) end - + -- Input game:GetService("UserInputService").InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end @@ -145,21 +146,21 @@ end) ``` ### Blockcast & Spherecast - -Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change cast type: - + +Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change the cast type: + ```lua -- Blockcast: pass a Vector3 size after origin Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) - + -- Spherecast: pass a number radius after origin Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (Or object pooling) - +### ObjectCache (or object pooling) + ObjectCache reuses cosmetic bullet instances instead of creating/destroying them every shot: - + ```lua local Caster = FastCast2.new() Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) @@ -167,10 +168,10 @@ Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) ``` The cache pre-allocates 500 parts by default, auto-expands when exhausted, and moves retired -parts to a far-away CFrame via `BulkMoveTo` — no instance creation/destruction overhead. +parts to a far-away CFrame via `BulkMoveTo` ### Parallel mode - + ```lua local Caster = FastCast2.newParallel() Caster:Init( @@ -179,24 +180,25 @@ Caster:Init( nil, -- FastCastEventsModule (optional) false -- useObjectCache ) - + -- Events work the same as serial Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + --[[ -- Except: -- Caster.CanPierce --> You will have to use FastCastEventsModule for that ]] - + -- Fire the same as serial Caster:RaycastFire(origin, direction, SPEED, behavior) Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` +
How to set up [FastCastEventsModule](https://weenachuangkud.github.io/FastCast2/api/TypeDefinitions/#FastCastEventsModule) From a63c1091f13a824c84c7aa73b79cea86e98d7019 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 22 Sep 2026 21:02:00 +0700 Subject: [PATCH 077/140] Update README.md --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index d4ba0212..c9eeca0c 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,19 @@ FastCast2:TerminateCast(cast) ### -> Get started with the [FastCast2 documentation](https://weenachuangkud.github.io/FastCast2/docs/api-reference) +--- + +## Contributing + +FastCast2 is community-driven, and contributions of all kinds are welcome, code, documentation, bug reports, examples, or ideas. + +1. Found a bug or have an idea? Open an [Issue](https://github.com/weenachuangkud/FastCast2/issues) first. +2. Want to submit code or docs? Fork the repo, branch off `main`, make your changes, and open a Pull Request. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide and [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for community expectations. + +
+ + ## Benchmarks FastCast2 includes repeatable client and server benchmarks for raycasts, blockcasts, and spherecasts. They compare serial and parallel execution using creation/cleanup throughput and simulation frame-time percentiles. See the [benchmark guide](benchmarks/README.md) for controls and the ready-to-sync Rojo project. From 36d31c547fa848f4d0083675dbe0089f9555604b Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 22 Sep 2026 21:05:03 +0700 Subject: [PATCH 078/140] Fix README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9eeca0c..44e39b9e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Built-in castVisualization - Built-in ObjectCache - Built-in HighFidelitySegment control -- Flexible, extensible, and easy to integrate +- Flexible, extensible - High Performance FastCast2 is an open-source project, and contributions from the community are welcome — see [Contributing](#contributing) below. From f240df93ce2ad76418ba7e48555266995a575d89 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 22 Sep 2026 21:07:51 +0700 Subject: [PATCH 079/140] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b822387..322c9100 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,6 @@ This project is open-source and community-driven, and **all kinds of help are we - Commit your changes - Push to your fork - Open a **Pull Request** against the `main` branch - - You must provide **RBXM** file to the author for release (Optional)
- > (Only if the author aka **Mawin_CK** himself requested you to provide **RBXM** for the release) All pull requests will go through **code review** before merging. From aa9f6667a9c16cf0c3f43516e56463352e699ae3 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 22 Sep 2026 21:12:32 +0700 Subject: [PATCH 080/140] Update intro.md --- docs/intro.md | 123 ++++++++++++++++++++++++++++---------------------- 1 file changed, 70 insertions(+), 53 deletions(-) diff --git a/docs/intro.md b/docs/intro.md index 93bc05a1..22ae2d69 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -40,7 +40,7 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith --- -# Benefits of using FastCast2 +# Features - Highly customizable - Versatile projectile library @@ -51,69 +51,69 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Built-in castVisualization - Built-in ObjectCache - Built-in HighFidelitySegment control -- Flexible and extensible design -- Improves development productivity +- Flexible, extensible - High Performance -- Completely free -FastCast2 is an open-source project, and contributions from the community are welcome.
-Read more on [FastCast2 devforum](https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed/4093890) +FastCast2 is an open-source project, and contributions from the community are welcome — see [Contributing](#contributing) below. --- -# Installation guide +## Installation guide -1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and install the `.rbxm` file from the latest release. +### 1. Model file (.rbxm) + +1. Go to [Releases](https://github.com/weenachuangkud/FastCast2/releases) and download the `.rbxm` file from the latest release. 2. Open Roblox Studio and open any project. 3. Go to **File → Import Roblox Model** and import the `.rbxm` file. 4. After importing, **FastCast2** will appear in your Workspace. 5. Drag **FastCast2** into **ReplicatedStorage**. -6. Done, and you’re ready to use FastCast2. - ---- - -## Install with [Wally](https://wally.run/) - -1. Make you have installed Wally: https://wally.run/install -2. After you've installed Wally, make sure to `wally init` -3. Then inside `wally.toml` below `[dependencies]` copy and paste `FastCast2 = "weenachuangkud/fastcast2@0.1.4"` -4. Run `wally install`, and you're ready to use - -NOTE: This does not include rojo setup +6. Done — you're ready to use FastCast2. + +### 2. Install with [Wally](https://wally.run/) + +1. Make sure you have [Wally installed](https://wally.run/install). +2. Run `wally init` if you haven't already. +3. Add FastCast2 under `[dependencies]` in your `wally.toml`: +```toml + FastCast2 = "weenachuangkud/fastcast2@0.1.4" +``` + +4. Run `wally install` and you're ready to use it. +> **Note:** This does not include a Rojo setup, you'll need your own `default.project.json` to sync `Packages` into your place. --- -# Code example - -Shooting projectiles from your head (Serial mode - simpler, main thread): - +## Quick start + +Shooting projectiles from your head (Serial mode, simpler, runs on the main thread): + ```lua -- Services local Rep = game:GetService("ReplicatedStorage") local Players = game:GetService("Players") - + -- Requires local FastCast2 = require(Rep:WaitForChild("FastCast2")) local FastCastEnums = require(Rep:WaitForChild("FastCast2"):WaitForChild("FastCastEnums")) - + -- CONSTANTS local SPEED = 500 - + -- Variables local player = Players.LocalPlayer local character = player.Character or player.CharacterAdded:Wait() local Head = character:WaitForChild("Head") local Mouse = player:GetMouse() - + local ProjectileContainer = workspace:WaitForChild("Projectiles") local ProjectileTemplate = Rep:WaitForChild("Projectile") - + -- CastParams local CastParams = RaycastParams.new() CastParams.FilterDescendantsInstances = {character} CastParams.FilterType = Enum.RaycastFilterType.Exclude CastParams.IgnoreWater = true - + -- Behavior (FastCastBehavior) local behavior = FastCast2.newBehavior() behavior.MaxDistance = 1000 @@ -123,24 +123,23 @@ behavior.HighFidelitySegmentSize = 1 behavior.Acceleration = Vector3.new(0, -workspace.Gravity/2.3, 0) behavior.CosmeticBulletContainer = ProjectileContainer behavior.CosmeticBulletTemplate = ProjectileTemplate - - + -- Serial Caster (runs on main thread, simpler) local Caster = FastCast2.new() Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache - + -- Events (can be set before Init) Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + -- Fire local function fire() local origin = Head.Position local direction = (Mouse.Hit.Position - origin).Unit Caster:RaycastFire(origin, direction, SPEED, behavior) end - + -- Input game:GetService("UserInputService").InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end @@ -151,21 +150,21 @@ end) ``` ### Blockcast & Spherecast - -Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change cast type: - + +Swap `RaycastFire` for `BlockcastFire` or `SpherecastFire` to change the cast type: + ```lua -- Blockcast: pass a Vector3 size after origin Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) - + -- Spherecast: pass a number radius after origin Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (Or object pooling) - +### ObjectCache (or object pooling) + ObjectCache reuses cosmetic bullet instances instead of creating/destroying them every shot: - + ```lua local Caster = FastCast2.new() Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) @@ -173,41 +172,37 @@ Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) ``` The cache pre-allocates 500 parts by default, auto-expands when exhausted, and moves retired -parts to a far-away CFrame via `BulkMoveTo` — no instance creation/destruction overhead. +parts to a far-away CFrame via `BulkMoveTo` ### Parallel mode - + ```lua local Caster = FastCast2.newParallel() Caster:Init( 4, -- numWorkers - workspace, -- VM folder parent - "FastCastVMs", -- VM folder name - workspace, -- container parent - "VMContainer", -- container name - "VM", -- VM name "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (optional) false -- useObjectCache ) - + -- Events work the same as serial Caster.Hit = function(cast, result, velocity, bullet) print("Hit: " .. result.Instance.Name) end - + --[[ -- Except: -- Caster.CanPierce --> You will have to use FastCastEventsModule for that ]] - + -- Fire the same as serial Caster:RaycastFire(origin, direction, SPEED, behavior) Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` +
How to set up [FastCastEventsModule](https://weenachuangkud.github.io/FastCast2/api/TypeDefinitions/#FastCastEventsModule) @@ -322,11 +317,33 @@ FastCast2:TerminateCast(cast) ### -> Get started with the [FastCast2 documentation](https://weenachuangkud.github.io/FastCast2/docs/api-reference) +--- + +## Contributing + +FastCast2 is community-driven, and contributions of all kinds are welcome, code, documentation, bug reports, examples, or ideas. + +1. Found a bug or have an idea? Open an [Issue](https://github.com/weenachuangkud/FastCast2/issues) first. +2. Want to submit code or docs? Fork the repo, branch off `main`, make your changes, and open a Pull Request. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide and [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for community expectations. + +
+ + +## Benchmarks + +FastCast2 includes repeatable client and server benchmarks for raycasts, blockcasts, and spherecasts. They compare serial and parallel execution using creation/cleanup throughput and simulation frame-time percentiles. See the [benchmark guide](benchmarks/README.md) for controls and the ready-to-sync Rojo project. + +## Tests + +The automated suite runs the functional tests and the six-case benchmark matrix inside Roblox Studio, prints a Markdown results table, and uploads the report to [paste.shellworks.dev](https://paste.shellworks.dev). Run it with `npm test` (or `lune run tests/run.luau`). See the [test guide](tests/README.md) for prerequisites and flags. + + --- # People behind FastCast2(Contributors) - [CK06](https://github.com/weenachuangkud): Main developer, Maintainer, Graphic designer -- [Naymmmm](https://github.com/Naymmmm): Maintainer (Inactive) +- [Naymmmm](https://github.com/Naymmmm): Maintainer - [EtiTheSpirit](https://github.com/EtiTheSpirit): Original developer - [Per2iako](https://github.com/Per2iako): Maintainer From 916ed21ddca8c0139276d68e8195bf59e01ea877 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 23 Sep 2026 12:35:15 +0700 Subject: [PATCH 081/140] Update FastCastParallel:Init API --- docs/cheatsheet.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 41951c8b..d9ce19a1 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -77,11 +77,6 @@ local Caster = FastCast2.newParallel() Caster:Init( numWorkers: number, -- Number of Actor VMs. Must be > 1. - newParent: Folder, -- Parent for the FastCastVMs Folder. - newName: string, -- Name for the FastCastVMs Folder. - ContainerParent: Folder, -- Parent for worker VM Containers. - VMContainerName: string, -- Name for VM Containers. - VMname: string, -- Name given to each worker VM. movementMode: "BulkMoveTo" | "Motor6D", -- Movement method. FastCastEventsModule: ModuleScript?,-- ModuleScript returning a FastCastEvents table. useObjectCache: boolean, -- Enable ObjectCache for this Caster. From 2af3e65ae79538b3dc62cf17264a1e61087fedd0 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 23 Sep 2026 12:40:48 +0700 Subject: [PATCH 082/140] Fix parallel ObjectCache and FastCastEventsModule handling --- src/BaseCastParallel.luau | 245 ++++++++------------------------------ 1 file changed, 51 insertions(+), 194 deletions(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index 1f89c9f5..63e0e126 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -1,4 +1,4 @@ ---[[ +--[[]] - Author : Mawin CK - Date : 2025 @@ -56,7 +56,6 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT castTerminatingFunction((cast :: any)) end - -- Inline cleanup (we're inside the Actor) if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then if ObjectCacheInstance then ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) @@ -70,7 +69,6 @@ end local BaseCastInitialized = false local BaseCastInstance = nil function BaseCast.Init(BindableOutput: BindableEvent, Data: any) - if BaseCastInitialized then return BaseCastInstance end @@ -82,25 +80,20 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) self.Actor = Actor Output = BindableOutput - -- Signals the dispatcher that this actor's controller has initialized. - -- The dispatcher retries "Init" until every actor sets this, which matters - -- on the client where a controller script can start a few frames late. Actor:SetAttribute("FastCast2Ready", true) if Data.useObjectCache then local objectCacheArgs = Data.objectCacheArgs or {} - if not objectCacheArgs.CacheSize then - objectCacheArgs.CacheSize = DEFAULT_CACHE_SIZE - end - - if not objectCacheArgs.CacheHolder then - objectCacheArgs.CacheHolder = DEFAULT_CACHE_HOLDER + if not objectCacheArgs.Template then + error("Template must be provided when enabling ObjectCache.") end - + objectCacheArgs.CacheSize = objectCacheArgs.CacheSize or DEFAULT_CACHE_SIZE + objectCacheArgs.CacheHolder = objectCacheArgs.CacheHolder or DEFAULT_CACHE_HOLDER ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any end self.ObjectCacheInstance = ObjectCacheInstance + self.CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder or nil CurrentMovementMode = Data.movementMode or "BulkMoveTo" if CurrentMovementMode == "Motor6D" then @@ -130,7 +123,6 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) SyncChangesConnection = SyncChanges.Event:Connect(function(cast: TypeDef.ActiveCastData) local ID = cast.ID local TargetCast = self.Actives[ID] - if TargetCast then for i, v in cast do if i == "StateInfo" and type(v) == "table" and type(TargetCast[i]) == "table" then @@ -151,49 +143,25 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) end) ParallelSimulation.Init(self) - ParallelSimulation.SetMovementModeEnabled(true, CurrentMovementMode) - ParallelSimulation.Start() BaseCastInstance = self return self end ---[=[ - -@method Raycast -@within BaseCast - -@param Origin Vector3 -- The origin of the raycast. -@param Direction Vector3 -- The direction of the raycast. -@param Velocity Vector3 | number -- The velocity of the raycast. -@param Behavior FastCastBehavior -- The behavior data for the raycast. -@param GUID string -- The unique identifier for the raycast. - -Create a raycast. - -]=] -function BaseCast:Raycast( - Origin: Vector3, - Direction: Vector3, - Velocity: Vector3 | number, - Behavior: TypeDef.FastCastBehavior -) +function BaseCast:Raycast(Origin: Vector3, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior) NextProjectileID += 1 - local cast = ActiveCast.createCastData({ Output = Output, Actor = Actor, SyncChange = SyncChanges, - CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder + CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Raycast - } :: any, ObjectCacheInstance, true) - + } :: any, self.ObjectCacheInstance, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire then SendCastFire(cast, Origin, Direction, Velocity, Behavior) end @@ -202,62 +170,37 @@ function BaseCast:Raycast( end end ---[=[ - -@method SetFastCastEventsModule -@within BaseCast - -@param moduleScript ModuleScript -- The FastCastEventsModule to set. +function BaseCast:SetFastCastEventsModule(moduleScript: ModuleScript?) + if moduleScript ~= nil and (typeof(moduleScript) ~= "Instance" or not moduleScript:IsA("ModuleScript")) then + error("FastCastEventsModule must be a ModuleScript") + end -]=] -function BaseCast:SetFastCastEventsModule(moduleScript: ModuleScript) FastCastEventsModule = moduleScript - if moduleScript and typeof(moduleScript) == "Instance" and moduleScript:IsA("ModuleScript") then - CastFireFunc = require(moduleScript) - if CastFireFunc.CastFire then - CastFireFunc = CastFireFunc.CastFire - else - CastFireFunc = nil + CastFireFunc = nil + if moduleScript then + local events = require(moduleScript) + if type(events) ~= "table" then + error("FastCastEventsModule must return a table") + end + if type(events.CastFire) == "function" then + CastFireFunc = events.CastFire end end end ---[=[ - -@method Blockcast -@within BaseCast - -@param Origin Vector3 -- The origin of the blockcast. -@param Size Vector3 -- The size of the blockcast. -@param Direction Vector3 -- The direction of the blockcast. -@param Velocity Vector3 | number -- The velocity of the blockcast. -@param Behavior FastCastBehavior -- The behavior data for the blockcast. - -Create a Blockcast. - -]=] -function BaseCast:Blockcast( - Origin: Vector3, - Size: Vector3, - Direction: Vector3, - Velocity: Vector3 | number, - Behavior: TypeDef.FastCastBehavior -) +function BaseCast:Blockcast(Origin: Vector3, Size: Vector3, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior) NextProjectileID += 1 - local cast = ActiveCast.createCastData({ Output = Output, Actor = Actor, SyncChange = SyncChanges, - CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder + CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Blockcast, Size = Size - } :: any, ObjectCacheInstance, true) - + } :: any, self.ObjectCacheInstance, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire then SendCastFire(cast, Origin, Direction, Velocity, Behavior) end @@ -266,42 +209,19 @@ function BaseCast:Blockcast( end end ---[=[ - -@method Spherecast -@within BaseCast - -@param Origin Vector3 -- The origin of the spherecast. -@param Radius number -- The radius of the spherecast. -@param Direction Vector3 -- The direction of the spherecast. -@param Velocity Vector3 | number -- The velocity of the spherecast. -@param Behavior FastCastBehavior -- The behavior data for the spherecast. - -Create a Spherecast. - -]=] -function BaseCast:Spherecast( - Origin: Vector3, - Radius: number, - Direction: Vector3, - Velocity: Vector3 | number, - Behavior: TypeDef.FastCastBehavior -) +function BaseCast:Spherecast(Origin: Vector3, Radius: number, Direction: Vector3, Velocity: Vector3 | number, Behavior: TypeDef.FastCastBehavior) NextProjectileID += 1 - local cast = ActiveCast.createCastData({ Output = Output, Actor = Actor, SyncChange = SyncChanges, - CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder + CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Spherecast, Radius = Radius - } :: any, ObjectCacheInstance, true) - + } :: any, self.ObjectCacheInstance, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast - if Behavior.FastCastEventsConfig.UseCastFire then SendCastFire(cast, Origin, Direction, Velocity, Behavior) end @@ -310,117 +230,54 @@ function BaseCast:Spherecast( end end ---[=[ - @method SetMovementModeEnabled - @within BaseCast - - @param mode "BulkMoveTo" | "Motor6D" -- The movement mode to set. - @param enabled boolean -- Whether to enable or disable the movement mode. - - Sets the movement mode for the casts. This determines how the cast's position is updated during simulation. - -]=] function BaseCast:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") CurrentMovementMode = mode - if mode == "Motor6D" and enabled then - if not Motor6DCacheInstance then - Motor6DCacheInstance = Motor6DCache.new() - end - else - if Motor6DCacheInstance then - Motor6DCacheInstance:Destroy() - Motor6DCacheInstance = nil - end + if not Motor6DCacheInstance then Motor6DCacheInstance = Motor6DCache.new() end + else + if Motor6DCacheInstance then Motor6DCacheInstance:Destroy(); Motor6DCacheInstance = nil end end - ParallelSimulation.SetMovementModeEnabled(enabled, mode) end -function BaseCast:BindObjectCache( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) +function BaseCast:BindObjectCache(enabled: boolean, Template: BasePart | Model?, CacheSize: number?, CacheHolder: Instance?) if enabled then - if ObjectCacheInstance then - return - end - - if not Template then - error("Template must be provided when enabling ObjectCache.") - end - - if not CacheSize then - CacheSize = DEFAULT_CACHE_SIZE - end - - if not CacheHolder then - CacheHolder = DEFAULT_CACHE_HOLDER - end - ObjectCacheInstance = ObjectCache.new(Template, CacheSize, CacheHolder) + if self.ObjectCacheInstance then return end + if not Template then error("Template must be provided when enabling ObjectCache.") end + local cache = ObjectCache.new(Template, CacheSize or DEFAULT_CACHE_SIZE, CacheHolder or DEFAULT_CACHE_HOLDER) + self.ObjectCacheInstance = cache + ObjectCacheInstance = cache + self.CacheHolder = cache.CacheHolder else - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - ObjectCacheInstance = nil - end + if self.ObjectCacheInstance then self.ObjectCacheInstance:Destroy() end + self.ObjectCacheInstance = nil + self.CacheHolder = nil + ObjectCacheInstance = nil end end ---[=[ - -@method Destroy -@within BaseCast - -Destroys the BaseCast instance and cleans up resources. - -]=] function BaseCast:Destroy() - if ParallelSimulation then - ParallelSimulation.Stop() - end - - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - end - - if Motor6DCacheInstance then - Motor6DCacheInstance:Destroy() - end - + if ParallelSimulation then ParallelSimulation.Stop() end + if self.ObjectCacheInstance then self.ObjectCacheInstance:Destroy() end + if Motor6DCacheInstance then Motor6DCacheInstance:Destroy() end FastCastEventsModule = nil - - if SyncChangesConnection then - SyncChangesConnection:Disconnect() - SyncChangesConnection = nil - end - + CastFireFunc = nil + ObjectCacheInstance = nil + if SyncChangesConnection then SyncChangesConnection:Disconnect(); SyncChangesConnection = nil end local activeList = {} - for _, v in self.Actives do - table.insert(activeList, v) - end - for _, v in activeList do - TerminateCast(v) - end - + for _, v in self.Actives do table.insert(activeList, v) end + for _, v in activeList do TerminateCast(v) end self.Actives = {} setmetatable(self, nil) end --- Motor6D - function BaseCast:_GetMotor6D(projectilePart: BasePart?) - if Motor6DCacheInstance and projectilePart then - return Motor6DCacheInstance:Connect(projectilePart) - end + if Motor6DCacheInstance and projectilePart then return Motor6DCacheInstance:Connect(projectilePart) end return nil end function BaseCast:_ReturnMotor6D(motor6d: Motor6D?) - if Motor6DCacheInstance then - Motor6DCacheInstance:Disconnect(motor6d) - end + if Motor6DCacheInstance then Motor6DCacheInstance:Disconnect(motor6d) end end return BaseCast From 3215d53773f8f84d228bec1c95a3c57c33cc2d69 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 23 Sep 2026 12:46:50 +0700 Subject: [PATCH 083/140] Fix: comment error --- src/BaseCastParallel.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index 63e0e126..f2e464a8 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -1,4 +1,4 @@ ---[[]] +--[[ - Author : Mawin CK - Date : 2025 From 42ebb81aee866553b10867ab13fa5a7ef0db61ee Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 23 Sep 2026 12:48:07 +0700 Subject: [PATCH 084/140] Fix serial CastFire and ObjectCache handling --- src/BaseCastSerial.luau | 186 +++++++++++++--------------------------- 1 file changed, 58 insertions(+), 128 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 07e19d37..9aff8ef7 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -1,4 +1,4 @@ ---[[ +--[[]] - Author : Mawin CK - Date : 2025 @@ -15,19 +15,12 @@ local Motor6DCache = require(FastCast2:WaitForChild("Motor6DCache")) local EnumCastTypes = FastCastEnums.CastType ---[=[ - @class BaseCast - - BaseCast is the underlying class that manages cast simulation, lifecycle, and movement modes. - It is used internally by FastCastSerial and FastCastParallel. -]=] local BaseCast = {} local DEFAULT_CACHE_SIZE = 500 local DEFAULT_CACHE_HOLDER = workspace local NextProjectileID = 0 - local Actives = {} local CastFirefn = nil local Motor6DCacheInstance = nil @@ -37,22 +30,24 @@ local CurrentMovementMode = "BulkMoveTo" function BaseCast.Init(events: any, Data: any) BaseCast.Destroy() - CastFirefn = events.CastFire + CastFirefn = if type(events.CastFire) == "function" then events.CastFire else nil Motor6DCacheInstance = nil ObjectCacheInstance = nil + BaseCast.CacheHolder = nil CurrentMovementMode = "BulkMoveTo" + Data = Data or {} if Data.useObjectCache then local objectCacheArgs = Data.objectCacheArgs or {} - if not objectCacheArgs.CacheSize then - objectCacheArgs.CacheSize = DEFAULT_CACHE_SIZE - end - - if not objectCacheArgs.CacheHolder then - objectCacheArgs.CacheHolder = DEFAULT_CACHE_HOLDER + local template = objectCacheArgs.Template + if not template then + error("Template must be provided when enabling ObjectCache.") end - ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any + local cacheSize = objectCacheArgs.CacheSize or DEFAULT_CACHE_SIZE + local cacheHolder = objectCacheArgs.CacheHolder or DEFAULT_CACHE_HOLDER + ObjectCacheInstance = ObjectCache.new(template, cacheSize, cacheHolder) :: any + BaseCast.CacheHolder = ObjectCacheInstance.CacheHolder end CurrentMovementMode = Data.movementMode or "BulkMoveTo" @@ -61,26 +56,16 @@ function BaseCast.Init(events: any, Data: any) end SerialSimulation.Init(BaseCast, Actives, events) - SerialSimulation.SetMovementModeEnabled(true, CurrentMovementMode) - SerialSimulation.Start() end ---[=[ - -@method Raycast -@within BaseCast - -@param Origin Vector3 -- The origin of the raycast. -@param Direction Vector3 -- The direction of the raycast. -@param Velocity Vector3 | number -- The velocity of the raycast. -@param Behavior FastCastBehavior -- The behavior data for the raycast. -@param GUID string -- The unique identifier for the raycast. - -Create a raycast. +local function FireCastFire(cast, origin, direction, velocity, behavior) + if behavior.FastCastEventsConfig.UseCastFire and CastFirefn then + CastFirefn(cast, origin, direction, velocity, behavior) + end +end -]=] function BaseCast.Raycast( Origin: Vector3, Direction: Vector3, @@ -94,26 +79,9 @@ function BaseCast.Raycast( SerialSimulation.Register(cast) Actives[cast.ID] = cast - - if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn:Fire(cast, Origin, Direction, Velocity, Behavior) - end + FireCastFire(cast, Origin, Direction, Velocity, Behavior) end ---[=[ - -@method Blockcast -@within BaseCast - -@param Origin Vector3 -- The origin of the raycast. -@param Size Vector3 -- The size of the raycast. -@param Direction Vector3 -- The direction of the raycast. -@param Velocity Vector3 | number -- The velocity of the raycast. -@param Behavior FastCastBehavior -- The behavior data for the raycast. - -Create a Blockcast. - -]=] function BaseCast.Blockcast( Origin: Vector3, Size: Vector3, @@ -122,7 +90,6 @@ function BaseCast.Blockcast( Behavior: TypeDef.FastCastBehavior ) NextProjectileID += 1 - local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Blockcast, Size = Size @@ -130,26 +97,9 @@ function BaseCast.Blockcast( SerialSimulation.Register(cast) Actives[cast.ID] = cast - - if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn:Fire(cast, Origin, Direction, Velocity, Behavior) - end + FireCastFire(cast, Origin, Direction, Velocity, Behavior) end ---[=[ - -@method Spherecast -@within BaseCast - -@param Origin Vector3 -- The origin of the spherecast. -@param Radius number -- The radius of the spherecast. -@param Direction Vector3 -- The direction of the spherecast. -@param Velocity Vector3 | number -- The velocity of the spherecast. -@param Behavior FastCastBehavior -- The behavior data for the spherecast. - -Create a Spherecast. - -]=] function BaseCast.Spherecast( Origin: Vector3, Radius: number, @@ -158,7 +108,6 @@ function BaseCast.Spherecast( Behavior: TypeDef.FastCastBehavior ) NextProjectileID += 1 - local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Spherecast, Radius = Radius @@ -166,23 +115,9 @@ function BaseCast.Spherecast( SerialSimulation.Register(cast) Actives[cast.ID] = cast - - if Behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn:Fire(cast, Origin, Direction, Velocity, Behavior) - end + FireCastFire(cast, Origin, Direction, Velocity, Behavior) end ---[=[ - -@method SetMovementModeEnabled -@within BaseCast - - @param mode "BulkMoveTo" | "Motor6D" -- The movement mode to set. - @param enabled boolean -- Whether to enable or disable the movement mode. - - Sets the movement mode for the casts. This determines how the cast's position is updated during simulation. - -]=] function BaseCast.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") CurrentMovementMode = mode @@ -190,7 +125,7 @@ function BaseCast.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | if not Motor6DCacheInstance then Motor6DCacheInstance = Motor6DCache.new() end - else + else if Motor6DCacheInstance then Motor6DCacheInstance:Destroy() Motor6DCacheInstance = nil @@ -202,7 +137,7 @@ end function BaseCast.BindObjectCache( enabled: boolean, - Template: BasePart?, + Template: BasePart | Model?, CacheSize: number?, CacheHolder: Instance? ) @@ -210,44 +145,28 @@ function BaseCast.BindObjectCache( if ObjectCacheInstance then return end - if not Template then error("Template must be provided when enabling ObjectCache.") end - if not CacheSize then - CacheSize = DEFAULT_CACHE_SIZE - end - - if not CacheHolder then - CacheHolder = DEFAULT_CACHE_HOLDER - end - ObjectCacheInstance = ObjectCache.new(Template, CacheSize, CacheHolder) + ObjectCacheInstance = ObjectCache.new( + Template, + CacheSize or DEFAULT_CACHE_SIZE, + CacheHolder or DEFAULT_CACHE_HOLDER + ) + BaseCast.CacheHolder = ObjectCacheInstance.CacheHolder else if ObjectCacheInstance then ObjectCacheInstance:Destroy() - ObjectCacheInstance = nil end + ObjectCacheInstance = nil + BaseCast.CacheHolder = nil end end ---[=[ - -@method Destroy -@within BaseCast - -Destroys the BaseCast instance and cleans up resources. - -]=] function BaseCast.Destroy() - SerialSimulation.Stop() - - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - end - - if Motor6DCacheInstance then - Motor6DCacheInstance:Destroy() + if SerialSimulation then + SerialSimulation.Stop() end local castIDs = {} @@ -257,28 +176,39 @@ function BaseCast.Destroy() for _, castID in castIDs do BaseCast._TerminateCast(castID) end - Actives = {} -end --- Utils + if ObjectCacheInstance then + ObjectCacheInstance:Destroy() + ObjectCacheInstance = nil + end + BaseCast.CacheHolder = nil + + if Motor6DCacheInstance then + Motor6DCacheInstance:Destroy() + Motor6DCacheInstance = nil + end + + CastFirefn = nil +end function BaseCast._TerminateCast(castID: number) - if Actives[castID] then - local cast = Actives[castID] - - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if ObjectCacheInstance then - ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - cast.RayInfo.CosmeticBulletObject = nil - end - end + local cast = Actives[castID] + if not cast then + return + end - table.clear(cast :: any) - Actives[castID] = nil + if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then + if ObjectCacheInstance then + ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) + else + cast.RayInfo.CosmeticBulletObject:Destroy() + cast.RayInfo.CosmeticBulletObject = nil + end end + + table.clear(cast :: any) + Actives[castID] = nil end function BaseCast._GetMotor6D(projectilePart: BasePart?) From 4ab899fdc7de7635313b16c3a4b271dc12ccd63c Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 23 Sep 2026 12:48:38 +0700 Subject: [PATCH 085/140] Fix: comment error --- src/BaseCastSerial.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 9aff8ef7..7e5868cf 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -1,4 +1,4 @@ ---[[]] +--[[ - Author : Mawin CK - Date : 2025 From 4d23fd5e1ccb9f5bf45d63115714a33553d6c623 Mon Sep 17 00:00:00 2001 From: CK06 Date: Thu, 24 Sep 2026 22:26:52 +0700 Subject: [PATCH 086/140] Remove useless comment --- src/ActiveCast.luau | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index d072f6d3..a8fcebf9 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -1,9 +1,6 @@ --[[ - Author : Mawin CK - Date : 2025 - - - ActiveCastSerial - Serial mode with single RunService, SoA pattern, queue technique ]] local FastCastModule = script.Parent From 613c97f3913a91832bedcf76192aaa768a6459e8 Mon Sep 17 00:00:00 2001 From: CK06 Date: Thu, 24 Sep 2026 22:36:32 +0700 Subject: [PATCH 087/140] Fix comments --- src/init.luau | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/init.luau b/src/init.luau index f679de8c..f799d9c6 100644 --- a/src/init.luau +++ b/src/init.luau @@ -13,7 +13,7 @@ YOU SHOULD ONLY CREATE ONE CASTER PER GUN. YOU SHOULD >>>NEVER<<< CREATE A NEW CASTER EVERY TIME THE GUN NEEDS TO BE FIRED. - A caster (created with FastCast.new() or FastCastParallel.new()) represents a "gun". + A caster (created with FastCast.new()) represents a "gun". When you consider a gun, you think of stats like accuracy, bullet speed, etc. This is the info a caster stores. -- @@ -31,7 +31,7 @@ Unfortunately, while reliable in terms of saying if something got hit or not, this method alone cannot be used if you wish to implement bullet travel time into a weapon. As a result of that, I made this library - an excellent remedy to this dilemma. - FastCastParallel is intended to be require()'d once in a script, as you can create as many casters as you need with FastCastParallel.new() + FastCast is intended to be require()'d once in a script, as you can create as many casters as you need with FastCast.new() This is generally handy since you can store settings and information in these casters, and even send them out to other scripts via events for use. @@ -39,11 +39,18 @@ Make the caster once, then use the caster to fire your bullets. Do not make a caster for each bullet. --]] --- Mozilla Public License 2.0 (files originally from FastCastParallel) +-- Mozilla Public License 2.0 (files originally from FastCast) --[[ - Modified by: Mawin CK - Date : 2025 + + NOTE: + For anyone who's reading this is FastCast2, An improved version of FastCast with Parallel scripting, more extensions, and statically typed. +A powerful modern projectile library + + Because FastCast is no longer actively maintained by EtiTheSpirit, this repository continues the project with updates and adaptations. + - Devforum: https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed-a-powerful-modern-projectile-library/4093890 ]] From 9d8633a8b786cb84017663228dd7d916f7b64b4b Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 15:42:07 +0700 Subject: [PATCH 088/140] Remove ObjectCache --- src/init.luau | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/src/init.luau b/src/init.luau index f799d9c6..7ab7cce7 100644 --- a/src/init.luau +++ b/src/init.luau @@ -686,34 +686,6 @@ function FastCastParallel:SetMovementModeEnabled(enabled: boolean, mode: "BulkMo self.MovementMode = mode end ---[=[ - Sets whether ObjectCache is enabled for this Caster. - It is recommended to interface with this via [`FastCastParallel:Init()`](FastCastParallel#Init) instead. - @method SetObjectCacheEnabled - @within FastCastParallel - - @param enabled boolean -]=] -function FastCastParallel:SetObjectCacheEnabled( - enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance -) - if not self.AlreadyInit then - error("Please Init caster") - end - local vmDispatcher = self.Dispatcher - - if enabled then - vmDispatcher:DispatchAll("BindObjectCache", enabled, Template, CacheSize, CacheHolder) - else - vmDispatcher:DispatchAll("BindObjectCache", enabled) - end - - self.ObjectCacheEnabled = enabled -end - -- Serial Caster Methods --[=[ @@ -842,25 +814,6 @@ function FastCastSerial:SetMovementModeEnabled(enabled: boolean, mode: "BulkMove self.MovementMode = mode end ---[=[ - @method SetObjectCacheEnabled - @within FastCastSerial -]=] -function FastCastSerial:SetObjectCacheEnabled( - enabled: boolean, - Template: BasePart?, - CacheSize: number, - CacheHolder: Instance -) - BaseCastSerial.BindObjectCache( - enabled, - Template, - CacheSize, - CacheHolder - ) - self.ObjectCacheEnabled = enabled -end - --[=[ @method Destroy @within FastCastSerial From 7645c931c94fb833364d6d7363e7452911af12e2 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 15:42:24 +0700 Subject: [PATCH 089/140] Delete src/ObjectCache.luau --- src/ObjectCache.luau | 204 ------------------------------------------- 1 file changed, 204 deletions(-) delete mode 100644 src/ObjectCache.luau diff --git a/src/ObjectCache.luau b/src/ObjectCache.luau deleted file mode 100644 index 2415e5ff..00000000 --- a/src/ObjectCache.luau +++ /dev/null @@ -1,204 +0,0 @@ ---[[ - - Modded By Mawin_CK - Desc : i added __type = "ObjectCache" to letting FastCast Recongize that this is ObjectCache -]] - ---[=[ - -@class ObjectCache -@private -@external ObjectCache https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112 -ObjectCache usage should be derived from their DevForum post: - -https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112 - -]=] - ---!strict ---!native -local HTTPS = game:GetService("HttpService") - -local FAR_AWAY_CFRAME = CFrame.new(2^24, 2^24, 2^24) -local EXPAND_BY_AMOUNT = 50 - -local MovingParts = table.create(10_000) -local MovingCFrames = table.create(10_000) - -local ScheduledUpdate = false -local function UpdateMovement() - while true do - workspace:BulkMoveTo(MovingParts, MovingCFrames, Enum.BulkMoveMode.FireCFrameChanged) - - table.clear(MovingParts) - table.clear(MovingCFrames) - - ScheduledUpdate = false - coroutine.yield() - end -end -local UpdateMovementThread = coroutine.create(UpdateMovement) - -local Cache = {} -Cache.__index = Cache -Cache.__type = "ObjectCache" - -function Cache:_GetNew(Amount: number, Warn: boolean) - if Warn then - warn(`ObjectCache: Cache retrieval exceeded preallocated amount! expanding by {Amount}...`) - end - - local FreeObjectsContainer = self._FreeObjects - local InitialLength = #self._FreeObjects - local CacheHolder = self.CacheHolder - - local IsTemplateModel = self._IsTemplateModel - local Template: Model | BasePart = self._Template - - local TargetParts = table.create(Amount) - local TargetCFrames = table.create(Amount) - local AddedObjects = table.create(Amount) - for Index = InitialLength + 1, InitialLength + Amount do - local Object = Template:Clone() - local ObjectRoot: BasePart = if IsTemplateModel then (Object:: Model).PrimaryPart:: BasePart else Object:: BasePart - - FreeObjectsContainer[Index] = Object - - local OffsetIndex = Index - InitialLength - TargetParts[OffsetIndex] = ObjectRoot - TargetCFrames[OffsetIndex] = FAR_AWAY_CFRAME - AddedObjects[OffsetIndex] = Object - end - - workspace:BulkMoveTo(TargetParts, TargetCFrames, Enum.BulkMoveMode.FireCFrameChanged) - - for _, Object in AddedObjects do - (Object:: Instance).Parent = CacheHolder - end - - return table.remove(FreeObjectsContainer) -end - -function Cache:GetPart(PartCFrame: CFrame?): BasePart - local Object = table.remove(self._FreeObjects) or self:_GetNew(self._ExpandAmount, true) - local Part = if self._IsTemplateModel then (Object :: Model).PrimaryPart :: BasePart else Object :: BasePart - - self._PartToObject[Part] = Object - self._Objects[Part] = nil - if PartCFrame then - table.insert(MovingParts, Part) - table.insert(MovingCFrames, PartCFrame) - - if not ScheduledUpdate then - ScheduledUpdate = true - task.defer(UpdateMovementThread) - end - end - - return Part -end -function Cache:ReturnPart(Part: BasePart) - if self._Objects[Part] then - return - end - - self._Objects[Part] = true - - local Object = self._PartToObject[Part] - if Object then - self._PartToObject[Part] = nil - else - Object = Part - end - - table.insert(self._FreeObjects, Object) - table.insert(MovingParts, Part) - table.insert(MovingCFrames, FAR_AWAY_CFRAME) - - if not ScheduledUpdate then - ScheduledUpdate = true - task.defer(UpdateMovementThread) - end -end - -function Cache:Update() - task.spawn(UpdateMovementThread) -end - -function Cache:ExpandCache(Amount: number) - assert(typeof(Amount) == "number" and Amount >= 0, `Invalid argument #1 to 'ObjectCache:ExpandCache' (positive number expected, got {typeof(Amount)})`) - self:_GetNew(Amount, false) -end -function Cache:SetExpandAmount(Amount: number) - assert(typeof(Amount) == "number" and Amount > 0, `Invalid argument #1 to 'ObjectCache:SetExpandAmount' (positive number expected, got {typeof(Amount)})`) - self._ExpandAmount = Amount -end - -function Cache:IsInUse(Object: BasePart): boolean - return self._Objects[Object] == nil -end - -function Cache:Destroy() - self.CacheHolder:Destroy() -end - -local function GetCacheContainer() - local CacheHolder = Instance.new("Folder") - CacheHolder.Name = "ObjectCache " .. HTTPS:GenerateGUID(false) - - return CacheHolder -end - -local Constructor = {} -function Constructor.new(Template: BasePart | Model, CacheSize: number?, CachesContainer: Instance?) - local TemplateType = typeof(Template) - assert(TemplateType == "Instance", `Invalid argument #1 to 'ObjectCache.new' (BasePart expected, got {TemplateType})`) - - assert(Template:IsA("BasePart") or Template:IsA("Model"), `Invalid argument #1 to 'ObjectCache.new' (BasePart or Model expected, got {Template.ClassName})`) - assert(Template.Archivable, `ObjectCache: Cannot use template object provided, as it has Archivable set to false.`) - if Template:IsA("Model") then - assert(Template.PrimaryPart ~= nil, `Invalid Template provided to 'ObjectCache.new': Model has no PrimaryPart set!`) - end - - local CacheSizeType = typeof(CacheSize) - assert(CacheSize == nil or CacheSizeType == "number", `Invalid argument #2 to 'ObjectCache.new' (number expected, got {CacheSizeType})`) - assert(CacheSize == nil or CacheSize >= 0, `Invalid argument #2 to 'ObjectCache.new' (positive number expected, got {CacheSize})`) - - local ContainerType = typeof(CachesContainer) - assert(CachesContainer == nil or ContainerType == "Instance", `Invalid argument #3 to 'ObjectCache.new' (Instance expected, got {ContainerType})`) - - local PreallocAmount = CacheSize or 10 - local CacheParent = GetCacheContainer() - - local Objects: {[BasePart]: boolean} = {} - local FreeObjects: {BasePart | Model} = table.create(PreallocAmount) - - local TargetParts = table.create(PreallocAmount) - - local IsTemplateModel = Template:IsA("Model") - for Index = 1, PreallocAmount do - local Object = Template:Clone() - local ObjectRoot: BasePart = if IsTemplateModel then (Object:: Model).PrimaryPart:: BasePart else Object:: BasePart - - FreeObjects[Index] = Object - TargetParts[Index] = ObjectRoot - - ObjectRoot.CFrame = FAR_AWAY_CFRAME; - (Object:: Instance).Parent = CacheParent - end - - CacheParent.Parent = CachesContainer or workspace - - return setmetatable({ - CacheHolder = CacheParent, - _ExpandAmount = EXPAND_BY_AMOUNT, - _Template = Template, - _FreeObjects = FreeObjects, - _Objects = Objects, - _PartToObject = {}, - _IsTemplateModel = IsTemplateModel, - _PreallocatedAmount = PreallocAmount, - Type = "ObjectCache" - }, Cache) -end - -return Constructor From aa4861d729b866ee4b98324ed6e24382b863a79b Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 15:49:13 +0700 Subject: [PATCH 090/140] Remove ObjectCache --- src/BaseCastSerial.luau | 68 ++--------------------------------------- 1 file changed, 3 insertions(+), 65 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 7e5868cf..88cc0177 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -17,14 +17,10 @@ local EnumCastTypes = FastCastEnums.CastType local BaseCast = {} -local DEFAULT_CACHE_SIZE = 500 -local DEFAULT_CACHE_HOLDER = workspace - local NextProjectileID = 0 local Actives = {} local CastFirefn = nil local Motor6DCacheInstance = nil -local ObjectCacheInstance = nil local CurrentMovementMode = "BulkMoveTo" function BaseCast.Init(events: any, Data: any) @@ -32,23 +28,9 @@ function BaseCast.Init(events: any, Data: any) CastFirefn = if type(events.CastFire) == "function" then events.CastFire else nil Motor6DCacheInstance = nil - ObjectCacheInstance = nil - BaseCast.CacheHolder = nil CurrentMovementMode = "BulkMoveTo" Data = Data or {} - if Data.useObjectCache then - local objectCacheArgs = Data.objectCacheArgs or {} - local template = objectCacheArgs.Template - if not template then - error("Template must be provided when enabling ObjectCache.") - end - - local cacheSize = objectCacheArgs.CacheSize or DEFAULT_CACHE_SIZE - local cacheHolder = objectCacheArgs.CacheHolder or DEFAULT_CACHE_HOLDER - ObjectCacheInstance = ObjectCache.new(template, cacheSize, cacheHolder) :: any - BaseCast.CacheHolder = ObjectCacheInstance.CacheHolder - end CurrentMovementMode = Data.movementMode or "BulkMoveTo" if CurrentMovementMode == "Motor6D" then @@ -75,7 +57,7 @@ function BaseCast.Raycast( NextProjectileID += 1 local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Raycast - } :: any, ObjectCacheInstance) + } :: any) SerialSimulation.Register(cast) Actives[cast.ID] = cast @@ -93,7 +75,7 @@ function BaseCast.Blockcast( local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Blockcast, Size = Size - } :: any, ObjectCacheInstance) + } :: any) SerialSimulation.Register(cast) Actives[cast.ID] = cast @@ -111,7 +93,7 @@ function BaseCast.Spherecast( local cast = ActiveCast.createCastData(BaseCast, NextProjectileID, Origin, Direction, Velocity, Behavior, nil, { CastType = EnumCastTypes.Spherecast, Radius = Radius - } :: any, ObjectCacheInstance) + } :: any) SerialSimulation.Register(cast) Actives[cast.ID] = cast @@ -135,35 +117,6 @@ function BaseCast.SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | SerialSimulation.SetMovementModeEnabled(enabled, mode) end -function BaseCast.BindObjectCache( - enabled: boolean, - Template: BasePart | Model?, - CacheSize: number?, - CacheHolder: Instance? -) - if enabled then - if ObjectCacheInstance then - return - end - if not Template then - error("Template must be provided when enabling ObjectCache.") - end - - ObjectCacheInstance = ObjectCache.new( - Template, - CacheSize or DEFAULT_CACHE_SIZE, - CacheHolder or DEFAULT_CACHE_HOLDER - ) - BaseCast.CacheHolder = ObjectCacheInstance.CacheHolder - else - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - end - ObjectCacheInstance = nil - BaseCast.CacheHolder = nil - end -end - function BaseCast.Destroy() if SerialSimulation then SerialSimulation.Stop() @@ -178,12 +131,6 @@ function BaseCast.Destroy() end Actives = {} - if ObjectCacheInstance then - ObjectCacheInstance:Destroy() - ObjectCacheInstance = nil - end - BaseCast.CacheHolder = nil - if Motor6DCacheInstance then Motor6DCacheInstance:Destroy() Motor6DCacheInstance = nil @@ -198,15 +145,6 @@ function BaseCast._TerminateCast(castID: number) return end - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if ObjectCacheInstance then - ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - cast.RayInfo.CosmeticBulletObject = nil - end - end - table.clear(cast :: any) Actives[castID] = nil end From 4b462c59093ddb50851f0d0447be2d85fc263ee8 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 15:49:35 +0700 Subject: [PATCH 091/140] Update BaseCastSerial.luau --- src/BaseCastSerial.luau | 1 - 1 file changed, 1 deletion(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 88cc0177..0d760f8f 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -10,7 +10,6 @@ local SerialSimulation = require(script.Parent.SerialSimulation) local FastCastEnums = require(FastCast2:WaitForChild("FastCastEnums")) local ActiveCast = require(FastCast2:WaitForChild("ActiveCast")) local TypeDef = require(FastCast2:WaitForChild("TypeDefinitions")) -local ObjectCache = require(FastCast2:WaitForChild("ObjectCache")) local Motor6DCache = require(FastCast2:WaitForChild("Motor6DCache")) local EnumCastTypes = FastCastEnums.CastType From 4171bff705c48f3ef4bb62b706a588a665ca8fad Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:06:35 +0700 Subject: [PATCH 092/140] Remove ObjectCache --- src/ActiveCast.luau | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index a8fcebf9..fcb66520 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -46,7 +46,6 @@ function ActiveCast.createCastData( behavior: TypeDef.FastCastBehavior, eventModule: TypeDef.FastCastEventsModule?, variant: CastVariants, - ObjectCacheRef: any, _parallel: boolean? ): any local cast = { @@ -115,22 +114,17 @@ function ActiveCast.createCastData( end local targetContainer: Instance? - if ObjectCacheRef then - cast.RayInfo.CosmeticBulletObject = ObjectCacheRef:GetPart(if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin)) - targetContainer = cast.Caster.CacheHolder - else - if cast.RayInfo.CosmeticBulletObject ~= nil then - local basePart = cast.RayInfo.CosmeticBulletObject - basePart = basePart:Clone() - basePart.CFrame = if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin) - basePart.Parent = behavior.CosmeticBulletContainer - - cast.RayInfo.CosmeticBulletObject = basePart - end + + if cast.RayInfo.CosmeticBulletObject ~= nil then + local basePart = cast.RayInfo.CosmeticBulletObject + basePart.CFrame = if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin) + basePart.Parent = behavior.CosmeticBulletContainer - if behavior.CosmeticBulletContainer then - targetContainer = behavior.CosmeticBulletContainer - end + cast.RayInfo.CosmeticBulletObject = basePart + end + + if behavior.CosmeticBulletContainer then + targetContainer = behavior.CosmeticBulletContainer end if behavior.AutoIgnoreContainer == true and targetContainer ~= nil then From 2b528a6193e4aba39171a169dc6a9cf902832942 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:11:45 +0700 Subject: [PATCH 093/140] Remove ObjectCache --- src/BaseCastParallel.luau | 58 +++------------------------------------ 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index f2e464a8..f03d1530 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -10,7 +10,6 @@ local FastCastEnums = require(FastCast2:WaitForChild("FastCastEnums")) local ActiveCast = require(FastCast2:WaitForChild("ActiveCast")) local TypeDef = require(FastCast2:WaitForChild("TypeDefinitions")) local ParallelSimulation = require(FastCast2:WaitForChild("ParallelSimulation")) -local ObjectCache = require(FastCast2:WaitForChild("ObjectCache")) local Motor6DCache = require(FastCast2:WaitForChild("Motor6DCache")) local FastCastEventsModule: ModuleScript? = nil @@ -20,12 +19,8 @@ local BaseCast = {} BaseCast.__index = BaseCast BaseCast.__type = "BaseCast" -local DEFAULT_CACHE_SIZE = 500 -local DEFAULT_CACHE_HOLDER = workspace - local Actor = nil local Output = nil -local ObjectCacheInstance: any = nil local Motor6DCacheInstance: any = nil local NextProjectileID = 0 local SyncChanges: BindableEvent = nil @@ -55,14 +50,7 @@ local function TerminateCast(cast: any, castTerminatingFunction: TypeDef.OnCastT if castTerminatingFunction then castTerminatingFunction((cast :: any)) end - - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if ObjectCacheInstance then - ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - end - end + table.clear(cast :: any) end @@ -82,19 +70,6 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) Actor:SetAttribute("FastCast2Ready", true) - if Data.useObjectCache then - local objectCacheArgs = Data.objectCacheArgs or {} - if not objectCacheArgs.Template then - error("Template must be provided when enabling ObjectCache.") - end - objectCacheArgs.CacheSize = objectCacheArgs.CacheSize or DEFAULT_CACHE_SIZE - objectCacheArgs.CacheHolder = objectCacheArgs.CacheHolder or DEFAULT_CACHE_HOLDER - ObjectCacheInstance = ObjectCache.new(objectCacheArgs.Template, objectCacheArgs.CacheSize, objectCacheArgs.CacheHolder) :: any - end - - self.ObjectCacheInstance = ObjectCacheInstance - self.CacheHolder = ObjectCacheInstance and ObjectCacheInstance.CacheHolder or nil - CurrentMovementMode = Data.movementMode or "BulkMoveTo" if CurrentMovementMode == "Motor6D" then Motor6DCacheInstance = Motor6DCache.new() @@ -103,14 +78,6 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) Actor:BindToMessage("Cleanup", function(activeCastID: number) if self.Actives[activeCastID] then local cast = self.Actives[activeCastID] - if cast.RayInfo and cast.RayInfo.CosmeticBulletObject then - if ObjectCacheInstance then - ObjectCacheInstance:ReturnPart(cast.RayInfo.CosmeticBulletObject) - else - cast.RayInfo.CosmeticBulletObject:Destroy() - cast.RayInfo.CosmeticBulletObject = nil - end - end self.Actives[activeCastID] = nil ParallelSimulation.Unregister(activeCastID) end @@ -159,7 +126,7 @@ function BaseCast:Raycast(Origin: Vector3, Direction: Vector3, Velocity: Vector3 CacheHolder = self.CacheHolder }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Raycast - } :: any, self.ObjectCacheInstance, true) + } :: any, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire then @@ -198,7 +165,7 @@ function BaseCast:Blockcast(Origin: Vector3, Size: Vector3, Direction: Vector3, }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Blockcast, Size = Size - } :: any, self.ObjectCacheInstance, true) + } :: any, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire then @@ -219,7 +186,7 @@ function BaseCast:Spherecast(Origin: Vector3, Radius: number, Direction: Vector3 }, NextProjectileID, Origin, Direction, Velocity, Behavior, FastCastEventsModule, { CastType = EnumCastTypes.Spherecast, Radius = Radius - } :: any, self.ObjectCacheInstance, true) + } :: any, true) ParallelSimulation.Register(cast) self.Actives[cast.ID] = cast if Behavior.FastCastEventsConfig.UseCastFire then @@ -240,25 +207,8 @@ function BaseCast:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | ParallelSimulation.SetMovementModeEnabled(enabled, mode) end -function BaseCast:BindObjectCache(enabled: boolean, Template: BasePart | Model?, CacheSize: number?, CacheHolder: Instance?) - if enabled then - if self.ObjectCacheInstance then return end - if not Template then error("Template must be provided when enabling ObjectCache.") end - local cache = ObjectCache.new(Template, CacheSize or DEFAULT_CACHE_SIZE, CacheHolder or DEFAULT_CACHE_HOLDER) - self.ObjectCacheInstance = cache - ObjectCacheInstance = cache - self.CacheHolder = cache.CacheHolder - else - if self.ObjectCacheInstance then self.ObjectCacheInstance:Destroy() end - self.ObjectCacheInstance = nil - self.CacheHolder = nil - ObjectCacheInstance = nil - end -end - function BaseCast:Destroy() if ParallelSimulation then ParallelSimulation.Stop() end - if self.ObjectCacheInstance then self.ObjectCacheInstance:Destroy() end if Motor6DCacheInstance then Motor6DCacheInstance:Destroy() end FastCastEventsModule = nil CastFireFunc = nil From 47de23cabf091d74122a1c793bd28e0b6bf4afdf Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:12:51 +0700 Subject: [PATCH 094/140] Remove automatic clean up --- src/ParallelSimulation.luau | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/ParallelSimulation.luau b/src/ParallelSimulation.luau index a193f9c6..403d26e3 100644 --- a/src/ParallelSimulation.luau +++ b/src/ParallelSimulation.luau @@ -706,20 +706,7 @@ local function FireQueuedEvents() if callback then callback(cast) end - - -- Inline cleanup (no BindableEvent needed, we're on main thread) - local rayInfo = casts_RayInfo[castID] - if rayInfo then - local cosmetic = rayInfo.CosmeticBulletObject - if cosmetic then - local cache = BaseCastRef.ObjectCacheInstance - if cache then - cache:ReturnPart(cosmetic) - else - cosmetic:Destroy() - end - end - end + ActivesRef[castID] = nil ParallelSimulation_Unregister(castID) end From 37d382d82bb62e6a138d896977ee9703ae2d4ecc Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:16:39 +0700 Subject: [PATCH 095/140] Remove ObjectCache --- src/FastCastVMs/ClientVM.client.luau | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/FastCastVMs/ClientVM.client.luau b/src/FastCastVMs/ClientVM.client.luau index a5e3b40b..20a66670 100644 --- a/src/FastCastVMs/ClientVM.client.luau +++ b/src/FastCastVMs/ClientVM.client.luau @@ -90,15 +90,6 @@ actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: " BaseCast:SetMovementModeEnabled(enabled, mode) end) -actor:BindToMessage("BindObjectCache", function( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) - BaseCast:BindObjectCache(enabled, Template, CacheSize, CacheHolder) -end) - -- CleanUp actor:BindToMessage("Destroy", function() From d99746423b67cda6373571d87e4ecee3f938ba9f Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:17:05 +0700 Subject: [PATCH 096/140] Remove ObjectCache --- src/FastCastVMs/ServerVM.server.luau | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/FastCastVMs/ServerVM.server.luau b/src/FastCastVMs/ServerVM.server.luau index 109071bb..d3643599 100644 --- a/src/FastCastVMs/ServerVM.server.luau +++ b/src/FastCastVMs/ServerVM.server.luau @@ -98,15 +98,6 @@ actor:BindToMessage("SetMovementModeEnabled", function(enabled: boolean, mode: " BaseCast:SetMovementModeEnabled(enabled, mode) end) -actor:BindToMessage("BindObjectCache", function( - enabled: boolean, - Template: BasePart?, - CacheSize: number?, - CacheHolder: Instance? -) - BaseCast:BindObjectCache(enabled, Template, CacheSize, CacheHolder) -end) - -- CleanUp actor:BindToMessage("Destroy", function() From 6a712c241312e10f35dcad53bf567311c95d7624 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:26:16 +0700 Subject: [PATCH 097/140] Remove unused variable --- src/BaseCastParallel.luau | 1 - 1 file changed, 1 deletion(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index f03d1530..d5e0ca1c 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -77,7 +77,6 @@ function BaseCast.Init(BindableOutput: BindableEvent, Data: any) Actor:BindToMessage("Cleanup", function(activeCastID: number) if self.Actives[activeCastID] then - local cast = self.Actives[activeCastID] self.Actives[activeCastID] = nil ParallelSimulation.Unregister(activeCastID) end From 8c0141eb95f743cf48a6301ce9c5ecc5bed9aded Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:41:28 +0700 Subject: [PATCH 098/140] Remove ObjectCache parameters for :Init() --- src/init.luau | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/src/init.luau b/src/init.luau index 7ab7cce7..0aff2426 100644 --- a/src/init.luau +++ b/src/init.luau @@ -480,21 +480,12 @@ end @param numWorkers number -- Number of workers for parallel luau @param movementMode "BulkMoveTo" | "Motor6D" -- MovementMode for projectiles @param FastCastEventsModule ModuleScript -- The ModuleScript containing the FastCastEvents, A table of callback functions (events/hooks) used by ActiveCast.. - @param useObjectCache boolean -- Whether to use ObjectCache for the [Caster](TypeDefinitions#Caster) - @param Template BasePart | Model -- The template object to use for the ObjectCache (if enabled) - @param CacheSize number -- The size of the ObjectCache (if enabled) - @param CacheHolder Instance -- The Instance in which to place cached objects (if enabled) ]=] function FastCastParallel:Init( numWorkers: number, movementMode: "BulkMoveTo" | "Motor6D", - FastCastEventsModule: ModuleScript, - - useObjectCache: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance + FastCastEventsModule: ModuleScript ) if self.AlreadyInit then warn("Cannot Init more than 1") @@ -512,12 +503,6 @@ function FastCastParallel:Init( local data = { movementMode = movementMode, - useObjectCache = useObjectCache, - objectCacheArgs = { - Template = Template, - CacheSize = CacheSize, - CacheHolder = CacheHolder - }, FastCastModule = script } self.Dispatcher = newDispatcher.new(numWorkers, data, function(eventName: string, ...) @@ -541,7 +526,6 @@ function FastCastParallel:Init( end) self.AlreadyInit = true - self.ObjectCacheEnabled = useObjectCache self.MovementMode = movementMode if FastCastEventsModule then @@ -693,18 +677,10 @@ end @method Init @within FastCastSerial - @param useBulkMoveTo boolean -- Whether to use BulkMoveTo for projectile movement. - @param useObjectCache boolean -- Whether to use ObjectCache. - @param Template BasePart | Model? -- Template for ObjectCache. - @param CacheSize number? -- Size of ObjectCache. - @param CacheHolder Instance? -- Parent for cached objects. + @param movementMode: "BulkMoveTo" | "Motor6D" -- Whether to use BulkMoveTo or Motor6D Transform for projectile movement. ]=] function FastCastSerial:Init( movementMode: "BulkMoveTo" | "Motor6D", - useObjectCache: boolean, - Template: BasePart | Model?, - CacheSize: number?, - CacheHolder: Instance? ) if self.BaseCast then warn("Serial Caster already initialized") @@ -712,13 +688,7 @@ function FastCastSerial:Init( end local data = { - movementMode = movementMode or "BulkMoveTo", - useObjectCache = useObjectCache, - objectCacheArgs = { - Template = Template, - CacheSize = CacheSize or DEFAULT_CACHE_SIZE, - CacheHolder = CacheHolder or DEFAULT_CACHE_HOLDER - } + movementMode = movementMode or "BulkMoveTo" } local events: TypeDef.FastCastSignalEvents = { From 60928c7994a9b12268c373a6f14147e7296b023d Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:48:36 +0700 Subject: [PATCH 099/140] Fix CastFire --- src/BaseCastSerial.luau | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 0d760f8f..8abb87b6 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -18,14 +18,14 @@ local BaseCast = {} local NextProjectileID = 0 local Actives = {} -local CastFirefn = nil +local CastFireEvent = nil local Motor6DCacheInstance = nil local CurrentMovementMode = "BulkMoveTo" function BaseCast.Init(events: any, Data: any) BaseCast.Destroy() - CastFirefn = if type(events.CastFire) == "function" then events.CastFire else nil + CastFireEvent = events.CastFire Motor6DCacheInstance = nil CurrentMovementMode = "BulkMoveTo" @@ -42,8 +42,8 @@ function BaseCast.Init(events: any, Data: any) end local function FireCastFire(cast, origin, direction, velocity, behavior) - if behavior.FastCastEventsConfig.UseCastFire and CastFirefn then - CastFirefn(cast, origin, direction, velocity, behavior) + if behavior.FastCastEventsConfig.UseCastFire and CastFireEvent then + CastFireEvent:Fire(cast, origin, direction, velocity, behavior) end end @@ -135,7 +135,7 @@ function BaseCast.Destroy() Motor6DCacheInstance = nil end - CastFirefn = nil + CastFireEvent = nil end function BaseCast._TerminateCast(castID: number) From d7eadf73ec67d6188c2b04e37dbd981e6f93bd55 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:54:19 +0700 Subject: [PATCH 100/140] Add _TerminationQueued = false --- src/ActiveCast.luau | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index fcb66520..51f7a5f3 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -86,6 +86,7 @@ function ActiveCast.createCastData( }, _Destroying = false, + _TerminationQueued = false, Type = variant.CastType, CastVariant = variant, CFrame = CFrame.new(origin), From 90df7aabb3707698bf6939c08a13110931a748d8 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 16:56:46 +0700 Subject: [PATCH 101/140] Fix :TerminateCast --- src/init.luau | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/init.luau b/src/init.luau index 0aff2426..406dad2a 100644 --- a/src/init.luau +++ b/src/init.luau @@ -984,26 +984,32 @@ end Note: If EndTime is already set, the cast is already terminated and this function returns early. ]=] function FastCast:TerminateCast(cast: vaildcast) - if cast._Destroying then return end - (cast :: any)._Destroying = true + if cast._Destroying or cast._TerminationQueued then + return + end if cast.Mode == "Serial" then - local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig - if eventsCfg and eventsCfg.UseCastTerminating then - local castTerminating = SerialSimulation.GetEvent("CastTerminating") - if castTerminating then - castTerminating:Fire(cast) - end - end - SerialSimulation.QueueEvent(cast.ID, "CastTerminating") + cast._Destroying = true + cast._TerminationQueued = true + + SerialSimulation.QueueEvent( + cast.ID, + "CastTerminating" + ) + elseif cast.Mode == "Parallel" then local caster = cast.Caster - if caster == nil then return end + if caster == nil then + return + end + + cast._Destroying = true local eventsCfg = cast.StateInfo and cast.StateInfo.FastCastEventsConfig if eventsCfg and eventsCfg.UseCastTerminating then caster.Output:Fire("CastTerminating", cast) end + caster.Actor:SendMessage("Cleanup", cast.ID) end end From f82a1d6ad8b84abade9a37f1bef99797676d6d5b Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:00:51 +0700 Subject: [PATCH 102/140] Add _TerminationQueued --- src/SerialSimulation.luau | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 5d4d98cc..61a92b2d 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -613,16 +613,17 @@ local function FireQueuedEvents() end elseif eventType == EVT_CAST_TERMINATING then - if not cast._Destroying then + if not cast._Destroying or cast._TerminationQueued then cast._Destroying = true + cast._TerminationQueued = false + local castTerminating = FastCastEvents.CastTerminating - --local eventConfig = cast.StateInfo and cast.StateInfo.FastCastEventsConfig if eventConfig.UseCastTerminating and castTerminating then castTerminating:Fire(cast) end end + BaseCastRef._TerminateCast(castID) - SerialSimulation_Unregister(castID) end end From 21e920dc431ccf0d5945a26744073e6381c021b5 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:02:38 +0700 Subject: [PATCH 103/140] Unregister when called TerminateCast --- src/BaseCastSerial.luau | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/BaseCastSerial.luau b/src/BaseCastSerial.luau index 8abb87b6..6701baf9 100644 --- a/src/BaseCastSerial.luau +++ b/src/BaseCastSerial.luau @@ -146,6 +146,8 @@ function BaseCast._TerminateCast(castID: number) table.clear(cast :: any) Actives[castID] = nil + + SerialSimulation.Unregister(castID) end function BaseCast._GetMotor6D(projectilePart: BasePart?) From cd6171053e9ced818f5f767d7bffd7f0c09c8dac Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:08:41 +0700 Subject: [PATCH 104/140] Fix :Destroy function --- src/BaseCastParallel.luau | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/BaseCastParallel.luau b/src/BaseCastParallel.luau index d5e0ca1c..26532c5c 100644 --- a/src/BaseCastParallel.luau +++ b/src/BaseCastParallel.luau @@ -211,11 +211,21 @@ function BaseCast:Destroy() if Motor6DCacheInstance then Motor6DCacheInstance:Destroy() end FastCastEventsModule = nil CastFireFunc = nil - ObjectCacheInstance = nil if SyncChangesConnection then SyncChangesConnection:Disconnect(); SyncChangesConnection = nil end + local activeList = {} - for _, v in self.Actives do table.insert(activeList, v) end - for _, v in activeList do TerminateCast(v) end + for _, v in self.Actives do + table.insert(activeList, v) + end + + for _, v in activeList do + local castID = v.ID + + TerminateCast(v) + self.Actives[castID] = nil + ParallelSimulation.Unregister(castID) + end + self.Actives = {} setmetatable(self, nil) end From 1be664c5411063816ed50e2c71efd16faa1148fc Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:10:55 +0700 Subject: [PATCH 105/140] Update TypeDefinitions.luau --- src/TypeDefinitions.luau | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index cb88459e..002418b9 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -223,16 +223,9 @@ export type CasterParallel = { ) -> (), SetMovementModeEnabled: ( - enabled: boolean, - mode: "BulkMoveTo" | "Motor6D" - ) -> (), - - SetObjectCacheEnabled: ( self: CasterParallel, enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance + mode: "BulkMoveTo" | "Motor6D" ) -> (), SetFastCastEventsModule: (self: CasterParallel, moduleScript: ModuleScript) -> (), @@ -312,16 +305,9 @@ export type CasterSerial = { ) -> (), SetMovementModeEnabled: ( - enabled: boolean, - mode: "BulkMoveTo" | "Motor6D" - ) -> (), - - SetObjectCacheEnabled: ( self: CasterSerial, enabled: boolean, - Template: BasePart | Model, - CacheSize: number, - CacheHolder: Instance + mode: "BulkMoveTo" | "Motor6D" ) -> (), AddVelocityCast: (CasterSerial, cast: vaildcast, velocity: Vector3) -> (), From 9afd383b82d40417eea29f4b17c34fc1f9ccb734 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:11:53 +0700 Subject: [PATCH 106/140] Update TypeDefinitions.luau --- src/TypeDefinitions.luau | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index 002418b9..02789d2c 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -234,7 +234,7 @@ export type CasterParallel = { SetVelocityCast: (CasterParallel, cast: vaildcast, velocity: Vector3) -> (), GetVelocityCast: (CasterParallel, cast: vaildcast) -> Vector3, - AddAccelerationCast: (CasterParallel, cast: vaildcast, acceleration: Vector3) -> Vector3, + AddAccelerationCast: (CasterParallel, cast: vaildcast, acceleration: Vector3) -> (), SetAccelerationCast: (CasterParallel, cast: vaildcast, acceleration: Vector3) -> (), GetAccelerationCast: (CasterParallel, cast: vaildcast) -> Vector3, @@ -314,7 +314,7 @@ export type CasterSerial = { SetVelocityCast: (CasterSerial, cast: vaildcast, velocity: Vector3) -> (), GetVelocityCast: (CasterSerial, cast: vaildcast) -> Vector3, - AddAccelerationCast: (CasterSerial, cast: vaildcast, acceleration: Vector3) -> Vector3, + AddAccelerationCast: (CasterSerial, cast: vaildcast, acceleration: Vector3) -> (), SetAccelerationCast: (CasterSerial, cast: vaildcast, acceleration: Vector3) -> (), GetAccelerationCast: (CasterSerial, cast: vaildcast) -> Vector3, From f34aed74245e330acd933d73bdce2074f3d5c91d Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:16:14 +0700 Subject: [PATCH 107/140] Update init.luau --- src/init.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/init.luau b/src/init.luau index 406dad2a..70169231 100644 --- a/src/init.luau +++ b/src/init.luau @@ -491,7 +491,7 @@ function FastCastParallel:Init( warn("Cannot Init more than 1") return end - assert(numWorkers > 1, "numWorker must be more than 1") + assert(numWorkers >= 1, "numWorker must be more than 1") local DispatcherClone = DispatcherModule:Clone() DispatcherClone.Parent = PARALLEL_PARENT From ff502470ae0d0d3b2893a06f9e2df1569d0c7aae Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:26:57 +0700 Subject: [PATCH 108/140] Update init.luau --- src/init.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/init.luau b/src/init.luau index 70169231..6f5d0460 100644 --- a/src/init.luau +++ b/src/init.luau @@ -419,7 +419,7 @@ local function CloneCastParams(params: RaycastParams): RaycastParams end local function SnapshotBehavior(behavior: any): any - local copy = table.clone(behavior) + local copy = deepCopyTable(behavior) if behavior.RaycastParams then copy.RaycastParams = CloneCastParams(behavior.RaycastParams) end From 0147d3d7592d0e28f6a3cc25b2ac845cd4c03aae Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 17:28:01 +0700 Subject: [PATCH 109/140] Add comment regarding OOP preference for performance Added a comment to clarify the preference against OOP for performance reasons. --- src/SerialSimulation.luau | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/SerialSimulation.luau b/src/SerialSimulation.luau index 61a92b2d..b70ca762 100644 --- a/src/SerialSimulation.luau +++ b/src/SerialSimulation.luau @@ -1543,6 +1543,11 @@ function SerialSimulation.SetMovementModeEnabled( SetMovementModeEnabled(enabled, mode) end +--[[ + Please do not make it OOP, because I don't want this to be OOP for performance reasons + - Mawin_CK +]] + function SerialSimulation.Start() if SerialSimulation.Connection then --warn("[FastCast2] SerialSimulation already started.") From fd9430aeb2470096b13b4bd7ed5ae941613e74ad Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:54:19 -0700 Subject: [PATCH 110/140] docs: update README for refactor --- README.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 44e39b9e..f3113737 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Raycast and Blockcast, Spherecast support - BulkMoveTo/Motor6D support - Built-in castVisualization -- Built-in ObjectCache - Built-in HighFidelitySegment control - Flexible, extensible - High Performance @@ -122,7 +121,7 @@ behavior.CosmeticBulletTemplate = ProjectileTemplate -- Serial Caster (runs on main thread, simpler) local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache +Caster:Init("BulkMoveTo") -- movementMode -- Events (can be set before Init) Caster.Hit = function(cast, result, velocity, bullet) @@ -157,20 +156,14 @@ Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (or object pooling) - -ObjectCache reuses cosmetic bullet instances instead of creating/destroying them every shot: - -```lua -local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) --- ^template ^size ^holder -``` +### Cosmetic bullets + +FastCast2 no longer includes a built-in ObjectCache. Manage cosmetic bullet instances yourself and provide them through `behavior.CosmeticBulletTemplate` and `behavior.CosmeticBulletContainer`. -The cache pre-allocates 500 parts by default, auto-expands when exhausted, and moves retired -parts to a far-away CFrame via `BulkMoveTo` +FastCast2 also does not automatically destroy or return cosmetic bullets when a cast terminates, so the caller controls their lifecycle. ### Parallel mode + ```lua local Caster = FastCast2.newParallel() @@ -178,7 +171,6 @@ Caster:Init( 4, -- numWorkers "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (optional) - false -- useObjectCache ) -- Events work the same as serial @@ -285,6 +277,18 @@ Caster:SetMovementModeEnabled(true, "Motor6D") -- enable Motor6D Caster:SetMovementModeEnabled(true, "BulkMoveTo") -- switch back ``` +### Simulation configuration + +Simulation behavior can be configured in `src/Config.luau`: + +```lua +Config.SimulationMode = "PerFrame" -- or "Fixed" +Config.FixedSimulationFrequency = Enum.StepFrequency.Hz60 +Config.CosmeticUpdateInterval = 3 +``` + +`PerFrame` uses `PreSimulation` on the client and `Heartbeat` on the server. `Fixed` uses `RunService:BindToSimulation()` at the configured step frequency. `CosmeticUpdateInterval` controls how often cosmetic bullet movement is applied; hit detection still runs every simulation step. + ### Cast manipulation Modify active casts at runtime using the static `FastCast` methods: @@ -361,5 +365,4 @@ More broadly, this community feedback has played a significant role in FastCast2 - [90gq31](https://computerunion.miraheze.org/90gq31) — Giving me the opportunity to be part of the community # Dependency -- [ObjectCache](https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112) - [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher) From 7c2188ea9f9321ef7f596cc5c02e8209556e6c1a Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:54:26 -0700 Subject: [PATCH 111/140] docs: update Thai README for refactor --- README.th.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/README.th.md b/README.th.md index f00234a3..29ab8f60 100644 --- a/README.th.md +++ b/README.th.md @@ -45,7 +45,6 @@ - รองรับ Raycast, Blockcast และ Spherecast - รองรับ BulkMoveTo/Motor6D - มี castVisualization ในตัว -- มี ObjectCache ในตัว - มีระบบควบคุม HighFidelitySegment ในตัว - ออกแบบมาให้ยืดหยุ่นและต่อยอดได้ง่าย - ช่วยเพิ่มประสิทธิภาพในการพัฒนา @@ -127,7 +126,7 @@ behavior.CosmeticBulletTemplate = ProjectileTemplate -- Serial Caster (รันบน main thread ใช้งานง่ายกว่า) local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache +Caster:Init("BulkMoveTo") -- movementMode -- Events (ตั้งค่าได้ก่อน Init) Caster.Hit = function(cast, result, velocity, bullet) @@ -162,20 +161,14 @@ Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (bullet pooling) +### Cosmetic bullet -ObjectCache ช่วยนำ instance ของ cosmetic bullet กลับมาใช้ซ้ำ แทนที่จะสร้าง/ทำลายทุกครั้งที่ยิง: +FastCast2 ไม่มี ObjectCache ในตัวแล้ว คุณสามารถจัดการ cosmetic bullet ด้วยระบบของคุณเอง และกำหนดผ่าน `behavior.CosmeticBulletTemplate` กับ `behavior.CosmeticBulletContainer` ได้ -```lua -local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) --- ^template ^size ^holder -``` +FastCast2 จะไม่ลบหรือคืน cosmetic bullet ให้อัตโนมัติเมื่อ cast จบ ทำให้คุณควบคุม lifecycle ของ projectile visual ได้เอง -โดยค่าเริ่มต้น cache จะเตรียม part ไว้ล่วงหน้า 500 ชิ้น และขยายเพิ่มอัตโนมัติเมื่อใช้จนหมด -พร้อมทั้งย้าย part ที่เลิกใช้ไปไว้ที่ CFrame ระยะไกลผ่าน `BulkMoveTo` — ไม่มีภาระจากการสร้าง/ทำลาย instance - -### โหมด Parallel (ประสิทธิภาพสูงด้วยหลาย VM) +### โหมด Parallel + (ประสิทธิภาพสูงด้วยหลาย VM) ```lua local Caster = FastCast2.newParallel() @@ -188,7 +181,6 @@ Caster:Init( "VM", -- VM name "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (ไม่บังคับ) - false -- useObjectCache ) -- Events ทำงานเหมือนกับโหมด Serial @@ -294,6 +286,18 @@ Caster:SetMovementModeEnabled(true, "Motor6D") -- เปิดใช้งา Caster:SetMovementModeEnabled(true, "BulkMoveTo") -- สลับกลับไปใช้ BulkMoveTo ``` +### การตั้งค่าการจำลอง + +สามารถตั้งค่ารูปแบบการจำลองได้ที่ `src/Config.luau`: + +```lua +Config.SimulationMode = "PerFrame" -- หรือ "Fixed" +Config.FixedSimulationFrequency = Enum.StepFrequency.Hz60 +Config.CosmeticUpdateInterval = 3 +``` + +`PerFrame` ใช้ `PreSimulation` บน client และ `Heartbeat` บน server ส่วน `Fixed` ใช้ `RunService:BindToSimulation()` ตามความถี่ที่กำหนด `CosmeticUpdateInterval` กำหนดว่าจะอัปเดตการเคลื่อนที่ของ cosmetic bullet ทุกกี่ simulation frames โดยการตรวจจับการชนยังทำงานทุก simulation step + ### การจัดการ Cast แก้ไข cast ที่กำลัง active อยู่ระหว่าง runtime โดยใช้ static method ของ `FastCast`: @@ -346,5 +350,4 @@ FastCast2:TerminateCast(cast) - [@nerd0ne](https://github.com/nerd0ne) — ที่ชี้ให้เห็นปัญหา Wally setup # Dependency -- [ObjectCache](https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112) -- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher) +- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher)(https://github.com/weenachuangkud/VMsDispatcher) From 52e977b77a5b48f9e042308209ea9cd349437174 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:54:30 -0700 Subject: [PATCH 112/140] docs: update intro for refactor --- docs/intro.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/intro.md b/docs/intro.md index 22ae2d69..a62dc7a4 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -49,7 +49,6 @@ Because FastCast is no longer actively maintained by [EtiTheSpirit](https://gith - Raycast and Blockcast, Spherecast support - BulkMoveTo/Motor6D support - Built-in castVisualization -- Built-in ObjectCache - Built-in HighFidelitySegment control - Flexible, extensible - High Performance @@ -126,7 +125,7 @@ behavior.CosmeticBulletTemplate = ProjectileTemplate -- Serial Caster (runs on main thread, simpler) local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", false) -- movementMode, useObjectCache +Caster:Init("BulkMoveTo") -- movementMode -- Events (can be set before Init) Caster.Hit = function(cast, result, velocity, bullet) @@ -161,20 +160,14 @@ Caster:BlockcastFire(origin, Vector3.new(2, 4, 2), direction, SPEED, behavior) Caster:SpherecastFire(origin, 3, direction, SPEED, behavior) ``` -### ObjectCache (or object pooling) - -ObjectCache reuses cosmetic bullet instances instead of creating/destroying them every shot: - -```lua -local Caster = FastCast2.new() -Caster:Init("BulkMoveTo", true, ProjectileTemplate, 500, workspace) --- ^template ^size ^holder -``` +### Cosmetic bullets + +FastCast2 no longer includes a built-in ObjectCache. Manage cosmetic bullet instances yourself through `behavior.CosmeticBulletTemplate` and `behavior.CosmeticBulletContainer`. -The cache pre-allocates 500 parts by default, auto-expands when exhausted, and moves retired -parts to a far-away CFrame via `BulkMoveTo` +FastCast2 does not automatically destroy or return cosmetic bullet instances when a cast terminates. Your game controls their lifecycle. ### Parallel mode + ```lua local Caster = FastCast2.newParallel() @@ -182,7 +175,6 @@ Caster:Init( 4, -- numWorkers "BulkMoveTo", -- movementMode nil, -- FastCastEventsModule (optional) - false -- useObjectCache ) -- Events work the same as serial @@ -289,6 +281,20 @@ Caster:SetMovementModeEnabled(true, "Motor6D") -- enable Motor6D Caster:SetMovementModeEnabled(true, "BulkMoveTo") -- switch back ``` +### Simulation configuration + +Configure simulation in `src/Config.luau`: + +```lua +Config.SimulationMode = "PerFrame" -- or "Fixed" +Config.FixedSimulationFrequency = Enum.StepFrequency.Hz60 +Config.CosmeticUpdateInterval = 3 +``` + +- `PerFrame`: `PreSimulation` on the client and `Heartbeat` on the server. +- `Fixed`: uses `RunService:BindToSimulation()` at the configured frequency. +- `CosmeticUpdateInterval`: controls cosmetic movement frequency. Collision simulation still runs every simulation step. + ### Cast manipulation Modify active casts at runtime using the static `FastCast` methods: @@ -365,5 +371,4 @@ More broadly, this community feedback has played a significant role in FastCast2 - [90gq31](https://computerunion.miraheze.org/90gq31) — Giving me the opportunity to be part of the community # Dependency -- [ObjectCache](https://devforum.roblox.com/t/objectcache-a-modern-blazing-fast-model-and-part-cache/3104112) -- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher) +- [VMsDispatcher](https://github.com/weenachuangkud/VMsDispatcher)(https://github.com/weenachuangkud/VMsDispatcher) From e32e783a8a222964820b47464a4dcce0c1f5a253 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:55:09 -0700 Subject: [PATCH 113/140] docs: update API reference for refactor From 37082ecf8592b755f4538bb2eaf9d73958d86e9e Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:55:11 -0700 Subject: [PATCH 114/140] docs: update cheatsheet for refactor From f3187a2f1fcb92267a5c6644cb06adfe9ef36b99 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:55:14 -0700 Subject: [PATCH 115/140] docs: update refactor changelog --- docs/changelog.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 93960694..7132e587 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,47 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Added +- Automated testing by @Naymmmm +- Better performance benchmarks by @Naymmmm +- `Config.SimulationMode` (`PerFrame`/`Fixed`) and `CosmeticUpdateInterval` by @Naymmmm +- `RAY_SEARCH_OFFSET` for pierce prevention +- `cast.Mode` +- `Signal` event dispatcher for Caster events + +### Fixed +- BenchmarkClient/BenchmarkServer +- Various simulation logic bugs +- `CastTerminating` callback/cleanup issues +- Pierce prevention issues (`RAY_SEARCH_OFFSET` only applies after a pierce) +- Motor6D movement issues +- Parallel client initialization +- Refactor regressions found by the test suite +- An annoying warning +- Duplicate Serial cast unregister cleanup +- Parallel behavior snapshots now deep-copy nested configuration data + +### Changed +- `SerialSimulation` is no longer OOP-based and no longer creates a per-instance connection. +- Replaced `BindableEvent` with the Signal module. +- Simplified `FastCastParallel:Init` API. +- Added `BindToSimulation`. +- Parallel fire requests are now batched into one message per worker per frame. +- Improved Serial/Parallel event systems. +- Caster events support both `:Connect()` and function assignment. +- A lot of optimizations. +- Major code improvements. +- Improved documentation. + +### Removed +- Built-in ObjectCache +- Automatic `CosmeticBulletTemplate` cleanup + +### Cancelled +- Dynamic RunService event configuration for Caster +- Debugger GUI for benchmarking and testing + + ### Added - **`Signal`** - lightweight synchronous event dispatcher (`src/Signal.luau`) used for Caster events instead of `BindableEvent` - Caster events (`Hit`, `Pierced`, `LengthChanged`, `CastFire`, `CastTerminating`) are now Signals supporting multiple listeners, `Once`, `Wait`, `Disconnect`, `DisconnectAll`, and `Destroy` From 861f7459a12bb4ed50b23407402290ce8c8b4372 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:58:38 -0700 Subject: [PATCH 116/140] docs: finish API reference refactor update --- docs/api-reference.md | 41 +++++++---------------------------------- 1 file changed, 7 insertions(+), 34 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index eb2c438d..a6c0c1b6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,20 +8,10 @@ Serial Caster runs all cast simulations on the main thread. Simpler. ```lua local caster = FastCast2.new() -caster:Init(movementMode, useObjectCache, template, cacheSize, cacheHolder) +caster:Init(movementMode) ``` -#### 1.1.1 Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `movementMode` | `"BulkMoveTo" \| "Motor6D"` | How cosmetic bullets are moved each frame | -| `useObjectCache` | `boolean` | Enable part pooling via ObjectCache | -| `Template` | `BasePart \| Model?` | Template for ObjectCache | -| `CacheSize` | `number?` | Pre-allocated cache size (default 500) | -| `CacheHolder` | `Instance?` | Parent for cached objects (default workspace) | - -#### 1.1.2 Events +#### 1.1.1 Events Caster events are lightweight `Signal` objects (not `BindableEvent`s), so any number of listeners can connect and disconnect independently. Events can be @@ -57,15 +47,7 @@ caster.Hit = function(cast, result, velocity, cosmeticBullet) end - **`"BulkMoveTo"`** — Uses `workspace:BulkMoveTo()` each frame. (General/Default) - **`"Motor6D"`** — Uses Motor6D instances (Transform property). Better for performance. -Switch modes at runtime with `caster:SetMovementMode(mode)`. - -#### 1.1.4 ObjectCache - -ObjectCache reuses projectile parts: - -```lua -caster:Init("BulkMoveTo", true, projectileTemplate, 500, workspace) -``` +Switch modes at runtime with `caster:SetMovementModeEnabled(enabled, mode)`. --- @@ -99,18 +81,9 @@ caster:Init( | Parameter | Type | Description | |-----------|------|-------------| -| `numWorkers` | `number` | Number of Actor VMs. Must be > 1. | -| `newParent` | `Folder` | Parent for the FastCastVMs Folder | -| `newName` | `string` | Name for the FastCastVMs Folder | -| `ContainerParent` | `Folder` | Parent for worker VM Containers | -| `VMContainerName` | `string` | Name for VM Containers | -| `VMname` | `string` | Name given to each worker VM | -| `movementMode` | `"BulkMoveTo" \| "Motor6D"` | Movement method | -| `fastCastEventsModule` | `ModuleScript?` | FastCastEvents module | -| `useObjectCache` | `boolean` | Enable ObjectCache | -| `template` | `BasePart \| Model?` | ObjectCache template | -| `cacheSize` | `number?` | ObjectCache size | -| `CacheHolder` | `Instance?` | ObjectCache parent | +| `numWorkers` | `number` | Number of worker VMs. Must be at least 1. | +| `movementMode` | `"BulkMoveTo" | "Motor6D"` | Movement method | +| `fastCastEventsModule` | `ModuleScript?` | Optional FastCastEvents module | #### 1.2.2 How It Works @@ -123,7 +96,7 @@ caster:Init( ```lua caster:SetFastCastEventsModule(moduleScript) -- Parallel only caster:SetMovementModeEnabled(enabled, mode) -caster:SetObjectCacheEnabled(enabled, template?, cacheSize?, cacheHolder?) + ``` --- From bbbc0e1c51705f74296680347398ae594cacd010 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:58:40 -0700 Subject: [PATCH 117/140] docs: finish cheatsheet refactor update --- docs/cheatsheet.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index d9ce19a1..fe14a3df 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -8,7 +8,7 @@ sidebar_position: 2 ```lua local caster = FastCast2.new() -caster:Init("BulkMoveTo", false) +caster:Init("BulkMoveTo") caster.Hit = function(cast, result, velocity, bullet) print("Hit:", result.Instance) @@ -25,11 +25,7 @@ caster:RaycastFire(origin, direction, 500, behavior) local Caster = FastCast2.new() -- Construct a new Serial Caster Caster:Init( - movementMode: "BulkMoveTo" | "Motor6D", -- Movement method for cosmetic bullets. - useObjectCache: boolean, -- Enable ObjectCache for this Caster. - Template: BasePart | Model?, -- Template for ObjectCache (if enabled). - CacheSize: number?, -- Number of objects to pre-allocate. - CacheHolder: Instance? -- Parent for cached objects. + movementMode: "BulkMoveTo" | "Motor6D" -- Movement method. ) -- ⚠ Must be called before any Fire methods — nothing happens without Init! @@ -58,7 +54,6 @@ Caster:SpherecastFire(origin, Radius, direction, velocity, BehaviorData) --// Configuration Caster:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") → () -Caster:SetObjectCacheEnabled(enabled: boolean, Template?, CacheSize?, CacheHolder?) → () --// Lifecycle @@ -76,13 +71,9 @@ Caster:Destroy() → () local Caster = FastCast2.newParallel() Caster:Init( - numWorkers: number, -- Number of Actor VMs. Must be > 1. + numWorkers: number, -- Number of Actor VMs. Must be at least 1. movementMode: "BulkMoveTo" | "Motor6D", -- Movement method. - FastCastEventsModule: ModuleScript?,-- ModuleScript returning a FastCastEvents table. - useObjectCache: boolean, -- Enable ObjectCache for this Caster. - Template: BasePart | Model?, -- Template for ObjectCache (if enabled). - CacheSize: number?, -- Number of objects to pre-allocate. - CacheHolder: Instance? -- Parent for cached objects. + FastCastEventsModule: ModuleScript? -- Optional event module. ) -- ⚠ Must be called before any Fire methods — nothing happens without Init! @@ -103,7 +94,6 @@ Caster:SpherecastFire(origin, Radius, direction, velocity, BehaviorData) Caster:SetFastCastEventsModule(moduleScript: ModuleScript) → () Caster:SetMovementModeEnabled(enabled: boolean, mode: "BulkMoveTo" | "Motor6D") → () -Caster:SetObjectCacheEnabled(enabled, Template?, CacheSize?, CacheHolder?) → () --// Cast Manipulation (use FastCast static methods) From 67c0d7c30f90b7a94c0d39f828eed9b51be43a69 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:58:44 -0700 Subject: [PATCH 118/140] docs: fix remaining intro API examples --- docs/intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro.md b/docs/intro.md index a62dc7a4..0f0a618c 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -270,7 +270,7 @@ Motor6D mode uses `Motor6D.Transform` for more performance instead of `BulkMoveT ```lua local Caster = FastCast2.new() -Caster:Init("Motor6D", false) -- movementMode = "Motor6D" +Caster:Init("Motor6D") -- movementMode = "Motor6D" ``` All active casts automatically get a Motor6D connection on registration and disconnection on cleanup. From 1817c865d1b8748f64e1b7937d0d30a7b337a6e8 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:58:46 -0700 Subject: [PATCH 119/140] docs: fix remaining README API example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f3113737..209a0ffb 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ Motor6D mode uses `Motor6D.Transform` for more performance instead of `BulkMoveT ```lua local Caster = FastCast2.new() -Caster:Init("Motor6D", false) -- movementMode = "Motor6D" +Caster:Init("Motor6D") -- movementMode = "Motor6D" ``` All active casts automatically get a Motor6D connection on registration and disconnection on cleanup. From bb3b9c1227157c9a45eaa88102c82da4385ab2de Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:58:50 -0700 Subject: [PATCH 120/140] docs: fix remaining Thai README API example --- README.th.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.th.md b/README.th.md index 29ab8f60..72809c03 100644 --- a/README.th.md +++ b/README.th.md @@ -275,7 +275,7 @@ Caster:SetFastCastEventsModule(pathTo.FastCastEventsModule) ```lua local Caster = FastCast2.new() -Caster:Init("Motor6D", false) -- movementMode = "Motor6D" +Caster:Init("Motor6D") -- movementMode = "Motor6D" ``` ทุก cast ที่ active จะได้รับการเชื่อมต่อ Motor6D โดยอัตโนมัติเมื่อลงทะเบียน และตัดการเชื่อมต่อเมื่อ cleanup From 1ebeab9024161671df6602c0f14e1e4295088819 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:59:28 -0700 Subject: [PATCH 121/140] docs: remove stale ObjectCache example --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 322c9100..f5acfa64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ All pull requests will go through **code review** before merging. ### Commit Message Guidelines Examples: -- `fix: prevent nil reference in ObjectCache` +- `fix: prevent nil reference in cast cleanup` - `feat: add BulkMoveTo support for parts.` - `docs: improve usage example for parallel mode.` - `chore: update Luau types in Caster.` From fb46e1c2df55befa18530b867b04632f5ab1286b Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:59:31 -0700 Subject: [PATCH 122/140] docs: remove stale ObjectCache test checklist --- TESTS.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/TESTS.md b/TESTS.md index 7cc96a78..33d8d480 100644 --- a/TESTS.md +++ b/TESTS.md @@ -22,12 +22,11 @@ available. 1. Initialization 2. Basic raycast/blockcast/spherecast firing 3. All [events](https://weenachuangkud.github.io/FastCast2/docs/api-reference/#112-events) should be able to works -4. ObjectCache -5. MovementMode -6. Cast Manipulation -7. Repeat the same tests above for parallel version -8. SetFastCastEventsModule (Parallel) -9. SyncChangesToCast (Parallel) +4. MovementMode +5. Cast Manipulation +6. Repeat the same tests above for parallel version +7. SetFastCastEventsModule (Parallel) +8. SyncChangesToCast (Parallel) ### FastCastBehavior - https://weenachuangkud.github.io/FastCast2/docs/api-reference/#2-fastcastbehavior ### ActiveCastData From 5599bf3df8c02e29b7e27daacd82dba9e1454358 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:59:34 -0700 Subject: [PATCH 123/140] docs: mark README update complete --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index a19a22c8..3b11fca2 100644 --- a/TODO.md +++ b/TODO.md @@ -15,4 +15,4 @@ - [ ] Create a FastCast2 tutorial video (DOCS) - [ ] Include the `.rbxl` file in the latest release (RELEASE) - [ ] Update Roblox Model of FastCast2 (RELEASE) -- [ ] Update README.md (DOCS) +- [X] Update README.md (DOCS) From c3e4ab891ba584224c3f53f87808779a19639eca Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:59:37 -0700 Subject: [PATCH 124/140] docs: remove stale ObjectCache test docs --- tests/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index ffc1ea1d..8220391f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,8 +43,8 @@ see [the hot-path runner](hotpaths/README.md) (`npm run test:hotpaths`). - **Functional tests** (`tests/src/FunctionalTests.luau`) follow the checklist in [`TESTS.md`](../TESTS.md): initialization, raycast/blockcast/spherecast - firing, every public event, ObjectCache, movement modes, cast manipulation, - high-fidelity behavior, and the parallel-only extensions + firing, every public event, movement modes, cast manipulation, high-fidelity behavior, + and the parallel-only extensions (`SetFastCastEventsModule`, `SyncChangesToCast`). - **Benchmarks** (`tests/src/Benchmarks.luau`) run a fixed six-case matrix of Serial/Parallel x Raycast/Blockcast/Spherecast, reporting creation and From 21e3dc4201a58169025fc01a0842e603d005339a Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 03:59:40 -0700 Subject: [PATCH 125/140] docs: update benchmark README for refactor --- benchmarks/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 77914791..3a0ad1da 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -17,7 +17,6 @@ Fires projectiles and tracks FPS using keyboard controls. Supports both serial a | K | Decrease projectile count by 500 (floor 0) | | U | Toggle Instanced / Instanceless | | J | Toggle BulkMoveTo / Motor6D | -| Y | Toggle ObjectCache | | V | Toggle VisualizeCasts | | R | Cycle Raycast / Blockcast / Spherecast | | X | Cycle HighFidelityBehavior (Default > Automatic > Always) | @@ -45,7 +44,6 @@ local Config = { VisualizeCasts = false, -- show cast ray visualization BenchmarkDuration = 6, -- seconds to simulate after creation MovementMode = "BulkMoveTo", - ObjectCacheEnabled = false, } ``` @@ -68,7 +66,6 @@ Type the letter in chat: | k | Decrease projectile count by 500 (floor 0) | | u | Toggle Instanced / Instanceless | | j | Toggle BulkMoveTo / Motor6D | -| y | Toggle ObjectCache | | v | Toggle VisualizeCasts | | r | Cycle Raycast / Blockcast / Spherecast | | x | Cycle HighFidelityBehavior | @@ -88,7 +85,6 @@ local Config = { NumWorkers = 4, Instanced = false, MovementMode = "BulkMoveTo", - ObjectCacheEnabled = false, ProjectileAmount = 500, CastType = "Raycast", VisualizeCasts = false, @@ -104,7 +100,6 @@ Both scripts print the same format after each run: ``` === MODE BENCHMARK === -ObjectCache Disabled VisualizeCasts: Disabled HighFidelityBehavior: Default MovementMode: BulkMoveTo From ebdbe828cedb61404e386f2d88271f91d54cc64f Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 04:00:06 -0700 Subject: [PATCH 126/140] docs: remove final stale parallel Init docs --- docs/api-reference.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index a6c0c1b6..09dc90b0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -58,18 +58,9 @@ Parallel Caster runs cast simulations on separate Actor VMs. ```lua local caster = FastCast2.newParallel() caster:Init( - numWorkers, -- number of worker VMs (must be > 1) - newParent, -- Folder to place FastCastVMs - newName, -- name for FastCastVMs folder - ContainerParent, -- parent for worker containers - VMContainerName, -- name for containers - VMname, -- name for each worker VM + numWorkers, -- number of worker VMs (must be at least 1) movementMode, -- "BulkMoveTo" or "Motor6D" - fastCastEventsModule,-- optional ModuleScript - useObjectCache, -- enable ObjectCache - template, -- ObjectCache template - cacheSize, -- ObjectCache size - CacheHolder -- ObjectCache parent + fastCastEventsModule -- optional ModuleScript ) ``` > [!NOTE] From 27758005b79e8b0b05fc7eff719c45404a435d6a Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 04:03:14 -0700 Subject: [PATCH 127/140] bench: remove ObjectCache from client benchmark --- benchmarks/BenchmarkClient.client.luau | 62 -------------------------- 1 file changed, 62 deletions(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index ceec779d..ee5ec225 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -9,7 +9,6 @@ K -500 projectiles U Toggle Instanced / Instanceless J Toggle BulkMoveTo / Motor6D - Y Toggle ObjectCache V Toggle Cast Visualization X Cycle HighFidelityBehavior > Increase HighFidelitySegmentSize @@ -44,7 +43,6 @@ local Config = { SpherecastRadius = 0.5, Instanced = true, - ObjectCacheEnabled = false, VisualizeCasts = false, BenchmarkDuration = 6, @@ -148,22 +146,6 @@ local function getMode() return Config.Parallel and "PARALLEL" or "SERIAL" end -local function getObjectCacheState() - if not Caster then - return false - end - - if Caster.ObjectCacheEnabled then - return true - end - - if Caster.BaseCast and Caster.BaseCast.ObjectCacheInstance then - return true - end - - return false -end - local function getHighFidelityName() local behavior = CastBehavior.HighFidelityBehavior @@ -207,24 +189,6 @@ local function createCaster() return newCaster end -local function applyObjectCache() - if not Caster then - return - end - - if not Config.ObjectCacheEnabled then - Caster:SetObjectCacheEnabled(false) - return - end - - Caster:SetObjectCacheEnabled( - true, - ProjectileTemplate, - Config.ProjectileAmount, - ProjectileContainer - ) -end - local function destroyCaster() if Caster then Caster:Destroy() @@ -243,7 +207,6 @@ end -- Create initial caster. Caster = createCaster() -applyObjectCache() --// Benchmark Information @@ -253,12 +216,6 @@ local function printBenchmarkInfo() print(("Projectiles: %d"):format(Config.ProjectileAmount)) print(("Velocity: %.2f"):format(Config.Velocity)) print(("CastType: %s"):format(Config.CastType)) - - print( - "ObjectCache: %s", - getObjectCacheState() and "Enabled" or "Disabled" - ) - print( "Instanced: %s", Config.Instanced and "Enabled" or "Disabled" @@ -525,18 +482,6 @@ local function toggleMovementMode() print(("MovementMode: %s"):format(Config.MovementMode)) end -local function toggleObjectCache() - Config.ObjectCacheEnabled = not Config.ObjectCacheEnabled - - applyObjectCache() - - print( - ("ObjectCache: %s"):format( - Config.ObjectCacheEnabled and "Enabled" or "Disabled" - ) - ) -end - local function toggleVisualization() CastBehavior.VisualizeCasts = not CastBehavior.VisualizeCasts @@ -671,7 +616,6 @@ local function showControls() print("K - Decrease projectile amount by 500") print("U - Toggle Instanced / Instanceless") print("J - Toggle BulkMoveTo / Motor6D") - print("Y - Toggle ObjectCache") print("V - Toggle VisualizeCasts") print("X - Cycle HighFidelityBehavior") print("> - Increase HighFidelitySegmentSize") @@ -714,12 +658,6 @@ local Keybinds = { end end, - [Enum.KeyCode.Y] = function() - if not IsBenchmarking and not IsReconfiguring then - toggleObjectCache() - end - end, - [Enum.KeyCode.V] = function() if not IsBenchmarking then toggleVisualization() From f727771d5b89a1280bb0f3f7438132824c2dbbd3 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 04:03:22 -0700 Subject: [PATCH 128/140] bench: remove ObjectCache from server benchmark --- benchmarks/BenchmarkServer.server.luau | 56 -------------------------- 1 file changed, 56 deletions(-) diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index aba5d284..bc874a73 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -12,7 +12,6 @@ k - -500 projectiles u - Toggle Instanced / Instanceless j - Toggle BulkMoveTo / Motor6D - y - Toggle ObjectCache v - Toggle Cast Visualization r - Cycle cast type x - Cycle HighFidelityBehavior @@ -38,7 +37,6 @@ local Config = { Instanced = false, MovementMode = "BulkMoveTo", - ObjectCacheEnabled = false, ProjectileAmount = 500, VisualizeCasts = false, CastType = "Raycast", @@ -155,22 +153,6 @@ local function getHighFidelityName() end end -local function getObjectCacheState() - if not Caster then - return false - end - - if Caster.ObjectCacheEnabled then - return true - end - - if Caster.BaseCast and Caster.BaseCast.ObjectCacheInstance then - return true - end - - return false -end - local function getModeName() return Config.Instanced and "Instanced" or "Instanceless" end @@ -200,24 +182,6 @@ local function createCaster() return caster end -local function applyObjectCache() - if not Caster then - return - end - - if not Config.ObjectCacheEnabled then - Caster:SetObjectCacheEnabled(false) - return - end - - Caster:SetObjectCacheEnabled( - true, - ProjectileTemplate, - Config.ProjectileAmount, - ProjectileContainer - ) -end - --// Cast Cleanup local function terminateActiveCasts() @@ -530,19 +494,6 @@ local function toggleMovementMode() )) end -local function toggleObjectCache() - Config.ObjectCacheEnabled = - not Config.ObjectCacheEnabled - - applyObjectCache() - - print(("ObjectCache: %s"):format( - Config.ObjectCacheEnabled - and "Enabled" - or "Disabled" - )) -end - local function toggleVisualization() Config.VisualizeCasts = not Config.VisualizeCasts @@ -647,7 +598,6 @@ local function showHelp() print("k - Decrease projectile amount by 500") print("u - Toggle Instanced / Instanceless") print("j - Toggle BulkMoveTo / Motor6D") - print("y - Toggle ObjectCache") print("v - Toggle VisualizeCasts") print("r - Cycle Raycast / Blockcast / Spherecast") print("x - Cycle HighFidelityBehavior") @@ -688,12 +638,6 @@ local Commands = { end end, - y = function() - if not IsBenchmarking and not IsReconfiguring then - toggleObjectCache() - end - end, - v = function() if not IsBenchmarking then toggleVisualization() From fb73a2acada5399433734a247b254d636dae8860 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 04:03:36 -0700 Subject: [PATCH 129/140] bench: remove remaining stale ObjectCache references --- benchmarks/BenchmarkClient.client.luau | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/BenchmarkClient.client.luau b/benchmarks/BenchmarkClient.client.luau index ee5ec225..f2cbf779 100644 --- a/benchmarks/BenchmarkClient.client.luau +++ b/benchmarks/BenchmarkClient.client.luau @@ -202,7 +202,6 @@ local function rebuildCaster() destroyCaster() Caster = createCaster() - applyObjectCache() end -- Create initial caster. From ed6a6d3944b5240fbb21081b0910b64b7ce45018 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 04:03:39 -0700 Subject: [PATCH 130/140] bench: remove remaining stale ObjectCache references --- benchmarks/BenchmarkServer.server.luau | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/BenchmarkServer.server.luau b/benchmarks/BenchmarkServer.server.luau index bc874a73..cfa564d3 100644 --- a/benchmarks/BenchmarkServer.server.luau +++ b/benchmarks/BenchmarkServer.server.luau @@ -216,7 +216,6 @@ local function rebuildCaster() destroyCaster() Caster = createCaster() - applyObjectCache() end --// Benchmark Information From ae285c28fb21aa73495604bf7bad3f8b3aadb2e3 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 04:03:52 -0700 Subject: [PATCH 131/140] bench: remove stale ObjectCache info output From 2cc6e34b4ca10e7fa34ef14bc767483e7892cc55 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 23:27:22 +0700 Subject: [PATCH 132/140] Update changelog.md --- docs/changelog.md | 91 +++++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 7132e587..812e07c8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,56 +13,63 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) ## [Unreleased] ### Added -- Automated testing by @Naymmmm -- Better performance benchmarks by @Naymmmm -- `Config.SimulationMode` (`PerFrame`/`Fixed`) and `CosmeticUpdateInterval` by @Naymmmm -- `RAY_SEARCH_OFFSET` for pierce prevention -- `cast.Mode` -- `Signal` event dispatcher for Caster events -### Fixed -- BenchmarkClient/BenchmarkServer -- Various simulation logic bugs -- `CastTerminating` callback/cleanup issues -- Pierce prevention issues (`RAY_SEARCH_OFFSET` only applies after a pierce) -- Motor6D movement issues -- Parallel client initialization -- Refactor regressions found by the test suite -- An annoying warning -- Duplicate Serial cast unregister cleanup -- Parallel behavior snapshots now deep-copy nested configuration data - -### Changed -- `SerialSimulation` is no longer OOP-based and no longer creates a per-instance connection. -- Replaced `BindableEvent` with the Signal module. -- Simplified `FastCastParallel:Init` API. -- Added `BindToSimulation`. -- Parallel fire requests are now batched into one message per worker per frame. -- Improved Serial/Parallel event systems. -- Caster events support both `:Connect()` and function assignment. -- A lot of optimizations. -- Major code improvements. -- Improved documentation. +* Automated testing by @Naymmmm +* Better performance benchmarks by @Naymmmm +* "Config.SimulationMode" (PerFrame/Fixed) and "CosmeticUpdateInterval" by @Naymmmm +* "RAY_SEARCH_OFFSET" for pierce prevention +* "cast.Mode" +* **`Signal`** - lightweight synchronous event dispatcher (`src/Signal.luau`) used for Caster events instead of `BindableEvent` +* Caster events (`Hit`, `Pierced`, `LengthChanged`, `CastFire`, `CastTerminating`) are now Signals supporting multiple listeners, `Once`, `Wait`, `Disconnect`, `DisconnectAll`, and `Destroy` + +### Fixes + +* BenchmarkClient/BenchmarkServer +* Various simulation logic bugs +* "CastTerminating" callback/cleanup issues (Serial now invokes the user callback before cleanup) +* Pierce prevention issues ("RAY_SEARCH_OFFSET" only applies after a pierce) +* Motor6D movement issues +* Parallel client initialization (Init is now retried until every actor is ready) +* Refactor regressions found by the test suite +* An annoying warning +* Duplicate Serial cast unregister cleanup +* Parallel behavior snapshots now properly deep-copy nested configuration data + +### Changes + +* "SerialSimulation" is no longer OOP-based and no longer creates a per-instance connection. +* Replaced "BindableEvent" with Signal module (supports multiple listeners, function assignment still works) +* Simplified "FastCastParallel:Init" API +* Added "BindToSimulation" +* Consolidated "TerminateCast" +* Parallel fire requests are now batched into one message per worker per frame +* Improved Serial/Parallel event systems +* You can now `Caster.Event:Connect(function() ... end)` or `Caster.Event = function() ... end` +* Parallel workers now batch all queued events into a single `Output` message per frame instead of firing one +* `BindableEvent` message per event, reducing Actor-boundary crossings by @Naymmmm +* `CanPierce` remains a single function because it has to return a boolean by @Naymmmm +* A lot of optimizations +* Major code improvements +* Improved documentation ### Removed -- Built-in ObjectCache -- Automatic `CosmeticBulletTemplate` cleanup -### Cancelled -- Dynamic RunService event configuration for Caster -- Debugger GUI for benchmarking and testing +* Bulit-in ObjectCache + (See: + * Youtube video: https://www.youtube.com/watch?v=YyQi82TzYL4&t=61s + * Devforum post: https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed-a-powerful-modern-projectile-library/4093890/544?u=mawin_ck + ) +* Automatic CosmeticBulletTemplate clean up + (So you can have more control over CosmeticBulletTemplate) -### Added -- **`Signal`** - lightweight synchronous event dispatcher (`src/Signal.luau`) used for Caster events instead of `BindableEvent` -- Caster events (`Hit`, `Pierced`, `LengthChanged`, `CastFire`, `CastTerminating`) are now Signals supporting multiple listeners, `Once`, `Wait`, `Disconnect`, `DisconnectAll`, and `Destroy` +### Cancelled -### Changed -- Assigning a function to a caster event is now shorthand for `:Connect` (still supported for backwards compatibility) -- `CanPierce` remains a single function because it has to return a boolean +* Add dynamic RunService event configuration for Caster (feature) + (Because you can simply edit the FastCast2 code if you want to change the specific RunService event) +* Create a Debugger GUI for benchmarking and testing (debuggergui) + (Might add this back) -### Performance -- Parallel workers now batch all queued events into a single `Output` message per frame instead of firing one `BindableEvent` message per event, reducing Actor-boundary crossings --- From f75d2e2c5e0729a8ff37630515f0f782f8df5827 Mon Sep 17 00:00:00 2001 From: CK06 Date: Tue, 29 Sep 2026 23:39:06 +0700 Subject: [PATCH 133/140] Update changelog.md --- docs/changelog.md | 191 ++++++++-------------------------------------- 1 file changed, 32 insertions(+), 159 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 812e07c8..dd4eda0c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,12 +1,20 @@ --- -sidebar_position: 3 ---- + +## sidebar_position: 3 # Changelog All notable changes to this project will be documented in this file. -The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +> **Version support** +> +> FastCast2 **1.0.0 and newer** are the current and supported versions. +> +> Versions **<1.0.0** are legacy releases. They are no longer actively maintained or documented, but remain available through GitHub Releases for existing projects and historical use. --- @@ -16,9 +24,9 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) * Automated testing by @Naymmmm * Better performance benchmarks by @Naymmmm -* "Config.SimulationMode" (PerFrame/Fixed) and "CosmeticUpdateInterval" by @Naymmmm -* "RAY_SEARCH_OFFSET" for pierce prevention -* "cast.Mode" +* `"Config.SimulationMode"` (PerFrame/Fixed) and `"CosmeticUpdateInterval"` by @Naymmmm +* `"RAY_SEARCH_OFFSET"` for pierce prevention +* `"cast.Mode"` * **`Signal`** - lightweight synchronous event dispatcher (`src/Signal.luau`) used for Caster events instead of `BindableEvent` * Caster events (`Hit`, `Pierced`, `LengthChanged`, `CastFire`, `CastTerminating`) are now Signals supporting multiple listeners, `Once`, `Wait`, `Disconnect`, `DisconnectAll`, and `Destroy` @@ -26,8 +34,8 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) * BenchmarkClient/BenchmarkServer * Various simulation logic bugs -* "CastTerminating" callback/cleanup issues (Serial now invokes the user callback before cleanup) -* Pierce prevention issues ("RAY_SEARCH_OFFSET" only applies after a pierce) +* `"CastTerminating"` callback/cleanup issues (Serial now invokes the user callback before cleanup) +* Pierce prevention issues (`"RAY_SEARCH_OFFSET"` only applies after a pierce) * Motor6D movement issues * Parallel client initialization (Init is now retried until every actor is ready) * Refactor regressions found by the test suite @@ -37,173 +45,38 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) ### Changes -* "SerialSimulation" is no longer OOP-based and no longer creates a per-instance connection. -* Replaced "BindableEvent" with Signal module (supports multiple listeners, function assignment still works) -* Simplified "FastCastParallel:Init" API -* Added "BindToSimulation" -* Consolidated "TerminateCast" +* `"SerialSimulation"` is no longer OOP-based and no longer creates a per-instance connection. +* Replaced `"BindableEvent"` with Signal module (supports multiple listeners, function assignment still works) +* Simplified `"FastCastParallel:Init"` API +* Added `"BindToSimulation"` +* Consolidated `"TerminateCast"` * Parallel fire requests are now batched into one message per worker per frame * Improved Serial/Parallel event systems * You can now `Caster.Event:Connect(function() ... end)` or `Caster.Event = function() ... end` -* Parallel workers now batch all queued events into a single `Output` message per frame instead of firing one -* `BindableEvent` message per event, reducing Actor-boundary crossings by @Naymmmm -* `CanPierce` remains a single function because it has to return a boolean by @Naymmmm +* Parallel workers now batch all queued events into a single `Output` message per frame instead of firing one message per event +* `CanPierce` remains a single function because it has to return a boolean by @Naymmmm * A lot of optimizations * Major code improvements * Improved documentation ### Removed -* Bulit-in ObjectCache +* Built-in ObjectCache (See: - * Youtube video: https://www.youtube.com/watch?v=YyQi82TzYL4&t=61s - * Devforum post: https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed-a-powerful-modern-projectile-library/4093890/544?u=mawin_ck + * YouTube video: https://www.youtube.com/watch?v=YyQi82TzYL4&t=61s + * DevForum post: https://devforum.roblox.com/t/fastcast2-an-improved-version-of-fastcast-with-parallel-scripting-more-extensions-and-statically-typed-a-powerful-modern-projectile-library/4093890/544?u=mawin_ck ) -* Automatic CosmeticBulletTemplate clean up - (So you can have more control over CosmeticBulletTemplate) + +* Automatic `CosmeticBulletTemplate` cleanup + (So you can have more control over `CosmeticBulletTemplate`) ### Cancelled -* Add dynamic RunService event configuration for Caster (feature) +* Dynamic RunService event configuration for Caster (Because you can simply edit the FastCast2 code if you want to change the specific RunService event) -* Create a Debugger GUI for benchmarking and testing (debuggergui) - (Might add this back) - - ---- - -## [0.1.0] — 2026-05-07 - -### Added -- **Serial Mode** (`FastCast.new()`) - Main thread projectile simulation, simpler API -- **Parallel Mode** (`FastCast.newParallel()`) - Worker VM based parallel simulation -- **Motor6D movement mode** - New movement method using Motor6D for better performance - - Pass `"Motor6D"` as the movement mode to `caster:Init()` -- **SerialSimulation** - Single RunService with SoA pattern for Serial casts -- **ParallelSimulation** - Per-Actor SoA pattern for Parallel casts -- **Motor6DCache** - Object pooling for Motor6D instances - -### Changed -- NONE - -### Fixed -- **HighFidelityBehavior = 2 bug** - Fixed subRayDir calculation using `delta` instead of `timeIncrement` - -### Performance -- Serial: 1 global RunService handling all casts with SoA arrays -- Parallel: 1 RunService per Actor with SoA arrays within each - ---- - -## [0.0.9] — 2026-03-03 - -### Changed -- Refactored ActiveCast.luau -- Merged ActiveBlockcast.luau and ActiveSpherecast.luau with ActiveCast.luau -- Updated TypeDef, Enums, FastCastVMs -- Removed FastCast:SafeCall(func, ...) -- Changed CFrame.new() to CFrame.new(origin) in ActiveCast.luau - -### Fixed -- Spherecast not working -- Type errors -- Typo fixes -- No longer errors now when attempting to index with FastCastEvents with guarding -- Fix CanPierce logic and unnecessary things -- Fix unnamed parameters in all callback function types -- Fix incorrect union types on Caster signal fields (removed RBXScriptSignal, RBXScriptConnection) -- Fix GetVelocityCast and AddAccelerationCast signatures -- Fix SphereCastRayInfo `@type` doc copying BlockCastRayInfo -- Fix OnCastFireFunction `@type` unnamed parameters -- Remove stale RBXScriptSignal references from Caster `@type` doc -- Fixed GetAccelerationCast returning velocity instead of acceleration -- Fixed AddPositionCast, AddVelocityCast, AddAccelerationCast calling nonexistent methods -- Fixed missing return after cascading cast warn in SimulateCast and Stepped -- Fixed Destroy referencing RayHit/RayPierced instead of Hit/Pierced -- Fixed DBG_SEGMENT_SUB_COLOR2 being identical to DBG_SEGMENT_SUB_COLOR -- Fixed numWorkers assertion from > 1 to >= 1 -- Fixed BulkMoveTo double-connection guard in BindBulkMoveTo - -### Removed -- Removed unused SafeCall, material, and dead code - -### Improved -- Merged ResumeCast into PauseCast(cast, value) for simplicity -- Added missing SetPositionCast method -- Updated all doc comments to use vaildcast type consistently -- Add guarding for SetFastCastEventsModule -- Cached CastFire from require FastCastEventsModule result in SetFastCastEventsModule -- Removed unused variables in ActiveCast.luau and BaseCast.luau - -## [0.0.8] — 2026-02-21 - -### Added -- Spherecast feature — adds sphere-based casting for broader collision detection and hit testing. - -### Changed -- Blockcast visualization no longer stretched by cast length. -- Cleaned up code and performed minor refactors for readability. -- Updated documentation comments for clarity. -### Fixed -- Type errors -- FastCast2 now uses copy table instead of shared table for .newBehavior() - -## [0.0.7] - 2026-02-11 - -### Added -- Support for initial `UserData` on cast behaviors (`behavior.UserData`). -- Additional Caster interface methods to mirror ActiveCast/ActiveBlockcast functionality. - -### Changed -- Refactored core architecture into an Entity-Component-System (ECS) structure. -- Updated FastCast method calls to use object-style syntax (`FastCast:method()`). -- Renamed events for clarity: - - `RayHit` → `Hit` - - `CanRayPierce` → `CanPierce` - - `RayPierced` → `Pierced` -- Improved and corrected type definitions. -- Updated documentation comments for clarity and accuracy. - -### Removed -- Removed `BetterLengthChangedModule`. -- Removed `CanRayPierceModule` (functionality replaced by FastCastEventsModule). - -### Fixed -- Fixed crash in `Caster:Destroy()` when indexing nil components. -- Fixed module loading issues when required in parallel threads. -- Added safeguards to prevent indexing nil `FastCastEventsModule`. -- Added proper initialization checks to prevent premature Caster method usage. - ---- - -## [0.0.6] - 2026-02-08 - -### Changed -- Internal structural improvements and refactoring for stability. -- Documentation refinements and cleanup. - -### Fixed -- Various minor internal consistency fixes. - ---- - -## [0.0.5] - 2026-01-31 - -### Added -- Introduced FastCast2 testing ground. -- Added benchmarking tools. -- Added AWPTestGround for projectile behavior testing. - ---- - -## [0.0.3] - 2026-01-03 - -- UNKNOWN +* Debugger GUI for benchmarking and testing + (Might add this back) --- - -## [0.0.1] - 2025-11-23 - -- UNKNOWN From 1987f3de1ea11008abec852ea3fab7c8e3528b5e Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 30 Sep 2026 01:39:50 +0700 Subject: [PATCH 134/140] Update ActiveCast.luau --- src/ActiveCast.luau | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index 51f7a5f3..4a9169b7 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -118,6 +118,7 @@ function ActiveCast.createCastData( if cast.RayInfo.CosmeticBulletObject ~= nil then local basePart = cast.RayInfo.CosmeticBulletObject + basePart = basePart:Clone() basePart.CFrame = if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin) basePart.Parent = behavior.CosmeticBulletContainer From c0664b2def7b982cb864ce871d144ed20e6e98bd Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 30 Sep 2026 01:53:53 +0700 Subject: [PATCH 135/140] Conditionally clone CosmeticBulletObject --- src/ActiveCast.luau | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ActiveCast.luau b/src/ActiveCast.luau index 4a9169b7..20e6594f 100644 --- a/src/ActiveCast.luau +++ b/src/ActiveCast.luau @@ -118,7 +118,9 @@ function ActiveCast.createCastData( if cast.RayInfo.CosmeticBulletObject ~= nil then local basePart = cast.RayInfo.CosmeticBulletObject - basePart = basePart:Clone() + if behavior.CloneTemplate == true then + basePart = basePart:Clone() + end basePart.CFrame = if direction ~= Vector3.new() then CFrame.new(origin, origin + direction) else CFrame.new(origin) basePart.Parent = behavior.CosmeticBulletContainer From 33c1ed9c4e69b5a803adcd0fc45d2e77c5bec6aa Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 30 Sep 2026 01:55:29 +0700 Subject: [PATCH 136/140] Update TypeDefinitions.luau --- src/TypeDefinitions.luau | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/TypeDefinitions.luau b/src/TypeDefinitions.luau index 02789d2c..679f53ae 100644 --- a/src/TypeDefinitions.luau +++ b/src/TypeDefinitions.luau @@ -357,7 +357,7 @@ export type FastCastEventsConfig = { } --[=[ - @type FastCastBehavior { RaycastParams: RaycastParams?, MaxDistance: number, Acceleration: Vector3, HighFidelityBehavior: number, HighFidelitySegmentSize: number, CosmeticBulletTemplate: Instance?, CosmeticBulletContainer: Instance?, AutoIgnoreContainer: boolean, MovementMethod: "BulkMoveTo" | "Transform", FastCastEventsModuleConfig: FastCastEventsModuleConfig, VisualizeCasts: boolean, VisualizeCastSettings: VisualizeCastSettings, FastCastEventsConfig: FastCastEventsConfig, UserData: any } + @type FastCastBehavior { RaycastParams: RaycastParams?, MaxDistance: number, Acceleration: Vector3, HighFidelityBehavior: number, HighFidelitySegmentSize: number, CosmeticBulletTemplate: Instance?, CosmeticBulletContainer: Instance?, AutoIgnoreContainer: boolean, MovementMethod: "BulkMoveTo" | "Transform", FastCastEventsModuleConfig: FastCastEventsModuleConfig, VisualizeCasts: boolean, VisualizeCastSettings: VisualizeCastSettings, CloneTemplate: boolean, FastCastEventsConfig: FastCastEventsConfig, UserData: any } @within TypeDefinitions Represents a FastCastBehavior configuration. @@ -376,6 +376,8 @@ export type FastCastBehavior = { VisualizeCasts: boolean, VisualizeCastSettings: VisualizeCastSettings, + CloneTemplate: boolean, + FastCastEventsConfig: FastCastEventsConfig, UserData: any } From f84e6a5fb7c7ef7f791c09f10671cdb6a260d9ec Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 30 Sep 2026 01:56:03 +0700 Subject: [PATCH 137/140] Add CloneTemplate option to DefaultConfigs --- src/DefaultConfigs.luau | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/DefaultConfigs.luau b/src/DefaultConfigs.luau index 8657bb19..26a1eb4f 100644 --- a/src/DefaultConfigs.luau +++ b/src/DefaultConfigs.luau @@ -63,6 +63,8 @@ Defaults.FastCastBehavior = { UseCastFire = true }, + CloneTemplate = true, + FastCastEventsConfig = { UseLengthChanged = false, UseHit = true, From 07c7df8416e1ad8938bd6eb104fd11ac73f3ef23 Mon Sep 17 00:00:00 2001 From: CK06 Date: Wed, 30 Sep 2026 01:59:06 +0700 Subject: [PATCH 138/140] Update api-reference.md --- docs/api-reference.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index 09dc90b0..10ff1761 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -200,6 +200,7 @@ behavior.VisualizeCastSettings = { | `AutoIgnoreContainer` | `boolean` | `true` | Auto-adds container to filter list | | `VisualizeCasts` | `boolean` | `false` | Debug visualization toggle | | `VisualizeCastSettings` | `table` | (defaults) | Debug viz colors, sizes, lifetimes | +| `CloneTemplate` | `boolean` | `true` | Clone CosmeticBulletTemplate or not | | `UserData` | `any` | `nil` | Arbitrary data accessible on the cast | ### Event Configuration From 336f56b01895c2c0c69a6856758c4209d5da2b0a Mon Sep 17 00:00:00 2001 From: CK06 Date: Sat, 3 Oct 2026 23:10:44 +0700 Subject: [PATCH 139/140] Update init.luau --- src/init.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/init.luau b/src/init.luau index 6f5d0460..81b58565 100644 --- a/src/init.luau +++ b/src/init.luau @@ -680,7 +680,7 @@ end @param movementMode: "BulkMoveTo" | "Motor6D" -- Whether to use BulkMoveTo or Motor6D Transform for projectile movement. ]=] function FastCastSerial:Init( - movementMode: "BulkMoveTo" | "Motor6D", + movementMode: "BulkMoveTo" | "Motor6D" ) if self.BaseCast then warn("Serial Caster already initialized") From e752dcc4176afbde35d342071af8e961c5c1361c Mon Sep 17 00:00:00 2001 From: CK06 Date: Sun, 4 Oct 2026 14:53:38 +0700 Subject: [PATCH 140/140] refactor: move ParallelDispatcher and fire queue to module scope --- src/init.luau | 79 ++++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/src/init.luau b/src/init.luau index 81b58565..d9ad3c7a 100644 --- a/src/init.luau +++ b/src/init.luau @@ -426,49 +426,56 @@ local function SnapshotBehavior(behavior: any): any return copy end -local function QueueFire(self: any, request: { any }) - local queue = self._fireQueue - queue[#queue + 1] = request +local FireQueue = {} +local FireBatches = {} +local FireUsed = {} +local FireFlushConnection: RBXScriptConnection? = nil +local ParallelDispatcher = nil + +local function QueueFire(request: { any }) + FireQueue[#FireQueue + 1] = request end -local function FlushFires(self: any) - local queue = self._fireQueue - local count = #queue +local function FlushFires() + local count = #FireQueue if count == 0 then return end - local dispatcher = self.Dispatcher + local dispatcher = ParallelDispatcher local threads = dispatcher and dispatcher.Threads local workerCount = if threads then #threads else 0 + if workerCount == 0 then - table.clear(queue) + table.clear(FireQueue) return end - local batches = self._fireBatches - local used = self._fireUsed local usedCount = 0 for i = 1, count do local worker = (i - 1) % workerCount + 1 - local batch = batches[worker] + local batch = FireBatches[worker] + if not batch then batch = {} - batches[worker] = batch + FireBatches[worker] = batch usedCount += 1 - used[usedCount] = worker + FireUsed[usedCount] = worker end - batch[#batch + 1] = queue[i] + + batch[#batch + 1] = FireQueue[i] end - table.clear(queue) + + table.clear(FireQueue) for i = 1, usedCount do - local worker = used[i] - local batch = batches[worker] + local worker = FireUsed[i] + local batch = FireBatches[worker] + threads[worker]:SendMessage("FireBatch", batch) table.clear(batch) - used[i] = nil + FireUsed[i] = nil end end @@ -505,7 +512,7 @@ function FastCastParallel:Init( movementMode = movementMode, FastCastModule = script } - self.Dispatcher = newDispatcher.new(numWorkers, data, function(eventName: string, ...) + ParallelDispatcher = newDispatcher.new(numWorkers, data, function(eventName: string, ...) if eventName == "Batch" then DispatchOutputBatch(self, ...) return @@ -518,12 +525,9 @@ function FastCastParallel:Init( end) -- Queue shots and flush them once per frame (see FlushFires). - self._fireQueue = {} - self._fireBatches = {} - self._fireUsed = {} - self._flushConnection = RunService.PreSimulation:Connect(function() - FlushFires(self) - end) + if not FireFlushConnection then + FireFlushConnection = RunService.PreSimulation:Connect(FlushFires) + end self.AlreadyInit = true self.MovementMode = movementMode @@ -546,7 +550,7 @@ function FastCastParallel:SetFastCastEventsModule(moduleScript: ModuleScript) error("Please Init caster") end - self.Dispatcher:DispatchAll("SetFastCastEventsModule", moduleScript) + ParallelDispatcher:DispatchAll("SetFastCastEventsModule", moduleScript) self.FastCastEventsModule = moduleScript end @@ -573,7 +577,7 @@ function FastCastParallel:RaycastFire( BehaviorData = FastCast.newBehavior() end - QueueFire(self, { + QueueFire({ 1, origin, direction, @@ -607,7 +611,7 @@ function FastCastParallel:BlockcastFire( BehaviorData = FastCast.newBehavior() end - QueueFire(self, { + QueueFire({ 2, origin, Size, @@ -642,7 +646,7 @@ function FastCastParallel:SpherecastFire( BehaviorData = FastCast.newBehavior() end - QueueFire(self, { + QueueFire({ 3, origin, Radius, @@ -666,7 +670,7 @@ function FastCastParallel:SetMovementModeEnabled(enabled: boolean, mode: "BulkMo return end - self.Dispatcher:DispatchAll("SetMovementModeEnabled", enabled, mode) + ParallelDispatcher:DispatchAll("SetMovementModeEnabled", enabled, mode) self.MovementMode = mode end @@ -810,15 +814,20 @@ function FastCastParallel:Destroy() return end - if self._flushConnection then - self._flushConnection:Disconnect() - self._flushConnection = nil + if FireFlushConnection then + FireFlushConnection:Disconnect() + FireFlushConnection = nil end - if self.Dispatcher then - self.Dispatcher:Destroy() + if ParallelDispatcher then + ParallelDispatcher:Destroy() + ParallelDispatcher = nil end + table.clear(FireQueue) + table.clear(FireBatches) + table.clear(FireUsed) + DestroyCasterSignals(self) self.Destroy = function() end setmetatable(self, nil)