Skip to content

Clothing: config files ​

These files stay readable in every build (escrow too), so you can change them. Shown as shipped in version 1.0.3.

shared.lua ​

lua
-- lumo-clothing | shared

Lumo = {}

-- Set to true to enable debug prints in the client and server consoles
Lumo.Debug = false

-- where thumbnails live (served by NUI)
Lumo.ImagesDir      = 'web/images/catalog/clothing/'

-- panel defaults
Lumo.UIDefaultDock  = 'left'              -- 'left' | 'right'
Lumo.PanelWidthPct  = 28                   -- default width (%)
Lumo.PanelWidthOptions   = { 28, 34, 42 }  -- Compact / Comfort / Wide

-- Interaction Mode
-- "interact"  = lumo-interact prompts, themed by lumo-ui  (recommended)
-- "marker"    = draw markers + press E
-- "none"      = no markers/NPCs, open via command/event
-- "npc" and "qb-target" are legacy aliases of "interact".
Lumo.InteractionMode = "interact"

-- Spawn a shop attendant ped that the prompt attaches to. With this off the
-- prompt anchors to the shop coordinates instead.
Lumo.SpawnNPCs = true

-- Saved outfits per character. Caps an otherwise unbounded client-driven
-- INSERT; names are trimmed to Lumo.OutfitNameMax characters.
Lumo.MaxOutfits     = 40
Lumo.OutfitNameMax  = 32

-- ─────────────────────────────────────────────────────────────────────────────
-- Clothing ownership
-- Buying a garment records it against the character. Re-wearing something you
-- already own is free; everything else is charged normally.
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.Ownership = {
  enabled = true,
  -- Fraction taken off a re-buy of something you already own. 1.0 = free.
  rebuyDiscount = 1.0,
  -- Seed a character's wardrobe from the clothes they are already wearing the
  -- first time they are seen. Turn off only on a brand-new server.
  grandfather = true,
  -- Strip components from a saved outfit that the character does not own.
  -- Off by default: it is a hard rule and will surprise existing players.
  strictOutfits = false,
  -- Categories ownership never applies to (underwear, skin, etc).
  ignoreCategories = { arms = true, undershirt = true, decals = true }
}

-- ─────────────────────────────────────────────────────────────────────────────
-- Condition & laundry
-- Worn garments accumulate dirt over time and damage from harm. Both are 0-100.
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.Laundry = {
  enabled = false,
  -- Dirt gained per tick of wear, and how long a tick is (minutes).
  dirtPerTick = 1,
  tickMinutes = 6,
  -- One-off dirt/damage events.
  onDeath   = { dirt = 22, damage = 14 },
  onDamage  = { dirt = 3,  damage = 4  },
  onRevive  = { dirt = 10, damage = 6  },
  -- Above this, the character reads as visibly unkempt to other resources.
  scruffyAt = 55,
  filthyAt  = 80,
  -- Try to swap to a dirtier texture variant of the same garment. Only applies
  -- when the garment actually has another variant; purely cosmetic.
  visualSwap = true,
  -- Wash pricing.
  washBase       = 25,     -- flat fee
  washPerGarment = 8,      -- per dirty garment
  repairPerPoint = 3,      -- mending charge per point of damage
  washSeconds    = 8,
  -- Counter interaction: the target eye (qb-target / ox_target through
  -- lumo-bridge) when one is running, lumo-interact prompts otherwise.
  useTarget = true,
  -- Actual walk-in MLO: Jerry's Laundromat by levdevlev (MIT).
  -- Attendant position verified by the server owner; prompt sits at ped waist height.
  -- No marker is registered unless the required map is running.
  locations = {
    { id = 'jerrys_laundromat', label = "Jerry's Laundromat",
      coords = vector4(897.31, -1039.78, 35.25, 5.96),
      ped = 's_f_m_sweatshop_01', promptOffset = vector3(0.0, 0.0, 0.0),
      resource = 'lev_laundromat', distance = 2.0, blip = 100, color = 3 }
  }
}

-- Attendant service: hand your clothes over and an employee walks to a free
-- machine and runs the cycle, instead of you standing at it yourself.
-- The location's own desk ped does the work: you undress and hand her the
-- basket, she loads a machine and starts it, waits while it runs, and brings
-- your clothes back folded for you to put on again.
Lumo.Laundry.attendant = {
  enabled     = true,
  -- Only used when a location has no desk ped of its own.
  model       = 's_f_y_shop_low',
  -- How long the machine runs once loaded. The walk, hand-over and folding
  -- come on top of this.
  washSeconds = 20,
  -- Added on top of the normal wash price for the service.
  serviceFee  = 10,
  -- Stay within this many metres of the counter to be handed the clean load.
  -- Farther away it is still finished and charged, just not handed over.
  handoverDistance = 6.0,
  -- Seconds to wait for the ped to reach a spot before she steps over to it.
  walkTimeout = 12,
  -- Carried to the machine, and back once it is washed and folded.
  props = { dirty = 'prop_washing_basket_01', clean = 'prop_cs_t_shirt_pile' },
  -- You undress to hand the load over and dress again when it comes back.
  -- Visual only: the saved skin is never touched, and the clothing menus stay
  -- shut until you are dressed.
  undress = true,
  -- The running machine. The MLO's washers are part of the building, so the
  -- cycle is drawn on top of one: a looping sound (a file in web/sounds/,
  -- heard within `range` metres), a glow in the drum, and laundry tumbling
  -- behind the door glass. `doorHeight` is the drum's centre above the floor.
  machine = {
    sound = 'washer_loop.wav', volume = 0.5, range = 14.0,
    light = true,
    tumble = { 'prop_cs_tshirt_ball_01', 'prop_cs_tshirt_ball_01', 'prop_cs_tshirt_ball_01' },
    doorHeight = 0.6,
  },
}

-- Washing machine spots per laundry location: where the attendant stands to
-- load a machine, facing it.
--
-- These are interior coordinates inside an MLO, and guessing them from an
-- offset does not work. Stand in front of a machine, face it, and run
-- `/washmachine <location id>` as an admin. The spot is saved to
-- laundry_machines.json and used at once, no restart; `/washmachine
-- <location id> clear` forgets that location's saved spots. Spots listed here
-- are used as well, ahead of the saved ones.
--
-- With none at all, the attendant turns to the machine behind her counter.
Lumo.Laundry.machines = {
  -- jerrys_laundromat = {
  --   vector4(x, y, z, heading),
  -- },
}

-- Cleaning consumable: the second route. Slower, partial, and it costs an item,
-- but it works anywhere -- useful if you are far from a laundromat.
Lumo.Laundry.item = {
  enabled   = true,
  name      = 'detergent',
  -- A garment is cleaned only if its dirt is at or below this. Heavier soiling
  -- still needs a real machine, which is what keeps laundromats worth visiting.
  maxDirt   = 60,
  seconds   = 12,
  -- Sold at the laundromat counter. 24/7 stores stock it through qb-shops.
  price     = 15,
}

-- Soap: wash yourself and what you have on, anywhere. Foam on the body and a
-- standing scrub; it takes dirt and blood off the skin and dirt out of the
-- clothes. It does not mend -- wear still needs the laundromat.
Lumo.Laundry.soap = {
  enabled = true,
  name    = 'soap',
  seconds = 10,
  -- Garments dirtier than this are left for a machine. 100 = anything.
  maxDirt = 100,
}

-- Foam on the body while scrubbing with soap or detergent; first that loads.
Lumo.Laundry.foam = {
  candidates = {
    { asset = 'cut_trevor3', effect = 'cs_trev3_soap_suds',       scale = 0.35 },
    { asset = 'core',        effect = 'water_splash_ped_bubbles', scale = 0.6 },
  },
}

-- How dirt shows on you, on top of the texture swap. From scruffyAt: grime on
-- skin and clothes (a ped damage pack). From filthyAt: flies around you, which
-- other players see too.
Lumo.Laundry.dirtyLook = {
  enabled = true,
  grime = true, grimePack = 'SCR_Dumpster', scruffyGrime = 0.4, filthyGrime = 1.0,
  flies = true, fliesScale = 0.7,
}

-- Washing particle effects (suds at the machine door). Several candidates
-- because asset names vary between game builds; the first that loads is used,
-- and if none do the wash still runs without visuals rather than failing.
Lumo.Laundry.ptfx = {
  enabled = true,
  candidates = {
    { asset = 'cut_trevor3',    effect = 'cs_trev3_soap_suds', scale = 0.5 },
    { asset = 'core',           effect = 'water_splash_ped_bubbles' },
    { asset = 'core',           effect = 'water_splash_ped_in' },
    { asset = 'scr_carwash',    effect = 'ent_amb_car_wash_jet' },
    { asset = 'core',           effect = 'ent_amb_steam' },
  },
  scale = 1.2,
}

function Lumo.IsLaundryLocationAvailable(location)
    return type(location) == 'table' and location.enabled ~= false
        and (not location.resource or GetResourceState(location.resource) == 'started')
end

-- ─────────────────────────────────────────────────────────────────────────────
-- Brand tiers. Tier drives the badge shown on an item and the default social
-- read of the garment. Owners set a margin per shop on top of listed prices.
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.Brands = {
  enabled = true,
  tiers = {
    [1] = { label = 'Discount', color = '#8b9096' },
    [2] = { label = 'Everyday', color = '#8fd0ff' },
    [3] = { label = 'Premium',  color = '#b7ff3a' },
    [4] = { label = 'Designer', color = '#ffb648' },
    [5] = { label = 'Couture',  color = '#ff7ad9' }
  },
  -- Bounds an owner's margin multiplier so nobody lists a $2,000,000 sock.
  marginMin = 0.50,
  marginMax = 3.00
}

-- ─────────────────────────────────────────────────────────────────────────────
-- Clothing as evidence. Police resources call the exports; nothing here runs
-- on its own.
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.Evidence = {
  enabled = true,
  -- Jobs allowed to pull a description or search past records.
  jobs = { police = true, sheriff = true, sasp = true, doj = true },
  -- A mask hides identity: the description says so and omits the face.
  maskConceals = true,
  -- How long a stored description stays queryable (days). 0 = forever.
  retainDays = 30
}

-- ─────────────────────────────────────────────────────────────────────────────
-- Clothing as inventory items
-- One qb-core item per wearable slot; the actual garment lives in metadata, so
-- thousands of drawables need only a dozen item definitions. Every conversion
-- is server-authoritative against the saved skin, which is what stops a client
-- banking the same garment twice.
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.ClothingItems = {
  enabled = true,
  -- Wearing a garment cut for the other body type shows a different drawable
  -- entirely, so it is blocked rather than silently wrong.
  enforceGender = true,
  -- A take-off is refused outright when there is no room to carry the garment.
  -- The check runs before the saved skin is touched, so a full inventory leaves
  -- the character dressed exactly as they were rather than half-converted.
  checkRoomFirst = true,
  -- Garments taken off become owned by whoever ends up holding them.
  transferOwnership = true,

  -- slot key -> qb-core item name. Slot keys are the shop's UI categories.
  -- Add a row here and to qb-core/shared/items.lua to support another slot.
  items = {
    mask       = 'clothing_mask',
    jacket     = 'clothing_jacket',
    undershirt = 'clothing_shirt',
    vests      = 'clothing_vest',
    pants      = 'clothing_pants',
    shoes      = 'clothing_shoes',
    bags       = 'clothing_bag',
    neck       = 'clothing_necklace',
    hats       = 'clothing_hat',
    glasses    = 'clothing_glasses',
    ear        = 'clothing_earrings',
    watch      = 'clothing_watch',
    bracelet   = 'clothing_bracelet'
  },

  -- Friendly names used when a garment has no catalog label.
  slotLabels = {
    mask = 'Mask', jacket = 'Jacket', undershirt = 'Shirt', vests = 'Vest',
    pants = 'Trousers', shoes = 'Shoes', bags = 'Bag', neck = 'Necklace',
    hats = 'Hat', glasses = 'Glasses', ear = 'Earrings', watch = 'Watch',
    bracelet = 'Bracelet'
  },

  -- Every name any other resource, command or menu might use for a slot.
  -- Lumo.Slot(name) resolves through this, so the radial menu's 'Top', a
  -- /tshirt command and the shop's own 'undershirt' all land on one slot.
  aliases = {
    mask = 'mask', masks = 'mask', facemask = 'mask',

    jacket = 'jacket', jackets = 'jacket', top = 'jacket', tops = 'jacket',
    torso = 'jacket', torso2 = 'jacket', coat = 'jacket',

    undershirt = 'undershirt', shirt = 'undershirt', tshirt = 'undershirt',
    ['t-shirt'] = 'undershirt', tee = 'undershirt',

    vest = 'vests', vests = 'vests', bulletproof = 'vests',

    pants = 'pants', pant = 'pants', trousers = 'pants', legs = 'pants',

    shoes = 'shoes', shoe = 'shoes', boots = 'shoes', feet = 'shoes',

    bag = 'bags', bags = 'bags', bagoff = 'bags', backpack = 'bags',
    parachute = 'bags',

    neck = 'neck', necklace = 'neck', neckl = 'neck', chain = 'neck',
    accessory = 'neck', tie = 'neck',

    hat = 'hats', hats = 'hats', cap = 'hats', helmet = 'hats',

    glasses = 'glasses', glass = 'glasses', sunglasses = 'glasses',
    specs = 'glasses', eyewear = 'glasses',

    ear = 'ear', ears = 'ear', earring = 'ear', earrings = 'ear',
    earpiece = 'ear',

    watch = 'watch', watches = 'watch',

    bracelet = 'bracelet', bracelets = 'bracelet', wristband = 'bracelet'
  },

  -- Chat command aliases lumo-clothing registers itself. Names already owned by
  -- another started resource are skipped at registration time, so nothing here
  -- silently overwrites qb-radialmenu's /shirt or /pants.
  commands = {
    'takeoff', 'wear', 'undress',
    'top', 'jacket', 'torso',
    'shirt', 'tshirt', 'undershirt',
    'pants', 'trousers', 'shoes',
    'vest', 'bag', 'backpack', 'mask',
    'neck', 'necklace', 'hat',
    'glasses', 'sunglasses',
    'ear', 'earrings', 'watch', 'bracelet'
  },

  -- Command names another resource is expected to own. lumo-clothing does not
  -- register these; that resource routes them here instead (see the adapter in
  -- qb-radialmenu/client/clothing.lua).
  reservedCommands = {
    ['qb-radialmenu'] = {
      'top', 'shirt', 'pants', 'shoes', 'vest', 'bag', 'bagoff', 'mask',
      'hat', 'glasses', 'ear', 'neck', 'watch', 'bracelet'
    }
  },

  -- Per-slot dressing animation. Played client-side before the server is asked
  -- to convert, so the change lands on the frame the hands finish moving.
  anims = {
    jacket     = { dict = 'clothingtie',                     clip = 'try_tie_negative_a',     ms = 1200 },
    undershirt = { dict = 'clothingtie',                     clip = 'try_tie_negative_a',     ms = 1200 },
    vests      = { dict = 'clothingtie',                     clip = 'try_tie_negative_a',     ms = 1200 },
    neck       = { dict = 'clothingtie',                     clip = 'try_tie_positive_a',     ms = 1600 },
    pants      = { dict = 're@construction',                 clip = 'out_of_breath',          ms = 1300 },
    shoes      = { dict = 'random@domestic',                 clip = 'pickup_low',             ms = 1200 },
    bags       = { dict = 'anim@heists@ornate_bank@grab_cash', clip = 'intro',                ms = 1400 },
    mask       = { dict = 'mp_masks@standard_car@ds@',       clip = 'put_on_mask',            ms = 900 },
    hats       = { dict = 'missheist_agency2ahelmet',        clip = 'take_off_helmet_stand',  ms = 1200 },
    glasses    = { dict = 'clothingspecs',                   clip = 'take_off',               ms = 1200 },
    ear        = { dict = 'mp_cp_stolen_tut',                clip = 'b_think',                ms = 900 },
    watch      = { dict = 'nmt_3_rcm-10',                    clip = 'cs_nigel_dual-10',       ms = 1100 },
    bracelet   = { dict = 'nmt_3_rcm-10',                    clip = 'cs_nigel_dual-10',       ms = 1100 }
  }
}

--- Canonical slot key for any name a caller might use, or nil if it is not a
--- wearable slot. Shared so the client, the server and every adapter agree.
function Lumo.Slot(name)
    if type(name) ~= 'string' then return nil end
    local key = name:lower():gsub('%s+', '')
    local aliases = (Lumo.ClothingItems or {}).aliases or {}
    return aliases[key]
end

--- True when the item system is on and the slot is one it manages.
function Lumo.IsItemSlot(name)
    if not (Lumo.ClothingItems and Lumo.ClothingItems.enabled) then return false end
    local slot = Lumo.Slot(name)
    return slot ~= nil and (Lumo.ClothingItems.items or {})[slot] ~= nil
end

--- True only when a resource folder really named `name` is started. The
--- fxmanifest `provide`s esx_skin and skinchanger, which makes
--- GetResourceState(name) answer 'started' for lumo-clothing itself.
function Lumo.RealResource(name)
    if GetResourceState(name) ~= 'started' then return false end
    for i = 0, GetNumResources() - 1 do
        if GetResourceByFindIndex(i) == name then return true end
    end
    return false
end

-- ─────────────────────────────────────────────────────────────────────────────
-- Hair fit: hair ties and the hat/helmet clipping fix
--
-- GTA decides how much hair a head prop cuts from `pedalternatevariations.meta`
-- in the ped's DLC. Vanilla hair is described there; add-on and EUP hair
-- usually is not, which is why it pokes through hats. There is no runtime
-- native to change that metadata, so the working fix — the one every headgear
-- script uses — is to swap the hair drawable for a shorter one while a head
-- prop is worn, and swap it back afterwards.
--
-- The swap is applied to the ped only. The character's real hair stays in the
-- saved skin, so nothing here can permanently change someone's hairstyle.
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.Hair = {
  enabled = true,

  -- Item that ties hair back. Toggling is free and instant.
  tieItem = 'hairtie',
  tieAnim = { dict = 'anim@mp_player_intincarcelebrate', clip = 'celebrate' },
  tieSeconds = 2,

  -- Automatically fit hair under a head prop (hat/helmet) as it goes on.
  autoFitUnderHats = true,
  -- Bike/flight helmets the game puts on by itself count as head props too.
  suppressVehicleHelmets = false,

  -- Hair drawables that clip badly. Anything not listed is left alone, so an
  -- untouched server behaves exactly as it does today.
  --   [hairDrawable] = tiedDrawable
  -- `fallback` is used for a hair that clips but has no mapping of its own.
  tied = {
    male = {
      fallback = 6,      -- short crop
      [7] = 6, [8] = 6, [9] = 6, [10] = 6, [11] = 6,
      [12] = 6, [13] = 6, [14] = 6, [15] = 6, [16] = 6
    },
    female = {
      fallback = 4,      -- tied-back bun
      [7] = 4, [8] = 4, [9] = 4, [10] = 4, [11] = 4,
      [12] = 4, [13] = 4, [14] = 4, [15] = 4, [16] = 4,
      [17] = 4, [18] = 4, [19] = 4, [20] = 4
    }
  },

  -- Hair drawables that clip through head props. Empty means "trust the map
  -- above": any hair with an entry in `tied` is treated as clipping.
  clipsUnderHats = {}
}

-- ─────────────────────────────────────────────────────────────────────────────
-- Property wardrobes. lumo-housing (or anything else) calls
-- exports['lumo-clothing']:OpenPropertyWardrobe(propertyId).
-- ─────────────────────────────────────────────────────────────────────────────
Lumo.PropertyWardrobe = {
  enabled = true,
  -- Outfits saved at a property are still global; this only gates the panel.
  requireInside = true
}

-- Performance Mode
-- shared.lua (near top)
Lumo.PerformanceMode = GetConvarInt('lumo:perf', 0) == 1 or Lumo.PerformanceMode

-- NPC Settings – only clothing shops now
Lumo.NPCs = {
  clothing = {
    model      = "s_f_m_shop_high",
    scenario   = "WORLD_HUMAN_STAND_IMPATIENT",
    frozen     = true,
    invincible = true
  },
  barber = {
    -- common barber model in GTA V
    model      = "s_m_m_hairdress_01",
    scenario   = "WORLD_HUMAN_STAND_IMPATIENT",
    frozen     = true,
    invincible = true
  },
  tattoo = {
    -- tattoo artist model
    model      = "u_m_y_tattoo_01",
    scenario   = "WORLD_HUMAN_CLIPBOARD",
    frozen     = true,
    invincible = true
  }
}

-- Single UI theme. The names below are the custom properties the NUI reads,
-- so editing a value here restyles the panel without rebuilding the bundle.
-- Anything left out keeps the stylesheet default.
Lumo.DefaultTheme = 'lumo-terminal'

Lumo.Themes = {
  ["lumo-terminal"] = {
    label = "Lumo Terminal",
    vars = {
      -- lime accent on near-black, matched to qb-inventory
      ["--accent"]         = "#b7ff3a",
      ["--accent-hover"]   = "#c9ff66",
      ["--accent-dim"]     = "rgba(183, 255, 58, 0.5)",
      ["--accent-soft"]    = "rgba(183, 255, 58, 0.10)",
      ["--accent-glow"]    = "rgba(183, 255, 58, 0.30)",
      ["--accent-deep"]    = "#6f9e18",
      ["--accent-text"]    = "#0a1400",

      ["--panel-bg"]       = "rgba(7, 10, 8, 0.90)",
      ["--panel-bg-2"]     = "rgba(5, 7, 6, 0.97)",
      ["--card-bg"]        = "rgba(255, 255, 255, 0.035)",
      ["--card-bg-hover"]  = "rgba(183, 255, 58, 0.10)",
      ["--btn-bg"]         = "rgba(255, 255, 255, 0.05)",
      ["--btn-hover"]      = "rgba(183, 255, 58, 0.12)",

      ["--border"]         = "rgba(255, 255, 255, 0.10)",
      ["--border-strong"]  = "rgba(183, 255, 58, 0.45)",
      ["--hairline"]       = "rgba(255, 255, 255, 0.07)",

      ["--text"]           = "#eef1e9",
      ["--muted"]          = "rgba(238, 241, 233, 0.60)",
      ["--faint"]          = "rgba(238, 241, 233, 0.34)",

      ["--danger"]         = "#ff5a5a",
      ["--warn"]           = "#ffd35a",
      ["--radius"]         = "2px"
    }
  }
}


Lumo.Underwear = Lumo.Underwear or { male = 61, female = 15, texture = 0 }

-- ## NEW: Tattoo Categories with Camera Positions ##
-- This table now controls the UI categories and camera views.
-- Format: { "ZONE_ID", "Display Name", { camera_positions }, focus_point }
Lumo.TattooCats = {
	{"ZONE_HEAD", "Head", {vec(0.0, 0.7, 0.7)}, vec(0.0, 0.0, 0.5)},
	{"ZONE_TORSO", "Torso", {vec(0.0, 0.8, 0.2)}, vec(0.0, 0.0, 0.2)},
  {"ZONE_BACK", "Back", {vec(0.0, -0.9, 0.2)}, vec(0.0, 0.0, 0.2)},
	{"ZONE_LEFT_ARM", "Left Arm", {vec(-0.7, 0.0, 0.2)}, vec(-0.2, 0.0, 0.2)},
	{"ZONE_RIGHT_ARM", "Right Arm", {vec(0.7, 0.0, 0.2)}, vec(0.2, 0.0, 0.2)},
	{"ZONE_LEFT_LEG", "Left Leg", {vec(-0.2, 0.7, -0.7)}, vec(-0.2, 0.0, -0.6)},
	{"ZONE_RIGHT_LEG", "Right Leg", {vec(0.2, 0.7, -0.7)}, vec(0.2, 0.0, -0.6)},
}

-- Keep only the defaults here. The tattoo list itself is gone.
Lumo.Services = {
  defaults = {
    barber_price = 500,
    tattoo_price = 1500,
  },
}

Lumo.CharacterCreation = {
  enabled = true, -- master toggle for creation clothing flow
  shopId = nil, -- set a specific clothing shopId or leave nil to auto-pick
  allowedCategories = { -- categories shown during creation; empty = none
    "jacket", "undershirt", "vests", "arms", "decals",
    "neck", "bags", "pants", "shoes",
    "hats", "mask", "glasses", "ear", "watch", "bracelet"
  },
  minItemsDefault = 200, -- min items per category (hard-capped to 200 in client)
  maxItemsDefault = 200, -- max items per category (hard-capped to 200 in client)
  minItemsPerCategory = {}, -- per-category min overrides
  maxItemsPerCategory = {}, -- per-category max overrides
  drawableAllowlist = {}, -- restrict to specific drawable IDs per category
  drawableRanges = {}, -- restrict to drawable ranges; empty = all available drawables
  forceFree = true -- set false to show prices during creation
}

-- Model selector for character creation.
-- If a player is not allowed by whitelist, their current model (from multicharacter) is kept.
Lumo.ModelSelection = {
  enabled = true,
  allowEveryone = false, -- true = all players can change model in creation UI
  fallbackToCurrentModel = true, -- false = fallback to freemode model by gender
  useDefaultPresets = false, -- true = auto add male/female freemode when presets list is empty
  whitelist = {
    citizenids = {}, -- example: { "ABCD1234" }
    licenses = {
    },
    jobs = {}, -- example: { "police", "admin" }
    gangs = {}, -- example: { "ballas" }
  },
  presets = {
    { id = "male_freemode", label = "Male Freemode", model = "mp_m_freemode_01", gender = "male" },
    { id = "female_freemode", label = "Female Freemode", model = "mp_f_freemode_01", gender = "female" },
    { id = "cop_male", label = "Police Male", model = "s_m_y_cop_01", gender = "male" },
    { id = "business_female", label = "Business Female", model = "a_f_y_business_01", gender = "female" },
    { id = "medic_male", label = "Medic Male", model = "s_m_m_paramedic_01", gender = "male" },
    { id = "hipster_female", label = "Hipster Female", model = "a_f_y_hipster_01", gender = "female" },
    -- Only models listed here are shown in UI.
    -- Add custom models below (gender should be "male", "female", or "keep")
    -- { id = "cop_male", label = "Police Male", model = "s_m_y_cop_01", gender = "male" },
  }
}

-- Job-only changing rooms. These use lumo-interact for the world prompt and
-- lumo-clothing for saved outfits; no second clothing resource is required.
Lumo.JobLockers = {
  { id = "police_mrpd", label = "Police Locker", job = "police", uniform = "police", coords = vector4(454.37, -993.23, 30.69, 357.75), ped = "s_m_y_cop_01", scenario = "WORLD_HUMAN_CLIPBOARD" },
  { id = "ems_pillbox", label = "EMS Locker", job = "ambulance", uniform = "ambulance", coords = vector4(341.93, -586.24, 43.32, 339.41), ped = "s_m_m_paramedic_01", scenario = "WORLD_HUMAN_CLIPBOARD" },

  -- Each mechanic company gets a locker beside its existing parts stash.
  { id = "mechanic_city", label = "City Workshop Locker", job = "mechanic", uniform = "mechanic", coords = vector4(-345.60, -123.08, 39.01, 239.77), ped = "s_m_y_xmech_01", scenario = "WORLD_HUMAN_CLIPBOARD" },
  { id = "mechanic_harmony", label = "Harmony Workshop Locker", job = "mechanic2", uniform = "mechanic", coords = vector4(1186.15, 2636.62, 38.40, 283.56), ped = "s_m_y_xmech_01", scenario = "WORLD_HUMAN_CLIPBOARD" },
  { id = "mechanic_airport", label = "Airport Workshop Locker", job = "mechanic3", uniform = "mechanic", coords = vector4(-1139.92, -2005.77, 13.18, 142.70), ped = "s_m_y_xmech_01", scenario = "WORLD_HUMAN_CLIPBOARD" },
  { id = "mechanic_bennys", label = "Benny's Locker", job = "bennys", uniform = "mechanic", coords = vector4(-201.87, -1314.76, 31.09, 181.40), ped = "s_m_y_xmech_01", scenario = "WORLD_HUMAN_CLIPBOARD" },
  { id = "mechanic_beeker", label = "Beeker's Locker", job = "beeker", uniform = "mechanic", coords = vector4(99.92, 6621.45, 32.44, 139.65), ped = "s_m_y_xmech_01", scenario = "WORLD_HUMAN_CLIPBOARD" },
}

-- Presets are deliberately temporary: putting on a duty uniform does not
-- overwrite the player's saved civilian appearance. Saved outfits selected in
-- the Lumo wardrobe continue to use the normal persistent outfit flow.
Lumo.JobUniforms = {
  police = {
    male = {
      { label = "Patrol - Short Sleeve", minGrade = 0, outfitData = {
        pants = { item = 24, texture = 0 }, arms = { item = 19, texture = 0 },
        ["t-shirt"] = { item = 58, texture = 0 }, vest = { item = 0, texture = 0 },
        torso2 = { item = 55, texture = 0 }, shoes = { item = 51, texture = 0 },
        accessory = { item = 0, texture = 0 }, bag = { item = 0, texture = 0 },
        hat = { item = -1, texture = 0 }, glass = { item = 0, texture = 0 }, mask = { item = 0, texture = 0 },
      } },
      { label = "Patrol - Long Sleeve", minGrade = 1, outfitData = {
        pants = { item = 24, texture = 0 }, arms = { item = 20, texture = 0 },
        ["t-shirt"] = { item = 58, texture = 0 }, vest = { item = 0, texture = 0 },
        torso2 = { item = 317, texture = 0 }, shoes = { item = 51, texture = 0 },
        accessory = { item = 0, texture = 0 }, bag = { item = 0, texture = 0 },
        hat = { item = -1, texture = 0 }, glass = { item = 0, texture = 0 }, mask = { item = 0, texture = 0 },
      } },
    },
    female = {
      { label = "Patrol - Short Sleeve", minGrade = 0, outfitData = {
        pants = { item = 133, texture = 0 }, arms = { item = 31, texture = 0 },
        ["t-shirt"] = { item = 35, texture = 0 }, vest = { item = 34, texture = 0 },
        torso2 = { item = 48, texture = 0 }, shoes = { item = 52, texture = 0 },
        accessory = { item = 0, texture = 0 }, bag = { item = 0, texture = 0 },
        hat = { item = -1, texture = 0 }, glass = { item = 0, texture = 0 }, mask = { item = 0, texture = 0 },
      } },
      { label = "Patrol - Long Sleeve", minGrade = 1, outfitData = {
        pants = { item = 133, texture = 0 }, arms = { item = 31, texture = 0 },
        ["t-shirt"] = { item = 35, texture = 0 }, vest = { item = 34, texture = 0 },
        torso2 = { item = 327, texture = 0 }, shoes = { item = 52, texture = 0 },
        accessory = { item = 0, texture = 0 }, bag = { item = 0, texture = 0 },
        hat = { item = -1, texture = 0 }, glass = { item = 0, texture = 0 }, mask = { item = 0, texture = 0 },
      } },
    },
  },
  ambulance = {
    male = {
      { label = "Paramedic Duty Uniform", minGrade = 0, outfitData = {
        arms = { item = 85, texture = 0 }, ["t-shirt"] = { item = 129, texture = 0 },
        torso2 = { item = 250, texture = 0 }, vest = { item = 0, texture = 0 },
        decals = { item = 58, texture = 0 }, accessory = { item = 127, texture = 0 },
        bag = { item = 0, texture = 0 }, pants = { item = 96, texture = 0 },
        shoes = { item = 54, texture = 0 }, mask = { item = 0, texture = 0 },
        hat = { item = -1, texture = 0 }, glass = { item = 0, texture = 0 },
      } },
    },
    female = {
      { label = "Paramedic Duty Uniform", minGrade = 0, outfitData = {
        arms = { item = 109, texture = 0 }, ["t-shirt"] = { item = 159, texture = 0 },
        torso2 = { item = 258, texture = 0 }, vest = { item = 0, texture = 0 },
        decals = { item = 66, texture = 0 }, accessory = { item = 97, texture = 0 },
        bag = { item = 0, texture = 0 }, pants = { item = 99, texture = 0 },
        shoes = { item = 55, texture = 0 }, mask = { item = 0, texture = 0 },
        hat = { item = -1, texture = 0 }, glass = { item = 0, texture = 0 },
      } },
    },
  },
  mechanic = {
    male = {
      { label = "Workshop Coveralls", minGrade = 0, outfitData = {
        mask = { item = 0, texture = 0 }, arms = { item = 112, texture = 0 },
        pants = { item = 98, texture = 0 }, shoes = { item = 24, texture = 0 },
        ["t-shirt"] = { item = 15, texture = 0 }, torso2 = { item = 104, texture = 0 },
        vest = { item = 0, texture = 0 }, hat = { item = -1, texture = 0 },
      } },
    },
    female = {
      { label = "Workshop Coveralls", minGrade = 0, outfitData = {
        mask = { item = 0, texture = 0 }, arms = { item = 113, texture = 0 },
        pants = { item = 101, texture = 0 }, shoes = { item = 25, texture = 0 },
        ["t-shirt"] = { item = 14, texture = 0 }, torso2 = { item = 109, texture = 0 },
        vest = { item = 0, texture = 0 }, hat = { item = -1, texture = 0 },
      } },
    },
  },
}

-- "None" preset values used when a category item with drawable -1 is applied.
-- Props use -1 to clear. Clothing components use explicit fallback drawables.
Lumo.NoneComponents = {
  male = {
    ["torso2"] = { item = 15, texture = 0 },
    ["t-shirt"] = { item = 15, texture = 0 },
    vest = { item = 0, texture = 0 },
    arms = { item = 15, texture = 0 },
    pants = { item = 21, texture = 0 },
    shoes = { item = 34, texture = 0 },
    mask = { item = 0, texture = 0 },
    bag = { item = 0, texture = 0 },
    accessory = { item = 0, texture = 0 },
    decals = { item = 0, texture = 0 },
  },
  female = {
    ["torso2"] = { item = 15, texture = 0 },
    ["t-shirt"] = { item = 14, texture = 0 },
    vest = { item = 0, texture = 0 },
    arms = { item = 15, texture = 0 },
    pants = { item = 15, texture = 0 },
    shoes = { item = 35, texture = 0 },
    mask = { item = 0, texture = 0 },
    bag = { item = 0, texture = 0 },
    accessory = { item = 0, texture = 0 },
    decals = { item = 0, texture = 0 },
  }
}

bridge/config.lua ​

lua
--- lumo-clothing — framework settings
---
--- QBCore, Qbox and ESX are detected automatically; with none of them running
--- the script runs standalone. Most servers never touch this file. The rest
--- of the settings live in shared.lua.
---
--- This folder (bridge/) is the script's built-in framework layer. You do NOT
--- need the separate lumo-bridge resource.

LumoBridgeConfig = {
    --- 'auto' | 'qb' | 'qbx' | 'esx' | 'standalone' | 'custom'
    ---   auto       — Qbox (qbx_core) > QBCore (qb-core) > ESX (es_extended) > Fallback
    Framework = 'auto',

    --- Used by 'auto' when no core is running. 'standalone' = no framework.
    --- Set 'custom' only if you wire your own core in Custom below.
    Fallback = 'standalone',

    --- Print the detected framework and every unresolved call to the console.
    Debug = false,

    --- Resource names, in case yours are renamed.
    Resources = {
        qb  = 'qb-core',
        qbx = 'qbx_core',
        esx = 'es_extended',
        oxlib = 'ox_lib',
    },

    --- How long a server callback waits for its answer before giving up (ms).
    CallbackTimeout = 15000,

    --- Inventory for clothing items (Config.ClothingItems in shared.lua).
    --- 'auto' probes: lumo-inventory, ox_inventory, qb-inventory, qs-inventory,
    --- ps-inventory, then ESX's own inventory. Force one if you run several.
    Inventory = 'auto',

    --- Targeting for shop peds and lockers. 'auto' probes: ox_target,
    --- qb-target, qtarget; without one, an [E] prompt is used.
    Target = 'auto',

    ESX = {
        --- How ESX multichar identifiers look. %s = slot, %l = licence hex.
        IdentifierFormat = 'char%s:%l',
        --- true only for an ESX build without per-slot identifiers.
        SingleCharacter = false,
        --- This script is not a character selector; leave false.
        VerifyMulticharAtBoot = false,
    },

    --- Standalone (no framework). A character script that owns characters
    --- (lumo-multicharacter) is used when it runs; without one every player is
    --- one character keyed by their Rockstar licence.
    Standalone = {
        OwnsCharacters = false,             -- keep false in this script
        CharacterResource = 'lumo-multicharacter',
        RememberPosition = false,           -- positions are a spawn script's job
        SetPedModel = false,                -- the saved skin decides the ped
        --- With neither a character script nor lumo-spawn, load the saved
        --- skin when FiveM's spawnmanager spawns the player.
        AnnounceOnSpawn = true,

        --- There is no money in standalone. With FreeShopping on, shops,
        --- barbers and tattoos cost nothing. To charge, plug your economy in:
        ---   GetMoney    = function(source, account) return number end,
        ---   RemoveMoney = function(source, account, amount, reason) return true/false end,
        ---   AddMoney    = function(source, account, amount, reason) return true/false end,
        --- account is 'cash' or 'bank'. Any hook set here wins over FreeShopping.
        FreeShopping = true,
        Money = {},

        --- Job lockers need a job. Return { name = 'police', grade = { level = 2 } }
        --- from here if your server tracks jobs, or leave nil (no job lockers).
        GetJob = nil,
    },

    --- Only for Framework = 'custom'. See docs/CUSTOM-FRAMEWORKS.md.
    Custom = {
        client = {},
        server = {},
        inventory = {},
    },
}