local MOD_DIRECTORY = g_currentModDirectory
local WHITE_DDS = MOD_DIRECTORY .. "gui/white.dds"
local PIN_DDS = MOD_DIRECTORY .. "gui/gpsPin.dds"
local ARROW_DDS = MOD_DIRECTORY .. "gui/directionArrow_straight.dds"
local ARROW_DDS_STRAIGHT = MOD_DIRECTORY .. "gui/directionArrow_straight.dds"
local ARROW_DDS_LEFT = MOD_DIRECTORY .. "gui/directionArrow_left.dds"
local ARROW_DDS_RIGHT = MOD_DIRECTORY .. "gui/directionArrow_right.dds"
local ARROW_DDS_REVERSE = MOD_DIRECTORY .. "gui/directionArrow_reverse.dds"
local overlayFailed = false

local cfg = {
    mapEnabled   = true,
    mapColorIdx  = 1,
    pinColorIdx  = 5, -- default orange waypoint pin
    mapLookIdx   = 12,
    mapThickIdx  = 2,
    arrowEnabled = true,
    roadEnabled  = false, -- maps-only build: 3D/world guidance disabled
    roadColorIdx = 1,
    roadLenIdx   = 5,
    roadThickIdx = 3,
    roadStartIdx = 1
}

local COLORS = {
    {key="wpgps_color_red",    r=1.00, g=0.00, b=0.00, pin="gui/gpsPin_red.dds"},
    {key="wpgps_color_green",  r=0.00, g=1.00, b=0.00, pin="gui/gpsPin_green.dds"},
    {key="wpgps_color_blue",   r=0.00, g=0.00, b=1.00, pin="gui/gpsPin_blue.dds"},
    {key="wpgps_color_white",  r=1.00, g=1.00, b=1.00, pin="gui/gpsPin_white.dds"},
    {key="wpgps_color_orange", r=1.00, g=0.45, b=0.00, pin="gui/gpsPin.dds"},
    {key="wpgps_color_cyan",   r=0.00, g=0.85, b=1.00, pin="gui/gpsPin_cyan.dds"},
    {key="wpgps_color_yellow", r=1.00, g=0.92, b=0.00, pin="gui/gpsPin_yellow.dds"},
    {key="wpgps_color_purple", r=0.70, g=0.25, b=1.00, pin="gui/gpsPin_purple.dds"},
    {key="wpgps_color_black",  r=0.02, g=0.02, b=0.02, pin="gui/gpsPin_black.dds"}
}

local MAP_LOOK = {100,150,200,250,300,350,400,450,500,550,600,math.huge}
local MAP_LOOK_K = {}
for _, v in ipairs(MAP_LOOK) do
    MAP_LOOK_K[#MAP_LOOK_K + 1] = v == math.huge and "wpgps_vis_full" or ("wpgps_vis_" .. v .. "m")
end

local MAP_THICK = {2, 3, 4, 5, 6}
local MAP_THICK_K = {}
for _, v in ipairs(MAP_THICK) do
    MAP_THICK_K[#MAP_THICK_K + 1] = "wpgps_thick_" .. v .. "px"
end

local ROAD_LEN = {50, 100, 150, 200, 300, 500}
local ROAD_LEN_K = {}
for _, v in ipairs(ROAD_LEN) do
    ROAD_LEN_K[#ROAD_LEN_K + 1] = "wpgps_road_len_" .. v .. "m"
end

local ROAD_THICK_COUNTS = {1, 2, 3, 5, 9}
local ROAD_THICK_K = {}
for _, v in ipairs(ROAD_THICK_COUNTS) do
    ROAD_THICK_K[#ROAD_THICK_K + 1] = "wpgps_road_lines_" .. v
end

local ROAD_TOTAL_WIDTH = 0.20

local ROAD_START = {0, 5, 10, 20, 30, 50}
local ROAD_START_K = {}
for _, v in ipairs(ROAD_START) do
    ROAD_START_K[#ROAD_START_K + 1] = "wpgps_road_start_" .. v .. "m"
end

local ROUTE_SAMPLE_DISTANCE = 10.0
-- Low-update/event-driven routing. Building the road route is the expensive part,
-- so do not rebuild just because the vehicle has driven away from the original start.
-- Rebuild only when a target is set, the route is missing, or the vehicle is clearly off-route.
local RECALC_INTERVAL = 18.0
local DEVIATION_THRESH = 95.0
local TARGET_REACHED_DISTANCE = 10.0
local STEERING_LOOKAHEAD = 12.0
local STEERING_SHARP_TURN_ANGLE = 42.0
local STEERING_SHARP_TURN_MIN_AHEAD = 2.5
-- Release turn-speed defaults. Keep local so older save/settings files cannot leave these nil.
local WPGPS_TURN_SCAN_DISTANCE = 110.0
local WPGPS_TURN_SPEED_HARD = 10.0
local WPGPS_TURN_SPEED_MEDIUM = 16.0
local WPGPS_TURN_SPEED_SOFT = 25.0
local WPGPS_TURN_SPEED_STRAIGHT = nil
local WPGPS_COLLISION_SCAN_DISTANCE = 18.0
local WPGPS_COLLISION_LATERAL_WIDTH = 4.0
local WPGPS_COLLISION_SLOW_SPEED = 6.0
local ROAD_Y_OFFSET = 0.3
local UI_ROUTE_RESOLVE_INTERVAL = 0.75
local TARGET_CHECK_INTERVAL = 1.00


-- Standalone road-biased routing. This is not an WayPoint GPS route-graph style road network; it
-- builds a light grid path and strongly prefers non-field corridors, which makes
-- the route follow map roads/lanes instead of drawing straight across crops.
local ROAD_ROUTE_BASE_GRID = 6.0
local ROAD_ROUTE_MAX_GRID = 20.0
local ROAD_ROUTE_SEARCH_MARGIN = 340.0
local ROAD_ROUTE_MAX_CELLS = 85000
local ROAD_ROUTE_MAX_ITERATIONS = 260000

-- Strict road routing costs. The first road-biased build only made fields
-- expensive, so large open non-field areas could still be chosen as a diagonal
-- shortcut. This build separates true road-like corridors from generic open
-- land and makes shortcuts very unattractive.
local ROAD_ROUTE_ROAD_COST = 1.0
local ROAD_ROUTE_OPEN_COST = 6500.0
local ROAD_ROUTE_FIELD_COST = 22000.0
local ROAD_ROUTE_WATER_COST = 250000.0
local ROAD_ROUTE_ENDPOINT_COST = 8.0
local ROAD_ROUTE_ENDPOINT_RADIUS = 32.0
local ROAD_ROUTE_HEURISTIC = 0.90
local ROAD_ROUTE_RELAXED_HEURISTIC = 1.08
local ROAD_ROUTE_SLOPE_COST = 2.2
local ROAD_ROUTE_SIDE_SAMPLE = 14.0
local ROAD_ROUTE_FORWARD_SAMPLE = 18.0
local ROAD_ROUTE_FAR_SIDE_SAMPLE = 30.0
local ROAD_ROUTE_MATERIAL_SCAN_RADIUS = 5.0
local ROAD_ROUTE_MATERIAL_SCAN_STEP = 2.5
local ROAD_ROUTE_VISUAL_SCAN_RADIUS = 5.0
local ROAD_ROUTE_VISUAL_SCAN_STEP = 2.5
local ROAD_ROUTE_DETOUR_FACTOR = 2.25
local ROAD_ROUTE_RELAXED_OPEN_COST = 650.0
local ROAD_ROUTE_RELAXED_FIELD_COST = 2200.0
local ROAD_ROUTE_RELAXED_ENDPOINT_RADIUS = 45.0
local ROAD_ROUTE_DIRECTNESS_SCALE = 150.0
local ROAD_ROUTE_DIRECTNESS_COST = 0.45
local ROAD_ROUTE_RELAXED_DIRECTNESS_COST = 0.16

-- Anti-detour mode is used only when the strict road finder creates a huge loop.
-- It still prefers detected roads, but it allows short ambiguous/field connectors
-- so missing side-road surfaces on custom maps do not send the route around half
-- the map.
local ROAD_ROUTE_ANTI_DETOUR_FACTOR = 1.42
local ROAD_ROUTE_CONNECTOR_ROAD_COST = 0.65
local ROAD_ROUTE_CONNECTOR_OPEN_COST = 420.0
local ROAD_ROUTE_CONNECTOR_FIELD_COST = 2400.0
local ROAD_ROUTE_CONNECTOR_WATER_COST = 250000.0
local ROAD_ROUTE_CONNECTOR_ENDPOINT_RADIUS = 55.0
local ROAD_ROUTE_CONNECTOR_HEURISTIC = 1.25
local ROAD_ROUTE_CONNECTOR_DIRECTNESS_COST = 0.35
local ROAD_ROUTE_CONNECTOR_MARGIN_FACTOR = 0.34

-- AI road graph routing. This uses the map's traffic/AI road splines when they
-- are available, which is much closer to how base-game AI knows drivable roads
-- than reading terrain colors. The graph is built lazily once per map, then
-- reused for Ctrl-click targets. If no spline graph is found, the old terrain
-- road detector remains the fallback.
local AI_ROAD_GRAPH_ENABLED = true
local AI_ROAD_SAMPLE_DISTANCE = 18.0
local AI_ROAD_INTERSECTION_RADIUS = 24.0
local AI_ROAD_BUCKET_SIZE = 24.0
local AI_ROAD_ENDPOINT_SEARCH_RADIUS = 460.0
local AI_ROAD_START_CONNECTOR_LIMIT = 280.0
local AI_ROAD_TARGET_CONNECTOR_LIMIT = 320.0
local AI_ROAD_MAX_SPLINES = 700
local AI_ROAD_MAX_NODES = 130000
local AI_ROAD_MAX_SCENE_NODES = 90000
local AI_ROAD_MAX_ASTAR_ITERATIONS = 80000
-- Use several nearby road nodes on each end instead of only the single closest
-- node. The closest node can be on a disconnected yard/river helper spline;
-- choosing from a candidate set keeps the route on the connected AI road network.
local AI_ROAD_ENDPOINT_CANDIDATE_LIMIT = 12
-- Route graphs are dense. Searching hundreds of meters around the click
-- and running terrain/water/connector checks on every node caused multi-second
-- freezes on Hutan and large route graph maps. Keep the broad AI spline search for
-- fallback graphs, but use a much tighter, prefiltered endpoint search for route graph.
local WPG_ROUTE_ENDPOINT_SEARCH_RADIUS = 220.0
local WPG_ROUTE_ENDPOINT_CANDIDATE_LIMIT = 10
local WPG_ROUTE_ENDPOINT_PREFILTER_LIMIT = 120
local WPG_ROUTE_ASTAR_SOFT_ITERATION_LIMIT = 85000
-- GPS-only shortest path tuning.  Source layers still matter, but they should not
-- overpower the actual shortest usable route.  This keeps the line from diving
-- through yard/helper routes when a normal road continues straight ahead.
local WPG_GPS_PATH_TIGHTENING_ENABLED = true
local WPG_GPS_REVERSE_FALLBACK_ENABLED = true
local WPG_GPS_REVERSE_FALLBACK_FACTOR = 1.18
local WPG_GPS_REVERSE_FALLBACK_EXTRA = 18.0
local WPG_GPS_AUTODRIVE_COST_FACTOR = 0.82
local WPG_GPS_MANUAL_COST_FACTOR = 0.86
local WPG_GPS_COURSEPACK_COST_FACTOR = 0.88
local WPG_GPS_UNKNOWN_HELPER_PENALTY = 1150.0
local WPG_GPS_CORRIDOR_BASE = 55.0
local WPG_GPS_CORRIDOR_FACTOR = 0.10
local WPG_GPS_CORRIDOR_MAX = 180.0
local WPG_GPS_CORRIDOR_PENALTY = 3.5
local WPG_GPS_AWAY_PENALTY = 2.4
-- Road-adherence tuning for GPS-only display. WayPoint GPS/XML route
-- graphs can contain yard/field/trail lines.  The GPS line should still choose
-- actual roads first, even when a field edge is mathematically shorter.
local WPG_GPS_ROAD_ADHERENCE_ENABLED = true
local WPG_GPS_EDGE_ROAD_SAMPLE = 6.0
local WPG_GPS_EDGE_OPEN_PENALTY = 1350.0
local WPG_GPS_EDGE_FIELD_PENALTY = 45000.0
local WPG_GPS_EDGE_WATER_BLOCKED_PENALTY = 350000.0
local WPG_GPS_EDGE_NONROAD_RUN_HARD = 44.0
local WPG_GPS_EDGE_NONROAD_HARD_PENALTY = 75000.0
local WPG_GPS_EDGE_NONROAD_RUN_FACTOR = 2600.0
local WPG_GPS_EDGE_NEAR_ENDPOINT_RADIUS = 34.0
local MINIMAP_FULL_DRAW_CAP = 650.0
local AI_ROAD_ENDPOINT_SNAP_PENALTY = 0.45
local AI_ROAD_ENDPOINT_WATER_PENALTY = 12000.0

-- Some maps expose AI drivable roads as separate spline fragments. They look
-- connected visually, but the sampled spline nodes can miss each other at
-- intersections, bridges, and junction transitions. After the normal intersection
-- pass, stitch nearby disconnected components so the route can cross to the
-- other side of rivers using real road/bridge nodes instead of falling back to
-- terrain shortcuts.
local AI_ROAD_COMPONENT_STITCH_RADIUS = 58.0
local AI_ROAD_COMPONENT_STITCH_MAX_EDGES = 900
local AI_ROAD_COMPONENT_STITCH_MAX_COST = 72.0

-- Bridge/island repair pass. Some maps split AI roads at bridge decks or island
-- transitions even though the vehicle AI can drive across them. A normal long
-- component stitch is risky because it could create river shortcuts, so only
-- allow longer stitches when the gap is close to a scene node whose name looks
-- like a bridge/ford/causeway/ferry crossing.
local AI_ROAD_BRIDGE_STITCH_RADIUS = 340.0
local AI_ROAD_BRIDGE_STITCH_MAX_COST = 360.0
local AI_ROAD_BRIDGE_STITCH_MAX_EDGES = 1000
local AI_ROAD_BRIDGE_OBJECT_RADIUS = 285.0

-- Endpoint/off-road connector tuning. AI splines are great for the main route,
-- but targets often sit on yards, fields, side paths, or trails that are not
-- part of the traffic spline graph. These values let the route leave/enter
-- the AI graph with a local terrain/path search instead of one long straight
-- off-road connector.
local AI_ROAD_ENDPOINT_GRAPH_RADIUS = 28.0
local AI_ROAD_OFFROAD_CONNECTOR_MIN = 26.0
local AI_ROAD_OFFROAD_CONNECTOR_MAX_FACTOR = 3.25
local AI_ROAD_OFFROAD_MIN_ROAD_GAIN = 0.10
-- Never draw long straight start/target connectors to the AI graph. If the
-- connector cannot be routed locally, trim it instead of cutting across rivers
-- or open land. This preserves the AI-road path and prevents water shortcuts.
local AI_ROAD_DIRECT_CONNECTOR_MAX = 34.0

-- Optional bundled WayPoint GPS route graph support. These files are used as a
-- high-confidence road/lane graph before falling back to scanned AI splines.
-- Add more base-map configs here as they are prepared.
local WPG_ROUTE_GRAPH_ENABLED = true
local WPG_ROUTE_MAX_EDGE_COST = 90.0
-- WayPoint GPS route graphs can contain ferry/water helper lanes or accidental straight
-- links. WayPoint GPS is a road GPS, so route graph edges that sample as water are
-- removed unless the segment is tightly attached to a named bridge/causeway.
local WPG_ROUTE_WATER_EDGE_GUARD_ENABLED = true
local WPG_ROUTE_WATER_EDGE_SAMPLE = 5.0
local WPG_ROUTE_WATER_BRIDGE_RADIUS = 62.0
-- WayPoint GPS route graphs also contain field, forestry, unload, wait, and yard lanes.
-- Penalize those edges hard so the public-road graph wins, and let the final
-- route validator reject anything that still tries to shortcut across fields.
local WPG_ROUTE_FIELD_EDGE_GUARD_ENABLED = true
local WPG_ROUTE_FIELD_EDGE_SAMPLE = 8.0
-- Hutan Pantai WayPoint GPS route graphs include field-work lanes and yard helper lanes.
-- Keep those out of the GPS graph by treating true field samples as almost
-- blocked while leaving normal roads/bridges usable.
local WPG_ROUTE_FIELD_EDGE_PENALTY = 24000.0
local WPG_ROUTE_BLOCKED_EDGE_PENALTY = 50000.0
local WPG_ROUTE_OPEN_EDGE_PENALTY = 45.0
local WPG_ROUTE_HEAVY_FIELD_RATIO = 0.08
local WPG_ROUTE_HEAVY_FIELD_MIN_LEN = 12.0
local WPG_ROUTE_NONROAD_NODE_PENALTY = 30000.0
local WPG_ROUTE_FIELD_NODE_PENALTY = 90000.0
local WPG_ROUTE_MAX_FIELD_RUN = 36.0
local WPG_ROUTE_MAX_FIELD_RATIO = 0.025
-- Route graph field/forestry destination branches can be connected on both ends
-- and A* may use them as shortcuts. Keep them usable near the start/target,
-- but strongly avoid them as through-roads.
local WPG_ROUTE_FIELD_BRANCH_DISTANCE = 120.0
local WPG_ROUTE_FIELD_BRANCH_ENDPOINT_RADIUS = 95.0
local WPG_ROUTE_FIELD_BRANCH_PENALTY = 85000.0

-- Prefer safer junctions over the mathematically shortest corner cut. This keeps
-- Hutan's tight bends and sharp route-graph joins from being chosen as shortcuts.
local WPG_ROUTE_TURN_PENALTY_ENABLED = true
local WPG_ROUTE_TURN_SOFT_DEG = 28.0
local WPG_ROUTE_TURN_SHARP_DEG = 58.0
local WPG_ROUTE_TURN_SOFT_PENALTY = 95.0
local WPG_ROUTE_TURN_SHARP_PENALTY = 520.0
local WPG_ROUTE_TURN_HARD_PENALTY = 1800.0
-- Extra intersection/junction stability. Route graphs often have several nodes very
-- close together at road crossings; without a heading preference A* may briefly dive
-- down a side road because that branch has a slightly lower distance score. Penalize
-- sharp branch choices unless the target actually requires them.
local WPG_ROUTE_CONTINUATION_ENABLED = true
local WPG_ROUTE_CONTINUATION_MIN_DEG = 18.0
local WPG_ROUTE_CONTINUATION_DEG_FACTOR = 36.0
local WPG_ROUTE_JUNCTION_EXTRA_FACTOR = 3.0
local WPG_ROUTE_BACKTRACK_PENALTY = 4500.0
local WPG_ROUTE_CONFIGS = {
    {key="Riverbend_Springs", mapHint="mapUS", aliases={"riverbend", "riverbendsprings", "mapus"}, file=MOD_DIRECTORY .. "routes/Riverbend_Springs.wpg.xml"},
    {key="Hutan_Pantai", mapHint="mapAS", aliases={"hutan", "hutanpantai", "mapas"}, file=MOD_DIRECTORY .. "routes/Hutan_Pantai.wpg.xml"},
    {key="Zielonka", mapHint="mapEU", aliases={"zielonka", "mapeu"}, file=MOD_DIRECTORY .. "routes/Zielonka.wpg.xml"},
    -- Never use generic "SampleModMap" as an alias: many custom maps expose that internal class name.
    -- BR-163 is matched by its real title/mod id only, so Solek/other SampleModMap maps do not load this graph.
    {key="BR-163_MS__Brazil", mapHint="BR-163 MS, Brazil", aliases={"br163", "br163brazil", "br163msbrazil", "br163msbrasilien", "fs25br163brazil"}, file=MOD_DIRECTORY .. "routes/BR_163_MS_Brazil.wpg.xml"},
    {key="Kinlaig", mapHint="Kinlaig", aliases={"kinlaig", "highlands", "highlandsfishing"}, file=MOD_DIRECTORY .. "routes/Kinlaig.wpg.xml"},
    {key="Elm_Creek", mapHint="Elmcreek", aliases={"elmcreek", "elmcreek25", "elm_creek"}, file=MOD_DIRECTORY .. "routes/Elm_Creek.wpg.xml"},
    {key="Diepholz_25", mapHint="Diepholz", aliases={"diepholz", "diepholz25"}, file=MOD_DIRECTORY .. "routes/Diepholz.wpg.xml"},
    {key="Leybucht_4Fach", mapHint="Leybucht", aliases={"leybucht", "leybucht4fach", "leybucht4x"}, file=MOD_DIRECTORY .. "routes/Leybucht_4x.wpg.xml"},
    {key="DEUTSCHLAND", mapHint="Deutschland", aliases={"deutschland", "deutschlandmap"}, file=MOD_DIRECTORY .. "routes/Deutschland.wpg.xml"},
    {key="Wollschow", mapHint="Wollschow", aliases={"wollschow"}, file=MOD_DIRECTORY .. "routes/Wollschow.wpg.xml"},
    {key="Negast_4X", mapHint="Negast", aliases={"negast", "negast4x"}, file=MOD_DIRECTORY .. "routes/Negast_4x.wpg.xml"},
    {key="Bohemia_Country_25", mapHint="Bohemia", aliases={"bohemia", "bohemiacountry", "bohemiacountry25"}, file=MOD_DIRECTORY .. "routes/Bohemia_Country_25.wpg.xml"},
    {key="Gülzow", mapHint="Guelzow", aliases={"guelzow", "gulzow", "gülzow"}, file=MOD_DIRECTORY .. "routes/Guelzow.wpg.xml"},
    {key="HOF_BERGMANN", mapHint="Hof Bergmann", aliases={"hofbergmann", "hof_bergmann"}, file=MOD_DIRECTORY .. "routes/Hof_Bergmann.wpg.xml"},
    {key="LS25_Thüringen_2_0_16x", mapHint="Thuringen", aliases={"thuringen", "thueringen", "thüringen", "ls25thuringen", "ls25thueringen", "ls25thüringen"}, file=MOD_DIRECTORY .. "routes/Thuringen_16x.wpg.xml"},
    {key="Castile_And_Leon", mapHint="Castile", aliases={"castile", "leon", "castileandleon", "castileandlleon"}, file=MOD_DIRECTORY .. "routes/Castile_And_Leon.wpg.xml"},
    {key="Maypole_Farm", mapHint="Maypole", aliases={"maypole", "maypolefarm"}, file=MOD_DIRECTORY .. "routes/Maypole_Farm.wpg.xml"},
    {key="Helden", mapHint="Helden", aliases={"helden"}, file=MOD_DIRECTORY .. "routes/Helden.wpg.xml"},
    {key="Am_Nord-Ostsee-Kanal", mapHint="Nord-Ostsee", aliases={"amnordostseekanal", "nordostseekanal", "nordostsee", "ostseekanal"}, file=MOD_DIRECTORY .. "routes/Am_Nord_Ostsee_Kanal.wpg.xml"},
    {key="Drensteinfurt", mapHint="Drensteinfurt", aliases={"drensteinfurt", "dreinsteinfurt"}, file=MOD_DIRECTORY .. "routes/Drensteinfurt.wpg.xml"},
    {key="Gaúcho_Fieds", mapHint="Gaucho", aliases={"gaucho", "gauchofieds", "gauchofields", "camposgauchos", "camposgaucho"}, file=MOD_DIRECTORY .. "routes/Gaucho_Fields.wpg.xml"},
    {key="Starbuck__MN", mapHint="Starbuck", aliases={"starbuck", "starbuckmn", "starbuck_mn"}, file=MOD_DIRECTORY .. "routes/Starbuck_MN.wpg.xml"},
    {key="Valachy", mapHint="Valachy", aliases={"valachy"}, file=MOD_DIRECTORY .. "routes/Valachy.wpg.xml"},
    {key="Golden_Lands", mapHint="Golden Lands", aliases={"goldenlands", "golden_lands", "goldenlandsmap"}, file=MOD_DIRECTORY .. "routes/Golden_Lands.wpg.xml"},
    {key="Karpatenlandschaft", mapHint="Carpathian", aliases={"karpaten", "karpatenlandschaft", "carpathian", "carpathiancountryside"}, file=MOD_DIRECTORY .. "routes/Carpathian_Countryside.wpg.xml"},
    {key="Almosta_Farm", mapHint="Almosta Farm", aliases={"almostafarm", "adalmostafarm"}, file=MOD_DIRECTORY .. "routes/Almosta_Farm.wpg.xml"},
    {key="Ausseerland", mapHint="Ausseerland", aliases={"ausseerland", "adausseerland4x", "ausseerland4x"}, file=MOD_DIRECTORY .. "routes/Ausseerland.wpg.xml"},
    {key="Klattenhof", mapHint="Klattenhof", aliases={"klattenhof", "adfs25klattenhofadasunzipme", "adklattenhofadasunzipme", "fs25klattenhofadasunzipme"}, file=MOD_DIRECTORY .. "routes/Klattenhof.wpg.xml"},
    {key="Lunow", mapHint="Lunow", aliases={"lunow", "adfs25lunowwelten11unzipme", "adlunowwelten11unzipme", "fs25lunowwelten11unzipme"}, file=MOD_DIRECTORY .. "routes/Lunow.wpg.xml"},
    {key="Geistal", mapHint="Geistal", aliases={"geistal", "adgeistal"}, file=MOD_DIRECTORY .. "routes/Geistal.wpg.xml"},
    {key="Goldcrest_Valley", mapHint="Goldcrest Valley", aliases={"goldcrestvalley", "adgoldcrestvalley", "goldcrest"}, file=MOD_DIRECTORY .. "routes/Goldcrest_Valley.wpg.xml"},
    {key="Norddeutsches_Land", mapHint="Norddeutsches Land", aliases={"norddeutschesland", "adkursndlv2entpacken", "kursndlv2entpacken", "ndl"}, file=MOD_DIRECTORY .. "routes/Norddeutsches_Land.wpg.xml"},
    {key="Mediterranes_Italien", mapHint="Mediterranes Italien", aliases={"mediterranesitalien", "admediterranesitalien"}, file=MOD_DIRECTORY .. "routes/Mediterranes_Italien.wpg.xml"},
    {key="FS25_Dondiego_Map", mapHint="FS25 Dondiego Map", aliases={"fs25dondiegomap", "addondiegomap", "dondiego", "dondiegomap"}, file=MOD_DIRECTORY .. "routes/FS25_Dondiego_Map.wpg.xml"},
    {key="The_Mechet", mapHint="The Mechet", aliases={"themechet", "adkursthemechet"}, file=MOD_DIRECTORY .. "routes/The_Mechet.wpg.xml"},
    {key="FrechDachs_Map_4fach", mapHint="FrechDachs Map 4fach", aliases={"frechdachsmap4fach", "adkursefrechdachsmapx4", "frechdachs", "frechdachsmap", "frechdachsmap4x"}, file=MOD_DIRECTORY .. "routes/FrechDachs_Map_4fach.wpg.xml"},
    {key="Münsinger_Alb", mapHint="Munsinger Alb", aliases={"munsingeralb", "admuensinger", "muensinger", "munsinger", "muensingeralb"}, file=MOD_DIRECTORY .. "routes/Munsinger_Alb.wpg.xml"},
    {key="FS25_Riverbend_Springs_4X_Edited_By_Stevie", mapHint="FS25 Riverbend Springs 4X Edited By Stevie", aliases={"fs25riverbendsprings4xeditedbystevie", "adriverbendsprings4xeditedbystevie"}, file=MOD_DIRECTORY .. "routes/FS25_Riverbend_Springs_4X_Edited_By_Stevie.wpg.xml"},
    {key="Altkirch_im_Elsass", mapHint="Altkirch im Elsass", aliases={"altkirchimelsass", "altkirchadfurv1005", "altkirch", "elsass"}, file=MOD_DIRECTORY .. "routes/Altkirch_im_Elsass.wpg.xml"},
    {key="В_ячеславка", mapHint="RouteGraph", aliases={"routegraph", "vyacheslavka", "vjaclavka", "vyacheslavka25"}, file=MOD_DIRECTORY .. "routes/RouteGraph.wpg.xml"},
    {key="Denman__Nebraska", mapHint="Denman Nebraska", aliases={"denmannebraska", "denman", "nebraska"}, file=MOD_DIRECTORY .. "routes/Denman_Nebraska.wpg.xml"},
    {key="Dunareni_X4", mapHint="Dunareni X4", aliases={"dunarenix4", "dunareni", "dunareni4x"}, file=MOD_DIRECTORY .. "routes/Dunareni_X4.wpg.xml"},
    {key="Kansas_16x", mapHint="Kansas 16x", aliases={"kansas16x", "kansas"}, file=MOD_DIRECTORY .. "routes/Kansas_16x.wpg.xml"},
    {key="Great_Grandfathers_Farm", mapHint="Great Grandfathers Farm", aliases={"greatgrandfathersfarm"}, file=MOD_DIRECTORY .. "routes/Great_Grandfathers_Farm.wpg.xml"},
    {key="Terre_Haute_Agriculture", mapHint="Terre Haute Agriculture", aliases={"terrehauteagriculture"}, file=MOD_DIRECTORY .. "routes/Terre_Haute_Agriculture.wpg.xml"},
    {key="FrechDachs_Map_4x", mapHint="FrechDachs Map 4x", aliases={"frechdachsmap4x", "frechdachs", "frechdachsmap", "frechdachsmap4fach"}, file=MOD_DIRECTORY .. "routes/FrechDachs_Map_4x.wpg.xml"},
    {key="De_Friese_Wouden", mapHint="De Friese Wouden", aliases={"defriesewouden"}, file=MOD_DIRECTORY .. "routes/De_Friese_Wouden.wpg.xml"},
    {key="Gage__Kentucky_(Row_Crop_System)", mapHint="Gage Kentucky Row Crop System", aliases={"gagekentuckyrowcropsystem", "fs25gagekentuckyv10ad", "gagekentuckyv10ad", "gagekentucky", "kentucky"}, file=MOD_DIRECTORY .. "routes/Gage_Kentucky_Row_Crop_System.wpg.xml"},
    {key="Oberthal", mapHint="Oberthal", aliases={"oberthal", "ls25adkursoberthal"}, file=MOD_DIRECTORY .. "routes/Oberthal.wpg.xml"},
    {key="В_ячеславка", mapHint="RouteGraph", aliases={"routegraph", "vyacheslavka", "vjaclavka", "vyacheslavka25"}, file=MOD_DIRECTORY .. "routes/RouteGraph_2.wpg.xml"},
    -- Additional bundled route graphs converted from AutoDrive courses.
    {key="4_Brücken", mapHint="4 Brücken", aliases={"4brcken", "fourbridges"}, file=MOD_DIRECTORY .. "routes/4_Brucken.wpg.xml"},
    {key="AgroForests", mapHint="AgroForests", aliases={"agroforests", "agroforestsbysetkasimulators", "agroforestssetkasimulators"}, file=MOD_DIRECTORY .. "routes/AgroForests.wpg.xml"},
    {key="Alliklepa", mapHint="Alliklepa", aliases={"alliklepa", "alliklepabycarltg", "alliklepacarltg"}, file=MOD_DIRECTORY .. "routes/Alliklepa.wpg.xml"},
    {key="Alma__Missouri", mapHint="Alma Missouri", aliases={"almamissouri", "almamissoribycelobuki", "almamissori", "almamissoricelobuki"}, file=MOD_DIRECTORY .. "routes/Alma_Missouri.wpg.xml"},
    {key="American_Farmlands_-_Creators_Edition", mapHint="American Farmlands - Creators Edition", aliases={"americanfarmlandscreatorsedition", "americanfarmlandsbyasylummodding", "americanfarmlands", "americanfarmlandsasylummodding"}, file=MOD_DIRECTORY .. "routes/American_Farmlands_Creators_Edition.wpg.xml"},
    {key="Angela_Whispering_Hollow", mapHint="Angela Whispering Hollow", aliases={"angelawhisperinghollow", "angelawhisperinghollowbymrtbone39shantyboys", "angelawhisperinghollowmrtbone39shantyboys"}, file=MOD_DIRECTORY .. "routes/Angela_Whispering_Hollow.wpg.xml"},
    {key="Arkansas_4X", mapHint="Arkansas 4X", aliases={"arkansas4x", "arkansas4xbysamualbatistamodding", "arkansas4xsamualbatistamodding"}, file=MOD_DIRECTORY .. "routes/Arkansas_4X.wpg.xml"},
    {key="Arkansas_Of_American", mapHint="Arkansas Of American", aliases={"arkansasofamerican", "arkansasofamericanbysamuelbatistamodding", "arkansasofamericansamuelbatistamodding"}, file=MOD_DIRECTORY .. "routes/Arkansas_Of_American.wpg.xml"},
    {key="Ashton_Corners__WI", mapHint="Ashton Corners WI", aliases={"ashtoncornerswi", "ashtoncornersbymrgmapping", "ashtoncorners", "ashtoncornersmrgmapping"}, file=MOD_DIRECTORY .. "routes/Ashton_Corners_WI.wpg.xml"},
    {key="Back_Country", mapHint="Back Country", aliases={"backcountry", "backcountrybyrgcgaming", "backcountryrgcgaming"}, file=MOD_DIRECTORY .. "routes/Back_Country.wpg.xml"},
    {key="Back_Roads_County", mapHint="Back Roads County", aliases={"backroadscounty", "backroadscounty1xbyfsgmoddingfsgtazman", "backroadscounty1x", "backroadscounty1xbyfsgmodding", "backroadscounty1xfsgmoddingfsgtazman"}, file=MOD_DIRECTORY .. "routes/Back_Roads_County.wpg.xml"},
    {key="Back_Roads_County_4x", mapHint="Back Roads County 4x", aliases={"backroadscounty4x", "backroadscounty4xbyfsgmoddingfsgtazman", "backroadscounty4xfsgmoddingfsgtazman"}, file=MOD_DIRECTORY .. "routes/Back_Roads_County_4x.wpg.xml"},
    {key="Bevington_Farm_Scroft", mapHint="Bevington Farm Scroft", aliases={"bevingtonfarmscroft", "bevingtonfarmoakbridgebyscroftoxygendavid", "bevingtonfarmoakbridge", "bevingtonfarmoakbridgescroftoxygendavid"}, file=MOD_DIRECTORY .. "routes/Bevington_Farm_Scroft.wpg.xml"},
    {key="Blue_Island_Forest", mapHint="Blue Island Forest", aliases={"blueislandforest", "blueislandforestmrtbone39shantyboymapcreations", "blueislandforestmrtbone39shantyboycreations"}, file=MOD_DIRECTORY .. "routes/Blue_Island_Forest.wpg.xml"},
    {key="Calm_Lands", mapHint="Calm Lands", aliases={"calmlands", "calmlandsbyershaba", "calmlandsershaba"}, file=MOD_DIRECTORY .. "routes/Calm_Lands.wpg.xml"},
    {key="Calmsden_Farm", mapHint="Calmsden Farm", aliases={"calmsdenfarm", "calmsdenfarmoakbridgefarmbyoxygendavid", "calmsdenfarmoakbridgefarm", "calmsdenfarmoakbridgefarmoxygendavid"}, file=MOD_DIRECTORY .. "routes/Calmsden_Farm.wpg.xml"},
    {key="Chambergo_2K25", mapHint="Chambergo 2K25", aliases={"chambergo2k25"}, file=MOD_DIRECTORY .. "routes/Chambergo_2K25.wpg.xml"},
    {key="ClairView_Farm", mapHint="ClairView Farm", aliases={"clairviewfarm", "clairviewfarmhocks23fsmapping"}, file=MOD_DIRECTORY .. "routes/ClairView_Farm.wpg.xml"},
    {key="Courtright_Line", mapHint="Courtright Line", aliases={"courtrightline", "courtrightlinebyfsgmoddingdavar", "courtrightlinefsgmoddingdavar"}, file=MOD_DIRECTORY .. "routes/Courtright_Line.wpg.xml"},
    {key="Cows_Farm", mapHint="Cows Farm", aliases={"cowsfarm", "cowfarm"}, file=MOD_DIRECTORY .. "routes/Cows_Farm.wpg.xml"},
    {key="Daheim", mapHint="Daheim", aliases={"daheim", "daheimbyka77e", "daheimka77e"}, file=MOD_DIRECTORY .. "routes/Daheim.wpg.xml"},
    {key="Dakota_4x", mapHint="Dakota 4x", aliases={"dakota4x", "dakota4xbybinksfarm", "dakota4xbinksfarm"}, file=MOD_DIRECTORY .. "routes/Dakota_4x.wpg.xml"},
    {key="Dark_Forest_The_Final_Chapter", mapHint="Dark Forest The Final Chapter", aliases={"darkforestthefinalchapter", "darkforestthefinalchapterbymrtbone39shantyboys", "darkforestthefinalchaptermrtbone39shantyboys"}, file=MOD_DIRECTORY .. "routes/Dark_Forest_The_Final_Chapter.wpg.xml"},
    {key="Dedelow_X4_in_GDR", mapHint="Dedelow X4 in GDR", aliases={"dedelowx4ingdr", "dedelowx4ingdrbyh3llrviz3r", "dedelowx4ingdrh3llrviz3r"}, file=MOD_DIRECTORY .. "routes/Dedelow_X4_in_GDR.wpg.xml"},
    {key="Dyersville", mapHint="Dyersville", aliases={"dyersville", "dyersvillebybronkematoys", "dyersvillebronkematoys"}, file=MOD_DIRECTORY .. "routes/Dyersville.wpg.xml"},
    {key="East_Vineland__NJ_USA", mapHint="East Vineland NJ USA", aliases={"eastvinelandnjusa", "eastvinelandnj"}, file=MOD_DIRECTORY .. "routes/East_Vineland_NJ_USA.wpg.xml"},
    {key="Elk_River", mapHint="Elk River", aliases={"elkriver", "elkriverbyelkrivermodding", "elkriverelkrivermodding"}, file=MOD_DIRECTORY .. "routes/Elk_River.wpg.xml"},
    {key="Erlengrat_2k25", mapHint="Erlengrat 2k25", aliases={"erlengrat2k25", "fs25erlengratautodrivecourse", "fs25erlengrat", "erlengrat"}, file=MOD_DIRECTORY .. "routes/Erlengrat_2k25.wpg.xml"},
    {key="Felsbrunn", mapHint="Felsbrunn", aliases={"felsbrunn", "fs25felsbrunnautodrivecourse", "fs25felsbrunn"}, file=MOD_DIRECTORY .. "routes/Felsbrunn.wpg.xml"},
    {key="Flat_Acres_4X_map", mapHint="Flat Acres 4X map", aliases={"flatacres4xmap", "flatacres4xbynestee", "flatacres4x", "flatacres4xnestee"}, file=MOD_DIRECTORY .. "routes/Flat_Acres_4X_map.wpg.xml"},
    {key="Flatlands_Grid", mapHint="Flatlands Grid", aliases={"flatlandsgrid", "flatlandsgridv10authorfastfarmdad", "flatlandsgridv10", "flatlandsgridfastfarmdad"}, file=MOD_DIRECTORY .. "routes/Flatlands_Grid.wpg.xml"},
    {key="Forest_River_North_dakota_4x", mapHint="Forest River North dakota 4x", aliases={"forestrivernorthdakota4x", "forestriverndbyosfarms", "forestrivernd", "forestriverndosfarms"}, file=MOD_DIRECTORY .. "routes/Forest_River_North_dakota_4x.wpg.xml"},
    {key="FourFields", mapHint="FourFields", aliases={"fourfields", "fourfieldsbyomatana", "fourfieldsomatana"}, file=MOD_DIRECTORY .. "routes/FourFields.wpg.xml"},
    {key="Frankenmuth_Farming_Map", mapHint="Frankenmuth Farming Map", aliases={"frankenmuthfarmingmap", "frankenmuthfarmsbytaylorfarms", "frankenmuthfarms", "frankenmuthfarmstaylorfarms"}, file=MOD_DIRECTORY .. "routes/Frankenmuth_Farming_Map.wpg.xml"},
    {key="FS25_Juotca_Big_Fields_4X_V1_2_0", mapHint="FS25 Juotca Big Fields 4X 2 0", aliases={"fs25juotcabigfields4xv120", "juotcabigfieldsbyjacuta", "juotcabigfields", "juotcabigfieldsjacuta", "fs25juotcabigfields4x20"}, file=MOD_DIRECTORY .. "routes/FS25_Juotca_Big_Fields_4X_2_0.wpg.xml"},
    {key="FS25_Ringwoods_4X_Edited_By_Stevie_V1_0_0_2_", mapHint="FS25 Ringwoods 4X Edited By Stevie 0 0 2", aliases={"fs25ringwoods4xeditedbysteviev1002", "ringwoodsbystevie", "ringwoods", "ringwoodsstevie", "fs25ringwoods4xeditedbystevie002"}, file=MOD_DIRECTORY .. "routes/FS25_Ringwoods_4X_Edited_By_Stevie_0_0_2.wpg.xml"},
    {key="FS25_Riverbend_Springs_4X_Edited_By_Stevie_V1_0_0_4_", mapHint="FS25 Riverbend Springs 4X Edited By Stevie 0 0 4", aliases={"fs25riverbendsprings4xeditedbysteviev1004", "riverbendsprings4xbysteviefsmods", "riverbendsprings4x", "riverbendsprings4xsteviefsmods", "fs25riverbendsprings4xeditedbystevie004"}, file=MOD_DIRECTORY .. "routes/FS25_Riverbend_Springs_4X_Edited_By_Stevie_0_0_4.wpg.xml"},
    {key="FS25_Riverbend_Springs_Edited_By_Stevie", mapHint="FS25 Riverbend Springs Edited By Stevie", aliases={"fs25riverbendspringseditedbystevie", "riverbendbystevie"}, file=MOD_DIRECTORY .. "routes/FS25_Riverbend_Springs_Edited_By_Stevie.wpg.xml"},
    {key="Gnadenthal", mapHint="Gnadenthal", aliases={"gnadenthal", "gnadenthalbyironthunderupdated", "gnadenthalironthunderupdated"}, file=MOD_DIRECTORY .. "routes/Gnadenthal.wpg.xml"},
    {key="Hagenstedt", mapHint="Hagenstedt", aliases={"hagenstedt", "hagenstadtbydhmodding", "hagenstadt", "hagenstadtdhmodding"}, file=MOD_DIRECTORY .. "routes/Hagenstedt.wpg.xml"},
    {key="Harrison_Farms", mapHint="Harrison Farms", aliases={"harrisonfarms", "harrisonsfarmpconlybysmbmodding", "harrisonsfarmpconly", "harrisonsfarmpconlysmbmodding"}, file=MOD_DIRECTORY .. "routes/Harrison_Farms.wpg.xml"},
    {key="HermannsHausen", mapHint="HermannsHausen", aliases={"hermannshausen", "hermannshausenbybeemer", "hermannshausenbeemer"}, file=MOD_DIRECTORY .. "routes/HermannsHausen.wpg.xml"},
    {key="Horizonte_Argentino", mapHint="Horizonte Argentino", aliases={"horizonteargentino"}, file=MOD_DIRECTORY .. "routes/Horizonte_Argentino.wpg.xml"},
    {key="HORSCH_AgroVation", mapHint="HORSCH AgroVation", aliases={"horschagrovation", "horshagrovationbygiantssoftware", "horshagrovationgiantssoftware"}, file=MOD_DIRECTORY .. "routes/HORSCH_AgroVation.wpg.xml"},
    {key="Huron_County_Michigan", mapHint="Huron County Michigan", aliases={"huroncountymichigan", "huroncountymichigan16xmap", "huroncountymichigan16x"}, file=MOD_DIRECTORY .. "routes/Huron_County_Michigan.wpg.xml"},
    {key="Iowa_Plains_View", mapHint="Iowa Plains View", aliases={"iowaplainsview", "iowaplainsviewbycelobuki", "iowaplainsviewcelobuki"}, file=MOD_DIRECTORY .. "routes/Iowa_Plains_View.wpg.xml"},
    {key="Ladyhill", mapHint="Ladyhill", aliases={"ladyhill", "ladyhillbyfslandscaping", "ladyhillfslandscaping"}, file=MOD_DIRECTORY .. "routes/Ladyhill.wpg.xml"},
    {key="Lindsay__Montana", mapHint="Lindsay Montana", aliases={"lindsaymontana", "lindsaymontanabypoorboymoddingbrucesgaming", "lindsaymontanapoorboymoddingbrucesgaming"}, file=MOD_DIRECTORY .. "routes/Lindsay_Montana.wpg.xml"},
    {key="Lipinki", mapHint="Lipinki", aliases={"lipinki", "lipinkibygreeniacfaevrey", "lipinkigreeniacfaevrey"}, file=MOD_DIRECTORY .. "routes/Lipinki.wpg.xml"},
    {key="Lossberg", mapHint="Lossberg", aliases={"lossberg", "lossbergmapcreatedbyvertexdezignfor", "lossbergmapcreated", "lossbergcreatedvertexdezignfor"}, file=MOD_DIRECTORY .. "routes/Lossberg.wpg.xml"},
    {key="Louisiana_Map", mapHint="Louisiana Map", aliases={"louisianamap", "louisianamapbyandradamlrenegadosmoddingmlstu", "louisiana", "louisianaandradamlrenegadosmoddingmlstu"}, file=MOD_DIRECTORY .. "routes/Louisiana_Map.wpg.xml"},
    {key="Lucid_Valley_-_Cannabis_Edition", mapHint="Lucid Valley - Cannabis Edition", aliases={"lucidvalleycannabisedition", "lucidvalley"}, file=MOD_DIRECTORY .. "routes/Lucid_Valley_Cannabis_Edition.wpg.xml"},
    {key="Maizy_Lou_Farms", mapHint="Maizy Lou Farms", aliases={"maizyloufarms", "maizyloufarmsbymattshantyboysmapcreations", "maizyloufarmsmattshantyboyscreations"}, file=MOD_DIRECTORY .. "routes/Maizy_Lou_Farms.wpg.xml"},
    {key="Matopiba_Map_2k25", mapHint="Matopiba Map 2k25", aliases={"matopibamap2k25", "matopibamapbylostgamergranjeirovergaminimodding", "matopibamap", "matopiba", "matopibalostgamergranjeirovergaminimodding"}, file=MOD_DIRECTORY .. "routes/Matopiba_Map_2k25.wpg.xml"},
    {key="Michigan_Map", mapHint="Michigan Map", aliases={"michiganmap", "michiganmapbytaylorfarms", "michigan", "michigantaylorfarms"}, file=MOD_DIRECTORY .. "routes/Michigan_Map.wpg.xml"},
    {key="Midwest_Acres", mapHint="Midwest Acres", aliases={"midwestacres", "midwestacresbyjacobrummer", "midwestacresjacobrummer"}, file=MOD_DIRECTORY .. "routes/Midwest_Acres.wpg.xml"},
    {key="MO_River_Bottom", mapHint="MO River Bottom", aliases={"moriverbottom", "moriverbottombyriverbottomcustomfarmingmods", "moriverbottomriverbottomcustomfarmingmods"}, file=MOD_DIRECTORY .. "routes/MO_River_Bottom.wpg.xml"},
    {key="Montana_Map_4x", mapHint="Montana Map 4x", aliases={"montanamap4x", "judithplainsbydjmodding", "judithplains", "judithplainsdjmodding"}, file=MOD_DIRECTORY .. "routes/Montana_Map_4x.wpg.xml"},
    {key="Multimap", mapHint="Multimap", aliases={"multimap", "multimapbylsmtfalpos", "multimaplsmtfalpos"}, file=MOD_DIRECTORY .. "routes/Multimap.wpg.xml"},
    {key="New_American", mapHint="New American", aliases={"newamerican", "newamericanbysamualbatistamodding", "newamericansamualbatistamodding"}, file=MOD_DIRECTORY .. "routes/New_American.wpg.xml"},
    {key="New_Bartelshagen", mapHint="New Bartelshagen", aliases={"newbartelshagen", "newbartelshagenbyatzevr", "newbartelshagenatzevr"}, file=MOD_DIRECTORY .. "routes/New_Bartelshagen.wpg.xml"},
    {key="New_Endeavour4x", mapHint="New Endeavour4x", aliases={"newendeavour4x", "newendeavour4xbydavidmerriman", "newendeavour4xdavidmerriman"}, file=MOD_DIRECTORY .. "routes/New_Endeavour4x.wpg.xml"},
    {key="New_Frontier", mapHint="New Frontier", aliases={"newfrontier"}, file=MOD_DIRECTORY .. "routes/New_Frontier.wpg.xml"},
    {key="New_Glarus__WI", mapHint="New Glarus WI", aliases={"newglaruswi", "newglarusmibymrgmappingpublicbetarelease", "newglarusmi", "newglarusmimrgmappingpublicbetarelease"}, file=MOD_DIRECTORY .. "routes/New_Glarus_WI.wpg.xml"},
    {key="New_Hope", mapHint="New Hope", aliases={"newhope", "newhopebydavidmerriman", "newhopedavidmerriman"}, file=MOD_DIRECTORY .. "routes/New_Hope.wpg.xml"},
    {key="Newell_Gaming_Louisiana_Atchafalaya_National_Wildlife_Reserve_-_WIP", mapHint="Newell Gaming Louisiana Atchafalaya National Wildlife Reserve - WIP", aliases={"newellgaminglouisianaatchafalayanationalwildlifereservewip", "atchafalayanationalwildlifereservebynewellgaming", "atchafalayanationalwildlifereservenewellgaming"}, file=MOD_DIRECTORY .. "routes/Newell_Gaming_Louisiana_Atchafalaya_National_Wildlife_Reserve_WIP.wpg.xml"},
    {key="NF_Marsch_4fach_oG", mapHint="NF Marsch 4fach oG", aliases={"nfmarsch4fachog", "northfrisianmarsh4xbyls25mods", "northfrisianmarsh4x", "northfrisianmarsh4xmods"}, file=MOD_DIRECTORY .. "routes/NF_Marsch_4fach_oG.wpg.xml"},
    {key="No_Ones_Land", mapHint="No Ones Land", aliases={"noonesland", "nooneslandbyfarmerbob", "nooneslandfarmerbob"}, file=MOD_DIRECTORY .. "routes/No_Ones_Land.wpg.xml"},
    {key="Nordkirchen", mapHint="Nordkirchen", aliases={"nordkirchen", "nordkirchenbyfrechdachs93", "nordkirchenfrechdachs93"}, file=MOD_DIRECTORY .. "routes/Nordkirchen.wpg.xml"},
    {key="North_Frisian_25", mapHint="North Frisian 25", aliases={"northfrisian25", "northfrisian25bysirjoki80jogi", "northfrisian25sirjoki80jogi"}, file=MOD_DIRECTORY .. "routes/North_Frisian_25.wpg.xml"},
    {key="North_German_Country", mapHint="North German Country", aliases={"northgermancountry", "northgermancountymapbyndsmoddinglsschleswigholsteinbal", "northgermancountymap", "northgermancountymapbyndsmoddinglsschleswig", "northgermancounty", "northgermancountyndsmoddinglsschleswigholsteinbal"}, file=MOD_DIRECTORY .. "routes/North_German_Country.wpg.xml"},
    {key="North_March_Reloaded", mapHint="North March Reloaded", aliases={"northmarchreloaded", "northmarshreloadedlsschleswigholstein", "northmarshreloaded"}, file=MOD_DIRECTORY .. "routes/North_March_Reloaded.wpg.xml"},
    {key="Oak_City", mapHint="Oak City", aliases={"oakcity", "oakcitybyjmggaming", "oakcityjmggaming"}, file=MOD_DIRECTORY .. "routes/Oak_City.wpg.xml"},
    {key="Oberschwaben", mapHint="Oberschwaben", aliases={"oberschwaben", "oberschwabenbyjogi", "oberschwabenjogi"}, file=MOD_DIRECTORY .. "routes/Oberschwaben.wpg.xml"},
    {key="Owendale_Michigan", mapHint="Owendale Michigan", aliases={"owendalemichigan", "owendalemichiganbynorthernmichiganmapping", "owendalemichigannorthernmichiganmapping"}, file=MOD_DIRECTORY .. "routes/Owendale_Michigan.wpg.xml"},
    {key="Palatinate_Forest", mapHint="Palatinate Forest", aliases={"palatinateforest", "palatinateforestbywestpfalzmodding", "palatinateforestwestpfalzmodding"}, file=MOD_DIRECTORY .. "routes/Palatinate_Forest.wpg.xml"},
    {key="Pallegney", mapHint="Pallegney", aliases={"pallegney", "pallegneybyma7studio", "pallegneyma7studio"}, file=MOD_DIRECTORY .. "routes/Pallegney.wpg.xml"},
    {key="Panhandle_Caprock", mapHint="Panhandle Caprock", aliases={"panhandlecaprock", "panhandlecaprockbytaylormanross", "panhandlecaprocktaylormanross"}, file=MOD_DIRECTORY .. "routes/Panhandle_Caprock.wpg.xml"},
    {key="Peaceful_Acres", mapHint="Peaceful Acres", aliases={"peacefulacres", "peacefulacresbynestee", "peacefulacresnestee"}, file=MOD_DIRECTORY .. "routes/Peaceful_Acres.wpg.xml"},
    {key="Polanki", mapHint="Polanki", aliases={"polanki", "polankibybartsonv3", "polankibartsonv3"}, file=MOD_DIRECTORY .. "routes/Polanki.wpg.xml"},
    {key="Portugal_SOUTH", mapHint="Portugal SOUTH", aliases={"portugalsouth", "portugalsouthbyfspt", "portugalsouthfspt"}, file=MOD_DIRECTORY .. "routes/Portugal_SOUTH.wpg.xml"},
    {key="Prairie_Farm_Michigan", mapHint="Prairie Farm Michigan", aliases={"prairiefarmmichigan", "prairiefarmsmichiganbytaylorfarms", "prairiefarmsmichigan", "prairiefarmsmichigantaylorfarms"}, file=MOD_DIRECTORY .. "routes/Prairie_Farm_Michigan.wpg.xml"},
    {key="Rancho_Fundo", mapHint="Rancho Fundo", aliases={"ranchofundo", "ranchofundobynaimogmod", "ranchofundonaimogmod"}, file=MOD_DIRECTORY .. "routes/Rancho_Fundo.wpg.xml"},
    {key="Ray_County_Missouri", mapHint="Ray County Missouri", aliases={"raycountymissouri", "raycountymissouribyljrmodding", "raycountymissouriljrmodding"}, file=MOD_DIRECTORY .. "routes/Ray_County_Missouri.wpg.xml"},
    {key="RedField", mapHint="RedField", aliases={"redfield", "redfieldbydyateel", "redfielddyateel"}, file=MOD_DIRECTORY .. "routes/RedField.wpg.xml"},
    {key="Rhönplateu", mapHint="Rhönplateu", aliases={"rhnplateu", "rhonplateubylsrhoendesigns", "rhonplateu", "rhonplateulsrhoendesigns"}, file=MOD_DIRECTORY .. "routes/Rhonplateu.wpg.xml"},
    {key="Richmond", mapHint="Richmond", aliases={"richmond", "richmondbytylilgaming", "richmondtylilgaming"}, file=MOD_DIRECTORY .. "routes/Richmond.wpg.xml"},
    {key="Riverbend_Timbers_4X", mapHint="Riverbend Timbers 4X", aliases={"riverbendtimbers4x", "riverbendtimbers4xbyrgcgaming", "riverbendtimbers4xrgcgaming"}, file=MOD_DIRECTORY .. "routes/Riverbend_Timbers_4X.wpg.xml"},
    {key="Riverview", mapHint="Riverview", aliases={"riverview", "riverviewfarmbycavalierroymonkeyshowmodding", "riverviewfarm", "riverviewfarmcavalierroymonkeyshowmodding"}, file=MOD_DIRECTORY .. "routes/Riverview.wpg.xml"},
    {key="Rolling_Meadow", mapHint="Rolling Meadow", aliases={"rollingmeadow", "rollingmeadowbydavidmerriman", "rollingmeadowdavidmerriman"}, file=MOD_DIRECTORY .. "routes/Rolling_Meadow.wpg.xml"},
    {key="Rügenerland", mapHint="Rügenerland", aliases={"rgenerland", "rugenerland4xbyforbiddenmodde", "rugenerland4x", "rugenerland4xforbiddenmodde"}, file=MOD_DIRECTORY .. "routes/Rugenerland.wpg.xml"},
    {key="Saxlingham_Farm_Estate", mapHint="Saxlingham Farm Estate", aliases={"saxlinghamfarmestate", "saxlinghamfarmestatebyfarmsimbrisco", "saxlinghamfarmestatefarmsimbrisco"}, file=MOD_DIRECTORY .. "routes/Saxlingham_Farm_Estate.wpg.xml"},
    {key="Schwesing_Bahnhof", mapHint="Schwesing Bahnhof", aliases={"schwesingbahnhof", "schwesingbahnhofbylazzlow", "schwesingbahnhoflazzlow"}, file=MOD_DIRECTORY .. "routes/Schwesing_Bahnhof.wpg.xml"},
    {key="Scroft_OakBridge_SmallHolding_Farm", mapHint="Scroft OakBridge SmallHolding Farm", aliases={"scroftoakbridgesmallholdingfarm", "scroftsmallholdingfarmoxy"}, file=MOD_DIRECTORY .. "routes/Scroft_OakBridge_SmallHolding_Farm.wpg.xml"},
    {key="Settlers", mapHint="Settlers", aliases={"settlers", "settlersbysetkasimulators", "settlerssetkasimulators"}, file=MOD_DIRECTORY .. "routes/Settlers.wpg.xml"},
    {key="Sharon_Center__Ohio", mapHint="Sharon Center Ohio", aliases={"sharoncenterohio", "sharoncenterohiobytonimifschutlzmodding", "sharoncenterohiotonimifschutlzmodding"}, file=MOD_DIRECTORY .. "routes/Sharon_Center_Ohio.wpg.xml"},
    {key="Silverrun_Forest", mapHint="Silverrun Forest", aliases={"silverrunforest", "silverlakeforest"}, file=MOD_DIRECTORY .. "routes/Silverrun_Forest.wpg.xml"},
    {key="Sobolewo", mapHint="Sobolewo", aliases={"sobolewo", "sobolewobytomix", "sobolewotomix"}, file=MOD_DIRECTORY .. "routes/Sobolewo.wpg.xml"},
    {key="Sonny_Farm", mapHint="Sonny Farm", aliases={"sonnyfarm", "sonnyfarmbybrenongamingmattshantyboysmapcreation", "sonnyfarmbrenongamingmattshantyboyscreation"}, file=MOD_DIRECTORY .. "routes/Sonny_Farm.wpg.xml"},
    {key="Sudbury__ONTARIO", mapHint="Sudbury ONTARIO", aliases={"sudburyontario", "sudburyontarioautodrivecanadianpheonixmodding", "sudburyontariocanadianpheonixmodding"}, file=MOD_DIRECTORY .. "routes/Sudbury_ONTARIO.wpg.xml"},
    {key="Sunshine_Forest", mapHint="Sunshine Forest", aliases={"sunshineforest", "sunshineforestbycarltg", "sunshineforestcarltg"}, file=MOD_DIRECTORY .. "routes/Sunshine_Forest.wpg.xml"},
    {key="Survival4X", mapHint="Survival4X", aliases={"survival4x", "survival4xbydakluit", "survival4xdakluit"}, file=MOD_DIRECTORY .. "routes/Survival4X.wpg.xml"},
    {key="Szpakowo", mapHint="Szpakowo", aliases={"szpakowo", "szpakowobyrajotgplayjankus", "szpakoworajotgplayjankus"}, file=MOD_DIRECTORY .. "routes/Szpakowo.wpg.xml"},
    {key="Tässi_Farm", mapHint="Tässi Farm", aliases={"tssifarm", "tassifarmbycarltgwikkyplays", "tassifarm", "tassifarmcarltgwikkyplays"}, file=MOD_DIRECTORY .. "routes/Tassi_Farm.wpg.xml"},
    {key="The_Alps", mapHint="The Alps", aliases={"thealps", "thealpsbylamai", "thealpslamai"}, file=MOD_DIRECTORY .. "routes/The_Alps.wpg.xml"},
    {key="The_Countryside_Lands", mapHint="The Countryside Lands", aliases={"thecountrysidelands", "thecountrysidelandslesterrescampagndresbygrimlockrrc", "thecountrysidelandslesterrescampagndresgrimlockrrc"}, file=MOD_DIRECTORY .. "routes/The_Countryside_Lands.wpg.xml"},
    {key="The_memory", mapHint="The memory", aliases={"thememory", "autodriveguy", "thememorybyprincessblack", "thememoryprincessblack"}, file=MOD_DIRECTORY .. "routes/The_memory.wpg.xml"},
    {key="The_Pichonnière_Valley", mapHint="The Pichonnière Valley", aliases={"thepichonnirevalley", "pichonierrebyblacksheepmoddinghelp", "pichonierre", "pichonierreblacksheepmoddinghelp"}, file=MOD_DIRECTORY .. "routes/The_Pichonniere_Valley.wpg.xml"},
    {key="The_Risoux_Forest", mapHint="The Risoux Forest", aliases={"therisouxforest", "risouxforestbyjmgaming", "risouxforest", "risouxforestjmgaming"}, file=MOD_DIRECTORY .. "routes/The_Risoux_Forest.wpg.xml"},
    {key="The_Shoreline_Of_Lake_Michigan", mapHint="The Shoreline Of Lake Michigan", aliases={"theshorelineoflakemichigan", "fs25theshorelineofmichiganautodrivecourse", "fs25theshorelineofmichigan", "theshorelineofmichigan"}, file=MOD_DIRECTORY .. "routes/The_Shoreline_Of_Lake_Michigan.wpg.xml"},
    {key="Tillamook_OR", mapHint="Tillamook OR", aliases={"tillamookor", "tillamookor1891bynuclearsalmonshantyboys", "tillamookor1891", "tillamookor1891nuclearsalmonshantyboys"}, file=MOD_DIRECTORY .. "routes/Tillamook_OR.wpg.xml"},
    {key="Trout_River_Valley", mapHint="Trout River Valley", aliases={"troutrivervalley", "troutrivelvalleyautodrivecoursemapbysamuelbatistamodding", "troutrivelvalleyautodrivecoursemap", "troutrivelvalleyautodrivecourse", "troutrivelvalley", "troutrivelvalleysamuelbatistamodding"}, file=MOD_DIRECTORY .. "routes/Trout_River_Valley.wpg.xml"},
    {key="Wampsville__NY_-_Homestead_Edition", mapHint="Wampsville NY - Homestead Edition", aliases={"wampsvillenyhomesteadedition", "wampsvillenymapbydtapgamingstarspangledmodz", "wampsvillenydtapgamingstarspangledmodz"}, file=MOD_DIRECTORY .. "routes/Wampsville_NY_Homestead_Edition.wpg.xml"},
    {key="Waseca_Co_Minnesota", mapHint="Waseca Co Minnesota", aliases={"wasecacominnesota", "wasecacominnesotabymatzymodding", "wasecacominnesotamatzymodding"}, file=MOD_DIRECTORY .. "routes/Waseca_Co_Minnesota.wpg.xml"},
    {key="Welcome_to_Nashville", mapHint="Welcome to Nashville", aliases={"welcometonashville", "nashvillemi4xmapbymikemacdonald", "nashvillemi4xmap", "nashvillemi4x", "nashvillemi4xmikemacdonald"}, file=MOD_DIRECTORY .. "routes/Welcome_to_Nashville.wpg.xml"},
    {key="Welker_Farms", mapHint="Welker Farms", aliases={"welkerfarms", "welkerfarmsbymappersparadise", "welkerfarmsmappersparadise"}, file=MOD_DIRECTORY .. "routes/Welker_Farms.wpg.xml"},
    {key="WestBridge_Hills_25", mapHint="WestBridge Hills 25", aliases={"westbridgehills25", "westbridgehills25byzerooiti", "westbridgehills25zerooiti"}, file=MOD_DIRECTORY .. "routes/WestBridge_Hills_25.wpg.xml"},
    {key="Witcombe_Park_Farm", mapHint="Witcombe Park Farm", aliases={"witcombeparkfarm", "fs25whitcombeparkautodrivecourse", "fs25whitcombepark", "whitcombepark"}, file=MOD_DIRECTORY .. "routes/Witcombe_Park_Farm.wpg.xml"},
    {key="Woodhull_Illinois", mapHint="Woodhull Illinois", aliases={"woodhullillinois", "woodhullillinoisbycamilcunack", "woodhullillinoiscamilcunack"}, file=MOD_DIRECTORY .. "routes/Woodhull_Illinois.wpg.xml"},
    {key="Yukon_Valley", mapHint="Yukon Valley", aliases={"yukonvalley", "yukonvalley16x"}, file=MOD_DIRECTORY .. "routes/Yukon_Valley.wpg.xml"},
    {key="Z/s_Lejaskuki_Farms", mapHint="Z/s Lejaskuki Farms", aliases={"zslejaskukifarms", "zslejaskukifarmv10farmertv", "zslejaskukifarmv10", "zslejaskukifarmfarmertv"}, file=MOD_DIRECTORY .. "routes/Z_s_Lejaskuki_Farms.wpg.xml"},
}

local SETTINGS_FILE = nil

local settingsOpen = false
local activeTab = 1
local MENU_MINIMAP = {}
local MENU_ROAD = {}

WayPointGPS = WayPointGPS or {}
WayPointGPS.DEBUG_LOGGING = false
WayPointGPS.clickMode = WayPointGPS.clickMode or "auto" -- auto, local, pointer, centered, raw, rendered

function WayPointGPS.logInfo(...)
    if WayPointGPS.DEBUG_LOGGING then
        Logging.info(...)
    end
end

function WayPointGPS.notify(textKey)
end

function WayPointGPS.clampIdx(val, arr)
    if val == nil then return nil end
    val = math.floor(val + 0.5)
    if val < 1 or val > #arr then return nil end
    return val
end

function WayPointGPS._forceRuntimeDisplayState()
    -- Keep the internal runtime gate from getting stuck off.
    -- Display visibility is controlled by cfg.mapEnabled; isActive should only mean the mod is alive.
    WayPointGPS.isActive = true
    cfg.roadEnabled = false
end

function WayPointGPS.loadSettings()
    if SETTINGS_FILE == nil then return end
    local xmlFile = XMLFile.loadIfExists("WayPointGPSSettings", SETTINGS_FILE, "waypointGPS")
    if xmlFile == nil then return end

    local b = xmlFile:getBool("waypointGPS.minimap#enabled", nil)
    if b ~= nil then cfg.mapEnabled = b end
    local v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.minimap#colorIdx", nil), COLORS)
    if v then cfg.mapColorIdx = v end
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.marker#colorIdx", nil), COLORS)
    if v then cfg.pinColorIdx = v end
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.minimap#thickIdx", nil), MAP_THICK)
    if v then cfg.mapThickIdx = v end
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.minimap#lookIdx", nil), MAP_LOOK)
    if v then cfg.mapLookIdx = v end
    b = xmlFile:getBool("waypointGPS.arrow#enabled", nil)
    if b ~= nil then cfg.arrowEnabled = b end

    -- Maps-only build: never allow saved settings to re-enable the 3D/world guidance line.
    cfg.roadEnabled = false
    WayPointGPS._forceRuntimeDisplayState()
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.road#colorIdx", nil), COLORS)
    if v then cfg.roadColorIdx = v end
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.road#lenIdx", nil), ROAD_LEN)
    if v then cfg.roadLenIdx = v end
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.road#thickIdx", nil), ROAD_THICK_COUNTS)
    if v then cfg.roadThickIdx = v end
    v = WayPointGPS.clampIdx(xmlFile:getInt("waypointGPS.road#startIdx", nil), ROAD_START)
    if v then cfg.roadStartIdx = v end

    xmlFile:delete()
    WayPointGPS.logInfo("[WayPointGPS] Settings loaded from %s", SETTINGS_FILE)
end

function WayPointGPS.saveSettings()
    if SETTINGS_FILE == nil then return end
    local xmlFile = XMLFile.create("WayPointGPSSettings", SETTINGS_FILE, "waypointGPS")
    if xmlFile == nil then
        Logging.warning("[WayPointGPS] Cannot create settings file: %s", SETTINGS_FILE)
        return
    end

    xmlFile:setBool("waypointGPS.minimap#enabled", cfg.mapEnabled)
    xmlFile:setInt("waypointGPS.minimap#colorIdx", cfg.mapColorIdx)
    xmlFile:setInt("waypointGPS.marker#colorIdx", cfg.pinColorIdx)
    xmlFile:setInt("waypointGPS.minimap#thickIdx", cfg.mapThickIdx)
    xmlFile:setInt("waypointGPS.minimap#lookIdx", cfg.mapLookIdx)
    xmlFile:setBool("waypointGPS.arrow#enabled", cfg.arrowEnabled)

    xmlFile:setBool("waypointGPS.road#enabled", false)
    xmlFile:setInt("waypointGPS.road#colorIdx", cfg.roadColorIdx)
    xmlFile:setInt("waypointGPS.road#lenIdx", cfg.roadLenIdx)
    xmlFile:setInt("waypointGPS.road#thickIdx", cfg.roadThickIdx)
    xmlFile:setInt("waypointGPS.road#startIdx", cfg.roadStartIdx)

    xmlFile:save()
    xmlFile:delete()
    WayPointGPS.logInfo("[WayPointGPS] Settings saved to %s", SETTINGS_FILE)
end

function WayPointGPS.buildMenus()
    local function add(t, labelKey, getVal, doPrev, doNext)
        table.insert(t, {label_key=labelKey, get=getVal, prev=doPrev, next=doNext})
    end
    local function cycleIdx(key, arr, dir)
        cfg[key] = ((cfg[key] - 1 + dir) % #arr) + 1
    end

    MENU_MINIMAP = {}
    add(MENU_MINIMAP, "wpgps_display",
        function() return g_i18n:getText(cfg.mapEnabled and "wpgps_on" or "wpgps_off") end,
        function() cfg.mapEnabled = not cfg.mapEnabled end,
        function() cfg.mapEnabled = not cfg.mapEnabled end)
    add(MENU_MINIMAP, "wpgps_arrow_display",
        function() return g_i18n:getText(cfg.arrowEnabled and "wpgps_on" or "wpgps_off") end,
        function() cfg.arrowEnabled = not cfg.arrowEnabled end,
        function() cfg.arrowEnabled = not cfg.arrowEnabled end)
    add(MENU_MINIMAP, "wpgps_line_color",
        function() return g_i18n:getText(COLORS[cfg.mapColorIdx].key) end,
        function() cycleIdx("mapColorIdx", COLORS, -1) end,
        function() cycleIdx("mapColorIdx", COLORS, 1) end)
    add(MENU_MINIMAP, "wpgps_waypoint_color",
        function() return g_i18n:getText(COLORS[cfg.pinColorIdx].key) end,
        function() cycleIdx("pinColorIdx", COLORS, -1) end,
        function() cycleIdx("pinColorIdx", COLORS, 1) end)
    add(MENU_MINIMAP, "wpgps_thickness",
        function() return g_i18n:getText(MAP_THICK_K[cfg.mapThickIdx]) end,
        function() cycleIdx("mapThickIdx", MAP_THICK, -1) end,
        function() cycleIdx("mapThickIdx", MAP_THICK, 1) end)
    add(MENU_MINIMAP, "wpgps_visibility",
        function() return g_i18n:getText(MAP_LOOK_K[cfg.mapLookIdx]) end,
        function() cycleIdx("mapLookIdx", MAP_LOOK, -1) end,
        function() cycleIdx("mapLookIdx", MAP_LOOK, 1) end)

    MENU_ROAD = {}
    add(MENU_ROAD, "wpgps_display",
        function() return g_i18n:getText("wpgps_off") end,
        function() cfg.roadEnabled = false end,
        function() cfg.roadEnabled = false end)
    add(MENU_ROAD, "wpgps_color",
        function() return g_i18n:getText(COLORS[cfg.roadColorIdx].key) end,
        function() cycleIdx("roadColorIdx", COLORS, -1) end,
        function() cycleIdx("roadColorIdx", COLORS, 1) end)
    add(MENU_ROAD, "wpgps_length",
        function() return g_i18n:getText(ROAD_LEN_K[cfg.roadLenIdx]) end,
        function() cycleIdx("roadLenIdx", ROAD_LEN, -1) end,
        function() cycleIdx("roadLenIdx", ROAD_LEN, 1) end)
    add(MENU_ROAD, "wpgps_thickness",
        function() return g_i18n:getText(ROAD_THICK_K[cfg.roadThickIdx]) end,
        function() cycleIdx("roadThickIdx", ROAD_THICK_COUNTS, -1) end,
        function() cycleIdx("roadThickIdx", ROAD_THICK_COUNTS, 1) end)
    add(MENU_ROAD, "wpgps_start",
        function() return g_i18n:getText(ROAD_START_K[cfg.roadStartIdx]) end,
        function() cycleIdx("roadStartIdx", ROAD_START, -1) end,
        function() cycleIdx("roadStartIdx", ROAD_START, 1) end)
end

function WayPointGPS.activeMenu()
    -- Single-page settings UI: keep the old MAP ROUTE page unavailable.
    -- The legacy Q/E tab handlers could still flip activeTab to 2, which
    -- exposed the removed/old map-route page. Always serve the current
    -- main settings list.
    activeTab = 1
    return MENU_MINIMAP
end

function WayPointGPS._missionTimeMs()
    return g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
end

function WayPointGPS._isSettingsOpenShortcut(sym)
    if Input == nil or sym ~= Input.KEY_n then
        return false
    end

    local shiftDown = false
    if Input.KEY_lshift ~= nil and Input.isKeyPressed(Input.KEY_lshift) then shiftDown = true end
    if Input.KEY_rshift ~= nil and Input.isKeyPressed(Input.KEY_rshift) then shiftDown = true end

    local ctrlDown = false
    if Input.KEY_lctrl ~= nil and Input.isKeyPressed(Input.KEY_lctrl) then ctrlDown = true end
    if Input.KEY_rctrl ~= nil and Input.isKeyPressed(Input.KEY_rctrl) then ctrlDown = true end

    return shiftDown and ctrlDown
end

function WayPointGPS.openSettingsDialog(source)
    if g_gui == nil then
        return false
    end

    local now = WayPointGPS._missionTimeMs()
    if now - (WayPointGPS.lastSettingsOpenTime or -math.huge) < 250 then
        return true
    end
    WayPointGPS.lastSettingsOpenTime = now

    -- The display/HUD option is allowed to hide the route, but it must never
    -- disable the settings dialog itself. Clear any stale modal flag before
    -- opening so a dialog closed by the UI cannot leave the menu locked out.
    settingsOpen = false
    WayPointGPS.settingsDialogIsOpen = false

    if WayPointGPS.settingsDialog == nil then
        WayPointGPS.settingsDialog = WayPointGPSSettingsDialog.new()
        g_gui:loadGui(MOD_DIRECTORY .. "gui/WayPointGPSSettingsDialog.xml", "WayPointGPSSettingsDialog", WayPointGPS.settingsDialog)
    end

    WayPointGPS.buildMenus()

    local ok, err = pcall(function()
        g_gui:showDialog("WayPointGPSSettingsDialog")
    end)

    if not ok then
        Logging.warning("[WayPointGPS] Failed to open settings dialog from %s: %s", tostring(source), tostring(err))
        settingsOpen = false
        WayPointGPS.settingsDialogIsOpen = false
        return false
    end

    return true
end

WayPointGPSSettingsDialog = {}
local WayPointGPSSettingsDialog_mt = Class(WayPointGPSSettingsDialog, DialogElement)

function WayPointGPSSettingsDialog.new(target)
    return DialogElement.new(target, WayPointGPSSettingsDialog_mt)
end

function WayPointGPSSettingsDialog:onOpen()
    WayPointGPSSettingsDialog:superClass().onOpen(self)
    settingsOpen = true
    WayPointGPS.settingsDialogIsOpen = true
    activeTab = 1
    WayPointGPS.buildMenus()
    self.settingsList:setDataSource(self)
    self.settingsList:reloadData()
    self:refreshTabButtons()
end

function WayPointGPSSettingsDialog:onClose()
    settingsOpen = false
    WayPointGPS.settingsDialogIsOpen = false
    WayPointGPS.lastSettingsCloseTime = WayPointGPS._missionTimeMs()
    WayPointGPS.saveSettings()
    WayPointGPSSettingsDialog:superClass().onClose(self)
    -- Keep the flag cleared even if the base dialog changes GUI focus during close.
    settingsOpen = false
    WayPointGPS.settingsDialogIsOpen = false
end

function WayPointGPSSettingsDialog:onClickBack()
    settingsOpen = false
    WayPointGPS.settingsDialogIsOpen = false
    WayPointGPS.saveSettings()

    local superClass = WayPointGPSSettingsDialog:superClass()
    if superClass ~= nil and type(superClass.onClickBack) == "function" then
        superClass.onClickBack(self)
    elseif type(self.close) == "function" then
        self:close()
    end

    settingsOpen = false
    WayPointGPS.settingsDialogIsOpen = false
end

function WayPointGPSSettingsDialog:refreshTabButtons()
    -- Settings dialog is now a single clean page. Older builds had two tab buttons here,
    -- so keep this method safe for callbacks/savegame UI refreshes without requiring tab controls.
    if self.tabMinimapBtn ~= nil then
        self.tabMinimapBtn:setText(g_i18n:getText("wpgps_tab_minimap"))
        self.tabMinimapBtn:setSelected(activeTab == 1)
    end
    if self.tabRoadBtn ~= nil then
        self.tabRoadBtn:setText(g_i18n:getText("wpgps_tab_road"))
        self.tabRoadBtn:setSelected(activeTab == 2)
    end
end

function WayPointGPSSettingsDialog:onClickTabMinimap()
    activeTab = 1
    self:refreshTabButtons()
    self.settingsList:reloadData()
end

function WayPointGPSSettingsDialog:onClickTabRoad()
    -- Legacy page callback kept for safety only. The new settings dialog is
    -- intentionally single-page, so never switch to the old MAP ROUTE page.
    activeTab = 1
    self:refreshTabButtons()
    self.settingsList:reloadData()
end

function WayPointGPSSettingsDialog:getNumberOfItemsInSection(list, section)
    return #WayPointGPS.activeMenu()
end

function WayPointGPSSettingsDialog:populateCellForItemInSection(list, section, index, cell)
    local item = WayPointGPS.activeMenu()[index]
    cell.attributes.labelText:setText(g_i18n:getText(item.label_key))
    cell.attributes.valueText:setText(item.get())
end

function WayPointGPSSettingsDialog:onClickPrev()
    self:cycleSelectedOption(-1)
end

function WayPointGPSSettingsDialog:cycleSelectedOption(direction)
    local i = nil
    if self.settingsList ~= nil then
        if type(self.settingsList.getSelectedIndexInSection) == "function" then
            i = self.settingsList:getSelectedIndexInSection()
        end
        if i == nil or i <= 0 then
            i = self.settingsList.selectedIndex
        end
    end
    if i == nil or i <= 0 then
        i = 1
    end

    local item = WayPointGPS.activeMenu()[i]
    if item ~= nil then
        if direction < 0 and item.prev ~= nil then
            item.prev()
        elseif direction >= 0 and item.next ~= nil then
            item.next()
        end

        if self.settingsList ~= nil then
            self.settingsList:reloadData()
        end
    end
end

function WayPointGPSSettingsDialog:onClickNext()
    self:cycleSelectedOption(1)
end

function WayPointGPSSettingsDialog:onDoubleClickOption(_, _, _, _)
    self:cycleSelectedOption(1)
end

function WayPointGPSSettingsDialog:onKeyEvent(unicode, sym, modifier, isDown)
    if isDown and sym == Input.KEY_return then
        return true
    end
    return WayPointGPSSettingsDialog:superClass().onKeyEvent(self, unicode, sym, modifier, isDown)
end

function WayPointGPSSettingsDialog:onEscPressed()
    self:onClickBack()
end

WayPointGPS = WayPointGPS or {}
WayPointGPS.overlayId = nil
WayPointGPS.targetOverlayId = nil
WayPointGPS.targetOverlayPath = nil
WayPointGPS.directionArrowOverlayId = nil
WayPointGPS.directionArrowOverlayIds = nil
WayPointGPS.directionArrowBackOverlayId = nil
WayPointGPS.directionArrowFailed = false
WayPointGPS.directionArrowStateLabel = "STRAIGHT"
WayPointGPS.directionArrowStateTexture = ARROW_DDS_STRAIGHT
WayPointGPS.directionArrowNextUpdateTime = -math.huge
WayPointGPS.directionArrowPendingLabel = nil
WayPointGPS.directionArrowPendingSince = -math.huge
WayPointGPS.directionArrowTurnDistance = nil
WayPointGPS.directionArrowTurnText = nil
WayPointGPS.directionArrowTotalDistance = nil
WayPointGPS.directionArrowTotalText = nil
WayPointGPS.isActive = true
WayPointGPS.target = nil
WayPointGPS.waypoints = nil
WayPointGPS.routeWaypoints = nil
WayPointGPS.fullRouteWaypoints = nil
WayPointGPS.lastRouteStartX = nil
WayPointGPS.lastRouteStartZ = nil
WayPointGPS.lastRecalcTime = 0
WayPointGPS.settingsDialog = nil
WayPointGPS.targetHotspot = nil
WayPointGPS.lastMapClickTime = -math.huge
WayPointGPS.ctrlDown = false
WayPointGPS.shiftDown = false
WayPointGPS.lastCtrlEventTime = -math.huge
WayPointGPS.guiHookInstalled = false
WayPointGPS.lastClickDebugTime = -math.huge
WayPointGPS.lastSettingsOpenTime = -math.huge
WayPointGPS.lastSettingsCloseTime = -math.huge
WayPointGPS.settingsDialogIsOpen = false
WayPointGPS.cachedResolveTime = -math.huge
WayPointGPS.cachedResolveVehicle = nil
WayPointGPS.cachedResolveWaypoints = nil
WayPointGPS.cachedResolveCurrentWp = nil
WayPointGPS.lastTargetReachedCheckTime = -math.huge
WayPointGPS.lastAutopilotStatusLogTime = -math.huge
WayPointGPS.aiRoadGraph = nil
WayPointGPS.aiRoadGraphBuildAttempted = false
WayPointGPS.aiRoadGraphScanCount = 0
WayPointGPS.steeringRightLaneOffset = 0.75
WayPointGPS.objectBlockers = nil
WayPointGPS.objectBlockerScanAttempted = false
WayPointGPS.objectBlockerScanCount = 0
-- Road-lock mode: when no safe road/connector route exists, do not draw or steer a
-- fake straight shortcut. This is intentionally strict for release routing.
WayPointGPS.blockUnsafeDirectFallback = true

function WayPointGPS.getMissionTimeSeconds()
    if g_currentMission ~= nil and g_currentMission.time ~= nil then
        return g_currentMission.time * 0.001
    end
    if g_time ~= nil then
        return g_time * 0.001
    end
    return 0
end

function WayPointGPS.getVehicleWorldPos(vehicle)
    if vehicle == nil then return nil, nil end
    local node = vehicle.rootNode or (vehicle.components and vehicle.components[1] and vehicle.components[1].node)
    if node == nil then return nil, nil end
    local x, _, z = getWorldTranslation(node)
    return x, z
end

function WayPointGPS.getCurrentVehicle()
    if g_localPlayer ~= nil and type(g_localPlayer.getCurrentVehicle) == "function" then
        local vehicle = g_localPlayer:getCurrentVehicle()
        if vehicle ~= nil then return vehicle end
    end

    if g_currentMission == nil or g_currentMission.vehicleSystem == nil then
        return nil
    end

    for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do
        if vehicle.spec_enterable ~= nil and vehicle.spec_enterable.isEntered then
            return vehicle
        end
    end

    return nil
end

function WayPointGPS.getGroundHeight(worldX, worldZ)
    local terrain = g_currentMission ~= nil and g_currentMission.terrainRootNode or nil
    if terrain == nil then return 0 end
    return getTerrainHeightAtWorldPos(terrain, worldX, 0, worldZ)
end

function WayPointGPS.buildDirectRoute(startX, startZ, targetX, targetZ)
    local route = {}
    local dx = targetX - startX
    local dz = targetZ - startZ
    local length = MathUtil.vector2Length(dx, dz)
    local count = math.max(1, math.ceil(length / ROUTE_SAMPLE_DISTANCE))

    for i = 0, count do
        local t = i / count
        local x = startX + dx * t
        local z = startZ + dz * t
        route[#route + 1] = {x=x, y=WayPointGPS.getGroundHeight(x, z), z=z}
    end

    return route
end

function WayPointGPS.isFieldAtWorldPos(worldX, worldZ)
    if g_currentMission == nil then return false end
    local y = WayPointGPS.getGroundHeight(worldX, worldZ)

    if FSDensityMapUtil ~= nil and type(FSDensityMapUtil.getFieldDataAtWorldPosition) == "function" then
        local ok, isOnField = pcall(FSDensityMapUtil.getFieldDataAtWorldPosition, worldX, y, worldZ)
        if ok and isOnField ~= nil then
            return isOnField == true
        end
    end

    if g_currentMission.terrainDetailId ~= nil and type(getDensityAtWorldPos) == "function" then
        local ok, bits = pcall(getDensityAtWorldPos, g_currentMission.terrainDetailId, worldX, y, worldZ)
        if ok and bits ~= nil then
            return bits ~= 0
        end
    end

    return false
end

function WayPointGPS.getEndpointDistanceFactor(worldX, worldZ, startX, startZ, targetX, targetZ)
    local ds = MathUtil.vector2Length(worldX - startX, worldZ - startZ)
    local dt = MathUtil.vector2Length(worldX - targetX, worldZ - targetZ)
    return math.min(ds, dt)
end

local SURFACE_NAME_CACHE = {}

function WayPointGPS.getSurfaceNameForMaterial(materialId)
    if materialId == nil then return nil end
    if SURFACE_NAME_CACHE[materialId] ~= nil then
        return SURFACE_NAME_CACHE[materialId]
    end

    local surfaceName = false
    if g_currentMission ~= nil and g_currentMission.materialIdToSurfaceSound ~= nil and g_currentMission.surfaceNameToSurfaceSound ~= nil then
        local surfaceSound = g_currentMission.materialIdToSurfaceSound[materialId]
        if surfaceSound ~= nil then
            for name, value in pairs(g_currentMission.surfaceNameToSurfaceSound) do
                if value == surfaceSound then
                    surfaceName = tostring(name)
                    break
                end
            end
        end
    end

    SURFACE_NAME_CACHE[materialId] = surfaceName
    return surfaceName ~= false and surfaceName or nil
end

function WayPointGPS.getTerrainSurface(worldX, worldZ)
    if g_currentMission == nil or g_currentMission.terrainRootNode == nil or type(getTerrainAttributesAtWorldPos) ~= "function" then
        return nil
    end

    local y = WayPointGPS.getGroundHeight(worldX, worldZ)
    local ok, r, g, b, a, materialId = pcall(getTerrainAttributesAtWorldPos, g_currentMission.terrainRootNode, worldX, y, worldZ, true, true, true, true, false)
    if not ok then
        return nil
    end

    return {r=r or 0, g=g or 0, b=b or 0, a=a or 0, materialId=materialId, name=WayPointGPS.getSurfaceNameForMaterial(materialId)}
end

function WayPointGPS.isNamedRoadSurface(surface)
    if surface == nil or surface.name == nil then return false end

    local n = string.lower(tostring(surface.name))
    if n:find("asphalt") or n:find("concrete") or n:find("gravel")
        or n:find("dirt") or n:find("road") or n:find("path")
        or n:find("track") or n:find("pavement") or n:find("paved")
        or n:find("cobble") or n:find("stone") then
        return true
    end

    return false
end

function WayPointGPS.getSurfaceColorStats(surface)
    if surface == nil then return nil end
    local r, g, b = surface.r or 0, surface.g or 0, surface.b or 0
    local maxC = math.max(r, math.max(g, b))
    local minC = math.min(r, math.min(g, b))
    local brightness = (r + g + b) / 3
    local saturation = maxC > 0.001 and ((maxC - minC) / maxC) or 0
    return r, g, b, brightness, saturation
end

function WayPointGPS.isGreyRoadColor(surface)
    local r, g, b, brightness, saturation = WayPointGPS.getSurfaceColorStats(surface)
    if r == nil then return false end

    local greenish = g > r * 1.10 and g > b * 1.10
    local bluish = b > r * 1.10 and b > g * 1.05
    local redish = r > g * 1.22 and r > b * 1.22

    -- Asphalt/concrete/compacted gravel tend to be low-saturation grey.
    -- This catches the grey PDA/terrain roads that many custom maps do not
    -- expose as named terrain materials.
    return brightness > 0.30 and brightness < 0.86 and saturation < 0.24
        and not greenish and not bluish and not redish
end

function WayPointGPS.isTanRoadColor(surface)
    local r, g, b, brightness, saturation = WayPointGPS.getSurfaceColorStats(surface)
    if r == nil then return false end

    local greenish = g > r * 1.08 and g > b * 1.18
    local bluish = b > r * 1.08 and b > g * 1.08

    -- Dirt/gravel lanes are usually beige/tan. Keep this narrower than the
    -- old fallback so ripe crops and field fill colors do not become roads.
    return brightness > 0.28 and brightness < 0.78
        and saturation >= 0.08 and saturation < 0.42
        and r >= g * 0.88 and r > b * 1.12 and g > b * 1.06
        and not greenish and not bluish
end

function WayPointGPS.isColorRoadSurface(surface)
    -- Visual fallback only. Field cells are filtered before this is used.
    return WayPointGPS.isGreyRoadColor(surface) or WayPointGPS.isTanRoadColor(surface)
end


function WayPointGPS.isNamedWaterSurface(surface)
    if surface == nil or surface.name == nil then return false end
    local n = string.lower(tostring(surface.name))
    return n:find("water") ~= nil or n:find("river") ~= nil or n:find("stream") ~= nil
        or n:find("pond") ~= nil or n:find("lake") ~= nil or n:find("canal") ~= nil
        or n:find("ditch") ~= nil
end

function WayPointGPS.isBlueWaterColor(surface)
    local r, g, b, brightness, saturation = WayPointGPS.getSurfaceColorStats(surface)
    if r == nil then return false end

    -- Terrain under rivers/water planes is commonly a blue/cyan low-detail color
    -- on PDA-style maps and on many terrain material setups. Keep the test narrow
    -- so grey asphalt and green grass are not classified as water.
    local blueDominant = b > r * 1.18 and b > g * 1.04
    local cyanWater = b > r * 1.10 and g > r * 1.08 and b >= g * 0.92
    local notGrey = saturation > 0.12
    local notGreen = not (g > b * 1.10 and g > r * 1.20)
    return brightness > 0.18 and brightness < 0.92 and notGrey and notGreen and (blueDominant or cyanWater)
end

function WayPointGPS.isWaterAtWorldPos(worldX, worldZ)
    local surface = WayPointGPS.getTerrainSurface(worldX, worldZ)
    if WayPointGPS.isNamedWaterSurface(surface) or WayPointGPS.isBlueWaterColor(surface) then
        return true
    end

    -- Some maps expose water through helper functions instead of terrain material.
    -- Try the common global names safely; if the function is absent, this is a no-op.
    local y = WayPointGPS.getGroundHeight(worldX, worldZ)
    local tryGlobals = {"getWaterYAtWorldPos", "getWaterYAtWorldPosition", "getWaterHeightAtWorldPos", "getWaterHeightAtWorldPosition"}
    for _, fnName in ipairs(tryGlobals) do
        local fn = _G[fnName]
        if type(fn) == "function" then
            local ok, wy = pcall(fn, worldX, y + 3.0, worldZ)
            if ok and type(wy) == "number" and wy > -1000 and math.abs(wy - y) < 8.0 then
                return true
            end
        end
    end

    return false
end

function WayPointGPS.segmentCrossesWater(ax, az, bx, bz, spacing)
    if ax == nil or az == nil or bx == nil or bz == nil then return false, 0 end
    local dx = bx - ax
    local dz = bz - az
    local len = MathUtil.vector2Length(dx, dz)
    if len < 1.0 then
        return WayPointGPS.isWaterAtWorldPos(ax, az), len
    end

    local step = spacing or 8.0
    local count = math.max(1, math.ceil(len / step))
    local waterHits = 0
    for i = 0, count do
        local t = i / count
        local x = ax + dx * t
        local z = az + dz * t
        if WayPointGPS.isWaterAtWorldPos(x, z) then
            waterHits = waterHits + 1
            if waterHits >= 2 then
                return true, len
            end
        end
    end

    return false, len
end

function WayPointGPS.hasNarrowCorridorShape(worldX, worldZ)
    -- This is only a fallback for maps that do not expose road material names.
    -- A real lane should be a narrow clear corridor with field/obstacle edges
    -- on BOTH sides, not just open grass near one field. Requiring two side
    -- edges prevents the route from treating every field border as a road.
    local f = ROAD_ROUTE_FORWARD_SAMPLE
    local s = ROAD_ROUTE_SIDE_SAMPLE
    local fs = ROAD_ROUTE_FAR_SIDE_SAMPLE

    local nsForwardClear = not WayPointGPS.isFieldAtWorldPos(worldX, worldZ + f)
    local nsBackClear = not WayPointGPS.isFieldAtWorldPos(worldX, worldZ - f)
    local nsSideEdges = 0
    if WayPointGPS.isFieldAtWorldPos(worldX + s, worldZ) then nsSideEdges = nsSideEdges + 1 end
    if WayPointGPS.isFieldAtWorldPos(worldX - s, worldZ) then nsSideEdges = nsSideEdges + 1 end
    if WayPointGPS.isFieldAtWorldPos(worldX + fs, worldZ) then nsSideEdges = nsSideEdges + 1 end
    if WayPointGPS.isFieldAtWorldPos(worldX - fs, worldZ) then nsSideEdges = nsSideEdges + 1 end

    local ewForwardClear = not WayPointGPS.isFieldAtWorldPos(worldX + f, worldZ)
    local ewBackClear = not WayPointGPS.isFieldAtWorldPos(worldX - f, worldZ)
    local ewSideEdges = 0
    if WayPointGPS.isFieldAtWorldPos(worldX, worldZ + s) then ewSideEdges = ewSideEdges + 1 end
    if WayPointGPS.isFieldAtWorldPos(worldX, worldZ - s) then ewSideEdges = ewSideEdges + 1 end
    if WayPointGPS.isFieldAtWorldPos(worldX, worldZ + fs) then ewSideEdges = ewSideEdges + 1 end
    if WayPointGPS.isFieldAtWorldPos(worldX, worldZ - fs) then ewSideEdges = ewSideEdges + 1 end

    return (nsForwardClear and nsBackClear and nsSideEdges >= 2)
        or (ewForwardClear and ewBackClear and ewSideEdges >= 2)
end

function WayPointGPS.isRoadSurface(surface)
    -- Keep the old function name for existing call sites, but make it strict.
    return WayPointGPS.isNamedRoadSurface(surface)
end

function WayPointGPS.findNearestRoadSurface(worldX, worldZ)
    local bestX, bestZ, bestDistSq = nil, nil, math.huge
    local r = ROAD_ROUTE_MATERIAL_SCAN_RADIUS
    local step = ROAD_ROUTE_MATERIAL_SCAN_STEP

    for ox = -r, r, step do
        for oz = -r, r, step do
            local x = worldX + ox
            local z = worldZ + oz
            local distSq = ox * ox + oz * oz
            if distSq <= r * r and distSq < bestDistSq then
                local surface = WayPointGPS.getTerrainSurface(x, z)
                if WayPointGPS.isNamedRoadSurface(surface) then
                    bestX, bestZ, bestDistSq = x, z, distSq
                end
            end
        end
    end

    return bestX, bestZ, bestDistSq < math.huge
end

function WayPointGPS.hasVisualRoadContinuity(worldX, worldZ)
    -- A single grey/tan pixel can be a building yard or field edge. Require a
    -- short same-color run in at least one axis, but do not require fields on
    -- both sides because many roads run through open grass/yard areas.
    local d1, d2 = 8.0, 16.0
    local ns = 0
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX, worldZ + d1)) and not WayPointGPS.isFieldAtWorldPos(worldX, worldZ + d1) then ns = ns + 1 end
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX, worldZ - d1)) and not WayPointGPS.isFieldAtWorldPos(worldX, worldZ - d1) then ns = ns + 1 end
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX, worldZ + d2)) and not WayPointGPS.isFieldAtWorldPos(worldX, worldZ + d2) then ns = ns + 1 end
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX, worldZ - d2)) and not WayPointGPS.isFieldAtWorldPos(worldX, worldZ - d2) then ns = ns + 1 end

    local ew = 0
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX + d1, worldZ)) and not WayPointGPS.isFieldAtWorldPos(worldX + d1, worldZ) then ew = ew + 1 end
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX - d1, worldZ)) and not WayPointGPS.isFieldAtWorldPos(worldX - d1, worldZ) then ew = ew + 1 end
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX + d2, worldZ)) and not WayPointGPS.isFieldAtWorldPos(worldX + d2, worldZ) then ew = ew + 1 end
    if WayPointGPS.isColorRoadSurface(WayPointGPS.getTerrainSurface(worldX - d2, worldZ)) and not WayPointGPS.isFieldAtWorldPos(worldX - d2, worldZ) then ew = ew + 1 end

    return ns >= 2 or ew >= 2
end

function WayPointGPS.findNearestVisualRoadSurface(worldX, worldZ)
    local bestX, bestZ, bestDistSq = nil, nil, math.huge
    local r = ROAD_ROUTE_VISUAL_SCAN_RADIUS
    local step = ROAD_ROUTE_VISUAL_SCAN_STEP

    for ox = -r, r, step do
        for oz = -r, r, step do
            local x = worldX + ox
            local z = worldZ + oz
            local distSq = ox * ox + oz * oz
            if distSq <= r * r and distSq < bestDistSq and not WayPointGPS.isFieldAtWorldPos(x, z) then
                local surface = WayPointGPS.getTerrainSurface(x, z)
                if WayPointGPS.isColorRoadSurface(surface) and WayPointGPS.hasVisualRoadContinuity(x, z) then
                    bestX, bestZ, bestDistSq = x, z, distSq
                end
            end
        end
    end

    return bestX, bestZ, bestDistSq < math.huge
end


function WayPointGPS.resetObjectBlockers()
    WayPointGPS.objectBlockers = nil
    WayPointGPS.objectBlockerScanAttempted = false
    WayPointGPS.objectBlockerScanCount = 0
end

function WayPointGPS.getBlockerRadiusForName(name)
    if name == nil then return nil end
    local n = string.lower(tostring(name))
    if n:find("trigger") or n:find("marker") or n:find("nav") or n:find("map") or n:find("icon") then
        return nil
    end

    -- Keep this intentionally narrow. We only want hard blockers that are commonly
    -- cut by bad endpoint connectors, not every decorative building beside a road.
    if n:find("bunkersilo") or n:find("bunker_silo") or n:find("bunker silo") then return 22.0 end
    if n:find("farmsilo") or n:find("farm_silo") or n:find("grainriver") then return 20.0 end
    if n:find("grainsilo") or n:find("grain_silo") or n:find("silo") then return 18.0 end
    if n:find("grainbin") or n:find("grain_bin") or n:find("binold") or n:find("bin_old") then return 14.0 end
    if n:find("dieseltank") or n:find("fuel_tank") or n:find("tank") then return 11.0 end
    return nil
end

function WayPointGPS.ensureObjectBlockers()
    if WayPointGPS.objectBlockerScanAttempted then
        return WayPointGPS.objectBlockers or {}
    end

    WayPointGPS.objectBlockerScanAttempted = true
    WayPointGPS.objectBlockers = {}

    if g_currentMission == nil or type(getName) ~= "function" or type(getWorldTranslation) ~= "function" then
        return WayPointGPS.objectBlockers
    end

    local roots = {
        g_currentMission.rootNode,
        g_currentMission.mapRootNode,
        g_currentMission.terrainRootNode
    }

    local seen = {}
    local queue = {}
    for _, root in ipairs(roots) do
        if root ~= nil and not seen[root] then
            seen[root] = true
            queue[#queue + 1] = {node=root, depth=0}
        end
    end

    local idx = 1
    local scanned = 0
    while idx <= #queue and scanned < 120000 do
        local item = queue[idx]
        idx = idx + 1
        local node = item.node
        scanned = scanned + 1

        local okName, name = pcall(getName, node)
        if okName and name ~= nil then
            local radius = WayPointGPS.getBlockerRadiusForName(name)
            if radius ~= nil then
                local okPos, x, _, z = pcall(getWorldTranslation, node)
                if okPos and type(x) == "number" and type(z) == "number" then
                    WayPointGPS.objectBlockers[#WayPointGPS.objectBlockers + 1] = {x=x, z=z, r=radius, name=tostring(name)}
                end
            end
        end

        if item.depth < 32 and type(getNumOfChildren) == "function" and type(getChildAt) == "function" then
            local okCount, count = pcall(getNumOfChildren, node)
            if okCount and type(count) == "number" and count > 0 then
                for childIndex = 0, count - 1 do
                    local okChild, child = pcall(getChildAt, node, childIndex)
                    if okChild and child ~= nil and not seen[child] then
                        seen[child] = true
                        queue[#queue + 1] = {node=child, depth=item.depth + 1}
                    end
                end
            end
        end
    end

    WayPointGPS.objectBlockerScanCount = scanned
    WayPointGPS.logInfo("[WayPointGPS] Object blocker scan complete | scanned=%d blockers=%d", scanned, #(WayPointGPS.objectBlockers or {}))
    return WayPointGPS.objectBlockers
end

function WayPointGPS.isNearBlockedStructure(worldX, worldZ, extraRadius)
    if worldX == nil or worldZ == nil then return false end
    local blockers = WayPointGPS.ensureObjectBlockers()
    local extra = extraRadius or 0.0
    for _, b in ipairs(blockers) do
        local r = (b.r or 0) + extra
        local dx = worldX - (b.x or 0)
        local dz = worldZ - (b.z or 0)
        if dx * dx + dz * dz <= r * r then
            return true, b
        end
    end
    return false, nil
end

function WayPointGPS.isHardBlockedKind(kind)
    return kind == "water" or kind == "blocked"
end

function WayPointGPS.classifyRoadCellDetailed(worldX, worldZ)
    -- First block water/river cells. Endpoint connectors used to treat water as
    -- generic open land, so the last-mile line could cut straight through rivers.
    if WayPointGPS.isWaterAtWorldPos(worldX, worldZ) then
        return "water", worldX, worldZ
    end

    if WayPointGPS.isNearBlockedStructure(worldX, worldZ, 0.0) then
        return "blocked", worldX, worldZ
    end

    -- First trust explicit terrain/paint material names only. Do not use broad
    -- color or field-edge guessing before field detection; that was creating
    -- long straight shortcuts through open ground.
    local surface = WayPointGPS.getTerrainSurface(worldX, worldZ)
    if WayPointGPS.isNamedRoadSurface(surface) then
        return "road", worldX, worldZ
    end

    local snapX, snapZ, foundRoadSurface = WayPointGPS.findNearestRoadSurface(worldX, worldZ)
    if foundRoadSurface then
        return "road", snapX, snapZ
    end

    if WayPointGPS.isFieldAtWorldPos(worldX, worldZ) then
        return "field", worldX, worldZ
    end

    -- Visual road fallback. Grey roads on custom maps often do not have a
    -- material name, so check the terrain color after field rejection. Grey
    -- roads are allowed with visual continuity only; tan dirt paths need either
    -- visual continuity or the older narrow-corridor field-edge test.
    if WayPointGPS.isGreyRoadColor(surface) and WayPointGPS.hasVisualRoadContinuity(worldX, worldZ) then
        return "road", worldX, worldZ
    end

    if WayPointGPS.isTanRoadColor(surface) and (WayPointGPS.hasVisualRoadContinuity(worldX, worldZ) or WayPointGPS.hasNarrowCorridorShape(worldX, worldZ)) then
        return "road", worldX, worldZ
    end

    local colorSnapX, colorSnapZ, foundColorRoad = WayPointGPS.findNearestVisualRoadSurface(worldX, worldZ)
    if foundColorRoad then
        return "road", colorSnapX, colorSnapZ
    end

    return "open", worldX, worldZ
end

function WayPointGPS.classifyRoadCell(worldX, worldZ)
    local kind = WayPointGPS.classifyRoadCellDetailed(worldX, worldZ)
    return kind
end

-- WayPoint GPS route graphs are already a waypoint network, so for filtering route graph lanes we
-- must not snap a field waypoint to a nearby road surface. The normal classifier
-- does road snapping for last-mile connectors, but that made Hutan field lanes
-- look road-safe. This strict variant checks the exact cell first.
function WayPointGPS.classifyRouteGraphRoadCellStrict(worldX, worldZ)
    if WayPointGPS.isWaterAtWorldPos(worldX, worldZ) then
        return "water"
    end
    if WayPointGPS.isNearBlockedStructure(worldX, worldZ, 0.0) then
        return "blocked"
    end
    if WayPointGPS.isFieldAtWorldPos(worldX, worldZ) then
        return "field"
    end

    local surface = WayPointGPS.getTerrainSurface(worldX, worldZ)
    if WayPointGPS.isNamedRoadSurface(surface) then
        return "road"
    end
    if WayPointGPS.isGreyRoadColor(surface) and WayPointGPS.hasVisualRoadContinuity(worldX, worldZ) then
        return "road"
    end
    if WayPointGPS.isTanRoadColor(surface) and (WayPointGPS.hasVisualRoadContinuity(worldX, worldZ) or WayPointGPS.hasNarrowCorridorShape(worldX, worldZ)) then
        return "road"
    end
    return "open"
end

function WayPointGPS.getEndpointConnectorPenalty(ax, az, bx, bz, spacing)
    if ax == nil or az == nil or bx == nil or bz == nil then
        return 0.0, "connectorPenalty=nil"
    end

    local dx = bx - ax
    local dz = bz - az
    local len = MathUtil.vector2Length(dx, dz)
    if len < 1.0 then
        return 0.0, "connectorPenalty=short"
    end

    local sampleSpacing = spacing or 8.0
    local steps = math.max(1, math.ceil(len / sampleSpacing))
    local roadHits, openHits, fieldHits, waterHits, blockedHits = 0, 0, 0, 0, 0
    local run, maxNonRoadRun = 0.0, 0.0

    for i = 0, steps do
        local t = i / steps
        local x = ax + dx * t
        local z = az + dz * t
        local kind = WayPointGPS.classifyRoadCell(x, z)
        if kind == "road" then
            roadHits = roadHits + 1
            if run > maxNonRoadRun then maxNonRoadRun = run end
            run = 0.0
        else
            if kind == "field" then
                fieldHits = fieldHits + 1
            elseif kind == "water" then
                waterHits = waterHits + 1
            elseif kind == "blocked" then
                blockedHits = blockedHits + 1
            else
                openHits = openHits + 1
            end
            run = run + sampleSpacing
        end
    end
    if run > maxNonRoadRun then maxNonRoadRun = run end

    local samples = roadHits + openHits + fieldHits + waterHits + blockedHits
    local roadRatio = samples > 0 and roadHits / samples or 0.0
    local penalty = 0.0
    penalty = penalty + openHits * 320.0
    penalty = penalty + fieldHits * 1350.0
    penalty = penalty + waterHits * 8000.0
    penalty = penalty + blockedHits * 12000.0
    penalty = penalty + math.max(0.0, maxNonRoadRun - AI_ROAD_DIRECT_CONNECTOR_MAX) * 75.0
    if roadRatio < 0.35 and len > AI_ROAD_DIRECT_CONNECTOR_MAX then
        penalty = penalty + len * 45.0
    end

    return penalty, string.format("penalty=%.0f len=%.0f road=%.2f open=%d field=%d water=%d blocked=%d maxOff=%.0f", penalty, len, roadRatio, openHits, fieldHits, waterHits, blockedHits, maxNonRoadRun)
end

function WayPointGPS.routeCrossesBlockedStructure(route)
    if route == nil or #route < 2 then return false, nil end
    for i = 2, #route do
        local a = route[i - 1]
        local b = route[i]
        local dx = (b.x or 0) - (a.x or 0)
        local dz = (b.z or 0) - (a.z or 0)
        local len = MathUtil.vector2Length(dx, dz)
        local steps = math.max(1, math.ceil(len / 6.0))
        for stepIdx = 0, steps do
            local t = stepIdx / steps
            local hit, blocker = WayPointGPS.isNearBlockedStructure((a.x or 0) + dx * t, (a.z or 0) + dz * t, 0.0)
            if hit then
                return true, blocker
            end
        end
    end
    return false, nil
end

function WayPointGPS.resampleRoute(points, spacing)
    if points == nil or #points == 0 then return nil end
    if #points == 1 then return points end

    local route = {}
    route[#route + 1] = {x=points[1].x, y=WayPointGPS.getGroundHeight(points[1].x, points[1].z), z=points[1].z}

    local carry = 0.0
    for i = 2, #points do
        local prev = points[i - 1]
        local cur = points[i]
        local dx = cur.x - prev.x
        local dz = cur.z - prev.z
        local len = MathUtil.vector2Length(dx, dz)

        if len > 0.001 then
            local dist = spacing - carry
            while dist < len do
                local t = dist / len
                local x = prev.x + dx * t
                local z = prev.z + dz * t
                route[#route + 1] = {x=x, y=WayPointGPS.getGroundHeight(x, z), z=z}
                dist = dist + spacing
            end
            carry = len - (dist - spacing)
            if carry < 0 then carry = 0 end
        end
    end

    local last = points[#points]
    route[#route + 1] = {x=last.x, y=WayPointGPS.getGroundHeight(last.x, last.z), z=last.z}
    return route
end

function WayPointGPS.scoreFallbackSegment(ax, az, bx, bz)
    local dx = bx - ax
    local dz = bz - az
    local len = MathUtil.vector2Length(dx, dz)
    local steps = math.max(1, math.ceil(len / 24.0))
    local score = 0

    for i = 0, steps do
        local t = i / steps
        local kind = WayPointGPS.classifyRoadCell(ax + dx * t, az + dz * t)
        if kind == "road" then
            score = score + 1
        elseif kind == "open" then
            score = score + 18
        else
            score = score + 95
        end
    end

    return score + len * 0.01
end

function WayPointGPS.buildOrthogonalFallbackRoute(startX, startZ, targetX, targetZ)
    local midA = {x=targetX, z=startZ}
    local midB = {x=startX, z=targetZ}

    local scoreA = WayPointGPS.scoreFallbackSegment(startX, startZ, midA.x, midA.z)
                 + WayPointGPS.scoreFallbackSegment(midA.x, midA.z, targetX, targetZ)
    local scoreB = WayPointGPS.scoreFallbackSegment(startX, startZ, midB.x, midB.z)
                 + WayPointGPS.scoreFallbackSegment(midB.x, midB.z, targetX, targetZ)

    local mid = scoreA <= scoreB and midA or midB
    return WayPointGPS.resampleRoute({
        {x=startX, z=startZ},
        {x=mid.x, z=mid.z},
        {x=targetX, z=targetZ}
    }, ROUTE_SAMPLE_DISTANCE)
end

function WayPointGPS.simplifyGridPath(path)
    if path == nil or #path <= 2 then return path end

    local simplified = {path[1]}
    local lastDx, lastDz = nil, nil

    for i = 2, #path do
        local prev = path[i - 1]
        local cur = path[i]
        local dx = cur.gx - prev.gx
        local dz = cur.gz - prev.gz
        if dx ~= lastDx or dz ~= lastDz then
            simplified[#simplified + 1] = prev
            lastDx = dx
            lastDz = dz
        end
    end

    simplified[#simplified + 1] = path[#path]
    return simplified
end

function WayPointGPS.getRouteLength(route)
    if route == nil or #route < 2 then return 0 end
    local len = 0
    for i = 2, #route do
        local a = route[i - 1]
        local b = route[i]
        len = len + MathUtil.vector2Length((b.x or 0) - (a.x or 0), (b.z or 0) - (a.z or 0))
    end
    return len
end

function WayPointGPS.safeGetNodeName(node)
    if node == nil or type(getName) ~= "function" then return "" end
    local ok, name = pcall(getName, node)
    if ok and name ~= nil then
        return tostring(name)
    end
    return ""
end

function WayPointGPS.aiRoadPathLooksUseful(path)
    local p = string.lower(tostring(path or ""))
    if p:find("water") or p:find("river") or p:find("stream")
        or p:find("rail") or p:find("train") or p:find("pedestrian")
        or p:find("field") or p:find("workarea") then
        return false
    end

    -- Use the same mixed AI/traffic discovery as the good AIROAD_OFFROAD build.
    -- Some maps expose usable drivable-road splines under aiSystem/vehicle paths
    -- instead of trafficSystem, so traffic-only scanning can produce zero nodes.
    if p:find("traffic") or p:find("road") or p:find("street")
        or p:find("drive") or p:find("drivable") or p:find("vehicle")
        or p:find("ai") or p:find("car") then
        return true
    end
    return false
end

function WayPointGPS.aiRoadNodeIsSafeSplineCandidate(node, sourcePath)
    if node == nil or type(node) ~= "number" or node <= 0 then return false end

    -- Some AI/scene tables contain regular transform ids mixed with real road splines.
    -- getSplineLength logs a GIANTS warning/error even inside pcall when a transform is
    -- passed in, so reject anything that is not a shape before calling spline APIs.
    if type(entityExists) == "function" then
        local okExists, exists = pcall(entityExists, node)
        if (not okExists) or (not exists) then return false end
    end

    if type(getHasClassId) == "function" and type(ClassIds) == "table" and ClassIds.SHAPE ~= nil then
        local okShape, isShape = pcall(getHasClassId, node, ClassIds.SHAPE)
        if okShape and not isShape then
            return false
        elseif not okShape then
            return false
        end
    end

    local name = string.lower(WayPointGPS.safeGetNodeName(node) or "")
    local path = string.lower(tostring(sourcePath or ""))

    -- These are common non-spline transforms found under road/vehicle nodes on mod maps.
    if name == "" or name == "rootnode" or name:find("ladder") or name:find("trigger")
        or name:find("collision") or name:find("collide") or name:find("marker")
        or name:find("ref") or name:find("light") or name:find("sign") then
        -- Keep true spline-looking names even if a mapper used a generic suffix, but do
        -- not let normal road props through just because their parent path says road.
        if not name:find("spline") and not name:find("curve") and not name:find("path") then
            return false
        end
    end

    -- For scene scanning, require the actual node name to look like a spline/path.
    -- For table scanning, the table path often contains the semantic road/spline label.
    if name:find("spline") or name:find("curve") or name:find("path") or name:find("airoad") or name:find("traffic") then
        return true
    end

    if path:find("spline") or path:find("traffic") or path:find("airoad") or path:find("roadnetwork") or path:find("vehicleai") then
        return true
    end

    return false
end

function WayPointGPS.aiRoadSplineLength(node, sourcePath)
    if node == nil or type(getSplineLength) ~= "function" then return nil end
    if not WayPointGPS.aiRoadNodeIsSafeSplineCandidate(node, sourcePath) then return nil end

    local ok, len = pcall(getSplineLength, node)
    if ok and type(len) == "number" and len > 20.0 and len < 100000.0 then
        return len
    end
    return nil
end

function WayPointGPS.aiRoadSplinePosition(node, t)
    if node == nil or type(getSplinePosition) ~= "function" then return nil, nil end
    local ok, x, y, z = pcall(getSplinePosition, node, t)
    if ok and type(x) == "number" and type(z) == "number" then
        return x, z
    end
    return nil, nil
end

function WayPointGPS.aiRoadGraphAddNode(graph, x, z)
    if graph == nil or x == nil or z == nil then return nil end
    if #graph.nodes >= AI_ROAD_MAX_NODES then return nil end

    local id = #graph.nodes + 1
    graph.nodes[id] = {x=x, z=z}
    graph.adj[id] = {}

    local bx = math.floor(x / AI_ROAD_BUCKET_SIZE)
    local bz = math.floor(z / AI_ROAD_BUCKET_SIZE)
    local key = bx .. ":" .. bz
    local bucket = graph.buckets[key]
    if bucket == nil then
        bucket = {}
        graph.buckets[key] = bucket
    end
    bucket[#bucket + 1] = id

    return id
end

function WayPointGPS.aiRoadGraphAddEdge(graph, a, b, maxCost)
    if graph == nil or a == nil or b == nil or a == b then return false end
    local na, nb = graph.nodes[a], graph.nodes[b]
    if na == nil or nb == nil then return false end
    local cost = MathUtil.vector2Length(nb.x - na.x, nb.z - na.z)
    local limit = maxCost or 80.0
    if cost < 0.1 or cost > limit then return false end

    -- Trust AI/traffic spline edges, including bridges over water.

    graph.adj[a][b] = cost
    graph.adj[b][a] = cost
    return true
end

function WayPointGPS.aiRoadGraphAddDirectedEdge(graph, a, b, maxCost)
    if graph == nil or a == nil or b == nil or a == b then return false end
    local na, nb = graph.nodes[a], graph.nodes[b]
    if na == nil or nb == nil then return false end
    local cost = MathUtil.vector2Length(nb.x - na.x, nb.z - na.z)
    local baseCost = cost
    local limit = maxCost or 80.0
    if cost < 0.1 or cost > limit then return false end

    if graph.isRouteGraph then
        local crossesWater = false
        if WPG_ROUTE_WATER_EDGE_GUARD_ENABLED then
            crossesWater = WayPointGPS.segmentCrossesWater(na.x, na.z, nb.x, nb.z, WPG_ROUTE_WATER_EDGE_SAMPLE)
            if crossesWater then
                local nearBridge = false
                if graph.bridgeObjects ~= nil then
                    local dx = nb.x - na.x
                    local dz = nb.z - na.z
                    local lenSq = dx * dx + dz * dz
                    for _, bridge in ipairs(graph.bridgeObjects) do
                        if bridge ~= nil and bridge.x ~= nil and bridge.z ~= nil then
                            local t = 0.0
                            if lenSq > 0.0001 then
                                t = ((bridge.x - na.x) * dx + (bridge.z - na.z) * dz) / lenSq
                                if t < 0 then t = 0 elseif t > 1 then t = 1 end
                            end
                            local cx = na.x + dx * t
                            local cz = na.z + dz * t
                            if MathUtil.vector2Length(bridge.x - cx, bridge.z - cz) <= WPG_ROUTE_WATER_BRIDGE_RADIUS then
                                nearBridge = true
                                break
                            end
                        end
                    end
                end
                if not nearBridge then
                    graph.adWaterSkippedEdges = (graph.adWaterSkippedEdges or 0) + 1
                    return false
                end
            end
        end

        if WPG_ROUTE_FIELD_EDGE_GUARD_ENABLED then
            local steps = math.max(1, math.ceil(cost / WPG_ROUTE_FIELD_EDGE_SAMPLE))
            local fieldHits, blockedHits, openHits, roadHits = 0, 0, 0, 0
            for i = 0, steps do
                local t = i / steps
                local x = na.x + (nb.x - na.x) * t
                local z = na.z + (nb.z - na.z) * t
                local kind = WayPointGPS.classifyRouteGraphRoadCellStrict(x, z)
                if kind == "field" then
                    fieldHits = fieldHits + 1
                elseif kind == "blocked" then
                    blockedHits = blockedHits + 1
                elseif kind == "road" then
                    roadHits = roadHits + 1
                elseif kind ~= "water" then
                    openHits = openHits + 1
                end
            end

            local samples = fieldHits + blockedHits + openHits + roadHits
            local fieldRatio = samples > 0 and fieldHits / samples or 0.0
            if blockedHits > 0 then
                cost = cost + blockedHits * WPG_ROUTE_BLOCKED_EDGE_PENALTY
                graph.adBlockedPenalizedEdges = (graph.adBlockedPenalizedEdges or 0) + 1
            end
            if fieldHits > 0 then
                cost = cost + fieldHits * WPG_ROUTE_FIELD_EDGE_PENALTY
                graph.adFieldPenalizedEdges = (graph.adFieldPenalizedEdges or 0) + 1
            end
            if openHits > 0 then
                cost = cost + openHits * WPG_ROUTE_OPEN_EDGE_PENALTY
            end

            -- Heavy field links are almost always route graph field/wait/unload lanes,
            -- not public road lanes. Keep short farm connectors possible, but remove
            -- long field diagonals so A* cannot choose them as a shortcut.
            if fieldRatio >= WPG_ROUTE_HEAVY_FIELD_RATIO and baseCost > WPG_ROUTE_HEAVY_FIELD_MIN_LEN then
                graph.adFieldSkippedEdges = (graph.adFieldSkippedEdges or 0) + 1
                return false
            end
        end
    end

    graph.adj[a][b] = cost
    if graph.isRouteGraph then
        graph.adUndirected = graph.adUndirected or {}
        graph.adUndirected[a] = graph.adUndirected[a] or {}
        graph.adUndirected[b] = graph.adUndirected[b] or {}
        graph.adUndirected[a][b] = math.min(graph.adUndirected[a][b] or math.huge, cost)
        graph.adUndirected[b][a] = math.min(graph.adUndirected[b][a] or math.huge, cost)
    end
    return true
end

function WayPointGPS.aiRoadGraphAddSpline(graph, node, sourcePath)
    if graph == nil or node == nil then return false end
    if graph.splineSeen[node] then return false end
    if graph.splineCount >= AI_ROAD_MAX_SPLINES then return false end

    local len = WayPointGPS.aiRoadSplineLength(node, sourcePath)
    if len == nil then return false end

    graph.splineSeen[node] = true
    graph.splineCount = graph.splineCount + 1

    local count = math.max(2, math.ceil(len / AI_ROAD_SAMPLE_DISTANCE))
    local prevId = nil
    local added = 0
    for i = 0, count do
        local t = i / count
        local x, z = WayPointGPS.aiRoadSplinePosition(node, t)
        if x ~= nil and z ~= nil then
            local id = WayPointGPS.aiRoadGraphAddNode(graph, x, z)
            if id ~= nil then
                added = added + 1
                if prevId ~= nil then
                    WayPointGPS.aiRoadGraphAddEdge(graph, prevId, id)
                end
                prevId = id
            end
        end
    end

    if added > 1 then
        graph.sourceCount[sourcePath or "unknown"] = (graph.sourceCount[sourcePath or "unknown"] or 0) + 1
        return true
    end
    return false
end

function WayPointGPS.aiRoadScanTableForSplines(graph, value, path, depth, seenTables)
    if graph == nil or value == nil or depth > 7 or graph.splineCount >= AI_ROAD_MAX_SPLINES then return end
    local tv = type(value)

    if tv == "number" then
        if WayPointGPS.aiRoadPathLooksUseful(path) then
            WayPointGPS.aiRoadGraphAddSpline(graph, value, path)
        end
        return
    end

    if tv ~= "table" then return end
    if seenTables[value] then return end
    seenTables[value] = true

    for k, v in pairs(value) do
        if graph.splineCount >= AI_ROAD_MAX_SPLINES then break end
        local childPath = tostring(path or "table") .. "." .. tostring(k)
        local vt = type(v)
        if vt == "number" then
            if WayPointGPS.aiRoadPathLooksUseful(childPath) then
                WayPointGPS.aiRoadGraphAddSpline(graph, v, childPath)
            end
        elseif vt == "table" and depth < 7 then
            WayPointGPS.aiRoadScanTableForSplines(graph, v, childPath, depth + 1, seenTables)
        end
    end
end

function WayPointGPS.aiRoadScanSceneNode(graph, node, path, depth)
    -- Keep scanning scene nodes even after the spline limit is reached so bridge/causeway
    -- objects can still be discovered for component stitching. The previous early
    -- return could miss late-loaded island bridge objects and make the route detour.
    if graph == nil or node == nil or depth > 14 then return end
    graph.sceneScanCount = (graph.sceneScanCount or 0) + 1
    if graph.sceneScanCount > AI_ROAD_MAX_SCENE_NODES then return end

    local name = WayPointGPS.safeGetNodeName(node)
    local nodePath = (path ~= nil and path ~= "") and (path .. "/" .. name) or name

    local lowerNodePath = string.lower(tostring(nodePath or ""))
    if graph.bridgeObjects ~= nil
        and (lowerNodePath:find("bridge") or lowerNodePath:find("causeway") or lowerNodePath:find("ford") or lowerNodePath:find("ferry") or lowerNodePath:find("crossing") or lowerNodePath:find("island") or lowerNodePath:find("overpass"))
        and type(getWorldTranslation) == "function" then
        local okPos, x, _, z = pcall(getWorldTranslation, node)
        if okPos and type(x) == "number" and type(z) == "number" then
            graph.bridgeObjects[#graph.bridgeObjects + 1] = {x=x, z=z, name=nodePath}
        end
    end

    if graph.splineCount < AI_ROAD_MAX_SPLINES
        and WayPointGPS.aiRoadPathLooksUseful(nodePath)
        and WayPointGPS.aiRoadNodeIsSafeSplineCandidate(node, nodePath) then
        WayPointGPS.aiRoadGraphAddSpline(graph, node, nodePath)
    end

    if type(getNumOfChildren) ~= "function" or type(getChildAt) ~= "function" then return end
    local ok, childCount = pcall(getNumOfChildren, node)
    if not ok or type(childCount) ~= "number" then return end

    for i = 0, childCount - 1 do
        if graph.sceneScanCount > AI_ROAD_MAX_SCENE_NODES then break end
        local okChild, child = pcall(getChildAt, node, i)
        if okChild and child ~= nil then
            WayPointGPS.aiRoadScanSceneNode(graph, child, nodePath, depth + 1)
        end
    end
end

function WayPointGPS.aiRoadConnectIntersections(graph)
    if graph == nil or graph.nodes == nil then return 0 end
    local connected = 0
    local radius = AI_ROAD_INTERSECTION_RADIUS
    local radiusSq = radius * radius

    for id, node in ipairs(graph.nodes) do
        local bx = math.floor(node.x / AI_ROAD_BUCKET_SIZE)
        local bz = math.floor(node.z / AI_ROAD_BUCKET_SIZE)
        for ox = -1, 1 do
            for oz = -1, 1 do
                local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
                if bucket ~= nil then
                    for _, otherId in ipairs(bucket) do
                        if otherId > id then
                            local other = graph.nodes[otherId]
                            local dx = other.x - node.x
                            local dz = other.z - node.z
                            local distSq = dx * dx + dz * dz
                            if distSq <= radiusSq then
                                WayPointGPS.aiRoadGraphAddEdge(graph, id, otherId)
                                connected = connected + 1
                            end
                        end
                    end
                end
            end
        end
    end

    return connected
end

function WayPointGPS.aiRoadBuildComponents(graph)
    local comp = {}
    local compCount = 0
    if graph == nil or graph.nodes == nil then return comp, compCount end

    for id, _ in ipairs(graph.nodes) do
        if comp[id] == nil then
            compCount = compCount + 1
            local queue = {id}
            comp[id] = compCount
            local head = 1
            while head <= #queue do
                local cur = queue[head]
                head = head + 1
                for nextId, _ in pairs(graph.adj[cur] or {}) do
                    if comp[nextId] == nil then
                        comp[nextId] = compCount
                        queue[#queue + 1] = nextId
                    end
                end
                -- Route graph directionals are kept for preferred travel, but GPS-only
                -- route planning may use a higher-cost reverse fallback.  Component
                -- detection must therefore be weak/undirected, otherwise endpoint
                -- candidates can be rejected even though a sensible GPS route exists.
                if graph.isRouteGraph and graph.adUndirected ~= nil then
                    for nextId, _ in pairs(graph.adUndirected[cur] or {}) do
                        if comp[nextId] == nil then
                            comp[nextId] = compCount
                            queue[#queue + 1] = nextId
                        end
                    end
                end
            end
        end
    end

    graph.components = comp
    graph.componentCount = compCount
    return comp, compCount
end

function WayPointGPS.aiRoadPointToSegmentDistance(px, pz, ax, az, bx, bz)
    local dx = bx - ax
    local dz = bz - az
    local lenSq = dx * dx + dz * dz
    if lenSq < 0.0001 then
        return MathUtil.vector2Length(px - ax, pz - az)
    end

    local t = ((px - ax) * dx + (pz - az) * dz) / lenSq
    if t < 0 then
        t = 0
    elseif t > 1 then
        t = 1
    end

    local cx = ax + dx * t
    local cz = az + dz * t
    return MathUtil.vector2Length(px - cx, pz - cz)
end

function WayPointGPS.aiRoadSegmentNearBridgeObjectRadius(graph, ax, az, bx, bz, radius)
    if graph == nil or graph.bridgeObjects == nil then return false end
    local r = radius or AI_ROAD_BRIDGE_OBJECT_RADIUS
    for _, bridge in ipairs(graph.bridgeObjects) do
        if bridge ~= nil and bridge.x ~= nil and bridge.z ~= nil then
            local d = WayPointGPS.aiRoadPointToSegmentDistance(bridge.x, bridge.z, ax, az, bx, bz)
            if d <= r then
                return true
            end
        end
    end
    return false
end

function WayPointGPS.aiRoadSegmentNearBridgeObject(graph, ax, az, bx, bz)
    return WayPointGPS.aiRoadSegmentNearBridgeObjectRadius(graph, ax, az, bx, bz, AI_ROAD_BRIDGE_OBJECT_RADIUS)
end

function WayPointGPS.aiRoadStitchNearbyComponents(graph)
    if graph == nil or graph.nodes == nil then return 0, 0 end

    local comp, beforeCount = WayPointGPS.aiRoadBuildComponents(graph)
    if beforeCount <= 1 then return 0, beforeCount end

    local radius = AI_ROAD_COMPONENT_STITCH_RADIUS
    local radiusSq = radius * radius
    local cellRadius = math.ceil(radius / AI_ROAD_BUCKET_SIZE)
    local added = 0

    for id, node in ipairs(graph.nodes) do
        if added >= AI_ROAD_COMPONENT_STITCH_MAX_EDGES then break end
        local bx = math.floor(node.x / AI_ROAD_BUCKET_SIZE)
        local bz = math.floor(node.z / AI_ROAD_BUCKET_SIZE)
        for ox = -cellRadius, cellRadius do
            if added >= AI_ROAD_COMPONENT_STITCH_MAX_EDGES then break end
            for oz = -cellRadius, cellRadius do
                if added >= AI_ROAD_COMPONENT_STITCH_MAX_EDGES then break end
                local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
                if bucket ~= nil then
                    for _, otherId in ipairs(bucket) do
                        if added >= AI_ROAD_COMPONENT_STITCH_MAX_EDGES then break end
                        if otherId > id and comp[id] ~= nil and comp[otherId] ~= nil and comp[id] ~= comp[otherId] then
                            local other = graph.nodes[otherId]
                            if other ~= nil then
                                local dx = other.x - node.x
                                local dz = other.z - node.z
                                local distSq = dx * dx + dz * dz
                                if distSq <= radiusSq then
                                    local dist = math.sqrt(distSq)
                                    if dist <= AI_ROAD_COMPONENT_STITCH_MAX_COST then
                                        -- Do not use terrain water here as a hard blocker: bridge decks can
                                        -- sample as water below the road. Keeping the stitch radius short makes
                                        -- this act like an intersection/bridge-gap repair, not a river shortcut.
                                        WayPointGPS.aiRoadGraphAddEdge(graph, id, otherId)
                                        added = added + 1
                                        local old = comp[otherId]
                                        local new = comp[id]
                                        for cid, c in pairs(comp) do
                                            if c == old then comp[cid] = new end
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    local _, afterCount = WayPointGPS.aiRoadBuildComponents(graph)
    return added, beforeCount - afterCount
end

function WayPointGPS.aiRoadStitchBridgeComponents(graph)
    if graph == nil or graph.nodes == nil then return 0, 0 end
    if graph.bridgeObjects == nil or #graph.bridgeObjects == 0 then return 0, 0 end

    local comp, beforeCount = WayPointGPS.aiRoadBuildComponents(graph)
    if beforeCount <= 1 then return 0, beforeCount end

    local radius = AI_ROAD_BRIDGE_STITCH_RADIUS
    local radiusSq = radius * radius
    local cellRadius = math.ceil(radius / AI_ROAD_BUCKET_SIZE)
    local added = 0

    for id, node in ipairs(graph.nodes) do
        if added >= AI_ROAD_BRIDGE_STITCH_MAX_EDGES then break end
        local bx = math.floor(node.x / AI_ROAD_BUCKET_SIZE)
        local bz = math.floor(node.z / AI_ROAD_BUCKET_SIZE)
        for ox = -cellRadius, cellRadius do
            if added >= AI_ROAD_BRIDGE_STITCH_MAX_EDGES then break end
            for oz = -cellRadius, cellRadius do
                if added >= AI_ROAD_BRIDGE_STITCH_MAX_EDGES then break end
                local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
                if bucket ~= nil then
                    for _, otherId in ipairs(bucket) do
                        if added >= AI_ROAD_BRIDGE_STITCH_MAX_EDGES then break end
                        if otherId > id and comp[id] ~= nil and comp[otherId] ~= nil and comp[id] ~= comp[otherId] then
                            local other = graph.nodes[otherId]
                            if other ~= nil then
                                local dx = other.x - node.x
                                local dz = other.z - node.z
                                local distSq = dx * dx + dz * dz
                                if distSq <= radiusSq then
                                    local dist = math.sqrt(distSq)
                                    if dist <= AI_ROAD_BRIDGE_STITCH_MAX_COST
                                        and WayPointGPS.aiRoadSegmentNearBridgeObject(graph, node.x, node.z, other.x, other.z) then
                                        if WayPointGPS.aiRoadGraphAddEdge(graph, id, otherId, AI_ROAD_BRIDGE_STITCH_MAX_COST) then
                                            added = added + 1
                                            local old = comp[otherId]
                                            local new = comp[id]
                                            for cid, c in pairs(comp) do
                                                if c == old then comp[cid] = new end
                                            end
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    local _, afterCount = WayPointGPS.aiRoadBuildComponents(graph)
    return added, beforeCount - afterCount
end


function WayPointGPS.aiRoadStitchBridgeHubs(graph)
    if graph == nil or graph.nodes == nil or graph.bridgeObjects == nil or #graph.bridgeObjects == 0 then return 0, 0 end

    local comp, beforeCount = WayPointGPS.aiRoadBuildComponents(graph)
    if beforeCount <= 1 then return 0, beforeCount end

    local added = 0
    local maxEdges = AI_ROAD_BRIDGE_STITCH_MAX_EDGES
    local nearRadius = AI_ROAD_BRIDGE_OBJECT_RADIUS
    local nearRadiusSq = nearRadius * nearRadius

    for _, bridge in ipairs(graph.bridgeObjects) do
        if added >= maxEdges then break end
        if bridge ~= nil and bridge.x ~= nil and bridge.z ~= nil then
            local nearby = {}
            local bx = math.floor(bridge.x / AI_ROAD_BUCKET_SIZE)
            local bz = math.floor(bridge.z / AI_ROAD_BUCKET_SIZE)
            local cellRadius = math.ceil(nearRadius / AI_ROAD_BUCKET_SIZE)
            for ox = -cellRadius, cellRadius do
                for oz = -cellRadius, cellRadius do
                    local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
                    if bucket ~= nil then
                        for _, id in ipairs(bucket) do
                            local n = graph.nodes[id]
                            if n ~= nil and comp[id] ~= nil then
                                local dx = n.x - bridge.x
                                local dz = n.z - bridge.z
                                local dSq = dx * dx + dz * dz
                                if dSq <= nearRadiusSq then
                                    nearby[#nearby + 1] = {id=id, distSq=dSq, comp=comp[id]}
                                end
                            end
                        end
                    end
                end
            end

            if #nearby > 1 then
                table.sort(nearby, function(a, b) return a.distSq < b.distSq end)
                -- Keep the per-bridge work bounded while still giving island bridges enough candidates.
                local maxNearby = math.min(#nearby, 90)
                for i = 1, maxNearby do
                    if added >= maxEdges then break end
                    local a = nearby[i]
                    local na = graph.nodes[a.id]
                    if na ~= nil then
                        local bestJ, bestDist = nil, math.huge
                        for j = 1, maxNearby do
                            local b = nearby[j]
                            if b ~= nil and a.comp ~= b.comp then
                                local nb = graph.nodes[b.id]
                                if nb ~= nil then
                                    local d = MathUtil.vector2Length(nb.x - na.x, nb.z - na.z)
                                    if d < bestDist and d <= AI_ROAD_BRIDGE_STITCH_MAX_COST then
                                        bestDist = d
                                        bestJ = j
                                    end
                                end
                            end
                        end
                        if bestJ ~= nil then
                            local b = nearby[bestJ]
                            if WayPointGPS.aiRoadGraphAddEdge(graph, a.id, b.id, AI_ROAD_BRIDGE_STITCH_MAX_COST) then
                                added = added + 1
                                local old = comp[b.id]
                                local new = comp[a.id]
                                for cid, c in pairs(comp) do
                                    if c == old then comp[cid] = new end
                                end
                                for _, item in ipairs(nearby) do
                                    if item.comp == old then item.comp = new end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    local _, afterCount = WayPointGPS.aiRoadBuildComponents(graph)
    return added, beforeCount - afterCount
end

function WayPointGPS.aiRoadScanBridgeObjectsOnly(graph, node, path, depth)
    if graph == nil or node == nil or depth > 14 then return end
    graph.sceneScanCount = (graph.sceneScanCount or 0) + 1
    if graph.sceneScanCount > AI_ROAD_MAX_SCENE_NODES then return end

    local name = WayPointGPS.safeGetNodeName(node)
    local nodePath = (path ~= nil and path ~= "") and (path .. "/" .. name) or name
    local lowerNodePath = string.lower(tostring(nodePath or ""))

    if graph.bridgeObjects ~= nil
        and (lowerNodePath:find("bridge") or lowerNodePath:find("causeway") or lowerNodePath:find("ford") or lowerNodePath:find("ferry") or lowerNodePath:find("crossing") or lowerNodePath:find("overpass"))
        and type(getWorldTranslation) == "function" then
        local okPos, x, _, z = pcall(getWorldTranslation, node)
        if okPos and type(x) == "number" and type(z) == "number" then
            graph.bridgeObjects[#graph.bridgeObjects + 1] = {x=x, z=z, name=nodePath}
        end
    end

    if type(getNumOfChildren) ~= "function" or type(getChildAt) ~= "function" then return end
    local ok, childCount = pcall(getNumOfChildren, node)
    if not ok or type(childCount) ~= "number" then return end

    for i = 0, childCount - 1 do
        if graph.sceneScanCount > AI_ROAD_MAX_SCENE_NODES then break end
        local okChild, child = pcall(getChildAt, node, i)
        if okChild and child ~= nil then
            WayPointGPS.aiRoadScanBridgeObjectsOnly(graph, child, nodePath, depth + 1)
        end
    end
end

function WayPointGPS.aiRoadScanBridgeObjectsForGraph(graph)
    if graph == nil or g_currentMission == nil then return end
    local roots = {
        {node=g_currentMission.terrainRootNode, name="terrainRootNode"},
        {node=g_currentMission.rootNode, name="missionRootNode"},
        {node=g_currentMission.mapRootNode, name="mapRootNode"}
    }
    local seenRoots = {}
    for _, r in ipairs(roots) do
        if r.node ~= nil and seenRoots[r.node] == nil then
            seenRoots[r.node] = true
            WayPointGPS.aiRoadScanBridgeObjectsOnly(graph, r.node, r.name, 0)
        end
    end
end

function WayPointGPS.newAIRoadGraph()
    return {nodes={}, adj={}, buckets={}, splineSeen={}, splineCount=0, sourceCount={}, sceneScanCount=0, bridgeObjects={}}
end

function WayPointGPS.adCsvToNumbers(text)
    local out = {}
    if text == nil then return out end
    for token in string.gmatch(tostring(text), "[^,]+") do
        local value = tonumber(token)
        if value ~= nil then
            out[#out + 1] = value
        end
    end
    return out
end

function WayPointGPS.adGetXMLString(xmlFile, key)
    if xmlFile == nil then return "" end
    local ok, value = pcall(function()
        return xmlFile:getString(key, "")
    end)
    if ok and value ~= nil then return tostring(value) end
    return ""
end

function WayPointGPS.adGetXMLFloat(xmlFile, key)
    if xmlFile == nil then return nil end
    local ok, value = pcall(function()
        if type(xmlFile.getString) == "function" then
            return tonumber(xmlFile:getString(key, ""))
        end
        if type(xmlFile.getFloat) == "function" then
            return xmlFile:getFloat(key, 0)
        end
        return nil
    end)
    if ok and value ~= nil then return tonumber(value) end
    return nil
end

function WayPointGPS.adReadMapMarkers(xmlFile)
    local markers = {}
    if xmlFile == nil then return markers end
    for i = 1, 600 do
        local root = "WayPointGPSRoadGraph.mapmarker.mm" .. tostring(i)
        local id = WayPointGPS.adGetXMLFloat(xmlFile, root .. ".id")
        if id == nil then
            -- Route graph marker numbering is normally dense. Give it a little room,
            -- then stop so we do not do hundreds of useless XML lookups.
            if i > 220 then break end
        else
            local adId = math.floor(id + 0.5)
            markers[adId] = {
                name = WayPointGPS.adGetXMLString(xmlFile, root .. ".name"),
                group = WayPointGPS.adGetXMLString(xmlFile, root .. ".group")
            }
        end
    end
    return markers
end

function WayPointGPS.adMarkerLooksLikeFieldBranch(marker)
    if marker == nil then return false end
    local text = string.lower(tostring(marker.group or "") .. " " .. tostring(marker.name or ""))
    text = text:gsub("ä", "a"):gsub("ö", "o"):gsub("ü", "u"):gsub("ß", "ss")
    text = text:gsub("á", "a"):gsub("à", "a"):gsub("â", "a"):gsub("ã", "a"):gsub("å", "a")
    text = text:gsub("é", "e"):gsub("è", "e"):gsub("ê", "e")
    text = text:gsub("í", "i"):gsub("ì", "i"):gsub("î", "i")
    text = text:gsub("ó", "o"):gsub("ò", "o"):gsub("ô", "o"):gsub("õ", "o")
    text = text:gsub("ú", "u"):gsub("ù", "u"):gsub("û", "u")
    text = text:gsub("ç", "c"):gsub("ñ", "n")

    local branchWords = {
        "fields", "field", "feld", "felder", "feldarbeit", "fieldwork", "champ", "champs", "campo", "campos",
        "tractor wait", "wait", "waiting", "warte", "warten", "wartepunkt", "park", "parking",
        "flood field", "rice field", "reisfeld", "paddy",
        "forestry area", "forestry", "forest", "forst", "wald", "bois", "holz", "wood",
        "biogas bunker", "silage bunker", "bunker", "silo", "bga",
        "unload", "loading", "load", "entladen", "ablad", "abladen", "abkippen", "laden",
        "yard", "farmyard", "farm", "hof", "hoefe", "hofe",
        "sellpoint", "sell point", "selling", "verkauf", "verkaufen", "decharg", "dechargement"
    }
    for _, word in ipairs(branchWords) do
        if text:find(word, 1, true) ~= nil then return true end
    end
    return false
end

function WayPointGPS.adGetActiveMapDescriptor()
    local parts = {}
    local mission = g_currentMission
    local info = mission ~= nil and mission.missionInfo or nil
    local function add(value)
        if value ~= nil then
            parts[#parts + 1] = tostring(value)
        end
    end

    if info ~= nil then
        add(info.mapId)
        add(info.mapTitle)
        add(info.mapName)
        add(info.mapXMLFilename)
        add(info.mapFilename)
    end
    if mission ~= nil then
        add(mission.mapId)
        add(mission.mapTitle)
        add(mission.mapName)
        add(mission.mapXMLFilename)
        add(mission.baseDirectory)
        add(mission.mapBaseDirectory)
    end

    return string.lower(table.concat(parts, " "))
end

function WayPointGPS.adNormalizeMapText(value)
    local s = string.lower(tostring(value or ""))
    s = s:gsub("ä", "a"):gsub("ö", "o"):gsub("ü", "u"):gsub("ß", "ss")
    s = s:gsub("á", "a"):gsub("à", "a"):gsub("â", "a"):gsub("ã", "a"):gsub("å", "a")
    s = s:gsub("é", "e"):gsub("è", "e"):gsub("ê", "e")
    s = s:gsub("í", "i"):gsub("ì", "i"):gsub("î", "i")
    s = s:gsub("ó", "o"):gsub("ò", "o"):gsub("ô", "o"):gsub("õ", "o")
    s = s:gsub("ú", "u"):gsub("ù", "u"):gsub("û", "u")
    s = s:gsub("ç", "c"):gsub("ñ", "n")
    s = s:gsub("[^%w]+", "")
    return s
end

function WayPointGPS.adIsGenericMapToken(value)
    local compact = WayPointGPS.adNormalizeMapText(value)
    return compact == "mapus" or compact == "mapas" or compact == "mapeu" or compact == "samplemodmap"
end

function WayPointGPS.adDescriptorIsBaseGameMapToken(value, descLower, compactDesc)
    local compact = WayPointGPS.adNormalizeMapText(value)
    if compact ~= "mapus" and compact ~= "mapas" and compact ~= "mapeu" then return false end

    descLower = descLower or string.lower(WayPointGPS.adGetActiveMapDescriptor() or "")
    compactDesc = compactDesc or WayPointGPS.adNormalizeMapText(descLower)

    -- Custom maps often keep the internal folder as mapUS/mapAS/mapEU.  Do not let
    -- that generic folder name select a bundled base-map route graph.  Generic
    -- map tokens are only trusted when the active map is loaded from the base game
    -- data/maps path, not from a mod folder.
    if descLower:find("/mods/", 1, true) ~= nil or descLower:find("\\mods\\", 1, true) ~= nil then
        return false
    end

    local pathNeedle = "data/maps/" .. compact
    local compactNeedle = "datamaps" .. compact
    if descLower:find(pathNeedle, 1, true) ~= nil then return true end
    if compactDesc:find(compactNeedle, 1, true) ~= nil then return true end
    return false
end

function WayPointGPS.adScoreConfigMatch(config, xmlMapName)
    local desc = WayPointGPS.adGetActiveMapDescriptor()
    if desc == nil or desc == "" then
        return 0, "emptyDescriptor"
    end

    local descLower = string.lower(desc)
    local compactDesc = WayPointGPS.adNormalizeMapText(desc)
    local bestScore = 0
    local bestReason = "noMatch"

    local function consider(value, score, reason, allowGeneric)
        if value == nil then return end
        local raw = string.lower(tostring(value))
        if raw == "" then return end
        local compact = WayPointGPS.adNormalizeMapText(raw)
        if compact == "" then return end

        local matched = false
        if WayPointGPS.adIsGenericMapToken(compact) then
            matched = allowGeneric == true and WayPointGPS.adDescriptorIsBaseGameMapToken(compact, descLower, compactDesc)
        else
            matched = descLower:find(raw, 1, true) ~= nil or compactDesc:find(compact, 1, true) ~= nil
        end

        if matched and score > bestScore then
            bestScore = score
            bestReason = tostring(reason) .. "=" .. tostring(value)
        end
    end

    if config ~= nil then
        consider(config.key, 980, "key", false)
        if config.aliases ~= nil then
            for _, alias in ipairs(config.aliases) do
                local generic = WayPointGPS.adIsGenericMapToken(alias)
                consider(alias, generic and 260 or 900, generic and "baseGameGenericAlias" or "alias", true)
            end
        end
        local hintGeneric = WayPointGPS.adIsGenericMapToken(config.mapHint)
        consider(config.mapHint, hintGeneric and 240 or 760, hintGeneric and "baseGameGenericHint" or "mapHint", true)
    end
    consider(xmlMapName, 1020, "xmlMapName", false)

    return bestScore, bestReason
end

function WayPointGPS.adConfigHintMatchesDescriptor(config, xmlMapName)
    local score = WayPointGPS.adScoreConfigMatch(config, xmlMapName)
    return score ~= nil and score > 0
end

function WayPointGPS.adMapLooksCompatible(config, xmlMapName)
    local score = WayPointGPS.adScoreConfigMatch(config, xmlMapName)
    return score ~= nil and score > 0
end

function WayPointGPS.adMarkFieldBranches(graph)
    if graph == nil or graph.nodes == nil then return 0, 0 end
    local queue = {}
    local head = 1
    local seeds = 0

    for id, node in ipairs(graph.nodes) do
        if node ~= nil and node.adFieldBranchSeed then
            seeds = seeds + 1
            node.adFieldBranch = true
            node.adFieldBranchDist = 0.0
            queue[#queue + 1] = id
        end
    end

    local marked = seeds
    while head <= #queue do
        local id = queue[head]
        head = head + 1
        local baseNode = graph.nodes[id]
        local baseDist = baseNode ~= nil and baseNode.adFieldBranchDist or 0.0
        for nextId, edgeCost in pairs((graph.adUndirected ~= nil and graph.adUndirected[id]) or {}) do
            local nd = baseDist + (edgeCost or 0.0)
            if nd <= WPG_ROUTE_FIELD_BRANCH_DISTANCE then
                local nextNode = graph.nodes[nextId]
                if nextNode ~= nil and (nextNode.adFieldBranchDist == nil or nd < nextNode.adFieldBranchDist) then
                    if not nextNode.adFieldBranch then marked = marked + 1 end
                    nextNode.adFieldBranch = true
                    nextNode.adFieldBranchDist = nd
                    queue[#queue + 1] = nextId
                end
            end
        end
    end

    graph.adFieldBranchSeeds = seeds
    graph.adFieldBranchMarked = marked
    return marked, seeds
end

function WayPointGPS.adTurnPenalty(graph, prevId, currentId, nextId)
    if (not WPG_ROUTE_TURN_PENALTY_ENABLED) or graph == nil or not graph.isRouteGraph then return 0.0 end
    if prevId == nil or currentId == nil or nextId == nil then return 0.0 end
    local p = graph.nodes[prevId]
    local c = graph.nodes[currentId]
    local n = graph.nodes[nextId]
    if p == nil or c == nil or n == nil then return 0.0 end
    local ax = (c.x or 0) - (p.x or 0)
    local az = (c.z or 0) - (p.z or 0)
    local bx = (n.x or 0) - (c.x or 0)
    local bz = (n.z or 0) - (c.z or 0)
    local al = MathUtil.vector2Length(ax, az)
    local bl = MathUtil.vector2Length(bx, bz)
    if al < 1.5 or bl < 1.5 then return 0.0 end
    local dot = (ax * bx + az * bz) / (al * bl)
    if dot < -1.0 then dot = -1.0 elseif dot > 1.0 then dot = 1.0 end
    local angle = math.deg(math.acos(dot))
    if angle < WPG_ROUTE_TURN_SOFT_DEG then return 0.0 end
    if angle >= 120.0 then
        return WPG_ROUTE_TURN_HARD_PENALTY
    elseif angle >= WPG_ROUTE_TURN_SHARP_DEG then
        return WPG_ROUTE_TURN_SHARP_PENALTY + (angle - WPG_ROUTE_TURN_SHARP_DEG) * 8.0
    end
    return WPG_ROUTE_TURN_SOFT_PENALTY + (angle - WPG_ROUTE_TURN_SOFT_DEG) * 2.0
end

function WayPointGPS.getRouteGraphAStarNeighbors(graph, nodeId)
    local out = {}
    local reverseFallback = nil
    if graph == nil or nodeId == nil then return out, reverseFallback end

    for nextId, cost in pairs(graph.adj ~= nil and graph.adj[nodeId] or {}) do
        out[nextId] = cost
    end

    -- GPS-only mode: keep WayPoint GPS/course-pack directionals as the preferred route,
    -- but allow a higher-cost reverse edge if it prevents a silly loop.  This is
    -- visual route guidance only, not vehicle control.
    if WPG_GPS_REVERSE_FALLBACK_ENABLED and graph.isRouteGraph and graph.adUndirected ~= nil then
        for nextId, cost in pairs(graph.adUndirected[nodeId] or {}) do
            if out[nextId] == nil then
                out[nextId] = (cost or 0.0) * WPG_GPS_REVERSE_FALLBACK_FACTOR + WPG_GPS_REVERSE_FALLBACK_EXTRA
                reverseFallback = reverseFallback or {}
                reverseFallback[nextId] = true
            end
        end
    end

    return out, reverseFallback
end

function WayPointGPS.gpsRouteTightenPenalty(graph, currentId, nextId, startX, startZ, targetX, targetZ)
    if not WPG_GPS_PATH_TIGHTENING_ENABLED or graph == nil or not graph.isRouteGraph then return 0.0 end
    if currentId == nil or nextId == nil or startX == nil or startZ == nil or targetX == nil or targetZ == nil then return 0.0 end
    local c = graph.nodes ~= nil and graph.nodes[currentId] or nil
    local n = graph.nodes ~= nil and graph.nodes[nextId] or nil
    if c == nil or n == nil then return 0.0 end

    local sx = targetX - startX
    local sz = targetZ - startZ
    local directLen = MathUtil.vector2Length(sx, sz)
    if directLen < 35.0 then return 0.0 end

    local penalty = 0.0
    local before = MathUtil.vector2Length((targetX or 0) - (c.x or 0), (targetZ or 0) - (c.z or 0))
    local after = MathUtil.vector2Length((targetX or 0) - (n.x or 0), (targetZ or 0) - (n.z or 0))

    -- Discourage temporary branches that move away from the selected waypoint.
    -- Needed for routes that dip into yards/sellpoints and come back to the road.
    if after > before + 4.0 then
        penalty = penalty + (after - before - 4.0) * WPG_GPS_AWAY_PENALTY
    end

    -- Keep candidates inside a generous start->target corridor when possible.
    -- This is deliberately soft, so real curved roads can still win, but large
    -- unnecessary detours become more expensive than the direct road section.
    local vx = (n.x or 0) - startX
    local vz = (n.z or 0) - startZ
    local cross = math.abs(vx * sz - vz * sx)
    local corridorDist = cross / math.max(directLen, 0.001)
    local allowed = math.min(WPG_GPS_CORRIDOR_MAX, WPG_GPS_CORRIDOR_BASE + directLen * WPG_GPS_CORRIDOR_FACTOR)
    if corridorDist > allowed then
        penalty = penalty + (corridorDist - allowed) * WPG_GPS_CORRIDOR_PENALTY
    end

    return penalty
end


function WayPointGPS.gpsRouteGraphRoadAdherencePenalty(graph, currentId, nextId, startX, startZ, targetX, targetZ)
    if not WPG_GPS_ROAD_ADHERENCE_ENABLED or graph == nil or not graph.isRouteGraph then return 0.0 end
    if currentId == nil or nextId == nil then return 0.0 end
    local a = graph.nodes ~= nil and graph.nodes[currentId] or nil
    local b = graph.nodes ~= nil and graph.nodes[nextId] or nil
    if a == nil or b == nil then return 0.0 end

    local ax, az = a.x or 0.0, a.z or 0.0
    local bx, bz = b.x or 0.0, b.z or 0.0
    local dx, dz = bx - ax, bz - az
    local len = MathUtil.vector2Length(dx, dz)
    if len < 1.0 then return 0.0 end

    local nearEndpoint = false
    if startX ~= nil and startZ ~= nil then
        nearEndpoint = nearEndpoint or MathUtil.vector2Length(ax - startX, az - startZ) <= WPG_GPS_EDGE_NEAR_ENDPOINT_RADIUS
        nearEndpoint = nearEndpoint or MathUtil.vector2Length(bx - startX, bz - startZ) <= WPG_GPS_EDGE_NEAR_ENDPOINT_RADIUS
    end
    if targetX ~= nil and targetZ ~= nil then
        nearEndpoint = nearEndpoint or MathUtil.vector2Length(ax - targetX, az - targetZ) <= WPG_GPS_EDGE_NEAR_ENDPOINT_RADIUS
        nearEndpoint = nearEndpoint or MathUtil.vector2Length(bx - targetX, bz - targetZ) <= WPG_GPS_EDGE_NEAR_ENDPOINT_RADIUS
    end

    local steps = math.max(1, math.ceil(len / WPG_GPS_EDGE_ROAD_SAMPLE))
    local stepLen = len / steps
    local openHits, fieldHits, hardHits, roadHits = 0, 0, 0, 0
    local nonRoadRun, maxNonRoadRun = 0.0, 0.0

    for i = 0, steps do
        local t = i / steps
        local x = ax + dx * t
        local z = az + dz * t
        local kind = WayPointGPS.classifyRouteGraphRoadCellStrict(x, z)
        if kind == "road" then
            roadHits = roadHits + 1
            if nonRoadRun > maxNonRoadRun then maxNonRoadRun = nonRoadRun end
            nonRoadRun = 0.0
        else
            nonRoadRun = nonRoadRun + stepLen
            if kind == "field" then
                fieldHits = fieldHits + 1
            elseif kind == "water" or kind == "blocked" then
                hardHits = hardHits + 1
            else
                openHits = openHits + 1
            end
        end
    end
    if nonRoadRun > maxNonRoadRun then maxNonRoadRun = nonRoadRun end

    local penalty = 0.0
    penalty = penalty + openHits * WPG_GPS_EDGE_OPEN_PENALTY
    penalty = penalty + fieldHits * WPG_GPS_EDGE_FIELD_PENALTY
    penalty = penalty + hardHits * WPG_GPS_EDGE_WATER_BLOCKED_PENALTY

    -- Let short last-meter connectors exist near the vehicle/target, but prevent
    -- longer mid-route grass/field shortcuts from beating a real road section.
    if not nearEndpoint and maxNonRoadRun > WPG_GPS_EDGE_NONROAD_RUN_HARD and len > 20.0 then
        penalty = penalty + WPG_GPS_EDGE_NONROAD_HARD_PENALTY + (maxNonRoadRun - WPG_GPS_EDGE_NONROAD_RUN_HARD) * WPG_GPS_EDGE_NONROAD_RUN_FACTOR
    end

    -- If the edge has no road samples at all, treat it like a helper/trail edge.
    -- This is intentionally a penalty, not a hard block, so missing map-material
    -- data can still be routed when there is truly no better option.
    if roadHits <= 0 and len > 12.0 and not nearEndpoint then
        penalty = penalty + math.min(90000.0, len * 950.0)
    end

    return penalty
end

function WayPointGPS.adContinuationPenalty(graph, prevId, currentId, nextId, startX, startZ, targetX, targetZ)
    if (not WPG_ROUTE_CONTINUATION_ENABLED) or graph == nil or prevId == nil or currentId == nil or nextId == nil then return 0.0 end
    local p = graph.nodes ~= nil and graph.nodes[prevId] or nil
    local c = graph.nodes ~= nil and graph.nodes[currentId] or nil
    local n = graph.nodes ~= nil and graph.nodes[nextId] or nil
    if p == nil or c == nil or n == nil then return 0.0 end

    local inX = (c.x or 0) - (p.x or 0)
    local inZ = (c.z or 0) - (p.z or 0)
    local outX = (n.x or 0) - (c.x or 0)
    local outZ = (n.z or 0) - (c.z or 0)
    local inLen = MathUtil.vector2Length(inX, inZ)
    local outLen = MathUtil.vector2Length(outX, outZ)
    if inLen < 2.0 or outLen < 2.0 then return 0.0 end

    local dot = (inX * outX + inZ * outZ) / (inLen * outLen)
    if dot < -1.0 then dot = -1.0 elseif dot > 1.0 then dot = 1.0 end
    local angle = math.deg(math.acos(dot))

    local penalty = 0.0
    if angle > WPG_ROUTE_CONTINUATION_MIN_DEG then
        local extra = angle - WPG_ROUTE_CONTINUATION_MIN_DEG
        penalty = penalty + extra * extra * WPG_ROUTE_CONTINUATION_DEG_FACTOR * 0.04
    end

    -- Make the preference stronger at actual intersections/junctions, but do not
    -- forbid turns. If the route truly needs the branch, A* can still select it.
    local degree = 0
    if graph.adj ~= nil and graph.adj[currentId] ~= nil then
        for _ in pairs(graph.adj[currentId]) do degree = degree + 1 end
    end
    if degree >= 3 and penalty > 0 then
        penalty = penalty * WPG_ROUTE_JUNCTION_EXTRA_FACTOR
    end

    -- If an edge points away from the final target while we already have incoming
    -- direction, discourage the temporary side-road dip seen at crossings.
    if targetX ~= nil and targetZ ~= nil then
        local before = MathUtil.vector2Length((targetX or 0) - (c.x or 0), (targetZ or 0) - (c.z or 0))
        local after = MathUtil.vector2Length((targetX or 0) - (n.x or 0), (targetZ or 0) - (n.z or 0))
        if after > before + 3.0 and angle > 35.0 then
            penalty = penalty + WPG_ROUTE_BACKTRACK_PENALTY
        end
    end

    return penalty
end

function WayPointGPS.adBranchPenalty(graph, nodeId, startX, startZ, targetX, targetZ)
    if graph == nil or not graph.isRouteGraph or nodeId == nil then return 0.0 end
    local node = graph.nodes[nodeId]
    if node == nil or not node.adFieldBranch then return 0.0 end

    local nearStart = MathUtil.vector2Length((node.x or 0) - startX, (node.z or 0) - startZ) <= WPG_ROUTE_FIELD_BRANCH_ENDPOINT_RADIUS
    local nearTarget = MathUtil.vector2Length((node.x or 0) - targetX, (node.z or 0) - targetZ) <= WPG_ROUTE_FIELD_BRANCH_ENDPOINT_RADIUS
    if nearStart or nearTarget then return 0.0 end

    local branchDist = node.adFieldBranchDist or 0.0
    return WPG_ROUTE_FIELD_BRANCH_PENALTY + math.max(0.0, WPG_ROUTE_FIELD_BRANCH_DISTANCE - branchDist) * 260.0
end

function WayPointGPS.buildWaypointRouteGraph(config)
    if not WPG_ROUTE_GRAPH_ENABLED or config == nil or config.file == nil then
        return nil, "disabled"
    end
    if XMLFile == nil or type(XMLFile.loadIfExists) ~= "function" then
        return nil, "noXMLFileAPI"
    end

    -- Avoid parsing every bundled WayPoint GPS route XML on startup/first route. Most custom
    -- course packs are large, so first match the active map against the config key,
    -- hint, and aliases, then parse only the likely map file.
    if not WayPointGPS.adConfigHintMatchesDescriptor(config, nil) then
        return nil, "mapMismatchPre config=" .. tostring(config.key)
    end

    local xmlFile = XMLFile.loadIfExists("WayPointGPSRoadGraph", config.file, "WayPointGPSRoadGraph")
    if xmlFile == nil then
        return nil, "missing " .. tostring(config.file)
    end

    local xmlMapName = WayPointGPS.adGetXMLString(xmlFile, "WayPointGPSRoadGraph.MapName")
    if not WayPointGPS.adMapLooksCompatible(config, xmlMapName) then
        if type(xmlFile.delete) == "function" then xmlFile:delete() end
        return nil, "mapMismatch config=" .. tostring(config.key) .. " xml=" .. tostring(xmlMapName)
    end

    local waypointRoot = "WayPointGPSRoadGraph.waypoints"
    local ids = WayPointGPS.adCsvToNumbers(WayPointGPS.adGetXMLString(xmlFile, waypointRoot .. ".id"))
    local xs = WayPointGPS.adCsvToNumbers(WayPointGPS.adGetXMLString(xmlFile, waypointRoot .. ".x"))
    local zs = WayPointGPS.adCsvToNumbers(WayPointGPS.adGetXMLString(xmlFile, waypointRoot .. ".z"))
    local outText = WayPointGPS.adGetXMLString(xmlFile, waypointRoot .. ".out")
    local seedValues = WayPointGPS.adCsvToNumbers(WayPointGPS.adGetXMLString(xmlFile, waypointRoot .. ".fieldBranchSeeds"))
    if type(xmlFile.delete) == "function" then xmlFile:delete() end

    local fieldBranchSeeds = {}
    for _, seedValue in ipairs(seedValues) do
        fieldBranchSeeds[math.floor((seedValue or 0) + 0.5)] = true
    end

    local count = math.min(#ids, #xs, #zs, AI_ROAD_MAX_NODES)
    if count < 8 then
        return nil, string.format("tooFewWaypoints ids=%d x=%d z=%d", #ids, #xs, #zs)
    end

    local graph = WayPointGPS.newAIRoadGraph()
    graph.isRouteGraph = true
    graph.usesLaneCenters = true
    graph.sourceMapName = xmlMapName ~= nil and xmlMapName ~= "" and xmlMapName or tostring(config.key)
    graph.sourceCount["RouteGraph:" .. tostring(graph.sourceMapName)] = 1

    local idToNode = {}
    for i = 1, count do
        local adId = math.floor((ids[i] or i) + 0.5)
        local nodeId = WayPointGPS.aiRoadGraphAddNode(graph, xs[i], zs[i])
        if nodeId ~= nil then
            idToNode[adId] = nodeId
            graph.nodes[nodeId].adId = adId
            graph.nodes[nodeId].wpgLayer = "coursePack"
            if fieldBranchSeeds[adId] then
                graph.nodes[nodeId].adFieldBranchSeed = true
            end
        end
    end

    -- Only collect bridge/causeway markers here; do not scan extra AI splines into
    -- the WayPoint GPS route graph. The markers let us keep real bridge edges while blocking
    -- route graph water/ferry/helper links that would draw the GPS into the river.
    WayPointGPS.aiRoadScanBridgeObjectsForGraph(graph)

    local edgeCount = 0
    local entryIndex = 1
    for entry in string.gmatch(tostring(outText or "") .. ";", "([^;]*);") do
        if entryIndex > count then break end
        local fromAD = math.floor((ids[entryIndex] or entryIndex) + 0.5)
        local fromNode = idToNode[fromAD]
        if fromNode ~= nil and entry ~= nil and entry ~= "" then
            for token in string.gmatch(entry, "[^,]+") do
                local toAD = tonumber(token)
                if toAD ~= nil then
                    local toNode = idToNode[math.floor(toAD + 0.5)]
                    if toNode ~= nil and WayPointGPS.aiRoadGraphAddDirectedEdge(graph, fromNode, toNode, WPG_ROUTE_MAX_EDGE_COST) then
                        graph.wpgEdgeMeta = graph.wpgEdgeMeta or {}
                        graph.wpgEdgeMeta[fromNode] = graph.wpgEdgeMeta[fromNode] or {}
                        graph.wpgEdgeMeta[fromNode][toNode] = {layer="coursePack", source="bundledRouteGraph", priority=2}
                        edgeCount = edgeCount + 1
                    end
                end
            end
        end
        entryIndex = entryIndex + 1
    end

    local branchMarked, branchSeeds = WayPointGPS.adMarkFieldBranches(graph)

    if edgeCount <= 0 then
        return nil, "noEdges"
    end

    graph.splineCount = 1
    graph.adEdgeCount = edgeCount
    graph.sceneScanCount = 0
    local _, componentCount = WayPointGPS.aiRoadBuildComponents(graph)
    WayPointGPS.logInfo("[WayPointGPS] WayPoint GPS route graph loaded | map=%s nodes=%d edges=%d waterEdgesSkipped=%d fieldEdgesSkipped=%d fieldEdgesPenalized=%d blockedEdgesPenalized=%d fieldBranchSeeds=%d fieldBranchNodes=%d bridgeObjects=%d components=%d file=%s", tostring(graph.sourceMapName), #graph.nodes, edgeCount, graph.adWaterSkippedEdges or 0, graph.adFieldSkippedEdges or 0, graph.adFieldPenalizedEdges or 0, graph.adBlockedPenalizedEdges or 0, branchSeeds or 0, branchMarked or 0, graph.bridgeObjects ~= nil and #graph.bridgeObjects or 0, componentCount or -1, tostring(config.file))
    return graph, "loaded"
end

function WayPointGPS.tryLoadWaypointRouteGraph()
    if not WPG_ROUTE_GRAPH_ENABLED then return nil, "disabled" end

    local candidates = {}
    for _, config in ipairs(WPG_ROUTE_CONFIGS) do
        local score, reason = WayPointGPS.adScoreConfigMatch(config, nil)
        if score ~= nil and score > 0 then
            candidates[#candidates + 1] = {config=config, score=score, reason=reason}
        end
    end

    table.sort(candidates, function(a, b)
        if (a.score or 0) ~= (b.score or 0) then return (a.score or 0) > (b.score or 0) end
        return tostring(a.config ~= nil and a.config.key or "") < tostring(b.config ~= nil and b.config.key or "")
    end)

    if #candidates <= 0 then
        return nil, "noBundledRouteGraphForActiveMap; fallbackToWayPointGPSLogic"
    end

    local lastInfo = "none"
    for _, candidate in ipairs(candidates) do
        local config = candidate.config
        local graph, info = WayPointGPS.buildWaypointRouteGraph(config)
        if graph ~= nil then
            WayPointGPS.aiRoadGraph = graph
            WayPointGPS.logInfo("[WayPointGPS] WayPoint GPS route graph match | selected=%s score=%s reason=%s map=%s file=%s", tostring(config ~= nil and config.key or "?"), tostring(candidate.score), tostring(candidate.reason), tostring(graph.sourceMapName), tostring(config ~= nil and config.file or "?"))
            return graph, "routeGraph:" .. tostring(graph.sourceMapName)
        end
        lastInfo = tostring(info)
    end

    return nil, lastInfo .. "; fallbackToWayPointGPSLogic"
end

function WayPointGPS.summarizeAIRoadSources(graph)
    if graph == nil or graph.sourceCount == nil then return "none" end
    local parts = {}
    for source, count in pairs(graph.sourceCount) do
        parts[#parts + 1] = tostring(source) .. "=" .. tostring(count)
        if #parts >= 6 then break end
    end
    if #parts == 0 then return "none" end
    return table.concat(parts, ",")
end

function WayPointGPS.finalizeAIRoadGraph(graph, stageName)
    if graph == nil then return nil, 0 end
    local intersectionEdges = WayPointGPS.aiRoadConnectIntersections(graph)
    local stitchedEdges, stitchedComponents = WayPointGPS.aiRoadStitchNearbyComponents(graph)
    local bridgeEdges, bridgeComponents = WayPointGPS.aiRoadStitchBridgeComponents(graph)
    local bridgeHubEdges, bridgeHubComponents = WayPointGPS.aiRoadStitchBridgeHubs(graph)
    local _, componentCount = WayPointGPS.aiRoadBuildComponents(graph)
    if #graph.nodes >= 8 and graph.splineCount > 0 then
        WayPointGPS.aiRoadGraph = graph
        WayPointGPS.logInfo("[WayPointGPS] AI road graph built | stage=%s splines=%d nodes=%d intersections=%d stitched=%d merged=%d bridgeStitched=%d bridgeMerged=%d bridgeHubStitched=%d bridgeHubMerged=%d bridgeObjects=%d components=%d sceneScanned=%d sources=%s", tostring(stageName), graph.splineCount, #graph.nodes, intersectionEdges, stitchedEdges or 0, stitchedComponents or 0, bridgeEdges or 0, bridgeComponents or 0, bridgeHubEdges or 0, bridgeHubComponents or 0, graph.bridgeObjects ~= nil and #graph.bridgeObjects or 0, componentCount or -1, graph.sceneScanCount or 0, WayPointGPS.summarizeAIRoadSources(graph))
        return graph, intersectionEdges
    end
    return nil, intersectionEdges
end

function WayPointGPS.ensureAIRoadGraph()
    if not AI_ROAD_GRAPH_ENABLED then return nil, "disabled" end
    if WayPointGPS.aiRoadGraphBuildAttempted then
        if WayPointGPS.aiRoadGraph ~= nil and #WayPointGPS.aiRoadGraph.nodes > 1 then
            return WayPointGPS.aiRoadGraph, "cached"
        end
        return nil, "notAvailable"
    end

    WayPointGPS.aiRoadGraphBuildAttempted = true

    local routeGraph, routeGraphInfo = WayPointGPS.tryLoadWaypointRouteGraph()
    if routeGraph ~= nil then
        return routeGraph, routeGraphInfo
    end
    WayPointGPS.logInfo("[WayPointGPS] WayPoint GPS route graph not used | %s | using WayPoint GPS fallback road logic", tostring(routeGraphInfo))

    -- Reverted to the good AIROAD_OFFROAD discovery order: build one mixed graph
    -- from trafficSystem, aiSystem, and named map scene splines. The later
    -- traffic-only pass returned zero splines on Elmcreek, which forced the route
    -- back to terrain/direct fallback and caused river shortcuts.
    local graph = WayPointGPS.newAIRoadGraph()

    if g_currentMission ~= nil then
        if g_currentMission.trafficSystem ~= nil then
            WayPointGPS.aiRoadScanTableForSplines(graph, g_currentMission.trafficSystem, "trafficSystem", 0, {})
        end
        if g_currentMission.aiSystem ~= nil then
            WayPointGPS.aiRoadScanTableForSplines(graph, g_currentMission.aiSystem, "aiSystem", 0, {})
        end

        local roots = {
            {node=g_currentMission.terrainRootNode, name="terrainRootNode"},
            {node=g_currentMission.rootNode, name="missionRootNode"},
            {node=g_currentMission.mapRootNode, name="mapRootNode"}
        }
        local seenRoots = {}
        for _, r in ipairs(roots) do
            if r.node ~= nil and seenRoots[r.node] == nil then
                seenRoots[r.node] = true
                WayPointGPS.aiRoadScanSceneNode(graph, r.node, r.name, 0)
            end
        end
    end

    local builtGraph = WayPointGPS.finalizeAIRoadGraph(graph, "mixedAIExtended")
    if builtGraph ~= nil then
        return builtGraph, "builtMixedAIExtended"
    end

    WayPointGPS.aiRoadGraph = nil
    WayPointGPS.logInfo("[WayPointGPS] AI road graph not available | mixed scan splines=%d nodes=%d sceneScanned=%d", graph.splineCount, #graph.nodes, graph.sceneScanCount or 0)
    return nil, "empty"
end

function WayPointGPS.aiRoadFindNearestNode(graph, worldX, worldZ, maxRadius)
    if graph == nil or graph.nodes == nil then return nil, math.huge end
    local bestId, bestDistSq = nil, math.huge
    local r = maxRadius or AI_ROAD_ENDPOINT_SEARCH_RADIUS
    local cellRadius = math.ceil(r / AI_ROAD_BUCKET_SIZE)
    local bx = math.floor(worldX / AI_ROAD_BUCKET_SIZE)
    local bz = math.floor(worldZ / AI_ROAD_BUCKET_SIZE)

    for ox = -cellRadius, cellRadius do
        for oz = -cellRadius, cellRadius do
            local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
            if bucket ~= nil then
                for _, id in ipairs(bucket) do
                    local node = graph.nodes[id]
                    local dx = node.x - worldX
                    local dz = node.z - worldZ
                    local distSq = dx * dx + dz * dz
                    if distSq < bestDistSq then
                        bestDistSq = distSq
                        bestId = id
                    end
                end
            end
        end
    end

    if bestId == nil then
        for id, node in ipairs(graph.nodes) do
            local dx = node.x - worldX
            local dz = node.z - worldZ
            local distSq = dx * dx + dz * dz
            if distSq < bestDistSq then
                bestDistSq = distSq
                bestId = id
            end
        end
    end

    local dist = bestId ~= nil and math.sqrt(bestDistSq) or math.huge
    if dist > r then return nil, dist end
    return bestId, dist
end


function WayPointGPS.aiRoadFindNearestReachableNode(graph, worldX, worldZ, maxRadius)
    if graph == nil or graph.nodes == nil then return nil, math.huge, "noGraph" end

    local bestDryId, bestDryScore, bestDryDist = nil, math.huge, math.huge
    local bestAnyId, bestAnyDist = WayPointGPS.aiRoadFindNearestNode(graph, worldX, worldZ, maxRadius)
    local r = maxRadius or AI_ROAD_ENDPOINT_SEARCH_RADIUS
    local cellRadius = math.ceil(r / AI_ROAD_BUCKET_SIZE)
    local bx = math.floor(worldX / AI_ROAD_BUCKET_SIZE)
    local bz = math.floor(worldZ / AI_ROAD_BUCKET_SIZE)
    local checked = 0

    for ox = -cellRadius, cellRadius do
        for oz = -cellRadius, cellRadius do
            local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
            if bucket ~= nil then
                for _, id in ipairs(bucket) do
                    local node = graph.nodes[id]
                    local dist = MathUtil.vector2Length(node.x - worldX, node.z - worldZ)
                    if dist <= r then
                        checked = checked + 1
                        local crossesWater = WayPointGPS.segmentCrossesWater(worldX, worldZ, node.x, node.z, 7.0)
                        if not crossesWater then
                            -- Prefer close nodes, but slightly favor candidates that sit on detected road
                            -- material so the connector lands on a usable road/yard rather than open grass.
                            local kind = WayPointGPS.classifyRoadCell(node.x, node.z)
                            local roadBonus = kind == "road" and -18.0 or 0.0
                            local score = dist + roadBonus
                            if score < bestDryScore then
                                bestDryScore = score
                                bestDryId = id
                                bestDryDist = dist
                            end
                        end
                    end
                end
            end
        end
    end

    if bestDryId ~= nil then
        return bestDryId, bestDryDist, string.format("dry checked=%d", checked)
    end

    return nil, bestAnyDist or math.huge, string.format("noDry checked=%d nearest=%.0f", checked, bestAnyDist or -1)
end

function WayPointGPS.aiRoadGetEndpointSearchRadius(graph, maxRadius)
    if graph ~= nil and graph.isRouteGraph then
        return math.min(maxRadius or WPG_ROUTE_ENDPOINT_SEARCH_RADIUS, WPG_ROUTE_ENDPOINT_SEARCH_RADIUS)
    end
    return maxRadius or AI_ROAD_ENDPOINT_SEARCH_RADIUS
end

function WayPointGPS.aiRoadGetEndpointCandidateLimit(graph, limit)
    if graph ~= nil and graph.isRouteGraph then
        return math.min(limit or WPG_ROUTE_ENDPOINT_CANDIDATE_LIMIT, WPG_ROUTE_ENDPOINT_CANDIDATE_LIMIT)
    end
    return limit or AI_ROAD_ENDPOINT_CANDIDATE_LIMIT
end

function WayPointGPS.aiRoadGetEndpointPrefilterLimit(graph)
    if graph ~= nil and graph.isRouteGraph then
        return WPG_ROUTE_ENDPOINT_PREFILTER_LIMIT
    end
    return nil
end

function WayPointGPS.aiRoadScoreEndpointCandidate(graph, worldX, worldZ, id, dist)
    local node = graph ~= nil and graph.nodes ~= nil and graph.nodes[id] or nil
    if node == nil then return nil end

    local crossesWater = WayPointGPS.segmentCrossesWater(worldX, worldZ, node.x, node.z, 7.0)
    local kind = WayPointGPS.classifyRoadCell(node.x, node.z)
    local adNodePenalty = 0.0
    if graph ~= nil and graph.isRouteGraph then
        kind = WayPointGPS.classifyRouteGraphRoadCellStrict(node.x, node.z)
        if kind == "field" then
            adNodePenalty = WPG_ROUTE_FIELD_NODE_PENALTY
        elseif kind == "water" or kind == "blocked" then
            adNodePenalty = WPG_ROUTE_BLOCKED_EDGE_PENALTY
        elseif kind ~= "road" then
            adNodePenalty = WPG_ROUTE_NONROAD_NODE_PENALTY
        end
    end

    local roadBonus = kind == "road" and -28.0 or 0.0

    -- Release hybrid graph priority with endpoint relevance.
    -- Driven/manual routes should win only when they are actually near the
    -- vehicle or waypoint being connected. A very large flat bonus made old
    -- test recordings win even when the waypoint was farther down the main
    -- road, which caused the GPS to dive into a saved side-route at junctions.
    local rawDist = dist or MathUtil.vector2Length(node.x - worldX, node.z - worldZ)
    local layerBonus = 0.0
    if node.manualRoute == true or node.wpgLayer == "driven" then
        if rawDist <= 35.0 then
            layerBonus = -55.0
        elseif rawDist <= 75.0 then
            layerBonus = -12.0
        else
            layerBonus = 220.0
        end
    elseif node.wpgLayer == "coursePack" then
        if rawDist <= 85.0 then
            layerBonus = -25.0
        else
            layerBonus = 0.0
        end
    end

    local connectorPenalty, connectorInfo = WayPointGPS.getEndpointConnectorPenalty(worldX, worldZ, node.x, node.z, 8.0)
    local score = rawDist + roadBonus + layerBonus + adNodePenalty + (crossesWater and AI_ROAD_ENDPOINT_WATER_PENALTY or 0.0) + (connectorPenalty or 0.0)
    return {id=id, dist=dist, score=score, water=crossesWater, kind=kind, connectorPenalty=connectorPenalty, connectorInfo=connectorInfo, layer=node.wpgLayer}
end

function WayPointGPS.aiRoadFilterCandidateComponents(graph, startCandidates, targetCandidates)
    if graph == nil or graph.components == nil or startCandidates == nil or targetCandidates == nil then
        return startCandidates, targetCandidates, "componentFilter none"
    end

    local startBest = {}
    for _, c in ipairs(startCandidates) do
        local comp = graph.components[c.id]
        if comp ~= nil then
            local score = c.score or c.dist or math.huge
            if startBest[comp] == nil or score < startBest[comp] then
                startBest[comp] = score
            end
        end
    end

    local bestComp = nil
    local bestScore = math.huge
    for _, c in ipairs(targetCandidates) do
        local comp = graph.components[c.id]
        if comp ~= nil and startBest[comp] ~= nil then
            local score = startBest[comp] + (c.score or c.dist or math.huge)
            if score < bestScore then
                bestScore = score
                bestComp = comp
            end
        end
    end

    if bestComp == nil then
        return startCandidates, targetCandidates, "componentFilter noCommon"
    end

    local sOut, tOut = {}, {}
    for _, c in ipairs(startCandidates) do
        if graph.components[c.id] == bestComp then
            sOut[#sOut + 1] = c
        end
    end
    for _, c in ipairs(targetCandidates) do
        if graph.components[c.id] == bestComp then
            tOut[#tOut + 1] = c
        end
    end

    if #sOut == 0 or #tOut == 0 then
        return startCandidates, targetCandidates, "componentFilter empty"
    end
    return sOut, tOut, string.format("componentFilter comp=%s s=%d/%d t=%d/%d", tostring(bestComp), #sOut, #startCandidates, #tOut, #targetCandidates)
end

function WayPointGPS.aiRoadFindNodeCandidates(graph, worldX, worldZ, maxRadius, limit)
    local out = {}
    if graph == nil or graph.nodes == nil then return out, math.huge, "noGraph" end

    local r = WayPointGPS.aiRoadGetEndpointSearchRadius(graph, maxRadius)
    local maxCount = WayPointGPS.aiRoadGetEndpointCandidateLimit(graph, limit)
    local prefilterLimit = WayPointGPS.aiRoadGetEndpointPrefilterLimit(graph)
    local cellRadius = math.ceil(r / AI_ROAD_BUCKET_SIZE)
    local bx = math.floor(worldX / AI_ROAD_BUCKET_SIZE)
    local bz = math.floor(worldZ / AI_ROAD_BUCKET_SIZE)
    local seen = {}
    local rough = {}
    local checked = 0
    local bestDist = math.huge
    local rSq = r * r

    local function addRough(id)
        if id == nil or seen[id] then return end
        seen[id] = true
        local node = graph.nodes[id]
        if node == nil then return end
        local dx = node.x - worldX
        local dz = node.z - worldZ
        local distSq = dx * dx + dz * dz
        if distSq > rSq then return end
        checked = checked + 1
        local dist = math.sqrt(distSq)
        if dist < bestDist then bestDist = dist end
        rough[#rough + 1] = {id=id, dist=dist}
    end

    for ox = -cellRadius, cellRadius do
        for oz = -cellRadius, cellRadius do
            local bucket = graph.buckets[(bx + ox) .. ":" .. (bz + oz)]
            if bucket ~= nil then
                for _, id in ipairs(bucket) do
                    addRough(id)
                end
            end
        end
    end

    if #rough == 0 then
        for id, _ in ipairs(graph.nodes) do
            addRough(id)
        end
    end

    if #rough == 0 then
        return out, bestDist, string.format("checked=%d eval=0 dry=0 kept=0", checked)
    end

    table.sort(rough, function(a, b) return a.dist < b.dist end)

    local candidates = {}
    local dry = 0
    local evalLimit = prefilterLimit or #rough
    if evalLimit < maxCount then evalLimit = maxCount end
    if evalLimit > #rough then evalLimit = #rough end

    for i = 1, evalLimit do
        local rItem = rough[i]
        local c = WayPointGPS.aiRoadScoreEndpointCandidate(graph, worldX, worldZ, rItem.id, rItem.dist)
        if c ~= nil then
            if not c.water then dry = dry + 1 end
            candidates[#candidates + 1] = c
        end
    end

    table.sort(candidates, function(a, b)
        if math.abs((a.score or 0) - (b.score or 0)) > 0.001 then return (a.score or 0) < (b.score or 0) end
        return (a.dist or 0) < (b.dist or 0)
    end)

    local wantDry = dry > 0
    for _, c in ipairs(candidates) do
        if (#out >= maxCount) then break end
        if (not wantDry) or (not c.water) then
            out[#out + 1] = c
        end
    end

    return out, bestDist, string.format("checked=%d eval=%d dry=%d kept=%d", checked, evalLimit, dry, #out)
end


function WayPointGPS.getManualRouteDistanceToPoint(graph, routeIndex, x, z)
    if graph == nil or graph.nodes == nil or routeIndex == nil or x == nil or z == nil then return math.huge end
    graph._wpgManualRouteDistanceCache = graph._wpgManualRouteDistanceCache or {}
    local cacheKey = tostring(routeIndex) .. ":" .. tostring(math.floor(x or 0)) .. ":" .. tostring(math.floor(z or 0))
    local cached = graph._wpgManualRouteDistanceCache[cacheKey]
    if cached ~= nil then return cached end

    local best = math.huge
    for _, n in ipairs(graph.nodes) do
        if n ~= nil and n.manualRouteIndex == routeIndex and n.x ~= nil and n.z ~= nil then
            local d = MathUtil.vector2Length((n.x or 0) - x, (n.z or 0) - z)
            if d < best then best = d end
        end
    end
    graph._wpgManualRouteDistanceCache[cacheKey] = best
    return best
end

function WayPointGPS.getManualEdgeRouteIndex(graph, a, b)
    if graph == nil or graph.nodes == nil then return nil end
    local na = graph.nodes[a]
    local nb = graph.nodes[b]
    if na ~= nil and nb ~= nil and na.manualRouteIndex ~= nil and na.manualRouteIndex == nb.manualRouteIndex then
        return na.manualRouteIndex
    end
    if na ~= nil and na.manualRouteIndex ~= nil then return na.manualRouteIndex end
    if nb ~= nil and nb.manualRouteIndex ~= nil then return nb.manualRouteIndex end
    return nil
end

function WayPointGPS.buildAIRoadGraphRoute(startX, startZ, targetX, targetZ)
    local graph, graphState = WayPointGPS.ensureAIRoadGraph()
    if graph == nil then
        return nil, "aiRoadGraph=" .. tostring(graphState)
    end

    -- Keep fallback route building safe on maps without a dedicated WayPoint GPS route graph.
    -- Some maps expose very dense AI spline graphs; running a full multi-candidate A* on those
    -- during a mouse click can spike memory and stall the game. Dedicated route graphs are still
    -- allowed to use their own tuned limits.
    if not graph.isRouteGraph then
        if (graph.nodes ~= nil and #graph.nodes > 3200) or (graph.intersectionCount ~= nil and graph.intersectionCount > 10000) then
            graph._wpgHeavyFallbackGraph = true
        end
    end

    local searchRadius = graph.isRouteGraph and WPG_ROUTE_ENDPOINT_SEARCH_RADIUS or AI_ROAD_ENDPOINT_SEARCH_RADIUS
    local candidateLimit = graph.isRouteGraph and WPG_ROUTE_ENDPOINT_CANDIDATE_LIMIT or AI_ROAD_ENDPOINT_CANDIDATE_LIMIT
    if graph._wpgHeavyFallbackGraph then
        searchRadius = math.min(searchRadius or 450, 260)
        candidateLimit = math.min(candidateLimit or 12, 6)
    end
    local startCandidates, startNearestDist, startCandidateInfo = WayPointGPS.aiRoadFindNodeCandidates(graph, startX, startZ, searchRadius, candidateLimit)
    local targetCandidates, targetNearestDist, targetCandidateInfo = WayPointGPS.aiRoadFindNodeCandidates(graph, targetX, targetZ, searchRadius, candidateLimit)
    local componentInfo = "componentFilter skipped"
    startCandidates, targetCandidates, componentInfo = WayPointGPS.aiRoadFilterCandidateComponents(graph, startCandidates, targetCandidates)

    if #startCandidates == 0 or #targetCandidates == 0 then
        return nil, string.format("aiRoadGraph noEndpointCandidates source=%s start=%.0f target=%.0f startC[%s] targetC[%s] %s", graph.isRouteGraph and "RouteGraph" or "AI", startNearestDist or -1, targetNearestDist or -1, tostring(startCandidateInfo), tostring(targetCandidateInfo), tostring(componentInfo))
    end

    if startNearestDist > AI_ROAD_START_CONNECTOR_LIMIT or targetNearestDist > AI_ROAD_TARGET_CONNECTOR_LIMIT then
        return nil, string.format("aiRoadGraph endpointTooFar source=%s start=%.0f target=%.0f startC[%s] targetC[%s] %s", graph.isRouteGraph and "RouteGraph" or "AI", startNearestDist, targetNearestDist, tostring(startCandidateInfo), tostring(targetCandidateInfo), tostring(componentInfo))
    end

    local targetSet = {}
    local targetPenalty = {}
    local targetList = {}
    for _, c in ipairs(targetCandidates) do
        targetSet[c.id] = true
        local penalty = (c.dist or 0) * AI_ROAD_ENDPOINT_SNAP_PENALTY + (c.water and AI_ROAD_ENDPOINT_WATER_PENALTY or 0.0) + (c.connectorPenalty or 0.0)
        targetPenalty[c.id] = math.min(targetPenalty[c.id] or math.huge, penalty)
        targetList[#targetList + 1] = c.id
    end

    local function heuristic(id)
        local n = graph.nodes[id]
        local best = math.huge
        for _, targetId in ipairs(targetList) do
            local t = graph.nodes[targetId]
            if t ~= nil then
                local d = MathUtil.vector2Length(t.x - n.x, t.z - n.z)
                if d < best then best = d end
            end
        end
        return best
    end

    local heap = {}
    local function heapPush(item)
        heap[#heap + 1] = item
        local i = #heap
        while i > 1 do
            local p = math.floor(i * 0.5)
            if heap[p].f <= item.f then break end
            heap[i] = heap[p]
            i = p
        end
        heap[i] = item
    end
    local function heapPop()
        local root = heap[1]
        local last = heap[#heap]
        heap[#heap] = nil
        if #heap > 0 then
            local i = 1
            while true do
                local c = i * 2
                if c > #heap then break end
                if c + 1 <= #heap and heap[c + 1].f < heap[c].f then c = c + 1 end
                if heap[c].f >= last.f then break end
                heap[i] = heap[c]
                i = c
            end
            heap[i] = last
        end
        return root
    end

    local cameFrom = {}
    local gScore = {}
    local openBest = {}
    local startOrigin = {}

    for _, c in ipairs(startCandidates) do
        local startPenalty = (c.dist or 0) * AI_ROAD_ENDPOINT_SNAP_PENALTY + (c.water and AI_ROAD_ENDPOINT_WATER_PENALTY or 0.0) + (c.connectorPenalty or 0.0)
        if startPenalty < (gScore[c.id] or math.huge) then
            gScore[c.id] = startPenalty
            startOrigin[c.id] = c.id
            local f = startPenalty + heuristic(c.id)
            openBest[c.id] = f
            heapPush({id=c.id, f=f})
        end
    end

    local foundTargetId = nil
    local foundStartId = nil
    local foundTotal = math.huge
    local iterations = 0
    local branchPenaltyCache = {}
    local astarLimit = graph.isRouteGraph and WPG_ROUTE_ASTAR_SOFT_ITERATION_LIMIT or AI_ROAD_MAX_ASTAR_ITERATIONS
    if graph._wpgHeavyFallbackGraph then
        astarLimit = math.min(astarLimit or 80000, 45000)
    end

    while #heap > 0 and iterations < astarLimit do
        iterations = iterations + 1
        local current = heapPop()
        if current ~= nil and current.f <= (openBest[current.id] or math.huge) + 0.001 then
            if targetSet[current.id] then
                local total = (gScore[current.id] or math.huge) + (targetPenalty[current.id] or 0.0)
                foundTargetId = current.id
                foundStartId = startOrigin[current.id]
                foundTotal = total
                break
            end

            local neighborCosts, reverseFallbackSet = WayPointGPS.getRouteGraphAStarNeighbors(graph, current.id)
            for nextId, cost in pairs(neighborCosts or {}) do
                local isReverseFallback = reverseFallbackSet ~= nil and reverseFallbackSet[nextId] == true
                local dynamicCost = cost or 0.0

                -- Layer priority: driven > bundled course pack > generated scan.
                -- Cost is adjusted at search time as a safety net, even if a graph
                -- was merged by an older build that did not write edge metadata.
                local currentNode = graph.nodes ~= nil and graph.nodes[current.id] or nil
                local nextNode = graph.nodes ~= nil and graph.nodes[nextId] or nil
                local edgeMeta = graph.wpgEdgeMeta ~= nil and graph.wpgEdgeMeta[current.id] ~= nil and graph.wpgEdgeMeta[current.id][nextId] or nil
                if edgeMeta == nil and isReverseFallback and graph.wpgEdgeMeta ~= nil and graph.wpgEdgeMeta[nextId] ~= nil then
                    edgeMeta = graph.wpgEdgeMeta[nextId][current.id]
                end
                local layer = edgeMeta ~= nil and edgeMeta.layer or nil
                if layer == nil and ((currentNode ~= nil and currentNode.manualRoute == true) or (nextNode ~= nil and nextNode.manualRoute == true)) then
                    layer = "driven"
                end
                if layer == "driven" then
                    -- Manual/driven routes are preferred only when they are relevant to
                    -- the current GPS request. A saved test loop near the tractor can be
                    -- close to the start but still be a worse choice for a waypoint that
                    -- is straight ahead. Gate the driven-route bonus by target progress,
                    -- direction alignment, and distance from the start->target corridor.
                    if currentNode ~= nil and nextNode ~= nil then
                        local curToTarget = MathUtil.vector2Length((currentNode.x or 0) - targetX, (currentNode.z or 0) - targetZ)
                        local nextToTarget = MathUtil.vector2Length((nextNode.x or 0) - targetX, (nextNode.z or 0) - targetZ)
                        local sx = targetX - startX
                        local sz = targetZ - startZ
                        local sl = MathUtil.vector2Length(sx, sz)
                        local nx = (nextNode.x or 0) - (currentNode.x or 0)
                        local nz = (nextNode.z or 0) - (currentNode.z or 0)
                        local nl = MathUtil.vector2Length(nx, nz)
                        local corridorDist = 0.0
                        local forwardDot = 1.0
                        local progressAlong = 0.0
                        if sl > 1.0 then
                            local vx = (nextNode.x or 0) - startX
                            local vz = (nextNode.z or 0) - startZ
                            progressAlong = (vx * sx + vz * sz) / sl
                            local cross = math.abs(vx * sz - vz * sx)
                            corridorDist = cross / sl
                            if nl > 0.25 then
                                forwardDot = (nx * sx + nz * sz) / (nl * sl)
                            end
                        end

                        local improvesToTarget = nextToTarget < (curToTarget - 3.0)
                        local nearTarget = nextToTarget < 65.0
                        local nearStart = MathUtil.vector2Length((nextNode.x or 0) - startX, (nextNode.z or 0) - startZ) < 55.0
                        local onCorridor = (sl <= 1.0) or (corridorDist <= 42.0) or nearTarget
                        local forwardEnough = (sl <= 1.0) or (forwardDot >= 0.18) or nearTarget
                        local notBehindStart = (sl <= 1.0) or (progressAlong > -20.0)

                        -- Whole-segment relevance gate.  A saved manual test course that
                        -- is near the tractor but never gets close to the selected waypoint
                        -- should not be preferred over the generated/course-pack main road.
                        -- This directly fixes the intersection dive where the route kept
                        -- choosing an old Solek recording instead of staying on the road to
                        -- the orange waypoint.
                        local routeIndex = nil
                        if edgeMeta ~= nil and edgeMeta.routeIndex ~= nil then
                            routeIndex = edgeMeta.routeIndex
                        elseif WayPointGPS.getManualEdgeRouteIndex ~= nil then
                            routeIndex = WayPointGPS.getManualEdgeRouteIndex(graph, current.id, nextId)
                        end
                        local routeNearTarget = true
                        local routeTargetDist = 0.0
                        local routeNearStart = true
                        local routeStartDist = 0.0
                        if routeIndex ~= nil and WayPointGPS.getManualRouteDistanceToPoint ~= nil then
                            routeTargetDist = WayPointGPS.getManualRouteDistanceToPoint(graph, routeIndex, targetX, targetZ)
                            routeStartDist = WayPointGPS.getManualRouteDistanceToPoint(graph, routeIndex, startX, startZ)
                            -- Saved/manual routes are only preferred when that specific
                            -- saved line is relevant to BOTH ends of the active GPS leg.
                            -- This prevents an old test loop near an intersection from
                            -- beating the bundled map XML or generated main road when the
                            -- selected waypoint is farther down a different road.
                            routeNearTarget = routeTargetDist <= 75.0
                            routeNearStart = routeStartDist <= 80.0
                        end

                        if routeNearTarget and routeNearStart and (((improvesToTarget and onCorridor and forwardEnough and notBehindStart) or nearTarget)) then
                            -- Prefer good, relevant driven lines, but never make them almost
                            -- free.  Keeping real edge cost prevents old side routes from
                            -- overpowering a clean generated road.
                            dynamicCost = dynamicCost * WPG_GPS_MANUAL_COST_FACTOR
                        else
                            -- Old or side manual routes remain connected as a fallback, but
                            -- they no longer pull the selected route away from the waypoint.
                            local detour = math.max(0.0, corridorDist - 35.0)
                            local away = math.max(0.0, nextToTarget - curToTarget)
                            local reverse = forwardDot < 0.0 and math.abs(forwardDot) * 120.0 or 0.0
                            local routeMiss = (routeNearTarget and 0.0 or math.max(0.0, routeTargetDist - 65.0) * 18.0) + (routeNearStart and 0.0 or math.max(0.0, routeStartDist - 70.0) * 10.0)
                            dynamicCost = dynamicCost + 2800.0 + detour * 18.0 + away * 14.0 + reverse + routeMiss
                        end
                    else
                        dynamicCost = dynamicCost + 350.0
                    end
                elseif layer == "autoDrive" then
                    -- Native WayPoint GPS route graphs are high quality, but in GPS-only mode
                    -- shortest usable path must still win over a long directional loop.
                    dynamicCost = dynamicCost * WPG_GPS_AUTODRIVE_COST_FACTOR
                elseif layer == "coursePack" then
                    -- Bundled XML course packs remain preferred, but the multiplier is
                    -- intentionally mild so a much shorter road path is not ignored.
                    dynamicCost = dynamicCost * WPG_GPS_COURSEPACK_COST_FACTOR
                elseif graph ~= nil and graph.isRouteGraph and layer == nil then
                    -- Unknown/unmarked links inside a route graph are treated as generated
                    -- helper links. Keep them available, but avoid letting helper links
                    -- pull the line through yards when the road continues straight.
                    dynamicCost = dynamicCost + WPG_GPS_UNKNOWN_HELPER_PENALTY
                end

                dynamicCost = dynamicCost + WayPointGPS.gpsRouteGraphRoadAdherencePenalty(graph, current.id, nextId, startX, startZ, targetX, targetZ)
                dynamicCost = dynamicCost + WayPointGPS.gpsRouteTightenPenalty(graph, current.id, nextId, startX, startZ, targetX, targetZ)

                if graph ~= nil and graph.isRouteGraph then
                    local branchPenalty = branchPenaltyCache[nextId]
                    if branchPenalty == nil then
                        branchPenalty = WayPointGPS.adBranchPenalty(graph, nextId, startX, startZ, targetX, targetZ)
                        branchPenaltyCache[nextId] = branchPenalty
                    end
                    dynamicCost = dynamicCost + branchPenalty
                    dynamicCost = dynamicCost + WayPointGPS.adTurnPenalty(graph, cameFrom[current.id], current.id, nextId)
                end
                if WayPointGPS.adContinuationPenalty ~= nil then
                    dynamicCost = dynamicCost + WayPointGPS.adContinuationPenalty(graph, cameFrom[current.id], current.id, nextId, startX, startZ, targetX, targetZ)
                end
                local tentative = (gScore[current.id] or math.huge) + dynamicCost
                if tentative < (gScore[nextId] or math.huge) then
                    cameFrom[nextId] = current.id
                    startOrigin[nextId] = startOrigin[current.id]
                    gScore[nextId] = tentative
                    local f = tentative + heuristic(nextId)
                    openBest[nextId] = f
                    heapPush({id=nextId, f=f})
                end
            end
        end
    end

    if foundTargetId == nil then
        return nil, string.format("aiRoadGraph noCandidatePath source=%s nodes=%d startNearest=%.0f targetNearest=%.0f startC[%s] targetC[%s] %s iterations=%d", graph.isRouteGraph and "RouteGraph" or "AI", #graph.nodes, startNearestDist or -1, targetNearestDist or -1, tostring(startCandidateInfo), tostring(targetCandidateInfo), tostring(componentInfo), iterations)
    end

    local reverse = {}
    local cur = foundTargetId
    while cur ~= nil do
        reverse[#reverse + 1] = cur
        if cur == foundStartId then break end
        cur = cameFrom[cur]
    end

    if reverse[#reverse] ~= foundStartId then
        return nil, "aiRoadGraph brokenCandidatePath"
    end

    local startNode = graph.nodes[foundStartId]
    local targetNode = graph.nodes[foundTargetId]
    local startSnap = startNode ~= nil and MathUtil.vector2Length(startNode.x - startX, startNode.z - startZ) or math.huge
    local targetSnap = targetNode ~= nil and MathUtil.vector2Length(targetNode.x - targetX, targetNode.z - targetZ) or math.huge

    local points = {{x=startX, z=startZ}}
    local pathUsesManualRoute = false
    for i = #reverse, 1, -1 do
        local n = graph.nodes[reverse[i]]
        local isManual = n ~= nil and n.manualRoute == true
        pathUsesManualRoute = pathUsesManualRoute or isManual
        points[#points + 1] = {x=n.x, z=n.z, manualRoute=isManual}
    end
    points[#points + 1] = {x=targetX, z=targetZ}

    -- For manually driven roads, keep the recorded nodes instead of resampling into
    -- anonymous points. This preserves the manualRoute flag and prevents steering
    -- assist from applying an extra side/lane offset to user-driven paths.
    local route = pathUsesManualRoute and points or WayPointGPS.resampleRoute(points, ROUTE_SAMPLE_DISTANCE)
    if pathUsesManualRoute then
        for _, rp in ipairs(route) do
            if rp.manualRoute == true then
                rp.manualRoutePath = true
            end
        end
    end
    local len = WayPointGPS.getRouteLength(route)
    return route, string.format("%s candidates splines=%d nodes=%d edges=%s pathNodes=%d startSnap=%.0f targetSnap=%.0f startNearest=%.0f targetNearest=%.0f startC[%s] targetC[%s] %s len=%.0f score=%.0f iterations=%d", graph.isRouteGraph and "routeGraph" or "aiRoadGraph", graph.splineCount, #graph.nodes, tostring(graph.adEdgeCount or "AI"), #reverse, startSnap, targetSnap, startNearestDist or -1, targetNearestDist or -1, tostring(startCandidateInfo), tostring(targetCandidateInfo), tostring(componentInfo), len, foundTotal or -1, iterations)
end

function WayPointGPS.getRouteRoadQuality(route, classifierFunc)
    local classifier = classifierFunc or WayPointGPS.classifyRoadCell
    local q = {samples=0, road=0, open=0, field=0, water=0, blocked=0, maxNonRoadRun=0, maxFieldRun=0, len=WayPointGPS.getRouteLength(route)}
    if route == nil or #route < 2 then
        return q
    end

    local run = 0.0
    local fieldRun = 0.0
    local sampleSpacing = 35.0

    for i = 2, #route do
        local a = route[i - 1]
        local b = route[i]
        local dx = (b.x or 0) - (a.x or 0)
        local dz = (b.z or 0) - (a.z or 0)
        local segLen = MathUtil.vector2Length(dx, dz)
        local steps = math.max(1, math.ceil(segLen / sampleSpacing))

        for stepIdx = 0, steps do
            local t = stepIdx / steps
            local x = (a.x or 0) + dx * t
            local z = (a.z or 0) + dz * t
            local ok, kind = pcall(classifier, x, z)
            if not ok or kind == nil then
                kind = "open"
            end
            q.samples = q.samples + 1
            if kind == "road" then
                q.road = q.road + 1
                if run > q.maxNonRoadRun then q.maxNonRoadRun = run end
                if fieldRun > q.maxFieldRun then q.maxFieldRun = fieldRun end
                run = 0.0
                fieldRun = 0.0
            else
                if kind == "field" then
                    q.field = q.field + 1
                    fieldRun = fieldRun + sampleSpacing
                elseif kind == "water" then
                    q.water = q.water + 1
                    if fieldRun > q.maxFieldRun then q.maxFieldRun = fieldRun end
                    fieldRun = 0.0
                elseif kind == "blocked" then
                    q.blocked = (q.blocked or 0) + 1
                    if fieldRun > q.maxFieldRun then q.maxFieldRun = fieldRun end
                    fieldRun = 0.0
                else
                    q.open = q.open + 1
                    if fieldRun > q.maxFieldRun then q.maxFieldRun = fieldRun end
                    fieldRun = 0.0
                end
                run = run + sampleSpacing
            end
        end
    end

    if run > q.maxNonRoadRun then q.maxNonRoadRun = run end
    if fieldRun > q.maxFieldRun then q.maxFieldRun = fieldRun end
    q.roadRatio = q.samples > 0 and (q.road / q.samples) or 0
    q.openRatio = q.samples > 0 and (q.open / q.samples) or 0
    q.fieldRatio = q.samples > 0 and (q.field / q.samples) or 0
    q.waterRatio = q.samples > 0 and ((q.water or 0) / q.samples) or 0
    q.blockedRatio = q.samples > 0 and ((q.blocked or 0) / q.samples) or 0
    return q
end

function WayPointGPS.formatRouteQuality(q)
    if q == nil then return "q=nil" end
    return string.format("len=%.0f road=%.0f%% open=%.0f%% field=%.0f%% water=%.0f%% blocked=%.0f%% maxOff=%.0f maxField=%.0f",
        q.len or 0, (q.roadRatio or 0) * 100, (q.openRatio or 0) * 100, (q.fieldRatio or 0) * 100, (q.waterRatio or 0) * 100, (q.blockedRatio or 0) * 100, q.maxNonRoadRun or 0, q.maxFieldRun or 0)
end

function WayPointGPS.candidateKeepsRoads(candidateRoute, directDist)
    local q = WayPointGPS.getRouteRoadQuality(candidateRoute)
    local maxAllowedOffRoad = math.max(48.0, directDist * 0.10)

    -- Road-lock build: a candidate is only allowed to replace the strict route
    -- when it is still genuinely road-like. Older relaxed/connector routes could
    -- win just by being shorter, which made the PDA line cut diagonally over grass.
    if (q.roadRatio or 0) < 0.58 then
        return false, q
    end

    if (q.maxNonRoadRun or 0) > maxAllowedOffRoad then
        return false, q
    end

    if (q.fieldRatio or 0) > 0.12 then
        return false, q
    end

    if (q.waterRatio or 0) > 0.0 then
        return false, q
    end

    if (q.blockedRatio or 0) > 0.0 then
        return false, q
    end

    return true, q
end

function WayPointGPS.routeGraphRouteLooksRoadLocked(route, directDist)
    local q = WayPointGPS.getRouteRoadQuality(route, classifyRouteGraphRoadCellStrict)
    if (q.waterRatio or 0) > 0.0 or (q.blockedRatio or 0) > 0.0 then
        return false, q, "waterOrBlocked"
    end

    -- route graph packs include field tracks and field-work waiting lanes. A true road GPS
    -- should not take those just because they are shorter than the highway network.
    if (q.fieldRatio or 0) > WPG_ROUTE_MAX_FIELD_RATIO then
        return false, q, "fieldRatio"
    end
    if (q.maxFieldRun or 0) > WPG_ROUTE_MAX_FIELD_RUN then
        return false, q, "fieldRun"
    end

    local maxAllowedOffRoad = math.max(48.0, (directDist or 0.0) * 0.07)
    if (q.maxNonRoadRun or 0) > maxAllowedOffRoad then
        return false, q, "longOffRoadRun"
    end

    if (q.openRatio or 0) > 0.28 and (q.roadRatio or 0) < 0.66 then
        return false, q, "tooMuchOpenGround"
    end

    return true, q, "ok"
end

function WayPointGPS.getDistanceToRouteChord(px, pz, ax, az, bx, bz)
    local abx = bx - ax
    local abz = bz - az
    local lenSq = abx * abx + abz * abz
    if lenSq < 0.0001 then
        return MathUtil.vector2Length(px - ax, pz - az)
    end

    local t = ((px - ax) * abx + (pz - az) * abz) / lenSq
    if t < 0 then t = 0 elseif t > 1 then t = 1 end
    local cx = ax + abx * t
    local cz = az + abz * t
    return MathUtil.vector2Length(px - cx, pz - cz)
end

function WayPointGPS.buildRoadBiasedRoute(startX, startZ, targetX, targetZ, relaxed)
    if g_currentMission == nil then return nil, "noMission" end

    local mode = relaxed
    local isConnector = mode == "connector"
    local isRelaxed = mode == true or mode == "relaxed" or isConnector

    local distance = MathUtil.vector2Length(targetX - startX, targetZ - startZ)
    if distance < 5 then
        return WayPointGPS.buildDirectRoute(startX, startZ, targetX, targetZ), "shortDirect"
    end

    local marginFactor = isConnector and ROAD_ROUTE_CONNECTOR_MARGIN_FACTOR or (isRelaxed and 0.34 or 0.24)
    local minMargin = isConnector and 70.0 or 95.0
    local searchMargin = math.min(ROAD_ROUTE_SEARCH_MARGIN, math.max(minMargin, distance * marginFactor))
    local minX = math.min(startX, targetX) - searchMargin
    local maxX = math.max(startX, targetX) + searchMargin
    local minZ = math.min(startZ, targetZ) - searchMargin
    local maxZ = math.max(startZ, targetZ) + searchMargin

    local roadCost = isConnector and ROAD_ROUTE_CONNECTOR_ROAD_COST or ROAD_ROUTE_ROAD_COST
    local openCost = isConnector and ROAD_ROUTE_CONNECTOR_OPEN_COST or (isRelaxed and ROAD_ROUTE_RELAXED_OPEN_COST or ROAD_ROUTE_OPEN_COST)
    local fieldCost = isConnector and ROAD_ROUTE_CONNECTOR_FIELD_COST or (isRelaxed and ROAD_ROUTE_RELAXED_FIELD_COST or ROAD_ROUTE_FIELD_COST)
    local waterCost = isConnector and ROAD_ROUTE_CONNECTOR_WATER_COST or ROAD_ROUTE_WATER_COST
    local endpointCost = isConnector and (ROAD_ROUTE_ENDPOINT_COST * 0.45) or (isRelaxed and (ROAD_ROUTE_ENDPOINT_COST * 0.65) or ROAD_ROUTE_ENDPOINT_COST)
    local endpointRadius = isConnector and ROAD_ROUTE_CONNECTOR_ENDPOINT_RADIUS or (isRelaxed and ROAD_ROUTE_RELAXED_ENDPOINT_RADIUS or ROAD_ROUTE_ENDPOINT_RADIUS)
    local heuristicScale = isConnector and ROAD_ROUTE_CONNECTOR_HEURISTIC or (isRelaxed and ROAD_ROUTE_RELAXED_HEURISTIC or ROAD_ROUTE_HEURISTIC)
    local directnessCost = isConnector and ROAD_ROUTE_CONNECTOR_DIRECTNESS_COST or (isRelaxed and ROAD_ROUTE_RELAXED_DIRECTNESS_COST or ROAD_ROUTE_DIRECTNESS_COST)

    local step = ROAD_ROUTE_BASE_GRID
    local cols = math.floor((maxX - minX) / step) + 1
    local rows = math.floor((maxZ - minZ) / step) + 1

    while cols * rows > ROAD_ROUTE_MAX_CELLS and step < ROAD_ROUTE_MAX_GRID do
        step = step + 5.0
        cols = math.floor((maxX - minX) / step) + 1
        rows = math.floor((maxZ - minZ) / step) + 1
    end

    if cols < 2 or rows < 2 or cols * rows > ROAD_ROUTE_MAX_CELLS then
        return nil, "gridTooLarge"
    end

    local function clamp(v, minV, maxV)
        return math.max(minV, math.min(maxV, v))
    end

    local function worldToGrid(x, z)
        local gx = clamp(math.floor((x - minX) / step + 0.5) + 1, 1, cols)
        local gz = clamp(math.floor((z - minZ) / step + 0.5) + 1, 1, rows)
        return gx, gz
    end

    local function gridToWorld(gx, gz)
        return minX + (gx - 1) * step, minZ + (gz - 1) * step
    end

    local function nodeKey(gx, gz)
        return (gz - 1) * cols + gx
    end

    local sx, sz = worldToGrid(startX, startZ)
    local tx, tz = worldToGrid(targetX, targetZ)
    local startKey = nodeKey(sx, sz)
    local targetKey = nodeKey(tx, tz)

    local cellCostCache = {}
    local cellKindCache = {}
    local cellSnapCache = {}
    local heightCache = {}

    local function getNodeHeight(gx, gz)
        local key = nodeKey(gx, gz)
        local h = heightCache[key]
        if h == nil then
            local x, z = gridToWorld(gx, gz)
            h = WayPointGPS.getGroundHeight(x, z)
            heightCache[key] = h
        end
        return h
    end

    local function getCellCost(gx, gz)
        local key = nodeKey(gx, gz)
        local c = cellCostCache[key]
        if c == nil then
            local x, z = gridToWorld(gx, gz)
            local kind, snapX, snapZ = WayPointGPS.classifyRoadCellDetailed(x, z)
            cellKindCache[key] = kind
            cellSnapCache[key] = {x=snapX or x, z=snapZ or z}

            if kind == "road" then
                c = roadCost
            elseif kind == "field" then
                c = fieldCost
            elseif kind == "water" or kind == "blocked" then
                c = waterCost
            else
                c = openCost
            end

            -- Let the route leave the vehicle position and enter the clicked target
            -- even when either endpoint is not exactly on a detected road. The
            -- cheap zone is small, so it cannot create a long diagonal shortcut.
            -- Never cheapen water cells; otherwise endpoint connectors can still
            -- cut straight through rivers.
            local endpointDist = WayPointGPS.getEndpointDistanceFactor(x, z, startX, startZ, targetX, targetZ)
            if kind ~= "water" and kind ~= "blocked" and endpointDist <= endpointRadius then
                local t = endpointDist / endpointRadius
                c = math.min(c, endpointCost + (c - endpointCost) * t)
            end

            if kind ~= "water" and kind ~= "blocked" and (key == startKey or key == targetKey) then
                c = math.min(c, endpointCost)
            end

            cellCostCache[key] = c
        end
        return c
    end

    local function getCellKind(gx, gz)
        local key = nodeKey(gx, gz)
        local kind = cellKindCache[key]
        if kind == nil then
            getCellCost(gx, gz)
            kind = cellKindCache[key]
        end
        return kind
    end

    local function getCellSnap(gx, gz)
        local key = nodeKey(gx, gz)
        if cellSnapCache[key] == nil then
            getCellCost(gx, gz)
        end
        return cellSnapCache[key]
    end

    local function heuristic(gx, gz)
        local dx = (tx - gx) * step
        local dz = (tz - gz) * step
        return MathUtil.vector2Length(dx, dz) * heuristicScale
    end

    local heap = {}
    local function heapPush(node)
        heap[#heap + 1] = node
        local i = #heap
        while i > 1 do
            local p = math.floor(i / 2)
            if heap[p].f <= node.f then break end
            heap[i] = heap[p]
            i = p
        end
        heap[i] = node
    end

    local function heapPop()
        local root = heap[1]
        local last = heap[#heap]
        heap[#heap] = nil
        if #heap > 0 then
            local i = 1
            while true do
                local left = i * 2
                local right = left + 1
                if left > #heap then break end
                local child = left
                if right <= #heap and heap[right].f < heap[left].f then
                    child = right
                end
                if heap[child].f >= last.f then break end
                heap[i] = heap[child]
                i = child
            end
            heap[i] = last
        end
        return root
    end

    local openBest = {}
    local closed = {}
    local cameFrom = {}
    local gScore = {}
    gScore[startKey] = 0
    openBest[startKey] = heuristic(sx, sz)
    heapPush({gx=sx, gz=sz, key=startKey, f=openBest[startKey]})

    local iterations = 0
    local found = false

    while #heap > 0 and iterations < ROAD_ROUTE_MAX_ITERATIONS do
        iterations = iterations + 1
        local current = heapPop()
        if current ~= nil and not closed[current.key] and current.f == openBest[current.key] then
            if current.key == targetKey then
                found = true
                break
            end

            closed[current.key] = true
            local currentCost = getCellCost(current.gx, current.gz)
            local currentHeight = getNodeHeight(current.gx, current.gz)

            local neighbours = {
                {dx= 1, dz= 0, diagonal=false},
                {dx=-1, dz= 0, diagonal=false},
                {dx= 0, dz= 1, diagonal=false},
                {dx= 0, dz=-1, diagonal=false},
                {dx= 1, dz= 1, diagonal=true},
                {dx= 1, dz=-1, diagonal=true},
                {dx=-1, dz= 1, diagonal=true},
                {dx=-1, dz=-1, diagonal=true}
            }

            for _, offset in ipairs(neighbours) do
                local ngx = current.gx + offset.dx
                local ngz = current.gz + offset.dz
                if ngx >= 1 and ngx <= cols and ngz >= 1 and ngz <= rows then
                    local nKey = nodeKey(ngx, ngz)
                    if not closed[nKey] then
                        local currentKind = getCellKind(current.gx, current.gz)
                        local nextKind = getCellKind(ngx, ngz)
                        local nxWorld, nzWorld = gridToWorld(ngx, ngz)
                        local nextEndpointDist = WayPointGPS.getEndpointDistanceFactor(nxWorld, nzWorld, startX, startZ, targetX, targetZ)

                        -- Strict pass is road-locked: outside the small endpoint connector
                        -- zones it may only expand onto detected road cells. This prevents
                        -- the pathfinder from choosing a cheaper diagonal across open grass
                        -- when a real road exists nearby.
                        local strictRoadLocked = (not isRelaxed) and (not isConnector)
                        local strictCellAllowed = ((not strictRoadLocked) or nextKind == "road" or nextEndpointDist <= endpointRadius) and not WayPointGPS.isHardBlockedKind(nextKind)
                        local diagonalAllowed = strictCellAllowed and not WayPointGPS.isHardBlockedKind(nextKind) and not WayPointGPS.isHardBlockedKind(currentKind)
                        if offset.diagonal then
                            if isConnector then
                                -- The connector pass exists only to avoid giant loops when a map does
                                -- not expose all side roads as real terrain materials. Allow diagonal
                                -- travel through ambiguous cells, but do not cut a diagonal when both
                                -- cells are clear field.
                                diagonalAllowed = not (currentKind == "field" and nextKind == "field") and not WayPointGPS.isHardBlockedKind(nextKind) and not WayPointGPS.isHardBlockedKind(currentKind)
                            else
                                diagonalAllowed = currentKind == "road" and nextKind == "road"
                                if diagonalAllowed then
                                    local cornerKindA = getCellKind(current.gx + offset.dx, current.gz)
                                    local cornerKindB = getCellKind(current.gx, current.gz + offset.dz)
                                    diagonalAllowed = cornerKindA ~= "field" and cornerKindB ~= "field" and not WayPointGPS.isHardBlockedKind(cornerKindA) and not WayPointGPS.isHardBlockedKind(cornerKindB)
                                end
                            end
                        end

                        if diagonalAllowed then
                        local moveDist = offset.diagonal and (step * 1.41421356237) or step
                        local nextCost = getCellCost(ngx, ngz)
                        local nextHeight = getNodeHeight(ngx, ngz)
                        local slopePenalty = math.abs(nextHeight - currentHeight) * ROAD_ROUTE_SLOPE_COST

                        -- If both cells are generic open land, add an extra
                        -- continuity penalty. This prevents the pathfinder from
                        -- treating a big open area like a road.
                        local continuityPenalty = 0
                        if currentKind == "open" and nextKind == "open" then
                            continuityPenalty = moveDist * openCost * (isConnector and 0.05 or (isRelaxed and 0.18 or 0.55))
                        end

                        local nx, nz = gridToWorld(ngx, ngz)
                        local directnessPenalty = moveDist * directnessCost * (WayPointGPS.getDistanceToRouteChord(nx, nz, startX, startZ, targetX, targetZ) / ROAD_ROUTE_DIRECTNESS_SCALE)
                        local tentative = gScore[current.key] + moveDist * ((currentCost + nextCost) * 0.5) + slopePenalty + continuityPenalty + directnessPenalty

                        if gScore[nKey] == nil or tentative < gScore[nKey] then
                            cameFrom[nKey] = current.key
                            gScore[nKey] = tentative
                            local f = tentative + heuristic(ngx, ngz)
                            openBest[nKey] = f
                            heapPush({gx=ngx, gz=ngz, key=nKey, f=f})
                        end
                        end
                    end
                end
            end
        end
    end

    if not found then
        return nil, "noPath"
    end

    local reverse = {}
    local curKey = targetKey
    while curKey ~= nil do
        local gz = math.floor((curKey - 1) / cols) + 1
        local gx = curKey - (gz - 1) * cols
        local snap = getCellSnap(gx, gz)
        local x, z = gridToWorld(gx, gz)
        if snap ~= nil and getCellKind(gx, gz) == "road" then
            x, z = snap.x or x, snap.z or z
        end
        reverse[#reverse + 1] = {gx=gx, gz=gz, x=x, z=z}
        if curKey == startKey then break end
        curKey = cameFrom[curKey]
    end

    if reverse[#reverse] == nil or reverse[#reverse].gx ~= sx or reverse[#reverse].gz ~= sz then
        return nil, "brokenPath"
    end

    local gridPath = {}
    for i = #reverse, 1, -1 do
        gridPath[#gridPath + 1] = reverse[i]
    end

    local roadCells, openCells, fieldCells, waterCells, blockedCells = 0, 0, 0, 0, 0
    for _, p in ipairs(gridPath) do
        local kind = getCellKind(p.gx, p.gz)
        if kind == "road" then
            roadCells = roadCells + 1
        elseif kind == "field" then
            fieldCells = fieldCells + 1
        elseif kind == "water" then
            waterCells = waterCells + 1
        elseif kind == "blocked" then
            blockedCells = blockedCells + 1
        else
            openCells = openCells + 1
        end
    end

    -- Keep the full grid path for drawing. Simplifying same-direction runs made
    -- roads look like long straight shortcuts across fields on the PDA.
    -- gridPath = WayPointGPS.simplifyGridPath(gridPath)

    local points = {{x=startX, z=startZ}}
    for i = 2, #gridPath - 1 do
        points[#points + 1] = {x=gridPath[i].x, z=gridPath[i].z}
    end
    points[#points + 1] = {x=targetX, z=targetZ}

    local route = WayPointGPS.resampleRoute(points, ROUTE_SAMPLE_DISTANCE)
    local modeName = isConnector and "antiDetourConnector" or (isRelaxed and "relaxedRoad" or "strictRoad")
    return route, string.format("%s step=%.0f margin=%.0f cells=%d iterations=%d road=%d open=%d field=%d water=%d blocked=%d", modeName, step, searchMargin, cols * rows, iterations, roadCells, openCells, fieldCells, waterCells, blockedCells)
end


function WayPointGPS.isNearAIRoadGraph(graph, worldX, worldZ)
    if graph == nil or worldX == nil or worldZ == nil then return false, math.huge end
    local _, dist = WayPointGPS.aiRoadFindNearestNode(graph, worldX, worldZ, AI_ROAD_ENDPOINT_GRAPH_RADIUS)
    return dist ~= nil and dist <= AI_ROAD_ENDPOINT_GRAPH_RADIUS, dist or math.huge
end

function WayPointGPS.findFirstGraphRoadIndex(route, graph)
    if route == nil then return nil end
    for i = 1, #route do
        local p = route[i]
        local near = WayPointGPS.isNearAIRoadGraph(graph, p.x, p.z)
        if near then return i end
    end
    return nil
end

function WayPointGPS.findLastGraphRoadIndex(route, graph)
    if route == nil then return nil end
    for i = #route, 1, -1 do
        local p = route[i]
        local near = WayPointGPS.isNearAIRoadGraph(graph, p.x, p.z)
        if near then return i end
    end
    return nil
end

function WayPointGPS.countRouteRoadSamples(route)
    local q = WayPointGPS.getRouteRoadQuality(route)
    return q.roadRatio or 0, q.maxNonRoadRun or 0, q.len or WayPointGPS.getRouteLength(route), q.waterRatio or 0
end

function WayPointGPS.buildLocalOffroadConnector(fromX, fromZ, toX, toZ, label)
    local directDist = MathUtil.vector2Length(toX - fromX, toZ - fromZ)
    if directDist < AI_ROAD_OFFROAD_CONNECTOR_MIN then
        return nil, string.format("%sSkipShort=%.0f", tostring(label or "connector"), directDist)
    end

    -- Try strict local road/path detection first. It usually catches beige side
    -- lanes and grey yards without allowing a straight shortcut. If strict fails,
    -- try the connector pass as a recovery option.
    local strictRoute, strictInfo = WayPointGPS.buildRoadBiasedRoute(fromX, fromZ, toX, toZ, false)
    local bestRoute, bestInfo = nil, nil
    local bestScore = -math.huge

    local function consider(candidate, info, source)
        if candidate == nil or #candidate < 2 then return end
        local len = WayPointGPS.getRouteLength(candidate)
        if len <= 0 or len > directDist * AI_ROAD_OFFROAD_CONNECTOR_MAX_FACTOR then return end
        local q = WayPointGPS.getRouteRoadQuality(candidate)
        local roadRatio, maxRun, _, waterRatio = q.roadRatio or 0, q.maxNonRoadRun or 0, q.len or WayPointGPS.getRouteLength(candidate), q.waterRatio or 0
        if (waterRatio or 0) > 0.0 or (q.blockedRatio or 0) > 0.0 then return end
        -- We do not require a perfect road score here, because this is explicitly
        -- the off-road/last-mile section. But the candidate must either find some
        -- road/path pixels, or reduce the longest non-road run compared to a direct
        -- line. That keeps it from replacing direct with an equally bad zigzag.
        local directRun = directDist
        local useful = roadRatio >= AI_ROAD_OFFROAD_MIN_ROAD_GAIN or maxRun < directRun * 0.72
        if useful then
            local score = (roadRatio * 1000.0) - (len / math.max(1.0, directDist) * 120.0) - (maxRun * 0.15)
            if score > bestScore then
                bestScore = score
                bestRoute = candidate
                bestInfo = tostring(source) .. " " .. tostring(info) .. string.format(" roadRatio=%.2f maxRun=%.0f len=%.0f direct=%.0f", roadRatio, maxRun, len, directDist)
            end
        end
    end

    consider(strictRoute, strictInfo, "strictLocal")
    local connectorRoute, connectorInfo = WayPointGPS.buildRoadBiasedRoute(fromX, fromZ, toX, toZ, "connector")
    consider(connectorRoute, connectorInfo, "connectorLocal")

    if bestRoute ~= nil then
        return bestRoute, tostring(label or "connector") .. " " .. bestInfo
    end

    return nil, string.format("%sNoBetter direct=%.0f strict=%s connector=%s", tostring(label or "connector"), directDist, tostring(strictInfo), tostring(connectorInfo))
end

function WayPointGPS.spliceRoutePrefix(route, connector, keepFromIndex)
    if route == nil or connector == nil or keepFromIndex == nil then return route end
    local out = {}
    for i = 1, #connector do
        out[#out + 1] = connector[i]
    end
    for i = keepFromIndex + 1, #route do
        out[#out + 1] = route[i]
    end
    return WayPointGPS.resampleRoute(out, ROUTE_SAMPLE_DISTANCE)
end

function WayPointGPS.spliceRouteSuffix(route, connector, keepToIndex)
    if route == nil or connector == nil or keepToIndex == nil then return route end
    local out = {}
    for i = 1, keepToIndex - 1 do
        out[#out + 1] = route[i]
    end
    for i = 1, #connector do
        out[#out + 1] = connector[i]
    end
    return WayPointGPS.resampleRoute(out, ROUTE_SAMPLE_DISTANCE)
end

function WayPointGPS.trimRoutePrefixToIndex(route, keepFromIndex)
    if route == nil or keepFromIndex == nil or keepFromIndex <= 1 then return route end
    local out = {}
    for i = keepFromIndex, #route do
        out[#out + 1] = route[i]
    end
    return WayPointGPS.resampleRoute(out, ROUTE_SAMPLE_DISTANCE)
end

function WayPointGPS.trimRouteSuffixToIndex(route, keepToIndex)
    if route == nil or keepToIndex == nil or keepToIndex >= #route then return route end
    local out = {}
    for i = 1, keepToIndex do
        out[#out + 1] = route[i]
    end
    return WayPointGPS.resampleRoute(out, ROUTE_SAMPLE_DISTANCE)
end

function WayPointGPS.improveAIRoadEndpointConnectors(route, startX, startZ, targetX, targetZ)
    if route == nil or #route < 2 then return route, "endpointConnectors skipped" end
    local graph = WayPointGPS.aiRoadGraph
    if graph == nil then return route, "endpointConnectors noGraph" end

    local info = ""

    -- Replace a long direct connector from the vehicle to the first AI-road point
    -- with a local terrain/path route, so starting in a yard or field does not draw
    -- a straight line across everything until it hits the traffic road.
    local firstRoadIndex = WayPointGPS.findFirstGraphRoadIndex(route, graph)
    if firstRoadIndex ~= nil and firstRoadIndex > 1 then
        local roadPoint = route[firstRoadIndex]
        local direct = MathUtil.vector2Length(roadPoint.x - startX, roadPoint.z - startZ)
        local crossesWater = WayPointGPS.segmentCrossesWater(startX, startZ, roadPoint.x, roadPoint.z, 7.0)
        local directPenalty, directPenaltyInfo = WayPointGPS.getEndpointConnectorPenalty(startX, startZ, roadPoint.x, roadPoint.z, 6.0)
        local directBlocked = directPenalty ~= nil and directPenalty > 2500.0
        if direct >= AI_ROAD_OFFROAD_CONNECTOR_MIN or crossesWater or directBlocked then
            local connector, connectorInfo = WayPointGPS.buildLocalOffroadConnector(startX, startZ, roadPoint.x, roadPoint.z, "startOffroad")
            local connectorBlocked = connector ~= nil and WayPointGPS.routeCrossesBlockedStructure(connector)
            if connector ~= nil and not connectorBlocked then
                route = WayPointGPS.spliceRoutePrefix(route, connector, firstRoadIndex)
                info = info .. " startConnector[" .. tostring(connectorInfo) .. "]"
            elseif crossesWater or direct > AI_ROAD_DIRECT_CONNECTOR_MAX or directBlocked then
                route = WayPointGPS.trimRoutePrefixToIndex(route, firstRoadIndex)
                info = info .. string.format(" startDirectTrimmed[direct=%.0f water=%s blocked=%s info=%s directQ=%s]", direct, tostring(crossesWater), tostring(directBlocked), tostring(connectorInfo), tostring(directPenaltyInfo))
            else
                info = info .. " startDirectKept[" .. tostring(connectorInfo) .. "]"
            end
        end
    end

    -- Re-scan after the prefix splice. Replace a long direct connector from the
    -- last AI-road point to the clicked target with a local side-road/off-road path.
    local lastRoadIndex = WayPointGPS.findLastGraphRoadIndex(route, graph)
    if lastRoadIndex ~= nil and lastRoadIndex < #route then
        local roadPoint = route[lastRoadIndex]
        local direct = MathUtil.vector2Length(targetX - roadPoint.x, targetZ - roadPoint.z)
        local crossesWater = WayPointGPS.segmentCrossesWater(roadPoint.x, roadPoint.z, targetX, targetZ, 7.0)
        local directPenalty, directPenaltyInfo = WayPointGPS.getEndpointConnectorPenalty(roadPoint.x, roadPoint.z, targetX, targetZ, 6.0)
        local directBlocked = directPenalty ~= nil and directPenalty > 2500.0
        if direct >= AI_ROAD_OFFROAD_CONNECTOR_MIN or crossesWater or directBlocked then
            local connector, connectorInfo = WayPointGPS.buildLocalOffroadConnector(roadPoint.x, roadPoint.z, targetX, targetZ, "targetOffroad")
            local connectorBlocked = connector ~= nil and WayPointGPS.routeCrossesBlockedStructure(connector)
            if connector ~= nil and not connectorBlocked then
                route = WayPointGPS.spliceRouteSuffix(route, connector, lastRoadIndex)
                info = info .. " targetConnector[" .. tostring(connectorInfo) .. "]"
            elseif crossesWater or direct > AI_ROAD_DIRECT_CONNECTOR_MAX or directBlocked then
                route = WayPointGPS.trimRouteSuffixToIndex(route, lastRoadIndex)
                info = info .. string.format(" targetDirectTrimmed[direct=%.0f water=%s blocked=%s info=%s directQ=%s]", direct, tostring(crossesWater), tostring(directBlocked), tostring(connectorInfo), tostring(directPenaltyInfo))
            else
                info = info .. " targetDirectKept[" .. tostring(connectorInfo) .. "]"
            end
        end
    end

    if info == "" then
        info = " endpointConnectors noneNeeded"
    end
    return route, info
end

function WayPointGPS.routeHasWaterCrossing(route)
    if route == nil or #route < 2 then return false end
    for i = 2, #route do
        local a = route[i - 1]
        local b = route[i]
        if WayPointGPS.segmentCrossesWater(a.x, a.z, b.x, b.z, 6.0) then
            return true
        end
    end
    return false
end

function WayPointGPS.routeHasUnsafeWaterCrossing(route, graph)
    if route == nil or #route < 2 then return false end
    for i = 2, #route do
        local a = route[i - 1]
        local b = route[i]
        if WayPointGPS.segmentCrossesWater(a.x, a.z, b.x, b.z, 6.0) then
            if graph == nil or not WayPointGPS.aiRoadSegmentNearBridgeObjectRadius(graph, a.x, a.z, b.x, b.z, WPG_ROUTE_WATER_BRIDGE_RADIUS) then
                return true
            end
        end
    end
    return false
end

function WayPointGPS._findClosestWaypoint(WPs, worldX, worldZ)
    local minDistSq = math.huge
    local minIdx = 1

    for i, wp in ipairs(WPs) do
        local dx = wp.x - worldX
        local dz = wp.z - worldZ
        local distSq = dx * dx + dz * dz
        if distSq < minDistSq then
            minDistSq = distSq
            minIdx = i
        end
    end

    return minIdx, math.sqrt(minDistSq)
end

function WayPointGPS._getRouteProgressSignature(WPs)
    if WPs == nil or #WPs == 0 then return "empty" end
    local first = WPs[1] or {}
    local last = WPs[#WPs] or {}
    return string.format("%d:%.1f:%.1f:%.1f:%.1f", #WPs, first.x or 0, first.z or 0, last.x or 0, last.z or 0)
end

function WayPointGPS._findBestRouteSegmentProgress(WPs, worldX, worldZ, startIdx, endIdx)
    if WPs == nil or #WPs < 2 then return 1, math.huge end
    local bestIdx = startIdx or 1
    local bestDistSq = math.huge
    local lastSeg = math.max(1, math.min(#WPs - 1, endIdx or (#WPs - 1)))
    local firstSeg = math.max(1, math.min(lastSeg, startIdx or 1))

    for i = firstSeg, lastSeg do
        local a = WPs[i]
        local b = WPs[i + 1]
        if a ~= nil and b ~= nil then
            local sx = (b.x or 0) - (a.x or 0)
            local sz = (b.z or 0) - (a.z or 0)
            local sl = sx * sx + sz * sz
            if sl > 0.0001 then
                local t = (((worldX or 0) - (a.x or 0)) * sx + ((worldZ or 0) - (a.z or 0)) * sz) / sl
                if t < 0 then t = 0 elseif t > 1 then t = 1 end
                local px = (a.x or 0) + sx * t
                local pz = (a.z or 0) + sz * t
                local dx = px - (worldX or 0)
                local dz = pz - (worldZ or 0)
                local distSq = dx * dx + dz * dz
                if distSq < bestDistSq then
                    bestDistSq = distSq
                    bestIdx = (t > 0.62) and (i + 1) or i
                end
            end
        end
    end

    return bestIdx, math.sqrt(bestDistSq)
end

function WayPointGPS._findRouteProgressWaypoint(WPs, vehicle, worldX, worldZ)
    -- Do not use a raw nearest-node lookup for live steering. At intersections the
    -- closest node can be on a side branch, which makes the assist briefly turn down
    -- the wrong road or even turn around. Track route progress forward along segments
    -- and only advance; reset only if the vehicle is clearly far from the stored path.
    if WPs == nil or #WPs == 0 then return 1, math.huge end
    if vehicle == nil or vehicle.spec_waypointGps == nil or #WPs < 2 then
        return WayPointGPS._findClosestWaypoint(WPs, worldX, worldZ)
    end

    local spec = vehicle.spec_waypointGps
    local sig = WayPointGPS._getRouteProgressSignature(WPs)
    local lastIdx = spec.wpgpsRouteProgressIndex
    if spec.wpgpsRouteProgressSignature ~= sig or lastIdx == nil or lastIdx < 1 or lastIdx > #WPs then
        local idx, dist = WayPointGPS._findBestRouteSegmentProgress(WPs, worldX, worldZ, 1, #WPs - 1)
        spec.wpgpsRouteProgressSignature = sig
        spec.wpgpsRouteProgressIndex = idx
        spec.wpgpsRouteProgressDistance = dist
        return idx, dist
    end

    -- If the player teleported, changed vehicles, or is badly off-route, allow a full
    -- re-acquire. Otherwise only search a forward window so nearby side-road nodes do
    -- not steal the active waypoint at junctions.
    local windowStart = math.max(1, lastIdx - 1)
    local windowEnd = math.min(#WPs - 1, lastIdx + 70)
    local idx, dist = WayPointGPS._findBestRouteSegmentProgress(WPs, worldX, worldZ, windowStart, windowEnd)

    if dist ~= nil and dist > 55.0 then
        idx, dist = WayPointGPS._findBestRouteSegmentProgress(WPs, worldX, worldZ, 1, #WPs - 1)
    elseif idx < lastIdx then
        idx = lastIdx
    end

    spec.wpgpsRouteProgressIndex = idx
    spec.wpgpsRouteProgressDistance = dist
    return idx, dist
end

function WayPointGPS._setupTargetHotspot()
    -- Disabled: AITargetHotspot draws the base-game white circle/arrow marker,
    -- which sits under our custom GPS pin and looks too much like other route mods.
    -- WayPoint GPS now renders only its own clean pin overlay on the minimap/ESC map.
    return
end

function WayPointGPS._removeTargetHotspot()
    if WayPointGPS.targetHotspot ~= nil then
        if g_currentMission ~= nil and type(g_currentMission.removeMapHotspot) == "function" then
            g_currentMission:removeMapHotspot(WayPointGPS.targetHotspot)
        end
        if type(WayPointGPS.targetHotspot.delete) == "function" then
            WayPointGPS.targetHotspot:delete()
        end
        WayPointGPS.targetHotspot = nil
    end
end

function WayPointGPS._refreshTargetHotspot()
    -- Keep the base-game AI target hotspot removed so only the custom GPS pin is visible.
    WayPointGPS._removeTargetHotspot()
end

function WayPointGPS._isCtrlDown()
    if WayPointGPS.ctrlDown then return true end
    if Input == nil or Input.isKeyPressed == nil then return false end
    local left = Input.KEY_lctrl ~= nil and Input.isKeyPressed(Input.KEY_lctrl)
    local right = Input.KEY_rctrl ~= nil and Input.isKeyPressed(Input.KEY_rctrl)
    return left or right
end

function WayPointGPS._isCtrlKey(sym)
    return Input ~= nil and (sym == Input.KEY_lctrl or sym == Input.KEY_rctrl)
end

function WayPointGPS._isShiftDown()
    if WayPointGPS.shiftDown then return true end
    if Input == nil or Input.isKeyPressed == nil then return false end
    local left = Input.KEY_lshift ~= nil and Input.isKeyPressed(Input.KEY_lshift)
    local right = Input.KEY_rshift ~= nil and Input.isKeyPressed(Input.KEY_rshift)
    return left or right
end

function WayPointGPS._isShiftKey(sym)
    return Input ~= nil and (sym == Input.KEY_lshift or sym == Input.KEY_rshift)
end

function WayPointGPS._safeGetVisible(element)
    if element == nil then return nil end
    if type(element.getIsVisible) == "function" then
        local ok, value = pcall(element.getIsVisible, element)
        if ok and value ~= nil then
            return value == true
        end
    end
    if element.visible ~= nil then return element.visible == true end
    if element.isVisible ~= nil then return element.isVisible == true end
    return nil
end

function WayPointGPS._getMenuMapElement()
    if g_inGameMenu == nil then return nil end
    return g_inGameMenu.baseIngameMap or g_inGameMenu.ingameMap or g_inGameMenu.inGameMap
end

function WayPointGPS._valueLooksLikeMapPage(value)
    if value == nil then return nil end
    if type(value) == "string" then
        return string.find(string.lower(value), "map") ~= nil
    end
    if type(value) == "table" then
        local fields = {"name", "id", "profile", "profileName", "className", "pageName", "frameName"}
        for _, field in ipairs(fields) do
            local v = rawget(value, field)
            if type(v) == "string" and string.find(string.lower(v), "map") ~= nil then
                return true
            end
        end
    end
    return nil
end

function WayPointGPS._isMenuMapPageActive()
    if g_inGameMenu == nil or not g_inGameMenu.isOpen then return false end
    if settingsOpen then return false end

    local map = WayPointGPS._getMenuMapElement()
    if map == nil or map.layout == nil then return false end

    local mapCandidates = {}
    local function addMapCandidate(value)
        if value ~= nil then mapCandidates[#mapCandidates + 1] = value end
    end
    addMapCandidate(g_inGameMenu.pageMap)
    addMapCandidate(g_inGameMenu.pageMapOverview)
    addMapCandidate(g_inGameMenu.pageOverview)
    addMapCandidate(g_inGameMenu.mapFrame)
    addMapCandidate(g_inGameMenu.ingameMapFrame)
    addMapCandidate(g_inGameMenu.inGameMapFrame)
    addMapCandidate(g_inGameMenu.ingameMenuMapFrame)
    addMapCandidate(g_inGameMenu.baseIngameMap)
    addMapCandidate(g_inGameMenu.ingameMap)
    addMapCandidate(g_inGameMenu.inGameMap)

    local activeValues = {}
    local function addActiveValue(value)
        if value ~= nil then activeValues[#activeValues + 1] = value end
    end
    addActiveValue(g_inGameMenu.currentPage)
    addActiveValue(g_inGameMenu.currentPageElement)
    addActiveValue(g_inGameMenu.currentPageName)
    addActiveValue(g_inGameMenu.currentPageId)
    addActiveValue(g_inGameMenu.selectedPage)
    addActiveValue(g_inGameMenu.selectedPageName)
    addActiveValue(g_inGameMenu.activePage)
    addActiveValue(g_inGameMenu.activePageName)
    addActiveValue(g_inGameMenu.currentFrame)

    local activeKnown = false
    for _, value in ipairs(activeValues) do
        if value ~= nil then
            activeKnown = true
            for _, candidate in ipairs(mapCandidates) do
                if candidate ~= nil and value == candidate then
                    return true
                end
            end
            local looks = WayPointGPS._valueLooksLikeMapPage(value)
            if looks == true then
                return true
            end
        end
    end

    local funcs = {"getCurrentPage", "getCurrentPageName", "getActivePage", "getActivePageName", "getCurrentFrame"}
    for _, fn in ipairs(funcs) do
        if type(g_inGameMenu[fn]) == "function" then
            local ok, value = pcall(g_inGameMenu[fn], g_inGameMenu)
            if ok and value ~= nil then
                activeKnown = true
                for _, candidate in ipairs(mapCandidates) do
                    if candidate ~= nil and value == candidate then
                        return true
                    end
                end
                local looks = WayPointGPS._valueLooksLikeMapPage(value)
                if looks == true then
                    return true
                end
            end
        end
    end

    -- If the menu tells us which page is active and none of the values match a map page,
    -- do not draw. This prevents the route overlay from leaking onto prices, contracts,
    -- vehicles, settings, and other ESC pages.
    if activeKnown then
        return false
    end

    -- Fallback for builds where the active-page field is not exposed: only draw if a
    -- known map element reports visible. If visibility cannot be read, stay hidden in
    -- the ESC menu rather than drawing over unrelated pages.
    for _, candidate in ipairs(mapCandidates) do
        local visible = WayPointGPS._safeGetVisible(candidate)
        if visible == true then return true end
    end

    return false
end

function WayPointGPS._isFullMapOpen()
    return WayPointGPS._isMenuMapPageActive()
end

function WayPointGPS._getFallbackMapDimensions(map)
    local sizeX = map ~= nil and map.worldSizeX or nil
    local sizeZ = map ~= nil and map.worldSizeZ or nil
    local offX = map ~= nil and map.worldCenterOffsetX or nil
    local offZ = map ~= nil and map.worldCenterOffsetZ or nil

    local mission = g_currentMission
    if sizeX == nil or sizeZ == nil then
        if mission ~= nil then
            local terrainSize = mission.terrainSize
            if terrainSize == nil and mission.terrainRootNode ~= nil and getTerrainSize ~= nil then
                local ok, value = pcall(getTerrainSize, mission.terrainRootNode)
                if ok then terrainSize = value end
            end
            if terrainSize ~= nil then
                sizeX = sizeX or terrainSize
                sizeZ = sizeZ or terrainSize
            end
        end
    end

    if sizeX == nil then sizeX = 2048 end
    if sizeZ == nil then sizeZ = sizeX end
    if offX == nil then offX = sizeX * 0.5 end
    if offZ == nil then offZ = sizeZ * 0.5 end
    return sizeX, sizeZ, offX, offZ
end

function WayPointGPS._screenClickCenteredToWorld(posX, posY, map)
    if posX == nil or posY == nil then return nil, nil end

    -- Classic FS full-map transform: the playable map square is usually in the
    -- centered 0.25..0.75 GUI region. This is intentionally kept as its own
    -- candidate because one affected profile does not return usable local map
    -- coords and the rendered-bounds estimate is offset.
    local localX, localY
    if posX >= 0.25 and posX <= 0.75 and posY >= 0.25 and posY <= 0.75 then
        localX = (posX - 0.25) / 0.5
        localY = (posY - 0.25) / 0.5
    else
        localX = posX
        localY = posY
    end

    localX = math.max(0, math.min(1, localX))
    localY = math.max(0, math.min(1, localY))

    local sizeX, sizeZ, offX, offZ = WayPointGPS._getFallbackMapDimensions(map)
    return (localX * sizeX) - offX, (localY * sizeZ) - offZ
end

function WayPointGPS._screenClickRawToWorld(posX, posY, map)
    if posX == nil or posY == nil then return nil, nil end
    local localX = math.max(0, math.min(1, posX))
    local localY = math.max(0, math.min(1, posY))
    local sizeX, sizeZ, offX, offZ = WayPointGPS._getFallbackMapDimensions(map)
    return (localX * sizeX) - offX, (localY * sizeZ) - offZ
end

function WayPointGPS._screenClickAspectSquareToWorld(posX, posY, map)
    if posX == nil or posY == nil then return nil, nil end

    -- The FS25 ESC map is commonly rendered as a true square in pixel space, not
    -- always as the old hard-coded 0.25..0.75 normalized GUI square. On 16:9
    -- screens that means the visible map can span the full height and only about
    -- 56.25% of the screen width. This fixes profiles where fullscreenLayout and
    -- renderedBounds point to a side/backing map while the visible cursor is on
    -- the large fullscreen map.
    local aspect = g_screenAspectRatio or 1.7777777778
    if aspect <= 0 then aspect = 1.7777777778 end

    local minX, maxX, minY, maxY
    if aspect >= 1.0 then
        local width = 1.0 / aspect
        minX = (1.0 - width) * 0.5
        maxX = minX + width
        minY = 0.0
        maxY = 1.0
    else
        local height = aspect
        minX = 0.0
        maxX = 1.0
        minY = (1.0 - height) * 0.5
        maxY = minY + height
    end

    local localX = (posX - minX) / (maxX - minX)
    local localY = (posY - minY) / (maxY - minY)

    -- Allow a tiny tolerance so clicks close to the map edge still work, then clamp.
    if localX < -0.03 or localX > 1.03 or localY < -0.03 or localY > 1.03 then
        return nil, nil
    end

    localX = math.max(0, math.min(1, localX))
    localY = math.max(0, math.min(1, localY))

    local sizeX, sizeZ, offX, offZ = WayPointGPS._getFallbackMapDimensions(map)
    return (localX * sizeX) - offX, (localY * sizeZ) - offZ
end

function WayPointGPS._screenClickFallbackToWorld(posX, posY, map)
    -- Backward-compatible name used by older code paths. Treat it as centered map
    -- math, not as the only fallback. v1.0.0.12 adds raw/centered/rendered modes.
    return WayPointGPS._screenClickCenteredToWorld(posX, posY, map)
end

function WayPointGPS._getClickMode()
    local mode = WayPointGPS.clickMode
    if mode == nil then return "auto" end
    mode = string.lower(tostring(mode))
    if mode == "local" or mode == "pointer" or mode == "centered" or mode == "raw" or mode == "aspect" or mode == "rendered" or mode == "auto" then
        return mode
    end
    return "auto"
end



function WayPointGPS._getBestFullscreenMapLayout(map)
    if map == nil then return nil, nil end
    if map.fullScreenLayout ~= nil and type(map.fullScreenLayout.getMapObjectPosition) == "function" then
        return map.fullScreenLayout, "fullScreenLayout"
    end
    if map.layout ~= nil and type(map.layout.getMapObjectPosition) == "function" then
        return map.layout, "layout"
    end
    return nil, nil
end

function WayPointGPS._menuClickToWorldByRenderedBounds(posX, posY, map, layout)
    if posX == nil or posY == nil or map == nil then return nil, nil end
    layout = layout or WayPointGPS._getBestFullscreenMapLayout(map)
    if layout == nil or type(layout.getMapObjectPosition) ~= "function" then return nil, nil end

    local ok1, sx1, sy1 = pcall(function() return layout:getMapObjectPosition(0.25, 0.25, 0, 0, 0, true) end)
    local ok2, sx2, sy2 = pcall(function() return layout:getMapObjectPosition(0.75, 0.75, 0, 0, 0, true) end)
    if not ok1 or not ok2 or sx1 == nil or sy1 == nil or sx2 == nil or sy2 == nil then return nil, nil end

    local minX, maxX = math.min(sx1, sx2), math.max(sx1, sx2)
    local minY, maxY = math.min(sy1, sy2), math.max(sy1, sy2)
    local width, height = maxX - minX, maxY - minY
    if width <= 0.001 or height <= 0.001 then return nil, nil end

    -- Invert the same fullscreen-map layout used by FS/CP to draw map objects.
    -- This avoids using the minimap/HUD layout as the click source on profiles where
    -- g_inGameMenu.baseIngameMap.layout does not represent the visible ESC map.
    local localX = (posX - minX) / width
    local localY = (posY - minY) / height
    if localX < -0.02 or localX > 1.02 or localY < -0.02 or localY > 1.02 then
        return nil, nil
    end
    localX = math.max(0, math.min(1, localX))
    localY = math.max(0, math.min(1, localY))

    local sizeX, sizeZ, offX, offZ = WayPointGPS._getFallbackMapDimensions(map)
    return (localX * sizeX) - offX, (localY * sizeZ) - offZ
end

function WayPointGPS._projectWorldToScreenError(map, worldX, worldZ, posX, posY)
    if map == nil or worldX == nil or worldZ == nil or posX == nil or posY == nil then return nil end
    if map.worldCenterOffsetX == nil or map.worldCenterOffsetZ == nil or map.worldSizeX == nil or map.worldSizeZ == nil then return nil end

    local layout = nil
    layout = WayPointGPS._getBestFullscreenMapLayout(map)
    if layout == nil then return nil end

    local best = nil
    local normX = (worldX + map.worldCenterOffsetX) / map.worldSizeX
    local normZ = (worldZ + map.worldCenterOffsetZ) / map.worldSizeZ

    local function try(mx, mz)
        if mx == nil or mz == nil then return end
        if mx < -0.25 or mx > 1.25 or mz < -0.25 or mz > 1.25 then return end
        local ok, sx, sy = pcall(function()
            return layout:getMapObjectPosition(mx, mz, 0, 0, 0, true)
        end)
        if ok and sx ~= nil and sy ~= nil then
            local dx = sx - posX
            local dy = sy - posY
            local e = math.sqrt(dx * dx + dy * dy)
            if best == nil or e < best then best = e end
        end
    end

    -- Fullscreen map drawing uses 0.25..0.75 map coordinates. Some layouts also
    -- accept raw 0..1 coordinates, so test both for diagnostics/scoring.
    try(normX * 0.5 + 0.25, normZ * 0.5 + 0.25)
    try(normX, normZ)
    return best
end

function WayPointGPS._addClickCandidate(list, sourceName, map, localX, localY, worldX, worldZ, posX, posY, baseScore)
    if worldX == nil or worldZ == nil then
        if map ~= nil and localX ~= nil and localY ~= nil and type(map.localToWorldPos) == "function" then
            if localX >= -0.05 and localX <= 1.05 and localY >= -0.05 and localY <= 1.05 then
                local ok, wx, wz = pcall(function() return map:localToWorldPos(localX, localY) end)
                if ok then
                    worldX, worldZ = wx, wz
                end
            end
        end
    end
    if worldX == nil or worldZ == nil then return end

    local err = WayPointGPS._projectWorldToScreenError(map, worldX, worldZ, posX, posY)
    local score = baseScore or 50
    if err ~= nil then
        -- Screen projection match is the strongest signal. This prevents a stale
        -- getLocalPointerTarget() from winning when the visible cursor is elsewhere.
        score = (err * 10000.0) + (baseScore or 0)
    end

    list[#list + 1] = {
        source = sourceName,
        localX = localX,
        localY = localY,
        worldX = worldX,
        worldZ = worldZ,
        screenErr = err,
        score = score
    }
end



function WayPointGPS._isUsableMapElementForClick(map)
    if map == nil then return false end

    -- BaseMission / generic GUI elements can receive the appended mouse event too,
    -- but they are not the visible full-map widget. If we accept their screenFallback
    -- result, the click lands using a hardcoded 0.25..0.75 square and is offset on
    -- some resolutions/profiles. Only handle a click directly when this element has
    -- real map coordinate APIs/data; otherwise let the later g_inGameMenu map lookup
    -- or final fullscreen fallback handle it.
    if type(map.getLocalPosition) == "function" or type(map.getLocalPointerTarget) == "function" then
        return true
    end
    if type(map.localToWorldPos) == "function" then
        return true
    end
    if map.layout ~= nil and map.worldSizeX ~= nil and map.worldSizeZ ~= nil
       and map.worldCenterOffsetX ~= nil and map.worldCenterOffsetZ ~= nil then
        return true
    end
    return false
end


function WayPointGPS._selectClickCandidate(candidates)
    if candidates == nil or #candidates == 0 then return nil end

    local mode = WayPointGPS._getClickMode()
    local manualSource = {
        ["local"] = "eventLocalPosition",
        pointer = "pointerTarget",
        centered = "centeredSquare",
        raw = "rawFullScreen",
        aspect = "aspectSquare",
        rendered = "fullScreenBounds"
    }

    local function firstBySource(source)
        for _, c in ipairs(candidates) do
            if c.source == source then return c end
        end
        return nil
    end

    if mode ~= "auto" then
        local wanted = manualSource[mode]
        local c = firstBySource(wanted)
        if c ~= nil then return c end
        -- If the requested mode is unavailable, fall back safely instead of dropping
        -- the click. The log still prints the active mode and available candidates.
    end

    -- Auto mode priority for the isolated click bug: trust true map coordinate APIs
    -- first. If those are unavailable, use aspect-correct fullscreen-map math before
    -- old centered/raw estimates. Do NOT trust fullScreenBounds first here: on the
    -- affected profile it is self-consistent but points at the backing/side map,
    -- producing a waypoint far left of the visible cursor.
    local priority = {
        "eventLocalPosition",
        "pointerTarget",
        "layoutLocalToWorld",
        "layoutMath",
        "aspectSquare",
        "centeredSquare",
        "rawFullScreen",
        "fullScreenBounds",
        "renderedBounds"
    }
    for _, source in ipairs(priority) do
        local c = firstBySource(source)
        if c ~= nil then return c end
    end

    local best = nil
    for _, c in ipairs(candidates) do
        if best == nil or (c.score or 999999) < (best.score or 999999) then
            best = c
        end
    end
    return best
end

function WayPointGPS._chooseBestClickCandidate(map, posX, posY)
    local candidates = {}

    if map ~= nil then
        if type(map.getLocalPosition) == "function" then
            local ok, lx, ly = pcall(function() return map:getLocalPosition(posX, posY) end)
            if ok then
                WayPointGPS._addClickCandidate(candidates, "eventLocalPosition", map, lx, ly, nil, nil, posX, posY, 5)
            end
        end

        if type(map.getLocalPointerTarget) == "function" then
            local ok, lx, ly = pcall(function() return map:getLocalPointerTarget() end)
            if ok then
                WayPointGPS._addClickCandidate(candidates, "pointerTarget", map, lx, ly, nil, nil, posX, posY, 35)
            end
        end

        local wxFull, wzFull = WayPointGPS._menuClickToWorldByRenderedBounds(posX, posY, map, map.fullScreenLayout)
        WayPointGPS._addClickCandidate(candidates, "fullScreenBounds", map, nil, nil, wxFull, wzFull, posX, posY, 15)

        local wx, wz = WayPointGPS._menuClickToWorldByRenderedBounds(posX, posY, map, map.layout)
        WayPointGPS._addClickCandidate(candidates, "renderedBounds", map, nil, nil, wx, wz, posX, posY, 80)

        local wxAspect, wzAspect = WayPointGPS._screenClickAspectSquareToWorld(posX, posY, map)
        WayPointGPS._addClickCandidate(candidates, "aspectSquare", map, nil, nil, wxAspect, wzAspect, posX, posY, 18)

        local wxCentered, wzCentered = WayPointGPS._screenClickCenteredToWorld(posX, posY, map)
        WayPointGPS._addClickCandidate(candidates, "centeredSquare", map, nil, nil, wxCentered, wzCentered, posX, posY, 40)

        local wxRaw, wzRaw = WayPointGPS._screenClickRawToWorld(posX, posY, map)
        WayPointGPS._addClickCandidate(candidates, "rawFullScreen", map, nil, nil, wxRaw, wzRaw, posX, posY, 45)

        local layout = map.layout
        if layout ~= nil and layout.absPosition ~= nil and layout.absSize ~= nil
           and map.worldSizeX ~= nil and map.worldSizeZ ~= nil
           and map.worldCenterOffsetX ~= nil and map.worldCenterOffsetZ ~= nil then
            local lx = (posX - layout.absPosition[1]) / layout.absSize[1]
            local ly = (posY - layout.absPosition[2]) / layout.absSize[2]
            if lx >= -0.05 and lx <= 1.05 and ly >= -0.05 and ly <= 1.05 then
                WayPointGPS._addClickCandidate(candidates, "layoutLocalToWorld", map, lx, ly, nil, nil, posX, posY, 25)
                local wx2 = (lx * map.worldSizeX) - map.worldCenterOffsetX
                local wz2 = (ly * map.worldSizeZ) - map.worldCenterOffsetZ
                WayPointGPS._addClickCandidate(candidates, "layoutMath", map, lx, ly, wx2, wz2, posX, posY, 30)
            end
        end
    end

    if map == nil then
        local wxA, wzA = WayPointGPS._screenClickAspectSquareToWorld(posX, posY, map)
        WayPointGPS._addClickCandidate(candidates, "aspectSquare", map, nil, nil, wxA, wzA, posX, posY, 88)
        local wx3, wz3 = WayPointGPS._screenClickRawToWorld(posX, posY, map)
        WayPointGPS._addClickCandidate(candidates, "rawFullScreen", map, nil, nil, wx3, wz3, posX, posY, 90)
        local wx4, wz4 = WayPointGPS._screenClickCenteredToWorld(posX, posY, map)
        WayPointGPS._addClickCandidate(candidates, "centeredSquare", map, nil, nil, wx4, wz4, posX, posY, 95)
    end

    local best = WayPointGPS._selectClickCandidate(candidates)

    if best ~= nil then
        local parts = {}
        for _, c in ipairs(candidates) do
            parts[#parts + 1] = string.format("%s wx=%.1f wz=%.1f lx=%s ly=%s err=%s score=%.1f",
                tostring(c.source), c.worldX or 0, c.worldZ or 0,
                c.localX ~= nil and string.format("%.4f", c.localX) or "nil",
                c.localY ~= nil and string.format("%.4f", c.localY) or "nil",
                c.screenErr ~= nil and string.format("%.5f", c.screenErr) or "nil",
                c.score or 0)
        end
        WayPointGPS.logInfo("[WayPointGPS] click candidate choose mode=%s screen=(%s,%s) selected=%s world=(%.1f,%.1f) candidates={%s}",
            tostring(WayPointGPS._getClickMode()), tostring(posX), tostring(posY), tostring(best.source), best.worldX, best.worldZ, table.concat(parts, "; "))
    end

    return best
end


function WayPointGPS._collectMenuClickMaps()
    local maps = {}
    local seen = {}

    local function add(map, name)
        if map ~= nil and seen[map] ~= true then
            seen[map] = true
            maps[#maps + 1] = { map, name }
        end
    end

    local function addField(parent, parentName, fieldName)
        if parent == nil then return end
        local value = nil
        local ok, result = pcall(function() return parent[fieldName] end)
        if ok then value = result end
        if value ~= nil then
            add(value, tostring(parentName) .. "." .. tostring(fieldName))
        end
    end

    if g_inGameMenu ~= nil then
        -- Prefer the active map-frame ingameMap objects first. These are the same
        -- kind of objects Courseplay uses with ingameMap:getLocalPosition() and
        -- ingameMap:localToWorldPos(). baseIngameMap can be a backing map and may
        -- project consistently while still being offset from the visible ESC map.
        local frames = {
            { g_inGameMenu.pageMap, "g_inGameMenu.pageMap" },
            { g_inGameMenu.pageMapOverview, "g_inGameMenu.pageMapOverview" },
            { g_inGameMenu.mapFrame, "g_inGameMenu.mapFrame" },
            { g_inGameMenu.ingameMapFrame, "g_inGameMenu.ingameMapFrame" },
            { g_inGameMenu.inGameMapFrame, "g_inGameMenu.inGameMapFrame" },
            { g_inGameMenu.ingameMenuMapFrame, "g_inGameMenu.ingameMenuMapFrame" },
            { g_inGameMenu.currentPage, "g_inGameMenu.currentPage" },
            { g_inGameMenu.currentFrame, "g_inGameMenu.currentFrame" }
        }

        for _, entry in ipairs(frames) do
            local frame = entry[1]
            local name = entry[2]
            addField(frame, name, "ingameMap")
            addField(frame, name, "inGameMap")
            addField(frame, name, "baseIngameMap")
            addField(frame, name, "map")
            addField(frame, name, "mapElement")
            add(frame, name)
        end

        add(g_inGameMenu.ingameMap, "g_inGameMenu.ingameMap")
        add(g_inGameMenu.inGameMap, "g_inGameMenu.inGameMap")
        add(g_inGameMenu.baseIngameMap, "g_inGameMenu.baseIngameMap")
    end

    return maps
end

function WayPointGPS._menuClickToWorld(posX, posY)
    if not WayPointGPS._isFullMapOpen() then return nil, nil end

    local maps = WayPointGPS._collectMenuClickMaps()
    for _, entry in ipairs(maps) do
        local map = entry[1]
        if WayPointGPS._isUsableMapElementForClick(map) then
            local best = WayPointGPS._chooseBestClickCandidate(map, posX, posY)
            if best ~= nil and best.worldX ~= nil and best.worldZ ~= nil and best.source ~= "screenFallback" then
                WayPointGPS.logInfo("[WayPointGPS] fullscreen fallback used %s/%s world=(%.1f,%.1f)", tostring(entry[2]), tostring(best.source), best.worldX, best.worldZ)
                return best.worldX, best.worldZ
            end
        end
    end

    -- Last resort only: if there is no usable map element at all, use the raw
    -- screen fallback so clicks still work, but the log will make that obvious.
    local best = WayPointGPS._chooseBestClickCandidate(nil, posX, posY)
    if best ~= nil then
        WayPointGPS.logInfo("[WayPointGPS] fullscreen fallback used raw %s world=(%.1f,%.1f)", tostring(best.source), best.worldX, best.worldZ)
        return best.worldX, best.worldZ
    end
    return nil, nil
end


function WayPointGPS._tryMapElementClick(mapElement, posX, posY, sourceName)
    if mapElement == nil then return false end

    if type(mapElement.getIsVisible) == "function" and not mapElement:getIsVisible() then
        return false
    end

    if not WayPointGPS._isUsableMapElementForClick(mapElement) then
        -- Do not allow BaseMission/generic event self to consume CTRL-click with
        -- screenFallback. That is the isolated-player offset case from v1.0.0.10.
        local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
        if sourceName ~= nil and now - (WayPointGPS.lastClickSkipDebugTime or 0) > 1000 then
            WayPointGPS.lastClickSkipDebugTime = now
            WayPointGPS.logInfo("[WayPointGPS] click source skipped: %s is not a map coordinate element", tostring(sourceName))
        end
        return false
    end

    local best = WayPointGPS._chooseBestClickCandidate(mapElement, posX, posY)
    if best == nil or best.worldX == nil or best.worldZ == nil then
        return false
    end

    -- A map element that can only produce screenFallback is not reliable enough to
    -- consume the click. Let the fullscreen-map fallback try the active map object.
    if best.source == "screenFallback" and sourceName ~= "base map fallback" then
        local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
        if now - (WayPointGPS.lastClickSkipDebugTime or 0) > 1000 then
            WayPointGPS.lastClickSkipDebugTime = now
            WayPointGPS.logInfo("[WayPointGPS] click source skipped: %s only produced screenFallback", tostring(sourceName))
        end
        return false
    end

    local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
    if now - WayPointGPS.lastMapClickTime < 250 then
        return true
    end

    WayPointGPS.lastMapClickTime = now
    WayPointGPS.logInfo("[WayPointGPS] CTRL map click source=%s/%s screen=(%s,%s) world=(%.1f,%.1f)", tostring(sourceName), tostring(best.source), tostring(posX), tostring(posY), best.worldX, best.worldZ)
    WayPointGPS.handleMapWaypointClick(best.worldX, best.worldZ, tostring(sourceName) .. "/" .. tostring(best.source))
    return true
end

function WayPointGPS._worldToMinimapPos(worldX, worldZ)
    local ingameMap = g_currentMission ~= nil and g_currentMission.hud ~= nil and g_currentMission.hud.ingameMap or nil
    if ingameMap == nil and g_currentMission ~= nil and g_currentMission.hud ~= nil and type(g_currentMission.hud.getIngameMap) == "function" then
        ingameMap = g_currentMission.hud:getIngameMap()
    end
    if ingameMap == nil or ingameMap.layout == nil then return nil, nil end

    local mapX = (worldX + ingameMap.worldCenterOffsetX) / ingameMap.worldSizeX * 0.5 + 0.25
    local mapZ = (worldZ + ingameMap.worldCenterOffsetZ) / ingameMap.worldSizeZ * 0.5 + 0.25
    if mapX < 0.25 or mapX > 0.75 or mapZ < 0.25 or mapZ > 0.75 then return nil, nil end
    return ingameMap.layout:getMapObjectPosition(mapX, mapZ, 0, 0, 0, true)
end

function WayPointGPS._getIngameMapHud()
    local ingameMap = g_currentMission ~= nil and g_currentMission.hud ~= nil and g_currentMission.hud.ingameMap or nil
    if ingameMap == nil and g_currentMission ~= nil and g_currentMission.hud ~= nil and type(g_currentMission.hud.getIngameMap) == "function" then
        ingameMap = g_currentMission.hud:getIngameMap()
    end
    return ingameMap
end

function WayPointGPS._getVehicleSpeedKmh(vehicle)
    if vehicle == nil then return 0.0 end

    local speed = nil
    if type(vehicle.getLastSpeed) == "function" then
        speed = vehicle:getLastSpeed()
    end

    if speed == nil and vehicle.lastSpeedReal ~= nil then
        speed = math.abs(vehicle.lastSpeedReal or 0) * 3600.0
    end

    speed = math.abs(speed or 0.0)
    -- GIANTS getLastSpeed is normally km/h.  If a vehicle/mod returns m/s instead,
    -- lastSpeedReal is the safer fallback when present.
    if speed < 1.0 and vehicle.lastSpeedReal ~= nil and math.abs(vehicle.lastSpeedReal or 0) > 0.001 then
        speed = math.abs(vehicle.lastSpeedReal or 0) * 3600.0
    end
    return speed
end

function WayPointGPS._formatEta(seconds)
    if seconds == nil or seconds ~= seconds or seconds == math.huge or seconds < 0 then
        return "ETA --"
    end

    seconds = math.floor(seconds + 0.5)
    if seconds < 60 then
        return string.format("ETA 0:%02d", seconds)
    end

    local minutes = math.floor(seconds / 60)
    local secs = seconds % 60
    if minutes < 60 then
        return string.format("ETA %d:%02d", minutes, secs)
    end

    local hours = math.floor(minutes / 60)
    minutes = minutes % 60
    return string.format("ETA %dh %02dm", hours, minutes)
end

function WayPointGPS._getMinimapEtaAnchor()
    local ingameMap = WayPointGPS._getIngameMapHud()
    local x, y = 0.105, 0.020

    if ingameMap ~= nil and ingameMap.layout ~= nil then
        local layout = ingameMap.layout
        local pos = layout.absPosition or layout.position
        local size = layout.absSize or layout.size
        if pos ~= nil and size ~= nil and pos[1] ~= nil and pos[2] ~= nil and size[1] ~= nil and size[2] ~= nil then
            x = pos[1] + size[1] * 0.5
            -- Keep ETA below the base-game coordinate text under the minimap.
            y = pos[2] - (96 / g_screenHeight)
        end
    end

    if y < 0.012 then y = 0.012 end
    return x, y
end

function WayPointGPS._drawMinimapEta(drawWPs, vehicle)
    if drawWPs == nil or #drawWPs < 2 or vehicle == nil then return end

    local speedKmh = WayPointGPS._getVehicleSpeedKmh(vehicle)
    local distanceMeters = WayPointGPS.getRouteLength(drawWPs) or 0.0

    local text = nil
    if speedKmh <= 1.0 then
        text = "ETA --"
    else
        local metersPerSecond = speedKmh / 3.6
        text = WayPointGPS._formatEta(distanceMeters / metersPerSecond)
    end

    local x, y = WayPointGPS._getMinimapEtaAnchor()
    local size = 15 / g_screenHeight
    local stateSize = 16 / g_screenHeight
    local gap = 5 / g_screenWidth
    local stateText = "+"
    local textWidth = getTextWidth(size, text)
    local stateWidth = getTextWidth(stateSize, stateText)
    local totalWidth = stateWidth + gap + textWidth
    local stateX = x - totalWidth * 0.5
    local tx = stateX + stateWidth + gap

    if setTextBold ~= nil then setTextBold(true) end
    setTextColor(0, 0, 0, 0.80)
    renderText(stateX + (1 / g_screenWidth), y - (1 / g_screenHeight), stateSize, stateText)
    renderText(tx + (1 / g_screenWidth), y - (1 / g_screenHeight), size, text)
    setTextColor(0.00, 1.00, 0.00, 1.0)
    renderText(stateX, y, stateSize, stateText)
    setTextColor(1, 1, 1, 1.0)
    renderText(tx, y, size, text)
    setTextColor(1, 1, 1, 1)
    if setTextBold ~= nil then setTextBold(false) end
end

function WayPointGPS._drawDisplayOffIndicator()
    local x, y = WayPointGPS._getMinimapEtaAnchor()
    local size = 16 / g_screenHeight
    local text = "-"
    local width = getTextWidth(size, text)
    local tx = x - width * 0.5

    if setTextBold ~= nil then setTextBold(true) end
    setTextColor(0, 0, 0, 0.80)
    renderText(tx + (1 / g_screenWidth), y - (1 / g_screenHeight), size, text)
    setTextColor(1.00, 0.00, 0.00, 1.0)
    renderText(tx, y, size, text)
    setTextColor(1, 1, 1, 1)
    if setTextBold ~= nil then setTextBold(false) end
end

function WayPointGPS._getRouteLookaheadPoint(drawWPs, lookaheadDistance)
    if drawWPs == nil or #drawWPs < 2 then return nil, nil end

    local distanceLeft = lookaheadDistance or 35.0
    local lastWp = drawWPs[1]
    if lastWp == nil then return nil, nil end

    for i = 2, #drawWPs do
        local wp = drawWPs[i]
        if wp == nil then break end
        local dx = (wp.x or 0) - (lastWp.x or 0)
        local dz = (wp.z or 0) - (lastWp.z or 0)
        local segLen = MathUtil.vector2Length(dx, dz)
        if segLen >= distanceLeft and segLen > 0.001 then
            local t = distanceLeft / segLen
            return (lastWp.x or 0) + dx * t, (lastWp.z or 0) + dz * t
        end
        distanceLeft = distanceLeft - segLen
        lastWp = wp
    end

    local finalWp = drawWPs[#drawWPs]
    if finalWp ~= nil then
        return finalWp.x, finalWp.z
    end
    return nil, nil
end

function WayPointGPS._getVehicleForward2D(vehicle)
    if vehicle == nil then return nil, nil, nil, nil end

    local node = nil
    if type(vehicle.getAISteeringNode) == "function" then
        node = vehicle:getAISteeringNode()
    end
    node = node or vehicle.rootNode or (vehicle.components and vehicle.components[1] and vehicle.components[1].node)
    if node == nil or type(localDirectionToWorld) ~= "function" then return nil, nil, nil, nil end

    local fx, _, fz = localDirectionToWorld(node, 0, 0, 1)
    local flen = MathUtil.vector2Length(fx or 0, fz or 0)
    if flen < 0.001 then return nil, nil, nil, nil end
    fx, fz = fx / flen, fz / flen

    -- Use the vehicle/steering node's real local +X direction for right/left.
    -- The previous perpendicular math could mirror the on-screen arrow on some maps/vehicle nodes.
    local rx, _, rz = localDirectionToWorld(node, 1, 0, 0)
    local rlen = MathUtil.vector2Length(rx or 0, rz or 0)
    if rlen >= 0.001 then
        rx, rz = rx / rlen, rz / rlen
    else
        -- Safe fallback only if the node does not expose a usable +X axis.
        rx, rz = fz, -fx
    end

    return fx, fz, rx, rz
end

function WayPointGPS._getVehicleSpeedKmh(vehicle)
    if vehicle == nil then return 0.0 end

    if type(vehicle.getLastSpeed) == "function" then
        local ok, value = pcall(vehicle.getLastSpeed, vehicle)
        if ok and type(value) == "number" then
            -- getLastSpeed usually returns km/h in GIANTS vehicle code.
            return math.max(0.0, value)
        end
    end

    if type(vehicle.lastSpeedReal) == "number" then
        return math.max(0.0, vehicle.lastSpeedReal * 3600.0)
    end

    return 0.0
end


function WayPointGPS._clamp(value, minValue, maxValue)
    if value == nil then return minValue end
    if value < minValue then return minValue end
    if value > maxValue then return maxValue end
    return value
end

function WayPointGPS._quantizeDistanceMeters(meters)
    if meters == nil or meters ~= meters or meters == math.huge or meters < 0 then
        return nil
    end

    local step = 10
    if meters >= 1000 then
        step = 100
    elseif meters >= 500 then
        step = 50
    elseif meters >= 200 then
        step = 25
    elseif meters >= 100 then
        step = 10
    elseif meters >= 25 then
        step = 5
    else
        step = 1
    end

    local displayMeters = math.floor(meters / step) * step
    if displayMeters < step then displayMeters = step end
    return displayMeters
end

function WayPointGPS._formatTurnDistance(meters)
    local displayMeters = WayPointGPS._quantizeDistanceMeters(meters)
    if displayMeters == nil then return nil end
    return string.format("TURN %d m", displayMeters)
end

function WayPointGPS._formatTotalDistance(meters)
    local displayMeters = WayPointGPS._quantizeDistanceMeters(meters)
    if displayMeters == nil then return nil end
    return string.format("TOTAL %d m", displayMeters)
end

function WayPointGPS._getNextSharpTurnDistance(route)
    if route == nil or #route < 4 then return nil end

    local cumulative = {0}
    local total = 0.0
    for i = 2, #route do
        local a = route[i - 1]
        local b = route[i]
        local segLen = MathUtil.vector2Length((b.x or 0) - (a.x or 0), (b.z or 0) - (a.z or 0))
        total = total + segLen
        cumulative[i] = total
    end

    if total < 50 then return nil end

    local function pointAtDistance(distance)
        if distance <= 0 then
            local first = route[1]
            return first.x, first.z
        end
        if distance >= total then
            local last = route[#route]
            return last.x, last.z
        end

        local low, high = 2, #route
        while low <= high do
            local mid = math.floor((low + high) * 0.5)
            if (cumulative[mid] or 0) < distance then
                low = mid + 1
            else
                high = mid - 1
            end
        end

        local i = WayPointGPS._clamp(low, 2, #route)
        local a = route[i - 1]
        local b = route[i]
        local segStart = cumulative[i - 1] or 0
        local segEnd = cumulative[i] or segStart
        local segLen = math.max(0.001, segEnd - segStart)
        local t = WayPointGPS._clamp((distance - segStart) / segLen, 0.0, 1.0)
        return (a.x or 0) + ((b.x or 0) - (a.x or 0)) * t, (a.z or 0) + ((b.z or 0) - (a.z or 0)) * t
    end

    -- Scan only the useful near route. The display is quantized, so this can stay
    -- stable and cheap while still announcing long-distance turns such as 1500m/1400m.
    local maxScan = math.min(total - 55.0, 3200.0)
    if maxScan < 18.0 then return nil end

    for d = 18.0, maxScan, 10.0 do
        local x0, z0 = pointAtDistance(math.max(0.0, d - 18.0))
        local x1, z1 = pointAtDistance(d + 6.0)
        local x2, z2 = pointAtDistance(d + 52.0)

        if x0 ~= nil and x1 ~= nil and x2 ~= nil then
            local ax, az = x1 - x0, z1 - z0
            local bx, bz = x2 - x1, z2 - z1
            local alen = MathUtil.vector2Length(ax, az)
            local blen = MathUtil.vector2Length(bx, bz)

            if alen >= 8.0 and blen >= 12.0 then
                ax, az = ax / alen, az / alen
                bx, bz = bx / blen, bz / blen
                local dot = WayPointGPS._clamp(ax * bx + az * bz, -1.0, 1.0)
                local angle = math.deg(math.acos(dot))

                -- Match the static-arrow intent: do not count shallow road bends as turns.
                -- Only announce hard bends/intersections where the player needs to prepare.
                if angle >= 43.0 then
                    return math.max(0.0, d - 8.0)
                end
            end
        end
    end

    return nil
end

function WayPointGPS._getArrowUpdateInterval(speedKmh)
    if speedKmh >= 55.0 then
        return 0.95
    elseif speedKmh >= 35.0 then
        return 0.75
    elseif speedKmh >= 18.0 then
        return 0.55
    end

    return 0.35
end

function WayPointGPS._getDirectionArrowAnchor()
    -- Center-screen guidance, kept away from the minimap and speedometer.
    return 0.5, 0.76
end

function WayPointGPS._drawDirectionArrow(drawWPs, vehicle)
    if drawWPs == nil or #drawWPs < 2 or vehicle == nil then return end
    if WayPointGPS.directionArrowFailed == true or overlayFailed then return end

    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return end

    local fx, fz, rx, rz = WayPointGPS._getVehicleForward2D(vehicle)
    if fx == nil then return end

    -- Static guidance arrow: choose one of four pre-rotated arrow textures.
    -- The decision is intentionally throttled/debounced. At high road speed the route
    -- trim can briefly put the nearest lookahead behind the vehicle, which caused a
    -- fast REVERSE/STRAIGHT flicker. Keep rendering every frame, but only re-evaluate
    -- the direction a few times per second and require a new state to remain stable.
    local now = WayPointGPS.getMissionTimeSeconds()
    local speedKmh = WayPointGPS._getVehicleSpeedKmh(vehicle)

    if WayPointGPS.directionArrowStateLabel == nil then
        WayPointGPS.directionArrowStateLabel = "STRAIGHT"
        WayPointGPS.directionArrowStateTexture = ARROW_DDS_STRAIGHT
        WayPointGPS.directionArrowNextUpdateTime = -math.huge
    end

    if now >= (WayPointGPS.directionArrowNextUpdateTime or -math.huge) then
        WayPointGPS.directionArrowNextUpdateTime = now + WayPointGPS._getArrowUpdateInterval(speedKmh)

        -- GIANTS vehicle +X behaves like the left-side axis for this HUD test, so invert the side dot.
        local bestSide = 0.0
        local bestForward = 1.0
        local bestSharpness = 0.0
        local lookaheadDistances = {24.0, 44.0, 74.0}
        for _, dist in ipairs(lookaheadDistances) do
            local lookX, lookZ = WayPointGPS._getRouteLookaheadPoint(drawWPs, dist)
            if lookX ~= nil then
                local dx = lookX - vx
                local dz = lookZ - vz
                local routeLen = MathUtil.vector2Length(dx, dz)
                if routeLen >= 1.0 then
                    dx, dz = dx / routeLen, dz / routeLen
                    local forwardDot = dx * fx + dz * fz
                    local sideDot = -(dx * rx + dz * rz)
                    if forwardDot < -1.0 then forwardDot = -1.0 elseif forwardDot > 1.0 then forwardDot = 1.0 end
                    if sideDot < -1.0 then sideDot = -1.0 elseif sideDot > 1.0 then sideDot = 1.0 end

                    -- Sharpness favors a strong side angle plus loss of forward alignment.
                    -- This keeps shallow sweeping road curves as STRAIGHT and only calls turns at intersections/hard bends.
                    local sharpness = math.abs(sideDot) * math.max(0.0, 1.0 - forwardDot * 0.55)
                    if sharpness > bestSharpness then
                        bestSharpness = sharpness
                        bestSide = sideDot
                        bestForward = forwardDot
                    end
                end
            end
        end

        local absSide = math.abs(bestSide)
        local isSharpTurn = (absSide >= 0.58 and bestForward <= 0.78) or (absSide >= 0.76)

        local candidateLabel = "STRAIGHT"
        local candidateTexture = ARROW_DDS_STRAIGHT
        if bestForward <= -0.45 and absSide < 0.45 then
            candidateLabel = "REVERSE"
            candidateTexture = ARROW_DDS_REVERSE
        elseif isSharpTurn then
            if bestSide > 0 then
                candidateLabel = "RIGHT"
                candidateTexture = ARROW_DDS_RIGHT
            else
                candidateLabel = "LEFT"
                candidateTexture = ARROW_DDS_LEFT
            end
        end

        -- Never flash REVERSE while moving at road speed. A real reverse instruction should
        -- only appear when the vehicle is slow enough that the driver can act on it.
        if candidateLabel == "REVERSE" and speedKmh > 10.0 then
            candidateLabel = WayPointGPS.directionArrowStateLabel ~= "REVERSE" and WayPointGPS.directionArrowStateLabel or "STRAIGHT"
            candidateTexture = candidateLabel == "LEFT" and ARROW_DDS_LEFT
                or candidateLabel == "RIGHT" and ARROW_DDS_RIGHT
                or candidateLabel == "REVERSE" and ARROW_DDS_REVERSE
                or ARROW_DDS_STRAIGHT
        end

        WayPointGPS.directionArrowTurnDistance = WayPointGPS._getNextSharpTurnDistance(drawWPs)
        WayPointGPS.directionArrowTurnText = WayPointGPS._formatTurnDistance(WayPointGPS.directionArrowTurnDistance)
        WayPointGPS.directionArrowTotalDistance = WayPointGPS.getRouteLength(drawWPs)
        WayPointGPS.directionArrowTotalText = WayPointGPS._formatTotalDistance(WayPointGPS.directionArrowTotalDistance)

        local currentLabel = WayPointGPS.directionArrowStateLabel or "STRAIGHT"
        if candidateLabel == currentLabel then
            WayPointGPS.directionArrowPendingLabel = nil
            WayPointGPS.directionArrowPendingSince = -math.huge
            WayPointGPS.directionArrowStateTexture = candidateTexture
        else
            local settleTime = 0.25
            if candidateLabel == "REVERSE" then
                settleTime = 1.10
            elseif speedKmh >= 35.0 then
                settleTime = 0.55
            elseif speedKmh >= 18.0 then
                settleTime = 0.40
            end

            if WayPointGPS.directionArrowPendingLabel ~= candidateLabel then
                WayPointGPS.directionArrowPendingLabel = candidateLabel
                WayPointGPS.directionArrowPendingSince = now
            elseif now - (WayPointGPS.directionArrowPendingSince or now) >= settleTime then
                WayPointGPS.directionArrowStateLabel = candidateLabel
                WayPointGPS.directionArrowStateTexture = candidateTexture
                WayPointGPS.directionArrowPendingLabel = nil
                WayPointGPS.directionArrowPendingSince = -math.huge
            end
        end
    end

    local label = WayPointGPS.directionArrowStateLabel or "STRAIGHT"
    local arrowTexture = WayPointGPS.directionArrowStateTexture or ARROW_DDS_STRAIGHT

    if WayPointGPS.directionArrowOverlayIds == nil then
        WayPointGPS.directionArrowOverlayIds = {}
    end

    local arrowId = WayPointGPS.directionArrowOverlayIds[label]
    if arrowId == nil then
        arrowId = createImageOverlay(arrowTexture)
        if arrowId == nil or arrowId == 0 then
            WayPointGPS.directionArrowFailed = true
            Logging.warning("[WayPointGPS] createImageOverlay returned %s for '%s'.", tostring(arrowId), tostring(arrowTexture))
            return
        end
        WayPointGPS.directionArrowOverlayIds[label] = arrowId
    end

    local x, y = WayPointGPS._getDirectionArrowAnchor()
    local arrowH = 40 / g_screenHeight
    local arrowW = arrowH / g_screenAspectRatio

    local col = COLORS[cfg.mapColorIdx] or COLORS[1]
    setOverlayRotation(arrowId, 0, 0, 0)
    setOverlayColor(arrowId, col.r or 1, col.g or 1, col.b or 1, 1.0)
    renderOverlay(arrowId, x - arrowW * 0.5, y + (18 / g_screenHeight), arrowW, arrowH)

    local textSize = 13 / g_screenHeight
    local turnTextSize = 11 / g_screenHeight
    local totalTextSize = 10 / g_screenHeight
    local turnText = WayPointGPS.directionArrowTurnText
    local totalText = WayPointGPS.directionArrowTotalText

    if setTextBold ~= nil then setTextBold(true) end

    local textW = getTextWidth(textSize, label)
    local tx = x - textW * 0.5
    local ty = y - (12 / g_screenHeight)
    setTextColor(0, 0, 0, 0.85)
    renderText(tx + (1 / g_screenWidth), ty - (1 / g_screenHeight), textSize, label)
    setTextColor(1, 1, 1, 1.0)
    renderText(tx, ty, textSize, label)

    local nextLineY = y - (35 / g_screenHeight)
    if turnText ~= nil then
        local turnW = getTextWidth(turnTextSize, turnText)
        local turnX = x - turnW * 0.5
        setTextColor(0, 0, 0, 0.85)
        renderText(turnX + (1 / g_screenWidth), nextLineY - (1 / g_screenHeight), turnTextSize, turnText)
        setTextColor(1, 1, 1, 1.0)
        renderText(turnX, nextLineY, turnTextSize, turnText)
        nextLineY = nextLineY - (15 / g_screenHeight)
    end

    if totalText ~= nil then
        local totalW = getTextWidth(totalTextSize, totalText)
        local totalX = x - totalW * 0.5
        setTextColor(0, 0, 0, 0.85)
        renderText(totalX + (1 / g_screenWidth), nextLineY - (1 / g_screenHeight), totalTextSize, totalText)
        setTextColor(1, 1, 1, 1.0)
        renderText(totalX, nextLineY, totalTextSize, totalText)
    end

    setTextColor(1, 1, 1, 1)
    if setTextBold ~= nil then setTextBold(false) end
end

function WayPointGPS._worldToMenuMapPos(worldX, worldZ)
    if g_inGameMenu == nil then return nil, nil end
    local map = WayPointGPS._getMenuMapElement()
    local layout = WayPointGPS._getBestFullscreenMapLayout(map)
    if map == nil or layout == nil then return nil, nil end
    if map.worldCenterOffsetX == nil or map.worldCenterOffsetZ == nil or map.worldSizeX == nil or map.worldSizeZ == nil then return nil, nil end
    local mapX = (worldX + map.worldCenterOffsetX) / map.worldSizeX * 0.5 + 0.25
    local mapZ = (worldZ + map.worldCenterOffsetZ) / map.worldSizeZ * 0.5 + 0.25
    if mapX < 0.25 or mapX > 0.75 or mapZ < 0.25 or mapZ > 0.75 then return nil, nil end
    return layout:getMapObjectPosition(mapX, mapZ, 0, 0, 0, true)
end




function WayPointGPS._disableGiantsVisualsForVehicle(vehicle)
    -- GPS-only release build: do not alter base-game steering/AI visuals or vehicle state.
end

function WayPointGPS._disableGiantsVisualsForAllVehicles()
    -- GPS-only release build: no vehicle control cleanup needed.
end

function WayPointGPS.hasTarget()
    return WayPointGPS.target ~= nil
end

function WayPointGPS.toggleDisplay()
    -- The old action toggled WayPointGPS.isActive, which was not saved to XML.
    -- That could leave a player with <minimap enabled="true"/> but no visible route in the current save session.
    -- Use the same saved display flag as the settings menu instead.
    cfg.mapEnabled = not cfg.mapEnabled
    WayPointGPS._forceRuntimeDisplayState()
    WayPointGPS.saveSettings()
    WayPointGPS.logInfo("[WayPointGPS] Map route display %s.", cfg.mapEnabled and "ON" or "OFF")
end

function WayPointGPS.enableDisplayAndRepairRuntime(saveNow)
    cfg.mapEnabled = true
    cfg.arrowEnabled = true
    WayPointGPS._forceRuntimeDisplayState()
    WayPointGPS._clearRouteCache()
    WayPointGPS._resetDirectionArrowState()
    WayPointGPS._refreshTargetHotspot()
    if saveNow then
        WayPointGPS.saveSettings()
    end
    Logging.info("[WayPointGPS] HUD/runtime repaired: mapEnabled=%s arrowEnabled=%s isActive=%s target=%s waypoints=%s", tostring(cfg.mapEnabled), tostring(cfg.arrowEnabled), tostring(WayPointGPS.isActive), tostring(WayPointGPS.target ~= nil), tostring(WayPointGPS.getWaypointCount and WayPointGPS.getWaypointCount() or 0))
end

function WayPointGPS.consoleCommandRepairHud()
    WayPointGPS.enableDisplayAndRepairRuntime(true)
end

function WayPointGPS.consoleCommandStatus()
    local count = WayPointGPS.getWaypointCount and WayPointGPS.getWaypointCount() or 0
    local vehicle = WayPointGPS.getCurrentVehicle and WayPointGPS.getCurrentVehicle() or nil
    Logging.info("[WayPointGPS] status | isActive=%s mapEnabled=%s arrowEnabled=%s roadEnabled=%s target=%s waypoints=%s settings=%s globalActions=%s preciseHook=%s fallbackMouse=%s vehicle=%s", tostring(WayPointGPS.isActive), tostring(cfg ~= nil and cfg.mapEnabled), tostring(cfg ~= nil and cfg.arrowEnabled), tostring(cfg ~= nil and cfg.roadEnabled), tostring(WayPointGPS.target ~= nil), tostring(count), tostring(SETTINGS_FILE), tostring(WayPointGPS.globalActionEventCount), tostring(WayPointGPS.preciseMapClickHookInstalled), tostring(WayPointGPS.inGameMapMouseHookInstalled), tostring(vehicle ~= nil))
end

function WayPointGPS._clearRouteCache()
    WayPointGPS.routeWaypoints = nil
    WayPointGPS.fullRouteWaypoints = nil
    WayPointGPS._routeDrawGeneration = (WayPointGPS._routeDrawGeneration or 0) + 1
    WayPointGPS.lastRouteStartX = nil
    WayPointGPS.lastRouteStartZ = nil
    WayPointGPS.cachedResolveWaypoints = nil
    WayPointGPS.cachedResolveCurrentWp = nil
    WayPointGPS.cachedResolveVehicle = nil
    WayPointGPS.cachedResolveTime = -math.huge
end

function WayPointGPS._makeWaypoint(worldX, worldZ)
    if worldX == nil or worldZ == nil then return nil end
    local terrain = g_currentMission ~= nil and g_currentMission.terrainRootNode or nil
    local y = 0
    if terrain ~= nil then
        y = getTerrainHeightAtWorldPos(terrain, worldX, 0, worldZ)
    end
    return {x=worldX, y=y, z=worldZ}
end

function WayPointGPS._getWaypointList()
    if WayPointGPS.waypoints ~= nil and #WayPointGPS.waypoints > 0 then
        return WayPointGPS.waypoints
    end
    if WayPointGPS.target ~= nil then
        return {WayPointGPS.target}
    end
    return nil
end

function WayPointGPS._syncFinalTargetFromWaypoints()
    if WayPointGPS.waypoints ~= nil and #WayPointGPS.waypoints > 0 then
        WayPointGPS.target = WayPointGPS.waypoints[#WayPointGPS.waypoints]
    else
        WayPointGPS.target = nil
        WayPointGPS.waypoints = nil
    end
end

function WayPointGPS.getWaypointCount()
    local list = WayPointGPS._getWaypointList()
    return list ~= nil and #list or 0
end

function WayPointGPS._resetDirectionArrowState()
    WayPointGPS.directionArrowStateLabel = "STRAIGHT"
    WayPointGPS.directionArrowStateTexture = ARROW_DDS_STRAIGHT
    WayPointGPS.directionArrowNextUpdateTime = -math.huge
    WayPointGPS.directionArrowPendingLabel = nil
    WayPointGPS.directionArrowPendingSince = -math.huge
    WayPointGPS.directionArrowTurnDistance = nil
    WayPointGPS.directionArrowTurnText = nil
    WayPointGPS.directionArrowTotalDistance = nil
    WayPointGPS.directionArrowTotalText = nil
end

function WayPointGPS.clearTarget(showMessage)
    local activeVehicle = WayPointGPS.getCurrentVehicle and WayPointGPS.getCurrentVehicle() or nil
    if activeVehicle ~= nil and WayPointGPS.isAutopilotActive ~= nil and WayPointGPS.isAutopilotActive(activeVehicle) then
        WayPointGPS.setAutopilotActive(activeVehicle, false, "target cleared")
    end
    WayPointGPS.target = nil
    WayPointGPS.waypoints = nil
    WayPointGPS._clearRouteCache()
    WayPointGPS._removeTargetHotspot()
    WayPointGPS._resetDirectionArrowState()

    if showMessage then
        WayPointGPS.notify("wpgps_target_cleared")
    end
    WayPointGPS.logInfo("[WayPointGPS] Target cleared.")
end

function WayPointGPS.setTarget(worldX, worldZ)
    local wp = WayPointGPS._makeWaypoint(worldX, worldZ)
    if wp == nil then return end

    WayPointGPS.waypoints = {wp}
    WayPointGPS.target = wp
    WayPointGPS._clearRouteCache()
    WayPointGPS._resetDirectionArrowState()
    WayPointGPS.isActive = true

    WayPointGPS._disableGiantsVisualsForAllVehicles()

    local vehicle = WayPointGPS.getCurrentVehicle()
    if vehicle ~= nil then
        WayPointGPS.ensureRouteForVehicle(vehicle, true)
    end

    WayPointGPS._refreshTargetHotspot()
    WayPointGPS.notify("wpgps_target_set")
    WayPointGPS.logInfo("[WayPointGPS] Target set at x=%.1f z=%.1f", worldX, worldZ)
end

function WayPointGPS.addWaypoint(worldX, worldZ)
    local wp = WayPointGPS._makeWaypoint(worldX, worldZ)
    if wp == nil then return end

    local list = WayPointGPS._getWaypointList()
    if list == nil or #list == 0 then
        WayPointGPS.setTarget(worldX, worldZ)
        return
    end

    if WayPointGPS.waypoints == nil then
        WayPointGPS.waypoints = {}
        for i, item in ipairs(list) do
            WayPointGPS.waypoints[i] = item
        end
    end

    WayPointGPS.waypoints[#WayPointGPS.waypoints + 1] = wp
    WayPointGPS.target = wp
    WayPointGPS._clearRouteCache()
    WayPointGPS.isActive = true

    WayPointGPS._disableGiantsVisualsForAllVehicles()

    local vehicle = WayPointGPS.getCurrentVehicle()
    if vehicle ~= nil then
        WayPointGPS.ensureRouteForVehicle(vehicle, true)
    end

    WayPointGPS._refreshTargetHotspot()
    WayPointGPS.notify("wpgps_waypoint_added")
    WayPointGPS.logInfo("[WayPointGPS] Waypoint added #%d at x=%.1f z=%.1f", #WayPointGPS.waypoints, worldX, worldZ)
end

function WayPointGPS.removeLastWaypoint(showMessage)
    local list = WayPointGPS._getWaypointList()
    if list == nil or #list == 0 then
        if showMessage then
            WayPointGPS.notify("wpgps_no_target")
        end
        return false
    end

    if WayPointGPS.waypoints == nil then
        WayPointGPS.waypoints = {}
        for i, item in ipairs(list) do
            WayPointGPS.waypoints[i] = item
        end
    end

    local removed = table.remove(WayPointGPS.waypoints)
    if WayPointGPS.waypoints == nil or #WayPointGPS.waypoints == 0 then
        WayPointGPS.clearTarget(false)
        if showMessage then
            WayPointGPS.notify("wpgps_target_cleared")
        end
        if removed ~= nil then
            WayPointGPS.logInfo("[WayPointGPS] Last waypoint removed; route cleared.")
        end
        return true
    end

    WayPointGPS._syncFinalTargetFromWaypoints()
    WayPointGPS._clearRouteCache()
    WayPointGPS._disableGiantsVisualsForAllVehicles()

    local vehicle = WayPointGPS.getCurrentVehicle()
    if vehicle ~= nil then
        WayPointGPS.ensureRouteForVehicle(vehicle, true)
    end

    if showMessage then
        WayPointGPS.notify("wpgps_waypoint_removed")
    end
    WayPointGPS.logInfo("[WayPointGPS] Last waypoint removed; remaining=%d", #WayPointGPS.waypoints)
    return true
end

function WayPointGPS.handleMapWaypointClick(worldX, worldZ, sourceName)
    if WayPointGPS._isShiftDown() then
        local removed = WayPointGPS.removeLastWaypoint(true)
        WayPointGPS.logInfo("[WayPointGPS] SHIFT map click handled by %s removeLast=%s", tostring(sourceName), tostring(removed))
        return true
    end

    if WayPointGPS.hasTarget() then
        WayPointGPS.addWaypoint(worldX, worldZ)
        WayPointGPS.logInfo("[WayPointGPS] CTRL map click added waypoint by %s at x=%.1f z=%.1f", tostring(sourceName), worldX, worldZ)
    else
        WayPointGPS.setTarget(worldX, worldZ)
        WayPointGPS.logInfo("[WayPointGPS] CTRL map click handled by %s at x=%.1f z=%.1f", tostring(sourceName), worldX, worldZ)
    end
    return true
end

function WayPointGPS.buildRouteSegment(startX, startZ, targetX, targetZ)
    local directDist = MathUtil.vector2Length(targetX - startX, targetZ - startZ)
    local route, routeInfo = WayPointGPS.buildAIRoadGraphRoute(startX, startZ, targetX, targetZ)
    local usedAIRoadGraph = route ~= nil and #route >= 2
    local aiGraphFailedWithNoPath = route == nil and tostring(routeInfo):find("aiRoadGraph no") ~= nil
    if usedAIRoadGraph then
        local improvedRoute, connectorInfo = WayPointGPS.improveAIRoadEndpointConnectors(route, startX, startZ, targetX, targetZ)
        if improvedRoute ~= nil and #improvedRoute >= 2 then
            route = improvedRoute
            routeInfo = tostring(routeInfo) .. " | " .. tostring(connectorInfo)
        end
        local graph = WayPointGPS.aiRoadGraph
        if graph ~= nil and graph.isRouteGraph and WayPointGPS.routeHasUnsafeWaterCrossing(route, graph) then
            routeInfo = tostring(routeInfo) .. " | routeGraphRejectedUnsafeWater"
            route = nil
            usedAIRoadGraph = false
        end
        if graph ~= nil and graph.isRouteGraph and route ~= nil and #route >= 2 then
            local locked, routeQ, lockReason = WayPointGPS.routeGraphRouteLooksRoadLocked(route, directDist)
            if not locked then
                local fallbackRoute, fallbackInfo = WayPointGPS.buildRoadBiasedRoute(startX, startZ, targetX, targetZ, false)
                local fallbackSafe, fallbackQ = false, nil
                if fallbackRoute ~= nil and #fallbackRoute >= 2 then
                    fallbackSafe, fallbackQ = WayPointGPS.candidateKeepsRoads(fallbackRoute, directDist)
                end
                if fallbackSafe then
                    route = fallbackRoute
                    usedAIRoadGraph = false
                    routeInfo = tostring(routeInfo) .. " | routeGraphRoadLockFallback reason=" .. tostring(lockReason) .. " q[" .. WayPointGPS.formatRouteQuality(routeQ) .. "] fallback=" .. tostring(fallbackInfo) .. " fallbackQ[" .. WayPointGPS.formatRouteQuality(fallbackQ) .. "]"
                else
                    routeInfo = tostring(routeInfo) .. " | routeGraphRoadLockNoSafeFallback reason=" .. tostring(lockReason) .. " q[" .. WayPointGPS.formatRouteQuality(routeQ) .. "] fallback=" .. tostring(fallbackInfo) .. " fallbackQ[" .. WayPointGPS.formatRouteQuality(fallbackQ) .. "]"
                end
            else
                routeInfo = tostring(routeInfo) .. " | routeGraphAcceptedRoadLocked q[" .. WayPointGPS.formatRouteQuality(routeQ) .. "]"
            end
        end
    end

    if not usedAIRoadGraph then
        local fallbackRoute, fallbackInfo = WayPointGPS.buildRoadBiasedRoute(startX, startZ, targetX, targetZ, false)
        if fallbackRoute ~= nil and WayPointGPS.routeHasWaterCrossing(fallbackRoute) then
            route = nil
            routeInfo = tostring(routeInfo) .. " | terrainFallbackRejectedWater=" .. tostring(fallbackInfo)
        else
            route = fallbackRoute
            if aiGraphFailedWithNoPath then
                routeInfo = tostring(routeInfo) .. " | aiGraphNoPathRoadBiasedFallback=" .. tostring(fallbackInfo)
            else
                routeInfo = tostring(routeInfo) .. " | terrainFallback=" .. tostring(fallbackInfo)
            end
        end
    end

    if (not usedAIRoadGraph) and route ~= nil and #route >= 2 then
        local strictLen = WayPointGPS.getRouteLength(route)
        local bestRoute = route
        local bestInfo = tostring(routeInfo)
        local bestLen = strictLen
        local strictQuality = WayPointGPS.getRouteRoadQuality(route)
        local bestQuality = strictQuality
        local triedInfo = string.format(" strictLen=%.0f direct=%.0f strictQ[%s]", strictLen, directDist, WayPointGPS.formatRouteQuality(strictQuality))

        triedInfo = triedInfo .. " roadLock=on relaxedSkipped connectorSkipped"

        route = bestRoute
        routeInfo = tostring(bestInfo) .. " detourCheck" .. triedInfo .. string.format(" chosenLen=%.0f chosenQ[%s]", bestLen, WayPointGPS.formatRouteQuality(bestQuality))
    end

    if route == nil or #route < 2 then
        local recoveryRoute, recoveryInfo = WayPointGPS.buildRoadBiasedRoute(startX, startZ, targetX, targetZ, "connector")
        local recoverySafe, recoveryQuality = false, nil
        if recoveryRoute ~= nil and #recoveryRoute >= 2 then
            recoverySafe, recoveryQuality = WayPointGPS.candidateKeepsRoads(recoveryRoute, directDist)
        end

        if recoverySafe or (aiGraphFailedWithNoPath and recoveryRoute ~= nil and #recoveryRoute >= 2) then
            route = recoveryRoute
            routeInfo = (aiGraphFailedWithNoPath and "recoveryConnectorAfterNoPath after " or "recoveryConnector after ") .. tostring(routeInfo) .. " | " .. tostring(recoveryInfo) .. " | recoveryQ[" .. WayPointGPS.formatRouteQuality(recoveryQuality) .. "]"
        else
            local sy = WayPointGPS.getGroundHeight(startX, startZ)
            route = {{x=startX, y=sy, z=startZ}, {x=startX, y=sy, z=startZ}}
            routeInfo = "noSafeRouteRoadLock after " .. tostring(routeInfo) .. " | connector=" .. tostring(recoveryInfo) .. " | recoveryQ[" .. WayPointGPS.formatRouteQuality(recoveryQuality) .. "]"
        end
    end

    return route, routeInfo
end

function WayPointGPS.ensureRouteForVehicle(vehicle, force)
    local targets = WayPointGPS._getWaypointList()
    if targets == nil or #targets == 0 then return nil end

    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return nil end

    local shouldRecalc = force or WayPointGPS.routeWaypoints == nil

    -- Do not recalculate just because the vehicle moved away from the original
    -- route start. That caused a full road search every short distance and could
    -- lag the game. A finished route stays valid; we only run a slow off-route
    -- check every few seconds and rebuild if the vehicle is truly far from it.
    local now = WayPointGPS.getMissionTimeSeconds()
    if not shouldRecalc and WayPointGPS.routeWaypoints ~= nil and now - WayPointGPS.lastRecalcTime >= RECALC_INTERVAL then
        WayPointGPS.lastRecalcTime = now
        local _, dist = WayPointGPS._findClosestWaypoint(WayPointGPS.routeWaypoints, vx, vz)
        if dist > DEVIATION_THRESH then
            shouldRecalc = true
            WayPointGPS.logInfo("[WayPointGPS] Off-route by %.1fm, rebuilding route.", dist)
        end
    end

    if shouldRecalc then
        local combinedRoute = {}
        local routeInfoParts = {}
        local sx, sz = vx, vz
        local stopCount = 1

        -- Build only the active/first waypoint for live steering and minimap.
        -- Earlier builds combined all waypoint legs into one active route, which could
        -- make overlapping/opposite waypoint legs fight each other and jump between
        -- directions while driving. The next leg is built only after the first
        -- waypoint is reached and removed from the queue.
        for stopIndex = 1, stopCount do
            local stop = targets[stopIndex]
            local route, routeInfo = WayPointGPS.buildRouteSegment(sx, sz, stop.x, stop.z)
            routeInfoParts[#routeInfoParts + 1] = string.format("stop%d[%s]", stopIndex, tostring(routeInfo))

            if route ~= nil and #route >= 2 then
                for i, point in ipairs(route) do
                    local addPoint = true
                    if #combinedRoute > 0 and i == 1 then
                        local last = combinedRoute[#combinedRoute]
                        if MathUtil.vector2Length((point.x or 0) - (last.x or 0), (point.z or 0) - (last.z or 0)) < 1.0 then
                            addPoint = false
                        end
                    end
                    if addPoint then
                        combinedRoute[#combinedRoute + 1] = point
                    end
                end
            end

            if tostring(routeInfo):find("noSafe") ~= nil then
                WayPointGPS.logInfo("[WayPointGPS] Multi-waypoint routing stopped at stop %d because no safe segment was found.", stopIndex)
                break
            end

            sx = stop.x
            sz = stop.z
        end

        if #combinedRoute < 2 then
            local sy = WayPointGPS.getGroundHeight(vx, vz)
            combinedRoute = {{x=vx, y=sy, z=vz}, {x=vx, y=sy, z=vz}}
        end

        WayPointGPS.routeWaypoints = combinedRoute
        WayPointGPS.lastRouteStartX = vx
        WayPointGPS.lastRouteStartZ = vz
        WayPointGPS.lastRecalcTime = now
        WayPointGPS.logInfo("[WayPointGPS] Route built with %d waypoints through %d stop(s) (%s).", #WayPointGPS.routeWaypoints, stopCount, table.concat(routeInfoParts, " | "))
    end

    return WayPointGPS.routeWaypoints
end


function WayPointGPS.ensureFullRouteForVehicle(vehicle, force)
    local targets = WayPointGPS._getWaypointList()
    if targets == nil or #targets == 0 then return nil end

    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return nil end

    if (not force) and WayPointGPS.fullRouteWaypoints ~= nil then
        return WayPointGPS.fullRouteWaypoints
    end

    local combinedRoute = {}
    local sx, sz = vx, vz
    for stopIndex, stop in ipairs(targets) do
        local route = WayPointGPS.buildRouteSegment(sx, sz, stop.x, stop.z)
        if route ~= nil and #route >= 2 then
            for i, point in ipairs(route) do
                local addPoint = true
                if #combinedRoute > 0 and i == 1 then
                    local last = combinedRoute[#combinedRoute]
                    if MathUtil.vector2Length((point.x or 0) - (last.x or 0), (point.z or 0) - (last.z or 0)) < 1.0 then
                        addPoint = false
                    end
                end
                if addPoint then
                    combinedRoute[#combinedRoute + 1] = point
                end
            end
        end
        sx = stop.x
        sz = stop.z
    end

    if #combinedRoute >= 2 then
        WayPointGPS.fullRouteWaypoints = combinedRoute
    else
        WayPointGPS.fullRouteWaypoints = nil
    end
    return WayPointGPS.fullRouteWaypoints
end

function WayPointGPS.updateTargetReached(vehicle)
    local targets = WayPointGPS._getWaypointList()
    if targets == nil or #targets == 0 then return false end
    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return false end

    local nextStop = targets[1]
    local dist = MathUtil.vector2Length(nextStop.x - vx, nextStop.z - vz)
    if dist <= TARGET_REACHED_DISTANCE then
        if WayPointGPS.waypoints == nil then
            WayPointGPS.waypoints = {}
            for i, item in ipairs(targets) do
                WayPointGPS.waypoints[i] = item
            end
        end

        table.remove(WayPointGPS.waypoints, 1)
        if #WayPointGPS.waypoints == 0 then
            WayPointGPS.clearTarget(false)
            WayPointGPS.notify("wpgps_target_reached")
            return true
        end

        WayPointGPS._syncFinalTargetFromWaypoints()
        WayPointGPS._clearRouteCache()
        WayPointGPS.notify("wpgps_target_reached")
        WayPointGPS.ensureRouteForVehicle(vehicle, true)
        WayPointGPS.logInfo("[WayPointGPS] Waypoint reached; remaining=%d", #WayPointGPS.waypoints)
        return true
    end

    return false
end

function WayPointGPS.getRightLaneSteeringPoint(WPs, index)
    if WPs == nil or index == nil then return nil, nil, nil end

    local p = WPs[index]
    if p == nil then return nil, nil, nil end

    -- Keep the drawn map route centered on the road, but steer toward the
    -- right-hand lane/side of that route. This makes steering assist behave more
    -- like road traffic while preserving the clean centered route line on the PDA.
    if #WPs < 2 then
        local y = p.y or WayPointGPS.getGroundHeight(p.x, p.z)
        return p.x, y + ROAD_Y_OFFSET, p.z
    end

    local prev = WPs[index - 1] or p
    local next = WPs[index + 1] or p
    if index <= 1 then
        next = WPs[2] or p
    elseif index >= #WPs then
        prev = WPs[#WPs - 1] or p
    end

    local dx = (next.x or p.x) - (prev.x or p.x)
    local dz = (next.z or p.z) - (prev.z or p.z)
    local len = MathUtil.vector2Length(dx, dz)
    if len < 0.001 then
        local y = p.y or WayPointGPS.getGroundHeight(p.x, p.z)
        return p.x, y + ROAD_Y_OFFSET, p.z
    end

    local offset = WayPointGPS.steeringRightLaneOffset or 0.75
    if WayPointGPS.aiRoadGraph ~= nil and WayPointGPS.aiRoadGraph.usesLaneCenters then
        -- Bundled WayPoint GPS route waypoints already follow the right-hand lane, so do
        -- not push the steering target farther right a second time.
        offset = 0.0
    end
    if p.manualRoute == true or (prev ~= nil and prev.manualRoute == true) or (next ~= nil and next.manualRoute == true) then
        -- Manually driven routes are already recorded from the vehicle's actual path.
        -- Do not shift the steering assist target sideways again, otherwise the assist
        -- node appears too far left/right from the driven line.
        offset = 0.0
    end
    local trafficSide = WayPointGPS.driveSide or WPGPS_DRIVE_SIDE or 1
    if trafficSide ~= -1 then trafficSide = 1 end
    local x = (p.x or 0) + (dz / len) * offset * trafficSide
    local z = (p.z or 0) - (dx / len) * offset * trafficSide
    return x, WayPointGPS.getGroundHeight(x, z) + ROAD_Y_OFFSET, z
end

function WayPointGPS.getRouteTurnAngle(WPs, index)
    if WPs == nil or index == nil or index <= 1 or index >= #WPs then return 0.0 end
    local a = WPs[index - 1]
    local b = WPs[index]
    local c = WPs[index + 1]
    if a == nil or b == nil or c == nil then return 0.0 end
    local ax = (b.x or 0) - (a.x or 0)
    local az = (b.z or 0) - (a.z or 0)
    local cx = (c.x or 0) - (b.x or 0)
    local cz = (c.z or 0) - (b.z or 0)
    local al = MathUtil.vector2Length(ax, az)
    local cl = MathUtil.vector2Length(cx, cz)
    if al < 1.0 or cl < 1.0 then return 0.0 end
    local dot = (ax * cx + az * cz) / (al * cl)
    if dot < -1.0 then dot = -1.0 elseif dot > 1.0 then dot = 1.0 end
    return math.deg(math.acos(dot))
end


function WayPointGPS.getUpcomingTurnSeverity(WPs, currentWp, scanDistance)
    if WPs == nil or currentWp == nil then return 0.0 end
    local maxAngle = 0.0
    local accumulated = 0.0
    local maxScanDistance = scanDistance or WPGPS_TURN_SCAN_DISTANCE or 90.0
    local lastWp = WPs[currentWp]
    if lastWp == nil then return 0.0 end

    for i = currentWp + 1, #WPs do
        local wp = WPs[i]
        if wp == nil then break end

        accumulated = accumulated + MathUtil.vector2Length((wp.x or 0) - (lastWp.x or 0), (wp.z or 0) - (lastWp.z or 0))
        local angle = WayPointGPS.getRouteTurnAngle(WPs, i) or 0.0
        if angle > maxAngle then maxAngle = angle end
        if accumulated >= maxScanDistance then
            break
        end
        lastWp = wp
    end
    return maxAngle
end

function WayPointGPS.getSuggestedTurnSpeedKmh(vehicle)
    local WPs = WayPointGPS.ensureRouteForVehicle(vehicle, false)
    if WPs == nil or #WPs < 3 then return nil end
    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return nil end
    local currentWp = WayPointGPS._findRouteProgressWaypoint(WPs, vehicle, vx, vz)
    local angle = WayPointGPS.getUpcomingTurnSeverity(WPs, currentWp, WPGPS_TURN_SCAN_DISTANCE)

    if angle >= 80 then return WPGPS_TURN_SPEED_HARD or 12.0 end
    if angle >= 55 then return WPGPS_TURN_SPEED_MEDIUM or 20.0 end
    if angle >= 30 then return WPGPS_TURN_SPEED_SOFT or 30.0 end
    return WPGPS_TURN_SPEED_STRAIGHT
end

function WayPointGPS.getForwardCollisionSpeedKmh(vehicle)
    -- Lightweight collision/obstacle pre-scan for GPS cruise assist.
    -- This is intentionally conservative and safe: it looks for vehicles/implements
    -- in a narrow corridor ahead of the current vehicle and lowers cruise speed
    -- before the steering assist drives into them. Static object raycast support
    -- varies by map/mod, so this build starts with vehicle/implement detection.
    if vehicle == nil or g_currentMission == nil or g_currentMission.vehicleSystem == nil then return nil end
    local node = nil
    if type(vehicle.getAISteeringNode) == "function" then
        node = vehicle:getAISteeringNode()
    end
    node = node or vehicle.rootNode or (vehicle.components and vehicle.components[1] and vehicle.components[1].node)
    if node == nil then return nil end

    local ox, oy, oz = getWorldTranslation(node)
    if ox == nil then return nil end
    local fx, fy, fz = localDirectionToWorld(node, 0, 0, 1)
    local fl = MathUtil.vector2Length(fx or 0, fz or 0)
    if fl < 0.001 then return nil end
    fx, fz = fx / fl, fz / fl

    local scanDist = WPGPS_COLLISION_SCAN_DISTANCE or 18.0
    local halfWidth = WPGPS_COLLISION_LATERAL_WIDTH or 4.0
    for _, other in pairs(g_currentMission.vehicleSystem.vehicles) do
        if other ~= nil and other ~= vehicle and other.rootNode ~= nil then
            local x, _, z = getWorldTranslation(other.rootNode)
            if x ~= nil then
                local dx = x - ox
                local dz = z - oz
                local forward = dx * fx + dz * fz
                if forward > 0 and forward <= scanDist then
                    local lateral = math.abs(dx * (-fz) + dz * fx)
                    if lateral <= halfWidth then
                        return WPGPS_COLLISION_SLOW_SPEED or 6.0
                    end
                end
            end
        end
    end
    return nil
end

function WayPointGPS.getSuggestedCruiseSpeedKmh(vehicle)
    local turnSpeed = nil
    if WayPointGPS.getSuggestedTurnSpeedKmh ~= nil then
        turnSpeed = WayPointGPS.getSuggestedTurnSpeedKmh(vehicle)
    end
    local collisionSpeed = nil
    if WayPointGPS.getForwardCollisionSpeedKmh ~= nil then
        collisionSpeed = WayPointGPS.getForwardCollisionSpeedKmh(vehicle)
    end

    if turnSpeed ~= nil and collisionSpeed ~= nil then
        return math.min(turnSpeed, collisionSpeed), "turn+collision"
    elseif collisionSpeed ~= nil then
        return collisionSpeed, "collision"
    elseif turnSpeed ~= nil then
        return turnSpeed, "turn"
    end
    return nil, nil
end


function WayPointGPS.getDistanceToNextRouteTurn(WPs, currentWp, minAngle, maxDistance)
    if WPs == nil or currentWp == nil then return nil, nil, 0.0 end
    local accumulated = 0.0
    local lastWp = WPs[currentWp]
    if lastWp == nil then return nil, nil, 0.0 end
    local angleLimit = minAngle or STEERING_SHARP_TURN_ANGLE or 42.0
    local distanceLimit = maxDistance or 35.0

    for i = currentWp + 1, #WPs - 1 do
        local wp = WPs[i]
        if wp == nil then break end
        accumulated = accumulated + MathUtil.vector2Length((wp.x or 0) - (lastWp.x or 0), (wp.z or 0) - (lastWp.z or 0))
        local angle = WayPointGPS.getRouteTurnAngle(WPs, i) or 0.0
        if angle >= angleLimit then
            return i, accumulated, angle
        end
        if accumulated >= distanceLimit then
            break
        end
        lastWp = wp
    end

    return nil, nil, 0.0
end

function WayPointGPS.getCappedSteeringPointBeforeTurn(WPs, currentWp)
    -- Steering assist should not look through a junction. If the next bend/intersection
    -- is inside the lookahead window, aim at the corner first and only move onto the
    -- outgoing road after route progress has actually passed the corner. This prevents
    -- the "turn down the side road / turn around" snap that happens when the nearest
    -- branch node is close but not the active route direction.
    local turnIdx, distToTurn, turnAngle = WayPointGPS.getDistanceToNextRouteTurn(WPs, currentWp, STEERING_SHARP_TURN_ANGLE, (STEERING_LOOKAHEAD or 12.0) + 18.0)
    if turnIdx ~= nil and distToTurn ~= nil then
        if distToTurn <= (STEERING_LOOKAHEAD or 12.0) + 8.0 then
            return WayPointGPS.getRightLaneSteeringPoint(WPs, turnIdx), turnIdx, distToTurn, turnAngle
        end
    end
    return nil, nil, nil, nil
end

function WayPointGPS.getSteeringLookaheadPoint(vehicle)
    local WPs = WayPointGPS.ensureRouteForVehicle(vehicle, false)
    if WPs == nil or #WPs == 0 then return nil, nil, nil end

    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return nil, nil, nil end

    local currentWp = WayPointGPS._findRouteProgressWaypoint(WPs, vehicle, vx, vz)
    local accumulated = 0
    local lastWp = WPs[currentWp]
    if lastWp == nil then return nil, nil, nil end

    -- First, hard-cap steering at the next intersection/turn. The route line can still
    -- show the full destination path, but the live steering target is not allowed to
    -- jump onto the outgoing branch until the vehicle has reached/passed the junction.
    local ix, iy, iz = WayPointGPS.getCappedSteeringPointBeforeTurn(WPs, currentWp)
    if ix ~= nil then
        return ix, iy, iz
    end

    for i = currentWp + 1, #WPs do
        local wp = WPs[i]
        if wp == nil then break end
        accumulated = accumulated + MathUtil.vector2Length((wp.x or 0) - (lastWp.x or 0), (wp.z or 0) - (lastWp.z or 0))

        -- At any meaningful turn, aim at the turn point first. This removes the
        -- intersection snap caused by looking across a junction toward a farther node.
        if accumulated >= (STEERING_SHARP_TURN_MIN_AHEAD or 2.5) and (WayPointGPS.getRouteTurnAngle(WPs, i) or 0.0) >= (STEERING_SHARP_TURN_ANGLE or 42.0) then
            return WayPointGPS.getRightLaneSteeringPoint(WPs, i)
        end

        if accumulated >= (STEERING_LOOKAHEAD or 12.0) then
            return WayPointGPS.getRightLaneSteeringPoint(WPs, i)
        end
        lastWp = wp
    end

    return WayPointGPS.getRightLaneSteeringPoint(WPs, #WPs)
end

function WayPointGPS._resolveRoute(vehicle, force)
    if WayPointGPS.target == nil then return nil, nil, nil, nil end

    local now = WayPointGPS.getMissionTimeSeconds()
    if not force
        and WayPointGPS.cachedResolveVehicle == vehicle
        and WayPointGPS.cachedResolveWaypoints ~= nil
        and now - WayPointGPS.cachedResolveTime < UI_ROUTE_RESOLVE_INTERVAL then
        return WayPointGPS.cachedResolveWaypoints, WayPointGPS.cachedResolveCurrentWp, nil, nil
    end

    local WPs = WayPointGPS.ensureRouteForVehicle(vehicle, force == true)
    if WPs == nil or #WPs == 0 then return nil, nil, nil, nil end
    local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    if vx == nil then return nil, nil, nil, nil end

    local currentWp = WayPointGPS._findRouteProgressWaypoint(WPs, vehicle, vx, vz)
    WayPointGPS.cachedResolveVehicle = vehicle
    WayPointGPS.cachedResolveWaypoints = WPs
    WayPointGPS.cachedResolveCurrentWp = currentWp
    WayPointGPS.cachedResolveTime = now

    return WPs, currentWp, vx, vz
end

function WayPointGPS._makeRemainingRoute(WPs, currentWp, vehicle)
    -- Return only the part of the active GPS route that is still ahead of the player.
    -- This is used by the ESC map so completed line sections disappear while driving.
    if WPs == nil or #WPs < 2 then return WPs end
    currentWp = currentWp or 1
    if currentWp < 1 then currentWp = 1 end
    if currentWp >= #WPs then return {WPs[#WPs]} end

    local vx, vz = nil, nil
    if vehicle ~= nil then
        vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
    end

    local remaining = {}
    if vx ~= nil and vz ~= nil then
        local y = WayPointGPS.getGroundHeight(vx, vz) + ROAD_Y_OFFSET
        remaining[#remaining + 1] = {x=vx, y=y, z=vz}
        for i = currentWp + 1, #WPs do
            remaining[#remaining + 1] = WPs[i]
        end
    else
        for i = currentWp, #WPs do
            remaining[#remaining + 1] = WPs[i]
        end
    end

    if #remaining < 2 then
        return WPs
    end
    return remaining
end

function WayPointGPS._drawRoute3D(WPs, currentWp)
    -- Maps-only build: disabled intentionally. Do not draw debug/world/ground guidance.
    return
end

function WayPointGPS._clipLineToRect(x1, y1, x2, y2, rect)
    if rect == nil then return x1, y1, x2, y2 end
    local xmin, ymin, xmax, ymax = rect.xMin, rect.yMin, rect.xMax, rect.yMax
    if xmin == nil or ymin == nil or xmax == nil or ymax == nil then return x1, y1, x2, y2 end

    local dx = x2 - x1
    local dy = y2 - y1
    local u1, u2 = 0.0, 1.0

    local function test(p, q)
        if p == 0 then
            return q >= 0
        end
        local r = q / p
        if p < 0 then
            if r > u2 then return false end
            if r > u1 then u1 = r end
        else
            if r < u1 then return false end
            if r < u2 then u2 = r end
        end
        return true
    end

    if not test(-dx, x1 - xmin) then return nil end
    if not test( dx, xmax - x1) then return nil end
    if not test(-dy, y1 - ymin) then return nil end
    if not test( dy, ymax - y1) then return nil end

    return x1 + u1 * dx, y1 + u1 * dy, x1 + u2 * dx, y1 + u2 * dy
end

function WayPointGPS._getMenuMapClipRect()
    -- Our route line overlay is not automatically clipped by the FS25 ESC map.
    -- Clip manually to the visible map panel so panning/zooming the map does not
    -- draw the GPS line over the left filter list, side HUD, top bar, or bottom controls.
    if not WayPointGPS._isMenuMapPageActive() then return nil end

    local map = WayPointGPS._getMenuMapElement()
    local xMin, yMin, xMax, yMax = nil, nil, nil, nil

    local function absorb(element)
        if element == nil or element.layout == nil then return end
        local layout = element.layout
        if layout.absPosition == nil or layout.absSize == nil then return end
        local ax = layout.absPosition[1]
        local ay = layout.absPosition[2]
        local sx = layout.absSize[1]
        local sy = layout.absSize[2]
        if ax == nil or ay == nil or sx == nil or sy == nil or sx <= 0.05 or sy <= 0.05 then return end
        local bx = ax + sx
        local by = ay + sy
        if xMin == nil then
            xMin, yMin, xMax, yMax = ax, ay, bx, by
        else
            -- Intersect with other visible map-frame bounds when available.
            xMin = math.max(xMin, ax)
            yMin = math.max(yMin, ay)
            xMax = math.min(xMax, bx)
            yMax = math.min(yMax, by)
        end
    end

    absorb(map)
    if g_inGameMenu ~= nil then
        absorb(g_inGameMenu.mapFrame)
        absorb(g_inGameMenu.ingameMapFrame)
        absorb(g_inGameMenu.inGameMapFrame)
        absorb(g_inGameMenu.ingameMenuMapFrame)
        absorb(g_inGameMenu.pageMap)
        absorb(g_inGameMenu.pageMapOverview)
    end

    if xMin == nil or yMin == nil or xMax == nil or yMax == nil or xMax <= xMin or yMax <= yMin then
        xMin, yMin, xMax, yMax = 0.30, 0.055, 0.995, 0.955
    end

    -- Some FS25 map elements report a full-screen backing layout while the visible map
    -- is clipped by the menu HUD. Keep a conservative left/bottom/top guard in that case.
    if xMin < 0.20 then xMin = 0.30 end
    if yMin < 0.03 then yMin = 0.055 end
    if xMax > 0.995 then xMax = 0.995 end
    if yMax > 0.965 then yMax = 0.965 end

    return {xMin=xMin, yMin=yMin, xMax=xMax, yMax=yMax}
end

function WayPointGPS._drawRoute2D(WPs, currentWp, lookaheadDist, posFunc, clipRect)
    if overlayFailed then return end
    if WayPointGPS.overlayId == nil then
        local id = createImageOverlay(WHITE_DDS)
        if id == nil or id == 0 then
            overlayFailed = true
            Logging.warning("[WayPointGPS] createImageOverlay returned %s for '%s'.", tostring(id), WHITE_DDS)
            return
        end
        WayPointGPS.overlayId = id
    end

    local col = COLORS[cfg.mapColorIdx]
    local thickness = MAP_THICK[cfg.mapThickIdx] / g_screenHeight
    local lastWp = nil
    local drawnDist = 0.0

    setOverlayColor(WayPointGPS.overlayId, col.r, col.g, col.b, 1.0)

    for index, wp in ipairs(WPs) do
        if lastWp ~= nil and index >= currentWp then
            local segWorld = MathUtil.vector2Length(wp.x - lastWp.x, wp.z - lastWp.z)
            drawnDist = drawnDist + segWorld
            if drawnDist > lookaheadDist then break end

            local startX, startY = posFunc(lastWp.x, lastWp.z)
            local endX, endY = posFunc(wp.x, wp.z)

            if startX ~= nil and endX ~= nil then
                if clipRect ~= nil then
                    startX, startY, endX, endY = WayPointGPS._clipLineToRect(startX, startY, endX, endY, clipRect)
                end
                if startX ~= nil and endX ~= nil then
                    local dx2D = endX - startX
                    local dy2D = (endY - startY) / g_screenAspectRatio
                    local segLen = MathUtil.vector2Length(dx2D, dy2D)
                    if segLen > 0.0001 then
                        local rotation = math.atan2(dy2D, dx2D)
                        setOverlayRotation(WayPointGPS.overlayId, rotation, 0, 0)
                        renderOverlay(WayPointGPS.overlayId, startX, startY, segLen, thickness)
                        setOverlayRotation(WayPointGPS.overlayId, 0, 0, 0)
                    end
                end
            end
        end
        lastWp = wp
    end
end

function WayPointGPS._getWaypointPinOverlayPath()
    local color = COLORS[cfg.pinColorIdx or 5]
    if color ~= nil and color.pin ~= nil then
        return MOD_DIRECTORY .. color.pin
    end
    return PIN_DDS
end

function WayPointGPS._drawTargetMarker(posFunc, clipRect)
    if overlayFailed or WayPointGPS.target == nil then return end

    local pinPath = WayPointGPS._getWaypointPinOverlayPath()
    if WayPointGPS.targetOverlayId ~= nil and WayPointGPS.targetOverlayPath ~= pinPath then
        pcall(delete, WayPointGPS.targetOverlayId)
        WayPointGPS.targetOverlayId = nil
        WayPointGPS.targetOverlayPath = nil
    end

    if WayPointGPS.targetOverlayId == nil then
        local id = createImageOverlay(pinPath)
        if id == nil or id == 0 then
            overlayFailed = true
            Logging.warning("[WayPointGPS] createImageOverlay returned %s for '%s'.", tostring(id), pinPath)
            return
        end
        WayPointGPS.targetOverlayId = id
        WayPointGPS.targetOverlayPath = pinPath
    end

    local list = WayPointGPS._getWaypointList()
    if list == nil or #list == 0 then return end

    -- Clean GPS pin marker. The waypoint pin has its own color setting so it can
    -- stay distinct from other GPS mods and from the route line.
    local height = 30 / g_screenHeight
    local width = height / g_screenAspectRatio
    setOverlayColor(WayPointGPS.targetOverlayId, 1, 1, 1, 1.0)
    setOverlayRotation(WayPointGPS.targetOverlayId, 0, 0, 0)

    for _, wp in ipairs(list) do
        local x, y = posFunc(wp.x, wp.z)
        if x ~= nil then
            local visible = true
            if clipRect ~= nil then
                visible = x >= clipRect.xMin and x <= clipRect.xMax and y >= clipRect.yMin and y <= clipRect.yMax
            end
            if visible then
                -- Anchor the marker by the bottom point of the pin, not by the icon center/top.
                -- The DDS has a small transparent pad below the tip, so keep a tiny offset.
                renderOverlay(WayPointGPS.targetOverlayId, x - width * 0.5, y - height * 0.09, width, height)
            end
        end
    end
end

function WayPointGPS.toggleArrowDisplay()
    cfg.arrowEnabled = not cfg.arrowEnabled
    WayPointGPS._resetDirectionArrowState()
    WayPointGPS.saveSettings()
end

function WayPointGPS.cycleRouteColor()
    cfg.mapColorIdx = (cfg.mapColorIdx % #COLORS) + 1
    cfg.roadColorIdx = cfg.mapColorIdx
    WayPointGPS.saveSettings()

    -- No right-side notification spam in the GPS-only release.
end

function WayPointGPS.drawHUD()
    WayPointGPS._forceRuntimeDisplayState()
    if WayPointGPS.target == nil then return end
    if g_inGameMenu ~= nil and g_inGameMenu.isOpen then return end
    if settingsOpen then return end
    if g_gui ~= nil and g_gui.currentGui ~= nil then return end

    local vehicle = WayPointGPS.getCurrentVehicle()
    if vehicle == nil then return end

    local now = WayPointGPS.getMissionTimeSeconds()
    if now - WayPointGPS.lastTargetReachedCheckTime >= TARGET_CHECK_INTERVAL then
        WayPointGPS.lastTargetReachedCheckTime = now
        WayPointGPS._disableGiantsVisualsForVehicle(vehicle)
        WayPointGPS.updateTargetReached(vehicle)
    end

    local WPs, currentWp = WayPointGPS._resolveRoute(vehicle, false)

    if not cfg.mapEnabled then
        WayPointGPS._drawDisplayOffIndicator()
        return
    end

    if cfg.mapEnabled then
        local ingameMap = g_currentMission.hud ~= nil and g_currentMission.hud.ingameMap or nil
        local minimapState = ingameMap ~= nil and (ingameMap.state or ingameMap.stateIndex or 0) or 0
        local lookahead = minimapState == 4 and math.huge or MAP_LOOK[cfg.mapLookIdx]
        -- Drawing a full long WayPoint GPS route on the minimap every frame can cost
        -- more than the route calculation itself on large maps. Keep full drawing
        -- for the ESC map, but cap the live minimap to a useful forward window.
        if lookahead == math.huge and minimapState ~= 4 then
            lookahead = MINIMAP_FULL_DRAW_CAP
        end
        if WPs ~= nil and currentWp ~= nil then
            local drawWPs = WayPointGPS._makeRemainingRoute(WPs, currentWp, vehicle)
            WayPointGPS._drawRoute2D(drawWPs, 1, lookahead, WayPointGPS._worldToMinimapPos, nil)
            WayPointGPS._drawMinimapEta(drawWPs, vehicle)
            if cfg.arrowEnabled then
                WayPointGPS._drawDirectionArrow(drawWPs, vehicle)
            end
        end
        WayPointGPS._drawTargetMarker(WayPointGPS._worldToMinimapPos, nil)
    end

    -- 3D/world guidance intentionally disabled; route is drawn only on minimap/full map.
end

function WayPointGPS.drawMenuMap()
    WayPointGPS._forceRuntimeDisplayState()
    if WayPointGPS.target == nil then return end
    if not cfg.mapEnabled then return end
    if not WayPointGPS._isMenuMapPageActive() then return end

    local map = WayPointGPS._getMenuMapElement()
    if map == nil or map.layout == nil then return end

    local vehicle = WayPointGPS.getCurrentVehicle()
    local WPs, currentWp = nil, nil
    if vehicle ~= nil then
        WPs, currentWp = WayPointGPS._resolveRoute(vehicle, false)
        local fullWPs = WayPointGPS.ensureFullRouteForVehicle(vehicle, false)
        if fullWPs ~= nil and #fullWPs >= 2 then
            local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
            WPs = fullWPs
            currentWp = 1
            if vx ~= nil and vz ~= nil then
                currentWp = WayPointGPS._findRouteProgressWaypoint(fullWPs, vehicle, vx, vz) or 1
            end
        end
    end

    if WPs ~= nil and currentWp ~= nil then
        local drawWPs = WayPointGPS._makeRemainingRoute(WPs, currentWp, vehicle)
        local clipRect = WayPointGPS._getMenuMapClipRect()
        WayPointGPS._drawRoute2D(drawWPs, 1, math.huge, WayPointGPS._worldToMenuMapPos, clipRect)
    end
    WayPointGPS._drawTargetMarker(WayPointGPS._worldToMenuMapPos, WayPointGPS._getMenuMapClipRect())
end

function WayPointGPS._isLeftMouseButton(button)
    if button == nil then return true end
    if Input ~= nil and Input.MOUSE_BUTTON_LEFT ~= nil then
        return button == Input.MOUSE_BUTTON_LEFT
    end
    return button == 1 or button == 0
end

function WayPointGPS.mouseEvent(self, posX, posY, isDown, isUp, button)
    if not isUp or (not WayPointGPS._isCtrlDown() and not WayPointGPS._isShiftDown()) or not WayPointGPS._isLeftMouseButton(button) then
        return false
    end

    -- Waypoint placement must only happen while the ESC fullscreen map page is open.
    -- The BaseMission/InGameMap mouse hooks also receive normal gameplay clicks; without
    -- this guard, CTRL-left-click outside the map can reuse stale/backing map data and
    -- place a random waypoint.
    if not WayPointGPS._isFullMapOpen() then
        return false
    end

    if WayPointGPS._tryMapElementClick(self, posX, posY, "eventSelf") then
        return true
    end

    for _, entry in ipairs(WayPointGPS._collectMenuClickMaps()) do
        if WayPointGPS._tryMapElementClick(entry[1], posX, posY, entry[2]) then
            return true
        end
    end

    if WayPointGPS._isFullMapOpen() then
        -- Exact map-click is preferred, but some player UI/input setups do not fire
        -- InGameMenuMapFrame.onClickMap even though the hook registered. Do not block
        -- the safer mission/menu fallback in that case, otherwise CTRL-click silently
        -- leaves target=false and waypoints=0 on affected saves/profiles.
        local worldX, worldZ = WayPointGPS._menuClickToWorld(posX, posY)
        if worldX ~= nil and worldZ ~= nil then
            local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
            if now - WayPointGPS.lastMapClickTime < 250 then
                return true
            end
            WayPointGPS.lastMapClickTime = now
            WayPointGPS.handleMapWaypointClick(worldX, worldZ, "base map fallback")
            return true
        end

        local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
        if now - WayPointGPS.lastClickDebugTime > 1000 then
            WayPointGPS.lastClickDebugTime = now
            WayPointGPS.logInfo("[WayPointGPS] map click fallback failed. posX=%s posY=%s button=%s", tostring(posX), tostring(posY), tostring(button))
        end
    end

    return false
end

function WayPointGPS.guiElementMouseEvent(self, posX, posY, isDown, isUp, button)
    if WayPointGPS ~= nil then
        WayPointGPS.mouseEvent(self, posX, posY, isDown, isUp, button)
    end
end

function WayPointGPS.installGuiMouseHooks()
    if WayPointGPS.guiHookInstalled then return end
    WayPointGPS.guiHookInstalled = true

    local hooked = {}
    local function hookClass(classTable, className)
        if classTable ~= nil and type(classTable.mouseEvent) == "function" and not hooked[classTable] then
            hooked[classTable] = true
            classTable.mouseEvent = Utils.appendedFunction(classTable.mouseEvent, WayPointGPS.guiElementMouseEvent)
        end
    end

    hookClass(GuiElement, "GuiElement")
    hookClass(BitmapElement, "BitmapElement")
    hookClass(TextElement, "TextElement")
    hookClass(ButtonElement, "ButtonElement")
    hookClass(ListElement, "ListElement")
    hookClass(FlowLayoutElement, "FlowLayoutElement")
    hookClass(BoxLayoutElement, "BoxLayoutElement")
    hookClass(TabbedMenu, "TabbedMenu")
    hookClass(Gui, "Gui")
    hookClass(MapElement, "MapElement")
    hookClass(MinimapElement, "MinimapElement")
    hookClass(InGameMapElement, "InGameMapElement")
    hookClass(InGameMenuMapFrame, "InGameMenuMapFrame")
    hookClass(InGameMenu, "InGameMenu")
    hookClass(InGameMap, "InGameMap")
end


function WayPointGPS._resolveInputAction(actionName)
    if InputAction ~= nil and InputAction[actionName] ~= nil then
        return InputAction[actionName]
    end
    return actionName
end

function WayPointGPS._removeGlobalActionEvents()
    if g_inputBinding == nil or WayPointGPS.globalActionEvents == nil then return end
    if type(g_inputBinding.removeActionEvent) == "function" then
        for _, evId in pairs(WayPointGPS.globalActionEvents) do
            if evId ~= nil then
                pcall(g_inputBinding.removeActionEvent, g_inputBinding, evId)
            end
        end
    end
    WayPointGPS.globalActionEvents = {}
end

function WayPointGPS._registerOneGlobalAction(actionName, callback, keyName)
    if g_inputBinding == nil or type(g_inputBinding.registerActionEvent) ~= "function" then
        return false
    end

    local ok, evId = g_inputBinding:registerActionEvent(WayPointGPS._resolveInputAction(actionName), WayPointGPS, callback, false, true, false, true)
    if ok and evId ~= nil then
        WayPointGPS.globalActionEvents = WayPointGPS.globalActionEvents or {}
        WayPointGPS.globalActionEvents[keyName or actionName] = evId
        if type(g_inputBinding.setActionEventTextVisibility) == "function" then
            g_inputBinding:setActionEventTextVisibility(evId, false)
        end
        return true
    end
    return false
end

function WayPointGPS._registerGlobalActionsForContext(contextName)
    if g_inputBinding == nil then return 0 end
    local count = 0
    local began = false

    if contextName ~= nil and type(g_inputBinding.beginActionEventsModification) == "function" then
        local ok = pcall(g_inputBinding.beginActionEventsModification, g_inputBinding, contextName)
        began = ok == true
    end

    if WayPointGPS._registerOneGlobalAction("WPGPS_TOGGLE_DISPLAY", WayPointGPS.actionEventToggleDisplay, tostring(contextName) .. "_display") then count = count + 1 end
    if WayPointGPS._registerOneGlobalAction("WPGPS_SETTINGS", WayPointGPS.actionEventOpenSettings, tostring(contextName) .. "_settings") then count = count + 1 end
    if WayPointGPS._registerOneGlobalAction("WPGPS_TOGGLE_ARROW", WayPointGPS.actionEventToggleArrow, tostring(contextName) .. "_arrow") then count = count + 1 end
    if WayPointGPS._registerOneGlobalAction("WPGPS_CYCLE_COLOR", WayPointGPS.actionEventCycleColor, tostring(contextName) .. "_color") then count = count + 1 end
    if WayPointGPS._registerOneGlobalAction("WPGPS_CLEAR_TARGET", WayPointGPS.actionEventClearTarget, tostring(contextName) .. "_clear") then count = count + 1 end

    if began and type(g_inputBinding.endActionEventsModification) == "function" then
        pcall(g_inputBinding.endActionEventsModification, g_inputBinding)
    end

    return count
end

function WayPointGPS.registerGlobalActionEvents()
    if g_inputBinding == nil then return end
    WayPointGPS._removeGlobalActionEvents()
    WayPointGPS.globalActionEvents = {}

    local total = 0
    local contexts = {}
    if PlayerInputComponent ~= nil and PlayerInputComponent.INPUT_CONTEXT_NAME ~= nil then
        table.insert(contexts, PlayerInputComponent.INPUT_CONTEXT_NAME)
    end
    -- Vehicle hotkeys are registered in WayPointGPSSpec:onRegisterActionEvents,
    -- which is the most reliable active-vehicle input path.  Keep global
    -- registration for non-vehicle/player context only to avoid missed inputs
    -- and double-toggle issues.

    if #contexts == 0 then
        total = total + WayPointGPS._registerGlobalActionsForContext(nil)
    else
        for _, contextName in ipairs(contexts) do
            total = total + WayPointGPS._registerGlobalActionsForContext(contextName)
        end
    end

    WayPointGPS.globalActionEventCount = total
    if total <= 0 then
        Logging.warning("[WayPointGPS] No global action events registered. Hotkeys may not respond; check inputBinding action names.")
    end
end

function WayPointGPS._allowInputAction(actionKey)
    local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
    WayPointGPS._lastInputActionTimes = WayPointGPS._lastInputActionTimes or {}
    local last = WayPointGPS._lastInputActionTimes[actionKey]
    if last ~= nil and now - last < 180 then
        return false
    end
    WayPointGPS._lastInputActionTimes[actionKey] = now
    return true
end

function WayPointGPS.handleInputAction(actionKey, source)
    if not WayPointGPS._allowInputAction(actionKey) then return end

    if actionKey == "toggleDisplay" then
        WayPointGPS.toggleDisplay()
    elseif actionKey == "openSettings" then
        if not settingsOpen then
            WayPointGPS.openSettingsDialog(source or "inputBinding")
        end
    elseif actionKey == "toggleArrow" then
        if WayPointGPS.toggleArrowDisplay ~= nil then
            WayPointGPS.toggleArrowDisplay()
        end
    elseif actionKey == "cycleColor" then
        if WayPointGPS.cycleRouteColor ~= nil then
            WayPointGPS.cycleRouteColor()
        end
    elseif actionKey == "clearTarget" then
        WayPointGPS.clearTarget(true)
    end
end

function WayPointGPS.actionEventToggleDisplay(_, actionName, inputValue, callbackState, isAnalog)
    WayPointGPS.handleInputAction("toggleDisplay", "globalAction")
end

function WayPointGPS.actionEventOpenSettings(_, actionName, inputValue, callbackState, isAnalog)
    WayPointGPS.handleInputAction("openSettings", "globalAction")
end

function WayPointGPS.actionEventToggleArrow(_, actionName, inputValue, callbackState, isAnalog)
    WayPointGPS.handleInputAction("toggleArrow", "globalAction")
end

function WayPointGPS.actionEventCycleColor(_, actionName, inputValue, callbackState, isAnalog)
    WayPointGPS.handleInputAction("cycleColor", "globalAction")
end

function WayPointGPS.actionEventClearTarget(_, actionName, inputValue, callbackState, isAnalog)
    WayPointGPS.handleInputAction("clearTarget", "globalAction")
end

function WayPointGPS.keyEvent(self, unicode, sym, modifier, isDown, used)
    if WayPointGPS._isCtrlKey(sym) then
        WayPointGPS.ctrlDown = isDown
        WayPointGPS.lastCtrlEventTime = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
    elseif WayPointGPS._isShiftKey(sym) then
        WayPointGPS.shiftDown = isDown
    end

    -- Do not force a hard-coded settings shortcut here. The settings hotkey is
    -- handled through the WPGPS_SETTINGS InputBinding action so player changes
    -- in the Controls/Key Bindings menu are respected. CTRL/SHIFT tracking below
    -- is kept only for the fixed full-map mouse click controls.

    if isDown and settingsOpen and WayPointGPS.settingsDialog ~= nil then
        local dlg = WayPointGPS.settingsDialog

        if sym == Input.KEY_q or sym == Input.KEY_e then
            -- Q/E are base-game tab/page navigation keys. In this single-page
            -- dialog they should be swallowed, otherwise E can expose the old
            -- MAP ROUTE settings page and Q flips back.
            activeTab = 1
            if dlg.settingsList ~= nil then
                dlg.settingsList:reloadData()
            end
            return true
        elseif sym == Input.KEY_up or sym == Input.KEY_down
            or sym == Input.KEY_left or sym == Input.KEY_right
            or sym == Input.KEY_f or sym == Input.KEY_r
            or sym == Input.KEY_a or sym == Input.KEY_d
            or sym == Input.KEY_return then
            -- Settings rows are mouse/click driven for release. Swallow the old
            -- navigation and cycle shortcuts so options only change by clicking
            -- Previous/Next or double-clicking a row.
            return true
        elseif sym == Input.KEY_esc or sym == Input.KEY_escape then
            dlg:onClickBack()
            return true
        end
    end

    return used
end

function WayPointGPS.onInGameMenuMapClick(frame, element, worldX, worldZ)
    if not WayPointGPS._isFullMapOpen() then return end
    if not (WayPointGPS._isCtrlDown() or WayPointGPS._isShiftDown()) then return end

    if worldX == nil or worldZ == nil then
        local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
        if now - (WayPointGPS.lastClickDebugTime or 0) > 1000 then
            WayPointGPS.lastClickDebugTime = now
            WayPointGPS.logInfo("[WayPointGPS] exact map click fired but world position was nil.")
        end
        return
    end

    local now = g_time or (g_currentMission ~= nil and g_currentMission.time) or 0
    if now - (WayPointGPS.lastMapClickTime or 0) < 250 then
        return
    end

    WayPointGPS.lastMapClickTime = now
    WayPointGPS.handleMapWaypointClick(worldX, worldZ, "InGameMenuMapFrame.onClickMap")
end



function WayPointGPS._setClickMode(mode)
    WayPointGPS.clickMode = mode or "auto"
    WayPointGPS.logInfo("[WayPointGPS] clickMode set to %s", tostring(WayPointGPS.clickMode))
end

function WayPointGPS.consoleCommandClickMode()
    WayPointGPS.logInfo("[WayPointGPS] clickMode=%s | commands: wpgpsClickAuto, wpgpsClickLocal, wpgpsClickPointer, wpgpsClickAspect, wpgpsClickCentered, wpgpsClickRaw, wpgpsClickRendered", tostring(WayPointGPS._getClickMode()))
end
function WayPointGPS.consoleCommandClickAuto() WayPointGPS._setClickMode("auto") end
function WayPointGPS.consoleCommandClickLocal() WayPointGPS._setClickMode("local") end
function WayPointGPS.consoleCommandClickPointer() WayPointGPS._setClickMode("pointer") end
function WayPointGPS.consoleCommandClickCentered() WayPointGPS._setClickMode("centered") end
function WayPointGPS.consoleCommandClickRaw() WayPointGPS._setClickMode("raw") end
function WayPointGPS.consoleCommandClickAspect() WayPointGPS._setClickMode("aspect") end
function WayPointGPS.consoleCommandClickRendered() WayPointGPS._setClickMode("rendered") end

function WayPointGPS.consoleCommandDebugTarget()
    local vehicle = WayPointGPS.getCurrentVehicle ~= nil and WayPointGPS.getCurrentVehicle() or nil
    local tx = WayPointGPS.targetX
    local tz = WayPointGPS.targetZ
    local wpCount = WayPointGPS.waypoints ~= nil and #WayPointGPS.waypoints or 0
    WayPointGPS.logInfo("[WayPointGPS] debugTarget | isActive=%s mapEnabled=%s arrowEnabled=%s target=%s targetX=%s targetZ=%s waypoints=%s preciseHook=%s fallbackMouse=%s",
        tostring(WayPointGPS.isActive), tostring(cfg ~= nil and cfg.mapEnabled), tostring(cfg ~= nil and cfg.arrowEnabled),
        tostring(tx ~= nil and tz ~= nil), tostring(tx), tostring(tz), tostring(wpCount),
        tostring(WayPointGPS.preciseMapClickHookInstalled), tostring(WayPointGPS.inGameMapMouseHookInstalled))
    if vehicle ~= nil then
        local vx, vz = WayPointGPS.getVehicleWorldPos(vehicle)
        WayPointGPS.logInfo("[WayPointGPS] debugTarget vehicle | x=%s z=%s", tostring(vx), tostring(vz))
    end
end

function WayPointGPS.onLoadMap()
    WayPointGPS.aiRoadGraph = nil
    WayPointGPS.aiRoadGraphBuildAttempted = false
    WayPointGPS.aiRoadGraphScanCount = 0
    local profilePath = getUserProfileAppPath()
    local settingsDir = profilePath .. "modSettings"
    createFolder(settingsDir)
    SETTINGS_FILE = settingsDir .. "/FS25_WayPointGPS.xml"
    WayPointGPS.loadSettings()
    WayPointGPS._forceRuntimeDisplayState()
    -- If a settings file does not exist yet, immediately create a clean default one.
    -- This prevents stale/new-save confusion and guarantees the map route display default is written.
    WayPointGPS.saveSettings()

    if WayPointGPS.statusConsoleCommandRegistered ~= true then
        addConsoleCommand("wpgpsStatus", "Print WayPoint GPS status and current settings", "consoleCommandStatus", WayPointGPS)
        addConsoleCommand("wpgpsRepairHud", "Repair WayPoint GPS HUD/runtime display state and save display on", "consoleCommandRepairHud", WayPointGPS)
        WayPointGPS.statusConsoleCommandRegistered = true
    end
    -- Register the five player-bindable hotkeys globally for both on-foot and in-vehicle contexts.
    -- The map mouse controls remain fixed as CTRL-left-click and SHIFT-left-click because
    -- GIANTS' Controls menu does not reliably support modifier + mouse-click map actions.
    WayPointGPS.registerGlobalActionEvents()
    WayPointGPS.logInfo("[WayPointGPS] GPS build active | map/minimap route display | completed route sections trim as you drive | bundled native WayPoint GPS route graphs.")

    g_gui:loadProfiles(MOD_DIRECTORY .. "gui/guiProfiles.xml")
    WayPointGPS.settingsDialog = WayPointGPSSettingsDialog.new()
    g_gui:loadGui(MOD_DIRECTORY .. "gui/WayPointGPSSettingsDialog.xml", "WayPointGPSSettingsDialog", WayPointGPS.settingsDialog)

    -- Release shop-click guard: do not globally hook GUI/list/button mouse events.
    -- Map waypoint clicks are handled by mission/menu/map fallbacks below.
    -- WayPointGPS.installGuiMouseHooks()

    BaseMission.draw = Utils.appendedFunction(BaseMission.draw, WayPointGPS.drawHUD)
    BaseMission.keyEvent = Utils.appendedFunction(BaseMission.keyEvent, WayPointGPS.keyEvent)
    BaseMission.mouseEvent = Utils.appendedFunction(BaseMission.mouseEvent, WayPointGPS.mouseEvent)

    if InGameMenu ~= nil and InGameMenu.draw ~= nil then
        InGameMenu.draw = Utils.appendedFunction(InGameMenu.draw, WayPointGPS.drawMenuMap)
    end
    if InGameMenu ~= nil and InGameMenu.keyEvent ~= nil then
        InGameMenu.keyEvent = Utils.appendedFunction(InGameMenu.keyEvent, WayPointGPS.keyEvent)
    end
    -- Release exact map-click fix: use the game map frame callback because it gives
    -- true world coordinates. This prevents the screen-coordinate fallback from
    -- placing waypoints far away from the clicked position.
    WayPointGPS.preciseMapClickHookInstalled = false
    if InGameMenuMapFrame ~= nil and InGameMenuMapFrame.onClickMap ~= nil then
        InGameMenuMapFrame.onClickMap = Utils.appendedFunction(InGameMenuMapFrame.onClickMap, WayPointGPS.onInGameMenuMapClick)
        WayPointGPS.preciseMapClickHookInstalled = true
        WayPointGPS.logInfo("[WayPointGPS] Exact map click hook registered via InGameMenuMapFrame.onClickMap.")
    else
        Logging.warning("[WayPointGPS] Exact map click hook not found; using approximate fallback only.")
    end

    -- Release shop-click guard: avoid global InGameMenu mouse hook; it can make shop list clicks behave like double-clicks.
    if InGameMap ~= nil and InGameMap.keyEvent ~= nil then
        InGameMap.keyEvent = Utils.appendedFunction(InGameMap.keyEvent, WayPointGPS.keyEvent)
    end
    if InGameMap ~= nil and InGameMap.mouseEvent ~= nil then
        InGameMap.mouseEvent = Utils.appendedFunction(InGameMap.mouseEvent, WayPointGPS.mouseEvent)
        WayPointGPS.inGameMapMouseHookInstalled = true
        WayPointGPS.logInfo("[WayPointGPS] InGameMap mouse hook registered.")
    else
        -- Fullscreen map click is handled by InGameMenuMapFrame and the mission/menu fallback.
    end

    WayPointGPS.logInfo("[WayPointGPS] Initialized. CTRL-click the full ESC map to set/add a waypoint. SHIFT-left-click the map to remove the last waypoint.")
end

Mission00.loadMap = Utils.appendedFunction(Mission00.loadMap, WayPointGPS.onLoadMap)

local WayPointGPSSpecFullName = g_currentModName .. ".waypointGpsSpec"

if g_specializationManager:getSpecializationByName("waypointGpsSpec") == nil then
    g_specializationManager:addSpecialization(
        "waypointGpsSpec", "WayPointGPSSpec",
        Utils.getFilename("scripts/WayPointGPSSpec.lua", g_currentModDirectory), nil)
end

local specCount = 0
for vehicleType, typeDef in pairs(g_vehicleTypeManager.types) do
    if typeDef ~= nil
       and typeDef.specializationsByName[WayPointGPSSpecFullName] == nil
       and WayPointGPSSpec.prerequisitesPresent(typeDef.specializations)
    then
        g_vehicleTypeManager:addSpecialization(vehicleType, WayPointGPSSpecFullName)
        specCount = specCount + 1
    end
end

WayPointGPS.logInfo("[WayPointGPS] WayPointGPSSpec added to %d vehicle types.", specCount)
