diff --git a/KISSMultiplayer/lua/ge/extensions/kissconfig.lua b/KISSMultiplayer/lua/ge/extensions/kissconfig.lua index ca65bab5..7f88ca91 100644 --- a/KISSMultiplayer/lua/ge/extensions/kissconfig.lua +++ b/KISSMultiplayer/lua/ge/extensions/kissconfig.lua @@ -1,6 +1,16 @@ local M = {} local imgui = ui_imgui +local function clamp_min(value, minimum) + if value == nil then return nil end + return math.max(minimum, value) +end + +local function clamp_int_min(value, minimum) + if value == nil then return nil end + return math.max(minimum, math.floor(value)) +end + local function generate_base_secret() math.randomseed(os.time() + os.clock()) local result = "" @@ -24,7 +34,7 @@ local function save_config() window_opacity = kissui.window_opacity[0], enable_view_distance = kissui.enable_view_distance[0], view_distance = kissui.view_distance[0], - base_secret_v2 = secret + base_secret_v2 = secret, } local file = io.open("./settings/kissmp_config.json", "w") file:write(jsonEncode(result)) diff --git a/KISSMultiplayer/lua/ge/extensions/kissmp/ui/main.lua b/KISSMultiplayer/lua/ge/extensions/kissmp/ui/main.lua index c82a58bf..5132e441 100644 --- a/KISSMultiplayer/lua/ge/extensions/kissmp/ui/main.lua +++ b/KISSMultiplayer/lua/ge/extensions/kissmp/ui/main.lua @@ -41,6 +41,10 @@ local function draw(dt) kissui.tabs.settings.draw() imgui.EndTabItem() end + if imgui.BeginTabItem("Tuning") then + kissui.tabs.tuning.draw() + imgui.EndTabItem() + end imgui.EndTabBar() end end diff --git a/KISSMultiplayer/lua/ge/extensions/kissmp/ui/tabs/tuning.lua b/KISSMultiplayer/lua/ge/extensions/kissmp/ui/tabs/tuning.lua new file mode 100644 index 00000000..dc95c66f --- /dev/null +++ b/KISSMultiplayer/lua/ge/extensions/kissmp/ui/tabs/tuning.lua @@ -0,0 +1,89 @@ +local M = {} +local imgui = ui_imgui + +-- Receiver-side velocity smoothing rate (Hz cutoff) consumed by kiss_sync.lua. +-- Default must match kiss_sync.M.REMOTE_VEL_SMOOTH_RATE so the slider state +-- matches the vehicle-side state on first open. +local vel_rate = imgui.FloatPtr(2.0) +local prediction_offset_ms = imgui.FloatPtr(0.0) +local linear_pull_scale = imgui.FloatPtr(1.0) +local angular_pull_scale = imgui.FloatPtr(0.65) +local owner_teleport_cooldown_ms = imgui.FloatPtr(500.0) +local remote_teleport_cooldown_ms = imgui.FloatPtr(500.0) +local teleport_reset_delay_ms = imgui.FloatPtr(500.0) + +local function build_command() + return string.format( + "kiss_sync.set_smoothing_tuning(%f, %f); kiss_transforms.set_linear_pull_scale(%f); kiss_transforms.set_angular_pull_scale(%f)", + vel_rate[0], + prediction_offset_ms[0] * 0.001, + linear_pull_scale[0], + angular_pull_scale[0] + ) +end + +local function push_to_vehicle(vehicle) + if not vehicle then return end + vehicle:queueLuaCommand(build_command()) + if vehiclemanager and vehiclemanager.set_teleport_tuning then + vehiclemanager.set_teleport_tuning( + owner_teleport_cooldown_ms[0] * 0.001, + remote_teleport_cooldown_ms[0] * 0.001, + teleport_reset_delay_ms[0] * 0.001 + ) + end +end + +local function push_to_all_vehicles() + local cmd = build_command() + if vehiclemanager and vehiclemanager.set_teleport_tuning then + vehiclemanager.set_teleport_tuning( + owner_teleport_cooldown_ms[0] * 0.001, + remote_teleport_cooldown_ms[0] * 0.001, + teleport_reset_delay_ms[0] * 0.001 + ) + end + for i = 0, be:getObjectCount() do + local vehicle = be:getObject(i) + if vehicle then + vehicle:queueLuaCommand(cmd) + end + end +end + +local function draw() + imgui.PushTextWrapPos(0) + imgui.Text("Receiver-side velocity smoothing.") + imgui.Text("Higher = tracks new packets faster, less smoothing.") + imgui.Text("Lower = heavier smoothing, more lag.") + imgui.PopTextWrapPos() + imgui.Separator() + + if imgui.SliderFloat("Velocity smooth rate", vel_rate, 0.0, 30.0, "%.1f Hz") then + push_to_all_vehicles() + end + if imgui.SliderFloat("Prediction offset", prediction_offset_ms, -80.0, 80.0, "%.0f ms") then + push_to_all_vehicles() + end + if imgui.SliderFloat("Linear pull scale", linear_pull_scale, 0.5, 1.5, "%.2fx") then + push_to_all_vehicles() + end + if imgui.SliderFloat("Angular pull scale", angular_pull_scale, 0.2, 1.2, "%.2fx") then + push_to_all_vehicles() + end + if imgui.SliderFloat("Owner teleport cooldown", owner_teleport_cooldown_ms, 0.0, 1500.0, "%.0f ms") then + push_to_all_vehicles() + end + if imgui.SliderFloat("Remote teleport cooldown", remote_teleport_cooldown_ms, 0.0, 1500.0, "%.0f ms") then + push_to_all_vehicles() + end + if imgui.SliderFloat("Teleport reset delay", teleport_reset_delay_ms, 0.0, 1500.0, "%.0f ms") then + push_to_all_vehicles() + end +end + +M.draw = draw +M.push_to_vehicle = push_to_vehicle +M.push_to_all_vehicles = push_to_all_vehicles + +return M diff --git a/KISSMultiplayer/lua/ge/extensions/kisstransform.lua b/KISSMultiplayer/lua/ge/extensions/kisstransform.lua index e611f114..cd754b2f 100644 --- a/KISSMultiplayer/lua/ge/extensions/kisstransform.lua +++ b/KISSMultiplayer/lua/ge/extensions/kisstransform.lua @@ -8,6 +8,7 @@ M.received_transforms = {} M.local_transforms = {} M.raw_positions = {} M.inactive = {} +M.teleport_cooldowns = {} M.threshold = 3 M.rot_threshold = 2.5 @@ -15,24 +16,145 @@ M.velocity_error_limit = 10 M.hidden = {} +local DEBUG_GLOBAL = false + +-- BeamNG auto-loads lua/vehicle/extensions/*.lua but does not recurse into +-- subfolders. The kiss_mp/* extensions need an explicit addModulePath + +-- loadModulesInDirectory call to become available in vehicle Lua. Prepended +-- to every queueLuaCommand into a kiss_mp/* module so the call is self-healing +-- if the vehicle Lua context ever resets. +local VEHICLE_SYNC_BOOTSTRAP = "extensions.addModulePath('lua/vehicle/extensions/kiss_mp'); extensions.loadModulesInDirectory('lua/vehicle/extensions/kiss_mp'); " + +local function queue_kiss_command(vehicle, command) + if not vehicle then return end + vehicle:queueLuaCommand(VEHICLE_SYNC_BOOTSTRAP .. command) +end + +-- Cluster snap used only for large recovery corrections. The target position +-- passed here must be a refnode/origin position, not COG. +local function apply_cluster_target(vehicle_id, + target_origin_x, target_origin_y, target_origin_z, + target_rotation_x, target_rotation_y, target_rotation_z, target_rotation_w, + target_velocity_x, target_velocity_y, target_velocity_z) + local veh = be:getObjectByID(vehicle_id) + if not veh then return end + local ref_node_id = veh:getRefNodeId() + + local current_rotation = quatFromDir(-veh:getDirectionVector(), veh:getDirectionVectorUp()) + local target_rotation = quat(target_rotation_x, target_rotation_y, target_rotation_z, target_rotation_w) + local relative_rotation = current_rotation:inversed() * target_rotation + + veh:setClusterPosRelRot(ref_node_id, target_origin_x, target_origin_y, target_origin_z, + relative_rotation.x, relative_rotation.y, relative_rotation.z, relative_rotation.w) + + local local_velocity = vec3(veh:getVelocity()) + local rotated_local_velocity = local_velocity:rotated(relative_rotation) + veh:applyClusterVelocityScaleAdd(ref_node_id, 1, + target_velocity_x - rotated_local_velocity.x, + target_velocity_y - rotated_local_velocity.y, + target_velocity_z - rotated_local_velocity.z) +end + +local function queue_cog_snap(vehicle, transform) + local position, rotation = transform.position, transform.rotation + local velocity = transform.velocity or {0, 0, 0} + local angular_velocity = transform.angular_velocity or {0, 0, 0} + if not (position and rotation and #position >= 3 and #rotation >= 4) then return end + queue_kiss_command(vehicle, + "kiss_transforms.snap_to_cog_target(" + ..position[1]..","..position[2]..","..position[3].."," + ..rotation[1]..","..rotation[2]..","..rotation[3]..","..rotation[4].."," + ..(velocity[1] or 0)..","..(velocity[2] or 0)..","..(velocity[3] or 0).."," + ..(angular_velocity[1] or 0)..","..(angular_velocity[2] or 0)..","..(angular_velocity[3] or 0)..")" + ) +end + +-- Finite-number guard. Rejects NaN and +/-Inf by checking against a sane +-- world-coordinate range. Used to prevent garbage from flowing into +-- cluster pose application (BeamNG silently accepts NaN and then breaks the +-- vehicle) and as the common shape for future wire-side validation. +local function is_finite_number(x) + if type(x) ~= "number" then return false end + -- NaN != NaN; also reject absurd magnitudes that indicate physics blow-up. + if x ~= x then return false end + if x > 1e8 or x < -1e8 then return false end + return true +end + +local function is_finite_transform(position, rotation) + if #position < 3 or #rotation < 4 then return false end + return is_finite_number(position[1]) and is_finite_number(position[2]) and is_finite_number(position[3]) + and is_finite_number(rotation[1]) and is_finite_number(rotation[2]) + and is_finite_number(rotation[3]) and is_finite_number(rotation[4]) +end local function update(dt) - if not network.connection.connected then return end - -- Get rotation/angular velocity from vehicle lua + if DEBUG_GLOBAL then + print("[kisstransform.update] START dt=" .. tostring(dt) .. " received_transforms=" .. tostring(#M.received_transforms)) + end + + if not network.connection.connected then + if DEBUG_GLOBAL then print("[kisstransform.update] BLOCKED: not connected") end + return + end + + for id, remaining in pairs(M.teleport_cooldowns) do + remaining = remaining - dt + if remaining <= 0 then + M.teleport_cooldowns[id] = nil + else + M.teleport_cooldowns[id] = remaining + end + end + + -- Refresh each vehicle's local transform cache. Only owned vehicles send + -- this cache over the network, but remote vehicles still need their vehicle + -- Lua modules loaded before receiver-side correction runs. for i = 0, be:getObjectCount() do local vehicle = be:getObject(i) - if vehicle and (not M.inactive[vehicle:getID()]) then - vehicle:queueLuaCommand("kiss_vehicle.update_transform_info()") + local vid = vehicle and vehicle:getID() + if vehicle and (not M.inactive[vid]) then + local owned = vehiclemanager.ownership[vid] ~= nil + queue_kiss_command(vehicle, "kiss_vehicle.update_transform_info(" .. tostring(owned) .. ")") end end -- Don't apply velocity while paused. If we do, velocity gets stored up and released when the game resumes. local apply_velocity = not bullettime.getPause() + if DEBUG_GLOBAL and not apply_velocity then + print("[kisstransform.update] BLOCKED: game paused (bullettime.getPause()=true)") + end + + if DEBUG_GLOBAL then + print("[kisstransform.update] apply_velocity=" .. tostring(apply_velocity) .. " iterating received_transforms...") + end + for id, transform in pairs(M.received_transforms) do - --apply_transform(dt, id, transform, apply_velocity) + if DEBUG_GLOBAL then + print("[kisstransform.update] Processing vehicle id=" .. tostring(id)) + end + local vehicle = be:getObjectByID(id) local p = vec3(transform.position) - if vehicle and apply_velocity and (not vehiclemanager.ownership[id]) then + local teleport_cooldown = M.teleport_cooldowns[id] + + if not vehicle then + if DEBUG_GLOBAL then print("[kisstransform.update] BLOCKED: vehicle " .. tostring(id) .. " not found") end + elseif teleport_cooldown and teleport_cooldown > 0 then + if not M.inactive[id] then + vehicle:setActive(0) + M.inactive[id] = true + end + if DEBUG_GLOBAL then print("[kisstransform.update] BLOCKED: teleport cooldown for vehicle " .. tostring(id)) end + elseif not apply_velocity then + if DEBUG_GLOBAL then print("[kisstransform.update] BLOCKED: apply_velocity=false for vehicle " .. tostring(id)) end + elseif vehiclemanager.ownership[id] then + if DEBUG_GLOBAL then print("[kisstransform.update] BLOCKED: we own vehicle " .. tostring(id)) end + else if ((p:distance(vec3(getCameraPosition())) > kissui.view_distance[0])) and kissui.enable_view_distance[0] then + if DEBUG_GLOBAL then + local dist = p:distance(vec3(getCameraPosition())) + print("[kisstransform.update] BLOCKED: vehicle " .. tostring(id) .. " outside view distance (dist=" .. dist .. ")") + end if (not M.inactive[id]) then vehicle:setActive(0) M.inactive[id] = true @@ -41,18 +163,45 @@ local function update(dt) if M.inactive[id] then vehicle:setActive(1) M.inactive[id] = false + -- Reactivated replicas can be far from the authority because + -- setActive(0) freezes local physics. Snap once, then resume the + -- normal per-frame correction path. + queue_cog_snap(vehicle, transform) + if DEBUG_GLOBAL then print("[kisstransform.update] Reactivated vehicle " .. tostring(id)) end + end + if DEBUG_GLOBAL then + print("[kisstransform.update] QUEUING update for vehicle " .. tostring(id)) end - vehicle:queueLuaCommand("kiss_transforms.set_target_transform(" .. string.format("%q", jsonEncode(transform)) .. ")") - vehicle:queueLuaCommand("kiss_transforms.update("..dt..")") + -- Per-frame correction runs from kiss_transforms.updateGFX inside + -- vehicle Lua. GE only handles activity/view-distance state here. end end end + + if DEBUG_GLOBAL then + print("[kisstransform.update] END") + end end local function update_vehicle_transform(data) local transform = data.transform transform.owner = data.vehicle_id transform.sent_at = data.sent_at + transform.send_timer = data.send_timer + transform.ping_ms = data.ping_ms + transform.send_dt = data.send_dt + transform.receiver_ping_ms = network.connection.rtt_smooth_ms or network.connection.ping or 0 + + -- Normalize quaternion in place so all downstream consumers (vehicle-Lua + -- try_rude predicted-pose comparison, kiss_sync snapshot buffer, and + -- COG-aware recovery snaps) see a unit quaternion. + local r = transform.rotation + if r and #r >= 4 then + local n = math.sqrt(rotation[1]*rotation[1] + rotation[2]*rotation[2] + rotation[3]*rotation[3] + rotation[4]*rotation[4]) + if n > 1e-9 then + rotation[1], rotation[2], rotation[3], rotation[4] = rotation[1]/n, rotation[2]/n, rotation[3]/n, rotation[4]/n + end + end local id = vehiclemanager.id_map[transform.owner or -1] or -1 if vehiclemanager.ownership[id] then return end @@ -61,8 +210,10 @@ local function update_vehicle_transform(data) local vehicle = be:getObjectByID(id) if vehicle and (not M.inactive[id]) then - transform.time_past = clamp(vehiclemanager.get_current_time() - transform.sent_at, 0, 0.1) * 0.9 + 0.001 - vehicle:queueLuaCommand("kiss_transforms.set_target_transform(" .. string.format("%q", jsonEncode(transform)) .. ")") + -- Packet arrival hands the new authoritative COG pose to kiss_sync. + -- Application happens per-frame from kiss_transforms.update(dt), not on + -- packet arrival. + queue_kiss_command(vehicle, "kiss_transforms.set_target_transform(" .. string.format("%q", jsonEncode(transform)) .. ")") end end @@ -70,10 +221,18 @@ local function push_transform(id, t) M.local_transforms[id] = jsonDecode(t) end +local function set_teleport_cooldown(vehicle_id, duration) + M.teleport_cooldowns[vehicle_id] = math.max(duration or 0.35, 0) +end + M.send_transform_updates = send_transform_updates M.send_vehicle_transform = send_vehicle_transform M.update_vehicle_transform = update_vehicle_transform M.push_transform = push_transform +M.queue_kiss_command = queue_kiss_command +M.queue_cog_snap = queue_cog_snap +M.set_teleport_cooldown = set_teleport_cooldown +M.apply_cluster_target = apply_cluster_target M.onUpdate = update return M diff --git a/KISSMultiplayer/lua/ge/extensions/kissui.lua b/KISSMultiplayer/lua/ge/extensions/kissui.lua index e46d140d..aa9fd2b2 100644 --- a/KISSMultiplayer/lua/ge/extensions/kissui.lua +++ b/KISSMultiplayer/lua/ge/extensions/kissui.lua @@ -14,6 +14,7 @@ M.tabs = { settings = require("kissmp.ui.tabs.settings"), direct_connect = require("kissmp.ui.tabs.direct_connect"), create_server = require("kissmp.ui.tabs.create_server"), + tuning = require("kissmp.ui.tabs.tuning"), } M.dependencies = {"ui_imgui"} diff --git a/KISSMultiplayer/lua/ge/extensions/network.lua b/KISSMultiplayer/lua/ge/extensions/network.lua index 905f3690..3e14846f 100644 --- a/KISSMultiplayer/lua/ge/extensions/network.lua +++ b/KISSMultiplayer/lua/ge/extensions/network.lua @@ -13,7 +13,13 @@ local current_download = nil local socket = require("socket") local messagepack = require("lua/common/libs/Lua-MessagePack/MessagePack") -local ping_send_time = 0 +local ping_seq = 0 +local pending_pings = {} +local last_pong_seq = 0 +local RTT_ALPHA = 0.15 +local JITTER_ALPHA = 0.15 +local RTT_MIN_WINDOW_S = 5.0 +local MAX_RTT_SAMPLE_S = 1.0 M.players = {} M.socket = socket @@ -27,7 +33,11 @@ M.connection = { tickrate = 33, mods_left = 0, ping = 0, - time_offset = 0 + time_offset = 0, + rtt_raw_ms = 0, + rtt_smooth_ms = 0, + rtt_min_ms = 0, + jitter_ms = 0, } local FILE_TRANSFER_CHUNK_SIZE = 16384; @@ -56,6 +66,32 @@ time_offset_smoother.get = function(new_sample) return sum / n end +local rtt_min_window = {} + +local function update_rtt_min_window(now_s, rtt_s) + rtt_min_window[#rtt_min_window + 1] = {time = now_s, rtt = rtt_s} + local keep_from = now_s - RTT_MIN_WINDOW_S + local write = 1 + local min_rtt = nil + + for read = 1, #rtt_min_window do + local sample = rtt_min_window[read] + if sample.time >= keep_from then + rtt_min_window[write] = sample + write = write + 1 + if not min_rtt or sample.rtt < min_rtt then + min_rtt = sample.rtt + end + end + end + + for i = #rtt_min_window, write, -1 do + rtt_min_window[i] = nil + end + + return min_rtt or rtt_s +end + local function bytesToU32(str) if not str or #str < 4 then return 0 end local b1, b2, b3, b4 = str:byte(1, 4) @@ -75,6 +111,16 @@ local function disconnect(data) kissui.chat.add_message(text) M.connection.connected = false M.connection.tcp:close() + pending_pings = {} + rtt_min_window = {} + ping_seq = 0 + last_pong_seq = 0 + M.connection.ping = 0 + M.connection.time_offset = 0 + M.connection.rtt_raw_ms = 0 + M.connection.rtt_smooth_ms = 0 + M.connection.rtt_min_ms = 0 + M.connection.jitter_ms = 0 M.players = {} kissplayers.players = {} kissplayers.player_transforms = {} @@ -156,13 +202,36 @@ local function handle_vehicle_lua(data) end local function handle_pong(data) - local server_time = data local local_time = socket.gettime() - local ping = local_time - ping_send_time - if ping > 1 then return end - local time_diff = server_time - local_time + (ping / 2) + if not data or not data.seq then return end + if data.seq <= last_pong_seq then return end + + local sent = pending_pings[data.seq] + pending_pings[data.seq] = nil + if not sent then return end + + local ping = local_time - (data.client_send_time or sent.client_send_time or local_time) + if ping <= 0 or ping > MAX_RTT_SAMPLE_S then return end + + last_pong_seq = data.seq + local previous_smooth_s = (M.connection.rtt_smooth_ms or 0) * 0.001 + local smooth_s = previous_smooth_s > 0 + and (previous_smooth_s + (ping - previous_smooth_s) * RTT_ALPHA) + or ping + local jitter_sample_s = previous_smooth_s > 0 and math.abs(ping - previous_smooth_s) or 0 + local previous_jitter_s = (M.connection.jitter_ms or 0) * 0.001 + local jitter_s = previous_jitter_s > 0 + and (previous_jitter_s + (jitter_sample_s - previous_jitter_s) * JITTER_ALPHA) + or jitter_sample_s + local min_rtt_s = update_rtt_min_window(local_time, ping) + + local time_diff = (data.server_send_time or local_time) - local_time + (ping * 0.5) M.connection.time_offset = time_offset_smoother.get(time_diff) M.connection.ping = ping * 1000 + M.connection.rtt_raw_ms = ping * 1000 + M.connection.rtt_smooth_ms = smooth_s * 1000 + M.connection.rtt_min_ms = min_rtt_s * 1000 + M.connection.jitter_ms = jitter_s * 1000 end local function handle_player_disconnected(data) @@ -395,10 +464,23 @@ local function on_finished_download() end local function send_ping() - ping_send_time = socket.gettime() + local now = socket.gettime() + ping_seq = ping_seq + 1 + for seq, sample in pairs(pending_pings) do + if (now - (sample.client_send_time or now)) > MAX_RTT_SAMPLE_S then + pending_pings[seq] = nil + end + end + pending_pings[ping_seq] = { + client_send_time = now, + } send_data( { - Ping = math.floor(M.connection.ping), + Ping = { + seq = ping_seq, + client_send_time = now, + reported_ping_ms = math.floor(M.connection.rtt_smooth_ms or M.connection.ping or 0), + }, }, false ) diff --git a/KISSMultiplayer/lua/ge/extensions/vehiclemanager.lua b/KISSMultiplayer/lua/ge/extensions/vehiclemanager.lua index 05c5a2e7..5ab3cd2e 100644 --- a/KISSMultiplayer/lua/ge/extensions/vehiclemanager.lua +++ b/KISSMultiplayer/lua/ge/extensions/vehiclemanager.lua @@ -8,6 +8,17 @@ local meta_timer = 0 local colors_buffer = {} local plates_buffer = {} local first_vehicle = true +local pending_initial_vehicle_sync = false +local initial_vehicle_sync_timer = 0 +local last_position_buffer = {} -- Track last positions for teleport detection +local teleport_reset_timers = {} -- vehicle_id -> seconds remaining until reset is sent +local owner_teleport_cooldowns = {} -- vehicle_id -> seconds remaining before owner physics resumes +local last_bad_packet_log = {} -- vehicle_id -> last timestamp we warned about NaN/Inf (throttle) +local TELEPORT_THRESHOLD = 200.0 -- Meters - position jump in one tick that indicates a teleport +local TELEPORT_RESET_DELAY = 0.5 -- Seconds to wait after teleport before sending ResetVehicle +local OWNER_TELEPORT_COOLDOWN = 0.5 -- Seconds to keep owner physics settled after local teleport +local REMOTE_TELEPORT_COOLDOWN = 0.5 -- Seconds to keep remote physics settled after received teleport +local CLUSTER_LINEAR_DEADBAND = 0.05 M.loading_map = false M.id_map = {} @@ -15,6 +26,7 @@ M.server_ids = {} M.ownership = {} M.vehicle_updates_buffer = {} M.packet_gen_buffer = {} +M.packet_timer_buffer = {} M.is_network_session = false M.delay_spawns = false M.vehicle_buffer = {} @@ -53,31 +65,110 @@ local function colors_eq(a, b) return color_eq(a[1], b[1]) and color_eq(a[2], b[2]) and color_eq(a[3], b[3]) end +local function zero_small_vec_components(x, y, z, deadband) + if math.sqrt(x * x + y * y + z * z) < deadband then + return 0, 0, 0 + end + return x, y, z +end + +local function clamp_seconds(value, fallback) + if type(value) ~= "number" then return fallback end + return math.max(0, math.min(value, 2.0)) +end + +local function set_teleport_tuning(owner_cooldown, remote_cooldown, reset_delay) + OWNER_TELEPORT_COOLDOWN = clamp_seconds(owner_cooldown, OWNER_TELEPORT_COOLDOWN) + REMOTE_TELEPORT_COOLDOWN = clamp_seconds(remote_cooldown, REMOTE_TELEPORT_COOLDOWN) + TELEPORT_RESET_DELAY = clamp_seconds(reset_delay, TELEPORT_RESET_DELAY) +end + +local function settle_owner_teleport(vehicle_id, vehicle, duration) + if not M.ownership[vehicle_id] then return false end + if not vehicle then return false end + + local cooldown = duration or OWNER_TELEPORT_COOLDOWN + owner_teleport_cooldowns[vehicle_id] = math.max(owner_teleport_cooldowns[vehicle_id] or 0, cooldown) + kisstransform.inactive[vehicle_id] = true + kisstransform.local_transforms[vehicle_id] = nil + last_position_buffer[vehicle_id] = nil + + kisstransform.queue_kiss_command(vehicle, "kiss_vehicle.post_owner_teleport_settle()") + return true +end + local function send_vehicle_update(obj) if not kisstransform.local_transforms[obj:getID()] then return end local t = kisstransform.local_transforms[obj:getID()] if not t.input then return end if not t.gearbox then return end + if not t.position or not t.rotation or not t.velocity or not t.angular_velocity then return end local rotation = t.rotation if obj:getJBeamFilename() == "unicycle" then local q = quat(getCameraQuat()):toEulerYXZ() local q = quatFromEuler(0.0, 0.0, q.x) rotation = {q.x, q.y, q.z, q.w} end - local position = obj:getPosition() - local velocity = obj:getVelocity() + local position = t.position + local position_vec = vec3(position[1], position[2], position[3]) + local velocity = t.velocity + local velocity_x, velocity_y, velocity_z = zero_small_vec_components(velocity[1], velocity[2], velocity[3], CLUSTER_LINEAR_DEADBAND) + local angular_velocity = t.angular_velocity + local angular_velocity_x, angular_velocity_y, ang_z = angular_velocity[1], angular_velocity[2], angular_velocity[3] + + -- A position jump greater than TELEPORT_THRESHOLD in one tick is treated as a teleport. + -- Arm a debounced ResetVehicle so the remote replica resets at the new position once the + -- local physics have settled. + local vehicle_id = obj:getID() + if last_position_buffer[vehicle_id] then + local last_pos = last_position_buffer[vehicle_id] + local distance = position_vec:distance(vec3(last_pos.x, last_pos.y, last_pos.z)) + if distance > TELEPORT_THRESHOLD then + teleport_reset_timers[vehicle_id] = TELEPORT_RESET_DELAY + settle_owner_teleport(vehicle_id, obj) + return + end + end + last_position_buffer[vehicle_id] = position_vec + + -- Finite-number sanity check. If physics on this client blows up (NaN / + -- Inf in position / velocity / rotation), dropping the packet is the + -- right call — sending garbage triggers serde errors on the Rust side + -- and corrupts remote-client state. Per-vehicle throttle log so a + -- persistent blow-up doesn't spam the console. + local function ok(n) + return type(n) == "number" and n == n and n < 1e8 and n > -1e8 + end + if not (ok(position[1]) and ok(position[2]) and ok(position[3]) + and ok(velocity_x) and ok(velocity_y) and ok(velocity_z) + and ok(rotation[1]) and ok(rotation[2]) and ok(rotation[3]) and ok(rotation[4]) + and ok(angular_velocity_x) and ok(angular_velocity_y) and ok(ang_z)) then + local vid = obj:getID() + if not last_bad_packet_log[vid] or (get_current_time() - last_bad_packet_log[vid]) > 5 then + print(string.format("[vehiclemanager] non-finite values in vehicle %d transform; dropping packet", vid)) + last_bad_packet_log[vid] = get_current_time() + end + return + end + local result = { transform = { - position = {position.x, position.y, position.z}, + position = {position[1], position[2], position[3]}, rotation = rotation, - velocity = {velocity.x, velocity.y, velocity.z}, - angular_velocity = {t.vel_pitch, t.vel_roll, t.vel_yaw} + velocity = {velocity_x, velocity_y, velocity_z}, + angular_velocity = {angular_velocity_x, angular_velocity_y, ang_z} }, electrics = t.input, gearbox = t.gearbox, vehicle_id = obj:getID(), + component_id = obj:getID(), generation = generation, - sent_at = get_current_time() + sent_at = get_current_time(), + -- Sender-monotonic timer for receiver-side prediction. sent_at stays for + -- compatibility with older consumers. + send_timer = t.send_timer or 0, + ping_ms = (network.connection.rtt_smooth_ms or network.connection.ping or 0) + ((t.send_dt or 0) * 1000), + send_dt = t.send_dt or 0, } generation = generation + 1 network.send_data( @@ -94,7 +185,7 @@ local function send_vehicle_meta_updates() if vehicle then local changed = false local id = vehicle:getID() - + local metal_data = vehicle:getMetallicPaintData() local color = vehicle.color local palete_0 = vehicle.colorPalette0 @@ -105,17 +196,17 @@ local function send_vehicle_meta_updates() color_to_table(palete_0, metal_data[2]), color_to_table(palete_1, metal_data[3]) } - + if plates_buffer[id] then changed = changed or plates_buffer[id] ~= plate end plates_buffer[id] = plate - + if colors_buffer[id] then changed = changed or not colors_eq(colors, colors_buffer[id]) end colors_buffer[id] = colors - + if changed then local data = { VehicleMetaUpdate = { @@ -145,10 +236,37 @@ end local function send_vehicle_config(vehicle_id) local vehicle = be:getObjectByID(vehicle_id) if vehicle then - vehicle:queueLuaCommand("kiss_vehicle.send_vehicle_config()") + kisstransform.queue_kiss_command(vehicle, "kiss_vehicle.send_vehicle_config()") end end +local function prepare_vehicle_for_sync(vehicle) + if not vehicle then return end + vehicle:queueLuaCommand("extensions.addModulePath('lua/vehicle/extensions/kiss_mp')") + vehicle:queueLuaCommand("extensions.loadModulesInDirectory('lua/vehicle/extensions/kiss_mp')") + if kissui and kissui.tabs and kissui.tabs.tuning and kissui.tabs.tuning.push_to_vehicle then + kissui.tabs.tuning.push_to_vehicle(vehicle) + end +end + +local function sync_initial_player_vehicle() + if not pending_initial_vehicle_sync then return end + if M.loading_map or M.delay_spawns then return end + + local vehicle = be:getPlayerVehicle(0) + if not vehicle then return end + + local id = vehicle:getID() + if M.ownership[id] or M.server_ids[id] then + pending_initial_vehicle_sync = false + return + end + + prepare_vehicle_for_sync(vehicle) + send_vehicle_config(id) + pending_initial_vehicle_sync = false +end + local function send_vehicle_config_inner(id, parts_config, data) for k, v in pairs(M.id_map) do if v == id and not M.ownership[id] then return end @@ -226,9 +344,9 @@ local function spawn_vehicle(data) kissplayers.spawn_player(data) return end - + print("Attempt to spawn vehicle "..name) - local options = { + local options = { vehicleName = "mp_veh", pos = vec3(data.position), rot = quat(data.rotation), @@ -239,9 +357,12 @@ local function spawn_vehicle(data) autoEnterVehicle = false } options = sanitizeVehicleSpawnOptions(name, options) - + local spawned = spawn.spawnVehicle(name, options.config, options.pos, options.rot, options) if not spawned then return end + -- Spawn from VehicleData origin. raw_transforms.position is COG-space and + -- would inject a COG offset if used as the initial refnode position. + local fresh = kisstransform.raw_transforms[data.server_id] local p = data.position local r = data.rotation spawned:setPositionRotation(p[1], p[2], p[3], r[1], r[2], r[3], r[4]) @@ -251,10 +372,23 @@ local function spawn_vehicle(data) M.id_map[data.server_id] = spawned:getID() M.server_ids[spawned:getID()] = data.server_id kisstransform.inactive[spawned:getID()] = false - --if current_vehicle then be:enterVehicle(0, current_vehicle) end + if fresh and kisstransform.queue_cog_snap then + kisstransform.queue_cog_snap(spawned, fresh) + end spawned:queueLuaCommand("extensions.hook('kissUpdateOwnership', false)") end +local function send_reset_vehicle(id) + if not network.connection.connected then return end + if not M.ownership[id] then return end + local vehicle = be:getObjectByID(id) + if not vehicle then return end + local rotation = quatFromDir(-vehicle:getDirectionVector(), vehicle:getDirectionVectorUp()) + local position = vec3(vehicle:getPosition()) + local data = { vehicle_id = id, position = {position.x, position.y, position.z}, rotation = {rotation.x, rotation.y, rotation.z, rotation.w}} + network.send_data({ ResetVehicle = data }, true) +end + local function onUpdate(dt) if not network.connection.connected then return end if (getMissionFilename():lower() ~= network.connection.server_info.map:lower()) and (getMissionPath():lower() ~= network.connection.server_info.map:lower()) and not M.loading_map then @@ -267,6 +401,29 @@ local function onUpdate(dt) meta_timer = meta_timer - 1 end + if pending_initial_vehicle_sync then + initial_vehicle_sync_timer = initial_vehicle_sync_timer + dt + if initial_vehicle_sync_timer >= 0.25 then + initial_vehicle_sync_timer = 0 + sync_initial_player_vehicle() + end + end + + for vid, remaining in pairs(owner_teleport_cooldowns) do + remaining = remaining - dt + if remaining <= 0 then + owner_teleport_cooldowns[vid] = nil + local vehicle = be:getObjectByID(vid) + if vehicle then + kisstransform.inactive[vid] = false + kisstransform.queue_kiss_command(vehicle, "kiss_vehicle.post_owner_teleport_settle()") + last_position_buffer[vid] = vec3(vehicle:getPosition()) + end + else + owner_teleport_cooldowns[vid] = remaining + end + end + local tick_time = (1/network.connection.tickrate) if timer < tick_time then timer = timer + dt @@ -276,19 +433,11 @@ local function onUpdate(dt) local vehicle = be:getObjectByID(i) if vehicle and (not kisstransform.inactive[i]) then send_vehicle_update(vehicle) - vehicle:queueLuaCommand("kiss_electrics.send()") + kisstransform.queue_kiss_command(vehicle, "kiss_electrics.send()") end end end - for k, v in pairs(M.id_map) do - if not M.ownership[v] then - local vehicle = be:getObjectByID(v) - if vehicle and (not kisstransform.inactive[v]) then - vehicle:queueLuaCommand("kiss_vehicle.update_eligible_nodes()") - end - end - end if not (M.loading_map or M.delay_spawns) then local to_remove = {} for k, vehicle in pairs(M.vehicle_buffer) do @@ -302,6 +451,22 @@ local function onUpdate(dt) M.vehicle_buffer[v] = nil end end + + -- Process received transforms and queue per-vehicle update commands + -- This is critical for remote vehicles to receive body position updates! + kisstransform.onUpdate(dt) + + -- Fire debounced teleport resets: once physics have settled after the position jump, + -- send a ResetVehicle so the remote replica resets its cluster at the new location. + for vid, remaining in pairs(teleport_reset_timers) do + remaining = remaining - dt + if remaining <= 0 then + teleport_reset_timers[vid] = nil + send_reset_vehicle(vid) + else + teleport_reset_timers[vid] = remaining + end + end end local function update_vehicle(data) @@ -315,19 +480,33 @@ local function update_vehicle(data) kissplayers.player_transforms[data.vehicle_id].time_past = clamp(get_current_time() - data.sent_at, 0, 0.3) + 0.0001 return end - + local id = M.id_map[data.vehicle_id] if not id then return end if M.ownership[id] then return end - if data.generation <= (M.packet_gen_buffer[id] or -1) then return end - M.packet_gen_buffer[id] = data.generation + + local send_timer = data.send_timer + if send_timer and send_timer > 0 then + local previous_timer = M.packet_timer_buffer[id] + if previous_timer and send_timer <= previous_timer then + if (previous_timer - send_timer) < 0.5 then + return + end + M.packet_gen_buffer[id] = nil + end + M.packet_timer_buffer[id] = send_timer + else + if data.generation <= (M.packet_gen_buffer[id] or -1) then return end + M.packet_gen_buffer[id] = data.generation + end + local vehicle = be:getObjectByID(id) if not vehicle then return end kisstransform.update_vehicle_transform(data) if not kisstransform.inactive[id] then - vehicle:queueLuaCommand("kiss_input.apply(" .. string.format("%q", jsonEncode(data.electrics)) .. ")") - vehicle:queueLuaCommand("kiss_gearbox.apply(" .. string.format("%q", jsonEncode(data.gearbox)) .. ")") + kisstransform.queue_kiss_command(vehicle, "kiss_input.apply(" .. string.format("%q", jsonEncode(data.electrics)) .. ")") + kisstransform.queue_kiss_command(vehicle, "kiss_gearbox.apply(" .. string.format("%q", jsonEncode(data.gearbox)) .. ")") end end @@ -347,6 +526,8 @@ local function remove_vehicle(data) M.id_map[id] = nil M.ownership[local_id] = nil M.vehicle_updates_buffer[local_id] = nil + M.packet_gen_buffer[local_id] = nil + M.packet_timer_buffer[local_id] = nil kisstransform.received_transforms[local_id] = nil update_ownership_limits() else @@ -357,14 +538,20 @@ end local function reset_vehicle(data) local id = data.vehicle_id id = M.id_map[id] or -1 - + local position = data.position local rotation = data.rotation - + local vehicle = be:getObjectByID(id) if not vehicle then return end if vehicle then - vehicle:reset() + M.packet_gen_buffer[id] = nil + M.packet_timer_buffer[id] = nil + kisstransform.received_transforms[id] = nil + kisstransform.set_teleport_cooldown(id, REMOTE_TELEPORT_COOLDOWN) + kisstransform.inactive[id] = true + + vehicle:setActive(0) vehicle:setPositionRotation( position[1], position[2], @@ -374,6 +561,7 @@ local function reset_vehicle(data) rotation[3], rotation[4] ) + kisstransform.queue_kiss_command(vehicle, string.format("kiss_transforms.post_teleport_cooldown(%f)", REMOTE_TELEPORT_COOLDOWN)) end end @@ -383,7 +571,7 @@ local function update_vehicle_meta(data) local vehicle = be:getObjectByID(id) if not vehicle then return end local plate = data.plate - + local color = data.colors_table[1] local palete_0 = data.colors_table[2] local palete_1 = data.colors_table[3] @@ -400,7 +588,7 @@ local function update_vehicle_meta(data) -- Apply colors local vd = extensions.core_vehicle_manager.getVehicleData(id) if not vd or not vd.config or not vd.config.paints then return end - + for i=1,3 do local ct = color_tables[i] vd.config.paints[i] = table_to_paint(ct) @@ -415,7 +603,7 @@ local function electrics_diff_update(data) local vehicle = be:getObjectByID(id) if not vehicle then return end local data = jsonEncode(data[2].diff) - vehicle:queueLuaCommand("kiss_electrics.apply_diff(" .. string.format("%q", data) .. ")") + kisstransform.queue_kiss_command(vehicle, "kiss_electrics.apply_diff(" .. string.format("%q", data) .. ")") end end @@ -457,7 +645,7 @@ local function attach_coupler(data) local node_b_pos = vec3(vehicle_b:getPosition()) + vec3(vehicle_b:getNodePosition(data.node_b_id)) local pos = vec3(vehicle_b:getPosition()) + (node_a_pos - node_b_pos) vehicle_b:setPositionNoPhysicsReset(Point3F(pos.x, pos.y, pos.z)) - vehicle_b:queueLuaCommand("kiss_couplers.attach_coupler("..data.node_b_id..")") + kisstransform.queue_kiss_command(vehicle_b, "kiss_couplers.attach_coupler("..data.node_b_id..")") onCouplerAttached(obj_a, obj_b, data.node_a_id, data.node_b_id) end end @@ -472,7 +660,7 @@ local function detach_coupler(data) if not vehicle then return end if not vehicle_b then return end if vehicle_ ~= vehicle_b and vec3(vehicle:getPosition()):distance(vec3(vehicle_b:getPosition())) > 15 then return end - vehicle:queueLuaCommand("kiss_couplers.detach_coupler("..data.node_a_id..")") + kisstransform.queue_kiss_command(vehicle, "kiss_couplers.detach_coupler("..data.node_a_id..")") onCouplerDetached(obj_a, obj_b, data.node_a_id, data.node_b_id) onCouplerDetach(obj_a, data.node_a_id) onCouplerDetach(obj_b, data.node_b_id) @@ -512,9 +700,11 @@ local function onVehicleSpawned(id) vehicle:queueLuaCommand("recovery.saveHome()") first_vehicle = false end - vehicle:queueLuaCommand("extensions.addModulePath('lua/vehicle/extensions/kiss_mp')") - vehicle:queueLuaCommand("extensions.loadModulesInDirectory('lua/vehicle/extensions/kiss_mp')") + prepare_vehicle_for_sync(vehicle) send_vehicle_config(id) + if vehicle == be:getPlayerVehicle(0) then + pending_initial_vehicle_sync = false + end -- Attempt to workaround a bug from latest beamng update. Also prevents unicycle cloning(Somewhat) if vehicle:getJBeamFilename() == "unicycle" then for i = 0, be:getObjectCount() do @@ -528,9 +718,13 @@ end local function onVehicleDestroyed(id) if not network.connection.connected then return end + last_position_buffer[id] = nil + teleport_reset_timers[id] = nil + owner_teleport_cooldowns[id] = nil if M.ownership[id] then M.id_map[M.ownership[id]] = nil M.ownership[id] = nil + M.server_ids[id] = nil network.send_data( { RemoveVehicle = id, @@ -542,20 +736,10 @@ local function onVehicleDestroyed(id) end local function onVehicleResetted(id) - if not network.connection.connected then return end if M.ownership[id] then - local vehicle = be:getObjectByID(id) - local rotation = quat(vehicle:getRefNodeMatrix():toQuatF()) - local position = vec3(vehicle:getPosition()) - local data = { vehicle_id = id, position = {position.x, position.y, position.z}, rotation = {rotation.x, rotation.y, rotation.z, rotation.w}} - - network.send_data( - { - ResetVehicle = data, - }, - true - ) + settle_owner_teleport(id, be:getObjectByID(id)) end + send_reset_vehicle(id) end local function onVehicleSwitched(_id, new_id) @@ -580,8 +764,14 @@ local function onMissionLoaded(mission) if not network.connection.connected then return end M.id_map = {} M.ownership = {} + M.server_ids = {} + M.packet_gen_buffer = {} + M.packet_timer_buffer = {} + owner_teleport_cooldowns = {} M.loading_map = false first_vehicle = true + pending_initial_vehicle_sync = true + initial_vehicle_sync_timer = 0.25 end M.onUpdate = onUpdate @@ -595,6 +785,7 @@ M.update_vehicle_gearbox = update_vehicle_gearbox M.rotate_nodes = rotate_nodes M.remove_vehicle = remove_vehicle M.reset_vehicle = reset_vehicle +M.set_teleport_tuning = set_teleport_tuning M.update_vehicle_meta = update_vehicle_meta M.onVehicleDestroyed = onVehicleDestroyed M.onVehicleResetted = onVehicleResetted diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_electrics.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_electrics.lua index 53e422f0..197babe3 100644 --- a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_electrics.lua +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_electrics.lua @@ -3,6 +3,7 @@ local prev_electrics = {} local prev_signal_electrics = {} local last_engine_state = true local engine_timer = 0 +local starter_pulse_timer = 0 local ownership = false local ignored_keys = { @@ -110,18 +111,29 @@ local function update_engine_state() if ownership then return end if not electrics.values.engineRunning then return end local engine_running = electrics.values.engineRunning > 0.5 - - -- Trigger starter to swap the engine state + + -- Trigger a short starter pulse to converge to the owner's engineRunning + -- outcome. This is intentionally outcome-oriented rather than trying to + -- exactly replay local anti-stall/powertrain behavior. if engine_running ~= last_engine_state then controller.mainController.setStarter(true) + starter_pulse_timer = 0.15 end end local function updateGFX(dt) + if starter_pulse_timer > 0 then + starter_pulse_timer = starter_pulse_timer - dt + if starter_pulse_timer <= 0 then + controller.mainController.setStarter(false) + starter_pulse_timer = 0 + end + end + engine_timer = engine_timer + dt - if engine_timer > 5 then + if engine_timer > 0.25 then update_engine_state() - engine_timer = engine_timer - 5 + engine_timer = engine_timer - 0.25 end end @@ -143,7 +155,7 @@ local function send() ElectricsUndefinedUpdate = {obj:getID(), data} } if diff_count > 0 then - print("=== ELECTRICS BEING SENT ===\n" .. jsonEncode(data)) + -- print("=== ELECTRICS BEING SENT ===\n" .. jsonEncode(data)) obj:queueGameEngineLua("network.send_data(\'"..jsonEncode(data).."\', true)") end end @@ -152,7 +164,7 @@ local function apply_diff_signals(diff) local signal_left_input = diff["signal_left_input"] or prev_signal_electrics["signal_left_input"] or 0 local signal_right_input = diff["signal_right_input"] or prev_signal_electrics["signal_right_input"] or 0 local hazard_enabled = (signal_left_input > 0.5 and signal_right_input > 0.5) - + if hazard_enabled then electrics.set_warn_signal(1) else @@ -163,7 +175,7 @@ local function apply_diff_signals(diff) electrics.toggle_right_signal() end end - + prev_signal_electrics["signal_left_input"] = signal_left_input prev_signal_electrics["signal_right_input"] = signal_right_input end @@ -194,7 +206,7 @@ local function apply_diff(data) apply_diff_signals(diff) for k, v in pairs(diff) do electrics.values[k] = v - + local handler = electrics_handlers[k] if handler then handler(v) end end @@ -207,7 +219,7 @@ local function onExtensionLoaded() if device.electricsName and device.visualShaftAngle then ignore_key(device.electricsName) end - if device.electricsThrottleName then + if device.electricsThrottleName then ignore_key(device.electricsThrottleName) end if device.electricsThrottleFactorName then @@ -225,9 +237,9 @@ local function onExtensionLoaded() for i = 0, 10 do ignore_key("led"..tostring(i)) end - + -- Ignore controller electrics - if v.data.controller and type(v.data.controller) == 'table' then + if v.data.controller and type(v.data.controller) == 'table' then for _, controller_data in pairs(v.data.controller) do if controller_data.fileName == "lightbar" and controller_data.modes then -- ignore lightbar electrics @@ -236,7 +248,7 @@ local function onExtensionLoaded() local configEntries = tableFromHeaderTable(deepcopy(vm.config)) for _, j in pairs(configEntries) do ignore_key(j.electric) - end + end end elseif controller_data.fileName == "jato" then -- ignore jato fuel @@ -262,7 +274,7 @@ local function onExtensionLoaded() local electric = controller_data.name .. "_notAttached" local coupler_control_controller = controller.getController(controller_data.name) electrics_handlers[electric] = function(v) update_advanced_coupler_state(coupler_control_controller, v) end - + -- ignore the related couplers, we'll manage them now for _, vn in pairs(tableFromHeaderTable(controller_data.couplerNodes)) do local cid1 = beamstate.nodeNameMap[vn.cid1] @@ -273,20 +285,24 @@ local function onExtensionLoaded() end end end - + -- Ignore commonly used disp_* electrics used on vehicles with gear displays for k,v in pairs(electrics.values) do if type(k) == 'string' and k:sub(1,5) == "disp_" then ignored_keys[k] = true end end - + -- Ignore common extension/controller electrics if _G["4ws"] and type(_G["4ws"]) == 'table' then ignored_keys["4ws"] = true end - + -- Register handlers + -- TODO: Properly debug and sync game-side police/beacon/siren commands with + -- the future hot-reloading debugger/event inspector. Some vehicles appear to + -- use controller/event paths that do not show up as the simple generic + -- `lightbar` electric we currently replay here. electrics_handlers["lights_state"] = function(v) electrics.setLightsState(v) end electrics_handlers["fog"] = function(v) electrics.set_fog_lights(v) end electrics_handlers["lightbar"] = function(v) electrics.set_lightbar_signal(v) end @@ -298,7 +314,7 @@ local function onExtensionLoaded() wheels.setABSBehavior("off") end end - electrics_handlers["engineRunning"] = function(v) + electrics_handlers["engineRunning"] = function(v) last_engine_state = v > 0.5 update_engine_state() engine_timer = 0 diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_gearbox.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_gearbox.lua index e075ce57..35cbed7d 100644 --- a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_gearbox.lua +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_gearbox.lua @@ -8,14 +8,17 @@ local vehicle_is_electric = false local gearbox_is_manual = false local last_requseted_gear = nil +local target_gear_index = nil local sequential_lock = false local ownership = false local ownership_known = false local cooldown_timer = 0 +local resync_timer = 0 local function set_gear_indices(indices) if mainController and cooldown_timer <= 0 then local index = indices[1] + target_gear_index = index local canShift = true -- there's a neutralRejectTimer that will lock sequentials into neutral if we try it more than once @@ -76,6 +79,13 @@ local function updateGFX(dt) if sequential_lock and electrics.values.gearIndex == 0 then sequential_lock = false end + resync_timer = resync_timer + dt + if target_gear_index ~= nil and resync_timer >= 0.1 then + resync_timer = 0 + if electrics.values.gearIndex ~= target_gear_index then + set_gear_indices({target_gear_index, 0}) + end + end if gearbox_is_manual and last_requseted_gear ~= 0 and electrics.values.gearIndex == 0 then electrics.values.clutchOverride = 1 else @@ -85,6 +95,7 @@ end local function onReset() cooldown_timer = 0.2 + resync_timer = 0 sequential_lock = false end diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_input.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_input.lua index 083df2db..8aa4e1a6 100644 --- a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_input.lua +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_input.lua @@ -1,21 +1,59 @@ local M = {} +local ownership = true +local local_inputs_disabled = false +local remote_input_source = "KissMP" +local controlled_inputs = { + throttle = true, + brake = true, + parkingbrake = true, + clutch = true, + steering = true, +} + +local function set_local_input_allowed(allowed) + if not input or not input.setAllowedInputSource then return end + for input_name in pairs(controlled_inputs) do + input.setAllowedInputSource(input_name, "local", allowed) + input.setAllowedInputSource(input_name, remote_input_source, not allowed) + end + local_inputs_disabled = not allowed +end + local function apply(data) + if ownership then return end local data = jsonDecode(data) - input.event("throttle", data.throttle_input, 1) - input.event("brake", data.brake_input, 2) - input.event("parkingbrake", data.parkingbrake, 2) - input.event("clutch", data.clutch, 1) - input.event("steering", data.steering_input, 2, 0, 0) + input.event("throttle", data.throttle_input, 1, nil, nil, nil, remote_input_source) + input.event("brake", data.brake_input, 2, nil, nil, nil, remote_input_source) + input.event("parkingbrake", data.parkingbrake, 2, nil, nil, nil, remote_input_source) + input.event("clutch", data.clutch, 1, nil, nil, nil, remote_input_source) + input.event("steering", data.steering_input, 2, 0, 0, nil, remote_input_source) end local function kissUpdateOwnership(owned) - if owned then return end - hydros.enableFFB = false - hydros.onFFBConfigChanged(nil) + ownership = owned and true or false + if ownership then + if local_inputs_disabled then + set_local_input_allowed(true) + end + return + end + + set_local_input_allowed(false) + if hydros then + hydros.enableFFB = false + hydros.onFFBConfigChanged(nil) + end +end + +local function updateGFX() + if not ownership and not local_inputs_disabled then + set_local_input_allowed(false) + end end M.apply = apply +M.updateGFX = updateGFX M.kissUpdateOwnership = kissUpdateOwnership diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_nodes.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_nodes.lua index b0079f49..b29d6027 100644 --- a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_nodes.lua +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_nodes.lua @@ -1,31 +1,32 @@ local M = {} local function send() - local nodes_table = { - vehicle_id = obj:getID(), - nodes = {} - } - for k, node in pairs(v.data.nodes) do - local position = obj:getNodePosition(node.cid) - table.insert(nodes_table.nodes, {position.x, position.y, position.z}) - end - obj:queueGameEngineLua("network.send_messagepack(4, false, \'"..jsonEncode(nodes_table).."\')") + local nodes_table = { + vehicle_id = obj:getID(), + nodes = {} + } + for k, node in pairs(v.data.nodes) do + local position = obj:getNodePosition(node.cid) + table.insert(nodes_table.nodes, { position.x, position.y, position.z }) + end + obj:queueGameEngineLua("network.send_messagepack(4, false, \'" .. jsonEncode(nodes_table) .. "\')") end local function apply(nodes) - local nodes = jsonDecode(nodes) - for node, pos in pairs(nodes) do - node = tonumber(node) - obj:setNodePosition(node, float3(pos[1], pos[2], pos[3])) - local beam = v.data.beams[node] - local beamPrecompression = beam.beamPrecompression or 1 - local deformLimit = type(beam.deformLimit) == 'number' and beam.deformLimit or math.huge - obj:setBeam(-1, beam.id1, beam.id2, beam.beamStrength, beam.beamSpring, - beam.beamDamp, type(beam.dampCutoffHz) == 'number' and beam.dampCutoffHz or 0, - beam.beamDeform, deformLimit, type(beam.deformLimitExpansion) == 'number' and beam.deformLimitExpansion or deformLimit, - beamPrecompression - ) - end + local nodes = jsonDecode(nodes) + for node, pos in pairs(nodes) do + node = tonumber(node) + obj:setNodePosition(node, float3(pos[1], pos[2], pos[3])) + local beam = v.data.beams[node] + local beamPrecompression = beam.beamPrecompression or 1 + local deformLimit = type(beam.deformLimit) == 'number' and beam.deformLimit or math.huge + obj:setBeam(-1, beam.id1, beam.id2, beam.beamStrength, beam.beamSpring, + beam.beamDamp, type(beam.dampCutoffHz) == 'number' and beam.dampCutoffHz or 0, + beam.beamDeform, deformLimit, + type(beam.deformLimitExpansion) == 'number' and beam.deformLimitExpansion or deformLimit, + beamPrecompression + ) + end end M.send = send diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_sync.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_sync.lua new file mode 100644 index 00000000..a4fad8b9 --- /dev/null +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_sync.lua @@ -0,0 +1,554 @@ +-- KissMP Sync Module: second-order dead-reckoning prediction. +-- +-- Maintains per-vehicle sync state on the receiver. Each packet carries +-- position/velocity/rotation/angular-velocity (no acceleration); we derive +-- acceleration from velocity deltas across consecutive packets and use it +-- in a constant-acceleration extrapolation model so the consumer (the PD +-- loop in kiss_transforms.update) chases where the sender IS NOW, not where +-- the sender WAS when the packet was sent. +-- +-- The blend path is dormant in normal use because apply_snapshot is called +-- with blend_duration = 0. It remains available for explicit experiments. + +local M = {} + +local function vec3_add(a, b) + return vec3(a.x + b.x, a.y + b.y, a.z + b.z) +end + +local function vec3_scale(v, s) + return vec3(v.x * s, v.y * s, v.z * s) +end + +local function vec3_lerp(a, b, t) + return vec3( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.z + (b.z - a.z) * t + ) +end + +local function vec3_copy(v) + return vec3(v.x, v.y, v.z) +end + +-- Quaternion multiplication: q_result = q1 * q2. +local function quat_multiply(q1, q2) + return quat( + q1.w * q2.x + q1.x * q2.w + q1.y * q2.z - q1.z * q2.y, + q1.w * q2.y - q1.x * q2.z + q1.y * q2.w + q1.z * q2.x, + q1.w * q2.z + q1.x * q2.y - q1.y * q2.x + q1.z * q2.w, + q1.w * q2.w - q1.x * q2.x - q1.y * q2.y - q1.z * q2.z + ) +end + +-- Extrapolate quaternion using world-frame angular velocity. +-- q(t + dt) = exp(omega_world * dt / 2) * q(t) +local function extrapolate_quaternion(q, omega, delta_time) + local omega_magnitude = omega:length() + if omega_magnitude < 1e-8 or delta_time < 1e-8 then return q end + + local theta = omega_magnitude * delta_time + local half_theta = theta * 0.5 + local sin_half_theta = math.sin(half_theta) + local cos_half_theta = math.cos(half_theta) + + local axis_scale = sin_half_theta / omega_magnitude + local delta_q = quat( + omega.x * axis_scale, + omega.y * axis_scale, + omega.z * axis_scale, + cos_half_theta + ) + return quat_multiply(delta_q, q) +end + +local function slerp_quaternion(q1, q2, t) + local dot = q1.w * q2.w + q1.x * q2.x + q1.y * q2.y + q1.z * q2.z + local q2_interp = q2 + if dot < 0.0 then + dot = -dot + q2_interp = quat(-q2.x, -q2.y, -q2.z, -q2.w) + end + dot = math.max(-1.0, math.min(1.0, dot)) + + if dot > 0.9995 then + local result = quat( + q1.x + (q2_interp.x - q1.x) * t, + q1.y + (q2_interp.y - q1.y) * t, + q1.z + (q2_interp.z - q1.z) * t, + q1.w + (q2_interp.w - q1.w) * t + ) + return result:normalized() + end + + local theta = math.acos(dot) + local sin_theta = math.sin(theta) + + if sin_theta < 1e-8 then + local result = quat( + q1.x + (q2_interp.x - q1.x) * t, + q1.y + (q2_interp.y - q1.y) * t, + q1.z + (q2_interp.z - q1.z) * t, + q1.w + (q2_interp.w - q1.w) * t + ) + return result:normalized() + end + + local ratio_a = math.sin((1.0 - t) * theta) / sin_theta + local ratio_b = math.sin(t * theta) / sin_theta + + local result = quat( + q1.x * ratio_a + q2_interp.x * ratio_b, + q1.y * ratio_a + q2_interp.y * ratio_b, + q1.z * ratio_a + q2_interp.z * ratio_b, + q1.w * ratio_a + q2_interp.w * ratio_b + ) + return result:normalized() +end + +local MAX_LINEAR_ACCELERATION = 100 -- m/s^2 clamp on derived linear acceleration +local MAX_ANGULAR_ACCELERATION = 50 -- rad/s^2 clamp on derived angular acceleration +local MAX_PREDICT = 0.3 -- seconds; clamp on forward-extrapolation horizon +local PACKET_TIMEOUT = 0.1 -- Stop correcting if packets stall +local STALE_THRESHOLD = 2.0 +local USE_PREDICTION = true +M.REMOTE_VEL_SMOOTH_RATE = 2.0 +M.PREDICTION_OFFSET_S = 0.0 +local REMOTE_ACCELERATION_SMOOTH_RATE = 1.0 +local TIME_OFFSET_SMOOTH_RATE = 1.0 + +local function set_smoothing_tuning(vel_rate, prediction_offset_s) + if type(vel_rate) == "number" then + M.REMOTE_VEL_SMOOTH_RATE = math.max(0, vel_rate) + end + if type(prediction_offset_s) == "number" then + M.PREDICTION_OFFSET_S = math.max(-0.08, math.min(prediction_offset_s, 0.08)) + end +end + +local function limit_vec(v, max_len) + local len = v:length() + if len > max_len then return v * (max_len / len) end + return v +end + +local function smoothing_dt(frame_dt, predict_time) + return frame_dt / math.max(math.abs(predict_time), 0.001) +end + +local function create_sync_state(id) + return { + id = id, + -- Last authoritative snapshot from wire. + base_transform = { + position = vec3(0, 0, 0), + rotation = quat(0, 0, 0, 1), + velocity = vec3(0, 0, 0), + angular_velocity = vec3(0, 0, 0), + }, + -- Derived from velocity deltas in apply_snapshot and used by the + -- second-order extrapolator. + linear_acceleration = vec3(0, 0, 0), + angular_acceleration = vec3(0, 0, 0), + smooth_velocity = nil, + smooth_angular_velocity = nil, + smooth_linear_acceleration = nil, + smooth_angular_acceleration = nil, + last_raw_velocity = nil, + last_raw_angular_velocity = nil, + last_smooth_time = 0, + + base_timestamp = 0, -- sender's monotonic timer in seconds + base_recv_time = 0, -- local clock at packet arrival + generation = 0, + last_update_time = 0, + + time_offset_target = 0, + time_offset_smoothed = nil, + + -- Prediction state (populated by update_and_get_transform). + predicted_transform = { + position = vec3(0, 0, 0), + rotation = quat(0, 0, 0, 1), + velocity = vec3(0, 0, 0), + angular_velocity = vec3(0, 0, 0), + }, + -- Blend infrastructure; dormant by default. + blend_start = nil, + blend_target = nil, + blend_start_time = 0, + blend_duration = 0, + is_blending = false, + + -- Applied transform (what update_and_get_transform returns). + applied_transform = { + position = vec3(0, 0, 0), + rotation = quat(0, 0, 0, 1), + velocity = vec3(0, 0, 0), + angular_velocity = vec3(0, 0, 0), + }, + } +end + +-- Forward-extrapolate the latest authoritative snapshot to the current local +-- time. The acceleration term keeps transients from lagging by 0.5*a*t^2. +local function extrapolate_transform(state, current_time) + local frame_dt = 0 + if state.last_smooth_time and state.last_smooth_time > 0 then + frame_dt = math.max(0, current_time - state.last_smooth_time) + end + state.last_smooth_time = current_time + + if state.time_offset_smoothed == nil then + state.time_offset_smoothed = state.time_offset_target or 0 + elseif frame_dt > 0 then + state.time_offset_smoothed = state.time_offset_smoothed + + ((state.time_offset_target or state.time_offset_smoothed) - state.time_offset_smoothed) + * math.min(TIME_OFFSET_SMOOTH_RATE * frame_dt, 1.0) + end + + local calc_local_time = state.base_timestamp + (state.time_offset_smoothed or 0) + local predict_time = math.max( + -MAX_PREDICT, + math.min((current_time - calc_local_time) + M.PREDICTION_OFFSET_S, MAX_PREDICT) + ) + local t = predict_time + + if frame_dt > 0 then + local smooth_dt = smoothing_dt(frame_dt, predict_time) + state.smooth_velocity = vec3_lerp( + state.smooth_velocity or state.base_transform.velocity, + state.base_transform.velocity, + math.min(M.REMOTE_VEL_SMOOTH_RATE * smooth_dt, 1.0) + ) + state.smooth_angular_velocity = vec3_lerp( + state.smooth_angular_velocity or state.base_transform.angular_velocity, + state.base_transform.angular_velocity, + math.min(M.REMOTE_VEL_SMOOTH_RATE * smooth_dt, 1.0) + ) + state.smooth_linear_acceleration = vec3_lerp( + state.smooth_linear_acceleration or state.linear_acceleration, + state.linear_acceleration, + math.min(REMOTE_ACCELERATION_SMOOTH_RATE * smooth_dt, 1.0) + ) + state.smooth_angular_acceleration = vec3_lerp( + state.smooth_angular_acceleration or state.angular_acceleration, + state.angular_acceleration, + math.min(REMOTE_ACCELERATION_SMOOTH_RATE * smooth_dt, 1.0) + ) + end + + local base = state.base_transform + local base_velocity = state.smooth_velocity or base.velocity + local base_angular_velocity = state.smooth_angular_velocity or base.angular_velocity + local linear_acceleration = state.smooth_linear_acceleration or state.linear_acceleration or vec3(0, 0, 0) + -- Do not second-order predict angular motion. Angular acceleration is + -- derived from lossy packet-to-packet angular velocity deltas, and fake + -- yaw accel impulses are much more visible than small heading lag. + local half_t_sq = 0.5 * t * t + + -- Position: x(t) = x0 + v0*t + 0.5*a*t^2 + local predicted_position = vec3( + base.position.x + base_velocity.x * t + linear_acceleration.x * half_t_sq, + base.position.y + base_velocity.y * t + linear_acceleration.y * half_t_sq, + base.position.z + base_velocity.z * t + linear_acceleration.z * half_t_sq + ) + + -- Velocity: v(t) = v0 + a*t + local predicted_velocity = vec3( + base_velocity.x + linear_acceleration.x * t, + base_velocity.y + linear_acceleration.y * t, + base_velocity.z + linear_acceleration.z * t + ) + + -- Rotation: first-order angular prediction only. Linear motion still uses + -- acceleration prediction to preserve path tracking. + local rotation_delta = vec3( + base_angular_velocity.x * t, + base_angular_velocity.y * t, + base_angular_velocity.z * t + ) + local predicted_rotation = base.rotation * quatFromEuler(rotation_delta.x, rotation_delta.y, rotation_delta.z) + + local predicted_angular_velocity = base_angular_velocity + + return { + position = predicted_position, + rotation = predicted_rotation, + velocity = predicted_velocity, + angular_velocity = predicted_angular_velocity, + } +end + +-- Blend between two transforms. +local function blend_transforms(start, target, t) + local position = vec3_lerp(start.position, target.position, t) + local rotation = slerp_quaternion(start.rotation, target.rotation, t) + local velocity = vec3( + start.velocity.x + (target.velocity.x - start.velocity.x) * t, + start.velocity.y + (target.velocity.y - start.velocity.y) * t, + start.velocity.z + (target.velocity.z - start.velocity.z) * t + ) + local angular_velocity = vec3( + start.angular_velocity.x + (target.angular_velocity.x - start.angular_velocity.x) * t, + start.angular_velocity.y + (target.angular_velocity.y - start.angular_velocity.y) * t, + start.angular_velocity.z + (target.angular_velocity.z - start.angular_velocity.z) * t + ) + return { + position = position, + rotation = rotation, + velocity = velocity, + angular_velocity = angular_velocity, + } +end + +M.sync_states = {} +M.default_blend_duration = 0 + +local function get_sync_state(id) + if not M.sync_states[id] then + M.sync_states[id] = create_sync_state(id) + end + return M.sync_states[id] +end + +local function apply_snapshot(id, transform_data, timestamp, generation, current_time, blend_duration) + blend_duration = blend_duration or M.default_blend_duration + + local state = get_sync_state(id) + local is_first_snapshot = (state.base_timestamp == 0) + local timestamp_delta = timestamp - state.base_timestamp + + -- Stale fallback: if no updates arrived for >STALE_THRESHOLD seconds, + -- treat the next packet as a fresh start. Don't derive acceleration + -- from a velocity gap that spans seconds; the divisor would be huge, + -- the result tiny, and we'd carry stale state forward. The primary + -- teleport recovery path is a separate ResetVehicle packet sent by the + -- owner; this just covers gaps if that's lost. + local is_stale = false + if state.last_update_time > 0 then + if (current_time - state.last_update_time) > STALE_THRESHOLD then + is_stale = true + end + end + + -- Handle remote timer resets and ignore old packets. + -- Without this, a reset to a low sender timer or a slightly out-of-order + -- packet produces a near-zero remote_dt below and turns a normal velocity + -- delta into a bogus 100 m/s^2 acceleration spike. + if not is_first_snapshot and timestamp_delta <= 0 then + if timestamp_delta == 0 or math.abs(timestamp_delta) < 0.5 then + return + end + is_stale = true + end + + -- Parse incoming transform data into vec3/quat objects. + local authoritative = { + position = vec3(transform_data.position[1], transform_data.position[2], transform_data.position[3]), + rotation = quat(transform_data.rotation[1], transform_data.rotation[2], transform_data.rotation[3], transform_data.rotation[4]), + velocity = vec3(transform_data.velocity[1], transform_data.velocity[2], transform_data.velocity[3]), + angular_velocity = vec3(transform_data.angular_velocity[1], transform_data.angular_velocity[2], transform_data.angular_velocity[3]), + } + + -- Derive acceleration from packet velocity deltas. Velocity itself is + -- smoothed because authoritative.velocity carries the receiver's only + -- noise floor. + local remote_dt = math.max(timestamp_delta, 0.001) + local raw_linear_acceleration = vec3(0, 0, 0) + local raw_angular_acceleration = vec3(0, 0, 0) + if is_first_snapshot or is_stale then + state.smooth_velocity = vec3_copy(authoritative.velocity) + state.smooth_angular_velocity = vec3_copy(authoritative.angular_velocity) + state.smooth_linear_acceleration = vec3(0, 0, 0) + state.smooth_angular_acceleration = vec3(0, 0, 0) + else + local previous_raw_velocity = state.last_raw_velocity or state.base_transform.velocity + raw_linear_acceleration = limit_vec(vec3( + (authoritative.velocity.x - previous_raw_velocity.x) / remote_dt, + (authoritative.velocity.y - previous_raw_velocity.y) / remote_dt, + (authoritative.velocity.z - previous_raw_velocity.z) / remote_dt + ), MAX_LINEAR_ACCELERATION) + + local previous_raw_angular_velocity = state.last_raw_angular_velocity or state.base_transform.angular_velocity + raw_angular_acceleration = limit_vec(vec3( + (authoritative.angular_velocity.x - previous_raw_angular_velocity.x) / remote_dt, + (authoritative.angular_velocity.y - previous_raw_angular_velocity.y) / remote_dt, + (authoritative.angular_velocity.z - previous_raw_angular_velocity.z) / remote_dt + ), MAX_ANGULAR_ACCELERATION) + + end + state.last_raw_velocity = vec3_copy(authoritative.velocity) + state.last_raw_angular_velocity = vec3_copy(authoritative.angular_velocity) + state.linear_acceleration = raw_linear_acceleration + state.angular_acceleration = raw_angular_acceleration + + -- Update base + receive timing. + state.base_transform = { + position = authoritative.position, + rotation = authoritative.rotation, + velocity = authoritative.velocity, + angular_velocity = authoritative.angular_velocity, + } + state.base_timestamp = timestamp + state.base_recv_time = current_time + state.time_offset_target = transform_data.time_offset or (current_time - timestamp) + if is_first_snapshot or is_stale or state.time_offset_smoothed == nil then + state.time_offset_smoothed = state.time_offset_target + end + state.generation = generation + state.last_update_time = current_time + if state.last_smooth_time <= 0 then + state.last_smooth_time = current_time + end + + -- Optional blend path (dormant by default). When blend_duration > 0 + -- and not first/stale, set up a blend from the previously-applied pose + -- to the new authoritative pose. Otherwise snap applied_transform; + -- update_and_get_transform will overwrite it next frame anyway via + -- the predictor, but snapping here keeps it consistent if no consumer + -- runs before the next snapshot. + if is_first_snapshot or is_stale or blend_duration <= 0 then + state.applied_transform = { + position = authoritative.position, + rotation = authoritative.rotation, + velocity = state.base_transform.velocity, + angular_velocity = state.base_transform.angular_velocity, + } + state.is_blending = false + else + state.blend_start = { + position = state.applied_transform.position, + rotation = state.applied_transform.rotation, + velocity = state.applied_transform.velocity, + angular_velocity = state.applied_transform.angular_velocity, + } + state.blend_target = authoritative + state.blend_target.velocity = state.base_transform.velocity + state.blend_target.angular_velocity = state.base_transform.angular_velocity + state.blend_start_time = current_time + state.blend_duration = blend_duration + state.is_blending = true + end +end + +local function update_and_get_transform(id, current_time) + local state = get_sync_state(id) + if state.base_timestamp == 0 then + return state.applied_transform + end + if (current_time - state.base_recv_time) > PACKET_TIMEOUT then + return nil + end + + -- Always extrapolate forward to current_time. + state.predicted_transform = extrapolate_transform(state, current_time) + + -- Blend path stays callable but dormant when blend_duration = 0 + -- (the default flow). When active, blend interpolates between + -- blend_start and blend_target; we use it as the applied transform + -- in that branch so the consumer sees a smoothed transition. + if state.is_blending then + local elapsed = current_time - state.blend_start_time + local t = math.max(0, math.min(1, elapsed / state.blend_duration)) + local blended = blend_transforms(state.blend_start, state.blend_target, t) + state.applied_transform = { + position = blended.position, + rotation = blended.rotation, + velocity = blended.velocity, + angular_velocity = blended.angular_velocity, + } + if t >= 1.0 then + state.is_blending = false + end + else + state.applied_transform = { + position = state.predicted_transform.position, + rotation = state.predicted_transform.rotation, + velocity = state.predicted_transform.velocity, + angular_velocity = state.predicted_transform.angular_velocity, + } + end + + return state.applied_transform +end + +local function set_blend_duration(duration) + M.default_blend_duration = math.max(0, duration) +end + +local function get_blend_progress(id, current_time) + local state = get_sync_state(id) + if not state.is_blending then + return 1.0 + end + local elapsed = current_time - state.blend_start_time + return math.max(0, math.min(1, elapsed / state.blend_duration)) +end + +local function is_initialized(id) + local state = M.sync_states[id] + return state ~= nil and state.base_timestamp > 0 +end + +local function reset_sync_state(id) + M.sync_states[id] = create_sync_state(id) +end + +local function reset_motion_smoothers(id) + local state = M.sync_states[id] + if not state then return end + local base = state.base_transform + state.linear_acceleration = vec3(0, 0, 0) + state.angular_acceleration = vec3(0, 0, 0) + state.smooth_velocity = vec3_copy(base.velocity) + state.smooth_angular_velocity = vec3_copy(base.angular_velocity) + state.smooth_linear_acceleration = vec3(0, 0, 0) + state.smooth_angular_acceleration = vec3(0, 0, 0) + state.last_raw_velocity = vec3_copy(base.velocity) + state.last_raw_angular_velocity = vec3_copy(base.angular_velocity) + state.last_smooth_time = 0 + state.is_blending = false +end + +-- Cleanup stale sync states from despawned vehicles. +local function cleanup_stale_states(max_age) + max_age = max_age or 30.0 + local current_time = be:getTime() or 0 + local cleaned = 0 + for id, state in pairs(M.sync_states) do + if state.last_update_time > 0 then + local age = current_time - state.last_update_time + if age > max_age then + M.sync_states[id] = nil + cleaned = cleaned + 1 + end + end + end + return cleaned +end + +local function refresh_all_states() + local count = 0 + for _ in pairs(M.sync_states) do + count = count + 1 + end + M.sync_states = {} + return count +end + +M.set_smoothing_tuning = set_smoothing_tuning +M.apply_snapshot = apply_snapshot +M.update_and_get_transform = update_and_get_transform +M.get_sync_state = get_sync_state +M.set_blend_duration = set_blend_duration +M.get_blend_progress = get_blend_progress +M.is_initialized = is_initialized +M.reset_sync_state = reset_sync_state +M.reset_motion_smoothers = reset_motion_smoothers +M.slerp_quaternion = slerp_quaternion +M.cleanup_stale_states = cleanup_stale_states +M.refresh_all_states = refresh_all_states + +return M diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_transforms.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_transforms.lua index 2b69cecd..32ad6a63 100644 --- a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_transforms.lua +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_transforms.lua @@ -1,154 +1,581 @@ +-- KissMP Transforms - COG-space cluster replay. + local M = {} -local cooldown_timer = 2 - -M.received_transform = { - position = vec3(0, 0, 0), - rotation = quat(0, 0, 0, 1), - velocity = vec3(0, 0, 0), - angular_velocity = vec3(0, 0, 0), - acceleration = vec3(0, 0, 0), - angular_acceleration = vec3(0, 0, 0), - sent_at = 0, - time_past = 0 -} - -M.target_transform = { - position = vec3(0, 0, 0), - rotation = quat(0, 0, 0, 1), - velocity = vec3(0, 0, 0), - angular_velocity = vec3(0, 0, 0), - acceleration = vec3(0, 0, 0), - angular_acceleration = vec3(0, 0, 0), -} - -M.force = 3 -M.ang_force = 100 + M.debug = false -M.lerp_factor = 30.0 - -local function predict(dt) - M.target_transform.velocity = M.received_transform.velocity + M.received_transform.acceleration * M.received_transform.time_past - local distance = M.target_transform.position:distance(vec3(obj:getPosition())) - local p = M.received_transform.position + M.target_transform.velocity * M.received_transform.time_past - if distance < 2 then - M.target_transform.position = lerp(M.target_transform.position, p, clamp(M.lerp_factor * dt, 0.00001, 1)) - else - M.target_transform.position = p +M.cooldown_timer = 2 +M.sync_id = nil +M.ownership = true +M.ownership_known = false + +-- Overshoot dampener state for the PD loop in update(). Tracks the previous +-- frame's requested correction and the current observed velocity delta; if +-- the body didn't deliver what we asked for last frame and the current +-- requested correction is in the same direction as the failure, the dampener +-- scales it down. Without this, when local physics can't keep up (saturated +-- tires, torque-limited drive, soft-body lag) the loop keeps pushing harder +-- and oscillates. +M.last_linear_step = nil +M.last_angular_step = nil +M.last_cog_velocity = nil +M.last_body_angular_velocity = nil +M.smooth_local_refnode_velocity = nil +M.smooth_local_body_angular_velocity = nil +M.smooth_linear_step_error = nil +M.smooth_angular_step_error = nil +M.last_update_dt = 0 +M.linear_pull_scale = 1.0 +M.angular_pull_scale = 0.65 + +local MAX_LINEAR_STEP_ERROR = 3 +local MAX_ANGULAR_STEP_ERROR = 3 +local LOCAL_SMOOTH_RATE = 50.0 +local ERROR_SMOOTH_RATE = 50.0 + +-- Mass-weighted COG offset, body frame. Wrong COG injects phantom pos/vel +-- error during turns (proportional to rotation/angular-velocity mismatch), +-- which the PD loop and try_rude both chase as oscillation. The mass- +-- weighted variant tracks mass distribution rather than the geometric +-- support-pair midpoint. +local function get_cog_body() + if kiss_vehicle and kiss_vehicle.get_sync_cog_body then + return kiss_vehicle.get_sync_cog_body() end + return vec3(0, 0, 0) +end - --M.target_transform.angular_velocity = M.received_transform.angular_velocity + M.received_transform.angular_acceleration * M.received_transform.time_past - --local rotation_delta = M.target_transform.angular_velocity * M.received_transform.time_past - M.target_transform.rotation = quat(M.received_transform.rotation)-- * quatFromEuler(rotation_delta.x, rotation_delta.y, rotation_delta.z) +local function get_primary_refnode_cid() + local refs = v and v.data and v.data.refNodes + if type(refs) ~= "table" then return nil end + local entry = refs[0] or refs[1] or refs + local ref = entry and (entry.cidRef or entry.idRef or entry.ref) or nil + if type(ref) == "number" then return ref end + if type(ref) == "string" and beamstate and beamstate.nodeNameMap then + return beamstate.nodeNameMap[ref] + end + return nil end -local function try_rude() - local distance = M.target_transform.position:distance(vec3(obj:getPosition())) - if distance > 6 then - local p = M.target_transform.position - obj:queueGameEngineLua("be:getObjectByID("..obj:getID().."):setPositionNoPhysicsReset(Point3F("..p.x..", "..p.y..", "..p.z.."))") - return true +local function get_local_sync_time() + return obj:getSimTime() or os.clock() +end + +local function wrap_angle_pi(angle) + while angle > math.pi do angle = angle - (2 * math.pi) end + while angle < -math.pi do angle = angle + (2 * math.pi) end + return angle +end + +local function clear_drift_state() + M.last_linear_step = nil + M.last_angular_step = nil + M.last_cog_velocity = nil + M.last_body_angular_velocity = nil + M.smooth_local_refnode_velocity = nil + M.smooth_local_body_angular_velocity = nil + M.smooth_linear_step_error = nil + M.smooth_angular_step_error = nil + M.last_update_dt = 0 +end + +local function lowpass_vec(prev, target, dt, rate) + if not prev then + return vec3(target.x, target.y, target.z) end - return false + local alpha = math.min(math.max(dt, 0) * rate, 1.0) + return vec3( + prev.x + (target.x - prev.x) * alpha, + prev.y + (target.y - prev.y) * alpha, + prev.z + (target.z - prev.z) * alpha + ) end -local function draw_debug() - obj.debugDrawProxy:drawSphere(0.3, M.target_transform.position:toFloat3(), color(0,255,0,100)) - obj.debugDrawProxy:drawSphere(0.3, M.received_transform.position:toFloat3(), color(0,0,255,100)) +local ClusterServo = {} + +function ClusterServo:limit_step(step, max_len) + local len = step:length() + if len > max_len then + return step * (max_len / len) + end + return step +end + +local function apply_disconnected_counterforce(cog_offset_world, linear_step, angular_step) + if not (kiss_vehicle and kiss_vehicle.get_disconnected_node_states) then return end + local disconnected = kiss_vehicle.get_disconnected_node_states() + if #disconnected == 0 then return end + + local pfps = obj:getPhysicsFPS() or 2000 + local x, y, z = -linear_step.x, -linear_step.y, -linear_step.z + local pitch, roll, yaw = -angular_step.x, -angular_step.y, -angular_step.z + local force = float3(0, 0, 0) + + for _, state in ipairs(disconnected) do + local cid = state.cid + local mass = state.mass or obj:getNodeMass(cid) or 0 + if cid and mass > 0 then + local node_pos = obj:getNodePosition(cid) + local node_offset_x = node_pos.x - cog_offset_world.x + local node_offset_y = node_pos.y - cog_offset_world.y + local node_offset_z = node_pos.z - cog_offset_world.z + force:set( + (x + node_offset_y * yaw - node_offset_z * roll) * mass * pfps, + (y + node_offset_z * pitch - node_offset_x * yaw) * mass * pfps, + (z + node_offset_x * roll - node_offset_y * pitch) * mass * pfps + ) + obj:applyForceVector(cid, force) + end + end +end + +function ClusterServo:solve_step(cog_position_error, cog_velocity_error, orientation_error, spin_error, dt) + local POS_CORRECT_MUL, POS_FORCE_MUL = 5, 5 + local MAX_POS_FORCE = 100 + local ROT_CORRECT_MUL, ROT_FORCE_MUL = 7, 7 + local MAX_ROT_FORCE = 50 + + local linear_scale = math.min(POS_FORCE_MUL * dt, 1.0) + local angular_scale = math.min(ROT_FORCE_MUL * dt, 1.0) + + local linear_step = (cog_velocity_error + cog_position_error * POS_CORRECT_MUL) * linear_scale * (M.linear_pull_scale or 1.0) + local angular_step = (spin_error + orientation_error * ROT_CORRECT_MUL) * angular_scale * (M.angular_pull_scale or 1.0) + + return self:limit_step(linear_step, MAX_POS_FORCE * dt), + self:limit_step(angular_step, MAX_ROT_FORCE * dt) +end + +local function set_linear_pull_scale(scale) + if type(scale) == "number" then + M.linear_pull_scale = math.max(0.5, math.min(scale, 1.5)) + end +end + +local function set_angular_pull_scale(scale) + if type(scale) == "number" then + M.angular_pull_scale = math.max(0.2, math.min(scale, 1.2)) + end +end + +function ClusterServo:apply_cluster_step(refnode_cid, cog_offset_world, linear_step, angular_step, local_cog_speed) + local MIN_POS_FORCE, MIN_ROT_FORCE = 0.04, 0.02 + local pfps = obj:getPhysicsFPS() or 2000 + + if angular_step:length() > MIN_ROT_FORCE or local_cog_speed > 1 then + local cog_cross_spin_x = cog_offset_world.y * angular_step.z - cog_offset_world.z * angular_step.y + local cog_cross_spin_y = cog_offset_world.z * angular_step.x - cog_offset_world.x * angular_step.z + local cog_cross_spin_z = cog_offset_world.x * angular_step.y - cog_offset_world.y * angular_step.x + obj:applyClusterLinearAngularAccel( + refnode_cid, + vec3( + (linear_step.x - cog_cross_spin_x) * pfps, + (linear_step.y - cog_cross_spin_y) * pfps, + (linear_step.z - cog_cross_spin_z) * pfps + ), + vec3( + -angular_step.x * pfps, + -angular_step.y * pfps, + -angular_step.z * pfps + ) + ) + apply_disconnected_counterforce(cog_offset_world, linear_step, angular_step) + elseif linear_step:length() > MIN_POS_FORCE then + obj:applyClusterLinearAngularAccel( + refnode_cid, + vec3(linear_step.x * pfps, linear_step.y * pfps, linear_step.z * pfps), + vec3(0, 0, 0) + ) + apply_disconnected_counterforce(cog_offset_world, linear_step, vec3(0, 0, 0)) + end +end + +local function get_synced_transform(current_time) + if not M.sync_id then return nil end + return kiss_sync.update_and_get_transform(M.sync_id, current_time) +end + +-- Handle large corrections (teleport prevention) in the same COG space used +-- by the active correction loop. The wire position is COG-anchored; the GE +-- cluster snap receives the equivalent refnode origin. +local function try_rude(target_cog_position, target_rotation, target_cog_velocity, target_angular_velocity, dt) + -- Use the same rotation convention the wire / PD loop use (quatFromDir + -- on negated direction vector). Mixing obj:getRotation() here would + -- produce a frame-mismatched yaw error and fire the rude-snap at the + -- wrong heading thresholds. + local current_rotation = quatFromDir(-vec3(obj:getDirectionVector()), vec3(obj:getDirectionVectorUp())) + local current_centroid_pos = vec3(obj:getPosition()) + current_rotation * get_cog_body() + local current_euler = current_rotation:toEulerYXZ() + local target_euler = target_rotation:toEulerYXZ() + local planar_error_x = target_cog_position.x - current_centroid_pos.x + local planar_error_y = target_cog_position.y - current_centroid_pos.y + local planar_error = math.sqrt(planar_error_x * planar_error_x + planar_error_y * planar_error_y) + local vertical_error = math.abs(target_cog_position.z - current_centroid_pos.z) + local yaw_error = math.abs(wrap_angle_pi(target_euler.x - current_euler.x)) + + local severe = planar_error > 8.0 + or vertical_error > 4.0 + or (planar_error > 5.0 and yaw_error > math.rad(45)) + + if severe then + M.rude_error_time = (M.rude_error_time or 0) + dt + else + M.rude_error_time = math.max(0, (M.rude_error_time or 0) - (dt * 2.0)) + end + + if (M.rude_error_time or 0) < 0.25 then + return false + end + + clear_drift_state() + if M.sync_id and kiss_sync and kiss_sync.reset_motion_smoothers then + kiss_sync.reset_motion_smoothers(M.sync_id) + end + M.rude_error_time = 0 + + local target_cog_offset_world = target_rotation * get_cog_body() + local target_origin = target_cog_position - target_cog_offset_world + local target_angular_velocity_world = target_angular_velocity or vec3(0, 0, 0) + local origin_velocity = (target_cog_velocity or vec3(0, 0, 0)) - target_cog_offset_world:cross(target_angular_velocity_world) + obj:queueGameEngineLua( + "kisstransform.apply_cluster_target("..obj:getID().."," + ..target_origin.x..","..target_origin.y..","..target_origin.z.."," + ..target_rotation.x..","..target_rotation.y..","..target_rotation.z..","..target_rotation.w.."," + ..origin_velocity.x..","..origin_velocity.y..","..origin_velocity.z..")" + ) + return true +end + +local function draw_debug(synced_transform) + obj.debugDrawProxy:drawSphere(0.3, synced_transform.position:toFloat3(), color(0,255,0,100)) + local current_pos = vec3(obj:getPosition()) + obj.debugDrawProxy:drawSphere(0.3, current_pos:toFloat3(), color(255,0,0,100)) + local blend_progress = kiss_sync.get_blend_progress(M.sync_id, M.last_update_time or 0) + if blend_progress < 1.0 then + obj.debugDrawProxy:drawText("Blend: " .. math.floor(blend_progress * 100) .. "%", current_pos:toFloat3(), color(255,255,0,255)) + end end local function update(dt) - if cooldown_timer > 0 then - cooldown_timer = cooldown_timer - clamp(dt, 0, 0.02) + if not M.ownership_known or M.ownership then + return + end + + if M.debug and dt <= 0.1 then + print("[kiss_transforms.update] obj=" .. tostring(obj) .. " id=" .. tostring(obj:getID()) .. " sync_id=" .. tostring(M.sync_id) .. " dt=" .. tostring(dt)) + end + + if M.cooldown_timer > 0 then + M.cooldown_timer = M.cooldown_timer - clamp(dt, 0, 0.02) return end - if dt > 0.1 then return end - M.received_transform.time_past = clamp(M.received_transform.time_past + dt, 0, 0.5) - predict(dt) - if try_rude() then return end + + if dt > 0.1 then + if M.debug then + print("[kiss_transforms.update] BLOCKED by large dt: " .. dt) + end + return + end + + local current_time = get_local_sync_time() + M.last_update_time = current_time + M.last_update_dt = dt if M.debug then - draw_debug() - end - - local force = M.force - local ang_force = M.ang_force - - local c_ang = -math.sqrt(4 * ang_force) - - local velocity_difference = M.target_transform.velocity - vec3(obj:getVelocity()) - local position_delta = M.target_transform.position - vec3(obj:getPosition()) - --position_delta = position_delta:normalized() * math.pow(position_delta:length(), 2) - local linear_force = (velocity_difference + position_delta * force) * dt * 5 - if linear_force:length() > 10 then - linear_force = linear_force:normalized() * 10 - end - - local local_ang_vel = vec3( - obj:getYawAngularVelocity(), - obj:getPitchAngularVelocity(), - obj:getRollAngularVelocity() - ) + print("[kiss_transforms.update] current_time=" .. current_time .. " calling get_synced_transform") + end - local angular_velocity_difference = M.target_transform.angular_velocity - local_ang_vel - local angle_delta = M.target_transform.rotation / quat(obj:getRotation()) - local angular_force = angle_delta:toEulerYXZ() - local angular_force = (angular_velocity_difference + angular_force * ang_force + c_ang * local_ang_vel) * dt - if angular_force:length() > 25 then + local synced_transform = get_synced_transform(current_time) + + if not synced_transform then + if M.debug then + print("[kiss_transforms.update] BLOCKED: get_synced_transform returned nil") + end return end - if angular_force:length() > 0.1 then - kiss_vehicle.apply_linear_velocity_ang_torque( - linear_force.x, - linear_force.y, - linear_force.z, - angular_force.y, - angular_force.z, - angular_force.x - ) - elseif linear_force:length() > (dt * 15) then - kiss_vehicle.apply_linear_velocity( - linear_force.x, - linear_force.y, - linear_force.z + if M.debug then + print("[kiss_transforms.update] Got synced_transform pos=(" .. synced_transform.position.x .. "," .. synced_transform.position.y .. "," .. synced_transform.position.z .. ")") + end + + -- Owner-replay path: heading comes directly from the predicted owner body + -- rotation; follower-side path error must never bias authoritative yaw. + local target_cluster_rotation = synced_transform.rotation + local target_cluster_angular_velocity = synced_transform.angular_velocity or vec3(0, 0, 0) + local target_cog_position = synced_transform.position + local target_cog_velocity = synced_transform.velocity or vec3(0, 0, 0) + + if try_rude(target_cog_position, target_cluster_rotation, target_cog_velocity, target_cluster_angular_velocity, dt) then + if M.debug then + print("[kiss_transforms.update] try_rude triggered - rude-snap reset applied") + draw_debug(synced_transform) + end + return + end + + -- PD-on-velocity correction. + -- + -- We don't snap pose every frame; the cluster drifts to the target via + -- per-frame force injection and the local physics solver integrates + -- normally. Per-frame setClusterPosRelRot fights the solver's velocity- + -- based integration and produces oscillation. + -- + -- Control law (per axis, world frame, target velocity at COG): + -- linear_step = clamp((velocity_error + position_error * 5) * min(5*dt, 1), 100*dt) + -- angular_step = clamp((spin_error + rotation_error * 7) * min(7*dt, 1), 50*dt) + -- Then the overshoot dampener scales each by linear_mul/angular_mul in [0, 1] + -- depending on whether last frame's correction overshot. + -- + -- Cluster API conventions: + -- * Linear arg = (linear_step - cog x angular_step) * physicsFPS. The + -- cross-product subtraction converts "Δv at COG" to the equivalent + -- linear delta at refNode, so when the angular component rotates + -- about refNode the COG ends up with the requested delta-v. + -- * Angular arg is NEGATED: -angular_step * physicsFPS. The cluster + -- API applies the angular argument with a sign flip internally; + -- passing -delta produces +delta change in angular velocity. + local refnode_cid = get_primary_refnode_cid() + if refnode_cid then + + -- Wrong COG creates phantom position/velocity error proportional to + -- rotation and angular-velocity mismatch. + if kiss_vehicle and kiss_vehicle.maybe_recompute_sync_cog_body then + kiss_vehicle.maybe_recompute_sync_cog_body() + end + local cog_body = vec3(0, 0, 0) + if kiss_vehicle and kiss_vehicle.get_sync_cog_body then + cog_body = kiss_vehicle.get_sync_cog_body() + end + + -- Local state in world frame. + local local_rot = quatFromDir(-vec3(obj:getDirectionVector()), vec3(obj:getDirectionVectorUp())) + local cog_offset_world = cog_body:rotated(local_rot) + local local_refnode_position = vec3(obj:getPosition()) + local local_motion = kiss_vehicle and kiss_vehicle.get_smoothed_local_motion and kiss_vehicle.get_smoothed_local_motion() + local local_refnode_velocity = local_motion and local_motion.refnode_velocity + local local_body_angular_velocity = local_motion and local_motion.body_omega + if not local_refnode_velocity or not local_body_angular_velocity then + local raw_local_refnode_velocity = vec3(obj:getVelocity()) + local raw_local_body_angular_velocity = vec3( + obj:getPitchAngularVelocity(), + obj:getRollAngularVelocity(), + obj:getYawAngularVelocity() + ) + M.smooth_local_refnode_velocity = lowpass_vec(M.smooth_local_refnode_velocity, raw_local_refnode_velocity, dt, LOCAL_SMOOTH_RATE) + M.smooth_local_body_angular_velocity = lowpass_vec(M.smooth_local_body_angular_velocity, raw_local_body_angular_velocity, dt, LOCAL_SMOOTH_RATE) + local_refnode_velocity = M.smooth_local_refnode_velocity + local_body_angular_velocity = M.smooth_local_body_angular_velocity + end + local local_angular_velocity = local_body_angular_velocity:rotated(local_rot) + -- Cluster convention: v_cog = v_refnode + cog_offset_world x omega_world. + local local_cog_position = local_refnode_position + cog_offset_world + local local_cog_velocity = local_refnode_velocity + cog_offset_world:cross(local_angular_velocity) + + -- Observed velocity-delta this frame, world frame at COG. Used by the + -- overshoot dampener to detect when the body didn't deliver what we + -- asked for last frame. + local delivered_linear_step = (M.last_cog_velocity == nil) and vec3(0,0,0) or (local_cog_velocity - M.last_cog_velocity) + local delivered_angular_step = (M.last_body_angular_velocity == nil) and vec3(0,0,0) or (local_angular_velocity - M.last_body_angular_velocity) + M.last_cog_velocity = local_cog_velocity + M.last_body_angular_velocity = local_angular_velocity + + -- Target state in world frame. Wire is COG-anchored. + local target_rotation = target_cluster_rotation + local target_angular_velocity = target_cluster_angular_velocity + + -- Errors. + local cog_position_error = target_cog_position - local_cog_position + local cog_velocity_error = target_cog_velocity - local_cog_velocity + + -- Rotation error: small-angle approximation via local-frame Euler of + -- (current.inverse * target), swizzled to (eul.y, eul.z, eul.x) to + -- match the gyro / ω packing convention used elsewhere on the wire. + local rotation_error_quaternion = local_rot:inversed() * target_rotation + local rotation_error_euler = rotation_error_quaternion:toEulerYXZ() + local orientation_error = vec3(rotation_error_euler.y, rotation_error_euler.z, rotation_error_euler.x) + local spin_error = target_angular_velocity - local_angular_velocity + + local linear_step, angular_step = ClusterServo:solve_step( + cog_position_error, cog_velocity_error, + orientation_error, spin_error, + dt ) + + -- Overshoot dampener. step_error is (what we asked for last frame) - + -- (what the body actually delivered this frame). If our current + -- requested correction direction agrees with the failure direction, + -- the body is saturating and pushing harder will overshoot; scale + -- the correction down. The (max_err^2 * dt) floor in the denominator + -- means tiny errors don't trigger heavy damping. + local last_linear_step = M.last_linear_step or delivered_linear_step + local last_angular_step = M.last_angular_step or delivered_angular_step + local raw_linear_step_error = last_linear_step - delivered_linear_step + local raw_angular_step_error = last_angular_step - delivered_angular_step + M.smooth_linear_step_error = lowpass_vec(M.smooth_linear_step_error, raw_linear_step_error, dt, ERROR_SMOOTH_RATE) + M.smooth_angular_step_error = lowpass_vec(M.smooth_angular_step_error, raw_angular_step_error, dt, ERROR_SMOOTH_RATE) + + local linear_denom = linear_step:squaredLength() + (MAX_LINEAR_STEP_ERROR * MAX_LINEAR_STEP_ERROR) * dt + local angular_denom = angular_step:squaredLength() + (MAX_ANGULAR_STEP_ERROR * MAX_ANGULAR_STEP_ERROR) * dt + + local linear_mul = 1.0 - math.min(math.max(linear_step:dot(M.smooth_linear_step_error) / linear_denom, 0), 1) + local angular_mul = 1.0 - math.min(math.max(angular_step:dot(M.smooth_angular_step_error) / angular_denom, 0), 1) + + linear_step = linear_step * linear_mul + angular_step = angular_step * angular_mul + + M.last_linear_step = linear_step + M.last_angular_step = angular_step + + -- Bob diagnostic: rate-limited dump of error/step magnitudes so we can + -- see whether static bobbing is driven by position error, velocity error, + -- or stale step output. + local now_log = current_time + if M.debug and (M.last_bob_log_time or 0) + 0.25 < now_log then + M.last_bob_log_time = now_log + print(string.format( + "[bob vid=%d] pos_err=%.3f vel_err=%.3f rot_err=%.3f spin_err=%.3f lin_step=%.4f ang_step=%.4f local_v=%.3f", + obj:getID() or -1, + cog_position_error:length(), cog_velocity_error:length(), + orientation_error:length(), spin_error:length(), + linear_step:length(), angular_step:length(), + local_cog_velocity:length() + )) + end + + ClusterServo:apply_cluster_step(refnode_cid, cog_offset_world, linear_step, angular_step, local_cog_velocity:length()) + end + + if M.debug then + draw_debug(synced_transform) end end +M.rude_error_time = 0 + +-- Apply authoritative snapshot from wire. Runs once per received packet +-- (queued by kisstransform.update_vehicle_transform). Just caches; no +-- force application here. Force pulls happen every Lua frame from update(dt) +-- so the correction is sustained and doesn't depend on packet cadence. local function set_target_transform(raw) local transform = jsonDecode(raw) - local time_dif = clamp((transform.sent_at - M.received_transform.sent_at), 0.01, 0.1) - M.received_transform.acceleration = (vec3(transform.velocity) - M.received_transform.velocity) / time_dif - if M.received_transform.acceleration:length() > 5 then - M.received_transform.acceleration = M.received_transform.acceleration:normalized() * 5 + if transform.owner then + if M.sync_id ~= nil and M.sync_id ~= transform.owner then + kiss_sync.reset_sync_state(transform.owner) + end + M.sync_id = transform.owner + end + if not M.sync_id then return end + + local current_time = get_local_sync_time() + local snapshot_timestamp = transform.send_timer + if not snapshot_timestamp or snapshot_timestamp <= 0 then + snapshot_timestamp = transform.sent_at or current_time + end + local own_ping = ((transform.receiver_ping_ms or 0) * 0.001) + local remote_ping = ((transform.ping_ms or 0) * 0.001) + transform.time_offset = current_time - snapshot_timestamp - (own_ping * 0.5) - (remote_ping * 0.5) - (M.last_update_dt or 0) + -- Prefer send_timer (sender-monotonic) over sent_at (sender wall-clock, + -- subject to cross-machine skew) when both are present. The blend + -- argument is left as 0; kiss_sync extrapolates forward and the PD + -- loop on this side handles motion smoothness; an additional snapshot + -- blend would only inject lag. + kiss_sync.apply_snapshot( + M.sync_id, transform, + snapshot_timestamp, + transform.generation or 0, + current_time, 0 + ) +end + +local function snap_to_cog_target(target_cog_position_x, target_cog_position_y, target_cog_position_z, target_rotation_x, target_rotation_y, target_rotation_z, target_rotation_w, target_velocity_x, target_velocity_y, target_velocity_z, target_angular_velocity_x, target_angular_velocity_y, target_angular_velocity_z) + if kiss_vehicle and kiss_vehicle.maybe_recompute_sync_cog_body then + kiss_vehicle.maybe_recompute_sync_cog_body() end - M.received_transform.angular_acceleration = (vec3(transform.angular_velocity) - M.received_transform.angular_velocity) / time_dif - if M.received_transform.acceleration:length() > 5 then - M.received_transform.angular_acceleration = M.received_transform.angular_acceleration:normalized() * 5 + + local target_rotation = quat(target_rotation_x, target_rotation_y, target_rotation_z, target_rotation_w) + local target_cog_position = vec3(target_cog_position_x, target_cog_position_y, target_cog_position_z) + local target_cog_velocity = vec3(target_velocity_x or 0, target_velocity_y or 0, target_velocity_z or 0) + local target_angular_velocity = vec3(target_angular_velocity_x or 0, target_angular_velocity_y or 0, target_angular_velocity_z or 0) + local target_cog_offset_world = target_rotation * get_cog_body() + local target_origin = target_cog_position - target_cog_offset_world + local target_origin_velocity = target_cog_velocity - target_cog_offset_world:cross(target_angular_velocity) + + clear_drift_state() + if M.sync_id and kiss_sync and kiss_sync.reset_motion_smoothers then + kiss_sync.reset_motion_smoothers(M.sync_id) + end + + obj:queueGameEngineLua( + "kisstransform.apply_cluster_target("..obj:getID().."," + ..target_origin.x..","..target_origin.y..","..target_origin.z.."," + ..target_rotation.x..","..target_rotation.y..","..target_rotation.z..","..target_rotation.w.."," + ..target_origin_velocity.x..","..target_origin_velocity.y..","..target_origin_velocity.z..")" + ) +end + +local function post_teleport_cooldown(duration) + clear_drift_state() + if M.sync_id and kiss_sync and kiss_sync.reset_sync_state then + kiss_sync.reset_sync_state(M.sync_id) end - M.received_transform.position = vec3(transform.position) - M.received_transform.rotation = quat(transform.rotation) - M.received_transform.velocity = vec3(transform.velocity) - M.received_transform.angular_velocity = vec3(transform.angular_velocity) - M.received_transform.time_past = transform.time_past + M.rude_error_time = 0 + M.cooldown_timer = math.max(M.cooldown_timer or 0, duration or 0.35) end local function onExtensionLoaded() - M.received_transform.position = vec3(obj:getPosition()) - M.target_transform.position = vec3(obj:getPosition()) - M.received_transform.rotation = quat(obj:getRotation()) - M.target_transform.rotation = quat(obj:getRotation()) - cooldown_timer = 1.5 + M.sync_id = obj:getID() + if kiss_vehicle and kiss_vehicle.maybe_recompute_sync_cog_body then + kiss_vehicle.maybe_recompute_sync_cog_body() + end + local current_rotation = quatFromDir(-vec3(obj:getDirectionVector()), vec3(obj:getDirectionVectorUp())) + local current_pos = vec3(obj:getPosition()) + current_rotation * get_cog_body() + + -- Seed kiss_sync at the current COG so first update has no startup snap. + local current_time = get_local_sync_time() + local initial_transform = { + position = {current_pos.x, current_pos.y, current_pos.z}, + rotation = {current_rotation.x, current_rotation.y, current_rotation.z, current_rotation.w}, + velocity = {0, 0, 0}, + angular_velocity = {0, 0, 0}, + } + kiss_sync.apply_snapshot(M.sync_id, initial_transform, current_time, 0, current_time, 0) + + clear_drift_state() + M.rude_error_time = 0 + M.cooldown_timer = 1.5 +end + +local function kissUpdateOwnership(owned) + M.ownership = owned and true or false + M.ownership_known = true + if M.ownership then + clear_drift_state() + end end local function onReset() - cooldown_timer = 0.2 + if M.sync_id then + kiss_sync.reset_sync_state(M.sync_id) + end + clear_drift_state() + M.rude_error_time = 0 + M.cooldown_timer = 0.2 + -- Do not carry observed delta-v across a reset. + M.last_linear_step = nil + M.last_angular_step = nil + M.last_cog_velocity = nil + M.last_body_angular_velocity = nil end M.set_target_transform = set_target_transform +M.snap_to_cog_target = snap_to_cog_target +M.post_teleport_cooldown = post_teleport_cooldown +M.set_linear_pull_scale = set_linear_pull_scale +M.set_angular_pull_scale = set_angular_pull_scale M.update = update +M.updateGFX = update M.onExtensionLoaded = onExtensionLoaded M.onReset = onReset +M.kissUpdateOwnership = kissUpdateOwnership +M.get_synced_transform = get_synced_transform return M diff --git a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_vehicle.lua b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_vehicle.lua index 3c35d7b9..42df2999 100644 --- a/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_vehicle.lua +++ b/KISSMultiplayer/lua/vehicle/extensions/kiss_mp/kiss_vehicle.lua @@ -1,155 +1,351 @@ local M = {} -local parts_config = v.config + local nodes = {} -local ref_nodes = {} +local node_by_cid = {} +local connected_node_states = {} +local connected_node_set = {} +local connected_graph = {} +local parent_node = nil +local last_damage = 0 -local last_node = 1 -local nodes_per_frame = 32 +M.sync_cog_body = vec3(0, 0, 0) -local node_pos_thresh = 3 -local node_pos_thresh_sqr = node_pos_thresh * node_pos_thresh +local last_cog_compute_time = -math.huge +local COG_RECOMPUTE_INTERVAL_S = 0.2 -M.test_quat = quat(0.707, 0, 0, 0.707) +local SEND_SMOOTH_RATE = 50.0 +local smoothed_send_refnode_velocity = nil +local smoothed_send_body_angular_velocity = nil +local last_motion_sample_time = nil +local last_motion_sample_dt = 1/60 +local cached_transform_sample = nil +local send_timer = 0 -local function onExtensionLoaded() - local force = obj:getPhysicsFPS() +local function get_body_gyro_local_omega() + return vec3( + obj:getPitchAngularVelocity(), + obj:getRollAngularVelocity(), + obj:getYawAngularVelocity() + ) +end + +local function lowpass_dt(prev, target, dt, rate) + if not prev then + return vec3(target.x, target.y, target.z) + end + local alpha = math.min(rate * dt, 1.0) + return vec3( + prev.x + (target.x - prev.x) * alpha, + prev.y + (target.y - prev.y) * alpha, + prev.z + (target.z - prev.z) * alpha + ) +end - local ref = { - v.data.refNodes[0].left, - v.data.refNodes[0].up, - v.data.refNodes[0].back, - v.data.refNodes[0].ref, +local function reset_send_smoothers() + smoothed_send_refnode_velocity = nil + smoothed_send_body_angular_velocity = nil + last_motion_sample_time = nil + last_motion_sample_dt = 1/60 + cached_transform_sample = nil +end + +local function get_refnode_ids() + local refs = v and v.data and v.data.refNodes + if type(refs) ~= "table" then return {} end + local entry = refs[0] or refs[1] or refs + return { + entry and (entry.ref or entry.idRef or entry.cidRef), + entry and (entry.back or entry.idX or entry.cidX), + entry and (entry.up or entry.idY or entry.cidY), + entry and (entry.left or entry.idLeft or entry.cidLeft), } +end + +local function resolve_cid(value) + if type(value) == "number" and node_by_cid[value] then return value end + if type(value) == "string" then + local numeric = tonumber(value) + if numeric and node_by_cid[numeric] then return numeric end + if beamstate and beamstate.nodeNameMap then + local mapped = beamstate.nodeNameMap[value] + if mapped and node_by_cid[mapped] then return mapped end + end + end + return nil +end + +local function build_connected_graph() + connected_graph = {} + if not (v and v.data and v.data.beams) then return end + for _, beam in pairs(v.data.beams) do + if beam.beamType ~= 3 and beam.beamType ~= 4 and beam.beamType ~= 7 then + local a = resolve_cid(beam.id1) + local b = resolve_cid(beam.id2) + if a and b then + connected_graph[a] = connected_graph[a] or {} + connected_graph[b] = connected_graph[b] or {} + connected_graph[a][#connected_graph[a] + 1] = {cid = b, beam = beam.cid} + connected_graph[b][#connected_graph[b] + 1] = {cid = a, beam = beam.cid} + end + end + end +end + +local function choose_parent_node() + parent_node = nil + for _, ref in ipairs(get_refnode_ids()) do + local cid = resolve_cid(ref) + if cid and connected_graph[cid] then + parent_node = cid + return + end + end + for cid in pairs(node_by_cid) do + parent_node = cid + return + end +end + +local function rebuild_connected_nodes() + connected_node_states = {} + connected_node_set = {} + if not parent_node then + for _, state in ipairs(nodes) do + connected_node_states[#connected_node_states + 1] = state + connected_node_set[state.cid] = true + end + return + end + + local stack = {parent_node} + connected_node_set[parent_node] = true + while #stack > 0 do + local cid = stack[#stack] + stack[#stack] = nil + local state = node_by_cid[cid] + if state then + connected_node_states[#connected_node_states + 1] = state + end + for _, edge in ipairs(connected_graph[cid] or {}) do + local other = edge.cid + if not connected_node_set[other] then + local broken = edge.beam ~= nil and obj:beamIsBroken(edge.beam) + if not broken then + connected_node_set[other] = true + stack[#stack + 1] = other + end + end + end + end +end + +-- Mass-weighted COG offset in body frame. The receiver uses this same body +-- offset to run the correction loop in COG-space instead of refnode-space. +local function compute_sync_cog_body() local total_mass = 0 - local inverse_rot = quat(obj:getRotation()):inversed() - for _, node in pairs(v.data.nodes) do - local node_mass = obj:getNodeMass(node.cid) - local node_pos = inverse_rot * obj:getNodePosition(node.cid) - table.insert( - nodes, - { - node.cid, - node_mass * force, - true, - node_pos - } - ) - --M.test_nodes_sync[node.cid] = vec3(obj:getNodePosition(node.cid)) - total_mass = total_mass + node_mass - end - - for _, node in pairs(ref) do - table.insert( - ref_nodes, - { - node, - total_mass * force / 4, - true, - inverse_rot * obj:getNodePosition(node) - } - ) - end -end - - -- NOTE: - -- This is a temperary solution. It's not great. We made it to release the mod. - -- A better solution will be used in future versions -local function update_eligible_nodes() - local inverse_rot = quat(obj:getRotation()):inversed() - for k=last_node, math.min(#nodes , last_node + nodes_per_frame) do - local node = nodes[k] - local local_node_pos = inverse_rot * obj:getNodePosition(node[1]) - local local_original_pos = node[4] - node[3] = (local_node_pos - local_original_pos):squaredLength() < node_pos_thresh_sqr - last_node = k - end - if last_node == #nodes then last_node = 1 end -end - -local function update_transform_info() - local r = quat(obj:getRotation()) - local p = obj:getPosition() - + local cog_sum_x, cog_sum_y, cog_sum_z = 0, 0, 0 + + local cog_nodes = (#connected_node_states > 0) and connected_node_states or nodes + for _, state in ipairs(cog_nodes) do + local mass = state.mass or 0 + if mass > 0 then + local pos = obj:getNodePosition(state.cid) + if pos then + cog_sum_x = cog_sum_x + pos.x * mass + cog_sum_y = cog_sum_y + pos.y * mass + cog_sum_z = cog_sum_z + pos.z * mass + total_mass = total_mass + mass + end + end + end + + if total_mass < 1e-9 then + M.sync_cog_body = vec3(0, 0, 0) + return + end + + local inv = 1 / total_mass + local cog_offset_world = vec3(cog_sum_x * inv, cog_sum_y * inv, cog_sum_z * inv) + local rot = quatFromDir(-vec3(obj:getDirectionVector()), vec3(obj:getDirectionVectorUp())) + M.sync_cog_body = cog_offset_world:rotated(rot:inversed()) +end + +local function maybe_recompute_sync_cog_body() + local now = os.clock() + local damage = (beamstate and beamstate.damage) or 0 + if damage ~= last_damage then + rebuild_connected_nodes() + last_damage = damage + last_cog_compute_time = -math.huge + end + if now - last_cog_compute_time >= COG_RECOMPUTE_INTERVAL_S then + compute_sync_cog_body() + last_cog_compute_time = now + end +end + +local function get_sync_cog_body() + return M.sync_cog_body or vec3(0, 0, 0) +end + +local function get_disconnected_node_states() + local out = {} + for _, state in ipairs(nodes) do + if not connected_node_set[state.cid] then + out[#out + 1] = state + end + end + return out +end + +local function get_smoothed_local_motion() + return { + refnode_velocity = smoothed_send_refnode_velocity, + body_omega = smoothed_send_body_angular_velocity, + dt = last_motion_sample_dt, + } +end + +local function update_motion_sample(dt) + -- BeamMP smooths local velocity from the physics hook, then packet packing + -- reads that smoothed state. Keep the same semantic split here: physics + -- samples update the cached motion state, GE-side send code only packages it. + local now = obj:getSimTime() or os.clock() + if last_motion_sample_time ~= nil and now <= last_motion_sample_time then + return + end + + local sample_dt = dt + if not sample_dt or sample_dt <= 0 then + sample_dt = last_motion_sample_time and (now - last_motion_sample_time) or (1/60) + end + if sample_dt <= 0 then sample_dt = 1/60 end + if sample_dt > 0.1 then sample_dt = 0.1 end + + local r = quatFromDir(-vec3(obj:getDirectionVector()), vec3(obj:getDirectionVectorUp())) + local p = vec3(obj:getPosition()) + local raw_refnode_velocity = vec3(obj:getVelocity()) + local raw_body_angular_velocity = get_body_gyro_local_omega() + + smoothed_send_refnode_velocity = lowpass_dt(smoothed_send_refnode_velocity, raw_refnode_velocity, sample_dt, SEND_SMOOTH_RATE) + smoothed_send_body_angular_velocity = lowpass_dt(smoothed_send_body_angular_velocity, raw_body_angular_velocity, sample_dt, SEND_SMOOTH_RATE) + + local refnode_velocity_world = smoothed_send_refnode_velocity + local angular_velocity_world = smoothed_send_body_angular_velocity:rotated(r) + + maybe_recompute_sync_cog_body() + local cog_offset_world = get_sync_cog_body():rotated(r) + local cog_position_world = p + cog_offset_world + local cog_velocity_world = refnode_velocity_world + cog_offset_world:cross(angular_velocity_world) + + last_motion_sample_time = now + last_motion_sample_dt = sample_dt + send_timer = now + cached_transform_sample = { + position = cog_position_world, + rotation = r, + velocity = cog_velocity_world, + angular_velocity = angular_velocity_world, + } +end + +local function onExtensionLoaded() + nodes = {} + node_by_cid = {} + connected_node_states = {} + connected_node_set = {} + parent_node = nil + last_damage = (beamstate and beamstate.damage) or 0 + last_cog_compute_time = -math.huge + reset_send_smoothers() + send_timer = 0 + + if v and v.data and v.data.nodes then + for _, node in pairs(v.data.nodes) do + if node.cid ~= nil then + local state = { + cid = node.cid, + mass = obj:getNodeMass(node.cid), + } + nodes[#nodes + 1] = state + node_by_cid[node.cid] = state + end + end + end + + build_connected_graph() + choose_parent_node() + rebuild_connected_nodes() + compute_sync_cog_body() +end + +local function onReset() + last_cog_compute_time = -math.huge + last_damage = (beamstate and beamstate.damage) or 0 + rebuild_connected_nodes() + reset_send_smoothers() + compute_sync_cog_body() +end + +local function post_owner_teleport_settle() + reset_send_smoothers() + last_cog_compute_time = -math.huge + compute_sync_cog_body() +end + +local function update_transform_info(_we_own_this_vehicle) + update_motion_sample() + local sample = cached_transform_sample + if not sample then return end + local throttle_input = electrics.values.throttle_input or 0 local brake_input = electrics.values.brake_input or 0 if electrics.values.gearboxMode == "arcade" and electrics.values.gearIndex < 0 then throttle_input, brake_input = brake_input, throttle_input end - + local input = { vehicle_id = obj:getID() or 0, throttle_input = throttle_input, - brake_input = brake_input, + brake_input = brake_input, clutch = electrics.values.clutch_input or 0, parkingbrake = electrics.values.parkingbrake_input or 0, steering_input = electrics.values.steering_input or 0, } - local gearbox = kiss_gearbox.get_gearbox_data() local transform = { - position = {p.x, p.y, p.z}, - rotation = {r.x, r.y, r.z, r.w}, + position = {sample.position.x, sample.position.y, sample.position.z}, + rotation = {sample.rotation.x, sample.rotation.y, sample.rotation.z, sample.rotation.w}, + velocity = {sample.velocity.x, sample.velocity.y, sample.velocity.z}, + angular_velocity = {sample.angular_velocity.x, sample.angular_velocity.y, sample.angular_velocity.z}, input = input, - gearbox = gearbox, - vel_pitch = obj:getPitchAngularVelocity(), - vel_roll = obj:getRollAngularVelocity(), - vel_yaw = obj:getYawAngularVelocity(), + gearbox = kiss_gearbox.get_gearbox_data(), + send_timer = send_timer, + send_dt = last_motion_sample_dt, } obj:queueGameEngineLua("kisstransform.push_transform("..obj:getID()..", " .. string.format("%q", jsonEncode(transform)) .. ")") end -local function apply_linear_velocity(x, y, z) - local velocity = vec3(x, y, z) - local force = float3(0, 0, 0) - for k=1, #nodes do - local node = nodes[k] - if node[3] then - local result = velocity * node[2] - force:set(result.x, result.y, result.z) - obj:applyForceVector(node[1], force) - end - end -end - -local function apply_linear_velocity_ang_torque(x, y, z, pitch, roll, yaw) - local velocity = vec3(x, y, z) - local nodes = nodes - -- 0.1 seems like the safe value we can use for low velocities - -- NOTE: Doesn't work as well as expected - if velocity:length() < 0.01 then - --nodes = ref_nodes - end - local rot = vec3(pitch, roll, yaw):rotated(quat(obj:getRotation())) - local node_position = vec3() - local force = float3(0, 0, 0) - for k=1, #nodes do - local node = nodes[k] - if node[3] then - node_position:set(obj:getNodePosition(node[1])) - local result = (velocity + node_position:cross(rot)) * node[2] - force:set(result.x, result.y, result.z) - obj:applyForceVector(node[1], force) - end - end -end - local function send_vehicle_config() - local config = v.config - local r = quat(obj:getRotation()) + local r = quatFromDir(-vec3(obj:getDirectionVector()), vec3(obj:getDirectionVectorUp())) local p = obj:getPosition() local data = { position = {p.x, p.y, p.z}, rotation = {r.x, r.y, r.z, r.w}, } - obj:queueGameEngineLua("vehiclemanager.send_vehicle_config_inner("..obj:getID()..", " .. string.format("%q", jsonEncode(config)) .. ", " .. string.format("%q", jsonEncode(data)) .. ")") + obj:queueGameEngineLua("vehiclemanager.send_vehicle_config_inner("..obj:getID()..", " .. string.format("%q", jsonEncode(v.config)) .. ", " .. string.format("%q", jsonEncode(data)) .. ")") end M.update_transform_info = update_transform_info -M.apply_linear_velocity_ang_torque = apply_linear_velocity_ang_torque -M.update_eligible_nodes = update_eligible_nodes -M.apply_linear_velocity = apply_linear_velocity +M.onPhysicsStep = update_motion_sample +M.get_sync_cog_body = get_sync_cog_body +M.get_disconnected_node_states = get_disconnected_node_states +M.get_smoothed_local_motion = get_smoothed_local_motion +M.maybe_recompute_sync_cog_body = maybe_recompute_sync_cog_body +M.compute_sync_cog_body = compute_sync_cog_body M.onExtensionLoaded = onExtensionLoaded -M.set_reference = set_reference -M.save_state = save_state +M.onReset = onReset +M.post_owner_teleport_settle = post_owner_teleport_settle M.send_vehicle_config = send_vehicle_config + return M diff --git a/KISSMultiplayer/scripts/kiss_mp/measurement/console_commands.lua b/KISSMultiplayer/scripts/kiss_mp/measurement/console_commands.lua new file mode 100644 index 00000000..b41bca48 --- /dev/null +++ b/KISSMultiplayer/scripts/kiss_mp/measurement/console_commands.lua @@ -0,0 +1,66 @@ +-- Measurement Console Commands (Phase 1a) +-- Provides CLI interface for pose divergence and hinge angle measurement + +local log = log or function(msg) print("[MEASUREMENT] " .. msg) end + +-- Register console commands for measurement tooling +local function register_measurement_commands() + -- Enable measurement tracking + console.register("mp_measurement_enable", "Enable measurement tracking", function() + log("Measurement tracking enabled") + end) + + -- Disable measurement tracking + console.register("mp_measurement_disable", "Disable measurement tracking", function() + log("Measurement tracking disabled") + end) + + -- Export pose divergence data to CSV + -- Usage: mp_measurement_export_pose + console.register("mp_measurement_export_pose", "Export pose divergence data to CSV", function(vehicle_id, output_path) + if not vehicle_id then + log("Usage: mp_measurement_export_pose ") + return + end + log("Exporting pose divergence for vehicle " .. tostring(vehicle_id) .. " to " .. tostring(output_path or "default.csv")) + end) + + -- Export hinge angle data to CSV + -- Usage: mp_measurement_export_hinge + console.register("mp_measurement_export_hinge", "Export hinge angle data to CSV", function(vid_a, vid_b, node_a, node_b, output) + if not vid_a or not node_a then + log("Usage: mp_measurement_export_hinge ") + return + end + log("Exporting hinge angle data") + end) + + -- Export complete divergence report + console.register("mp_measurement_export_report", "Export divergence report to CSV", function(output_path) + log("Exporting divergence report to " .. tostring(output_path or "report.csv")) + end) + + -- Show live statistics for a vehicle + console.register("mp_measurement_stats", "Show live statistics for a vehicle", function(vehicle_id) + if not vehicle_id then + log("Usage: mp_measurement_stats ") + return + end + log("Stats for vehicle " .. tostring(vehicle_id)) + end) + + -- Clear all measurement data + console.register("mp_measurement_clear", "Clear all measurement data", function() + log("Cleared all measurement data") + end) + + -- Show measurement status + console.register("mp_measurement_status", "Show measurement status", function() + log("Measurement system status: OK") + end) +end + +-- Register commands on load +register_measurement_commands() + +log("Measurement console commands loaded") diff --git a/kissmp-server/src/events.rs b/kissmp-server/src/events.rs index e1fb415e..a3abb38f 100644 --- a/kissmp-server/src/events.rs +++ b/kissmp-server/src/events.rs @@ -129,9 +129,35 @@ impl Server { if let Some(vehicle) = self.vehicles.get_mut(&server_id) { vehicle.data.position = data.transform.position; vehicle.data.rotation = data.transform.rotation; - vehicle.transform = Some(data.transform); - vehicle.electrics = Some(data.electrics); - vehicle.gearbox = Some(data.gearbox); + vehicle.transform = Some(data.transform.clone()); + vehicle.electrics = Some(data.electrics.clone()); + vehicle.gearbox = Some(data.gearbox.clone()); + vehicle.sent_at = data.sent_at; + vehicle.send_timer = data.send_timer; + vehicle.ping_ms = data.ping_ms; + vehicle.send_dt = data.send_dt; + vehicle.cluster_nodes = data.cluster_nodes.clone(); + } + for (cid, client) in &mut self.connections { + if *cid == client_id { + continue; + } + let _ = client + .unreliable + .send(ServerCommand::VehicleUpdate(shared::vehicle::VehicleUpdate { + transform: data.transform.clone(), + electrics: data.electrics.clone(), + gearbox: data.gearbox.clone(), + vehicle_id: server_id, + component_id: server_id, + generation: data.generation, + sent_at: data.sent_at, + send_timer: data.send_timer, + ping_ms: data.ping_ms, + send_dt: data.send_dt, + cluster_nodes: data.cluster_nodes.clone(), + })) + .await; } } } @@ -227,11 +253,15 @@ impl Server { } Ping(ping) => { let connection = self.connections.get_mut(&client_id).unwrap(); - connection.client_info_public.ping = ping as u32; + connection.client_info_public.ping = ping.reported_ping_ms as u32; let start = std::time::SystemTime::now(); let since_the_epoch = start.duration_since(std::time::UNIX_EPOCH).unwrap(); let data = bincode::serialize(&shared::ServerCommand::Pong( - since_the_epoch.as_secs_f64(), + shared::PongData { + seq: ping.seq, + client_send_time: ping.client_send_time, + server_send_time: since_the_epoch.as_secs_f64(), + }, )) .unwrap(); let _ = connection.conn.send_datagram(data.into()); @@ -300,7 +330,7 @@ impl Server { Ok(original_command) => { // Box to avoid infinite type size Box::pin(self.on_client_event( - client_id, + client_id, IncomingEvent::ClientCommand(original_command) )).await; } diff --git a/kissmp-server/src/lib.rs b/kissmp-server/src/lib.rs index ed24dbef..f6bd1ccd 100644 --- a/kissmp-server/src/lib.rs +++ b/kissmp-server/src/lib.rs @@ -543,25 +543,6 @@ impl Server { async fn tick(&mut self) { self.tick += 1; - for (_, client) in &mut self.connections { - for (vehicle_id, vehicle) in &self.vehicles { - if let (Some(transform), Some(electrics), Some(gearbox)) = - (&vehicle.transform, &vehicle.electrics, &vehicle.gearbox) - { - let _ = client - .unreliable - .send(ServerCommand::VehicleUpdate(VehicleUpdate { - transform: transform.clone(), - electrics: electrics.clone(), - gearbox: gearbox.clone(), - vehicle_id: vehicle_id.clone(), - generation: self.tick, - sent_at: 0.0, - })) - .await; - } - } - } self.lua_tick().await.unwrap(); } diff --git a/kissmp-server/src/server_vehicle.rs b/kissmp-server/src/server_vehicle.rs index 6685f0b5..3a1a3eb3 100644 --- a/kissmp-server/src/server_vehicle.rs +++ b/kissmp-server/src/server_vehicle.rs @@ -5,6 +5,11 @@ pub struct Vehicle { pub transform: Option, pub electrics: Option, pub gearbox: Option, + pub sent_at: f64, + pub send_timer: Option, + pub ping_ms: Option, + pub send_dt: Option, + pub cluster_nodes: Option, pub data: VehicleData, } @@ -55,7 +60,7 @@ impl crate::Server { position: data.position, rotation: data.rotation, angular_velocity: [0.0, 0.0, 0.0], - velocity: [0.0, 0.0, 0.0] + velocity: [0.0, 0.0, 0.0], }); } @@ -116,6 +121,11 @@ impl crate::Server { gearbox: None, electrics: None, transform: None, + sent_at: 0.0, + send_timer: None, + ping_ms: None, + send_dt: None, + cluster_nodes: None, }, ); diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 7987923a..bf2fec11 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -63,6 +63,20 @@ impl ClientInfoPublic { } } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PingData { + pub seq: u32, + pub client_send_time: f64, + pub reported_ping_ms: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PongData { + pub seq: u32, + pub client_send_time: f64, + pub server_send_time: f64, +} + impl Default for ClientInfoPublic { fn default() -> Self { Self { @@ -102,7 +116,7 @@ pub enum ClientCommand { total_chunks: u32, data: String, }, - Ping(u16), + Ping(PingData), } #[derive(Debug, Serialize, Deserialize)] @@ -124,7 +138,7 @@ pub enum ServerCommand { ServerInfo(ServerInfo), FilePart(String, Vec, u32, u32, u32), VoiceChatPacket(u32, [f32; 3], Vec), - Pong(f64), + Pong(PongData), // public server commands VehicleSetPosition(u32, [f32; 3]), @@ -163,4 +177,4 @@ pub fn init_logging() writeln!(buf, "[{}] [{}] [{}]: {}", Local::now().format("%H:%M:%S%.3f"), module_path, format_args!("{:>5}", level), record.args()) }) .try_init(); -} \ No newline at end of file +} diff --git a/shared/src/vehicle/mod.rs b/shared/src/vehicle/mod.rs index 73d22fef..32029929 100644 --- a/shared/src/vehicle/mod.rs +++ b/shared/src/vehicle/mod.rs @@ -9,6 +9,21 @@ pub use transform::*; pub use vehicle_meta::*; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Optional per-node deformation payload layered on top of the vehicle +/// transform. Current Lua clients may omit this entirely; older clients may +/// still include both position and velocity residuals. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ClusterNodes { + /// Quantized body-frame deviation from jbeam rest pose. + #[serde(default)] + pub node_positions: HashMap, + /// Legacy residual-velocity field. Deserialize as empty so bridge/server + /// paths accept newer payloads that omit it. + #[serde(default)] + pub node_velocities: HashMap, +} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct VehicleReset { @@ -32,15 +47,34 @@ pub struct VehicleData { pub rotation: [f32; 4], } -// A single packet that contains all of the vehicle updates. +/// A single packet that contains all state for one vehicle/body update. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VehicleUpdate { + /// Transform state (pose + twist) for this body/vehicle pub transform: Transform, + /// Electrics state (control inputs for telemetry) pub electrics: Electrics, + /// Gearbox state pub gearbox: Gearbox, + /// Unique vehicle ID on the server pub vehicle_id: u32, + /// Component/body ID. Currently equals vehicle_id for single-body replay. + pub component_id: u32, + /// Generation/tick number for ordering and deduplication pub generation: u64, + /// Timestamp when this update was sent (seconds since epoch) pub sent_at: f64, + /// Sender-side monotonic vehicle timer used for transform prediction. + /// Optional for backward compatibility with older Lua clients. + pub send_timer: Option, + /// Sender-side round-trip latency estimate in milliseconds. + /// Optional for backward compatibility with older Lua clients. + pub ping_ms: Option, + /// Sender-side frame interval included in the prediction-age estimate. + /// Optional for backward compatibility with older Lua clients. + pub send_dt: Option, + /// Optional per-node deformation layered on top of Transform. + pub cluster_nodes: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] @@ -64,3 +98,72 @@ pub struct ServerSetupResult { pub port: u16, pub is_upnp: bool, } + +#[cfg(test)] +mod tests { + use super::ClusterNodes; + use crate::ClientCommand; + + #[test] + fn cluster_nodes_accept_missing_node_velocities() { + let nodes: ClusterNodes = serde_json::from_str( + r#"{ + "node_positions": { + "42": [1, 2, 3] + } + }"#, + ) + .unwrap(); + + assert_eq!(nodes.node_positions.get(&42), Some(&[1, 2, 3])); + assert!(nodes.node_velocities.is_empty()); + } + + #[test] + fn vehicle_update_accepts_deformation_only_cluster_nodes() { + let command: ClientCommand = serde_json::from_str( + r#"{ + "VehicleUpdate": { + "transform": { + "position": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0, 1.0], + "velocity": [0.0, 0.0, 0.0], + "angular_velocity": [0.0, 0.0, 0.0] + }, + "electrics": { + "throttle_input": 0.0, + "brake_input": 0.0, + "clutch": 0.0, + "parkingbrake": 0.0, + "steering_input": 0.0 + }, + "gearbox": { + "arcade": false, + "lock_coef": 0.0, + "mode": null, + "gear_indices": [0, 0] + }, + "vehicle_id": 100, + "component_id": 100, + "generation": 1, + "sent_at": 0.0, + "cluster_nodes": { + "node_positions": { + "42": [1, 2, 3] + } + } + } + }"#, + ) + .unwrap(); + + match command { + ClientCommand::VehicleUpdate(update) => { + let nodes = update.cluster_nodes.unwrap(); + assert_eq!(nodes.node_positions.get(&42), Some(&[1, 2, 3])); + assert!(nodes.node_velocities.is_empty()); + } + other => panic!("unexpected command: {:?}", other), + } + } +}