-- @author DoomTero 2026

require "TimedActions/ISZomboRutSceneAction"

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

local DEBUG = false
local function log(fmt, ...)
	if DEBUG then print(string.format("[ZR Scene] " .. fmt, ...)) end
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 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 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")
	cfg.id = sceneId
	cfg.label = cfg.label or sceneId
	cfg.pickup = cfg.pickup or "vanilla"
	if cfg.pickup == "skip" and not cfg.promotedType then
		cfg.promotedType = "BwdDragHead"
	end
	ZomboRutScene._scenes[sceneId] = cfg
	log("registered scene: %s (pickup=%s)", sceneId, cfg.pickup)
	return true
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._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

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._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 partner and partner.isFemale 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
	if ZomboRutScene._scenes[assumedId] then
		return assumedId
	end

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

	if ZomboRutScene._scenes[sceneId] then
		return sceneId
	end

	return nil
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)
	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
	if grace then
		wasInvincible    = grace.wasInvincible
		bystanderZombies = grace.bystanderZombies 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 = {}
		pcall(function()
			if player.isInvincible then wasInvincible = player:isInvincible() end
		end)
	end

	ZomboRutScene._active[player] = {
		sceneId            = sceneId,
		cfg                = cfg,
		body               = body,
		ctx                = {},
		draggingObserved   = false,
		startTimeoutTicks  = 120,
		wasInvincible      = wasInvincible,
		bystanderZombies   = bystanderZombies,
	}
	pcall(function() if player.setInvincible then player:setInvincible(true) end end)

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

	pushZombiesAwayFromPlayer(player, 3)

	local rolledChain = ZomboRutScene._rollChain(player, sceneId, cfg)
	if rolledChain then
		ZomboRutScene._active[player].chain = rolledChain
		log("scene %s rolled chain=%s", sceneId, rolledChain)
	end

	pcall(function()
		player:setVariable("ZR_" .. sceneId .. "Outro", "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 function snapshotLiveTarget(target)
	return {
		x   = target:getX(),
		y   = target:getY(),
		z   = target:getZ(),
		dir = target:getDir(),
	}
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
	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,
	ItemBodyLocation.UNDERWEAR_EXTRA1,
	ItemBodyLocation.UNDERWEAR_EXTRA2,
}
local LOWER_BODY_LOC_NAMES = {
	"Pants", "Pants_Skinny", "PantsExtra",
	"ShortPants", "ShortsShort",
	"Skirt", "LongSkirt",
	"Dress", "LongDress",
	"UnderwearBottom", "Underwear",
	"UnderwearExtra1", "UnderwearExtra2",
}

function ZomboRutScene._stripLowerBody(character, outStripped)
	if not character 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
		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
		if removed > 0 then
			if character.resetModel          then character:resetModel()          end
			if character.resetModelNextFrame then character:resetModelNextFrame() end
		end
	end)
	return removed
end

local TORSO_LOC_NAMES = {
	"Shirt", "TShirt", "ShortSleevedShirt",
	"Tank", "TankTop",
	"Sweater", "Jacket",
	"Top",
}

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 _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 _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

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

	local skipNames = {}
	for _, name in ipairs(LOWER_BODY_LOC_NAMES) do skipNames[name] = true end
	if _hasTraitSafe(player, "submissive") then
		for _, name in ipairs(TORSO_LOC_NAMES) do skipNames[name] = true end
	end

	local eligible = {}
	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 wi then
				local item = wi:getItem()
				local loc  = wi:getLocation()
				if item and loc and not skipNames[loc]
				   and not _isFaceSlot(loc)
				   and not _isShoeSlot(loc)
				then
					eligible[#eligible + 1] = item
				end
			end
		end
	end)

	if #eligible == 0 then return nil end

	local pick = eligible[ZombRand(#eligible) + 1]
	local disposal = _resolveDisposal(player, baseMode)

	pcall(function() player:removeWornItem(pick) end)
	_disposeStripped(player, pick, disposal)

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

	return pick
end

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

	local lowerItem
	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 then
				lowerItem = item
				return
			end
		end
	end)

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

	return ZomboRutScene._dropRandomNonLowerItem(player, baseMode)
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._stripLowerBodyVisuals(zombie)
	if not zombie or not zombie.getItemVisuals then return 0 end
	local removed = 0
	pcall(function()
		local iv = zombie:getItemVisuals()
		if not iv or not iv.size then return end
		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
		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)
	if not snapshot then return 0 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)
	if not brain or type(brain.clothing) ~= "table" then return 0 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

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
	end
	if not (Bandit and BanditCustom and BanditBrain) then
		log("[v3.2] playSceneFromLive abort: Bandits mod not loaded — live-target requires it")
		return false
	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
	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
	end
	if ZomboRutScene._active[player] then
		log("[v3.2] playSceneFromLive abort: scene already active for player")
		return false
	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(target, "playSceneFromLive:" .. safeId(target))

	local hideX = snap.x + OFF_SCREEN_OFFSET
	local hideY = snap.y + OFF_SCREEN_OFFSET
	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)
	log("[v3.2] STEP B dummy teleported to off-screen %.2f,%.2f", hideX, hideY)

	pcall(function() ZomboRutScene._stripLowerBody(target) end)
	if origBrain then
		local origRemoved = ZomboRutScene._stripBrainClothing(origBrain)
		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 spawnX = player:getX() + pdirX * 0.6
	local spawnY = player:getY() + pdirY * 0.6
	local spawnZ = player:getZ()
	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)
		pcall(function() if target.teleportTo then target:teleportTo(snap.x, snap.y, snap.z) end end)
		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 and fresh.setFemale then
			fresh:setFemale(target:isFemale())
		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
		log("[v3.2] STEP E WARN no brain to clone — fresh will be nude")
	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 = fresh:becomeCorpseSilently() 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)
		pcall(function() if target.teleportTo then target:teleportTo(snap.x, snap.y, snap.z) end end)
		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.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)
	if not started then
		log("[v3.2] STEP L playSceneFromCorpse refused — cleanup + restore")
		pcall(function() if body.removeFromWorld  then body:removeFromWorld()  end end)
		pcall(function() if target.teleportTo then target:teleportTo(snap.x, snap.y, snap.z) end end)
		return false
	end

	ZomboRutScene._active[player].liveTargetSnapshot = snap
	ZomboRutScene._active[player].liveOriginal      = target
	ZomboRutScene._active[player].corpseHusk        = body
	ZomboRutScene._active[player].brainClone        = brainClone
	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
	end
	local resolvedId = ZomboRutScene._resolveGender(player, attacker, sceneId)
	if not resolvedId then
		log("playSceneFromAttacker abort: unknown scene %s", tostring(sceneId))
		return false
	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
	end
	if ZomboRutScene._active[player] then
		log("playSceneFromAttacker abort: scene already active")
		return false
	end

	auditPlayerTile(player, "playSceneFromAttacker-pre")

	local snap = {
		x = attacker:getX(),
		y = attacker:getY(),
		z = attacker:getZ(),
		dir = attacker:getDir(),
	}
	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 attackerVisualsSnapshot = ZomboRutScene._snapshotItemVisuals(attacker)
	local snapshotStripped = ZomboRutScene._stripSnapshotLowerBody(attackerVisualsSnapshot)
	log("[Attacker] STEP A.5 ItemVisuals snapshot taken (count=%d, stripped=%d)",
		#attackerVisualsSnapshot, snapshotStripped)

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

	local hideX = snap.x + 50
	local hideY = snap.y + 50
	pcall(function()
		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()
		attacker:setUseless(true)
		attacker:setNoTeeth(true)
		attacker:setCanWalk(false)
		attacker:clearAggroList()
		attacker:setTarget(nil)
	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 origWorn   = ZomboRutScene._stripLowerBody(attacker)
	local origVisual = ZomboRutScene._stripLowerBodyVisuals(attacker)
	log("[Attacker] STEP B.1 original stripped (worn=%d visuals=%d)",
		origWorn, origVisual)

	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)
	local spawnX = player:getX() + pdirX * 0.6
	local spawnY = player:getY() + pdirY * 0.6
	local spawnZ = player:getZ()
	log("[Attacker] STEP C spawn pos = player_pos + forward*0.6 = %.2f,%.2f (player_fwd=%.2f,%.2f)",
		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")
		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() attacker:setUseless(false); attacker:setNoTeeth(false); attacker:setCanWalk(true) end)
		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)

	pcall(function()
		if attacker.isFemale and fresh.setFemale then
			fresh:setFemale(attacker:isFemale())
		end
		if 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 = fresh:becomeCorpseSilently() 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)
		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() attacker:setUseless(false); attacker:setNoTeeth(false); attacker:setCanWalk(true) end)
		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.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)
	if not started then
		log("[Attacker] STEP L scene refused — cleanup + restore")
		pcall(function() if body.removeFromWorld then body:removeFromWorld() end end)
		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() attacker:setUseless(false); attacker:setNoTeeth(false); attacker:setCanWalk(true) end)
		return false
	end

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

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

	pcall(function()
		if player.setDoGrappleLetGo then player:setDoGrappleLetGo() end
	end)

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

local function restoreLiveTarget(snapshot, partner, husk, original, player)
	log("restoreLiveTarget: husk=%s partner=%s original=%s",
		safeId(husk), safeId(partner), safeId(original))
	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)

	despawnAndVerify(partner, "partner-clone")

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

	pcall(function()
		local rx, ry, rz = snapshot.x, snapshot.y, snapshot.z
		if player then
			rx, ry, rz = player:getX(), player:getY(), player:getZ()
		end
		if original.teleportTo then
			original:teleportTo(rx, ry, rz)
		end
		original:setX(rx); original:setY(ry); original:setZ(rz)
		if original.setCurrentSquareFromPosition then
			pcall(function() original:setCurrentSquareFromPosition() end)
		end
		if snapshot.dir and original.setDir then original:setDir(snapshot.dir) end

		log("[v3.2] restoreLiveTarget: original teleported to player pos %.2f,%.2f id=%s",
			rx, ry, tostring(original:getID()))

		local md = original:getModData()
		local brain = md and md.brain
		local isBandit = 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.setNoTeeth then original:setNoTeeth(false) end
				if original.setCanWalk then original:setCanWalk(true)  end
			end)
			pcall(function()
				if original.resetModel          then original:resetModel()          end
				if original.resetModelNextFrame then original:resetModelNextFrame() end
			end)
			log("[v3.2] restoreLiveTarget: plain zombie id=%s un-pacified + alpha=1 + model reset",
				safeId(original))
		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
		end
	end)

	auditPlayerTile(player, "restore-end")
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)
	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

	ZomboRutScene._postSceneGrace[player] = {
		ticksRemaining   = 60,
		wasInvincible    = active.wasInvincible,
		bystanderZombies = active.bystanderZombies or {},
	}
	log("transitioning to post-scene grace (60 ticks, %d bystander(s) tracked)",
		(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)

	ZomboRutScene._active[player] = nil
end

local function _endPostSceneGrace(player, grace)
	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 z.setUseless    then z:setUseless(false)   end
				if z.setNoTeeth    then z:setNoTeeth(false)   end
				restored = restored + 1
			end
		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) + setInvincible(%s)",
		restored, tostring(grace.wasInvincible))
	auditPlayerTile(player, "grace-end")
end

local function _pacifyNearbyZombies(player, partner, bystanderZombies)
	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 nearPlayer  = z:DistTo(player) <= 1.5
			local nearPartner = partner and z:DistTo(partner) <= 1.5
			if nearPlayer or nearPartner then
				z:clearAggroList()
				z:setTarget(nil)
				if z.setUseless    then z:setUseless(true)     end
				if z.setNoTeeth    then z:setNoTeeth(true)     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

ZomboRutScene.registerSceneStartCallback(function(player, active)
	local sceneId = active and active.sceneId or ""
	local isDefeat = type(sceneId) == "string" and sceneId:find("Defeat", 1, true)
	local stripMode = (SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.clothingStripMode) or 2

	local removedB = ZomboRutScene._stripLowerBody(active and active.body)

	if isDefeat then
		local dropped = ZomboRutScene._stripOneDefeatItem(player, stripMode)
		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 dropped=%s body_removed=%d body=%s",
			stripMode, 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=false mode=%d player_removed=%d body_removed=%d body=%s",
			stripMode, 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
		grace.ticksRemaining = grace.ticksRemaining - 1
		if grace.ticksRemaining <= 0 then
			_endPostSceneGrace(player, grace)
			return
		end
		pcall(_pacifyNearbyZombies, player, nil, grace.bystanderZombies)
		return
	end

	if not active then return end

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

	pcall(_pacifyNearbyZombies, player, active.partnerRef, active.bystanderZombies)

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

	if active.partnerRef then
		if not active.partnerAnchor then
			active.partnerAnchor = {
				qx = active.partnerRef:getX(),
				qy = active.partnerRef:getY(),
				qz = active.partnerRef:getZ(),
			}
			log("[ZR anchor] partner captured @ (%.2f,%.2f,%.2f) (intro=%s)",
				active.partnerAnchor.qx, active.partnerAnchor.qy,
				active.partnerAnchor.qz, 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 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" 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
			log("[v3.2] partner detected: id=%s (from husk_id=%s)",
				safeId(target), safeId(active.corpseHusk))

			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)

			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

			if active.liveTargetSnapshot and active.brainClone then
				pcall(function()
					ZomboRutScene._stripBrainClothing(active.brainClone)
					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
			pcall(function() ZomboRutScene._stripLowerBody(target) end)
			pcall(function() ZomboRutScene._stripLowerBodyVisuals(target) 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
				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)
				active.partnerRevealed = true
				log("[v3.2] partner revealed (sgan=%s)", zsgan)
			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
			active.dragTicks = (active.dragTicks or 0) + 1

			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 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 outroStartTick = (active.cfg.duration - 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

			local durationTicks = active.cfg.duration * 60
			if active.dragTicks >= durationTicks then
				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

	active.startTimeoutTicks = active.startTimeoutTicks - 1
	if active.startTimeoutTicks <= 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)

return ZomboRutScene
