-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:02438c37
-- @license Proprietary. Not licensed for reuse, redistribution or derivative works without written permission from DoomTero. Ask - permission is obtainable. See ZomboRut/LICENSE.txt

require "TimedActions/ISZomboRutSceneAction"

local ZomboRutScene = {
	_scenes = {},
	_active = {},
	_postSceneGrace = {},
}

local DEBUG = true
local function log(fmt, ...)
	if DEBUG then DebugLog.log(string.format("[ZR Scene] " .. fmt, ...)) end
end

local DRAG_FOOTSTEP_TICKS = 38

local function _gameSpeedScale()
	local ok, m = pcall(function() return getGameTime():getTrueMultiplier() end)
	if not ok or type(m) ~= "number" or m ~= m or m <= 0 then return 1 end
	return m
end

local function safeId(o)
	if not o then return "nil" end
	local id = "?"
	pcall(function()
		if o.getID then id = tostring(o:getID()) end
	end)
	return id
end

local function _forceIdleState(target, label)
	if not target then return end
	pcall(function()
		if not (IdleState and IdleState.instance and target.changeState) then return end
		local cur = target.getCurrentStateName and target:getCurrentStateName() or ""
		if cur == "IdleState" then return end
		target:changeState(IdleState.instance())
		log("[forceIdleState] %s state '%s' -> IdleState", tostring(label or "?"), cur)
	end)
end

local function _restoreTeeth(z)
	pcall(function()
		if not (z and z.setNoTeeth) then return end
		local phase = ZR_Surrender and ZR_Surrender.getPhase
			and ZR_Surrender.getPhase(getPlayer())
		z:setNoTeeth(phase == "surrender")
	end)
end

local function despawnAndVerify(obj, kind)
	if not obj then
		log("[despawn] %s: obj=nil, skipping", kind)
		return false, false
	end
	local id = safeId(obj)
	local called = false
	pcall(function()
		if obj.removeFromSquare then obj:removeFromSquare() end
		if obj.removeFromWorld  then obj:removeFromWorld()  end
		called = true
	end)
	local stillInCell = false
	pcall(function()
		if obj.getCurrentSquare and obj:getCurrentSquare() then
			stillInCell = true
		end
	end)
	log("[despawn] %s id=%s called=%s still_in_cell=%s",
		kind, id, tostring(called), tostring(stillInCell))
	return called, stillInCell
end

local function _becomeCorpseSilently(z)
	if z.becomeCorpseSilently then return z:becomeCorpseSilently() end
	if not z.dieNetwork then return nil end
	local body = z:dieNetwork(nil, nil, false, nil)
	if z.setOnDeathDone then z:setOnDeathDone(true) end
	return body
end

local function auditPlayerTile(player, label)
	if not player then return end
	local cell = getCell()
	if not cell then return end
	local px, py, pz = player:getX(), player:getY(), player:getZ()
	local sq = cell.getGridSquare and cell:getGridSquare(px, py, pz)
	if not sq then
		log("[AUDIT %s] no square at player pos %.2f,%.2f", label, px, py)
		return
	end

	local moving = sq.getMovingObjects and sq:getMovingObjects()
	local liveCount, liveIds = 0, {}
	if moving and moving.size then
		for i = 0, moving:size() - 1 do
			local o = moving:get(i)
			if o and instanceof(o, "IsoZombie") then
				liveCount = liveCount + 1
				table.insert(liveIds, safeId(o))
			end
		end
	end

	local statics = sq.getStaticMovingObjects and sq:getStaticMovingObjects()
	local corpseCount, corpseIds = 0, {}
	if statics and statics.size then
		for i = 0, statics:size() - 1 do
			local o = statics:get(i)
			if o and instanceof(o, "IsoDeadBody") then
				corpseCount = corpseCount + 1
				table.insert(corpseIds, safeId(o))
			end
		end
	end

	log("[AUDIT %s] tile=(%.1f,%.1f) live_zombies=%d ids=[%s] corpses=%d ids=[%s]",
		label, px, py,
		liveCount, table.concat(liveIds, ","),
		corpseCount, table.concat(corpseIds, ","))
end

function ZomboRutScene.register(sceneId, cfg)
	assert(sceneId and type(sceneId) == "string", "scene id must be a string")
	assert(cfg, "scene config required")
	local own = {}
	for k, v in pairs(cfg) do own[k] = v end
	own.id = sceneId
	own.label = own.label or sceneId
	own.pickup = own.pickup or "vanilla"
	if own.pickup == "skip" and not own.promotedType then
		own.promotedType = "BwdDragHead"
	end
	if not own.kind then
		if own.isUtility then
			own.kind = "utility"
		elseif sceneId:find("Defeat", 1, true) then
			own.kind = "defeat"
		else
			own.kind = "consensual"
		end
		log("WARN scene %s registered without cfg.kind — derived %q from legacy signals (isUtility/id substring)",
			sceneId, own.kind)
	end
	own.isDefeat  = (own.kind == "defeat")
	own.isUtility = own.isUtility or (own.kind == "utility")
	ZomboRutScene._scenes[sceneId] = own
	log("registered scene: %s (pickup=%s)", sceneId, own.pickup)
	return true
end

local function _resolvePositionTag(position)
	if type(position) == "string" then
		return position .. "_" .. position .. "_" .. position
	end
	if type(position) == "table" then
		assert(position.intro and position.main and position.outro,
			"position table needs intro/main/outro keys")
		return position.intro .. "_" .. position.main .. "_" .. position.outro
	end
	error("position must be a string or {intro=,main=,outro=} table")
end

local function _mergeCfg(base, override)
	local out = {}
	for k, v in pairs(base or {})     do out[k] = v end
	for k, v in pairs(override or {}) do out[k] = v end
	return out
end

function ZomboRutScene.registerVariants(position, baseCfg, variants)
	assert(position, "position required")
	assert(type(variants) == "table", "variants table required")
	local positionTag = _resolvePositionTag(position)
	local registered = {}
	for combo, override in pairs(variants) do
		assert(type(combo) == "string" and #combo == 2,
			"variant key must be a 2-char gender combo like 'MF' / 'FM' / 'FF'")
		local sceneId = positionTag .. "_" .. combo
		local merged = _mergeCfg(baseCfg, override)
		ZomboRutScene.register(sceneId, merged)
		registered[#registered + 1] = sceneId
	end
	return registered
end

function ZomboRutScene.registerSymmetric(position, cfg)
	return ZomboRutScene.registerVariants(position, cfg, { MF = {}, FM = {} })
end

function ZomboRutScene.get(sceneId)
	return ZomboRutScene._scenes[sceneId]
end

function ZomboRutScene.getLogical(sceneId)
	local literal = ZomboRutScene._scenes[sceneId]
	if literal then return literal end
	local prefix = sceneId .. "_"
	for id, cfg in pairs(ZomboRutScene._scenes) do
		if id:sub(1, #prefix) == prefix then
			return cfg
		end
	end
	return nil
end

function ZomboRutScene.isPlayerInScene(player)
	return player ~= nil and ZomboRutScene._active[player] ~= nil
end

function ZomboRutScene.isPlayerInPostSceneGrace(player)
	return player ~= nil and ZomboRutScene._postSceneGrace[player] ~= nil
end

function ZomboRutScene._rollChain(player, sceneId, cfg)
	if not cfg or type(cfg.chains) ~= "table" then return nil end
	if not player or not sceneId then return nil end

	local chainLetters = {}
	for letter in pairs(cfg.chains) do
		chainLetters[#chainLetters + 1] = letter
	end
	if #chainLetters == 0 then return nil end
	table.sort(chainLetters)

	local picked = chainLetters[ZombRand(#chainLetters) + 1]

	local allStages = {}
	for _, stageList in pairs(cfg.chains) do
		for _, stageId in ipairs(stageList) do
			allStages[stageId] = true
		end
	end

	for stageId in pairs(allStages) do
		local letter
		for _, s in ipairs(cfg.chains[picked]) do
			if s == stageId then letter = picked; break end
		end
		if not letter then
			for _, candidate in ipairs(chainLetters) do
				for _, s in ipairs(cfg.chains[candidate]) do
					if s == stageId then letter = candidate; break end
				end
				if letter then break end
			end
		end
		if letter then
			local varName = "ZR_" .. sceneId .. stageId .. "Variant"
			local ok, err = pcall(function()
				player:setVariable(varName, letter)
			end)
			if ok then
				log("_rollChain: set %s = %s", varName, letter)
			else
				log("_rollChain: setVariable(%s, %s) FAILED: %s",
					varName, letter, tostring(err))
			end
		else
			log("_rollChain: stage %s not covered by any chain — skipped",
				tostring(stageId))
		end
	end

	return picked
end

function ZomboRutScene._clearChainVars(player, sceneId, cfg)
	if not cfg or type(cfg.chains) ~= "table" then return end
	if not player or not sceneId then return end

	local allStages = {}
	for _, stageList in pairs(cfg.chains) do
		for _, stageId in ipairs(stageList) do
			allStages[stageId] = true
		end
	end
	for stageId in pairs(allStages) do
		pcall(function()
			player:setVariable("ZR_" .. sceneId .. stageId .. "Variant", "")
		end)
	end
	pcall(function() player:setVariable("ZR_" .. sceneId .. "Outro", "") end)
end

ZomboRutScene._sceneStartCallbacks = ZomboRutScene._sceneStartCallbacks or {}

function ZomboRutScene.registerSceneStartCallback(fn)
	if type(fn) ~= "function" then return end
	table.insert(ZomboRutScene._sceneStartCallbacks, fn)
end

ZomboRutScene._sceneEndCallbacks = ZomboRutScene._sceneEndCallbacks or {}

function ZomboRutScene.registerSceneEndCallback(fn)
	if type(fn) ~= "function" then return end
	table.insert(ZomboRutScene._sceneEndCallbacks, fn)
end

ZomboRutScene._sceneUpdateCallbacks = ZomboRutScene._sceneUpdateCallbacks or {}

function ZomboRutScene.registerSceneUpdateCallback(fn)
	if type(fn) ~= "function" then return end
	table.insert(ZomboRutScene._sceneUpdateCallbacks, fn)
end

function ZomboRutScene.list()
	local ids = {}
	for id in pairs(ZomboRutScene._scenes) do ids[#ids+1] = id end
	table.sort(ids)
	return ids
end

function ZomboRutScene.listLogical()
	local seen = {}
	local out  = {}
	for id, cfg in pairs(ZomboRutScene._scenes) do
		if not (cfg and cfg.debug) then
			local base = id:match("^(.-)_[MF][MF]$")
				or id:match("^(.-)_[MF]$")
				or id
			if not seen[base] then
				seen[base] = true
				out[#out + 1] = base
			end
		end
	end
	table.sort(out)
	return out
end

function ZomboRutScene._sameGenderBlocked(player, partner)
	if not (SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.oppositeGenderOnly == true)
	then
		return false
	end
	if not (player and player.isFemale and partner and partner.isFemale) then
		return false
	end
	return player:isFemale() == partner:isFemale()
end

function ZomboRutScene._attractedToPlayer(player, other)
	if not (player and player.isFemale and other and other.isFemale) then
		return true
	end
	local pf, of = player:isFemale(), other:isFemale()
	if pf ~= of then return true end
	if ZomboRutScene._sameGenderBlocked(player, other) then return false end
	if pf then return true end
	return SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.allowMaleMale == true or false
end

function ZomboRutScene._resolveGender(player, partner, sceneId)
	if type(partner) == "string" and sceneId == nil then
		sceneId = partner
		partner = nil
	end
	if not sceneId then return nil end

	local pg = (player and player.isFemale and player:isFemale()) and "F" or "M"

	if not ZomboRutScene._sameGenderBlocked(player, partner) then
		local partnerKnown = (partner and partner.isFemale) and true or false
		if partnerKnown then
			local zg = partner:isFemale() and "F" or "M"
			local exactId = sceneId .. "_" .. pg .. zg
			if ZomboRutScene._scenes[exactId] then
				return exactId
			end
		end

		local assumedZG = (pg == "F") and "M" or "F"
		local assumedId = sceneId .. "_" .. pg .. assumedZG
		local assumedCfg = ZomboRutScene._scenes[assumedId]
		if assumedCfg and not (partnerKnown and assumedCfg.exactGenderOnly) then
			return assumedId
		end

		local legacyId = sceneId .. "_" .. pg
		if ZomboRutScene._scenes[legacyId] then
			return legacyId
		end
	end

	if ZomboRutScene._scenes[sceneId] then
		return sceneId
	end

	return nil
end

local function _stateNeedsForceIdle(stateName)
	return stateName:find("Lunge")
		or stateName:find("Attack")
		or stateName:find("Thump")
		or stateName:find("TurnAlerted")
end

local function _isBandit(z)
	if not (Bandit and BanditBrain and BanditBrain.Get) then return false end
	if not (z and z.getVariableBoolean and z:getVariableBoolean("Bandit")) then return false end
	local brain
	pcall(function() brain = BanditBrain.Get(z) end)
	return brain ~= nil
end

local function _pacifyBandit(z, banditStates)
	if not banditStates or banditStates[z] then return end
	local snap = { isBandit = true }
	pcall(function()
		local brain = BanditBrain.Get(z)
		if brain then
			snap.wasHostile    = brain.hostile
			snap.wasHostileP   = brain.hostileP
			snap.wasStationary = brain.stationary
		end
	end)
	banditStates[z] = snap
	pcall(function()
		if Bandit.SetHostile      then Bandit.SetHostile(z, false)     end
		if Bandit.SetHostileP     then Bandit.SetHostileP(z, false)    end
		if Bandit.ForceStationary then Bandit.ForceStationary(z, true) end
		if Bandit.ClearTasks      then Bandit.ClearTasks(z)            end
	end)
end

local function _restoreBandit(z, snap)
	pcall(function()
		local brain = BanditBrain and BanditBrain.Get and BanditBrain.Get(z)
		if brain then
			brain.hostile    = snap.wasHostile
			brain.hostileP   = snap.wasHostileP
			brain.stationary = snap.wasStationary
		end
	end)
end

local function _sceneStartPacifyRadius(player, radius, bystanderZombies, banditStates)
	if not player then return 0 end
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return 0 end
	local count = 0
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive() and z:DistTo(player) <= radius then
			local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
			if _stateNeedsForceIdle(cur) then
				pcall(_forceIdleState, z, "scene-start pacify (attack-state)")
			end
			pcall(function()
				z:clearAggroList()
				z:setTarget(nil)
				if z.setUseless then z:setUseless(true)  end
				if z.setNoTeeth then z:setNoTeeth(true)  end
			end)
			if _isBandit(z) then _pacifyBandit(z, banditStates) end
			if bystanderZombies then bystanderZombies[z] = true end
			count = count + 1
		end
	end
	return count
end

local function pushZombiesAwayFromPlayer(player, radius)
	if not player then return end
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end
	local px, py, pz = player:getX(), player:getY(), player:getZ()
	local pushed = 0
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive() and z:DistTo(player) < radius then
			local dx = z:getX() - px
			local dy = z:getY() - py
			local d  = math.sqrt(dx*dx + dy*dy)
			local tx, ty
			if d < 0.1 then
				tx = px + radius * 0.7
				ty = py + radius * 0.7
			else
				local k = radius / d
				tx = px + dx * k
				ty = py + dy * k
			end
			pcall(function()
				if z.teleportTo then z:teleportTo(tx, ty, pz) end
				z:setX(tx); z:setY(ty); z:setZ(pz)
			end)
			pushed = pushed + 1
		end
	end
	if pushed > 0 then
		log("scene-start push: cleared %d zombie(s) within radius %.1f",
			pushed, radius)
	end
end

function ZomboRutScene.playSceneFromCorpse(player, body, sceneId, opts)
	if not player or not body then
		log("playSceneFromCorpse abort: player=%s body=%s",
			tostring(player), tostring(body))
		return false
	end
	local resolvedId = ZomboRutScene._resolveGender(player, body, sceneId)
	if not resolvedId then
		log("playSceneFromCorpse abort: unknown scene %s (no _<PG><ZG>/_<PG> variant either)",
			tostring(sceneId))
		return false
	end
	if resolvedId ~= sceneId then
		log("playSceneFromCorpse: gender resolved %s -> %s", sceneId, resolvedId)
	end
	sceneId = resolvedId
	local cfg = ZomboRutScene._scenes[sceneId]
	if not cfg then
		log("playSceneFromCorpse abort: unknown scene %s", tostring(sceneId))
		return false
	end
	if player:isDraggingCorpse() then
		log("playSceneFromCorpse abort: player already dragging")
		return false
	end
	if ZomboRutScene._active[player] then
		log("playSceneFromCorpse abort: scene already active for player")
		return false
	end

	player:setVariable("ZR_SceneId", sceneId)
	player:setVariable("ZR_IntroDone", "false")
	player:setVariable("ZR_SceneActive", "true")

	local grace = ZomboRutScene._postSceneGrace[player]
	local wasInvincible
	local bystanderZombies
	local banditStates
	if grace then
		wasInvincible    = grace.wasInvincible
		bystanderZombies = grace.bystanderZombies or {}
		banditStates     = grace.banditStates or {}
		ZomboRutScene._postSceneGrace[player] = nil
		log("scene start: inherited wasInvincible=%s + %d bystander(s) from grace",
			tostring(wasInvincible),
			(function() local n=0 for _ in pairs(bystanderZombies) do n=n+1 end return n end)())
	else
		wasInvincible    = false
		bystanderZombies = {}
		banditStates     = {}
		pcall(function()
			if player.isInvincible then wasInvincible = player:isInvincible() end
		end)
	end

	local mainMult = 1
	if SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.mainDurationMultiplier
	then
		mainMult = SandboxVars.ZomboRut.mainDurationMultiplier
	end
	local INTRO_SECONDS = 5
	local mainBase = math.max(1,
		(cfg.duration or 24) - (cfg.outroDuration or 5) - INTRO_SECONDS)
	local effectiveDuration = (cfg.duration or 24)
		+ (mainMult - 1) * mainBase

	ZomboRutScene._active[player] = {
		sceneId            = sceneId,
		cfg                = cfg,
		body               = body,
		ctx                = {},
		draggingObserved   = false,
		startTimeoutMsLeft = 2000,
		startTimeoutLastMs = nil,
		wasInvincible      = wasInvincible,
		effectiveDuration  = effectiveDuration,
		bystanderZombies   = bystanderZombies,
		banditStates       = banditStates,
		launchSource       = (opts and opts.launchSource) or "fromCorpse",
	}
	if not cfg.noSceneProtection then
		pcall(function() if player.setInvincible then player:setInvincible(true) end end)
	end

	auditPlayerTile(player, "scene-start[" .. sceneId .. "]")

	if cfg.noSceneProtection then
		log("scene-start pacify SKIPPED: cfg.noSceneProtection set on %s — zombies will engage normally", sceneId)
	else
		local pacifyCount = _sceneStartPacifyRadius(player, 6, bystanderZombies, banditStates)
		if pacifyCount > 0 then
			log("scene-start pacify: %d zombie(s) within radius 6 (force-idle on attack states only)",
				pacifyCount)
		end
	end

	local rolledChain = ZomboRutScene._rollChain(player, sceneId, cfg)
	if rolledChain then
		ZomboRutScene._active[player].chain = rolledChain
		local stageList = cfg.chains and cfg.chains[rolledChain]
		if type(stageList) == "table" then
			for i = 1, #stageList do
				if stageList[i] == "Outro" then
					ZomboRutScene._active[player].chainHasOutro = true
					break
				end
			end
		end
		log("scene %s rolled chain=%s (chainHasOutro=%s)", sceneId, rolledChain,
			tostring(ZomboRutScene._active[player].chainHasOutro))
	end

	pcall(function()
		player:setVariable("ZR_" .. sceneId .. "Outro", "false")
		player:setVariable("ZR_OutroClipEnded", "false")
	end)

	if cfg.onBegin then
		local ok, err = pcall(cfg.onBegin, player, body,
			ZomboRutScene._active[player].ctx)
		if not ok then
			log("scene %s onBegin error: %s", sceneId, tostring(err))
		end
	end

	for _, cb in ipairs(ZomboRutScene._sceneStartCallbacks or {}) do
		local ok, err = pcall(cb, player, ZomboRutScene._active[player])
		if not ok then
			log("scene-start callback error: %s", tostring(err))
		end
	end

	local ok, err = pcall(function()
		ISTimedActionQueue.add(ISZomboRutSceneAction:new(player, body, cfg))
	end)
	if not ok then
		log("ISTimedActionQueue.add FAILED: %s", tostring(err))
		ZomboRutScene._active[player] = nil
		player:setVariable("ZR_SceneId", "")
		return false
	end

	log("scene %s queued: body=%s pickup=%s duration=%s",
		sceneId, tostring(body), cfg.pickup,
		tostring(cfg.duration))
	return true
end

local OFF_SCREEN_OFFSET = 50

local FOLLOW_HIDE_OFFSET = 18

local function snapshotLiveTarget(target)
	return {
		x   = target:getX(),
		y   = target:getY(),
		z   = target:getZ(),
		dir = target:getDir(),
		subdued = (target.getModData and target:getModData().zrSubdued) == true,
	}
end

local function applyBanditVisuals(zombie, brain)
	if not zombie or not brain or not brain.cid then return false end
	if not (Bandit and BanditUtils) then return false end

	local fv = zombie:getHumanVisual()
	if not fv then return false end

	if fv.getBodyVisuals and fv.removeBodyVisualFromItemType then
		local bodyVisuals = fv:getBodyVisuals()
		if bodyVisuals and bodyVisuals.size then
			local toRemove = {}
			for i = 0, bodyVisuals:size() - 1 do
				local item = bodyVisuals:get(i)
				if item and item.getItemType then
					local itemType = item:getItemType()
					if BanditUtils and BanditUtils.ItemVisuals
						and BanditUtils.ItemVisuals[itemType]
					then
						toRemove[#toRemove + 1] = itemType
					elseif not BanditUtils or not BanditUtils.ItemVisuals then
						toRemove[#toRemove + 1] = itemType
					end
				end
			end
			for i = 1, #toRemove do
				pcall(function() fv:removeBodyVisualFromItemType(toRemove[i]) end)
			end
		end
	end

	if Bandit and Bandit.GetSkinTexture and brain.skin then
		fv:setSkinTextureName(Bandit.GetSkinTexture(brain.female, brain.skin))
	end
	if Bandit and Bandit.GetHairStyle and brain.hairType then
		fv:setHairModel(Bandit.GetHairStyle(brain.female, brain.hairType))
	end
	if not zombie:isFemale() and Bandit and Bandit.GetBeardStyle and brain.beardType then
		local b = Bandit.GetBeardStyle(brain.female, brain.beardType)
		if b then fv:setBeardModel(b) end
	end
	if Bandit and Bandit.GetHairColor and brain.hairColor then
		local c = Bandit.GetHairColor(brain.hairColor)
		if c then
			local imm = ImmutableColor.new(c.r, c.g, c.b)
			fv:setHairColor(imm)
			fv:setBeardColor(imm)
		end
	end

	local itemVisuals = zombie:getItemVisuals()
	if itemVisuals then
		itemVisuals:clear()

		for bodyLoc, itemType in pairs(brain.clothing or {}) do
			local iv = ItemVisual.new()
			iv:setItemType(itemType)
			iv:setClothingItemName(itemType)
			if brain.tint and brain.tint[bodyLoc] then
				local color = BanditUtils.dec2rgb(brain.tint[bodyLoc])
				local imm = ImmutableColor.new(color.r, color.g, color.b, 1)
				iv:setTint(imm)
			end
			itemVisuals:add(iv)
		end

		if brain.bag and brain.bag.name then
			local iv = ItemVisual.new()
			iv:setItemType(brain.bag.name)
			iv:setClothingItemName(brain.bag.name)
			local imm = ImmutableColor.new(0.1, 0.1, 0.1, 1)
			iv:setTint(imm)
			itemVisuals:add(iv)
		end
	end

	if zombie.resetModel          then zombie:resetModel()          end
	if zombie.resetModelNextFrame then zombie:resetModelNextFrame() end
	return true
end

local function deepCloneVisualBrain(brain)
	if not brain then return nil end
	local clone = {}
	for k, v in pairs(brain) do clone[k] = v end
	clone.id = nil
	if type(brain.clothing) == "table" then
		clone.clothing = {}
		for k, v in pairs(brain.clothing) do clone.clothing[k] = v end
	end
	if type(brain.tint) == "table" then
		clone.tint = {}
		for k, v in pairs(brain.tint) do clone.tint[k] = v end
	end
	if type(brain.bag) == "table" then
		clone.bag = {}
		for k, v in pairs(brain.bag) do clone.bag[k] = v end
	end
	return clone
end

local LOWER_BODY_LOC_OBJECTS = {
	ItemBodyLocation.PANTS,
	ItemBodyLocation.PANTS_SKINNY,
	ItemBodyLocation.PANTS_EXTRA,
	ItemBodyLocation.SHORT_PANTS,
	ItemBodyLocation.SHORTS_SHORT,
	ItemBodyLocation.SKIRT,
	ItemBodyLocation.LONG_SKIRT,
	ItemBodyLocation.DRESS,
	ItemBodyLocation.LONG_DRESS,
	ItemBodyLocation.UNDERWEAR_BOTTOM,
	ItemBodyLocation.UNDERWEAR,
}
local LOWER_BODY_LOC_NAMES = {
	"Pants", "Pants_Skinny", "PantsExtra",
	"ShortPants", "ShortsShort",
	"Skirt", "LongSkirt",
	"Dress", "LongDress",
	"UnderwearBottom", "Underwear",
}

local ZCM_PANTS, ZCM_NAKED, ZCM_CLOTHED = 1, 2, 3
local function _zombieClothingMode()
	local v = SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.zombieClothingMode
	return v or ZCM_PANTS
end

local BODY_MOD_NAMESPACES = { "rasbomo", "toc", "zomborut" }
local function _isBodyModSlot(loc)
	if loc == nil then return false end
	local s = string.lower(tostring(loc))
	for _, ns in ipairs(BODY_MOD_NAMESPACES) do
		if s:find(ns .. ":", 1, true) then return true end
	end
	return false
end

local function _isInvisibleItem(item)
	if item == nil then return false end
	local invisible = false
	pcall(function()
		if not item.getClothingItem then return end
		local ci = item:getClothingItem()
		if ci and ci.hasModel then invisible = not ci:hasModel() end
	end)
	return invisible
end

local function _locKey(loc)
	if loc == nil then return nil end
	local s = string.lower(tostring(loc))
	return string.match(s, "([^:]+)$") or s
end

local VANILLA_GARMENT_LOC_KEYS = {
	["shirt"] = true, ["tshirt"] = true,
	["shortsleeveshirt"] = true, ["sweater"] = true, ["sweaterhat"] = true,
	["jacket"] = true, ["jacket_bulky"] = true, ["jacket_down"] = true,
	["jackethat"] = true, ["jackethat_bulky"] = true, ["jacketsuit"] = true,
	["jersey"] = true, ["torso1"] = true, ["torso1legs1"] = true,
	["torsoextra"] = true, ["torsoextravest"] = true,
	["torsoextravestbullet"] = true, ["fulltop"] = true,
	["bathrobe"] = true, ["fullrobe"] = true, ["boilersuit"] = true,
	["fullsuit"] = true, ["bodycostume"] = true,
	["pants"] = true, ["pants_skinny"] = true, ["pantsextra"] = true,
	["shortpants"] = true, ["shortsshort"] = true,
	["skirt"] = true, ["longskirt"] = true,
	["dress"] = true, ["longdress"] = true,
	["legs1"] = true, ["legs5"] = true,
	["underwear"] = true, ["underwearbottom"] = true, ["underweartop"] = true,
}
local function _isVanillaGarmentLoc(loc)
	local k = _locKey(loc)
	return k ~= nil and VANILLA_GARMENT_LOC_KEYS[k] == true
end

local function _isHiddenOverlay(item, loc)
	if _isVanillaGarmentLoc(loc) then return false end
	return _isInvisibleItem(item)
end

local KEEPON_TYPE_PATTERNS = { "stocking", "sock" }
local function _isKeepOnUnderlayer(loc, item)
	if loc == ItemBodyLocation.UNDERWEAR_EXTRA1
		or loc == ItemBodyLocation.UNDERWEAR_EXTRA2
		or loc == ItemBodyLocation.SOCKS then
		return true
	end
	local t
	pcall(function()
		if item and item.getType then t = string.lower(tostring(item:getType())) end
	end)
	if t then
		for _, pat in ipairs(KEEPON_TYPE_PATTERNS) do
			if t:find(pat, 1, true) then return true end
		end
	end
	return false
end

function ZomboRutScene._collectLowerBodyItems(character)
	local out = {}
	if not character or not character.getWornItems then return out end
	pcall(function()
		local worn = character:getWornItems()
		if not worn or not worn.getItem then return end
		for _, loc in ipairs(LOWER_BODY_LOC_OBJECTS) do
			local item = worn:getItem(loc)
			if item then
				out[#out + 1] = { loc = loc, item = item }
			end
		end
	end)
	return out
end

function ZomboRutScene._stripLowerBody(character, outStripped, mode)
	if not character then return 0 end
	mode = mode or ZCM_PANTS
	if mode == ZCM_CLOTHED then return 0 end
	local removed = 0
	pcall(function()
		if not character.getWornItems then return end
		local worn = character:getWornItems()
		if not worn or not worn.getItem then return end
		if mode == ZCM_NAKED then
			for i = worn:size() - 1, 0, -1 do
				local wi   = worn:get(i)
				local item = wi and wi:getItem()
				if item and not _isBodyModSlot(wi:getLocation()) and not _isHiddenOverlay(item, wi:getLocation()) then
					if outStripped then
						outStripped[#outStripped + 1] = { loc = wi:getLocation(), item = item }
					end
					if character.removeWornItem then
						character:removeWornItem(item)
						removed = removed + 1
					elseif worn.remove then
						worn:remove(item)
						removed = removed + 1
					end
				end
			end
		else
			for _, loc in ipairs(LOWER_BODY_LOC_OBJECTS) do
				local item = worn:getItem(loc)
				if item then
					if outStripped then
						outStripped[#outStripped + 1] = { loc = loc, item = item }
					end
					if character.removeWornItem then
						character:removeWornItem(item)
						removed = removed + 1
					elseif worn.remove then
						worn:remove(item)
						removed = removed + 1
					end
				end
			end
		end
		if removed > 0 then
			if character.resetModel          then character:resetModel()          end
			if character.resetModelNextFrame then character:resetModelNextFrame() end
		end
	end)
	return removed
end

local FACE_SLOT_PATTERNS = {
	"face",
	"mask",
	"makeup",
	"make_up",
	"eye",
	"nose",
	"lip",
}

local function _isFaceSlot(loc)
	if loc == nil then return false end
	local s = string.lower(tostring(loc))
	for _, pat in ipairs(FACE_SLOT_PATTERNS) do
		if s:find(pat, 1, true) then return true end
	end
	return false
end

local function _isShoeSlot(loc)
	if loc == nil then return false end
	if SandboxVars and SandboxVars.ZomboRut
	   and SandboxVars.ZomboRut.preserveShoes == false then
		return false
	end
	local s = string.lower(tostring(loc))
	return s:find("shoe", 1, true) ~= nil
end

local function _isGloveSlot(loc)
	return loc == ItemBodyLocation.HANDS
		or loc == ItemBodyLocation.HANDS_LEFT
		or loc == ItemBodyLocation.HANDS_RIGHT
end

local ACCESSORY_SLOT_PATTERNS = {
	"neck",
	"ears",
	"eartop",
	"tail",
	"wrist",
	"finger",
	"scarf",
}

local function _isAccessorySlot(loc)
	if loc == nil then return false end
	local s = string.lower(tostring(loc))
	for _, pat in ipairs(ACCESSORY_SLOT_PATTERNS) do
		if s:find(pat, 1, true) then return true end
	end
	return false
end

local function _hasTraitSafe(player, name)
	return ZR_Trait ~= nil and ZR_Trait.has ~= nil and ZR_Trait.has(player, name)
end

local function _dropOnPlayerTile(player, item)
	pcall(function()
		local inv = player:getInventory()
		if inv and inv.Remove then inv:Remove(item) end
	end)
	pcall(function()
		local sq = player:getSquare()
		if sq and sq.AddWorldInventoryItem then
			sq:AddWorldInventoryItem(item, ZombRand(100) / 100, ZombRand(100) / 100, 0)
		end
	end)
end

local function _dropZombieItemOnPlayerTile(zombie, player, item)
	if not item then return end
	pcall(function()
		local inv = zombie and zombie.getInventory and zombie:getInventory()
		if inv and inv.Remove then inv:Remove(item) end
	end)
	pcall(function()
		local sq = player and player.getSquare and player:getSquare()
		if sq and sq.AddWorldInventoryItem then
			sq:AddWorldInventoryItem(item, ZombRand(100) / 100, ZombRand(100) / 100, 0)
		end
	end)
end

local function _dropOneHandItem(player, item, dropToGround)
	if not item then return end
	pcall(function()
		if player.removeAttachedItem then player:removeAttachedItem(item) end
	end)
	pcall(function()
		local worn = player.getWornItems and player:getWornItems()
		if worn and worn.contains and worn:contains(item) then
			player:removeWornItem(item)
		end
	end)
	pcall(function()
		if item == player:getPrimaryHandItem()   then player:setPrimaryHandItem(nil)   end
		if item == player:getSecondaryHandItem() then player:setSecondaryHandItem(nil) end
	end)
	if dropToGround then
		_dropOnPlayerTile(player, item)
	end
end

function ZomboRutScene._dropHandItems(player)
	if not player then return end
	local primary, secondary
	pcall(function()
		if player.getPrimaryHandItem   then primary   = player:getPrimaryHandItem()   end
		if player.getSecondaryHandItem then secondary = player:getSecondaryHandItem() end
	end)
	if not primary and not secondary then return end
	local mode = (SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.weaponDropMode) or 2
	local dropToGround = (mode == 2)
	_dropOneHandItem(player, primary, dropToGround)
	if secondary and secondary ~= primary then
		_dropOneHandItem(player, secondary, dropToGround)
	end
	pcall(function()
		if sendEquip then sendEquip(player) end
	end)
	pcall(function()
		if player.resetModel          then player:resetModel()          end
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)
	local pName, sName = "nil", "nil"
	pcall(function()
		if primary   and primary.getName   then pName = tostring(primary:getName())   end
		if secondary and secondary.getName then sName = tostring(secondary:getName()) end
	end)
	log("[ZR hands] dropped primary=%s secondary=%s", pName, sName)
end

local function _isBackpackLoc(loc)
	return _locKey(loc) == "back"
end

function ZomboRutScene._dropWornBackpack(player)
	if not player or not player.getWornItems then return end

	local bags = {}
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.size then return end
		for i = 0, worn:size() - 1 do
			local entry = worn:get(i)
			local item = entry and entry.getItem and entry:getItem()
			local loc  = entry and entry.getLocation and entry:getLocation()
			if item and item.IsInventoryContainer and item:IsInventoryContainer()
			   and _isBackpackLoc(loc) then
				bags[#bags + 1] = item
			end
		end
	end)
	if #bags == 0 then return end

	local dropped = {}
	for _, bag in ipairs(bags) do
		local unequipped = pcall(function() player:removeWornItem(bag, false) end)
		if not unequipped then
			pcall(function() player:removeWornItem(bag) end)
		end

		local held = true
		pcall(function()
			local inv = player:getInventory()
			if inv and inv.contains then held = inv:contains(bag) end
		end)
		if held then _dropOnPlayerTile(player, bag) end
		pcall(function()
			if bag.getName then dropped[#dropped + 1] = tostring(bag:getName()) end
		end)
	end

	pcall(function()
		if player.resetModel          then player:resetModel()          end
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)
	log("[ZR bag] dropped %d worn bag(s): %s", #bags, table.concat(dropped, ", "))
end

local function _resolveDisposal(player, baseMode)
	if _hasTraitSafe(player, "betterAlive") then return "unequip" end
	if baseMode == 2 then return "drop" end
	return "unequip"
end

local function _disposeStripped(player, item, disposal)
	if _hasTraitSafe(player, "viciousZombies")
	   and not _hasTraitSafe(player, "betterAlive")
	then
		pcall(function()
			if item.setCondition and item.getCondition then
				local newCond = math.max(0, math.floor(item:getCondition() / 2))
				item:setCondition(newCond)
			end
		end)
	end
	if disposal == "drop" then
		_dropOnPlayerTile(player, item)
	end
end

local function _isUnderwearLoc(loc)
	return loc == ItemBodyLocation.UNDERWEAR
		or loc == ItemBodyLocation.UNDERWEAR_BOTTOM
		or loc == ItemBodyLocation.UNDERWEAR_EXTRA1
		or loc == ItemBodyLocation.UNDERWEAR_EXTRA2
end

local STRIP_ONE_SKIP_TYPE_PATTERNS = { "stocking", "sock", "underwear" }
local function _isStripOneProtected(item, loc)
	if _isBodyModSlot(loc) then return true end
	if _isHiddenOverlay(item, loc) then return true end
	if _isUnderwearLoc(loc) then return true end
	local t
	pcall(function()
		if item and item.getType then t = string.lower(tostring(item:getType())) end
	end)
	if t then
		for _, pat in ipairs(STRIP_ONE_SKIP_TYPE_PATTERNS) do
			if t:find(pat, 1, true) then return true end
		end
	end
	return false
end

local _degradeClothingLikeVanilla

function ZomboRutScene._stripOneDefeatItem(player, baseMode)
	if not player or not player.getWornItems then return nil end

	local toStrip = {}
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.getItem then return end
		for _, loc in ipairs(LOWER_BODY_LOC_OBJECTS) do
			local item = worn:getItem(loc)
			if item and not _isBodyModSlot(loc) and not _isKeepOnUnderlayer(loc, item) then
				toStrip[#toStrip + 1] = item
			end
		end
	end)

	if #toStrip == 0 then return nil end

	local disposal = _resolveDisposal(player, baseMode)
	for _, item in ipairs(toStrip) do
		_degradeClothingLikeVanilla(player, item)
		pcall(function() player:removeWornItem(item) end)
		_disposeStripped(player, item, disposal)
	end
	pcall(function()
		if player.resetModel          then player:resetModel()          end
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)
	return toStrip[1]
end

function _degradeClothingLikeVanilla(player, item)
	if _hasTraitSafe(player, "betterAlive") then return end
	pcall(function()
		if not item or not item.getCoveredParts then return end
		local parts = item:getCoveredParts()
		if not parts or not parts.size or parts:size() == 0 then return end
		local part = parts:get(ZombRand(parts:size()))
		if not part then return end
		if player.addBlood then player:addBlood(part, true, false, false) end
		if player.addHoleFromZombieAttacks then player:addHoleFromZombieAttacks(part, true) end
		if ZomboRutScene._canHaveHoles(item) then
			pcall(function()
				local vis = item.getVisual and item:getVisual()
				if vis and vis.setHole then vis:setHole(part) end
			end)
		end
	end)
end

function ZomboRutScene._maxDamageAllClothing(player)
	if not player or not player.getWornItems then return end
	if _hasTraitSafe(player, "betterAlive") then return end
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.size then return end
		local slots = {}
		for i = 0, worn:size() - 1 do
			slots[#slots + 1] = worn:get(i)
		end
		for _, wi in ipairs(slots) do
			local item = wi and wi:getItem()
			local isClothing = false
			pcall(function() isClothing = item and item.IsClothing and item:IsClothing() end)
			if isClothing and ZomboRutScene._canHaveHoles(item) then
				pcall(function()
					local parts = item.getCoveredParts and item:getCoveredParts()
					local vis   = item.getVisual and item:getVisual()
					if parts and parts.size then
						for j = 0, parts:size() - 1 do
							local part = parts:get(j)
							if part then
								if player.addBlood then player:addBlood(part, true, false, false) end
								if player.addHoleFromZombieAttacks then player:addHoleFromZombieAttacks(part, true) end
								if vis and vis.setHole then vis:setHole(part) end
							end
						end
					end
					if item.getConditionMax and item.setCondition then
						local maxc = item:getConditionMax() or 0
						item:setCondition(math.max(1, math.floor(maxc * 0.05)))
					end
				end)
			end
		end
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)
end

local function _isGrabbableClothing(item, loc)
	if not item or not loc then return false end
	if _isStripOneProtected(item, loc) then return false end
	if _isShoeSlot(loc) then return false end
	if _isGloveSlot(loc) then return false end
	if _isFaceSlot(loc) then return false end
	if _isAccessorySlot(loc) then return false end
	local isClothing = false
	pcall(function() isClothing = item.IsClothing and item:IsClothing() end)
	return isClothing
end

function ZomboRutScene._isArmor(item)
	local armored = false
	pcall(function()
		if item and item.getBulletDefense then armored = (item:getBulletDefense() or 0) > 0 end
	end)
	return armored
end

function ZomboRutScene._canHaveHoles(item)
	if ZomboRutScene._isArmor(item) then return false end
	local ok = true
	pcall(function() if item and item.getCanHaveHoles then ok = item:getCanHaveHoles() ~= false end end)
	return ok
end

local function _ripWornItem(player, item, baseMode)
	local disposal = _resolveDisposal(player, baseMode)
	_degradeClothingLikeVanilla(player, item)
	pcall(function() player:removeWornItem(item) end)
	_disposeStripped(player, item, disposal)
	pcall(function()
		if player.resetModel          then player:resetModel()          end
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)
end

local function _itemCoversPart(item, partIdx)
	local covers = false
	pcall(function()
		if not item.getCoveredParts then return end
		local parts = item:getCoveredParts()
		if not parts or not parts.size then return end
		for j = 0, parts:size() - 1 do
			local bp = parts:get(j)
			if bp and BloodBodyPartType.ToIndex(bp) == partIdx then covers = true; return end
		end
	end)
	return covers
end

function ZomboRutScene._findItemCoveringPart(player, partIdx)
	if not player or not player.getWornItems or partIdx == nil then return nil end
	local found
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.size then return end
		for i = 0, worn:size() - 1 do
			local wi = worn:get(i)
			local item, loc = wi and wi:getItem(), wi and wi:getLocation()
			if _isGrabbableClothing(item, loc) and _itemCoversPart(item, partIdx) then
				found = item
				return
			end
		end
	end)
	return found
end

function ZomboRutScene._clothingClass(item)
	local cls = "other"
	pcall(function()
		if not item or not item.getBodyLocation then return end
		local loc = item:getBodyLocation()
		if loc == nil then return end
		if loc == ItemBodyLocation.PANTS or loc == ItemBodyLocation.PANTS_SKINNY
		   or loc == ItemBodyLocation.PANTS_EXTRA then
			cls = "pants"
		elseif loc == ItemBodyLocation.SHORT_PANTS or loc == ItemBodyLocation.SHORTS_SHORT then
			cls = "shorts"
		elseif loc == ItemBodyLocation.SKIRT or loc == ItemBodyLocation.LONG_SKIRT then
			cls = "skirt"
		elseif loc == ItemBodyLocation.DRESS or loc == ItemBodyLocation.LONG_DRESS then
			cls = "dress"
		end
	end)
	return cls
end

function ZomboRutScene._damageWornItem(player, item)
	_degradeClothingLikeVanilla(player, item)
	pcall(function()
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)
end

function ZomboRutScene._garmentDefense(item, partIdx)
	local def = 0
	pcall(function()
		if not item or not item.getDefForPart or partIdx == nil then return end
		local bpt = BloodBodyPartType.FromIndex(partIdx)
		if not bpt then return end
		def = item:getDefForPart(bpt, false, false) or 0
	end)
	return def
end

function ZomboRutScene._grabOneClothingItem(player, baseMode)
	if not player or not player.getWornItems then return nil end
	local grabbed
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.size then return end
		for i = 0, worn:size() - 1 do
			local wi = worn:get(i)
			if _isGrabbableClothing(wi and wi:getItem(), wi and wi:getLocation()) then
				grabbed = wi:getItem()
				return
			end
		end
	end)
	if not grabbed then return nil end
	_ripWornItem(player, grabbed, baseMode)
	return grabbed
end

function ZomboRutScene._grabItemCoveringPart(player, partIdx, baseMode)
	local grabbed = ZomboRutScene._findItemCoveringPart(player, partIdx)
	if not grabbed then return nil end
	_ripWornItem(player, grabbed, baseMode)
	return grabbed
end

function ZomboRutScene._dropPantsOnly(player)
	if not player or not player.getWornItems then return nil end
	local target
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.size then return end
		for i = 0, worn:size() - 1 do
			local wi   = worn:get(i)
			local item = wi and wi:getItem()
			local cls  = item and ZomboRutScene._clothingClass(item)
			if cls == "pants" or cls == "shorts" then
				target = item
				return
			end
		end
	end)
	if not target then return nil end
	_ripWornItem(player, target, 2)
	return target
end

function ZomboRutScene._isPartCovered(player, partIdx)
	return ZomboRutScene._findItemCoveringPart(player, partIdx) ~= nil
end

function ZomboRutScene._stripAllDefeatItems(player, baseMode)
	if not player or not player.getWornItems then return nil end

	local lowerNames = {}
	for _, name in ipairs(LOWER_BODY_LOC_NAMES) do lowerNames[name] = true end

	local disposal = _resolveDisposal(player, baseMode)
	local representative
	pcall(function()
		local worn = player:getWornItems()
		if not worn or not worn.size then return end
		for i = worn:size() - 1, 0, -1 do
			local wi   = worn:get(i)
			local item = wi and wi:getItem()
			local loc  = wi and wi:getLocation()
			if item and loc and not _isBodyModSlot(loc) and not _isHiddenOverlay(item, loc) and not _isShoeSlot(loc)
			   and not _isKeepOnUnderlayer(loc, item) then
				if not representative or lowerNames[loc] then
					representative = item
				end
				_degradeClothingLikeVanilla(player, item)
				pcall(function() player:removeWornItem(item) end)
				_disposeStripped(player, item, disposal)
			end
		end
	end)

	pcall(function()
		if player.resetModel          then player:resetModel()          end
		if player.resetModelNextFrame then player:resetModelNextFrame() end
	end)

	return representative
end

function ZomboRutScene._reequipItems(character, stripped)
	if not character or not stripped or #stripped == 0 then return 0 end
	local restored = 0
	pcall(function()
		if not character.setWornItem then return end
		for _, entry in ipairs(stripped) do
			if entry.loc and entry.item then
				pcall(function()
					character:setWornItem(entry.loc, entry.item)
					restored = restored + 1
				end)
			end
		end
		if restored > 0 then
			if character.resetModel          then character:resetModel()          end
			if character.resetModelNextFrame then character:resetModelNextFrame() end
		end
	end)
	return restored
end

function ZomboRutScene._dropLowerBodyVisualsAsItems(zombie, player, mode)
	if not zombie or not player then return 0 end
	mode = mode or ZCM_PANTS
	if mode == ZCM_CLOTHED then return 0 end
	local dropped = 0
	pcall(function()
		if not zombie.getItemVisuals then return end
		local iv = zombie:getItemVisuals()
		if not iv or not iv.size then return end
		local sq = player.getSquare and player:getSquare()
		if not sq or not sq.AddWorldInventoryItem then return end

		local targets
		if mode == ZCM_PANTS then
			targets = {}
			for _, loc in ipairs(LOWER_BODY_LOC_OBJECTS) do targets[loc] = true end
		end

		for i = 0, iv:size() - 1 do
			pcall(function()
				local visual = iv:get(i)
				if not visual then return end
				local script = visual.getScriptItem and visual:getScriptItem()
				local loc    = script and script.getBodyLocation and script:getBodyLocation()
				local include = (mode == ZCM_NAKED) or (targets and loc and targets[loc])
				if not include or _isBodyModSlot(loc) then return end

				if not visual.getItemType then return end
				local itemType = visual:getItemType()
				if type(itemType) ~= "string" or itemType == "" then return end

				local spawned = sq:AddWorldInventoryItem(itemType,
					ZombRand(100) / 100, ZombRand(100) / 100, 0)
				if not spawned then return end

				pcall(function()
					local nv = spawned.getVisual and spawned:getVisual()
					if nv and nv.copyFrom then nv:copyFrom(visual) end
					if spawned.synchWithVisual then spawned:synchWithVisual() end
				end)

				dropped = dropped + 1
			end)
		end
	end)
	return dropped
end

local ORAL_BLOCKING_LOC_KEYS = {
	["mask"] = true, ["maskeyes"] = true, ["maskfull"] = true,
	["scba"] = true, ["scbanotank"] = true,
	["fullsuithead"] = true, ["fullsuitheadscba"] = true,
	["fullhat"] = true,
}
local function _isOralBlockingLoc(loc)
	if _isBodyModSlot(loc) then return false end
	local k = _locKey(loc)
	return k ~= nil and ORAL_BLOCKING_LOC_KEYS[k] == true
end

function ZomboRutScene._removeOralBlockersWorn(character)
	if not character or not character.getWornItems then return 0 end
	local removed = 0
	pcall(function()
		local worn = character:getWornItems()
		if not worn or not worn.size then return end
		local doomed = {}
		for i = 0, worn:size() - 1 do
			local e = worn:get(i)
			local it = e and e.getItem and e:getItem()
			local loc = e and e.getLocation and e:getLocation()
			if it and _isOralBlockingLoc(loc) then doomed[#doomed + 1] = it end
		end
		for _, it in ipairs(doomed) do
			if character.removeWornItem then
				pcall(function() character:removeWornItem(it) end)
				removed = removed + 1
			end
		end
	end)
	return removed
end

function ZomboRutScene._removeOralBlockerVisuals(zombie)
	if not zombie or not zombie.getItemVisuals then return 0 end
	if not instanceof(zombie, "IsoZombie") then return 0 end
	local removed = 0
	pcall(function()
		local iv = zombie:getItemVisuals()
		if not iv or not iv.size then return end
		local idx = {}
		for i = 0, iv:size() - 1 do
			local visual = iv:get(i)
			local scriptItem = visual and visual.getScriptItem and visual:getScriptItem()
			if scriptItem and scriptItem.getBodyLocation
			   and _isOralBlockingLoc(scriptItem:getBodyLocation()) then
				idx[#idx + 1] = i
			end
		end
		for k = #idx, 1, -1 do
			pcall(function()
				iv:remove(idx[k])
				removed = removed + 1
			end)
		end
	end)
	return removed
end

function ZomboRutScene._clearOralBlockers(character)
	if not character then return 0 end
	local n = ZomboRutScene._removeOralBlockersWorn(character)
	         + ZomboRutScene._removeOralBlockerVisuals(character)
	if n > 0 then
		pcall(function()
			if character.resetModel          then character:resetModel()          end
			if character.resetModelNextFrame then character:resetModelNextFrame() end
		end)
	end
	return n
end

function ZomboRutScene._stripLowerBodyVisuals(zombie, mode)
	if not zombie or not zombie.getItemVisuals then return 0 end
	mode = mode or ZCM_PANTS
	if mode == ZCM_CLOTHED then return 0 end
	local removed = 0
	pcall(function()
		local iv = zombie:getItemVisuals()
		if not iv or not iv.size then return end
		if mode == ZCM_NAKED then
			removed = iv:size()
			if iv.clear then iv:clear() end
		else
			local targets = {}
			for _, loc in ipairs(LOWER_BODY_LOC_OBJECTS) do targets[loc] = true end

			local toRemoveIdx = {}
			for i = 0, iv:size() - 1 do
				local visual = iv:get(i)
				if visual then
					local scriptItem = visual.getScriptItem and visual:getScriptItem()
					if scriptItem and scriptItem.getBodyLocation then
						local loc = scriptItem:getBodyLocation()
						if loc and targets[loc] then
							toRemoveIdx[#toRemoveIdx + 1] = i
						end
					end
				end
			end
			for k = #toRemoveIdx, 1, -1 do
				pcall(function()
					iv:remove(toRemoveIdx[k])
					removed = removed + 1
				end)
			end
		end
		if removed > 0 then
			if zombie.resetModel          then zombie:resetModel()          end
			if zombie.resetModelNextFrame then zombie:resetModelNextFrame() end
		end
	end)
	return removed
end

function ZomboRutScene._snapshotItemVisuals(zombie)
	local out = {}
	if not zombie or not zombie.getItemVisuals then return out end
	pcall(function()
		local iv = zombie:getItemVisuals()
		if not iv or not iv.size then return end
		for i = 0, iv:size() - 1 do
			local src = iv:get(i)
			if src then
				local copy = ItemVisual.new()
				copy:copyFrom(src)
				out[#out + 1] = copy
			end
		end
	end)
	return out
end

function ZomboRutScene._stripSnapshotLowerBody(snapshot, mode)
	if not snapshot then return 0 end
	mode = mode or ZCM_PANTS
	if mode == ZCM_CLOTHED then return 0 end
	if mode == ZCM_NAKED then
		local removed = #snapshot
		for i = #snapshot, 1, -1 do snapshot[i] = nil end
		return removed
	end
	local removed = 0
	local targets = {}
	for _, loc in ipairs(LOWER_BODY_LOC_OBJECTS) do targets[loc] = true end
	for i = #snapshot, 1, -1 do
		local v = snapshot[i]
		local script = v.getScriptItem and v:getScriptItem()
		if script and script.getBodyLocation then
			local loc = script:getBodyLocation()
			if loc and targets[loc] then
				table.remove(snapshot, i)
				removed = removed + 1
			end
		end
	end
	return removed
end

function ZomboRutScene._applyItemVisuals(zombie, snapshot)
	if not zombie or not snapshot then return 0 end
	local applied = 0
	pcall(function()
		if not zombie.getItemVisuals then return end
		local iv = zombie:getItemVisuals()
		if not iv or not iv.clear then return end
		iv:clear()
		for _, src in ipairs(snapshot) do
			local copy = ItemVisual.new()
			copy:copyFrom(src)
			iv:add(copy)
			applied = applied + 1
		end
		if zombie.getPersistentOutfitID and zombie.setPersistentOutfitID then
			local id = zombie:getPersistentOutfitID() or 0
			zombie:setPersistentOutfitID(id, true)
		end
		if zombie.resetModel          then zombie:resetModel()          end
		if zombie.resetModelNextFrame then zombie:resetModelNextFrame() end
	end)
	return applied
end

function ZomboRutScene._stripBrainClothing(brain, mode)
	if not brain or type(brain.clothing) ~= "table" then return 0 end
	mode = mode or ZCM_PANTS
	if mode == ZCM_CLOTHED then return 0 end
	if mode == ZCM_NAKED then
		local removed = 0
		for _ in pairs(brain.clothing) do removed = removed + 1 end
		brain.clothing = {}
		if type(brain.tint) == "table" then brain.tint = {} end
		brain.bag = nil
		return removed
	end
	local removed = 0
	for _, name in ipairs(LOWER_BODY_LOC_NAMES) do
		if brain.clothing[name] ~= nil then
			brain.clothing[name] = nil
			if brain.tint then brain.tint[name] = nil end
			removed = removed + 1
		end
	end
	return removed
end

local function _cancelPathfind(char)
	pcall(function()
		local pfb = char and char.getPathFindBehavior2 and char:getPathFindBehavior2()
		if pfb and pfb.cancel then pfb:cancel() end
		if char and char.setPath2 then char:setPath2(nil) end
		if pfb and pfb.reset then pfb:reset() end
		if char.allowRepathDelay ~= nil then char.allowRepathDelay = 0 end
	end)
end

function ZomboRutScene.playSceneFromLive(player, target, sceneId)
	if not player or not target then
		log("[v3.2] playSceneFromLive abort: player=%s target=%s",
			tostring(player), tostring(target))
		return false, "invalid-args"
	end
	if not (Bandit and BanditCustom and BanditBrain) then
		log("[v3.2] playSceneFromLive abort: Bandits mod not loaded — live-target requires it")
		return false, "bandits-missing"
	end
	local resolvedId = ZomboRutScene._resolveGender(player, target, sceneId)
	if not resolvedId then
		log("[v3.2] playSceneFromLive abort: unknown scene %s (no _<PG><ZG>/_<PG> variant either)",
			tostring(sceneId))
		return false, "unknown-scene"
	end
	if resolvedId ~= sceneId then
		log("[v3.2] playSceneFromLive: gender resolved %s -> %s", sceneId, resolvedId)
	end
	sceneId = resolvedId
	if player:isDraggingCorpse() then
		log("[v3.2] playSceneFromLive abort: player already dragging")
		return false, "already-dragging"
	end
	if ZomboRutScene._active[player] then
		log("[v3.2] playSceneFromLive abort: scene already active for player")
		return false, "already-active"
	end

	local snap = snapshotLiveTarget(target)
	log("[v3.2] STEP A snapshot dummy id=%s pos=%.2f,%.2f,%.2f female=%s",
		tostring(target:getID()), snap.x, snap.y, snap.z,
		tostring(target.isFemale and target:isFemale()))

	local origBrain
	pcall(function() origBrain = target:getModData() and target:getModData().brain end)
	log("[v3.2] STEP A brain captured: %s (zrDummyBandit=%s)",
		tostring(origBrain ~= nil),
		tostring(origBrain and origBrain.zrDummyBandit))

	_forceIdleState(player, "playSceneFromLive:player")
	_forceIdleState(target, "playSceneFromLive:" .. safeId(target))

	local hideX = snap.x + OFF_SCREEN_OFFSET
	local hideY = snap.y + OFF_SCREEN_OFFSET
	_cancelPathfind(target)
	pcall(function() if target.teleportTo then target:teleportTo(hideX, hideY, snap.z) end end)
	pcall(function() target:setX(hideX); target:setY(hideY); target:setZ(snap.z) end)
	pcall(function() if target.setAlpha       then target:setAlpha(0.00392)       end end)
	pcall(function() if target.setTargetAlpha then target:setTargetAlpha(0.00392) end end)
	pcall(function() if target.setDoRender    then target:setDoRender(false)      end end)
	pcall(function()
		target:setUseless(true)
		target:setNoTeeth(true)
		target:setCanWalk(false)
		target:clearAggroList()
		target:setTarget(nil)
	end)
	pcall(function()
		if target:getVariableBoolean("Bandit") and Bandit then
			local pinBrain = BanditBrain and BanditBrain.Get and BanditBrain.Get(target)
			log("[v3.2] bandit park (live): brain=%s hostile=%s hostileP=%s stationary=%s",
				tostring(pinBrain ~= nil), tostring(pinBrain and pinBrain.hostile),
				tostring(pinBrain and pinBrain.hostileP), tostring(pinBrain and pinBrain.stationary))
			if pinBrain then
				snap.wasHostile    = pinBrain.hostile
				snap.wasHostileP   = pinBrain.hostileP
				snap.wasStationary = pinBrain.stationary
				snap.banditPacified = true
			end
			if Bandit.ForceStationary then Bandit.ForceStationary(target, true) end
			if Bandit.SetHostile      then Bandit.SetHostile(target, false)     end
			if Bandit.SetHostileP     then Bandit.SetHostileP(target, false)    end
			if Bandit.ClearTasks      then Bandit.ClearTasks(target)            end
		end
	end)
	pcall(function()
		local md = target.getModData and target:getModData()
		if md then md._zrHusk = true end
	end)
	pcall(function()
		if target.getDescriptor then
			local desc = target:getDescriptor()
			if desc and desc.setVoicePrefix then desc:setVoicePrefix("NotAZombie") end
		end
		if target.getEmitter then
			local em = target:getEmitter()
			if em and em.stopAll then em:stopAll() end
		end
		if target.setDoDeathSound then target:setDoDeathSound(false) end
	end)
	log("[v3.2] STEP B dummy parked off-screen %.2f,%.2f (hidden + pinned + silenced)", hideX, hideY)

	local function _unparkTarget()
		pcall(function() if target.setAlpha       then target:setAlpha(1)       end end)
		pcall(function() if target.setTargetAlpha then target:setTargetAlpha(1) end end)
		pcall(function() if target.setDoRender    then target:setDoRender(true) end end)
		pcall(function()
			local md = target.getModData and target:getModData()
			if md then md._zrHusk = nil end
		end)
		if snap.banditPacified then
			pcall(function()
				local b = BanditBrain and BanditBrain.Get and BanditBrain.Get(target)
				if b then
					b.hostile    = snap.wasHostile
					b.hostileP   = snap.wasHostileP
					b.stationary = snap.wasStationary
				end
			end)
		else
			pcall(function()
				if Bandit and Bandit.ForceStationary then
					Bandit.ForceStationary(target, false)
				end
			end)
		end
		pcall(function() target:setUseless(false); target:setCanWalk(true) end)
		_restoreTeeth(target)
	end

	local zcMode = _zombieClothingMode()
	local droppedZ = ZomboRutScene._dropLowerBodyVisualsAsItems(target, player, zcMode)
	local strippedZ = {}
	pcall(function() ZomboRutScene._stripLowerBody(target, strippedZ, zcMode) end)
	for _, e in ipairs(strippedZ) do _dropZombieItemOnPlayerTile(target, player, e.item) end
	log("[v3.2] STEP B.0 dummy clothing dropped (visualsToGround=%d worn=%d)",
		droppedZ, #strippedZ)
	if origBrain then
		local origRemoved = ZomboRutScene._stripBrainClothing(origBrain, zcMode)
		if origRemoved > 0 then
			pcall(function() applyBanditVisuals(target, origBrain) end)
			log("[v3.2] STEP B.1 dummy brain.clothing stripped (slots=%d) + visuals re-applied",
				origRemoved)
		end
	end

	local pdirX, pdirY = 1, 0
	pcall(function()
		local fwd = player.getForwardDirection and player:getForwardDirection()
		if fwd then
			local fx = fwd.getX and fwd:getX() or fwd.x or 0
			local fy = fwd.getY and fwd:getY() or fwd.y or 0
			if math.abs(fx) > 1e-3 or math.abs(fy) > 1e-3 then
				pdirX, pdirY = fx, fy
			end
		end
	end)
	local _liveCfg = ZomboRutScene._scenes[sceneId]
	local spawnOffset = (_liveCfg and _liveCfg.noMovementLock) and 0.6 or 0
	local spawnX = player:getX() + pdirX * spawnOffset
	local spawnY = player:getY() + pdirY * spawnOffset
	local spawnZ = player:getZ()
	local targetVisualsSnapshot = ZomboRutScene._snapshotItemVisuals(target)
	local outfit = "Naked" .. tostring(1 + ZombRand(101))
	local femaleChance = (target.isFemale and target:isFemale()) and 100 or 0
	local zombieList
	pcall(function()
		if addZombiesInOutfit then
			zombieList = addZombiesInOutfit(
				spawnX, spawnY, spawnZ, 1, outfit, femaleChance,
				false, false, false, false, false, false, 1)
		end
	end)
	local fresh
	if zombieList and zombieList:size() > 0 then
		fresh = zombieList:get(0)
	end
	if not fresh then
		log("[v3.2] STEP C addZombiesInOutfit FAILED (outfit=%s, femaleChance=%d) — restoring dummy + abort",
			outfit, femaleChance)
		_cancelPathfind(target)
		pcall(function() if target.teleportTo then target:teleportTo(snap.x, snap.y, snap.z) end end)
		_unparkTarget()
		return false
	end
	log("[v3.2] STEP C fresh spawned id=%s at player_tile %.2f,%.2f (dummy was at %.2f,%.2f) outfit=%s",
		tostring(fresh:getID()), spawnX, spawnY, snap.x, snap.y, outfit)

	pcall(function() if fresh.setAlpha       then fresh:setAlpha(0.00392)       end end)
	pcall(function() if fresh.setTargetAlpha then fresh:setTargetAlpha(0.00392) end end)

	local skinPre, skinPost
	pcall(function()
		if target.isFemale then
			if     fresh.setFemaleEtc then fresh:setFemaleEtc(target:isFemale())
			elseif fresh.setFemale    then fresh:setFemale(target:isFemale()) end
		end
		local fv = fresh.getHumanVisual and fresh:getHumanVisual()
		if fv and fv.getSkinTexture then skinPre = fv:getSkinTexture() end
		if fv and target.getHumanVisual then
			local tv = target:getHumanVisual()
			if tv and fv.copyFrom then fv:copyFrom(tv) end
		end
		if fv and fv.getSkinTexture then skinPost = fv:getSkinTexture() end
	end)
	log("[v3.2] STEP D skin pre-copy=%s post-copy=%s",
		tostring(skinPre), tostring(skinPost))

	local brainClone = deepCloneVisualBrain(origBrain)
	if brainClone then
		pcall(function()
			if applyBanditVisuals(fresh, brainClone) then
				log("[v3.2] STEP E visuals applied to fresh via helper (clothing+bag+hair)")
			else
				log("[v3.2] STEP E WARN applyBanditVisuals returned false")
			end
		end)
	else
		local applied = ZomboRutScene._applyItemVisuals(fresh, targetVisualsSnapshot)
		log("[v3.2] STEP E no brain — applied target ItemVisuals snapshot (visuals=%d)", applied)
	end

	pcall(function()
		if fresh.resetModel          then fresh:resetModel()          end
		if fresh.resetModelNextFrame then fresh:resetModelNextFrame() end
	end)

	pcall(function()
		local fdesc = fresh:getDescriptor()
		if fdesc and fdesc.setVoicePrefix then fdesc:setVoicePrefix("NotAZombie") end
		local em = fresh:getEmitter()
		if em then
			if em.stopAll then em:stopAll() end
			if em.stopSoundByName then
				em:stopSoundByName("FemaleZombieVoiceA")
				em:stopSoundByName("FemaleZombieVoiceB")
				em:stopSoundByName("FemaleZombieVoiceC")
				em:stopSoundByName("MaleZombieVoiceA")
				em:stopSoundByName("MaleZombieVoiceB")
				em:stopSoundByName("MaleZombieVoiceC")
				em:stopSoundByName("ZombieDeath")
			end
		end
		if fresh.setDoDeathSound then fresh:setDoDeathSound(false) end
	end)

	pcall(function() if fresh.setAlpha       then fresh:setAlpha(0.00392)       end end)
	pcall(function() if fresh.setTargetAlpha then fresh:setTargetAlpha(0.00392) end end)

	pcall(function()
		if fresh.Kill then
			fresh:Kill(nil, false)
		end
	end)

	local body
	local ok, err = pcall(function() body = _becomeCorpseSilently(fresh) end)
	if not ok or not body then
		log("[v3.2] STEP I becomeCorpseSilently FAILED: %s — cleanup + restore",
			tostring(err))
		pcall(function() if fresh.removeFromWorld then fresh:removeFromWorld() end end)
		_cancelPathfind(target)
		pcall(function() if target.teleportTo then target:teleportTo(snap.x, snap.y, snap.z) end end)
		_unparkTarget()
		return false
	end
	pcall(function()
		local md = body.getModData and body:getModData()
		if md then
			for k in pairs(md) do md[k] = nil end
			md._zrHusk = true
		end
	end)
	log("[v3.2] STEP I corpse spawned at %.2f,%.2f", snap.x, snap.y)

	pcall(function() if body.setAlpha       then body:setAlpha(0.00392)       end end)
	pcall(function() if body.setTargetAlpha then body:setTargetAlpha(0.00392) end end)
	pcall(function() if body.setDoRender    then body:setDoRender(false)      end end)
	pcall(function()
		if body.getEmitter then
			local em = body:getEmitter()
			if em and em.stopAll then em:stopAll() end
		end
	end)

	local removed = 0
	pcall(function()
		local cell = getCell()
		if cell and cell.getGridSquare then
			local sq = cell:getGridSquare(snap.x, snap.y, snap.z)
			if sq then
				local specials = sq.getSpecialObjects and sq:getSpecialObjects()
				if specials and specials.size then
					for i = specials:size() - 1, 0, -1 do
						local o = specials:get(i)
						if instanceof(o, "IsoBloodSplat") then
							sq:RemoveTileObject(o)
							removed = removed + 1
						end
					end
				end
				local objs = sq.getObjects and sq:getObjects()
				if objs and objs.size then
					for i = objs:size() - 1, 0, -1 do
						local o = objs:get(i)
						if instanceof(o, "IsoBloodSplat") then
							sq:RemoveTileObject(o)
							removed = removed + 1
						end
					end
				end
			end
		end
	end)
	log("[v3.2] STEP K removed %d IsoBloodSplat(s) from tile", removed)

	local started = ZomboRutScene.playSceneFromCorpse(player, body, sceneId, { launchSource = "fromLive" })
	if not started then
		log("[v3.2] STEP L playSceneFromCorpse refused — cleanup + restore")
		pcall(function() if body.removeFromWorld  then body:removeFromWorld()  end end)
		_cancelPathfind(target)
		pcall(function() if target.teleportTo then target:teleportTo(snap.x, snap.y, snap.z) end end)
		_unparkTarget()
		return false
	end

	ZomboRutScene._active[player].liveTargetSnapshot = snap
	ZomboRutScene._active[player].liveOriginal      = target
	ZomboRutScene._active[player].corpseHusk        = body
	ZomboRutScene._active[player].brainClone        = brainClone
	ZomboRutScene._active[player].partnerIsBandit   = true
	log("[v3.2] STEP M scene queued; dummy off-screen at %.2f,%.2f, corpse pickup pending",
		hideX, hideY)
	return true
end

function ZomboRutScene.playSceneFromAttacker(player, attacker, sceneId)
	if not player or not attacker then
		log("playSceneFromAttacker abort: player=%s attacker=%s",
			tostring(player), tostring(attacker))
		return false, "invalid-args"
	end
	if SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.zombieDefeatEnabled == false
		and not attacker:getVariableBoolean("Bandit")
	then
		log("playSceneFromAttacker abort: zombieDefeatEnabled=false and attacker is non-bandit zombie (sceneId=%s)",
			tostring(sceneId))
		return false, "defeat-disabled"
	end
	local resolvedId = ZomboRutScene._resolveGender(player, attacker, sceneId)
	if not resolvedId then
		log("playSceneFromAttacker abort: unknown scene %s", tostring(sceneId))
		return false, "unknown-scene"
	end
	if resolvedId ~= sceneId then
		log("playSceneFromAttacker: gender resolved %s -> %s", sceneId, resolvedId)
	end
	if player:isDraggingCorpse() then
		log("playSceneFromAttacker abort: player already dragging")
		return false, "already-dragging"
	end
	if ZomboRutScene._active[player] then
		log("playSceneFromAttacker abort: scene already active")
		return false, "already-active"
	end

	auditPlayerTile(player, "playSceneFromAttacker-pre")

	local snap = {
		x = attacker:getX(),
		y = attacker:getY(),
		z = attacker:getZ(),
		dir = attacker:getDir(),
	}
	pcall(function()
		snap.speedType   = attacker.speedType
		snap.lunger      = attacker.lunger
		snap.running     = attacker.running
		snap.walkVariant = attacker.walkVariant
		if attacker.getWalkType then snap.walkType = attacker:getWalkType() end
	end)
	pcall(function()
		snap.subdued = attacker.getModData and attacker:getModData().zrSubdued == true
	end)
	log("[Attacker] STEP A snapshot attacker_id=%s pos=%.2f,%.2f female=%s",
		safeId(attacker), snap.x, snap.y,
		tostring(attacker.isFemale and attacker:isFemale()))

	local _utilCfg  = ZomboRutScene._scenes[resolvedId]
	local isUtility = _utilCfg and _utilCfg.isUtility
	local zcMode = _zombieClothingMode()
	local attackerVisualsSnapshot = ZomboRutScene._snapshotItemVisuals(attacker)
	local snapshotStripped = 0
	if not isUtility then
		snapshotStripped = ZomboRutScene._stripSnapshotLowerBody(attackerVisualsSnapshot, zcMode)
	end
	log("[Attacker] STEP A.5 ItemVisuals snapshot taken (count=%d, stripped=%d)",
		#attackerVisualsSnapshot, snapshotStripped)

	_forceIdleState(player,   "playSceneFromAttacker:player")
	_forceIdleState(attacker, "playSceneFromAttacker:" .. safeId(attacker))

	local _freeWalkHide = _utilCfg and _utilCfg.noMovementLock
	local hideX = snap.x + (_freeWalkHide and FOLLOW_HIDE_OFFSET or 50)
	local hideY = snap.y + (_freeWalkHide and 0 or 50)
	pcall(function()
		_cancelPathfind(attacker)
		if attacker.teleportTo then attacker:teleportTo(hideX, hideY, snap.z) end
		attacker:setX(hideX); attacker:setY(hideY); attacker:setZ(snap.z)
	end)
	pcall(function() if attacker.setAlpha       then attacker:setAlpha(0.00392)       end end)
	pcall(function() if attacker.setTargetAlpha then attacker:setTargetAlpha(0.00392) end end)
	pcall(function() if attacker.setDoRender    then attacker:setDoRender(false)      end end)
	pcall(function()
		attacker:setUseless(true)
		attacker:setNoTeeth(true)
		attacker:setCanWalk(false)
		attacker:clearAggroList()
		attacker:setTarget(nil)
	end)
	pcall(function()
		if attacker:getVariableBoolean("Bandit") and Bandit then
			snap.banditParked = true
			local brain = BanditBrain and BanditBrain.Get and BanditBrain.Get(attacker)
			log("[v3.2] bandit park: brain=%s hostile=%s hostileP=%s stationary=%s",
				tostring(brain ~= nil), tostring(brain and brain.hostile),
				tostring(brain and brain.hostileP), tostring(brain and brain.stationary))
			if brain then
				snap.wasHostile    = brain.hostile
				snap.wasHostileP   = brain.hostileP
				snap.wasStationary = brain.stationary
				snap.banditPacified = true
			end
			if Bandit.ForceStationary then Bandit.ForceStationary(attacker, true) end
			if Bandit.SetHostile      then Bandit.SetHostile(attacker, false)     end
			if Bandit.SetHostileP     then Bandit.SetHostileP(attacker, false)    end
			if Bandit.ClearTasks      then Bandit.ClearTasks(attacker)            end
		end
	end)
	pcall(function()
		local md = attacker.getModData and attacker:getModData()
		if md then md._zrHusk = true end
	end)
	pcall(function()
		if attacker.getDescriptor then
			local desc = attacker:getDescriptor()
			if desc and desc.setVoicePrefix then
				desc:setVoicePrefix("NotAZombie")
			end
		end
		if attacker.getEmitter then
			local em = attacker:getEmitter()
			if em and em.stopAll then em:stopAll() end
		end
		if attacker.setDoDeathSound then attacker:setDoDeathSound(false) end
	end)
	log("[Attacker] STEP B original hidden at %.2f,%.2f + pacified + silenced", hideX, hideY)

	local droppedAtk, strippedAtk, origWorn, origVisual = 0, {}, 0, 0
	if not isUtility then
		droppedAtk = ZomboRutScene._dropLowerBodyVisualsAsItems(attacker, player, zcMode)
		origWorn   = ZomboRutScene._stripLowerBody(attacker, strippedAtk, zcMode)
		origVisual = ZomboRutScene._stripLowerBodyVisuals(attacker, zcMode)
		for _, e in ipairs(strippedAtk) do _dropZombieItemOnPlayerTile(attacker, player, e.item) end
		log("[Attacker] STEP B.1 original stripped (worn=%d visuals=%d visualsToGround=%d wornDropped=%d)",
			origWorn, origVisual, droppedAtk, #strippedAtk)
	else
		log("[Attacker] STEP B.1 SKIP strip (isUtility scene %s)", tostring(resolvedId))
	end

	pcall(function()
		if player.setSprinting    then player:setSprinting(false)    end
		if player.setRunning      then player:setRunning(false)      end
		if player.setForceSprint  then player:setForceSprint(false)  end
		if player.StopAllActionQueue then player:StopAllActionQueue() end
	end)
	log("[Attacker] STEP B.5 player movement pinned")

	local pdirX, pdirY = 1, 0
	pcall(function()
		local fwd = player.getForwardDirection and player:getForwardDirection()
		if fwd then
			local fx = fwd.getX and fwd:getX() or fwd.x or 0
			local fy = fwd.getY and fwd:getY() or fwd.y or 0
			if math.abs(fx) > 1e-3 or math.abs(fy) > 1e-3 then
				pdirX, pdirY = fx, fy
			end
		end
	end)
	if _utilCfg and _utilCfg.keepPlayerFacing then
		pcall(function()
			local ox = tonumber(player:getVariableString("ZR_OrigFaceX") or "")
			local oy = tonumber(player:getVariableString("ZR_OrigFaceY") or "")
			if ox and oy and (math.abs(ox) > 1e-3 or math.abs(oy) > 1e-3) then
				pdirX, pdirY = ox, oy
				if player.setForwardDirection then player:setForwardDirection(ox, oy) end
				log("[Attacker] keepPlayerFacing: using captured pre-grab heading %.2f,%.2f", ox, oy)
			end
		end)
	end
	local spawnOffset = (_utilCfg and _utilCfg.noMovementLock) and 0.6 or 0
	local spawnX = player:getX() + pdirX * spawnOffset
	local spawnY = player:getY() + pdirY * spawnOffset
	local spawnZ = player:getZ()
	log("[Attacker] STEP C spawn pos = player_pos + forward*%.2f = %.2f,%.2f (player_fwd=%.2f,%.2f)",
		spawnOffset, spawnX, spawnY, pdirX, pdirY)
	local fresh
	pcall(function()
		if createZombie then
			fresh = createZombie(spawnX, spawnY, spawnZ, nil, 0, attacker:getDir())
		end
	end)
	if not fresh then
		log("[Attacker] STEP C createZombie FAILED — restoring original + abort")
		_cancelPathfind(attacker)
		pcall(function() if attacker.teleportTo then attacker:teleportTo(snap.x, snap.y, snap.z) end end)
		pcall(function() if attacker.setAlpha then attacker:setAlpha(1) end end)
		pcall(function() if attacker.setTargetAlpha then attacker:setTargetAlpha(1) end end)
		pcall(function() if attacker.setDoRender    then attacker:setDoRender(true)  end end)
		pcall(function()
			local md = attacker.getModData and attacker:getModData()
			if md then md._zrHusk = nil end
		end)
		pcall(function()
			if Bandit and Bandit.ForceStationary then
				Bandit.ForceStationary(attacker, false)
			end
		end)
		pcall(function() attacker:setUseless(false); attacker:setCanWalk(true) end)
		_restoreTeeth(attacker)
		return false
	end
	log("[Attacker] STEP C fresh spawned fresh_id=%s (clone of attacker_id=%s)",
		safeId(fresh), safeId(attacker))

	pcall(function() if fresh.setAlpha       then fresh:setAlpha(0.00392)       end end)
	pcall(function() if fresh.setTargetAlpha then fresh:setTargetAlpha(0.00392) end end)

	local attackerIsBandit = ((attacker.getVariableBoolean
		and attacker:getVariableBoolean("Bandit"))
		or (attacker.getModData and attacker:getModData().brain ~= nil))
		and true or false

	pcall(function()
		if attacker.isFemale and fresh.setFemale then
			fresh:setFemale(attacker:isFemale())
		end
		if not attackerIsBandit
			and attacker.getPersistentOutfitID and fresh.setPersistentOutfitID
		then
			local poid = attacker:getPersistentOutfitID()
			if poid then fresh:setPersistentOutfitID(poid) end
		end
		local fv = fresh.getHumanVisual and fresh:getHumanVisual()
		local av = attacker.getHumanVisual and attacker:getHumanVisual()
		if fv and av and fv.copyFrom then fv:copyFrom(av) end
	end)

	local applied = ZomboRutScene._applyItemVisuals(fresh, attackerVisualsSnapshot)
	log("[Attacker] STEP D.5 applied snapshot to fresh (visuals=%d)", applied)

	pcall(function()
		local fwi = fresh.getWornItems and fresh:getWornItems()
		local awi = attacker.getWornItems and attacker:getWornItems()
		if fwi and awi and fwi.size then
			for i = fwi:size() - 1, 0, -1 do
				local w = fwi:get(i)
				local item = w and w:getItem()
				if item and fresh.removeWornItem then fresh:removeWornItem(item) end
			end
			for i = 0, awi:size() - 1 do
				local w = awi:get(i)
				if w then
					local srcItem = w:getItem()
					local loc     = w:getLocation()
					if srcItem and loc then
						local fullType = srcItem.getFullType and srcItem:getFullType()
						local newItem
						if fullType and InventoryItemFactory and InventoryItemFactory.CreateItem then
							newItem = InventoryItemFactory.CreateItem(fullType)
						end
						if newItem then
							local srcVis = srcItem.getVisual and srcItem:getVisual()
							local dstVis = newItem.getVisual and newItem:getVisual()
							if srcVis and dstVis and dstVis.copyFrom then
								pcall(function() dstVis:copyFrom(srcVis) end)
							end
							if fresh.setWornItem then
								fresh:setWornItem(loc, newItem)
							end
						end
					end
				end
			end
		end
	end)

	pcall(function()
		if fresh.resetModel          then fresh:resetModel()          end
		if fresh.resetModelNextFrame then fresh:resetModelNextFrame() end
	end)

	pcall(function()
		local desc = fresh:getDescriptor()
		if desc and desc.setVoicePrefix then desc:setVoicePrefix("NotAZombie") end
		local em = fresh:getEmitter()
		if em and em.stopAll then em:stopAll() end
		if fresh.setDoDeathSound then fresh:setDoDeathSound(false) end
	end)

	pcall(function() if fresh.Kill then fresh:Kill(nil, false) end end)

	local body
	local ok, err = pcall(function() body = _becomeCorpseSilently(fresh) end)
	if not ok or not body then
		log("[Attacker] STEP I becomeCorpseSilently FAILED: %s — cleanup + restore",
			tostring(err))
		pcall(function() if fresh.removeFromWorld then fresh:removeFromWorld() end end)
		_cancelPathfind(attacker)
		pcall(function() if attacker.teleportTo then attacker:teleportTo(snap.x, snap.y, snap.z) end end)
		pcall(function() if attacker.setAlpha then attacker:setAlpha(1) end end)
		pcall(function() if attacker.setTargetAlpha then attacker:setTargetAlpha(1) end end)
		pcall(function() if attacker.setDoRender    then attacker:setDoRender(true)  end end)
		pcall(function()
			local md = attacker.getModData and attacker:getModData()
			if md then md._zrHusk = nil end
		end)
		pcall(function()
			if Bandit and Bandit.ForceStationary then
				Bandit.ForceStationary(attacker, false)
			end
		end)
		pcall(function() attacker:setUseless(false); attacker:setCanWalk(true) end)
		_restoreTeeth(attacker)
		return false
	end
	pcall(function()
		local md = body.getModData and body:getModData()
		if md then
			for k in pairs(md) do md[k] = nil end
			md._zrHusk = true
		end
	end)
	log("[Attacker] STEP I corpse spawned body_id=%s (from fresh_id=%s) at %.2f,%.2f",
		safeId(body), safeId(fresh), snap.x, snap.y)

	pcall(function() if body.setAlpha       then body:setAlpha(0.00392)       end end)
	pcall(function() if body.setTargetAlpha then body:setTargetAlpha(0.00392) end end)
	pcall(function() if body.setDoRender    then body:setDoRender(false)      end end)
	pcall(function()
		if body.getEmitter then
			local em = body:getEmitter()
			if em and em.stopAll then em:stopAll() end
		end
	end)

	local removed = 0
	pcall(function()
		local cell = getCell()
		if cell and cell.getGridSquare then
			local sq = cell:getGridSquare(snap.x, snap.y, snap.z)
			if sq then
				local specials = sq.getSpecialObjects and sq:getSpecialObjects()
				if specials and specials.size then
					for i = specials:size() - 1, 0, -1 do
						local o = specials:get(i)
						if instanceof(o, "IsoBloodSplat") then
							sq:RemoveTileObject(o); removed = removed + 1
						end
					end
				end
				local objs = sq.getObjects and sq:getObjects()
				if objs and objs.size then
					for i = objs:size() - 1, 0, -1 do
						local o = objs:get(i)
						if instanceof(o, "IsoBloodSplat") then
							sq:RemoveTileObject(o); removed = removed + 1
						end
					end
				end
			end
		end
	end)
	if removed > 0 then
		log("[Attacker] STEP K removed %d IsoBloodSplat(s)", removed)
	end

	local started = ZomboRutScene.playSceneFromCorpse(player, body, resolvedId, { launchSource = "fromAttacker" })
	if not started then
		log("[Attacker] STEP L scene refused — cleanup + restore")
		pcall(function() if body.removeFromWorld then body:removeFromWorld() end end)
		_cancelPathfind(attacker)
		pcall(function() if attacker.teleportTo then attacker:teleportTo(snap.x, snap.y, snap.z) end end)
		pcall(function() if attacker.setAlpha then attacker:setAlpha(1) end end)
		pcall(function() if attacker.setTargetAlpha then attacker:setTargetAlpha(1) end end)
		pcall(function() if attacker.setDoRender    then attacker:setDoRender(true)  end end)
		pcall(function()
			local md = attacker.getModData and attacker:getModData()
			if md then md._zrHusk = nil end
		end)
		pcall(function()
			if Bandit and Bandit.ForceStationary then
				Bandit.ForceStationary(attacker, false)
			end
		end)
		pcall(function() attacker:setUseless(false); attacker:setCanWalk(true) end)
		_restoreTeeth(attacker)
		return false
	end

	ZomboRutScene._active[player].liveTargetSnapshot = snap
	ZomboRutScene._active[player].liveOriginal      = attacker
	ZomboRutScene._active[player].corpseHusk        = body
	ZomboRutScene._active[player].ctx.attackerVisualsSnapshot = attackerVisualsSnapshot
	ZomboRutScene._active[player].partnerIsBandit = attackerIsBandit
	log("[Attacker] STEP M stashed refs: husk=%s liveOriginal=%s snapshot_count=%d bandit=%s",
		safeId(body), safeId(attacker), #attackerVisualsSnapshot,
		tostring(ZomboRutScene._active[player].partnerIsBandit))
	return true
end

function ZomboRutScene.endScene(player, reason)
	if not player then return end
	local active = ZomboRutScene._active[player]
	if not active then return end

	pcall(function()
		if player.LetGoOfGrappled then
			player:LetGoOfGrappled(reason or "Released")
		elseif player.setDoGrappleLetGo then
			player:setDoGrappleLetGo()
		end
	end)

	ZomboRutScene._fireEnd(player, active, reason or "Released")
end

local function _sweepStrayHuskCorpses(player, snapshot)
	local cell = getCell()
	if not cell then return 0 end
	local removed = 0
	local function sweepSquare(sq)
		if not (sq and sq.getStaticMovingObjects) then return end
		local objs = sq:getStaticMovingObjects()
		if not (objs and objs.size) then return end
		for i = objs:size() - 1, 0, -1 do
			local o = objs:get(i)
			if o and instanceof(o, "IsoDeadBody") then
				local isHusk = false
				pcall(function()
					local md = o.getModData and o:getModData()
					isHusk = md and md._zrHusk == true
				end)
				if isHusk then
					despawnAndVerify(o, "stray-husk-corpse")
					removed = removed + 1
				end
			end
		end
	end
	pcall(function()
		if player and player.getCurrentSquare then sweepSquare(player:getCurrentSquare()) end
	end)
	pcall(function()
		if snapshot and cell.getGridSquare then
			sweepSquare(cell:getGridSquare(snapshot.x, snapshot.y, snapshot.z))
		end
	end)
	return removed
end

local function _sameRoomAsPlayer(player, x, y, z)
	local ok = false
	pcall(function()
		local cell = getCell()
		local sq = cell and cell.getGridSquare
			and cell:getGridSquare(math.floor(x), math.floor(y), math.floor(z))
		if not (sq and sq.TreatAsSolidFloor and sq:TreatAsSolidFloor()) then return end
		local psq = player.getCurrentSquare and player:getCurrentSquare()
		if not psq then return end
		ok = (sq:getRoom() == psq:getRoom())
	end)
	return ok
end

local function restoreLiveTarget(snapshot, partner, husk, original, player, partnerIsBandit)
	log("restoreLiveTarget: husk=%s partner=%s original=%s bandit=%s",
		safeId(husk), safeId(partner), safeId(original), tostring(partnerIsBandit))
	auditPlayerTile(player, "restore-begin")

	despawnAndVerify(husk, "husk")

	pcall(function()
		if partner and partner.setOnDeathDone then
			partner:setOnDeathDone(true)
		end
		if partner and partner.setHealth then
			partner:setHealth(100.0)
		end
	end)

	pcall(function()
		if partner and partner.setReanimatedPlayer then
			partner:setReanimatedPlayer(false)
		end
	end)

	local _fzId, _fzState = "nil", "nil"
	pcall(function()
		if partner then
			_fzId    = tostring(partner.getID and partner:getID())
			_fzState = tostring(partner.getCurrentStateName and partner:getCurrentStateName())
		end
	end)

	local dropX, dropY, dropZ, dropDir
	pcall(function()
		if partner and partner.getX then
			dropX, dropY, dropZ = partner:getX(), partner:getY(), partner:getZ()
			if partner.getDir then dropDir = partner:getDir() end
		end
	end)

	local _fzCalled, _fzStillInCell = despawnAndVerify(partner, "partner-clone")

	pcall(function()
		local oId, oState = "nil", "nil"
		if original then
			oId    = tostring(original.getID and original:getID())
			oState = tostring(original.getCurrentStateName and original:getCurrentStateName())
		end
		DebugLog.log(string.format(
			"[ZR FROZEN] scene-end: partnerId=%s partnerState=%s partnerNil=%s despawnCalled=%s stillInCell=%s | originalId=%s originalState=%s",
			_fzId, _fzState, tostring(partner == nil), tostring(_fzCalled), tostring(_fzStillInCell), oId, oState))
	end)

	pcall(function()
		if partner then
			if partner.setReanimatedForGrappleOnly then
				partner:setReanimatedForGrappleOnly(false)
			end
			local md = partner.getModData and partner:getModData()
			if md then md._zrHusk = nil end
		end
	end)

	if not original then
		log("[v3.1] restoreLiveTarget: no original dummy ref to restore")
		return
	end

	local isCaptive = (snapshot and snapshot.subdued) == true
	if not isCaptive then
		pcall(function()
			isCaptive = original.getModData and original:getModData().zrSubdued == true
		end)
	end

	pcall(function()
		local rx, ry, rz = snapshot.x, snapshot.y, snapshot.z
		if player and isCaptive then
			rx, ry, rz = player:getX(), player:getY(), player:getZ()
			if dropX and dropY
				and math.abs(dropX - rx) <= 3 and math.abs(dropY - ry) <= 3
				and math.floor(dropZ or rz) == math.floor(rz)
				and _sameRoomAsPlayer(player, dropX, dropY, rz)
			then
				rx, ry = dropX, dropY
			end
		elseif player then
			rx, ry, rz = player:getX(), player:getY(), player:getZ()
			local ox = (snapshot.x or rx) - rx
			local oy = (snapshot.y or ry) - ry
			local olen = math.sqrt(ox * ox + oy * oy)
			if olen > 0.1 then
				ox, oy = ox / olen, oy / olen
			else
				ox, oy = 1, 0
			end
			rx, ry = rx + ox * 1.4, ry + oy * 1.4
		end
		if original.teleportTo then
			original:teleportTo(rx, ry, math.floor(rz))
		end
		_cancelPathfind(original)
		original:setX(rx); original:setY(ry); original:setZ(rz)
		if original.setCurrentSquareFromPosition then
			pcall(function() original:setCurrentSquareFromPosition() end)
		end
		pcall(function()
			if original.setMovingSquareNow then original:setMovingSquareNow() end
		end)
		if isCaptive and dropDir and original.setDir then
			original:setDir(dropDir)
		elseif snapshot.dir and original.setDir then
			original:setDir(snapshot.dir)
		end

		pcall(function()
			local md0 = original.getModData and original:getModData()
			if md0 then md0._zrHusk = nil end
		end)

		log("[v3.2] restoreLiveTarget: original teleport target=(%.2f,%.2f) actual=(%.2f,%.2f) id=%s captive=%s puppet=(%s,%s) dir=%s",
			rx, ry, original:getX(), original:getY(), tostring(original:getID()), tostring(isCaptive),
			dropX and string.format("%.2f", dropX) or "nil",
			dropY and string.format("%.2f", dropY) or "nil",
			tostring(original.getDir and original:getDir()))

		pcall(function() if original.setDoRender then original:setDoRender(true) end end)

		local md = original:getModData()
		local brain = md and md.brain
		local isBandit = partnerIsBandit or (brain ~= nil and brain.cid ~= nil)

		if not isBandit then
			pcall(function() if original.setAlpha       then original:setAlpha(1)       end end)
			pcall(function() if original.setTargetAlpha then original:setTargetAlpha(1) end end)
			pcall(function()
				if original.setUseless then original:setUseless(false) end
				if original.setCanWalk then original:setCanWalk(true)  end
			end)
			_restoreTeeth(original)
			pcall(function()
				if original.resetModel          then original:resetModel()          end
				if original.resetModelNextFrame then original:resetModelNextFrame() end
			end)
			pcall(function()
				if snapshot.speedType   ~= nil then original.speedType   = snapshot.speedType   end
				if snapshot.lunger      ~= nil then original.lunger      = snapshot.lunger      end
				if snapshot.running     ~= nil then original.running     = snapshot.running     end
				if snapshot.walkVariant ~= nil then original.walkVariant = snapshot.walkVariant end
				if snapshot.walkType    ~= nil and original.setWalkType then
					original:setWalkType(snapshot.walkType)
				end
			end)
			log("[v3.2] restoreLiveTarget: plain zombie id=%s un-pacified + alpha=1 + model reset (speedType→%s lunger→%s)",
				safeId(original),
				tostring(snapshot.speedType), tostring(snapshot.lunger))
		else
			log("[v3.2] restoreLiveTarget: bandit state: Bandit=%s brain=%s brain.zrDummyBandit=%s",
				tostring(original:getVariableBoolean("Bandit")),
				tostring(brain ~= nil),
				tostring(brain and brain.zrDummyBandit))

			if applyBanditVisuals(original, brain) then
				log("[v3.2] restoreLiveTarget: re-applied bandit visuals via helper")
			end

			pcall(function() if original.setAlpha       then original:setAlpha(1)       end end)
			pcall(function() if original.setTargetAlpha then original:setTargetAlpha(1) end end)
			pcall(function()
				if original.setUseless then original:setUseless(false) end
				if original.setCanWalk then original:setCanWalk(true)  end
			end)

			local hostilityRestored = false
			if snapshot and snapshot.banditPacified then
				pcall(function()
					local b = BanditBrain and BanditBrain.Get and BanditBrain.Get(original)
					if b then
						b.hostile    = snapshot.wasHostile
						b.hostileP   = snapshot.wasHostileP
						b.stationary = snapshot.wasStationary
						hostilityRestored = true
					end
				end)
			end
			if not hostilityRestored and snapshot and snapshot.banditParked then
				pcall(function()
					if Bandit then
						if Bandit.SetHostile      then Bandit.SetHostile(original, true)       end
						if Bandit.SetHostileP     then Bandit.SetHostileP(original, true)      end
						if Bandit.ForceStationary then Bandit.ForceStationary(original, false) end
						hostilityRestored = "default-hostile"
					end
				end)
			end
			if not hostilityRestored then
				pcall(function()
					if Bandit and Bandit.ForceStationary then
						Bandit.ForceStationary(original, false)
					end
				end)
			end
			log("[v3.2] restoreLiveTarget: bandit un-pinned (useless=false canWalk=true alpha=1), hostility restored=%s",
				tostring(hostilityRestored))
		end
	end)

	auditPlayerTile(player, "restore-end")
end

local function releaseCorpsePartner(partner)
	if not partner then return end
	pcall(function()
		if partner.setReanimatedPlayer then partner:setReanimatedPlayer(false) end
	end)
	log("scene-end: released corpse partner %s (un-reanimated, kept in world)", safeId(partner))
end

function ZomboRutScene._fireEnd(player, active, result)
	log("scene %s ending: result=%s liveOriginal=%s husk=%s partner=%s",
		active.sceneId, result,
		safeId(active.liveOriginal), safeId(active.corpseHusk), safeId(active.partnerRef))
	auditPlayerTile(player, "fireEnd-begin[" .. active.sceneId .. "]")

	if active.cfg.onEnd then
		local ok, err = pcall(active.cfg.onEnd, player, active.ctx, result)
		if not ok then
			log("scene %s onEnd error: %s", active.sceneId, tostring(err))
		end
	end

	ZomboRutScene._clearChainVars(player, active.sceneId, active.cfg)

	if active.liveTargetSnapshot then
		restoreLiveTarget(
			active.liveTargetSnapshot,
			active.partnerRef,
			active.corpseHusk,
			active.liveOriginal,
			player,
			active.partnerIsBandit)
	else
		releaseCorpsePartner(active.partnerRef)
	end

	local reaped = _sweepStrayHuskCorpses(player, active.liveTargetSnapshot)
	if reaped > 0 then
		log("scene-end: reaped %d stray husk-corpse(s) (engine redrop leak)", reaped)
	end

	for _, cb in ipairs(ZomboRutScene._sceneEndCallbacks) do
		local ok, err = pcall(cb, player, active, result)
		if not ok then
			log("scene-end callback error: %s", tostring(err))
		end
	end

	local holdMs = 0
	pcall(function()
		if ZR_Surrender and ZR_Surrender.getSceneFireHoldMs then
			holdMs = ZR_Surrender.getSceneFireHoldMs(player) or 0
		end
	end)
	local graceMs = 1000

	ZomboRutScene._postSceneGrace[player] = {
		graceMsLeft       = graceMs,
		graceLastMs       = nil,
		wasInvincible     = active.wasInvincible,
		bystanderZombies  = active.bystanderZombies or {},
		banditStates      = active.banditStates or {},
		noSceneProtection = (active.cfg and active.cfg.noSceneProtection) or false,
	}
	log("transitioning to post-scene grace (%dms, give-up needs %dms, %d bystander(s) tracked)",
		graceMs, holdMs,
		(function() local n=0 for _ in pairs(active.bystanderZombies or {}) do n=n+1 end return n end)())

	pcall(function() player:setVariable("ZR_SceneId", "") end)
	pcall(function() player:setVariable("ZR_SceneActive", "") end)
	pcall(function() player:setVariable("ZR_IntroDone", "") end)
	pcall(function() player:setVariable("ZR_BridgePending", "") end)
	pcall(function() player:setVariable("ZR_PromotedSgt", "") end)

	pcall(function()
		if player.setIgnoreMovement then player:setIgnoreMovement(false) end
	end)

	pcall(function()
		if player.resetGrappleStateToDefault then
			player:resetGrappleStateToDefault("")
		end
	end)

	ZomboRutScene._active[player] = nil
end

local function _endPostSceneGrace(player, grace)
	local cloakActive = ZR_ZombieCloak and ZR_ZombieCloak.isActive
		and ZR_ZombieCloak.isActive(player)
	local phase = ZR_Surrender and ZR_Surrender.getPhase
		and ZR_Surrender.getPhase(player)
	local cloakManaging = cloakActive and phase ~= "surrender"
	local restored = 0
	for z in pairs(grace.bystanderZombies or {}) do
		pcall(function()
			if z and z:isAlive() then
				if z.setCollidable then z:setCollidable(true) end
				if not cloakManaging then
					if z.setUseless then z:setUseless(false) end
					if z.setNoTeeth then z:setNoTeeth(phase == "surrender") end
				end
				if z.setCanWalk
					and not (z.getModData and z:getModData().zrSubdued) then
					z:setCanWalk(true)
				end
				restored = restored + 1
			end
		end)
	end

	local banditsRestored = 0
	for z, snap in pairs(grace.banditStates or {}) do
		if z and z:isAlive() then
			_restoreBandit(z, snap)
			banditsRestored = banditsRestored + 1
		end
	end

	pcall(function()
		if player.setInvincible then player:setInvincible(grace.wasInvincible) end
	end)

	ZomboRutScene._postSceneGrace[player] = nil
	log("grace ended: restored %d bystander(s) + %d bandit(s) + setInvincible(%s) [phase=%s cloakActive=%s cloakManaging=%s teethKept=%s]",
		restored, banditsRestored, tostring(grace.wasInvincible),
		tostring(phase), tostring(cloakActive), tostring(cloakManaging),
		tostring(phase == "surrender"))
	auditPlayerTile(player, "grace-end")
end

local function _pacifyNearbyZombies(player, partner, bystanderZombies, banditStates)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z ~= partner and z:isAlive() then
			local d_player  = z:DistTo(player)
			local d_partner = partner and z:DistTo(partner) or math.huge

			if d_player <= 1.5 or d_partner <= 1.5 then
				z:clearAggroList()
				z:setTarget(nil)
				if z.setUseless then z:setUseless(true) end
				if z.setNoTeeth then z:setNoTeeth(true) end
				if _isBandit(z) then _pacifyBandit(z, banditStates) end
				bystanderZombies[z] = true
			end
		end
	end
end

local function snapshot(player)
	return {
		state    = player.getCurrentStateName and player:getCurrentStateName() or "?",
		dragging = player:isDraggingCorpse(),
		grappling = player.isGrappling and player:isGrappling() or false,
		sgt      = player:getVariableString("sharedGrappleType") or "",
		sgan     = player:getVariableString("sharedGrappleAnimNode") or "",
		zrid     = player:getVariableString("ZR_SceneId") or "",
	}
end

local function snapshotEqual(a, b)
	return a.state == b.state and a.dragging == b.dragging
		and a.grappling == b.grappling and a.sgt == b.sgt
		and a.sgan == b.sgan and a.zrid == b.zrid
end

local function zombieSnapshot(z)
	if not z then return nil end
	return {
		state    = z.getCurrentStateName and z:getCurrentStateName() or "?",
		bBg      = z:getVariableBoolean("bBeingGrappled"),
		sgan     = z:getVariableString("sharedGrappleAnimNode") or "",
		sgt      = z:getVariableString("sharedGrappleType") or "",
		bOnFloor = z:getVariableBoolean("bOnFloor"),
		fallOnFront = z:getVariableBoolean("fallOnFront"),
		isOnFloor = z:isOnFloor(),
	}
end

local function zombieSnapEqual(a, b)
	if not a or not b then return false end
	return a.state == b.state and a.bBg == b.bBg and a.sgan == b.sgan
		and a.sgt == b.sgt and a.bOnFloor == b.bOnFloor
		and a.fallOnFront == b.fallOnFront and a.isOnFloor == b.isOnFloor
end

local SBD_FORCED_ONLY, SBD_NEVER = 1, 2
local function _shouldDropBag(player, active)
	if not active or not player then return false end
	if active.cfg and active.cfg.isUtility then return false end
	if active.cfg and active.cfg.friendlyOnly then return false end
	if not (ZR_Surrender and ZR_Surrender.isActive
	        and ZR_Surrender.isActive(player)) then
		return false
	end
	if SandboxVars and SandboxVars.ZomboRut
	   and SandboxVars.ZomboRut.sceneBagDrop == SBD_NEVER then
		return false
	end
	return true
end

ZomboRutScene.registerSceneStartCallback(function(player, active)
	ZomboRutScene._dropHandItems(player)
	if _shouldDropBag(player, active) then
		ZomboRutScene._dropWornBackpack(player)
	end
end)

local function _consensualSceneShoutGate(player, active, allow)
	if not (active and active.cfg) then return end
	if active.cfg.isDefeat or active.cfg.isUtility then return end
	pcall(function() if player.setCanShout then player:setCanShout(allow) end end)
end
ZomboRutScene.registerSceneStartCallback(function(player, active)
	_consensualSceneShoutGate(player, active, false)
end)
ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	_consensualSceneShoutGate(player, active, true)
end)

ZomboRutScene.registerSceneStartCallback(function(player, active)
	if active and active.cfg and active.cfg.isUtility then
		log("[ZR strip] scene-start: isUtility scene %q — skipping clothing strip",
			tostring(active.sceneId))
		return
	end

	local isDefeatScene = active and active.cfg and active.cfg.isDefeat
	local fromAttacker = active and active.launchSource == "fromAttacker"
	local actualDefeat = isDefeatScene and fromAttacker
	local stripMode = (SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.clothingStripMode) or 2
	local zcMode = _zombieClothingMode()

	local isRealCorpse = active and active.launchSource == "fromCorpse"
	local removedB = 0
	if not isRealCorpse then
		removedB = ZomboRutScene._stripLowerBody(active and active.body, nil, zcMode)
	end

	if active and active.cfg and active.cfg.isOral then
		local facePartner = ZomboRutScene._clearOralBlockers(active.body)
		local facePlayer  = ZomboRutScene._clearOralBlockers(player)
		if facePartner > 0 or facePlayer > 0 then
			log("[ZR strip] oral scene %s: removed face-covering items (partner=%d player=%d)",
				tostring(active.sceneId), facePartner, facePlayer)
		end
	end

	if actualDefeat then
		local pStripMode = (SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.playerDefeatStripMode) or 1
		local dropped
		if pStripMode == 2 then
			dropped = ZomboRutScene._stripAllDefeatItems(player, stripMode)
		else
			dropped = ZomboRutScene._stripOneDefeatItem(player, stripMode)
		end
		if active and active.ctx then
			active.ctx.fakeGrabbedItem = dropped
		end
		local droppedName = "nil"
		pcall(function()
			if dropped and dropped.getName then droppedName = tostring(dropped:getName()) end
		end)
		log("[ZR strip] scene-start defeat=true mode=%d pStrip=%d zcMode=%d dropped=%s body_removed=%d body=%s",
			stripMode, pStripMode, zcMode, droppedName, removedB, safeId(active and active.body))
	else
		local stripped = {}
		local removedP = ZomboRutScene._stripLowerBody(player, stripped)
		if active and active.ctx then
			active.ctx.strippedPlayerItems = stripped
		end
		log("[ZR strip] scene-start defeat-as-sex mode=%d zcMode=%d isDefeatScene=%s launchSource=%s player_removed=%d body_removed=%d body=%s",
			stripMode, zcMode, tostring(isDefeatScene),
			tostring(active and active.launchSource),
			removedP, removedB, safeId(active and active.body))
	end
end)

ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	local stripped = active and active.ctx and active.ctx.strippedPlayerItems
	if stripped and #stripped > 0 then
		local restored = ZomboRutScene._reequipItems(player, stripped)
		log("[ZR strip] scene-end: player_reequipped=%d/%d (result=%s)",
			restored, #stripped, tostring(result))
	end

	local snapshot = active and active.ctx and active.ctx.attackerVisualsSnapshot
	local original = active and active.liveOriginal
	if snapshot and original then
		local applied = ZomboRutScene._applyItemVisuals(original, snapshot)
		log("[ZR strip] scene-end: original_visuals_re-applied=%d (id=%s)",
			applied, safeId(original))
	end
end)

Events.OnPlayerUpdate.Add(function(player)
	local grace = ZomboRutScene._postSceneGrace[player]
	local active = ZomboRutScene._active[player]

	if not active and grace then
		local gnow = getTimestampMs()
		local ggap = grace.graceLastMs and (gnow - grace.graceLastMs) or 0
		grace.graceLastMs = gnow
		if ggap > 0 and ggap <= 400 then grace.graceMsLeft = grace.graceMsLeft - ggap end
		local heldByGiveUp = false
		pcall(function()
			heldByGiveUp = (ZR_Surrender and ZR_Surrender.ownsPlayer
				and ZR_Surrender.ownsPlayer(player)) or false
		end)
		if heldByGiveUp then
			grace.leaseHeldMs = (grace.leaseHeldMs or 0) + (ggap <= 400 and ggap or 0)
			if grace.leaseHeldMs > 300000 then
				log("grace LEASE CAP hit (%.0fs held) -- the give-up never released this player; forcing cover down. THIS IS A LEAK, not normal play.",
					grace.leaseHeldMs / 1000)
				heldByGiveUp = false
			end
		end
		if grace.graceMsLeft <= 0 and not heldByGiveUp then
			_endPostSceneGrace(player, grace)
			return
		end
		if not grace.noSceneProtection then
			pcall(_pacifyNearbyZombies, player, nil, grace.bystanderZombies, grace.banditStates)
		end
		return
	end

	if not active then return end

	active.ticks = (active.ticks or 0) + 1

	for _, cb in ipairs(ZomboRutScene._sceneUpdateCallbacks) do
		local ok, err = pcall(cb, player, active)
		if not ok then
			log("scene-update callback error: %s", tostring(err))
		end
	end

	if ZomboRutScene._active[player] ~= active then return end

	if not (active.cfg and active.cfg.noSceneProtection) then
		pcall(_pacifyNearbyZombies, player, active.partnerRef, active.bystanderZombies, active.banditStates)
	end

	local introDone = (player:getVariableString("ZR_IntroDone") == "true")

	local freeWalk = active.cfg and active.cfg.noMovementLock

	local original = active.liveOriginal
	if freeWalk and original and active.liveTargetSnapshot then
		pcall(function()
			if not (original.isAlive and original:isAlive()) then return end
			local hx = player:getX() + FOLLOW_HIDE_OFFSET
			local hy = player:getY()
			local hz = player:getZ()
			local fkey = math.floor(hx) .. ":" .. math.floor(hy) .. ":" .. math.floor(hz)
			if active.followKey == fkey then return end
			active.followKey = fkey
			if math.floor(hz) ~= 0 then
				local cell = getCell()
				local gsq = cell and cell:getGridSquare(
					math.floor(hx), math.floor(hy), 0)
				if gsq and gsq.TreatAsSolidFloor and gsq:TreatAsSolidFloor() then
					hz = 0
				else
					local sq = cell and cell:getGridSquare(
						math.floor(hx), math.floor(hy), math.floor(hz))
					local safe = sq and sq.TreatAsSolidFloor and sq:TreatAsSolidFloor()
					if not safe then
						hx = player:getX()
						hy = player:getY()
					end
				end
			end
			if math.floor(original:getX()) ~= math.floor(hx)
				or math.floor(original:getY()) ~= math.floor(hy)
				or math.floor(original:getZ()) ~= math.floor(hz)
			then
				if original.teleportTo then original:teleportTo(hx, hy, math.floor(hz)) end
				original:setX(hx); original:setY(hy); original:setZ(hz)
				if original.setCurrentSquareFromPosition then
					original:setCurrentSquareFromPosition()
				end
				if original.setMovingSquareNow then original:setMovingSquareNow() end
			end
		end)
	end

	if active.partnerRef and not freeWalk then
		if not active.partnerAnchor then
			local cfg = active.cfg
			local offsetForward = (cfg and cfg.partnerOffsetForward) or -0.9
			local pfx, pfy = 1, 0
			pcall(function()
				local fwd = player.getForwardDirection and player:getForwardDirection()
				if fwd then
					local fx = fwd.getX and fwd:getX() or fwd.x or 0
					local fy = fwd.getY and fwd:getY() or fwd.y or 0
					local len = math.sqrt(fx*fx + fy*fy)
					if len > 1e-3 then
						pfx, pfy = fx/len, fy/len
					end
				end
			end)
			active.partnerAnchor = {
				qx = player:getX() + offsetForward * pfx,
				qy = player:getY() + offsetForward * pfy,
				qz = player:getZ(),
			}
			log("[ZR anchor] partner derived @ (%.2f,%.2f,%.2f) offset=%.2f fwd=(%.2f,%.2f) (intro=%s)",
				active.partnerAnchor.qx, active.partnerAnchor.qy,
				active.partnerAnchor.qz, offsetForward, pfx, pfy,
				tostring(introDone))
		end
		local pa = active.partnerAnchor
		pcall(function()
			local pr = active.partnerRef
			pr:setX(pa.qx); pr:setY(pa.qy); pr:setZ(pa.qz)
			if pr.setNextX then pr:setNextX(pa.qx) end
			if pr.setNextY then pr:setNextY(pa.qy) end
		end)
	end

	if introDone and not freeWalk then
		if not active.anchor then
			active.anchor = {
				px = player:getX(), py = player:getY(), pz = player:getZ(),
			}
			log("[ZR anchor] player captured @ (%.2f,%.2f,%.2f)",
				active.anchor.px, active.anchor.py, active.anchor.pz)
		end
		local a = active.anchor
		pcall(function()
			player:setX(a.px); player:setY(a.py); player:setZ(a.pz)
			if player.setNextX then player:setNextX(a.px) end
			if player.setNextY then player:setNextY(a.py) end
		end)
	end

	local snap = snapshot(player)
	if not active.lastSnap or not snapshotEqual(active.lastSnap, snap) then
		log("t=%d player: state=%s dragging=%s grappling=%s sgt=%q sgan=%q",
			active.ticks, snap.state, tostring(snap.dragging),
			tostring(snap.grappling), snap.sgt, snap.sgan)
		active.lastSnap = snap
	end

	local target = player:getGrapplingTarget()
	local zsnap = zombieSnapshot(target)
	if zsnap and (not active.lastZSnap or not zombieSnapEqual(active.lastZSnap, zsnap)) then
		log("t=%d zombie: state=%s bBg=%s sgan=%q sgt=%q bOnFloor=%s fallFront=%s onFloor=%s",
			active.ticks, zsnap.state, tostring(zsnap.bBg), zsnap.sgan,
			zsnap.sgt, tostring(zsnap.bOnFloor),
			tostring(zsnap.fallOnFront), tostring(zsnap.isOnFloor))
		active.lastZSnap = zsnap
	end

	if player:getVariableString("ZR_BridgePending") == "true" then
		local zState = (zsnap and zsnap.state) or ""
		if zState ~= "" and zState ~= "ZombieOnGroundState"
			and active.partnerDetectTick ~= nil
			and active.ticks >= active.partnerDetectTick + 8
		then
			local promotedSgt = player:getVariableString("ZR_PromotedSgt")
			if promotedSgt and promotedSgt ~= "" then
				log("t=%d: bridge done (zombie state=%s) -> promote sgt=%s",
					active.ticks, zState, promotedSgt)
				pcall(function() player:setSharedGrappleType(promotedSgt) end)
				if target then
					pcall(function() target:setVariable("bOnFloor", "false") end)
				end
				pcall(function() player:setVariable("ZR_BridgePending", "false") end)
				pcall(function()
					if player.setIgnoreMovement then
						player:setIgnoreMovement(false)
					end
				end)
			end
		end
	end

	if snap.dragging then
		active.draggingObserved = true

		if not active.partnerRef and target then
			active.partnerRef = target
			active.partnerDetectTick = active.ticks
			log("[v3.2] partner detected: id=%s (from husk_id=%s) t=%d",
				safeId(target), safeId(active.corpseHusk), active.ticks)

			pcall(function()
				if target.setInvincible then target:setInvincible(true) end
				if target.setNoDamage   then target:setNoDamage(true)   end
				if target.setHealth     then target:setHealth(100.0)    end
				if target.setCollidable then target:setCollidable(false) end
			end)

			pcall(function()
				if target.getSpriteName and target:getSpriteName() == "Base_Torso" then
					local female = target:isFemale()
					local desc = target.getDescriptor and target:getDescriptor()
					local prefix = nil
					if desc then
						if desc.getVoicePrefix then prefix = desc:getVoicePrefix() end
						if desc.setTorso then desc:setTorso(female and "Kate" or "Male") end
					end
					if target.setFemaleEtc then target:setFemaleEtc(female) end
					if desc and prefix and desc.setVoicePrefix then desc:setVoicePrefix(prefix) end
					if target.InitSpritePartsZombie then target:InitSpritePartsZombie() end
					active.partnerModelRepaired = true
					local added = target.isAddedToModelManager
						and target:isAddedToModelManager()
					log("[v3.2] partner model repaired (Base_Torso -> %s) addedToMM=%s",
						female and "Kate" or "Male", tostring(added))
				end
			end)

			if active.launchSource ~= "fromCorpse" then
				pcall(function()
					local md = target.getModData and target:getModData()
					if md then md._zrHusk = true end
				end)
			end

			if not active.liveTargetSnapshot then
				pcall(function()
					if target.getDescriptor then
						local desc = target:getDescriptor()
						if desc and desc.setVoicePrefix then
							desc:setVoicePrefix("NotAZombie")
						end
					end
					if target.getEmitter then
						local em = target:getEmitter()
						if em and em.stopAll then em:stopAll() end
					end
					if target.setDoDeathSound then target:setDoDeathSound(false) end
				end)
			end

			local zcMode = _zombieClothingMode()
			local isUtility = active.cfg and active.cfg.isUtility
			if active.liveTargetSnapshot and active.brainClone then
				pcall(function()
					if not isUtility then
						ZomboRutScene._stripBrainClothing(active.brainClone, zcMode)
					end
					target:getModData().brain = active.brainClone
					if applyBanditVisuals(target, active.brainClone) then
						log("[v3.2] OnPlayerUpdate: visuals applied to partner id=%s (alpha=0 until intro)",
							tostring(target:getID()))
					end
				end)
			end
			if not isUtility then
				local strippedTick = {}
				pcall(function() ZomboRutScene._stripLowerBody(target, strippedTick, zcMode) end)
				for _, e in ipairs(strippedTick) do
					_dropZombieItemOnPlayerTile(target, player, e.item)
				end
				pcall(function() ZomboRutScene._stripLowerBodyVisuals(target, zcMode) end)
			end
		end

		if active.partnerRef and active.liveTargetSnapshot
			and not active.partnerRevealed
		then
			local zsgan = target and target:getVariableString("sharedGrappleAnimNode") or ""
			if zsgan:sub(1, 3) == "ZR_" then
				active.partnerRevealed = true
				active.partnerShowAt = active.ticks + 4
				log("[v3.2] partner reveal armed (sgan=%s) show at t=%d",
					zsgan, active.partnerShowAt)
			end
		end

		if active.partnerRef and active.partnerShowAt
			and active.ticks >= active.partnerShowAt
		then
			active.partnerShowAt = nil
			pcall(function()
				local p = active.partnerRef
				if p.setAlpha       then p:setAlpha(1)       end
				if p.setTargetAlpha then p:setTargetAlpha(1) end
				if p.setDoRender    then p:setDoRender(true)  end
			end)
			log("[v3.2] partner revealed (post-rebuild show, t=%d)", active.ticks)
		end

		if active.partnerModelRepaired and not active.partnerModelSettled then
			pcall(function()
				local p = active.partnerRef
				local revealDone = active.partnerRevealed
					or not active.liveTargetSnapshot
				local afterWindow = active.partnerDetectTick ~= nil
					and active.ticks >= active.partnerDetectTick + 10
				if not (revealDone and afterWindow) then return end
				if p.resetModelNextFrame then p:resetModelNextFrame() end
				local added = p.isAddedToModelManager and p:isAddedToModelManager()
				if added then
					active.partnerModelSettled = true
					log("[v3.2] partner model re-arm settled (rebuild queued post-reveal t=%d)",
						active.ticks)
				end
			end)
		end

		if active.partnerRef then
			active.partnerProbeTicks = (active.partnerProbeTicks or 0) + 1
			pcall(function()
				local p = active.partnerRef
				if active.partnerProbeTicks == 2 or active.partnerProbeTicks == 32 then
					log("[v3.2] partner renderState t+%d: sprite=%s added=%s alpha=%s culled=%s",
						active.partnerProbeTicks,
						tostring(p.getSpriteName and p:getSpriteName()),
						tostring(p.isAddedToModelManager and p:isAddedToModelManager()),
						tostring(p.getAlpha and p:getAlpha(0)),
						tostring(p.isSceneCulled and p:isSceneCulled()))
				end
				if p.setSceneCulled then p:setSceneCulled(false) end
				if not active.partnerRevealed and active.liveTargetSnapshot then
					local al = p.getAlpha and p:getAlpha(0)
					if al and al < 0.031 then
						if p.setAlpha       then p:setAlpha(0.031)       end
						if p.setTargetAlpha then p:setTargetAlpha(0.031) end
					end
				end
			end)
		end

		if active.partnerRef and not active.liveTargetSnapshot
			and active.ctx and active.ctx.attackerVisualsSnapshot
		then
			active.partnerApplyTicks = (active.partnerApplyTicks or 0) + 1
			if active.partnerApplyTicks <= 60 then
				pcall(function()
					ZomboRutScene._applyItemVisuals(active.partnerRef,
						active.ctx.attackerVisualsSnapshot)
				end)
			end
		end

		if active.cfg.duration and active.cfg.duration > 0
			and not active.releaseSent
		then
			local nowMs = getTimestampMs()
			if not active.dragAccumMs then
				active.dragAccumMs   = 0
				active.dragBaseTicks = active.dragTicks or 0
			end
			local gap = nowMs - (active.dragLastMs or nowMs)
			active.dragLastMs = nowMs
			if gap > 0 and gap <= 400 then
				active.dragAccumMs = active.dragAccumMs + gap * _gameSpeedScale()
			end
			active.dragTicks = active.dragBaseTicks
				+ math.floor(active.dragAccumMs * 0.06)

			if player.isPlayerMoving and player:isPlayerMoving()
				and (active.dragTicks - (active.lastFootstepTick or -99)) >= DRAG_FOOTSTEP_TICKS
			then
				active.lastFootstepTick = active.dragTicks
				pcall(function()
					if player.DoFootstepSound then player:DoFootstepSound("walk") end
				end)
			end

			if not active.startHp then
				active.startHp = player:getHealth()
				log("t=%d: drag started -> arming %ds duration timer (startHp=%.3f)",
					active.ticks, active.cfg.duration, active.startHp)
			end

			local shouldAutoWalk = active.cfg.autoWalk
				or (active.ctx and active.ctx.autoWalkOverride)
			if shouldAutoWalk and not active.autoWalkFired
				and active.dragTicks >= 30
			then
				active.autoWalkFired = true
				if ZR_WalkToRandom and ZR_WalkToRandom.start then
					local ok, err = pcall(ZR_WalkToRandom.start, player)
					if not ok then log("ZR_WalkToRandom.start ERROR: %s", tostring(err)) end
				else
					log("ZR_WalkToRandom module missing")
				end
			end

			local nowHp = player:getHealth()
			if nowHp < active.startHp - 0.001 then
				log("t=%d: damage taken (%.3f -> %.3f) -> auto-end",
					active.ticks, active.startHp, nowHp)
				active.releaseSent = true
				pcall(function()
					if player.setDoGrappleLetGo then
						player:setDoGrappleLetGo()
					end
				end)
				return
			end

			if active.cfg.outroDuration and active.cfg.outroDuration > 0
				and not active.outroActivated
			then
				local effDur = active.effectiveDuration or active.cfg.duration
				local outroStartTick = (effDur - active.cfg.outroDuration) * 60
				if active.dragTicks >= outroStartTick then
					active.outroActivated = true
					local flagVar = "ZR_" .. active.sceneId .. "Outro"
					log("t=%d: outro stage starting (%ds remaining) -> %s=true",
						active.ticks, active.cfg.outroDuration, flagVar)
					pcall(function() player:setVariable(flagVar, "true") end)
				end
			end

			if active.outroActivated and not active.outroFinished then
				local clipEnded = false
				pcall(function()
					clipEnded = player:getVariableString("ZR_OutroClipEnded") == "true"
				end)
				if clipEnded then
					active.outroFinished   = true
					active.outroFinishedBy = "clip"
					log("t=%d: outro clip ended (engine End event, dragTicks=%d) -> auto-end",
						active.ticks, active.dragTicks)
					active.releaseSent = true
					pcall(function()
						if player.setDoGrappleLetGo then
							player:setDoGrappleLetGo()
						end
					end)
					return
				end
			end

			local expectOutroClip = active.chainHasOutro == true
				or active.outroClipExpected == true
			local effDur2 = active.effectiveDuration or active.cfg.duration
			local durationTicks = effDur2 * 60
			if active.outroActivated and expectOutroClip then
				durationTicks = durationTicks
					+ ((active.cfg.outroDuration or 0) * 60) + 120
			end
			if active.dragTicks >= durationTicks then
				if active.outroActivated and not active.outroFinished then
					active.outroFinished   = true
					active.outroFinishedBy = expectOutroClip and "watchdog" or "duration"
					if expectOutroClip then
						log("t=%d: WATCHDOG - outro clip-end event never arrived; completing by deadline",
							active.ticks)
					end
				end
				log("t=%d: duration elapsed (%d/%d drag ticks) -> auto-end",
					active.ticks, active.dragTicks, durationTicks)
				active.releaseSent = true
				pcall(function()
					if player.setDoGrappleLetGo then
						player:setDoGrappleLetGo()
					end
				end)
				return
			end
		end

		return
	end

	if active.draggingObserved then
		log("t=%d: drag ended (was dragging, now not) -> firing onEnd",
			active.ticks)
		ZomboRutScene._fireEnd(player, active, "Released")
		return
	end

	local snow = getTimestampMs()
	local sgap = active.startTimeoutLastMs and (snow - active.startTimeoutLastMs) or 0
	active.startTimeoutLastMs = snow
	if sgap > 0 and sgap <= 400 then active.startTimeoutMsLeft = active.startTimeoutMsLeft - sgap end
	if active.startTimeoutMsLeft <= 0 then
		log("t=%d: TIMEOUT - engine never entered drag state", active.ticks)
		log("       last state=%s sgt=%q sgan=%q",
			snap.state, snap.sgt, snap.sgan)
		ZomboRutScene._fireEnd(player, active, "Failed")
	end
end)

Events.OnDeadBodySpawn.Add(function(body)
	if not body then return end
	local marker
	pcall(function()
		local md = body.getModData and body:getModData()
		marker = md and md._zrHusk
	end)
	if marker ~= true then return end

	for _, _act in pairs(ZomboRutScene._active or {}) do
		if _act then return end
	end

	pcall(function()
		if body.setAlpha       then body:setAlpha(0.00392)       end
		if body.setTargetAlpha then body:setTargetAlpha(0.00392) end
		if body.setDoRender    then body:setDoRender(false)      end
	end)

	local fired = false
	local function tickFn()
		if fired then return end
		fired = true
		Events.OnPlayerUpdate.Remove(tickFn)
		pcall(function()
			if body and body.getCurrentSquare then
				despawnAndVerify(body, "leak-husk")
			end
		end)
	end
	Events.OnPlayerUpdate.Add(tickFn)
end)

Events.OnZombieUpdate.Add(function(zombie)
	if not zombie or not (zombie.isReanimatedPlayer and zombie:isReanimatedPlayer()) then return end
	if zombie.isBeingGrappled and zombie:isBeingGrappled() then return end

	local tagged = false
	pcall(function()
		local md = zombie.getModData and zombie:getModData()
		tagged = md ~= nil and md._zrHusk == true
	end)
	if not tagged then return end

	pcall(function()
		if zombie.setReanimatedPlayer then zombie:setReanimatedPlayer(false) end
		if zombie.setReanimatedForGrappleOnly then zombie:setReanimatedForGrappleOnly(false) end
		local md = zombie.getModData and zombie:getModData()
		if md then md._zrHusk = nil end
	end)
	despawnAndVerify(zombie, "ghost-partner")
end)

Events.LoadGridsquare.Add(function(sq)
	if not sq then return end
	local statics = sq.getStaticMovingObjects and sq:getStaticMovingObjects()
	if not statics or not statics.size or statics:size() == 0 then return end
	for i = statics:size() - 1, 0, -1 do
		local o = statics:get(i)
		if o and instanceof(o, "IsoDeadBody") then
			local tagged = false
			pcall(function()
				local md = o.getModData and o:getModData()
				tagged = md ~= nil and md._zrHusk == true
			end)
			if tagged then
				despawnAndVerify(o, "chunk-husk-corpse")
			end
		end
	end
end)

Events.OnSave.Add(function()
	for player, active in pairs(ZomboRutScene._active) do
		if active then
			log("OnSave: scene %s still active — forcing teardown before world save",
				tostring(active.sceneId))
			ZomboRutScene.endScene(player, "Released")
		end
	end
end)

local function clearStaleSceneOnLoad()
	local player = getPlayer()
	if not player then return end

	local staleSceneId, staleActive = "", ""
	pcall(function() staleSceneId = player:getVariableString("ZR_SceneId")     or "" end)
	pcall(function() staleActive  = player:getVariableString("ZR_SceneActive") or "" end)
	if staleSceneId == "" and staleActive ~= "true" then return end

	log("LoadCleanup: stale scene detected (id=%q active=%q) — forcing teardown",
		staleSceneId, staleActive)

	if staleSceneId ~= "" then
		local cfg = ZomboRutScene._scenes and ZomboRutScene._scenes[staleSceneId]
		if cfg then ZomboRutScene._clearChainVars(player, staleSceneId, cfg) end
	end

	pcall(function() player:setVariable("ZR_SceneId",       "") end)
	pcall(function() player:setVariable("ZR_SceneActive",   "") end)
	pcall(function() player:setVariable("ZR_IntroDone",     "") end)
	pcall(function() player:setVariable("ZR_BridgePending", "") end)
	pcall(function() player:setVariable("ZR_PromotedSgt",   "") end)

	pcall(function() if player.LetGoOfGrappled     then player:LetGoOfGrappled("LoadCleanup") end end)
	pcall(function() if player.setSharedGrappleType then player:setSharedGrappleType("")      end end)
	pcall(function() if player.setIgnoreMovement   then player:setIgnoreMovement(false)       end end)
	pcall(function() if player.setInvincible       then player:setInvincible(false)           end end)

	local cell  = getCell()
	local zList = cell and cell:getZombieList()
	if zList then
		local released = 0
		for i = 0, zList:size() - 1 do
			local z = zList:get(i)
			if z and z:isAlive() and z:DistTo(player) < 8 then
				local wasGrappled = false
				pcall(function() wasGrappled = z:getVariableBoolean("bBeingGrappled") end)
				if wasGrappled then
					pcall(function() z:setVariable("bBeingGrappled", "false") end)
					pcall(function() if z.setCollidable then z:setCollidable(true) end end)
					pcall(function() if z.setInvincible then z:setInvincible(false) end end)
					released = released + 1
				end
				pcall(function() z:setVariable("ZR_SceneId", "") end)
			end
		end
		if released > 0 then
			log("LoadCleanup: released %d grappled zombie(s) within 8 tiles", released)
		end
	end

	log("LoadCleanup: complete")
end

Events.OnGameStart.Add(clearStaleSceneOnLoad)

return ZomboRutScene
