FLIGHT_PLAN = { { slot = 5, hold = 0.06, aim = "free", label = "smoke_bomb_try00" }, { slot = 5, hold = 0.12, aim = "near", label = "smoke_bomb_try01" }, { slot = 5, hold = 0.18, aim = "far", label = "smoke_bomb_try02" }, { slot = 5, hold = 0.24, aim = "free", label = "smoke_bomb_try03" }, { slot = 5, hold = 0.30, aim = "near", label = "smoke_bomb_try04" }, { slot = 5, hold = 0.38, aim = "far", label = "smoke_bomb_try05" }, { slot = 5, hold = 0.45, aim = "free", label = "smoke_bomb_try06" }, { slot = 5, hold = 0.06, aim = "near", label = "smoke_bomb_try07" }, { slot = 5, hold = 0.12, aim = "far", label = "smoke_bomb_try08" }, { slot = 5, hold = 0.18, aim = "free", label = "smoke_bomb_try09" }, { slot = 5, hold = 0.24, aim = "near", label = "smoke_bomb_try10" }, { slot = 5, hold = 0.30, aim = "far", label = "smoke_bomb_try11" }, { slot = 5, hold = 0.38, aim = "free", label = "smoke_bomb_try12" }, { slot = 5, hold = 0.45, aim = "near", label = "smoke_bomb_try13" }, { slot = 5, hold = 0.06, aim = "far", label = "smoke_bomb_try14" }, { slot = 5, hold = 0.12, aim = "free", label = "smoke_bomb_try15" }, { slot = 5, hold = 0.18, aim = "near", label = "smoke_bomb_try16" }, { slot = 5, hold = 0.24, aim = "far", label = "smoke_bomb_try17" }, { slot = 5, hold = 0.30, aim = "free", label = "smoke_bomb_try18" }, { slot = 5, hold = 0.38, aim = "near", label = "smoke_bomb_try19" }, { slot = 5, hold = 0.45, aim = "far", label = "smoke_bomb_try20" }, { slot = 5, hold = 0.06, aim = "free", label = "smoke_bomb_try21" }, { slot = 5, hold = 0.12, aim = "near", label = "smoke_bomb_try22" }, { slot = 5, hold = 0.18, aim = "far", label = "smoke_bomb_try23" }, { slot = 5, hold = 0.24, aim = "free", label = "smoke_bomb_try24" }, { slot = 5, hold = 0.30, aim = "near", label = "smoke_bomb_try25" }, { slot = 5, hold = 0.38, aim = "far", label = "smoke_bomb_try26" }, { slot = 5, hold = 0.45, aim = "free", label = "smoke_bomb_try27" }, { slot = 5, hold = 0.06, aim = "near", label = "smoke_bomb_try28" }, { slot = 5, hold = 0.12, aim = "far", label = "smoke_bomb_try29" }, { slot = 5, hold = 0.18, aim = "free", label = "smoke_bomb_try30" }, { slot = 5, hold = 0.24, aim = "near", label = "smoke_bomb_try31" }, { slot = 5, hold = 0.30, aim = "far", label = "smoke_bomb_try32" }, { slot = 5, hold = 0.38, aim = "free", label = "smoke_bomb_try33" }, { slot = 5, hold = 0.45, aim = "near", label = "smoke_bomb_try34" }, { slot = 5, hold = 0.06, aim = "far", label = "smoke_bomb_try35" }, { slot = 5, hold = 0.12, aim = "free", label = "smoke_bomb_try36" }, { slot = 5, hold = 0.18, aim = "near", label = "smoke_bomb_try37" }, { slot = 5, hold = 0.24, aim = "far", label = "smoke_bomb_try38" }, { slot = 5, hold = 0.30, aim = "free", label = "smoke_bomb_try39" }, { slot = 5, hold = 0.38, aim = "near", label = "smoke_bomb_try40" }, { slot = 5, hold = 0.45, aim = "far", label = "smoke_bomb_try41" }, { slot = 5, hold = 0.06, aim = "free", label = "smoke_bomb_try42" }, { slot = 5, hold = 0.12, aim = "near", label = "smoke_bomb_try43" }, { slot = 5, hold = 0.18, aim = "far", label = "smoke_bomb_try44" }, { slot = 5, hold = 0.24, aim = "free", label = "smoke_bomb_try45" }, { slot = 5, hold = 0.30, aim = "near", label = "smoke_bomb_try46" }, { slot = 5, hold = 0.38, aim = "far", label = "smoke_bomb_try47" }, } -- flight-measure.lua — measure real projectile flight for every held projectile weapon. -- Read-only probe: wraps Effects.Projectile / Effects.Pellets and Ballistics.Advance for the -- duration of the run, hooks WeaponAction:FireServer to read the client's charge, listens on the -- CombatFX/CombatFXU channels for ProjectileHit, aims through the game's own Camera module and -- fires through Controller.Dispatch("Primary", true/false). Everything is restored at the end. -- -- Output: one "M ..." line per shot (raw launch + samples + fit), plus "MEND". local Players = game:GetService("Players") local RS = game:GetService("ReplicatedStorage") local CollectionService = game:GetService("CollectionService") local LP = Players.LocalPlayer local char = LP.Character local Camera = require(RS.Shared.Camera) local Util = require(LP.PlayerScripts.Combat.Util) local Effects = require(LP.PlayerScripts.Combat.Effects) local Ball = require(RS.Shared.Combat.Ballistics) local C = require(LP.PlayerScripts.Combat.Controller) local Weapons = require(RS.Shared.Weapons) local function f(x, n) return string.format("%." .. (n or 4) .. "f", x) end -- shot plan: {slot, hold (seconds Primary held, nil = tap), aim = "near"/"far"/"free"} -- A caller may prepend `FLIGHT_PLAN = { ... }` to run a subset. local PLAN = FLIGHT_PLAN or { -- smoke bombs first (only 2 charges live at a time); retry later in the run { slot = 5, hold = 0.06, aim = "near", label = "smoke_bomb_c0.25" }, { slot = 5, hold = 0.40, aim = "near", label = "smoke_bomb_c1.00" }, { slot = 1, hold = 0.125, aim = "near", label = "short_bow_c0.25" }, { slot = 1, hold = 0.25, aim = "near", label = "short_bow_c0.50" }, { slot = 1, hold = 0.375, aim = "near", label = "short_bow_c0.75" }, { slot = 1, hold = 0.75, aim = "near", label = "short_bow_c1.00" }, { slot = 1, hold = 0.75, aim = "far", label = "short_bow_c1.00" }, { slot = 1, hold = 0.75, aim = "free", label = "short_bow_c1.00" }, { slot = 3, aim = "near", label = "throwing_knives" }, { slot = 3, aim = "far", label = "throwing_knives" }, { slot = 3, aim = "free", label = "throwing_knives" }, { slot = 2, aim = "near", label = "sling" }, { slot = 2, aim = "far", label = "sling" }, { slot = 2, aim = "free", label = "sling" }, { slot = 5, hold = 0.13, aim = "near", label = "smoke_bomb_c0.50_late" }, { slot = 5, hold = 0.20, aim = "near", label = "smoke_bomb_c0.75_late" }, { slot = 5, hold = 0.40, aim = "far", label = "smoke_bomb_c1.00_far_late" }, { slot = 5, hold = 0.40, aim = "free", label = "smoke_bomb_c1.00_free_late" }, } -- ---------------------------------------------------------------- hooks local projCalls, pelletCalls, fires, hits = {}, {}, {}, {} local trace = {} local pendingMine = false local origProj = Effects.Projectile Effects.Projectile = function(key, kind, origin, velocity, gravity, shooter, prediction) table.insert(projCalls, { key = key, kind = kind, origin = origin, velocity = velocity, gravity = gravity, mine = (shooter == char), t = os.clock() }) pendingMine = (shooter == char) local a, b, c, d, e, f2, g2 = origProj(key, kind, origin, velocity, gravity, shooter, prediction) pendingMine = false return a, b, c, d, e, f2, g2 end local origPellets = Effects.Pellets if type(origPellets) == "function" then Effects.Pellets = function(key, kind, origin, dir, speed, gravity, count, spread, seed, shooter, prediction) table.insert(pelletCalls, { kind = kind, origin = origin, dir = dir, speed = speed, gravity = gravity, count = count, spread = spread, mine = (shooter == char), t = os.clock() }) pendingMine = (shooter == char) local r = origPellets(key, kind, origin, dir, speed, gravity, count, spread, seed, shooter, prediction) pendingMine = false return r end end -- tag the ballistics state created for a launched projectile so Advance can be filtered to MY shots local origNew = Ball.new Ball.new = function(p1, p2, p3, p4) local s = origNew(p1, p2, p3, p4) s.__probeMine = pendingMine return s end local origAdvance = Ball.Advance Ball.Advance = function(shot, dt) if not shot.__probeMine then return origAdvance(shot, dt) end local p0, v0, g = shot.pos, shot.vel, shot.gravity local a, b = origAdvance(shot, dt) table.insert(trace, { dt = dt, p0 = p0, v0 = v0, dp = b, p1 = shot.pos, v1 = shot.vel, g = g, t = os.clock() }) return a, b end local origNamecall local nc = newcclosure(function(self, ...) if self == RS.Remotes.WeaponAction and getnamecallmethod() == "FireServer" then local action, payload = ... if action == "Shoot" then table.insert(fires, { charge = type(payload) == "table" and payload.charge or nil, dir = type(payload) == "table" and payload.dir or nil, origin = type(payload) == "table" and payload.origin or nil, t = os.clock() }) end end return origNamecall(self, ...) end) origNamecall = hookmetamethod(game, "__namecall", nc) local fxConns = {} for _, name in ipairs({ "CombatFX", "CombatFXU" }) do local r = RS.Remotes:FindFirstChild(name) if r then table.insert(fxConns, r.OnClientEvent:Connect(function(action, data) if action == "ProjectileHit" and type(data) == "table" then table.insert(hits, { key = data.key, position = data.position, expired = data.expired, part = data.part, character = data.character, t = os.clock() }) end end)) end end -- ---------------------------------------------------------------- dummy list local dummies = {} do local folder = game.Workspace:FindFirstChild("Lobby") folder = folder and folder:FindFirstChild("Range") folder = folder and folder:FindFirstChild("Dummies") if folder then for _, d in ipairs(folder:GetChildren()) do local p = d.PrimaryPart or d:FindFirstChild("HumanoidRootPart") or d:FindFirstChildWhichIsA("BasePart") if p then dummies[#dummies + 1] = { model = d, pos = p.Position, dist = (p.Position - char.HumanoidRootPart.Position).Magnitude } end end end end table.sort(dummies, function(a, b) return a.dist < b.dist end) for i, d in ipairs(dummies) do print(string.format("DUMMY i=%d name=%s dist=%.1f pos=%.1f,%.1f,%.1f", i, d.model.Name, d.dist, d.pos.X, d.pos.Y, d.pos.Z)) end -- ---------------------------------------------------------------- aiming local savedYaw, savedPitch = Camera.GetYaw(), Camera.GetPitch() -- pick the most open horizontal direction (max raycast distance) for free shots local freeYaw = savedYaw do local best, bestYaw = 0, savedYaw local o = char.Head.Position for k = 0, 23 do local yaw = k / 24 * math.pi * 2 local dir = Vector3.new(-math.sin(yaw), 0, -math.cos(yaw)) local hit = workspace:Raycast(o, dir * 600, Util.WorldParams()) local d = hit and (hit.Position - o).Magnitude or 600 if d > best then best, bestYaw = d, yaw end end freeYaw = bestYaw print(string.format("FREEYAW %.3f freedist=%.0f (savedYaw=%.3f)", freeYaw, best, savedYaw)) end -- ballistic aim: find the launch pitch to hit `target` from `origin` given speed s, accel g, and -- the game's post-normalise upward bias b (Throw adds (0,b,0) before normalising; Bow/Gun b=0). local function ballisticPitch(origin, target, s, g, b) local dx, dz = target.X - origin.X, target.Z - origin.Z local d = math.sqrt(dx * dx + dz * dz) local h = target.Y - origin.Y if d < 0.01 then return 0 end if s * s < 0.01 then return math.atan2(h, d) end local phi if g < 1e-3 then phi = math.atan2(h, d) else local disc = s ^ 4 - g * (g * d * d + 2 * h * s * s) if disc < 0 then phi = math.atan2(h, d) + 0.35 -- unreachable: lob it else phi = math.atan((s * s - math.sqrt(disc)) / (g * d)) end end -- undo the +b vertical bias the game adds after normalising local theta = phi for _ = 1, 4 do local ct = math.cos(theta) if ct < 1e-3 then break end local st = math.sin(theta) local want = ct * math.tan(phi) - b want = math.max(-0.999, math.min(0.999, want)) theta = math.asin(want) end return theta end local function setLookAt(origin, target, s, g, b) local dx, dz = target.X - origin.X, target.Z - origin.Z local yaw = math.atan2(-dx, -dz) local theta = ballisticPitch(origin, target, s, g, b) Camera.SetLook(yaw, theta) end local kindBias = { Bomb = 0.16, Sling = 0.05, Javelin = 0.04 } local function biasFor(kind) return kindBias[kind] or 0.01 end -- ---------------------------------------------------------------- run local function equipSlot(slot) LP:SetAttribute("EquipSlot", slot) local deadline = os.clock() + 3 while os.clock() < deadline do local t = char:FindFirstChildWhichIsA("Tool") if t and t:GetAttribute("SlotIndex") == slot then return t end task.wait(0.03) end return char:FindFirstChildWhichIsA("Tool") end local function analyze(rec) local O, V, g = rec.origin, rec.velocity, rec.gravity local speed = V.Magnitude local vh = math.sqrt(V.X * V.X + V.Z * V.Z) local pitch = math.deg(math.asin(math.clamp(V.Y / math.max(speed, 1e-6), -1, 1))) -- trace samples local samples = {} local t, d = 0, 0 local targets = { 20, 50, 100, 150 } local ti = 1 local maxResid, maxKin = 0, 0 local gsum, gwt = 0, 0 local prev = nil for _, st in ipairs(rec.steps) do local expect = st.v0 * st.dt + Vector3.new(0, -0.5 * st.g * st.dt * st.dt, 0) local r = (st.dp - expect).Magnitude if r > maxResid then maxResid = r end if math.abs(st.dt) > 1e-6 then gsum = gsum + (st.v0.Y - st.v1.Y) / st.dt * st.dt gwt = gwt + st.dt end local d0 = math.sqrt((st.p0.X - O.X) ^ 2 + (st.p0.Z - O.Z) ^ 2) local d1 = math.sqrt((st.p1.X - O.X) ^ 2 + (st.p1.Z - O.Z) ^ 2) local t1 = t + st.dt -- kinematic model residual: p1 vs O + V*t1 + (0,-0.5 g t1^2,0) local model = O + V * t1 + Vector3.new(0, -0.5 * g * t1 * t1, 0) local kr = (st.p1 - model).Magnitude if kr > maxKin then maxKin = kr end -- sample crossings of horizontal distance (evaluate the verified kinematic model at t*) while ti <= #targets and d1 >= targets[ti] and d1 > d0 + 1e-9 do local w = (targets[ti] - d0) / (d1 - d0) local tt = t + st.dt * w local ty = O.Y + V.Y * tt - 0.5 * g * tt * tt -- model y at the crossing time local line = O.Y + (V.Y / math.max(vh, 1e-6)) * targets[ti] local drop = line - ty local lift = 0.5 * g * (targets[ti] / math.max(speed, 1e-6)) ^ 2 samples[#samples + 1] = { dist = targets[ti], t = tt, y = ty - O.Y, drop = drop, lift = lift } ti = ti + 1 end t = t1; d = d1 prev = st end rec.speed = speed; rec.vh = vh; rec.pitch = pitch rec.samples = samples rec.maxResid = maxResid rec.maxKin = maxKin rec.gMeasured = gwt > 0 and gsum / gwt or nil rec.endT = t rec.endPos = prev and prev.p1 or nil end local report = {} for si, shot in ipairs(PLAN) do local tool = equipSlot(shot.slot) task.wait(0.5) local weaponId = tool and tool:GetAttribute("WeaponId") local def = weaponId and Weapons.Get(weaponId) local tuning = def and def.tuning or {} local kind = def and def.kind -- nominal speed for aiming local nominal = tuning.speed or tuning.speedMax or 200 if shot.hold and tuning.speedMin and tuning.speedMax and tuning.drawTime then local ch = math.min(shot.hold / tuning.drawTime, 1) nominal = tuning.speedMin + (tuning.speedMax - tuning.speedMin) * ch end local origin = Util.ShotOrigin(tool, kind == "Bow" and "Muzzle" or "Tip") local target if shot.aim == "free" then Camera.SetLook(freeYaw, math.rad(40)) else local idx if shot.aim == "near" then idx = 1 else idx = #dummies end local dd = dummies[idx] target = dd and (dd.pos + Vector3.new(0, 0.0, 0)) if target then setLookAt(origin, target, nominal, tuning.gravity or 60, biasFor(kind)) end end task.wait(0.1) local nF, nH, nP, nA = #fires, #hits, #projCalls, #trace local t0 = os.clock() C.Dispatch("Primary", true) if shot.hold then task.wait(shot.hold) end if shot.hold then C.Dispatch("Primary", false) else task.wait(0.05); C.Dispatch("Primary", false) end -- wait for impact, or for the trace to stop growing local lastN, lastGrow = nA, os.clock() local deadline = os.clock() + 6.5 local armed = os.clock() + 0.7 -- give the client this long to send FireServer/Projectile while os.clock() < deadline do if #hits > nH then break end if #trace > lastN then lastN = #trace; lastGrow = os.clock() end if #trace > nA and os.clock() - lastGrow > 0.55 then break end if #fires == nF and #projCalls == nP and os.clock() > armed then -- never fired (out of charges / wrong tool); stop waiting if os.clock() > armed + 0.5 then break end end task.wait(0.02) end task.wait(0.15) local fire = (#fires > nF) and fires[#fires] or nil local proj = (#projCalls > nP) and projCalls[#projCalls] or nil local hit if proj and proj.key ~= nil then for i = nH + 1, #hits do if hits[i].key == proj.key then hit = hits[i] break end end end local rec = { label = shot.label, weaponId = weaponId, kind = kind, aim = shot.aim, hold = shot.hold, chargeSent = fire and fire.charge, origin = proj and proj.origin, velocity = proj and proj.velocity, gravity = proj and proj.gravity or tuning.gravity, dirSent = fire and fire.dir, hit = hit and hit.position, expired = hit and hit.expired, hitPart = hit and hit.part and hit.part.Name, hitTarget = hit and hit.character and hit.character.Name, fired = fire ~= nil, steps = {} } for i = nA + 1, #trace do rec.steps[#rec.steps + 1] = trace[i] end if rec.origin and rec.velocity and #rec.steps > 0 then analyze(rec) end report[#report + 1] = rec task.wait(0.35) end -- ---------------------------------------------------------------- restore Camera.SetLook(savedYaw, savedPitch) Effects.Projectile = origProj if type(origPellets) == "function" then Effects.Pellets = origPellets end Ball.new = origNew Ball.Advance = origAdvance hookmetamethod(game, "__namecall", origNamecall) for _, c in ipairs(fxConns) do c:Disconnect() end LP:SetAttribute("EquipSlot", 1) -- ---------------------------------------------------------------- print for _, r in ipairs(report) do local p = { "M", "label=" .. tostring(r.label), "aim=" .. tostring(r.aim), "weapon=" .. tostring(r.weaponId), "kind=" .. tostring(r.kind), "fired=" .. tostring(r.fired), "hold=" .. tostring(r.hold), "chargeSent=" .. tostring(r.chargeSent and f(r.chargeSent, 4) or "nil"), "gPassed=" .. tostring(r.gravity), "speed=" .. tostring(r.speed and f(r.speed, 3) or "nil"), "pitchDeg=" .. tostring(r.pitch and f(r.pitch, 2) or "nil"), "gMeas=" .. tostring(r.gMeasured and f(r.gMeasured, 3) or "nil"), "O=" .. tostring(r.origin), "V=" .. tostring(r.velocity), "hit=" .. tostring(r.hit), "expired=" .. tostring(r.expired), "hitPart=" .. tostring(r.hitPart), "hitTarget=" .. tostring(r.hitTarget), "nSteps=" .. tostring(#r.steps), "endT=" .. tostring(r.endT and f(r.endT, 3) or "nil"), "endPos=" .. tostring(r.endPos), "maxResid=" .. tostring(r.maxResid and f(r.maxResid, 6) or "nil"), "maxKin=" .. tostring(r.maxKin and f(r.maxKin, 6) or "nil"), } print(table.concat(p, " ")) if r.samples then for _, s in ipairs(r.samples) do print(string.format("SAMPLE label=%s d=%.0f t=%.4f y=%.4f drop=%.4f lift=%.4f ratio=%.4f", r.label, s.dist, s.t, s.y, s.drop, s.lift, s.lift / math.max(s.drop, 1e-9))) end end if r.chargeSent ~= nil and r.speed and r.weaponId then local d2 = Weapons.Get(r.weaponId) local t2 = d2 and d2.tuning if t2 and t2.speedMin and t2.speedMax then local ch = r.speed and (r.speed - t2.speedMin) / (t2.speedMax - t2.speedMin) print(string.format("CHARGE label=%s chargeSent=%.4f speed=%s formula=(%.1f+(%.1f-%.1f)*c) chargeBacksolved=%.4f speedModel=%.3f err=%.4f", r.label, r.chargeSent or -1, f(r.speed, 3), t2.speedMin, t2.speedMax, t2.speedMin, ch, t2.speedMin + (t2.speedMax - t2.speedMin) * (r.chargeSent or 0), r.speed - (t2.speedMin + (t2.speedMax - t2.speedMin) * (r.chargeSent or 0)))) end end end print("MEND shots=" .. #report)