Configuration
Everything is in config.lua at the resource root. It is a shared script, so it is loaded on the client and the server, and every player can read it. Secrets such as the Discord webhook go in a server convar instead (see Installation → Discord webhook).
General
| Option | Default | Description |
|---|---|---|
Config.Helicopters | { polmav = true, buzzard2 = true } | Models that have the camera, by lowercase spawn name (matched by model hash on client and server). false turns one off |
Config.CameraSeats | { [-1] = true, [0] = true } | Seats that can use the camera (-1 pilot, 0 copilot). false turns a seat off |
Config.CanUse | function(ped, heli) return true end | Client-side permission hook. Return false to deny. See Installation |
Config.Department | 'LOS SANTOS POLICE DEPARTMENT' | HUD header |
Config.Callsign | 'AIRSURV' | HUD header, before the air callsign |
Framework & roles
Config.Framework = 'auto' -- 'auto' | 'qbx' | 'qb' | 'esx' | 'standalone'
Config.Jobs = {
air = { 'police', 'sheriff', 'lspd', 'bcso', 'sasp' },
ground = { 'police', 'sheriff', 'lspd', 'bcso', 'sasp', 'ambulance' },
bolo = { 'police', 'sheriff', 'lspd', 'bcso', 'sasp' },
requireDuty = true, -- qb/qbx: job.onduty must be true
standaloneAllow = true, -- standalone (no framework): everyone holds every role, like v1
}
Config.Callsigns = {
air = 'AIR-1', -- shown in the header / to ground units
perModel = { buzzard2 = 'AIR-2' },
fallback = 'UNIT-%d', -- ground units without a framework callsign (%d = server id)
}Roles: air operates the camera, ground gets the Air Support panel, markers and alerts, and bolo manages the BOLO list with /bolo (police only by default, so EMS keep the panel but can't flag plates). With standaloneAllow = false, standalone servers grant the roles through the ACE permissions np_helicam.air, np_helicam.ground and np_helicam.bolo. See Installation → Roles.
The air callsign belongs to the airframe: perModel[model], otherwise air. Ground units use their framework callsign (qb / qbx metadata.callsign, except the default NO CALLSIGN), otherwise fallback.
Camera
Config.Camera = {
fovMax = 70.0, -- widest zoom
fovMin = 3.0, -- tightest zoom
zoomStep = 5.0, -- fov change per scroll tick
zoomLerp = 0.12, -- fov smoothing per frame
sensitivity = 4.0, -- gimbal degrees per input unit at fovMax (scales down with zoom)
pitchMin = -89.0,
pitchMax = 45.0,
attachOffset = { x = 0.0, y = 2.4, z = -1.2 },
lockBreakDist = 900.0, -- auto-unlock beyond this range
lockLerp = 0.15, -- tracking smoothing per frame
probeRange = 4000.0, -- crosshair raycast length
cctvEffect = false, -- true = grainy CCTV timecycle while the cam is active
timecycle = 'heliGunCam',
}Zoom is shown as magnification: tan(fovMax / 2) / tan(fov / 2), so 1× at 70° and about 23× at 3°.
sensitivity is the fallback. The value in use is the player's sensitivity setting (default 4.0, see Settings). cctvEffect = true forces the grain for everyone. Otherwise each player can turn it on in F9. attachOffset is also used by the live feed viewer.
HUD
Config.Hud = {
showTargetSpeed = true, -- speed row on the target card
headingArrow = true, -- heli heading caret on the compass tape + arrow by the crosshair
colors = { -- HUD colour options a player can pick in F9
white = { 255, 255, 255 },
green = { 120, 255, 140 },
amber = { 255, 196, 70 },
},
timeline = true, -- mission timeline strip along the bottom edge
maxMarkersDrawn = 12,
maxUnitsDrawn = 16,
}Adding an entry to colors adds it to the F9 colour picker. See HUD.
Orbit
Config.Orbit = {
doubleTapWindow = 400, -- ms between lock presses to count as a double-tap
radius = 120.0, -- m, orbit radius around the locked position
speed = 25.0, -- m/s autopilot max speed
height = 60.0, -- m above the locked position
direction = 'left', -- LAPD-style left-hand orbit; 'right' flips it
radiusMin = 50.0, -- m, bounds for the per-player orbit radius (F9 / LSHIFT+scroll)
radiusMax = 400.0,
radiusStep = 10.0, -- m per LSHIFT+scroll tick while orbiting
leadAngle = 30.0, -- deg ahead on the circle the autopilot chases
retaskInterval = 1000, -- ms between carrot updates (floored at 250)
}In v2 the radius and direction come from the player's orbitRadius and orbitDirection settings (defaults 120 and 'left'), clamped to radiusMin–radiusMax. radius and direction here are only fallbacks.
The autopilot steers by re-issuing a GoTo task toward a point leadAngle degrees ahead on the circle every retaskInterval ms. It doesn't use the engine's circle task, because that task never runs its sustained phase on a player-controlled ped.
Overlay
Config.Overlay = {
streetLookupsPerFrame = 25, -- native street lookups per frame
maxLabels = 90, -- labels drawn at once
labelGridRadius = 6, -- samples a (2r+1)^2 grid of rays across the visible view
labelSpacingMin = 40.0, -- m, label cell size scales with camera height
labelSpacingMax = 250.0,
maxSampleDist = 2500.0, -- how far ahead labels are sampled on grazing / horizon rays
labelCellFactor = 2.0, -- same-street labels repeat every labelCellFactor x spacing
labelMarginX = 0.006, -- min horizontal gap between labels (screen units)
labelMarginY = 0.004, -- min vertical gap
crossingPriorityM = 100.0, -- "A & B" junction labels win ties within this much extra depth
gridHalfLines = 10, -- GEO+ grid is (2n+1) lines per axis
gridSpacing = 100.0,
}Labels are sampled across the visible view frustum out to maxSampleDist, so they cover what is on screen at any camera angle. To lower the overlay's cost, reduce streetLookupsPerFrame and maxLabels first.
Searchlight
Config.Searchlight = {
radius = 14.0,
distance = 300.0, -- beam length
brightness = 10.0,
hardness = 0.4,
falloff = 18.0,
shadowDist = 250.0, -- shadowed light within this range of the local player
maxDist = 800.0, -- beyond this, don't render at all
color = { 255, 255, 255 },
beams = { -- J cycles; radius/falloff per beam
narrow = { radius = 14.0, falloff = 18.0 },
wide = { radius = 32.0, falloff = 10.0 },
},
strobeHz = 4.0, -- strobe blink rate
minSendInterval = 100, -- ms; max 10 Hz state bag writes
heartbeat = 5000, -- ms
angleThreshold = 1.5, -- degrees
slerpRate = 0.15, -- receiver direction smoothing per frame
}Only the two beam names narrow and wide exist on the wire. A beam missing from beams falls back to the top-level radius / falloff. The last four options control replication. See Searchlight sync.
Hover
Config.Hover = {
autoEngageSolo = true, -- auto-engage when a solo pilot opens the camera
minAltitude = 15.0, -- m above ground required to engage, and the floor the hold won't descend below
climbRate = 3.0, -- m/s of anchor travel from the collective (W/S)
nudgeRate = 4.0, -- m/s of lateral anchor travel from the roll keys (NUM4/NUM6)
gain = 1.6, -- position error -> commanded velocity
damping = 0.18, -- velocity easing per 60 fps frame
maxCorrection = 12.0, -- m/s clamp on the servo's corrective command
maxEngageSpeed = 12.0, -- m/s; refuse to engage above this (clamped to maxCorrection)
maxLead = 40.0, -- m; longest glide-out to the hold point when engaging with speed on
pitchDeadzone = 0.15, -- cyclic pitch magnitude that hands control back
drift = {
horiz = 0.45, -- m, horizontal sway envelope
vert = 0.22, -- m, vertical bob envelope
periods = { 7.3, 11.7, 5.1 }, -- s, incommensurate on purpose
},
}The hold only sets linear velocity. Rotor, engine, angular physics and collisions stay with the game, so the airframe still banks when you nudge it. autoEngageSolo is the server switch. With it on, each player can still turn auto-hover off for themselves (autoHoverSolo in F9).
Player settings
Config.Settings = {
defaults = {
units = 'imperial', -- 'imperial' | 'metric'
hudColor = 'white', -- key of Config.Hud.colors
reticle = 'cross', -- 'cross' | 'box' | 'dot'
hudScale = 1.0, -- 0.8 .. 1.3
keyHints = true,
highContrast = false, -- dark backing behind HUD text and labels
cctv = false, -- grainy timecycle (Config.Camera.timecycle)
sensitivity = 4.0, -- 1 .. 8
invertY = false,
autoHoverSolo = true,
orbitRadius = 120.0,
orbitDirection = 'left',
lightFollowsCam = true, -- searchlight slaved to the camera
shareFeed = true, -- let ground units watch the feed
thermalPalette = 'whitehot',
showContextMap = true, -- minimap inset while the camera is up
mapFollow = 'both', -- Air Support map: 'both' | 'heli' | 'me' | 'target' (set in the panel)
mapZoom = 0, -- Air Support map zoom steps, -3 .. 3 (set in the panel)
},
locked = {}, -- e.g. { 'shareFeed' }
}These are the defaults for the F9 sheet. Keys listed in locked always use the default and can't be changed by players. See Player settings.
Thermal
Config.Thermal = {
warmupMs = 8000, -- FLIR unavailable this long after the camera opens
palettes = { -- Y cycles in this order. Values feed SEETHROUGH_* natives.
{ id = 'whitehot', label = 'WHT-HOT', near = { 0, 0, 0 }, heatscale = { 0.0, 0.1, 0.5, 1.0 },
noiseMin = 0.0, noiseMax = 0.1, hiIntensity = 0.5, hiNoise = 0.4, maxThickness = 1.0 },
{ id = 'blackhot', label = 'BLK-HOT', near = { 255, 255, 255 }, heatscale = { 1.0, 0.6, 0.2, 0.0 },
noiseMin = 0.0, noiseMax = 0.1, hiIntensity = 0.0, hiNoise = 0.2, maxThickness = 1.0 },
{ id = 'ironbow', label = 'IRONBOW', near = { 40, 0, 80 }, heatscale = { 0.0, 0.25, 0.6, 1.0 },
noiseMin = 0.05, noiseMax = 0.2, hiIntensity = 0.8, hiNoise = 0.5, maxThickness = 1.0,
timecycle = 'NG_filmic01' },
{ id = 'edge', label = 'EDGE', near = { 0, 0, 0 }, heatscale = { 0.0, 0.0, 0.8, 1.0 },
noiseMin = 0.0, noiseMax = 0.0, hiIntensity = 1.0, hiNoise = 0.0, maxThickness = 3.0 },
},
}Each palette is a preset for the game's single thermal renderer:
| Field | Native |
|---|---|
near (RGB) | SeethroughSetColorNear |
heatscale (4 bands, coolest first, 0–1) | SeethroughSetHeatscale, mapped onto the native's 0–0.75 range |
noiseMin / noiseMax | SeethroughSetNoiseAmountMin / Max |
hiIntensity / hiNoise | SeethroughSetHiLightIntensity / Noise |
maxThickness | SeethroughSetMaxThickness (1–10000) |
fadeStart / fadeEnd (optional) | SeethroughSetFadeStartDistance / EndDistance |
timecycle (optional) | SetTimecycleModifier while that palette is active |
The engine is reset (SeethroughReset) when FLIR is left, the camera closes or the resource stops. A new palette's id is picked up by the F9 palette list automatically.
Verify in-game
The palette values are a starting point and have not been tuned in-game. Black-hot is an approximation: there is no real inversion native.
ANPR, heat and trail
Config.Anpr = {
readMs = 1500, -- steady lock needed before the plate is read
maxRange = 450.0, -- m
minZoomX = 4.0, -- magnification needed to read a plate
}
Config.Heat = {
scanPerFrame = 24, -- entities examined per frame (round robin)
max = 24, -- signatures boxed at once
maxRange = 900.0, -- m
}
Config.Trail = { intervalMs = 750, minSpacing = 6.0, max = 40 }The ANPR timer resets whenever range, zoom or line of sight drop out. minZoomX also feeds the lock quality score. Config.Trail samples the locked target every intervalMs, skips points closer than minSpacing m, and keeps the last max points. See Operations.
BOLO
Config.Bolo = {
cacheMs = 60000, -- client cache per plate
bannerMs = 8000, -- red banner on the HUD
-- Server hook for an external MDT: return { reason = '...', code = '10-32' } or nil.
lookup = nil, -- function(plate) ... end
commandJobs = 'bolo', -- role allowed to use /bolo ('bolo' | 'ground' | 'air')
}cacheMs is also the server's window for de-duplicating alerts (one alert per plate per heli). lookup runs on the server and receives the normalised plate (upper case, letters and digits only):
Config.Bolo.lookup = function(plate)
local hit = exports.my_mdt:GetWarrantForPlate(plate) -- your MDT
if hit then return { reason = hit.title, code = '10-32' } end
endconfig.lua is shared, so the function is also loaded on clients, but it is only called on the server. Don't put credentials in it. commandJobs is a role ('bolo', 'ground' or 'air'), not a job list.
The server reads the plate itself from the vehicle the operator locked (by network id, within Config.Anpr.maxRange + 50 m of the heli), so a client can't check arbitrary plates. At most 2 checks per second per operator reach lookup. The in-memory registry holds at most 500 BOLOs.
Markers
Config.Markers = {
max = 12, -- air-placed markers per server (min 1); oldest is dropped.
-- Ground "orbit on me" requests have their own 4 slots
-- (one per requester) and never push out an air marker.
ttlMin = 30, -- auto-expire
labelMax = 32, -- characters
rateMs = 750, -- per player
}Units
Config.Units = {
intervalMs = 1000, -- position broadcast while somebody is watching
includeAir = true,
}The broadcast only runs while at least one player is subscribed (an operator with the camera up, or a ground unit with the panel open). intervalMs is floored at 250. includeAir = false leaves players in helicopters out.
Mission
Config.Mission = {
autoRecord = true, -- start recording when the camera opens
maxEvents = 200,
trackIntervalMs = 5000, -- flight track sample rate
idleEndMs = 120000, -- report is finalised this long after the cam closes
screenshots = true, -- B also takes an evidence screenshot (needs screenshot-basic)
-- Discord webhook comes from the server convar `np_helicam_webhook`.
}trackIntervalMs is floored at 1000 on the server. A mission shorter than 10 s with at most one real event is discarded (no report, event or webhook), so quick E taps with autoRecord don't flood your channel. One mission start per 10 s per operator. When the event list is full, the oldest event after the start line is dropped and counted as "trimmed" in the report.
Feed
Config.Feed = {
maxViewersPerHeli = 8,
cullRadius = 1500.0, -- OneSync culling radius for helis being watched
sendIntervalMs = 200, -- helicam_cam max rate (5 Hz)
angleThreshold = 0.75, -- deg
fovThreshold = 0.5, -- deg
heartbeat = 3000,
statusIntervalMs = 1000,
}The operator sends its camera state to the server when the gimbal moved more than angleThreshold, the FOV more than fovThreshold, the vision or palette changed, or heartbeat ms passed, but never faster than sendIntervalMs. The server then writes helicam_cam. The status summary is sent every statusIntervalMs (at least 1000) and is kept on the server, not in a state bag. See Events → State bags.
Context map
Config.ContextMap = {
zoomMin = 900, -- SetRadarZoom at low altitude (camera inset, top-right)
zoomMax = 1400,
-- Minimap layout put back when the radar is released (camera closed AND Air Support panel
-- closed): { posX, posY, sizeX, sizeY } per component, 'L'/'B'-aligned. Defaults are
-- GTA's own (frontend.xml).
restore = {
minimap = { -0.0045, 0.002, 0.150, 0.188888 },
minimap_mask = { 0.020, 0.032, 0.111, 0.159 },
minimap_blur = { -0.03, 0.022, 0.266, 0.237 },
},
}The zoom moves from zoomMin at 50 m above ground to zoomMax at 500 m and above.
When the radar is released (the camera is closed and the Air Support panel is closed), the minimap layout from restore is put back. A missing or malformed component falls back to GTA's default. np_hud re-applies its own layout by itself. Another HUD that moves the minimap should either copy its values into restore, or listen for the client event np_helicam:contextMap (true when np_helicam takes the minimap, false right after it restored it) and re-apply its own layout.
Map
The Air Support panel map and the camera's context map both show the game's own radar. Config.Map controls the panel map, Cayo Perico and map regions:
Config.Map = {
mode = 'radar', -- 'radar' (game radar under the panel) | 'svg' (abstract tactical map)
-- Cayo Perico island map:
-- 'auto' switch the radar to the island map while the radar centre is over the island
-- (SetUseIslandMap + SetRadarAsInteriorThisFrame) and back when leaving / closing
-- 'always' the same, but never switch the island map OFF (for servers that keep it on
-- with another resource)
-- 'off' never touch it (the heistIsland entries below are ignored)
cayo = 'auto',
-- Width / height of the panel window. GTA's own minimap is ~1.41 (MapMath.NATIVE_ASPECT);
-- if the radar looks stretched in-game, set 1.41.
windowAspect = 1.6,
hideHealthArmour = true, -- hide the radar's health/armour bars while the panel owns it
-- minimap_blur under the panel window: 'fit' = exactly under the map (invisible), 'scaled' =
-- GTA's proportions (a dark plate ~1.8x the map that spills over the world beside the panel)
blur = 'fit',
clipType = 0, -- SetMinimapClipType while we own the radar: 0 rectangular, 1 round, nil = leave
restoreClipType = nil, -- clip type to put back on release (nil = leave as is; the game default is 0)
updateMs = 250, -- panel view (centre / zoom target) recomputed at ~4 Hz
centerRate = 6.0, -- 1/s exponential approach of the radar centre (smooth follow)
snapDist = 1500.0, -- m; a jump further than this snaps instead of sliding
fitPadding = 1.5, -- 'both' view: margin around me + heli + aim + target (clears the overlay chips)
minHalf = 120.0, -- m, closest view (centre to top edge)
maxHalf = 4000.0, -- m, widest view
followHalf = 450.0, -- m, view size for heli / me / target follow at zoom step 0
zoomStepFactor = 1.6, -- each −/+ step divides / multiplies the view by this
-- SetRadarZoom level -> metres from the radar centre to its top/bottom edge. APPROXIMATE:
-- community values and eyeballing, there is no documented scale (the native docs say
-- 0..200, scripts use 0..1400; 0 = the game's own speed-based zoom). The radar draws the
-- same world area into any component size, so this holds for the panel window too. Tune
-- in-game: stand at a junction, compare a known distance with the window.
zoomFit = {
{ zoom = 0, half = 110 },
{ zoom = 200, half = 210 },
{ zoom = 400, half = 360 },
{ zoom = 600, half = 540 },
{ zoom = 800, half = 780 },
{ zoom = 1000, half = 1080 },
{ zoom = 1200, half = 1500 },
{ zoom = 1400, half = 2050 },
},
-- Map regions, checked against the radar CENTRE (the player when the radar follows them),
-- first match wins. Fields: name, minX, minY, maxX, maxY (world bounds); interior = hash
-- or name + ix, iy -> SetRadarAsInteriorThisFrame(interior, ix, iy, 0, 0) every frame
-- while inside; heistIsland = true -> SetUseIslandMap(true) (see `cayo` above).
-- Map extensions that stream minimap tiles (Roxwood etc.) need NO entry: their tiles are
-- part of the exterior radar. Add one only if the extension ships its own radar interior.
regions = {
{ name = 'cayo_perico', minX = 3500.0, minY = -6300.0, maxX = 6000.0, maxY = -4000.0,
interior = 'h4_fake_islandx', ix = 4700.0, iy = -5145.0, heistIsland = true },
-- Roxwood County (The Ambitioneers) -- example only, PLACEHOLDER bounds: the expansion
-- sits beyond the vanilla north-west coast, reached by a bridge near Paleto Bay; check
-- the real extent with /coords on your server. Its minimap tiles (Extra Map Tiles /
-- minimap_manager) already draw into the radar, so a region is only useful to give
-- it a radar interior of its own, which Roxwood does not need:
-- { name = 'roxwood', minX = -4000.0, minY = 7000.0, maxX = 1500.0, maxY = 11000.0 },
},
-- Blip styles (sprite / colour ids: docs.fivem.net/docs/game-references/blips). Shown on
-- the radar only (SetBlipDisplay 5), hidden on the legend, removed on release.
blips = {
heli = { sprite = 43, colour = 0, scale = 0.9, priority = 13 }, -- radar_police_heli, rotates with heading
aim = { sprite = 1, colour = 0, scale = 0.55, priority = 11 }, -- radar_level, white
target = { sprite = 1, colour = 1, scale = 0.85, priority = 12 }, -- red
marker = { sprite = 1, colour = 17, scale = 0.7, priority = 10 }, -- orange PNT
orbitReq = { sprite = 1, colour = 5, scale = 0.8, priority = 10 }, -- yellow ORBIT REQ
unitCar = { sprite = 56, colour = 3, scale = 0.7, priority = 9 }, -- radar_cop_patrol, blue
unitFoot = { sprite = 3, colour = 3, scale = 0.7, priority = 9 }, -- radar_police_ped, blue
},
}| Key | Default | Effect |
|---|---|---|
mode | 'radar' | 'radar': the game's radar under a transparent window in the panel. 'svg': the abstract tactical map drawn by the NUI (the radar is not touched) |
cayo | 'auto' | Cayo Perico island map: 'auto', 'always' or 'off'. See Air Support → Map extensions |
windowAspect | 1.6 | Width / height of the panel window. GTA's own minimap is about 1.41: use that if the radar looks stretched in game |
hideHealthArmour | true | Hides the radar's health and armour bars while the panel owns it |
blur | 'fit' | 'fit': the radar's blur plate sits exactly under the map. 'scaled': GTA's proportions, a dark plate about 1.8× the map that shows beside the panel |
clipType | 0 | SetMinimapClipType while np_helicam owns the radar: 0 rectangular, 1 round, nil leaves it |
restoreClipType | nil | Clip type put back on release. nil leaves it as is (the game default is 0) |
updateMs | 250 | How often the panel view (centre, zoom, blips) is recomputed |
centerRate | 6.0 | Speed (1/s) of the smooth slide to a new centre |
snapDist | 1500.0 | A jump further than this (m) snaps instead of sliding |
fitPadding | 1.5 | Margin around you, the heli, the aim point and the target in the Both view |
minHalf / maxHalf | 120.0 / 4000.0 | Closest and widest view, in metres from the centre to the top edge |
followHalf | 450.0 | View size for the Heli, Me and Target modes at zoom step 0 |
zoomStepFactor | 1.6 | Each + / − step divides or multiplies the view by this |
zoomFit | table | Radar zoom level → metres from the centre to the top/bottom edge. Interpolated linearly, clamped at both ends |
regions | Cayo Perico | Map regions checked against the radar centre, first match wins. See Custom regions |
blips | see above | Sprite, colour, scale and priority per blip kind. Sprite and colour ids: FiveM blip reference |
zoomFit is approximate
The radar zoom has no documented scale. The values come from community scripts and eyeballing. To tune them, stand at a junction in game, compare a known distance with the panel window and adjust the half values.
The follow mode and zoom steps players pick in the panel are stored as the mapFollow and mapZoomplayer settings. They are not listed in the F9 sheet, but you can set their defaults or lock them like any other setting.
Notifications & prompt
Config.Notify = {
provider = 'auto', -- 'auto' (built-in Nimbus toasts) | 'np_hud' | 'ox_lib' | 'native'
}
Config.Prompt = { showForMs = 8000 } -- boarding prompt after entering an eligible seat| Provider | Uses |
|---|---|
'auto' | The built-in Nimbus toasts in np_helicam's NUI |
'np_hud' | exports.np_hud:Notify({ type, tint, icon, app, title, message, duration }) |
'ox_lib' | exports.ox_lib:notify({ title, description, type, duration, icon }) |
'native' | A GTA feed post |
When the configured np_hud or ox_lib is not started, or its export fails, the built-in toasts are used.