Sal's Kewl Korner Documentation / Sal's Kewl Signage Discord

Sal's Kewl Signage — owner's manual

Electronic sign props for FiveM — portable message boards, overhead gantries, arrow boards, full-color digital billboards, fuel pylons, parking counters — with the messaging under script control and an export API any other resource can drive. This manual covers installation, configuration, daily use and troubleshooting for server owners.

documents resource version 1.8.2 ↓ Download as PDF

Overview

Sal's Kewl Signage puts working electronic signs in your world. Fifteen types across three families, from a solar-powered VMS trailer at a roadworks site to a 24-meter overhead gantry to a drive-thru menu board, and every one of them shows whatever your scripts tell it to show — not a fixed set of baked messages.

Three things make it different from a prop pack with some text on it:

  • Per-instance content. Two boards using the same prop show different things, because the lamps are drawn in world space rather than painted onto a shared texture. A street of message boards is a street of message boards, not fifteen copies of one.
  • It ships with the hardware, as real props. Every type has a streamed model in the download — the solar trailer with its mast and wheels, the gantry posts and truss, the billboard monopole and catwalk — lit by the game, casting shadows, with collision. A trailer blocks a lane and traffic drives round it; whether it does is one config switch.
  • A hard performance ceiling. Lamp geometry is computed once and replayed, boards off-camera cost nothing, and a per-frame triangle budget means a street full of signs degrades in detail instead of dropping your frame rate.

Server-authoritative, framework agnostic (qb / qbx / esx / standalone), no database, no dependencies.

Requirements

None. No framework, no database, no target resource, no ox_lib.

qb-core, qbx_core or es_extended are detected if present, to resolve jobs for the permission check. Without any of them the resource runs standalone and permissions fall back to ACE.

Installation

  1. Drop the folder into resources/.
  2. Add ensure sals_kewlsignage to server.cfg.
  3. Give yourself permission:
    add_ace group.admin sals_kewlsignage.admin allow
  4. Join and run /sign demo. It teleports you in front of a field containing one of every sign type, each showing its plate number — the whole catalog in one look.
  5. Run /sign probe. Every type should say OK — the models ship in stream/. MISSING means a model did not stream; DRAWN means Config.Models.UsePack is off and the type is on its fallback rig.
  6. Before you go live, set Config.Demo.Enabled = false.

The demo field ships on so that step 4 works on a fresh install, and the server console reminds you every boot until you turn it off. Demo signs are marked as config signs, so they cannot be deleted in-game — switching the setting off is how they go away.

Updating

Signs you place in-game are saved to data/signs.json, and pictures added to the library to data/images/. Neither ships in the download, so copying a new version over the old one leaves them alone. If you prefer to delete the folder and drop the new one in, keep data/ and put it back afterward. config.lua is yours too; the CHANGELOG calls out any key that changed.

Coming from 1.5.0 or earlier, read Upgrading to 1.6.0 — one default changed in a way that can stop linked pictures working.

The fifteen sign types

Each has a plate number, which is what the demo field prints on it.

PlatetypeWhat it isBackend
SKS-101vms_trailerPortable message board on a solar trailermatrix
SKS-102arrow_trailerSequential arrow boardlamps
SKS-103dms_gantryFull-span overhead gantry, with lane headsmatrix
SKS-104dms_cantileverHalf-span cantilever armmatrix
SKS-105lcs_headSingle lane control signallamps
SKS-106radar_speedRadar speed feedback signmatrix
SKS-107vsl_roundelVariable speed limit with zone beaconsmatrix
SKS-201billboardDigital billboard, full colordui
SKS-202fuel_pylonFuel price pylon, per gradematrix
SKS-203tickerFacade ticker strip, scrollingmatrix
SKS-204aframeSidewalk A-framematrix
SKS-205menu_boardDrive-thru menu board, full colordui
SKS-301parking_countParking space countermatrix
SKS-302transit_panelTransit arrivals panelmatrix
SKS-303marqueeVenue marquee with chasing bulbsmatrix

How boards are drawn

Three backends, chosen per type:

matrix

Text as individual lamps on a 5×7 dot font, drawn on the plane of the board's face. Close up you see individual round lenses with dark gaps between them; further away, runs of lit lamps merge into solid strokes — which is what a real board looks like from a distance anyway, and costs a fraction of the polygons. Each lit lamp is drawn twice: a soft oversize halo in the lamp color under a whiter core, so neighboring lamps bleed into one glowing stroke the way LEDs do.

lamps

Fixed lamp patterns rather than text — the arrow boards and lane control signals, whose vocabulary is a set of shapes, not a set of words.

dui

An HTML page rendered off-screen and painted onto the board's face: pictures, any typeface, a color per line, slide shows with fades. Used by the billboard and the menu board out of the box, and available to any matrix type through Config.Dui.Types. See Full-color boards.

The hardware

Every type ships with a streamed model in stream/: a real prop, lit by the game's sun and weather, casting and receiving shadows, with collision. The screen is drawn onto a dark recess on the model, so the model carries no text and no texture swap, and two boards on one model still say two different things. Config.Models.Collision decides whether players and cars hit the hardware — on by default, so a trailer blocks a lane like a real one. If a model fails to stream, the type falls back to a drawn rig from the same dimensions: unlit, no collision, but the board still works. Config.Render.DrawRigs = false leaves such a sign screens only.

Run /sign winding once, on your first install. FiveM's DrawPoly draws one side of a triangle, and which side depends on a vertex order a script cannot ask the renderer about. Stand in front of the demo field and run /sign winding until the boards look right, then paste the letter it prints into Config.Render.Winding. The shipped default 'both' always works but costs twice as much — it is for finding out, not for keeping.

The in-game editor

/signs, or bind a key in Config.Editor. Every board on your server in a searchable list, with a live preview drawn using the same font the game uses — and for a full-color board, the very page the game paints onto it, so the preview is the board.

  • Page-by-page message editing, with a per-line color, alignment and effect.
  • One-click presets from Config.Presets.
  • Place, move, duplicate and delete without touching a config file.
  • Map pins for every sign, so you can see where they all are.
  • Variables, schedule and mirroring, covered below.
  • Watch board — a camera on the sign you are editing, with the panel still open and working beside it. The preview is a drawing of the message; this is the board itself, with the street behind it. In the tablet too.
  • An arrow board or a lane signal has no text, so its preview is drawn as the hardware — the lamp grid running the real pattern.

With Config.Editor.FocusOnAimed, looking at a board and pressing the bind opens the editor on that sign.

Permission is checked on the server before the UI is allowed to open, so leaving the command available to everyone is safe. A player with only the message permission sees the message editor; the group, schedule, mirror and Jobs allowed controls are admin-only.

The tablet

The editor is the control room. The tablet is what a road crew, a traffic officer or a restaurant employee holds in their hand. /signtablet, a key bind in Config.Tablet, or using the signage_tablet item (registered as usable with qb-core, qbx_core or es_extended when your inventory defines it) opens a modern slate over the game, and the player holds a tablet prop while it is open.

  • Only the signs the player may run. An admin sees everything; a job sees what Config.Access.Jobs and each sign's own Jobs allowed list grant it — see Permissions below. The server checks the same thing on every save.
  • The same live preview the editor has.
  • Message and presets as one-tap chips; pages with hold, effect and alignment. Pictures and per-line styles a slide carries are kept.
  • A drive-thru board's item names and prices as plain fields.
  • Power, brightness, color, the arrow pattern as tiles, lane signals as the heads themselves, and the values on a data board.
  • Overrides, with Release.
  • Quick actions from the list, multi-select with one action for all of them, one-tap alert modes, "show for 20 minutes" timed messages, a camera peek at any board, and an admin health screen with release-all.

Placing signs

Two ways, and they do not mix.

Config signs — the map's furniture

Signs in Config.Signs exist on every boot and cannot be deleted in-game, so an admin cannot quietly remove the furniture and leave the next restart to put it back. Their content can still be changed.

Config.Signs = {
    {
        id      = 'vms.route68_nb',
        type    = 'vms_trailer',
        coords  = vector3(1234.5, -678.9, 60.1),
        heading = 180.0,          -- degrees the FACE points, 0 = north
        group   = 'highway',
        pages   = { { lines = { 'ROAD WORK', 'AHEAD' } } },
    },
}

coords is the base of the sign — the ground, not the screen. /sign here prints a ready-made block for wherever you are standing.

Placed signs — the in-game tool

/sign place <type> [id] [group], or Place new in the editor, gives you the real prop with a live face preview to drive into position:

KeyDoes
W A S DMove
Q / EUp and down
ScrollTurn
Arrow keysFine adjust
SpaceSnap to 90°
RSnap to the ground
ShiftMove faster
EnterPlace it
BackspaceCancel

Leave the id out and the sign is named after the street it lands on. Placed signs are saved to data/signs.json and can be moved and deleted freely.

Messages, pages and styles

A sign shows a list of pages, cycled with a hold time each. A line too long for the board scrolls rather than being cut.

-- the convenient forms, all equivalent to a page list
SetMessage('vms.route68_nb', 'ROAD WORK')
SetMessage('vms.route68_nb', { 'ROAD WORK', 'AHEAD' })
SetMessage('vms.route68_nb', {
    { lines = { 'ROAD WORK', 'AHEAD' }, hold = 2500 },
    { lines = { 'EXPECT', 'DELAYS' }, effect = 'flash' },
})

Every line can carry its own look through an optional styles list beside lines. Lamp boards honor color, alignment and flash; full-color boards add a typeface, a size, bold, italic, and the effects pulse, glow, scroll and rainbow.

{ lines  = { 'HAPPY HOUR', '5 TO 7 PM' },
  styles = { { color = '#FFD24A', size = 1.5, font = 'display', effect = 'glow' },
             { color = 'white', size = 0.9 } },
  background = '#1A0B2E', transition = 'fade', hold = 5000 }

Config.Messages sets the ceilings — MaxLineLength (32), MaxLines (4), MaxPages (8) — and the character set the lamp font has glyphs for. They are applied on the server to everything, including export callers, so no resource can push something that renders as garbage.

Lines are stored as typed. The lamp boards apply their single case and charset when they draw, so a lamp board reads exactly as it always did, while a full-color board keeps its capitals and its accents. As of 1.6.0 the length cap counts characters rather than bytes, so accented text gets the same room as ASCII.

Overrides

An override jumps the queue without destroying what the board was showing, and releases itself:

Override('group:highway', { 'AMBER ALERT', 'BLU SEDAN' },
         { owner = 'my_dispatch', seconds = 300 })

When it expires — or when you release it — the board goes back to its own pages on its own. That is how you do an emergency broadcast without every resource having to remember and restore state. With an owner, ClearOverride only releases overrides that owner placed, so two resources overriding the same board cannot release each other's.

Full-color boards & pictures

The billboard and the menu board render an HTML page onto their face. No model pack and no texture are involved, and every board has its own page, so two billboards side by side show two different adverts. Config.Dui.Types promotes any other message board — the marquee, the A-frame, the ticker — to full color, keeping its hardware.

Drive-thru menus

A full-color page can carry a menu instead of lines, and it is then laid out the way a menu is printed rather than as centered text: a brand strip, sections set in columns under their own rules, calories under each item name, prices in a column that lines up, and the nutrition line along the bottom.

exports.sals_kewlsignage:SetMessage('menu.drivethru', { {
    menu = {
        theme = 'light',                     -- or 'dark' for a backlit panel
        brand = "CLUCKIN' BELL", header = 'Order Here',
        accent = '#C8102E', priceHeads = { 'Med', 'Lg' },
        sections = {
            { header = 'Combo Meals', items = {
                { num = '1', name = 'Bell Box Meal', kcal = '1240 Cal',
                  price = '11.99', price2 = '13.49' },
            } },
            { header = 'Sides & More', items = { --[[ ... ]] } },
        },
        footer = '2,000 calories a day is used for general nutrition advice',
    },
} })

Every field is optional, and an item may also carry a desc and an image — a library id or a link — drawn as a thumbnail against the row. Sections are dealt into columns to suit the board's shape, and the type is sized so the rows fill the panel without clipping a name.

A menu is content, not a texture. The same exports, overrides and schedules that change a message change the prices, so a script can run a daypart or a promotion. If the board cannot get a browser page it falls back to amber lamps, and the menu writes those lines itself.

Slides

A full-color board's pages are slides: picture, text or both, each with its own hold, background color, fill-or-fit, and a cut or a fade into the next. A picture-only slide is held eight seconds by default — it is an advert, not a message.

The picture library

Pictures live on the server in data/images/ (flat in data/ if that folder is missing; the console says which), so every player sees the same picture without anyone hosting it. Staff add them from the editor: open a slide's Choose picture and paste one with Ctrl+V — copied from any app or web page, or a Win+Shift+S screenshot.

There is no Browse button. The game's embedded browser cannot open a file picker, so a file input would sit there doing nothing. The clipboard works, and so does a link.

An uploaded picture is shrunk to the board it is going on before it leaves the browser, so a screenshot of anything becomes a billboard-sized JPEG of a couple of hundred kilobytes. A board refers to a library picture as lib:<id>.

Linked pictures, and why they are off by default

A linked picture is not fetched by your server. It becomes an <img src> inside the page painted onto the board, on the machine of every player who can see it. So whoever can set a board's picture can make everyone in view fetch a URL of their choosing — an IP-logging endpoint handed to anyone with the message permission, and nothing about the board looks wrong while it happens.

So Config.Images.AllowedHosts ships empty, which refuses every link and allows only uploaded pictures. To allow links, list the hosts:

AllowedHosts = { 'i.imgur.com', '.discordapp.net', 'media.tenor.com' },

A leading dot allows subdomains and only subdomains, so .imgur.com covers i.imgur.com but not eviimgur.com. '*' allows anything — only reasonable on a server where everyone who can set a board's picture is someone you would hand a shell to. The editor greys out the link box when no host is allowed, rather than letting someone paste one and watch it be silently refused.

Settings

Config.Images sets the ceiling per picture (MaxKilobytes, 600), the library size (MaxCount, 60), and whether the jobs in Config.Access.Jobs may upload (AllowJobUploads, off — uploads are ACE by default, deleting always is). Seed puts links in the library from the start, and they go through the same allowlist.

Config.Dui.MaxSurfaces (default 6) is the pool of browser pages shared by every full-color board in view, handed out nearest first. A board without one renders as a matrix board until one frees up. /sign perf reports what is in use.

Schedules

A sign can say different things at different times of day without anything driving it: a menu board that switches to breakfast at six, a marquee that runs the evening show after five, a highway board that reads differently overnight.

Config.Signs = {
  { id = 'menu.burgershot', type = 'menu_board',
    coords = vector3(...), heading = 0.0,
    pages = { { lines = { 'BURGER SHOT' } } },      -- outside every slot
    schedule = {
      { from = '06:00', to = '11:00', label = 'Breakfast',
        pages = { { lines = { 'BREAKFAST', 'UNTIL 11' } } } },
      { from = '22:00', to = '02:00', label = 'Late night',
        pages = { { lines = { 'OPEN LATE' } } } },
    } },
}

The first slot covering the current time wins, so the list is a priority order — put the exception above the rule. When no slot covers it, the board shows its own pages, which is what it did before it had a schedule. A slot crossing midnight is written the obvious way and understood: 22:00 to 02:00 is four hours, not twenty.

An override always beats a schedule. An emergency broadcast is not something a menu board's breakfast slot should be able to interrupt.

A slot needs both times and something to show. A slot with no pages is refused rather than accepted as a way to blank a board for four hours — use SetPower for that. An existing data/signs.json carrying one from an older version loses the slot, never the sign.

Which clock

Config.Schedules.Clock:

  • 'game' (default) — the in-game clock, which is what your players experience. A GTA day is 48 real minutes, so a scheduled board changes several times an hour of real time.
  • 'real' — the server's wall clock, for a server running synced or frozen time, or one that wants slots to line up with real events. A days list (1–7, Monday first) only means anything here; GTA's day of week is not something a server owner reasons about.

Slots are evaluated on the client, so a scheduled board changing costs no network traffic at all.

Settable at runtime with SetSchedule and /sign schedule, and editable in the editor — where the slot that is live right now is marked, because otherwise a scheduled board showing something other than the message in the big box looks like a bug.

Template variables

A line may contain {name} placeholders, filled in from the sign's own variables when it draws. The point is that a caller pushes the message once and then only what changes:

SetMessage('group:highway', { 'DELAY {minutes} MIN', 'VIA {route}' })
SetVars('group:highway', { minutes = 12, route = 'ROUTE 68' })

-- later, every minute, without touching the message again:
SetVars('group:highway', { minutes = 9 })

SetVars merges, so setting one name does not wipe the others. To remove one, use ClearVars(target, { 'route' }) — there is deliberately no in-band "delete" value, because false is a perfectly good thing for a variable to be (it renders as "no") and a Lua table cannot carry a nil, so any sentinel would be ambiguous with a real value. A name that has no value is left on the board exactly as typed — a board reading DELAY {minutes} MIN is a mistake you can see and fix, where DELAY  MIN is a mystery.

Substitution happens at draw time, so the stored message stays readable and the editor shows the template rather than one frozen instant of it.

Mirroring

Mirror(target, sourceId) makes a board show whatever another board is showing — its pages, its override, its schedule, its type behavior.

Mirror('dms.cantilever_nb', 'dms.gantry_nb')
Unmirror('dms.cantilever_nb')

Nothing is copied: the mirror reads through to its source every time it draws, so the two cannot drift and setting the source sets them both. A gantry's cantilever repeating the main board is the obvious use, and it is convincing.

Variables are still the mirroring board's own, so a row of near-identical signs can share one message and each fill in its own number.

Chains are followed eight deep; a cycle stops with the board showing nothing rather than with a client that has locked up. Deleting a source releases everything mirroring it back to its own pages.

Radar speed signs

The radar_speed type reads the approaching vehicle and answers under a printed SPEED LIMIT plate. It only reads vehicles inside a cone in front of it (Config.Radar.Range and ConeDegrees), so a board does not read the traffic behind it, and it flashes once the driver is FlashOver past the limit.

Config.Radar.Unit is 'mph' or 'kmh' server-wide; a sign on a server that mixes them can carry its own:

/sign data radar.grove unit kmh
/sign data radar.grove limit 30

The plate above the screen is the reference the driver is being measured against and is there the whole time. Config.Radar.Plate.Lines is { 'SPEED', 'LIMIT' } for the US plate; Canada would be { 'MAXIMUM' }.

Exports & events

Every export takes a target as its first argument and returns how many signs it changed, so a caller can tell the difference between "did it" and "that group is empty":

TargetMeans
'vms.route68_nb'one sign by id
'group:highway'every sign tagged into that group
'all'everything
local sk = exports.sals_kewlsignage

-- content
sk:SetMessage(target, pages)
sk:Clear(target)
sk:Override(target, pages, { owner = 'my_resource', seconds = 300 })
sk:ClearOverride(target, owner)

-- lamps
sk:SetArrow(target, 'seq_right')
sk:SetLanes(target, { 'green', 'amberx', 'redx' })

-- numbers and switches
sk:SetData(target, { spaces = 41 })                  -- parking counter
sk:SetData(target, { prices = { REG = '3.59' } })    -- fuel pylon
sk:SetData(target, { limit = 30 })                   -- radar / variable limit
sk:SetPower(target, true)
sk:SetBrightness(target, 0.8)
sk:SetColor(target, 'amber')

-- variables, schedules, mirroring
sk:SetVars(target, { minutes = 12 })
sk:GetVars(id)
sk:ClearVars(target, names)          -- names optional; omit for all
sk:SetSchedule(target, slots)
sk:GetSchedule(id)
sk:ClearSchedule(target)
sk:Mirror(target, sourceId)
sk:Unmirror(target)

-- placing and removing
sk:CreateSign({ id = ..., type = ..., coords = ..., heading = ..., group = ... })
sk:DeleteSign(id)

-- reading
sk:GetSign(id)
sk:ListSigns(target)        -- or no argument for everything
sk:Types()

-- the picture library
sk:AddImage({ label = 'Sprunk', url = 'https://.../sprunk.png' })
sk:RemoveImage(id)
sk:ListImages()

-- moving a build between servers
sk:ExportSigns(includeStatic)
sk:ImportSigns(list, { replace = false, prefix = '', static = false })

Exports are not permission-checked. Server code is trusted by definition — if another resource can call this, it is already running on your server. Player-facing permission lives on the commands.

Events

For callers that would rather fire and forget. These are server-side events only and deliberately not net events: a client must never be able to reach them.

TriggerEvent('sals_kewlsignage:setMessage', 'group:highway', { 'FOG', 'SLOW DOWN' })
TriggerEvent('sals_kewlsignage:override', target, pages, opts)
TriggerEvent('sals_kewlsignage:clearOverride', target, owner)
TriggerEvent('sals_kewlsignage:setMode', 'group:highway', 'storm', { seconds = 1800 })

Alert modes, live variables, the radar hook

SetMode(target, name, { seconds }) puts every board in a target into a named look from Config.Modes — amber alert, storm, lockdown, all clear ship — as an override that beats a schedule and releases itself; ClearMode puts them back. Live variables are values every board can use in a {placeholder} with nothing pushing them: {players}, {time}, {date}, {weather}, and SetLiveVar('fuel_reg', '3.49') for the resource that knows the number. A full-color slide can carry a video link that loops behind the text, host-checked like a picture.

AddEventHandler('sals_kewlsignage:speeding', function(r)
    -- r = { sign, plate, speed, limit, unit, over, driver, model, at }
end)

A radar sign that reads a driver Config.Radar.Report.Over past the limit fires that once per driver per sign per cooldown. A client only ever reports its own vehicle and the server reads the plate itself; the speed is the trigger for a look, not a verdict.

And to listen — for a CAD bridge, a logger, or a board that mirrors another:

AddEventHandler('sals_kewlsignage:signChanged', function(id, record) end)
AddEventHandler('sals_kewlsignage:signRemoved', function(id) end)

Commands

A command that answers with a list — list, types, the help — opens a panel rather than firing one notification per row: it scrolls, it filters, you can copy out of it, and Esc closes it. Short answers stay notifications.

/signs                                  open the editor
/signtablet                             open the tablet
/sign mode <target> <mode | off> [secs]  an alert mode from Config.Modes
/sign live [name] [value | -]           live variables

/sign list                              what is placed, and its state
/sign types                             the catalog
/sign probe                             which models exist on this server
/sign perf                              what the boards are costing right now
/sign count                             what your client holds, and why a board
                                        it holds is not being drawn
/sign winding                           cycle the one-sided draw setting, live
/sign demo                              teleport to the demo field

/sign place <type> [id] [group]         drive it into position, ENTER to place
/sign move <id>                         pick an existing sign up and re-place it
/sign delete <id>
/sign here                              coords/heading block for Config.Signs
/sign face <type>                       nudge a type's screen onto its prop

/sign msg <target> ROAD WORK | AHEAD    '|' splits lines
/sign clear <target>
/sign power <target> on|off
/sign arrow <target> <pattern>
/sign lanes <target> green,amberx,redx
/sign data <target> <key> <value>
/sign override <target> <seconds> <text>
/sign release <target>

/sign vars <target>                     what this board substitutes
/sign vars <target> <name> <value>      set one ('-' drops it)
/sign schedule <target>                 the slots it runs
/sign schedule <target> add 06:00 11:00 BREAKFAST | UNTIL 11
/sign schedule <target> clear
/sign mirror <target> <source id>
/sign mirror <target> off

/sign image list                        the picture library
/sign image add <label> <url>
/sign image remove <id>

/sign export [all]                      write data/export.json
/sign import [replace] [prefix]         read it back
/sign save                              flush placed signs to disk now

Permissions

Two tiers, because a roadworks crew should be able to set a board without being handed the keys to the map.

TierGranted byCan
Adminthe ACE permission sals_kewlsignage.admineverything
Messagea job in Config.Access.Jobs, or a job named on a sign's Jobs allowed listchange what the signs it is granted say
add_ace group.admin sals_kewlsignage.admin allow

Config.Access = {
    AcePermission = 'sals_kewlsignage.admin',
    Jobs = {
        'dot',                                  -- a plain name: every sign
        police     = { 'highway', 'traffic' },  -- only the signs in these groups
        burgershot = { 'burgershot' },          -- its own boards and nothing else
    },
    RequireOnDuty = true,
}

The message tier is scoped per sign. A plain name grants every sign; a name keyed to a list of group tags grants those groups; and an admin can hand one sign to a job with Jobs allowed, next to the group in the editor or on the tablet — which works with nothing in config at all. The tablet lists only what the player may run, and the server refuses a save, a release or a /sign subcommand that reaches any other sign, including through all or a group tag.

Admin only: place, move, delete, face, save, demo (it teleports you), mirror (it changes what two boards say), export and import (they move the map). Regrouping a sign and editing its schedule are admin-only in the editor for the same reason, and so is the editor's Go to button — as of 1.6.1, because it is the same free ride /sign demo is.

list, types and help need nothing. Everything else needs the message permission.

Audit log

Who changed what, and when. With a job-level permission tier this is the difference between "somebody put something rude on the billboard" being a question and being an answer; and when a board is showing the wrong thing, the first useful fact is whether a person or a resource put it there.

Config.Audit has three sinks, independently switchable:

  • The server console — off by default, because it is noisy on a busy server.
  • A file, data/audit.log.
  • A Discord webhook — the one that actually gets read.
Config.Audit = {
    Enabled = true,
    Console = false,
    File = 'data/audit.log',
    Webhook = 'https://discord.com/api/webhooks/...',
    LogContent = true, LogPlacement = true, LogImages = true,
    LogExports = false,
    Ignore = { 'my_dispatch' },
}

Entries name the player with their license identifier, so the log is still useful after they have left and their server id has been reused. Calls from other resources are not logged by default: they are machine traffic and can be constant. Ignore drops entries from named override owners, for the dispatch resource that cycles a board every few seconds.

The file is rewritten rather than appended — there is no append native — so it is flushed on a timer, and when it passes MaxFileKilobytes the contents move to audit.log.1 and it starts again. That is one generation of history; point the webhook at something that keeps more if you need it.

Rate limits

Everything a client can ask the server to do is capped per player in Config.Limits. None of the limits are reachable by anyone playing normally — they exist so one client in a loop cannot make the server work on its behalf.

The two that matter are StateRequests, whose answer is the whole sign registry plus the image library including thumbnails, and Uploads, whose request carries a few hundred kilobytes.

Each setting is { how many, per how many seconds }. The first time a player trips a limit the console says so once; further refusals are counted rather than printed, so the log cannot itself be used to flood you. /sign perf, run by an admin, lists who has been refused and how often.

HTTP control

Optional and off by default, so a CAD, a Discord bot or a website can put a message on a board without anything running inside FiveM. It refuses to listen without a token — an open endpoint that rewrites every sign on the map is not a feature.

Routes, all POST with the token in the body: /message, /release, /power, /mode, /modes, /vars, /live and /status. A Discord bot ships in integrations/discord-bot/ with /sign message, /sign alert, /sign status and friends driving them, with role gating; its README has the bodies and the setup.

-- server.cfg
set sals_kewlsignage_http_token "a-long-random-string"

-- config.lua
Config.Http = { Enabled = true, TokenConvar = 'sals_kewlsignage_http_token' }
POST http://your-server:30120/sals_kewlsignage/message
{ "token": "...",
  "target": "group:highway",
  "pages": [ { "lines": ["AMBER ALERT", "BLU SEDAN"] } ],
  "seconds": 300 }

With seconds it is an override that releases itself. Without, it replaces what the board says.

Backup, export & import

Placed signs live in data/signs.json and pictures in data/images/. Writes are batched a few seconds after the last change, so a dispatch resource hammering a board does not hammer the disk; /sign save flushes immediately, and so does stopping the resource.

To move a build between servers:

/sign export            -- writes data/export.json (placed signs)
/sign export all        -- and the config signs too

/sign import                    -- skips ids that already exist
/sign import replace            -- overwrites them
/sign import replace venue.     -- and puts 'venue.' in front of every id

The export is in the same shape Config.Signs takes, so you can paste it into a config file as easily as import it. A prefix lets a build come in alongside one already using those ids, and a mirror inside an imported build points at the imported copy of its source rather than at whatever happens to hold that id already.

Validation is all or nothing: a list with one bad record imports nothing and tells you which ones failed, because half a map is worse than none of it.

Performance

/sign perf reports what the boards are actually costing: boards drawing, triangles this frame, the peak, how many are at reduced detail, and how many browser pages the full-color boards are using.

Four things keep it cheap, and they matter more than the drawing itself:

  • Nothing allocates in the render loop.
  • Lamp vertices are computed once into a flat buffer and replayed until something actually changes.
  • Boards outside the camera frustum are rejected by one dot product.
  • A hard triangle budget per frame, spent nearest board first — and spent on detail, not on existence. A board that does not fit at full detail draws at low detail rather than vanishing, because a board that disappears as you walk toward it is worse than any amount of simplification.

The dials

SettingDefaultWhat it does
TriangleBudget8000Triangles per frame the boards aim to stay under. A ceiling, not a cost.
DrawDistance220 mPast this a board's content is not drawn. The prop stays.
DotDistance45 mInside this, individual lamps; outside, merged strokes.
MaxDrawn16Never render more than this many boards' content at once.
RoundLampstrueRound lenses instead of squares. Four times the triangles per lit lamp.
DrawUnlitLampstrueThe dark lens grid. The most expensive thing a small board can do.
Winding'both'Set this properly — see above. It halves the cost of everything.
Config.Dui.MaxSurfaces6Browser pages shared by the full-color boards in view.
Config.Dui.SurfaceSize1024Pixels per side of each surface. 2048 is four times the memory.

The demo field, with fifteen rigs in one view, is the worst case you will ever see. Run /sign perf there.

Models

Fifteen models ship in stream/, one per type, with the .ytyp that declares them — about 600 KB in all. They are generated from the catalog's own face and plate numbers, so the recess on the model and the screen the renderer draws cannot drift apart. Each has two LODs and box collision. stream/MODELS.md in the resource says how the pack is built and how to replace any model with your own.

To swap a type onto a prop you already have, or a model of your own:

Config.Models = {
    UsePack = true,
    Collision = true,                  -- players and cars hit the hardware
    Overrides = { billboard = 'prop_billboard_01' },
    Faces = { billboard = { offset = vector3(0.0, 0.30, 8.40),
                            width = 14.60, height = 4.30 } },
    Yaw   = { billboard = 180.0 },     -- for a prop modeled facing its own -Y
    Scale = { radar_speed = 1.5 },     -- fallback rigs only
}

Do not guess the face. Stand in front of a board, run /sign face <type>, nudge the screen onto the prop with the arrow keys and PgUp/PgDn, then press Enter and paste the line it prints.

Troubleshooting

The screens are invisible, or the rigs look inside out

The winding setting is wrong. Run /sign winding until they look right and paste the letter into Config.Render.Winding. On 'both' screens show but boxes look hollow — that is for finding out, not for keeping.

A board is floating, or sunk into the ground

coords is the base of the sign, not the screen. Use /sign move <id> and R to snap it to the ground, or nudge the z.

/sign probe says MISSING

A shipped model did not stream. Check stream/ is intact — fifteen .ydr files and salskewl_signage.ytyp — and that the data_file line is still in fxmanifest.lua. For an override, the prop named in Config.Models.Overrides is not on this server. DRAWN means Config.Models.UsePack is off.

A full-color board is showing amber lamps

It has not been given a browser page yet — the pool goes to the nearest boards first. If it never gets one, raise Config.Dui.MaxSurfaces. If every full-color board shows lamps, the console will say this client build has no DrawTexturedPoly native.

Placed signs vanish on restart

The console will be warning that it cannot write data/signs.json. A script cannot create a folder, so data/ has to exist — it ships in the download, so this usually means it was removed while copying.

A pasted picture is refused

Check the size against Config.Images.MaxKilobytes and the count against MaxCount. If it was a link, see linked pictures — the host allowlist ships empty.

A scheduled board is not showing what I typed in the message box

A schedule slot has taken over. The editor marks the live slot. Remember that on the in-game clock a GTA day is 48 real minutes, so slots come round far faster than you might expect.

A board reads DELAY {minutes} MIN

That variable has no value set. This is deliberate — a placeholder left visible is a mistake you can find, where a silent gap is not. Set it with /sign vars or in the editor.

Upgrading to 1.6.0

One default changed in a way that can stop something working.

Linked pictures are now refused unless their host is allowed. Config.Images.AllowedHosts ships empty, which means uploads only. If you use linked pictures, run /sign image list to see what you are using and add those hosts, or set AllowedHosts = { '*' } to keep the old behavior. Uploaded pictures are unaffected. Deleting the key entirely also keeps the old behavior, for a server that upgrades without reading this.

Everything else is additive or a fix. Worth knowing:

  • Config.Demo.Enabled now ships true, so a fresh install can run /sign demo. Your existing config.lua is untouched.
  • /sign demo now needs the admin permission — it teleports you, and it used to be reachable with the message permission.
  • Full-color surfaces are now square and interchangeable (Config.Dui.SurfaceSize). Previously a pool that filled with billboard-shaped pages had nothing to give a menu board, which then rendered as an amber matrix board for the rest of the session. No config change needed.
  • MaxLineLength now counts characters rather than bytes, so accented text gets its full line. Lines on non-English servers will get longer, not shorter.

Support

Questions, bug reports and feature requests: the Discord. Include your server's framework, the resource version from fxmanifest.lua, and the output of /sign probe and /sign perf — between them they answer most of what anyone would ask first.

The resource's own README.md ships in the download and is the source of truth; this manual is the same content organized for reading in a browser.