freeslot("sfx_swipe", "sfx_thrust")

freeslot("S_PLAY_BSCOOL", "SPR2_GRND")

states[S_PLAY_BSCOOL] = {
    sprite = SPR_PLAY,
    frame = FF_ANIMATE | SPR2_GRND,
    tics = -1,
    var1 = 5,
    var2 = 2,
    nextstate = S_PLAY_SPRING,
}



// --- BS Homing-to-Grind (nodes) ---
// We pre-spawn invisible nodes along ML_EFFECT4 rails in LUA_GRND.
// When no normal homing target exists, we can lock onto the nearest node.

local function abs(x) if x < 0 then return -x else return x end end

local function BS_GetGrindNodeType()
	return rawget(_G, "MT_BS_GRINDNODE")
end

local function BS_IsGrindNode(mo)
	local t = BS_GetGrindNodeType()
	return (t ~= nil and mo and mo.valid and mo.type == t)
end

// Rail / grind-node targeting range.
// User request: limit rail targeting to within 500 FRACUNITS.
local BS_GRINDNODE_LOCKDIST = 500*FRACUNIT      // max distance to allow node lock-on
local BS_GRINDNODE_MINFWD   = 64*FRACUNIT       // minimum forward distance in front of player to allow node lock-on
local BS_GRINDNODE_MAXZDIFF = 256*FRACUNIT      // vertical clamp (prevents weird locks far above/below)

// Facing requirement for rail lock-on.
// Only allow nodes within a forward cone of the player's facing angle.
// Default: +/-45 degrees (90 degrees total).
// For wider lock-on, try ANGLE_67h (135 total) or ANGLE_90 (180 total).
local BS_GRINDNODE_FOVHALF = ANGLE_45
local BS_GRINDNODE_FOVCOS  = cos(BS_GRINDNODE_FOVHALF)

-- ------------------------------------------------------------
-- Rail targeting toggles (saved)
-- ------------------------------------------------------------
-- normalrailtarget on/off  : linedef rail homing
-- gsrailtarget on/off      : GS rail homing
-- allrailtarget on/off     : both

local cv_normalrailtarget
local cv_gsrailtarget

if CV_RegisterVar then
	-- Saved to config, client-side preference.
	cv_normalrailtarget = CV_RegisterVar({
		name = "normalrailtarget",
		defaultvalue = "On",
		flags = CV_SAVE|CV_SHOWMODIF,
		PossibleValue = CV_OnOff
	})

	cv_gsrailtarget = CV_RegisterVar({
		name = "gsrailtarget",
		defaultvalue = "On",
		flags = CV_SAVE|CV_SHOWMODIF,
		PossibleValue = CV_OnOff
	})
end

local function BS_NormalRailTargetEnabled()
	return (not cv_normalrailtarget) or (cv_normalrailtarget.value ~= 0)
end

local function BS_GSRailTargetEnabled()
	return (not cv_gsrailtarget) or (cv_gsrailtarget.value ~= 0)
end

if COM_AddCommand then
	COM_AddCommand("allrailtarget", function(p, arg)
		if not (p and p.valid) then return end

		if arg == nil then
			CONS_Printf(p, "[BS] usage: allrailtarget on/off")
			CONS_Printf(p, "[BS] normalrailtarget="..(BS_NormalRailTargetEnabled() and "ON" or "OFF").." gsrailtarget="..(BS_GSRailTargetEnabled() and "ON" or "OFF"))
			return
		end

		local a = string.lower(tostring(arg))
		local v
		if (a == "on") or (a == "1") or (a == "true") then v = "On" end
		if (a == "off") or (a == "0") or (a == "false") then v = "Off" end
		if v == nil then
			CONS_Printf(p, "[BS] usage: allrailtarget on/off")
			return
		end

		-- Use console buffer so CV_SAVE persists exactly like manual console use.
		if COM_BufInsertText then
			COM_BufInsertText(p, "normalrailtarget "..v)
			COM_BufInsertText(p, "gsrailtarget "..v)
		end

		-- Clear any cached rail target so you don't keep a stale rail when turning off.
		if v == "Off" then
			p.bs_airlock_target = nil
			p.bs_gn_cached = nil
			p.bs_rh_lastnode = nil
			p.bs_rh_lastlinedefnode = nil
			p.bs_rh_lastlinedefsig = nil
		end

		CONS_Printf(p, "[BS] all rail targeting "..(v == "On" and "ENABLED" or "DISABLED"))
	end)
end


-- ------------------------------------------------------------
-- Linedef-rail homing debug + ignore filters (NON-GS rails only)
-- ------------------------------------------------------------
-- Commands:
--   bs_railhoming_debug [0/1]
--   bs_railhoming_last
--   bs_railhoming_ignore_tag <tag>
--   bs_railhoming_ignore_special <special>
--   bs_railhoming_ignore_midtex <textureid>
--   bs_railhoming_ignore_flagmask <mask>     (reject if ANY bits set)
--   bs_railhoming_ignore_last_tag / _special / _midtex
--   bs_railhoming_listignore
--   bs_railhoming_clearignore

local BS_RH_DEBUG = 0

local BS_RH_IGNORE = {
    tags = {},
    specials = {},
    midtex = {},
    sigs = {},
    segs = {},
    flagmask = 0,
}

-- Built-in ignore list for specific linedef rail segments (from your dump).
-- Format is "x1,y1,x2,y2" with endpoints normalized so order doesn't matter.
local BS_RH_BUILTIN_SEGS = {
    ["-3904,544,-3712,544"] = true,
    ["-3712,544,-3392,544"] = true,
    ["-3392,544,-3264,544"] = true,
}

local function BS_RH_SegKey(x1, y1, x2, y2)
    if x1 == nil or y1 == nil or x2 == nil or y2 == nil then return nil end
    -- normalize endpoint order
    if (x1 > x2) or (x1 == x2 and y1 > y2) then
        local tx, ty = x1, y1
        x1, y1 = x2, y2
        x2, y2 = tx, ty
    end
    return tostring(x1)..","..tostring(y1)..","..tostring(x2)..","..tostring(y2)
end

local function BS_RH_SegKeyFromLine(line)
    if not (line and line.v1 and line.v2) then return nil end
    return BS_RH_SegKey(
        FixedInt(line.v1.x), FixedInt(line.v1.y),
        FixedInt(line.v2.x), FixedInt(line.v2.y)
    )
end


local function BS_RH_ToNum(v)
    if v == nil then return nil end
    if type(v) == "number" then return v end
    if type(v) == "string" then
        return tonumber(v)
    end
    return nil
end

local function BS_RH_LineMidTex(line)
    if not line then return 0 end
    local fs = line.frontside
    if fs and fs.midtexture ~= nil then return fs.midtexture end
    local bs = line.backside
    if bs and bs.midtexture ~= nil then return bs.midtexture end
    return 0
end

local function BS_RH_IsIgnoredLine(line)
    if not line then return false end

    local sk = BS_RH_SegKeyFromLine(line)
    if sk and (BS_RH_BUILTIN_SEGS[sk] or BS_RH_IGNORE.segs[sk]) then return true end

    local tag = line.tag or 0
    if BS_RH_IGNORE.tags[tag] then return true end

    local sp = line.special or 0
    if BS_RH_IGNORE.specials[sp] then return true end

    local mt = BS_RH_LineMidTex(line) or 0
    if BS_RH_IGNORE.midtex[mt] then return true end

    local sig = tostring(tag)..":"..tostring(sp)..":"..tostring(line.flags or 0)..":"..tostring(mt)
    if BS_RH_IGNORE.sigs[sig] then return true end

    local mask = BS_RH_IGNORE.flagmask or 0
    if mask ~= 0 and (line.flags & mask) ~= 0 then
        return true
    end

    return false
end

local function BS_RH_PrintLineInfo(p, node, label)
    if not (p and p.valid) then return end
    if not (node and node.valid) then
        CONS_Printf(p, "[BS][RH] "..label.." (no node)")
        return
    end
    local line = node.bs_grindline
    if not line then
        CONS_Printf(p, "[BS][RH] "..label.." (node has no bs_grindline)")
        return
    end

    local tag = line.tag or 0
    local sp  = line.special or 0
    local fl  = line.flags or 0

    local fmid = (line.frontside and line.frontside.midtexture) or 0
    local ftop = (line.frontside and line.frontside.toptexture) or 0
    local fbot = (line.frontside and line.frontside.bottomtexture) or 0
    local bmid = (line.backside and line.backside.midtexture) or 0
    local btop = (line.backside and line.backside.toptexture) or 0
    local bbot = (line.backside and line.backside.bottomtexture) or 0

    local ff = (line.frontsector and line.frontsector.floorheight) or 0
    local fc = (line.frontsector and line.frontsector.ceilingheight) or 0
    local bf = (line.backsector and line.backsector.floorheight) or 0
    local bc = (line.backsector and line.backsector.ceilingheight) or 0

    local vinfo = ""
    if line.v1 and line.v2 then
        vinfo = " v1=("..tostring(FixedInt(line.v1.x))..","..tostring(FixedInt(line.v1.y))..")"
            .." v2=("..tostring(FixedInt(line.v2.x))..","..tostring(FixedInt(line.v2.y))..")"
    end

    CONS_Printf(p, "[BS][RH] "..label
        .." tag="..tostring(tag)
        .." sp="..tostring(sp)
        .." fl="..tostring(fl)
        .." tex(fmid="..tostring(fmid).." ftop="..tostring(ftop).." fbot="..tostring(fbot)
        .." bmid="..tostring(bmid).." btop="..tostring(btop).." bbot="..tostring(bbot)..")"
        .." z(ff="..tostring(FixedInt(ff)).." fc="..tostring(FixedInt(fc))
        .." bf="..tostring(FixedInt(bf)).." bc="..tostring(FixedInt(bc))..")"..vinfo
    )
end

local function BS_RH_LineSig(node)
    if not (node and node.valid) then return "" end
    local line = node.bs_grindline
    if not line then return "" end
    local tag = line.tag or 0
    local sp  = line.special or 0
    local fl  = line.flags or 0
    local mt  = BS_RH_LineMidTex(line) or 0
    return tostring(tag)..":"..tostring(sp)..":"..tostring(fl)..":"..tostring(mt)
end
local function BS_RH_PrintNodeBrief(p, node, label)
    if not (p and p.valid) then return end
    if not (node and node.valid) then
        CONS_Printf(p, "[BS][RH] "..label.." (no node)")
        return
    end

    local kind = "other"
    if node.bs_grindline ~= nil then
        kind = "linedef"
    elseif node.bs_gsrailnum ~= nil then
        kind = "gs"
    end

    CONS_Printf(p, "[BS][RH] "..label.." kind="..kind
        .." x="..tostring(FixedInt(node.x))
        .." y="..tostring(FixedInt(node.y))
        .." z="..tostring(FixedInt(node.z))
        .." fuse="..tostring(node.fuse or 0)
    )
end




local BS_GRINDNODE_SCAN_INTERVAL = 5  // tics between expensive node searches (airborne only)
local BS_AIRLOCK_SCAN_INTERVAL = 8  // tics between expensive lock-on scans (perf)

local function BS_GetFacingAngle(p)
	-- drawangle matches the player's intended facing, but Ninja Moves temporarily spin drawangle for visuals.
	-- While a Ninja Move is running, use the stored angle (or mo.angle) so targeting doesn't thrash.
	if p and p.KickStunts and p.KickStunts.kicking and p.KickStunts.vars and p.KickStunts.vars.storedangle ~= nil then
		return p.KickStunts.vars.storedangle
	end
	if p and p.drawangle ~= nil then return p.drawangle end
	if p and p.mo and p.mo.valid then return p.mo.angle end
	return 0
end


-- ------------------------------------------------------------
-- Standard homing target filters (non-rail targets)
-- Prevents locking to enemies/monitors/springs from across the map or behind the player.
-- ------------------------------------------------------------
local BS_HOMING_LOCKDIST = 1024*FRACUNIT   -- max range for normal homing targets
local BS_HOMING_FOVHALF  = ANGLE_45        -- must be within this cone in front of player
local BS_HOMING_FOVCOS   = cos(BS_HOMING_FOVHALF)

local function BS_IsNormalTargetEligible(p, mo, t, fcos, fsin)
	if not (t and t.valid and t.health) then return false end
	if not (mo and mo.valid) then return false end

	-- Planar distance check (scaled with player)
	local dx = t.x - mo.x
	local dy = t.y - mo.y
	local dxy = P_AproxDistance(dx, dy)

	local maxd = FixedMul(BS_HOMING_LOCKDIST, mo.scale)
	if dxy > maxd then return false end

	-- Facing / cone check
	-- dot is "how far forward" the target is in player-facing space
	local dot = FixedMul(dx, fcos) + FixedMul(dy, fsin)
	if dot < 0 then return false end
	if dot < FixedMul(dxy, BS_HOMING_FOVCOS) then return false end

	return true
end

local function BS_NormalTargetEligibleNow(p, t)
	if not (p and p.valid and p.mo and p.mo.valid) then return false end
	local ang = BS_GetFacingAngle(p)
	return BS_IsNormalTargetEligible(p, p.mo, t, cos(ang), sin(ang))
end



local function BS_QuickNodeEligible(p, mo, n, fcos, fsin, MT_BS_GRINDNODE_CONST)
	if not (n and n.valid) then return false end
	if n.type ~= MT_BS_GRINDNODE_CONST then return false end
	if (n.bs_grindline == nil) and (n.bs_gsrailnum == nil) then return false end
	if (n.bs_gsrailnum ~= nil) and (n.bs_gsrail ~= nil) and (not n.bs_gsrail.valid) then return false end
	-- Rail targeting toggles
	if n.bs_gsrailnum ~= nil then
		if not BS_GSRailTargetEnabled() then return false end
	elseif n.bs_grindline ~= nil then
		if not BS_NormalRailTargetEnabled() then return false end
	end

    -- Apply linedef-rail ignore filters (does not affect GS rails).
    if n.bs_grindline ~= nil then
        if BS_RH_IsIgnoredLine(n.bs_grindline) then return false end
    end

	local dx = n.x - mo.x
	local dy = n.y - mo.y
	local dist2d = P_AproxDistance(dx, dy)
	if dist2d <= 0 then dist2d = 1 end

	// must be in front (and within the forward cone)
	local dot = FixedMul(dx, fcos) + FixedMul(dy, fsin)
	if dot <= 0 then return false end
	if dot < BS_GRINDNODE_MINFWD then return false end  // must be at least 64 FRACUNITS ahead (prevents above/below/side snaps)
	if dot < FixedMul(dist2d, BS_GRINDNODE_FOVCOS) then return false end

	local dz = (n.z + n.height/2) - (mo.z + mo.height/2)

	// Do not target rails above the player.
	if dz > 0 then return false end
	if abs(dz) > BS_GRINDNODE_MAXZDIFF then return false end

	local d = P_AproxDistance(dist2d, dz)
	if d > BS_GRINDNODE_LOCKDIST then return false end

	return true, d
end

local function BS_IsNodeEligibleNow(p, node)
	if not (p and p.valid and p.mo and p.mo.valid and node and node.valid) then return false end
	local MT = BS_GetGrindNodeType()
	if MT == nil then return false end
	local fang = BS_GetFacingAngle(p)
	local ok = BS_QuickNodeEligible(p, p.mo, node, cos(fang), sin(fang), MT)
	return ok and true or false
end


local function BS_FindClosestGrindNode(p, needsSight)
	local MT_BS_GRINDNODE_CONST = BS_GetGrindNodeType()
	if MT_BS_GRINDNODE_CONST == nil then return nil end

	local mo = p.mo
	if not (mo and mo.valid) then return nil end

	// Only do the expensive search every few tics while airborne.
	// Between scans, reuse the cached node if it still satisfies the cheap eligibility checks.
	if (p.pflags & PF_JUMPED) and (p.bs_gn_scan_cd and p.bs_gn_scan_cd > 0) then
		p.bs_gn_scan_cd = $-1
		local cached = p.bs_gn_cached
		if cached and cached.valid then
			local fang = BS_GetFacingAngle(p)
			local fcos = cos(fang)
			local fsin = sin(fang)
			local ok = BS_QuickNodeEligible(p, mo, cached, fcos, fsin, MT_BS_GRINDNODE_CONST)
			if ok then
				return cached
			end
		end
	else
		// reset countdown when grounded / not jumping
		if not (p.pflags & PF_JUMPED) then
			p.bs_gn_scan_cd = 0
		end
	end

	local fang = BS_GetFacingAngle(p)
	local fcos = cos(fang)
	local fsin = sin(fang)

	// Keep up to 3 best candidates by distance; we'll do expensive sight checks on just these.
	local c1, c2, c3
	local d1, d2, d3

	local function Consider(n, d)
		if d1 == nil or d < d1 then
			c3, d3 = c2, d2
			c2, d2 = c1, d1
			c1, d1 = n, d
		elseif d2 == nil or d < d2 then
			c3, d3 = c2, d2
			c2, d2 = n, d
		elseif d3 == nil or d < d3 then
			c3, d3 = n, d
		end
	end

	// PERFORMANCE: search nearby blockmap cells, not the entire global node list.
	local dist = BS_GRINDNODE_LOCKDIST
	if searchBlockmap then
		searchBlockmap("objects", function(refmo, n)
			local ok, d = BS_QuickNodeEligible(p, mo, n, fcos, fsin, MT_BS_GRINDNODE_CONST)
			if ok then
				Consider(n, d)
			end
		end, mo, mo.x - dist, mo.x + dist, mo.y - dist, mo.y + dist)
	else
		// Fallback: iterate the global list (slower, but safe).
		local list = rawget(_G, "BS_GRINDNODE_LIST")
		if list then
			for i = 1, #list do
				local n = list[i]
				local ok, d = BS_QuickNodeEligible(p, mo, n, fcos, fsin, MT_BS_GRINDNODE_CONST)
				if ok then
					Consider(n, d)
				end
			end
		end
	end

	// Now do line-of-sight checks only when requested (expensive).
	local best = c1

	if needsSight then
		best = nil
		if c1 and c1.valid then
			if (not P_CheckSight) or P_CheckSight(mo, c1) then
				best = c1
			end
		end
		if best == nil and c2 and c2.valid then
			if (not P_CheckSight) or P_CheckSight(mo, c2) then
				best = c2
			end
		end
		if best == nil and c3 and c3.valid then
			if (not P_CheckSight) or P_CheckSight(mo, c3) then
				best = c3
			end
		end
	end

    -- Remember last selected node (any), and also persist last linedef-rail node separately.
    p.bs_rh_lastnode = best
    if best and best.valid and best.bs_grindline ~= nil then
        local sig = BS_RH_LineSig(best)
        p.bs_rh_lastlinedefnode = best
        p.bs_rh_lastlinedefsig = sig

        -- If debug is enabled, print when the actual targeted node changes.
        if BS_RH_DEBUG ~= 0 then
            if (BS_RH_DEBUG >= 2) or (p.bs_rh_lastprintnode ~= best) then
                p.bs_rh_lastprintnode = best
                BS_RH_PrintLineInfo(p, best, "TARGET")
            end
        end
    end

p.bs_gn_cached = best
	p.bs_gn_scan_cd = BS_GRINDNODE_SCAN_INTERVAL

	return best
end

local function BS_PickRailZNearPlayer(player, line)
    local mo = player.mo
    local bestZ
    local bestDZ

    for x = 1, 8 do
        for _, side in ipairs({line.frontside, line.backside}) do
            if side then
                local railBase = side.rowoffset + side.sector.floorheight
                local ez = railBase + (50 * x << 16)
                local dz = abs((mo.z + mo.height/2) - ez)
                if bestDZ == nil or dz < bestDZ then
                    bestDZ = dz
                    bestZ = ez
                end
            end
        end
    end

    return bestZ
end
// ===== homing stall settings =====
local HOMSTALL_ZOFF     = 8*FRACUNIT   // hover height above target
local HOMSTALL_HOLDTICS = 6            // (unused now) kept for compatibility
local HOMSTALL_BOUNCEZ  = 12*FRACUNIT  // bounce strength
local HOMSTALL_XYDIV    = 2            // keep some horizontal on bounce (2 = half)

// If true, enemies keep moving during stall (player "rides" them).
// If false, stall freezes the enemy (old behavior).
local HOMSTALL_RIDE_ENEMY = true


// If true, the enemy's angle is forced to the player's angle while stalling.
// This lets you "steer" enemies whose movement AI uses their current angle.
local HOMSTALL_CONTROL_ANGLE = true

// If true, the enemy's momentum vector is redirected to the player's angle while stalling.
// This makes controlled enemies actually MOVE in the facing direction.
local HOMSTALL_CONTROL_MOM = true


// If true, the enemy's autonomous movement is canceled during the stall,
// and the player pushes them at a constant speed.
local HOMSTALL_CONTROL_ENEMYMOVE = true
local HOMSTALL_CTRL_SPEED = 10*FRACUNIT

local HOMSTALL_CTRL_FALLSPEED = 10*FRACUNIT

// Flying enemies should not be forced downward during control.
// (Pterabyte + Buzz + Dragonbomber)
local MT_PTERABYTE_CONST = rawget(_G, "MT_PTERABYTE")
local MT_BUZZ_CONST = rawget(_G, "MT_BUZZ")
local MT_REDBUZZ_CONST = rawget(_G, "MT_REDBUZZ")
local MT_GOLDBUZZ_CONST = rawget(_G, "MT_GOLDBUZZ")
local MT_DRAGONBOMBER_CONST = rawget(_G, "MT_DRAGONBOMBER")
local MT_SNAILER_CONST = rawget(_G, "MT_SNAILER")
local function Hom_IsSnailer(mo)
	if not mo or not mo.valid then return false end
	if MT_SNAILER_CONST ~= nil and mo.type == MT_SNAILER_CONST then return true end
	// fallback: by doomednum (Snailer is thing type 114)
	if mo.info and mo.info.doomednum == 114 then return true end
	return false
end
local function Hom_IsFlightExempt(mo)
	if not mo or not mo.valid then return false end
	local t = mo.type
	if MT_PTERABYTE_CONST ~= nil and t == MT_PTERABYTE_CONST then return true end
	if MT_BUZZ_CONST ~= nil and t == MT_BUZZ_CONST then return true end
	if MT_REDBUZZ_CONST ~= nil and t == MT_REDBUZZ_CONST then return true end
	if MT_GOLDBUZZ_CONST ~= nil and t == MT_GOLDBUZZ_CONST then return true end
	if MT_DRAGONBOMBER_CONST ~= nil and t == MT_DRAGONBOMBER_CONST then return true end
	// fallback: most flying enemies ignore gravity (or use float logic)
	if MF_NOGRAVITY and (mo.flags & MF_NOGRAVITY) then return true end
	if MF_FLOAT and (mo.flags & MF_FLOAT) then return true end
	return false
end

// damage happens AFTER bounce begins (tics delay)
local HOMSTALL_DMGDELAY = 1


// make stalled-on enemies invulnerable while being ridden
addHook("MobjDamage", function(mo, inflictor, source, damage, damagetype)
	if not mo or not mo.valid then return end
	if mo.homstall_invulncount ~= nil and mo.homstall_invulncount > 0 then
		return true
	end
end)

// prevent the player from taking instant-kill / touch damage from scenery
// (ex: trees) while riding an enemy. We still allow damage coming from enemies/bosses.
addHook("ShouldDamage", function(target, inflictor, source, damage, damagetype)
	local p = target.player
	if not p then return end
	if not (p.mo and p.mo.valid and p.mo.skin == "sonic") then return end
	if not p.homstall then return end

	// allow genuine enemy/boss damage through (optional)
	if inflictor and inflictor.valid and (inflictor.flags & (MF_ENEMY|MF_BOSS)) then return end
	if source and source.valid and (source.flags & (MF_ENEMY|MF_BOSS)) then return end

	return false
end, MT_PLAYER)



// ridden enemy can damage other enemies while being ridden
local HOMSTALL_RAM_DAMAGE = 1
local HOMSTALL_RAM_COOLDOWN = 3

// simple per-mobj cooldown so we don't multi-hit the same enemy every tic
addHook("MobjThinker", function(mo)
	if not mo or not mo.valid then return end
	if mo.homstall_ramcd ~= nil and mo.homstall_ramcd > 0 then
		mo.homstall_ramcd = $-1
		if mo.homstall_ramcd < 0 then
			mo.homstall_ramcd = 0
		end
	end
end)

addHook("MobjMoveCollide", function(mo, mobj)
	if not mo or not mo.valid then return end
	if not mobj or not mobj.valid then return end
	if mobj == mo then return end

	// only when THIS mobj is being ridden in a homing stall
	if not (mo.homstall_by and mo.homstall_by.valid) then return end

	// only damage other enemies/bosses
	if not (mobj.flags & (MF_ENEMY|MF_BOSS)) then return end
	if not mobj.health then return end

	// per-target cooldown
	if mobj.homstall_ramcd ~= nil and mobj.homstall_ramcd > 0 then return end
	mobj.homstall_ramcd = HOMSTALL_RAM_COOLDOWN

	// inflictor: ridden enemy, source: player riding it
	P_DamageMobj(mobj, mo, mo.homstall_by, HOMSTALL_RAM_DAMAGE)
end)


// ===== enemy squash (visual) =====
// Uses spritexscale/spriteyscale/spriteyoffset like the player "thicc" script, but applied to the enemy hit.
// homsquash_jt is an integer "strength" like the player's jt (negative values = squash vertically, stretch horizontally).
local HOM_SQUASH_JT_HIT   = -2   // impact squash strength (negative = squash)
local HOM_SQUASH_HOLDTICS = 4    // tics to hold full squash before relaxing
local HOM_SQUASH_RELAXSPD = 1    // jt moves toward 0 by this per tic

local function Hom_ApplySquash(mo, jt, holdtics)
	if not mo or not mo.valid then return end

	// capture base once (supports scaled enemies)
	if mo.homsquash_basex == nil then mo.homsquash_basex = (mo.spritexscale or FRACUNIT) end
	if mo.homsquash_basey == nil then mo.homsquash_basey = (mo.spriteyscale or FRACUNIT) end
	if mo.homsquash_baseoff == nil then mo.homsquash_baseoff = (mo.spriteyoffset or 0) end

	mo.homsquash_jt = jt
	mo.homsquash_hold = (holdtics or 0)
end

// per-mobj relax back to base
addHook("MobjThinker", function(mo)
	if not mo or not mo.valid then return end
	if mo.homsquash_jt == nil then return end

	// If base got cleared (rare), restore sane defaults.
	if mo.homsquash_basex == nil then mo.homsquash_basex = (mo.spritexscale or FRACUNIT) end
	if mo.homsquash_basey == nil then mo.homsquash_basey = (mo.spriteyscale or FRACUNIT) end
	if mo.homsquash_baseoff == nil then mo.homsquash_baseoff = (mo.spriteyoffset or 0) end

	// hold full squash briefly
	if mo.homsquash_hold and mo.homsquash_hold > 0 then
		mo.homsquash_hold = $-1
	else
		// relax jt toward 0
		if mo.homsquash_jt > 0 then
			mo.homsquash_jt = $ - HOM_SQUASH_RELAXSPD
		elseif mo.homsquash_jt < 0 then
			mo.homsquash_jt = $ + HOM_SQUASH_RELAXSPD
		end
	end

	local jt = mo.homsquash_jt or 0

	// once fully relaxed, snap exactly back to base and stop thinking
	if jt == 0 then
		mo.spritexscale = mo.homsquash_basex
		mo.spriteyscale = mo.homsquash_basey
		mo.spriteyoffset = mo.homsquash_baseoff
		mo.homsquash_jt = nil
		mo.homsquash_hold = 0
		return
	end

	// replicate the player's math, but relative to base scale.
	// yscale factor = 1 + (jt/10)
	local dy = (jt * FRACUNIT) / 10
	mo.spriteyscale = FixedMul(mo.homsquash_basey, FRACUNIT + dy)

	// xscale factor = 1 - (jt/10)   (so negative jt -> xscale > 1)
	mo.spritexscale = FixedMul(mo.homsquash_basex, FRACUNIT - dy)

	// offset to keep the sprite visually "planted" (same pattern as thicc)
	local off = (jt * mo.height) / 20
	mo.spriteyoffset = mo.homsquash_baseoff + (-1 * off)
end)


local function ClearHomStall(p)
	if not p then return end

	// clear any pending delayed damage
	p.homstall_dmg_target = nil
	p.homstall_dmg_timer = 0

	// clear any pending homing-triggered trick
	p.homtrick_timer = 0

	// clear any locked homing target
	p.homlock_target = nil

	if p.homstall_target and p.homstall_target.valid then
		// make the target vulnerable again when we stop stalling on it
		if p.homstall_target.homstall_invulncount ~= nil and p.homstall_target.homstall_invulncount > 0 then
			p.homstall_target.homstall_invulncount = $-1
			if p.homstall_target.homstall_invulncount <= 0 then
				p.homstall_target.homstall_invulncount = 0
			end
		end

		// restore ridden enemy collision flags
		if p.homstall_target.homstall_oldflags ~= nil then
			p.homstall_target.flags = p.homstall_target.homstall_oldflags
			p.homstall_target.homstall_oldflags = nil
		end
		if p.homstall_target.homstall_oldflags2 ~= nil then
			p.homstall_target.flags2 = p.homstall_target.homstall_oldflags2
			p.homstall_target.homstall_oldflags2 = nil
		end

		p.homstall_target.homstall_by = nil
		p.homstall_target.homstall_x = nil
		p.homstall_target.homstall_y = nil
		p.homstall_target.homstall_z = nil
		p.homstall_target.homstall_ctrlx = nil
		p.homstall_target.homstall_ctrly = nil
		p.homstall_target.homstall_ctrlz = nil
	end

	p.homstall = false
	p.homstall_target = nil
	p.homstall_x = nil
	p.homstall_y = nil
	p.homstall_z = nil
	p.homstall_h = nil
	p.homstall_momx = nil
	p.homstall_momy = nil
	p.homstall_autotimer = 0
	p.homstall_holdjump = false
	p.homstall_prevjump = false
	p.homstall_c1timer = 0
	p.homstall_c1prev = false
	p.homstall_c1prev = false
		p.homstall_safex = nil
		p.homstall_safey = nil
		p.homstall_safez = nil
	p.homstall_safex = nil
	p.homstall_safey = nil
	p.homstall_safez = nil
end

// ===== homing stall safety =====
// Prevent the player from being crushed/killed when the ridden enemy forces them under low ceilings.
// If snapping the player above the target would cause a crush, we immediately dismount and grant brief invulnerability.
local HOMSTALL_SAFEFLASH = 10

local function Hom_GiveFlashing(p, tics)
	if not p or not p.powers then return end
	local ft = (tics ~= nil) and tics or HOMSTALL_SAFEFLASH
	if p.powers[pw_flashing] == nil then
		p.powers[pw_flashing] = ft
		return
	end
	if p.powers[pw_flashing] < ft then
		p.powers[pw_flashing] = ft
	end
end

local function Hom_CanPlaceAt(mo, x, y, z)
	if not mo or not mo.valid then return false end

	local oz = mo.z
	mo.z = z
	local ok = P_CheckPosition(mo, x, y)
	local floorz = mo.floorz
	local ceilz = mo.ceilingz
	mo.z = oz

	if not ok then return false end
	if z < floorz then return false end
	if (z + mo.height) > ceilz then return false end
	return true
end

local function Hom_SafeDismount(p)
	if not p or not p.mo or not p.mo.valid then return end
	local mo = p.mo

	// snap back to the last known safe position (before we started following the enemy under tight gaps)
	if p.homstall_safex ~= nil then
		P_MoveOrigin(mo, p.homstall_safex, p.homstall_safey, p.homstall_safez)
	end

	// clear stall state + unlock target + remove enemy invulnerability
	ClearHomStall(p)

	// grant brief invulnerability so reckless riding can't kill the player
	Hom_GiveFlashing(p, HOMSTALL_SAFEFLASH)

	// fall away safely
	mo.state = S_PLAY_FALL
	mo.momx = 0
	mo.momy = 0
	mo.momz = (-1 * FRACUNIT) * P_MobjFlip(mo)
end

local function Hom_SafeSnapOrDismount(p, x, y, z)
	if not p or not p.mo or not p.mo.valid then return false end
	local mo = p.mo

	// remember a safe position BEFORE snapping (used if the next snap would crush us)
	p.homstall_safex = mo.x
	p.homstall_safey = mo.y
	p.homstall_safez = mo.z

	if not Hom_CanPlaceAt(mo, x, y, z) then
		Hom_SafeDismount(p)
		return false
	end

	P_MoveOrigin(mo, x, y, z)
	return true
end

local function StartHomStall(p, target)
	if not p or not p.mo or not p.mo.valid then return end
	if not target or not target.valid then return end
	if p.homstall then return end

	local mo = p.mo

	// never start a homstall on snailers (they are embedded in walls)
	if Hom_IsSnailer(target) then
		p.homattack = false
		p.homlock_target = nil
		return
	end

	// stop the homing logic immediately
	p.homattack = false

	// lock target into the stall too (prevents directional input from breaking "who we hit")
	p.homlock_target = target

	p.homstall = true
	p.homstall_target = target
	p.homstall_c1timer = 0
	p.homstall_c1prev = ((p.cmd.buttons & BT_CUSTOM1) ~= 0)


	// make the target invulnerable during the stall so it can't be killed while ridden
		target.homstall_invulncount = (target.homstall_invulncount or 0) + 1

	// while ridden, force the enemy to obey solid map/object collision (prevents phasing into scenery)
	if target.homstall_oldflags == nil then
		target.homstall_oldflags = target.flags
	end
	if target.flags2 ~= nil and target.homstall_oldflags2 == nil then
		target.homstall_oldflags2 = target.flags2
	end
	if MF_NOCLIP then target.flags = $ & (~MF_NOCLIP) end
	if MF_NOCLIPHEIGHT then target.flags = $ & (~MF_NOCLIPHEIGHT) end
	if MF_NOBLOCKMAP then target.flags = $ & (~MF_NOBLOCKMAP) end
	if MF_SOLID then target.flags = $ | MF_SOLID end

	// remember last known target position (so dying targets don't break the stall)
	p.homstall_x = target.x
	p.homstall_y = target.y
	p.homstall_z = target.z
	p.homstall_h = target.height

	// preserve incoming horizontal so the bounce can carry it
	p.homstall_momx = mo.momx
	p.homstall_momy = mo.momy

	// (kept, but stall now ends on release)
	p.homstall_prevjump = ((p.cmd.buttons & BT_JUMP) ~= 0)
	p.homstall_holdjump = p.homstall_prevjump
	p.homstall_autotimer = 0

	// hard stop PLAYER immediately
	mo.momx = 0; mo.momy = 0; mo.momz = 0

	// Only freeze/tag the target if we're NOT riding it
	if not HOMSTALL_RIDE_ENEMY then
		// tag target so we can keep it frozen too
		target.homstall_by = mo
		target.homstall_x = target.x
		target.homstall_y = target.y
		target.homstall_z = target.z

		// hard stop target immediately
		target.momx = 0; target.momy = 0; target.momz = 0
	end

	// squash the target on stall start
	Hom_ApplySquash(target, HOM_SQUASH_JT_HIT, HOM_SQUASH_HOLDTICS)

	// immediately snap player above target
	local holdz = (P_MobjFlip(mo) == 1)
		and (target.z + target.height + HOMSTALL_ZOFF)
		or  (target.z - mo.height - HOMSTALL_ZOFF)

	if not Hom_SafeSnapOrDismount(p, target.x, target.y, holdz) then return end
	mo.state = S_PLAY_ROLL
end

local function EndHomStallBounce(p)
	if not p or not p.mo or not p.mo.valid then return end

	local mo = p.mo
	local mx = p.homstall_momx or 0
	local my = p.homstall_momy or 0

	// cache the target so we can damage AFTER bounce begins
	local dmg_target = p.homstall_target

	// make the target vulnerable again when we stop stalling on it
	if dmg_target and dmg_target.valid then
		if dmg_target.homstall_invulncount ~= nil and dmg_target.homstall_invulncount > 0 then
			dmg_target.homstall_invulncount = $-1
			if dmg_target.homstall_invulncount <= 0 then
				dmg_target.homstall_invulncount = 0
			end
		end
	end

	// restore ridden enemy collision flags
	if dmg_target.homstall_oldflags ~= nil then
		dmg_target.flags = dmg_target.homstall_oldflags
		dmg_target.homstall_oldflags = nil
	end
	if dmg_target.homstall_oldflags2 ~= nil then
		dmg_target.flags2 = dmg_target.homstall_oldflags2
		dmg_target.homstall_oldflags2 = nil
	end

	// squash the target on stall end bounce
	if dmg_target and dmg_target.valid then
		Hom_ApplySquash(dmg_target, HOM_SQUASH_JT_HIT, HOM_SQUASH_HOLDTICS)
	end

	if p.homstall_target and p.homstall_target.valid then
		p.homstall_target.homstall_by = nil
		p.homstall_target.homstall_x = nil
		p.homstall_target.homstall_y = nil
		p.homstall_target.homstall_z = nil
	end

	p.homstall = false
	p.homstall_target = nil
	p.homstall_autotimer = 0
	p.homstall_holdjump = false
	p.homstall_prevjump = false
	p.homstall_c1timer = 0
	p.homstall_c1prev = false

	// homing is finished; unlock target
	p.homlock_target = nil

	// do the bounce first (preserves the "good bounce" feel)
	P_SetObjectMomZ(mo, HOMSTALL_BOUNCEZ, false)
	// re-route stored horizontal speed into the direction the player is currently facing
	local speed = FixedHypot(mx,my) / HOMSTALL_XYDIV
	if speed and speed ~= 0 then
		P_InstaThrust(mo, mo.angle, speed)
	else
		mo.momx = 0
		mo.momy = 0
	end
	p.weakthok = true
	mo.state = S_PLAY_SPRING

	// let Ninja Moves know this was a successful homing hit (even if Jump is released)
	p.homtrick_timer = 2
	p.shadowspringed = true

	// schedule damage for AFTER bounce begins
	if dmg_target and dmg_target.valid and dmg_target.health then
		if dmg_target.flags & (MF_MONITOR|MF_ENEMY|MF_BOSS) then
			p.homstall_dmg_target = dmg_target
			p.homstall_dmg_timer = HOMSTALL_DMGDELAY
		end
	end
end

// If the stall target disappears (dies/gets removed) while we're hovering, we should NOT rebound.
// Instead, immediately drop the player back into a normal fall state.
local function EndHomStallFall(p)
	if not p or not p.mo or not p.mo.valid then return end

	local mo = p.mo

	// clear stall + any pending delayed damage / trick trigger / target locks
	ClearHomStall(p)

	// kick the player into a real fall right away (prevents "hovering" at 0 momz)
	mo.state = S_PLAY_FALL
	mo.momx = 0
	mo.momy = 0
	mo.momz = (-1 * FRACUNIT) * P_MobjFlip(mo)
end

// apply delayed damage after bounce has begun
addHook("PlayerThink",function(p)
	if not p or not p.mo or not p.mo.valid then return end
	if not p.homstall_dmg_timer or p.homstall_dmg_timer <= 0 then return end

	p.homstall_dmg_timer = $-1
	if p.homstall_dmg_timer > 0 then return end

	local t = p.homstall_dmg_target
	p.homstall_dmg_target = nil
	p.homstall_dmg_timer = 0

	if t and t.valid and t.health then
		// make sure we're "attacking" for enemies that check spin
		p.pflags = $|PF_SPINNING
		P_DamageMobj(t, p.mo, p.mo, 1)
	end
end)

addHook("PlayerThink",function(p)
	if p.homattack == nil
		p.homattack = false
		p.canhome = false
		p.weakthok = false
		p.neothok = false
		p.homstall = false
		p.homstall_target = nil
		p.homstall_autotimer = 0
		p.homstall_holdjump = false
		p.homstall_prevjump = false
		p.homstall_c1timer = 0
		p.homstall_c1prev = false
		// init delayed damage fields
		p.homstall_dmg_target = nil
		p.homstall_dmg_timer = 0
		// init homing-triggered trick timer
		p.homtrick_timer = 0
		// init locked target
		p.homlock_target = nil
	end
	if P_IsObjectOnGround(p.mo)
		p.homattack = false
		p.canhome = false
		p.weakthok = false
		p.neothok = false
		// BS: clear node-homing watchdog
		p.bs_nodehome_timer = nil
		// BS: clear general homing timeout
		p.bs_homtimeout = nil
		p.bs_nodehome_lastdist = nil
		p.bs_nodehome_stuck = 0
		ClearHomStall(p)
	end
	if p.mo.eflags & MFE_SPRUNG
		p.homattack = false
		p.canhome = false
		p.weakthok = false
		p.neothok = false
		ClearHomStall(p)
	end
	if p.mo.eflags & MFE_JUSTHITFLOOR
		p.homattack = false
		p.canhome = false
		p.weakthok = false
		p.neothok = false
		ClearHomStall(p)
	end
	if P_PlayerInPain(p)
		p.homattack = false
		ClearHomStall(p)
	end

	if p.mo.skin == "hsonic"
		if p.mo.eflags & MFE_JUSTHITFLOOR
		and p.homattack
			p.pflags = $|PF_SPINNING
			p.mo.state = S_PLAY_ROLL
		end
	end
end)

local function homingattack(p) //allow the player to constantly pursue their target after a jump press
	local mo = p.mo

	// BS: GENERAL HOMING FAILSAFE
	// If a homing attempt lasts too long without resolving (hit/cancel), force it to end.
	// This prevents "hanging" in mid-air when a target is unreachable or non-resolving.
	if p.homattack then
		if p.bs_homtimeout == nil then
			p.bs_homtimeout = 70
		end
		p.bs_homtimeout = $-1
		if p.bs_homtimeout <= 0 then
			p.homattack = false
			p.homlock_target = nil
			mo.hometarget = nil
			// clear rail/node watchdogs too
			p.bs_railhome_lock = nil
			p.bs_railhome_timer = nil
			p.bs_nodehome_timer = nil
			p.bs_nodehome_lastdist = nil
			p.bs_nodehome_stuck = 0
			ClearHomStall(p)

			// force a real fall immediately
			mo.state = S_PLAY_FALL
			mo.momx = 0
			mo.momy = 0
			local dk = (-6*FRACUNIT) * P_MobjFlip(mo)
			if P_MobjFlip(mo) == 1 then
				if mo.momz > dk then mo.momz = dk end
			else
				if mo.momz < dk then mo.momz = dk end
			end
			return
		end
	else
		p.bs_homtimeout = nil
	end



	// BS: cancel homing instantly if we have attached to a GS grindrail (prevents infinite homing loop on jumpropes)
	if p.homattack then
		// cancel on any kind of GS rail-grind attachment (GS sets mo.GSgrind while grinding)
		local GS = mo.GSgrind
		if GS and (
			(GS.grinding and GS.grinding > 0)
			or (GS.myrail and GS.myrail.valid)
			or (GS.cannotgrind and GS.cannotgrind > 0)
		) then
			p.homattack = false
			p.homlock_target = nil
			mo.hometarget = nil
			return
		end

		// also cancel if we're in any grind state (load-order-safe via rawget)
		local st = mo.state
		local s1 = rawget(_G, "S_PLAYER_GRINDING")
		local s2 = rawget(_G, "S_PLAY_GSGRINDING")
		local s3 = rawget(_G, "S_PLAY_GSCUSTOMGRIND")
		local s4 = rawget(_G, "S_PLAY_GSHANGRAIL")
		if (s1 and st == s1) or (s2 and st == s2) or (s3 and st == s3) or (s4 and st == s4) then
			p.homattack = false
			p.homlock_target = nil
			mo.hometarget = nil
			return
		end

		// safety: if we touched the floor, cancel so we don't keep steering on landing
		if P_IsObjectOnGround(mo) or (mo.eflags & MFE_JUSTHITFLOOR) then
			p.homattack = false
			p.homlock_target = nil
			mo.hometarget = nil
			return
		end
	end

	// LOCK-IN: once homing starts, keep the original target no matter what inputs do
	if p.homattack then
		if p.homlock_target and p.homlock_target.valid and p.homlock_target.health then
			mo.hometarget = p.homlock_target
		else
			// if the locked target vanished, cancel the homing so we don't "slam" helplessly
			if p.homlock_target ~= nil then
				p.homlock_target = nil
				p.homattack = false
			end
		end
	end


	// BS: hard timeout for rail (grind-node) homing attempts.
	// If we fail to actually hit a node within ~35 tics, cancel so we don't hang mid-air.
	if p.homattack then
		local tgt = p.homlock_target
		if BS_IsGrindNode(tgt) then
			if p.bs_railhome_lock ~= tgt then
				p.bs_railhome_lock = tgt
				p.bs_railhome_timer = 35
			elseif p.bs_railhome_timer == nil then
				p.bs_railhome_timer = 35
			end

			p.bs_railhome_timer = $-1
			if p.bs_railhome_timer <= 0 then
				p.homattack = false
				p.homlock_target = nil
				mo.hometarget = nil
				p.bs_railhome_lock = nil
				p.bs_railhome_timer = nil

				// small downward kick so we resume falling even if pressed against a ceiling
				local dk = (-6*FRACUNIT) * P_MobjFlip(mo)
				if P_MobjFlip(mo) == 1 then
					if mo.momz > dk then mo.momz = dk end
				else
					if mo.momz < dk then mo.momz = dk end
				end
				return
			end
		else
			p.bs_railhome_lock = nil
			p.bs_railhome_timer = nil
		end
	else
		p.bs_railhome_lock = nil
		p.bs_railhome_timer = nil
	end



	if p.mo.skin == "sonic"
	and mo.hometarget and mo.hometarget.valid and mo.hometarget.health //make sure we have a target
		if p.homattack //need PF_THOKKED to start zero-ing in
			if not p.powers[pw_super]
			p.pflags = $|PF_THOKKED|PF_SPINNING
			end
			local zdist = (P_MobjFlip(mo) == 1) and (mo.hometarget.z + mo.hometarget.height - mo.z - mo.height) or (mo.hometarget.z - mo.z) //variables for distance and stuff!
			local dist = P_AproxDistance(P_AproxDistance(mo.hometarget.x - mo.x, mo.hometarget.y - mo.y), zdist)

			// BS: if we're homing to a grind node and we aren't making progress (or time runs out),
			// cancel the homing so the player doesn't stall/hang in mid-air against geometry.
			if BS_IsGrindNode(mo.hometarget) then
				if p.bs_nodehome_timer == nil then
					p.bs_nodehome_timer = 35
				end
				if p.bs_nodehome_lastdist == nil then
					p.bs_nodehome_lastdist = dist
				end
				if p.bs_nodehome_stuck == nil then
					p.bs_nodehome_stuck = 0
				end

				p.bs_nodehome_timer = $-1
				// consider it 'stuck' if distance isn't decreasing by at least ~4 fracunits per tic
				if dist >= (p.bs_nodehome_lastdist - 4*FRACUNIT) then
					p.bs_nodehome_stuck = $+1
				else
					p.bs_nodehome_stuck = 0
				end
				p.bs_nodehome_lastdist = dist

				if p.bs_nodehome_timer <= 0 or p.bs_nodehome_stuck >= 15 then
					p.homattack = false
					p.homlock_target = nil
					mo.hometarget = nil
					// small downward kick so we resume falling even if pressed against a ceiling
					local dk = (-6*FRACUNIT) * P_MobjFlip(mo)
					if P_MobjFlip(mo) == 1 then
						if mo.momz > dk then mo.momz = dk end
					else
						if mo.momz < dk then mo.momz = dk end
					end
					return
				end
			else
				// not a node target: clear watchdog
				p.bs_nodehome_timer = nil
				p.bs_nodehome_lastdist = nil
				p.bs_nodehome_stuck = 0
			end
			local angle = R_PointToAngle2(mo.x, mo.y, mo.hometarget.x, mo.hometarget.y)
			local zangle = R_PointToAngle2(0, mo.z, P_AproxDistance(mo.x - mo.hometarget.x, mo.y - mo.hometarget.y), mo.hometarget.z)
			local maxspeed = ((p.powers[pw_super] or p.powers[pw_sneakers] or MODID == 14) and 2 or 1)*FixedMul(p.normalspeed, mo.scale)
			local x = FixedMul(maxspeed, FixedMul(cos(angle), cos(zangle)))
			local y = FixedMul(maxspeed, FixedMul(sin(angle), cos(zangle)))
			local z = FixedMul(maxspeed, sin(zangle))

			//listen buddy i like special effects in my sonic the hedgehog videogame
			local thokfx = P_SpawnGhostMobj(p.mo,0,0,0,MT_GHOST)
			thokfx.scale = p.mo.scale
			thokfx.destscale = FRACUNIT*3
			if p.powers[pw_super] then
			thokfx.color = SKINCOLOR_CRIMSON
			else
			thokfx.color = SKINCOLOR_COBALT
			end
			thokfx.blendmode = AST_ADD
			thokfx.tics = 6

			if mo.hometarget
				mo.momx = FixedMul(FixedDiv(mo.hometarget.x-mo.x, dist), 55*FRACUNIT)
				mo.momy = FixedMul(FixedDiv(mo.hometarget.y-mo.y, dist), 55*FRACUNIT)
				mo.momz = FixedMul(FixedDiv(zdist, dist), 55*FRACUNIT)
			end

			//momentum now works the same when you're super sonic lol
			if p.powers[pw_super]
				mo.momx = FixedMul(FixedDiv(mo.hometarget.x-mo.x, dist), 100*FRACUNIT)
				mo.momy = FixedMul(FixedDiv(mo.hometarget.y-mo.y, dist), 100*FRACUNIT)
				mo.momz = FixedMul(FixedDiv(zdist, dist), 100*FRACUNIT)
			end

			if p.powers[pw_pushing]
				p.homattack = false
				p.homlock_target = nil
				p.playerline = lines[p.lastlinehit]

				if p.playerline
					p.bounceside = P_PointOnLineSide(p.mo.x, p.mo.y, p.playerline)
					P_InstaThrust(p.mo,p.bounceside + ANGLE_180, 5*FRACUNIT)
				end
			end
			if p.cmd.buttons & BT_SPIN
			p.homattack = false
			p.homlock_target = nil
			end
		end
	end
end
addHook("PlayerThink",homingattack) //needs to be active for the homing attack, but only starts when we hit jump while we have a target in range.

addHook("AbilitySpecial",function(p)
	local mo = p.mo
	local angle = mo.angle

	if p.homstall then return true end

	local momangle = R_PointToAngle2(0,0,p.rmomx,p.rmomy)
	if mo and mo.skin == "sonic"
			// BS: ensure rails can be acquired as a target the same tic AbilitySpecial is pressed
			if not mo.hometarget and (p.pflags & PF_JUMPED)
				mo.hometarget = P_LookForEnemies(p,true,true)
				if not mo.hometarget then
				p.bs_gn_scan_cd = 0
				mo.hometarget = BS_FindClosestGrindNode(p, true)
				end
			end
			if mo.hometarget and not (p.cmd.buttons & BT_TOSSFLAG)
				
				-- Validate the current hometarget before locking in (prevents cross-map / behind homing).
				if mo.hometarget and mo.hometarget.valid and mo.hometarget.health and (not BS_IsGrindNode(mo.hometarget)) then
					if not BS_NormalTargetEligibleNow(p, mo.hometarget) then
						mo.hometarget = nil
						p.bs_airlock_target = nil
					end
				end

// LOCK-IN: capture the target at the moment the homing attack begins
				p.homlock_target = mo.hometarget
				// BS: node-homing watchdog (prevents mid-air hanging if the rail is unreachable)
				p.bs_nodehome_timer = nil
				p.bs_nodehome_lastdist = nil
				p.bs_nodehome_stuck = 0
				if BS_IsGrindNode(p.homlock_target) then
					p.bs_nodehome_timer = 35
				end
				p.homattack = true
				S_StartSound(mo,sfx_thok)
				homingattack(p) //execute your homing attack
			else
				if p.neothok == false and not (p.cmd.buttons & BT_TOSSFLAG)
					local neothokeffect = P_SpawnGhostMobj(p.mo,0,0,0,MT_GHOST)
					if neothokeffect then
					neothokeffect.scale = p.mo.scale
					neothokeffect.blendmode = AST_ADD
					end
					S_StartSound(mo,sfx_swipe)
					p.neothok = true
					if not p.powers[pw_super]
						if p.speed < 50*FRACUNIT
							P_InstaThrust(mo,angle,50*FRACUNIT)
							p.mo.state = S_PLAY_DIVE
						else
							P_InstaThrust(mo,angle,p.speed)
							p.mo.state = S_PLAY_DIVE
						end
					else
						P_InstaThrust(mo,angle,90*FRACUNIT)
						p.mo.state = S_PLAY_RUN
					end
				end
			end
		return true
	end
end)

addHook("PlayerThink",function(p)
	if not p or not p.mo or not p.mo.valid then return end

	local mom = FixedDiv(FixedHypot(p.rmomx,p.rmomy),p.mo.scale)
	local friction = FixedDiv(p.mo.friction,p.mo.movefactor)
	local momangle = R_PointToAngle2(0,0,p.rmomx,p.rmomy)

	if p.powers[pw_justlaunched]
		P_SetObjectMomZ(p.mo,p.mo.momz/5,true)
	end

	local mo = p.mo

	// LOCK-IN: don't keep overwriting the target while homing/stalling
	if p.homattack and p.homlock_target and p.homlock_target.valid and p.homlock_target.health then
		mo.hometarget = p.homlock_target
		p.bs_airlock_target = p.homlock_target
		p.bs_airlock_cd = 0
	else
		// PERF: throttle expensive lock-on scans while airborne; reuse last target for the reticle.
		if mo.skin == "sonic" and (p.pflags & PF_JUMPED) and not p.homstall and not p.homattack then
			if p.bs_airlock_cd == nil then p.bs_airlock_cd = 0 end

			// drop invalid cached target
			if p.bs_airlock_target and (not p.bs_airlock_target.valid or not p.bs_airlock_target.health) then
				p.bs_airlock_target = nil
			end

			-- PERF: if Jump was just pressed, force an immediate rescan this tic (without using PlayerCmd).
			if p.cmd then
				local j = ((p.cmd.buttons & BT_JUMP) ~= 0)
				if p.bs_prevjumpbtn == nil then p.bs_prevjumpbtn = false end
				if j and (not p.bs_prevjumpbtn) then
					p.bs_airlock_cd = 0
					p.bs_gn_scan_cd = 0
				end
				p.bs_prevjumpbtn = j
			end

			if p.bs_airlock_cd > 0 then
				p.bs_airlock_cd = $-1
			end

			if p.bs_airlock_cd <= 0 then
				local newt = P_LookForEnemies(p,true,true)
				-- Reject far/behind standard targets
				if newt and newt.valid and newt.health and (not BS_IsGrindNode(newt)) then
					local ang = BS_GetFacingAngle(p)
					if not BS_IsNormalTargetEligible(p, mo, newt, cos(ang), sin(ang)) then
						newt = nil
					end
				end
				if not newt then
					newt = BS_FindClosestGrindNode(p, false)
				end

				// Only replace the cached target if we found a real one.
				// If we found nothing, keep the old cached target a little longer to avoid reticle blinking.
				if newt and newt.valid and newt.health then
					p.bs_airlock_target = newt
				end

				p.bs_airlock_cd = BS_AIRLOCK_SCAN_INTERVAL
			end

			-- BS_AIRLOCK_VALIDATE: prevent long-range "magnetic" rail targets persisting across the map.
			if p.bs_airlock_target and BS_IsGrindNode(p.bs_airlock_target) then
				if not BS_IsNodeEligibleNow(p, p.bs_airlock_target) then
					p.bs_airlock_target = nil
					-- Don't set this to 0, or we can end up rescanning every tic when drawangle is being spun (Ninja Moves).
					p.bs_airlock_cd = 2
				end
			end

			
			-- Also validate normal (non-rail) homing targets so they can't persist from across the map / behind.
			if p.bs_airlock_target and (not BS_IsGrindNode(p.bs_airlock_target)) then
				if not BS_NormalTargetEligibleNow(p, p.bs_airlock_target) then
					p.bs_airlock_target = nil
					p.bs_airlock_cd = 2
				end
			end

mo.hometarget = p.bs_airlock_target
		else
			p.bs_airlock_target = nil
			p.bs_airlock_cd = 0
			mo.hometarget = nil
		end
	end

	//target availability check for the reticle arrow
	if mo.hometarget and mo.hometarget.valid and mo.hometarget.health
	and mo.skin == "sonic"
	and p.pflags & PF_JUMPED
	and not (p.KickStunts and p.KickStunts.kicking)

		// Keep the reticle stable: spawn each tic (P_SpawnLockOn objects are short-lived).
		if not p.powers[pw_shield] == SH_ATTRACT
			P_SpawnLockOn(p,mo.hometarget,S_LOCKON2)
		else
			P_SpawnLockOn(p,mo.hometarget,S_LOCKON1)
		end
	end
end)

//rebound after a homing attack
local function hatkrebound(mo, mobj)
    if not mo.player then return end  -- Ensure mo.player is valid before proceeding


    // BS: homing hit a grind node -> snap onto the line and begin grinding immediately
	local MT_BS_GRINDNODE_CONST = BS_GetGrindNodeType()
	if MT_BS_GRINDNODE_CONST ~= nil
    and mobj and mobj.valid
    and mobj.type == MT_BS_GRINDNODE_CONST
    and mo.skin == "hsonic"
    and mo.player.homattack
    and not P_PlayerInPain(mo.player)
        local p = mo.player
        local line = mobj.bs_grindline
        if line and (line.flags & ML_EFFECT4) then
            // stop homing cleanly
            p.homattack = false
            p.homlock_target = nil

            // snap to the closest point on the line, but keep the player on the SAME side they approached from.
            // Placing exactly on the linedef can sometimes pick the "wrong" sector side for floor/FOF checks,
            // causing the player to instantly fall off instead of mounting.
            local gx, gy = P_ClosestPointOnLine(mo.x, mo.y, line)
            local pushang = R_PointToAngle2(gx, gy, mo.x, mo.y)
            local pushoff = 12*FRACUNIT
            local ox = gx + P_ReturnThrustX(mo, pushang, pushoff)
            local oy = gy + P_ReturnThrustY(mo, pushang, pushoff)
            if not P_TryMove(mo, ox, oy, true) then
                // fallback for finicky collision cases
                P_MoveOrigin(mo, ox, oy, mo.z)
            end

            // pick a sane rail Z near the player
            local z = mobj.bs_grindz
            if z == nil then
                z = BS_PickRailZNearPlayer(p, line)
            end
            if z ~= nil then
                mo.z = z
                mo.momz = 0
                mo.eflags = $|MFE_JUSTHITFLOOR

                // keep the player planted on that height for the duration of the grind (prevents falling off)
                p.bs_grind_forcez = z
                p.bs_grind_lockz = true
            end

            // align momentum to the rail direction so you don't skid off sideways on entry
            if line.v1 and line.v2 then
                local la = R_PointToAngle2(line.v1.x, line.v1.y, line.v2.x, line.v2.y)
                local lx = cos(la)
                local ly = sin(la)
                local dot = FixedMul(mo.momx, lx) + FixedMul(mo.momy, ly)
                if dot < 0 then
                    la = $ + ANGLE_180
                end
                local spd = FixedHypot(mo.momx, mo.momy)
                if spd < 24*FRACUNIT then spd = 24*FRACUNIT end
                P_InstaThrust(mo, la, spd)
            end

            // store approach-side info so LUA_GRND can keep you on the correct side during the first few tics
            p.bs_grind_pushang = pushang
            p.bs_grind_pushoff = pushoff
            p.bs_grind_snap_tics = 12

            // begin grinding via LUA_GRND's logic
            p.rail = line
            p.railthistic = true
            p.bs_rail_grace = 8

            return false
        end
    end

    if mobj.flags & (MF_MONITOR|MF_ENEMY|MF_BOSS)
    and mo.skin == "sonic"
    and mo.player.homattack
    and not P_PlayerInPain(mo.player)
        if (mo.z + mo.height < mobj.z) 
        or (mo.z > mobj.z + mobj.height)
            return false
        else
            // squash the enemy on impact (stall or bounce)
            Hom_ApplySquash(mobj, HOM_SQUASH_JT_HIT, HOM_SQUASH_HOLDTICS)

            // stall only if Jump is being HELD during the homing attack impact
            if ((mo.player.cmd.buttons & BT_JUMP) ~= 0) and not Hom_IsSnailer(mobj) then
                if not mo.player.homstall then
                    StartHomStall(mo.player, mobj)
                end
            else
                // otherwise, bounce immediately (damage happens after bounce begins)
                local p = mo.player
                local mx = mo.momx
                local my = mo.momy

                p.homattack = false
                p.homlock_target = nil

                P_SetObjectMomZ(mo, HOMSTALL_BOUNCEZ, false)
                mo.momx = mx / HOMSTALL_XYDIV
                mo.momy = my / HOMSTALL_XYDIV
                p.weakthok = true
                mo.state = S_PLAY_SPRING
				p.neothok = true
				p.shadowspringed = true
                // let Ninja Moves know this was a successful homing hit
                p.homtrick_timer = 2

                if mobj and mobj.valid and mobj.health then
                    p.homstall_dmg_target = mobj
                    p.homstall_dmg_timer = HOMSTALL_DMGDELAY
                end
            end
        end
    end
end
addHook("MobjMoveCollide", hatkrebound)

//keep player (and target) frozen while stalled
addHook("PlayerThink",function(p)
	if not p.mo or not p.mo.valid then return end
	if p.mo.skin ~= "sonic" then return end
	if not p.homstall then return end

	local mo = p.mo
	local t = p.homstall_target

	// never homstall on snailers (they are embedded in walls and cause bugs)
	// if we somehow ended up stalling on one, immediately finish with a normal bounce.
	if Hom_IsSnailer(t) then
		EndHomStallBounce(p)
		return
	end

	// If the thing we're stalling on gets destroyed/removed, we have nothing to hover on.
	// Drop immediately (no rebound bounce).
	if not (t and t.valid and t.health) then
		Hom_SafeDismount(p)
		return
	end

	if t and t.valid then
		p.homstall_x = t.x
		p.homstall_y = t.y
		p.homstall_z = t.z
		p.homstall_h = t.height

		if not HOMSTALL_RIDE_ENEMY then
			t.momx = 0
			t.momy = 0
			t.momz = 0
		end



		// enemy-control: cancel autonomous movement and push at a constant speed while stalled
		if HOMSTALL_RIDE_ENEMY and HOMSTALL_CONTROL_ENEMYMOVE and (t.flags & MF_ENEMY) then
			t.angle = mo.angle
			t.momx = 0
			t.momy = 0
			if Hom_IsFlightExempt(t) then
				t.momz = 0
			else
				// ensure ridden enemies can fall when pushed off ledges / grabbed in mid-air
				if not P_IsObjectOnGround(t) then
					t.momz = (-1 * HOMSTALL_CTRL_FALLSPEED) * P_MobjFlip(t)
				end
			end
			t.homstall_by = mo
		end
		// keep the target squashed while we are stalled on it
		Hom_ApplySquash(t, HOM_SQUASH_JT_HIT, 1)

		if not HOMSTALL_RIDE_ENEMY then
			t.homstall_by = mo
		end
	end

	if p.homstall_x == nil then
		ClearHomStall(p)
		return
	end

	local holdz = (P_MobjFlip(mo) == 1)
		and (p.homstall_z + p.homstall_h + HOMSTALL_ZOFF)
		or  (p.homstall_z - mo.height - HOMSTALL_ZOFF)

	if not Hom_SafeSnapOrDismount(p, p.homstall_x, p.homstall_y, holdz) then return end
	mo.momx = 0
	mo.momy = 0
	mo.momz = 0
	mo.state = S_PLAY_BSCOOL
	
	local jumpheld = ((p.cmd.buttons & BT_JUMP) ~= 0)

	// stall persists only while Jump is HELD; release to bounce away
	if not jumpheld then
		EndHomStallBounce(p)
		p.neothok = true
		p.weakthok = true
		p.shadowspringed = true
		return
	end
end)

// apply stall control early each tic so enemy logic sees updated angle/momentum
// --- homing stall enemy control ---
// Snapshot the enemy at the start of the tic, then restore and move it at the end.
// This cancels its autonomous movement while you're stalled on it.

addHook("PreThinkFrame", function()
	if not HOMSTALL_CONTROL_ENEMYMOVE then return end
	if not HOMSTALL_RIDE_ENEMY then return end

	for p in players.iterate do
		if not p or not p.mo or not p.mo.valid then continue end
		if p.mo.skin ~= "hsonic" then continue end
		if not p.homstall then continue end

		local jumpheld = ((p.cmd.buttons & BT_JUMP) ~= 0)
		if not jumpheld then continue end

		local t = p.homstall_target
		if not (t and t.valid and t.health) then Hom_SafeDismount(p) continue end
		if not (t.flags & MF_ENEMY) then continue end

		t.homstall_ctrlx = t.x
		t.homstall_ctrly = t.y
		t.homstall_ctrlz = t.z
	end
end)

addHook("ThinkFrame", function()
	if not HOMSTALL_CONTROL_ENEMYMOVE then return end
	if not HOMSTALL_RIDE_ENEMY then return end

	for p in players.iterate do
		if not p or not p.mo or not p.mo.valid then continue end
		if p.mo.skin ~= "sonic" then continue end
		if not p.homstall then continue end

		local jumpheld = ((p.cmd.buttons & BT_JUMP) ~= 0)
		if not jumpheld then continue end

		local t = p.homstall_target
		if not (t and t.valid and t.health) then Hom_SafeDismount(p) continue end
		if not (t.flags & MF_ENEMY) then continue end

		local isfly = Hom_IsFlightExempt(t)

		// restore to pre-think position to cancel AI/autonomous movement (x/y only for grounded enemies)
		if t.homstall_ctrlx ~= nil then
			if isfly then
				// keep flying enemies at their stored altitude while controlled
				P_MoveOrigin(t, t.homstall_ctrlx, t.homstall_ctrly, t.homstall_ctrlz)
			else
				// allow gravity/falling by not restoring z
				P_MoveOrigin(t, t.homstall_ctrlx, t.homstall_ctrly, t.z)
			end
		end

		// face where the player is facing
		t.angle = p.mo.angle

		// hard stop anything the enemy tried to do this tic (x/y only)
		t.momx = 0
		t.momy = 0
		if isfly then
			t.momz = 0
		else
			// ensure controlled enemies fall when off the ground
			if not P_IsObjectOnGround(t) then
				t.momz = (-1 * HOMSTALL_CTRL_FALLSPEED) * P_MobjFlip(t)
			end
		end

		// move at a constant player-driven speed
		// Hold Custom 1: first 3 tics are a "stall" at 1/3 speed, then 3x speed.
		local movespeed = HOMSTALL_CTRL_SPEED
		local c1held = ((p.cmd.buttons & BT_CUSTOM1) ~= 0)

		// play thrust sfx once per Custom 1 press while riding
		if c1held and not p.homstall_c1prev then
			S_StartSound(p.mo, sfx_thrust)
		end
		p.homstall_c1prev = c1held

		if c1held then
			p.homstall_c1timer = (p.homstall_c1timer or 0) + 1
			if p.homstall_c1timer > 4 then
				p.homstall_c1timer = 4
			end

			// tics 1..3: slow stall
			if p.homstall_c1timer <= 3 then
				movespeed = FixedDiv(HOMSTALL_CTRL_SPEED, 3*FRACUNIT)
			else
				// tic 4+: triple speed
				movespeed = HOMSTALL_CTRL_SPEED * 3
			end
		else
			p.homstall_c1timer = 0
		end

		// dust trail when accelerating grounded enemies
		if (c1held and (p.homstall_c1timer ~= nil and p.homstall_c1timer > 3) and P_IsObjectOnGround(t)) then
			t.homstall_dusttimer = (t.homstall_dusttimer or 0) + 1
			if (t.homstall_dusttimer >= 2) then
				t.homstall_dusttimer = 0
				local dusttype = rawget(_G, "MT_SPINDUST")
				if dusttype ~= nil then
					local d = P_SpawnMobjFromMobj(t, 0, 0, 0, dusttype)
					if d and d.valid then
						d.scale = t.scale
						d.destscale = t.scale
						d.momx = 0
						d.momy = 0
						d.momz = 0
					end
				end
			end
		else
			t.homstall_dusttimer = 0
		end

		local dx = P_ReturnThrustX(t, t.angle, movespeed)
		local dy = P_ReturnThrustY(t, t.angle, movespeed)
		local moved = P_TryMove(t, t.x + dx, t.y + dy, true)
		if not moved then
			// blocked by scenery; end the ride before the player can get shoved into bad geometry
			EndHomStallBounce(p)
			return
		end

		// lock the player to the target AFTER moving it this tic, so the player doesn't lag behind
		p.homstall_x = t.x
		p.homstall_y = t.y
		p.homstall_z = t.z
		p.homstall_h = t.height

		local mo = p.mo
		local holdz = (P_MobjFlip(mo) == 1)
			and (p.homstall_z + p.homstall_h + HOMSTALL_ZOFF)
			or  (p.homstall_z - mo.height - HOMSTALL_ZOFF)

		if not Hom_SafeSnapOrDismount(p, p.homstall_x, p.homstall_y, holdz) then continue end
		mo.momx = 0
		mo.momy = 0
		mo.momz = 0

		// prevent drift on the next tic
		t.momx = 0
		t.momy = 0
		if isfly then
			t.momz = 0
		end
	end
end)

//OPTIONAL: keeps the target frozen even if its own thinker sets momentum
addHook("MobjThinker",function(mo)
	if HOMSTALL_RIDE_ENEMY then return end
	local by = mo.homstall_by
	if not by or not by.valid or not by.player then
		mo.homstall_by = nil
		return
	end

	local p = by.player
	if not p.homstall or p.homstall_target ~= mo then
		mo.homstall_by = nil
		return
	end

	mo.momx = 0
	mo.momy = 0
	mo.momz = 0

	if mo.homstall_x ~= nil then
		P_MoveOrigin(mo, mo.homstall_x, mo.homstall_y, mo.homstall_z)
	end
end)

--Ninja Moves
--Based on Pointy/Fluffy/ARJr's tricks
-- Backflip A
local function TRICK_Backlash(player, ticker)
-- ticker is the same as the titlecard HUD hook ticker (the amount of time the trick has been going on for in tics)
	if ticker >= 16 then
		player.drawangle = player.KickStunts.vars.storedangle
		player.mo.rollangle = 0
		if player.followmobj then player.followmobj.rollangle = 0 end
		-- Make sure to set trick-specific variables in player.KickStunts.vars
		player.KickStunts.vars.storedangle = nil
		player.mo.state = S_PLAY_SPRING
		player.KickStunts.kicking = false --< Set this to false when you want to end the trick
		return
	elseif ticker == 1 then
		player.KickStunts.vars.storedangle = player.drawangle
	end

	player.drawangle = player.KickStunts.vars.storedangle + (ticker*ANGLE_45)
	if (ticker % 2) == 0 then
		player.mo.rollangle = player.KickStunts.vars.storedangle + (ticker*-ANGLE_45)
	end
	if player.followmobj then player.followmobj.rollangle = (ticker*-ANGLE_22h) end
	player.mo.state = S_PLAY_FALL
	player.mo.frame = 0
end

-- Backflip B
local function TRICK_Kickflip(player, ticker)
-- ticker is the same as the titlecard HUD hook ticker (the amount of time the trick has been going on for in tics)
	if ticker >= 16 then
		player.drawangle = player.KickStunts.vars.storedangle
		player.mo.rollangle = 0
		if player.followmobj then player.followmobj.rollangle = 0 end
		-- Make sure to set trick-specific variables in player.KickStunts.vars
		player.KickStunts.vars.storedangle = nil
		player.mo.state = S_PLAY_SPRING
		player.KickStunts.kicking = false --< Set this to false when you want to end the trick
		return
	elseif ticker == 1 then
		player.KickStunts.vars.storedangle = player.drawangle
	end

	player.drawangle = player.KickStunts.vars.storedangle + (ticker*ANGLE_45)
	if (ticker % 2) == 0 then
		player.mo.rollangle = player.KickStunts.vars.storedangle + (ticker*ANGLE_45)
	end
	if player.followmobj then player.followmobj.rollangle = (ticker*-ANGLE_22h) end
	player.mo.state = S_PLAY_FALL
	player.mo.frame = 0
end

local trickTable = {
	-- Format: {name = "Name of trick", char = "skinname", func = TRICK_Function, sound = sfx_sndid}
	{name = "RADICAL!", char = "sonic", func = TRICK_Backlash, sound = sfx_prloop},
	{name = "INSANE!", char = "sonic", func = TRICK_Kickflip, sound = sfx_prloop},

}
rawset(_G, "KICK_KickList", trickTable)

local function RandTrick(skin)
	local randtrick = KICK_KickList[P_RandomRange(1, #KICK_KickList)]
	if randtrick.char == "all" or randtrick.char == skin then
		return randtrick
	end
	return RandTrick(skin) -- Recursive reshuffle
end

addHook("PlayerThink", function(player)
	if not (player.mo and player.mo.valid and player.mo.skin == "sonic" and player.playerstate == PST_LIVE and not player.powers[pw_super]) then return end

	player.KickStunts = $ or {
		vars = {},
		kicking = false,
		kickTicker = 0,
		kick = nil,
		kickchain = 0,
		kickbt = false,
		kickready = false
	}

	// Disable Ninja Moves during homing (including stall).
	// IMPORTANT: do NOT clear homtrick_timer here, because we WANT the end of a successful homing bounce
	// to auto-trigger a Ninja Move (regardless of stall).
	if player.homattack or player.homstall then
		// If a trick is already running, kill it cleanly so it can't play during homing.
		if player.KickStunts.kicking then
			player.KickStunts.kicking = false
			player.KickStunts.kickTicker = 0
			player.KickStunts.kick = nil
			player.mo.rollangle = 0
			if player.followmobj then player.followmobj.rollangle = 0 end
		end

		// Don't latch the button during homing; let the post-bounce auto-trigger start cleanly.
		player.KickStunts.kickbt = false
		player.KickStunts.kickready = false
		return
	end

	if not (player.mo.eflags & MFE_SPRUNG) and not (player.ceilingd or player.celdash or player.celcling or player.shadowspringed == true)
	and (P_IsObjectOnGround(player.mo) == false) and not (player.homattack or player.canhome) then
		player.KickStunts.kickready = true
		if not player.KickStunts.kicking then
			player.KickStunts.kickchain = 0
		end
	elseif player.mo.state == S_PLAY_PAIN
	or (P_IsObjectOnGround(player.mo) and player.KickStunts.kickready)
	or (player.mo.eflags & MFE_SPRUNG)
	or player.mo.skipdove then -- Skip support issues
		player.KickStunts.kickready = false
		player.KickStunts.kick = nil
		player.KickStunts.kicking = false
		player.KickStunts.kickchain = 0
		player.KickStunts.kickTicker = 0
		player.mo.rollangle = 0
		if player.followmobj then player.followmobj.rollangle = 0 end
		player.homtrick_timer = 0
	end

	// Auto-trigger trick after a successful homing bounce (stall or no-stall)
	player.homtrick_timer = $ or 0
	local homtrick = (player.homtrick_timer and player.homtrick_timer > 0)
	if homtrick then
		player.homtrick_timer = $-1
	end

	if homtrick and not (player.ceilingd or player.celdash or player.celcling)
	and not player.homattack and player.weakthok then
		if not player.KickStunts.kickbt
		and (player.KickStunts.kickready or homtrick)
		and not player.KickStunts.kicking then
			player.KickStunts.kick = RandTrick(player.mo.skin)
			player.KickStunts.kicking = true
			player.KickStunts.kickTicker = 1
			player.KickStunts.kickchain = $+1
			local score = player.KickStunts.kickchain
			if player.KickStunts.kick.sound then
				S_StartSound(player.mo, player.KickStunts.kick.sound)
			end
			// consume auto-trigger so it can't fire twice
			if homtrick then player.homtrick_timer = 0 end
		end
		player.KickStunts.kickbt = true
	else
		player.KickStunts.kickbt = false
	end

	if player.KickStunts.kickTicker then
		player.KickStunts.kick.func(player, player.KickStunts.kickTicker)
		if not player.KickStunts.kicking then
			player.KickStunts.kickTicker = 0
		else
			player.KickStunts.kickTicker = $+1
        end
	end
end)

-- BS: GSGRINDRAIL DISCOVERY DEBUG
-- This debug is NOT based on lock-on targets. It discovers nearby GS rails directly from the blockmap,
-- so it works even when you cannot currently target GSGrindRails.
if not rawget(_G, "BS_GSRAIL_DISCOVERY_DEBUG_LOADED") then
	rawset(_G, "BS_GSRAIL_DISCOVERY_DEBUG_LOADED", true)

	-- tiny helpers (no math lib)
	local function BS_ClampInt(n, lo, hi)
		if n < lo then return lo end
		if n > hi then return hi end
		return n
	end

	local function BS_ToNum(s)
		if s == nil then return nil end
		-- SRB2 Lua has tonumber; if something weird, just nil out
		local ok, v = pcall(tonumber, s)
		if ok then return v end
		return nil
	end

	local function BS_IsJumprope(rail)
	if not (rail and rail.valid) then return false end
	local ty = rail.GStype
	if ty == 18 then return true end -- GS '06 jumprope custom railtype
	local gf = rail.GSgrindflags or 0
	local jf = rail.GSjumpflags or 0
	-- Exact jumprope signature used by GS rails (see LUA_MAINRAIL): type=18, jumpflags=2490368, grindflags=1835008
	if gf == 1835008 and jf == 2490368 then return true end
	-- Fallback heuristic: ZEROSPEED grindflag + SUPER/MEGA jumpflags
	if (gf & 524288) != 0 and (jf & (1048576|2097152)) != 0 then return true end
	return false
end

	local function BS_RailBase(mo)
		-- segments/attached things often point to the base rail as GSrailobject
		if mo and mo.valid and mo.GSrailobject and mo.GSrailobject.valid then
			return mo.GSrailobject
		end
		return mo
	end

-- GS rail -> BS grindnode bridge (state)
local BS_GS_NODES = {}
local BS_GS_NODES_BUILT = false
local BS_GS_NODECOUNT = 0


	local function BS_PrintRailLine(p, idx, d, rail, tag)
		local rn = rail and rail.GSrailnum
		local ty = rail and rail.GStype
		local gf = (rail and rail.GSgrindflags) or 0
		local jf = (rail and rail.GSjumpflags) or 0
		local lf = (rail and rail.GSloopflags) or 0

		local hasHoming = ((gf & 1048576) != 0) and 1 or 0
		local jr = BS_IsJumprope(rail) and 1 or 0

		-- the ones we want nodes for: not jumprope, not already homing-enabled
		local needsNode = (jr == 0 and hasHoming == 0) and 1 or 0

		local node = 0
		local rnkey = rn
		if rnkey ~= nil then
			local n = BS_GS_NODES[rnkey]
			if n and n.valid then node = 1 end
		end

		local nextn = (rail and rail.GSnextrail and rail.GSnextrail.valid) and rail.GSnextrail.GSrailnum or -1
		local prevn = (rail and rail.GSprevrail and rail.GSprevrail.valid) and rail.GSprevrail.GSrailnum or -1

		CONS_Printf(p,
			"[BS][GSR] #"..idx..
			" d="..d..
			" railnum="..tostring(rn)..
			" type="..tostring(ty)..
			" gf="..tostring(gf)..
			" jf="..tostring(jf)..
			" lf="..tostring(lf)..
			" hom="..hasHoming..
			" jr="..jr..
			" node="..node..
			" NEEDS_NODE="..needsNode..
			" next="..tostring(nextn)..
			" prev="..tostring(prevn)..
			(tag and (" "..tag) or "")
		)
	end
local function BS_GS_ClearNodes()
	BS_GS_NODES = {}
	BS_GS_NODES_BUILT = false
	BS_GS_NODECOUNT = 0
end

addHook("MapLoad", function()
	BS_GS_ClearNodes()
end)

local function BS_GS_HasGSHoming(rail)
	local gf = rail.GSgrindflags or 0
	local lf = rail.GSloopflags or 0
	-- GS only spawns its own homing point when HOMING flag is set and it's not a loop
	if (gf & 1048576) == 0 then return false end
	if (lf & 2097152) != 0 then return false end
	return true
end

local function BS_GS_SpawnNodeForRail(rail)
	local MT_NODE = rawget(_G, "MT_BS_GRINDNODE")
	if not MT_NODE then return nil end

	-- Compute midpoint exactly like GS's own homing-point code, but spawn our MT_BS_GRINDNODE instead.
	local len = rail.GSrailXYlength
	if (not len) and rail.GSnextrail and rail.GSnextrail.valid then
		local dx = rail.x - rail.GSnextrail.x
		local dy = rail.y - rail.GSnextrail.y
		len = (rawget(_G, "FixedHypot") and FixedHypot(dx, dy)) or P_AproxDistance(dx, dy)
	end
	if not len or len <= 0 then return nil end

	local half = len/2
	local snapX = rail.x - FixedMul(half, cos(rail.angle))
	local snapY = rail.y - FixedMul(half, sin(rail.angle))

	local node = P_SpawnMobj(snapX, snapY, rail.z, MT_NODE)
	if not (node and node.valid) then return nil end

	-- keep it invisible + simple
	node.flags2 = $|MF2_DONTDRAW
	node.flags = ($|MF_NOSECTOR|MF_NOCLIP|MF_NOGRAVITY|MF_NOTHINK) & ~MF_SOLID

	local snapZ = rail.z
	local RAILT = rawget(_G, "RAIL")
	if RAILT and RAILT.GetRailZ then
		local z = RAILT.GetRailZ(rail, node, 0, nil, {X=snapX, Y=snapY})
		if z then snapZ = z end
	end

	-- Vertical placement: emulate GS homing point so we aim at the rail surface, not the underside.
	local zpos = snapZ - (10<<16)
	if rail.floorz and (abs(rail.z - rail.floorz) < (42<<16)) then
		if ((rail.GSgrindflags or 0) & 524288) != 0 then
			zpos = $ + (14<<16)
		else
			zpos = $ + (2<<16)
		end
	end

	P_MoveOrigin(node, snapX, snapY, zpos)

	node.bs_gsrail = rail
	node.bs_gsrailnum = rail.GSrailnum
	node.bs_gstype = rail.GStype

	return node
end

local function BS_GS_BuildNodes()
	if BS_GS_NODES_BUILT then return end

	local dir = rawget(_G, "GS_RAILS_DIRECTOR")
	if not (dir and dir.valid) then return end
	local t = dir.GSrailtable
	if type(t) ~= "table" then return end

	local made = 0
	local maxmake = 2048

	for _, rail0 in pairs(t) do
		local rail = rail0
		if rail and rail.valid then
			local rn = rail.GSrailnum
			if rn ~= nil and not BS_GS_NODES[rn] then
				-- Skip jumpropes and any rail that already has GS's own homing-point.
				if (not BS_IsJumprope(rail)) and (not BS_GS_HasGSHoming(rail)) then
					-- Skip disabled/invisible rails.
					if (rail.GSdisabled != true) and (not rail.GSinvisiblerail) then
						local node = BS_GS_SpawnNodeForRail(rail)
						if node and node.valid then
							BS_GS_NODES[rn] = node
							made = $+1
						end
					end
				end
			end
		end
		if made >= maxmake then break end
	end

	BS_GS_NODECOUNT = made
	BS_GS_NODES_BUILT = true
end

addHook("MapLoad", function()
	-- Reset per map so you don't need to run bs_gsrail_buildnodes every level.
	BS_GS_ClearNodes()
	BS_GS_NODES_BUILT = false
	BS_GS_NODECOUNT = 0
end)

addHook("ThinkFrame", function()
	-- Respect gsrailtarget toggle (still allows manual bs_gsrail_buildnodes for debugging)
	if not BS_GSRailTargetEnabled() then return end
	-- Build once per map, after GS rails populate their table.
	-- This does NOT require that rails be targetable or in the blockmap.
	if (not BS_GS_NODES_BUILT) and (leveltime > 0) then
		BS_GS_BuildNodes()
	end
end)

if COM_AddCommand then
	COM_AddCommand("bs_gsrail_buildnodes", function(p)
		BS_GS_ClearNodes()
		BS_GS_BuildNodes()
		if p and p.valid then
			CONS_Printf(p, "[BS][GSR] built nodes="..tostring(BS_GS_NODECOUNT))
		end
	end)
	COM_AddCommand("bs_gsrail_nodestatus", function(p)
		if p and p.valid then
			CONS_Printf(p, "[BS][GSR] nodes built="..tostring(BS_GS_NODES_BUILT).." count="..tostring(BS_GS_NODECOUNT))
		end
	end)
end



	-- Console command: dump nearest GS rails around you (works even when not targetable)
	-- Usage: bs_gsrail_dump [radius] [max]
	-- radius/max are in map units (not FRACUNIT). ex: bs_gsrail_dump 2048 12
	if COM_AddCommand then
		COM_AddCommand("bs_gsrail_dump", function(p, radArg, maxArg)
			if not p or not p.valid then return end
			if not p.mo or not p.mo.valid then return end

			local rad = (BS_ToNum(radArg) or 2048)
			local maxn = (BS_ToNum(maxArg) or 12)

			rad = BS_ClampInt(rad, 128, 16384) * FRACUNIT
			maxn = BS_ClampInt(maxn, 1, 50)

local dir = rawget(_G, "GS_RAILS_DIRECTOR")
local t = dir and dir.GSrailtable
if type(t) ~= "table" then
	CONS_Printf(p, "[BS][GSR] GS_RAILS_DIRECTOR.GSrailtable not found (GS rails not loaded?)")
	return
end

local me = p.mo
local best = {}
local seen = {}

for _, rail0 in pairs(t) do
	local rail = rail0
	if rail and rail.valid then
		local key = rail.GSrailnum
		if key == nil then key = rail end
		if not seen[key] then
			-- early-out by AABB to keep this cheap
			local dx = rail.x - me.x
			if dx <= rad and dx >= -rad then
				local dy = rail.y - me.y
				if dy <= rad and dy >= -rad then
					local dz = rail.z - me.z
					if dz <= rad*2 and dz >= -rad*2 then
						seen[key] = true

						-- cheap sort distance (no hypot)
						local d = abs(dx) + abs(dy) + abs(dz/2)
						local dint = FixedInt(d)

						local entry = {d=dint, rail=rail}
						local inserted = false
						for i=1,#best do
							if dint < best[i].d then
								table.insert(best, i, entry)
								inserted = true
								break
							end
						end
						if not inserted then
							table.insert(best, entry)
						end
						if #best > maxn then
							best[#best] = nil
						end
					end
				end
			end
		end
	end
end

			CONS_Printf(p, "[BS][GSR] dump radius="..tostring(FixedInt(rad)).." max="..tostring(maxn).." found="..tostring(#best))
			for i=1,#best do
				BS_PrintRailLine(p, i, best[i].d, best[i].rail)
			end
		end)

		-- Console command: show full info for a specific railnum (nearest instance)
		-- Usage: bs_gsrail_info <railnum> [radius]
		COM_AddCommand("bs_gsrail_info", function(p, railnumArg, radArg)
			if not p or not p.valid then return end
			if not p.mo or not p.mo.valid then return end

			local want = BS_ToNum(railnumArg)
			if want == nil then
				CONS_Printf(p, "[BS][GSR] bs_gsrail_info <railnum> [radius]")
				return
			end

			local rad = (BS_ToNum(radArg) or 4096)
			rad = BS_ClampInt(rad, 128, 32768) * FRACUNIT

			local me = p.mo
			local x1, x2 = me.x - rad, me.x + rad
			local y1, y2 = me.y - rad, me.y + rad

			local bestRail = nil
			local bestD = 99999999

			local dir = rawget(_G, "GS_RAILS_DIRECTOR")
local t = dir and dir.GSrailtable
if type(t) ~= "table" then
	CONS_Printf(p, "[BS][GSR] GS_RAILS_DIRECTOR.GSrailtable not found (GS rails not loaded?)")
	return
end

local function BS_AbsFixed(v) if v < 0 then return -v end return v end
for _, rail0 in pairs(t) do
	local rail = rail0
	if rail and rail.valid and rail.GSrailnum == want then
		local dx = rail.x - me.x
		if dx <= rad and dx >= -rad then
			local dy = rail.y - me.y
			if dy <= rad and dy >= -rad then
				local d = FixedInt(BS_AbsFixed(dx)+BS_AbsFixed(dy))
				if d < bestD then
					bestD = d
					bestRail = rail
				end
			end
		end
	end
end


			if not bestRail then
				CONS_Printf(p, "[BS][GSR] railnum="..tostring(want).." not found within radius.")
				return
			end

			BS_PrintRailLine(p, 1, bestD, bestRail, "(DETAIL)")
			CONS_Printf(p, "[BS][GSR] flags="..tostring(bestRail.flags).." flags2="..tostring(bestRail.flags2).." typeid="..tostring(bestRail.type))
			CONS_Printf(p, "[BS][GSR] pos x="..tostring(bestRail.x).." y="..tostring(bestRail.y).." z="..tostring(bestRail.z).." angle="..tostring(bestRail.angle))
			if bestRail.spawnpoint then
				local sp = bestRail.spawnpoint
				CONS_Printf(p, "[BS][GSR] spawn tag="..tostring(sp.tag).." options="..tostring(sp.options).." extra="..tostring(sp.extrainfo).." pitch="..tostring(sp.pitch).." roll="..tostring(sp.roll))
			end
		end)
	end
end


-- ------------------------------------------------------------
-- Console commands: linedef rail homing debug + ignore lists
-- ------------------------------------------------------------
if COM_AddCommand then
    COM_AddCommand("bs_railhoming_debug", function(p, arg)
        local v = BS_RH_ToNum(arg)
        if v == nil then
            -- toggle between OFF and mode 1
            if BS_RH_DEBUG == 0 then BS_RH_DEBUG = 1 else BS_RH_DEBUG = 0 end
        else
            -- allow 0/1/2
            if v < 0 then v = 0 end
            if v > 2 then v = 2 end
            BS_RH_DEBUG = v
        end
        if p and p.valid then
            CONS_Printf(p, "[BS][RH] debugmode="..tostring(BS_RH_DEBUG).." (0=off 1=on-change 2=spam)")
        end
    end)

    COM_AddCommand("bs_railhoming_last", function(p)
        if not (p and p.valid) then return end
        BS_RH_PrintNodeBrief(p, p.bs_rh_lastnode, "LAST_ANY")
        BS_RH_PrintLineInfo(p, p.bs_rh_lastlinedefnode, "LAST_LINEDEF")
    end)

    COM_AddCommand("bs_railhoming_ignore_tag", function(p, tagArg)
        local tag = BS_RH_ToNum(tagArg)
        if tag == nil then
            if p and p.valid then CONS_Printf(p, "[BS][RH] usage: bs_railhoming_ignore_tag <tag>") end
            return
        end
        BS_RH_IGNORE.tags[tag] = true
        if p and p.valid then CONS_Printf(p, "[BS][RH] ignoring tag="..tostring(tag)) end
    end)

    COM_AddCommand("bs_railhoming_ignore_special", function(p, spArg)
        local sp = BS_RH_ToNum(spArg)
        if sp == nil then
            if p and p.valid then CONS_Printf(p, "[BS][RH] usage: bs_railhoming_ignore_special <special>") end
            return
        end
        BS_RH_IGNORE.specials[sp] = true
        if p and p.valid then CONS_Printf(p, "[BS][RH] ignoring special="..tostring(sp)) end
    end)

    COM_AddCommand("bs_railhoming_ignore_midtex", function(p, mtArg)
        local mt = BS_RH_ToNum(mtArg)
        if mt == nil then
            if p and p.valid then CONS_Printf(p, "[BS][RH] usage: bs_railhoming_ignore_midtex <textureid>") end
            return
        end
        BS_RH_IGNORE.midtex[mt] = true
        if p and p.valid then CONS_Printf(p, "[BS][RH] ignoring midtex="..tostring(mt)) end
    end)

    COM_AddCommand("bs_railhoming_ignore_flagmask", function(p, maskArg)
        local mask = BS_RH_ToNum(maskArg)
        if mask == nil then
            if p and p.valid then CONS_Printf(p, "[BS][RH] usage: bs_railhoming_ignore_flagmask <mask> (reject if ANY bits set)") end
            return
        end
        BS_RH_IGNORE.flagmask = mask
        if p and p.valid then CONS_Printf(p, "[BS][RH] flagmask="..tostring(mask)) end
    end)

    COM_AddCommand("bs_railhoming_clearignore", function(p)
        BS_RH_IGNORE.tags = {}
        BS_RH_IGNORE.specials = {}
        BS_RH_IGNORE.midtex = {}
        BS_RH_IGNORE.sigs = {}
        BS_RH_IGNORE.segs = {}
        BS_RH_IGNORE.flagmask = 0
        if p and p.valid then CONS_Printf(p, "[BS][RH] ignore lists cleared") end
    end)

    
    COM_AddCommand("bs_railhoming_dumpnodes", function(p, radArg, maxArg)
        if not (p and p.valid) then return end
        local mo = p.mo
        if not (mo and mo.valid) then return end

        local MT_BS_GRINDNODE_CONST = BS_GetGrindNodeType()
        if MT_BS_GRINDNODE_CONST == nil then
            CONS_Printf(p, "[BS][RH] dumpnodes: grind node type not available")
            return
        end

        local rad = BS_RH_ToNum(radArg) or 600
        local maxn = BS_RH_ToNum(maxArg) or 12
        if maxn < 1 then maxn = 1 end
        if maxn > 64 then maxn = 64 end

        rad = rad * FRACUNIT

        local found = {}

        local function Push(n)
            if not (n and n.valid) then return end
            if n.type ~= MT_BS_GRINDNODE_CONST then return end
            local dx = n.x - mo.x
            local dy = n.y - mo.y
            local d = P_AproxDistance(dx, dy)
            found[#found+1] = {n=n, d=d}
        end

        if searchBlockmap then
            searchBlockmap("objects", function(n)
                -- only keep grind nodes within radius cube; we'll compute 2D dist for sorting
                if n and n.valid and n.type == MT_BS_GRINDNODE_CONST then
                    Push(n)
                end
            end, mo, mo.x-rad, mo.x+rad, mo.y-rad, mo.y+rad)
        else
            local list = rawget(_G, "BS_GRINDNODE_LIST")
            if list then
                for i = 1, #list do
                    Push(list[i])
                end
            end
        end

        table.sort(found, function(a,b) return a.d < b.d end)

        CONS_Printf(p, "[BS][RH] dumpnodes radius="..tostring(FixedInt(rad)).." max="..tostring(maxn).." found="..tostring(#found))

        local printed = 0
        for i = 1, #found do
            local n = found[i].n
            local d = found[i].d
            if d <= rad then
                printed = $+1
                local kind = "other"
                if n.bs_grindline ~= nil then kind = "linedef"
                elseif n.bs_gsrailnum ~= nil then kind = "gs" end

                local extra = ""
                if kind == "linedef" and n.bs_grindline ~= nil then
                    local line = n.bs_grindline
                    local tag = line.tag or 0
                    local sp  = line.special or 0
                    local fl  = line.flags or 0
                    local mt  = BS_RH_LineMidTex(line) or 0
                    extra = " sig="..tostring(tag)..":"..tostring(sp)..":"..tostring(fl)..":"..tostring(mt)
                    if line.v1 and line.v2 then
                        extra = extra.." v1=("..tostring(FixedInt(line.v1.x))..","..tostring(FixedInt(line.v1.y))..")"
                            .." v2=("..tostring(FixedInt(line.v2.x))..","..tostring(FixedInt(line.v2.y))..")"
                    end
                elseif kind == "gs" then
                    extra = " gsnum="..tostring(n.bs_gsrailnum or -1)
                end

                CONS_Printf(p, "[BS][RH] #"..tostring(printed)
                    .." d="..tostring(FixedInt(d))
                    .." kind="..kind
                    .." x="..tostring(FixedInt(n.x)).." y="..tostring(FixedInt(n.y)).." z="..tostring(FixedInt(n.z))
                    ..extra
                )

                if printed >= maxn then break end
            end
        end
    end)

COM_AddCommand("bs_railhoming_listignore", function(p)
        if not (p and p.valid) then return end
        local tc, sc, mc, xc, gc = 0, 0, 0, 0, 0
        for _ in pairs(BS_RH_IGNORE.tags) do tc = $+1 end
        for _ in pairs(BS_RH_IGNORE.specials) do sc = $+1 end
        for _ in pairs(BS_RH_IGNORE.midtex) do mc = $+1 end
        for _ in pairs(BS_RH_IGNORE.sigs) do xc = $+1 end
        for _ in pairs(BS_RH_IGNORE.segs) do gc = $+1 end
        local bc = 0
        for _ in pairs(BS_RH_BUILTIN_SEGS) do bc = $+1 end
        CONS_Printf(p, "[BS][RH] ignore counts: tags="..tostring(tc).." specials="..tostring(sc).." midtex="..tostring(mc).." sigs="..tostring(xc).." segs="..tostring(gc).." builtinsegs="..tostring(bc).." flagmask="..tostring(BS_RH_IGNORE.flagmask))
    end)

    COM_AddCommand("bs_railhoming_ignore_last_tag", function(p)
        if not (p and p.valid) then return end
        local n = p.bs_rh_lastlinedefnode
        if not (n and n.valid and n.bs_grindline) then
            CONS_Printf(p, "[BS][RH] no last linedef rail target")
            return
        end
        local tag = n.bs_grindline.tag or 0
        BS_RH_IGNORE.tags[tag] = true
        CONS_Printf(p, "[BS][RH] ignoring last tag="..tostring(tag))
    end)

    COM_AddCommand("bs_railhoming_ignore_last_special", function(p)
        if not (p and p.valid) then return end
        local n = p.bs_rh_lastlinedefnode
        if not (n and n.valid and n.bs_grindline) then
            CONS_Printf(p, "[BS][RH] no last linedef rail target")
            return
        end
        local sp = n.bs_grindline.special or 0
        BS_RH_IGNORE.specials[sp] = true
        CONS_Printf(p, "[BS][RH] ignoring last special="..tostring(sp))
    end)

    COM_AddCommand("bs_railhoming_ignore_last_midtex", function(p)
        if not (p and p.valid) then return end
        local n = p.bs_rh_lastlinedefnode
        if not (n and n.valid and n.bs_grindline) then
            CONS_Printf(p, "[BS][RH] no last linedef rail target")
            return
        end
        local mt = BS_RH_LineMidTex(n.bs_grindline) or 0
        BS_RH_IGNORE.midtex[mt] = true
        CONS_Printf(p, "[BS][RH] ignoring last midtex="..tostring(mt))
    end)

    COM_AddCommand("bs_railhoming_ignore_last_seg", function(p)
        if not (p and p.valid) then return end
        local n = p.bs_rh_lastlinedefnode
        if not (n and n.valid and n.bs_grindline) then
            CONS_Printf(p, "[BS][RH] no last linedef rail target")
            return
        end
        local sk = BS_RH_SegKeyFromLine(n.bs_grindline)
        if sk == nil then
            CONS_Printf(p, "[BS][RH] last linedef has no v1/v2")
            return
        end
        BS_RH_IGNORE.segs[sk] = true
        CONS_Printf(p, "[BS][RH] ignoring last seg="..sk)
    end)

    COM_AddCommand("bs_railhoming_ignore_sig", function(p, tagArg, spArg, flArg, mtArg)
        local tag = BS_RH_ToNum(tagArg) or 0
        local sp  = BS_RH_ToNum(spArg)  or 0
        local fl  = BS_RH_ToNum(flArg)  or 0
        local mt  = BS_RH_ToNum(mtArg)  or 0
        local sig = tostring(tag)..":"..tostring(sp)..":"..tostring(fl)..":"..tostring(mt)
        BS_RH_IGNORE.sigs[sig] = true
        if p and p.valid then CONS_Printf(p, "[BS][RH] ignoring sig="..sig) end
    end)

    COM_AddCommand("bs_railhoming_ignore_last_sig", function(p)
        if not (p and p.valid) then return end
        local sig = p.bs_rh_lastlinedefsig
        if sig == nil or sig == "" then
            -- fallback: compute from persisted linedef node if present
            local n = p.bs_rh_lastlinedefnode
            if n and n.valid and n.bs_grindline ~= nil then
                sig = BS_RH_LineSig(n)
            end
        end
        if sig == nil or sig == "" then
            CONS_Printf(p, "[BS][RH] no last linedef rail signature")
            return
        end
        BS_RH_IGNORE.sigs[sig] = true
        CONS_Printf(p, "[BS][RH] ignoring last sig="..sig)
    end)
end

