Files
skate-3-rust-engine/mods/Skyline_Drive_Mod/main.lua
T

956 lines
45 KiB
Lua

-- Driving edition 1: latest working vehicle + unchanged audio; render-rate cameras,
-- retained dashboard, progressive driver-input mapping, explicit optional aero.
-- Skyline Physics v4 -- Lua 5.4 / supplied Skate SDK API 2.
-- Reduced-order vehicle: one 6-DOF chassis, four rotating tire DOFs,
-- passive clutch/differential/brakes, and a combined-slip brush tire law.
-- No yaw targets, countersteer assist, slide-recovery state, lateral-velocity
-- deletion, handbrake grip multiplier, or upright torque. Aero is an explicit load.
-- See README.md and PHYSICS_AND_API.md for changes and declared assumptions.
local BODY, MODEL = "chassis", "skyline.glb"
local C = {
mass = 1400, gravity = 9.81,
-- At nominal ride height: CG is 0.400 m above a level road.
-- This is an explicitly tuned chassis, NOT measured factory Skyline data.
center = {0, -0.27160, 0.16},
inertia_half = {0.92, 0.55, 2.10},
hull_half = {0.86, 0.30, 2.04},
radius = 0.341,
ride_length = 0.20, droop_length = 0.37,
front_spring = 37000, rear_spring = 32000,
damping_ratio = 0.85,
front_antiroll = 8000, rear_antiroll = 6800,
antiroll_damping = 450,
bump_length = 0.055, bump_stiffness = 110000,
maximum_load = 22000,
wheel_inertia = 1.85,
-- Cornering/longitudinal stiffness in N per unit normalized slip, not N/(m/s).
front_stiffness_per_load = 23.0, rear_stiffness_per_load = 24.0,
load_exponent = 0.90,
front_drive = 0.15,
brake_torque_total = 6800, front_brake_fraction = 0.72,
handbrake_torque = 3800, rolling_coefficient = 0.012,
rear_diff_preload = 12, rear_diff_ramp = 0.18,
steer_limit = math.rad(40), steer_rate = math.rad(170),
steering_exponent = 1.70,
final_drive = 3.545, reverse_ratio = 3.280,
gear_ratios = {3.827, 2.360, 1.685, 1.312, 1.000, 0.793},
idle_rpm = 900, redline_rpm = 8000, shift_time = 0.18,
engine_inertia = 0.30, clutch_capacity = 620,
iterations = 8, tire_root_iterations = 10,
aero_drag = 0.42,
}
-- Model's named left wheels are +X; model forward is +Z.
-- A right input therefore requests NEGATIVE yaw, matching a rear chase view.
local wheels = {
{name="FL", key="wheel_fl", pos={ 0.794235,-0.330559, 1.443641}, front=true, mate=2},
{name="FR", key="wheel_fr", pos={-0.796149,-0.330652, 1.443556}, front=true, mate=1},
{name="RL", key="wheel_rl", pos={ 0.795353,-0.330587,-1.347033}, front=false,mate=4},
{name="RR", key="wheel_rr", pos={-0.796153,-0.330635,-1.347024}, front=false,mate=3},
}
local wheelbase = 1.44360 + 1.34703
local track = 1.591
local I = {
C.mass/3 * (C.inertia_half[2]^2 + C.inertia_half[3]^2),
C.mass/3 * (C.inertia_half[1]^2 + C.inertia_half[3]^2),
C.mass/3 * (C.inertia_half[1]^2 + C.inertia_half[2]^2),
}
local state = {
spawned=false, occupied=false, pending=nil, gear=1,
rpm=C.idle_rpm, engine_omega=C.idle_rpm*math.pi/30, clutch=0,
steer=0, shift=0, time=0, enter_after=0,
keys={}, buttons=0, hud_time=0, mass_audit=nil, keyboard_axis=0, aero_load=0,
}
local function clamp(x,a,b) return math.max(a,math.min(b,x)) end
local function finite(x) return type(x)=="number" and x==x and math.abs(x)<math.huge end
local function number(x, fallback) return finite(x) and x or fallback end
local function vec(x) return type(x)=="table" and finite(x[1]) and finite(x[2]) and finite(x[3]) end
local function add(a,b) return {a[1]+b[1],a[2]+b[2],a[3]+b[3]} end
local function sub(a,b) return {a[1]-b[1],a[2]-b[2],a[3]-b[3]} end
local function mul(a,s) return {a[1]*s,a[2]*s,a[3]*s} end
local function dot(a,b) return a[1]*b[1]+a[2]*b[2]+a[3]*b[3] end
local function cross(a,b) return {a[2]*b[3]-a[3]*b[2],a[3]*b[1]-a[1]*b[3],a[1]*b[2]-a[2]*b[1]} end
local function norm(a)
local l=math.sqrt(dot(a,a))
return l>1e-8 and mul(a,1/l) or {0,0,0}
end
local function rotate(q,v)
local t=mul(cross({q[1],q[2],q[3]},v),2)
return add(v,add(mul(t,q[4]),cross({q[1],q[2],q[3]},t)))
end
local function unrotate(q,v) return rotate({-q[1],-q[2],-q[3],q[4]},v) end
local function inertia(q,v,inverse)
local a=unrotate(q,v)
for i=1,3 do a[i]=inverse and a[i]/I[i] or a[i]*I[i] end
return rotate(q,a)
end
local function yaw(q) return math.atan(2*(q[4]*q[2]+q[1]*q[3]),1-2*(q[2]^2+q[3]^2)) end
local function yaw_rotation(a) return {0,math.sin(a/2),0,math.cos(a/2)} end
local function approach(x,target,step) return x+clamp(target-x,-step,step) end
local function deadzone(x,threshold)
if math.abs(x)<=threshold then return 0 end
return (x<0 and -1 or 1)*(math.abs(x)-threshold)/(1-threshold)
end
local function gear_ratio()
if state.gear==0 then return 0 end
return (state.gear<0 and -C.reverse_ratio or C.gear_ratios[state.gear])*C.final_drive
end
local function gear_label() return state.gear<0 and "R" or (state.gear==0 and "N" or tostring(state.gear)) end
local function down(key) return sdk.input.down(key)==true end
local function edge(key)
local held=down(key)
local yes=held and not state.keys[key]
state.keys[key]=held
return yes
end
local function pressed_button(buttons,mask)
return (buttons & mask)~=0 and (state.buttons & mask)==0
end
-- AUDIO EXTENSION: presentation only. Never writes RPM, wheel speed, or forces.
local sound = {
running=false, supported=false, prepared=false, clock=0, cooldown=0,
rpm=900, throttle=0, shift=0, previous_throttle=0,
overrun_remaining=0, pop_slot=0, random_state=73641, chop_until=0,
}
local sound_anchors={900,2400,4200,6600}
local function sound_random()
sound.random_state=(sound.random_state*48271)%2147483647
return sound.random_state/2147483647
end
local function stop_audio()
if sound.supported then sdk.audio.stop_all() end
sound.running=false; sound.cooldown=0; sound.overrun_remaining=0
sound.previous_throttle=0; sound.chop_until=0
end
local function prepare_audio()
sound.supported=type(sdk.audio)=="table" and number(sdk.audio.version,0)>=1
if not sound.supported then
sdk.log("Skyline audio unavailable: install the native Audio API update and rebuild the game.")
sdk.ui.text("skyline_audio","Sound requires native Audio API extension 1. Physics still works.")
return
end
if sound.prepared then return end
for _,rpm in ipairs(sound_anchors) do
sdk.audio.preload("audio/engine_"..rpm.."_coast.wav")
sdk.audio.preload("audio/engine_"..rpm.."_load.wav")
end
sdk.audio.preload("audio/turbo_loop.wav")
sdk.audio.preload("audio/lift.wav")
for i=1,6 do sdk.audio.preload(string.format("audio/pop_%02d.wav",i)) end
sound.prepared=true
sdk.ui.text("skyline_audio","")
end
local function start_audio()
if not sound.supported or sdk.settings.audio_enabled==false or sound.running then return end
sound.clock=0; sound.cooldown=0; sound.rpm=math.max(state.rpm,200)
sound.throttle=0; sound.previous_throttle=0; sound.overrun_remaining=0
sound.chop_until=0
for _,rpm in ipairs(sound_anchors) do
for _,layer in ipairs({"coast","load"}) do
sdk.audio.play("engine_"..rpm.."_"..layer,{
path="audio/engine_"..rpm.."_"..layer..".wav",
body=BODY,offset={0,-0.1,0.45},loop=true,volume=0,
pitch=clamp(sound.rpm/rpm,0.25,4),spatial=true,
spatial_scale=0.10,fade_in=0.08,
})
end
end
sdk.audio.play("turbo",{
path="audio/turbo_loop.wav",body=BODY,offset={0,0,0.8},
loop=true,volume=0,pitch=1,spatial=true,spatial_scale=0.1,fade_in=0.08,
})
sound.running=true
end
local function exhaust_pop(intensity)
if not sound.running then return end
local master=clamp(number(sdk.settings.engine_volume,0.70),0,1)
local volume=clamp(number(sdk.settings.pop_volume,0.65),0,1)
if master*volume<=0 then return end
sound.pop_slot=sound.pop_slot%6+1
local variant=1+math.floor(sound_random()*6)
sdk.audio.play("exhaust_pop_"..sound.pop_slot,{
path=string.format("audio/pop_%02d.wav",variant),
body=BODY,offset={-0.5,-0.22,-1.95},loop=false,
volume=clamp(master*volume*intensity,0,1),
pitch=0.90+0.22*sound_random(),spatial=true,spatial_scale=0.1,fade_in=0,
})
end
local function update_audio(event)
if not sound.supported then return end
if not state.spawned or sdk.settings.audio_enabled==false then
if sound.running then stop_audio() end
return
end
if not sound.running then start_audio() end
-- Native host pauses sinks in menus and replay. This also protects test hosts.
if sdk.snapshot.paused or sdk.snapshot.replay then return end
local dt=clamp(number(event and event.dt,1/60),0,0.10)
sound.clock=sound.clock+dt
local actual_rpm=clamp(number(state.rpm,0),0,12000)
sound.rpm=sound.rpm+(actual_rpm-sound.rpm)*(1-math.exp(-dt/0.04))
local throttle=clamp(number(sound.throttle,0),0,1)
local master=clamp(number(sdk.settings.engine_volume,0.70),0,1)
sound.cooldown=math.max(0,sound.cooldown-dt)
-- Existing physics uses a SOFT fuel taper. Detect its near-redline region;
-- this acoustic chatter does not impose a new RPM/clutch/ignition model.
local limiter=actual_rpm>=C.redline_rpm-160 and throttle>0.55 and sound.shift<=0
if limiter and sdk.settings.redline_pops~=false then
sound.overrun_remaining=0
if sound.cooldown<=0 then
exhaust_pop(0.78+0.20*sound_random())
sound.chop_until=sound.clock+0.022+0.012*sound_random()
sound.cooldown=0.095+0.075*sound_random()
end
else
local lifted=sound.previous_throttle>0.6 and throttle<0.2 and actual_rpm>4000
if lifted then
if sdk.settings.overrun_pops~=false then
sound.overrun_remaining=2+math.floor(sound_random()*2)
sound.cooldown=0.04+0.06*sound_random()
end
if sdk.settings.turbo_audio~=false and master>0 then
sdk.audio.play("turbo_lift",{
path="audio/lift.wav",body=BODY,offset={0,0,0.8},
volume=master*0.30,pitch=0.9+0.2*sound_random(),
spatial=true,spatial_scale=0.1,fade_in=0,
})
end
end
if throttle>0.3 or actual_rpm<3000 or sdk.settings.overrun_pops==false then
sound.overrun_remaining=0
end
if sound.overrun_remaining>0 and sound.cooldown<=0 then
exhaust_pop(0.36+0.24*sound_random())
sound.overrun_remaining=sound.overrun_remaining-1
sound.cooldown=0.11+0.12*sound_random()
end
end
sound.previous_throttle=throttle
local rpm=math.max(sound.rpm,200)
local low=1
for i=1,#sound_anchors-1 do if rpm>=sound_anchors[i] then low=i end end
if rpm>=sound_anchors[#sound_anchors] then low=#sound_anchors end
local high=math.min(low+1,#sound_anchors)
local mix=0
if low~=high then
mix=clamp(math.log(rpm/sound_anchors[low])/math.log(sound_anchors[high]/sound_anchors[low]),0,1)
end
local load=throttle^0.70
local alive=clamp((actual_rpm-150)/450,0,1)
local chop=sound.clock<sound.chop_until and 0.22 or 1.0
local shifting=sound.shift>0 and 0.52 or 1.0
local gain=master*0.50*alive*(0.65+0.35*load)*chop*shifting
for i,anchor in ipairs(sound_anchors) do
local weight=0
if i==low then weight=low==high and 1 or math.sqrt(1-mix)
elseif i==high then weight=math.sqrt(mix) end
local pitch=clamp(rpm/anchor,0.25,4)
sdk.audio.update("engine_"..anchor.."_coast",{
volume=gain*weight*math.sqrt(1-load),pitch=pitch,
})
sdk.audio.update("engine_"..anchor.."_load",{
volume=gain*weight*math.sqrt(load),pitch=pitch,
})
end
local boost=clamp((rpm-2300)/3700,0,1)*load
sdk.audio.update("turbo",{
volume=sdk.settings.turbo_audio==false and 0 or master*0.16*boost,
pitch=clamp(0.65+rpm/9000,0.25,4),
})
end
-- END AUDIO EXTENSION
-- DRIVING PRESENTATION: no writes to physical body state.
local presentation = {
mode="chase", supported=false, debug=false, hud_timer=0,
speed=0, handbrake=false, active_hud=false,
}
local function clear_debug_text()
for _,key in ipairs({"skyline_help","skyline_status","skyline_handling","skyline_wheels"}) do
sdk.ui.text(key,"")
end
end
local function set_debug_visible(visible)
presentation.debug=visible
clear_debug_text()
if visible then
sdk.ui.text("skyline_help","F10 spawn | E/Y enter | RT/W gas | LT/S brake | B/Shift handbrake | RB/X up, LB/Z down | N neutral | Xbox X/C camera | H debug | R/R3 reset")
end
end
local function configure_camera()
if not state.occupied then return end
presentation.hud_timer=0
if not presentation.supported then
sdk.camera.follow(BODY,{0,2.1,-5.8})
return
end
local hood=presentation.mode=="hood"
sdk.camera.rig(BODY,{
mode=presentation.mode,
distance=clamp(number(sdk.settings.camera_distance,5.8),3.5,10),
distance_gain=1.8,height=1.9,height_gain=0.25,target_height=0.78,
look_ahead=0.12,velocity_heading=0.18,
spring_hz=clamp(number(sdk.settings.camera_spring,2.8),1,6),
heading_half_life=0.14,acceleration_lag=0.018,speed_reference=55.556,
fov=hood and 60 or clamp(number(sdk.settings.camera_fov,55),40,80),
fov_gain=hood and 0 or 8,near=hood and 0.04 or 0.07,
collision=true,collision_radius=0.28,
hood_offset={0,clamp(number(sdk.settings.hood_height,0.48),0.30,0.9),1.05},
})
end
local function clear_presentation()
sdk.camera.clear_follow()
if presentation.supported then sdk.ui.remove("driving_dashboard") end
presentation.active_hud=false;presentation.hud_timer=0
clear_debug_text()
end
local function prepare_presentation()
presentation.supported=type(sdk.camera.rig)=="function" and type(sdk.ui.canvas)=="function"
set_debug_visible(sdk.settings.show_driving_debug==true)
if not presentation.supported then
sdk.log("Skyline: camera/canvas extension missing. Rebuild with the supplied Driving API source patch.")
sdk.ui.text("skyline_presentation","Dynamic camera + speedometer require the Driving API rebuild.")
else sdk.ui.text("skyline_presentation","") end
end
local function update_dashboard(event)
if not presentation.supported then return end
if not state.occupied or not state.spawned or sdk.settings.show_speedometer==false then
if presentation.active_hud then sdk.ui.remove("driving_dashboard");presentation.active_hud=false end
return
end
if sdk.snapshot.paused or sdk.snapshot.replay then return end
local dt=clamp(number(event and event.dt,1/60),0,0.1)
presentation.hud_timer=presentation.hud_timer-dt
if presentation.hud_timer>0 then return end
presentation.hud_timer=0.05
local mph=sdk.settings.speed_mph==true
local speed=math.floor(math.max(0,presentation.speed)*(mph and 2.236936292 or 3.6)+0.5)
local rpm=clamp(number(state.rpm,0),0,12000)
local amount=clamp(rpm/C.redline_rpm,0,1)
local hot=amount>0.94
local accent=hot and {1.0,0.27,0.18,1} or {0.23,0.79,0.98,1}
local white={0.96,0.98,1.0,1};local muted={0.60,0.67,0.75,1}
local items={}
local function rect(key,x,y,w,h,color)
items[#items+1]={key=key,type="rect",position={x,y},size={w,h},color=color}
end
local function text(key,x,y,w,h,value,size,color)
items[#items+1]={key=key,type="text",position={x,y},size={w,h},text=value,font_size=size,color=color}
end
rect("back",0,0,344,180,{0.018,0.026,0.041,0.88})
rect("accent",0,0,344,3,accent)
text("title",16,12,180,20,"SKYLINE",13,muted)
text("view",250,12,78,20,presentation.mode=="hood" and "HOOD" or "CHASE",13,muted)
text("speed",14,31,200,78,tostring(speed),64,white)
text("unit",180,82,68,22,mph and "MPH" or "KM/H",13,muted)
rect("gear_box",258,39,70,68,{0.075,0.105,0.15,1})
text("gear",277,39,47,65,gear_label(),50,white)
text("gear_label",267,109,61,17,"GEAR",10,muted)
text("rpm",16,115,158,22,string.format("%d RPM",math.floor(rpm/10+0.5)*10),15,white)
text("brake",170,115,82,22,presentation.handbrake and "BRAKE" or (hot and "SHIFT" or ""),13,
{1,0.39,0.25,1})
rect("rpm_bg",16,144,312,9,{0.13,0.18,0.23,1})
rect("rpm_fill",16,144,312*amount,9,accent)
rect("redline_tick",16+312*0.94,141,2,15,{1,0.31,0.20,1})
text("hint",16,161,312,16,"X / C CAMERA H DEBUG",10,muted)
sdk.ui.canvas("driving_dashboard",{
anchor="bottom_right",offset={24,28},size={344,180},
scale=clamp(number(sdk.settings.dashboard_scale,1),0.65,1.5),visible=true,items=items,
})
presentation.active_hud=true
end
-- END DRIVING PRESENTATION
local function reset_simulation()
state.gear=1; state.rpm=C.idle_rpm; state.steer=0; state.shift=0; state.keyboard_axis=0; state.aero_load=0
state.engine_omega=C.idle_rpm*math.pi/30; state.clutch=0; state.mass_audit=nil
for _,w in ipairs(wheels) do
w.omega=0; w.load=0; w.length=C.ride_length; w.contact=false; w.slip=0
-- Front/rear rates match STATIC MOMENT BALANCE, not an equal-load floor.
local fraction=w.front and ((C.center[3]+1.34703)/wheelbase)
or ((1.44360-C.center[3])/wheelbase)
w.static_load=C.mass*C.gravity*fraction/2
w.stiffness=w.front and C.front_spring or C.rear_spring
w.rest_length=C.ride_length+w.static_load/w.stiffness
w.drive_share=(w.front and C.front_drive or 1-C.front_drive)/2
w.alpha=0; w.kappa=0; w.fx=0; w.fy=0; w.usage=0
w.damping=2*C.damping_ratio*math.sqrt(w.stiffness*w.static_load/C.gravity)
end
end
-- Keep the old keys clean on reload: no orphan wheel/carrier bodies or meshes.
local function remove_rig()
stop_audio(); clear_presentation()
if state.occupied or sdk.player.attached()==BODY then
sdk.player.detach(); sdk.camera.clear_follow()
end
sdk.graphics.remove("skyline_visual")
for _,w in ipairs(wheels) do
sdk.graphics.remove(w.key)
sdk.physics.remove(w.key)
sdk.physics.remove("carrier_"..w.key)
sdk.physics.remove("knuckle_"..w.key)
end
sdk.physics.remove(BODY)
state.spawned=false; state.occupied=false
end
local function ground_at(position)
-- Search locally, not from y=500 (which could choose a roof/upper road).
local h=sdk.physics.raycast(add(position,{0,3,0}),{0,-1,0},
{max_distance=12,filter="ground"})
return h and vec(h.point) and h.point[2] or nil
end
local function spawn_rig(request)
local player=sdk.player.read()
if not vec(player.position) then sdk.log("Skyline: player position unavailable"); return end
local heading=request.heading or number(player.heading,0)
local q=yaw_rotation(heading)
local position=request.position or add(player.position,mul(rotate(q,{0,0,1}),4.5))
local height=-math.huge
for _,w in ipairs(wheels) do
local off=rotate(q,w.pos)
local y=ground_at(add(position,off))
if not y then sdk.log("Skyline: spawn needs map ground beneath all four wheels"); return end
height=math.max(height,y+C.radius-off[2]+0.025)
end
sdk.physics.spawn(BODY,{
shape={type="box",half_extents=C.hull_half},
body_type="dynamic",mass=C.mass,position={position[1],height,position[3]},
heading=heading,friction=0.25,ccd=true,
center_of_mass=C.center,inertia_half_extents=C.inertia_half,
linear_damping=0,angular_damping=0,
})
sdk.graphics.mesh("skyline_visual",{path=MODEL,body=BODY})
reset_simulation()
state.spawned=true; state.enter_after=state.time+0.25
start_audio()
if request.reenter then
sdk.player.attach(BODY,{-0.38,0.12,-0.23})
state.occupied=true; configure_camera()
end
end
local function respawn(car,at_car)
local request={}
if at_car and car then
request={position=car.position,heading=yaw(car.rotation),reenter=state.occupied}
end
remove_rig()
-- Spawn on the next tick: queries cannot see this tick's queued spawns.
state.pending=request
end
local function input_state(buttons,dt)
local p=sdk.input.pad(); local t=p.triggers or {}; local stick=p.left or {}
local up=edge("KeyX") or pressed_button(buttons,0x0200)
local dn=edge("KeyZ") or pressed_button(buttons,0x0100)
local neutral=edge("KeyN")
local throttle,brake,steer,hand=0,1,0,false
if state.occupied then
if neutral then state.gear=0; state.shift=C.shift_time
elseif up and state.gear<6 then state.gear=state.gear+1; state.shift=C.shift_time
elseif dn and state.gear> -1 then state.gear=state.gear-1; state.shift=C.shift_time end
throttle=math.max(deadzone(clamp(number(t[2],0),0,1),0.04),down("KeyW") and 1 or 0)
brake=math.max(deadzone(clamp(number(t[1],0),0,1),0.04),down("KeyS") and 1 or 0)
local digital=(down("KeyD") and 1 or 0)-(down("KeyA") and 1 or 0)
state.keyboard_axis=approach(state.keyboard_axis,digital,dt/(digital==0 and 0.22 or 0.45))
steer=clamp(deadzone(clamp(number(stick[1],0),-1,1),0.09)+state.keyboard_axis,-1,1)
hand=down("ShiftLeft") or down("ShiftRight") or (buttons & 0x2000)~=0
end
state.shift=math.max(0,state.shift-dt)
return {throttle=throttle,brake=brake,steer=steer,hand=hand,raw_rt=number(t[2],0)}
end
local function steering_angles(input,dt,speed)
-- Driver-input mapping, NOT steering assistance or a tire/force correction.
-- More precision near stick centre at speed; FULL +/-40 deg remains available.
-- No measured/target sideslip or yaw rate is used. Zero input requests zero.
local blend=clamp((number(speed,0)-15)/40,0,1)
blend=blend*blend*(3-2*blend)
local precision=clamp(number(sdk.settings.steering_precision,1),0,1.5)
local exponent=C.steering_exponent+1.25*precision*blend
local shaped=(input<0 and -1 or 1)*math.abs(input)^exponent
state.steer=approach(state.steer,-shaped*C.steer_limit,C.steer_rate*dt)
if math.abs(state.steer)<1e-6 then return {0,0,0,0} end
local radius=wheelbase/math.tan(math.abs(state.steer))
local inner=math.atan(wheelbase/(radius-track/2))
local outer=math.atan(wheelbase/(radius+track/2))
if state.steer<0 then return {-outer,-inner,0,0} end
return {inner,outer,0,0}
end
local function torque_curve(rpm)
local points={{900,185},{2000,270},{3500,365},{5000,405},{6500,395},{8000,320}}
for i=2,#points do
if rpm<=points[i][1] then
local a,b=points[i-1],points[i]
return a[2]+(b[2]-a[2])*clamp((rpm-a[1])/(b[1]-a[1]),0,1)
end
end
return points[#points][2]
end
local function prepare_powertrain(input,dt,speed)
-- A finite-torque engine. Idle is a torque controller, NOT an RPM floor.
local rpm=state.engine_omega*30/math.pi
local loss=12+0.025*math.abs(state.engine_omega)
+(1-input.throttle)*35*clamp((rpm-C.idle_rpm)/2200,0,1)
local fuel=clamp((C.redline_rpm-rpm)/350,0,1)
local idle=clamp(loss+0.9*(C.idle_rpm-rpm),0,100)
local combustion=math.max(input.throttle*torque_curve(rpm)*fuel,idle)
local net=combustion-loss
state.engine_omega=math.max(0,state.engine_omega+net*dt/C.engine_inertia)
-- Automatic clutch pedal; it does not look at tire slip or sideslip.
local wanted=clamp((rpm-1000)/1100,0,1)
if speed>4 and rpm>1000 then wanted=1 end
if (speed<1 and input.throttle==0) or state.shift>0 or state.gear==0 then wanted=0 end
state.clutch=approach(state.clutch,wanted,(wanted<state.clutch and 10 or 5)*dt)
if state.shift>0 or state.gear==0 then state.clutch=0 end
local ratio=gear_ratio()
local inverse=1/C.engine_inertia
for _,w in ipairs(wheels) do
w.shaft_factor=ratio*w.drive_share
inverse=inverse+w.shaft_factor*w.shaft_factor/C.wheel_inertia
end
return {p=0, cap=C.clutch_capacity*state.clutch*dt,
inverse=inverse, diff_p=0, ratio=ratio}
end
local function solve_powertrain(power,dt)
-- Projected passive clutch impulse in gearbox coordinates.
local relative=state.engine_omega
for _,w in ipairs(wheels) do relative=relative-w.shaft_factor*w.omega end
local next_p=clamp(power.p+relative/power.inverse,-power.cap,power.cap)
local change=next_p-power.p
state.engine_omega=state.engine_omega-change/C.engine_inertia
for _,w in ipairs(wheels) do w.omega=w.omega+w.shaft_factor*change/C.wheel_inertia end
power.p=next_p
-- A torque-limited rear clutch LSD, not a forced equal-speed axle.
-- Brake/drive force remains entirely a tire result. Front diff is open.
local rear_torque=math.abs(power.p*power.ratio*(1-C.front_drive)/dt)
local cap=(C.rear_diff_preload+C.rear_diff_ramp*rear_torque)*dt
local delta=wheels[3].omega-wheels[4].omega
local next_diff=clamp(power.diff_p-delta*C.wheel_inertia/2,-cap,cap)
local dp=next_diff-power.diff_p
wheels[3].omega=wheels[3].omega+dp/C.wheel_inertia
wheels[4].omega=wheels[4].omega-dp/C.wheel_inertia
power.diff_p=next_diff
end
-- Invert a small SPD matrix (1..4 contacts), with partial pivoting.
local function inverse_matrix(matrix)
local n=#matrix; local a={}
for i=1,n do
a[i]={}
for j=1,n do a[i][j]=matrix[i][j]; a[i][n+j]=(i==j and 1 or 0) end
end
for k=1,n do
local pivot=k
for i=k+1,n do if math.abs(a[i][k])>math.abs(a[pivot][k]) then pivot=i end end
if math.abs(a[pivot][k])<1e-12 then return nil end
a[k],a[pivot]=a[pivot],a[k]
local d=a[k][k]
for j=1,2*n do a[k][j]=a[k][j]/d end
for i=1,n do if i~=k then
local factor=a[i][k]
for j=1,2*n do a[i][j]=a[i][j]-factor*a[k][j] end
end end
end
local result={}
for i=1,n do result[i]={}; for j=1,n do result[i][j]=a[i][n+j] end end
return result
end
local function constraint_axis(q,arm,axis)
local torque=cross(arm,axis)
local angular=inertia(q,torque,true)
return {axis=axis,torque=torque,angular=angular,inv_mass=1/C.mass+dot(torque,angular)}
end
local function velocity_along(sim,c)
return dot(sim.v,c.axis)+dot(sim.w,c.torque)
end
local function push(sim,c,impulse)
for i=1,3 do
sim.v[i]=sim.v[i]+c.axis[i]*impulse/C.mass
sim.w[i]=sim.w[i]+c.angular[i]*impulse
end
end
local function collect_contacts(car,angles,dt)
local result={}; local by_wheel={}
local q=car.rotation; local up=rotate(q,{0,1,0}); local direction=mul(up,-1)
local center=add(car.position,rotate(q,C.center))
for i,w in ipairs(wheels) do
w.contact=false; w.load=0; w.slip=0; w.length=C.droop_length
w.alpha=0; w.kappa=0; w.fx=0; w.fy=0; w.usage=0
local local_mount={w.pos[1],w.pos[2]+C.ride_length,w.pos[3]}
local mount=add(car.position,rotate(q,local_mount))
local origin=add(mount,mul(up,0.10))
-- Ground-only is intentional: ignores our chassis, skater proxies,
-- and other dynamic objects. Dynamic-prop driving is not implemented.
local hit=sdk.physics.raycast(origin,direction,
{max_distance=0.10+C.droop_length+C.radius,filter="ground"})
if hit and vec(hit.point) and vec(hit.normal) then
local n=norm(hit.normal)
local alignment=dot(n,up)
local distance=dot(sub(hit.point,mount),direction)
local length=distance-C.radius/math.max(0.45,alignment)
if alignment>0.45 and n[2]>0.1 and length<=C.droop_length then
local forward=rotate(q,{math.sin(angles[i]),0,math.cos(angles[i])})
forward=norm(sub(forward,mul(n,dot(forward,n))))
local lateral=norm(cross(n,forward))
-- Spherical contact-envelope approximation; normal force acts ON the road.
local hub=sub(mount,mul(up,length))
local point=sub(hub,mul(n,C.radius))
local arm=sub(point,center)
local c={wheel=w,wheel_index=i,point=point,alignment=alignment,
length=length,compression=w.rest_length-length,
pn=0,px=0,py=0,pb=0,
n=constraint_axis(q,arm,n),
x=constraint_axis(q,arm,forward),y=constraint_axis(q,arm,lateral)}
c.xy=dot(c.x.axis,c.y.axis)/C.mass+dot(c.x.torque,c.y.angular)
result[#result+1]=c; by_wheel[i]=#result
w.contact=true; w.length=length
end
end
end
if #result==0 then return result end
-- Backward Euler for ALL corner springs + axle anti-roll springs:
-- (W + Gamma) p = beta - v_free; p is unilateral and force-limited.
-- Gamma = [dt A^-1(D + dt K)A^-1]^-1, beta=Gamma*dt*A^-1*K*x.
-- Axle couplings make anti-roll torque equal/opposite, not free downforce.
local matrix,bias={},{ }
for i,c in ipairs(result) do
local partner=by_wheel[c.wheel.mate]
local antiroll=c.wheel.front and C.front_antiroll or C.rear_antiroll
local k=c.wheel.stiffness
local force=k*c.compression
local bump=math.max(0,C.bump_length-c.length)
if bump>0 then k=k+C.bump_stiffness; force=force+C.bump_stiffness*bump end
local d=c.wheel.damping
if partner then
k=k+antiroll; d=d+C.antiroll_damping
force=force+antiroll*(c.compression-result[partner].compression)
end
matrix[i]={}
for j,other in ipairs(result) do
local value=0
if i==j then value=d+dt*k
elseif j==partner then value=-C.antiroll_damping-dt*antiroll end
matrix[i][j]=dt*value/(c.alignment*other.alignment)
end
bias[i]=dt*force/c.alignment
end
local gamma=inverse_matrix(matrix)
if not gamma then return {} end
for i,c in ipairs(result) do
c.gamma=gamma[i]; c.beta=0
for j=1,#result do c.beta=c.beta+gamma[i][j]*bias[j] end
end
return result
end
local function solve_normal(sim,contacts,index,dt)
local c=contacts[index]
local residual=velocity_along(sim,c.n)-c.beta
for j,other in ipairs(contacts) do residual=residual+c.gamma[j]*other.pn end
local new=clamp(c.pn-residual/(c.n.inv_mass+c.gamma[index]),0,C.maximum_load*dt)
push(sim,c.n,new-c.pn); c.pn=new
end
-- Solve the tire contact for a trial scalar compliance. Brake complementarity
-- is solved analytically: first test omega=0, otherwise use a saturated brake.
-- vx0,vy0,omega0 have THIS contact's accumulated impulses removed.
local function tire_candidate(c,g,vx0,vy0,omega0,brake)
local a,b,d=c.x.inv_mass+g,c.xy,c.y.inv_mass+g
local determinant=a*d-b*b
local px=(-d*vx0+b*vy0)/determinant
local py=(b*vx0-a*vy0)/determinant
local required=C.radius*px-C.wheel_inertia*omega0
if math.abs(required)<=brake then return px,py,required,0 end
local pb=clamp(required,-brake,brake)
local free_omega=omega0+pb/C.wheel_inertia
local sx=vx0-C.radius*free_omega
a=a+C.radius*C.radius/C.wheel_inertia
determinant=a*d-b*b
px=(-d*sx+b*vy0)/determinant
py=(b*sx-a*vy0)/determinant
return px,py,pb,free_omega-C.radius*px/C.wheel_inertia
end
local function brush_inverse_scale(ratio)
-- Inverse of F/D = 1 - (1 - demand/(3D))^3.
-- Rationalized to avoid cancellation as force approaches zero.
local root=(1-clamp(ratio,0,1))^(1/3)
return 3/(1+root+root*root)
end
local function solve_tire(sim,c,input,mu,dt)
local w=c.wheel
local oldx,oldy=c.px,c.py
local vx,vy=velocity_along(sim,c.x),velocity_along(sim,c.y)
local vx0=vx-c.x.inv_mass*oldx-c.xy*oldy
local vy0=vy-c.xy*oldx-c.y.inv_mass*oldy
local omega0=w.omega+(C.radius*oldx-c.pb)/C.wheel_inertia
local load=c.pn/dt
local load_ratio=math.max(0,load/w.static_load)
-- Sublinear load sensitivity, with bounded low-load coefficient.
local sensitivity=clamp(math.max(load_ratio,0.01)^(C.load_exponent-1),0.65,1.15)
local limit=mu*sensitivity*c.pn
local fraction=w.front and C.front_brake_fraction or 1-C.front_brake_fraction
local brake=(input.brake*C.brake_torque_total*fraction/2
+C.rolling_coefficient*load*C.radius
+((input.hand and not w.front) and C.handbrake_torque or 0))*dt
local px,py,pb,omega=0,0,clamp(-C.wheel_inertia*omega0,-brake,brake),0
omega=omega0+pb/C.wheel_inertia
if limit>1e-8 then
local transport=math.max(math.abs(vx),math.abs(w.omega*C.radius))
-- Static-friction limit near rest. Smooth transition to rolling brush.
-- This is local tire-contact regularization, never a chassis assist.
local blend=clamp((transport-0.15)/0.85,0,1)
blend=blend*blend*(3-2*blend)
local stiffness=(w.front and C.front_stiffness_per_load or C.rear_stiffness_per_load)
*w.static_load*math.max(load_ratio,0.01)^C.load_exponent
local base=blend*math.max(transport,0.15)/(stiffness*dt)
local low,high=base,3*base
px,py,pb,omega=tire_candidate(c,high,vx0,vy0,omega0,brake)
local amount=math.sqrt(px*px+py*py)
if base==0 and amount<=limit then
-- True sticking branch; a parked vehicle can hold a slope.
else
local sliding=amount>limit
if sliding then
low=high
high=high+(math.sqrt(vx0*vx0+vy0*vy0)+C.radius*math.abs(omega0))/limit+1e-6
end
for _=1,C.tire_root_iterations do
local g=(low+high)/2
local tx,ty=tire_candidate(c,g,vx0,vy0,omega0,brake)
local magnitude=math.sqrt(tx*tx+ty*ty)
local increase
if sliding then increase=magnitude>limit
else increase=g<base*brush_inverse_scale(magnitude/limit) end
if increase then low=g else high=g end
end
px,py,pb,omega=tire_candidate(c,high,vx0,vy0,omega0,brake)
end
end
w.omega=omega
push(sim,c.x,px-oldx); push(sim,c.y,py-oldy)
c.px=px; c.py=py; c.pb=pb; c.limit=limit
end
local function publish_hud(car,input,contacts,dt)
state.hud_time=state.hud_time-dt
if state.hud_time>0 then return end
state.hud_time=0.10
if not presentation.debug then
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels",""); return
end
local velocity=unrotate(car.rotation,car.linvel)
local local_up=unrotate(car.rotation,{0,1,0})
local roll=math.deg(math.atan(local_up[1],local_up[2]))
local beta=math.deg(math.atan(velocity[1],math.max(0.1,math.abs(velocity[3]))))
local loaded=0
for _,w in ipairs(wheels) do if w.load>1 then loaded=loaded+1 end end
sdk.ui.text("skyline_status",string.format(
"Skyline DRIVE | %+.1f km/h | %s | %.0f rpm | T %.0f%% Brake %.0f%% HB %s",
velocity[3]*3.6,gear_label(),state.rpm,input.throttle*100,input.brake*100,
input.hand and "ON" or "off"))
sdk.ui.text("skyline_handling",string.format(
"Rack %+.1f deg | beta %+.1f deg | roll %+.1f deg | mu %.2f | load %d/4 | mass %s | aero %.0fN",
math.deg(state.steer),beta,roll,number(sdk.settings.tire_friction,1.3),loaded,
state.mass_audit or "pending",state.aero_load))
sdk.ui.text("skyline_wheels",string.format(
"FL %.0fN a%+.1f k%+.2f | FR %.0fN a%+.1f k%+.2f | RL %.0fN a%+.1f k%+.2f | RR %.0fN a%+.1f k%+.2f | clutch %.0f%%",
wheels[1].load,wheels[1].alpha,wheels[1].kappa,wheels[2].load,wheels[2].alpha,wheels[2].kappa,
wheels[3].load,wheels[3].alpha,wheels[3].kappa,wheels[4].load,wheels[4].alpha,wheels[4].kappa,state.clutch*100))
end
-- Read-only consistency check against NATIVE effective mass. Snapshot `mass`
-- is metadata in this engine, so checking that field alone proves nothing.
-- This never changes forces, inertia, pose, or velocity to hide a mismatch.
local function audit_mass_properties(car)
if state.mass_audit~=nil then return end
local q=car.rotation
local center=add(car.position,rotate(q,C.center))
local probes={
{{0,0,0},{0,1,0},1/C.mass},
{{0,1,0},{0,0,1},1/C.mass+1/I[1]},
{{0,0,1},{1,0,0},1/C.mass+1/I[2]},
{{1,0,0},{0,1,0},1/C.mass+1/I[3]},
}
local error=0
for _,probe in ipairs(probes) do
local actual=sdk.physics.effective_inv_mass(BODY,
add(center,rotate(q,probe[1])),rotate(q,probe[2]))
if not finite(actual) then
state.mass_audit="unavailable"
sdk.log("Skyline: native mass check unavailable; supplied-source mass properties assumed")
return
end
error=math.max(error,math.abs(actual-probe[3])/probe[3])
end
state.mass_audit=error<0.01 and "OK" or "MISMATCH"
sdk.log(string.format("Skyline: native mass/inertia check %s (maximum relative error %.3f%%)",
state.mass_audit,100*error))
end
-- Explicit aerodynamic surface loads. Cl*A values are editable ASSUMPTIONS,
-- not measured R34 data. Positive values mean downforce, negative mean lift.
-- Forces act at fore/aft locations and enter the existing coupled solver.
-- No contact-count condition, global-down force, or tire-friction multiplier.
local function apply_aerodynamics(sim,car,dt)
state.aero_load=0
if sdk.settings.aero_enabled==false then return end
local front=clamp(number(sdk.settings.aero_front_area,0.08),-0.4,0.6)
local rear=clamp(number(sdk.settings.aero_rear_area,0.12),-0.4,0.6)
local up=rotate(car.rotation,{0,1,0})
local forward=rotate(car.rotation,{0,0,1})
local surface_positions={{0,0.04,1.44360},{0,0.16,-1.34703}}
-- Evaluate both surfaces at the same state; avoid update-order dependence.
local air_linear,air_angular=sim.v,sim.w
for i,area in ipairs({front,rear}) do
local arm=rotate(car.rotation,sub(surface_positions[i],C.center))
local velocity=add(air_linear,cross(air_angular,arm))
local speed2=dot(velocity,velocity)
if speed2>0.01 and area~=0 then
local along=dot(velocity,forward)
-- Project body-down perpendicular to local airflow: lift does not
-- deliberately add thrust or oppose the body's roll orientation.
local flow=mul(velocity,1/math.sqrt(speed2))
local direction=sub(mul(up,-1),mul(flow,dot(mul(up,-1),flow)))
local projected2=dot(direction,direction)
if projected2>1e-8 then
local force=mul(direction,(0.5*1.225*along*along*area)/math.sqrt(projected2))
local impulse=mul(force,dt)
sim.v=add(sim.v,mul(impulse,1/C.mass))
sim.w=add(sim.w,inertia(car.rotation,cross(arm,impulse),true))
state.aero_load=state.aero_load-dot(force,up)
end
end
end
end
local function simulate(car,input,dt)
if not vec(car.linvel) or not vec(car.angvel) or not vec(car.position)
or type(car.rotation)~="table" or not finite(car.rotation[4]) then return end
audit_mass_properties(car)
local speed=math.sqrt(car.linvel[1]^2+car.linvel[3]^2)
local angles=steering_angles(input.steer,dt,speed)
local power=prepare_powertrain(input,dt,speed)
local contacts=collect_contacts(car,angles,dt)
-- Gravity is integrated by DynamicsWorld with the submitted forces.
-- Include it in the prediction, but DO NOT submit gravity a second time.
local free={car.linvel[1],car.linvel[2]-C.gravity*dt,car.linvel[3]}
local sim={v={free[1],free[2],free[3]},w={car.angvel[1],car.angvel[2],car.angvel[3]}}
local drag=1/(1+C.aero_drag*speed*dt/C.mass)
sim.v[1]=sim.v[1]*drag; sim.v[3]=sim.v[3]*drag
apply_aerodynamics(sim,car,dt)
local mu=clamp(number(sdk.settings.tire_friction,1.3),0.4,2)
-- Alternating sweep order avoids a persistent front/rear solver bias.
for pass=1,C.iterations do
solve_powertrain(power,dt)
for k=1,#contacts do
local index=(pass%2==1) and k or (#contacts-k+1)
solve_normal(sim,contacts,index,dt)
end
for k=1,#contacts do
local index=(pass%2==1) and k or (#contacts-k+1)
solve_tire(sim,contacts[index],input,mu,dt)
end
end
for _,c in ipairs(contacts) do
c.wheel.load=c.pn/dt
local vx,vy=velocity_along(sim,c.x),velocity_along(sim,c.y)
local tread=c.wheel.omega*C.radius
c.wheel.slip=tread-vx
c.wheel.kappa=(tread-vx)/math.max(math.abs(vx),math.abs(tread),0.5)
c.wheel.alpha=math.deg(math.atan(vy,math.max(math.abs(vx),0.1)))
c.wheel.fx=c.px/dt; c.wheel.fy=c.py/dt
c.wheel.usage=c.limit and math.sqrt(c.px*c.px+c.py*c.py)/math.max(c.limit,1e-8) or 0
end
for _,w in ipairs(wheels) do if not w.contact then
local fraction=w.front and C.front_brake_fraction or 1-C.front_brake_fraction
local brake=input.brake*C.brake_torque_total*fraction/2
+((input.hand and not w.front) and C.handbrake_torque or 0)
w.omega=approach(w.omega,0,brake*dt/C.wheel_inertia)
end end
local impulse=mul(sub(sim.v,free),C.mass)
local angular=inertia(car.rotation,sub(sim.w,car.angvel),false)
if vec(impulse) and vec(angular) then
-- apply_force/apply_torque each REPLACE the previous force/torque
-- in this engine. Aggregate here, then submit once per body per tick.
sdk.physics.force(BODY,mul(impulse,1/dt))
sdk.physics.torque(BODY,mul(angular,1/dt))
end
state.rpm=state.engine_omega*30/math.pi
sound.throttle=input.throttle; sound.shift=state.shift
local body_velocity=unrotate(car.rotation,car.linvel)
presentation.speed=math.sqrt(body_velocity[1]^2+body_velocity[3]^2)
presentation.handbrake=input.hand
publish_hud(car,input,contacts,dt)
end
return {
on_load=function()
reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation()
sdk.log("Skyline DRIVE: dynamic chase/hood, clean dashboard; physics preserved except explicit aero and optional input shaping")
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
end,
on_update=function(event) update_audio(event); update_dashboard(event) end,
on_settings=function(event)
if event.key=="audio_enabled" and event.value==false then stop_audio() end
if event.key=="show_driving_debug" then set_debug_visible(event.value==true) end
if event.key=="show_speedometer" and event.value==false and presentation.supported then
sdk.ui.remove("driving_dashboard");presentation.active_hud=false
end
presentation.hud_timer=0
if event.key:match("^camera_") or event.key=="hood_height" then configure_camera() end
end,
on_unload=function()
remove_rig()
sdk.ui.text("skyline_audio",""); sdk.ui.text("skyline_presentation","")
sdk.ui.text("skyline_help",""); sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
end,
on_fixed_update=function(event)
local dt=number(event and event.dt,1/120)
if dt<=0 or dt>0.1 then return end
state.time=state.time+dt
local p=sdk.input.pad(); local buttons=math.floor(number(p.buttons,0))
-- Sample edges unconditionally; held keys must not become new presses
-- merely because another shortcut returned early this tick.
local spawn=edge("F10")
local reset_key=edge("KeyR")
local enter_key=edge("KeyE")
local camera_key=edge("KeyC")
local debug_key=edge("KeyH")
local camera_pressed=camera_key or pressed_button(buttons,0x4000)
if debug_key then set_debug_visible(not presentation.debug) end
local enter=enter_key or pressed_button(buttons,0x8000)
local reset=reset_key or pressed_button(buttons,0x0080)
local car=sdk.physics.read(BODY)
if spawn then respawn(nil,false)
elseif state.pending then
local request=state.pending; state.pending=nil; spawn_rig(request)
elseif car and state.spawned then
if reset then respawn(car,true)
else
if enter then
if state.occupied then
sdk.player.detach(); state.occupied=false; clear_presentation()
if presentation.debug then set_debug_visible(true) end
elseif state.time>=state.enter_after then
local player=sdk.player.read()
if vec(player.position) and dot(sub(car.position,player.position),sub(car.position,player.position))<64 then
sdk.player.attach(BODY,{-0.38,0.12,-0.23})
state.occupied=true; configure_camera()
end
end
end
if camera_pressed and state.occupied then
presentation.mode=presentation.mode=="chase" and "hood" or "chase"
configure_camera();presentation.hud_timer=0
end
simulate(car,input_state(buttons,dt),dt)
end
end
state.buttons=buttons
end,
on_event=function(event)
if event.name=="world_changed" then
stop_audio(); clear_presentation(); sound.prepared=false; prepare_audio()
state.spawned=false; state.occupied=false; state.pending=nil
state.keys={}; state.buttons=0; reset_simulation()
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
end
end,
}