local sim = ac.getSim() -- CSP doesn't guarantee the player is car 0 in this mode config (OPPONENT_NOUN=revenant + -- AI_LIMIT=1). The assignment has been observed swapping between runs. Resolve it once at -- init: prefer sim.focusedCar, otherwise pick the non-AI car out of the two. local PLAYER, GHOST = 0, 1 do local focused = sim.focusedCar if focused and focused >= 0 and sim.carsCount and sim.carsCount >= 2 then PLAYER = focused GHOST = (focused == 0) and 1 or 0 else local c0, c1 = ac.getCar(0), ac.getCar(1) if c0 and c0.isAIControlled then PLAYER, GHOST = 1, 0 elseif c1 and c1.isAIControlled then PLAYER, GHOST = 0, 1 end end end local cfg = ac.storage{ collisionsType = 2, -- Saved recording metadata, survives Lua script reloads within the same session. -- The actual frame data lives in AC's replay buffer (not ours to manage); we only need -- to remember the time range. savedStartTime = -1 means no recording saved. -- savedDuration = 0 means recording was in progress when saved (not yet finalized). -- -- We keep TWO clocks: session time (sim.currentSessionTime) and replay-writer frames -- (sim.replayFrames). Playback offset MUST be computed from replay frames — when the -- player alt-tabs with DXGI 'Pause Rendering' on, AC keeps ticking session time (physics -- thread) but throttles/pauses the replay writer. A session-time-based offset then -- points past the last written frame by the size of that gap, and the ghost starts -- somewhere in the middle of the recording. Replay-frame-based offset is immune to it: -- both endpoints (record start, now) come from the same clock that actually drives the -- replay buffer. Session time is kept around only for the stale-recording check below -- (session restart makes sim.currentSessionTime go backwards). savedStartTime = -1, savedStartReplayFrames = -1, savedDuration = 0, -- recording duration, in replay-writer ms (frames * replayFrameMs) -- Player's body transform at recording start — used to render the translucent silhouette -- marking where the lap started. Stored as 4 raw matrix rows (ac.storage doesn't persist -- mat4x4 directly, but supports vec4). Rebuilt exactly via the mat4x4(r1, r2, r3, r4) -- constructor on restore, no handedness guessing. savedStartRow1 = vec4(1, 0, 0, 0), savedStartRow2 = vec4(0, 1, 0, 0), savedStartRow3 = vec4(0, 0, 1, 0), savedStartRow4 = vec4(0, 0, 0, 1), -- 'Breakable' mode tuning (exposed as sliders in the main menu). See sliders below for ranges. breakableThresholdMps = 5, -- closing speed at which ghost gets knocked out breakableDampRate = 20, -- exponential damping rate for player's rebound (1/s) breakableWindowSec = 0.5, -- how long damping stays active after a light contact teleportMode = 3, -- index into teleportModes below; 3 = Behind (default) playMode = 3, -- index into playModes below; 3 = Warp, ready, go (default) } local TELEPORT_LATERAL_OFFSET = 3.0 -- metres to the side of ghost, room for both car widths local TELEPORT_BEHIND_OFFSET = 15.0 -- metres behind ghost -- [1] internal key, [2] display label, [3] tooltip local teleportModes = { {'left', 'Left side', 'Teleport to the left of the Revenant\'s recorded start'}, {'right', 'Right side', 'Teleport to the right of the Revenant\'s recorded start'}, {'behind', 'Behind', 'Teleport 15 metres behind the Revenant\'s recorded start'}, } -- Controls what the play button does when a recording is ready. -- 'normal' — button just starts playback (original behaviour). -- 'combined' — button teleports you to the Revenant's start AND starts playback in one press. -- 'staged' — first press teleports you and leaves the Revenant frozen at the start; the -- next press starts playback. Lets you settle / align before the run. -- All teleport-capable modes reuse the position chosen in the Teleport on Drive combo; if -- that's Off, Combined/Staged degrade gracefully to just playing without teleporting. local playModes = { {'normal', 'Off', 'Button just starts playback. No teleport on play.'}, {'combined', 'Warp and go', 'Button teleports you next to the Revenant and starts playback at once.'}, {'staged', 'Warp, ready, go', 'First press teleports you next to the Revenant; the next press starts playback.'}, } local btn = ac.ControlButton('newmode.revenant/Revenant: recording') -- [1] internal key used by collision handling logic -- [2] short label shown in the combo UI -- [3] tooltip shown on hover -- [4] CSP collision mode passed to ac.setReplayBasedGhost local collisions = { {'disabled', 'Off', 'Pass through ghost as if it’s not there', 'disabled'}, {'breakable', 'On', 'Ghost is an immovable wall, but a strong impact knocks it out entirely. Light contacts are softened on your side.', 'stiff'}, } -- Clamp collisionsType in case an older build wrote an out-of-range index to storage -- (the table used to have 5 entries, now it has 2). Default to 'On' on mismatch. if cfg.collisionsType < 1 or cfg.collisionsType > #collisions then cfg.collisionsType = 2 end if cfg.teleportMode < 1 or cfg.teleportMode > #teleportModes then cfg.teleportMode = 3 end if cfg.playMode < 1 or cfg.playMode > #playModes then cfg.playMode = 3 end -- CSP's script-settings parser (and CM's override layer) tokenize INI values on `_`, so a -- user-selected `HOTLAP_START` arrives as `HOTLAP` and `TIME_ATTACK` as `TIME`. Map the -- known truncated prefixes back to the full spawn-set names. local rawStartFrom = ac.INIConfig.scriptSettings():get('SETTINGS', 'START_FROM', 'PIT') local spawnRestore = { HOTLAP = 'HOTLAP_START', TIME = 'TIME_ATTACK' } local startFrom = spawnRestore[rawStartFrom] or rawStartFrom -- `physics.teleportCarTo(0, 'HOTLAP_START')` drops the car at garbage coordinates (floating -- above the track) in TRACK_DAY-based modes on at least some tracks. Bypass it by looking -- up the track's hotlap-start locator node directly and placing the car there ourselves. local HOTLAP_LOCATOR_NAMES = { 'AC_HOTLAP_START_0', 'AC_HOTLAP_0', 'AC_HOTLAP_START' } local function teleportToHotlapStart() for _, name in ipairs(HOTLAP_LOCATOR_NAMES) do local node = ac.findNodes(name) if node and #node > 0 then local t = node:getWorldTransformationRaw() if t then local pos = vec3(t.row4.x, t.row4.y, t.row4.z) local fwd = vec3(-t.row3.x, -t.row3.y, -t.row3.z) -- Some tracks (e.g. fn_albertpark) have the locator suspended a few metres above the -- track surface. Dropping the car there triggers AC's "car not on ground" bailout, -- which then yanks it to pits. Snap to the physical surface before teleporting. local hitPoint = vec3() local dist = physics.raycastTrack(pos + vec3(0, 5, 0), vec3(0, -1, 0), 20, hitPoint) if dist > 0 then pos = hitPoint end physics.setCarPosition(PLAYER, pos, fwd) ac.log('Revenant: teleported via locator ' .. name .. ' (raycast dist=' .. tostring(dist) .. ')') return true end end end ac.log('Revenant: no HOTLAP locator node found') return false end if startFrom == 'HOTLAP_START' then if not teleportToHotlapStart() then physics.teleportCarTo(PLAYER, 'START') end else physics.teleportCarTo(PLAYER, startFrom) end -- Break material sharing between the player's car and the ghost, but SURGICALLY. Naive -- ensureUniqueMaterials() on all meshes breaks CSP car-extension bindings from the car's -- ext_config.ini — sections like [Material_Chrome] with Materials=EXT_EXHAUST,Exhaust_Pipes -- hold on to the original material instance, and after we clone it the extension ends up -- applied to the wrong car (e.g. ghost's EXT_EXHAUST visually attached to the player car -- during playback). -- -- Observation: the only textures AC's TRACK_DAY skin cycling can swap on the ghost are the -- ones that live in a skin folder. Materials bound only to static KN5 textures (chrome, -- windows, exhaust metal, etc.) are untouched by cycling — so we don't need to fork those. -- We scan the player's skin folder for .dds files, then only fork materials whose meshes -- reference one of those filenames in any texture slot. That is exactly the set that can -- actually flicker, and leaves every other material (including the ones car extensions -- name-bind to) shared and intact. local playerCarRoot = ac.findNodes('carRoot:' .. PLAYER) if playerCarRoot and #playerCarRoot > 0 then local skinID = ac.getCarSkinID(PLAYER) local carID = ac.getCarID(PLAYER) if skinID and carID then local skinDir = ac.getFolder(ac.FolderID.ContentCars) .. '/' .. carID .. '/skins/' .. skinID local skinFiles = io.scanDir(skinDir, '*.dds') local skinSet = {} if skinFiles then for i = 1, #skinFiles do skinSet[skinFiles[i]:lower()] = true end end if next(skinSet) ~= nil then local allMeshes = playerCarRoot:findMeshes('?') local single = ac.emptySceneReference() local forkedCount = 0 for i = 1, #allMeshes do local hit = false local slots = allMeshes:getTextureSlotsCount(i) for s = 1, slots do local fn = allMeshes:getTextureSlotFilename(i, s) if fn then -- Texture filename may be a full path or just a basename; take the basename. local base = fn:match('([^\\/]+)$') or fn if skinSet[base:lower()] then hit = true; break end end end if hit then allMeshes:at(i, single):ensureUniqueMaterials() forkedCount = forkedCount + 1 end end ac.log(string.format('Revenant: forked materials for %d skin-sensitive meshes (skin=%s, %d skin files)', forkedCount, skinID, #(skinFiles or {}))) end end end -- Disable damage globally: prevents ghost's wheels from detaching when pushed off trajectory. -- Damage/suspension state isn't reliably restored by setReplayBasedGhost reversion or resetCarState, -- so any collision would leave suspension at threshold, making even light subsequent hits detach wheels again. -- Acceptable tradeoff for a practice mode. do local tweaks = ac.configureSceneTweaks() if tweaks then tweaks.disableDamage = true end end local startSessionTime = -1 -- Frame count of the replay writer at recording start. Kept only as a cross-session -- sanity stamp (stored in cfg.savedStartReplayFrames) — a saved frame count above the -- current sim.replayFrames means the save is from a previous session (fresh session -- resets replayFrames to 0) and should be discarded as stale. local startReplayFrames = -1 local startingTransform local isRecording = false local playing = false local ghostVisible = false local replayDuration = 0 -- recording duration, in session-time ms local ghostPlaybackEndSessionMs = 0 -- session-clock deadline for stopping playback -- True between breakGhost() and the next hideAndStopGhost()/armGhost(): ghost has been -- physically knocked out and is rolling/tumbling in the world. Suppresses the per-frame -- pin-above-player so a hit doesn't instantly teleport the broken ghost into the sky. local ghostKnocked = false -- In 'staged' play mode: true means the player has been teleported and the next button -- press will start playback. Cleared whenever the setup changes (new recording, pits drive, -- etc.) so an old stage doesn't linger and misfire in an unrelated context. local stagedTeleportDone = false -- Alt-tab drift compensator state. When DXGI 'Pause Rendering' is enabled (default on some -- rigs), alt-tabbing pauses the replay writer but NOT the physics/session clock. Session -- time keeps ticking, but no frames are written — so a ghost offset computed as -- (now - startSessionTime) at play time ends up pointing past the actual recorded content -- by the size of that gap. We detect the mismatch frame-by-frame while recording and nudge -- startSessionTime forward by the undercounted delta, keeping the session-time offset -- aligned with what the buffer actually holds. Initialised to -1 as "not tracking yet". local lastKnownSessionTime = -1 local lastKnownReplayFrames = -1 -- Running balance of (sessionDelta - frameDelta*replayFrameMs) since the last compensation. -- In normal operation this oscillates around 0 — zero-frame ticks contribute positive gap, -- one-frame ticks contribute negative gap, and over any reasonable window they cancel. -- During an alt-tab the balance grows monotonically because frameDelta stays at 0, and the -- sub-frame remainder of each alt-tab event persists here instead of being dropped by a -- per-tick threshold, so cumulative drift doesn't accumulate across many short alt-tabs. local gapAccumulator = 0 -- Snapshot taken when AC's built-in replay opens, used by the 'stop' handler to compute -- the exact offset that resumes playback at the same target frame the ghost was showing -- when replay was opened. Relying on lastKnown* here doesn't work — script.update can run -- between sim.isReplayActive flipping false and the 'stop' callback, overwriting them with -- post-replay values. Initialised to -1 = "no replay snapshot active". local preReplayReplayFrames = -1 local preReplayOffsetSec = -1 local preReplaySessionTime = -1 -- Settle window after AC's replay closes: sim.currentSessionTime can hold its scrubbed value -- for a tick or two before AC snaps it back to the live position. The compensator must NOT -- treat that snap as a real alt-tab gap (it would dump a multi-second phantom bump into -- startSessionTime, pushing it past the live clock and tripping "Recording lost due to rewind" -- on the next PLAY press). During settle, the compensator only refreshes its baseline. local replayExitSettleTicks = 0 local REPLAY_EXIT_SETTLE_TICKS = 10 local function hideAndStopGhost() ac.setReplayBasedGhost(GHOST, -1) ac.setDriverVisible(GHOST, false) ac.disableCar(GHOST, true) physics.teleportCarTo(GHOST, ac.SpawnSet.Pits) physics.setAINoInput(GHOST, true, true) ghostVisible = false playing = false ghostKnocked = false end -- Break ghost out of replay playback into the real physical world. -- Re-arm in 'knockable' mode rather than fully disabling the ghost: the translucent "ghost look" -- is AC's default rendering for TRACK_DAY AI opponents, which setReplayBasedGhost suppresses. -- 'knockable' keeps the ghost system engaged (= proper rendering) and CSP releases the car -- physically after a hit. Manual setCarVelocity applies the impact impulse as a backup in case -- contact has already ended by the time CSP processes the knock. local function breakGhost(newVelocity) ac.setReplayBasedGhost(GHOST, PLAYER, currentOffsetSeconds, 'knockable') physics.setCarVelocity(GHOST, newVelocity) ghostVisible = false playing = false ghostKnocked = true end local function resetGhost() hideAndStopGhost() end local function reset() startSessionTime = -1 startReplayFrames = -1 startingTransform = nil isRecording = false stagedTeleportDone = false cfg.savedStartTime = -1 cfg.savedStartReplayFrames = -1 cfg.savedDuration = 0 resetGhost() end ac.addMainMenuSettings(function () ui.text('Start/play recording:') local isBreakable = collisions[cfg.collisionsType][1] == 'breakable' local function drawCollisionsCombo(width) -- No setNextItemIcon here: the icon sits OUTSIDE the setNextItemWidth footprint, so -- with three combos cramped onto one row it would push the row wider than the two -- above it and force Teleport off the right edge. ui.setNextItemWidth(width) ui.combo('##collisions', 'Collisions: %s' % collisions[cfg.collisionsType][2], function () for i, v in ipairs(collisions) do if ui.selectable(v[2], cfg.collisionsType == i) then cfg.collisionsType = i -- resetGhost (not reset) — we only want to stop any active playback so the new -- collision mode applies on the next start. reset() would wipe the saved recording. resetGhost() end if ui.itemHovered() then ui.setTooltip(v[3]) end end end) end -- Row 1 (two columns): record button | slider block (or invisible dummy in 'Off'). -- Row 2 (three columns, same total width as row 1): Collisions | Play behavior | Teleport. -- Row-2 width math: it spans the same ui.availableSpaceX() as row 1. With two 8px gaps -- between three combos, each combo gets (availableSpaceX - 16) / 3. local SLIDER_BLOCK_H = 48 local halfWidth = ui.availableSpaceX() * 0.5 - 4 ui.beginGroup() btn:control(vec2(halfWidth, SLIDER_BLOCK_H)) ui.endGroup() ui.sameLine(0, 8) ui.beginGroup() if isBreakable then ui.pushFont(ui.Font.Small) local sliderWidth = ui.availableSpaceX() ui.setNextItemWidth(sliderWidth) local v, changed = ui.slider('##hitThreshold', cfg.breakableThresholdMps, 1, 20, 'KO speed: %.1f m/s') if changed then cfg.breakableThresholdMps = v end if ui.itemHovered() then ui.setTooltip('Relative closing speed at which a hit knocks the ghost out of replay. Lower = breaks on softer hits.') end ui.setNextItemWidth(sliderWidth) v, changed = ui.slider('##dampRate', cfg.breakableDampRate, 0, 30, 'Soft contact damping: %.0f') if changed then cfg.breakableDampRate = v end if ui.itemHovered() then ui.setTooltip('Exponential damping rate for the player\'s rebound from the ghost on light contacts. 0 = no softening, higher = softer.') end ui.popFont() else ui.dummy(vec2(ui.availableSpaceX(), SLIDER_BLOCK_H)) end ui.endGroup() local thirdWidth = (ui.availableSpaceX() - 16) / 3 drawCollisionsCombo(thirdWidth) ui.sameLine(0, 8) ui.setNextItemWidth(thirdWidth) ui.combo('##playMode', 'Quick teleport: %s' % playModes[cfg.playMode][2], function () for i, v in ipairs(playModes) do if ui.selectable(v[2], cfg.playMode == i) then cfg.playMode = i end if ui.itemHovered() then ui.setTooltip(v[3]) end end end) if ui.itemHovered() then ui.setTooltip('What happens when you press the play button with a recording ready. Uses the spot chosen in the Position combo.') end ui.sameLine(0, 8) ui.setNextItemWidth(thirdWidth) ui.combo('##teleportMode', 'Position: %s' % teleportModes[cfg.teleportMode][2], function () for i, v in ipairs(teleportModes) do if ui.selectable(v[2], cfg.teleportMode == i) then cfg.teleportMode = i end if ui.itemHovered() then ui.setTooltip(v[3]) end end end) if ui.itemHovered() then ui.setTooltip('Where to respawn your car relative to the Revenant\'s recorded start when Quick teleport fires.') end ui.offsetCursorY(8) ui.pushFont(ui.Font.Small) ui.textWrapped('When playing record, press button to restart playback, or hold button for a half a second to reset and start a new recording') ui.popFont() end, nil, 200) physics.setAIDriverName(GHOST, 'Revenant', ac.getDriverNationCode(PLAYER)) ac.disableQuickMenuPitstop(true) ac.hideCarLabels(GHOST, true) -- Clean ghost state from any previous Lua run, but DON'T clear cfg storage — we want to -- restore the recording below. (Calling reset() here would wipe savedStartTime/savedDuration.) hideAndStopGhost() -- Restore a previously saved recording if it's still valid. Session time running backwards -- means the session restarted — AC's replay buffer was reset, so our saved time range is stale. -- We additionally check savedStartReplayFrames against sim.replayFrames: in a fresh session -- replayFrames starts at 0, so a saved frame count above that is from a previous session. if cfg.savedStartTime >= 0 and cfg.savedStartTime <= sim.currentSessionTime and cfg.savedStartReplayFrames >= 0 and cfg.savedStartReplayFrames <= (sim.replayFrames or 0) then startSessionTime = cfg.savedStartTime startReplayFrames = cfg.savedStartReplayFrames replayDuration = cfg.savedDuration isRecording = (cfg.savedDuration == 0) startingTransform = mat4x4(cfg.savedStartRow1, cfg.savedStartRow2, cfg.savedStartRow3, cfg.savedStartRow4) elseif cfg.savedStartTime >= 0 then -- Stale recording from a previous session. cfg.savedStartTime = -1 cfg.savedStartReplayFrames = -1 cfg.savedDuration = 0 end local function startRecording() startSessionTime = ac.getSim().currentSessionTime startReplayFrames = sim.replayFrames or 0 isRecording = true stagedTeleportDone = false startingTransform = ac.getCar(PLAYER).bodyTransform:clone() cfg.savedStartTime = startSessionTime cfg.savedStartReplayFrames = startReplayFrames cfg.savedDuration = 0 cfg.savedStartRow1 = startingTransform.row1 cfg.savedStartRow2 = startingTransform.row2 cfg.savedStartRow3 = startingTransform.row3 cfg.savedStartRow4 = startingTransform.row4 resetGhost() end -- Session-time (ms) of our last intentional physics.setCarPosition(0, ...) call. -- The onCarJumped(0) handler ignores jumps within a short window after this — that's "our" -- teleport, not a pit return. A stamp is more robust than a flag: if setCarPosition ever -- fails to fire onCarJumped, a flag would linger and eat the next REAL jump instead. local lastSelfTeleportMs = -1e9 local SELF_TELEPORT_GRACE_MS = 200 -- Session-time (ms) until which onCarJumped(PLAYER) should ignore jumps because they -- belong to AC's replay-exit teleport. Set by ac.onReplay('stop') just before re-arming -- the ghost — the "stop" transition emits one or two PLAYER jumps a moment later as AC -- snaps the car back to live state, and without this grace they'd race against our re-arm -- and silently kill it (verified via diagnostic logging). local replayExitGraceUntilMs = -1e9 local REPLAY_EXIT_GRACE_MS = 250 -- Teleports the player car to a spot relative to the ghost's recorded start, per cfg.teleportMode. -- Returns true if a teleport actually happened, false if no recording to anchor to. local function teleportPlayerToGhostStart() if not startingTransform then return false end local mode = teleportModes[cfg.teleportMode][1] -- Empirically: row1 points to the car's LEFT in world space and row3 points FORWARD -- (opposite of what a "row3 = backward" assumption would give). The facing direction -- passed to setCarPosition is still -row3 — that combination was confirmed correct. local gp, leftAxis, fwd = startingTransform.row4, startingTransform.row1, startingTransform.row3 local ox, oy, oz = 0, 0, 0 if mode == 'left' then ox, oy, oz = leftAxis.x * TELEPORT_LATERAL_OFFSET, leftAxis.y * TELEPORT_LATERAL_OFFSET, leftAxis.z * TELEPORT_LATERAL_OFFSET elseif mode == 'right' then ox, oy, oz = -leftAxis.x * TELEPORT_LATERAL_OFFSET, -leftAxis.y * TELEPORT_LATERAL_OFFSET, -leftAxis.z * TELEPORT_LATERAL_OFFSET elseif mode == 'behind' then ox, oy, oz = -fwd.x * TELEPORT_BEHIND_OFFSET, -fwd.y * TELEPORT_BEHIND_OFFSET, -fwd.z * TELEPORT_BEHIND_OFFSET end lastSelfTeleportMs = sim.currentSessionTime physics.setCarPosition(PLAYER, vec3(gp.x + ox, gp.y + oy, gp.z + oz), vec3(-fwd.x, -fwd.y, -fwd.z)) return true end -- Return-to-pits from the pause menu teleports the player car. CSP's replay-based ghost -- keeps cheerfully replaying car 0's frames past the recorded endpoint (including the -- post-return lap the player is in the middle of), which looks like the ghost "following" -- the player around. Catching the jump and stopping the ghost is the cleanest cutoff. ac.onCarJumped(PLAYER, function () if sim.currentSessionTime - lastSelfTeleportMs < SELF_TELEPORT_GRACE_MS then return end if sim.currentSessionTime < replayExitGraceUntilMs then return end if playing or ghostVisible then hideAndStopGhost() end stagedTeleportDone = false end) local currentOffsetSeconds = 0 -- Shared ghost arming path. Full teardown (which resets ODE state via teleport) followed by -- setReplayBasedGhost WHILE car is still disabled, then enable car next frame. This exact ordering -- is what makes the button-triggered restart consistently give the ghost clean wheels. local function armGhost(offsetSeconds) hideAndStopGhost() physics.resetCarState(GHOST, 0) currentOffsetSeconds = offsetSeconds ac.setReplayBasedGhost(GHOST, PLAYER, offsetSeconds, collisions[cfg.collisionsType][4]) ac.setDriverVisible(GHOST, true) setTimeout(function () ac.disableCar(GHOST, false) end) playing = true ghostVisible = true end local function playRecordingFromStart() -- Offset MUST be session-time-based: setReplayBasedGhost interprets its timeOffset -- against sim.currentSessionTime, and a frame-quantized value (even 2 ms off from the -- session-time equivalent) produces rapid sub-frame shimmer in the replayed ghost. -- Alt-tab resilience is handled by bumping startSessionTime whenever sessionTime -- advances without a matching replayFrames tick (see script.update below). local offsetSeconds = (sim.currentSessionTime - startSessionTime) / 1000.0 if offsetSeconds < 0 then reset() ac.setMessage("Revenant", "Recording lost due to rewind") return end armGhost(offsetSeconds) -- End-of-playback deadline on the session clock — stays in the same clock as the -- offset above so they can't disagree about when playback should stop. ghostPlaybackEndSessionMs = sim.currentSessionTime + replayDuration end local function onShortPress() if startSessionTime == -1 then startRecording() return end if isRecording then -- Stop recording but DON'T auto-play: the user is wherever they ended the lap, -- not at the start line. Just finalize the duration and leave the green silhouette -- at the recorded start visible. The next press will trigger playback. -- Duration is measured on the session clock, same as the offset at play time. The -- alt-tab compensator below in script.update keeps startSessionTime aligned with -- what the buffer actually holds, so this duration is accurate even if the player -- alt-tabbed mid-recording. replayDuration = ac.getSim().currentSessionTime - startSessionTime isRecording = false cfg.savedDuration = replayDuration hideAndStopGhost() stagedTeleportDone = false return end local playBehaviour = playModes[cfg.playMode][1] if playBehaviour == 'combined' then teleportPlayerToGhostStart() -- ignore return: if disabled/no-op, just play anyway playRecordingFromStart() stagedTeleportDone = false elseif playBehaviour == 'staged' then -- Two-press flow: press 1 = teleport + wait; press 2 = play. -- `not playing` is in the condition so that pressing while playback is active restarts -- (teleport + re-stage) instead of advancing — otherwise a double tap mid-run would -- skip the teleport-to-start step on the next run. if stagedTeleportDone and not playing then stagedTeleportDone = false playRecordingFromStart() elseif teleportPlayerToGhostStart() then hideAndStopGhost() -- stop any active playback so the ghost shows as green silhouette stagedTeleportDone = true else -- teleportMode = Off: staged has nothing to stage on, fall back to plain play. playRecordingFromStart() stagedTeleportDone = false end else -- 'normal' playRecordingFromStart() stagedTeleportDone = false end end local function onLongPress() startRecording() end local complexPress btn:onPressed(function () complexPress = setTimeout(function () onLongPress() complexPress = nil end, 0.5) end) btn:onReleased(function () if complexPress then clearTimeout(complexPress) complexPress = nil onShortPress() end end) render.on('main.track.transparent', function () if startingTransform and not sim.isReplayActive and not ghostVisible then render.setBlendMode(render.BlendMode.AlphaBlend) render.setCullMode(render.CullMode.None) render.setDepthMode(render.DepthMode.ReadOnly) render.mesh({ mesh = ac.SimpleMesh.carShape(PLAYER), transform = startingTransform, textures = {}, -- Yellow while actively recording, green once the recording is finalized and ready to play. values = { gTint = isRecording and vec3(1, 0.8, 0) or vec3(0.5, 1, 0) }, shader = [[ float4 main(PS_IN pin) { return float4((pin.NormalW.yyy + 1) * gTint * gWhiteRefPoint, 0.1 * pin.FogAlphaMultiplier()); } ]] }) end end) -- Debounce cooldown: suppresses repeat events during a multi-contact crash so handler runs once per event. local collisionCooldownUntil = 0 local COLLISION_COOLDOWN_SEC = 0.1 -- Active-until timestamp (ms) for the per-frame rebound damping applied after a light 'breakable' hit. local softContactUntil = 0 -- Rough "we're actually touching" cutoff. ac.onCarCollision(1, ...) fires for ANY ghost -- collision — including the ghost bouncing on a kerb, clipping a wall, etc. Without this gate, -- a curb jump on the ghost's replay can spike its vertical velocity and trigger breakGhost -- even though the player never came near it. Two car centers within this distance is a -- reasonable "they could be in contact" proxy (sedan length ~4.5m, so 5m covers nose-to-tail). local GHOST_CONTACT_MAX_DIST = 5.0 ac.onCarCollision(GHOST, function () if not (playing and ghostVisible) then return end if sim.currentSessionTime < collisionCooldownUntil then return end local ghost = ac.getCar(GHOST) local player = ac.getCar(PLAYER) if (player.position - ghost.position):length() > GHOST_CONTACT_MAX_DIST then return end collisionCooldownUntil = sim.currentSessionTime + COLLISION_COOLDOWN_SEC * 1000 local modeName = collisions[cfg.collisionsType][1] if modeName == 'pushable' then armGhost(currentOffsetSeconds) elseif modeName == 'breakable' then -- Threshold check uses the HORIZONTAL magnitude of relative velocity. Two reasons we -- prefer this over either raw |relVel| or a centre-to-centre projection: -- -- vs raw |relVel|: zeroing y kills the kerb-bounce false-positive. When the ghost pops -- up over a kerb, ghost.velocity.y can spike to 5-7 m/s for a single physics step. With -- the y-component included, a barely-touching side contact during that moment crosses -- the KO threshold even though the actual closing speed at the contact patch is near -- zero. Stripping y removes this entirely without touching any horizontal sensitivity. -- -- vs centre-to-centre projection: a projection onto the line between car centres -- systematically under-counts angled contacts. E.g. overtaking on the inside, clipping -- the rear corner with player's velocity mostly parallel to the ghost: the closing -- vector along the diagonal centre-line is much smaller than the actual contact-patch -- closing speed. The user reported these felt "tougher to KO" than head-ons of -- equivalent speed. Horizontal magnitude doesn't penalise angle — kinetic-energy-wise -- "how different are our ground-plane velocity vectors" is a better severity proxy -- than "how much of that difference points along the centre-line specifically". -- -- Concerns we considered and ruled out: -- - Tangential scraping causing false KOs: in practice cars travelling parallel have -- near-equal velocity vectors, so |relVel.xz| stays well below threshold. -- - Rebound/separation events double-firing: the 100ms collisionCooldownUntil gate -- above suppresses repeat events from the same collision, including the rebound. -- -- breakGhost itself still receives the full 3D relVel — once it's been decided this is -- a real impact, the ghost should fly off with the actual closing vector, including -- whatever vertical component existed (looks more natural than a flattened impulse). local relVel = player.velocity - ghost.velocity local relVelHorizontal = vec3(relVel.x, 0, relVel.z) if relVelHorizontal:length() >= cfg.breakableThresholdMps then -- Half of the closing-speed impulse transfers into the ghost (crude inelastic impact). breakGhost(ghost.velocity + relVel * 0.5) else -- Arm a damping window. The actual velocity damping happens every frame in script.update -- (see comment on BREAKABLE_SOFT_CONTACT_WINDOW_SEC for why per-frame is required). softContactUntil = sim.currentSessionTime + cfg.breakableWindowSec * 1000 end end end) -- Tracks whether our ghost was mid-playback when the user entered AC's built-in replay. -- CSP's replay-based ghost conflicts with AC replay (both want to drive car state from -- the buffer), so we must hide it on entry — but hiding it also drops the state we'd need -- to seamlessly resume. Remembering this single flag lets us re-arm on replay exit so -- popping into replay for a quick peek doesn't abort the revenant run. local wasPlayingBeforeReplay = false ac.onReplay(function (event) -- Events: 'start' (replay just opened), 'stop' (replay closed, returning to live), -- 'jump' (user seeked inside replay). 'jump' fires while replay is already active, so -- it must NOT re-capture wasPlayingBeforeReplay — by then `playing` has long since been -- cleared by the 'start' handler, and re-capturing would overwrite a valid true with false -- and silently kill the resume path. if event == 'start' then -- Capture state BEFORE hideAndStopGhost clears `playing`. The frame/offset snapshot is -- only meaningful if a ghost playback was actually in progress — capturing during e.g. -- a "free-look" replay open with no run going gives garbage values that we'd then try -- to resume from. wasPlayingBeforeReplay = playing if playing then preReplayReplayFrames = sim.replayFrames or 0 preReplayOffsetSec = currentOffsetSeconds -- DO NOT use sim.currentSessionTime here. By the time CSP fires this 'start' callback, -- AC has already scrubbed sim.currentSessionTime to wherever the replay viewer opened -- (typically the start of the session, which can be negative). Using that scrubbed value -- as the snapshot anchor produces a phantom multi-second gap in the 'stop' handler that -- bumps startSessionTime past the live clock, tripping "Recording lost due to rewind" -- on the next PLAY press. Use the compensator's lastKnownSessionTime instead — it tracks -- the most recent stable, live session-time value (the compensator's own sanity check -- below refuses to update lastKnown* on wildly-negative sessionDelta jumps). preReplaySessionTime = (lastKnownSessionTime >= 0) and lastKnownSessionTime or sim.currentSessionTime end hideAndStopGhost() ac.disableCar(GHOST, false) elseif event == 'stop' then -- Resume requires the snapshot taken at 'start'. The per-tick alt-tab compensator's -- lastKnown* variables can't be used as the snapshot — script.update is allowed to run -- between sim.isReplayActive flipping false and this 'stop' callback firing, and that -- intervening tick overwrites lastKnown* with post-replay values, killing the ability -- to compute the replay-window delta. hideAndStopGhost() if wasPlayingBeforeReplay and startSessionTime >= 0 and preReplayReplayFrames >= 0 then local replayFrameMs = sim.replayFrameMs or 30 local frameAdvance = (sim.replayFrames or 0) - preReplayReplayFrames local sessionAdvance = sim.currentSessionTime - preReplaySessionTime -- If the writer was paused for some part of the replay window (sessionAdvance > -- frameAdvance*frameMs), session-time ran ahead of writer-time by `gap` ms. Bump -- startSessionTime AND ghostPlaybackEndSessionMs by that gap so future session-time -- math (next PLAY press, end-deadline check) stays aligned with buffer contents. local gap = sessionAdvance - frameAdvance * replayFrameMs if gap > 0 then startSessionTime = startSessionTime + gap cfg.savedStartTime = startSessionTime ghostPlaybackEndSessionMs = ghostPlaybackEndSessionMs + gap end -- Sync the per-tick compensator's running state to the live values so the next -- script.update tick has a clean baseline. lastKnownSessionTime = sim.currentSessionTime lastKnownReplayFrames = sim.replayFrames or 0 gapAccumulator = 0 -- Arm the settle window: sim.currentSessionTime here might still be the scrubbed -- replay-seek value, and AC will snap it to the live clock within the next few ticks. -- Without the settle window the compensator would see that snap as a giant single-tick -- gap and bump startSessionTime past the live clock — next PLAY then computes a negative -- offset and trips the rewind path. replayExitSettleTicks = REPLAY_EXIT_SETTLE_TICKS if sim.currentSessionTime < ghostPlaybackEndSessionMs then -- AC fires onCarJumped(PLAYER) one or two times immediately after this 'stop' event -- (snapping the car from replay-seek position back to live). That handler kills any -- active ghost. Stamp the grace window BEFORE armGhost so the imminent jumps fall -- inside it and skip their kill path. replayExitGraceUntilMs = sim.currentSessionTime + REPLAY_EXIT_GRACE_MS -- Resume offset math: AC computes ghost target as -- target = currentReplayFrame - offsetSec*1000/replayFrameMs -- We want target at exit equal to target at entry: -- target_at_entry = preReplayReplayFrames - preReplayOffsetSec*1000/replayFrameMs -- Solving for newOffset: -- newOffset = preReplayOffsetSec + (currentReplayFrame - preReplayReplayFrames) * replayFrameMs / 1000 -- This works for both writer-running (frameAdvance > 0) and writer-paused -- (frameAdvance == 0) replay scenarios. local newOffsetSec = preReplayOffsetSec + frameAdvance * replayFrameMs / 1000 if newOffsetSec >= 0 then armGhost(newOffsetSec) end end end preReplayReplayFrames = -1 preReplayOffsetSec = -1 preReplaySessionTime = -1 wasPlayingBeforeReplay = false end -- 'jump' is intentionally ignored: replay is already in the hidden state from 'start', -- and seek position has no meaning for our ghost until replay exits. end) function script.update(dt) -- Alt-tab drift compensator. CSP's setReplayBasedGhost interprets timeOffset by converting -- it to a frame offset (offsetSec * 1000 / replayFrameMs) and indexing the ring buffer -- backwards from the current writer frame. So the offset must be in WRITER time, not -- session time — those diverge whenever the writer is paused while the physics clock keeps -- ticking (DXGI 'Pause Rendering' on alt-tab is the common trigger). -- -- We run this compensation whenever a recording anchor exists (startSessionTime >= 0), not -- just during active recording: alt-tabbing BETWEEN stop-recording and play still pushes -- session time forward while the writer sits still, which would make the computed offset -- reach back past the recording's start frame into older/overwritten content — exactly the -- "rewound after alt-tab" symptom. Nudging startSessionTime forward by the gap keeps -- (now - startSessionTime)/1000 equal to how many writer-seconds actually live in the -- buffer between the start anchor and now. -- Pause the compensator while either condition holds: -- (a) AC's built-in replay is active — sim.currentSessionTime gets scrubbed (jumps to -- negative values, jumps around on seek) and sim.replayFrames is frozen, so any -- gap math during replay produces garbage. -- (b) preReplaySessionTime is held (>= 0) — replay has just closed but the 'stop' -- callback hasn't run yet. AC can fit a script.update tick into that window, and -- if the compensator runs there it will absorb the replay-pause gap into -- startSessionTime — then the 'stop' handler does the same gap math from the -- preReplay snapshot and bumps startSessionTime AGAIN. The double bump puts -- startSessionTime far enough in the future that the next PLAY computes a -- negative offset and trips the "Recording lost due to rewind" path. -- Keeping lastKnown* pinned to the pre-replay snapshot lets the 'stop' handler do the -- one-shot compensation correctly, then it resyncs lastKnown* before clearing the -- snapshot so subsequent ticks have a clean baseline. if not sim.isReplayActive and preReplaySessionTime < 0 then local nowSessionTime = sim.currentSessionTime local nowReplayFrames = sim.replayFrames or 0 local replayFrameMs = sim.replayFrameMs or 30 -- Sanity gate: if sessionTime jumped backwards by more than one writer frame, AC has -- scrubbed the clock for replay even though sim.isReplayActive hasn't flipped to true yet -- (the flip is racy with onReplay 'start'). Pin lastKnown* to its previous value so the -- next 'start' callback can read the LIVE pre-scrub sessionTime out of it, not the scrub -- target. Without this, snapshotting in 'start' captures negative session times and the -- 'stop' handler computes a multi-second phantom replay-exit gap. local sessionDelta = (lastKnownSessionTime >= 0) and (nowSessionTime - lastKnownSessionTime) or 0 if lastKnownSessionTime >= 0 and sessionDelta < -replayFrameMs then -- Skip the entire compensator body AND the lastKnown* refresh. elseif replayExitSettleTicks > 0 then -- Inside the post-replay settle window: AC may still snap sim.currentSessionTime from -- its scrubbed value to the live clock during these ticks. Just refresh baseline so the -- snap doesn't show up as a phantom alt-tab gap. The 'stop' handler already absorbed -- the real replay-pause gap synchronously from the preReplay snapshot. replayExitSettleTicks = replayExitSettleTicks - 1 gapAccumulator = 0 lastKnownSessionTime = nowSessionTime lastKnownReplayFrames = nowReplayFrames elseif startSessionTime >= 0 and lastKnownSessionTime >= 0 then local frameDelta = nowReplayFrames - lastKnownReplayFrames gapAccumulator = gapAccumulator + sessionDelta - frameDelta * replayFrameMs -- Compensate when the balance crosses one writer frame in either direction. In normal -- running the balance stays within ±replayFrameMs (writer ticks half as often as -- script.update on a 60fps+ rig), so this rarely fires outside real drift events. if gapAccumulator > replayFrameMs then local bump = gapAccumulator startSessionTime = startSessionTime + bump cfg.savedStartTime = startSessionTime -- If alt-tab happened mid-playback, the end deadline (set in session-time at play -- start) needs the same shift. CSP drives ghost replay off writer frames, so the -- ghost itself froze during the gap and hasn't consumed its full trajectory yet; -- without this bump the session-time deadline fires early and truncates the ghost -- before it reaches the end of the recording. if playing then ghostPlaybackEndSessionMs = ghostPlaybackEndSessionMs + bump end gapAccumulator = 0 elseif gapAccumulator < -replayFrameMs * 4 then -- Safety valve: if the balance ever swings deeply negative (writer somehow pulling -- ahead of session time), don't let it eat future positive drift. Reset rather than -- bump backwards — we have no evidence of buffer-ahead scenarios in practice. gapAccumulator = 0 end lastKnownSessionTime = nowSessionTime lastKnownReplayFrames = nowReplayFrames else -- First tick after init / startSessionTime cleared: just establish the baseline. lastKnownSessionTime = nowSessionTime lastKnownReplayFrames = nowReplayFrames end end -- AC treats the second car as TRACK_DAY traffic regardless of BASE_MODE: periodically -- teleports it onto the track near the player AND cycles its skin on each teleport. -- The skin cycling bleeds into the player's shared materials (interior flicker). Parking -- the ghost in the pits doesn't prevent the respawn — AC sees it as "too far" and brings -- it back. Sticking it 100m above the player every frame keeps AC satisfied that the -- car is "already near", so no respawn and no skin cycling. Only active when the ghost -- isn't being played back. if not (playing and ghostVisible) and not ghostKnocked and not sim.isReplayActive then local pc = ac.getCar(PLAYER) if pc then physics.setCarPosition(GHOST, pc.position + vec3(0, 50, 0), vec3(0, 0, 1)) physics.setCarVelocity(GHOST, vec3(0, 0, 0)) physics.setAINoInput(GHOST, true, true) physics.disableCarCollisions(GHOST, true) -- Re-assert disabled state every frame. AC periodically re-enables the AI slot as -- TRACK_DAY traffic — that's what causes the leftover teleports and (more importantly) -- the skin cycling that bleeds into the player's shared materials. Keeping the car -- flagged disabled tells AC it isn't in active use, so it stops cycling the livery. ac.disableCar(GHOST, true) end end if playing and ghostVisible and not sim.isReplayActive then -- End-of-playback check lives on the same session clock as the offset we passed to -- setReplayBasedGhost — mixing clocks here (e.g. gating on sim.replayFrames) would let -- the two disagree by a frame or two around the end and produce visible shimmer. if sim.currentSessionTime >= ghostPlaybackEndSessionMs then hideAndStopGhost() return end -- Per-frame damping of player's rebound from a light 'breakable' contact. -- Frame-rate independent exponential decay of RELATIVE outward velocity. Using relative -- velocity is critical: the ghost is moving, so absolute-velocity damping would drag the -- player along the ghost's trajectory. Only the outward-vs-ghost component is touched — -- tangential sliding is untouched, so scraping along the ghost isn't artificially slowed. if sim.currentSessionTime < softContactUntil and collisions[cfg.collisionsType][1] == 'breakable' then local player = ac.getCar(PLAYER) local ghost = ac.getCar(GHOST) local delta = player.position - ghost.position local dist = delta:length() if dist > 0.01 then local normal = delta / dist -- Damp relative normal velocity in BOTH directions. Sustained contact produces -- ODE penalty oscillations that flip sign each tick; damping only the outward -- half-cycle leaves the inward one untouched and the car vibrates. Symmetric -- damping kills the alternation while still allowing intentional push-through. local relNormalSpeed = (player.velocity - ghost.velocity):dot(normal) local damp = 1 - math.exp(-cfg.breakableDampRate * dt) physics.setCarVelocity(PLAYER, player.velocity - normal * (relNormalSpeed * damp)) end end end end