local scene_manager_type = sdk.find_type_definition("via.SceneManager")
local ENEMY_FIELD = "<EnemyContextList>k__BackingField"

-- Patient X V2.1 - RE9 Mod
-- By Kazzy
-- 03rd April 2026

log.info("[MrX] MRX MODE RELEASE LOADED")

local state = nil

_G.RE9_MRX_DEBUG_STATE = nil

local function wall_now()
    return os.clock()
end

math.randomseed(os.time() + math.floor((wall_now() or 0) * 100000))
math.random()
math.random()
math.random()

local function now()
    return (state and state.gameplay_time) or wall_now()
end

local function get_game_clock()
    return sdk.get_managed_singleton("app.GameClock")
        or sdk.get_managed_singleton("offline.GameClock")
        or sdk.get_managed_singleton("app.GameTimeManager")
end

local function normalize_time_value(v)
    if type(v) ~= "number" then return nil end
    if v > 100000.0 then
        return v * 0.000001
    end
    if v < 0 then return 0.0 end
    return v
end

local function try_get_time_value(obj, methods, fields)
    if not obj then return nil end
    for _, name in ipairs(methods or {}) do
        local ok, v = pcall(function() return obj:call(name) end)
        v = normalize_time_value(v)
        if ok and v ~= nil then return v end
    end
    for _, name in ipairs(fields or {}) do
        local ok, v = pcall(function() return obj:get_field(name) end)
        v = normalize_time_value(v)
        if ok and v ~= nil then return v end
    end
    return nil
end

local function tick_gameplay_clock(player_go)
    local gc = get_game_clock()
    local paused = false
    local delta = nil
    local running_timers = nil

    if gc then
        local ok = pcall(function()
            local pause_flag = gc._MeasurePauseSpendingTime
            local inventory_flag = gc._MeasureInventorySpendingTime
            paused = (pause_flag ~= nil and pause_flag ~= false and pause_flag ~= 0)
                or (inventory_flag ~= nil and inventory_flag ~= false and inventory_flag ~= 0)
        end)
        if not ok then paused = false end

        pcall(function()
            running_timers = gc._RunningTimers
            if running_timers == nil then
                running_timers = gc:get_field("_RunningTimers")
            end
        end)
        if type(running_timers) == "number" then
            state.last_running_timers = running_timers
            if running_timers ~= 7 then
                paused = true
            end
        end

        local gui = sdk.get_managed_singleton("app.GuiManager") or sdk.get_managed_singleton("app.GUIManager")
        if gui then
            local ok_gui = pcall(function()
                local active_gui_with_pause = nil
                local hud_paused = nil
                local visible_type = nil

                local ok1, v1 = pcall(function() return gui:call("get_ActiveGuiWithPause") end)
                if ok1 then active_gui_with_pause = v1 end
                if active_gui_with_pause == nil then
                    local ok1f, v1f = pcall(function() return gui:get_field("<ActiveGuiWithPause>k__BackingField") end)
                    if ok1f then active_gui_with_pause = v1f end
                end

                local ok2, v2 = pcall(function() return gui:call("get_IsHudPaused") end)
                if ok2 then hud_paused = v2 end

                local ok3, v3 = pcall(function() return gui:call("get_CurrentVisibleSituationType") end)
                if ok3 then visible_type = v3 end

                state.last_gui_active_with_pause = active_gui_with_pause
                state.last_gui_hud_paused = hud_paused
                state.last_gui_visible_type = visible_type

                if active_gui_with_pause == true or hud_paused == true then
                    paused = true
                end
            end)
            if not ok_gui then
                state.last_gui_active_with_pause = nil
                state.last_gui_hud_paused = nil
                state.last_gui_visible_type = nil
            end
        end

        local pm = sdk.get_managed_singleton("app.PauseManager")
        if pm then
            local ok_pm = pcall(function()
                local systems = nil
                local bits = nil
                local ok1, v1 = pcall(function() return pm:get_field("_CurrentPauseSystems") end)
                if ok1 then systems = v1 end
                local ok2, v2 = pcall(function() return pm:get_field("_CurrentPauseTypeBits") end)
                if ok2 then bits = v2 end
                state.last_pause_systems = systems
                state.last_pause_type_bits = bits
                if type(systems) == "number" and systems > 0 then
                    paused = true
                end
            end)
            if not ok_pm then
                state.last_pause_systems = nil
                state.last_pause_type_bits = nil
            end
        end

        delta = try_get_time_value(gc,
            {"get_DeltaTime", "get_DeltaSecond", "get_DeltaSec", "get_GameDeltaTime", "get_PlayingDeltaTime", "get_FrameDeltaTime", "get_ElapsedSecond"},
            {"_DeltaTime", "_PlayingDeltaTime", "_DeltaSecond", "_FrameDeltaTime"})

        if delta == nil then
            local engine_time = try_get_time_value(gc,
                {"get_PlayingTime", "get_ActualPlayingTime", "get_GameTime", "get_ElapsedTime", "get_PlayTime", "get_Time"},
                {"_PlayingTime", "_ActualPlayingTime", "_GameTime", "_ElapsedTime"})
            if engine_time ~= nil then
                if state.last_engine_time ~= nil then
                    delta = math.max(0.0, engine_time - state.last_engine_time)
                else
                    delta = 0.0
                end
                state.last_engine_time = engine_time
            end
        end
    end

    if player_go then
        local ok, update_self = pcall(function() return player_go:get_UpdateSelf() end)
        if ok and update_self == false then paused = true end
    end

    local wall = wall_now()
    local wall_delta = math.max(0.0, wall - (state.last_wall_time or wall))
    state.last_wall_time = wall

    if delta == nil then
        delta = wall_delta
    end

    if delta <= 0.000001 then
        paused = true
    end

    state.paused = paused
    state.last_clock_delta = delta

    if not paused then
        state.gameplay_time = (state.gameplay_time or wall) + math.min(delta, 0.25)
    end

    return paused
end

local function load_lua_table_from_file(path)
    local f = io.open(path, "r")
    if not f then return nil, "open failed: " .. tostring(path) end
    local text = f:read("*a")
    f:close()
    if not text or #text == 0 then return nil, "empty file: " .. tostring(path) end
    local chunk, err = load(text, "@" .. tostring(path), "t", _ENV)
    if not chunk then return nil, tostring(err) end
    local ok, ret = pcall(chunk)
    if not ok then return nil, tostring(ret) end
    if type(ret) ~= "table" then return nil, "file did not return a table" end
    return ret
end

local function load_first_existing_lua_table(paths)
    for _, path in ipairs(paths or {}) do
        local ret, err = load_lua_table_from_file(path)
        if type(ret) == "table" then
            return ret, path
        end
    end
    return nil, "no compatible mapper export found"
end

local embedded_mapper_export = (function()
-- RE9 cutoff mapper export
-- paste pieces of this into your hospital script config

local cutoff_points = {
    EW_Lobby_Hall = {
        note = "",
        x = 14.983778,
        y = 2.280000,
        z = 22.247967,
    },
    Gallery = {
        note = "",
        x = -17.992113,
        y = 7.285001,
        z = 23.354080,
    },
    HDD1_Hall = {
        note = "",
        x = 8.872718,
        y = 2.280000,
        z = 39.008194,
    },
    HDD2_Hall = {
        note = "",
        x = -9.320707,
        y = 2.280000,
        z = 38.957268,
    },
    HDU_Hall = {
        note = "",
        x = -12.556682,
        y = 7.285001,
        z = 36.757076,
    },
    Isolation_HallRoom = {
        note = "",
        x = 42.094460,
        y = 2.280000,
        z = 29.077759,
    },
    Kitchenette = {
        note = "",
        x = -41.077015,
        y = 7.280001,
        z = 24.219681,
    },
    LROffice_Room = {
        note = "",
        x = 52.553780,
        y = 7.281632,
        z = 59.699265,
    },
    Lockerroom = {
        note = "",
        x = 45.022823,
        y = 7.281630,
        z = 43.477154,
    },
    SideStairs = {
        note = "",
        x = 54.109165,
        y = 4.780000,
        z = 38.499931,
    },
    TreatmentRoom = {
        note = "",
        x = 25.763069,
        y = 2.280030,
        z = 21.261261,
    },
    Treatmentroom_Hall = {
        note = "",
        x = 32.432491,
        y = 2.280000,
        z = 17.301022,
    },
    Waitingroom = {
        note = "",
        x = 22.570520,
        y = 2.280000,
        z = 44.223484,
    },
    Waitingroom_Hall = {
        note = "",
        x = 38.451347,
        y = 2.280000,
        z = 43.901173,
    },
    bar = {
        note = "",
        x = -28.828928,
        y = 7.295380,
        z = 14.367432,
    },
    coldstorage_hallroom = {
        note = "",
        x = -37.834587,
        y = 2.279999,
        z = 11.964463,
    },
    custodian_hall = {
        note = "towards kitchen",
        x = -39.969967,
        y = 2.285022,
        z = 42.068523,
    },
    custodian_stairs = {
        note = "looking down stairs",
        x = -45.585155,
        y = 4.780002,
        z = 54.678169,
    },
    diningroom_hallroom = {
        note = "",
        x = -17.823235,
        y = 2.280000,
        z = 26.959644,
    },
    filingroom_hall = {
        note = "",
        x = -23.754814,
        y = 2.279999,
        z = 7.475746,
    },
    kitchen = {
        note = "",
        x = -38.154427,
        y = 2.284999,
        z = 28.415382,
    },
    kitchen_hallway = {
        note = "",
        x = -35.058975,
        y = 2.285021,
        z = 42.114738,
    },
    office_entrance = {
        note = "",
        x = -43.127209,
        y = 7.280000,
        z = 41.268333,
    },
    restroom_hallroom = {
        note = "",
        x = -41.613335,
        y = 2.285000,
        z = 24.243614,
    },
    upper_stairs = {
        note = "",
        x = -47.069481,
        y = 4.812734,
        z = 3.500879,
    },
}

local cutoff_zones = {
    {
        center = {
            x = -7.105704,
            y = 2.277446,
            z = 39.005306,
        },
        destinations = {
            "custodian_hall",
            "custodian_stairs",
            "office_entrance",
        },
        name = "H_DD_2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -42.856152,
            y = 7.280000,
            z = 40.620590,
        },
        destinations = {
            "upper_stairs",
            "Gallery",
            "bar",
            "HDU_Hall",
            "custodian_stairs",
        },
        name = "Office_Doors",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -37.325035,
            y = 7.289083,
            z = 14.726840,
        },
        destinations = {
            "upper_stairs",
            "Kitchenette",
            "office_entrance",
            "custodian_stairs",
            "HDU_Hall",
        },
        name = "Bar_Door_1",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -19.043272,
            y = 7.285001,
            z = 31.193441,
        },
        destinations = {
            "HDU_Hall",
            "upper_stairs",
            "bar",
            "Kitchenette",
            "office_entrance",
            "custodian_stairs",
        },
        name = "Chairmas_Office",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -9.332930,
            y = 7.285001,
            z = 39.076450,
        },
        destinations = {
            "custodian_stairs",
            "upper_stairs",
            "bar",
            "Kitchenette",
            "office_entrance",
        },
        name = "H_D_U",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -37.284458,
            y = 7.280001,
            z = 34.962299,
        },
        destinations = {
            "Kitchenette",
            "office_entrance",
            "upper_stairs",
            "HDU_Hall",
            "custodian_stairs",
        },
        name = "records_room",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -57.339439,
            y = 1.233311,
            z = 11.926749,
        },
        destinations = {
            "upper_stairs",
            "bar",
            "Kitchenette",
            "office_entrance",
            "coldstorage_hallroom",
        },
        name = "Cold_Storage",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -33.015896,
            y = 2.279999,
            z = 8.192430,
        },
        destinations = {
            "upper_stairs",
            "coldstorage_hallroom",
            "bar",
        },
        name = "filingroom",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -29.976013,
            y = 2.279999,
            z = 14.084188,
        },
        destinations = {
            "filingroom_hall",
            "upper_stairs",
            "coldstorage_hallroom",
            "bar",
            "Kitchenette",
        },
        name = "filingroom_2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -22.255461,
            y = 2.279999,
            z = 7.534563,
        },
        destinations = {
            "upper_stairs",
            "restroom_hallroom",
            "coldstorage_hallroom",
            "HDD2_Hall",
            "kitchen_hallway",
            "custodian_hall",
            "custodian_stairs",
        },
        name = "DiningRoom_1",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -18.009821,
            y = 2.285149,
            z = 20.685780,
        },
        destinations = {
            "restroom_hallroom",
            "HDD2_Hall",
            "kitchen_hallway",
            "custodian_hall",
            "custodian_stairs",
        },
        name = "DiningRoom_2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -14.500240,
            y = 2.280000,
            z = 28.556671,
        },
        destinations = {
            "custodian_stairs",
            "kitchen_hallway",
            "coldstorage_hallroom",
            "upper_stairs",
            "HDD2_Hall",
        },
        name = "DiningRoom_Hall_Door",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -34.922806,
            y = 2.285000,
            z = 23.876368,
        },
        destinations = {
            "kitchen",
            "restroom_hallroom",
            "diningroom_hallroom",
            "filingroom_hall",
            "kitchen_hallway",
            "HDD2_Hall",
        },
        name = "Restroom_Door",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -38.540497,
            y = 2.285000,
            z = 17.036398,
        },
        destinations = {
            "diningroom_hallroom",
            "restroom_hallroom",
            "kitchen",
            "filingroom_hall",
            "upper_stairs",
            "custodian_hall",
        },
        name = "Restroom_Stall_1",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -40.655972,
            y = 2.285000,
            z = 17.250084,
        },
        destinations = {
            "diningroom_hallroom",
            "restroom_hallroom",
            "kitchen",
            "filingroom_hall",
            "upper_stairs",
            "custodian_hall",
        },
        name = "Restroom_Stall_2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -42.650867,
            y = 2.285000,
            z = 17.213278,
        },
        destinations = {
            "diningroom_hallroom",
            "restroom_hallroom",
            "kitchen",
            "filingroom_hall",
            "upper_stairs",
            "custodian_hall",
        },
        name = "Restroom_Stall_3",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -29.411293,
            y = 2.285000,
            z = 30.475897,
        },
        destinations = {
            "diningroom_hallroom",
            "filingroom_hall",
            "restroom_hallroom",
            "custodian_hall",
            "custodian_stairs",
            "kitchen_hallway",
            "HDD2_Hall",
            "coldstorage_hallroom",
        },
        name = "Kitchen_DD",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -40.624512,
            y = 2.284999,
            z = 37.465118,
        },
        destinations = {
            "kitchen",
            "kitchen_hallway",
            "diningroom_hallroom",
            "restroom_hallroom",
            "HDD2_Hall",
            "custodian_stairs",
        },
        name = "Pantry",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -29.581425,
            y = 2.284999,
            z = 39.688171,
        },
        destinations = {
            "custodian_stairs",
            "HDD2_Hall",
            "diningroom_hallroom",
            "filingroom_hall",
        },
        name = "Kitchen_D",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -20.514475,
            y = 2.280000,
            z = 44.239655,
        },
        destinations = {
            "custodian_hall",
            "HDD2_Hall",
            "diningroom_hallroom",
            "filingroom_hall",
            "custodian_stairs",
            "coldstorage_hallroom",
        },
        name = "Parlor",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -40.077576,
            y = 1.585000,
            z = 60.122890,
        },
        destinations = {
            "custodian_stairs",
            "kitchen",
            "kitchen_hallway",
            "diningroom_hallroom",
            "restroom_hallroom",
            "filingroom_hall",
            "HDD1_Hall",
        },
        name = "Garage",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 7.503091,
            y = 2.277481,
            z = 38.994804,
        },
        destinations = {
            "EW_Lobby_Hall",
            "Waitingroom",
            "TreatmentRoom",
        },
        name = "H_DD_1",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 19.594948,
            y = 2.280000,
            z = 38.924145,
        },
        destinations = {
            "EW_Lobby_Hall",
            "SideStairs",
            "Isolation_HallRoom",
            "Treatmentroom_Hall",
            "HDD1_Hall",
        },
        name = "Waitingroom_DD",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 31.911106,
            y = 2.280000,
            z = 45.458538,
        },
        destinations = {
            "SideStairs",
            "EW_Lobby_Hall",
            "HDD1_Hall",
            "Isolation_HallRoom",
            "Treatmentroom_Hall",
            "Waitingroom",
        },
        name = "Waitingroom_Closet",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 37.066181,
            y = 2.280000,
            z = 39.991402,
        },
        destinations = {
            "SideStairs",
            "EW_Lobby_Hall",
            "TreatmentRoom",
            "Waitingroom",
            "Treatmentroom_Hall",
            "HDD1_Hall",
        },
        name = "Waitingroom_D",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 44.102989,
            y = 2.280000,
            z = 29.047798,
        },
        destinations = {
            "TreatmentRoom",
            "Waitingroom",
            "HDD1_Hall",
            "Treatmentroom_Hall",
            "EW_Lobby_Hall",
            "SideStairs",
        },
        name = "Isolation_D1",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 55.192287,
            y = 2.280000,
            z = 26.804844,
        },
        destinations = {
            "EW_Lobby_Hall",
            "Waitingroom_Hall",
            "Treatmentroom_Hall",
            "HDD1_Hall",
            "Waitingroom",
            "SideStairs",
        },
        name = "Isolation_D2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 16.159702,
            y = 2.280000,
            z = 25.029501,
        },
        destinations = {
            "Treatmentroom_Hall",
            "Waitingroom_Hall",
            "Isolation_HallRoom",
            "HDD1_Hall",
            "Waitingroom",
            "SideStairs",
        },
        name = "Examroom",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 22.550915,
            y = 2.280030,
            z = 16.272694,
        },
        destinations = {
            "EW_Lobby_Hall",
            "HDD1_Hall",
            "Waitingroom_Hall",
            "Isolation_HallRoom",
            "Waitingroom",
            "SideStairs",
        },
        name = "Treatmentroom",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 38.856544,
            y = 2.280000,
            z = 14.809379,
        },
        destinations = {
            "EW_Lobby_Hall",
            "Treatmentroom_Hall",
            "Isolation_HallRoom",
            "Waitingroom",
            "HDD1_Hall",
        },
        name = "BloodLab",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 68.149773,
            y = 2.280000,
            z = 22.578882,
        },
        destinations = {
            "Isolation_HallRoom",
            "Treatmentroom_Hall",
            "HDD1_Hall",
            "EW_Lobby_Hall",
            "Waitingroom",
        },
        name = "SecurityManagerOffice",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 53.938217,
            y = 7.281631,
            z = 50.602623,
        },
        destinations = {
            "SideStairs",
            "Lockerroom",
            "Isolation_HallRoom",
            "Waitingroom",
            "EW_Lobby_Hall",
            "HDD1_Hall",
        },
        name = "LResearcherOffice",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 44.803852,
            y = 7.281631,
            z = 56.316353,
        },
        destinations = {
            "SideStairs",
            "Lockerroom",
            "Isolation_HallRoom",
            "Waitingroom",
            "EW_Lobby_Hall",
            "HDD1_Hall",
        },
        name = "LResearcherOffice_2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = 40.297230,
            y = 7.281631,
            z = 47.500690,
        },
        destinations = {
            "SideStairs",
            "Lockerroom",
            "Isolation_HallRoom",
            "Waitingroom",
            "HDD1_Hall",
            "EW_Lobby_Hall",
        },
        name = "ConferenceRoom",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
    {
        center = {
            x = -28.497219,
            y = 7.295380,
            z = 20.818609,
        },
        destinations = {
            "Gallery",
            "HDU_Hall",
            "upper_stairs",
            "Kitchenette",
            "custodian_stairs",
        },
        name = "Bar_Door_2",
        stall_time = 7.000000,
        trigger_radius = 3.200000,
    },
}

return { cutoff_points = cutoff_points, cutoff_zones = cutoff_zones }
end)()

local MAPPER_EXPORT_PATHS = {
    "reframework/data/re9_cutoff_mapper_export.lua",
    "re9_cutoff_mapper_export.lua",
}

local function build_cutoff_zones_from_export(export, source_label)
    if type(export) ~= "table" then
        return {}, tostring(source_label or "mapper export missing")
    end

    local raw_points = export.cutoff_points or {}
    local raw_zones = export.cutoff_zones or {}
    if type(raw_points) ~= "table" or type(raw_zones) ~= "table" then
        return {}, tostring(source_label or "mapper export invalid structure")
    end

    local zones = {}
    local built = 0

    for _, zone in ipairs(raw_zones) do
        if zone and zone.center and zone.center.x and zone.center.y and zone.center.z then
            local points = {}
            for _, dest_name in ipairs(zone.destinations or {}) do
                local p = raw_points[dest_name]
                if p and p.x and p.y and p.z then
                    table.insert(points, {
                        label = tostring(dest_name),
                        note = p.note or "",
                        x = p.x,
                        y = p.y,
                        z = p.z,
                    })
                end
            end

            table.insert(zones, {
                name = zone.name or ("Zone " .. tostring(#zones + 1)),
                center = { x = zone.center.x, y = zone.center.y, z = zone.center.z },
                trigger_radius = zone.trigger_radius or 3.2,
                player_radius = zone.player_radius,
                stall_time = zone.stall_time or 7.0,
                delay_min = zone.delay_min,
                delay_max = zone.delay_max,
                points = points,
            })
            built = built + 1
        end
    end

    return zones, string.format("%s (%d zones)", tostring(source_label or "mapper export"), built)
end

local function build_cutoff_zones_from_embedded_export()
    return build_cutoff_zones_from_export(embedded_mapper_export, "embedded mapper export")
end

local function build_cutoff_zones_external_first()
    local external, source = load_first_existing_lua_table(MAPPER_EXPORT_PATHS)
    if type(external) == "table" then
        local zones, status = build_cutoff_zones_from_export(external, "external mapper export: " .. tostring(source))
        if type(zones) == "table" and #zones > 0 then
            return zones, status
        end
    end
    return build_cutoff_zones_from_embedded_export()
end

local loaded_cutoff_zones, loaded_cutoff_status = build_cutoff_zones_external_first()

local function build_road_graph_from_export(export, source_label)
    if type(export) ~= "table" then
        return {
            nodes = {},
            links = {},
            node_by_label = {},
            neighbors_by_label = {},
            count_nodes = 0,
            count_links = 0,
        }, tostring(source_label or "road graph missing")
    end

    local raw_nodes = export.road_nodes or {}
    local raw_links = export.road_links or {}
    if type(raw_nodes) ~= "table" or type(raw_links) ~= "table" then
        return {
            nodes = {},
            links = {},
            node_by_label = {},
            neighbors_by_label = {},
            count_nodes = 0,
            count_links = 0,
        }, tostring(source_label or "road graph invalid structure")
    end

    local graph = {
        nodes = {},
        links = {},
        node_by_label = {},
        neighbors_by_label = {},
        count_nodes = 0,
        count_links = 0,
    }

    for _, node in ipairs(raw_nodes) do
        if type(node) == "table" and node.label and node.x and node.y and node.z then
            local normalized = {
                label = tostring(node.label),
                x = tonumber(node.x) or 0.0,
                y = tonumber(node.y) or 0.0,
                z = tonumber(node.z) or 0.0,
                kind = tostring(node.kind or "road"),
                region = tostring(node.region or "none"),
                floor = tostring(node.floor or "none"),
                tags = type(node.tags) == "table" and node.tags or {},
            }
            table.insert(graph.nodes, normalized)
            graph.node_by_label[normalized.label] = normalized
        end
    end

    for _, link in ipairs(raw_links) do
        if type(link) == "table" and link.from then
            local from_label = tostring(link.from)
            local arr = {}
            if type(link.to) == "table" then
                for _, dst in ipairs(link.to) do
                    arr[#arr + 1] = tostring(dst)
                    graph.neighbors_by_label[from_label] = graph.neighbors_by_label[from_label] or {}
                    graph.neighbors_by_label[from_label][#graph.neighbors_by_label[from_label] + 1] = tostring(dst)
                end
            end
            graph.links[#graph.links + 1] = { from = from_label, to = arr }
        end
    end

    graph.count_nodes = #graph.nodes
    graph.count_links = #graph.links
    return graph, string.format("%s (%d nodes / %d links)", tostring(source_label or "road graph"), graph.count_nodes, graph.count_links)
end

local function build_road_graph_external_first()
    local external, source = load_first_existing_lua_table(MAPPER_EXPORT_PATHS)
    if type(external) == "table" then
        return build_road_graph_from_export(external, "external road graph: " .. tostring(source))
    end
    return build_road_graph_from_export({}, "embedded export has no road graph")
end

local loaded_road_graph, loaded_road_status = build_road_graph_external_first()
local motion_type = sdk.typeof("via.motion.Motion")
local enemy_attack_damage_driver_type = sdk.find_type_definition("app.EnemyAttackDamageDriver")
local MRX_NAMES = { cp_C500 = true, cp_C500_00 = true, cp_C500_01 = true, cp_C500_02 = true }

local cfg = {
    enabled = true,
    debug_ui = false,
    publish_debug_bridge = false,

    release_delay = 2.0,
    stash_cooldown = 1.0,
    move_verify_radius = 2.0,
    release_prewarm_hold = 0.18,
    release_nodraw_prime_hold = 0.10,
    hold_light_refresh_interval = 0.25,
    hold_light_confirm_stale = 0.30,

    release = {
        prefer_global_points = true,
        min_player_distance = 16.0,
        max_player_distance = 120.0,
        safe_room_exclusion_radius = 18.0,
        current_room_exclusion_radius = 24.0,
        require_probably_offscreen = true,
        visible_front_dot = 0.55,
        visible_reject_distance = 14.0,
        history_size = 4,
        recent_label_penalty = 0.18,
        recent_family_penalty = 0.45,
        same_family_bonus = 1.12,
        family_bias_by_safe_room = {
            ["Custodian's Office"] = { west = 0.42, central = 0.95, east = 1.15, upper = 1.20 },
            ["Guard Office"] = { west = 1.00, central = 0.72, east = 1.10, upper = 1.10 },
        },
    },

    bootstrap_grace = 2.5,
    scene_change_grace = 2.0,
    bootstrap_safe_room_stash = true,
    bootstrap_spawn_scrub = true,
    bootstrap_spawn_scrub_delay = 0.12,
    bootstrap_spawn_scrub_hold = 0.20,
    bootstrap_spawn_scrub_min_distance = 34.0,

    startup_quarantine = {
        enabled = true,
        hold_point = { x = -27.4000, y = 7.2900, z = 28.5500 },
    },

    behavior = {
        search_repath_min = 30.0,
        search_repath_max = 60.0,
        idle_global_roam_repath_min = 10.0,
        idle_global_roam_repath_max = 18.0,
        idle_global_roam_linger_min = 2.0,
        idle_global_roam_linger_max = 4.0,
        idle_global_roam_player_min_distance = 16.0,
        idle_global_roam_min_unseen_time = 2.5,
        release_search_delay_min = 4.0,
        release_search_delay_max = 7.0,

        investigate_repath_min = 20.0,
        investigate_repath_max = 24.0,
        investigate_zone_repath_min = 20.0,
        investigate_zone_repath_max = 24.0,

        hard_investigate_repath_min = 18.0,
        hard_investigate_repath_max = 22.0,
        hard_investigate_zone_repath_min = 18.0,
        hard_investigate_zone_repath_max = 22.0,

        chase_recovery_repath_min = 3.0,
        chase_recovery_repath_max = 5.0,

        investigate_hold = 8.0,
        alert_hold = 12.0,

        search_linger_min = 4.0,
        search_linger_max = 8.0,

        investigate_linger_min = 2.0,
        investigate_linger_max = 4.0,
        investigate_move_linger_min = 4.0,
        investigate_move_linger_max = 7.0,
        investigate_zone_move_linger_min = 6.0,
        investigate_zone_move_linger_max = 10.0,

        investigate_commit_min = 1.75,
        investigate_commit_max = 3.00,
        investigate_commit_force_distance = 4.25,
        investigate_commit_visual_confirm = 1.10,
        investigate_commit_rearm_lock_distance = 10.0,
        investigate_commit_rearm_retry = 0.35,
        investigate_decay_no_stimulus = 10.0,
        investigate_decay_pending_max = 18.0,
        investigate_decay_displacement = 14.0,
        investigate_decay_far_displacement = 22.0,
        investigate_decay_search_cooldown = 1.25,

        visual_chase_distance = 10.0,
        visual_alert_distance = 15.0,
        chase_force_distance = 7.5,
        chase_confirm_time = 0.60,
        chase_stall_recover_time = 1.75,
        cutoff_settle_duration = 1.20,
        cutoff_settle_reset_interval = 0.20,
        cutoff_settle_post_search_delay = 0.85,
        investigate_recover_hold = 6.0,
        light_noise_radius = 8.0,
        soft_sound_speed = 0.85,
        hard_sound_speed = 2.2,
        soft_sound_radius = 24.0,
        hard_sound_radius = 60.0,
        rear_hearing_radius = 18.0,
        cross_floor_chase_y = 2.5,

        escalation_step_1 = 20.0,
        escalation_step_2 = 45.0,
        escalation_step_3 = 75.0,

        fire_input_key = 0x01, -- VK_LBUTTON
        allow_global_fire_input = true,

        fire_burst_window = 2.25,
        fire_pull_in_per_shot = 0.90,
        fire_min_pending_lead = 0.35,
        fire_burst_max_stacks = 5,
        fire_retarget_cooldown = 0.60,
        fire_repeat_hold_extend = 0.25,

        commit_replan_cooldown = 1.25,
        planned_replan_cooldown = 0.75,

        travel_delay = {
            near_dist = 10.0,
            mid_dist = 20.0,
            far_dist = 32.0,

            near_bonus_min = 0.0,
            near_bonus_max = 1.0,

            mid_bonus_min = 2.0,
            mid_bonus_max = 4.0,

            far_bonus_min = 4.0,
            far_bonus_max = 6.0,
        },

        investigate_travel_delay = {
            near_dist = 10.0,
            mid_dist = 18.0,
            far_dist = 28.0,

            near_bonus_min = 5.0,
            near_bonus_max = 11.0,

            mid_bonus_min = 9.0,
            mid_bonus_max = 15.0,

            far_bonus_min = 13.0,
            far_bonus_max = 19.0,
        },

        investigate_zone_travel_delay = {
            near_dist = 10.0,
            mid_dist = 18.0,
            far_dist = 28.0,

            near_bonus_min = 5.0,
            near_bonus_max = 11.0,

            mid_bonus_min = 9.0,
            mid_bonus_max = 15.0,

            far_bonus_min = 13.0,
            far_bonus_max = 19.0,

            floor_change_bonus_min = 3.0,
            floor_change_bonus_max = 5.0,
        },

        search_anchor = {
            enabled = true,
            min_player_distance = 8.0,
            investigate_min_player_distance = 6.0,
            max_player_distance = 48.0,
            investigate_max_player_distance = 28.0,
            investigate_zone_priority_distance = 24.0,
            investigate_zone_fallback_distance = 36.0,
            min_mrx_distance = 6.0,
            max_mrx_distance = 90.0,
            require_probably_offscreen = true,
            visible_front_dot = 0.55,
            visible_reject_distance = 12.0,
            prefer_forward_dot = 0.10,
            roam_cooldown = 0.75,
            investigate_cooldown = 0.50,
            near_player_move_lock_radius = 14.0,
            near_player_move_lock_speed = 0.35,
            near_player_move_lock_linger_min = 2.0,
            near_player_move_lock_linger_max = 4.0,

            investigate_zone_hijack = true,
            investigate_zone_reuse_cooldown = 12.0,
            investigate_zone_ignore_after_jump = 4.5,
            investigate_zone_random_top_n = 3,
            investigate_zone_center_forward_weight = 0.75,
            investigate_zone_too_close = 7.5,
            investigate_commit_fire_min_player_distance = 10.5,
            investigate_commit_fire_visible_distance = 36.0,
            investigate_commit_fire_front_distance = 42.0,
            investigate_commit_fire_front_dot = -0.30,
            investigate_commit_fire_visible_dot = 0.00,
            investigate_commit_fire_center_distance = 40.0,
            investigate_commit_fire_center_dot = 0.35,
        search_commit_fire_min_player_distance = 10.5,
        search_commit_fire_visible_distance = 34.0,
        search_commit_fire_front_distance = 40.0,
        search_commit_fire_front_dot = -0.30,
        search_commit_fire_visible_dot = 0.00,
        search_commit_fire_center_distance = 38.0,
        search_commit_fire_center_dot = 0.30,
        },
    },

    movement = {
        enabled = true,
        protect_animations = true,
        walk_search_speed = 0.70,
        investigate_speed = 0.80,
        chase_run_speed = 0.82,
        offscreen_enabled = true,
        offscreen_distance = 16.0,
        offscreen_search_speed = 1.05,
        offscreen_investigate_speed = 1.05,
        offscreen_chase_speed = 1.05,
        offscreen_speed_cap = 1.05,
        offscreen_anchor_step_max = 6.50,
        hidden_relocate_min_safe_y = -20.0,
        hidden_relocate_verify_y = 3.50,
        hidden_anchor_move_cooldown = 1.25,
        hidden_forward_advance_enabled = true,
        hidden_forward_advance_min_player_dist = 14.0,
        hidden_forward_advance_stall_time = 0.75,
        hidden_forward_advance_speed_threshold = 0.05,
        hidden_forward_advance_max_distance = 3.5,
        hidden_forward_advance_max_tries = 1,
        hidden_forward_advance_cooldown = 1.25,
        hidden_forward_advance_forward_dot = 0.25,
        hidden_forward_advance_min_step = 0.75,
    },

    combat = {
        enabled = true,
        damage_taken_multiplier = 1.0,
        stagger_taken_multiplier = 1.70,
    },

    graph = {
        enabled = true,
        nodes = loaded_road_graph.nodes,
        links = loaded_road_graph.links,
        node_by_label = loaded_road_graph.node_by_label,
        neighbors_by_label = loaded_road_graph.neighbors_by_label,
        count_nodes = loaded_road_graph.count_nodes,
        count_links = loaded_road_graph.count_links,
        load_status = loaded_road_status,
    },

    sound_map = {
        enabled = true,
        decay_per_sec = 0.32,
        min_energy = 0.05,
        max_energy = 3.0,
        propagation_hops = 2,
        falloff = 0.62,
        inject_cooldown = 0.35,
        fire_inject_cooldown = 0.15,
        inject_medium = 0.75,
        inject_hard = 1.20,
        inject_fire = 1.90,
        bias_min_energy = 0.35,
        bias_zone_distance = 12.0,
        publish_max_nodes = 16,
        heat_timer_override_enabled = true,
        heat_timer_override_min_age = 0.55,
        heat_timer_override_score_gain = 1.40,
        heat_timer_override_cooldown = 1.25,
        heat_timer_override_max_per_anchor = 1,
        heat_timer_override_hold_extend = 0.10,
    },

    cutoff = {
        enabled = true,
        sample_interval = 0.10,
        move_threshold = 0.20,
        max_stall_drift = 1.25,
        cooldown = 8.0,
        retry_delay = 0.35,

        min_player_distance = 10.0,
        min_mrx_distance = 2.5,
        max_player_distance = 22.0,

        require_probably_offscreen = true,
        visible_front_dot = 0.45,
        visible_reject_distance = 16.0,
        prefer_roadblock_dot = 0.15,

        -- cutoff destination fairness pass
        post_cutoff_min_player_distance = 14.0,
        -- keep cutoff relocations from landing too close to the door-stall origin
        post_cutoff_min_origin_distance = 12.0,
        post_cutoff_forward_reject_distance = 20.0,
        post_cutoff_forward_reject_dot = -0.10,
        post_cutoff_same_hall_player_band = 22.0,
        post_cutoff_same_hall_zone_band = 10.0,
        post_cutoff_same_hall_lateral_max = 3.25,

        strict_post_cutoff_fallbacks = true,
        allow_relaxed_hidden_fallback = false,
        allow_relaxed_any_fallback = false,

        -- dormant unless crouch state is already being published into `state`
        crouch_post_cutoff_extra_distance = 0.0,

        delay_min = 1.0,
        delay_max = 2.2,
        suspend_after_release = 2.5,
        suspend_after_anchor_move = 2.25,
        suspend_after_cutoff = 4.00,
        global_cutoff_cooldown = 4.50,
        chase_stall_recover_gate = 0.0,
        chase_extra_stall_time = 0.50,
        zone_center_buffer_radius = 5.5,
        zones = loaded_cutoff_zones,
        load_status = loaded_cutoff_status,
    },

    safe_rooms = {
        {
            name = "Custodian's Office",
            door_point = { x = -39.0522, y = 2.2850, z = 51.1258 },
            stash_point = { x = -28.5022, y = 2.2850, z = 46.3619 },
            release_points = {
                { label = "Parlor Entrance", x = -20.2820, y = 2.2800, z = 46.4038, weight = 2 },
            },
        },
        {
            name = "Guard Office",
            door_point = { x = -4.8050, y = 1.7903, z = 45.6993 },
            stash_point = { x = -27.4000, y = 7.2900, z = 28.5500 },
            release_points = {
                { label = "Default",         x = -0.0141, y = 4.2851, z = 27.5492, weight = 2 },
                { label = "Hall Corner",     x = -7.2443, y = 5.2800, z = 28.2216, weight = 2 },
                { label = "Side Corridor 1", x = -6.3235, y = 2.2774, z = 38.8998, weight = 1 },
                { label = "Side Corridor 2", x =  4.8221, y = 1.7800, z = 51.0859, weight = 1 },
            },
        },
    },
}

state = {
    scene_name = "n/a",
    scene_guard_until = now(),
    bootstrap_until = now(),
    gameplay_time = wall_now(),
    last_wall_time = wall_now(),
    last_engine_time = nil,
    last_clock_delta = 0.0,
    last_running_timers = -1,
    paused = false,

    player_pos = nil,
    player_go = nil,
    player_prev_pos = nil,
    player_prev_time = -999.0,
    player_step_xz = 0.0,
    player_speed_xz = 0.0,
    fire_input_prev = false,

    mrx_pos = nil,
    mrx_pos_source = "none",
    mrx_draw_pos = nil,
    mrx_draw_source = "none",
    mrx_prev_draw_pos = nil,
    mrx_prev_draw_time = -999.0,
    mrx_step_xz = 0.0,
    mrx_speed_xz = 0.0,
    last_live_confirmed_pos = nil,
    last_live_confirmed_source = "none",
    last_live_confirmed_time = -999.0,
    cached_mrx = nil,
    cached_components = nil,

    player_in_safe_room = false,
    prev_player_in_safe_room = false,
    startup_quarantine_active = true,
    startup_quarantine_seen_safe_room = false,
    mrx_found = false,
    mrx_was_found_prev = false,

    attempted_move = false,
    move_succeeded = false,
    hidden = false,
    stashed = false,
    waiting_release = false,
    release_time = -999.0,
    release_prewarm_pending = false,
    release_nodraw_prime_pending = false,
    release_nodraw_prime_time = -999.0,
    bootstrap_spawn_scrub_done = false,
    bootstrap_spawn_scrub_pending = false,
    bootstrap_spawn_scrub_point = nil,
    bootstrap_spawn_scrub_label = "none",
    bootstrap_spawn_scrub_time = -999.0,
    bootstrap_spawn_scrub_phase = "none",
    last_stash_time = -999.0,
    last_hold_refresh_time = -999.0,
    last_hold_confirm_time = -999.0,
    last_hold_mode = "none",
    last_hold_reason = "none",
    active_safe_room_index = nil,
    pending_release_point = nil,
    pending_release_label = "none",
    pending_release_family = "none",
    last_release_label = "none",
    last_release_family = "none",
    release_history_labels = {},
    release_history_families = {},

    last_status = "idle",
    last_action = "none",
    last_hide_mode = "none",
    last_native_reset = "none",

    behavior = {
        mode = "SEARCH",
        since = now(),
        reason = "boot",
        noise_tier = "none",
        noise_strength = 0.0,
        last_seen_time = -999.0,
        last_heard_time = -999.0,
        unseen_since = now(),
        investigate_until = -999.0,
        chase_until = -999.0,
        search_refresh_time = -999.0,
        search_target_label = "none",
        search_target_zone = "none",
        search_target_point = nil,
        search_target_index = 0,
        search_target_strategy = "none",
        planned_target_label = "none",
        planned_target_zone = "none",
        planned_target_point = nil,
        planned_target_index = 0,
        planned_target_strategy = "none",
        planned_target_selected_at = -999.0,
        committed_target_label = "none",
        committed_target_zone = "none",
        committed_target_point = nil,
        committed_target_index = 0,
        committed_target_strategy = "none",
        committed_target_armed_at = -999.0,
        committed_target_fire_at = -999.0,
        committed_target_reason = "none",
        search_repositions = 0,
        investigate_zone_last_name = "none",
        investigate_zone_last_time = -999.0,
        investigate_zone_ignore_name = "none",
        investigate_zone_ignore_until = -999.0,
        search_last_destination = "none",
        search_last_zone = "none",
        search_last_result = "none",
        search_last_move_time = -999.0,
        search_next_index_by_zone = {},
        anchor_force_refresh = true,
        release_search_pending = false,
        release_search_origin = nil,
        pending_anchor_time = -999.0,
        pending_anchor_delay = 0.0,
        pending_anchor_mode = "none",
        pending_anchor_reason = "none",
        pending_anchor_noise = "none",
        pending_heat_override_count = 0,
        pending_heat_override_last_time = -999.0,
        linger_until = -999.0,
        last_noise_kind = "none",
        last_stimulus = "none",
        last_player_known_pos = nil,
        last_player_known_time = -999.0,
        investigate_decay_reason = "none",
        investigate_decay_last_time = -999.0,
        escalation_level = 0,
        reposition_grace_until = -999.0,
        reposition_grace_reason = "none",
        investigate_commit_until = -999.0,
        investigate_commit_reason = "none",
        chase_stall_time = 0.0,
        chase_last_pos = nil,
        visual_confirm_since = -999.0,
        cutoff_settle_until = -999.0,
        cutoff_settle_point = nil,
        cutoff_settle_label = "none",
        cutoff_settle_reset_at = -999.0,
        last_fire_stimulus_time = -999.0,
        fire_burst_count = 0,
        last_fire_retarget_time = -999.0,
        offscreen_stall_time = 0.0,
        offscreen_stall_last_pos = nil,
        hidden_forward_advance_tries = 0,
        hidden_forward_advance_last_time = -999.0,
        last_hidden_anchor_move_time = -999.0,
        last_update = -999.0,
    },

    movement = {
        cached_motion = nil,
        current_anim_name = "none",
        current_anim_class = "idle",
        current_speed_mode = "none",
        current_applied_speed = 1.0,
        visible = false,
        offscreen_active = false,
        last_found_reason = "none",
        suppress_until = -999.0,
        last_error = "",
    },

    graph = {
        nearest_player_node_label = "none",
        nearest_mrx_node_label = "none",
    },

    sound = {
        node_energy = {},
        active_count = 0,
        peak_label = "none",
        peak_value = 0.0,
        published_nodes = {},
        last_decay_time = now(),
        last_inject_time = -999.0,
        last_inject_kind = "none",
        last_inject_node_label = "none",
        bias_pending = false,
        bias_source_label = "none",
        bias_source_kind = "none",
        last_bias_use_time = -999.0,
        last_override_time = -999.0,
    },

    combat = {
        last_hit_time = -999.0,
        last_weapon_id = "none",
        last_damage_factor = -1.0,
        last_wince_factor = -1.0,
    },

    cutoff = {
        last_trigger_time = -999.0,
        last_sample_time = -999.0,
        stall_time = 0.0,
        last_pos = nil,
        stall_start_pos = nil,
        zone_name = "none",
        result = "none",
        destination_label = "none",
        teleports = 0,
        pending_active = false,
        pending_zone_name = "none",
        pending_fire_time = -999.0,
        pending_delay = 0.0,
        pending_started_at = -999.0,
        suspend_until = -999.0,
        last_cutoff_fire_time = -999.0,
        last_destination_label = "none",
        next_index_by_zone = {},
    },
}

state.scene_guard_until = now() + cfg.bootstrap_grace
state.bootstrap_until = now() + cfg.bootstrap_grace
state.behavior.unseen_since = now()

local is_obj_valid

local function safe_call(obj, method, ...)
    if not is_obj_valid(obj) then return nil end
    local ok, ret = pcall(obj.call, obj, method, ...)
    if ok then return ret end
    return nil
end

local function safe_field(obj, field)
    if not is_obj_valid(obj) then return nil end
    local ok, ret = pcall(function() return obj:get_field(field) end)
    if ok then return ret end
    return nil
end

local function get_obj_name(obj)
    if not is_obj_valid(obj) then return nil end
    local name = safe_call(obj, "get_Name")
    if type(name) == "string" and name ~= "" then return name end
    return nil
end

local function pos_offset_y(pos, dy)
    if pos == nil then return nil end
    return Vector3f.new(pos.x, pos.y + (dy or 0.0), pos.z)
end

is_obj_valid = function(obj)
    if not obj then return false end
    local ok, ret = pcall(function()
        if obj.is_valid then
            local v = obj:is_valid()
            if v ~= nil then return v ~= false end
        end
        return obj:get_type_definition() ~= nil
    end)
    return ok and ret == true
end

local function safe_set_field(obj, field, value)
    if not is_obj_valid(obj) then return false end

    local current = safe_field(obj, field)
    if current == nil and value ~= nil then
        return false
    end

    if type(current) == "boolean" and type(value) ~= "boolean" then
        return false
    end
    if type(current) == "number" and type(value) ~= "number" then
        return false
    end
    if type(current) == "string" and type(value) ~= "string" then
        return false
    end

    local ok = pcall(function() obj:set_field(field, value) end)
    return ok
end

local function vec3(x, y, z)
    return Vector3f.new(x, y, z)
end

local function copy_point(p)
    if not p then return nil end
    return { x = p.x, y = p.y, z = p.z }
end

local function dist_sq_xz(a, b)
    local dx = a.x - b.x
    local dz = a.z - b.z
    return dx * dx + dz * dz
end

local function dist_xz(a, b)
    return math.sqrt(dist_sq_xz(a, b))
end

local function point_to_string(p)
    if not p then return "none" end
    return string.format("%.3f, %.3f, %.3f", p.x, p.y, p.z)
end

local function clamp01(v)
    if v < 0.0 then return 0.0 end
    if v > 1.0 then return 1.0 end
    return v
end

local function rand_range(min_v, max_v)
    if max_v <= min_v then return min_v end
    return min_v + (math.random() * (max_v - min_v))
end

local function get_character_manager()
    return sdk.get_managed_singleton("app.CharacterManager")
end

local function get_player_context(cm)
    if not cm then return nil end
    local methods = {
        "getPlayerContextRef",
        "get_PlayerContext",
        "get_CurrentPlayerContext",
        "getManualPlayerContext",
    }
    for _, method in ipairs(methods) do
        local ctx = safe_call(cm, method)
        if ctx then return ctx end
    end
    return nil
end

local function get_player_game_object(ctx)
    if not ctx then return nil end
    return safe_call(ctx, "get_GameObject")
end

local function get_transform(go)
    if not go then return nil end
    local ok, t = pcall(function() return go:call("get_Transform") end)
    if ok and t then return t end
    return nil
end

local function get_world_position(transform)
    if not transform then return nil end

    local pos = safe_call(transform, "get_position")
    if pos then return pos end

    pos = safe_call(transform, "get_Position")
    if pos then return pos end

    pos = safe_field(transform, "Position")
    if pos then return pos end

    pos = safe_field(transform, "position")
    if pos then return pos end

    return nil
end

local function get_player_pos_and_go()
    local cm = get_character_manager()
    if not cm then return nil, nil end

    local ctx = get_player_context(cm)
    if not ctx then return nil, nil end

    local go = get_player_game_object(ctx)
    if not go then return nil, nil end

    local transform = get_transform(go)
    if not transform then return nil, go end

    local pos = get_world_position(transform)
    if not pos then return nil, go end

    return { x = pos.x, y = pos.y, z = pos.z }, go
end

local function get_engine_safe_room_flag()
    local cm = get_character_manager()
    if not cm then return false end
    return safe_call(cm, "get_IsPlayerInSafeRoom") == true
end

local function get_scene()
    local scene_manager = sdk.get_native_singleton("via.SceneManager")
    if not scene_manager or not scene_manager_type then return nil end

    local ok, scene = pcall(function()
        return sdk.call_native_func(scene_manager, scene_manager_type, "get_CurrentScene")
    end)

    if ok then return scene end
    return nil
end

local function get_scene_name(scene)
    if not scene then return "n/a" end

    local name = safe_call(scene, "get_Name")
    if type(name) == "string" and name ~= "" then return name end

    name = safe_call(scene, "get_SceneName")
    if type(name) == "string" and name ~= "" then return name end

    name = safe_field(scene, "Name")
    if type(name) == "string" and name ~= "" then return name end

    return "unknown"
end

local function get_position(go)
    local t = get_transform(go)
    if t == nil then return nil end

    local ok, pos = pcall(function() return t:call("get_Position") end)
    if ok and pos then return pos end

    ok, pos = pcall(function() return t:call("get_position") end)
    if ok and pos then return pos end

    ok, pos = pcall(function() return t:call("get_LocalPosition") end)
    if ok and pos then return pos end

    ok, pos = pcall(function() return t:get_field("Position") end)
    if ok and pos then return pos end

    ok, pos = pcall(function() return t:get_field("LocalPosition") end)
    if ok and pos then return pos end

    return nil
end

local function get_mrx_runtime_pos(go)
    local transform_pos = get_position(go)
    local best_named_pos = nil
    local best_named_dist = 999999.0

    if go then
        local cm = get_character_manager()
        if cm then
            local ok_list, enemy_list = pcall(function() return cm:get_field(ENEMY_FIELD) end)
            if ok_list and enemy_list then
                local ok_cnt, cnt = pcall(function() return enemy_list:get_Count() end)
                if ok_cnt and cnt then
                    for i = 0, cnt - 1 do
                        local ok_ctx, ctx = pcall(function() return enemy_list:get_Item(i) end)
                        if ok_ctx and ctx then
                            local ctx_go = safe_call(ctx, "get_GameObject")
                            local cpos = safe_call(ctx, "get_Position")
                            if ctx_go == go and cpos then
                                if transform_pos and dist_xz(cpos, transform_pos) > 6.0 then
                                    return transform_pos, "transform_guard"
                                end
                                return cpos, "exact_ctx"
                            end

                            if cpos and transform_pos then
                                local name = safe_call(ctx_go, "get_Name")
                                if type(name) == "string" and string.find(name, "cp_C500", 1, true) then
                                    local d = dist_xz(cpos, transform_pos)
                                    if d < best_named_dist then
                                        best_named_dist = d
                                        best_named_pos = cpos
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    if transform_pos then
        if best_named_pos and best_named_dist <= 3.0 then
            return best_named_pos, "near_name_ctx"
        end
        return transform_pos, "transform"
    end

    if best_named_pos then
        return best_named_pos, "near_name_ctx"
    end

    return nil, "none"
end


local function is_strong_mrx_pos_source(source)
    return source == "exact_ctx"
end

local function can_visual_chase_from_source(source, player_dist, in_reposition_grace)
    player_dist = player_dist or 9999.0
    return source == "exact_ctx"
end

local function should_draw_mrx_debug(source, player_dist, grace_remaining, mode)
    player_dist = player_dist or 9999.0
    grace_remaining = grace_remaining or 0.0
    return source == "transform"
end

local function clear_published_mrx_pos(source)
    state.mrx_pos = nil
    state.mrx_pos_source = source or "none"
end

local function publish_live_mrx_pos(actual, source)
    if actual and source == "exact_ctx" then
        state.mrx_pos = { x = actual.x, y = actual.y, z = actual.z }
        state.mrx_pos_source = source
        state.last_live_confirmed_pos = { x = actual.x, y = actual.y, z = actual.z }
        state.last_live_confirmed_source = source
        state.last_live_confirmed_time = now()
        return true
    end

    clear_published_mrx_pos(source or "none")
    return false
end

local function clear_published_mrx_draw_pos(source)
    state.mrx_draw_pos = nil
    state.mrx_draw_source = source or "none"
end

local function get_live_draw_mrx_pos(go)
    if not is_obj_valid(go) then
        return nil, "invalid_go"
    end

    local root_enabled = safe_call(go, "get_Enabled")
    if root_enabled == false then
        return nil, "root_disabled"
    end

    local ok_update, update_self = pcall(function() return go:get_UpdateSelf() end)
    if ok_update and update_self == false then
        return nil, "update_disabled"
    end

    local ok_draw, draw_self = pcall(function() return go:get_DrawSelf() end)
    if ok_draw and draw_self == false then
        return nil, "draw_disabled"
    end

    local pos = get_position(go)
    if not pos then
        return nil, "no_transform"
    end

    return { x = pos.x, y = pos.y, z = pos.z }, "transform"
end

local function publish_live_mrx_draw_pos(go)
    local actual, source = get_live_draw_mrx_pos(go)
    if actual then
        state.mrx_draw_pos = actual
        state.mrx_draw_source = source
        return true
    end

    clear_published_mrx_draw_pos(source or "none")
    return false
end

local function get_behavior_mrx_ref_pos()
    return state.mrx_pos or state.mrx_draw_pos
end


local function begin_reposition_grace(seconds, reason)
    state.behavior.reposition_grace_until = now() + (seconds or 1.5)
    state.behavior.reposition_grace_reason = reason or "reposition"
end

local function clear_investigate_commit()
    state.behavior.investigate_commit_until = -999.0
    state.behavior.investigate_commit_reason = "none"
end

local function begin_investigate_commit(seconds, reason)
    state.behavior.investigate_commit_until = now() + (seconds or 2.0)
    state.behavior.investigate_commit_reason = reason or "investigate commit"
end

local probable_visual_contact
local try_reset_native_alert_state
local set_cutoff_suspend
local enter_investigate_recover
local dot_from_player_forward
local validate_search_anchor_point
local mark_last_player_known

local function should_block_investigate_rearm(player_pos, player_go, mrx_pos)
    local t = now()
    local remaining = math.max(0.0, (state.behavior.investigate_commit_until or -999.0) - t)
    if remaining <= 0.0 then return false, "none", remaining end

    local lock_dist = cfg.behavior.investigate_commit_rearm_lock_distance or 10.0
    local pd = player_pos and mrx_pos and dist_xz(player_pos, mrx_pos) or 9999.0
    local visual = probable_visual_contact and probable_visual_contact(player_go, player_pos, mrx_pos) or false

    if visual then
        return true, "visual", remaining
    end
    if pd <= lock_dist then
        return true, string.format("near %.1fm", pd), remaining
    end
    return false, "none", remaining
end

local function validate_investigate_commit_point(point, player_pos, player_go, mrx_pos)
    local valid, preferred, pd, md, dot = validate_search_anchor_point(point, player_pos, player_go, mrx_pos, "INVESTIGATE")
    if not valid then
        return false, "base", preferred, pd, md, dot
    end

    local min_pd = cfg.behavior.search_anchor.investigate_commit_fire_min_player_distance
        or cfg.behavior.search_anchor.investigate_min_player_distance
        or 10.5
    local visible_dist = cfg.behavior.search_anchor.investigate_commit_fire_visible_distance or 24.0
    local visible_dot = cfg.behavior.search_anchor.investigate_commit_fire_visible_dot or 0.15
    local front_dist = cfg.behavior.search_anchor.investigate_commit_fire_front_distance or visible_dist
    local front_dot = cfg.behavior.search_anchor.investigate_commit_fire_front_dot or -0.15
    local center_dist = cfg.behavior.search_anchor.investigate_commit_fire_center_distance or 28.0
    local center_dot = cfg.behavior.search_anchor.investigate_commit_fire_center_dot or 0.55

    local visible = false
    local frontal = false
    local center_interest = false
    if player_go and player_pos and point then
        local pd_now = dist_xz(point, player_pos)
        local dot_now = dot_from_player_forward(player_go, player_pos, point) or -2.0
        visible = pd_now <= visible_dist and dot_now >= visible_dot
        frontal = pd_now <= front_dist and dot_now >= front_dot
        center_interest = pd_now <= center_dist and dot_now >= center_dot
        pd = pd_now
        dot = dot_now
    end

    if pd < min_pd then
        return false, "close", preferred, pd, md, dot
    end
    if center_interest then
        return false, "center", preferred, pd, md, dot
    end
    if visible then
        return false, "visible", preferred, pd, md, dot
    end
    if frontal then
        return false, "frontal", preferred, pd, md, dot
    end

    return true, "clean", preferred, pd, md, dot
end

local function validate_search_commit_point(point, player_pos, player_go, mrx_pos)
    local valid, preferred, pd, md, dot = validate_search_anchor_point(point, player_pos, player_go, mrx_pos, "SEARCH")
    if not valid then
        return false, "base", preferred, pd, md, dot
    end

    local min_pd = cfg.behavior.search_anchor.search_commit_fire_min_player_distance
        or cfg.behavior.search_anchor.min_player_distance
        or 10.5
    local visible_dist = cfg.behavior.search_anchor.search_commit_fire_visible_distance or 34.0
    local visible_dot = cfg.behavior.search_anchor.search_commit_fire_visible_dot or 0.00
    local front_dist = cfg.behavior.search_anchor.search_commit_fire_front_distance or visible_dist
    local front_dot = cfg.behavior.search_anchor.search_commit_fire_front_dot or -0.30
    local center_dist = cfg.behavior.search_anchor.search_commit_fire_center_distance or 38.0
    local center_dot = cfg.behavior.search_anchor.search_commit_fire_center_dot or 0.30

    local visible = false
    local frontal = false
    local center_interest = false
    if player_go and player_pos and point then
        local pd_now = dist_xz(point, player_pos)
        local dot_now = dot_from_player_forward(player_go, player_pos, point) or -2.0
        visible = pd_now <= visible_dist and dot_now >= visible_dot
        frontal = pd_now <= front_dist and dot_now >= front_dot
        center_interest = pd_now <= center_dist and dot_now >= center_dot
        pd = pd_now
        dot = dot_now
    end

    if pd < min_pd then
        return false, "close", preferred, pd, md, dot
    end
    if center_interest then
        return false, "center", preferred, pd, md, dot
    end
    if visible then
        return false, "visible", preferred, pd, md, dot
    end
    if frontal then
        return false, "frontal", preferred, pd, md, dot
    end

    return true, "clean", preferred, pd, md, dot
end

local function clear_chase_stall_tracking()
    state.behavior.chase_stall_time = 0.0
    state.behavior.chase_last_pos = nil
end

local function update_chase_stall_tracking(mrx_pos)
    if state.behavior.mode ~= "CHASE" or not mrx_pos then
        clear_chase_stall_tracking()
        return
    end

    if state.behavior.chase_last_pos then
        local moved = dist_xz(mrx_pos, state.behavior.chase_last_pos)
        if moved <= 0.15 then
            state.behavior.chase_stall_time = (state.behavior.chase_stall_time or 0.0) + 1.0/60.0
        else
            state.behavior.chase_stall_time = 0.0
        end
    else
        state.behavior.chase_stall_time = 0.0
    end
    state.behavior.chase_last_pos = copy_point(mrx_pos)
end

local function maybe_break_chase_stall(mrx, player_pos, player_go, mrx_pos)
    if state.behavior.mode ~= "CHASE" or not mrx or not player_pos or not mrx_pos then return false end

    local dist = dist_xz(player_pos, mrx_pos)
    local ydiff = math.abs((player_pos.y or 0.0) - (mrx_pos.y or 0.0))
    local pos_source = state.mrx_pos_source or "none"
    local weak_source = not is_strong_mrx_pos_source(pos_source)
    local cross_floor = ydiff >= (cfg.behavior.cross_floor_chase_y or 2.5)
    local recover_time = (cfg.behavior.chase_stall_recover_time or 1.75)
    if weak_source then
        recover_time = 0.20
    end
    if cross_floor then
        recover_time = math.min(recover_time, 0.35)
    end
    if (state.behavior.chase_stall_time or 0.0) < recover_time then return false end

    local visual = probable_visual_contact(player_go, player_pos, mrx_pos)
    if cross_floor then
        visual = false
    end
    if weak_source and not can_visual_chase_from_source(pos_source, dist, false) then
        visual = false
    end

    if visual and dist <= (cfg.behavior.visual_alert_distance + 1.5) then
        return false
    end

    try_reset_native_alert_state(mrx)
    enter_investigate_recover(weak_source and "chase recover weak source" or "chase recover", player_pos, player_go, mrx_pos)
    set_cutoff_suspend(0.85, "chase recover")
    state.last_status = weak_source and "chase -> investigate recover (weak source)" or "chase -> investigate recover"
    state.last_action = "chase recover"
    begin_reposition_grace(0.90, weak_source and "chase recover weak source" or "chase recover")
    clear_chase_stall_tracking()
    return true
end

local function get_forward(go)
    local t = get_transform(go)
    if not t then return nil end

    local f = safe_call(t, "get_Forward")
    if f then return f end

    f = safe_call(t, "get_forward")
    if f then return f end

    f = safe_field(t, "Forward")
    if f then return f end

    return nil
end

local function set_position(go, x, y, z)
    local t = get_transform(go)
    if t == nil then return false end

    local pos = vec3(x, y, z)

    local ok = pcall(function() t:call("set_Position", pos) end)
    if ok then return true end

    ok = pcall(function() t:call("set_position", pos) end)
    if ok then return true end

    ok = pcall(function() t:call("set_LocalPosition", pos) end)
    if ok then return true end

    ok = pcall(function() t:set_field("Position", pos) end)
    if ok then return true end

    ok = pcall(function() t:set_field("LocalPosition", pos) end)
    if ok then return true end

    return false
end

local function verify_at_point(go, point)
    local pos = get_position(go)
    if not pos or not point then return false end
    return dist_sq_xz(pos, point) <= (cfg.move_verify_radius * cfg.move_verify_radius)
end

local function try_runtime_relocate_light(go)
    if not go then return false end

    local did_anything = false
    local components = safe_call(go, "get_Components")
    if not components then return false end

    local ok, count = pcall(function() return components:get_Count() end)
    if not ok or not count then return false end

    for i = 0, count - 1 do
        local ok_comp, comp = pcall(function() return components:get_Item(i) end)
        if ok_comp and comp and is_obj_valid(comp) then
            -- performance cleanup path:
            -- intentionally skip blind zero-arg warp/Warp calls here because they were
            -- generating REFramework warnings on unrelated components and lining up with
            -- safe-room hitch spikes.

            local ok_wp = pcall(function() comp:call("onWayPointComplete") end)
            if ok_wp then did_anything = true end

            ok_wp = pcall(function() comp:call("onWaypointComplete") end)
            if ok_wp then did_anything = true end

            local ok_nav = pcall(function() comp:call("onNavigationComplete") end)
            if ok_nav then did_anything = true end

            ok_nav = pcall(function() comp:call("OnNavigationComplete") end)
            if ok_nav then did_anything = true end

            local ok_reset = pcall(function() comp:call("resetPath") end)
            if ok_reset then did_anything = true end
        end
    end

    return did_anything
end

local function try_runtime_relocate_strong(go)
    if not go then return false end

    local did_anything = false
    local components = safe_call(go, "get_Components")
    if not components then return false end

    local ok, count = pcall(function() return components:get_Count() end)
    if not ok or not count then return false end

    for i = 0, count - 1 do
        local ok_comp, comp = pcall(function() return components:get_Item(i) end)
        if ok_comp and comp and is_obj_valid(comp) then
            local ok_warp = pcall(function() comp:call("warp") end)
            if ok_warp then did_anything = true end

            ok_warp = pcall(function() comp:call("Warp") end)
            if ok_warp then did_anything = true end

            local ok_wp = pcall(function() comp:call("onWayPointComplete") end)
            if ok_wp then did_anything = true end

            ok_wp = pcall(function() comp:call("onWaypointComplete") end)
            if ok_wp then did_anything = true end

            local ok_nav = pcall(function() comp:call("onNavigationComplete") end)
            if ok_nav then did_anything = true end

            ok_nav = pcall(function() comp:call("OnNavigationComplete") end)
            if ok_nav then did_anything = true end

            local ok_reset = pcall(function() comp:call("resetPath") end)
            if ok_reset then did_anything = true end
        end
    end

    return did_anything
end

local function try_runtime_relocate(go)
    return try_runtime_relocate_strong(go)
end

try_reset_native_alert_state = function(go)
    if not go then
        state.last_native_reset = "native reset: no go"
        return false
    end

    local methods = {
        "requestLost",
        "RequestLost",
        "forceLost",
        "ForceLost",
        "toLost",
        "ToLost",
        "endChase",
        "EndChase",
        "cancelChase",
        "CancelChase",
        "resetFindState",
        "ResetFindState",
        "clearFindState",
        "ClearFindState",
        "clearTarget",
        "ClearTarget",
        "resetTarget",
        "ResetTarget",
        "clearHate",
        "ClearHate",
        "resetHate",
        "ResetHate",
        "clearNotice",
        "ClearNotice",
        "requestSearchEnd",
        "RequestSearchEnd",
        "stop",
        "Stop",
        "resetPath",
        "ResetPath",
        "onNavigationComplete",
        "OnNavigationComplete",
        "onWayPointComplete",
        "onWaypointComplete",
    }

    local field_sets = {
        {"_IsChase", false},
        {"IsChase", false},
        {"_IsAlert", false},
        {"IsAlert", false},
        {"_IsFinding", false},
        {"IsFinding", false},
        {"_HasTarget", false},
        {"HasTarget", false},
        {"_IsNotice", false},
        {"IsNotice", false},
    }

    local called = 0
    local set_count = 0
    local components = safe_call(go, "get_Components")
    if not components then
        state.last_native_reset = "native reset: no components"
        return false
    end

    local ok, count = pcall(function() return components:get_Count() end)
    if not ok or not count then
        state.last_native_reset = "native reset: count failed"
        return false
    end

    for i = 0, count - 1 do
        local ok_comp, comp = pcall(function() return components:get_Item(i) end)
        if ok_comp and comp and is_obj_valid(comp) then
            for _, m in ipairs(methods) do
                local ok_call = pcall(function() comp:call(m) end)
                if ok_call then called = called + 1 end
            end
            for _, f in ipairs(field_sets) do
                if safe_set_field(comp, f[1], f[2]) then
                    set_count = set_count + 1
                end
            end
        end
    end

    local did_anything = (called > 0) or (set_count > 0)
    state.last_native_reset = string.format("native reset: calls=%d fields=%d", called, set_count)
    return did_anything
end

local function force_move_verified(go, point, relocate_mode)
    local relocate = try_runtime_relocate_strong
    if relocate_mode == "light" then
        relocate = try_runtime_relocate_light
    end

    local moved = set_position(go, point.x, point.y, point.z)
    local relocated = relocate(go)
    if (moved or relocated) and verify_at_point(go, point) then
        return true
    end

    moved = set_position(go, point.x, point.y, point.z)
    relocated = relocate(go) or relocated
    if (moved or relocated) and verify_at_point(go, point) then
        return true
    end

    local t = get_transform(go)
    if t then
        local ok = pcall(function() t:call("set_Position", vec3(point.x, point.y, point.z)) end)
        if ok then
            relocated = relocate(go) or relocated
            if verify_at_point(go, point) then
                return true
            end
        end
    end

    return false
end

local function validate_cached_mrx()
    if not is_obj_valid(state.cached_mrx) then
        state.cached_mrx = nil
        state.cached_components = nil
        return nil
    end
    local pos = get_mrx_runtime_pos(state.cached_mrx)
    if pos == nil then
        state.cached_mrx = nil
        state.cached_components = nil
        return nil
    end
    return state.cached_mrx
end

local function clear_mrx_cache()
    state.cached_mrx = nil
    state.cached_components = nil
    state.movement.cached_motion = nil
end

local function find_mrx()
    local cached = validate_cached_mrx()
    if cached then
        return cached, "cached"
    end

    local scene = get_scene()
    if scene == nil then
        return nil, "no current scene"
    end

    state.scene_name = get_scene_name(scene)

    local names = {
        "cp_C500",
        "cp_C500_00",
        "cp_C500_01",
        "cp_C500_02",
    }

    for _, name in ipairs(names) do
        local ok, go = pcall(function()
            return scene:call("findGameObject(System.String)", name)
        end)
        if ok and go then
            state.cached_mrx = go
            return go, "found " .. name
        end

        ok, go = pcall(function()
            return scene:call("findGameObject", name)
        end)
        if ok and go then
            state.cached_mrx = go
            return go, "found " .. name
        end
    end

    return nil, "Mr. X not found in scene"
end

local protected_motion_patterns = {
    "attack", "damage", "dead", "death", "stun", "down", "grapple", "finish", "execution", "hit", "knock", "blow",
}

local chase_motion_patterns = {
    "chase", "run", "sprint", "dash", "lunge", "pursuit", "rush",
}

local walk_motion_patterns = {
    "walk", "search", "investigate", "patrol", "move", "locomotion", "roam",
}

local function contains_any_pattern(lower, patterns)
    if type(lower) ~= "string" then return false end
    for _, pat in ipairs(patterns) do
        if string.find(lower, pat, 1, true) then
            return true
        end
    end
    return false
end

local function classify_mrx_motion_anim(anim_name)
    local lower = type(anim_name) == "string" and string.lower(anim_name) or ""
    if lower == "" then return "idle" end
    if cfg.movement.protect_animations and contains_any_pattern(lower, protected_motion_patterns) then
        return "protected"
    end
    if contains_any_pattern(lower, chase_motion_patterns) then
        return "chase"
    end
    if contains_any_pattern(lower, walk_motion_patterns) then
        return "walk"
    end
    return "idle"
end

local function get_mrx_motion_component(go)
    if is_obj_valid(state.movement.cached_motion) and is_obj_valid(go) and state.cached_mrx == go then
        return state.movement.cached_motion
    end
    state.movement.cached_motion = nil
    if not is_obj_valid(go) then return nil end
    local motion = safe_call(go, "getComponent(System.Type)", motion_type)
    if is_obj_valid(motion) then
        state.movement.cached_motion = motion
        state.movement.last_found_reason = "motion component"
        return motion
    end
    state.movement.last_found_reason = "no motion component"
    return nil
end

local function get_mrx_motion_layer_and_anim(go)
    local motion = get_mrx_motion_component(go)
    if not is_obj_valid(motion) then return nil, nil, "" end
    local layer = safe_call(motion, "getLayer", 0)
    if not is_obj_valid(layer) then return motion, nil, "" end
    local node = safe_call(layer, "get_HighestWeightMotionNode")
    local anim_name = node and safe_call(node, "get_MotionName") or ""
    return motion, layer, anim_name or ""
end

local function set_mrx_motion_layer_speed(layer, speed)
    if not is_obj_valid(layer) then return false end
    speed = tonumber(speed) or 1.0
    if speed < 0.05 then speed = 0.05 end
    local ok = pcall(function() layer:call("set_Speed", speed) end)
    if ok then
        state.movement.current_applied_speed = speed
        return true
    end
    return false
end

local function is_point_on_screen(pos)
    if not pos or not draw or not draw.world_to_screen then return false end
    local ok, sp = pcall(draw.world_to_screen, pos)
    return ok and sp ~= nil
end

local function find_nearest_graph_node_label(pos)
    if not pos or not cfg.graph.enabled then return "none" end
    local best_label = "none"
    local best_dist = 999999.0
    for _, node in ipairs(cfg.graph.nodes or {}) do
        local d = dist_xz(pos, node)
        if d < best_dist then
            best_dist = d
            best_label = tostring(node.label or "none")
        end
    end
    return best_label
end

local function update_graph_proximity(player_pos, mrx_pos)
    state.graph.nearest_player_node_label = find_nearest_graph_node_label(player_pos)
    state.graph.nearest_mrx_node_label = find_nearest_graph_node_label(mrx_pos)
end

local function resolve_mrx_speed_mode(player_pos, mrx_pos, anim_class)
    if not cfg.movement.enabled then return "disabled", 1.0, false, false end
    if state.stashed or state.hidden or state.waiting_release or state.player_in_safe_room or state.behavior.mode == "SAFE" then
        return "SAFE", 1.0, false, false
    end
    local screen_test_pos = mrx_pos and pos_offset_y(mrx_pos, 1.0) or nil
    if anim_class == "protected" then
        return "PROTECTED", 1.0, false, is_point_on_screen(screen_test_pos)
    end

    local visible = is_point_on_screen(screen_test_pos)
    local offscreen = cfg.movement.offscreen_enabled
        and not visible
        and player_pos ~= nil and mrx_pos ~= nil
        and dist_xz(player_pos, mrx_pos) >= (cfg.movement.offscreen_distance or 16.0)
        and not state.cutoff.pending_active
        and ((state.behavior.cutoff_settle_until or -999.0) < now())

    local cap = cfg.movement.offscreen_speed_cap or 1.10
    if state.behavior.mode == "CHASE" then
        local spd = offscreen and (cfg.movement.offscreen_chase_speed or cfg.movement.chase_run_speed) or (cfg.movement.chase_run_speed or 0.86)
        if offscreen then spd = math.min(spd, cap) end
        return offscreen and "OFFSCREEN_CHASE" or "VISIBLE_CHASE", spd, offscreen, visible
    elseif state.behavior.mode == "INVESTIGATE" or state.behavior.mode == "CUTOFF_ARMED" then
        local spd = offscreen and (cfg.movement.offscreen_investigate_speed or cfg.movement.investigate_speed) or (cfg.movement.investigate_speed or 0.80)
        if offscreen then spd = math.min(spd, cap) end
        return offscreen and "OFFSCREEN_INVESTIGATE" or "VISIBLE_INVESTIGATE", spd, offscreen, visible
    elseif state.behavior.mode == "SEARCH" or state.behavior.mode == "RELEASE_PRIMED" then
        local spd = offscreen and (cfg.movement.offscreen_search_speed or cfg.movement.walk_search_speed) or (cfg.movement.walk_search_speed or 0.76)
        if offscreen then spd = math.min(spd, cap) end
        return offscreen and "OFFSCREEN_SEARCH" or "VISIBLE_SEARCH", spd, offscreen, visible
    end

    return "IDLE", 1.0, false, visible
end

local function apply_mrx_movement_profile(player_pos, go, mrx_pos)
    if not is_obj_valid(go) then
        state.movement.current_anim_name = "none"
        state.movement.current_anim_class = "idle"
        state.movement.current_speed_mode = "none"
        state.movement.current_applied_speed = 1.0
        state.movement.visible = false
        state.movement.offscreen_active = false
        return
    end

    local motion, layer, anim_name = get_mrx_motion_layer_and_anim(go)
    state.movement.current_anim_name = anim_name ~= "" and anim_name or "none"
    state.movement.current_anim_class = classify_mrx_motion_anim(anim_name)
    if state.movement.current_anim_class == "idle" and (state.behavior.mode == "SEARCH" or state.behavior.mode == "INVESTIGATE" or state.behavior.mode == "CHASE" or state.behavior.mode == "CUTOFF_ARMED") then
        state.movement.current_anim_class = "generic"
    end

    if not is_obj_valid(layer) then
        state.movement.current_speed_mode = "no_layer"
        state.movement.current_applied_speed = 1.0
        state.movement.visible = false
        state.movement.offscreen_active = false
        return
    end

    local mode, speed, offscreen, visible = resolve_mrx_speed_mode(player_pos, mrx_pos or state.mrx_draw_pos or state.mrx_pos, state.movement.current_anim_class)
    state.movement.current_speed_mode = mode
    state.movement.offscreen_active = offscreen
    state.movement.visible = visible
    set_mrx_motion_layer_speed(layer, speed)
end

local function suppress_mrx_movement_profile(go, reason)
    state.movement.current_speed_mode = reason or "SUPPRESSED"
    state.movement.current_applied_speed = 1.0
    state.movement.visible = false
    state.movement.offscreen_active = false
    state.movement.last_error = state.movement.last_error or ""

    if not is_obj_valid(go) then return end
    local _, layer = get_mrx_motion_layer_and_anim(go)
    if is_obj_valid(layer) then
        set_mrx_motion_layer_speed(layer, 1.0)
    end
end

local function run_integrated_runtime_updates(player_pos, go, mrx_pos)
    local effective_pos = mrx_pos or state.mrx_draw_pos or state.mrx_pos
    state.graph.nearest_player_node_label = find_nearest_graph_node_label(player_pos)

    local should_suppress = (not is_obj_valid(go))
        or state.stashed
        or state.hidden
        or state.waiting_release
        or state.player_in_safe_room
        or state.release_prewarm_pending
        or state.release_nodraw_prime_pending
        or state.bootstrap_spawn_scrub_pending
        or effective_pos == nil
        or now() < (state.movement.suppress_until or -999.0)

    if should_suppress then
        state.graph.nearest_mrx_node_label = "none"
        suppress_mrx_movement_profile(go, "SUPPRESSED")
        return false
    end

    local ok, err = pcall(function()
        update_graph_proximity(player_pos, effective_pos)
        apply_mrx_movement_profile(player_pos, go, effective_pos)
    end)

    if not ok then
        state.graph.nearest_mrx_node_label = "none"
        state.movement.last_error = tostring(err or "movement update failed")
        suppress_mrx_movement_profile(go, "ERROR")
        return false
    end

    state.movement.last_error = ""
    return true
end

local combat_hook_installed = false
local function install_mrx_combat_hook()
    if combat_hook_installed or enemy_attack_damage_driver_type == nil then return end
    local method = enemy_attack_damage_driver_type:get_method("calcBodyPartsRate")
    if not method then return end

    local saved = { damage_info = nil, go_name = nil }

    sdk.hook(method,
        function(args)
            saved.damage_info = nil
            saved.go_name = nil
            local ok = pcall(function()
                local driver = sdk.to_managed_object(args[2])
                if not is_obj_valid(driver) then return end
                local td = driver:get_type_definition()
                if not td or not td:is_a(enemy_attack_damage_driver_type) then return end
                local go = safe_call(driver, "get_GameObject")
                local name = get_obj_name(go)
                if not MRX_NAMES[name] then return end
                local damage_info = sdk.to_managed_object(args[3])
                if not is_obj_valid(damage_info) then return end
                saved.damage_info = damage_info
                saved.go_name = name
            end)
        end,
        function(retval)
            pcall(function()
                if not cfg.combat.enabled or not saved.damage_info or not saved.go_name then return end
                local damage_info = saved.damage_info
                local damage_factor = safe_call(damage_info, "get_DamageFactor")
                local wince_factor = safe_call(damage_info, "get_WinceFactor")
                if type(damage_factor) == "number" then
                    local new_damage = damage_factor * (cfg.combat.damage_taken_multiplier or 1.0)
                    safe_set_field(damage_info, "_DamageFactor", new_damage)
                    state.combat.last_damage_factor = new_damage
                end
                if type(wince_factor) == "number" then
                    local new_wince = wince_factor * (cfg.combat.stagger_taken_multiplier or 1.70)
                    safe_set_field(damage_info, "_WinceFactor", new_wince)
                    state.combat.last_wince_factor = new_wince
                end
                state.combat.last_hit_time = now()
                state.combat.last_weapon_id = saved.go_name
            end)
            return retval
        end)

    combat_hook_installed = true
end

local function collect_components(go)
    if not is_obj_valid(go) then return {} end
    if state.cached_components then
        local valid = {}
        for _, comp in ipairs(state.cached_components) do
            if is_obj_valid(comp) then
                table.insert(valid, comp)
            end
        end
        state.cached_components = valid
        if #valid > 0 then return valid end
    end

    local comps = {}
    local c = safe_call(go, "get_Components")
    if c then
        local ok = pcall(function()
            local count = c:get_Count()
            for i = 0, count - 1 do
                local comp = c:get_Item(i)
                if comp and is_obj_valid(comp) then
                    table.insert(comps, comp)
                end
            end
        end)
        if ok then
            state.cached_components = comps
            return comps
        end
    end

    state.cached_components = comps
    return comps
end

local function try_set_enabled(obj, enabled)
    if not is_obj_valid(obj) then return false end

    local ok = pcall(function() obj:call("set_Enabled", enabled) end)
    if ok then return true end

    ok = pcall(function() obj:call("set_enabled", enabled) end)
    if ok then return true end

    ok = safe_set_field(obj, "Enabled", enabled)
    if ok then return true end

    ok = safe_set_field(obj, "_Enabled", enabled)
    if ok then return true end

    ok = safe_set_field(obj, "enabled", enabled)
    if ok then return true end

    return false
end

local function try_set_runtime_active(obj, enabled)
    if not is_obj_valid(obj) then return false end

    local changed = false
    local method_names = {
        "set_UpdateSelf",
        "set_updateSelf",
        "set_DrawSelf",
        "set_drawSelf",
        "set_Update",
        "set_update",
        "set_Draw",
        "set_draw",
    }
    for _, name in ipairs(method_names) do
        local ok = pcall(function() obj:call(name, enabled) end)
        if ok then changed = true end
    end

    local field_names = {
        "UpdateSelf", "_UpdateSelf", "updateSelf",
        "DrawSelf", "_DrawSelf", "drawSelf",
        "Update", "_Update", "update",
        "Draw", "_Draw", "draw",
    }
    for _, name in ipairs(field_names) do
        if safe_set_field(obj, name, enabled) then
            changed = true
        end
    end

    return changed
end

local function try_set_runtime_split(obj, update_enabled, draw_enabled)
    if not is_obj_valid(obj) then return false end

    local changed = false

    local update_methods = {
        "set_UpdateSelf",
        "set_updateSelf",
        "set_Update",
        "set_update",
    }
    local draw_methods = {
        "set_DrawSelf",
        "set_drawSelf",
        "set_Draw",
        "set_draw",
    }
    local update_fields = {
        "UpdateSelf", "_UpdateSelf", "updateSelf",
        "Update", "_Update", "update",
    }
    local draw_fields = {
        "DrawSelf", "_DrawSelf", "drawSelf",
        "Draw", "_Draw", "draw",
    }

    for _, name in ipairs(update_methods) do
        local ok = pcall(function() obj:call(name, update_enabled) end)
        if ok then changed = true end
    end
    for _, name in ipairs(draw_methods) do
        local ok = pcall(function() obj:call(name, draw_enabled) end)
        if ok then changed = true end
    end
    for _, name in ipairs(update_fields) do
        if safe_set_field(obj, name, update_enabled) then
            changed = true
        end
    end
    for _, name in ipairs(draw_fields) do
        if safe_set_field(obj, name, draw_enabled) then
            changed = true
        end
    end

    return changed
end

local function hide_disable_mrx(go)
    if not go then return false end

    local hide_mode = {}

    local root_disabled = false
    if try_set_enabled(go, false) then
        root_disabled = true
    end
    if try_set_runtime_active(go, false) then
        root_disabled = true
    end
    if root_disabled then
        table.insert(hide_mode, "root_disabled")
    end

    local components = collect_components(go)
    local disabled_count = 0

    for _, comp in ipairs(components) do
        local changed = false
        if try_set_enabled(comp, false) then
            changed = true
        end
        if try_set_runtime_active(comp, false) then
            changed = true
        end
        if changed then
            disabled_count = disabled_count + 1
        end
    end

    if disabled_count > 0 then
        table.insert(hide_mode, "components_disabled:" .. tostring(disabled_count))
    end

    state.last_hide_mode = (#hide_mode > 0) and table.concat(hide_mode, " | ") or "none"
    state.hidden = (#hide_mode > 0)

    return state.hidden
end

local function show_enable_mrx(go)
    if not go then return false end

    local show_mode = {}

    local root_enabled = false
    if try_set_enabled(go, true) then
        root_enabled = true
    end
    if try_set_runtime_active(go, true) then
        root_enabled = true
    end
    if root_enabled then
        table.insert(show_mode, "root_enabled")
    end

    local components = collect_components(go)
    local enabled_count = 0

    for _, comp in ipairs(components) do
        local changed = false
        if try_set_enabled(comp, true) then
            changed = true
        end
        if try_set_runtime_active(comp, true) then
            changed = true
        end
        if changed then
            enabled_count = enabled_count + 1
        end
    end

    if enabled_count > 0 then
        table.insert(show_mode, "components_enabled:" .. tostring(enabled_count))
    end

    state.last_hide_mode = (#show_mode > 0) and table.concat(show_mode, " | ") or "none"
    state.hidden = false

    return (#show_mode > 0)
end

local function show_enable_mrx_nodraw(go)
    if not go then return false end

    local show_mode = {}

    local root_enabled = false
    if try_set_enabled(go, true) then
        root_enabled = true
    end
    if try_set_runtime_split(go, true, false) then
        root_enabled = true
    end
    if root_enabled then
        table.insert(show_mode, "root_enabled_nodraw")
    end

    local components = collect_components(go)
    local enabled_count = 0

    for _, comp in ipairs(components) do
        local changed = false
        if try_set_enabled(comp, true) then
            changed = true
        end
        if try_set_runtime_split(comp, true, false) then
            changed = true
        end
        if changed then
            enabled_count = enabled_count + 1
        end
    end

    if enabled_count > 0 then
        table.insert(show_mode, "components_enabled_nodraw:" .. tostring(enabled_count))
    end

    state.last_hide_mode = (#show_mode > 0) and table.concat(show_mode, " | ") or "none"
    state.hidden = true

    return (#show_mode > 0)
end

local function enable_mrx_draw(go)
    if not go then return false end

    local show_mode = {}

    local root_draw = try_set_runtime_split(go, true, true)
    if root_draw then
        table.insert(show_mode, "root_draw_enabled")
    end

    local components = collect_components(go)
    local enabled_count = 0

    for _, comp in ipairs(components) do
        if try_set_runtime_split(comp, true, true) then
            enabled_count = enabled_count + 1
        end
    end

    if enabled_count > 0 then
        table.insert(show_mode, "components_draw_enabled:" .. tostring(enabled_count))
    end

    if #show_mode > 0 then
        state.last_hide_mode = table.concat(show_mode, " | ")
    end
    state.hidden = false

    return (#show_mode > 0)
end

local function relocate_visible_mrx(go, point, use_nodraw_prime)
    if not go or not point then return false end

    local primed = false
    local moved_prime = false
    local relocated_prime = false

    if use_nodraw_prime then
        primed = show_enable_mrx_nodraw(go)
        moved_prime = force_move_verified(go, point, "strong")
        relocated_prime = try_runtime_relocate_strong(go)
    end

    local enabled = show_enable_mrx(go)
    local draw_enabled = enable_mrx_draw(go)
    local moved = force_move_verified(go, point, "strong")
    local relocated = try_runtime_relocate_strong(go)
    local verified = verify_at_point(go, point)

    if not verified then
        moved = force_move_verified(go, point, "strong") or moved
        relocated = try_runtime_relocate_strong(go) or relocated
        verified = verify_at_point(go, point)
    end

    state.hidden = false

    return (primed or enabled or draw_enabled) and (moved_prime or relocated_prime or moved or relocated or verified)
end

local function normalize_weighted_point_entry(entry, index, label_prefix)
    if not entry then return nil end

    if entry.point then
        return {
            label = entry.label or ((label_prefix or "Point") .. " " .. tostring(index)),
            x = entry.point.x,
            y = entry.point.y,
            z = entry.point.z,
            weight = entry.weight or 1,
        }
    end

    return {
        label = entry.label or ((label_prefix or "Point") .. " " .. tostring(index)),
        x = entry.x,
        y = entry.y,
        z = entry.z,
        weight = entry.weight or 1,
    }
end

local function choose_weighted_point(entries, label_prefix)
    if not entries or #entries == 0 then
        return nil, "none"
    end

    local normalized = {}
    local total_weight = 0

    for i, entry in ipairs(entries) do
        local p = normalize_weighted_point_entry(entry, i, label_prefix)
        if p and p.x and p.y and p.z then
            local w = math.max(1, p.weight or 1)
            total_weight = total_weight + w
            table.insert(normalized, p)
        end
    end

    if #normalized == 0 then
        return nil, "none"
    end

    local roll = math.random(1, total_weight)
    local running = 0

    for _, p in ipairs(normalized) do
        running = running + math.max(1, p.weight or 1)
        if roll <= running then
            return { x = p.x, y = p.y, z = p.z }, p.label
        end
    end

    local last = normalized[#normalized]
    return { x = last.x, y = last.y, z = last.z }, last.label
end

local function get_safe_room_name(index)
    if not index then return "none" end
    local room = cfg.safe_rooms[index]
    if not room then return "none" end
    return room.name or ("Safe Room " .. tostring(index))
end

local function get_startup_quarantine_room()
    local q = cfg.startup_quarantine
    local point = q and q.hold_point or nil
    if not point then return nil end
    return {
        name = "Startup Quarantine",
        stash_point = point,
    }
end

local function find_nearest_safe_room_index(player_pos)
    if not player_pos then return nil end
    if not cfg.safe_rooms or #cfg.safe_rooms == 0 then return nil end

    local best_index = nil
    local best_dist = nil

    for i, room in ipairs(cfg.safe_rooms) do
        local d = dist_sq_xz(player_pos, room.door_point)
        if best_dist == nil or d < best_dist then
            best_dist = d
            best_index = i
        end
    end

    return best_index
end

local function release_point_probably_visible(player_go, player_pos, point)
    if not cfg.release.require_probably_offscreen then return false end
    if not player_go or not player_pos or not point then return false end
    local pd = dist_xz(point, player_pos)
    if pd > (cfg.release.visible_reject_distance or 14.0) then return false end
    local dot = dot_from_player_forward(player_go, player_pos, point)
    if dot == nil then return false end
    return dot >= (cfg.release.visible_front_dot or 0.55)
end

local function choose_weighted_entry(entries)
    if not entries or #entries == 0 then return nil end
    local total_weight = 0.0
    for _, entry in ipairs(entries) do
        total_weight = total_weight + math.max(0.001, entry.weight or 0.0)
    end
    if total_weight <= 0.0 then return entries[1] end
    local roll = math.random() * total_weight
    local running = 0.0
    for _, entry in ipairs(entries) do
        running = running + math.max(0.001, entry.weight or 0.0)
        if roll <= running then return entry end
    end
    return entries[#entries]
end

local function trim_history_list(list, max_size)
    while #list > max_size do
        table.remove(list, #list)
    end
end

local function push_release_history(label, family)
    local max_size = math.max(1, cfg.release.history_size or 4)
    if label and label ~= "none" then
        table.insert(state.release_history_labels, 1, label)
        trim_history_list(state.release_history_labels, max_size)
        state.last_release_label = label
    end
    if family and family ~= "none" then
        table.insert(state.release_history_families, 1, family)
        trim_history_list(state.release_history_families, max_size)
        state.last_release_family = family
    end
end

local function recent_history_rank(list, value)
    if not value or value == "none" then return nil end
    for i, entry in ipairs(list or {}) do
        if entry == value then return i end
    end
    return nil
end

local function infer_release_family(entry)
    if not entry then return "none" end
    if type(entry.family) == "string" and entry.family ~= "" then
        return entry.family
    end

    local label = string.lower(tostring(entry.label or ""))
    local x = tonumber(entry.x or 0.0) or 0.0
    local y = tonumber(entry.y or 0.0) or 0.0

    if y >= 5.5
        or string.find(label, "stairs", 1, true)
        or string.find(label, "gallery", 1, true)
        or string.find(label, "bar", 1, true)
        or string.find(label, "hdu", 1, true)
        or string.find(label, "office", 1, true)
        or string.find(label, "locker", 1, true)
        or string.find(label, "kitchenette", 1, true) then
        return "upper"
    end

    if x <= -18.0 then return "west" end
    if x >= 18.0 then return "east" end
    return "central"
end

local function get_release_room_family_bias(room, family)
    local by_room = cfg.release.family_bias_by_safe_room or {}
    local room_name = room and room.name or nil
    if room_name and by_room[room_name] and by_room[room_name][family] then
        return by_room[room_name][family]
    end
    return 1.0
end

local function get_release_recent_penalty(label, family)
    local label_rank = recent_history_rank(state.release_history_labels, label)
    local family_rank = recent_history_rank(state.release_history_families, family)
    local weight = 1.0

    if label_rank == 1 then
        weight = weight * math.max(0.05, cfg.release.recent_label_penalty or 0.18)
    elseif label_rank == 2 then
        weight = weight * 0.35
    elseif label_rank == 3 then
        weight = weight * 0.60
    end

    if family_rank == 1 then
        weight = weight * math.max(0.10, cfg.release.recent_family_penalty or 0.45)
    elseif family_rank == 2 then
        weight = weight * 0.70
    elseif family_rank == 3 then
        weight = weight * 0.85
    elseif family_rank == nil and family ~= "none" then
        weight = weight * (cfg.release.same_family_bonus or 1.12)
    end

    return weight
end

local function build_global_release_pool()
    local pool = {}
    local seen = {}
    local zones = cfg.cutoff and cfg.cutoff.zones or nil
    if not zones then return pool end

    for _, zone in ipairs(zones) do
        local points = zone.points or zone.destinations or {}
        for idx, entry in ipairs(points) do
            local p = normalize_weighted_point_entry(entry, idx, "Release")
            if p and p.x and p.y and p.z then
                local key = tostring(p.label or (string.format('%.3f|%.3f|%.3f', p.x, p.y, p.z)))
                if not seen[key] then
                    seen[key] = true
                    table.insert(pool, {
                        label = p.label or key,
                        x = p.x,
                        y = p.y,
                        z = p.z,
                        family = infer_release_family(p),
                    })
                end
            end
        end
    end

    return pool
end

local function build_release_candidate(room, player_pos, player_go, p, source)
    local pd = dist_xz(player_pos, p)
    local valid = (pd >= (cfg.release.min_player_distance or 16.0))
        and (pd <= (cfg.release.max_player_distance or 120.0))
        and (not release_point_probably_visible(player_go, player_pos, p))

    if valid then
        for _, sr in ipairs(cfg.safe_rooms or {}) do
            local sr_radius = cfg.release.safe_room_exclusion_radius or 18.0
            if room and sr == room then sr_radius = cfg.release.current_room_exclusion_radius or sr_radius end
            if sr.door_point and dist_xz(sr.door_point, p) < sr_radius then
                valid = false
                break
            end
        end
    end

    if not valid then return nil end

    local family = infer_release_family(p)
    local weight = 1.0
    if pd >= 40.0 then weight = 4.0
    elseif pd >= 28.0 then weight = 3.0
    elseif pd >= 20.0 then weight = 2.0 end

    weight = weight * get_release_room_family_bias(room, family)
    weight = weight * get_release_recent_penalty(p.label, family)

    return {
        label = p.label,
        x = p.x,
        y = p.y,
        z = p.z,
        family = family,
        source = source or "Global",
        dist = pd,
        weight = math.max(0.001, weight),
    }
end

local function choose_release_family_bucket(candidates)
    local grouped = {}
    for _, candidate in ipairs(candidates or {}) do
        local family = candidate.family or "none"
        grouped[family] = grouped[family] or {}
        table.insert(grouped[family], candidate)
    end

    local family_entries = {}
    for family, entries in pairs(grouped) do
        table.sort(entries, function(a, b) return (a.weight or 0.0) > (b.weight or 0.0) end)
        local score = 0.0
        if entries[1] then score = score + (entries[1].weight or 0.0) end
        if entries[2] then score = score + (entries[2].weight or 0.0) * 0.35 end
        if entries[3] then score = score + (entries[3].weight or 0.0) * 0.15 end
        table.insert(family_entries, { family = family, entries = entries, weight = math.max(0.001, score) })
    end

    return choose_weighted_entry(family_entries)
end

local function choose_global_release_point(room, player_pos, player_go)
    if not cfg.release.prefer_global_points then return nil, "none", "none" end
    if not player_pos then return nil, "none", "none" end

    local candidates = {}
    local pool = build_global_release_pool()
    for _, p in ipairs(pool) do
        local candidate = build_release_candidate(room, player_pos, player_go, p, "Global")
        if candidate then
            table.insert(candidates, candidate)
        end
    end

    if #candidates <= 0 then return nil, "none", "none" end

    local family_bucket = choose_release_family_bucket(candidates)
    if not family_bucket or not family_bucket.entries or #family_bucket.entries == 0 then
        return nil, "none", "none"
    end

    local picked = choose_weighted_entry(family_bucket.entries)
    if not picked then return nil, "none", "none" end

    return { x = picked.x, y = picked.y, z = picked.z }, picked.label, picked.family
end

local function choose_room_fallback_release_point(room, player_pos, player_go)
    if not room or not room.release_points then return nil, "none", "none" end
    local candidates = {}
    for idx, entry in ipairs(room.release_points) do
        local p = normalize_weighted_point_entry(entry, idx, "Release")
        if p and p.x and p.y and p.z then
            p.family = infer_release_family(p)
            local candidate = build_release_candidate(room, player_pos, player_go, p, "Fallback")
            if candidate then
                candidate.weight = candidate.weight * math.max(1.0, p.weight or 1.0)
                table.insert(candidates, candidate)
            end
        end
    end
    if #candidates <= 0 then return nil, "none", "none" end

    local family_bucket = choose_release_family_bucket(candidates)
    if not family_bucket or not family_bucket.entries or #family_bucket.entries == 0 then
        return nil, "none", "none"
    end

    local picked = choose_weighted_entry(family_bucket.entries)
    if not picked then return nil, "none", "none" end
    return { x = picked.x, y = picked.y, z = picked.z }, picked.label, picked.family
end

local function choose_release_point(room, player_pos, player_go)
    if room then
        local point, label, family = choose_global_release_point(room, player_pos, player_go)
        if point then
            return point, "Global: " .. tostring(label), family
        end
        local fallback, fallback_label, fallback_family = choose_room_fallback_release_point(room, player_pos, player_go)
        if fallback then
            return fallback, "Fallback: " .. tostring(fallback_label), fallback_family
        end
        return nil, "none", "none"
    end

    local point, label, family = choose_global_release_point(nil, player_pos, player_go)
    if point then return point, "Global: " .. tostring(label), family end
    return nil, "none", "none"
end

local function choose_bootstrap_spawn_scrub_point(room, player_pos, player_go)
    if not cfg.bootstrap_spawn_scrub then return nil, "none" end
    if not player_pos then return nil, "none" end

    local candidates = {}
    local pool = build_global_release_pool()
    local min_dist = math.max(
        cfg.bootstrap_spawn_scrub_min_distance or 34.0,
        (cfg.release.min_player_distance or 16.0) + 8.0
    )

    for _, p in ipairs(pool) do
        local candidate = build_release_candidate(room, player_pos, player_go, p, "BootstrapScrub")
        if candidate and (candidate.dist or 0.0) >= min_dist then
            candidate.weight = math.max(0.001, (candidate.dist or min_dist) * (candidate.weight or 1.0))
            table.insert(candidates, candidate)
        end
    end

    if #candidates <= 0 then
        local fallback, label = choose_release_point(room, player_pos, player_go)
        return fallback, label
    end

    table.sort(candidates, function(a, b) return (a.weight or 0.0) > (b.weight or 0.0) end)
    local top_n = math.min(4, #candidates)
    local top = {}
    for i = 1, top_n do
        table.insert(top, candidates[i])
    end

    local picked = choose_weighted_entry(top) or top[1]
    if not picked then return nil, "none" end

    return { x = picked.x, y = picked.y, z = picked.z }, "Bootstrap: " .. tostring(picked.label)
end

local stash_and_hide_mrx
local function begin_bootstrap_spawn_scrub(room, player_pos, player_go)
    if state.bootstrap_spawn_scrub_done or state.bootstrap_spawn_scrub_pending then return false end

    local point, label = choose_bootstrap_spawn_scrub_point(room, player_pos, player_go)
    if not point then
        state.bootstrap_spawn_scrub_done = true
        state.bootstrap_spawn_scrub_pending = false
        state.bootstrap_spawn_scrub_point = nil
        state.bootstrap_spawn_scrub_label = "none"
        state.bootstrap_spawn_scrub_phase = "none"
        return false
    end

    state.bootstrap_spawn_scrub_point = copy_point(point)
    state.bootstrap_spawn_scrub_label = label or "none"
    state.bootstrap_spawn_scrub_pending = true
    state.bootstrap_spawn_scrub_done = true
    state.bootstrap_spawn_scrub_phase = "prime"
    state.bootstrap_spawn_scrub_time = now() + (cfg.bootstrap_spawn_scrub_delay or 0.12)
    return true
end

local function run_bootstrap_spawn_scrub(go, room)
    if not state.bootstrap_spawn_scrub_pending or not go or not room then return false end
    if now() < (state.bootstrap_spawn_scrub_time or -999.0) then return true end

    local point = state.bootstrap_spawn_scrub_point
    if not point then
        state.bootstrap_spawn_scrub_pending = false
        state.bootstrap_spawn_scrub_phase = "none"
        return false
    end

    if state.bootstrap_spawn_scrub_phase == "prime" then
        force_move_verified(go, point)
        show_enable_mrx(go)
        force_move_verified(go, point)
        try_runtime_relocate(go)
        state.hidden = false
        state.stashed = false
        state.mrx_pos = nil
        state.mrx_pos_source = "none"
        state.mrx_draw_pos = nil
        state.mrx_draw_source = "bootstrap_spawn_scrub"
        state.bootstrap_spawn_scrub_phase = "restash"
        state.bootstrap_spawn_scrub_time = now() + (cfg.bootstrap_spawn_scrub_hold or 0.20)
        state.last_status = "bootstrap spawn scrub prime: " .. tostring(state.bootstrap_spawn_scrub_label or "none")
        state.last_action = "bootstrap spawn scrub prime"
        return true
    end

    local restashed = stash_and_hide_mrx(go, room, "bootstrap spawn scrub restash: " .. tostring(state.bootstrap_spawn_scrub_label or "none"))
    state.bootstrap_spawn_scrub_pending = false
    state.bootstrap_spawn_scrub_point = nil
    state.bootstrap_spawn_scrub_label = "none"
    state.bootstrap_spawn_scrub_time = -999.0
    state.bootstrap_spawn_scrub_phase = "none"
    return restashed
end

local function reset_waiting_release()
    state.waiting_release = false
    state.release_time = -999.0
    state.release_prewarm_pending = false
    state.release_nodraw_prime_pending = false
    state.release_nodraw_prime_time = -999.0
    state.pending_release_point = nil
    state.pending_release_label = "none"
    state.pending_release_family = "none"
end

local function clear_cutoff_pending(reason)
    state.cutoff.pending_active = false
    state.cutoff.pending_zone_name = "none"
    state.cutoff.pending_fire_time = -999.0
    state.cutoff.pending_delay = 0.0
    state.cutoff.pending_started_at = -999.0
    state.cutoff.destination_label = "none"
    if reason then
        state.cutoff.result = reason
    end
end

set_cutoff_suspend = function(seconds, reason)
    state.cutoff.suspend_until = now() + (seconds or 0.0)
    if reason then
        state.cutoff.result = "suspend: " .. tostring(reason)
    end
end

local function reset_cutoff_tracking(msg)
    clear_cutoff_settle()
    state.cutoff.last_sample_time = -999.0
    state.cutoff.stall_time = 0.0
    state.cutoff.last_pos = nil
    state.cutoff.stall_start_pos = nil
    state.cutoff.zone_name = "none"
    state.cutoff.destination_label = "none"
    clear_cutoff_pending()
    state.cutoff.result = msg or "reset"
end

local function set_behavior_mode(mode, reason)
    local t = now()
    if state.behavior.mode ~= mode then
        state.behavior.mode = mode
        state.behavior.since = t
        if mode == "SEARCH" or mode == "INVESTIGATE" then
            state.behavior.anchor_force_refresh = true
        end
    end
    state.behavior.reason = reason or state.behavior.reason
    state.behavior.last_update = t
end

local function reset_behavior(mode, reason)
    local t = now()
    local keep_investigate_zone_last_name = state.behavior.investigate_zone_last_name or "none"
    local keep_investigate_zone_last_time = state.behavior.investigate_zone_last_time or -999.0
    local keep_investigate_zone_ignore_name = state.behavior.investigate_zone_ignore_name or "none"
    local keep_investigate_zone_ignore_until = state.behavior.investigate_zone_ignore_until or -999.0

    state.behavior.mode = mode or "SEARCH"
    state.behavior.since = t
    state.behavior.reason = reason or "reset"
    state.behavior.noise_tier = "none"
    state.behavior.noise_strength = 0.0
    state.behavior.last_seen_time = -999.0
    state.behavior.last_heard_time = -999.0
    state.behavior.unseen_since = t
    state.behavior.investigate_until = -999.0
    state.behavior.chase_until = -999.0
    state.behavior.search_refresh_time = -999.0
    state.behavior.search_target_label = "none"
    state.behavior.search_target_zone = "none"
    state.behavior.search_target_point = nil
    state.behavior.search_target_index = 0
    state.behavior.search_target_strategy = "none"
    state.behavior.planned_target_label = "none"
    state.behavior.planned_target_zone = "none"
    state.behavior.planned_target_point = nil
    state.behavior.planned_target_index = 0
    state.behavior.planned_target_strategy = "none"
    state.behavior.planned_target_selected_at = -999.0
    state.behavior.committed_target_label = "none"
    state.behavior.committed_target_zone = "none"
    state.behavior.committed_target_point = nil
    state.behavior.committed_target_index = 0
    state.behavior.committed_target_strategy = "none"
    state.behavior.committed_target_armed_at = -999.0
    state.behavior.committed_target_fire_at = -999.0
    state.behavior.committed_target_reason = "none"
    state.behavior.search_repositions = 0
    state.behavior.search_last_destination = "none"
    state.behavior.search_last_zone = "none"
    state.behavior.search_last_result = "none"
    state.behavior.search_last_move_time = -999.0
    state.behavior.search_next_index_by_zone = {}
    state.behavior.anchor_force_refresh = true
    state.behavior.release_search_pending = false
    state.behavior.release_search_origin = nil
    state.behavior.pending_anchor_time = -999.0
    state.behavior.pending_anchor_delay = 0.0
    state.behavior.pending_anchor_mode = "none"
    state.behavior.pending_anchor_reason = "none"
    state.behavior.pending_anchor_noise = "none"
    state.behavior.pending_heat_override_count = 0
    state.behavior.pending_heat_override_last_time = -999.0
    state.behavior.linger_until = -999.0
    state.behavior.last_noise_kind = "none"
    state.behavior.last_stimulus = "none"
    state.behavior.last_player_known_pos = nil
    state.behavior.last_player_known_time = -999.0
    state.behavior.investigate_decay_reason = "none"
    state.behavior.investigate_decay_last_time = -999.0
    state.behavior.escalation_level = 0
    state.behavior.reposition_grace_until = -999.0
    state.behavior.reposition_grace_reason = "none"
    state.behavior.investigate_commit_until = -999.0
    state.behavior.investigate_commit_reason = "none"
    state.behavior.chase_stall_time = 0.0
    state.behavior.chase_last_pos = nil
    state.behavior.visual_confirm_since = -999.0
    state.behavior.cutoff_settle_until = -999.0
    state.behavior.cutoff_settle_point = nil
    state.behavior.cutoff_settle_label = "none"
    state.behavior.cutoff_settle_reset_at = -999.0
    state.behavior.last_fire_stimulus_time = -999.0
    state.behavior.fire_burst_count = 0
    state.behavior.last_fire_retarget_time = -999.0
    state.behavior.offscreen_stall_time = 0.0
    state.behavior.offscreen_stall_last_pos = nil
    state.behavior.hidden_forward_advance_tries = 0
    state.behavior.hidden_forward_advance_last_time = -999.0
    state.behavior.last_hidden_anchor_move_time = -999.0
    state.behavior.last_update = t
    state.behavior.investigate_zone_last_name = keep_investigate_zone_last_name
    state.behavior.investigate_zone_last_time = keep_investigate_zone_last_time
    state.behavior.investigate_zone_ignore_name = keep_investigate_zone_ignore_name
    state.behavior.investigate_zone_ignore_until = keep_investigate_zone_ignore_until
end

local function enter_clean_search(reason)
    local keep_repositions = state.behavior.search_repositions or 0
    local keep_last_destination = state.behavior.search_last_destination or "none"
    local keep_last_zone = state.behavior.search_last_zone or "none"
    local keep_last_result = state.behavior.search_last_result or "none"

    reset_behavior("SEARCH", reason or "clean search")

    state.behavior.search_repositions = keep_repositions
    state.behavior.search_last_destination = keep_last_destination
    state.behavior.search_last_zone = keep_last_zone
    state.behavior.search_last_result = keep_last_result
    state.behavior.unseen_since = now()
    state.behavior.anchor_force_refresh = true
    state.behavior.search_refresh_time = -999.0
end

local arm_behavior_anchor_move

local function enter_clean_safe(reason)
    reset_behavior("SAFE", reason or "clean safe")
    state.behavior.anchor_force_refresh = false
    state.behavior.search_refresh_time = -999.0
end

enter_investigate_recover = function(reason, player_pos, player_go, mrx_pos)
    local keep_repositions = state.behavior.search_repositions or 0
    local keep_last_destination = state.behavior.search_last_destination or "none"
    local keep_last_zone = state.behavior.search_last_zone or "none"

    reset_behavior("INVESTIGATE", reason or "investigate recover")

    state.behavior.search_repositions = keep_repositions
    state.behavior.search_last_destination = keep_last_destination
    state.behavior.search_last_zone = keep_last_zone
    mark_last_player_known(player_pos)
    state.behavior.investigate_until = now() + (cfg.behavior.investigate_recover_hold or 6.0)
    state.behavior.last_stimulus = reason or "investigate recover"
    state.behavior.last_noise_kind = "visual"
    state.behavior.anchor_force_refresh = true
    state.behavior.search_refresh_time = -999.0
    if player_pos then
        arm_behavior_anchor_move("INVESTIGATE", player_pos, player_go, mrx_pos, reason or "investigate recover", "visual")
    end
end

local function reset_runtime_for_scene(scene_name)
    state.scene_name = scene_name or "n/a"
    state.player_pos = nil
    state.player_go = nil
    state.player_prev_pos = nil
    state.player_prev_time = -999.0
    state.player_step_xz = 0.0
    state.player_speed_xz = 0.0
    state.fire_input_prev = false

    state.mrx_pos = nil
    state.mrx_pos_source = "none"
    state.mrx_draw_pos = nil
    state.mrx_draw_source = "none"
    state.mrx_prev_draw_pos = nil
    state.mrx_prev_draw_time = -999.0
    state.mrx_step_xz = 0.0
    state.mrx_speed_xz = 0.0
    state.last_live_confirmed_pos = nil
    state.last_live_confirmed_source = "none"
    state.last_live_confirmed_time = -999.0

    state.player_in_safe_room = false
    state.prev_player_in_safe_room = false
    state.startup_quarantine_active = (cfg.startup_quarantine == nil or cfg.startup_quarantine.enabled ~= false)
    state.startup_quarantine_seen_safe_room = false

    state.mrx_found = false
    state.mrx_was_found_prev = false

    state.attempted_move = false
    state.move_succeeded = false

    state.hidden = false
    state.stashed = false
    state.bootstrap_spawn_scrub_done = false
    state.bootstrap_spawn_scrub_pending = false
    state.bootstrap_spawn_scrub_point = nil
    state.bootstrap_spawn_scrub_label = "none"
    state.bootstrap_spawn_scrub_time = -999.0
    state.bootstrap_spawn_scrub_phase = "none"
    reset_waiting_release()
    state.last_stash_time = -999.0
    state.last_hold_refresh_time = -999.0
    state.last_hold_confirm_time = -999.0
    state.last_hold_mode = "none"
    state.last_hold_reason = "none"
    state.active_safe_room_index = nil
    state.pending_release_family = "none"
    state.last_release_label = "none"
    state.last_release_family = "none"
    state.release_history_labels = {}
    state.release_history_families = {}

    state.last_status = "scene bootstrap"
    state.last_action = "scene bootstrap"
    state.last_hide_mode = "none"
    state.last_native_reset = "scene bootstrap"

    clear_mrx_cache()
    clear_sound_heatmap()
    reset_cutoff_tracking("scene reset")
    reset_behavior("SEARCH", "scene reset")
    state.behavior.investigate_zone_last_name = "none"
    state.behavior.investigate_zone_last_time = -999.0
    state.behavior.investigate_zone_ignore_name = "none"
    state.behavior.investigate_zone_ignore_until = -999.0

    state.scene_guard_until = now() + cfg.scene_change_grace
    state.bootstrap_until = now() + cfg.scene_change_grace
end

stash_and_hide_mrx = function(go, room, why)
    if not go or not room then return false end

    state.attempted_move = true

    local moved = force_move_verified(go, room.stash_point, "light")
    local native_reset = try_reset_native_alert_state(go)
    local hidden = hide_disable_mrx(go)

    state.move_succeeded = moved or hidden or native_reset

    if state.move_succeeded then
        state.stashed = true
        state.mrx_pos = nil
        state.mrx_draw_pos = nil
        state.mrx_draw_source = "stashed"
        reset_waiting_release()
        state.last_stash_time = now()
        state.last_hold_refresh_time = state.last_stash_time
        state.last_hold_confirm_time = state.last_stash_time
        state.last_hold_mode = "stash"
        state.last_hold_reason = why
        state.last_status = why .. " | " .. tostring(state.last_native_reset)
        state.last_action = why
        reset_cutoff_tracking("stashed")
        enter_clean_safe(why)
        return true
    end

    state.last_status = why .. " failed"
    state.last_action = why .. " failed"
    return false
end

local function hold_hidden_stash_refresh(go, room, reason)
    if not go or not room or not room.stash_point then return false end

    local do_move = (reason == "not stashed") or (reason == "drift")
    local moved = false
    if do_move then
        moved = force_move_verified(go, room.stash_point, "light")
    end

    local hidden = hide_disable_mrx(go)

    local native_reset = false
    if do_move or reason == "visibility/reactivation" then
        native_reset = try_reset_native_alert_state(go)
    end

    if moved or hidden or native_reset then
        state.stashed = true
        state.hidden = true
        state.mrx_pos = nil
        state.mrx_pos_source = "none"
        state.mrx_draw_pos = nil
        state.mrx_draw_source = "stashed"
        state.last_hold_refresh_time = now()
        state.last_hold_confirm_time = state.last_hold_refresh_time
        state.last_hold_mode = do_move and "refresh" or "soft_refresh"
        state.last_hold_reason = reason or "refresh"
        return true
    end

    state.last_hold_mode = "refresh_failed"
    state.last_hold_reason = reason or "refresh_failed"
    return false
end

local function hold_hidden_stash(go, room)
    if not go or not room or not room.stash_point then return false end

    local t = now()
    local at_stash = verify_at_point(go, room.stash_point)

    local refresh_reason = nil
    if not state.stashed then
        refresh_reason = "not stashed"
    elseif not state.hidden then
        refresh_reason = "visibility/reactivation"
    elseif not at_stash then
        refresh_reason = "drift"
    end

    if refresh_reason ~= nil then
        return hold_hidden_stash_refresh(go, room, refresh_reason)
    end

    state.stashed = true
    state.hidden = true
    state.mrx_pos = nil
    state.mrx_pos_source = "none"
    state.mrx_draw_pos = nil
    state.mrx_draw_source = "stashed"
    state.last_hold_confirm_time = t
    state.last_hold_mode = "light"
    state.last_hold_reason = "light hold"
    return true
end

local function prewarm_hidden_release(go, point)
    if not go or not point then return false end

    local moved = force_move_verified(go, point, "light")
    local relocated = try_runtime_relocate_light(go)
    if not verify_at_point(go, point) then
        moved = force_move_verified(go, point, "light") or moved
        relocated = try_runtime_relocate_light(go) or relocated
    end

    state.mrx_pos = nil
    state.mrx_pos_source = "none"
    state.mrx_draw_pos = nil
    state.mrx_draw_source = "release_prewarm"

    return verify_at_point(go, point) or moved or relocated
end

local function begin_release_nodraw_prime(go, point)
    if not go or not point then return false end

    state.attempted_move = true

    local shown = show_enable_mrx_nodraw(go)
    local moved = force_move_verified(go, point, "strong")
    local relocated = try_runtime_relocate_strong(go)
    local verified = verify_at_point(go, point)

    state.move_succeeded = shown and (moved or relocated or verified)

    if state.move_succeeded then
        state.mrx_pos = nil
        state.mrx_pos_source = "none"
        state.mrx_draw_pos = nil
        state.mrx_draw_source = "release_nodraw_prime"
        state.hidden = true
        return true
    end

    state.last_status = "release nodraw prime retry"
    state.last_action = "release nodraw prime retry"
    return false
end

local function finish_release_mrx(go, point, why)
    if not go or not point then return false end

    state.attempted_move = true

    local enabled = show_enable_mrx(go)
    local draw_enabled = enable_mrx_draw(go)
    local moved = force_move_verified(go, point, "strong")
    local relocated = try_runtime_relocate_strong(go)
    local verified = verify_at_point(go, point)

    if not verified then
        moved = force_move_verified(go, point, "strong") or moved
        relocated = try_runtime_relocate_strong(go) or relocated
        verified = verify_at_point(go, point)
    end

    state.move_succeeded = (enabled or draw_enabled) and (moved or relocated or verified)

    if state.move_succeeded then
        local actual, actual_source = get_mrx_runtime_pos(go)
        actual = actual or get_position(go)
        publish_live_mrx_pos(actual, actual_source)
        publish_live_mrx_draw_pos(go)
        state.stashed = false
        local released_label = state.pending_release_label or "none"
        local released_family = state.pending_release_family or "none"
        push_release_history(released_label, released_family)
        reset_waiting_release()
        state.movement.suppress_until = now() + 0.75
        state.last_status = why
        state.last_action = why
        reset_cutoff_tracking("released")
        set_cutoff_suspend(cfg.cutoff.suspend_after_release or 2.5, "release")
        enter_clean_search("released into hospital")
        state.behavior.release_search_pending = true
        state.behavior.release_search_origin = copy_point(point)
        local release_delay = rand_range(
            cfg.behavior.release_search_delay_min or 4.0,
            cfg.behavior.release_search_delay_max or 7.0
        )
        state.behavior.anchor_force_refresh = false
        state.behavior.search_refresh_time = now() + release_delay
        state.behavior.linger_until = math.max(state.behavior.linger_until or -999.0, now() + math.min(release_delay, 4.0))
        state.behavior.search_last_result = string.format("release settle %.2f", release_delay)
        begin_reposition_grace(2.0, "release")
        state.startup_quarantine_active = false
        state.startup_quarantine_seen_safe_room = false
        return true
    end

    state.last_status = "release retry"
    state.last_action = "release retry"
    return false
end

local function update_player_motion(player_pos)
    local t = now()
    if not player_pos then
        state.player_step_xz = 0.0
        state.player_speed_xz = 0.0
        state.player_prev_pos = nil
        state.player_prev_time = t
        return
    end

    if state.player_prev_pos and state.player_prev_time > 0.0 then
        local dt = t - state.player_prev_time
        local step = dist_xz(player_pos, state.player_prev_pos)
        state.player_step_xz = step
        if dt > 0.0001 then
            state.player_speed_xz = step / dt
        else
            state.player_speed_xz = 0.0
        end
    else
        state.player_step_xz = 0.0
        state.player_speed_xz = 0.0
    end

    state.player_prev_pos = copy_point(player_pos)
    state.player_prev_time = t
end

local function update_mrx_motion(mrx_draw_pos)
    local t = now()
    if not mrx_draw_pos then
        state.mrx_step_xz = 0.0
        state.mrx_speed_xz = 0.0
        state.mrx_prev_draw_pos = nil
        state.mrx_prev_draw_time = t
        return
    end

    if state.mrx_prev_draw_pos and state.mrx_prev_draw_time > 0.0 then
        local dt = t - state.mrx_prev_draw_time
        local step = dist_xz(mrx_draw_pos, state.mrx_prev_draw_pos)
        state.mrx_step_xz = step
        if dt > 0.0001 then
            state.mrx_speed_xz = step / dt
        else
            state.mrx_speed_xz = 0.0
        end
    else
        state.mrx_step_xz = 0.0
        state.mrx_speed_xz = 0.0
    end

    state.mrx_prev_draw_pos = copy_point(mrx_draw_pos)
    state.mrx_prev_draw_time = t
end

local function poll_fire_proxy()
    if not cfg.behavior.allow_global_fire_input then return false end
    if not reframework or not reframework.is_key_down then return false end
    local ok, down = pcall(function()
        return reframework:is_key_down(cfg.behavior.fire_input_key)
    end)
    if not ok then
        return false
    end
    local pressed = (down == true) and (state.fire_input_prev ~= true)
    state.fire_input_prev = (down == true)
    return pressed
end

dot_from_player_forward = function(player_go, player_pos, point)
    if not player_go or not player_pos or not point then return nil end
    local fwd = get_forward(player_go)
    if not fwd then return nil end
    local vx = point.x - player_pos.x
    local vz = point.z - player_pos.z
    local mag = math.sqrt((vx * vx) + (vz * vz))
    if mag <= 0.0001 then return 1.0 end
    vx = vx / mag
    vz = vz / mag
    return (fwd.x * vx) + (fwd.z * vz)
end

local function point_probably_visible(player_go, player_pos, point)
    if not cfg.cutoff.require_probably_offscreen then return false end
    if not player_go or not player_pos or not point then return false end
    local pd = dist_xz(point, player_pos)
    if pd > cfg.cutoff.visible_reject_distance then return false end
    local dot = dot_from_player_forward(player_go, player_pos, point)
    if dot == nil then return false end
    return dot >= cfg.cutoff.visible_front_dot
end

probable_visual_contact = function(player_go, player_pos, mrx_pos)
    if not player_pos or not mrx_pos then return false end
    local d = dist_xz(player_pos, mrx_pos)
    if d <= cfg.behavior.visual_chase_distance then
        return true
    end
    if d > cfg.behavior.visual_alert_distance then
        return false
    end
    local dot = dot_from_player_forward(player_go, player_pos, mrx_pos)
    if dot ~= nil and dot >= 0.10 then
        return true
    end
    return false
end

local function classify_noise(player_pos, mrx_pos, fire_pressed)
    local speed = state.player_speed_xz or 0.0
    local dist = (player_pos and mrx_pos) and dist_xz(player_pos, mrx_pos) or 9999.0

    if fire_pressed and dist <= cfg.behavior.hard_sound_radius then
        return "hard", 1.0, dist
    end

    if dist <= cfg.behavior.light_noise_radius and speed > 0.05 then
        return "light", clamp01(speed / math.max(0.10, cfg.behavior.soft_sound_speed)), dist
    end

    if speed >= cfg.behavior.hard_sound_speed and dist <= cfg.behavior.hard_sound_radius then
        return "hard", clamp01(speed / math.max(0.10, cfg.behavior.hard_sound_speed * 1.5)), dist
    end

    if speed >= cfg.behavior.soft_sound_speed and dist <= cfg.behavior.soft_sound_radius then
        return "medium", clamp01(speed / math.max(0.10, cfg.behavior.hard_sound_speed)), dist
    end

    if dist <= cfg.behavior.rear_hearing_radius and speed >= 0.20 then
        return "medium", clamp01(speed / math.max(0.20, cfg.behavior.soft_sound_speed)), dist
    end

    return "none", 0.0, dist
end

local function search_anchor_point_visible(player_go, player_pos, point)
    if not cfg.behavior.search_anchor.require_probably_offscreen then return false end
    if not player_go or not player_pos or not point then return false end
    local pd = dist_xz(point, player_pos)
    if pd > (cfg.behavior.search_anchor.visible_reject_distance or 12.0) then return false end
    local dot = dot_from_player_forward(player_go, player_pos, point)
    if dot == nil then return false end
    return dot >= (cfg.behavior.search_anchor.visible_front_dot or 0.55)
end

validate_search_anchor_point = function(point, player_pos, player_go, mrx_pos, mode)
    if not point or not point.x or not point.y or not point.z then
        return false, false, 0.0, 0.0, -2.0
    end

    local min_player_distance = cfg.behavior.search_anchor.min_player_distance or 8.0
    if mode == "INVESTIGATE" then
        min_player_distance = cfg.behavior.search_anchor.investigate_min_player_distance or min_player_distance
    end

    local max_player_distance = cfg.behavior.search_anchor.max_player_distance or 32.0
    if mode == "INVESTIGATE" then
        max_player_distance = cfg.behavior.search_anchor.investigate_max_player_distance or max_player_distance
    end
    local min_mrx_distance = cfg.behavior.search_anchor.min_mrx_distance or 4.0
    local max_mrx_distance = cfg.behavior.search_anchor.max_mrx_distance or 90.0

    local pd = player_pos and dist_xz(point, player_pos) or 9999.0
    local md = mrx_pos and dist_xz(point, mrx_pos) or 9999.0
    local dot = dot_from_player_forward(player_go, player_pos, point) or -2.0
    local visible = search_anchor_point_visible(player_go, player_pos, point)
    local preferred = (dot >= (cfg.behavior.search_anchor.prefer_forward_dot or 0.10))

    local valid = (pd >= min_player_distance)
        and (pd <= max_player_distance)
        and (md >= min_mrx_distance)
        and (md <= max_mrx_distance)
        and (not visible)

    return valid, preferred, pd, md, dot
end

local function choose_next_search_anchor_point(zone, player_pos, player_go, mrx_pos, mode, peek_only)
    if not zone or not zone.points or #zone.points == 0 then
        return nil, "none", 0
    end

    local total = #zone.points
    local start_index = state.behavior.search_next_index_by_zone[zone.name] or 1
    if start_index < 1 or start_index > total then start_index = 1 end

    local first_valid = nil
    local first_preferred = nil

    for offset = 0, total - 1 do
        local idx = ((start_index - 1 + offset) % total) + 1
        local entry = normalize_weighted_point_entry(zone.points[idx], idx, "Search")
        if entry and entry.x and entry.y and entry.z then
            local point = { x = entry.x, y = entry.y, z = entry.z }
            local valid, preferred, pd, md, dot = validate_search_anchor_point(point, player_pos, player_go, mrx_pos, mode)
            if valid then
                local choice = {
                    point = point,
                    label = entry.label,
                    index = idx,
                    player_dist = pd,
                    mrx_dist = md,
                    dot = dot,
                }
                if not first_valid then
                    first_valid = choice
                end
                if preferred and not first_preferred then
                    first_preferred = choice
                end
            end
        end
    end

    local picked = first_preferred or first_valid
    if picked then
        if not peek_only then
            state.behavior.search_next_index_by_zone[zone.name] = (picked.index % total) + 1
        end
        return picked.point, picked.label, picked.index
    end

    return nil, "none", 0
end

local function choose_weighted_zone_candidate(entries, top_n)
    if not entries or #entries == 0 then return nil end
    top_n = math.max(1, math.floor(top_n or #entries))
    top_n = math.min(top_n, #entries)

    local total_weight = 0.0
    for i = 1, top_n do
        total_weight = total_weight + math.max(0.001, entries[i].weight or 0.0)
    end
    if total_weight <= 0.0 then
        return entries[1]
    end

    local roll = math.random() * total_weight
    local running = 0.0
    for i = 1, top_n do
        local w = math.max(0.001, entries[i].weight or 0.0)
        running = running + w
        if roll <= running then
            return entries[i]
        end
    end

    return entries[top_n]
end

local function build_search_zone_candidates(player_pos)
    local all_candidates = {}
    for _, zone in ipairs(cfg.cutoff.zones or {}) do
        table.insert(all_candidates, { zone = zone, dist = dist_xz(player_pos, zone.center) })
    end
    table.sort(all_candidates, function(a, b) return a.dist < b.dist end)
    return all_candidates
end

local function build_search_zone_candidates_from_origin(origin_pos, fallback_player_pos)
    if origin_pos then
        return build_search_zone_candidates(origin_pos)
    end
    return build_search_zone_candidates(fallback_player_pos)
end

local function filter_investigate_zone_candidates(all_candidates)
    local priority = {}
    local fallback = {}
    local priority_dist = cfg.behavior.search_anchor.investigate_zone_priority_distance or 24.0
    local fallback_dist = cfg.behavior.search_anchor.investigate_zone_fallback_distance or 36.0

    for _, candidate in ipairs(all_candidates or {}) do
        if candidate.dist <= priority_dist then
            table.insert(priority, candidate)
        elseif candidate.dist <= fallback_dist then
            table.insert(fallback, candidate)
        end
    end

    if #priority > 0 then return priority end
    if #fallback > 0 then return fallback end
    return all_candidates or {}
end

function clear_sound_heatmap()
    state.sound.node_energy = {}
    state.sound.active_count = 0
    state.sound.peak_label = "none"
    state.sound.peak_value = 0.0
    state.sound.published_nodes = {}
    state.sound.last_decay_time = now()
    state.sound.last_inject_time = -999.0
    state.sound.last_inject_kind = "none"
    state.sound.last_inject_node_label = "none"
    state.sound.bias_pending = false
    state.sound.bias_source_label = "none"
    state.sound.bias_source_kind = "none"
    state.sound.last_bias_use_time = -999.0
    state.sound.last_override_time = -999.0
end

function refresh_sound_heat_summary()
    local entries = {}
    local peak_label = "none"
    local peak_value = 0.0

    for label, energy in pairs(state.sound.node_energy or {}) do
        if type(energy) == "number" and energy >= (cfg.sound_map.min_energy or 0.05) then
            local node = cfg.graph.node_by_label and cfg.graph.node_by_label[label] or nil
            if node then
                local e = {
                    label = tostring(label),
                    energy = energy,
                    x = node.x,
                    y = node.y,
                    z = node.z,
                }
                entries[#entries + 1] = e
                if energy > peak_value then
                    peak_value = energy
                    peak_label = tostring(label)
                end
            end
        end
    end

    table.sort(entries, function(a, b) return a.energy > b.energy end)
    local max_publish = cfg.sound_map.publish_max_nodes or 16
    local published = {}
    for i = 1, math.min(#entries, max_publish) do
        published[#published + 1] = entries[i]
    end

    state.sound.active_count = #entries
    state.sound.peak_label = peak_label
    state.sound.peak_value = peak_value
    state.sound.published_nodes = published

    if peak_value < (cfg.sound_map.bias_min_energy or 0.35) then
        state.sound.bias_pending = false
    end
end

function decay_sound_heatmap()
    if not cfg.sound_map.enabled then return end
    local t = now()
    local last = state.sound.last_decay_time or t
    local dt = t - last
    if dt <= 0.0 then return end

    local decay = (cfg.sound_map.decay_per_sec or 0.32) * dt
    for label, energy in pairs(state.sound.node_energy or {}) do
        local new_energy = (energy or 0.0) - decay
        if new_energy < (cfg.sound_map.min_energy or 0.05) then
            state.sound.node_energy[label] = nil
        else
            state.sound.node_energy[label] = math.min(cfg.sound_map.max_energy or 3.0, new_energy)
        end
    end

    state.sound.last_decay_time = t
    refresh_sound_heat_summary()
end

function add_sound_heat_to_node(label, amount)
    if not label or label == "none" then return end
    if not (cfg.graph.node_by_label and cfg.graph.node_by_label[label]) then return end
    local current = state.sound.node_energy[label] or 0.0
    state.sound.node_energy[label] = math.min(cfg.sound_map.max_energy or 3.0, current + amount)
end

function inject_sound_heat_from_source(label, base_energy, kind)
    if not cfg.sound_map.enabled then return false end
    if not label or label == "none" then return false end
    if type(base_energy) ~= "number" or base_energy <= 0.0 then return false end

    local t = now()
    local cooldown = (kind == "fire") and (cfg.sound_map.fire_inject_cooldown or 0.15) or (cfg.sound_map.inject_cooldown or 0.35)
    if state.sound.last_inject_node_label == label
        and state.sound.last_inject_kind == kind
        and (t - (state.sound.last_inject_time or -999.0)) < cooldown then
        return false
    end

    local visited = {}
    local frontier = { label }
    visited[label] = true
    local hops = cfg.sound_map.propagation_hops or 2
    local falloff = cfg.sound_map.falloff or 0.62

    for hop = 0, hops do
        local energy = base_energy * (falloff ^ hop)
        if energy < (cfg.sound_map.min_energy or 0.05) then break end
        local next_frontier = {}
        for _, node_label in ipairs(frontier) do
            add_sound_heat_to_node(node_label, energy)
            for _, dst in ipairs((cfg.graph.neighbors_by_label and cfg.graph.neighbors_by_label[node_label]) or {}) do
                if not visited[dst] then
                    visited[dst] = true
                    next_frontier[#next_frontier + 1] = dst
                end
            end
        end
        frontier = next_frontier
        if #frontier == 0 then break end
    end

    state.sound.last_inject_time = t
    state.sound.last_inject_kind = kind or "none"
    state.sound.last_inject_node_label = tostring(label)
    state.sound.bias_pending = true
    state.sound.bias_source_label = tostring(label)
    state.sound.bias_source_kind = kind or "none"
    refresh_sound_heat_summary()
    return true
end

function inject_sound_heat_from_noise(player_pos, noise_tier, noise_strength, fire_pressed)
    if not cfg.sound_map.enabled then return end
    local source_label = state.graph.nearest_player_node_label
    if not source_label or source_label == "none" then
        source_label = find_nearest_graph_node_label(player_pos)
    end
    if not source_label or source_label == "none" then return end

    local base = 0.0
    local kind = "none"
    local strength = clamp01(noise_strength or 1.0)
    if fire_pressed then
        kind = "fire"
        base = (cfg.sound_map.inject_fire or 1.90) * math.max(0.35, strength)
    elseif noise_tier == "hard" then
        kind = "hard"
        base = (cfg.sound_map.inject_hard or 1.20) * math.max(0.25, strength)
    elseif noise_tier == "medium" then
        kind = "medium"
        base = (cfg.sound_map.inject_medium or 0.75) * math.max(0.25, strength)
    else
        return
    end

    inject_sound_heat_from_source(source_label, base, kind)
end

local set_planned_target

function choose_sound_heat_target(player_pos, player_go, mrx_pos, mode, force_override)
    if not cfg.sound_map.enabled then return false end
    if (not force_override) and (not state.sound.bias_pending) then return false end
    if (state.sound.peak_value or 0.0) < (cfg.sound_map.bias_min_energy or 0.35) then return false end

    local hot_label = state.sound.peak_label or "none"
    local hot_node = cfg.graph.node_by_label and cfg.graph.node_by_label[hot_label] or nil
    if not hot_node then return false end

    local candidates = build_search_zone_candidates_from_origin(hot_node, player_pos)
    local filtered = {}
    local max_zone_dist = cfg.sound_map.bias_zone_distance or 12.0
    for _, candidate in ipairs(candidates or {}) do
        if candidate.dist <= max_zone_dist then
            filtered[#filtered + 1] = candidate
        end
    end
    if #filtered == 0 then
        filtered = candidates or {}
    end
    if mode == "INVESTIGATE" then
        filtered = filter_investigate_zone_candidates(filtered)
    end

    local best = nil
    local best_score = -999999.0
    for _, candidate in ipairs(filtered or {}) do
        local zone = candidate.zone
        local point, label, idx = choose_next_search_anchor_point(zone, player_pos, player_go, mrx_pos, mode or state.behavior.mode)
        if point then
            local hd = dist_xz(point, hot_node)
            local md = mrx_pos and dist_xz(point, mrx_pos) or 0.0
            local pd = player_pos and dist_xz(point, player_pos) or 9999.0
            local score = 0.0
            score = score - (hd * 5.0)
            score = score - (md * 0.20)
            score = score + math.min(pd, 18.0) * 0.06
            if label == hot_label then
                score = score + 4.0
            end
            if candidate.dist then
                score = score - (candidate.dist * 0.35)
            end
            local ydiff = math.abs((point.y or hot_node.y or 0.0) - (hot_node.y or 0.0))
            score = score - (ydiff * 3.0)
            if score > best_score then
                best_score = score
                best = {
                    zone = zone,
                    point = point,
                    label = label,
                    idx = idx,
                }
            end
        end
    end

    if best then
        state.behavior.search_target_zone = best.zone.name or "zone"
        state.behavior.search_target_label = best.label or (best.zone.name or "zone")
        state.behavior.search_target_point = best.point
        state.behavior.search_target_index = best.idx or 0
        state.behavior.search_target_strategy = (mode == "INVESTIGATE") and "investigate-heat" or "search-heat"
        set_planned_target(
            state.behavior.search_target_zone,
            state.behavior.search_target_label,
            state.behavior.search_target_point,
            state.behavior.search_target_index,
            state.behavior.search_target_strategy
        )
        if not force_override then
            state.sound.bias_pending = false
        end
        state.sound.last_bias_use_time = now()
        return true
    end

    return false
end

local function choose_investigate_zone_target(player_pos, player_go, mrx_pos, candidates)
    if not cfg.behavior.search_anchor.investigate_zone_hijack then return false end
    if not candidates or #candidates == 0 then return false end

    local t = now()
    local reuse_cooldown = cfg.behavior.search_anchor.investigate_zone_reuse_cooldown or 12.0
    local forward_weight = cfg.behavior.search_anchor.investigate_zone_center_forward_weight or 0.75
    local too_close = cfg.behavior.search_anchor.investigate_zone_too_close or 7.5

    local function gather(list, allow_reuse)
        local weighted = {}
        for _, candidate in ipairs(list or {}) do
            local zone = candidate.zone
            local recently_used = (zone.name == (state.behavior.investigate_zone_last_name or "none"))
                and ((t - (state.behavior.investigate_zone_last_time or -999.0)) < reuse_cooldown)

            if allow_reuse or not recently_used then
                local point, label, idx = choose_next_search_anchor_point(zone, player_pos, player_go, mrx_pos, "INVESTIGATE", true)
                if point then
                    local center_dot = dot_from_player_forward(player_go, player_pos, zone.center) or 0.0
                    local dist_weight = math.max(1.0, 32.0 - candidate.dist)
                    if candidate.dist < too_close then
                        dist_weight = dist_weight * 0.55
                    end
                    local score = dist_weight * (1.0 + math.max(0.0, center_dot) * forward_weight)
                    table.insert(weighted, {
                        zone = zone,
                        point = point,
                        label = label,
                        index = idx,
                        dist = candidate.dist,
                        center_dot = center_dot,
                        weight = score,
                    })
                end
            end
        end
        table.sort(weighted, function(a, b) return a.weight > b.weight end)
        return weighted
    end

    local weighted = gather(candidates, false)
    if #weighted == 0 then
        weighted = gather(candidates, true)
    end
    if #weighted == 0 then
        return false
    end

    local picked = choose_weighted_zone_candidate(weighted, cfg.behavior.search_anchor.investigate_zone_random_top_n or 3)
    if not picked then
        return false
    end

    if picked.zone and picked.index and picked.zone.name then
        local total = #(picked.zone.points or {})
        if total > 0 then
            state.behavior.search_next_index_by_zone[picked.zone.name] = (picked.index % total) + 1
        end
    end

    state.behavior.search_target_zone = picked.zone.name or "zone"
    state.behavior.search_target_label = picked.label or (picked.zone.name or "zone")
    state.behavior.search_target_point = picked.point
    state.behavior.search_target_index = picked.index or 0
    state.behavior.search_target_strategy = "investigate-zone"
    set_planned_target(
        state.behavior.search_target_zone,
        state.behavior.search_target_label,
        state.behavior.search_target_point,
        state.behavior.search_target_index,
        state.behavior.search_target_strategy
    )
    return true
end


local function clear_planned_target()
    state.behavior.planned_target_label = "none"
    state.behavior.planned_target_zone = "none"
    state.behavior.planned_target_point = nil
    state.behavior.planned_target_index = 0
    state.behavior.planned_target_strategy = "none"
    state.behavior.planned_target_selected_at = -999.0
end

set_planned_target = function(zone_name, label, point, index, strategy)
    state.behavior.planned_target_zone = zone_name or "none"
    state.behavior.planned_target_label = label or zone_name or "none"
    state.behavior.planned_target_point = point and copy_point(point) or nil
    state.behavior.planned_target_index = index or 0
    state.behavior.planned_target_strategy = strategy or "none"
    state.behavior.planned_target_selected_at = now()
end

local function clear_committed_target()
    state.behavior.committed_target_label = "none"
    state.behavior.committed_target_zone = "none"
    state.behavior.committed_target_point = nil
    state.behavior.committed_target_index = 0
    state.behavior.committed_target_strategy = "none"
    state.behavior.committed_target_armed_at = -999.0
    state.behavior.committed_target_fire_at = -999.0
    state.behavior.committed_target_reason = "none"
end

local function commit_planned_target(reason)
    state.behavior.committed_target_zone = state.behavior.planned_target_zone or "none"
    state.behavior.committed_target_label = state.behavior.planned_target_label or "none"
    state.behavior.committed_target_point = state.behavior.planned_target_point and copy_point(state.behavior.planned_target_point) or nil
    state.behavior.committed_target_index = state.behavior.planned_target_index or 0
    state.behavior.committed_target_strategy = state.behavior.planned_target_strategy or "none"
    state.behavior.committed_target_armed_at = now()
    state.behavior.committed_target_fire_at = state.behavior.pending_anchor_time or -999.0
    state.behavior.committed_target_reason = reason or state.behavior.pending_anchor_reason or "anchor refresh"
end

function should_use_idle_global_roam(mode)
    if mode ~= "SEARCH" then return false end
    -- Keep the first post-release search anchored to the release origin.
    if state.behavior.release_search_pending then return false end
    local t = now()
    if (state.sound.peak_value or 0.0) >= (cfg.sound_map.bias_min_energy or 0.35) then return false end
    if state.sound.bias_pending then return false end
    if (t - (state.behavior.last_heard_time or -999.0)) < (cfg.behavior.idle_global_roam_min_unseen_time or 2.5) then return false end
    if (t - (state.behavior.last_seen_time or -999.0)) < (cfg.behavior.idle_global_roam_min_unseen_time or 2.5) then return false end
    return true
end

function choose_global_idle_roam_target(player_pos, player_go, mrx_pos, mode)
    if not player_pos or not mrx_pos then return false end
    local min_pd = cfg.behavior.idle_global_roam_player_min_distance or 16.0
    local valid = {}
    for _, node in ipairs(cfg.graph.nodes or {}) do
        if node and validate_search_anchor_point(node, player_pos, player_go, mrx_pos, mode or "SEARCH") then
            local pd = dist_xz(player_pos, node)
            local md = dist_xz(mrx_pos, node)
            if pd >= min_pd and md >= (cfg.behavior.search_anchor.min_mrx_distance or 4.0) and tostring(node.label or "") ~= tostring(state.behavior.search_last_destination or "") then
                valid[#valid + 1] = { point = {x=node.x,y=node.y,z=node.z}, label = tostring(node.label or "roam"), pd = pd, md = md }
            end
        end
    end
    if #valid == 0 then return false end
    for i = 1, #valid do
        local v = valid[i]
        v.sort_score = (v.pd * 1.0) + (v.md * 0.15) + (math.random() * 6.0)
    end
    table.sort(valid, function(a, b)
        local as = tonumber(a.sort_score) or 0.0
        local bs = tonumber(b.sort_score) or 0.0
        if as == bs then
            return tostring(a.label or "") < tostring(b.label or "")
        end
        return as > bs
    end)
    local top_n = math.max(1, math.min(#valid, 24))
    local picked = valid[math.random(1, top_n)]
    if not picked then return false end
    state.behavior.search_target_zone = "Global Roam"
    state.behavior.search_target_label = picked.label
    state.behavior.search_target_point = picked.point
    state.behavior.search_target_index = 0
    state.behavior.search_target_strategy = "search-global-roam"
    set_planned_target(
        state.behavior.search_target_zone,
        state.behavior.search_target_label,
        state.behavior.search_target_point,
        state.behavior.search_target_index,
        state.behavior.search_target_strategy
    )
    return true
end

function choose_commit_target_rescue_point(player_pos, player_go, mrx_pos, target)
    if not player_pos or not mrx_pos or not target then return nil, "missing positions" end
    local best = nil
    local best_score = -999999.0
    for _, node in ipairs(cfg.graph.nodes or {}) do
        if node and is_hidden_relocate_destination_safe(mrx_pos, node) then
            local td = dist_xz(target, node)
            local pd = dist_xz(player_pos, node)
            local md = dist_xz(mrx_pos, node)
            if td <= 5.5 and pd >= (cfg.behavior.search_anchor.min_player_distance or 8.0) and md >= 1.0 and md <= 12.0 and (not search_anchor_point_visible(player_go, player_pos, node)) then
                local score = 100.0 - (td * 10.0) - (md * 0.5) + math.min(pd, 18.0) * 0.2
                if score > best_score then
                    best = { x = node.x, y = node.y, z = node.z, label = tostring(node.label or "rescue") }
                    best_score = score
                end
            end
        end
    end
    if best then return best, "ok" end
    return nil, "no target-near rescue"
end

local function choose_search_target(player_pos, player_go, mrx_pos, mode)
    if not player_pos or not cfg.cutoff.zones or #cfg.cutoff.zones == 0 then
        state.behavior.search_target_label = "none"
        state.behavior.search_target_zone = "none"
        state.behavior.search_target_point = nil
        state.behavior.search_target_index = 0
        state.behavior.search_target_strategy = "none"
        clear_planned_target()
        return
    end

    if choose_sound_heat_target(player_pos, player_go, mrx_pos, mode) then
        return
    end

    local use_release_origin = (mode == "SEARCH") and (state.behavior.release_search_pending == true) and state.behavior.release_search_origin

    if (not use_release_origin) and should_use_idle_global_roam(mode) and choose_global_idle_roam_target(player_pos, player_go, mrx_pos, mode) then
        return
    end

    local all_candidates = build_search_zone_candidates_from_origin(use_release_origin, player_pos)
    local candidates = all_candidates
    if mode == "INVESTIGATE" then
        candidates = filter_investigate_zone_candidates(all_candidates)
        if choose_investigate_zone_target(player_pos, player_go, mrx_pos, candidates) then
            return
        end
    end

    local fallback_zone = candidates[1] and candidates[1].zone or (all_candidates[1] and all_candidates[1].zone or nil)
    local fallback_label = fallback_zone and (fallback_zone.name or "zone") or "none"

    for _, candidate in ipairs(candidates) do
        local zone = candidate.zone
        local point, label, idx = choose_next_search_anchor_point(zone, player_pos, player_go, mrx_pos, mode or state.behavior.mode)
        if point then
            state.behavior.search_target_zone = zone.name or "zone"
            state.behavior.search_target_label = label or (zone.name or "zone")
            state.behavior.search_target_point = point
            state.behavior.search_target_index = idx or 0
            state.behavior.search_target_strategy = (mode == "INVESTIGATE") and "investigate-fallback" or "search-anchor"
            set_planned_target(
                state.behavior.search_target_zone,
                state.behavior.search_target_label,
                state.behavior.search_target_point,
                state.behavior.search_target_index,
                state.behavior.search_target_strategy
            )
            return
        end
    end

    state.behavior.search_target_zone = fallback_label
    state.behavior.search_target_label = fallback_label
    state.behavior.search_target_point = nil
    state.behavior.search_target_index = 0
    state.behavior.search_target_strategy = "none"
    clear_planned_target()
end

local function update_escalation()
    local t = now()
    local unseen_for = t - (state.behavior.unseen_since or t)
    local level = 0
    if unseen_for >= cfg.behavior.escalation_step_3 then
        level = 3
    elseif unseen_for >= cfg.behavior.escalation_step_2 then
        level = 2
    elseif unseen_for >= cfg.behavior.escalation_step_1 then
        level = 1
    end
    state.behavior.escalation_level = level
end


local function clear_pending_anchor()
    state.behavior.pending_anchor_time = -999.0
    state.behavior.pending_anchor_delay = 0.0
    state.behavior.pending_anchor_mode = "none"
    state.behavior.pending_anchor_reason = "none"
    state.behavior.pending_anchor_noise = "none"
    state.behavior.offscreen_stall_time = 0.0
    state.behavior.offscreen_stall_last_pos = nil
    state.behavior.hidden_forward_advance_tries = 0
    clear_committed_target()
    clear_planned_target()
end

mark_last_player_known = function(player_pos)
    if not player_pos then return end
    state.behavior.last_player_known_pos = copy_point(player_pos)
    state.behavior.last_player_known_time = now()
end

local function maybe_decay_investigate(player_pos, visual, noise_tier, mrx_pos)
    local t = now()
    local pending_investigate = (state.behavior.pending_anchor_mode == "INVESTIGATE") and ((state.behavior.pending_anchor_time or -999.0) > t)
    local active_investigate = (state.behavior.mode == "INVESTIGATE")
        or ((state.behavior.investigate_until or -999.0) > t)
        or ((state.behavior.investigate_commit_until or -999.0) > t)

    if (not pending_investigate) and (not active_investigate) then return false end
    if visual or (noise_tier ~= "none") then return false end

    local last_known_age = t - (state.behavior.last_player_known_time or -999.0)
    local no_stimulus = cfg.behavior.investigate_decay_no_stimulus or 10.0
    if last_known_age < no_stimulus then return false end

    local decay_disp = cfg.behavior.investigate_decay_displacement or 14.0
    local far_disp = cfg.behavior.investigate_decay_far_displacement or 22.0
    local displacement = (player_pos and state.behavior.last_player_known_pos) and dist_xz(player_pos, state.behavior.last_player_known_pos) or 0.0
    local player_mrx_dist = (player_pos and mrx_pos) and dist_xz(player_pos, mrx_pos) or 9999.0
    local decay_reason = nil

    if displacement >= far_disp then
        decay_reason = string.format("displaced %.1fm", displacement)
    elseif pending_investigate and last_known_age >= (cfg.behavior.investigate_decay_pending_max or 18.0) and displacement >= decay_disp then
        decay_reason = string.format("stale pending %.1fs", last_known_age)
    elseif active_investigate and last_known_age >= (cfg.behavior.investigate_decay_pending_max or 18.0) and displacement >= math.max(8.0, decay_disp * 0.75) then
        decay_reason = string.format("stale investigate %.1fs", last_known_age)
    elseif active_investigate and player_mrx_dist > math.max(14.0, decay_disp) and last_known_age >= (cfg.behavior.investigate_decay_pending_max or 18.0) then
        decay_reason = string.format("lost track %.1fs", last_known_age)
    end

    if not decay_reason then return false end

    clear_pending_anchor()
    clear_investigate_commit()
    state.behavior.investigate_until = -999.0
    state.behavior.linger_until = -999.0
    state.behavior.search_target_label = "none"
    state.behavior.search_target_zone = "none"
    state.behavior.search_target_point = nil
    state.behavior.investigate_decay_reason = decay_reason
    state.behavior.investigate_decay_last_time = t
    state.behavior.last_stimulus = "none"
    set_behavior_mode("SEARCH", "investigate decay")
    state.behavior.search_refresh_time = t + (cfg.behavior.investigate_decay_search_cooldown or 1.25)
    state.behavior.anchor_force_refresh = false
    return true
end

local function get_anchor_travel_bonus(player_pos, mrx_pos, target, mode, strategy)
    if not target then return 0.0 end

    local td = cfg.behavior.travel_delay
    local d = player_pos and dist_xz(player_pos, target) or 0.0
    local floor_bonus = 0.0

    if mode == "INVESTIGATE" then
        if mrx_pos then
            d = math.max(d, dist_xz(mrx_pos, target))
        end

        if strategy == "investigate-zone" then
            td = cfg.behavior.investigate_zone_travel_delay or cfg.behavior.investigate_travel_delay or td
            if mrx_pos and math.abs((target.y or 0.0) - (mrx_pos.y or 0.0)) >= 2.0 then
                floor_bonus = rand_range(td.floor_change_bonus_min or 0.0, td.floor_change_bonus_max or 0.0)
            end
        else
            td = cfg.behavior.investigate_travel_delay or td
        end
    end

    local bonus = 0.0
    if d >= td.far_dist then
        bonus = rand_range(td.far_bonus_min, td.far_bonus_max)
    elseif d >= td.mid_dist then
        bonus = rand_range(td.mid_bonus_min, td.mid_bonus_max)
    else
        bonus = rand_range(td.near_bonus_min, td.near_bonus_max)
    end

    return bonus + floor_bonus
end

local function get_anchor_base_delay(mode, noise_kind, strategy)
    if mode == "INVESTIGATE" then
        if strategy == "investigate-zone" then
            if noise_kind == "hard" or noise_kind == "fire" then
                return rand_range(
                    cfg.behavior.hard_investigate_zone_repath_min or cfg.behavior.hard_investigate_repath_min,
                    cfg.behavior.hard_investigate_zone_repath_max or cfg.behavior.hard_investigate_repath_max
                )
            end
            return rand_range(
                cfg.behavior.investigate_zone_repath_min or cfg.behavior.investigate_repath_min,
                cfg.behavior.investigate_zone_repath_max or cfg.behavior.investigate_repath_max
            )
        end
        if noise_kind == "hard" or noise_kind == "fire" then
            return rand_range(cfg.behavior.hard_investigate_repath_min, cfg.behavior.hard_investigate_repath_max)
        end
        return rand_range(cfg.behavior.investigate_repath_min, cfg.behavior.investigate_repath_max)
    end

    if mode == "CHASE" then
        return rand_range(cfg.behavior.chase_recovery_repath_min, cfg.behavior.chase_recovery_repath_max)
    end

    local min_t = cfg.behavior.search_repath_min
    local max_t = cfg.behavior.search_repath_max
    if strategy == "search-global-roam" then
        return rand_range(cfg.behavior.idle_global_roam_repath_min or 10.0, cfg.behavior.idle_global_roam_repath_max or 18.0)
    end
    if state.behavior.escalation_level >= 1 then
        min_t = min_t - 1.0
        max_t = max_t - 1.0
    end
    if state.behavior.escalation_level >= 2 then
        min_t = min_t - 1.0
        max_t = max_t - 1.0
    end
    if state.behavior.escalation_level >= 3 then
        min_t = min_t - 1.0
        max_t = max_t - 1.5
    end
    min_t = math.max(4.0, min_t)
    max_t = math.max(min_t, max_t)
    return rand_range(min_t, max_t)
end

local function get_anchor_linger(mode, strategy)
    if mode == "INVESTIGATE" then
        if strategy == "investigate-zone" then
            return rand_range(
                cfg.behavior.investigate_zone_move_linger_min or cfg.behavior.investigate_move_linger_min or cfg.behavior.investigate_linger_min,
                cfg.behavior.investigate_zone_move_linger_max or cfg.behavior.investigate_move_linger_max or cfg.behavior.investigate_linger_max
            )
        end
        return rand_range(
            cfg.behavior.investigate_move_linger_min or cfg.behavior.investigate_linger_min,
            cfg.behavior.investigate_move_linger_max or cfg.behavior.investigate_linger_max
        )
    end
    if strategy == "search-global-roam" then
        return rand_range(cfg.behavior.idle_global_roam_linger_min or 2.0, cfg.behavior.idle_global_roam_linger_max or 4.0)
    end
    return rand_range(cfg.behavior.search_linger_min, cfg.behavior.search_linger_max)
end

local function should_hold_near_player_anchor_move(player_pos)
    local live_pos = state.mrx_draw_pos or state.mrx_pos
    if not player_pos or not live_pos then return false, 9999.0, 0.0 end

    local radius = cfg.behavior.search_anchor.near_player_move_lock_radius or 14.0
    local speed_gate = cfg.behavior.search_anchor.near_player_move_lock_speed or 0.35
    local d = dist_xz(player_pos, live_pos)
    local speed = state.mrx_speed_xz or 0.0

    if d <= radius and speed >= speed_gate then
        return true, d, speed
    end

    return false, d, speed
end

local function hold_committed_target_as_search_target(mode)
    state.behavior.search_target_zone = state.behavior.committed_target_zone or "none"
    state.behavior.search_target_label = state.behavior.committed_target_label or "none"
    state.behavior.search_target_point = state.behavior.committed_target_point and copy_point(state.behavior.committed_target_point) or nil
    state.behavior.search_target_index = state.behavior.committed_target_index or 0
    state.behavior.search_target_strategy = state.behavior.committed_target_strategy or ((mode == "INVESTIGATE") and "investigate-lock" or "search-lock")
    if state.behavior.search_target_point then
        set_planned_target(
            state.behavior.search_target_zone,
            state.behavior.search_target_label,
            state.behavior.search_target_point,
            state.behavior.search_target_index,
            state.behavior.search_target_strategy
        )
        return true
    end
    return false
end

local function should_hold_committed_replan(mode, player_pos, player_go, mrx_pos)
    local t = now()
    if not state.behavior.committed_target_point then return false end
    if (state.behavior.pending_anchor_time or -999.0) > t then return false end
    local cooldown = cfg.behavior.commit_replan_cooldown or 1.25
    local age = t - (state.behavior.committed_target_armed_at or -999.0)
    if age > cooldown then return false end

    local ok = false
    if mode == "INVESTIGATE" then
        ok = select(1, validate_investigate_commit_point(state.behavior.committed_target_point, player_pos, player_go, mrx_pos))
    else
        ok = select(1, validate_search_commit_point(state.behavior.committed_target_point, player_pos, player_go, mrx_pos))
    end
    if not ok then return false end
    return hold_committed_target_as_search_target(mode)
end

local function should_hold_planned_replan(mode, player_pos, player_go, mrx_pos)
    local t = now()
    if state.behavior.committed_target_point then return false end
    if not state.behavior.planned_target_point then return false end
    local cooldown = cfg.behavior.planned_replan_cooldown or 0.75
    local age = t - (state.behavior.planned_target_selected_at or -999.0)
    if age > cooldown then return false end

    local ok = false
    if mode == "INVESTIGATE" then
        ok = select(1, validate_investigate_commit_point(state.behavior.planned_target_point, player_pos, player_go, mrx_pos))
    else
        ok = select(1, validate_search_commit_point(state.behavior.planned_target_point, player_pos, player_go, mrx_pos))
    end
    if not ok then return false end

    state.behavior.search_target_zone = state.behavior.planned_target_zone or "none"
    state.behavior.search_target_label = state.behavior.planned_target_label or "none"
    state.behavior.search_target_point = state.behavior.planned_target_point and copy_point(state.behavior.planned_target_point) or nil
    state.behavior.search_target_index = state.behavior.planned_target_index or 0
    state.behavior.search_target_strategy = state.behavior.planned_target_strategy or ((mode == "INVESTIGATE") and "investigate-lock" or "search-lock")
    return state.behavior.search_target_point ~= nil
end

function reset_offscreen_door_stall_tracking()
    state.behavior.offscreen_stall_time = 0.0
    state.behavior.offscreen_stall_last_pos = nil
end

function update_offscreen_door_stall_tracking(mrx_pos)
    if not mrx_pos then
        reset_offscreen_door_stall_tracking()
        return
    end
    if state.behavior.offscreen_stall_last_pos then
        local moved = dist_xz(mrx_pos, state.behavior.offscreen_stall_last_pos)
        if moved <= 0.12 then
            state.behavior.offscreen_stall_time = (state.behavior.offscreen_stall_time or 0.0) + (1.0/60.0)
        else
            state.behavior.offscreen_stall_time = 0.0
            state.behavior.hidden_forward_advance_tries = 0
        end
    else
        state.behavior.offscreen_stall_time = 0.0
    end
    state.behavior.offscreen_stall_last_pos = copy_point(mrx_pos)
end

function is_hidden_relocate_destination_safe(current_pos, dest)
    if not dest then return false end
    if type(dest.y) ~= "number" then return false end
    if dest.y < (cfg.movement.hidden_relocate_min_safe_y or -20.0) then
        return false
    end
    if current_pos and type(current_pos.y) == "number" then
        if math.abs((dest.y or 0.0) - (current_pos.y or 0.0)) > (cfg.movement.hidden_relocate_verify_y or 3.50) then
            return false
        end
    end
    return true
end

local function make_step_toward(current_pos, target_pos, max_step)
    if not current_pos or not target_pos then return nil end
    local dx = (target_pos.x or 0.0) - (current_pos.x or 0.0)
    local dz = (target_pos.z or 0.0) - (current_pos.z or 0.0)
    local mag = math.sqrt(dx * dx + dz * dz)
    if mag <= 0.001 then return nil end
    if mag <= max_step then return copy_point(target_pos) end
    return {
        x = current_pos.x + (dx / mag) * max_step,
        y = current_pos.y,
        z = current_pos.z + (dz / mag) * max_step,
    }
end

local function relocate_hidden_mrx_checked(mrx, current_pos, dest, use_nodraw_prime)
    if not is_hidden_relocate_destination_safe(current_pos, dest) then
        return false, "unsafe destination"
    end
    local moved_ok = relocate_visible_mrx(mrx, dest, use_nodraw_prime)
    if not moved_ok then return false, "move failed" end
    local actual, actual_source = get_mrx_runtime_pos(mrx)
    local check_pos = actual or get_position(mrx) or dest
    if not is_hidden_relocate_destination_safe(dest, check_pos) then
        return false, "bad runtime y"
    end
    publish_live_mrx_pos(actual or dest, actual_source or state.mrx_pos_source or "transform")
    publish_live_mrx_draw_pos(mrx)
    return true, "ok"
end

function gather_hidden_forward_advance_candidates(mrx_pos, target_pos)
    local current_label = state.graph.nearest_mrx_node_label or "none"
    local neighbor_labels = cfg.graph.neighbors_by_label and cfg.graph.neighbors_by_label[current_label] or nil
    local candidates = {}

    if type(neighbor_labels) == "table" and #neighbor_labels > 0 then
        for _, lbl in ipairs(neighbor_labels) do
            local node = cfg.graph.node_by_label and cfg.graph.node_by_label[lbl] or nil
            if node then candidates[#candidates + 1] = node end
        end
    else
        for _, node in ipairs(cfg.graph.nodes or {}) do
            candidates[#candidates + 1] = node
        end
    end

    return candidates
end

function choose_hidden_forward_advance_point(player_pos, mrx_pos, target_pos)
    if not player_pos or not mrx_pos or not target_pos then return nil, "missing positions" end

    local heading_x = (target_pos.x or 0.0) - (mrx_pos.x or 0.0)
    local heading_z = (target_pos.z or 0.0) - (mrx_pos.z or 0.0)
    local heading_mag = math.sqrt(heading_x * heading_x + heading_z * heading_z)
    if heading_mag <= 0.001 then return nil, "no heading" end
    heading_x = heading_x / heading_mag
    heading_z = heading_z / heading_mag

    local best = nil
    local best_score = -999999.0
    local max_dist = cfg.movement.hidden_forward_advance_max_distance or 4.0
    local min_step = cfg.movement.hidden_forward_advance_min_step or 0.75
    local min_dot = cfg.movement.hidden_forward_advance_forward_dot or 0.25

    for _, node in ipairs(gather_hidden_forward_advance_candidates(mrx_pos, target_pos)) do
        if node and is_hidden_relocate_destination_safe(mrx_pos, node) then
            local adv_dx = (node.x or 0.0) - (mrx_pos.x or 0.0)
            local adv_dz = (node.z or 0.0) - (mrx_pos.z or 0.0)
            local adv_mag = math.sqrt(adv_dx * adv_dx + adv_dz * adv_dz)
            if adv_mag >= min_step and adv_mag <= max_dist then
                local dot = ((adv_dx / adv_mag) * heading_x) + ((adv_dz / adv_mag) * heading_z)
                if dot >= min_dot and dist_xz(player_pos, node) >= (cfg.movement.hidden_forward_advance_min_player_dist or 12.0) then
                    local target_dx = (target_pos.x or 0.0) - (node.x or 0.0)
                    local target_dz = (target_pos.z or 0.0) - (node.z or 0.0)
                    local dist_to_target = math.sqrt(target_dx * target_dx + target_dz * target_dz)
                    local score = (dot * 100.0) - (dist_to_target * 2.0) - adv_mag
                    if score > best_score then
                        best = {
                            x = node.x,
                            y = node.y,
                            z = node.z,
                            label = tostring(node.label or "advance"),
                            dot = dot,
                            dist_to_target = dist_to_target,
                            advance_dist = adv_mag,
                        }
                        best_score = score
                    end
                end
            end
        end
    end

    if best then return best, "ok" end
    return nil, "no forward graph point"
end

function try_hidden_forward_graph_advance(mrx, player_pos, mrx_pos)
    if not cfg.movement.hidden_forward_advance_enabled then return false end
    if not mrx or not player_pos or not mrx_pos then return false end
    if state.movement.visible then return false end
    if not state.movement.offscreen_active then return false end
    if dist_xz(player_pos, mrx_pos) < (cfg.movement.hidden_forward_advance_min_player_dist or 12.0) then return false end
    if state.behavior.mode ~= "SEARCH" and state.behavior.mode ~= "INVESTIGATE" then return false end
    if (state.behavior.offscreen_stall_time or 0.0) < (cfg.movement.hidden_forward_advance_stall_time or 0.90) then return false end
    if (state.mrx_speed_xz or 0.0) > (cfg.movement.hidden_forward_advance_speed_threshold or 0.05) then return false end
    if (state.behavior.hidden_forward_advance_tries or 0) >= (cfg.movement.hidden_forward_advance_max_tries or 2) then return false end
    if (now() - (state.behavior.hidden_forward_advance_last_time or -999.0)) < (cfg.movement.hidden_forward_advance_cooldown or 1.25) then return false end

    local target = state.behavior.committed_target_point or state.behavior.search_target_point or state.behavior.planned_target_point
    local target_label = state.behavior.committed_target_label or state.behavior.search_target_label or state.behavior.planned_target_label or "none"
    if not target then return false end

    local dest, reason = choose_hidden_forward_advance_point(player_pos, mrx_pos, target)
    if not dest then
        state.behavior.search_last_result = "hidden advance blocked: " .. tostring(reason or "failed")
        return false
    end

    local moved_ok, move_reason = relocate_hidden_mrx_checked(mrx, mrx_pos, dest, true)
    if moved_ok then
        state.behavior.hidden_forward_advance_tries = (state.behavior.hidden_forward_advance_tries or 0) + 1
        state.behavior.hidden_forward_advance_last_time = now()
        state.behavior.offscreen_stall_time = 0.0
        state.behavior.offscreen_stall_last_pos = copy_point(dest)
        set_cutoff_suspend(1.50, "post hidden forward advance")
        state.behavior.search_last_result = string.format("hidden advance -> %s toward %s (%d/%d)", tostring(dest.label or "advance"), tostring(target_label or "none"), state.behavior.hidden_forward_advance_tries, cfg.movement.hidden_forward_advance_max_tries or 2)
        return true
    end

    state.behavior.search_last_result = "hidden advance blocked: " .. tostring(move_reason or "failed")
    return false
end

local function sustain_pending_investigate_from_noise(player_pos, player_go, mrx_pos, reason_label)
    local t = now()
    local pending = (state.behavior.pending_anchor_time or -999.0) > t
    if not pending then return false end
    if state.behavior.pending_anchor_mode ~= "INVESTIGATE" and state.behavior.pending_anchor_mode ~= "SEARCH" then
        return false
    end
    local current_target = state.behavior.committed_target_point or state.behavior.search_target_point or state.behavior.planned_target_point
    if not current_target then return false end

    local tiny_extend = cfg.behavior.noise_repeat_hold_extend or 0.25
    state.behavior.investigate_until = math.max(state.behavior.investigate_until or -999.0, t + tiny_extend)
    state.behavior.linger_until = -999.0
    if state.behavior.pending_anchor_mode == "SEARCH" then
        state.behavior.pending_anchor_mode = "INVESTIGATE"
    end
    set_behavior_mode("INVESTIGATE", reason_label or "noise sustain")
    state.behavior.search_last_result = tostring(reason_label or "noise") .. " sustain existing travel"
    return true
end

function maybe_heat_override_pending_anchor(player_pos, player_go, mrx_pos, mode)
    if not cfg.sound_map.enabled or not cfg.sound_map.heat_timer_override_enabled then return false end
    local t = now()
    if (state.behavior.pending_anchor_time or -999.0) <= t then return false end
    if (state.behavior.pending_heat_override_count or 0) >= (cfg.sound_map.heat_timer_override_max_per_anchor or 1) then return false end
    if (t - (state.behavior.committed_target_armed_at or -999.0)) < (cfg.sound_map.heat_timer_override_min_age or 0.35) then return false end
    if (t - (state.sound.last_override_time or -999.0)) < (cfg.sound_map.heat_timer_override_cooldown or 0.75) then return false end
    local hot_label = state.sound.peak_label or "none"
    local hot_node = cfg.graph.node_by_label and cfg.graph.node_by_label[hot_label] or nil
    if not hot_node then return false end
    local source_kind = tostring(state.sound.bias_source_kind or "none")
    if source_kind ~= "fire" and source_kind ~= "hard" then return false end
    local min_peak = (cfg.sound_map.bias_min_energy or 0.35) * (cfg.sound_map.heat_timer_override_score_gain or 1.40)
    if source_kind == "hard" then
        min_peak = min_peak * 1.20
    end
    if (state.sound.peak_value or 0.0) < min_peak then return false end

    local current_target = state.behavior.committed_target_point or state.behavior.search_target_point or state.behavior.planned_target_point
    if current_target then
        local current_dist = dist_xz(current_target, hot_node)
        if current_dist <= (cfg.sound_map.bias_zone_distance or 12.0) * 0.55 then
            return false
        end
    end

    local prev_planned_label = state.behavior.planned_target_label or "none"
    local prev_committed_label = state.behavior.committed_target_label or "none"
    if not choose_sound_heat_target(player_pos, player_go, mrx_pos, mode or state.behavior.pending_anchor_mode or state.behavior.mode, true) then
        return false
    end

    local new_label = state.behavior.planned_target_label or "none"
    if new_label == "none" or (new_label == prev_planned_label and new_label == prev_committed_label) then
        return false
    end

    local old_fire = state.behavior.pending_anchor_time or t
    commit_planned_target("heat override")
    local hold_extend = cfg.sound_map.heat_timer_override_hold_extend or 0.05
    state.behavior.pending_anchor_time = old_fire + hold_extend
    state.behavior.pending_anchor_delay = math.max(0.0, state.behavior.pending_anchor_time - t)
    state.behavior.search_refresh_time = state.behavior.pending_anchor_time
    state.behavior.pending_heat_override_count = (state.behavior.pending_heat_override_count or 0) + 1
    state.behavior.pending_heat_override_last_time = t
    state.sound.last_override_time = t
    state.behavior.search_last_result = string.format("heat override %s -> %s", tostring(prev_committed_label ~= "none" and prev_committed_label or prev_planned_label), tostring(new_label))
    return true
end

arm_behavior_anchor_move = function(mode, player_pos, player_go, mrx_pos, reason, noise_kind)
    if mode == "INVESTIGATE" then
        local blocked, block_reason, block_remaining = should_block_investigate_rearm(player_pos, player_go, mrx_pos)
        if blocked then
            local retry = cfg.behavior.investigate_commit_rearm_retry or 0.35
            state.behavior.search_last_result = string.format(
                "investigate commit lock %s %.2f",
                tostring(block_reason or "hold"),
                block_remaining or 0.0
            )
            state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, now() + retry)
            state.behavior.anchor_force_refresh = false
            return false
        end
    end

    local held_existing = false
    if should_hold_committed_replan(mode, player_pos, player_go, mrx_pos) then
        held_existing = true
        state.behavior.search_last_result = string.format("%s commit lock", string.lower(mode or "SEARCH"))
    elseif should_hold_planned_replan(mode, player_pos, player_go, mrx_pos) then
        held_existing = true
        state.behavior.search_last_result = string.format("%s planned lock", string.lower(mode or "SEARCH"))
    else
        choose_search_target(player_pos, player_go, mrx_pos, mode)
    end

    if mode == "SEARCH" and state.behavior.release_search_pending and state.behavior.search_target_point then
        state.behavior.release_search_pending = false
        state.behavior.release_search_origin = nil
        state.behavior.search_target_strategy = "release-search-anchor"
        set_planned_target(
            state.behavior.search_target_zone,
            state.behavior.search_target_label,
            state.behavior.search_target_point,
            state.behavior.search_target_index,
            state.behavior.search_target_strategy
        )
    end

    if not state.behavior.search_target_point then
        clear_pending_anchor()
        state.behavior.search_last_result = "no valid search anchor"
        state.behavior.search_refresh_time = now() + 1.0
        return false
    end

    local strategy = state.behavior.search_target_strategy or "none"
    local base_delay = get_anchor_base_delay(mode, noise_kind or state.behavior.last_noise_kind, strategy)
    local travel_bonus = get_anchor_travel_bonus(player_pos, mrx_pos, state.behavior.search_target_point, mode, strategy)
    local total_delay = base_delay + travel_bonus
    local t = now()

    state.behavior.pending_anchor_time = t + total_delay
    state.behavior.pending_anchor_delay = total_delay
    state.behavior.pending_anchor_mode = mode or "SEARCH"
    state.behavior.pending_anchor_reason = reason or "anchor refresh"
    state.behavior.pending_anchor_noise = noise_kind or state.behavior.last_noise_kind or "none"
    commit_planned_target(reason or "anchor refresh")
    state.behavior.anchor_force_refresh = false
    state.behavior.search_refresh_time = t + total_delay
    state.behavior.pending_heat_override_count = 0
    state.behavior.pending_heat_override_last_time = -999.0
    state.behavior.search_last_result = string.format("%s anchor armed %.2fs", string.lower(mode or "SEARCH"), total_delay)
    return true
end

local function accelerate_pending_investigate_from_fire(player_pos, player_go, mrx_pos)
    local t = now()
    local pending_investigate = (state.behavior.pending_anchor_mode == "INVESTIGATE")
        and ((state.behavior.pending_anchor_time or -999.0) > t)

    local window = cfg.behavior.fire_burst_window or 2.25
    local prev_fire_t = state.behavior.last_fire_stimulus_time or -999.0
    local within_burst = (t - prev_fire_t) <= window
    local next_count = within_burst and ((state.behavior.fire_burst_count or 0) + 1) or 1
    local burst_count = math.min(cfg.behavior.fire_burst_max_stacks or 5, next_count)

    state.behavior.last_fire_stimulus_time = t
    state.behavior.fire_burst_count = burst_count
    state.behavior.last_heard_time = t
    mark_last_player_known(player_pos)
    state.behavior.last_stimulus = "fire"
    state.behavior.last_noise_kind = "fire"

    if not pending_investigate then
        return false
    end

    local current_target = state.behavior.committed_target_point
        or state.behavior.search_target_point
        or state.behavior.planned_target_point

    local keep_target = current_target ~= nil
    local pull_scale = math.max(1, burst_count - 1)
    local pull_in = (cfg.behavior.fire_pull_in_per_shot or 0.90) * pull_scale
    local min_lead = cfg.behavior.fire_min_pending_lead or 0.35
    local tiny_extend = cfg.behavior.fire_repeat_hold_extend or 0.25

    if keep_target then
        local old_fire = state.behavior.pending_anchor_time or t
        local new_fire = math.max(t + min_lead, old_fire - pull_in)
        if new_fire < old_fire then
            state.behavior.pending_anchor_time = new_fire
            state.behavior.pending_anchor_delay = math.max(min_lead, new_fire - t)
            state.behavior.search_refresh_time = new_fire
            state.behavior.committed_target_fire_at = new_fire
            state.behavior.committed_target_reason = string.format("fire burst x%d", burst_count)
            state.behavior.search_last_result = string.format(
                "fire burst accelerate %.2fs -> %.2fs x%d",
                math.max(0.0, old_fire - t),
                math.max(0.0, new_fire - t),
                burst_count
            )
        else
            state.behavior.search_last_result = string.format("fire burst hold %.2fs x%d", math.max(0.0, old_fire - t), burst_count)
        end
        state.behavior.investigate_until = math.max(state.behavior.investigate_until or -999.0, t + tiny_extend)
        state.behavior.linger_until = -999.0
        set_behavior_mode("INVESTIGATE", "fire burst")
        return true
    end

    if (t - (state.behavior.last_fire_retarget_time or -999.0)) >= (cfg.behavior.fire_retarget_cooldown or 0.60) then
        state.behavior.last_fire_retarget_time = t
        clear_pending_anchor()
        state.behavior.search_target_label = "none"
        state.behavior.search_target_zone = "none"
        state.behavior.search_target_point = nil
        state.behavior.search_refresh_time = -999.0
        state.behavior.anchor_force_refresh = true
        state.behavior.investigate_until = math.max(state.behavior.investigate_until or -999.0, t + tiny_extend)
        set_behavior_mode("INVESTIGATE", "fire retarget")
        arm_behavior_anchor_move("INVESTIGATE", player_pos, player_go, mrx_pos, "fire burst retarget", "fire")
        return true
    end

    state.behavior.investigate_until = math.max(state.behavior.investigate_until or -999.0, t + tiny_extend)
    state.behavior.linger_until = -999.0
    set_behavior_mode("INVESTIGATE", "fire burst")
    return true
end

local function update_behavior(player_pos, player_go, mrx_pos)
    if state.player_in_safe_room or state.stashed or state.hidden then
        clear_investigate_commit()
        set_behavior_mode("SAFE", "safe room / stashed")
        state.behavior.noise_tier = "none"
        state.behavior.noise_strength = 0.0
        state.behavior.last_noise_kind = "none"
        clear_pending_anchor()
        return
    end

    if state.waiting_release then
        clear_investigate_commit()
        set_behavior_mode("RELEASE_PRIMED", "waiting release")
        state.behavior.noise_tier = "none"
        state.behavior.noise_strength = 0.0
        state.behavior.last_noise_kind = "none"
        clear_pending_anchor()
        return
    end

    local t = now()
    if (state.behavior.investigate_commit_until or -999.0) > 0.0 and t >= (state.behavior.investigate_commit_until or -999.0) then
        clear_investigate_commit()
    end
    local fire_pressed = poll_fire_proxy()
    local in_reposition_grace = t < (state.behavior.reposition_grace_until or -999.0)
    local commit_remaining = math.max(0.0, (state.behavior.investigate_commit_until or -999.0) - t)
    local in_investigate_commit = commit_remaining > 0.0 and ((state.behavior.mode == "INVESTIGATE") or ((state.behavior.investigate_until or -999.0) > t))
    local pos_source = state.mrx_pos_source or "none"
    local strong_source = is_strong_mrx_pos_source(pos_source)
    local player_mrx_dist = player_pos and mrx_pos and dist_xz(player_pos, mrx_pos) or 9999.0

    if not strong_source and (state.behavior.mode == "CHASE" or state.behavior.mode == "CUTOFF_ARMED") then
        clear_pending_anchor()
        clear_investigate_commit()
        clear_chase_stall_tracking()
        reset_cutoff_tracking("await live ctx")
        set_behavior_mode("SEARCH", "await live ctx")
    end

    local visual = probable_visual_contact(player_go, player_pos, mrx_pos)
    if in_reposition_grace and player_mrx_dist > 6.0 then
        visual = false
    end
    if visual and not can_visual_chase_from_source(pos_source, player_mrx_dist, in_reposition_grace) then
        visual = false
    end
    decay_sound_heatmap()

    local noise_tier, noise_strength, noise_dist = classify_noise(player_pos, mrx_pos, fire_pressed)
    if in_reposition_grace and noise_tier ~= "none" and noise_dist > 10.0 then
        noise_tier = "none"
        noise_strength = 0.0
    end

    state.behavior.noise_tier = noise_tier
    state.behavior.noise_strength = noise_strength
    state.behavior.last_noise_kind = fire_pressed and "fire" or noise_tier
    inject_sound_heat_from_noise(player_pos, noise_tier, noise_strength, fire_pressed)

    if visual then
        local visual_dist = dist_xz(player_pos, mrx_pos)
        local immediate_chase = visual_dist <= (cfg.behavior.chase_force_distance or 7.5)
        local confirm_needed = cfg.behavior.chase_confirm_time or 0.60

        if in_investigate_commit then
            immediate_chase = visual_dist <= (cfg.behavior.investigate_commit_force_distance or 4.25)
            confirm_needed = math.max(confirm_needed, cfg.behavior.investigate_commit_visual_confirm or 1.10)
        end

        if not immediate_chase then
            if (state.behavior.visual_confirm_since or -999.0) < 0.0 then
                state.behavior.visual_confirm_since = t
            end

            local confirm_elapsed = t - (state.behavior.visual_confirm_since or t)
            if confirm_elapsed < confirm_needed then
                state.behavior.last_seen_time = t
                state.behavior.unseen_since = t
                mark_last_player_known(player_pos)
                state.behavior.last_stimulus = in_investigate_commit and "investigate visual" or "visual confirm"
                state.behavior.investigate_until = math.max(state.behavior.investigate_until or -999.0, t + math.max(1.5, commit_remaining))
                set_behavior_mode("INVESTIGATE", string.format("%s %.1fm", in_investigate_commit and "investigate commit" or "visual confirm", visual_dist))
                if (not in_investigate_commit)
                    and state.behavior.pending_anchor_time < 0.0
                    and state.behavior.linger_until <= t
                    and ((state.behavior.search_refresh_time or -999.0) < 0.0 or t >= (state.behavior.search_refresh_time or -999.0)) then
                    arm_behavior_anchor_move("INVESTIGATE", player_pos, player_go, mrx_pos, "visual confirm", "visual")
                end
                return
            end
        end

        clear_investigate_commit()
        state.behavior.visual_confirm_since = t
        state.behavior.last_seen_time = t
        state.behavior.unseen_since = t
        mark_last_player_known(player_pos)
        state.behavior.chase_until = t + cfg.behavior.alert_hold
        state.behavior.last_stimulus = "sight"
        clear_pending_anchor()
        state.behavior.search_target_label = "none"
        state.behavior.search_target_zone = "none"
        state.behavior.search_target_point = nil
        state.behavior.linger_until = -999.0
        clear_chase_stall_tracking()
        set_behavior_mode("CHASE", string.format("visual %.1fm", visual_dist))
        return
    else
        state.behavior.visual_confirm_since = -999.0
    end

    update_escalation()

    if noise_tier == "hard" then
        state.behavior.last_heard_time = t
        mark_last_player_known(player_pos)
        state.behavior.last_stimulus = fire_pressed and "fire" or "hard noise"
        state.behavior.linger_until = -999.0
        local ydiff = (player_pos and mrx_pos) and math.abs((player_pos.y or 0.0) - (mrx_pos.y or 0.0)) or 0.0
        local can_floor_chase = ydiff <= (cfg.behavior.cross_floor_chase_y or 2.5)
        if noise_dist <= cfg.behavior.rear_hearing_radius and can_floor_chase then
            state.behavior.chase_until = t + cfg.behavior.alert_hold
            clear_pending_anchor()
            state.behavior.search_target_label = "none"
            state.behavior.search_target_zone = "none"
            state.behavior.search_target_point = nil
            set_behavior_mode("CHASE", state.behavior.last_stimulus)
        else
            if fire_pressed and accelerate_pending_investigate_from_fire(player_pos, player_go, mrx_pos) then
                return
            end
            if sustain_pending_investigate_from_noise(player_pos, player_go, mrx_pos, can_floor_chase and state.behavior.last_stimulus or "hard noise sustain") then
                return
            end
            state.behavior.investigate_until = t + cfg.behavior.investigate_hold
            set_behavior_mode("INVESTIGATE", can_floor_chase and state.behavior.last_stimulus or "hard noise (cross-floor)")
            clear_pending_anchor()
            state.behavior.search_target_label = "none"
            state.behavior.search_target_zone = "none"
            state.behavior.search_target_point = nil
            state.behavior.search_refresh_time = -999.0
            state.behavior.anchor_force_refresh = true
            arm_behavior_anchor_move("INVESTIGATE", player_pos, player_go, mrx_pos, state.behavior.last_stimulus, state.behavior.last_noise_kind)
        end
        return
    end

    if noise_tier == "medium" then
        state.behavior.last_heard_time = t
        mark_last_player_known(player_pos)
        state.behavior.last_stimulus = "medium noise"
        state.behavior.investigate_until = t + cfg.behavior.investigate_hold
        state.behavior.linger_until = -999.0
        if state.behavior.mode ~= "CHASE" then
            if sustain_pending_investigate_from_noise(player_pos, player_go, mrx_pos, "medium noise sustain") then
                return
            end
            set_behavior_mode("INVESTIGATE", state.behavior.last_stimulus)
            if state.behavior.pending_anchor_time < 0.0 then
                arm_behavior_anchor_move("INVESTIGATE", player_pos, player_go, mrx_pos, state.behavior.last_stimulus, "medium")
            end
        end
        return
    end

    if maybe_decay_investigate(player_pos, visual, noise_tier, mrx_pos) then
        return
    end

    if state.behavior.chase_until > t then
        clear_pending_anchor()
        state.behavior.search_target_label = "none"
        state.behavior.search_target_zone = "none"
        state.behavior.search_target_point = nil
        set_behavior_mode("CHASE", "alert hold")
        return
    end

    if state.behavior.investigate_until > t then
        set_behavior_mode("INVESTIGATE", "investigate hold")
        if state.behavior.pending_anchor_time >= 0.0 then
            if t < state.behavior.pending_anchor_time then
                state.behavior.search_last_result = string.format(
                    "%s travel %.2f/%.2f",
                    string.lower(state.behavior.pending_anchor_mode or "INVESTIGATE"),
                    state.behavior.pending_anchor_time - t,
                    state.behavior.pending_anchor_delay or 0.0
                )
            end
        elseif state.behavior.linger_until > t then
            state.behavior.search_last_result = string.format("lingering %.2f", state.behavior.linger_until - t)
        elseif state.behavior.anchor_force_refresh or state.behavior.search_refresh_time < 0.0 or t >= state.behavior.search_refresh_time then
            arm_behavior_anchor_move("INVESTIGATE", player_pos, player_go, mrx_pos, "investigate hold", state.behavior.last_noise_kind)
        end
        return
    end

    if state.cutoff.pending_active then
        clear_pending_anchor()
        set_behavior_mode("CUTOFF_ARMED", "pending cutoff delay")
        return
    end

    if state.behavior.mode ~= "SEARCH" then
        state.behavior.unseen_since = t
    end
    set_behavior_mode("SEARCH", "roaming search")

    if state.behavior.linger_until > t then
        state.behavior.search_last_result = string.format("lingering %.2f", state.behavior.linger_until - t)
        return
    end

    if state.behavior.pending_anchor_time >= 0.0 then
        if t < state.behavior.pending_anchor_time then
            state.behavior.search_last_result = string.format(
                "%s travel %.2f/%.2f",
                string.lower(state.behavior.pending_anchor_mode or "SEARCH"),
                state.behavior.pending_anchor_time - t,
                state.behavior.pending_anchor_delay or 0.0
            )
        end
    elseif state.behavior.anchor_force_refresh or state.behavior.search_refresh_time < 0.0 or t >= state.behavior.search_refresh_time then
        arm_behavior_anchor_move("SEARCH", player_pos, player_go, mrx_pos, "search refresh", state.behavior.last_noise_kind)
    end
end

local function get_scaled_cutoff_delay_range(zone)
    local min_delay = zone.delay_min or cfg.cutoff.delay_min or 1.0
    local max_delay = zone.delay_max or cfg.cutoff.delay_max or min_delay
    if max_delay < min_delay then max_delay = min_delay end

    local scale = 1.0
    if state.behavior.escalation_level >= 3 then
        scale = 0.75
    elseif state.behavior.escalation_level >= 2 then
        scale = 0.85
    end

    min_delay = math.max(0.20, min_delay * scale)
    max_delay = math.max(min_delay, max_delay * scale)
    return min_delay, max_delay
end

local function choose_active_cutoff_zone(player_pos, mrx_pos)
    if not cfg.cutoff.enabled or not cfg.cutoff.zones then return nil end
    if not player_pos or not mrx_pos then return nil end

    local best_zone = nil
    local best_mrx_dist = nil
    local best_player_dist = nil

    for _, zone in ipairs(cfg.cutoff.zones) do
        local zone_player_dist = dist_xz(player_pos, zone.center)
        local zone_mrx_dist = dist_xz(mrx_pos, zone.center)
        local player_radius = zone.player_radius or cfg.cutoff.max_player_distance
        if zone_player_dist <= player_radius and zone_mrx_dist <= (zone.trigger_radius or 3.2) then
            if best_mrx_dist == nil or zone_mrx_dist < best_mrx_dist then
                best_zone = zone
                best_mrx_dist = zone_mrx_dist
                best_player_dist = zone_player_dist
            end
        end
    end

    return best_zone, best_player_dist, best_mrx_dist
end

local function get_cutoff_zone_by_name(name)
    if not name or name == "none" or not cfg.cutoff or not cfg.cutoff.zones then return nil end
    for _, zone in ipairs(cfg.cutoff.zones) do
        if zone.name == name then
            return zone
        end
    end
    return nil
end

local function get_cutoff_extra_player_distance()
    if not state then return 0.0 end

    local crouched = (state.player_is_crouched == true) or (state.player_crouched == true)
    if crouched then
        return cfg.cutoff.crouch_post_cutoff_extra_distance or 0.0
    end

    return 0.0
end

local function is_post_cutoff_forward_reject(player_go, player_pos, point, pd, dot)
    if not player_pos or not point then return false end

    pd = pd or dist_xz(point, player_pos)
    if pd > (cfg.cutoff.post_cutoff_forward_reject_distance or 16.0) then
        return false
    end

    if dot == nil then
        dot = dot_from_player_forward(player_go, player_pos, point)
    end
    if dot == nil then
        return false
    end

    return dot >= (cfg.cutoff.post_cutoff_forward_reject_dot or -0.10)
end

local function is_same_hall_too_direct(zone, player_pos, point)
    if not zone or not zone.center or not player_pos or not point then
        return false
    end

    local player_zone_dist = dist_xz(player_pos, zone.center)
    if player_zone_dist > (cfg.cutoff.post_cutoff_same_hall_player_band or 18.0) then
        return false
    end

    local point_zone_dist = dist_xz(point, zone.center)
    if point_zone_dist > (cfg.cutoff.post_cutoff_same_hall_zone_band or 8.0) then
        return false
    end

    local ax = player_pos.x - zone.center.x
    local az = player_pos.z - zone.center.z
    local amag = math.sqrt((ax * ax) + (az * az))
    if amag <= 0.0001 then
        return false
    end
    ax = ax / amag
    az = az / amag

    local bx = point.x - zone.center.x
    local bz = point.z - zone.center.z

    local along = (bx * ax) + (bz * az)
    if along < -0.5 then
        return false
    end

    local lateral = math.abs((bx * -az) + (bz * ax))
    return lateral <= (cfg.cutoff.post_cutoff_same_hall_lateral_max or 3.25)
end

local function validate_intercept_point(zone, point, player_pos, player_go, mrx_pos, origin_pos)
    if not point or not point.x or not point.y or not point.z then
        return false, false, 0.0, 0.0, -2.0, false, false, false, 0.0
    end

    local pd = player_pos and dist_xz(point, player_pos) or 9999.0
    local md = mrx_pos and dist_xz(point, mrx_pos) or 9999.0
    local od = origin_pos and dist_xz(point, origin_pos) or 9999.0
    local dot = dot_from_player_forward(player_go, player_pos, point) or -2.0
    local visible = point_probably_visible(player_go, player_pos, point)

    local min_player_distance = math.max(
        cfg.cutoff.min_player_distance or 0.0,
        (cfg.cutoff.post_cutoff_min_player_distance or 0.0) + get_cutoff_extra_player_distance()
    )
    local min_origin_distance = cfg.cutoff.post_cutoff_min_origin_distance or 0.0

    local too_forward = is_post_cutoff_forward_reject(player_go, player_pos, point, pd, dot)
    local same_hall_direct = is_same_hall_too_direct(zone, player_pos, point)
    local too_close_to_origin = (origin_pos ~= nil) and (od < min_origin_distance)

    local preferred = (dot >= (cfg.cutoff.prefer_roadblock_dot or 0.15))
        and (not too_forward)
        and (not same_hall_direct)
        and (not too_close_to_origin)

    local valid = (pd >= min_player_distance)
        and (md >= (cfg.cutoff.min_mrx_distance or 0.0))
        and (not visible)
        and (not too_forward)
        and (not same_hall_direct)
        and (not too_close_to_origin)

    return valid, preferred, pd, md, dot, visible, too_forward, same_hall_direct, od
end

local function choose_next_valid_linked_point(zone, player_pos, player_go, mrx_pos)
    if not zone or not zone.points or #zone.points == 0 then
        return nil, "none"
    end

    local origin_pos = state.cutoff.stall_start_pos or mrx_pos

    local total = #zone.points
    local start_index = state.cutoff.next_index_by_zone[zone.name] or 1
    if start_index < 1 or start_index > total then
        start_index = 1
    end

    local first_valid = nil
    local first_preferred = nil
    local relaxed_hidden = nil
    local relaxed_any = nil

    local allow_relaxed_hidden = (cfg.cutoff.allow_relaxed_hidden_fallback == true)
        and (cfg.cutoff.strict_post_cutoff_fallbacks ~= true)

    local allow_relaxed_any = (cfg.cutoff.allow_relaxed_any_fallback == true)
        and (cfg.cutoff.strict_post_cutoff_fallbacks ~= true)

    for offset = 0, total - 1 do
        local idx = ((start_index - 1 + offset) % total) + 1
        local entry = normalize_weighted_point_entry(zone.points[idx], idx, "Intercept")
        if entry and entry.x and entry.y and entry.z then
            local point = { x = entry.x, y = entry.y, z = entry.z }
            local zone_buffer = cfg.cutoff.zone_center_buffer_radius or 0.0
            local too_close_to_zone_center = (zone_buffer > 0.0)
                and zone.center
                and (dist_xz(point, zone.center) < zone_buffer)

            local valid, preferred, pd, md, dot, visible, too_forward, same_hall_direct, od =
                validate_intercept_point(zone, point, player_pos, player_go, mrx_pos, origin_pos)

            if too_close_to_zone_center then
                valid = false
            end

            if valid then
                local choice = {
                    point = point,
                    label = entry.label,
                    index = idx,
                    player_dist = pd,
                    mrx_dist = md,
                    dot = dot,
                }

                if not first_valid then
                    first_valid = choice
                end
                if preferred and not first_preferred then
                    first_preferred = choice
                end
            else
                if (not too_close_to_zone_center)
                    and (not too_forward)
                    and (not same_hall_direct)
                    and pd >= math.max(
                        4.5,
                        ((cfg.cutoff.post_cutoff_min_player_distance or cfg.cutoff.min_player_distance or 6.0) - 1.5)
                    )
                    and md >= math.max(
                        1.75,
                        ((cfg.cutoff.min_mrx_distance or 3.0) - 0.5)
                    )
                    and (origin_pos == nil or od >= math.max(8.0, (cfg.cutoff.post_cutoff_min_origin_distance or 0.0) - 2.0)) then

                    local choice = {
                        point = point,
                        label = entry.label,
                        index = idx,
                        player_dist = pd,
                        mrx_dist = md,
                        dot = dot,
                    }

                    if allow_relaxed_hidden and (not visible) and not relaxed_hidden then
                        relaxed_hidden = choice
                    end
                    if allow_relaxed_any and not relaxed_any then
                        relaxed_any = choice
                    end
                end
            end
        end
    end

    local picked = first_preferred or first_valid
    if not picked and allow_relaxed_hidden then
        picked = relaxed_hidden
    end
    if not picked and allow_relaxed_any then
        picked = relaxed_any
    end

    if picked then
        state.cutoff.next_index_by_zone[zone.name] = (picked.index % total) + 1
        return picked.point, picked.label, picked.index
    end

    return nil, "none"
end

local function arm_cutoff_delay(zone, t)
    local min_delay, max_delay = get_scaled_cutoff_delay_range(zone)
    local delay = min_delay
    if max_delay > min_delay then
        delay = min_delay + (math.random() * (max_delay - min_delay))
    end

    state.cutoff.pending_active = true
    state.cutoff.pending_zone_name = zone.name or "zone"
    state.cutoff.pending_started_at = t
    state.cutoff.pending_delay = delay
    state.cutoff.pending_fire_time = t + delay
    state.cutoff.result = string.format("armed %.2fs delay", delay)
    set_behavior_mode("CUTOFF_ARMED", "armed cutoff delay")
end

clear_cutoff_settle = function()
    state.behavior.cutoff_settle_until = -999.0
    state.behavior.cutoff_settle_point = nil
    state.behavior.cutoff_settle_label = "none"
    state.behavior.cutoff_settle_reset_at = -999.0
end

local function start_cutoff_settle(destination, label, zone_name)
    local t = now()
    clear_pending_anchor()
    clear_investigate_commit()
    enter_clean_search("cutoff settle")
    state.behavior.cutoff_settle_until = t + (cfg.behavior.cutoff_settle_duration or 1.20)
    state.behavior.cutoff_settle_point = copy_point(destination)
    state.behavior.cutoff_settle_label = label or "unknown"
    state.behavior.cutoff_settle_reset_at = t
    state.behavior.anchor_force_refresh = false
    state.behavior.search_refresh_time = state.behavior.cutoff_settle_until + (cfg.behavior.cutoff_settle_post_search_delay or 0.85)
    state.behavior.linger_until = math.max(state.behavior.linger_until or -999.0, state.behavior.cutoff_settle_until)
    state.behavior.search_last_destination = label or "unknown"
    state.behavior.search_last_zone = zone_name or "zone"
    state.behavior.search_last_result = "cutoff settle"
    state.behavior.last_stimulus = "cutoff settle"
    state.behavior.last_noise_kind = "none"
    state.behavior.noise_tier = "none"
    state.behavior.noise_strength = 0.0
    state.behavior.last_player_known_pos = nil
    state.behavior.last_player_known_time = -999.0
end

local function run_cutoff_settle(mrx)
    local t = now()
    local until_t = state.behavior.cutoff_settle_until or -999.0
    local point = state.behavior.cutoff_settle_point
    if until_t <= t or not point or not mrx then
        clear_cutoff_settle()
        return false
    end

    state.hidden = false
    state.stashed = false
    state.waiting_release = false

    force_move_verified(mrx, point, "strong")
    if t >= (state.behavior.cutoff_settle_reset_at or -999.0) then
        try_reset_native_alert_state(mrx)
        state.behavior.cutoff_settle_reset_at = t + (cfg.behavior.cutoff_settle_reset_interval or 0.20)
    end

    local actual, actual_source = get_mrx_runtime_pos(mrx)
    publish_live_mrx_pos(actual, actual_source)
    publish_live_mrx_draw_pos(mrx)
    update_mrx_motion(state.mrx_draw_pos)
    set_behavior_mode("SEARCH", "cutoff settle")
    state.last_status = "cutoff settle: " .. tostring(state.behavior.cutoff_settle_label or "unknown")
    state.last_action = "cutoff settle"
    return true
end

local function maybe_cutoff_teleport(mrx, player_pos, player_go, mrx_pos)
    if not cfg.cutoff.enabled then return false end
    if not mrx or not player_pos or not mrx_pos then return false end
    if not is_strong_mrx_pos_source(state.mrx_pos_source or "none") then
        reset_cutoff_tracking("await live ctx")
        return false
    end
    if state.stashed or state.hidden or state.waiting_release or state.player_in_safe_room then
        reset_cutoff_tracking("inactive")
        return false
    end
    if now() < state.scene_guard_until or now() < state.bootstrap_until then
        reset_cutoff_tracking("bootstrap")
        return false
    end
    if now() < (state.cutoff.suspend_until or -999.0) then
        state.cutoff.zone_name = "none"
        state.cutoff.destination_label = "none"
        state.cutoff.result = "cutoff suspended"
        return false
    end
    if (now() - (state.cutoff.last_cutoff_fire_time or -999.0)) < (cfg.cutoff.global_cutoff_cooldown or 4.50) then
        state.cutoff.zone_name = "none"
        state.cutoff.destination_label = "none"
        state.cutoff.result = string.format("cutoff cooldown %.2f", (cfg.cutoff.global_cutoff_cooldown or 4.50) - (now() - (state.cutoff.last_cutoff_fire_time or -999.0)))
        return false
    end
    if (state.behavior.investigate_commit_until or -999.0) > now() then
        state.cutoff.zone_name = "none"
        state.cutoff.destination_label = "none"
        clear_cutoff_pending("investigate commit")
        state.cutoff.result = string.format("investigate commit %.2f", (state.behavior.investigate_commit_until or now()) - now())
        return false
    end

    local zone = choose_active_cutoff_zone(player_pos, mrx_pos)
    if state.cutoff.pending_active and state.cutoff.pending_zone_name ~= "none" then
        local pending_zone = get_cutoff_zone_by_name(state.cutoff.pending_zone_name)
        if pending_zone and mrx_pos and dist_xz(mrx_pos, pending_zone.center) <= ((pending_zone.trigger_radius or 3.2) * 1.6) then
            zone = pending_zone
        end
    end
    if not zone then
        reset_cutoff_tracking("no zone")
        return false
    end

    state.cutoff.zone_name = zone.name or "zone"
    local t = now()

    if zone.name == (state.behavior.investigate_zone_ignore_name or "none")
        and t < (state.behavior.investigate_zone_ignore_until or -999.0) then
        state.cutoff.destination_label = "none"
        state.cutoff.result = string.format(
            "ignored after investigate %.2f",
            math.max(0.0, (state.behavior.investigate_zone_ignore_until or -999.0) - t)
        )
        return false
    end

    if state.cutoff.last_sample_time < 0.0 then
        state.cutoff.last_sample_time = t
        state.cutoff.last_pos = copy_point(mrx_pos)
        state.cutoff.stall_start_pos = copy_point(mrx_pos)
        state.cutoff.stall_time = 0.0
        state.cutoff.result = "tracking stall"
        return false
    end

    if (t - state.cutoff.last_sample_time) < cfg.cutoff.sample_interval then
        if state.cutoff.pending_active and state.cutoff.pending_zone_name == zone.name then
            local remaining = state.cutoff.pending_fire_time - t
            if remaining > 0.0 then
                state.cutoff.result = string.format("delay %.2f/%.2f", remaining, state.cutoff.pending_delay)
            end
        end
        return false
    end

    local moved = 9999.0
    if state.cutoff.last_pos then
        moved = dist_xz(mrx_pos, state.cutoff.last_pos)
    end

    local dt = t - state.cutoff.last_sample_time
    local drift = 0.0
    if state.cutoff.stall_start_pos then
        drift = dist_xz(mrx_pos, state.cutoff.stall_start_pos)
    end

    if moved <= cfg.cutoff.move_threshold then
        if drift <= cfg.cutoff.max_stall_drift then
            state.cutoff.stall_time = state.cutoff.stall_time + dt
        else
            state.cutoff.stall_time = 0.0
            state.cutoff.stall_start_pos = copy_point(mrx_pos)
        end
    else
        state.cutoff.stall_time = 0.0
        state.cutoff.stall_start_pos = copy_point(mrx_pos)
    end

    state.cutoff.last_sample_time = t
    state.cutoff.last_pos = copy_point(mrx_pos)

    if state.cutoff.pending_active and state.cutoff.pending_zone_name ~= zone.name then
        local pending_zone = get_cutoff_zone_by_name(state.cutoff.pending_zone_name)
        local can_keep_pending = pending_zone and mrx_pos and dist_xz(mrx_pos, pending_zone.center) <= ((pending_zone.trigger_radius or 3.2) * 1.6)
        if can_keep_pending then
            zone = pending_zone
            state.cutoff.zone_name = zone.name or "zone"
        else
            clear_cutoff_pending("pending cancelled: zone changed")
        end
    end

    if state.cutoff.pending_active then
        if state.cutoff.stall_time <= 0.0 then
            clear_cutoff_pending("pending cancelled: stall cleared")
            return false
        end

        local remaining = state.cutoff.pending_fire_time - t
        if remaining > 0.0 then
            state.cutoff.result = string.format("delay %.2f/%.2f | moved %.3f | drift %.3f", remaining, state.cutoff.pending_delay, moved, drift)
            return false
        end

        local destination, label = choose_next_valid_linked_point(zone, player_pos, player_go, mrx_pos)
        state.cutoff.destination_label = label or "none"
        if not destination then
            state.cutoff.pending_fire_time = t + cfg.cutoff.retry_delay
            state.cutoff.result = "no valid linked point; retrying"
            return false
        end

        state.attempted_move = true
        local moved_ok, move_reason = relocate_hidden_mrx_checked(mrx, mrx_pos, destination, true)
        state.move_succeeded = moved_ok

        if moved_ok then
            update_mrx_motion(state.mrx_draw_pos)
            state.cutoff.last_trigger_time = t
            state.cutoff.last_cutoff_fire_time = t
            state.cutoff.teleports = state.cutoff.teleports + 1
            state.cutoff.last_destination_label = label or "unknown"
            state.cutoff.destination_label = "none"
            if state.mrx_pos ~= nil then
                state.cutoff.result = "teleported to " .. tostring(label)
            else
                state.cutoff.result = "teleported to " .. tostring(label) .. " (await live ctx)"
            end
            state.cutoff.stall_time = 0.0
            state.cutoff.last_pos = copy_point(state.mrx_pos or destination)
            state.cutoff.stall_start_pos = copy_point(state.mrx_pos or destination)
            state.last_status = "cutoff teleport: " .. tostring(label)
            state.last_action = "cutoff teleport"
            clear_cutoff_pending()
            set_cutoff_suspend(cfg.cutoff.suspend_after_cutoff or 2.0, "post cutoff")
            try_reset_native_alert_state(mrx)
            start_cutoff_settle(state.mrx_pos or destination, label or "unknown", zone.name or "zone")
            state.behavior.visual_confirm_since = -999.0
            state.behavior.chase_until = -999.0
            begin_reposition_grace(1.10, "cutoff")
            return true
        end

        state.cutoff.result = "teleport failed (" .. tostring(move_reason or "transform/nav did not stick") .. ")"
        clear_cutoff_pending()
        return false
    end

    if (t - state.cutoff.last_trigger_time) < cfg.cutoff.cooldown then
        state.cutoff.result = "cooldown"
        return false
    end

    local required_stall = zone.stall_time or 7.0
    if state.behavior.mode == "CHASE" then
        required_stall = math.max(required_stall + (cfg.cutoff.chase_extra_stall_time or 0.50), zone.stall_time or 7.0)
    end

    if state.cutoff.stall_time < required_stall then
        state.cutoff.result = string.format("stall %.2f/%.2f | moved %.3f | drift %.3f", state.cutoff.stall_time, required_stall, moved, drift)
        return false
    end

    arm_cutoff_delay(zone, t)
    return false
end

local function maybe_anchor_roam_move(mrx, player_pos, player_go, mrx_pos)
    if not cfg.behavior.search_anchor.enabled then return false end
    if not mrx or not player_pos or not mrx_pos then return false end
    if state.stashed or state.hidden or state.waiting_release or state.player_in_safe_room then return false end
    if now() < state.scene_guard_until or now() < state.bootstrap_until then return false end

    local mode = state.behavior.mode
    if mode ~= "SEARCH" and mode ~= "INVESTIGATE" then return false end

    local t = now()
    local cooldown = (mode == "INVESTIGATE") and (cfg.behavior.search_anchor.investigate_cooldown or 0.50) or (cfg.behavior.search_anchor.roam_cooldown or 0.75)
    if (t - (state.behavior.search_last_move_time or -999.0)) < cooldown then
        state.behavior.search_last_result = string.format("anchor cooldown %.2f", cooldown - (t - (state.behavior.search_last_move_time or -999.0)))
        return false
    end

    if state.behavior.linger_until > t then
        state.behavior.search_last_result = string.format("linger %.2f", state.behavior.linger_until - t)
        return false
    end

    if state.behavior.pending_anchor_time < 0.0 then
        state.behavior.search_last_result = "no pending anchor"
        return false
    end

    if t < state.behavior.pending_anchor_time then
        update_offscreen_door_stall_tracking(mrx_pos)
        if maybe_heat_override_pending_anchor(player_pos, player_go, mrx_pos, state.behavior.pending_anchor_mode or mode) then
            return false
        end
        if try_hidden_forward_graph_advance(mrx, player_pos, mrx_pos) then
            state.behavior.search_last_move_time = t
            state.behavior.anchor_force_refresh = false
            state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + 0.35)
            state.behavior.search_last_result = "pending travel stalled; hidden forward rescue"
            return false
        end
        state.behavior.search_last_result = string.format(
            "%s travel %.2f/%.2f",
            string.lower(state.behavior.pending_anchor_mode or mode),
            state.behavior.pending_anchor_time - t,
            state.behavior.pending_anchor_delay or 0.0
        )
        return false
    end

    if not state.behavior.search_target_point then
        clear_pending_anchor()
        state.behavior.search_last_result = "pending anchor lost target"
        return false
    end

    local target = state.behavior.search_target_point
    local label = state.behavior.search_target_label or "none"
    local zone_name = state.behavior.search_target_zone or "none"

    local hold_near_player, live_dist, live_speed = should_hold_near_player_anchor_move(player_pos)
    if hold_near_player then
        local hold_linger = rand_range(
            cfg.behavior.search_anchor.near_player_move_lock_linger_min or 2.0,
            cfg.behavior.search_anchor.near_player_move_lock_linger_max or 4.0
        )
        state.behavior.search_last_result = string.format(
            "hold near player %.1fm speed %.2f",
            live_dist or 0.0,
            live_speed or 0.0
        )
        state.behavior.linger_until = math.max(state.behavior.linger_until or -999.0, t + hold_linger)
        state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + hold_linger)
        clear_pending_anchor()
        state.behavior.anchor_force_refresh = false
        state.last_status = "holding nearby patrol"
        state.last_action = "anchor hold near player"
        return false
    end

    local fire_mode = state.behavior.pending_anchor_mode or mode
    local retarget_valid, retarget_reason, _, retarget_pd, retarget_md, retarget_dot
    if fire_mode == "INVESTIGATE" then
        retarget_valid, retarget_reason, _, retarget_pd, retarget_md, retarget_dot = validate_investigate_commit_point(target, player_pos, player_go, mrx_pos)
    else
        retarget_valid, retarget_reason, _, retarget_pd, retarget_md, retarget_dot = validate_search_commit_point(target, player_pos, player_go, mrx_pos)
    end
    if not retarget_valid then
        state.behavior.search_last_result = string.format(
            "%s blocked at fire time %s pd=%.2f md=%.2f dot=%.2f",
            string.lower(fire_mode or mode),
            tostring(retarget_reason or "base"),
            retarget_pd or 0.0,
            retarget_md or 0.0,
            retarget_dot or -2.0
        )
        state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + 0.20)
        state.behavior.linger_until = math.max(state.behavior.linger_until or -999.0, t + 0.20)
        clear_pending_anchor()
        state.behavior.anchor_force_refresh = true
        return false
    end

    if dist_xz(target, mrx_pos) < (cfg.behavior.search_anchor.min_mrx_distance or 6.0) then
        if mode == "INVESTIGATE" and state.behavior.search_target_strategy == "investigate-zone" and zone_name ~= "none" then
            state.behavior.investigate_zone_last_name = zone_name
            state.behavior.investigate_zone_last_time = t
        end
        state.behavior.search_last_result = (mode == "INVESTIGATE" and state.behavior.search_target_strategy == "investigate-zone")
            and "investigate zone too near; holding"
            or "already near search anchor"
        state.behavior.search_last_move_time = t
        local hold = (mode == "INVESTIGATE" and state.behavior.search_target_strategy == "investigate-zone") and 1.25 or get_anchor_linger(mode, state.behavior.search_target_strategy)
        state.behavior.linger_until = t + hold
        state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + hold)
        clear_pending_anchor()
        state.behavior.anchor_force_refresh = false
        return false
    end

    local move_target = target
    local move_label = label

    state.attempted_move = true
    local moved_ok, move_reason = relocate_hidden_mrx_checked(mrx, mrx_pos, move_target, true)
    state.move_succeeded = moved_ok
    state.behavior.search_last_move_time = t
    state.behavior.anchor_force_refresh = false

    if moved_ok then
        state.behavior.search_repositions = (state.behavior.search_repositions or 0) + 1
        state.behavior.search_last_destination = move_label
        state.behavior.search_last_zone = zone_name
        local move_tag = (mode == "INVESTIGATE" and state.behavior.search_target_strategy == "investigate-zone") and "investigate zone" or (string.lower(mode) .. " anchor")
        if state.mrx_pos ~= nil then
            state.behavior.search_last_result = string.format("%s -> %s", move_tag, tostring(move_label))
        else
            state.behavior.search_last_result = string.format("%s -> %s (await live ctx)", move_tag, tostring(move_label))
        end
        local post_move_linger = get_anchor_linger(mode, state.behavior.search_target_strategy)
        state.behavior.linger_until = t + post_move_linger
        state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + post_move_linger)
        clear_pending_anchor()
        reset_cutoff_tracking("anchor move")
        if mode == "INVESTIGATE" then
            local commit_duration = rand_range(cfg.behavior.investigate_commit_min or 1.75, cfg.behavior.investigate_commit_max or 3.00)
            local cutoff_ignore_duration = 0.0
            begin_investigate_commit(commit_duration, "investigate move")
            state.behavior.investigate_until = math.max(state.behavior.investigate_until or -999.0, t + commit_duration + 1.0)

            if state.behavior.search_target_strategy == "investigate-zone" and zone_name ~= "none" then
                cutoff_ignore_duration = cfg.behavior.search_anchor.investigate_zone_ignore_after_jump or 4.5
                state.behavior.investigate_zone_last_name = zone_name
                state.behavior.investigate_zone_last_time = t
                state.behavior.investigate_zone_ignore_name = zone_name
                state.behavior.investigate_zone_ignore_until = t + cutoff_ignore_duration
            end

            set_cutoff_suspend(
                math.max(cfg.cutoff.suspend_after_anchor_move or 3.5, commit_duration, cutoff_ignore_duration),
                state.behavior.search_target_strategy == "investigate-zone" and "investigate zone move" or "investigate move"
            )
        else
            clear_investigate_commit()
            set_cutoff_suspend(cfg.cutoff.suspend_after_anchor_move or 3.5, "search move")
        end
        begin_reposition_grace(mode == "INVESTIGATE" and 1.25 or 1.75, mode == "INVESTIGATE" and "investigate move" or "search move")
        if mode == "SEARCH" then
            state.last_status = "searching: " .. tostring(label)
            state.last_action = "search-anchor relocate"
        else
            state.last_status = "investigating: " .. tostring(label)
            state.last_action = "investigate-anchor relocate"
        end
        return true
    end

    local rescue_point, rescue_reason = choose_commit_target_rescue_point(player_pos, player_go, mrx_pos, target)
    if rescue_point then
        local rescue_ok, rescue_move_reason = relocate_hidden_mrx_checked(mrx, mrx_pos, rescue_point, true)
        if rescue_ok then
            state.behavior.search_repositions = (state.behavior.search_repositions or 0) + 1
            state.behavior.search_last_destination = rescue_point.label or move_label
            state.behavior.search_last_zone = zone_name
            state.behavior.search_last_result = string.format("anchor target-near rescue -> %s", tostring(rescue_point.label or move_label))
            state.behavior.linger_until = t + math.max(1.0, get_anchor_linger(mode, state.behavior.search_target_strategy))
            state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + 1.0)
            clear_pending_anchor()
            state.behavior.anchor_force_refresh = false
            set_cutoff_suspend(cfg.cutoff.suspend_after_anchor_move or 3.5, "target-near rescue")
            begin_reposition_grace(mode == "INVESTIGATE" and 1.0 or 1.5, "target-near rescue")
            return true
        end
    end

    if try_hidden_forward_graph_advance(mrx, player_pos, mrx_pos) then
        state.behavior.search_last_move_time = t
        state.behavior.anchor_force_refresh = false
        state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + 0.35)
        state.behavior.search_last_result = "anchor move failed; hidden forward rescue"
        clear_pending_anchor()
        return false
    end

    if mode == "INVESTIGATE" and state.behavior.search_target_strategy == "investigate-zone" and zone_name ~= "none" then
        state.behavior.investigate_zone_last_name = zone_name
        state.behavior.investigate_zone_last_time = t
    end
    state.behavior.search_last_move_time = t
    local fail_hold = (mode == "INVESTIGATE") and 0.45 or 0.35
    state.behavior.linger_until = math.max(state.behavior.linger_until or -999.0, t + fail_hold)
    state.behavior.search_refresh_time = math.max(state.behavior.search_refresh_time or -999.0, t + fail_hold)
    clear_pending_anchor()
    state.behavior.anchor_force_refresh = true
    state.behavior.search_last_result = "anchor move failed; rearm"
    return false
end


function clone_point_for_debug(p)
    if p == nil then return nil end
    return { x = p.x or 0.0, y = p.y or 0.0, z = p.z or 0.0 }
end

function publish_debug_state()
    if not cfg.publish_debug_bridge then return end
    _G.RE9_MRX_DEBUG_STATE = {
        scene_name = state.scene_name,
        player_in_safe_room = state.player_in_safe_room,
        player_pos = clone_point_for_debug(state.player_pos),
        mrx_pos = clone_point_for_debug(state.mrx_pos),
        mrx_pos_source = state.mrx_pos_source,
        mrx_draw_pos = clone_point_for_debug(state.mrx_draw_pos),
        mrx_draw_source = state.mrx_draw_source,
        last_live_confirmed_pos = clone_point_for_debug(state.last_live_confirmed_pos),
        last_live_confirmed_source = state.last_live_confirmed_source,
        last_live_confirmed_age = (state.last_live_confirmed_time or -999.0) > 0.0 and (now() - (state.last_live_confirmed_time or now())) or -1.0,
        mrx_step_xz = state.mrx_step_xz,
        mrx_speed_xz = state.mrx_speed_xz,
        mrx_found = state.mrx_found,
        mrx_draw_active = (function()
            if not (state.mrx_found and not state.stashed and not state.hidden and not state.waiting_release and not state.player_in_safe_room and state.mrx_draw_pos ~= nil and state.player_pos ~= nil) then
                return false
            end
            local grace_remaining = math.max(0.0, (state.behavior.reposition_grace_until or -999.0) - now())
            local player_dist = dist_xz(state.player_pos, state.mrx_draw_pos)
            return should_draw_mrx_debug(state.mrx_draw_source or "none", player_dist, grace_remaining, state.behavior.mode)
        end)(),
        stashed = state.stashed,
        hidden = state.hidden,
        waiting_release = state.waiting_release,
        pending_release_label = state.pending_release_label,
        pending_release_family = state.pending_release_family,
        last_release_label = state.last_release_label,
        last_release_family = state.last_release_family,
        paused = state.paused,
        clock_delta = state.last_clock_delta,
        running_timers = state.last_running_timers,
        last_status = state.last_status,
        last_action = state.last_action,
        last_hide_mode = state.last_hide_mode,
        native_reset = state.last_native_reset,

        behavior_mode = state.behavior.mode,
        behavior_reason = state.behavior.reason,
        behavior_noise = state.behavior.noise_tier,
        behavior_noise_strength = state.behavior.noise_strength,
        behavior_stimulus = state.behavior.last_stimulus,
        behavior_search_target_zone = ((state.behavior.mode == "SEARCH" or state.behavior.mode == "INVESTIGATE" or state.behavior.mode == "CUTOFF_ARMED") and state.behavior.search_target_zone) or "none",
        behavior_search_target_label = ((state.behavior.mode == "SEARCH" or state.behavior.mode == "INVESTIGATE" or state.behavior.mode == "CUTOFF_ARMED") and state.behavior.search_target_label) or "none",
        behavior_search_target_point = ((state.behavior.mode == "SEARCH" or state.behavior.mode == "INVESTIGATE" or state.behavior.mode == "CUTOFF_ARMED") and clone_point_for_debug(state.behavior.search_target_point)) or nil,
        behavior_search_target_strategy = state.behavior.search_target_strategy,
        behavior_planned_target_zone = state.behavior.planned_target_zone,
        behavior_planned_target_label = state.behavior.planned_target_label,
        behavior_planned_target_point = clone_point_for_debug(state.behavior.planned_target_point),
        behavior_planned_target_strategy = state.behavior.planned_target_strategy,
        behavior_planned_target_age = (state.behavior.planned_target_selected_at or -999.0) > 0.0 and (now() - (state.behavior.planned_target_selected_at or now())) or -1.0,
        behavior_committed_target_zone = state.behavior.committed_target_zone,
        behavior_committed_target_label = state.behavior.committed_target_label,
        behavior_committed_target_point = clone_point_for_debug(state.behavior.committed_target_point),
        behavior_committed_target_strategy = state.behavior.committed_target_strategy,
        behavior_committed_target_reason = state.behavior.committed_target_reason,
        behavior_committed_target_age = (state.behavior.committed_target_armed_at or -999.0) > 0.0 and (now() - (state.behavior.committed_target_armed_at or now())) or -1.0,
        behavior_committed_target_remaining = (state.behavior.committed_target_fire_at or -999.0) - now(),
        behavior_offscreen_stall_time = state.behavior.offscreen_stall_time,
        behavior_hidden_forward_advance_tries = state.behavior.hidden_forward_advance_tries,
        behavior_anchor_result = state.behavior.search_last_result,
        behavior_anchor_moves = state.behavior.search_repositions,
        behavior_last_anchor = state.behavior.search_last_destination,
        behavior_release_search_pending = state.behavior.release_search_pending,
        behavior_release_search_origin = clone_point_for_debug(state.behavior.release_search_origin),
        behavior_pending_anchor_mode = state.behavior.pending_anchor_mode,
        behavior_pending_anchor_delay = state.behavior.pending_anchor_delay,
        behavior_pending_anchor_remaining = (state.behavior.pending_anchor_time or -999.0) - now(),
        behavior_linger_remaining = (state.behavior.linger_until or -999.0) - now(),
        behavior_last_noise = state.behavior.last_noise_kind,
        behavior_escalation = state.behavior.escalation_level,
        behavior_grace_remaining = (state.behavior.reposition_grace_until or -999.0) - now(),
        behavior_grace_reason = state.behavior.reposition_grace_reason,
        behavior_investigate_commit_remaining = (state.behavior.investigate_commit_until or -999.0) - now(),
        behavior_investigate_commit_reason = state.behavior.investigate_commit_reason,
        behavior_last_player_known_age = now() - (state.behavior.last_player_known_time or -999.0),
        behavior_investigate_decay_reason = state.behavior.investigate_decay_reason,
        behavior_investigate_decay_age = now() - (state.behavior.investigate_decay_last_time or -999.0),
        behavior_investigate_zone_last_name = state.behavior.investigate_zone_last_name,
        behavior_investigate_zone_last_age = now() - (state.behavior.investigate_zone_last_time or -999.0),
        behavior_investigate_zone_ignore_name = state.behavior.investigate_zone_ignore_name,
        behavior_investigate_zone_ignore_remaining = (state.behavior.investigate_zone_ignore_until or -999.0) - now(),
        behavior_chase_stall = state.behavior.chase_stall_time,

        movement_anim_name = state.movement.current_anim_name,
        movement_anim_class = state.movement.current_anim_class,
        movement_speed_mode = state.movement.current_speed_mode,
        movement_applied_speed = state.movement.current_applied_speed,
        movement_visible = state.movement.visible,
        movement_offscreen_active = state.movement.offscreen_active,
        movement_last_error = state.movement.last_error,

        graph_load = cfg.graph.load_status,
        graph_nodes = cfg.graph.count_nodes,
        graph_links = cfg.graph.count_links,
        graph_nearest_player_node = state.graph.nearest_player_node_label,
        graph_nearest_mrx_node = state.graph.nearest_mrx_node_label,

        sound_heat_enabled = cfg.sound_map.enabled,
        sound_heat_active_count = state.sound.active_count,
        sound_heat_peak_label = state.sound.peak_label,
        sound_heat_peak_value = state.sound.peak_value,
        sound_heat_bias_pending = state.sound.bias_pending,
        sound_heat_bias_source_label = state.sound.bias_source_label,
        sound_heat_bias_source_kind = state.sound.bias_source_kind,
        sound_heat_override_count = state.behavior.pending_heat_override_count,
        sound_heat_last_override_age = (state.sound.last_override_time or -999.0) > 0.0 and (now() - (state.sound.last_override_time or now())) or -1.0,
        sound_heat_nodes = state.sound.published_nodes,

        combat_stagger_taken_multiplier = cfg.combat.stagger_taken_multiplier,
        combat_last_hit_age = (state.combat.last_hit_time or -999.0) > 0.0 and (now() - (state.combat.last_hit_time or now())) or -1.0,
        combat_last_weapon_id = state.combat.last_weapon_id,
        combat_last_damage_factor = state.combat.last_damage_factor,
        combat_last_wince_factor = state.combat.last_wince_factor,

        cutoff_enabled = cfg.cutoff.enabled,
        cutoff_load = cfg.cutoff.load_status,
        cutoff_zone_name = state.cutoff.zone_name,
        cutoff_stall_time = state.cutoff.stall_time,
        cutoff_teleports = state.cutoff.teleports,
        cutoff_pending_zone_name = state.cutoff.pending_zone_name,
        cutoff_destination_label = state.cutoff.destination_label,
        cutoff_last_destination_label = state.cutoff.last_destination_label,
        cutoff_suspend_remaining = (state.cutoff.suspend_until or -999.0) - now(),
        cutoff_result = state.cutoff.result,
    }
end

install_mrx_combat_hook()

re.on_frame(function()
    if not cfg.enabled then
        state.last_status = "disabled"
        return
    end

    state.attempted_move = false
    state.move_succeeded = false
    state.mrx_found = false
    state.player_pos = nil
    state.player_go = nil
    state.mrx_pos = nil
    state.mrx_pos_source = "none"
    state.mrx_draw_pos = nil
    state.mrx_draw_source = "none"

    local scene = get_scene()
    local scene_name = get_scene_name(scene)
    if scene_name ~= state.scene_name then
        reset_runtime_for_scene(scene_name)
    else
        state.scene_name = scene_name
    end

    local player_pos, player_go = get_player_pos_and_go()
    state.player_in_safe_room = get_engine_safe_room_flag()

    if player_pos == nil then
        state.last_status = "player pos not found"
        publish_debug_state()
        return
    end

    update_player_motion(player_pos)
    state.player_pos = player_pos
    state.player_go = player_go
    tick_gameplay_clock(player_go)

    if state.player_in_safe_room and state.active_safe_room_index == nil then
        state.active_safe_room_index = find_nearest_safe_room_index(player_pos)
    end

    local mrx, reason = find_mrx()
    if mrx == nil then
        state.last_status = reason
        state.mrx_was_found_prev = false
        publish_debug_state()
        return
    end

    state.mrx_found = true

    local mrx_pos, mrx_pos_source = get_mrx_runtime_pos(mrx)
    if not publish_live_mrx_pos(mrx_pos, mrx_pos_source) then
        mrx_pos = nil
    end
    publish_live_mrx_draw_pos(mrx)
    run_integrated_runtime_updates(player_pos, mrx, get_behavior_mrx_ref_pos() or mrx_pos or state.mrx_draw_pos)

    if state.paused then
        state.last_status = "paused"
        state.last_action = "paused"
        publish_debug_state()
        return
    end

    local active_room = nil
    if state.active_safe_room_index then
        active_room = cfg.safe_rooms[state.active_safe_room_index]
    end

    if now() < state.bootstrap_until or now() < state.scene_guard_until then
        if cfg.bootstrap_safe_room_stash and state.player_in_safe_room and active_room then
            if state.bootstrap_spawn_scrub_pending then
                local scrubbed = run_bootstrap_spawn_scrub(mrx, active_room)
                if scrubbed then
                    state.last_status = state.last_status or "bootstrap spawn scrub active"
                    state.last_action = state.last_action or "bootstrap spawn scrub active"
                else
                    state.last_status = "bootstrap spawn scrub pending"
                    state.last_action = "bootstrap spawn scrub pending"
                end
            else
                local bootstrapped = stash_and_hide_mrx(mrx, active_room, "bootstrap safe-room stash: " .. get_safe_room_name(state.active_safe_room_index))
                if bootstrapped then
                    if cfg.bootstrap_spawn_scrub and (not state.bootstrap_spawn_scrub_done) and (not state.bootstrap_spawn_scrub_pending) then
                        begin_bootstrap_spawn_scrub(active_room, player_pos, player_go)
                    end
                end
            end

            state.prev_player_in_safe_room = true
        elseif state.startup_quarantine_active then
            local quarantine_room = get_startup_quarantine_room()
            if quarantine_room ~= nil then
                if not state.stashed then
                    stash_and_hide_mrx(mrx, quarantine_room, "startup quarantine bootstrap stash")
                else
                    hold_hidden_stash(mrx, quarantine_room)
                    state.last_status = "startup quarantine bootstrap"
                    state.last_action = "startup quarantine bootstrap"
                    set_behavior_mode("SAFE", "startup quarantine bootstrap")
                end
            else
                state.last_status = "bootstrap guard"
                state.last_action = "bootstrap guard"
            end
            state.prev_player_in_safe_room = state.player_in_safe_room
        else
            state.prev_player_in_safe_room = state.player_in_safe_room
            state.last_status = "bootstrap guard"
            state.last_action = "bootstrap guard"
        end
        state.mrx_was_found_prev = true
        publish_debug_state()
        return
    end

    local mrx_just_appeared = state.mrx_found and not state.mrx_was_found_prev
    local can_restash = (now() - state.last_stash_time) >= cfg.stash_cooldown

    if state.player_in_safe_room and not state.prev_player_in_safe_room then
        state.active_safe_room_index = find_nearest_safe_room_index(player_pos)
        reset_waiting_release()
        active_room = state.active_safe_room_index and cfg.safe_rooms[state.active_safe_room_index] or nil
    end

    if state.startup_quarantine_active then
        if state.player_in_safe_room and active_room ~= nil then
            state.startup_quarantine_seen_safe_room = true
            if (not state.stashed) or mrx_just_appeared then
                stash_and_hide_mrx(mrx, active_room, "startup quarantine safe-room stash: " .. get_safe_room_name(state.active_safe_room_index))
            else
                hold_hidden_stash(mrx, active_room)
                state.last_status = "startup quarantine hidden: " .. get_safe_room_name(state.active_safe_room_index)
                state.last_action = "startup quarantine hold"
                set_behavior_mode("SAFE", "startup quarantine hold")
            end
            state.mrx_was_found_prev = true
            state.prev_player_in_safe_room = true
            publish_debug_state()
            return
        end

        local startup_quarantine_handoff_ready = (not state.player_in_safe_room)
            and state.stashed
            and active_room ~= nil
            and state.startup_quarantine_seen_safe_room

        if not startup_quarantine_handoff_ready then
            local quarantine_room = get_startup_quarantine_room()
            if quarantine_room ~= nil then
                if (not state.stashed) or mrx_just_appeared then
                    stash_and_hide_mrx(mrx, quarantine_room, "startup quarantine stash")
                else
                    hold_hidden_stash(mrx, quarantine_room)
                    state.last_status = "startup quarantine hidden"
                    state.last_action = "startup quarantine hold"
                    set_behavior_mode("SAFE", "startup quarantine hold")
                end
            else
                state.last_status = "startup quarantine active"
                state.last_action = "startup quarantine active"
            end
            state.mrx_was_found_prev = true
            state.prev_player_in_safe_room = state.player_in_safe_room
            publish_debug_state()
            return
        end
    end

    if state.player_in_safe_room then
        if active_room ~= nil then
            if (not state.stashed) or mrx_just_appeared then
                stash_and_hide_mrx(mrx, active_room, "safe-room hide/stash: " .. get_safe_room_name(state.active_safe_room_index))
            else
                hold_hidden_stash(mrx, active_room)
                state.last_status = "safe-room hidden: " .. get_safe_room_name(state.active_safe_room_index)
                state.last_action = "safe-room hold"
                set_behavior_mode("SAFE", "safe-room hold")
            end
        else
            state.last_status = "safe-room active but no matching config"
        end

    elseif state.prev_player_in_safe_room and state.stashed and active_room ~= nil then
        if not state.waiting_release then
            state.waiting_release = true
            state.mrx_pos = nil
            state.release_time = now() + cfg.release_delay
            state.pending_release_point, state.pending_release_label, state.pending_release_family = choose_release_point(active_room, player_pos, player_go)
            hold_hidden_stash(mrx, active_room)
            state.last_status = "waiting release: " .. get_safe_room_name(state.active_safe_room_index)
            state.last_action = "queued release"
            set_behavior_mode("RELEASE_PRIMED", "queued release")
        end

    elseif state.waiting_release and now() < state.release_time and active_room ~= nil then
        hold_hidden_stash(mrx, active_room)
        state.last_status = "waiting release: " .. get_safe_room_name(state.active_safe_room_index)
        state.last_action = "waiting release hold"

    elseif state.waiting_release and now() >= state.release_time and active_room ~= nil and state.pending_release_point ~= nil then
        if not state.release_prewarm_pending then
            local prewarmed = prewarm_hidden_release(mrx, state.pending_release_point)
            if prewarmed then
                state.release_prewarm_pending = true
                state.release_time = now() + (cfg.release_prewarm_hold or 0.18)
                state.last_status = "release prewarm: " .. get_safe_room_name(state.active_safe_room_index)
                state.last_action = "release prewarm"
            else
                hold_hidden_stash(mrx, active_room)
                state.release_time = now() + 0.05
                state.last_status = "release prewarm retry"
                state.last_action = "release prewarm retry"
            end
        elseif not state.release_nodraw_prime_pending then
            local primed = begin_release_nodraw_prime(mrx, state.pending_release_point)
            if primed then
                state.release_nodraw_prime_pending = true
                state.release_nodraw_prime_time = now() + (cfg.release_nodraw_prime_hold or 0.10)
                state.release_time = state.release_nodraw_prime_time
                state.last_status = "release nodraw prime: " .. get_safe_room_name(state.active_safe_room_index)
                state.last_action = "release nodraw prime"
            else
                hold_hidden_stash(mrx, active_room)
                state.release_time = now() + 0.05
                state.last_status = "release nodraw prime retry"
                state.last_action = "release nodraw prime retry"
            end
        elseif now() < (state.release_nodraw_prime_time or -999.0) then
            state.last_status = "release nodraw settle: " .. get_safe_room_name(state.active_safe_room_index)
            state.last_action = "release nodraw settle"
        else
            local released = finish_release_mrx(
                mrx,
                state.pending_release_point,
                "released Mr. X: " .. get_safe_room_name(state.active_safe_room_index) .. " -> " .. tostring(state.pending_release_label)
            )
            if released then
                state.active_safe_room_index = nil
            end
        end

    else
        if run_cutoff_settle(mrx) then
            run_integrated_runtime_updates(player_pos, mrx, get_behavior_mrx_ref_pos() or state.mrx_draw_pos or state.mrx_pos)
            state.mrx_was_found_prev = true
            publish_debug_state()
            return
        end

        local behavior_mrx_pos = get_behavior_mrx_ref_pos()
        update_behavior(player_pos, player_go, behavior_mrx_pos)
        update_chase_stall_tracking(behavior_mrx_pos)
        local chase_broken = maybe_break_chase_stall(mrx, player_pos, player_go, behavior_mrx_pos)
        local cutoff_done = false
        local anchor_done = false
        if not chase_broken then
            cutoff_done = maybe_cutoff_teleport(mrx, player_pos, player_go, behavior_mrx_pos)
            if not cutoff_done then
                anchor_done = maybe_anchor_roam_move(mrx, player_pos, player_go, behavior_mrx_pos)
            end
        end
        if not chase_broken and not cutoff_done and not anchor_done then
            if state.behavior.mode == "SEARCH" then
                state.last_status = "searching: " .. tostring(state.behavior.search_target_label)
                state.last_action = "search"
            elseif state.behavior.mode == "INVESTIGATE" then
                state.last_status = "investigating: " .. tostring(state.behavior.last_stimulus)
                state.last_action = "investigate"
            elseif state.behavior.mode == "CHASE" then
                state.last_status = "chasing: " .. tostring(state.behavior.reason)
                state.last_action = "chase"
            elseif state.behavior.mode == "CUTOFF_ARMED" then
                state.last_status = "cutoff armed"
                state.last_action = "cutoff armed"
            else
                state.last_status = string.lower(tostring(state.behavior.mode))
                state.last_action = string.lower(tostring(state.behavior.mode))
            end
        end
    end

    if state.stashed or state.hidden or state.waiting_release or state.player_in_safe_room then
        state.mrx_pos = nil
        state.mrx_pos_source = "none"
    end

    run_integrated_runtime_updates(player_pos, mrx, get_behavior_mrx_ref_pos() or state.mrx_draw_pos or state.mrx_pos)
    state.prev_player_in_safe_room = state.player_in_safe_room
    state.mrx_was_found_prev = state.mrx_found
    publish_debug_state()
end)



re.on_draw_ui(function()
    if not cfg.debug_ui then return end

    if imgui.tree_node("Mr. X Mode Release") then
        imgui.text("build = MRX MODE RELEASE")
        imgui.text("scene = " .. tostring(state.scene_name))
        imgui.text("player in safe room = " .. tostring(state.player_in_safe_room))
        imgui.text("Mr. X found = " .. tostring(state.mrx_found))
        imgui.text("stashed = " .. tostring(state.stashed))
        imgui.text("hidden = " .. tostring(state.hidden))
        imgui.text("waiting release = " .. tostring(state.waiting_release))
        imgui.text("paused = " .. tostring(state.paused))
        imgui.text(string.format("clock delta = %.6f", state.last_clock_delta or 0.0))
        imgui.text("running timers = " .. tostring(state.last_running_timers))
        imgui.text("gui active with pause = " .. tostring(state.last_gui_active_with_pause))
        imgui.text("gui hud paused = " .. tostring(state.last_gui_hud_paused))
        imgui.text("gui visible type = " .. tostring(state.last_gui_visible_type))
        imgui.text("pause systems = " .. tostring(state.last_pause_systems))
        imgui.text("pause type bits = " .. tostring(state.last_pause_type_bits))
        imgui.text("attempted move = " .. tostring(state.attempted_move))
        imgui.text("move succeeded = " .. tostring(state.move_succeeded))
        imgui.text("active safe room = " .. get_safe_room_name(state.active_safe_room_index))
        imgui.text("pending release = " .. tostring(state.pending_release_label) .. " [" .. tostring(state.pending_release_family) .. "]")
        imgui.text("last release = " .. tostring(state.last_release_label) .. " [" .. tostring(state.last_release_family) .. "]")
        imgui.text("last status = " .. tostring(state.last_status))
        imgui.text("last action = " .. tostring(state.last_action))
        imgui.text("hide mode = " .. tostring(state.last_hide_mode))
        imgui.text("native reset = " .. tostring(state.last_native_reset))
        imgui.text("hold mode = " .. tostring(state.last_hold_mode))
        imgui.text("hold reason = " .. tostring(state.last_hold_reason))
        imgui.text(string.format("hold refresh age = %.2f", math.max(0.0, (state.last_hold_refresh_time or -999.0) < 0 and 0.0 or (now() - (state.last_hold_refresh_time or now())))))
        imgui.text(string.format("hold confirm age = %.2f", math.max(0.0, (state.last_hold_confirm_time or -999.0) < 0 and 0.0 or (now() - (state.last_hold_confirm_time or now())))))
        imgui.text("bootstrap until = " .. string.format("%.2f", state.bootstrap_until))
        imgui.text("scene guard until = " .. string.format("%.2f", state.scene_guard_until))

        if state.player_pos then
            imgui.text("player pos = " .. point_to_string(state.player_pos))
        end
        imgui.text(string.format("player speed xz = %.3f", state.player_speed_xz or 0.0))
        imgui.text(string.format("player step xz = %.3f", state.player_step_xz or 0.0))

        if state.mrx_pos then
            imgui.text("Mr. X logic pos = " .. point_to_string(state.mrx_pos))
        end
        if state.mrx_draw_pos then
            imgui.text("Mr. X draw pos = " .. point_to_string(state.mrx_draw_pos))
        end
        if state.last_live_confirmed_pos then
            imgui.text("Mr. X last live confirmed = " .. point_to_string(state.last_live_confirmed_pos))
        end
        imgui.text(string.format("Mr. X last live age = %.2f", math.max(0.0, (state.last_live_confirmed_time or -999.0) < 0 and 0.0 or (now() - (state.last_live_confirmed_time or now())))))
        imgui.text("Mr. X pos source = " .. tostring(state.mrx_pos_source))
        imgui.text("Mr. X draw source = " .. tostring(state.mrx_draw_source))
        imgui.text(string.format("Mr. X speed xz = %.3f", state.mrx_speed_xz or 0.0))
        imgui.text(string.format("Mr. X step xz = %.3f", state.mrx_step_xz or 0.0))

        imgui.separator()
        imgui.text("behavior mode = " .. tostring(state.behavior.mode))
        imgui.text("behavior reason = " .. tostring(state.behavior.reason))
        imgui.text("behavior noise = " .. tostring(state.behavior.noise_tier) .. string.format(" (%.2f)", state.behavior.noise_strength or 0.0))
        imgui.text("behavior stimulus = " .. tostring(state.behavior.last_stimulus))
        imgui.text("behavior search target = " .. tostring(state.behavior.search_target_zone) .. " -> " .. tostring(state.behavior.search_target_label))
        imgui.text("behavior target strategy = " .. tostring(state.behavior.search_target_strategy))
        imgui.text("behavior release search pending = " .. tostring(state.behavior.release_search_pending))
        if state.behavior.release_search_origin then
            imgui.text("behavior release search origin = " .. point_to_string(state.behavior.release_search_origin))
        end
        imgui.text("behavior planned target = " .. tostring(state.behavior.planned_target_zone) .. " -> " .. tostring(state.behavior.planned_target_label))
        imgui.text("behavior planned strategy = " .. tostring(state.behavior.planned_target_strategy))
        imgui.text(string.format("behavior planned age = %.2f", math.max(0.0, (state.behavior.planned_target_selected_at or -999.0) < 0 and 0.0 or (now() - (state.behavior.planned_target_selected_at or now())))))
        imgui.text("behavior committed target = " .. tostring(state.behavior.committed_target_zone) .. " -> " .. tostring(state.behavior.committed_target_label))
        imgui.text("behavior committed strategy = " .. tostring(state.behavior.committed_target_strategy))
        imgui.text("behavior committed reason = " .. tostring(state.behavior.committed_target_reason))
        imgui.text(string.format("behavior committed remaining = %.2f", math.max(0.0, (state.behavior.committed_target_fire_at or -999.0) - now())))
        imgui.text("behavior anchor result = " .. tostring(state.behavior.search_last_result))
        imgui.text("behavior anchor moves = " .. tostring(state.behavior.search_repositions))
        imgui.text("behavior last anchor = " .. tostring(state.behavior.search_last_destination))
        imgui.text("behavior pending anchor = " .. tostring(state.behavior.pending_anchor_mode))
        imgui.text(string.format("behavior pending delay = %.2f", state.behavior.pending_anchor_delay or 0.0))
        imgui.text(string.format("behavior pending remaining = %.2f", math.max(0.0, (state.behavior.pending_anchor_time or -999.0) - now())))
        imgui.text(string.format("behavior linger remaining = %.2f", math.max(0.0, (state.behavior.linger_until or -999.0) - now())))
        imgui.text("behavior last noise = " .. tostring(state.behavior.last_noise_kind))
        imgui.text("behavior escalation = " .. tostring(state.behavior.escalation_level))
        imgui.text(string.format("behavior grace remaining = %.2f", math.max(0.0, (state.behavior.reposition_grace_until or -999.0) - now())))
        imgui.text("behavior grace reason = " .. tostring(state.behavior.reposition_grace_reason))
        imgui.text(string.format("behavior investigate commit = %.2f", math.max(0.0, (state.behavior.investigate_commit_until or -999.0) - now())))
        imgui.text("behavior investigate commit reason = " .. tostring(state.behavior.investigate_commit_reason))
        imgui.text(string.format("behavior last known age = %.2f", math.max(0.0, now() - (state.behavior.last_player_known_time or -999.0))))
        imgui.text("behavior investigate decay reason = " .. tostring(state.behavior.investigate_decay_reason))
        imgui.text(string.format("behavior investigate decay age = %.2f", math.max(0.0, now() - (state.behavior.investigate_decay_last_time or -999.0))))
        imgui.text("behavior investigate zone last = " .. tostring(state.behavior.investigate_zone_last_name))
        imgui.text(string.format("behavior investigate zone last age = %.2f", math.max(0.0, now() - (state.behavior.investigate_zone_last_time or -999.0))))
        imgui.text("behavior investigate zone ignore = " .. tostring(state.behavior.investigate_zone_ignore_name))
        imgui.text(string.format("behavior investigate zone ignore remaining = %.2f", math.max(0.0, (state.behavior.investigate_zone_ignore_until or -999.0) - now())))
        imgui.text(string.format("behavior chase stall = %.2f", state.behavior.chase_stall_time or 0.0))
        imgui.text(string.format("behavior visual confirm = %.2f", math.max(0.0, (state.behavior.visual_confirm_since or -999.0) < 0 and 0.0 or (now() - (state.behavior.visual_confirm_since or now())))))
        imgui.text("investigate until = " .. string.format("%.2f", state.behavior.investigate_until or -999.0))
        imgui.text("chase until = " .. string.format("%.2f", state.behavior.chase_until or -999.0))

        imgui.separator()
        imgui.text("cutoff enabled = " .. tostring(cfg.cutoff.enabled))
        imgui.text("cutoff load = " .. tostring(cfg.cutoff.load_status))
        imgui.text("cutoff zone = " .. tostring(state.cutoff.zone_name))
        imgui.text("cutoff stall time = " .. string.format("%.2f", state.cutoff.stall_time))
        imgui.text("cutoff teleports = " .. tostring(state.cutoff.teleports))
        imgui.text("cutoff pending zone = " .. tostring(state.cutoff.pending_zone_name))
        imgui.text(string.format("cutoff suspend remaining = %.2f", math.max(0.0, (state.cutoff.suspend_until or -999.0) - now())))
        imgui.text("cutoff last destination = " .. tostring(state.cutoff.last_destination_label))
        imgui.text("cutoff current destination = " .. tostring(state.cutoff.destination_label))
        imgui.text("cutoff result = " .. tostring(state.cutoff.result))

        imgui.tree_pop()
    end
end)
