# Welcome to RAINMAD Scripts

Welcome to **RAINMAD Scripts**, your premier destination for high-quality FiveM resources and scripts! Whether you’re a server owner looking to enhance your player's experience or a developer seeking top-tier scripts, **RAINMAD** has got you covered.

<figure><img src="https://content.gitbook.com/content/a5Ce4DSFKExllozwopRT/blobs/YOq1lmUjxvoSAzcAzoFB/websitelogo.png" alt="" width="395"><figcaption></figcaption></figure>

## About Us

At RAINMAD, we are passionate about providing the FiveM community with innovative and reliable resources. Our store features a diverse range of scripts, from gameplay-enhancing tools to fully customizable mods, all designed to help you create the best possible server environment.

## What We Offer

* **Premium Scripts**: Our scripts are crafted with attention to detail, ensuring seamless integration and optimal performance on your server.
* **Ongoing Support**: Our commitment doesn’t end with your purchase. We provide ongoing support and updates to keep your resources running smoothly.

## Why Choose RAINMAD?

* **Trusted by the Community**: With a proven track record and a growing community of satisfied customers, Rainmad is a name you can trust.
* **Regular Updates**: We regularly update our scripts to ensure compatibility with the latest FiveM versions and to add new features.
* **Easy Integration**: Our scripts are designed to be easy to install and configure, so you can get them up and running with minimal hassle.

## Get Started

Explore our store and discover how RAINMAD can elevate your FiveM server to the next level. If you have any questions or need assistance, our support team is always here to help.

***

**RAINMAD** is not affiliated with or endorsed by Rockstar North, Take-Two Interactive or other rights holders. © **2025, RAINMAD.**


# FAQ

## Frequently Asked Questions

<details>

<summary>Can I get a discount?</summary>

We are unable to provide discounts on request. Any active promotions or sales will be shared in the [**#**︱discount](https://discord.com/channels/869260667348197416/1233486052686299307) channel, so keep an eye out there!

</details>

<details>

<summary>I can’t find my transaction ID. Where can I locate it?</summary>

You can find your transaction ID by logging in at: <https://checkout.tebex.io/payment-history/login>.

</details>

<details>

<summary>I’m getting a “payment declined” message during checkout. What should I do?</summary>

If your payment was declined, here are a few steps you can try:

1. Wait for a while and try again later.
2. Use a different payment method or credit card.
3. Try a different device or network (e.g., use a VPN).

If none of these solutions work, it’s possible that your account has been restricted by Tebex. Alternatively, you could ask a trusted friend to purchase the asset for you and transfer it to your account.

</details>

<details>

<summary>My Keymaster account has been hacked. Can you transfer the scripts to me?</summary>

Unfortunately, we can't transfer assets between Keymaster accounts, even if your account has been hacked. We recommend contacting [Cfx.re support](https://support.cfx.re/hc/en-us) for further assistance.

</details>

<details>

<summary>How can I cancel my subscription?</summary>

You can cancel or manage your subscription by visiting: <https://checkout.tebex.io/payment-history/login>.

</details>

<details>

<summary>I have XAMPP. How can I switch to MariaDB?</summary>

You can easily switch to MariaDB by following this tutorial: <https://www.youtube.com/watch?v=bigFDwM8YCA>.

</details>


# Support and Assistance

For any support or assistance with our scripts, please follow these steps:

* **Join Our Discord Server**: To receive support, you must join our official Discord server. This is where we manage all support requests and provide updates.
* **Create a Support Ticket**: Once you’ve joined the Discord server, create a support ticket in the designated channel. Provide as much detail as possible about your issue or question to help us assist you effectively.

{% hint style="info" %}
**Working Days**: Please note that our support team is available from Monday to Saturday. We aim to respond to your tickets as promptly as possible during these days.
{% endhint %}

If you have any further questions or need assistance with the ticket creation process, don’t hesitate to ask in the Discord server. We’re here to help!


# Discord Roles

## Guide to Receiving Your Discord Role After a Tebex Purchase

Receiving your Discord role after making a purchase through Tebex is crucial for staying informed about updates, following customer announcements, and verifying your purchase. Below, you’ll find the steps to obtain your role.

## Automatic Role Assignment

Once your purchase is complete, our Discord bot will automatically assign you the appropriate role. This process is important for verifying your purchase and ensuring you receive the latest updates and exclusive customer announcements.

#### Link Your Discord Account

* Before making a purchase, ensure your Discord account is linked to our store via Tebex.
* You can check if your account is linked by visiting the **Account Settings** on our Tebex store.

#### Complete Your Purchase

* Purchase the desired product from our Tebex store.
* After the transaction is completed, the Discord bot will recognize your account and automatically assign the role.

#### Verify Your Role

* Log in to our Discord server to check if your role has been assigned.
* If the role isn’t assigned immediately, please wait 5-10 minutes. If the issue persists, contact our support team.

## Manual Role Assignment (Using a Bot Command)

If your role isn’t assigned automatically, you can manually claim it using the following steps:

#### Enter the Command

* Go to the designated claim  channel on our Discord server.
* Use the following command to manually claim your role:

```
/claim
```

### Troubleshooting

* Ensure that you’re using the correct command.
* If you still encounter issues, please reach out to our support team.

<figure><img src="https://3946157545-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fa5Ce4DSFKExllozwopRT%2Fuploads%2FxaQj7VWiR367EunHJxzQ%2Fdc-logo.png?alt=media&amp;token=e8a42ff4-c968-4aec-a902-d7ce8a4c5d12" alt="" width="333"><figcaption><p><a href="https://discord.com/invite/sJG56ZsGrr">Rainmad Scripts Discord Server</a></p></figcaption></figure>


# How to Update Your Asset

Keeping your assets up-to-date via FiveM Keymaster ensures that you have the latest features, improvements, and bug fixes. Follow these steps to smoothly update your asset:

## Step 1: Backup Your Current Version

Before updating, it's crucial to back up your current version in case you need to revert to it later.

* **Server Files**: Copy your current asset folder to a safe location on your server.
* **Database** (if applicable): Export your current database or relevant tables.

## Step 2: Access FiveM Keymaster

* Go to the [**FiveM Keymaster**](https://keymaster.fivem.net/) website.
* Log in with the credentials you used to purchase or manage your assets.

## Step 3: Download the Latest Version

* Navigate to the **"Granted Assets"** section.
* Find the asset you want to update and click on it.
* Download the latest version of the asset to an easily accessible location on your computer.

## Step 4: Review the Changelog

* Check the **Changelog** file included with the update or available on the Keymaster page.
* Note any changes that might require you to update your configuration or other scripts.

## Step 5: Replace the Old Files

* Delete or replace the old asset files on your server with the new ones you downloaded from Keymaster.
  * **Important:** Do not overwrite your `config.lua` or `cfg.lua` files unless the update specifically requires it.
* If the asset includes a `config.lua` or similar file, compare it with your old version to merge any new settings.

## Step 6: Update Database (if necessary)

* If the update requires database changes, follow the instructions provided in the update notes.
* Run any provided SQL scripts to update your database structure.

## Step 7: Restart Your Server

* After replacing the files and updating the database (if needed), restart your server to apply the update.
* Check the server console for any errors during startup.

## Step 8: Test the Asset

* Once the server is running, thoroughly test the updated asset to ensure everything works as expected.
* Verify that new features or fixes are functioning correctly.

## Troubleshooting

If you encounter any issues after updating:

* Review the installation and update instructions again.
* Check for any missing dependencies or configuration errors.
* Contact support if you need further assistance.

## Stay Updated

* Regularly check for updates on FiveM Keymaster to ensure you always have the latest version.
* Join our [Discord Server](https://discord.com/invite/sJG56ZsGrr) for update notifications.


# FiveM Asset Escrow System

The FiveM Asset Escrow System is designed to protect and encrypt assets purchased on the FiveM platform, ensuring robust security and protection against unauthorized access or distribution. Below, you will find common issues and solutions related to this system. Please review each section carefully to resolve any problems you may encounter.

## Common Issues and Solutions

### 1. Error: "Failed to Verify Protected Resource: rm\_resourceName"

**Description:** This error indicates that the system was unable to verify the integrity of the protected resource, possibly due to file corruption during transfer.

**Solution:**

* Ensure that all encrypted files are correctly transferred. The `.fxap` file is crucial and should not be omitted. Some FTP clients might miss these files; if you are using [FileZilla](https://filezilla-project.org/), consider switching to [WinSCP](https://winscp.net/eng/index.php) to ensure complete transfer.

### 2. Error: "You Lack the Required Entitlement to Use rm\_resourceName"

**Description:** This issue arises when the FiveM server license key in use does not match the one associated with the asset purchase. It could also be related to installation or activation issues.

**Solution:**

* Verify that your FiveM server license key is correctly linked to the account from which the asset was purchased. If there’s a mismatch, update the license key accordingly.
* Ensure you have restarted your server after installing the asset. Sometimes, a server restart is necessary for proper activation.

### 3. Error: "Syntax Error \</1> or Similar"

**Description:** Syntax errors can be caused by a variety of issues, including outdated artifact versions, unauthorized modifications, or failure to restart the server.

**Solution:**

* Confirm that you are using an artifact version greater than or equal to +4960. You can download the latest artifacts for Linux or Windows from the official FiveM website.
* Check if any encrypted code has been modified, which should be avoided. Refrain from editing encrypted files and ensure to restart the server after installation to apply changes.

## Additional Tips

* Always use the latest version of FiveM artifacts to ensure compatibility with the escrow system.
* Avoid making unauthorized changes to encrypted assets to prevent system errors.
* If problems persist, consult our support team through the designated support channels for further assistance.

For any further issues or questions, please join our Discord server or check the detailed documentation available in the support section.


# Common Issues

This section is dedicated to addressing the most common issues you may face while setting up or using any of our scripts. We recommend carefully reading through the list of problems and their solutions to troubleshoot effectively and ensure everything runs smoothly.

Remember, a quick review of this section could save you time and prevent potential frustration. If you encounter a problem that isn't covered here or need additional help, please reach out via Discord at join our support server at [Rainmad Scripts](https://discord.com/invite/sJG56ZsGrr).

<figure><img src="https://content.gitbook.com/content/a5Ce4DSFKExllozwopRT/blobs/YOq1lmUjxvoSAzcAzoFB/websitelogo.png" alt="" width="188"><figcaption></figcaption></figure>

<details>

<summary>Casino Heist - Double Door or Door Not Opening</summary>

If you're having issues with the doors in the Casino Heist, try stopping the `bob74_ipl` or `fivem-ipl` script and then test the heist again. If this fixes the problem, it means there's a conflict. You should remove the Casino IPL codes from your IPL script to avoid this issue in the future.

</details>

<details>

<summary>ESX - attempt to index a nil value (local 'player')</summary>

If you encounter the "attempt to index a nil value (local 'player')" error, follow these steps to resolve it:

1. **Open the `server.lua` file** of the script where you encountered the error.
2. **Find the function call** `ESX.GetPlayers()` or `GetPlayers()`.
3. **Replace it with** `ESX.GetExtendedPlayers()`.
4. **Locate the following code:**

```lua
for i = 1, #players do
    local player = ESX.GetPlayerFromId(players[i])
    for k, v in pairs(Config['CasinoHeist']['dispatchJobs']) do
        if player['job']['name'] == v then
            policeCount = policeCount + 1
        end
    end
end
```

Change it to:

```lua
for i = 1, #players do
    local player = players[i]
    for k, v in pairs(Config['CasinoHeist']['dispatchJobs']) do
        if player['job']['name'] == v then
            policeCount = policeCount + 1
        end
    end
end
```

**Save and restart** your server to apply the changes.

</details>

<details>

<summary>Humane Labs. Heist - Grill Cutting Not Working</summary>

Some users may encounter issues with grill cutting during the Humane Labs heist. The root cause of this problem is that the `fingerpoint` script continues to function while underwater. To resolve this issue, you can follow the steps below:

1. **Solution with Provided File**: The easiest solution is to use the updated `fingerpoint` script provided by us. This file includes a condition that prevents the script from running underwater, automatically resolving the issue.
2. **Update Your Own Script**: If you prefer to continue using your own `fingerpoint` script, you'll need to add a condition to prevent it from working while underwater:
   * Open your script file.
   * Add a check to ensure the script doesn't run when the player is underwater.
   * For example, you can use the `IsPedSwimming` function to check if the player is in water and stop the process accordingly.

Once these changes are made, the grill cutting issue during the Humane Labs heist should be resolved.\
\
Example:

```lua
local mp_pointing = false
local keyPressed = false

local function startPointing()
    local ped = GetPlayerPed(-1)
    if not IsPedSwimming(ped) then
        RequestAnimDict("anim@mp_point")
        while not HasAnimDictLoaded("anim@mp_point") do
            Wait(0)
        end
        SetPedCurrentWeaponVisible(ped, 0, 1, 1, 1)
        SetPedConfigFlag(ped, 36, 1)
        Citizen.InvokeNative(0x2D537BA194896636, ped, "task_mp_pointing", 0.5, 0, "anim@mp_point", 24)
        RemoveAnimDict("anim@mp_point")
    end
end

local function stopPointing()
    local ped = GetPlayerPed(-1)
    if not IsPedSwimming(ped) then
        Citizen.InvokeNative(0xD01015C7316AE176, ped, "Stop")
        if not IsPedInjured(ped) then
            ClearPedSecondaryTask(ped)
        end
        if not IsPedInAnyVehicle(ped, 1) then
            SetPedCurrentWeaponVisible(ped, 1, 1, 1, 1)
        end
        SetPedConfigFlag(ped, 36, 0)
        ClearPedSecondaryTask(PlayerPedId())
    end
end

local once = true
local oldval = false
local oldvalped = false

Citizen.CreateThread(function()
    while true do
        Wait(0)

        if once then
            once = false
        end

        if not keyPressed then
            if IsControlPressed(0, 29) and not mp_pointing and IsPedOnFoot(PlayerPedId()) then
                Wait(200)
                if not IsControlPressed(0, 29) then
                    keyPressed = true
                    startPointing()
                    mp_pointing = true
                else
                    keyPressed = true
                    while IsControlPressed(0, 29) do
                        Wait(50)
                    end
                end
            elseif (IsControlPressed(0, 29) and mp_pointing) or (not IsPedOnFoot(PlayerPedId()) and mp_pointing) then
                keyPressed = true
                mp_pointing = false
                stopPointing()
            end
        end

        if keyPressed then
            if not IsControlPressed(0, 29) then
                keyPressed = false
            end
        end
        if Citizen.InvokeNative(0x921CE12C489C4C41, PlayerPedId()) and not mp_pointing then
            stopPointing()
        end
        if Citizen.InvokeNative(0x921CE12C489C4C41, PlayerPedId()) then
            if not IsPedOnFoot(PlayerPedId()) and not IsPedSwimming(PlayerPedId()) then
                stopPointing()
            else
                local ped = GetPlayerPed(-1)
                local camPitch = GetGameplayCamRelativePitch()
                if camPitch < -70.0 then
                    camPitch = -70.0
                elseif camPitch > 42.0 then
                    camPitch = 42.0
                end
                camPitch = (camPitch + 70.0) / 112.0

                local camHeading = GetGameplayCamRelativeHeading()
                local cosCamHeading = Cos(camHeading)
                local sinCamHeading = Sin(camHeading)
                if camHeading < -180.0 then
                    camHeading = -180.0
                elseif camHeading > 180.0 then
                    camHeading = 180.0
                end
                camHeading = (camHeading + 180.0) / 360.0

                local blocked = 0
                local nn = 0

                local coords = GetOffsetFromEntityInWorldCoords(ped, (cosCamHeading * -0.2) - (sinCamHeading * (0.4 * camHeading + 0.3)), (sinCamHeading * -0.2) + (cosCamHeading * (0.4 * camHeading + 0.3)), 0.6)
                local ray = Cast_3dRayPointToPoint(coords.x, coords.y, coords.z - 0.2, coords.x, coords.y, coords.z + 0.2, 0.4, 95, ped, 7);
                nn,blocked,coords,coords = GetRaycastResult(ray)

                Citizen.InvokeNative(0xD5BB4025AE449A4E, ped, "Pitch", camPitch)
                Citizen.InvokeNative(0xD5BB4025AE449A4E, ped, "Heading", camHeading * -1.0 + 1.0)
                Citizen.InvokeNative(0xB0A6CFD2C69C1088, ped, "isBlocked", blocked)
                Citizen.InvokeNative(0xB0A6CFD2C69C1088, ped, "isFirstPerson", Citizen.InvokeNative(0xEE778F8C7E1142E2, Citizen.InvokeNative(0x19CAFA3C87F7C2FF)) == 4)

            end
        end
    end
end)
```

</details>

<details>

<summary>Van Heist - Enter/Exit Issue</summary>

If you're experiencing issues with entering or exiting the van during the Van Heist, you'll need to update the van's stream file. To resolve this issue:

1. **Download the Provided File**: We have a file available for download that will fix the entry/exit problem.
2. **Update the Stream File**: After downloading the file, update your van's stream file by replacing it with the one provided.

Once you've updated the stream file, the issue with entering and exiting the van during the heist should be resolved.\
\
[Download Van File](https://cdn.discordapp.com/attachments/911993276540784711/1143457453774733352/k4mb1_heist_van2.ydr?ex=66d967bd\&is=66d8163d\&hm=deb152c4522d146589d5c82d975dcd1a44c67bc5c4eb96310e9c5317690c29c0&)

</details>

<details>

<summary>Object Spawning Issues</summary>

If the objects in the script you've purchased are not spawning, there could be a couple of reasons for this:

1. **Anticheat Interference**:
   * If you're using an anticheat, it might be blocking the objects from spawning. In this case, you'll need to whitelist the script within your anticheat configuration to allow the objects to spawn correctly.
2. **Other Scripts Deleting Objects**:
   * If you don't have an anticheat and objects are still being deleted, it's likely that another script in your server is responsible for removing these objects. You'll need to check your server's scripts to identify which one might be deleting the objects and adjust its configuration or remove it if necessary.

By following these steps, you should be able to resolve any issues with objects not spawning in your purchased script.

</details>


# How to Add Webhooks

## How to Add Your Webhook (Old & New File Structure)

Webhooks are essential for integrating external services like Discord with your FiveM server. They can be used to log player activity, send notifications, or track in-game events. Below you will find instructions for both the **old file structure** (`editable_server.lua`) and the **new file structure** (`server/discord_log.lua`).

***

## **🔄New Files: Using `server/discord_log.lua`**

This is the recommended and updated method for configuring your webhook.

### **Step 1: Obtain Your Webhook URL**

**Create or Use an Existing Webhook:**\
Set up a webhook using your preferred platform (e.g., Discord). Once created, copy the webhook URL.

**Keep the URL Secure:**\
Treat your webhook URL like a password. Never share it publicly or store it in unsecured locations.

### **Step 2: Locate the `discord_log.lua` File**

**Access Your Server Files:**\
Connect to your server using FTP or a file manager.

**Navigate to the Correct Path:**\
Go to the `server` directory inside your script folder and locate the `discord_log.lua` file:\
`resources/[your_script]/server/discord_log.lua`

**Open the File:**\
Use a text editor such as VS Code or Notepad++ to open it.

### **Step 3: Insert Your Webhook URL**

Find the configuration section that looks like this:

```lua
discord = {
    ['webhook'] = 'https://discord.com/api/webhooks/xxxxxxxx/xxxxxxxx',
    ['name'] = 'Rainmad Scripts',
    ['image'] = 'https://cdn.discordapp.com/avatars/869260464775921675/dff6a13a5361bc520ef126991405caae.png?size=1024',
}
```

**Replace the `'webhook'` value** with your actual Discord webhook URL. Paste it inside the quotation marks. Do not change any other lines unless necessary.

**Step 4: Test Your Webhook**

* **Restart Your Server** to apply the changes.
* Perform an action that triggers the webhook (e.g., player join, command usage).
* **Check your Discord channel** to see if the message was sent.

***

## **🗂️ Old Files: Using `editable_server.lua`**

This method was used in older versions of the script.

### **Step 1: Obtain Your Webhook URL**

**Create or Use an Existing Webhook:**\
If you haven't already, create a webhook in your preferred service (e.g., Discord, Slack).\
Copy the webhook URL provided by the service.

**Keep the URL Secure:**\
Treat your webhook URL like a password. Do not share it publicly or expose it in unsecured locations.

### **Step 2: Locate the `editable_server.lua` File**

**Access Your Server Files:**\
Connect to your server via FTP or your file manager.\
Navigate to the directory where your server's scripts are stored.

**Open `editable_server.lua`:**\
Locate the `editable_server.lua` file in the script directory.\
Open the file using a text editor like Notepad++ or VS Code.

### **Step 3: Insert Your Webhook URL**

**Find the Webhook Section:**\
Search for a comment like `-- Webhooks`. You should see a block like this:

```lua
discord = {
    ['webhook'] = 'https://discord.com/api/webhooks/xxxxxxxx/xxxxxxxx',
    ['name'] = 'Rainmad Scripts',
    ['image'] = 'https://cdn.discordapp.com/avatars/869260464775921675/dff6a13a5361bc520ef126991405caae.png?size=1024',
}
```

Replace the placeholder webhook URL with your actual one, keeping it inside the quotation marks.

### **Step 4: Test the Webhook**

* Restart your server.
* Perform an action that would trigger the webhook.
* Check your Discord (or other service) to ensure it was received.

***

## **⚠️ Troubleshooting & Security Tips**

* **Webhook not working?**\
  Double-check the URL for typos and ensure it’s placed correctly in the respective file.
* **No events triggered?**\
  Confirm that the server is set up to fire the events linked to the webhook.
* **Security Reminder:**\
  Never share your webhook URL publicly. Treat it like a password.


# Target Options Configuration

In the `config.lua` file, you can configure which targeting system your script will use. The available options are `qtarget`, `qb-target`, `ox_target`, and `default` (ShowHelpNotification). Follow the instructions below to set your preferred targeting option.

## Step 1: Selecting Your Target Option

Locate the `target` section in your `config.lua` or `cfg.lua` file. This section controls which targeting system is active.

### Configuration Options:

```lua
-- WARNING: Default mode will consume more ms. This option may cause performance loss!
targetScript = 'default', -- Target script name (qtarget or qb-target or ox_target or default (for showhelpnotification))
```

### qtarget

If you choose `qtarget`, ensure you have the corresponding integration set up.

* **Set the target option in `config.lua`:**

```lua
-- WARNING: Default mode will consume more ms. This option may cause performance loss!
targetScript = 'qtarget', -- Target script name (qtarget or qb-target or ox_target or default (for showhelpnotification))
```

* Ensure that `qtarget` is correctly installed and started on your server.

### qb-target

If you choose `qb-target`, ensure you have the corresponding integration set up.

* **Set the target option in `config.lua`:**

```lua
-- WARNING: Default mode will consume more ms. This option may cause performance loss!
targetScript = 'qb-target', -- Target script name (qtarget or qb-target or ox_target or default (for showhelpnotification))
```

* Ensure that `qb-target`is correctly installed and started on your server.

### ox\_target

If you choose `ox_target`, ensure you have the corresponding integration set up.

* **Set the target option in `config.lua`:**

```lua
-- WARNING: Default mode will consume more ms. This option may cause performance loss!
targetScript = 'ox_target', -- Target script name (qtarget or qb-target or ox_target or default (for showhelpnotification))
```

* Ensure that `ox_target`is correctly installed and started on your server.

### default (ShowHelpNotification)

The `default` option will display help notifications instead of using an advanced targeting system.

* **Set the target option in `config.lua`:**

```lua
-- WARNING: Default mode will consume more ms. This option may cause performance loss!
targetScript = 'default', -- Target script name (qtarget or qb-target or ox_target or default (for showhelpnotification))
```

* This option does not require additional setup beyond ensuring that the `ShowHelpNotification` functionality is properly implemented in your script.

## Step 2: Applying the Configuration

* **Save the Changes:**
  * After selecting your desired target system, save the changes to your `config.lua` file.
* **Restart Your Server:**
  * Restart your server to apply the new configuration.
* **Test the Targeting System:**
  * Verify that the selected targeting system is working as expected. Test interactions and ensure that the functionality matches the chosen option.

## **Troubleshooting**

* **Targeting System Not Working:** Ensure that the chosen target system is installed and correctly configured. Double-check your `config.lua` file for typos.
* **Interactions Not Displaying:** Verify that the integration with the target system is correctly set up and that there are no conflicts with other resources.


# How to Translate Your Script

Translating your script into different languages is made easy by modifying the `config.lua` file. Follow the steps below to customize the language settings using the `Strings` object within the `config.lua`.

## Step 1: Locate the `config.lua` File

* **Access Your Server Files:**
  * Connect to your server via FTP or use your file manager.
  * Navigate to the directory where your script is located.
* **Open `config.lua`:**
  * Find and open the `config.lua` file using a text editor (e.g., Notepad++, VS Code).

## Step 2: Find the `Strings` Object

Within the `config.lua` file, locate the `Strings` object. This object contains all the text strings used in the script that you can translate.

#### Example:

```lua
Strings = {
    ["welcomeMessage"] = "Welcome to our server!",
    ["errorMessage"] = "An error has occurred. Please try again.",
    -- Add more strings here
}
```

## Step 3: Translate the Strings

* **Identify the Text Strings:**
  * Review the `Strings` object to see which text strings are used in the script.
* **Translate Each String:**
  * Replace the English text (or the default language) with your desired translation. Ensure that the syntax remains correct.

#### Example of Translation:

```lua
Strings = {
    welcomeMessage = "Bienvenido a nuestro servidor!", -- Spanish translation
    errorMessage = "Se ha producido un error. Por favor, inténtelo de nuevo.", -- Spanish translation
    -- Add more translations here
}
```

**Save Your Changes:**

* After translating the strings, save the `config.lua` file.

## Step 4: Apply the Translation

* **Restart Your Server:**
  * To apply the new translations, restart your server.
* **Test the Script:**
  * Verify that the translated text appears correctly in your server's interface and messages.

## Troubleshooting

* **Text Not Displaying:** Ensure that the `config.lua` file is correctly edited and saved. Double-check for any syntax errors.
* **Incorrect Translations:** Review the translations for accuracy and context. Make sure the translated strings fit the intended use.

## Additional Tips

* **Backup Before Editing:** Always back up your original `config.lua` file before making changes.
* **Use Online Translation Tools:** If you're not fluent in the target language, consider using online translation tools or services for assistance.


# How to Integrate Your Custom Notification Script

## How to Integrate Your Custom Notification Script (Old & New File Structure)

Custom notifications help enhance player experience by using personalized or visually styled messages. Below are the instructions for both the **old integration method** using `editable_client.lua` and the **new method** using `bridge/frameworks/client/[framework].lua` files (`esx.lua` or `qb.lua` depending on your framework).

***

## **🔄 New Files: Using `bridge/frameworks/client/esx.lua` or `qb.lua`**

This is the **recommended method** for newer versions of the script.

### **Step 1: Locate the Framework File**

**Access Your Server Files:**\
Connect via FTP or a file manager.

**Navigate to the Notification File:**\
Go to the following directory based on your framework:\
`resources/[your_script]/bridge/frameworks/client/`\
Open either `esx.lua` or `qb.lua` depending on what your server uses.

### **Step 2: Find the `madCore.showNotify` Function**

Search for this function inside the file:

```lua
madCore.showNotify = function(msg)
    -- Default notification logic here
end
```

This function is called internally whenever the script needs to display a notification.

### **Step 3: Replace It With Your Custom Notification Script**

Example using `okokNotify`:

```lua
madCore.showNotify = function(msg)
    exports['okokNotify']:Alert('Alert', msg, 3000, 'info', true)
end
```

You can modify it to fit any custom notification script (e.g., `mythic_notify`, `ox_lib`, etc.).

### **Step 4: Test the Integration**

* Restart your server.
* Trigger an event that sends a notification.
* Verify that the custom notification appears in-game.

***

## **🗂️ Old Files: Using `editable_client.lua`**

This method was used in older versions of the script for managing notifications.

### **Step 1: Locate `editable_client.lua`**

* Access your server files via FTP or a file manager.
* Navigate to the directory where your client scripts are located.
* Open `editable_client.lua` using a text editor like Notepad++ or VS Code.

### **Step 2: Find the `ShowNotification` Function**

Search for this default notification function:

```lua
function ShowNotification(msg)
    SetNotificationTextEntry('STRING')
    AddTextComponentString(msg)
    DrawNotification(false, true)
end
```

### **Step 3: Integrate Your Custom Notification**

Replace the contents of the function with your custom notification script.\
For example, using `okokNotify`:

```lua
function ShowNotification(msg)
    exports['okokNotify']:Alert('Alert', msg, 3000, 'info', true)
end
```

Save the file after making changes.

### **Step 4: Test Your Integration**

* Restart your server.
* Perform any action that triggers a notification.
* Confirm that your custom notification is working in-game.

***

## **⚠️ Troubleshooting & Best Practices**

* **Notification Not Showing?**\
  Ensure the custom script is installed, started in `server.cfg`, and referenced properly.
* **Console Errors?**\
  Check the F8 client console or server logs for syntax or export-related errors.
* **Performance Issues?**\
  Optimize the custom script and test for any FPS drops or delays.
* **Make Backups:**\
  Always back up original files before editing them.
* **Check Docs:**\
  Refer to your custom notification script's documentation for detailed usage instructions.


# How to Change the Black Money Name

## How to Customize the Black Money Name (Old & New Script Versions)

In our scripts, black money is typically referred to as `markedbills`. However, you can easily rename it to `black_money`, `dirtymoney`, or anything else depending on your framework or preference. This guide walks you through the customization steps for both **old** and **new script versions**, including examples for **QBCore** and **ESX** frameworks.

***

## Newer Scripts

In newer versions of our scripts, black money behavior is centralized in configuration and logic files such as `shared/config.lua` and `editable_server.lua`.

### **Step 1: Configure `shared/config.lua`**

Navigate to `shared/config.lua` and locate the following configuration block:

```lua
lConfig.moneyOptions = {
    blackMoney = false,
    blackMoneyName = "markedbills", -- if you are using ESX use "black_money", for QBCore use "markedbills"
    blackMoneyIsItem = true,
    useMetadataForBlackMoney = true,
    moneyName = "money",
    moneyIsItem = false,
}
```

**Edit the following keys:**

* `blackMoneyName`: Change this to `"black_money"`, `"dirtymoney"`, or your preferred name.
* `blackMoneyIsItem`: Set to `false` if you're using an account-based system (like ESX), or `true` if it's an item (like QBCore).
* `useMetadataForBlackMoney`: Set to `true` only if you're using metadata-based tracking.

### **Step 2: Update Logic in `editable_server.lua`**

Locate and modify the `addBlackMoney` function. Below are examples for both frameworks:

&#x20;**QBCore Example**

```lua
self.addBlackMoney = function(amount)
    Player.Functions.AddItem('black_money', amount)
end
```

### **ESX Example**

```lua
self.addBlackMoney = function(amount)
    return Player.addAccountMoney("black_money", amount)
end
```

> 💡 Make sure the blackMoney-related config options match the logic used here (item vs. account).

***

## Older Scripts

In older versions, black money handling is directly embedded inside the `server.lua` file.

### **Step 1: Open `server.lua`**

Open your older script folder and locate `server.lua`.

### **Step 2: QBCore Integration**

Search for code that looks like this:

```lua
local info = {
    worth = count
}
player.Functions.AddItem('markedbills', 1, false, info)
TriggerClientEvent('inventory:client:ItemBox', src, QBCore.Shared.Items['markedbills'], "add")
```

Replace with:

```lua
player.Functions.AddItem('black_money', count)
```

> 🧹 You can remove the `info` table and `TriggerClientEvent` line if you’re not using metadata or item boxes.

### **Step 3: ESX Integration**

Search for:

```lua
Player.addAccountMoney("black_money", count)
```

If you want to rename `black_money` to something else (e.g., `dirtymoney`), simply update this line:

```lua
Player.addAccountMoney("dirtymoney", count)
```

Be sure to use this name consistently across all functions and config files.

***

#### Final Checklist

* 🔁 Restart your server after making changes.
* 🧪 Test black money creation (e.g., drug sale or robbery reward).
* 📦 Ensure the new item or account name is recognized in your inventory or UI.
* 🗂 Use consistent naming across all scripts, configs, and notifications.

***

#### 🛠 Troubleshooting

| Problem                  | Solution                                                            |
| ------------------------ | ------------------------------------------------------------------- |
| Black money not updating | Check all instances where the original name was used.               |
| Inventory errors         | Make sure `blackMoneyIsItem` and `blackMoneyName` are properly set. |
| Console warnings         | Verify syntax and correct function usage per your framework.        |


# How to Add an Item

To add a new item to your FiveM server, you need to modify specific files based on the inventory system you’re using. These files are located in the `[items]` directory within your script. Follow the instructions below based on your inventory system.

## For `qb-core` and `qb-inventory`

If you’re using `qb-core` and `qb-inventory`, you need to edit the `qb-core/shared/items.lua` file.

#### **Steps to Modify:**

* **Locate the `[items]` Directory:**
  * Connect to your server files via FTP or file manager.
  * Navigate to the `qb-core` resource directory and find the `qb-core/shared/items.lua`.
* **Open `shared.lua`:**
  * Use a text editor to open the `shared.lua` file within the `[items]` folder.
* **Add Your Item:**
  * Find the section where items are defined. Add your new item details to the existing item table.
* **Save and Restart:**
  * Save the `items.lua` file and restart your server.

## For `ox_inventory`

If you’re using `ox_inventory`, you need to modify the `ox_inventory/data/items.lua` file.

#### **Steps to Modify:**

* **Locate the `[items]` Directory:**
  * Connect to your server files via FTP or file manager.
  * Navigate to the `ox_inventory` resource directory and find the `ox_inventory/data/items.lua`.
* **Open `items - ox_inventory.lua`:**
  * Use a text editor to open the `items - ox_inventory.lua` file.
* **Add Your Item:**
  * Find the section where items are defined and use the appropriate method to register your new item.
* **Save and Restart:**
  * Save the `items.lua` file and restart your server.

## For `esx inventory`

If you’re using `esx inventory`, you’ll need to update the `items` table in your database.

#### **Steps to Modify:**

* **Locate the `[items]` Directory:**
  * Connect to your server’s database management tool (e.g., phpMyAdmin) and find the `items.sql` file.
* **Open and Edit `items.sql`:**
  * Open the `items.sql` file and add your new item details to the appropriate table, usually `items`.
* **Apply Changes:**
  * Execute the SQL query to add the item to your database.
* **Restart Your Server:**
  * Restart your server to ensure the new item is available.

## For `qs_inventory`

If you’re using `qs_inventory`, you need to edit the `shared.lua` file located in the `[items]` folder.

#### **Steps to Modify:**

* **Locate the `[items]` Directory:**
  * Connect to your server files via FTP or file manager.
  * Navigate to the `qs_inventory` resource directory and find the `qs_inventory/shared/items.lua`.
* **Open `shared.lua`:**
  * Use a text editor to open the `shared.lua` file within the `[items]` folder.
* **Add Your Item:**
  * Find the section where items are defined and add your new item details.
* **Save and Restart:**
  * Save the `items.lua` file and restart your server.

## **Verifying Your Changes**

* **Test the New Item:**
  * Verify in-game that the new item appears and functions as expected.
* **Check Inventory System:**
  * Ensure the item is properly integrated into your inventory system and is visible in the correct sections.

## Troubleshooting

* **Item Not Showing Up:** Check for syntax errors and confirm that the server has been restarted.
* **Errors in Console:** Review server logs for any error messages related to item addition.

## Additional Tips

* **Backup Files:** Always backup the original files before making changes.
* **Consult Documentation:** Refer to the documentation for your specific inventory system for additional guidance and best practices.


# How to Change the Dispatch

To customize the dispatch system used in our scripts, follow these steps:

* **Locate the Config File**: Find the `config.lua` file in your script directory. This file contains various configuration options for the script.
* **Edit the Dispatch Setting**: Open the `config.lua` file with a text editor. Look for the line that defines the dispatch setting:

```lua
dispatch = 'default',  -- cd_dispatch | qs-dispatch | ps-dispatch | rcore_dispatch | default
```

* **Choose Your Dispatch System**: Replace `'default'` with one of the following options based on the dispatch system you want to use:
  * `'cd_dispatch'` – For CD Dispatch
  * `'qs-dispatch'` – For QS Dispatch
  * `'ps-dispatch'` – For PS Dispatch
  * `'rcore_dispatch'` – For RCore Dispatch
  * `'default'` – For the default dispatch system (blip)
* **Save Your Changes**: After selecting the desired dispatch system, save the changes to the `config.lua` file.
* **Restart Your Server**: To apply the changes, restart your FiveM server.

## PS Dispatch

If you are using ps-dispatch, you should replace the dispatch jobs with the corresponding job types found in qb-core/shared/jobs.lua. Additionally, if you want a custom blip, you should add a blip option with the codeName found in the editable part of our scripts.

<figure><img src="https://3946157545-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fa5Ce4DSFKExllozwopRT%2Fuploads%2F1LLQlnRgCLxKhDWNt5HN%2F2.png?alt=media&amp;token=b2673b76-8e11-4990-9f61-e8814c99d77e" alt=""><figcaption></figcaption></figure>

<figure><img src="https://3946157545-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fa5Ce4DSFKExllozwopRT%2Fuploads%2Ff5VjwwR7mSQ5rGn7vpQs%2F4.png?alt=media&amp;token=7cb16ff5-0b8a-4e6a-9694-0dfec2a19ba0" alt=""><figcaption></figcaption></figure>

<figure><img src="https://3946157545-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fa5Ce4DSFKExllozwopRT%2Fuploads%2FHFUCqJOGGg6n5cjHiGb3%2F1.png?alt=media&amp;token=e2699a1e-6ef4-4f93-8987-4f6e47d1a81e" alt=""><figcaption></figcaption></figure>

<figure><img src="https://3946157545-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fa5Ce4DSFKExllozwopRT%2Fuploads%2FDNY8jv7WlzEJDPoafxau%2F3.png?alt=media&amp;token=7f8299f0-d1f6-4ef7-b6d6-c12c58881692" alt=""><figcaption></figcaption></figure>

## Custom Dispatch

{% hint style="warning" %}
Our scripts generally support cd dispatch, qs-dispatch, ps-dispatch, rcore dispatch. If you are using a different dispatch, find the madCore.policeAlert function in the editable sections of our scripts and add your own dispatch script's export, trigger, etc. to the default section.&#x20;
{% endhint %}

By following these steps, you can configure the dispatch system that best fits your server’s needs. If you encounter any issues or need further assistance, please refer to the support section or reach out via our Discord server.


# Framework Selection and Configuration

After purchasing your script, follow these steps to configure the framework:

* **Open the Config.lua File**
  * Locate and open the `config.lua` file in the main directory of your script using a text editor.
* **Select Your Framework**
  * In the `config.lua` file, find the section where you need to specify your framework. Make the following adjustments:

```lua
Config.framework = {
    name = 'ESX',                       -- Only ESX or QB.
    scriptName = 'es_extended',         -- Framework script name work framework exports. (Example: qb-core or es_extended)
}
```

* Set the `framework` value to:
  * `'ESX'` if you are using ESX
  * `'QB'` if you are using QBCore
  * `'STANDALONE'` if the script supports standalone mode
* Set the `scriptName` value to:
  * `'es_extended'` for ESX
  * `'qb-core'` for QBCore

## **Configure For Old ESX/QBCore**

* If you are using an older version of ESX or QBCore, adjust the `eventName` section as follows:

```lua
-- Old ESX/QBCore settings
eventName = 'esx:getSharedObject',  -- 'esx:getSharedObject' for ESX or 'QBCore:GetObject' for QBCore
```

* Set the `eventName` value to:
  * `'esx:getSharedObject'` for old ESX
  * `'QBCore:GetObject'` for old QBCore
* If you are using a newer version of ESX or QBCore, you do not need to modify the `eventName` setting.
* **Save and Restart**
  * Save your changes to the `config.lua` file and restart your server to apply the new settings.

By following these steps, you’ll ensure that the script is properly configured for your chosen framework. If you encounter any issues or need further assistance, please refer to our support documentation or join our Discord server.


# Finding Item Images

You may need various item images to use in your servers. To help you find these images easily, we've prepared this guide.

## **Steps to Find Item Images:**

1. **Access the Site:**
   * First, visit [items.rainmad.com](https://items.rainmad.com/). This site hosts a vast database of item images for various games and software.
2. **Use the Search Feature:**
   * On the homepage, use the search bar to enter the name of the item you're looking for. The search bar helps you quickly find the desired item images.
3. **Explore the Categories:**
   * If you're not searching for a specific item, you can browse through the categories. The site organizes items into different categories, making it easier to explore.
4. **Download the Images:**
   * Click on the image of the item you are interested in and start downloading it to your computer.
5. **Using the Images:**
   * Once downloaded, you can add these item images to your server files or integrate them into your project. These images can be used to visually represent your items.


# 3D-Interactive Minigames Bundle

A library of 33 minigames and a hack-point placer, in one resource. Call a minigame from any other script for heists, robberies, jobs, tuning, chop shops, whatever you need. Or drop a scanner, laptop, tablet or monitor into the world with `/create_minigame` and let players walk up and press E.

The same minigame can run fullscreen, on the player's own tablet, on a wall-mounted tablet, on a closed laptop that opens on E, or on a world monitor where the cam slides in and paints the game onto the screen. Everything is persisted to a plain Lua file, so a server restart keeps every hack point where you put it.

***

### Features

**33 minigames, one export each.** Untangle, wire matching, port scanner, DNA splicer, spinner lock, vault combination, sliding puzzle, cipher wheel, and so on. Each one returns `true` on success and `false` on cancel or fail, so wiring it into your own job takes a single line.

**Three placements per minigame.** `screen` for a classic fullscreen overlay, `prop` to pop the player's tablet out with the minigame drawn on it, and `monitor` to paint the minigame onto a world prop and slide the camera in. The placement is chosen at the callsite, so the same minigame reuses across all three surfaces.

**Six hack types.** Fingerprint scanner with USB and phone sync-scene, laptop sync-scene on a wall panel, hand-tablet dock, wall-mounted tablet, closed laptop that opens on E, and a plain world monitor. Every type is a preset in `cfg.lua`, so adding a seventh takes an entry, not a code change.

**World placement gizmo.** `/create_minigame` spawns the prop in front of you, opens a three.js gizmo so you can slide, spin and dial it in with the mouse, then writes the finished point to `data/saved_minigames.lua`. Restart-safe, network-spawned for shared props like monitors and laptops.

**DUI keyboard forwarding.** Tablets and monitors get real keyboard input routed through raw VK codes into the React side, so a minigame that reads letters works the same whether it renders fullscreen or on a prop.

**onSuccess and onFail hooks.** Global defaults in `cfg.lua`, per-point overrides in the saved list. Fire a client event, a server event, an inline function, or all three, with any arg list.

**Admin auth for placement.** ACE, an identifier whitelist, or both. Discord, license, license2, steam, fivem or ip identifiers all work, so an ACE-free Discord-role setup is a two-line config change.

**Open where it matters.** `cfg.lua`, `data/saved_minigames.lua` and `client/editable_client.lua` sit outside escrow. Rewrite notifications, swap the interact key, add your own hack type, or point the auto-save at a different file, without touching protected code.

***

### Dependencies

`ox_lib`, `rm_stream`, and the shipped `rm_tablet_02` stream asset. Full version notes on the Installation page.

***

### Next steps

* [Installation](https://docs.rainmad.com/resources/3d-interactive-minigames-bundle/installation)
* [Configuration](https://docs.rainmad.com/resources/3d-interactive-minigames-bundle/configuration)
* [Hack Types](https://docs.rainmad.com/resources/3d-interactive-minigames-bundle/configuration/hack-types)
* [Minigames](https://docs.rainmad.com/resources/3d-interactive-minigames-bundle/systems/minigames)

Trouble getting a minigame to fire, tablet not showing up, monitor cam behind the wall? Head to [Troubleshooting](https://docs.rainmad.com/resources/3d-interactive-minigames-bundle/reference/troubleshooting).


# Installation

### 1. Drop in the resource

Place the `rm_3dminigames` folder in your resources directory, then add it to `server.cfg` **after** `ox_lib` and `rm_stream`:

```cfg
ensure ox_lib
ensure rm_stream
ensure rm_3dminigames
```

{% hint style="warning" %}
Load order matters. `rm_stream` provides the runtime DUI textures that back the tablet and monitor placements. If `rm_3dminigames` starts first, prop and monitor modes will silently fail to render.
{% endhint %}

***

### 2. Stream assets

The tablet prop `rm_tablet_02` is bundled in the `stream/` folder and streams automatically. Nothing to copy, nothing to add to your `[stream]` folder.

If you already run another script that ships a prop with the same name, keep one copy. Two identical models loaded from different resources will spam `DUPLICATE_ARCHETYPE` warnings.

***

### 3. Verify

Start the server and check the console. You should see something like:

```
[rm_3dminigames] 33 minigames registered
[rm_3dminigames] hack types loaded: fingerprint, laptop, tablet, tablet_wall, laptop_open, monitor
```

Then in game, with permission (see step 4):

```
/test_minigame screen untangle
```

A fullscreen minigame should open. Solve it or press Backspace to cancel. If the console prints `[test_minigame] untangle -> true` you are done.

{% hint style="info" %}
`/test_minigame` bypasses hack point placement entirely. It is the fastest way to confirm the React bundle loads before you start dropping props into the world.
{% endhint %}

***

### 4. Grant yourself admin

`/create_minigame`, `/remove_minigame` and `/test_minigame` are all admin-gated. Out of the box, permission is checked against the ACE `command.create_minigame`. Give it to yourself in `server.cfg`:

```cfg
add_ace identifier.discord:123456789012345678 command.create_minigame allow
```

Prefer a straight identifier whitelist? Open `cfg.lua` and add yours:

```lua
cfg.hacks.adminWhitelist = {
    'discord:123456789012345678',
    'license:abcdef0123456789abcdef0123456789abcdef01',
}
```

Both mechanisms are OR'd, so ACE **or** whitelist match lets a player through. See cfg.lua Reference for the full identifier list.

{% hint style="danger" %}
If you leave `adminAce` set to `command.create_minigame` and never grant it, nobody can place hack points, including you. Either grant the ACE, add yourself to the whitelist, or set `adminAce = ''` for testing.
{% endhint %}

***

### 5. Place your first hack point

In game, walk to where you want it, look at the ground, then:

```
/create_minigame
```

An `ox_lib` dialog opens.

1. Give the point a **label**, anything short.
2. Pick a **hack type** from the dropdown (fingerprint, laptop, tablet, and so on, see Hack Types).
3. Pick a **minigame**.
4. Pick a **difficulty**, or leave it on default.

A preview prop spawns in front of you and a three.js gizmo attaches to it. Drag the arrows to move, the rings to rotate. Left-click empty space to confirm.

The point is written to `data/saved_minigames.lua` and is live immediately. Walk within `pointRadius` (default 1.5m) and the `[E] Hack` prompt appears.

***

### 6. Controls

| Input            | Action                                                   |
| ---------------- | -------------------------------------------------------- |
| `E`              | Start hacking the point you are looking at               |
| `Backspace`      | Cancel a running minigame                                |
| Gizmo mouse-drag | Move / rotate the preview prop during `/create_minigame` |
| Left-click empty | Confirm placement                                        |
| `Esc`            | Cancel placement, deletes the preview                    |

Keybinds inside a minigame are minigame-specific. See Minigames for per-game controls.

***

### 7. Backup and edit

`data/saved_minigames.lua` is auto-written on every `/create_minigame` and `/remove_minigame`. It is a plain Lua file with a single `SavedMinigames = { }` table, safe to open, diff, commit and hand-edit **between restarts**.

{% hint style="warning" %}
Do not edit the file while the resource is running. The server rewrites it on the next placement change and your edits will be lost.

If you want manual control, set `cfg.hacks.autoSave = false`. The commands still work, but the file is never touched, so you own it.
{% endhint %}


# Configuration

Every setting lives in `cfg.lua`. The file is a `shared_script`, so both client and server see the same table. Do not put secrets in it.

***

### Hack points

#### `cfg.hacks.textUI`

```lua
cfg.hacks.textUI = '[E] Hack'
```

The prompt shown to the player when they walk within range of a hack point. Rendered through `lib.showTextUI`, so anything ox\_lib text supports works here.

***

#### `cfg.hacks.pointRadius`

```lua
cfg.hacks.pointRadius = 1.5
```

Distance in metres at which the prompt starts showing. Small values feel snappy but frustrate players who round the prop slightly. 1.5 covers most cases.

***

#### `cfg.hacks.pressKey`

```lua
cfg.hacks.pressKey = 38   -- E
```

FiveM control ID for the interact button. `38` is `E`. See the [FiveM controls list](https://docs.fivem.net/docs/game-references/controls/) for other values.

***

#### `cfg.hacks.adminAce` / `adminWhitelist`

```lua
cfg.hacks.adminAce       = 'command.create_minigame'
cfg.hacks.adminWhitelist = {
    'discord:123456789012345678',
    'license:abcdef0123456789abcdef0123456789abcdef01',
}
```

Gate for `/create_minigame`, `/remove_minigame` and `/test_minigame`.

The two mechanisms are OR'd. A player passes if the ACE matches **or** any of their identifiers is in the whitelist. Leave both empty and everyone can run the commands, which is fine for local testing and terrible for a live server.

Accepted identifier prefixes:

| Prefix      | Example                                               |
| ----------- | ----------------------------------------------------- |
| `discord:`  | `discord:123456789012345678`                          |
| `license:`  | `license:abcdef0123456789abcdef0123456789abcdef01`    |
| `license2:` | `license2:0011223344556677889900aabbccddeeff00112233` |
| `steam:`    | `steam:110000112345678`                               |
| `fivem:`    | `fivem:1234567`                                       |
| `ip:`       | `ip:203.0.113.42`                                     |

Grab yours by opening F8 and running `getplayeridentifiers`, or from txAdmin's player list.

{% hint style="warning" %}
Client-side, only ACE is checked directly. The identifier whitelist is authoritative on the server, so a player without the ACE will fall through to a server callback the first time they run the command. That is fine, it just adds a single round-trip on the first call.
{% endhint %}

***

#### `cfg.hacks.autoSave`

```lua
cfg.hacks.autoSave = true
```

When `true`, every `/create_minigame` and `/remove_minigame` rewrites `data/saved_minigames.lua`. Turn it off if you want to hand-edit that file and never have the server touch it. The commands still work, hack points just do not survive a restart.

{% hint style="danger" %}
Auto-save cannot serialise Lua functions. If a hack point has an `onSuccess.fn` or `onFail.fn`, the next auto-save drops the function and prints a warning. Use `clientEvent` or `serverEvent` instead if you need the hook to survive a restart. See Hooks.
{% endhint %}

***

#### `cfg.hacks.types`

The dictionary of hack types available to `/create_minigame`. Each key is a type name, each value is a preset. Six ship in. Adding a seventh is an entry, not a code change. Full walkthrough on Hack Types.

***

#### `cfg.hacks.onSuccess` / `cfg.hacks.onFail`

```lua
cfg.hacks.onSuccess = {
    serverEvent = 'bank:server:openVault',
    args        = { 'vault_1' },
}
```

Global hooks fired on every hack, regardless of type. Per-point overrides in `saved_minigames.lua` replace these entirely (not merged). Full field list on Hooks.

***

### Placement presets

#### `cfg.monitorPresets`

Camera + DUI settings for `placement = 'monitor'`. Each key is a preset name referenced from a hack type's `monitorPreset` field.

```lua
cfg.monitorPresets.monitor = {
    duiWidth       = 1280,
    duiHeight      = 720,
    renderDistance = 50.0,
    camOffsetX     = 0.0,
    camOffsetY     = -0.55,   -- negative = in front of screen
    camOffsetZ     = 0.35,
    camPitch       = -5.0,
    camRoll        = 0.0,
    camFov         = 45.0,
    camEaseMs      = 800,
}
```

| Field             | Meaning                                             |
| ----------------- | --------------------------------------------------- |
| `duiWidth/Height` | DUI texture resolution. Higher = crisper, more VRAM |
| `renderDistance`  | Metres beyond which the DUI stops being drawn       |
| `camOffsetX/Y/Z`  | Local offset from the prop, in metres               |
| `camPitch/Roll`   | Camera rotation in degrees                          |
| `camFov`          | Field of view. Lower = tighter framing              |
| `camEaseMs`       | Time in ms for the cam to slide in and out          |

The `tabletWall` preset also carries `renderMode = 'quad'` and a bounding-box block. That switches the renderer from `AddReplaceTexture` to a quad draw, so the tablet stays untouched instead of overwriting a texture globally. See Placements.

***

#### `cfg.propPresets.tablet`

Settings for `placement = 'prop'`. Calibrated for the shipped `rm_tablet_02`. You should not need to touch these unless you swap the tablet model.

```lua
cfg.propPresets.tablet = {
    propModel      = 'rm_tablet_02',
    bone           = 28422,               -- right hand
    attachOffset   = vec3(0.00, -0.030, 0.000),
    attachRot      = vec3(20.0, -90.0, 0.0),
    animDict       = 'amb@world_human_tourist_map@male@base',
    animName       = 'base',
    duiWidth       = 1500,
    duiHeight      = 820,
    renderDistance = 50.0,
    bbOffset       = vec3(0.040, -0.006, -0.040),
    bbScaleX       = 0.205,
    bbScaleY       = 0.120,
    bbRot          = -90.0,
    bbPlane        = 'xz',
    camOffset      = vec3(0.040, 0.180, 0.510),
    camPitch       = -49.0,
    camFov         = 29.0,
    camEaseMs      = 600,
}
```

The `bb*` block controls where the DUI quad sits relative to the prop's local origin. If the minigame draws in front of, behind, or through the tablet, this is what to nudge.

***

### Minigame difficulties

Every minigame lives in `cfg` under its own key with two shapes.

#### Shape 1: full difficulty table

```lua
cfg.untangle = {
    default = 'medium',
    difficulty = {
        easy   = { nodeCount = 6,  timeLimit = 90 },
        medium = { nodeCount = 8,  timeLimit = 60 },
        hard   = { nodeCount = 12, timeLimit = 40 },
    },
}
```

`default` is used when the caller does not pass `difficulty`. Each difficulty value is passed straight to the React component as props, so the fields listed per minigame in Minigames are the ones the game reads.

#### Shape 2: difficulty-only

```lua
cfg.rhythmArrows = { default = 'medium' }
```

The React component owns its own tunables and only the difficulty string crosses the bridge. Change `default` to shift the baseline, or override at the callsite with `opts.difficulty = 'hard'`.

***

### Overriding at the callsite

Every field in a difficulty preset can be overridden per-call:

```lua
exports['rm_3dminigames']:untangle({
    difficulty = 'hard',
    timeLimit  = 20,   -- overrides the 40 from cfg.untangle.difficulty.hard
})
```

Anything not listed in the preset that the React component reads also works, provided the component reads it. This is the escape hatch for one-off tuning without editing `cfg.lua`.


# Hack Types

A **hack type** is the surface a hack point uses. Each one is a preset in `cfg.hacks.types` that binds a world prop, an animation, a placement mode and a set of on-screen behaviour. Six ship in, and adding a seventh is a config entry not a code change.

Every type is picked from the dropdown in `/create_minigame`, or referenced by its key in a hand-written `data/saved_minigames.lua` entry.

***

### `fingerprint`

Fullscreen minigame with a USB and phone sync-scene playing in front of the player.

```lua
cfg.hacks.types.fingerprint = {
    propModel = 'ch_prop_fingerprint_scanner_01e',
    animDict  = 'anim_heist@hs3f@ig1_hack_keypad@arcade@male@',
    objects   = { 'ch_prop_ch_usb_drive01x', 'prop_phone_ing' },
    scenes    = { ... 4 scenes ... },
    timings   = { intro = 4000, loop = 2000, outcome = 5000 },
    placement = 'screen',
}
```

The `scenes` list drives a four-stage `NetworkSynchronisedScene`:

1. **intro** (`action_var_01`), USB goes in, minigame is not visible yet.
2. **loop** (`hack_loop_var_01`), phone and USB idle, minigame is on screen.
3. **success** (`success_react_exit_var_01`), player pulls the USB back out.
4. **fail** (`fail_react`), player throws the USB and swears.

`timings.intro/loop/outcome` control how long the ped stays in each pose. The minigame runs during `loop`, so the outcome anim only starts once the player succeeds or fails.

**Use it for:** classic hack-into-a-terminal moments where you want a full scene, not just a UI.

***

### `laptop`

Same idea as `fingerprint`, but heavier. Wall panel, laptop, bag and hack card, with a three-stage scene.

```lua
cfg.hacks.types.laptop = {
    propModel = 'hei_prop_hei_securitypanel',
    animDict  = 'anim@heists@ornate_bank@hack',
    objects   = { 'hei_p_m_bag_var22_arm_s', 'hei_prop_hst_laptop', 'hei_prop_heist_card_hack_02' },
    scenes    = { ... 3 scenes ... },
    timings   = { intro = 6300, loop = 2000, outcome = 4600 },
    placement = 'screen',
}
```

Player drops a bag, sets up the laptop, plays the minigame, then packs everything back up. Longer intro means it feels weightier than `fingerprint`, so use it for the payoff hack in a heist rather than every terminal.

***

### `tablet`

The player's own tablet pops out on E and the minigame is drawn onto the tablet screen. No fullscreen overlay, no sync-scene, just the player standing in place holding a tablet.

```lua
cfg.hacks.types.tablet = {
    propModel = 'ch_prop_fingerprint_scanner_01e',
    placement = 'prop',
    preset    = 'tablet',
}
```

The world marker (`propModel`) is only there for the interaction range. Once the player presses E, the tablet from `cfg.propPresets.tablet` takes over.

**Use it for:** discreet hacks that should not attract attention, or for handheld reads on a device (guard's tablet, delivery scanner, and so on).

***

### `tablet_wall`

A wall-mounted tablet, drawn onto the world prop itself instead of pulled into the player's hand. Camera slides in for a close read, minigame lives on the wall.

```lua
cfg.hacks.types.tablet_wall = {
    propModel     = 'rm_tablet_02',
    placement     = 'monitor',
    monitorPreset = 'tabletWall',
}
```

Uses `renderMode = 'quad'` under the hood, so no texture is globally replaced and every `rm_tablet_02` in the world stays untouched.

**Use it for:** control-room panels, apartment door tablets, mounted diagnostics screens.

***

### `laptop_open`

A closed laptop sits in the world. On E, it opens, the camera slides in, and the minigame appears on the screen.

```lua
cfg.hacks.types.laptop_open = {
    propModel       = 'p_laptop_02_s',
    networked       = true,
    animDict        = 'switch@franklin@on_laptop',
    animName        = '001927_01_fras_v2_4_on_laptop_exit_laptop',
    defaultAnimTime = 1.0,   -- 1 = closed
    hackAnimTime    = 0.0,   -- 0 = open
    replaceTexture  = 'script_rt_tvscreen',
    placement       = 'monitor',
    monitorPreset   = 'laptop',
}
```

`defaultAnimTime` and `hackAnimTime` are anim phase values, not durations. The laptop lives frozen at `defaultAnimTime` until a player interacts, at which point it slides to `hackAnimTime` and back on close.

`networked = true` means the server spawns the prop and syncs it to every client through a state bag, so all players see the same open / closed state.

**Use it for:** desktop workstations, tech-lab machines, anywhere the laptop should be part of the scenery until someone uses it.

***

### `monitor`

A world monitor. E slides the camera in, the screen turns into the minigame via `AddReplaceTexture`.

```lua
cfg.hacks.types.monitor = {
    propModel      = 'sf_prop_sf_monitor_01a',
    networked      = true,
    replaceTexture = 'prop_monitor_fib_01_d',
    placement      = 'monitor',
}
```

**Use it for:** CCTV consoles, security desks, arcade machines, dispatch terminals.

{% hint style="warning" %}
`AddReplaceTexture` is model-global. Every instance of `sf_prop_sf_monitor_01a` in the streamed world will show the minigame while the hack is running. That is fine when only one instance is placed per area, and it is why the shipped `tablet_wall` type uses quad-render instead.
{% endhint %}

***

### Field reference

| Field               | Applies to                        | Meaning                                         |
| ------------------- | --------------------------------- | ----------------------------------------------- |
| `propModel`         | all                               | World prop hash                                 |
| `placement`         | all                               | `screen`, `prop` or `monitor`                   |
| `preset`            | prop                              | Key in `cfg.propPresets`                        |
| `monitorPreset`     | monitor                           | Key in `cfg.monitorPresets`                     |
| `networked`         | monitor                           | true = server-spawned + state-bag synced        |
| `animDict/animName` | fingerprint, laptop, laptop\_open | Prop or ped anim                                |
| `defaultAnimTime`   | laptop\_open                      | Phase 0..1 when idle                            |
| `hackAnimTime`      | laptop\_open                      | Phase 0..1 while hacking                        |
| `replaceTexture`    | monitor (non-quad)                | Texture key on the model to overwrite           |
| `objects`           | fingerprint, laptop               | Extra props spawned for the sync-scene          |
| `scenes`            | fingerprint, laptop               | Ordered list of animation names per scene       |
| `timings`           | fingerprint, laptop               | `intro`, `loop`, `outcome` durations in ms      |
| `onSuccess/onFail`  | all                               | Per-type default hooks. Per-point overrides win |

***

### Adding a new type

1. Add a new key under `cfg.hacks.types` with the fields above.
2. Add `{ value = 'yourType', label = 'Your Type' }` to the `HACK_TYPES` list at the top of `client/creator.lua` so it appears in the dropdown.
3. Restart the resource.

The framework picks it up automatically. Server-spawned entities only need `networked = true`, the server handles the spawn and state bag on its own.

If you need a placement that is not `screen`, `prop` or `monitor`, you also need a branch in `runMinigame` in `client/client.lua`.


# Placements

A **placement** is the surface a minigame renders on. Three exist. The same minigame reuses across all three, so `untangle` looks identical whether it renders fullscreen, on a hand-held tablet, or on a wall monitor. The placement is picked at the callsite:

```lua
exports['rm_3dminigames']:untangle({ placement = 'screen' })
exports['rm_3dminigames']:untangle({ placement = 'prop' })
exports['rm_3dminigames']:untangle({ placement = 'monitor', _targetEntity = entity })
```

Or in a hack point, the placement comes from the type preset in `cfg.hacks.types`.

***

### `screen`

Classic fullscreen NUI overlay. `SetNuiFocus(true, true)`, dim the world, draw the minigame in front of everything. This is the default when no placement is passed.

**Best for:** anything that needs a lot of screen real estate, benefits from mouse precision, or is called from a menu.

**Under the hood:** `SendNUIMessage` with the minigame name and props. The React app in `web/` renders it, calls back with success or fail, and `SetNuiFocus(false, false)` releases the cursor.

**Cancel:** Backspace, or the minigame's own quit button.

***

### `prop`

The player's tablet (`rm_tablet_02`) pops out into their hands, they hold the tourist-map anim, and the minigame is drawn onto the tablet screen.

**Best for:** discreet reads, mobile-feeling minigames, jobs where the player is already handling a device.

**Under the hood:** `PropTablet.open()` attaches the prop to the right-hand bone with `cfg.propPresets.tablet` offsets, opens a DUI at 1500x820, and draws it to the tablet as a quad with `DrawSpritePoly` (four passes, so it stays visible from both sides). Keyboard input is forwarded from raw VK codes to the React side through `DuiInputBridge`.

**Cancel:** Backspace, ped death, entering a vehicle, or the minigame's quit button. The tablet slides away and the anim releases.

{% hint style="info" %}
Prop mode looks the same to other players. They see you standing there holding a tablet, they do not see the minigame itself.
{% endhint %}

***

### `monitor`

The camera slides to a world prop and the minigame is painted onto its screen. Two render modes:

#### Texture replace (default)

`AddReplaceTexture` swaps the prop's screen texture for the DUI's runtime texture. The shipped `monitor` and `laptop_open` types use this.

**Best for:** dedicated screens that are only placed once in a scene (security desks, dispatch terminals, single wall monitors).

{% hint style="warning" %}
`AddReplaceTexture` is model-global. Every instance of the same prop model in the streamed world will show the minigame while the hack is running. This is fine when only one instance is placed per area. If you need many identical monitors in the same scene, use quad-render mode instead.
{% endhint %}

#### Quad render

Set `renderMode = 'quad'` in the monitor preset and the renderer switches to `DrawSpritePoly` instead. The DUI is drawn as a screen-space quad locked to the prop's local origin, no texture is swapped, so other instances of the same model stay untouched.

The shipped `tabletWall` preset uses this to draw onto `rm_tablet_02` without stealing the hand-held tablet's texture.

**Under the hood:** `PropMonitor.open(entity)` builds the DUI, either issues `AddReplaceTexture` or starts a per-frame quad-draw thread, then lerps a fresh cam from the player to the prop's local offset.

**Cancel:** Backspace, or the minigame's quit button. The cam eases back out.

***

### Meta keys

A handful of options control the placement itself rather than the minigame. They are stripped from the props before they reach React:

| Key             | Meaning                                                                                                                        |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `placement`     | `screen`, `prop` or `monitor`                                                                                                  |
| `preset`        | Prop preset key (only when `placement = 'prop'`)                                                                               |
| `_targetEntity` | Entity handle to paint on (only when `placement = 'monitor'`, and only for callsite use, hack points resolve their own entity) |
| `_hackType`     | Hack type key. Used by `runMinigame` to pull the right monitor preset. Set by the hack-point system, not you                   |
| `difficulty`    | Difficulty key. Consumed by `resolveProps` before dispatch                                                                     |


# Systems


# Minigames

Thirty-three minigames, one export each. Every one returns `true` on success and `false` on cancel or fail. Every one accepts an `opts` table where you can override the difficulty, the placement, or any React prop the component reads.

The list below groups them by feel. Names in the code map straight to `cfg.<name>` and `exports['rm_3dminigames']:<name>()`.

***

### Puzzle & logic

| Name              | Feel                                         | Time-ish |
| ----------------- | -------------------------------------------- | -------- |
| `untangle`        | Drag nodes so no edges cross                 | 40-90s   |
| `slidingPuzzle`   | Slide tiles to rebuild an image              | 60s      |
| `wireMatching`    | Match coloured wires across a panel          | 30-60s   |
| `wireSnip`        | Cut the right wire before the timer runs out | 20s      |
| `pathTracing`     | Trace a path through a grid without lifting  | 15-25s   |
| `dataConduit`     | Route packets across a board of switches     | 45s      |
| `circuitPulse`    | Fire pulses in time so they arrive together  | 20-45s   |
| `laserGrid`       | Reflect a laser through mirrors to a target  | 30-60s   |
| `sequenceBreaker` | Decode a hidden sequence from partial hints  | 30s      |
| `chemicalBalance` | Balance a chemical equation by drag-and-drop | 60-120s  |

***

### Timing & reflex

| Name             | Feel                                                      | Time-ish |
| ---------------- | --------------------------------------------------------- | -------- |
| `rhythmArrows`   | Press arrow keys in sync with the incoming beat           | 30s      |
| `flashPad`       | Repeat the flashing pattern back at it, longer each round | 30-60s   |
| `motionDetector` | Move only when the guard looks away                       | 30s      |
| `spinnerLock`    | Stop the spinner when it lines up with the target         | 20s      |
| `vaultDrill`     | Hold pressure in the sweet spot while the drill bites     | 30s      |
| `powerGrid`      | Balance current across breakers before one blows          | 30s      |

***

### Terminal & hack flavour

| Name                | Feel                                                    | Time-ish |
| ------------------- | ------------------------------------------------------- | -------- |
| `terminalHack`      | Fallout-style word guess against a countdown            | 30-60s   |
| `portScanner`       | Sweep ports for the one that opens the door             | 30s      |
| `dataStreamCapture` | Snap the moving cursor when the target byte is in frame | 30s      |
| `firewallBreach`    | Peel firewall layers by matching packet types           | 30s      |
| `ipTracer`          | Ping subnets to zero in on the target IP                | 25-50s   |
| `packetInterceptor` | Grab packets off a moving stream before they leave      | 30s      |
| `signalDescrambler` | Rotate carrier bands until the audio comes clean        | 30s      |

***

### Vault, keycard & combination

| Name                | Feel                                          | Time-ish |
| ------------------- | --------------------------------------------- | -------- |
| `vaultCombination`  | Dial N wheels to the target number sequence   | 30-60s   |
| `cipherWheel`       | Line up cipher wheels to decode a phrase      | 30s      |
| `keycardReassembly` | Match keycard-fragment pairs on a memory grid | 55-100s  |

***

### Investigation flavour

| Name               | Feel                                                   | Time-ish |
| ------------------ | ------------------------------------------------------ | -------- |
| `fingerprintTrace` | Repair a scanned print by fixing corrupt zones         | 35-70s   |
| `cctvSweep`        | Sweep cameras until the target appears in shot         | 30s      |
| `dnaSplicer`       | Splice DNA strands so the pattern reads clean          | 30s      |
| `frequencyMatch`   | Tune two dials until the waveforms align               | 30s      |
| `dataMatcher`      | Pick the matching record from a scrolling wall of data | 30s      |
| `colorMatch`       | Reproduce the shown colour by mixing sliders           | 30s      |
| `mathChallenge`    | Solve N arithmetic problems back to back               | 30-45s   |

***

### Per-minigame tuning

Every entry has a `cfg.<name>` key. Two shapes:

#### Full difficulty table

```lua
cfg.untangle = {
    default = 'medium',
    difficulty = {
        easy   = { nodeCount = 6,  timeLimit = 90 },
        medium = { nodeCount = 8,  timeLimit = 60 },
        hard   = { nodeCount = 12, timeLimit = 40 },
    },
}
```

The values are handed straight to the React component as props. Adjust freely.

#### Difficulty-only

```lua
cfg.rhythmArrows = { default = 'medium' }
```

The React component owns its own numbers and reads only the difficulty label. If you want finer control, open the component in `web/src/minigames/<name>.tsx`, expose the tunables as props, and rebuild.

#### Overriding per-call

Any field of a difficulty preset can be overridden at the callsite:

```lua
exports['rm_3dminigames']:untangle({
    difficulty = 'hard',
    timeLimit  = 20,       -- overrides the hard preset's 40
    placement  = 'monitor',
    _targetEntity = ent,
})
```

Fields the component reads that are not in the preset also work, so this is the escape hatch for one-off tuning. Anything unrecognised is ignored.

***

### Localisation

All player-facing text lives in `locales/`. `en.json` is the reference file. To add a language, copy `en.json` to a new locale code and translate the values while leaving the keys untouched. Locales load through `ox_lib`, so a player with `lib.locale('de')` will see the German file.

Keep any `%d` or `{token}` placeholders exactly where they were. They are filled in at runtime, so shuffling them shows wrong numbers or breaks the message.

***

### Adding a new minigame

1. Write `web/src/minigames/MyGame.tsx`. Accept `locales` plus whatever props you want.
2. Register it in the `Minigames` map at the top of `web/src/App.tsx`.
3. Add `makeExport('myGame', cfg.myGame)` in `client/client.lua`.
4. Add `cfg.myGame = { default = 'medium', difficulty = { ... } }` in `cfg.lua`.
5. Add `myGame = true` to `KNOWN_MINIGAMES` in `server/sync.lua` so `/create_minigame` will accept it.
6. Add it to the `MINIGAMES` lists at the top of `client/creator.lua` and `client/test_command.lua` so the dropdowns list it.
7. Rebuild the bundle: `cd web && npm run build`.

Call it once with `/test_minigame screen myGame` to sanity-check. If it opens and returns to the console, you are done.


# Hooks

`onSuccess` and `onFail` let a hack point fire something on the client, on the server, or run an inline function when the minigame ends. Every hack point supports them, and every hack type in `cfg.hacks.types` supports defaults that apply when a point does not override.

***

### Where hooks live

Three layers, most-specific first. Only one wins per side, hooks do not merge:

1. **Per-point override**, on the entry in `data/saved_minigames.lua`.
2. **Per-type default**, on the hack type in `cfg.hacks.types`.
3. **Global default**, `cfg.hacks.onSuccess` / `cfg.hacks.onFail`.

The first one that exists wins. If a point sets `onSuccess`, the type and global defaults are ignored for success, even if the point does not set `onFail`, the fail hook still falls through to the type or global.

***

### Shape

```lua
onSuccess = {
    fn          = function(ctx) print('done') end,
    clientEvent = 'mypack:client:openDoor',
    serverEvent = 'mypack:server:openVault',
    args        = { 'vault_1', 42 },
}
```

Every field is optional. Any subset works. All that are present fire.

| Field         | Runs where | Notes                                         |
| ------------- | ---------- | --------------------------------------------- |
| `fn`          | client     | Called with a context table. Not serialisable |
| `clientEvent` | client     | `TriggerEvent(clientEvent, args...)`          |
| `serverEvent` | server     | `TriggerServerEvent(serverEvent, args...)`    |
| `args`        | both       | Passed as varargs to whichever event fires    |

***

### Context table (`fn`)

The inline function receives a single table:

```lua
onSuccess = {
    fn = function(ctx)
        print(ctx.hackType)   -- 'fingerprint'
        print(ctx.minigame)   -- 'untangle'
        print(ctx.coords)     -- vec4
        print(ctx.entity)     -- entity handle (nil for local types)
        print(ctx.playerPed)  -- PlayerPedId()
    end,
}
```

Great for one-off hacks. Not durable, see the auto-save warning below.

***

### Persistence

`data/saved_minigames.lua` is rewritten on every `/create_minigame` and `/remove_minigame`. It serialises Lua values, but functions are not serialisable, so a hook with `fn = function() ... end` on a saved point gets dropped on the next auto-save and a warning prints:

```
[rm_3dminigames] WARNING: entry #3 (bank vault) has an fn hook that will
be dropped by auto-save. Use clientEvent/serverEvent for persistence, or
set cfg.hacks.autoSave = false.
```

Two ways out:

1. **Use `clientEvent` or `serverEvent`** and put the actual code in a handler somewhere else. That is the recommended path.
2. **Turn off auto-save.** `cfg.hacks.autoSave = false` and the file is never rewritten. The commands still work, they just do not persist.

***

### Examples

#### Global default that logs to Discord

```lua
cfg.hacks.onSuccess = {
    serverEvent = 'mypack:log',
    args        = { 'hack:success' },
}
cfg.hacks.onFail = {
    serverEvent = 'mypack:log',
    args        = { 'hack:fail' },
}
```

Every hack point in the world reports its outcome. Per-point overrides still work on top.

#### Per-type default: laptop always opens a menu

```lua
cfg.hacks.types.laptop_open.onSuccess = {
    clientEvent = 'mypack:openLaptopMenu',
}
```

Applies to every `laptop_open` point that does not set its own `onSuccess`.

#### Per-point override in `data/saved_minigames.lua`

```lua
SavedMinigames = {
    {
        label      = 'Bank vault laptop',
        hackType   = 'laptop_open',
        coords     = vec4(150.20, -1040.10, 29.35, 340.00),
        minigame   = 'vaultCombination',
        difficulty = 'hard',
        onSuccess  = {
            serverEvent = 'bank:server:openVault',
            args        = { 'main_vault' },
        },
        onFail = {
            clientEvent = 'bank:client:triggerAlarm',
        },
    },
}
```

#### Callsite hook (library mode)

Exports do not support hooks directly, because the export already returns the boolean. Handle the outcome inline:

```lua
CreateThread(function()
    if exports['rm_3dminigames']:untangle({ difficulty = 'hard' }) then
        TriggerServerEvent('mypack:server:openVault')
    else
        TriggerServerEvent('mypack:server:triggerAlarm')
    end
end)
```

***

### Ordering

When a hack point ends, the client resolves hooks in this order:

1. `fn` fires immediately, on the client.
2. `clientEvent` fires next, on the same client.
3. `serverEvent` fires last, over the network.

The scenes and cam movement finish first, so a `fn` that opens a follow-up minigame will not fight the sync-scene for control.

***

### What not to do

* Do not put secrets in `args`. The event fires from client to server, so the client owns the payload.
* Do not use `fn` for anything you need to survive a `/create_minigame` reshuffle. Auto-save will drop it. See above.
* Do not fire a hack point's own event from inside its own hook. That will re-enter the pipeline and softlock.


# Reference


# Exports & Commands

Every minigame is exposed as a single export, and the resource ships three commands for placement and testing. Everything else (hooks, hack points, state) is driven from `cfg.lua` and `data/saved_minigames.lua`.

***

### Exports

#### Signature

```lua
local ok = exports['rm_3dminigames']:<minigameName>(opts)
```

The call blocks the current thread until the player finishes, fails, or cancels the minigame. `ok` is `true` on success, `false` on anything else. Wrap it in a coroutine or thread if you need to keep other work running.

#### `opts`

Every field is optional.

| Field           | Type   | Notes                                                              |
| --------------- | ------ | ------------------------------------------------------------------ |
| `placement`     | string | `'screen'` (default), `'prop'`, `'monitor'`                        |
| `difficulty`    | string | `'easy'`, `'medium'`, `'hard'`. Falls back to `cfg.<name>.default` |
| `preset`        | string | Prop preset key. Defaults to the placement type                    |
| `_targetEntity` | entity | Required for `placement = 'monitor'` outside a hack point          |
| any other       | any    | Passed straight to the React component as props                    |

The difficulty preset is merged into `opts` first, then your call-site values override on top, then the meta keys are stripped off. See Configuration.

#### Example

```lua
CreateThread(function()
    local success = exports['rm_3dminigames']:untangle({
        difficulty = 'hard',
        timeLimit  = 25,
        placement  = 'screen',
    })

    if success then
        TriggerServerEvent('mypack:server:openVault')
    else
        exports.ox_lib:notify({ type = 'error', description = 'Hack failed.' })
    end
end)
```

#### Full minigame list

```
untangle, colorMatch, rhythmArrows, mathChallenge, pathTracing,
portScanner, dataStreamCapture, terminalHack, firewallBreach, ipTracer,
wireMatching, laserGrid, sequenceBreaker, vaultDrill, packetInterceptor,
cipherWheel, keycardReassembly, powerGrid, chemicalBalance,
fingerprintTrace, dataMatcher, flashPad, frequencyMatch, motionDetector,
cctvSweep, dnaSplicer, signalDescrambler, spinnerLock, wireSnip,
slidingPuzzle, dataConduit, circuitPulse, vaultCombination
```

See Minigames for a description of each one.

***

### Commands

#### `/create_minigame`

Opens an `ox_lib` dialog, spawns a preview prop, attaches a gizmo so you can slide and rotate the prop into place, then writes the point to `data/saved_minigames.lua`.

Fields in the dialog:

| Field      | Notes                                                            |
| ---------- | ---------------------------------------------------------------- |
| Label      | Short identifier for the point. Shows up in server logs          |
| Hack Type  | Preset from `cfg.hacks.types` (fingerprint, laptop, tablet, ...) |
| Minigame   | Which minigame runs on E                                         |
| Difficulty | `easy` / `medium` / `hard`, or blank for the cfg default         |

Left-click empty space to confirm placement. Escape cancels and deletes the preview.

**Access:** admin-gated. See Configuration.

***

#### `/remove_minigame`

Deletes the nearest hack point within 3 metres of the player, removes it from `data/saved_minigames.lua`, and despawns the entity if it was networked. Prints a notify with the label of the removed point.

**Access:** admin-gated.

***

#### `/test_minigame`

Runs a minigame right now, no hack point required.

```
/test_minigame <placement> <name> [difficulty]
```

| Argument   | Notes                                |
| ---------- | ------------------------------------ |
| placement  | `screen` or `prop`                   |
| name       | One of the minigame names above      |
| difficulty | Optional, `easy` / `medium` / `hard` |

Example:

```
/test_minigame screen untangle hard
```

The result (`true` / `false`) is printed to the F8 console. `monitor` placement is intentionally excluded here because it needs an existing world entity, use `/create_minigame` to test that.

**Access:** admin-gated.

***

### State bags

Networked hack types (`laptop_open`, `monitor`) tag their spawned entity with a state bag named `rmHackPoint`. It carries the hack type, coordinates, minigame name and hook overrides. Other resources can read it, but should not write to it:

```lua
local sb = Entity(ent).state.rmHackPoint
if sb and sb.hackType == 'monitor' then
    -- this monitor is a hack point
end
```

{% hint style="warning" %}
Setting this state bag from another resource will confuse the hack-point system. Read only.
{% endhint %}

***

### Events

The resource ships a small handful of net events. All are internal, so do not fire them from other scripts unless you know what you are doing.

| Event                                    | Direction | Purpose                                       |
| ---------------------------------------- | --------- | --------------------------------------------- |
| `rm_3dminigames:server:spawnHackPoint`   | c → s     | Networked spawn from `/create_minigame`       |
| `rm_3dminigames:server:persistHackPoint` | c → s     | Local spawn, server just persists             |
| `rm_3dminigames:server:removeHackPoint`  | c → s     | Delete nearest point                          |
| `rm_3dminigames:client:removeResult`     | s → c     | Feedback for the remove command               |
| `rm_3dminigames:client:removeLocalNear`  | s → c     | Broadcast so every client drops a local point |

Server-side identity check callback:

| Callback                        | Returns                            |
| ------------------------------- | ---------------------------------- |
| `rm_3dminigames:server:isAdmin` | `true` if the caller is authorised |

***

### Localisation

Locale JSON files live in `locales/`. `en.json` ships as the reference. Add another file next to it named after the locale code and translate the values only. Load it in `cfg.lua`:

```lua
lib.locale('de')   -- loads locales/de.json
```

Keep `%d`, `%s` and `{name}`-style placeholders in the same order as the English file. They are filled in at runtime.


# Troubleshooting

***

### `/create_minigame` says "No permission"

The command is admin-gated. Either grant yourself the ACE:

```cfg
add_ace identifier.discord:123456789012345678 command.create_minigame allow
```

Or add your identifier to `cfg.hacks.adminWhitelist`. Discord, license, steam and fivem all work. See Configuration.

For local testing you can also set both to empty:

```lua
cfg.hacks.adminAce       = ''
cfg.hacks.adminWhitelist = {}
```

Every command opens up. Never do this on a live server.

***

### Nothing happens when I press E

Work through these in order:

1. **Is the point live?** Restart the resource and watch the console for the load count. If your hand-written entry in `data/saved_minigames.lua` is malformed, it is silently skipped.
2. **Are you inside the radius?** `cfg.hacks.pointRadius` defaults to 1.5 metres. Bump it while debugging.
3. **Is the model streamed?** Networked types (`laptop_open`, `monitor`) need the player near the spawn coords for the entity handle to resolve. Check the F8 console for `waiting for entity` warnings.
4. **Is another script eating the key?** `cfg.hacks.pressKey` is `E` (control 38) by default. If a menu script grabs `E` first, you never get the input.

***

### The minigame does not open

Almost always the React bundle. Rebuild it:

```
cd web
npm run build
```

The bundle lands in `build/`. `fxmanifest.lua` points at `build/index.html`, so a stale bundle means a blank overlay.

If the bundle is fresh and the overlay is still blank, watch the F8 console for red errors. Missing minigame names come through as:

```
[rm_3dminigames] unknown minigame: myGame
```

Check the `Minigames` map in `web/src/App.tsx`.

***

### Prop mode: the tablet is invisible

Two possibilities.

**The stream asset is missing.** Confirm `stream/rm_tablet_02.ydr` and `stream/rm_tablet_02.ytyp` are both present. Restart the resource and watch the console for `couldn't stream asset` warnings.

**The tablet is drawn behind the player's arm.** The default calibration in `cfg.propPresets.tablet` is dialled in for `rm_tablet_02`. If you swapped the model, adjust `bbOffset`, `bbScaleX/Y`, and `attachOffset` in small steps.

***

### Monitor mode: the camera is inside the wall

Almost always the wrong `camOffsetY` sign. Y is local-forward on the prop. Negative Y puts the cam **in front of** the screen, which is what you want. Positive Y puts the cam behind. Flip the sign and try again.

If the framing is off centre, tweak `camOffsetX` and `camOffsetZ` after you have the depth right. `renderMode = 'quad'` presets also need `bbOffset` and `bbScaleX/Y` calibrated to the prop's local origin.

***

### Monitor mode: every same-model prop in the map shows the minigame

Expected. `AddReplaceTexture` is model-global. If only one instance is placed per area this is fine.

If you need many identical monitors in the same scene, switch the type to `renderMode = 'quad'` in its monitor preset. The quad renderer draws onto that one entity only, so the others stay untouched. See the shipped `tablet_wall` type for a working example.

***

### Backspace does not cancel

Two known cases.

**A DUI input has focus.** Prop and monitor placements forward keyboard input to the DUI. Once a text field takes focus, Backspace edits the field instead of cancelling. That is on purpose. Click outside the input or press `Esc` first.

**The minigame swallows the key.** Some minigames rebind Backspace for their own use (delete character, undo). In that case the minigame's own quit button is the way out.

***

### Sync-scene props (USB, phone, laptop) do not spawn

The scene tables in `cfg.hacks.types.fingerprint` and `cfg.hacks.types.laptop` reference vanilla model hashes. If a mod removes them the scene fails silently. Verify with:

```
/spawnobject prop_phone_ing
```

If the object does not spawn, that model is missing from the client's game files.

***

### `/create_minigame` saves the point but a restart forgets it

Check the console for auto-save errors. If `cfg.hacks.autoSave = false`, persistence is off by design. Otherwise the server needs write access to `data/saved_minigames.lua`, which txAdmin blocks in some read-only setups.

Also check the file itself is valid Lua. A parse error at load time skips the whole file, so a single trailing comma from a hand-edit can lose every saved point.

***

### Networked laptop opens twice / anim glitches

This was a known issue in early builds and is fixed. The symptom was the open anim replaying every time the state bag fired. If you still see it, you are on an old build, update the resource.

If you customised `applyStaticAnim` in `client/fingerprints.lua`, verify that the "already playing" fast-path is still in place. Removing it brings the glitch back.

***

### Reporting a bug

Include:

* The hack type and minigame name involved.
* Whether it reproduces with `/test_minigame <placement> <name>` outside a hack point.
* Console output from both F8 and the server console around the failure.
* If prop or monitor mode, whether other minigames work in the same mode.

That output usually pins the cause on the first read.


# Map Editor: Place Anything

{% hint style="warning" %}
**Pre-Installation Note**

This guide assumes you already know how to manage a FiveM server (start resources, edit configs, grant aces). Following the steps out of order is the most common cause of errors.

**Support**

If something isn't working after you've followed every step, run through [Troubleshooting](/resources/map-editor-place-anything/troubleshooting) first, then open a ticket in our [Discord](https://discord.gg/rainmad) with your server console output. Don't skip the [Installation](/resources/map-editor-place-anything/installation) and [Dependencies](/resources/map-editor-place-anything/dependencies) pages.
{% endhint %}

Map Editor answers two questions for any script that needs objects placed: what props exist, and where does the player want them.

It ships a catalogue of **over 30000 props** with rendered thumbnails, measured dimensions, collision, shaders, embedded lights and the gamebuild each one needs, and an in-game editor that hands a layout back as structured data. Nothing is stored unless you want it stored: your script gets a list of rows and decides what to do with them.

Standalone, it keeps a library of layouts of its own and can stand one up for the whole server. Called from another resource, it is whatever that resource needs it to be:

* a **furniture placer** for a housing script, working inside the shell and handing back offsets that stand up in every copy of it
* a **prop placer** for shop dressing, signage, event staging or a job that leaves things behind
* a **map editor** for building a place and shipping it as a ymap or a resource
* an **object inspector**: `/prop_inspect` reads any prop or any piece of the world, and `getPropDetails` answers the same question to a script
* a **prop catalogue** other resources ask by name or hash, without opening anything

## Features

* **A measured catalogue:** around 23000 props, 5000 elements and 2200 structures, browsed by kind. Every row carries its size, ground offset, triangle count, shaders, collision, DLC and minimum gamebuild, so the picker can dim what this server can't spawn instead of failing on the click. Half of them are mounted everywhere and the other half come and go with the interior they belong to, which [The Catalogue](/resources/map-editor-place-anything/the-catalogue) explains.
* **A full editor:** free camera, a prop in hand that seats itself on whatever is under the crosshair, move and rotate handles, grid snapping, edge snapping to neighbours, undo and redo, groups, and typed coordinates for the times a number beats a drag.
* **Tools for more than one prop:** a scatter brush, a fill that covers a marked shape, and a path that lays props along a route. All three take from a pool of props you pick, with spacing, jitter and per-prop turn.
* **The map's own props:** click any piece of the world to read it, take it out of view, or stand an editable copy in its place. What was hidden travels with the layout, so a build that needed a wall gone comes back with the wall gone.
* **Lights:** point and spot lights with colour, range, intensity, cone and shadows, drawn while the layout is active.
* **Sessions with other people:** invite builders or viewers, see each other's cursors, share the boundary, hold the world at an hour and weather everybody sees, and optionally build in a routing bucket of your own with traffic switched off.
* **Layouts:** save, reopen, rename, copy and delete; activate one and it stands up for every player on the server, across restarts.
* **Import and export:** a layout file that comes back in, a generated resource that stands the build up on its own, a Lua table, a ymap and a spooner map. Imports read layout files, ymap XML and spooner/MapObject XML.
* **Your own props:** a tool that renders a prop pack and folds it into the catalogue, thumbnails and measurements included, so custom props answer the same questions as shipped ones.
* **A viewer:** `/prop_inspect` opens the catalogue as a reference: click anything in the world or any tile to read what it is, what it costs and where the game uses it.

## Next Steps

1. [Dependencies](/resources/map-editor-place-anything/dependencies)
2. [Installation](/resources/map-editor-place-anything/installation)
3. [Configuration](/resources/map-editor-place-anything/configuration)
4. [The Catalogue](/resources/map-editor-place-anything/the-catalogue)
5. [Using the Editor](/resources/map-editor-place-anything/using-the-editor)


# Dependencies

## Required

| What                                               | Why                                                                                |
| -------------------------------------------------- | ---------------------------------------------------------------------------------- |
| [oxmysql](https://github.com/overextended/oxmysql) | the layout library. Started before this resource.                                  |
| OneSync                                            | routing buckets for a private session, and the general assumption everywhere else. |

{% hint style="info" %}
No framework. QBCore, Qbox, ESX, standalone: it makes no difference. Access is an ace or a list of identifiers, set in [Configuration](/resources/map-editor-place-anything/configuration). No `ox_lib` either: notifications are drawn by this resource's own UI, and nothing here reads a player's job, money or inventory.
{% endhint %}

## With the Layout Library Off

`cfg.layouts = false` stores nothing: no browser, no table, no server-wide activation. The resource becomes an editor other scripts call, and they store what comes back.

`oxmysql` still has to be startable even then, because the resource loads its library file at start.


# Installation

{% stepper %}
{% step %}

### Install the Dependencies

{% hint style="info" %}
Make sure everything on the [Dependencies](/resources/map-editor-place-anything/dependencies) page is installed and starting **before** this resource.
{% endhint %}
{% endstep %}

{% step %}

### Add the Resource

Download the package from [Portal](https://portal.cfx.re/assets/granted-assets), unzip it, and put the folder in your server's `resources` directory:

* `rm_mapeditor`

{% hint style="danger" %}
Do **not** rename the folder. The resource reads its own files by name: the catalogue, the thumbnails, the locale. A renamed folder is a resource that finds none of them.
{% endhint %}
{% endstep %}

{% step %}

### Start It

Add it to your `server.cfg`, below `oxmysql`:

```cfg
ensure oxmysql

ensure rm_mapeditor
```

{% endstep %}

{% step %}

### Grant Access

Nobody can open the editor until they are allowed to. Two tiers, and the broad `command` ace counts as both:

```cfg
# a builder: their own layouts, and nothing of anybody else's
add_ace group.builder command.map_editor allow

# every layout, and the switch that stands one up for the whole server
add_ace group.admin rm_mapeditor.admin allow
```

Or by identifier, for one or two people without touching aces, in `cfg.lua`:

```lua
cfg.authorizedIdentifiers = {
    ['license:110000112a4b6c8'] = true,
}
```

Both ace names are settings, not fixed strings. See [Configuration](/resources/map-editor-place-anything/configuration).
{% endstep %}

{% step %}

### Database

Nothing to run. The `rm_mapeditor_layouts` table is created on the first save, and only if the layout library is switched on.
{% endstep %}

{% step %}

### Try It

In game:

```
/map_editor
```

The layout browser opens. **New layout** drops you into the editor with a free camera and the prop picker along the bottom.

```
/prop_inspect
```

The same session with nothing to place: click anything in the world to read what it is.
{% endstep %}
{% endstepper %}

## About the Prop Pictures

The picker draws around 30000 thumbnails, packed into sprite sheets. They are **not** downloaded when a player joins: they arrive the first time somebody opens the picker (about 80 MB) and are cached by hash from then on, so it is one wait, once, per version.

If you would rather serve them yourself, point `cfg.nui.imageBaseUrl` at a host you control and copy `assets/props/` across. Details in [Configuration](/resources/map-editor-place-anything/configuration).


# Configuration

Everything is in `cfg.lua`, and it is short: what follows is every setting in it, in the order they appear.

## Locale

```lua
cfg.locale = 'en'
```

A file under `locales/`. See Translate the Strings.

## Commands

```lua
cfg.commands = {
    editor = 'map_editor',
    inspect = 'prop_inspect',
    details = 'prop_details',
    join = 'map_join',
    leave = 'map_leave',
}
```

Each one is the word it answers to, and `false` registers none at all, which is useful when a menu or an item opens the editor instead. See [Exports](/resources/map-editor-place-anything/exports).

| Command                                  | What                                                                    |
| ---------------------------------------- | ----------------------------------------------------------------------- |
| `/map_editor [playerId]`                 | opens the editor. With an id, on that player's screen instead of yours. |
| `/prop_inspect [model\|hash] [playerId]` | the viewer. `-` in the first slot to reach the second.                  |
| `prop_details <model\|hash>`             | server console only: everything known about one prop.                   |
| `/map_join [no]`                         | answers the last session invitation.                                    |
| `/map_leave`                             | stops watching somebody's session.                                      |

The first three need a permission below. `/map_join` and `/map_leave` answer an invitation, so they ask for none: whoever sent it has the permission.

## Permissions

```lua
cfg.editorPermission = 'command.map_editor'
cfg.adminPermission = 'rm_mapeditor.admin'
cfg.authorizedIdentifiers = {}
```

Two tiers, because opening the editor and running the map are different jobs.

**`editorPermission`** opens the editor. A builder with this sees their own layouts and nothing of anybody else's: they cannot read, rename, overwrite or delete somebody else's, and they cannot stand one up for the server.

**`adminPermission`** sees every layout and can activate one. The broad `command` ace counts as both, which is what an admin panel or a server recipe usually grants.

**`authorizedIdentifiers`** is the first tier without an ace, for one or two people:

```lua
cfg.authorizedIdentifiers = {
    ['license:110000112a4b6c8'] = true,
    ['discord:123456789012345678'] = true,
}
```

Either permission can be `false`, which switches that tier off entirely.

## The Editor

```lua
cfg.editor = { ... }
```

| Setting             | Default   | What                                                                                                                                                                                       |
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `reach`             | `300.0`   | how far the placement ray reaches from the camera, in metres                                                                                                                               |
| `area`              | `250.0`   | how far from where a session opens it may place things. The host can widen it or switch it off in the session settings; a caller's own zone or shell overrides it. `false` for no boundary |
| `limit`             | `200`     | objects one session may place. `false` for no cap                                                                                                                                          |
| `gridStep`          | `0.25`    | where the grid button starts. It walks 0.1, 0.25, 0.5, 1 and back to off                                                                                                                   |
| `rotateStep`        | `1.5`     | degrees per rotate press                                                                                                                                                                   |
| `snapDistance`      | `0.25`    | how close a dragged prop's edge comes to a neighbour's before it snaps flush. `0` to never snap                                                                                            |
| `tiers`             | all three | what the standalone editor browses                                                                                                                                                         |
| `allowCustomModels` | `true`    | whether a player may type a model the catalogue does not carry                                                                                                                             |

### Keys

```lua
pickerKey = 'F6', testKey = 'F7', clearKey = 'BACK', bringKey = 'H',
deleteKey = 'DELETE', duplicateKey = 'C', undoKey = 'B',
```

Defaults only. Each is a key mapping, so a player can rebind it under **Settings → Key Bindings → FiveM**, and the on-screen guide reads their own binding back.

### Tiers

```lua
tiers = { 'prop', 'structure', 'element' },
```

| Tier        | What it holds                                                |
| ----------- | ------------------------------------------------------------ |
| `prop`      | standalone objects: furniture, clutter, gear                 |
| `structure` | walls, ceilings, floors, window frames, building shells      |
| `element`   | glass panes, light rigs, decals, emissive strips, vfx planes |

Narrowing this changes browsing only. Every row still ships, the server still answers about it by name or hash, and a resource calling `openEditor` still asks for what it wants.

## The Picker

```lua
cfg.nui = {
    primaryColor = '#f15d38',
    imageBaseUrl = '',
}
```

**`primaryColor`** is the accent of the picker, the panels and the gizmo.

**`imageBaseUrl`** empty ships the thumbnails with the resource: about 80 MB the first time a player opens the picker, cached by hash after. A URL serves them from a host you control instead. Mirror either layout. The picker asks for `atlas.json` and takes the answer:

| Layout | Files                                                           |
| ------ | --------------------------------------------------------------- |
| sheets | `atlas.json` + `atlas_000.webp` … (copy `assets/props/` across) |
| files  | `<model>.webp`, one per prop, no `atlas.json` present           |

## The Layout Library

```lua
cfg.layouts = true
```

On: the browser, the `rm_mapeditor_layouts` table, and the switch that stands a layout up for the whole server. See [Layouts](/resources/map-editor-place-anything/layouts).

Off: this resource writes nothing down. It becomes an editor other resources call, and none of the layout handlers are registered for a client to reach.

## Your Own Props

Props your own resources stream, added by hand, go in `data/custom.json`:

```json
[{ "model": "my_chair_01", "label": "Custom Chair", "category": "seating" }]
```

* `tier` defaults to `prop`; `structure` or `element` keeps it out of furnishing sessions
* `tags` are optional, and are what the picker's **Where** menu filters on
* thumbnail: `assets/custom/<model>.webp`, or none and the tile draws empty
* a model this server does not stream never appears, and says nothing

A whole prop pack goes in `data/custom/` instead, one file per pack. See [Adding Props](/resources/map-editor-place-anything/adding-props).

Both are yours: copy `cfg.lua`, `data/custom.json` and `data/custom/` forward when you replace the folder with a new version.


# The Catalogue

Over 30000 props, measured offline and shipped as data: size, ground offset, triangles, shaders, collision, embedded lights, the DLC each came with and the gamebuild it needs. The picker draws from that, and so does `getPropDetails` for any script that asks.

## The Three Kinds

Every row is one of three, and the picker has a control that switches between them:

| Tier        | Roughly | What                                                         |
| ----------- | ------- | ------------------------------------------------------------ |
| `prop`      | 23000   | standalone objects: furniture, clutter, gear                 |
| `element`   | 5000    | glass panes, light rigs, decals, emissive strips, vfx planes |
| `structure` | 2200    | walls, ceilings, floors, window frames, building shells      |

What a session opens onto depends on who opened it. `/map_editor` browses whatever `cfg.editor.tiers` lists, all three as it ships. `/prop_inspect` browses everything, since a room's own surfaces are often the reason for opening it. A resource calling `openEditor` without saying gets props only, which is what a furnishing session wants, and asks for the rest by passing `filter.tiers`.

Narrowing browsing hides nothing else: every row still ships, the server still answers about it, and any of them can be asked for by name or hash.

Props with no archetype at all never entered the catalogue: a row for something that would spawn nothing is worse than no row.

## Where a Prop Can Be Placed

This is the part worth understanding before somebody reports it as a bug.

A prop exists in the game only while the `.ytyp` holding its archetype is mounted, and what mounts a ytyp is the map. Roughly **half the catalogue is mounted everywhere**, permanently. The other half belongs to an interior or an area, and comes and goes with it: it can be placed near where it belongs and not on the other side of the map.

Measured at one spot with nothing declared: **15276 rows were valid there**, about half. Same catalogue, same server, a different spot gives a different half.

So the picker asks the game rather than guessing. A tile the game cannot make right now is dimmed, and picking it says which case it is:

> Not available where you are: this prop belongs to an interior that is not loaded.

Walk closer and it lights up. Nothing is broken, and nothing was placed that would have spawned as nothing.

## Making the Whole Catalogue Placeable Anywhere

The resource ships the other half as a ytyp of its own, `stream/rm_mapeditor_props.ytyp`, holding **14365 archetypes**. Registering it makes the whole catalogue placeable from anywhere: the same spot that had 15276 valid props has all of them.

It is **off by default**. To turn it on, uncomment the last line of `fxmanifest.lua`:

```lua
data_file 'DLC_ITYP_REQUEST' 'stream/rm_mapeditor_props.ytyp'
```

{% hint style="danger" %}
Four costs, and none of them can be worked around from inside this resource.

**The archetype pool.** Every archetype is a permanent slot out of 65535, shared with the whole game. These 14365 took **95% of it** on a server running little else, and a full pool fails on whoever registers next rather than on this resource. Check your headroom first: `archetypelist 1` in the F8 console, with this off.

**It is measured against one gamebuild.** An archetype declared from a file registers on any build, so on an older one `IsModelValid` answers true for a prop whose model that build never shipped: it looks placeable and picking it spends five seconds arriving at nothing. Audited on gamebuild 3751: 1284 of the declared props were valid everywhere and streamable nowhere, and every prop that was not declared behaved correctly.

**Restarting or stopping the resource crashes every connected player.** Registered archetypes cannot be taken back. Restart on an empty server only.

**The console fills.** One `Duplicate Archetype` line per prop per session as each interior mounts.
{% endhint %}

The honest summary: leave it off unless you are building a map and you know the pool has room. The dimmed tile is the cheaper answer, and it is correct.

## Gamebuild

Every row records the minimum gamebuild it needs and the DLC it came with. On an older build the newer props are dimmed with that reason, rather than placed and never arriving. Nothing to configure.

## Your Own Props

They go in beside the shipped rows and answer the same questions, thumbnails and measurements included. See [Adding Props](/resources/map-editor-place-anything/adding-props).


# Using the Editor

A session opens on a free camera with the prop picker along the bottom and a key bar across the top. The bar always says what the keys do right now, including rebound ones, so this page is a map of the parts rather than a list to memorise.

## Getting Around

|                  |                                                                 |
| ---------------- | --------------------------------------------------------------- |
| **WASD**         | fly. Hold **Shift** to fly faster                               |
| **Space / Ctrl** | up and down                                                     |
| **Right mouse**  | hold to fly and look, aiming at the middle of the screen        |
| **Alt**          | hand the mouse to the camera, or take it back for the cursor    |
| **Mouse wheel**  | fly speed                                                       |
| **F7**           | step out and walk what you have built. Press again to come back |
| **Escape**       | closes what is open: a list, then a tool, then the session      |

The cursor and the camera share the mouse. While the cursor is up you click panels and tiles; the game never sees those clicks.

## Placing One Prop

Pick a tile and the prop is in your hand, seated on whatever is under the crosshair.

|                   |                                     |
| ----------------- | ----------------------------------- |
| **Left click**    | put it down                         |
| **Shift + click** | put it down and keep a copy in hand |
| **Q / E**         | turn it                             |
| **F**             | drop it on the ground               |
| **Backspace**     | put it back                         |
| **F6**            | reopen the picker for another       |

The prop sits on the surface under the aim, tilted to it when the surface isn't flat. What was placed is selected, so the handles are already on it.

## Moving What Is Placed

Click a prop to select it. **Shift-click** adds to the selection; the **Box** tool drags a rectangle over several.

|                   |                                  |
| ----------------- | -------------------------------- |
| **Handles**       | drag to move                     |
| **R**             | move handles or turn handles     |
| **G**             | world axes or the prop's own     |
| **F**             | drop it on the ground            |
| **H**             | bring it to the crosshair        |
| **Shift + click** | add to the selection             |
| **Backspace**     | let go of it                     |
| **C**             | duplicate                        |
| **Delete**        | delete. More than one asks first |
| **B**             | undo. **Shift + B** redo         |

The panel on the right has the numbers: typed position and rotation, tint, collision, and the grid and snap switches. Snapping is two things: a grid the button walks through, and edge snapping that pulls a dragged prop flush against its neighbour.

## More Than One at a Time

Three tools take from a pool rather than your hand. Arm one, then pick props from the grid: they go into the pool instead of into your hand, and you can add several.

**Scatter** drops copies while the button is held. Circle sets how wide, speed how many a second, and there's a minimum gap so nothing lands inside its neighbour. It can lean props with the ground.

**Fill area** takes clicks on the ground as corners, paints the shape as you go, and covers what they enclose when you press **Fill it**.

**Along a line** takes clicks as a route and lays props down it when you press **Lay them**: a fence, a row of lamp posts, cones down a closed road. Each one faces along the route, plus whatever turn you set.

All three read the same **Turn** control, which is the offset added to what they work out. A fence panel built across its own heading is corrected once for the whole run.

## The Map's Own Props

Switch **World objects** on and clicks reach the map itself. What is under the pointer is boxed before you click it, in its own colour, so a click never lands on a surprise.

Three things can be done with a piece of map:

* **Hide** it. Gone for everybody the layout is stood up for, and it travels with the layout.
* **Replace with prop.** A hide plus a copy of the same model in the same place, which you can then move, turn, tint or throw away. The game gives nobody a handle on its own scenery, so this is what "moving a wall" means.
* **Look at it** in the viewer.

Not everything can be copied. Most walls, floors and kerbs have no name in any catalogue, and nothing can spawn a copy of a nameless archetype, and the panel says which case it is.

The **Hidden map props** list puts anything back, one at a time or all of them.

## Lights

The **Light** tool puts one where you point. Its panel sets shape (point or spot), colour, range, brightness, cone and edge for a spot, and shadows.

{% hint style="info" %}
A light is a native call every frame, not an object. It exists while a layout is active and this resource is running. No map file can carry one. The same is true of hidden map pieces, a prop's collision switch and the colour of a light inside a drawable. The editor tags all four **Runtime**, and a session can be told not to offer them at all.
{% endhint %}

## Groups

Select several props and **Create group**: they move, turn, duplicate and delete as one thing, and the group's name travels with the layout. The **Placed props** list is where groups are renamed, reordered and taken apart.

## Session Settings

The last button on the tool strip. What is in here is decided once and then left alone:

| Row                | What                                                                                             |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| **Reach**          | the boundary, as a radius from a middle. Widen it, switch it off, or type the middle             |
| **Follows**        | whether the boundary walks with what you place                                                   |
| **Outline**        | whether the boundary is drawn. Your screen only; the rule holds either way                       |
| **Runtime**        | whether the four things a map file cannot carry are offered at all                               |
| **Others**         | whether the other people in the session are drawn where they are working                         |
| **Time / Weather** | held for everybody in the session. Two in the morning under a clear sky is what a shell opens at |
| **World**          | a routing bucket of the session's own, and whether there is any traffic in it                    |
| **People**         | who is in it, invitations, and who may change things                                             |

## Building with Somebody

Invite from the session settings. Anybody connected can be asked, permission or not, since the invitation is yours to give. They get a card with your name and the command to answer with, and they arrive in a session of their own holding your rows: every tool, their own undo, and an id range that can't collide with yours.

Two roles. An **editor** can place, move and delete. A **viewer** watches and can't change anything. The host can change either mid-session, or remove somebody: their copy of the map goes with them and whatever they placed stays.

The layout is the host's. Nothing a guest does saves it, and closing their window is leaving.

## Finishing

**Done** asks what to call the layout, or hands the rows to whoever opened the session. See [Layouts](/resources/map-editor-place-anything/layouts) and [Exports](/resources/map-editor-place-anything/exports). **Export** on the same screen writes the build out in any of five shapes; see Import & Export.

Cancel throws the session away, and asks first if there's anything to throw.


# Layouts

A layout is a saved build: the props, and the pieces of the map that went to make room for them. This page is about the library this resource keeps for itself, which is what `/map_editor` opens onto.

{% hint style="info" %}
None of this exists with `cfg.layouts = false`. That turns the resource into an editor other scripts call, and they store what comes back. The handlers aren't even registered, so nothing on a client can reach them.
{% endhint %}

## The Browser

`/map_editor` opens the list rather than the editor: what is saved, who saved it, how many props each holds and how big it is. From a card:

|                                  |                                            |
| -------------------------------- | ------------------------------------------ |
| **Open**                         | edit it. Done saves back over it           |
| **Activate**                     | stand it up for every player on the server |
| **Export**                       | write it out, see Import & Export          |
| **Rename**, **Copy**, **Delete** | the record, not the world                  |

**New layout** starts empty. **Import** brings one in from a file.

The filters down the left are all layouts, the active ones, and by whoever saved them.

## Who Sees What

A builder, somebody with the editor permission but not the admin one, sees their own layouts and only those. They can't read, rename, overwrite or delete anybody else's, and they can't activate one.

That is decided by identifier rather than by name: two players can carry the same name, and one player can change theirs between saving a layout and coming back to it.

An admin sees everything and can activate. See [Configuration](/resources/map-editor-place-anything/configuration) for the two aces.

A guest invited into somebody's session reaches none of this. The layout is the host's, and saving it is the host's press.

## Activating One

**Activate** stands the layout up for everybody: props spawn on each client, hidden pieces go out of view, lights are drawn while it is on. It survives a restart: what was standing comes back up when the resource starts.

{% hint style="warning" %}
An activated layout is scenery for the whole server, not a preview. Deactivate takes it away again for everybody, and deleting an active layout takes it down first, because otherwise the props would stay in the world with no row to explain them.
{% endhint %}

While you're editing a layout that is standing, your own copy of it is held back. Two copies of the same build in one place is every prop doubled, and the session already has one.

## Where It Is Stored

One table, created on the first save:

```
rm_mapeditor_layouts
```

It holds the name, the author's name and identifier, the rows, the hidden pieces, whether it is active, and when it was made and last touched.

A layout is saved as **both halves**, the props and the hides, because it isn't reproducible from the props alone: some of them stand where the map's own geometry used to. Reopening a layout without its hides stands a copy of a wall inside the wall it replaced.

## The Boundary and Saving

A session is held to an area, and the server filters a save against it rather than trusting the client. Rows outside are dropped, and the save says how many. That is the same rule whether the boundary came from the config, from the host widening it, or from a caller's own zone.

## Backing Up

The table is ordinary MySQL: back it up the way you back up everything else. If you want a copy outside the database, the **Export** button writes a layout file that comes back in through **Import**.


# Import & Export

A build can leave in five shapes and come back in three. **Export** is on every card in the layout browser and on the way out of a session.

The dialog shows the layout file straight away, because that is the one nearly everybody wants. The others are underneath: picking one shows it and writes nothing. **Save on the server** is the press that leaves a file behind, and it says where afterwards.

## What Each Shape Carries

| Shape           | Carries                                                                                  | Comes back in |
| --------------- | ---------------------------------------------------------------------------------------- | ------------- |
| **Layout JSON** | everything: props, lights, hidden pieces, collision, tint, embedded light colour, groups | yes           |
| **Resource**    | everything, as a folder that stands the build up on its own                              | no            |
| **Lua table**   | the rows, for a script of your own that already spawns props                             | no            |
| **ymap XML**    | props as map data, with their tint                                                       | yes           |
| **Spooner XML** | props with their tint, frozen where they stand                                           | yes           |

{% hint style="info" %}
**Runtime features** are lights, hidden map pieces, collision and embedded light colour, and each needs a script running. No map file can hold them, so a ymap or spooner export leaves them out and says how many rows it dropped. The layout file and the generated resource keep them.
{% endhint %}

## Where the Files Go

Inside the resource, one folder per shape:

```
rm_mapeditor/exports/json/<name>.json
rm_mapeditor/exports/lua/<name>.lua
rm_mapeditor/exports/xml/<name>.ymap.xml
rm_mapeditor/exports/spooner/<name>.xml
rm_mapeditor/exports/resource/rmme_<name>/
```

## The Generated Resource

The resource shape writes a folder you can drop straight into `resources` and `ensure`. Two files: a manifest, and a `layout.lua` that makes the props when it starts and deletes them when it stops. Nothing is saved and no database is touched: stopping the resource takes the build away.

Because it is a script rather than a file, it is the one export with anything to decide, and the dialog asks:

|                        | Default  | What                                                                                                                        |
| ---------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Runtime features**   | Included | left out, it writes the least a script can do: props where they were put, with their tint, and nothing it has to keep doing |
| **Draw lights within** | 120 m    | further than this from the camera and a light is skipped rather than drawn                                                  |
| **Lights at once**     | 24       | the nearest this many, and no more: the game shares one light pool with everything on screen                                |
| **Hide radius**        | 0.10 m   | how near a map piece has to be to the saved spot to count as the one being taken out of view                                |

Changing any of them rewrites what is on screen, so you're reading the file you're about to keep.

{% hint style="info" %}
The generated script asks the game for every model once, then places the rows, so a layout streams the way the game streams everything else rather than one file at a time. A light's direction and reach are worked out at load, and what is near enough to draw is swept a few times a second rather than every frame.
{% endhint %}

## Importing

**Import** in the browser rail. Paste the file, give it a name, bring it in. Three kinds are read:

* **A layout file** from this editor: everything comes back, lights and hidden pieces included.
* **ymap XML** as a map tool writes it. Props come back where they stood, with their tint.
* **Spooner XML**, and the older `MapObject` kind. Props come back with their tint; vehicles and peds are counted out, because a layout is props and a car quietly turning into nothing would be worse than a number saying so.

What it refuses, and why:

| Message                                                  | What happened                                         |
| -------------------------------------------------------- | ----------------------------------------------------- |
| That is not a layout file this can read                  | the text is not any of the three                      |
| That file comes from a newer version of this editor      | a layout file written by a later version              |
| That XML is not a map this can read                      | XML, but not a `CMapData` or a spooner placement list |
| That file stores its rotations in a way this cannot read | a rotation order this cannot convert                  |
| A layout is already called that                          | pick another name; nothing was overwritten            |

An import counts the models it does not recognise and says how many. A file written on another server can name that server's own prop packs, and those rows come in fine and then stand up as nothing here.

## Rotations

Everything here writes and reads rotation order 2 (ZXY), which is what the game uses for an object. A layout file records the order it was written in, so a file from a later version that changes it is refused rather than read wrong. The ymap export writes the quaternion a map file expects.


# Adding Props

The catalogue is the base game's props. Your own go in beside them, and then they behave the same: a tile in the grid, a thumbnail, measurements in the viewer, an answer when another script asks what they are.

Two ways in, split by how many there are. A couple of props is a couple of lines you type; a whole pack is a tool that opens every model, renders a thumbnail and measures it.

## A Prop or Two, by Hand

`data/custom.json`, one object each:

```json
[
  { "model": "my_chair_01", "label": "Custom Chair", "category": "seating" },
  { "model": "my_lamp_01", "label": "Street Lamp", "category": "lighting", "tags": ["night"] }
]
```

* `tier` defaults to `prop`; `structure` or `element` keeps it out of furnishing sessions
* `tags` are what the picker's **Where** menu filters on
* thumbnail: `assets/custom/<model>.webp`, or none and the tile draws empty
* a model this server doesn't stream never appears, and says nothing about it

No tool touches this file, so what you type stays.

## A Whole Pack, with the Tool

`tools/prop-pack/` reads a prop resource, renders a thumbnail for every prop in it, measures what the viewer shows, and writes the rows.

### What It Needs

* **Node 18 or newer**
* **Blender 4.x with the** [**Sollumz**](https://github.com/Sollumz/Sollumz) **addon**: Sollumz is the only thing that reads a `.ydr`

Neither has to be installed already:

```
node tools/prop-pack/index.mjs --install
```

That fetches a portable Blender and the current Sollumz release into `tools/prop-pack/blender/`, enables the addon in that copy, and stops. Nothing is registered with Windows, nothing needs an administrator, and deleting the folder undoes all of it. About 400 MB, which is why it is a flag rather than something the tool decides on its own.

### Running It

{% hint style="warning" %}
Run this on your own machine, not on the live server. It wants Blender and a copy of the pack, and neither belongs on a machine that is serving players.
{% endhint %}

Copy the pack and the `tools/prop-pack` folder somewhere local, then:

```
node tools/prop-pack/index.mjs --pack "C:/packs/my_pack" --category lighting
```

```
16 added, 17 rows in data/custom/my_pack.json, 0 could not be rendered
written to C:\work\prop-pack\output

Copy these two into the server's copy of rm_mapeditor and restart the resource:
  data/custom/my_pack.json
  assets/custom/   (the thumbnails; that folder holds every pack, so copy the new files)
```

Inside a resource it writes there; carried out on its own it writes into `output/` beside itself, in the same shape. `--out <path>` puts it anywhere.

### Copying It Across

Two things, into the server's `rm_mapeditor`:

* `data/custom/<pack>.json`: the pack's own file, which is nobody else's
* the new files in `assets/custom/`: that folder holds every pack's thumbnails together

Then restart the resource. Nothing has to be merged: the server reads every file in `data/custom/`, and a pack that arrives twice is one entry rather than two. Removing a pack is deleting its file.

### Options

| Option                               | What                                                                                |
| ------------------------------------ | ----------------------------------------------------------------------------------- |
| `--pack <path>`                      | the prop resource to read (required)                                                |
| `--out <path>`                       | where to write; defaults to the resource this sits in, or `output/` beside the tool |
| `--category <name>`                  | category for every prop in this pack (default `misc`)                               |
| `--tier <name>`                      | `prop`, `structure` or `element`                                                    |
| `--tags <a,b>`                       | tags for every prop                                                                 |
| `--size <px>`                        | thumbnail size (default 256)                                                        |
| `--jobs <n>`                         | Blender processes at once (default 2)                                               |
| `--overwrite`                        | re-render thumbnails that already exist                                             |
| `--dry-run`                          | list what would be done and stop                                                    |
| `--install`                          | fetch Blender and Sollumz, then stop                                                |
| `--blender <path>`                   | the executable, if it is somewhere this cannot find it                              |
| `--tex <path>`                       | a folder of textures, for the ones a pack asks for but does not carry               |
| `--codewalker <path>` `--gta <path>` | export those textures out of the game itself                                        |

### Editing What It Wrote

The pack's file is yours to edit. A second run keeps everything you typed, a label you rewrote, a category you moved a prop to, and only takes the measurements again, since those are the tool's answer and the pack may have changed under them.

### Textures a Pack Doesn't Carry

Many packs reuse the game's own textures rather than shipping copies. Those surfaces come out plain grey in a thumbnail, and the run says which names were missing. The shape is still right.

If the machine has GTA V and a copy of CodeWalker, the tool can take those textures out of the game and render again:

```
node tools/prop-pack/index.mjs --pack "...\my_pack" ^
  --codewalker "C:\CodeWalker" --gta "C:\Program Files\Rockstar Games\Grand Theft Auto V"
```

Only the names a prop actually asked for are looked up, and what it finds is kept for the next pack that shares a texture. That cache is game art on the machine that owns the game: it isn't in a release.

## When Something Goes Wrong

**`no .ydr files under ...`**: point `--pack` at the prop resource itself. The models usually sit in a `stream/` subfolder, and it looks there on its own.

**`every model in ... is escrow-protected`**: the pack was uploaded to Keymaster and came back encrypted. Only the server it was sold to can read those, so no tool on this side opens one. Run this on the copy you made before uploading; a pack somebody else sells you can't be catalogued this way.

**`could not run Blender`**: run with `--install`, or pass `--blender <path>`.

**A thumbnail is black or empty**: the prop is glass, a decal, or something else with almost nothing opaque in it. The picker draws the tile either way and the prop places normally.


# Exports

Four exports, all of them **server-side**: call them from a server script with the player's `source`. There is no client export, so a client that wants the editor triggers an event of its own and the server calls from there.

The editor is a UI another resource can borrow: it places props, hands the list back, and stores nothing of its own unless you asked it to.

## Opening the Editor

```lua
exports.rm_mapeditor:openEditor(source --[[number]], opts --[[table]], cb --[[function]])
```

The callback is required, and it fires exactly once: with the rows when the player is done, or with `nil` when they cancelled, dropped, or the session could not open.

```lua
exports.rm_mapeditor:openEditor(src, {
    origin = shellOrigin,        -- given: results come back as offsets from it
    existing = savedFurniture,   -- the current layout, edited in place
    hidden = savedHides,         -- and the pieces of map it took out of view
    filter = { tags = { 'furniture' } },
    limit = 50,
}, function(result, hidden)
    -- result: rows, or nil when cancelled
    -- hidden: pieces of the map the session took out of view
end)
```

### What Comes Back

A prop row:

```lua
{ model = 'prop_chair_01a', x = 1.5, y = -2.0, z = 0.0, rx = 0.0, ry = 0.0, rz = 90.0,
  solid = true, tint = 3, group = 'Kitchen' }
```

`solid` is false for a prop whose collision was switched off, `tint` is a palette row and absent at 0, `group` is a name and absent for a prop in none. Rotations are degrees, in order 2 (ZXY), the order `SetEntityRotation` wants.

A light row says so:

```lua
{ kind = 'light', x = 1.0, y = 2.0, z = 2.4, rx = -20.0, rz = 90.0, shape = 'spot',
  r = 255, g = 214, b = 170, range = 9.0, intensity = 2.0, angle = 45.0, falloff = 8.0 }
```

And the second argument, the pieces of map the session hid:

```lua
{ model = 1234567890, x = 210.4, y = -1001.2, z = 29.1 }   -- archetype hash
```

{% hint style="warning" %}
Store both halves. A layout is not reproducible from the props alone: some of them stand where the map's own geometry used to, so replaying the props without the hides stands a copy of a wall inside the wall it replaced. Both go back in through `existing` and `hidden`.
{% endhint %}

### Options

| Field      | What                                                                                                           |
| ---------- | -------------------------------------------------------------------------------------------------------------- |
| `origin`   | a `vector3`. Rows come back as offsets from it rather than world coordinates                                   |
| `existing` | rows to open with, edited in place                                                                             |
| `hidden`   | the hides that went with them                                                                                  |
| `limit`    | how many objects this session may place                                                                        |
| `area`     | a zone the session is held to: `{ x, y, z, radius }`, or a polygon `{ points = { vector3, ... }, minZ, maxZ }` |
| `shell`    | build inside an interior, see below                                                                            |
| `filter`   | what the picker offers: `categories`, `excludeCategories`, `tiers`, `tags`, `maxSize`                          |
| `allow`    | what the session may make, see below                                                                           |
| `look`     | `{ hour, weather }` held for everybody in the session                                                          |
| `mode`     | `'inspect'` opens the viewer instead. `openInspector` is the readable way to say it                            |
| `model`    | opens the viewer straight onto one prop                                                                        |

### Building Inside a Shell

```lua
exports.rm_mapeditor:openEditor(src, {
    shell = { model = 'shell_v_ret_ml_store', at = coords, heading = 90.0 },
    existing = savedFurniture,
}, function(result) end)
```

The boundary becomes the building's own box and everything comes back **in the building's frame**, so the same layout stands up in every copy of that shell, at whatever heading each one was spawned at.

A model and a place, never a handle or a network id: a shell is scenery, spawned on each client that needs to see it, so there is usually no network id naming it and the handle that exists belongs to a client. One already standing there is worked inside and left alone; otherwise one goes up for the session and comes down with it.

`margin` widens the box by that many metres at every face (0.5 by default), because a box measured to the millimetre refuses the chair somebody meant to push against the wall.

Standing the result back up is the mirror of it:

```lua
for _, row in ipairs(saved) do
    local rad = math.rad(shellHeading)
    local x = shellCoords.x + row.x * math.cos(rad) - row.y * math.sin(rad)
    local y = shellCoords.y + row.x * math.sin(rad) + row.y * math.cos(rad)
    local prop = CreateObjectNoOffset(joaat(row.model), x, y, shellCoords.z + row.z, false, false, false)
    SetEntityRotation(prop, row.rx, row.ry, row.rz + shellHeading, 2, true)
end
```

### What a Session May Make

Four things this editor does are not a prop standing in a map: a light, a hidden piece of map, a prop's collision switch, and the colour of the light inside a drawable. Each is a call somebody has to keep making, so a caller that stores rows and spawns props itself gets the props and whatever of these it makes the calls for.

So a session says what it offers:

```lua
allow = { runtime = true, lights = true, hide = false, collision = true, lightColor = true }
```

`runtime` is the switch over all four and each flag is under it. **Off by default for a shell session**, because that caller is exactly the one who will stand the layout up somewhere else. The standalone editor gets everything.

## Opening the Viewer

```lua
exports.rm_mapeditor:openInspector(source --[[number]], opts --[[table?]], cb --[[function?]])
```

The same session with nothing to place: the catalogue as a reference, and a click on the world to ask what something is. The callback is optional and is handed nothing, since a session that places nothing has no result, and it fires on close, so you can put your own UI back.

```lua
exports.rm_mapeditor:openInspector(src, { model = 'prop_bench_01a' })
exports.rm_mapeditor:openInspector(src, { tiers = { 'prop' } }, function() end)
```

| Field    | What                                                                                                                      |
| -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `model`  | opens straight onto one prop, by name or hash                                                                             |
| `tiers`  | what the picker browses. The viewer opens on all three, because a room's own surfaces are often the reason for opening it |
| `filter` | the editor's `filter`, the same fields                                                                                    |

## Asking About a Prop

```lua
local row = exports.rm_mapeditor:getPropDetails('prop_bench_01a')   -- name or hash
local name = exports.rm_mapeditor:getPropName(1234567890)           -- hash, signed or not
```

Both are synchronous, read the server's own files, and need no player and no session. Both answer `nil` for a model this catalogue does not carry, which is also how you ask whether it carries one.

`getPropDetails` answers for **every** row the pipeline measured, including the ones the picker does not offer:

|                               |                                                                                                                                                                                                                       |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| measured off the asset        | `dims`, `groundOffset`, `bbmin`, `footprint`, `triangles`, `shaders`, `hasCollision`, `collisionType`, `lights`, `lightTypes`, `emissive`, `alpha`, `decal`, `cloth`, `memoryBytes`, `lodDist`, `ytyp`, `dlc`, `path` |
| decided about it              | `category`, `label`, `tier`, `minBuild`, `offered`                                                                                                                                                                    |
| where the shipped map uses it | `usedWorld`, `usedInterior`, `usedAt`, `usedIn`                                                                                                                                                                       |

`offered` is not the same question as `tier`: a row can be tiered `prop` and still not be offered, because a measurement said it cannot be placed.

`getPropName` takes a hash in either spelling and also takes a name, which is the other use for it: somewhere to send a name of unknown case before comparing it to one of yours.

## Replacing the Commands

Any command can be `false` in `cfg.lua`, and these exports are how a menu, an item or a job script opens the same thing:

```lua
cfg.commands = { editor = false, inspect = false }
```

No permission is asked for on the way through. The command checks an ace because a player typed it; an export was called by a server script that had already decided, and this one opens the editor for the player it names.


# Translate the Strings

Every word a player reads comes from one file. Nothing is written in the code, so a translation is a copy of that file and one line of config.

## Where They Are

```
rm_mapeditor/locales/en.json
```

A flat JSON object, one key per string:

```json
{
  "ui.tool.select": "Select",
  "ui.fill.go": "Fill it",
  "limit_reached": "Placement limit reached (%s)."
}
```

Keys with a `ui.` prefix are read by the interface; the rest are the messages that appear as a toast. Both halves of the resource read the same file, so a key that works in one works in the other.

## Adding a Language

{% stepper %}
{% step %}

### Copy the File

```
locales/en.json  ->  locales/de.json
```

Keep every key. A missing key shows up as the key itself, on purpose: an untranslated string has to be visible rather than blank, or it ships.
{% endstep %}

{% step %}

### Translate the Values

Leave the keys alone and translate the right-hand side.
{% endstep %}

{% step %}

### Point the Config at It

```lua
cfg.locale = 'de'
```

Restart the resource. A locale that is not there falls back to `en` and says so in the console.
{% endstep %}
{% endstepper %}

## Placeholders

`%s` is filled in by the resource, in the order it appears:

```json
"import_done": "Imported %s of %s rows.",
"ui.session.reach_fixed": "%s m, set by whatever opened this session"
```

Keep every `%s` and keep them in that order. A string that loses one loses the number it was carrying.

## Two Conventions Worth Keeping

**Runtime features.** Lights, hidden map pieces, collision and embedded light colour are named as a group in several places, because they behave as a group: they need a script running, so they only work while a layout is active. If you translate that phrase one way in the tag and another way in the settings, it stops reading as one idea.

**What a key says about a press.** A hint under a switch says what pressing it does next rather than what the switch is: "On, click to show every prop". It reads oddly out of context and correctly in it.


# Troubleshooting

Work down the list. Everything here is something the resource says out loud, in the server console, the client console (**F8**) or on screen.

## The Command Does Nothing

**"You are not allowed to use the map editor."**: the permission. Grant `command.map_editor`, or add the identifier to `cfg.authorizedIdentifiers`. See [Installation](/resources/map-editor-place-anything/installation).

**Nothing at all, not even that**: the command is switched off (`cfg.commands.editor = false`), or the resource isn't running. Check the console for `rm_mapeditor` at start.

## The Editor Opens but the Tiles Are Empty or Wrong

**Empty tiles, everything else fine**: the thumbnails haven't arrived yet. They are fetched the first time somebody opens the picker, about 80 MB, and cached by hash after. The console says `propImages did not arrive` if it timed out; check the player's download and try again.

**Every tile shows the wrong picture**: a mix of files from two versions. The console says so:

```
N catalogue rows carry no picture reference; this install mixes data and image files
from different versions
```

Replace the whole folder rather than some of it. `data/catalog.json` and `assets/props/` are a matched pair.

**Serving them yourself and nothing loads**: `cfg.nui.imageBaseUrl` must serve either `atlas.json` plus the sheets, or one `<model>.webp` per prop with no `atlas.json`. Anything half-mirrored looks like this.

## A Prop Won't Place

The message says which case it is:

| Message                                                                          | What it means                                                 |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| This server does not stream that model                                           | nothing on this server carries it                             |
| This prop came with a game update your server does not run                       | it needs a newer gamebuild                                    |
| Not available where you are: this prop belongs to an interior that is not loaded | its archetype is only mounted near that interior. Walk closer |
| Nothing under the crosshair to stand it on                                       | aim at something                                              |
| Outside the area this session is building in                                     | the boundary. Widen it in the session settings, or move       |
| Placement limit reached (N)                                                      | `cfg.editor.limit`                                            |

## The Map's Own Props

**"This is part of the map itself and cannot be re-made."**: the archetype has no name in any catalogue, and nothing can spawn a copy of a nameless one. Most walls, floors and kerbs are like that. It can still be taken out of view.

**A hidden piece comes back after a while**: hides are re-applied when the layout is active. If the layout isn't active, the hide belongs to the session and ends with it.

## Layouts

**"That layout belongs to somebody else."**: the builder tier sees only its own. Grant `rm_mapeditor.admin` for the full library.

**"Only an admin can activate a layout."**: the same ace.

**The list is empty on a server that has layouts**: the table exists but this player is a builder, so they see their own. Or `cfg.layouts = false`, which switches the library off entirely.

## Exporting

**"Could not write it. The server may not be allowed to write into the resource."**: the resource folder is read-only, or the server runs as a user that can't write into it. The export folders ship empty for exactly this write.

**"%s rows are runtime features, which no map file can hold. Left out."**: not an error. A ymap can't carry lights, hidden pieces, collision or embedded light colour, so those rows were left out. Use the layout file or the generated resource if you need them.

## Importing

See the table in Import & Export. Every refusal there's a different file problem, and the message names which one.

## Sessions with Other People

**The invitation card never arrives**: they were already in a session of their own, or the invitation timed out before it was answered. A guest needs no permission: whoever invited them has it.

**A guest sees no props**: they are given their own session holding the host's rows, so this means the host's snapshot never arrived. Check the server console for a warning at the moment they joined.

**"That session has ended."**: the host closed it, dropped, or the resource restarted.

## Still Stuck

Open a ticket in the [Discord](https://discord.gg/rainmad) with:

* what you did, and what happened instead
* the **server** console from the moment the resource started
* the **client** console (**F8**) if it is a UI or placement problem
* your `cfg.lua`


# GTA Dual Wield (Two-Handed Weapons)

**GTA Dual Wield (Two-Handed Weapons)** lets players carry a weapon in each hand and fight with both. Pull them out from an inventory item, aim, and fire each hand independently, or switch to unified mode and empty both at once. Every hand keeps its own magazine, its own fire rate and its own reload, and the ammo you leave in a gun is still there next time you take it out. 39 dual weapons ship in, from twin pistols to an RPG in one hand and a minigun in the other.

It works on QBCore/Qbox and ESX (auto-detected), with bridges for four inventories, five notification systems and three progress bars, so it slots into whatever you already run.

***

### Features

**39 ready-made duals:** pistols, revolvers, SMGs, assault rifles, shotguns, machine guns, miniguns and launchers, each with its own magazine size, fire rate, reload time and ammo cap, all editable in one open config file.

**Combined duals:** a different weapon in each hand. Six ship in: RPG + Minigun, Revolver + Sawed-Off, Firework + Grenade Launcher and more. Each side runs completely independent stats, so a 1-round launcher can sit next to a 5000-round minigun without either compromising.

**Two fire modes:** independent, where left click fires the left gun and right click the right, or unified, where left click fires both. Toggle mid-fight with a keybind; the current mode shows on the HUD.

**Persistent per-hand ammo:** ammo is tracked separately for each hand and survives disconnects, restarts and re-equipping. On inventories with metadata it rides on the item itself, so a gun you drop, trade or stash keeps its rounds.

**Smart ammo loading:** using an ammo item splits the stack evenly between both hands, caps each side at its own limit, redistributes the surplus when one hand fills early, and consumes only the rounds that actually went in.

**Six animation sets:** relaxed, gangster, braced, machine gun, minigun and shoulder-fired postures, picked per weapon so a dual minigun doesn't stand like a dual pistol. Aim pitch is synced, so other players see exactly where you are pointing.

**Resolution-aware HUD:** a compact ammo counter showing both magazines, reserves and the active fire mode. It scales with the monitor, staying vector-crisp from 1080p to 4K.

**Open where it counts:** the full weapon table, every setting, all item definitions, all bridges and all translations sit outside escrow. Add a weapon, retune a fire rate or wire in your own inventory without touching protected code.

**Support tooling:** a diagnostics command prints a state snapshot for support tickets, and an opt-in debug mode reports what each shot hit and what damage landed, so a bug report takes one message instead of five.

***

### Dependencies

`ox_lib`, `oxmysql`, a framework, and an inventory if you want ammo to travel with the item. Full list and version notes on the Installation page.

***

### Next steps

* [Installation](https://docs.rainmad.com/resources/dual-gun-wield/installation)
* [Configuration](https://docs.rainmad.com/resources/dual-gun-wield/configuration)
* [Weapons](https://docs.rainmad.com/resources/dual-gun-wield/configuration/weapons)
* [Items](https://docs.rainmad.com/resources/dual-gun-wield/configuration/items)

Running into something odd? [Troubleshooting](https://docs.rainmad.com/resources/gta-dual-wield-two-handed-weapons/reference/troubleshooting) covers the common cases.


# Installation

### 1. Drop in the resource

Place the `rm_dualgun` folder in your resources directory and add it to `server.cfg` **after** your framework, `ox_lib` and your inventory:

```cfg
ensure ox_lib
ensure oxmysql
ensure ox_inventory      # or your inventory
ensure rm_dualgun
```

{% hint style="warning" %}
Load order matters. `rm_dualgun` detects your framework and inventory at start time. If it starts first, auto-detection falls back to defaults and ammo silently drops to SQL mode.
{% endhint %}

***

### 2. Add the items

Open the `[items]` folder and copy the block that matches your inventory:

| Inventory                         | File                       | Where it goes                 |
| --------------------------------- | -------------------------- | ----------------------------- |
| ox\_inventory                     | `items - ox_inventory.lua` | `ox_inventory/data/items.lua` |
| qb-inventory / qs / origen / ak47 | `items - qb shared.lua`    | `qb-core/shared/items.lua`    |
| Database-backed inventories       | `items.sql`                | Run against your database     |

Each file contains **39 weapon items** and **10 ammo items**. See Items for the full list and image requirements.

***

### 3. Ammo storage

Nothing to do if your inventory supports metadata. The script detects it and stores ammo on the item.

If it does not, the script falls back to SQL and creates its own table on first start. You can also run it manually:

```sql
CREATE TABLE IF NOT EXISTS dualgun_ammo (
    id VARCHAR(96) NOT NULL,
    weapon_key VARCHAR(64) NOT NULL,
    ammo_left INT UNSIGNED NOT NULL DEFAULT 0,
    ammo_right INT UNSIGNED NOT NULL DEFAULT 0,
    owner VARCHAR(64) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_owner (owner),
    KEY idx_weapon_key (weapon_key)
);
```

See Ammo System for how the two modes differ.

***

### 4. Verify

Start the server and check the console. You should see one line like:

```
[rm_dualgun] ammo persistence: metadata mode (inventory=ox_inventory)
```

Then in game:

```
/useDual rm_appistol_dual
```

This spawns a dual gun with full ammo, bypassing item ownership. Hold the aim button and both hands should raise with the weapons attached.

{% hint style="info" %}
`/useDual` is a testing command that ignores your inventory. It is the fastest way to confirm animations and attachment work before wiring up items.
{% endhint %}

***

### 5. Controls

| Input             | Action                                      |
| ----------------- | ------------------------------------------- |
| Mouse wheel click | Aim (raises both weapons)                   |
| Left click        | Fire left gun, or both in unified mode      |
| Right click       | Fire right gun, or aim only in unified mode |
| `X`               | Toggle fire mode                            |
| `M`               | Put the dual guns away                      |

Keybinds are registered through `ox_lib`, so players can rebind `X` and `M` in the FiveM keybind settings.


# Configuration

### Framework & integration

#### `cfg.framework`

```lua
cfg.framework = 'auto'   -- 'auto' | 'qb' | 'esx'
```

Which framework bridge to bind. `auto` picks whichever of `qb-core` / `qbx_core` / `es_extended` is running.

Set it explicitly if you run more than one framework resource at once, or if detection picks the wrong one.

***

#### `cfg.inventory`

```lua
cfg.inventory = 'auto'   -- 'auto' | 'ox_inventory' | 'qb-inventory'
                         -- | 'ak47_inventory' | 'origen_inventory'
```

Which inventory bridge to bind. This decides **whether ammo can be stored on item metadata**. See Ammo System.

{% hint style="warning" %}
`qs-inventory` ships a `provide 'ox_inventory'`, which would make the ox bridge bind by mistake. The ox bridge explicitly refuses to load when `qs-inventory` is running. If you use qs-inventory, set `cfg.inventory` yourself.
{% endhint %}

***

#### `cfg.notification`

```lua
cfg.notification = 'ox_lib'  -- 'ox_lib' | 'qb' | 'esx' | 'okokNotify' | 'ps-ui'
```

Which notification system receives messages like *Out of ammo* and *Loaded 30 into L, 30 into R*. Unlike the others this one is **not** auto-detected, so set it to match your server.

***

#### `cfg.progressbar`

```lua
cfg.progressbar = 'auto'  -- 'auto' | 'ox_lib' | 'qb' | 'esx'
```

Which progress bar shows while equipping a dual gun or loading ammo. `auto` resolves to `ox_lib`, which is already a hard dependency.

***

#### `cfg.progress_duration_ms`

```lua
cfg.progress_duration_ms = 1500
```

How long the equip / reload-item progress bar runs, in milliseconds. The player is locked in place for this duration and **cannot cancel it**.

Set it lower for snappier gameplay, higher if you want equipping a minigun to feel deliberate.

***

### Ammo

#### `cfg.ammo_mode`

```lua
cfg.ammo_mode = 'auto'   -- 'auto' | 'metadata' | 'sql'
```

Where ammo counts are stored between sessions.

| Value      | Behaviour                                                                   |
| ---------- | --------------------------------------------------------------------------- |
| `auto`     | Metadata if an inventory bridge with metadata support loaded, otherwise SQL |
| `metadata` | Force metadata. Falls back to SQL with a console warning if unsupported     |
| `sql`      | Always use the `dualgun_ammo` table, even when metadata is available        |

Leave this on `auto` unless you have a specific reason. Full comparison in Ammo System.

***

#### `cfg.ammo_save_interval`

```lua
cfg.ammo_save_interval = 30000
```

How often the background thread flushes dirty ammo entries, in milliseconds. **SQL mode only.** Metadata mode is persisted by the inventory itself.

The same loop also evicts cache entries untouched for more than 5 minutes.

{% hint style="info" %}
Lowering this increases database writes without making ammo meaningfully safer. Ammo is already flushed on unequip, on player drop, and on resource stop. The interval only covers a hard server crash.
{% endhint %}

***

#### `cfg.spawn_empty`

```lua
cfg.spawn_empty = true
```

What happens the **first** time a dual gun item is used and no ammo has been recorded for it yet.

* `true`: spawns empty. Players must load ammo with a `dual_X_ammo` item.
* `false`: spawns full, using each side's `max_ammo`.

`true` is the gameplay-friendly default: it makes ammo items meaningful. Set it to `false` if you sell dual guns pre-loaded.

***

### Diagnostics

#### `cfg.debug`

```lua
cfg.debug = false
```

When `true`, every client prints what each shot hit, and prints a line whenever the engine registers damage on that player.

**Leave this off in production.** It prints on every single shot, which a minigun fires 33 times per second.

Turn it on when investigating a *"my bullets don't hurt him"* report. See Troubleshooting for how to read the output.

***

### Framework bridge defaults

```lua
cfg.requiredPoliceCount = 0
cfg.enableOldMethodForPoliceCount = false
cfg.dispatch = false
```

These exist only so the shared QB/ESX bridge stubs do not throw nil-comparison errors. `rm_dualgun` itself has no police or dispatch logic, so leave them alone unless you are adapting the bridge for your own scripts.

***

### Helper functions

Two functions are exported on the `cfg` table for combined weapons. They are used internally on both client and server, and are available if you write your own integration.

```lua
cfg.isCombined(wcfg)      --> boolean
cfg.sideCfg(wcfg, 'L')    --> the left side's config table
cfg.sideCfg(wcfg, 'R')    --> the right side's config table
```

`sideCfg` returns the per-side subtable for combined weapons, or the entry itself for normal ones. Always read per-side fields through it. That is what makes one function work for both weapon shapes.

```lua
local wcfg = cfg.weapons['rm_chaos_dual']
print(cfg.sideCfg(wcfg, 'L').weapon)  -- weapon_rpg
print(cfg.sideCfg(wcfg, 'R').weapon)  -- weapon_minigun
```

***

### Weapon table

`cfg.weapons` holds all 39 dual weapons and is documented separately in Weapons.


# Weapons

All dual weapons live in `cfg.weapons`. The table key is the **item name**: `rm_appistol_dual` is both the config key and the inventory item.

***

### Normal entries

Both hands hold the same weapon. All fields sit at the top level:

```lua
rm_appistol_dual = {
    label = 'AP Pistol', weapon = 'weapon_appistol', weaponHash = 584646201,
    animSet = 'small', ammo = 'dual_pistol_ammo',
    magazine_capacity = 18, max_ammo = 100, reload_time_ms = 1500,
    shot_interval = 80, recoilData = { recoil = 0.5, screenShake = 0.1 },
},
```

| Field               | Meaning                                                     |
| ------------------- | ----------------------------------------------------------- |
| `label`             | Display name in the HUD and notifications                   |
| `weapon`            | Vanilla weapon name                                         |
| `weaponHash`        | Numeric hash, must match `weapon`                           |
| `animSet`           | Which animation set to use (see below)                      |
| `ammo`              | Ammo item name that reloads this weapon                     |
| `magazine_capacity` | Rounds per magazine, **per hand**, before a reload triggers |
| `max_ammo`          | Total ammo cap per hand (magazine + reserve)                |
| `reload_time_ms`    | How long the reload animation locks the player              |
| `shot_interval`     | Minimum milliseconds between shots from the **same** hand   |
| `recoilData`        | `{ recoil, screenShake }`, reserved, not applied yet        |

{% hint style="info" %}
`magazine_capacity` and `max_ammo` are **per hand**. `max_ammo = 100` on a dual pistol means 100 rounds in the left gun and 100 in the right, 200 total.
{% endhint %}

***

### Combined entries

A different weapon in each hand. Per-side fields move into `left` and `right` subtables; everything shared stays at the top:

```lua
rm_chaos_dual = {
    label = 'Chaos (RPG + Minigun)',
    animSet = 'mini',              -- shared: favour the minigun's posture
    ammo = 'dual_minigun_ammo',    -- shared: one ammo item reloads both sides
    recoilData = { recoil = 0.5, screenShake = 0.1 },
    left = {
        weapon = 'weapon_rpg', weaponHash = 2982836145, label = 'RPG',
        magazine_capacity = 1, max_ammo = 5, reload_time_ms = 3500,
        shot_interval = 1500,
    },
    right = {
        weapon = 'weapon_minigun', weaponHash = 1119849093, label = 'Minigun',
        magazine_capacity = 5000, max_ammo = 20000, reload_time_ms = 4000,
        shot_interval = 30,
    },
},
```

**Shared at the top level:** `label`, `animSet`, `ammo`, `recoilData` **Per side:** `weapon`, `weaponHash`, `label`, `magazine_capacity`, `max_ammo`, `reload_time_ms`, `shot_interval`

Six combined weapons ship by default:

| Item                   | Left hand | Right hand        |
| ---------------------- | --------- | ----------------- |
| `rm_microassault_dual` | Micro SMG | Assault Rifle     |
| `rm_desperado_dual`    | Revolver  | Sawed-Off Shotgun |
| `rm_chaos_dual`        | RPG       | Minigun           |
| `rm_breacher_dual`     | SMG       | Pump Shotgun      |
| `rm_pyro_dual`         | Firework  | Grenade Launcher  |
| `rm_akimbo_dual`       | AP Pistol | Micro SMG         |

{% hint style="warning" %}
Combined weapons share **one** ammo item and **one** ammo pool split across the two sides. A `dual_minigun_ammo` item used on `rm_chaos_dual` fills both the RPG and the minigun. Pick the ammo item that matches the side you want players to resupply most.
{% endhint %}

***

### Animation sets

`animSet` picks the pose. Six sets ship in `stream/`:

| Set     | Posture                | Typical weapons                      |
| ------- | ---------------------- | ------------------------------------ |
| `small` | Relaxed two-hand       | Pistols, SMGs, compacts              |
| `gang`  | Sideways gangster hold | Heavy pistols, revolvers, micro SMGs |
| `long`  | Braced, longer weapons | Rifles, shotguns, railgun            |
| `mg`    | Machine gun stance     | MG                                   |
| `mini`  | Minigun stance         | Minigun                              |
| `rpg`   | Shoulder-fired         | RPG, homing launcher, firework       |

For combined weapons, use the set that suits the **larger** of the two weapons.

The `gang`, `mg` and `long` sets also override the walk clipset while moving. This exists to cancel the "sassy walk" that GTA otherwise blends in.

***

### Adding a weapon

1. Add an entry to `cfg.weapons`, keyed by your new item name.
2. Add the same item name to all three files in `[items]/`.
3. Make sure `weaponHash` matches `weapon`. A mismatch means that hand ends up holding the wrong gun.
4. Point `ammo` at an existing ammo item, or create a new one and add it to the item files too.

```lua
rm_carbinerifle_dual = {
    label = 'Carbine Rifle',
    weapon = 'weapon_carbinerifle', weaponHash = -2084633992,
    animSet = 'long', ammo = 'dual_rifle_ammo',
    magazine_capacity = 30, max_ammo = 250, reload_time_ms = 2000,
    shot_interval = 70, recoilData = { recoil = 1.0, screenShake = 0.2 },
},
```

{% hint style="info" %}
No client changes are needed. The weapon table is read at runtime on both sides and the animation set is looked up by name, so a restart is enough.
{% endhint %}

***

### Balancing notes

`shot_interval` is the single most important number for balance. It is a **hard floor between shots on one hand**, so the real DPS of a dual weapon is roughly:

```
shots per second = 2000 / shot_interval     (both hands firing)
```

A dual minigun at `shot_interval = 30` therefore produces about 66 shots per second. That is also the worst case for network traffic. See Rate Limits.

The default fire rates range from 30 ms (minigun) to 1500 ms (launchers).


# Items

`rm_dualgun` ships **39 weapon items** and **10 ammo items**. All three formats live in the `[items]` folder. Copy the one matching your inventory.

***

### Where each file goes

| Your inventory                    | File                       | Destination                   |
| --------------------------------- | -------------------------- | ----------------------------- |
| ox\_inventory                     | `items - ox_inventory.lua` | `ox_inventory/data/items.lua` |
| qb-inventory / qs / origen / ak47 | `items - qb shared.lua`    | `qb-core/shared/items.lua`    |
| Database-backed inventories       | `items.sql`                | Run against your database     |

***

### Weapon items

The item name and the `cfg.weapons` key are always identical. Adding a weapon to the config without adding the matching item means players can never obtain it.

#### ox\_inventory format

```lua
['rm_appistol_dual'] = {
    label = 'Dual AP Pistol', weight = 2000, stack = false, consume = 0,
    client = { event = 'rm_dualgun:client:useItem' },
},
```

{% hint style="warning" %}
`consume = 0` and `stack = false` are both required. `consume = 0` makes the item a toggle instead of being eaten on use; `stack = false` keeps each gun's ammo metadata separate.
{% endhint %}

#### qb-core format

```lua
['rm_appistol_dual'] = {
    name = 'rm_appistol_dual', label = 'Dual AP Pistol', weight = 2000,
    type = 'item', image = 'rm_appistol_dual.png',
    unique = false, useable = true, shouldClose = true,
    description = 'Two AP Pistols, one in each hand',
},
```

***

### Ammo items

Ten ammo items cover all weapon families:

| Item                | Reloads                            |
| ------------------- | ---------------------------------- |
| `dual_pistol_ammo`  | Pistols, revolvers, heavy pistols  |
| `dual_smg_ammo`     | SMGs, micro SMGs, machine pistols  |
| `dual_rifle_ammo`   | Assault rifles, carbines, bullpups |
| `dual_shotgun_ammo` | All shotguns                       |
| `dual_stungun_ammo` | Stun gun                           |
| `dual_grenade_ammo` | Grenade / compact launchers        |
| `dual_rocket_ammo`  | RPG, homing launcher, firework     |
| `dual_mg_ammo`      | MG                                 |
| `dual_minigun_ammo` | Minigun                            |
| `dual_railgun_ammo` | Railgun                            |

Which item reloads which weapon is set by the `ammo` field in `cfg.weapons`, so you can freely regroup them.

Ammo items **are** consumed, and only the rounds actually loaded are taken. See Ammo System.

***

### Images

Every item needs an icon named after the item, in your inventory's image folder:

```
ox_inventory/web/images/rm_appistol_dual.png
qb-inventory/html/images/rm_appistol_dual.png
```

Missing images do not break anything; the slot just renders blank.

***

### Combined weapon items

The six combined duals follow the same pattern, with heavier weights reflecting what they carry:

| Item                   | Label                                  | Weight |
| ---------------------- | -------------------------------------- | ------ |
| `rm_microassault_dual` | Micro SMG + Assault Rifle              | 4500   |
| `rm_desperado_dual`    | Desperado (Revolver + Sawed-Off)       | 4000   |
| `rm_chaos_dual`        | Chaos (RPG + Minigun)                  | 18000  |
| `rm_breacher_dual`     | Breacher (SMG + Pump Shotgun)          | 5000   |
| `rm_pyro_dual`         | Pyro (Firework + Grenade Launcher)     | 6000   |
| `rm_akimbo_dual`       | Classic Akimbo (AP Pistol + Micro SMG) | 3000   |

***

### Adding your own

1. Add the entry to `cfg.weapons`. See Weapons.
2. Add an item with the **same name** to your inventory.
3. Drop in an image.
4. Restart both resources.

{% hint style="info" %}
Item name, config key and image name must all match exactly. Almost every "my new dual gun does nothing" report comes down to a typo across these three.
{% endhint %}


# Systems


# Ammo System

Ammo is tracked **per hand**, survives disconnects, and can travel with the item itself. This page covers how it is stored, how it flows, and when it is written.

***

### The two numbers per hand

Each hand has a magazine and a reserve:

```
magazine_capacity = 30, max_ammo = 250

  ┌─ magazine ─┐  ┌────── reserve ──────┐
  │    30      │  │        220          │   = 250 total for that hand
  └────────────┘  └─────────────────────┘
```

* **Magazine:** counts down as you fire. Hitting zero triggers a reload.
* **Reserve:** everything not currently in the magazine.
* **Persistence stores the total** (`magazine + reserve`), not the split. On re-equip the total is refilled into the magazine first, remainder to reserve.

***

### Storage modes

#### Metadata mode

Ammo is written onto the item itself:

```lua
item.metadata.dualgun_ammo = { left = 143, right = 150 }
```

The inventory persists it like any other metadata, so ammo **travels with the item**: drop it, trade it, stash it, and the ammo goes along. No SQL table, no rows to clean up.

Requires an inventory bridge that supports metadata:

| Inventory          | Metadata |
| ------------------ | -------- |
| `ox_inventory`     | Yes      |
| `origen_inventory` | Yes      |
| `ak47_inventory`   | Yes      |
| `qb-inventory`     | Yes      |

#### SQL mode

The fallback when no metadata-capable inventory is present. Ammo is keyed by player identifier plus weapon key:

```
id = "license:abc123...:rm_appistol_dual"
```

Ammo is bound to the **player**, not the item. Dropping the gun and picking it up again keeps your ammo; giving it to someone else gives them an empty gun with their own separate count.

{% hint style="warning" %}
SQL mode rows accumulate one per player per weapon key. They are never deleted automatically. If you switch inventories later, old rows stay behind harmlessly but the table will keep growing.
{% endhint %}

***

### Which mode am I in?

The resolved mode is printed once at startup:

```
[rm_dualgun] ammo persistence: metadata mode (inventory=ox_inventory)
[rm_dualgun] ammo persistence: sql mode (inventory=none)
```

Resolution follows `cfg.ammo_mode`:

```
'auto'      → metadata if a metadata-capable bridge loaded, else sql
'metadata'  → metadata, or sql + a console warning if unsupported
'sql'       → always sql
```

***

### When ammo is saved

Ammo is held in memory on the server while you play and written to the backing store at these points:

| Trigger                    | What is written          |
| -------------------------- | ------------------------ |
| Unequipping the dual gun   | That weapon              |
| Using an ammo item         | That weapon, immediately |
| Periodic background save   | Anything changed since   |
| Player disconnects         | That player's ammo       |
| Server / resource shutdown | Everything               |

The periodic save runs every `cfg.ammo_save_interval` ms (default 30 000).

{% hint style="info" %}
Because ammo is already written on unequip, on disconnect and on shutdown, the periodic save only matters for a hard server crash. Lowering the interval increases database writes without making ammo meaningfully safer.
{% endhint %}

While firing, the client reports its count to the server at most **once every 1.5 seconds**, and immediately on reload and unequip. A hard crash mid-burst can therefore lose up to 1.5 seconds of spent rounds. See Rate Limits for why this throttle exists.

### Loading ammo

Using a `dual_X_ammo` item while a matching dual gun is active splits the stack **equally between both hands**, capped at each side's `max_ammo`.

The distribution logic:

1. Split the available count in half, one half per side.
2. Cap each half at what that side actually needs.
3. If the count was odd, the spare round goes to the side with more room.
4. If one side was capped early, its surplus is redistributed to the other side.
5. **Only the rounds actually loaded are consumed** from the inventory.

Example: 100 `dual_pistol_ammo`, left needs 20, right needs 100:

```
perSide     = 50
loadL       = min(50, 20)  = 20
loadR       = min(50, 100) = 50
surplus L   = 30 → moved to R
loadR       = 80

consumed = 100, left +20, right +80
```

Rejections are notified and consume nothing:

| Condition                          | Message                                |
| ---------------------------------- | -------------------------------------- |
| No dual gun active                 | *Activate a dual gun first*            |
| Ammo item doesn't match the weapon | *This ammo doesn't fit the active gun* |
| Both hands already full            | *Both guns are already full*           |
| Inventory removal failed           | *Couldn't take ammo from inventory*    |

***

### Reloading

Reload is automatic. When a magazine hits zero and reserve remains, the reload animation plays for `reload_time_ms` and **both** magazines are topped up from their reserves, so you never end up reloading twice in a row.

With no reserve left the player is notified *Out of ammo* and that hand is blocked for 1 second before it will retry.

***

### The `/useDual` testing command

`/useDual <weaponKey>` spawns a dual gun with full ammo and no item required.

{% hint style="warning" %}
Ammo spent from a `/useDual` weapon is never persisted. This is a testing tool for confirming your setup works. Do not build gameplay on it.
{% endhint %}

### Ammo items

Ten ammo items ship by default, one per weapon family:

`dual_pistol_ammo`, `dual_smg_ammo`, `dual_rifle_ammo`, `dual_shotgun_ammo`, `dual_stungun_ammo`, `dual_grenade_ammo`, `dual_rocket_ammo`, `dual_mg_ammo`, `dual_minigun_ammo`, `dual_railgun_ammo`

Which item reloads which weapon is set by the `ammo` field in `cfg.weapons`. Several weapons can, and do, share one ammo item.


# Rate Limits

### What the resource sets

On start, `rm_dualgun` raises the **state bag flood limiter**:

```cfg
rateLimiter_stateBagFlood_rate   500    # default 150
rateLimiter_stateBagFlood_burst  750    # default 175
```

| Setting | Meaning                                                  |
| ------- | -------------------------------------------------------- |
| `rate`  | Sustained state bag updates a client may send per second |
| `burst` | Short spike allowed before the limiter starts dropping   |

Without this, a player aiming and firing continuously can exceed the default 150/s and be kicked with a state bag flood message.

{% hint style="warning" %}
`SetConvar` is **process-wide**. This raises the limit for your entire server, not just for `rm_dualgun`. That is a deliberate trade, since the limiter cannot be scoped to one resource, but you should know it applies globally.
{% endhint %}

***

### Setting it yourself instead

If you would rather control the value centrally, put it in `server.cfg`:

```cfg
set rateLimiter_stateBagFlood_rate  500
set rateLimiter_stateBagFlood_burst 750
```

The resource writes the same values at start-up, so a matching `server.cfg` entry is harmless. If you set a **higher** value in `server.cfg`, the resource will lower it back to 500/750 when it starts, so set `cfg` values you are happy with, or raise them again after `ensure rm_dualgun`.

***

### When to raise it further

The defaults shipped here are sized for normal play. Consider raising them if:

* You run **other** resources that write state bags heavily (vehicle systems, large HUD frameworks, sync scripts) alongside this one.
* You see state bag flood kicks in your logs **while** players are dual-wielding.
* You have raised fire rates in `cfg.weapons` well below the stock 30 ms floor.

Reasonable next step:

```cfg
set rateLimiter_stateBagFlood_rate  1000
set rateLimiter_stateBagFlood_burst 1500
```

{% hint style="danger" %}
Do not disable the limiter. It exists to stop a modified client flooding your server. Raise it to fit your workload, never remove it.
{% endhint %}

***

### Other limiters worth knowing

State bags are not the only path FiveM rate-limits. If you see kicks that are **not** state bag related, these are the usual suspects:

| Limiter          | Governs                                  |
| ---------------- | ---------------------------------------- |
| `netEventFlood`  | `TriggerServerEvent` calls from a client |
| `msgReliable`    | Reliable network messages                |
| `entityCreation` | How fast a client may create entities    |

`rm_dualgun` does not modify any of these. Its own server events are throttled internally so they stay well inside the defaults. See below.

***

### Built-in throttles

Rather than relying on a raised limiter alone, the resource throttles its own traffic at the source. These are the ones worth knowing about because they have visible gameplay effects:

#### Ammo sync: once per 1.5 seconds

While firing, the client pushes its ammo count to the server at most once every 1500 ms, and forces a push immediately on reload and on unequip.

**Effect:** a hard server crash mid-burst can lose up to 1.5 seconds of spent rounds. This is deliberate: a dual minigun would otherwise send \~66 updates per second per player.

#### Aim sync: 20 Hz, with a movement threshold

The aim angle is sent at most 20 times per second, and only when it has actually moved by a meaningful amount. Holding still sends nothing.

**Effect:** none visible. Remote players see smooth aim tracking.

#### Equip cooldown: 2 seconds

A player may only request a new dual gun once every 2 seconds.

**Effect:** spamming an item shows a *Slow down, try again in a moment* notification instead of equipping.

***

### Scaling notes

The heaviest cost is **per dual-wielding player**, not per player online. A 200-slot server with 5 active dual guns costs roughly the same as a 20-slot server with 5 active dual guns.

Two consequences:

* Traffic scales with **how popular** the weapons are, not with your slot count. If you sell them cheaply and 40 people carry them, plan accordingly.
* The fastest weapons dominate the cost. A dual minigun (`shot_interval = 30`) produces roughly 20x the shot traffic of a dual pistol (`shot_interval = 80`) and 100x that of a launcher.

{% hint style="info" %}
If you need to reduce load, raising `shot_interval` on the fastest weapons in `cfg.weapons` is far more effective than tuning limiters. It reduces traffic and balances the weapon at the same time.
{% endhint %}

***

### Diagnosing a flood kick

1. Confirm the kick message mentions **state bag** flooding. If it names events or entities, this page is not your problem.
2. Check whether the kicked players were dual-wielding at the time.
3. Confirm the convars actually took effect. Another resource starting later may have overwritten them:

```
> get rateLimiter_stateBagFlood_rate
```

4. If the value is not 500 (or higher), find what is resetting it and move your setting after that resource in `server.cfg`.


# Bridges

Everything framework-specific lives in `bridge/`, outside escrow protection. If your server runs something not supported out of the box, you can add it yourself by dropping in one file.

```
bridge/
├── framework/      qb, esx
├── inventory/      ox_inventory, qb-inventory, ak47_inventory, origen_inventory
├── notification/   ox_lib, qb, esx, okokNotify, ps-ui
└── progressbar/    ox_lib, qb, esx
```

Each file guards itself: it checks `cfg` and the running resources, and returns early if it is not the right one. Only one bridge per category ever binds.

***

### Framework bridge

Provides player lookup and usable-item registration.

```lua
function registerUsableItem(item, cb)   -- cb(playerId)
function getPlayerIdentifier(playerId)  -- stable unique string
function getPlayer(playerId)
```

`getPlayerIdentifier` is used as the SQL ammo key, so it must be **stable across sessions**. Returning something session-scoped would reset ammo on every reconnect.

***

### Inventory bridge

Provides item access, and declares whether metadata ammo is possible.

```lua
inventoryName = 'ox_inventory'
inventorySupportsMetadata = true

function getItemMetadata(playerId, item)              --> metadata, slot
function setItemMetadata(playerId, item, slot, meta)  --> boolean
function getItemCount(playerId, item)                 --> number
function removeItem(playerId, item, count)            --> boolean
```

Setting `inventorySupportsMetadata = false` is legitimate. The ammo system falls back to SQL automatically. Only the last two functions are then required.

#### Writing your own

Create `bridge/inventory/myinventory.lua`:

```lua
if cfg.inventory == 'auto' then
    if not GetResourceState('my-inventory'):find('start') then return end
    cfg.inventory = 'my-inventory'
elseif cfg.inventory ~= 'my-inventory' then
    return
end

inventoryName = 'my-inventory'
inventorySupportsMetadata = false

function getItemCount(playerId, item)
    return exports['my-inventory']:GetItemCount(playerId, item) or 0
end

function removeItem(playerId, item, count)
    return exports['my-inventory']:RemoveItem(playerId, item, count) and true or false
end
```

The glob in `fxmanifest.lua` picks up any `.lua` in the folder, so no manifest edit is needed.

{% hint style="warning" %}
Keep the auto-detect guard at the top. Without it your bridge binds even when a different inventory is configured, and whichever file loads last wins.
{% endhint %}

***

### Notification bridge

```lua
notify = function(text, type)              -- client
notify = function(playerId, text, type)    -- server
```

`type` is one of `'success'`, `'error'`, `'inform'`.

Unlike the others, notifications are **not** auto-detected, so set `cfg.notification` to match your server.

***

### Progress bar bridge

Shown while equipping a dual gun or loading ammo, for `cfg.progress_duration_ms`. The action cannot be cancelled.

***

### Load order

The manifest loads bridges before the main scripts, in this order:

```
1. framework bridge    → registerUsableItem, getPlayerIdentifier
2. inventory bridge    → inventorySupportsMetadata, item functions
3. ammo system         → depends on both above
4. main script         → depends on all above
```

This is why the ammo mode line appears in your console at start-up: by then the inventory bridge has already declared its capabilities.

{% hint style="info" %}
If your console reports `inventory=none` when you expect a real inventory, your inventory resource is starting **after** `rm_dualgun`. Fix the order in `server.cfg`.
{% endhint %}


# Reference


# Commands & Controls

***

### Player controls

| Input             | Action                                      |
| ----------------- | ------------------------------------------- |
| Mouse wheel click | Aim, raises both weapons                    |
| Left click        | Fire left gun, or both in unified mode      |
| Right click       | Fire right gun, or aim only in unified mode |
| `X`               | Toggle fire mode                            |
| `M`               | Put the dual guns away                      |

`X` and `M` are registered through `ox_lib`, so players can rebind them in the FiveM keybind settings under **rm\_dualgun**.

***

### Fire modes

Toggled in game with `X`. The current mode is shown on the ammo HUD.

#### Mode 1: Independent (default)

| Button      | Result          |
| ----------- | --------------- |
| Left click  | Left gun fires  |
| Right click | Right gun fires |

Each hand runs its own magazine and fire-rate timer. Best for combined duals, where the two sides behave very differently.

#### Mode 2: Unified

| Button      | Result         |
| ----------- | -------------- |
| Left click  | Both guns fire |
| Right click | Aim only       |

Simpler to use, and closer to how dual-wielding usually feels in shooters.

***

### Aim behaviour

There is a short delay between raising the guns and the first shot being allowed, and the aim pose is held briefly after you release the button. This keeps the animation from snapping in and out during rapid taps.

***

### Commands

| Command                | Access  | Purpose                                         |
| ---------------------- | ------- | ----------------------------------------------- |
| `/useDual <weaponKey>` | Testing | Spawn a dual gun with full ammo, no item needed |
| `/undual`              | Player  | Put the dual guns away                          |
| `/dualdiag`            | Support | Print a state snapshot for support tickets      |

#### `/useDual`

```
/useDual rm_chaos_dual
```

Bypasses inventory ownership entirely, which makes it the fastest way to confirm your install works. Ammo spent this way is never saved.

{% hint style="danger" %}
`/useDual` is not restricted by default. Before going live, either remove the command or gate it behind an ace permission so players cannot give themselves weapons.
{% endhint %}

#### `/dualdiag`

Prints a diagnostic snapshot to the F8 console. If you open a support ticket, run this and include the output. It identifies most issues immediately.

***

### Integration hook

Other resources can check whether a player is currently dual-wielding, and with what:

```lua
-- Server side
local dg = Player(playerId).state.dualGun
if dg then
    print(dg.weaponKey)   -- e.g. 'rm_chaos_dual'
end
```

```lua
-- Client side, for any player
local dg = Player(serverId).state.dualGun
local isDualWielding = dg ~= nil
```

Useful for weapon-restricted zones, HUD integrations, job checks and logging.

{% hint style="warning" %}
Read only. Writing to this value from another resource will desync the player's weapons.
{% endhint %}

***

### Localisation

All player-facing text lives in `locales/`. `en.json` is the reference file:

```json
{
    "out_of_ammo": "Out of ammo",
    "guns_full": "Both guns are already full",
    "ammo_loaded": "Loaded %d into L, %d into R"
}
```

To add a language, copy `en.json` to a new file named after the locale code, replace the **values** while leaving the keys untouched, then point `cfg.lua` at it on the first line:

```lua
lib.locale('de')   -- loads locales/de.json
```

{% hint style="warning" %}
Keep the `%d` placeholders and their order. They are filled in at runtime, so removing one or swapping their positions will produce wrong numbers or an error.
{% endhint %}


# Troubleshooting

### Nothing happens when I use the item

**Check the item name matches the config key exactly.**

The item name, the `cfg.weapons` key and the image filename must all be identical. This is the cause of the large majority of reports.

```lua
cfg.weapons = {
    rm_appistol_dual = { ... }   -- config key
}
```

```lua
['rm_appistol_dual'] = { ... }   -- item name, must match
```

Then confirm:

* The item is `useable = true` (qb) or has the `client.event` line (ox).
* `consume = 0` on ox\_inventory, otherwise the item is eaten instead of toggled.
* `/useDual rm_appistol_dual` works. If it does, the problem is the item, not the script.

***

### Ammo resets every time I reconnect

Check the ammo mode line printed at start-up:

```
[rm_dualgun] ammo persistence: sql mode (inventory=none)
```

`inventory=none` means no inventory bridge bound. Either your inventory started after `rm_dualgun`, or `cfg.inventory` points at something that is not running.

In SQL mode, also confirm `getPlayerIdentifier` returns a **stable** value. A session-scoped identifier produces a new database row on every reconnect.

***

### Ammo doesn't travel with the item

Expected in SQL mode: ammo is bound to the player, not the item.

For ammo that follows the item when dropped or traded you need metadata mode, which requires a metadata-capable inventory. See Ammo System.

***

### Players are being kicked for flooding

See Rate Limits. In short: confirm the kick actually mentions state bags, then check the convar survived start-up:

```
> get rateLimiter_stateBagFlood_rate
```

If it is not 500 or higher, another resource is resetting it after `rm_dualgun` starts.

***

### Other players see the wrong pose, or no weapons

Almost always a streaming issue.

1. Confirm the `stream/` folder is intact and the resource has full read access.
2. Have the affected player rejoin, which forces a fresh sync.
3. Check for another resource that overrides player animations or movement clipsets while weapons are drawn.

***

### Shots don't seem to do damage

Work through these in order:

1. **Is PvP enabled on your server at all?** Test with a normal weapon first.
2. **Is another resource lowering AI weapon damage?** Many anti-grief scripts set a global damage modifier. `rm_dualgun` sets its own at start-up, so anything loading **after** it wins. Move `rm_dualgun` later in `server.cfg`.
3. **Are damage-blocking flags active?** Godmode, admin invincibility and revive states all block damage silently.

If none apply, enable diagnostics and collect output:

```lua
cfg.debug = true
```

Restart the resource, have **both** players open F8, then:

* Both run `/dualdiag` and copy the output.
* The shooter fires a few rounds at the other player.
* Both copy everything printed to their console.

Send all four blocks with your support ticket. That output distinguishes an aiming problem from a damage problem immediately, which is otherwise guesswork.

{% hint style="warning" %}
Turn `cfg.debug` back off afterwards. It prints on every shot: a minigun produces about 33 lines per second.
{% endhint %}

***

### Explosive duals kill the user

Working as intended. Firing an RPG, firework or grenade launcher at something a few metres away puts you inside your own blast, exactly as a vanilla launcher would.

If you want them to be more forgiving on your server, raise `shot_interval` so players cannot panic-fire at their feet, or remove the explosive entries from `cfg.weapons`.

***

### A new weapon I added does nothing

1. `weaponHash` must match `weapon`. A mismatch means that hand ends up holding the wrong gun, or nothing.
2. The item must exist in your inventory with the same name.
3. `animSet` must be one of: `small`, `gang`, `long`, `mg`, `mini`, `rpg`.
4. `ammo` must point at an ammo item that actually exists.

Test with `/useDual <yourNewKey>` to rule out the inventory entirely.

***

### Weapons stay attached after death or a vehicle

The dual guns are put away automatically when the player dies, enters a vehicle, starts swimming, climbs, or ragdolls. If they persist through any of these, another resource is likely overriding the player ped in a way that hides the state change. Check for revive and animation scripts.

***

### Reporting a bug

Include:

* Your framework and inventory, and the ammo mode line from start-up.
* The weapon key involved.
* `/dualdiag` output from every player involved.
* Console output with `cfg.debug = true` if it is a damage or sync issue.


# Decal V: Graffiti & Vehicle Sticker

{% hint style="info" %}
This documentation covers Decal V v**3.x**. Still running the legacy `rm_decalv2`? The old documentation is archived [here](/resources/decal-v-graffiti-and-vehicle-sticker-v2), and [Migrate from v2](/resources/decal-v-graffiti-and-vehicle-sticker/migrate-from-v2) brings you across.
{% endhint %}

{% hint style="warning" %}
**Pre-Installation Note**

This guide assumes you already know how to manage a FiveM server (start resources, edit configs). Following the steps out of order is the most common cause of errors.

**Support**

If something isn't working after you've followed every step, run through [Troubleshooting](/resources/decal-v-graffiti-and-vehicle-sticker/troubleshooting) first, then open a ticket in our [Discord](https://discord.gg/rainmad) with your server console output. Don't skip the [Installation](/resources/decal-v-graffiti-and-vehicle-sticker/installation) and [Dependencies](/resources/decal-v-graffiti-and-vehicle-sticker/dependencies) pages.
{% endhint %}

Decal V lets players spray graffiti on the world and put stickers on vehicles. Aim anywhere, lock the spot with a click, fine-tune size, rotation and color live, and save. Graffiti persists in the world for everyone, stickers follow the vehicle by its plate. A catalog of 340+ bundled decals ships in, and admins can grow it in-game from a URL, an animated GIF, or the built-in decal editor without touching a single image tool.

It works on QBCore/Qbox and ESX (auto-detected) and also runs standalone; notifications bridge to whatever UI resource you already use.

## Features

* **Free placement:** aim at any wall, object or vehicle, click (or Enter) to lock, then adjust size, rotation and color with live preview. Scroll rotates, Ctrl+scroll resizes.
* **Vehicle stickers:** stickers stick to the vehicle by plate and model, sync to every player, and survive restarts.
* **Mirror & flip:** mirror a sticker to the far side of the vehicle, flip it horizontally, or combine both for a true mirror. Every bundled texture ships with a pre-built flipped twin.
* **World graffiti:** graffiti persists in the database, streams to clients around them, and plays a spray-can animation with particles on placement.
* **Catalog permissions:** lock any decal or category to jobs, gangs, identifiers or admins, gate it with your own server-side check, or reserve it for specific vehicle models.
* **In-game decal editor:** admins compose new decals from text layers (27 runtime-loaded fonts), catalog images and URL images on a canvas, then export straight into the catalog.
* **URL & GIF import:** admins add decals from any https image URL; animated GIFs render in-world (opt-in, capped).
* **Recolorable art:** the bundled line-art decals are authored in white so the color picker really works. Pick black to get the classic look.
* **Admin panel:** search, teleport to, delete and permanent-flag every decal on the server; bulk-delete by creator; live diagnostics overlay for support cases.
* **Ownership & protection:** vehicle-ownership checks with job/zone bypasses for shops, creator-only clearing, restricted zones with an event hook, density and size limits. All enforced server-side.
* **Performance-minded:** category textures stream like game assets instead of decoding at runtime, and render distance and budgets are configurable.
* **Discord logging:** decal create/clear logs through a customer-editable file. Bring your own webhook format.

## Dependencies

A framework, `ox_lib`, `oxmysql` and OneSync are required. See the full list on the [Dependencies](/resources/decal-v-graffiti-and-vehicle-sticker/dependencies) page before installing.

## Next steps

1. [Dependencies](/resources/decal-v-graffiti-and-vehicle-sticker/dependencies)
2. [Installation](/resources/decal-v-graffiti-and-vehicle-sticker/installation)
3. [Configuration](/resources/decal-v-graffiti-and-vehicle-sticker/configuration)

Upgrading from v2? [Migrate from v2](/resources/decal-v-graffiti-and-vehicle-sticker/migrate-from-v2) carries your data and edits over.


# Dependencies

Decal V ships as **two resources**: `rm_decalv` (the script) and `rm_decalv_assets` (decal types, streamed textures and CLI tools). Both come in your download; there is nothing extra to buy or build.

## Required

| Resource                                                        | Purpose                                         | Notes                                                                                                                                |
| --------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| [**ox\_lib**](https://github.com/overextended/ox_lib/releases)  | Server/client callbacks, locale, commands       | `3.30.0` or higher recommended                                                                                                       |
| [**oxmysql**](https://github.com/overextended/oxmysql/releases) | Decal persistence                               | `2.10.0` or higher recommended; tables are created automatically                                                                     |
| **OneSync**                                                     | Sticker sync via entity statebags               | `onesync on` is required; `off` and `legacy` mode are not supported, the script warns at start                                       |
| **Framework**                                                   | Player identity, jobs, items, vehicle ownership | QBCore/Qbox or ESX, auto-detected (`cfg.bridge.framework`). Runs standalone too (item entry points and ownership checks are skipped) |

{% hint style="info" %}
Notifications need nothing extra: the default uses ox\_lib, and `'builtin'` renders through the resource's own interface toasts. Every file in `rm_decalv/bridge/notification/` is an adapter that ships open, and its file name is the value for `cfg.bridge.notification`. Pick one, or copy one and point it at your own resource; see [Configuration](/resources/decal-v-graffiti-and-vehicle-sticker/configuration).
{% endhint %}


# Installation

{% hint style="info" %}
Coming from v2? Follow [Migrate from v2](/resources/decal-v-graffiti-and-vehicle-sticker/migrate-from-v2) instead; it wraps these steps and carries your data and edits over.
{% endhint %}

{% stepper %}
{% step %}

### Install the dependencies

Make sure everything on the [Dependencies](/resources/decal-v-graffiti-and-vehicle-sticker/dependencies) page is installed and starting **before** this resource. OneSync must be enabled.
{% endstep %}

{% step %}

### Add both resources

Download the package from [Portal](https://portal.cfx.re/assets/granted-assets), unzip it, and put **both** folders in your server's `resources` directory:

* `rm_decalv`: the script
* `rm_decalv_assets`: decal types (`decals.dat`), streamed textures

{% hint style="danger" %}
Do **not** rename the folders, and do **not** restart `rm_decalv_assets` on a populated server. Its decal types mount when the game level loads, which means they only register for players who **join while it is running**. Start it at boot and leave it alone; restarting `rm_decalv` itself is always safe.
{% endhint %}
{% endstep %}

{% step %}

### Start the resources

Add both to your `server.cfg`, below `oxmysql`, `ox_lib` and your framework so the dependencies are already running:

```cfg
ensure oxmysql
ensure ox_lib
ensure qb-core # your framework

ensure rm_decalv_assets
ensure rm_decalv
```

Keep `rm_decalv_assets` above `rm_decalv`; the script checks for `rm_decalv_assets` at start and warns when it cannot find it. Neither needs to sit at the top of the file: anywhere below the dependencies works, as long as both are running before players connect.
{% endstep %}

{% step %}

### Database

No SQL to run. The tables are created (and older installs migrated) automatically on first start:

* `rm_decalv_stickers`
* `rm_decalv_graffities`
  {% endstep %}

{% step %}

### Items (optional)

Commands work out of the box. If you also want item entry points, set `cfg.disableItems = false` and register the items. Ready-made definitions ship in the resource's `[items]/` folder:

* `items - ox_inventory.lua`: paste into ox\_inventory's `data/items.lua`. The entries route their use through the resource directly, so this works even without a qb/esx bridge.
* `items - qb shared.lua`: paste into qb-core's `shared/items.lua` (bring your own inventory images).
* `items.sql`: run against the ESX `items` table.

The defaults are `paint_spray` (opens the catalog), `scraper` (vehicle clearing mode) and `white_spray` (graffiti clearing). Item names are yours to change in `cfg.items`. Set `cfg.deleteItemInUse = true` to consume the item on use.
{% endstep %}

{% step %}

### Grant admin access

Admins manage the panel, place permanent decals, and bypass zones, limits and ownership checks. Anyone with the ace permission `command` (txAdmin admins) already counts; everyone else goes into `cfg.adminList`:

```lua
cfg.adminList = {
    ['license:0aa00a00a00aa000a000000a00000a00a00aa000'] = true,
    ['XWJ4Q354'] = true, -- citizenid (qb/qbox)
}
```

{% endstep %}

{% step %}

### Configure

Open the catalog in-game with `/decals`. Then head to [Configuration](/resources/decal-v-graffiti-and-vehicle-sticker/configuration) to tune permissions, limits and features.

{% hint style="success" %}
The debug overlay (`/decals_debug`, admin-only) tells you at a glance whether the decal types registered, textures resolve and the catalog loaded. Check it first whenever something looks off.
{% endhint %}
{% endstep %}
{% endstepper %}


# Migrate From v2

v3 is a ground-up rewrite, but it was built to upgrade in place: **your placed decals survive**, and the customer-edited files from v2 keep working. The resource is renamed (`rm_decalv2` becomes `rm_decalv`) and a second resource, `rm_decalv_assets`, now carries the decal types and textures.

{% hint style="info" %}
**What carries over automatically:** every sticker and graffiti in the database (same tables, missing columns are added on first start), `discord_log.lua` copies, event handlers written against the v2 names, and custom decal entries in the old flat `decals.lua` format.
{% endhint %}

{% stepper %}
{% step %}

### Back up your customizations

Before touching anything, copy these out of your old `rm_decalv2` folder:

* `cfg.lua` (to read your old values from, not to reuse as a file)
* your custom `decals.lua` entries and their images from `assets/`
* `server/discord_log.lua` if you edited it
* your locale file if you translated one

A database backup never hurts, but the upgrade does not drop or rewrite existing rows.
{% endstep %}

{% step %}

### Remove v2 completely

Delete the `rm_decalv2` folder and its `ensure rm_decalv2` line. Do **not** run v2 and v3 side by side: v2 ships its own `gta5.meta`, and the two resources would fight over the level meta.
{% endstep %}

{% step %}

### Install v3

Follow the [Installation](/resources/decal-v-graffiti-and-vehicle-sticker/installation) page: both folders (`rm_decalv` + `rm_decalv_assets`) into `resources`, both ensured in `server.cfg`.
{% endstep %}

{% step %}

### Port your configuration

The new `cfg.lua` is reorganized, so re-apply your old values by hand instead of copying the old file over. Shared settings kept their names (`vehicleOwnerCheck`, `blacklistModels`, commands, items, ...); the one move is `cfg.framework`, which is now `cfg.bridge.framework` and defaults to `'auto'`. Everything else you'll find in there is new in v3, with safe defaults, and the file is fully commented.
{% endstep %}

{% step %}

### Port your custom decals

Copy your custom images into `rm_decalv/assets/` and your entries into the new `decals.lua`. The old flat array format still loads as-is, but the new grouped format (category blocks with inherited permissions) is worth adopting, see [Adding decals](/resources/decal-v-graffiti-and-vehicle-sticker/adding-decals).
{% endstep %}

{% step %}

### Port edited files and translations

* `discord_log.lua`: your v2 copy works unchanged, drop it into `rm_decalv/server/`.
* Locale files: copy yours into `rm_decalv/locales/`, then compare against the new `en.json`. v3 added keys (editor, admin panel, imports) and renamed two (`back_decal_choise` → `back_decal_choice`, `back_pozition_adjust` → `back_position_adjust`). Untranslated keys fall back to showing the key name, so the interface stays usable while you catch up.
  {% endstep %}

{% step %}

### Restart the server

A full server restart (not a resource restart) is required once, so every player's next join mounts the new decal types from `rm_decalv_assets`.
{% endstep %}

{% step %}

### Verify

Join, open `/decals`, and check that your v2 decals render in the world. If anything looks off, the [Troubleshooting](/resources/decal-v-graffiti-and-vehicle-sticker/troubleshooting) page reads the debug overlay card by card.

{% hint style="success" %}
Integrations keep running during the transition: every server hook fires under both the new `rm_decalv:server:*` and the legacy `rm_decalv2:server:*` names, so external scripts can be updated at your own pace. New names are on [Events & Exports](/resources/decal-v-graffiti-and-vehicle-sticker/events-and-exports).
{% endhint %}
{% endstep %}
{% endstepper %}


# Configuration

Everything lives in `rm_decalv/cfg.lua` (open file, comments included) and `rm_decalv/decals.lua` (the catalog, covered on [Adding decals](/resources/decal-v-graffiti-and-vehicle-sticker/adding-decals)). The important knobs, in the order they appear:

{% hint style="info" %}
**Updating from an older version?** The config was reorganized into groups in 3.3.0, but every key from older configs (`cfg.disableVehicleStickers`, `cfg.renderDistance`, `cfg.interfaceSettings`, `cfg.limits.maxSize`, ...) keeps working unchanged — and when both spellings exist, the old one wins. You do not need to touch an existing `cfg.lua`.
{% endhint %}

## Locale & bridges

```lua
cfg.locale = 'en'          -- locales/<locale>.json
cfg.bridge = {
    framework = 'auto',    -- 'auto' | 'qb' | 'esx'
    notification = 'ox_lib', -- any file name from bridge/notification/
}
cfg.nui = { primaryColor = '#f15d38' } -- accent color of the whole interface
```

Every notification adapter is an open file in `rm_decalv/bridge/notification/` and its file name is the value for `cfg.bridge.notification`; the annotation in `cfg.lua` lists the set bundled with your version. Copy an adapter to integrate a resource we don't cover.

### Placement ranges

```lua
cfg.placement = {
    minSize = 0.5,     -- sliders and ctrl+scroll offer this range...
    maxSize = 5.0,
    sizeStep = 0.5,
    minOpacity = 0.4,  -- lowest opacity players can pick (0.4 = 40%)
}
```

The server enforces the same ranges on save, so this block is the single source of truth; there is no separate server-side size cap to keep in sync.

## Feature toggles

```lua
cfg.features = {
    vehicleStickers = true,  -- false: the catalog only places graffiti
    graffiti = true,         -- false: the catalog only places vehicle stickers

    gifDecals = false,       -- admin GIF import + animated rendering
    maxGifDuis = 8,          -- distinct GIFs a client renders per session

    httpImageUrls = false,   -- allow plain http:// sources for admin imports
}
```

{% hint style="warning" %}
Every distinct GIF runs its own browser instance on **every client**, and that's real memory per GIF. Keep the feature admin-only and the cap low.
{% endhint %}

## Rendering

```lua
cfg.rendering = {
    distance = 50,           -- synced to clients, also caps placement range

    graffitiBudget = 128,    -- most graffiti drawn at once (farthest dropped first)
    vehicleBudget = 24,      -- vehicles with stickers rendered at once

    farDecalDistance = 25.0, -- beyond this, vehicles render a reduced set...
    farDecalCap = 2,         -- ...of this many decals

    occlusionCulling = true, -- decals nobody can see release their engine load
}
```

`occlusionCulling` keeps the live decal load low by releasing decals that are off screen or blocked from view for a few seconds; they return as soon as they are visible again. It ships enabled; set it to `false` if you suspect it in a visual issue.

## Zones & model rules

* `cfg.restrictedZones`: decals can't be placed inside these (admins bypass). Every attempt fires the `onRestrictedAttempt` hook, see [Events & Exports](/resources/decal-v-graffiti-and-vehicle-sticker/events-and-exports).
* `cfg.blacklistModels`: stickers can **never** go on these vehicle models (emergency fleet by default). To *reserve* models for a faction instead, use `vehicleModels` on a catalog block, see [Adding decals](/resources/decal-v-graffiti-and-vehicle-sticker/adding-decals). Unoptimized vehicle models that overload the decal engine also belong here: the [mesh probe](/resources/decal-v-graffiti-and-vehicle-sticker/troubleshooting) finds them for you and prints ready-to-paste lines.

Both zone lists (`cfg.restrictedZones` and `cfg.vehicleOwnerCheck.bypassZones` below) accept spheres plus the parameter shapes of ox\_lib zones and PolyZone, so definitions you already have paste over unchanged:

```lua
{ coords = vec3(441.0, -982.0, 30.7), radius = 60.0 }                       -- sphere
{ coords = vec3(...), size = vec3(40.0, 60.0, 20.0), rotation = 45.0 }      -- box, ox_lib style
{ coords = vec3(...), length = 60.0, width = 40.0, heading = 45.0, minZ = 25.0, maxZ = 45.0 } -- box, PolyZone style
{ points = { vec3(...), vec3(...), vec3(...) }, thickness = 20.0 }          -- poly, ox_lib style
{ points = { vec2(...), vec2(...), vec2(...) }, minZ = 25.0, maxZ = 45.0 }  -- poly, PolyZone style
```

The checks run server side with the resource's own math; neither ox\_lib zones nor PolyZone needs to be installed for this.

## Entry points

```lua
cfg.commands = {
    create = 'decals',            -- opens the catalog
    catalog = 'decal_catalog',    -- read-only browser (customers window-shop)
    clear_sticker = 'clear_sticker',
    clear_graffiti = 'clear_graffiti',
    admin = 'decals_admin',       -- admin panel

    disabled = false,             -- removes all commands
    adminsOnly = false,           -- keeps them, but only admins may use them
}

cfg.items = {
    create = 'paint_spray',
    clear_sticker = 'scraper',
    clear_graffiti = 'white_spray',

    disabled = true,
    consumeOnUse = false,         -- consume the item on use
}
```

## Permissions & protection

```lua
cfg.adminList = { }                   -- license/steam/fivem/citizenid; ace 'command' also counts

cfg.vehicleOwnerCheck = {
    create = true,                    -- must own the vehicle to sticker it
    clear = false,                    -- ...and/or to clear it
    bypassJobs = { ['mechanic'] = true },
    bypassZones = {
        -- { coords = vec3(-347.3, -133.6, 39.0), radius = 25.0, jobs = { 'mechanic' } },
    },
}

cfg.onlyOwnersCanClear = true         -- creator / vehicle owner / admin; false = anyone
```

`bypassZones` is how tuning shops work on customer cars: inside the area the ownership check is skipped for everyone, or only for the listed jobs/gangs.

## Server-side limits

```lua
cfg.limits = {
    maxSize = 10.0,                   -- absolute decal size cap
    saveCooldown = 3000,              -- ms between saves per player
    maxDecalsPerVehicle = 5,          -- mirrored decals count as two; admins bypass
    densityMax = 5,                   -- graffiti within densityRadius; 0 disables
    densityRadius = 10.0,
}
cfg.inactiveStickerDeleteInterval = '2 WEEK' -- prune stickers of unseen vehicles at boot
```

These are enforced on the server no matter what a client sends. Decal size and opacity limits live in `cfg.placement` at the top of the file; the interface and the server validation share them.

## Editor fonts

`cfg.editorFonts` lists the fonts offered by the admin decal editor's text tool. They load at runtime; nothing ships in the resource. Both forms work, and `name` must match the font's real family name:

```lua
{ name = 'Bangers', url = 'https://fonts.googleapis.com/css2?family=Bangers&display=swap' }, -- Google Fonts embed URL
{ name = 'MyFont',  url = 'https://example.com/fonts/myfont.woff2' },                        -- direct file URL
```

### Advanced

A few optional keys exist for fine-tuning; the defaults suit most servers, add them only if you need them:

```lua
cfg.limits.maxPlaceDistance = 60.0             -- placement range cap; defaults to cfg.rendering.distance + 10
cfg.limits.maxClearDistance = 30.0             -- how far away a decal can still be cleared
cfg.maxImageUrlBytes        = 15 * 1024 * 1024 -- size cap for admin URL imports and editor image layers
cfg.dev                     = true             -- enables the diagnostic /decals_meshprobe command (see Troubleshooting)
```

Raising `maxImageUrlBytes` also moves the editor save ceiling to match, so a large imported layer can still bake and save. Saved decals stay small regardless of import size, because the editor export is capped and re-encoded.


# Adding Decals

The catalog is `rm_decalv/decals.lua`, an open file of **category blocks**. Fields set on a block (`textureDict`, `jobs`, `gangs`, `identifiers`, `adminOnly`, `resolution`, `canUse`, `vehicleModels`) are inherited by every entry inside it; any entry can override them.

```lua
{
    category = 'My Crew',
    gangs = { 'ballas' },              -- whole block gang-locked
    decals = {
        { label = 'Crew Tag', fileName = 'crew_tag.webp' },
        { label = 'Boss Tag', fileName = 'boss_tag.webp', adminOnly = true },
    },
},
```

Every decal needs its image in `rm_decalv/assets/` (webp, png or jpg) even when a ytd texture is used, because the interface preview reads the image file. After editing, restart `rm_decalv`.

## Method 1: texture dictionary (recommended)

Streamed `.ytd` textures are how all bundled categories work: the game loads them through its normal texture streaming, the same way it loads vehicle and map textures. That means no image decoding at runtime, no small texture pool to fill up, and it scales to hundreds of decals. Prefer this for anything beyond a handful of custom decals.

```lua
{
    category = 'JDM',
    textureDict = 'rm_dcl_jdm',        -- ytd in rm_decalv_assets/stream/
    decals = {
        { label = 'Antisocial Kanji', fileName = 'antisocial_kanji.webp' },
        -- textureName defaults to the file name without extension
    },
},
```

{% stepper %}
{% step %}

## Build the texture dictionary

Build a `.ytd` containing one **DXT5** texture per decal (OpenIV: new texture dictionary, import images). Texture names must match each entry's `fileName` without the extension, or set `textureName` per entry.
{% endstep %}

{% step %}

## Put the file in the stream folder

Put the ytd in `rm_decalv_assets/stream/` and keep the preview images in `rm_decalv/assets/`.
{% endstep %}

{% step %}

## Set the block field

Set `textureDict` on the block.
{% endstep %}
{% endstepper %}

**Flip support:** the flip/true-mirror feature looks for a `<dict>_f` twin. Generate it from your ytd with the bundled tool, no OpenIV needed. The only requirement is [Node.js](https://nodejs.org/) (18 or newer) on the machine you run it on; the tool has no packages to install:

```bash
cd rm_decalv_assets/tools/ytd-flip
node index.js ../../stream/my_dict.ytd
```

It writes `my_dict_f.ytd` next to the input. Without a twin the decal simply renders unflipped and the interface greys the flip switch out.

## Method 2: plain image file (quick for a few decals)

{% stepper %}
{% step %}

## Add the image

Drop your image into `rm_decalv/assets/`.
{% endstep %}

{% step %}

## Add the entry

Add an entry to a category block **without** a `textureDict` (or with `textureDict = false` inside a dict-carrying block):

```lua
{ label = 'My New Decal', fileName = 'my_new_decal.webp' },
```

The texture is decoded at runtime into a small per-client pool (8× 256², 16× 512², 6× 1024² slots) and its resolution is read from the file automatically. Perfectly fine for a handful of decals, but the pool is finite, so packs belong in a dictionary.
{% endstep %}
{% endstepper %}

## Method 3: in-game import (admins)

The [admin panel](/resources/decal-v-graffiti-and-vehicle-sticker/admin-panel) adds decals without touching files:

* **Add from URL:** downloads any https image into `assets/` for you.
* **Add GIF:** same, for animated GIFs (needs `cfg.enableGifDecals`).
* **Decal editor:** compose text + images on a canvas, exported as a webp.

Each flow ends with a ready-to-paste `decals.lua` entry, e.g.:

```lua
{ label = 'My Import', fileName = 'url_my_import.webp', textureDict = false },
```

Paste it inside the category block you want and restart the resource. The `textureDict = false` is important: it keeps the entry an image file even inside a block that carries a block-level texture dictionary. Files that never get their entry pasted are reported at startup as unreferenced. Imports are image-source decals (Method 2 rules apply); once a batch grows, move it into its own dictionary.

## Entry field reference

| Field            | Type              | Meaning                                                                                                            |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| `label`          | string            | Name shown in the catalog (must be set)                                                                            |
| `fileName`       | string            | Image in `assets/`, unique, extension included                                                                     |
| `textureDict`    | string \| `false` | Stream ytd to render from; `false` forces the image file                                                           |
| `textureName`    | string            | Texture inside the dict; defaults to `fileName` minus extension                                                    |
| `resolution`     | number            | Optional override for the runtime pool bucket (auto-read from the file otherwise)                                  |
| `jobs` / `gangs` | string\[]         | Only these jobs/gangs see and use the decal                                                                        |
| `identifiers`    | table             | `['license:...'] = true` style allow-list (citizenid works on qb/qbox)                                             |
| `adminOnly`      | boolean           | Admins only                                                                                                        |
| `vehicleModels`  | string\[]         | Sticker can only be applied to these models (server-enforced)                                                      |
| `canUse`         | function          | Server-side final gate, see [Events & Exports](/resources/decal-v-graffiti-and-vehicle-sticker/events-and-exports) |


# Admin Panel

Open with `/decals_admin` (configurable). Access requires the ace permission `command` or an entry in `cfg.adminList`.

## Managing decals

Two tabs, **Stickers** and **Graffiti**, each with search, pagination, and a thumbnail per row:

* **Teleport** to any graffiti's position.
* **Delete:** removes a single decal, live for every player.
* **Delete all by creator:** bulk-removes everything one player ever placed, with a confirmation step.
* **Permanent:** permanent decals survive the inactivity prune and only admins can clear them. Admins can also flip the *Permanent* switch while placing a decal.

## Growing the catalog

Three import flows live in the panel header; each ends with a ready-to-paste `decals.lua` snippet ([details](/resources/decal-v-graffiti-and-vehicle-sticker/adding-decals)):

* **Add from URL:** validates and downloads an https image (3 MB cap, webp/png/jpg).
* **Add GIF:** animated decals behind `cfg.enableGifDecals`, with an admin-only recommendation baked into the dialog.
* **Decal editor:** full-screen canvas with text layers (27 runtime-loaded fonts), outline/shadow controls, catalog and URL image layers, drag/resize/rotate handles, alignment tools, undo/redo, and 256/512/1024 export.

## Debug overlay

`/decals_debug` toggles a diagnostics overlay (also a button in the panel); `/decals_debug <serverId>` flips it on another player's screen so they can read values out during a support case. It shows:

* **Catalog:** whether the client received the decal catalog.
* **decals.dat probe:** whether the decal types from `rm_decalv_assets` registered in this session. **This is the first thing to check** when decals don't render: a failed probe means the player joined while `rm_decalv_assets` wasn't running, or another resource overrode the level meta.
* **gta5.meta owners:** which resource owns the level meta (expected: `rm_decalv_assets`).
* **Decal type slots:** how many of the 280 patchable decal types this session is using.
* **Runtime texture pool:** locked/capacity per bucket (256²/512²/1024²) for image-file decals.
* **GIF DUI browsers:** active browser instances vs the `cfg.maxGifDuis` cap.
* **Rendering:** tracked/rendered vehicles, loaded graffiti cells/rows, rendered graffiti.

While the overlay is open, **Alt** grabs the cursor (and Alt/Esc releases it). During placement, a small preview sprite in the bottom-right corner shows the raw texture: if the sprite renders but the decal doesn't, the texture is fine and the decal types simply didn't register in this session (a join-order or level-meta condition, see [Troubleshooting](/resources/decal-v-graffiti-and-vehicle-sticker/troubleshooting)).

The card-by-card diagnosis flow for "decals aren't showing" lives on [Troubleshooting](/resources/decal-v-graffiti-and-vehicle-sticker/troubleshooting).


# Events & Exports

## Server event hooks

Three public hooks fire on the server. Handle them in `rm_decalv/server/events.lua`; the file is open (escrow-ignored) and safe to edit.

```lua
AddEventHandler('rm_decalv:server:onDecalAdded', function(data)
    -- data.source    number  player who placed it
    -- data.type      'sticker' | 'graffiti'
    -- data.fileName  string
    -- stickers: data.plate, data.model
    -- graffiti: data.posX, data.posY, data.posZ
end)

AddEventHandler('rm_decalv:server:onDecalRemoved', function(data)
    -- same payload as onDecalAdded
end)

AddEventHandler('rm_decalv:server:onRestrictedAttempt', function(data)
    -- data.source  number  player who tried
    -- data.zone    string  cfg.restrictedZones label
    -- data.posX / posY / posZ
    -- hook this into your dispatch for "someone tagging near the PD" alerts
end)
```

{% hint style="info" %}
Handlers written for v2 keep working unchanged: every hook also fires under the legacy `rm_decalv2:server:*` names.
{% endhint %}

## Per-decal `canUse`

Any catalog entry (or whole block) can carry a server-side gate that runs as the final check on save. The decal still shows in the catalog, but locked:

```lua
{
    label = 'VIP Tag',
    fileName = 'vip_tag.webp',
    canUse = function(playerId, label, fileName)
        return exports['my_vip_script']:isVip(playerId)
    end,
},
```

## Discord logging

Decal create/clear events route through `rm_decalv/server/discord_log.lua`, an open file you can rewrite entirely. Logging is **off by default**: set `enabled = true` and your webhook URL in the file's config to turn it on, and shape the embed however you like. v2-era `discordLog = { enabled, webhook, sendLog }` table copies are still honored.

The default logger attaches the decal's image to every create/clear embed as its thumbnail, so moderators see what was sprayed without joining. Drop the `image` field from the entry in `discord_log.lua` if you'd rather keep the logs text-only.


# Translate Strings

All player-facing text (notifications, the whole interface, command descriptions) lives in one locale file.

{% stepper %}
{% step %}

## Copy the locale file

Copy `rm_decalv/locales/en.json` to `rm_decalv/locales/<code>.json` (e.g. `de.json`).
{% endstep %}

{% step %}

## Translate the values

Translate the values. Keys stay as they are; `%s` placeholders must survive.
{% endstep %}

{% step %}

## Set the locale

```lua
cfg.locale = 'de'
```

{% endstep %}
{% endstepper %}

Top-level keys are server notifications, everything under `"ui"` is the interface. Missing keys fall back to showing the key name, so a partial translation still runs.


# Troubleshooting

Every failure mode surfaces in the debug overlay. Open it with `/decals_debug` (admin-only; `/decals_debug <serverId>` opens it on another player's screen so they can read values to you), then check the cards top to bottom.

### 1. DECALS.DAT PROBE says FAILED

The decal types from `rm_decalv_assets` never registered in this session, so nothing can render no matter what else is right. Two causes:

* **The player joined while `rm_decalv_assets` wasn't running.** Its decal types mount when the game level loads, i.e. at join. Make sure it starts at boot, then have the player **reconnect**. Restarting `rm_decalv_assets` does *not* fix already-connected players.
* **Another resource owns the level meta.** Check the **GTA5.META OWNERS** card: it must say `rm_decalv_assets (expected)`. If a map/meta resource is listed instead, it wins the `replace_level_meta` conflict. Load it before `rm_decalv_assets` or remove its override.

### 2. DECAL TYPE SLOTS shows 31 instead of 280

Milder form of check 1, same causes: the player joined while `rm_decalv_assets` wasn't running, either because it never started at boot or because it was being restarted at that moment. The session then falls back to the game's own 31 built-in decal types, so decals render but only 31 distinct textures fit. Start `rm_decalv_assets` at boot, leave it running, and have the affected player reconnect to restore the full 280.

### 3. CATALOG isn't "Loaded"

The client never received the catalog. Read the **server** console from the last `rm_decalv` start:

* `File not found: [x.webp]` means the entry was pruned; the image is missing from `rm_decalv/assets/`.
* `[x] has an unsupported file extension` means only webp/png/jpg are accepted (gif when enabled).
* A Lua error in `decals.lua` stops the whole catalog. Fix the syntax and restart.

### 4. One specific decal is blank

While placing, a small **preview sprite** sits in the bottom-right corner showing the raw texture. Read it like this:

* **Sprite visible, decal missing:** the texture is fine; the decal types didn't register in this session. Back to checks 1 and 2.
* **Sprite blank too:** the texture never resolved. Wrong `textureDict`/`textureName`, the ytd isn't in `rm_decalv_assets/stream/`, or the dict was updated without players reconnecting. For image-file decals, check the **RUNTIME TEXTURE POOL** card: a full bucket (e.g. `512² 16/16`) means too many distinct image decals are in range at once, so move the pack into a dictionary (recommended method).

### 5. Decals exist but don't draw right now

The **RENDERING** card counts what's loaded vs what's drawn:

* **Graffiti rows > rendered:** the extras are beyond `cfg.rendering.distance` or over `cfg.rendering.graffitiBudget` (farthest drop first).
* **Sticker vehicles tracked > 0, rendered 0:** the vehicles are out of render range, or currently hidden by visibility culling (blocked from view / behind the camera); they redraw as soon as they come back into view.
* **Only one player affected:** ask them to open the catalog and check the gear icon: players can turn sticker and graffiti rendering off for themselves, and the choice persists between sessions.

### 6. Decals vanish near a specific vehicle (ENGINE says Stalled)

The **ENGINE** cell in the status strip probes live whether GTA's decal engine is accepting new decals. If it reads **Stalled**, some vehicle model on your server is overloading the engine: unoptimized high-poly conversions make every decal cost many times more than it should, and past a budget the engine stops creating **any** new decals in that player's game (all decal types, other scripts' decals included) until the load drops. The stall is per client, but every player who renders that vehicle's stickers hits the same wall on their own screen, so in practice it reads as server-wide. Typical symptoms: previews stop rendering near a certain car, stickers on healthy cars blink out and return when that car is stored.

The script protects itself automatically (it sheds load and recovers), but the real fix is finding and blacklisting the model:

1. Add `cfg.dev = true` to `cfg.lua` and restart the resource.
2. Run `/decals_meshprobe <model>` for a suspect, or `/decals_meshprobe all` to sweep every vehicle model on the server (takes a while; `/decals_meshprobe stop` aborts).
3. The run ends with a summary and **ready-to-paste `cfg.blacklistModels` lines** for every model that stalled the engine. Paste them, remove `cfg.dev`, restart.

For the vehicle's creator: the fix on their side is lowering body-panel polygon density, adding real LODs, and making the collision match the visible body. Vanilla-quality models pass the probe with a wide margin.

### 7. Flip switch is greyed out

Expected in three cases: the decal is a GIF (no flip variant exists), the texture dictionary has no `<dict>_f` twin (build one with `ytd-flip`), or you're placing on a wall (graffiti has no flip).

### 8. Console warnings worth acting on

Since 3.3.0 routine self-healing messages sit behind verbose logging so consoles stay quiet; run `setr ox:printlevel:rm_decalv verbose` (before resource start) when you want the full diagnostic stream. The warnings below always print:

* `decal capacity: X of 280 slots...`: see check 2.
* `Asset ... uses N MiB of physical memory`: a custom ytd is oversized; split it into smaller dictionaries.
* `no texture source registered for x.webp`: a placed decal's catalog entry was removed. Delete the orphaned decal from the admin panel or restore the entry.
* `N asset file(s) not referenced by decals.lua`: imported files whose snippet never got pasted. Paste the entries or delete the files.

{% hint style="info" %}
Still stuck? Open a ticket in our [Discord](https://discord.gg/rainmad) with your server console output and a screenshot of the debug overlay. Those two cover nearly every case.
{% endhint %}


# Sea Battle: Dive, Sharks & Crate Tow!

***

{% hint style="warning" %}
&#x20;**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.

{% endhint %}


# Installation

## Step 1: Download and Prepare Files

#### **Download the Script from FiveM Keymaster**

* Visit the [FiveM Keymaster](https://keymaster.fivem.net/) site.
* Log in with your account credentials.
* In the **Granted Assets** section, locate the Sea Battle: Dive, Sharks & Crate Tow! script.
* Download the script files from Keymaster.

## Step 2: Add Script to Server Configuration

#### **Edit `server.cfg`**

* Open the `server.cfg` file located in the root directory of your FiveM server.
* Add the following line to ensure the Sea Battle: Dive, Sharks & Crate Tow! script start automatically with your server:

```
rm_seabattle
```


# Configuration

## General Configuration

```lua
Config = {}

----------------------------------
------------ General -------------
----------------------------------

Config.framework = {
    name = "auto", -- esx | qb | auto (auto-detects)
    targetScript = "ox_target", -- ox_target | qb-target
    useOxNotify = true, -- use ox_lib notify instead of framework notify
    debug = false, -- developer debug prints
}

Config.moneyOptions = {
    moneyName = "money", -- account name, or item name if moneyIsItem
    moneyIsItem = false,
}

Config.seaBattle = {
    ----------------------------------
    ---------- Event Start -----------
    ----------------------------------

    start = {
        auto = {
            enabled = false,
            interval = 30,
        },
        command = {
            enabled = true,
            name = "startseabattle",
            staffList = {
                ["license:change_me"] = true,
            }
        },
        npc = {
            enabled = true,
            model = "s_m_y_dockwork_01",
            coords = vector4(-1611.922, 5262.081, 3.974, 206.627),
            item = {
                enabled = false,
                itemName = "sea_intel", -- consumed to start when intelItem is enabled
                removeItemOnUse = true,
            },
        },
        requiredPlayers = 0,
        maxConcurrent = 3, -- how many events can be active at once
        cooldown = 10, -- minutes a drop slot stays locked after an event ends
        missionTimer = 30, -- minutes before an undelivered event auto-destructs
    },
    notify = {
        announce = true, -- big scaleform shard announcement
        jobList = {}, -- empty = everyone is notified; else only these jobs
    },

    ----------------------------------
    ---------- Plane & Drop ----------
    ----------------------------------

    plane = {
        lineLength = 600.0, -- plane fly-in line length over the drop point
        planeSpeed = 150.0, -- km/h
        planeModels = { "titan" },
        pilotModels = { "s_m_m_pilot_01", "s_m_y_pilot_01" },
    },
    dropCoords = {
        vector3(-1687.698, 5330.878, 0.324)
    },
    deliveryCoords = {
        vector3(-1661.575, 5159.983, -0.249)
    },

    ----------------------------------
    ------------- Blips --------------
    ----------------------------------

    blipOptions = {
        plane = {
            enabled = true,
            label = "Smuggler Plane",
            sprite = 423,
            color = 1,
            scale = 1.2,
        },
        crate = {
            enabled = true,
            label = "Contraband Crate",
            sprite = 478,
            color = 5,
            scale = 1.0,
        },
        npc = {
            enabled = true,
            label = "Maritime Contact",
            sprite = 410,
            color = 3,
            scale = 0.9,
        },
    },

    ----------------------------------
    --------- Crate & Towing ---------
    ----------------------------------
    bob = {
        enabled = true,
        height = 0.35, -- vertical sway in meters
        speed = 0.75, -- sway cycles per second
        tilt = 1.25, -- pitch/roll sway in degrees
    },
    tow = {
        attachDistance = 3.0, -- proximity radius (m) for the attach/cut text prompt
        secureTime = 120, -- seconds you must hold next to the crate (under fire) before the rope can be grabbed
    },

    ----------------------------------
    ------------- Guards -------------
    ----------------------------------

    guards = {
        enabled = true,
        boatModel = "dinghy5",
        pedModel = "s_m_y_blackops_01",
        pedWeapon = "WEAPON_CARBINERIFLE",
        boatCount = 3, -- guard boats spawned in a ring around the drop
        accuracy = 10, -- ped weapon hit chance (0-100), lower = miss more
        shootRate = 50, -- ped fire rate (0-1000); note: barely affects drive-by / mounted-gun firing
        health = 200,
    },

    ----------------------------------
    ------ Underwater & Sharks -------
    ----------------------------------

    underwater = {
        chance = 100, -- % chance an event also spawns the underwater layer
        searchTime = 5, -- seconds the search progress bar takes
        scuba = {
            item = "rebreather", -- usable item that grants underwater air and is required to search the wrecks
            duration = 120, -- seconds of air per use
        },
        wreckModels = { -- sunken junk to search
            "prop_rub_carwreck_9",
            "prop_rub_carwreck_8",
        },
        wreckAmount = {
            min = 3,
            max = 5,
        },
        searchPointsPerWreck = {
            min = 1,
            max = 3
        },
        shark = {
            enabled = true,
            amount = {
                min = 3,
                max = 5,
            },
            biteDamage = 20, -- damage per bite
            biteCooldown = 1500, -- ms between bites from the same shark
        }
    },

    ----------------------------------
    ------- Equipment & Spear --------
    ----------------------------------

    spear = {
        item = "speargun", -- usable inventory item; on use the weapon is handed to the player
    },

    ----------------------------------
    ---------- Maritime NPC ----------
    ----------------------------------

    npcOptions = {
        rentVehicles = {
            {
                model = "dinghy",
                price = 100,
            },
            {
                model = "seashark",
                price = 50,
            }
        },
        items = {
            {
                label = "Rebreather",
                name = "rebreather",
                price = 50,
            },
            {
                label = "Speargun",
                name = "speargun",
                price = 100,
            },
        },
        spawnCoords = { -- rent vehicles
            vector4(-1602.375, 5260.171, 0.120, 24.24)
        }
    },
}

----------------------------------
------------- Loot ---------------
----------------------------------

Config.seaBattle.lootSystem = {
    surface = {
        itemAmount = function() return math.random(3, 6) end,
        money = function() return math.random(5000, 15000) end,

        categories = {
            {
                name = "weapon",
                chance = 35,
                items = {
                    { itemName = "WEAPON_CARBINERIFLE", chance = 40, amount = function() return 1 end },
                    { itemName = "WEAPON_SMG", chance = 35, amount = function() return 1 end },
                    { itemName = "WEAPON_SNIPERRIFLE", chance = 18, amount = function() return 1 end },
                    { itemName = "WEAPON_RPG", chance = 7, amount = function() return 1 end },
                },
            },
            {
                name = "valuable",
                chance = 40,
                items = {
                    { itemName = "goldbar", chance = 50, amount = function() return math.random(1, 2) end },
                    { itemName = "diamond", chance = 35, amount = function() return 1 end },
                    { itemName = "artifact", chance = 15, amount = function() return 1 end },
                },
            },
            {
                name = "ammo",
                chance = 25,
                items = {
                    { itemName = "rifle_ammo", chance = 50, amount = function() return math.random(60, 120) end },
                    { itemName = "smg_ammo", chance = 30, amount = function() return math.random(60, 120) end },
                    { itemName = "sniper_ammo", chance = 20, amount = function() return math.random(10, 20) end },
                },
            },
        },
    },

    underwater = {
        itemAmount = function() return math.random(1, 3) end,
        money = function() return math.random(1000, 4000) end,

        categories = {
            {
                name = "salvage",
                chance = 60,
                items = {
                    { itemName = "scrap_metal", chance = 60, amount = function() return math.random(2, 5) end },
                    { itemName = "copper", chance = 30, amount = function() return math.random(1, 3) end },
                    { itemName = "electronics", chance = 10, amount = function() return 1 end },
                },
            },
            {
                name = "valuable",
                chance = 40,
                items = {
                    { itemName = "goldbar", chance = 60, amount = function() return 1 end },
                    { itemName = "diamond", chance = 40, amount = function() return 1 end },
                },
            },
        },
    },
}

----------------------------------
------------ Strings -------------
----------------------------------

Strings = {
    ["event_title"] = "Sea Battle",
    ["event_started"] = "A smuggler plane is dropping contraband at sea!",
    ["need_item"] = "You need: x%s %s",
    ["not_staff"] = "You are not allowed to do this.",
    ["no_slots"] = "Too many active operations right now.",
    ["not_enough_players"] = "Not enough players online for an operation.",
    ["spawn_occupied"] = "The boat spawn area is blocked.",

    ["add_item_log"] = "Player: %s\nIdentifier: %s\nItem: %s\nAmount: %s",
    ["add_money_log"] = "Player: %s\nIdentifier: %s\nMoney: $%s",
    ["start_log"] = "Started by: %s\nIdentifier: %s\nTrigger: %s\nEvent: %s",

    ["talk_to_contact"] = "Talk to Contact",
    ["rent_boat"] = "Rent Boat",
    ["rent_boat_desc"] = "Rent a boat to reach the open sea",
    ["buy_item"] = "Buy Item",
    ["buy_item_desc"] = "Dive gear and equipment",
    ["start_operation"] = "Start Operation",
    ["start_operation_desc"] = "Trigger a contraband sea drop",
    ["price"] = "Price: $%s",

    ["grab_rope"] = "[E] Grab Rope",
    ["cut_rope"] = "[E] Cut Rope",
    ["securing_rope"] = "Securing the rope...",
    ["tying_boat"] = "Tying to the boat...",
    ["cutting_rope"] = "Cutting the rope...",
    ["let_go_rope"] = "You let go of the rope.",
    ["attach_to_boat"] = "[E] Attach to boat  [X] Cancel",
    ["go_to_boat"] = "[X] Cancel  -  go to your boat",

    ["delivery_point"] = "Delivery Point",
    ["deliver_prompt"] = "[E] Deliver Crate",
    ["delivering"] = "Delivering...",
    ["delivered"] = "Contraband delivered! You earned $%s.",

    ["sunken_wreck"] = "Sunken Wreck",
    ["search_wreck"] = "[E] Search Wreck",
    ["searching_wreck"] = "Searching the wreck...",
    ["need_rebreather"] = "You need a rebreather to dive here.",
    ["salvaged"] = "Salvaged contraband! You found $%s.",
    ["shark_blip"] = "Shark",
    ["scuba_on"] = "Rebreather on.",
    ["scuba_depleted"] = "Rebreather air depleted.",
}

```

### Heist Configuration

```
Config.heist = {
    --------------------------
    ----- QUIET APPROACH -----
    --------------------------
    terminalHacking = {
        coords = vector3(-623.15, -216.22, 53.54),
        requiredItem = "hacker_phone",
        removeItemOnUse = false,

        -- minigame options
        minigame = "none",
        duration = 60,      -- duration of hack minigame in seconds
        solutionLength = 5, -- length of the solution string
        policeAlertDuration = 30, -- seconds (after the hacking incident should the police be alerted)
    },
    plantingGasBomb = {
        ventModel = "prop_aircon_m_04", -- model of the vent where the bomb should be planted
        coords = vector3(-631.13, -228.23, 55.65),
        offset = vector3(-0.83, -0.5, 0.8), -- offset from the coords where the bomb should be planted (relative to the vent)
        requiredItem = "gas_bomb",
        removeItemOnUse = true,

        mask = {
            enable = true,
            requiredItem = "gas_mask",
            removeItemOnUse = true,

            clotheId = 175,
            duration = 300, -- duration of the gas mask effect in seconds
            damage = 5, -- damage dealt to the player every interval while the gas mask is active
            damageInterval = 5, -- interval in seconds at which the damage is applied to the player while the gas mask is active
            zone = vector4(-622.364, -231.020, 38.047, 11.0)
        },

        -- minigame options
        minigame = "none",
    },
    --------------------------
    ----- LOUD APPROACH ------
    --------------------------
    vangelicoEntrance = {
        coords = vector3(-631.760, -237.737, 38.073),
        lockpick = {
            requiredItem = "lockpick",
            removeItemOnUse = true,

            strength = 0.1, -- from 0.1 (easy) to 7 (hard)
            difficulty = 0.1, -- from 0.1 (easy) to 7 (hard)
            pins = 2, -- number of pins (max 9)

            -- minigame options
            minigame = "none",
        },
        thermite = {
            offset = vector3(1.3, -0.03, 0.0),
            requiredItem = "thermite_bomb",
            removeItemOnUse = true,

            -- minigame options
            minigame = "default",
        },
    },
    -------------------------
    ---- GENERAL OPTIONS ----
    -------------------------
    keypad = {
        coords = vector3(-629.135, -230.949, 38.057),
        searchCoords = {
            vector3(-625.925, -226.127, 38.057),
            vector3(-623.040, -235.807, 38.057),
            vector3(-618.774, -236.339, 38.057),
            vector3(-617.183, -231.740, 38.057),
            vector3(-621.453, -225.867, 38.057),
            vector3(-622.500, -229.772, 38.057),
            vector3(-630.369, -232.243, 38.057)
        },
    },
    doors = {
        { -- front doors
            door_1_model = 1425919976,
            door_2_model = 9467943,
            door_1_coords = vector4(-631.95538330078, -236.33326721191, 38.206531524658, 306.60577392578),
            door_2_coords = vector4(-630.42651367188, -238.43754577637, 38.206531524658, 305.39260864258),
            status = "locked", -- "locked", "unlocked"
        },
        { -- security room door
            door_1_model = 1335309163,
            door_1_coords = vector4(-629.13385009766, -230.15170288086, 38.20658493042, 36.000022888184),
            status = "locked", -- "locked", "unlocked"
        }
    },
    ---------------------------
    ----- LOOTABLE OPTIONS ----
    ---------------------------
    containers = {
        --  Weapons allowed to smash jewelry cabinets (whitelisted weapons)
        smashWeapons = {
            'WEAPON_ASSAULTRIFLE',
            'WEAPON_CARBINERIFLE',
            'WEAPON_ADVANCEDRIFLE',
            'WEAPON_BULLPUPRIFLE',
        },

        -- required items for certain loot actions:
        requiredItems = {
            paintings = {
                requiredItem = "painting_knife",
                removeItemOnUse = false,
            },
            displayCases = {
                requiredItem = "glass_cutter",
                removeItemOnUse = false,
            },
            statues = {
                requiredItem = "bag",
                removeItemOnUse = false,
            }
        },

        -- Cabinets:
        cabinets = {
            {
                coords = vector4(-626.83, -235.35, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-625.81, -234.7, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-626.95, -233.14, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-628.0, -233.86, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-625.7, -237.8, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-626.7, -238.58, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-624.55, -231.06, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-623.13, -232.94, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.29, -234.44, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-619.15, -233.66, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.19, -233.44, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-617.63, -230.58, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-618.33, -229.55, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-619.7, -230.33, 38.05, 125.0),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.95, -228.6, 38.05, 125.0),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-619.79, -227.6, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.42, -226.6, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-623.94, -227.18, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-624.91, -227.87, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-623.94, -228.05, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            }
        },

        -- Paintings:
        paintings = {
            {
                model = 'h4_prop_h4_painting_01h',
                coords = vector4(-627.21325683594, -228.30999755859, 38.106048583984, 92.391586303711),
                rewards = {
                    { itemName = "painting_01h", amount = { min = 1, max = 1 } },
                }
            },
            {
                model = 'h4_prop_h4_painting_01d',
                coords = vector4(-622.79998779297, -225.13999938965, 38.106048583984, 339.9309387207),
                rewards = {
                    { itemName = "painting_01d", amount = { min = 1, max = 1 } },
                }
            },
            {
                model = 'h4_prop_h4_painting_01e',
                coords = vector4(-617.0, -233.2200012207, 38.106048583984, 271.89691162109),
                rewards = {
                    { itemName = "painting_01e", amount = { min = 1, max = 1 } },
                }
            },
            {
                model = 'h4_prop_h4_painting_01b',
                coords = vector4(-621.36102294922, -236.33099365234, 38.106048583984, 161.18635559082),
                rewards = {
                    { itemName = "painting_01b", amount = { min = 1, max = 1 } },
                }
            }
        },

        -- Display Cases:
        displayCases = {
            {
                baseModel = "h4_prop_h4_glass_disp_01a",
                displayModel = "h4_prop_h4_diamond_disp_01a",
                rewardModel = "h4_prop_h4_diamond_01a",
                coords = vector4(-617.4622, -227.4347, 37.057, 127.06),
                rewardOffset = vector3(0.0, 0.0, 1.225),
                rewards = {
                    { itemName = "big_diamond", amount = 1 },
                }
            },
            {
                baseModel = "h4_prop_h4_glass_disp_01a",
                displayModel = "h4_prop_h4_diamond_disp_01a",
                rewardModel = "h4_prop_h4_art_pant_01a",
                coords = vector4(-631.7185, -234.6784, 36.9402, -53.705),
                rewardOffset = vector3(0.0, 0.0, 1.25),
                rewards = {
                    { itemName = "panther", amount = 1 },
                }
            },
            {
                baseModel = "h4_prop_h4_glass_disp_01a",
                displayModel = "h4_prop_h4_neck_disp_01a",
                rewardModel = "h4_prop_h4_necklace_01a",
                coords = vector4(-628.8231, -238.7317, 36.8647, -53.705),
                rewardOffset = vector3(0.0, 0.0, 1.2),
                rewards = {
                    { itemName = "diamond_necklace", amount = 1 },
                }  
            }
        },

        -- Statues:
        statues = {
            {
                tableModel = "v_ret_tablesml",
                statueModel = "vw_prop_casino_art_panther_01b",
                coords = vector4(-622.04541015625, -230.70222473145, 37.058856964111, 303.0),
                rewards = {
                    { itemName = "panther_statue", amount = { min = 1, max = 1 } },
                }
            }
        },

        -- Safes:
        safes = {
            {
                model = "h4_prop_h4_safe_01a",
                coords = vector4(-630.88995361328, -228.27618408203, 36.959362030029, 35.38557434082),
                difficulty = 1, -- 1-4 (1 being the easiest, 4 being the hardest)
                rewards = {
                    { itemName = "money", amount = { min = 500, max = 1500 } },
                }
            }
        }
    },
}


Config.missionData = {
    takePhoto = {
        vector3(-632.86, -238.62, 38.07),
        vector3(-623.01, -216.17, 53.54),
        vector3(-622.48, -233.67, 59.16),
        vector3(-623.24, -231.54, 38.06),
        vector3(-626.04, -238.05, 38.06),
        vector3(-629.15, -230.69, 38.06)
    },
    takeEquipment = {
        pedModel = "g_m_y_mexgang_01",
        coords = {
            vector4(591.996, 2782.770, 42.481, 8.644)
        },
        items = {
            {
                name = "hacker_phone",
                amount = 1
            },
            {
                name = "gas_bomb",
                amount = 1
            },
            {
                name = "gas_mask",
                amount = 4
            },
            {
                name = "glass_cutter",
                amount = 1
            },
            {
                name = "painting_knife",
                amount = 1
            },
            {
                name = "bag",
                amount = 1
            },
        }
    },
    takeClothes = {
        pedModel = "s_m_m_autoshop_02",
        coords = {
            vector4(632.676, -3015.434, 6.336, 359.265)
        },
        items = {
            {
                name = "outfit_bag",
                amount = 1
            }
        }
    },
}
```


# Mad Racing System - Racing Tablet

***

{% hint style="warning" %}
&#x20;**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.

{% endhint %}


# Installation

## Step 1: Download and Prepare Files

#### **Download the Script from FiveM Keymaster**

* Visit the [FiveM Keymaster](https://keymaster.fivem.net/) site.
* Log in with your account credentials.
* In the **Granted Assets** section, locate the Mad Racing System - Racing Tablet script.
* Download the script files from Keymaster.

## Step 2: Add Script to Server Configuration

#### **Edit `server.cfg`**

* Open the `server.cfg` file located in the root directory of your FiveM server.
* Add the following line to ensure the Mad Racing System - Racing Tablet script start automatically with your server:

```
rm_racing
```


# Configuration

## General Configuration

```lua
lib.locale('en')

cfg = {}

-- ============================================================================
--  rm_racing — server configuration
--
--  Sections (in order):
--    1.  Framework & dependencies          — qbox/qb/esx/standalone, ox_*, etc.
--    2.  Tablet & UI                       — /racing command, item gating, map tile
--    3.  Economy                           — money type used for prizes / fees
--    4.  User tiers & permissions          — racer/creator/master/god + flag map
--    5.  Player profile defaults           — pictures, picker palettes, name change cost
--    6.  ELO rating                        — Arpad K-factor + display tiers
--    7.  Race engine                       — countdown, grid layout, DNF timeout
--    8.  Race types                        — sprint/circuit/drift/etc.
--    9.  Drift scoring                     — points-per-second, combo, penalties
--    10. Tracks                            — creator rules + tags + placeable objects
--    11. Competitions                      — auto schedule, prize pool, vehicle classes
--    12. Pink slips (1v1 vehicle wager)    — allowed types, redeem locations
--    13. Bounties (time-trial chase)       — interval, prize, target formula
--    14. Item payouts                      — item rolls per finishing position
--    15. 3D GPS route renderer             — line/arrow style, lookahead, sizing
--    16. Discord webhooks                  — notifications channel + colors
--    17. Limits                            — page sizes / row caps
-- ============================================================================


-- ============================================================================
--  1.  FRAMEWORK & DEPENDENCIES
-- ============================================================================

---@type 'auto' | 'qbox' | 'qb' | 'esx' | 'standalone'
cfg.framework = 'auto'

-- Standalone fallback (no qbox/qb/esx). license identifiers become the
-- citizenid and money uses a virtual wallet in `rm_racing_wallet` instead
-- of a framework account.
cfg.standalone = {
    initialBalance = 10000,     ---@type number  -- credits new players start with
    currencyLabel = 'credits',  ---@type string  -- displayed in modals / tooltips (informational only)

    -- DEV ONLY: when true, the standalone bridge suffixes the citizenid with
    -- the player's server id so two FiveM clients on the same license get
    -- distinct profiles. Each session is treated as a separate player — new
    -- profile row, new wallet with initialBalance. TURN OFF in production
    -- or every reconnect orphans the previous profile.
    perSessionId = false,       ---@type boolean
}

---@type 'auto' | 'ox_target' | 'qb-target' | false
cfg.target       = 'auto'

---@type 'rm_minigames' | 'ox_lib' | 'qb-minigames' | 'ps-ui' | 'bl_ui' | false
cfg.minigame     = 'ox_lib'

---@type 'ox_lib' | 'qb' | 'esx' | 'okokNotify' | 'ps-ui'
cfg.notification = 'ox_lib'

---@type 'rm_racing' | 'ox_lib' | 'esx' | 'qb' | 'okokTextUI' | 'jg-textui'
-- 'rm_racing' = built-in NUI hint (RaceHud-styled, auto-chips for [KEY]).
-- Switch to a third-party system if you want it to match the rest of your
-- server's UI instead.
cfg.textUI       = 'rm_racing'

---@type 'ox_lib' | 'qb' | 'esx' | false
cfg.progressbar  = 'ox_lib'

---@type 'auto' | false | 'qb-vehiclekeys' | 'wasabi_carlock' | 'qs-vehiclekeys' | 'cd_garage' | 'Renewed-Vehiclekeys' | 'okokGarage' | 't1ger_keys' | 'MrNewbVehicleKeys'
cfg.vehiclelock  = 'auto'

---@type false | 'default_dispatch' | 'cd_dispatch' | 'qs-dispatch' | 'ps-dispatch' | 'rcore_dispatch' | 'sonoran_cad' | 'origen_police' | 'redutzu-mdt' | 'lb-tablet' | 'core_dispatch' | 'tk_dispatch' | 'l2s-dispatch'
cfg.dispatch     = false

-- Visual style for the in-world `[E] interact` prompt (used by some bridges).
cfg.interaction = {
    controlId = 38,
    text = 'E',
    colors = {
        background = { r = 241, g = 93,  b = 56,  a = 255 },
        text       = { r = 226, g = 232, b = 240, a = 255 },
    },
}

-- Police count gating (kept for parity with other resources; unused here unless > 0).
cfg.requiredPoliceCount         = 0
cfg.enableOldMethodForPoliceCount = false


-- ============================================================================
--  2.  TABLET & UI
-- ============================================================================

-- Chat command that opens the racing tablet. `/racing` by default.
cfg.command = 'racing'  ---@type string

-- Inventory item that opens the racing tablet when used. Set to `false` to
-- disable item-gating (the /racing command always works).
cfg.tabletItem = 'rm_racing_tablet'  ---@type string|false

-- NUI-specific UI tweaks. The TrackInfoPage's Leaflet preview renders a
-- GTA V tile background. Default points at the Rockstar Social Club tile
-- server (stable, dark styled). Set `tileUrl = false` for a blank
-- background that only shows the track polyline.
cfg.ui = {
    tileUrl = 'https://s.rsg.sc/sc/images/games/GTAV/map/game/{z}/{x}/{y}.jpg',  ---@type string|false
}


-- ============================================================================
--  3.  ECONOMY
-- ============================================================================

-- Money type used for entry fees and prizes.
cfg.moneyType = 'bank'  ---@type 'cash' | 'bank'


-- ============================================================================
--  4.  USER TIERS & PERMISSIONS
--
--  Tier hierarchy is by INDEX, NOT by ELO threshold. Index 1 = highest;
--  the last entry is the default for everyone without an override. ELO-based
--  auto-assignment uses `min` (highest tier whose min the player meets). A
--  tier override on profile.tier (set via /createracinguser) wins over ELO.
-- ============================================================================

---@type { name: string, min: number }[]
cfg.tiers = {
    { name = 'God',     min = 99999 },  -- never auto-assigned, must be granted
    { name = 'Master',  min = 1850  },
    { name = 'Creator', min = 1700  },
    { name = 'Racer',   min = 0     },
}

-- For each capability, the MINIMUM tier required. Anything at or above the
-- listed tier in cfg.tiers (lower index = higher tier) passes.
cfg.permissions = {
    join             = 'Racer',     ---@type string  -- join races / lobbies
    create           = 'Racer',     ---@type string  -- start a race (lobby or solo)
    createTrack      = 'Creator',   ---@type string  -- save a new track via the editor
    createCrew       = 'Racer',     ---@type string  -- found a crew
    control          = 'Master',    ---@type string  -- moderate own creations (delete tracks, etc.)
    controlAll       = 'God',       ---@type string  -- moderate everyone's creations
    handleBounties   = 'Master',    ---@type string  -- re-roll / cancel active bounties
    runAdminCommands = 'God',       ---@type string  -- /createracinguser, /removeallracetracks etc.
}


-- ============================================================================
--  5.  PLAYER PROFILE DEFAULTS
-- ============================================================================

cfg.profile = {
    pictureCount     = 8,  ---@type number  -- number of presets in /web/public/profiles/1..N.png
    checkpointColors = { 'orange', 'red', 'blue', 'navy', 'green', 'yellow', 'purple', 'white' },
    gpsColors        = { 'orange', 'red', 'blue', 'navy', 'green', 'yellow', 'purple', 'white' },
    -- 3D checkpoint visual styles. 'classic' = the original tall translucent
    -- pillar + sphere top. 'highway' = highway-style billboard sign on a
    -- vertical pole, distance label updating live. 'nav' = GPS-style nav
    -- panel (distance + direction arrow + street name from
    -- GetStreetNameAtCoord). Persisted on profile.checkpoint_style
    -- (default 'classic').
    checkpointStyles = { 'classic', 'highway', 'nav' },
    -- Default for the "show my 3D PlayerCard above my head to other
    -- players" privacy preference. Each player can flip it individually
    -- in their profile; new players inherit this default. Saved on
    -- profile.card_visible (1 = visible, 0 = hidden).
    cardVisibleByDefault = true,
    nameChangeCost   = 1000,
}

-- Crew emblem allow-list. Must mirror `web/src/constants/crewEmblems.ts`
-- exactly — server validates the picked emblem against this list before
-- inserting the crew, so a new icon there needs a new entry here too.
cfg.crew = {
    emblems = {
        'flag', 'trophy', 'crown', 'bolt', 'flame',
        'skull', 'shield', 'swords', 'target', 'anchor',
        'compass', 'gem', 'ghost', 'rocket', 'star',
        'wrench', 'cog', 'eye', 'heart', 'crosshair',
    },
}


-- ============================================================================
--  PlayerCard — in-world 3D nameplates rendered above racers / nearby players
--
--  Powered by rm_stream (DUI projected onto a billboard marker). The same
--  React PlayerCard component used in the tablet renders standalone via
--  `build/index.html?card=1&...`; one stream per visible player, attached
--  to their vehicle (preferred) or ped fallback. Distance-faded.
-- ============================================================================
cfg.playerCard = {
    enabled         = true,   ---@type boolean
    duringRace      = true,   ---@type boolean  -- show above each racer's vehicle in-race
    outOfRace       = true,   ---@type boolean  -- show above nearby players' peds in free-roam

    -- Render & culling. renderDistance is the same value rm_stream uses to
    -- skip drawing — keep it tight to limit DUI overdraw on busy servers.
    renderDistance  = 50.0,   ---@type number   -- metres
    maxConcurrent   = 10,     ---@type integer  -- hard cap; closest-N kept, rest hidden
    scanIntervalMs  = 1000,   ---@type integer  -- free-roam scan cadence

    -- Server-side profile cache TTL (seconds). A nearby-scan returns up to
    -- maxConcurrent player summaries; cache means repeated scans don't slam
    -- the DB if the same players hang around.
    profileCacheSec = 30,     ---@type number

    -- Vertical offset above the attach entity. Vehicle uses model height +
    -- this; ped uses ~head height + this. Tune per server vehicle pool.
    vehicleOffsetZ  = 0.8,    ---@type number
    pedOffsetZ      = 0.6,    ---@type number   -- on top of head bone (already ~head height)

    -- Card billboard sizing (passed to rm_stream). Width/height drive the
    -- DUI texture resolution; scale is the world-space size of the marker
    -- (1.0 ≈ 1 metre tall before distance falloff).
    width           = 600,    ---@type integer  -- DUI texture px
    height          = 200,    ---@type integer
    scale           = 0.8,    ---@type number   -- world units
}


-- ============================================================================
--  6.  ELO RATING
-- ============================================================================

cfg.elo = {
    initial         = 1500,  ---@type number  -- starting rating for new players
    kFactor         = 32,    ---@type number  -- rating volatility (chess uses 10-40)
    minParticipants = 1,     ---@type number  -- minimum racers required for ELO to apply

    -- Display tiers shown on profiles / leaderboards (separate from cfg.tiers
    -- which gates permissions). Purely cosmetic ranks based on ELO bands.
    tiers = {                ---@type { name: string, min: number }[]
        { name = 'Racer', min = 0    },
        { name = 'Host',  min = 1600 },
        { name = 'Alien', min = 1850 },
    },
}


-- ============================================================================
--  7.  RACE ENGINE
-- ============================================================================

cfg.race = {
    countdownSec             = 5,     ---@type number  -- 3-2-1-go countdown length
    dnfTimeoutSec            = 100,   ---@type number  -- racer kicked if still running this long after first finish
    checkpointArriveDistance = 3.5,   ---@type number  -- on-foot trigger distance; vehicles use cp.radius

    -- Players must be within this distance of cp1 when /start fires, otherwise
    -- the race rejects start with a "too far from grid" modal.
    maxDistanceFromGrid      = 20.0,

    -- Starting-grid layout. Racers are placed in a `columns` × ceil(N/columns)
    -- grid behind cp1, all facing toward cp2.
    grid = {
        columns       = 2,     ---@type number  -- racers per row
        columnSpacing = 4.0,   ---@type number  -- m between side-by-side slots
        rowSpacing    = 5.0,   ---@type number  -- m between rows
        backOffset    = 4.0,   ---@type number  -- m added to cp1.radius for the front row
    },
}


-- ============================================================================
--  8.  RACE TYPES
--
--  circuit = true   →  loops, supports laps
--  drift   = true   →  drift scoring is the win condition
--  timeTrial         →  solo runs only, no lobby
--  elimination       →  last-place dropped each lap
-- ============================================================================

---@type { id: string, label: string, circuit: boolean, drift: boolean, timeTrial?: boolean, elimination?: boolean }[]
cfg.raceTypes = {
    { id = 'sprint',          label = 'Sprint',          circuit = false, drift = false },
    { id = 'circuit',         label = 'Circuit',         circuit = true,  drift = false },
    { id = 'drift',           label = 'Drift',           circuit = false, drift = true  },
    { id = 'drift_challenge', label = 'Drift challenge', circuit = true,  drift = true  },
    { id = 'time_trial',      label = 'Time trial',      circuit = false, drift = false, timeTrial   = true },
    { id = 'elimination',     label = 'Elimination',     circuit = true,  drift = false, elimination = true },
}


-- ============================================================================
--  9.  DRIFT SCORING
--  Used by drift & drift_challenge race types.
-- ============================================================================

cfg.drift = {
    pointsPerSecond     = 8,      -- baseline points for sustained drift
    angleThreshold      = 12.0,   -- degrees; below this, no drift
    minSpeedMps         = 10.0,   -- metres/sec; below this, no drift
    wallHitPenalty      = 500,    -- points lost when hitting a wall/entity mid-drift
    comboMultiplierStep = 0.25,   -- +25% points per sustained second up to max
    comboMultiplierMax  = 3.0,
    tickMs              = 100,    -- scoring tick interval
}


-- ============================================================================
--  10. TRACKS
-- ============================================================================

-- Creator rules — bounds enforced server-side at saveTrack.
cfg.track = {
    minCheckpoints          = 2,
    maxCheckpoints          = 300,
    maxObjects              = 600,
    checkpointDefaultRadius = 15.0,
    checkpointMinRadius     = 10.0,
    checkpointMaxRadius     = 50.0,
    creatorMoneyCost        = 5000,
    nameMaxLen              = 40,
    descriptionMaxLen       = 140,
    objectPlacementDistance = 3.5,    ---@type number  -- m in front of player for object preview spawn (fallback when raycast misses)
    objectYawStep           = 15.0,
}

-- Track tags shown on track cards.
cfg.trackTags = {
    newTrackDays            = 7,    ---@type number  -- track <= N days old → 'New'
    popularMinTimesLastWeek = 25,   ---@type number  -- ≥ N races last week → 'Popular'
    legendaryMinTimesDriven = 200,  ---@type number  -- ≥ N total races → 'Legendary'
}

-- "Author" of seed-tracks.sql tracks (creator_cid = ''). Substituted
-- server-side in decorateTrack so the listing UI doesn't fall back to
-- "Unknown" + default avatar. picture accepts:
--   - a numeric preset id '1'..'8'         (web/public/profiles/N.png)
--   - a relative file ending in .png/.webp (web/public/<file>, e.g.
--     'eyescoloured.png' which ships alongside the NUI bundle)
--   - a full https:// URL                  (any image hostable to FiveM CEF)
cfg.seedTracks = {
    creatorName    = 'rm_racing',
    creatorPicture = 'eyescoloured.png',
}

cfg.trackObjects = {
    -- ============ Flags ============
    { id = 'flag_red',         label = 'Red flag',           model = 'prop_flagpole_2b'  },
    { id = 'flag_blue',        label = 'Blue flag',          model = 'prop_flagpole_2c'  },
    { id = 'flag_checkered',   label = 'Checkered flag',     model = 'prop_flagpole_2d'  },
    { id = 'flag_beach_red',   label = 'Beach flag (black)', model = 'prop_beachflag_01' },
    { id = 'flag_beach_blue',  label = 'Beach flag (green)', model = 'prop_beachflag_02' },

    -- ============ Tyres (single / pile) ============
    { id = 'tyre',                 label = 'Tyre',                  model = 'prop_wheel_tyre'         },
    { id = 'tyre_stack',           label = 'Tyre stack',            model = 'prop_offroad_tyres02'    },
    { id = 'tyre_offroad',         label = 'Offroad tyre',          model = 'prop_offroad_tyres01_tu' },
    { id = 'tyre_offroad_open',    label = 'Offroad tyres (open)',  model = 'prop_offroad_tyres01'    },
    { id = 'tyre_snow',            label = 'Snow tyre',             model = 'prop_snow_tyre_01'       },
    { id = 'tyre_industrial_1',    label = 'Industrial tyre #1',    model = 'v_ind_cm_tyre01'         },
    { id = 'tyre_industrial_3',    label = 'Industrial tyre #3',    model = 'v_ind_cm_tyre03'         },
    { id = 'tyre_industrial_6',    label = 'Industrial tyre #6',    model = 'v_ind_cm_tyre06'         },
    { id = 'tyre_industrial_7',    label = 'Industrial tyre #7',    model = 'v_ind_cm_tyre07'         },
    { id = 'tyre_procedural',      label = 'Procedural tyre',       model = 'ng_proc_tyre_01'         },
    { id = 'tyre_junk_pile',       label = 'Junk tyre cluster',     model = 'v_46_cm_tyres'           },
    { id = 'tyre_carware_pile',    label = 'Carware tyre rack',     model = 'imp_carwarecw_tyres'     },
    { id = 'tyre_sea_pile',        label = 'Sea tyre pile',         model = 'ch1_12_sea_tyrepileb'    },
    { id = 'tyre_shelves',         label = 'Tyre shelves',          model = 'v_71_tyreshelves'        },
    { id = 'tyre_hoop_atomic',     label = 'Atomic hoop tyre',      model = 'stt_prop_hoop_tyre_01a'  },
    { id = 'tyre_arrow',           label = 'Arrow tyre',            model = 'xs_prop_arrow_tyre_01a'  },
    { id = 'tyre_gate_arch',       label = 'Tyre gate arch',        model = 'xs_prop_gate_tyre_01a_wl'},

    -- ============ Tyre walls (basic) ============
    { id = 'tyre_wall',            label = 'Tyre wall',             model = 'prop_tyre_wall_03b'      },
    { id = 'tyre_wall_short',      label = 'Tyre wall (short)',     model = 'prop_tyre_wall_01'       },
    { id = 'tyre_wall_rolled',     label = 'Tyre wall (rolled)',    model = 'prop_tyre_wall_03'       },
    { id = 'tyre_wall_mid',        label = 'Tyre wall (mid)',       model = 'prop_tyre_wall_03c'      },
    { id = 'tyre_wall_yellow',     label = 'Tyre wall (yellow)',    model = 'prop_tyre_wall_04'       },
    { id = 'tyre_wall_heavy',      label = 'Tyre wall (heavy)',     model = 'prop_tyre_wall_05'       },
    { id = 'tyre_wall_curved',     label = 'Tyre wall (curved)',    model = 'xs_prop_wall_tyre_l_01a' },
    { id = 'tyre_wall_u_curve',    label = 'Tyre wall (U-curve)',   model = 'tr_prop_tr_tyre_wall_u_l'},

    -- ============ Stunt tyre walls — straight (sponsor liveries) ============
    { id = 'stt_wall_basic',       label = 'Stunt wall #1',         model = 'stt_prop_tyre_wall_01'   },
    { id = 'stt_wall_shitzu',      label = 'Shitzu wall',           model = 'stt_prop_tyre_wall_02'   },
    { id = 'stt_wall_sprunk',      label = 'Sprunk wall',           model = 'stt_prop_tyre_wall_03'   },
    { id = 'stt_wall_xero',        label = 'Xero wall',             model = 'stt_prop_tyre_wall_04'   },
    { id = 'stt_wall_pisswasser',  label = 'Pißwasser wall',        model = 'stt_prop_tyre_wall_05'   },
    { id = 'stt_wall_burgershot',  label = 'Burger Shot wall',      model = 'stt_prop_tyre_wall_06'   },
    { id = 'stt_wall_cheetah',     label = 'Cheetah wall',          model = 'stt_prop_tyre_wall_07'   },
    { id = 'stt_wall_globeoil',    label = 'Globe Oil wall',        model = 'stt_prop_tyre_wall_08'   },
    { id = 'stt_wall_vapid',       label = 'Vapid wall',            model = 'stt_prop_tyre_wall_09'   },
    { id = 'stt_wall_chevron',     label = 'Chevron wall (black)',  model = 'stt_prop_tyre_wall_010'  },
    { id = 'stt_wall_patriot',     label = 'Patriot wall',          model = 'stt_prop_tyre_wall_011'  },
    { id = 'stt_wall_pisswasser2', label = 'Pißwasser wall #2',     model = 'stt_prop_tyre_wall_012'  },
    { id = 'stt_wall_imponte_p',   label = 'Imponte wall (purple)', model = 'stt_prop_tyre_wall_013'  },
    { id = 'stt_wall_imponte_b',   label = 'Imponte wall (black)',  model = 'stt_prop_tyre_wall_014'  },
    { id = 'stt_wall_fukaru_f',    label = 'Fukaru F wall',         model = 'stt_prop_tyre_wall_015'  },
    { id = 'stt_wall_atomic_g',    label = 'Atomic wall (green)',   model = 'stt_prop_tyre_wall_016'  },
    { id = 'stt_wall_pisswasser3', label = 'Pißwasser wall #3',     model = 'stt_prop_tyre_wall_017'  },
    { id = 'stt_wall_imponte_w',   label = 'Imponte wall (white)',  model = 'stt_prop_tyre_wall_018'  },

    -- ============ Stunt tyre walls — curved (L = left bend, R = right bend) ============
    { id = 'stt_curve_l3',         label = 'Fukaru curve (L)',      model = 'stt_prop_tyre_wall_0l3'  },
    { id = 'stt_curve_r3',         label = 'Fukaru curve (R)',      model = 'stt_prop_tyre_wall_0r3'  },
    { id = 'stt_curve_r1',         label = 'Tyre wall curve (R)',   model = 'stt_prop_tyre_wall_0r1'  },
    { id = 'stt_curve_l2',         label = 'Sprunk curve (L)',      model = 'stt_prop_tyre_wall_0l2'  },
    { id = 'stt_curve_l04',        label = 'Xero curve (L)',        model = 'stt_prop_tyre_wall_0l04' },
    { id = 'stt_curve_r04',        label = 'Xero curve (R)',        model = 'stt_prop_tyre_wall_0r04' },
    { id = 'stt_curve_l05',        label = 'Sprunk curve (L)',      model = 'stt_prop_tyre_wall_0l05' },
    { id = 'stt_curve_r05',        label = 'Atomic curve (R)',      model = 'stt_prop_tyre_wall_0r05' },
    { id = 'stt_curve_l06',        label = 'Auto Exotic curve (L)', model = 'stt_prop_tyre_wall_0l06' },
    { id = 'stt_curve_r06',        label = 'Pißwasser curve (R)',   model = 'stt_prop_tyre_wall_0r06' },
    { id = 'stt_curve_l07',        label = 'Fukaru curve (L)',      model = 'stt_prop_tyre_wall_0l07' },
    { id = 'stt_curve_r07',        label = 'Vapid curve (R)',       model = 'stt_prop_tyre_wall_0r07' },
    { id = 'stt_curve_l08',        label = 'Cheetah curve (L)',     model = 'stt_prop_tyre_wall_0l08' },
    { id = 'stt_curve_r08',        label = 'Cheetah curve (R)',     model = 'stt_prop_tyre_wall_0r08' },
    { id = 'stt_curve_r09',        label = 'Globe Oil curve (R)',   model = 'stt_prop_tyre_wall_0r09' },
    { id = 'stt_curve_l010',       label = 'Globe Oil curve (L)',   model = 'stt_prop_tyre_wall_0l010'},
    { id = 'stt_curve_r010',       label = 'Vapid curve (R)',       model = 'stt_prop_tyre_wall_0r010'},
    { id = 'stt_curve_l012',       label = 'Vapid curve (L)',       model = 'stt_prop_tyre_wall_0l012'},
    { id = 'stt_curve_r012',       label = 'Xero curve (R)',        model = 'stt_prop_tyre_wall_0r012'},
    { id = 'stt_curve_l013',       label = 'Junk Energy curve (L)', model = 'stt_prop_tyre_wall_0l013'},
    { id = 'stt_curve_r013',       label = 'Nagasaki curve (R)',    model = 'stt_prop_tyre_wall_0r013'},
    { id = 'stt_curve_l014',       label = 'Cheetah curve (L)',     model = 'stt_prop_tyre_wall_0l014'},
    { id = 'stt_curve_r014',       label = 'Xero curve (R)',        model = 'stt_prop_tyre_wall_0r014'},
    { id = 'stt_curve_l015',       label = 'Nagasaki curve (L)',    model = 'stt_prop_tyre_wall_0l015'},
    { id = 'stt_curve_r015',       label = 'Burger Shot curve (R)', model = 'stt_prop_tyre_wall_0r015'},
    { id = 'stt_curve_l016',       label = 'Atomic curve (L)',      model = 'stt_prop_tyre_wall_0l016'},
    { id = 'stt_curve_l16',        label = 'Fukaru green curve',    model = 'stt_prop_tyre_wall_0l16' },
    { id = 'stt_curve_l017',       label = 'Pißwasser curve (L)',   model = 'stt_prop_tyre_wall_0l017'},
    { id = 'stt_curve_r017',       label = 'Fukaru curve (R)',      model = 'stt_prop_tyre_wall_0r017'},
    { id = 'stt_curve_l018',       label = 'Imponte curve (L)',     model = 'stt_prop_tyre_wall_0l018'},
    { id = 'stt_curve_r018',       label = 'Nagasaki curve (R)',    model = 'stt_prop_tyre_wall_0r018'},
    { id = 'stt_curve_l019',       label = 'Fukaru curve (L)',      model = 'stt_prop_tyre_wall_0l019'},
    { id = 'stt_curve_r019',       label = 'Fukaru curve (R)',      model = 'stt_prop_tyre_wall_0r019'},
    { id = 'stt_curve_l020',       label = 'Fukaru F curve (L)',    model = 'stt_prop_tyre_wall_0l020'},

    -- ============ Chevron / direction walls (Tuner DLC arrows + lit panels) ============
    { id = 'chevron_lit',          label = 'Chevron wall (lit)',    model = 'sum_prop_ac_tyre_wall_lit_01'  },
    { id = 'chevron_lit_l',        label = 'Chevron wall lit (L)',  model = 'sum_prop_ac_tyre_wall_lit_0l1' },
    { id = 'chevron_lit_r',        label = 'Chevron wall lit (R)',  model = 'sum_prop_ac_tyre_wall_lit_0r1' },
    { id = 'chevron_arrow_l',      label = 'Chevron arrow (L)',     model = 'sum_prop_ac_tyre_wall_u_l'     },
    { id = 'chevron_arrow_r',      label = 'Chevron arrow (R)',     model = 'sum_prop_ac_tyre_wall_u_r'     },
    { id = 'pit_arrow_l',          label = 'PITS arrow (L)',        model = 'sum_prop_ac_tyre_wall_pit_l'   },
    { id = 'pit_arrow_r',          label = 'PITS arrow (R)',        model = 'sum_prop_ac_tyre_wall_pit_r'   },

    -- ============ Special ============
    { id = 'wall_nitrous',         label = 'Nitrous boost wall',    model = 'm24_2_prop_m42_tyre_wall_nos' },

    -- ============ Cones / barriers / barrels / bales ============
    { id = 'cone',                 label = 'Traffic cone',          model = 'prop_mp_cone_01'    },
    { id = 'barrier',              label = 'Barrier',               model = 'prop_mp_barrier_02' },
    { id = 'barrel',               label = 'Barrel',                model = 'prop_barrel_02a'    },
    { id = 'hay_bale',             label = 'Hay bale',              model = 'prop_offroad_bale01'},
}


-- ============================================================================
--  11. COMPETITIONS
--  Auto-generated competition lobbies + manual competition rules.
-- ============================================================================

cfg.competition = {
    -- Local server time HH:MM. Set to {} to disable the auto-scheduler.
    autoSchedule = {
        '12:00',
        '16:00',
        '19:00',
        '22:00',
    },

    lobbyDurationSec   = 900,   ---@type number  -- 15 min lobby before scheduler auto-starts (or auto-cancels if empty)
    minRatingToHost    = 1700,  ---@type number  -- min ELO to start a manual competition
    prizePoolPerPlayer = 2500,  ---@type number  -- added to pool for each joined racer

    -- Percent per finishing position; must sum to 100.
    prizeDistribution = {
        50, 25, 15, 10,
    },

    -- Vehicle classes pickable in lobby setup. id = -1 means "any class".
    vehicleClasses = {                 ---@type { id: number, label: string }[]
        { id = -1, label = 'Any'             },
        { id =  0, label = 'Compacts'        },
        { id =  1, label = 'Sedans'          },
        { id =  2, label = 'SUVs'            },
        { id =  3, label = 'Coupes'          },
        { id =  4, label = 'Muscle'          },
        { id =  5, label = 'Sports Classics' },
        { id =  6, label = 'Sports'          },
        { id =  7, label = 'Super'           },
    },

    defaultMaxPlayers = 8,
    phasingDefault    = true,

    -- Slipstream (drafting) — flips the engine's built-in slipstream
    -- physics for the duration of the race via SetEnableVehicleSlipstreaming.
    -- Global toggle (not per-vehicle), called once on start and once on end.
    slipstreamDefault = false,

    -- Private routing bucket — when on, race start moves every participant
    -- (and their vehicle) into an isolated server bucket so non-racers in
    -- the public world don't collide / desync with the race. Race end
    -- restores everyone to bucket 0. Voice does NOT respect buckets —
    -- proximity voice still bleeds through.
    privateBucketDefault = false,
}


-- ============================================================================
--  12. PINK SLIPS — wager your vehicle ownership in a 1v1 race.
--
--  The losing driver's vehicle gets transferred to the winner via the
--  framework bridge (qb/qbx → player_vehicles, esx → owned_vehicles).
--  Standalone has no inventory of vehicles, so the toggle is hidden in
--  the lobby UI when running standalone.
--  Claims are pending until the winner visits a redeemLocations entry and
--  interacts there — physical delivery, not a silent DB swap.
-- ============================================================================

cfg.pinkSlips = {
    enabled      = true,

    -- Race types this flag CAN be applied to. Drift / elimination / TT are
    -- excluded — pink slips need a clean position-based winner.
    allowedTypes = { 'sprint', 'circuit' },  ---@type string[]

    -- Forfeit window: if either player DNFs, the race still resolves and
    -- the surviving racer claims. Set false to require both to finish.
    awardOnDnf   = true,

    -- Where the winner picks up the vehicle. Each entry produces a blip
    -- and a small interaction zone. Add as many as you like.
    redeemLocations = {
        { x = 100.0, y = -1080.0, z = 29.2, label = 'Pink Slip Garage (Mission Row)' },
    },

    redeemBlip = {
        sprite = 357,                ---@type number
        color  = 1,                  ---@type number  -- 1 = red
        scale  = 0.85,
        label  = 'Pink Slip Redeem',
    },
}


-- ============================================================================
--  13. BOUNTIES — time-trial chase prizes.
--
--  A scheduled job rolls one active bounty at a time. When a player runs
--  the bountied track in a Time Trial mode and finishes UNDER the
--  target_ms, they claim the prize. Bounties expire after `expireMin` if
--  unclaimed.
-- ============================================================================

cfg.bounties = {
    enabled            = true,
    intervalMin        = 30,                        ---@type number  -- new roll every N minutes (also re-rolls if previous claimed/expired)
    expireMin          = 240,                       ---@type number  -- auto-expire after N minutes (0 = never)
    prize              = 75000,                     ---@type number  -- credits paid to the claimer
    eligibleTrackTypes = { 'time_trial', 'sprint', 'circuit' },  ---@type string[]
    requireRank        = false,                     ---@type string|false  -- e.g. 'Master' to gate claims; false = open
    minResultsOnTrack  = 0,                         ---@type number  -- 0 = never block on race count (random track gets picked even on fresh DBs)

    -- Target time formula. Bounty is computed as `best_ms * targetMult + random(extraMs)`.
    -- 1.0 + small variance keeps it tight for top players; pad it (e.g. 1.10 →
    -- 10% slower than best) for casual servers.
    targetMult         = 1.05,
    extraMs            = { min = 0, max = 3000 },

    -- Fallback target for tracks with NO best time yet (fresh server / no
    -- results on the picked track). Target = checkpoints_count × this value.
    -- ~5 s per CP is a generous ballpark; tweak if your tracks are unusually
    -- short or long.
    fallbackMsPerCp    = 5000,
}


-- ============================================================================
--  14. ITEM PAYOUTS
--
--  Item rewards distributed alongside money prizes when a race finishes.
--  Each `lists.<name>` is a weighted pool: an entry's `weight` is the chance
--  relative to the sum of weights in its list; `count` is rolled uniformly
--  in [min, max]. payoutStyle picks WHO gets a roll:
--    'all'       → every finisher gets one roll
--    'topThree'  → positions 1..3 each roll
--    'onlyOne'   → only the winner rolls
--    'custom'    → uses `perPosition[pos]` to pick which list(s) to roll
-- ============================================================================

cfg.itemPayouts = {
    enabled          = true,         ---@type boolean  -- enable when item names below exist in your inventory
    defaultList      = 'standard',   ---@type string   -- list rolled for non-custom styles
    payoutStyle      = 'topThree',   ---@type 'all'|'topThree'|'onlyOne'|'custom'
    minRaceLengthSec = 10,           ---@type number   -- skip payouts for short fluke races
    onlyCompetitions = false,        ---@type boolean  -- if true, only ranked competitions drop items

    ---@type table<string, { item: string, weight: number, count: { min: number, max: number } }[]>
    lists = {
        standard = {
            { item = 'water',    weight = 8, count = { min = 1, max = 3 } },
            { item = 'sandwich', weight = 6, count = { min = 1, max = 2 } },
            { item = 'lockpick', weight = 2, count = { min = 1, max = 1 } },
        },
        rare = {
            { item = 'lockpick',         weight = 4, count = { min = 1, max = 2 } },
            { item = 'advancedlockpick', weight = 1, count = { min = 1, max = 1 } },
        },
    },

    -- Only used when payoutStyle = 'custom'.
    ---@type table<integer, string[]>
    perPosition = {
        [1] = { 'rare', 'standard' },
        [2] = { 'standard' },
        [3] = { 'standard' },
    },
}


-- ============================================================================
--  15. 3D GPS ROUTE RENDERER
--
--  During a race the upcoming checkpoint polyline is drawn in 3D world
--  space so the player can follow the route without taking their eyes off
--  the road to glance at the minimap. Color is per-player (profile.gps_color)
--  and style is per-player (profile.gps_route_type). Set `enabled = false`
--  to disable the entire renderer.
-- ============================================================================

cfg.gps3d = {
    enabled       = true,

    -- Number of upcoming checkpoints to render ahead of the player. 1 = next
    -- one only; higher = full path preview (more drawcalls per frame).
    lookahead     = 4,

    -- Default style; each player can override via profile.gps_route_type:
    --   'off'           → nothing drawn
    --   'line' / 'arrow'→ legacy DrawLine-based renderer (no YTD needed)
    --   any preset id   → textured ribbon using the asset in stream/ (chevrons.ytd)
    defaultStyle  = 'classic',

    -- Lift the line a hair above the road so it doesn't z-fight on inclines
    -- but stays close to the surface — the car effectively drives over it
    -- instead of under a floating ribbon.
    heightOffset  = 0.1,

    -- Chevron arrow spacing (m) and dimensions for the LEGACY DrawLine path.
    arrowSpacing  = 6.0,
    arrowLength   = 1.6,
    arrowWidth    = 0.9,

    -- Textured ribbon (preset) parameters. The YTD ships in stream/ — its
    -- internal texture-dictionary name is what RequestStreamedTextureDict
    -- expects (usually 'chevrons' even if the .ytd file was renamed).
    textureDict     = 'routes',
    ribbonWidth     = 1.35,   -- half-width of the ribbon (m), so total = 2.7m
    ribbonLift      = 0.05,   -- extra Z offset on top of heightOffset
    ribbonRepeatM   = 4.0,    -- texture repeats every N metres along the path

    -- Preset list. `id` is what gets stored in profile.gps_route_type;
    -- `texture` is the entry inside textureDict to sample. Order here drives
    -- the order shown in the profile picker.
    --
    -- Per-preset `repeatM` overrides the global ribbonRepeatM. Per-preset
    -- `tintable = false` skips the player's gps_color tint and draws the
    -- texture with white modulation, preserving the colors baked into the
    -- texture itself. Per-preset `alpha` overrides the default 230 — the
    -- chevron_line textures have an opaque white background in their alpha
    -- channel, so we drop alpha to fade the slab and keep the chevrons
    -- the dominant element.
    presets = {
        { id = 'classic',         label = 'Classic',        texture = 'chevrons',        repeatM = 4.0, tintable = true  },
        { id = 'chevron_line_06', label = 'Line 06',        texture = 'chevron_line_06', repeatM = 0.8, tintable = false, alpha = 130 },
        { id = 'chevron_line_07', label = 'Line 07',        texture = 'chevron_line_07', repeatM = 0.8, tintable = false, alpha = 130 },
        { id = 'chevron_line_08', label = 'Line 08',        texture = 'chevron_line_08', repeatM = 0.8, tintable = false, alpha = 130 },
        { id = 'chevron_fire_01', label = 'Fire',           texture = 'chevron_fire_01', repeatM = 4.0, tintable = true  },
        { id = 'chevron_ice_01',  label = 'Ice',            texture = 'chevron_ice_01',  repeatM = 4.0, tintable = true  },
        { id = 'chevron_neon_01', label = 'Neon',           texture = 'chevron_neon_01', repeatM = 4.0, tintable = true  },
    },
}


-- ============================================================================
--  16. DISCORD WEBHOOKS — config moved to server/webhook.lua
--
--  The webhook URL is sensitive credential data and must NOT live in cfg.lua,
--  which is shipped/escrowed as part of the resource. server/webhook.lua is
--  listed in escrow_ignore so customers can edit the URL after install
--  without seeing the rest of the source. Set `WEBHOOK_URL` at the top of
--  that file.
-- ============================================================================


-- ============================================================================
--  17. LIMITS — page sizes / row caps
-- ============================================================================

cfg.limits = {
    tracksPerRequest  = 500,
    leaderboardSize   = 100,
    recentEventsCount = 10,
    recentRacesCount  = 100,
    trackBestResults  = 10,
}

```

### Heist Configuration

```
Config.heist = {
    --------------------------
    ----- QUIET APPROACH -----
    --------------------------
    terminalHacking = {
        coords = vector3(-623.15, -216.22, 53.54),
        requiredItem = "hacker_phone",
        removeItemOnUse = false,

        -- minigame options
        minigame = "none",
        duration = 60,      -- duration of hack minigame in seconds
        solutionLength = 5, -- length of the solution string
        policeAlertDuration = 30, -- seconds (after the hacking incident should the police be alerted)
    },
    plantingGasBomb = {
        ventModel = "prop_aircon_m_04", -- model of the vent where the bomb should be planted
        coords = vector3(-631.13, -228.23, 55.65),
        offset = vector3(-0.83, -0.5, 0.8), -- offset from the coords where the bomb should be planted (relative to the vent)
        requiredItem = "gas_bomb",
        removeItemOnUse = true,

        mask = {
            enable = true,
            requiredItem = "gas_mask",
            removeItemOnUse = true,

            clotheId = 175,
            duration = 300, -- duration of the gas mask effect in seconds
            damage = 5, -- damage dealt to the player every interval while the gas mask is active
            damageInterval = 5, -- interval in seconds at which the damage is applied to the player while the gas mask is active
            zone = vector4(-622.364, -231.020, 38.047, 11.0)
        },

        -- minigame options
        minigame = "none",
    },
    --------------------------
    ----- LOUD APPROACH ------
    --------------------------
    vangelicoEntrance = {
        coords = vector3(-631.760, -237.737, 38.073),
        lockpick = {
            requiredItem = "lockpick",
            removeItemOnUse = true,

            strength = 0.1, -- from 0.1 (easy) to 7 (hard)
            difficulty = 0.1, -- from 0.1 (easy) to 7 (hard)
            pins = 2, -- number of pins (max 9)

            -- minigame options
            minigame = "none",
        },
        thermite = {
            offset = vector3(1.3, -0.03, 0.0),
            requiredItem = "thermite_bomb",
            removeItemOnUse = true,

            -- minigame options
            minigame = "default",
        },
    },
    -------------------------
    ---- GENERAL OPTIONS ----
    -------------------------
    keypad = {
        coords = vector3(-629.135, -230.949, 38.057),
        searchCoords = {
            vector3(-625.925, -226.127, 38.057),
            vector3(-623.040, -235.807, 38.057),
            vector3(-618.774, -236.339, 38.057),
            vector3(-617.183, -231.740, 38.057),
            vector3(-621.453, -225.867, 38.057),
            vector3(-622.500, -229.772, 38.057),
            vector3(-630.369, -232.243, 38.057)
        },
    },
    doors = {
        { -- front doors
            door_1_model = 1425919976,
            door_2_model = 9467943,
            door_1_coords = vector4(-631.95538330078, -236.33326721191, 38.206531524658, 306.60577392578),
            door_2_coords = vector4(-630.42651367188, -238.43754577637, 38.206531524658, 305.39260864258),
            status = "locked", -- "locked", "unlocked"
        },
        { -- security room door
            door_1_model = 1335309163,
            door_1_coords = vector4(-629.13385009766, -230.15170288086, 38.20658493042, 36.000022888184),
            status = "locked", -- "locked", "unlocked"
        }
    },
    ---------------------------
    ----- LOOTABLE OPTIONS ----
    ---------------------------
    containers = {
        --  Weapons allowed to smash jewelry cabinets (whitelisted weapons)
        smashWeapons = {
            'WEAPON_ASSAULTRIFLE',
            'WEAPON_CARBINERIFLE',
            'WEAPON_ADVANCEDRIFLE',
            'WEAPON_BULLPUPRIFLE',
        },

        -- required items for certain loot actions:
        requiredItems = {
            paintings = {
                requiredItem = "painting_knife",
                removeItemOnUse = false,
            },
            displayCases = {
                requiredItem = "glass_cutter",
                removeItemOnUse = false,
            },
            statues = {
                requiredItem = "bag",
                removeItemOnUse = false,
            }
        },

        -- Cabinets:
        cabinets = {
            {
                coords = vector4(-626.83, -235.35, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-625.81, -234.7, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-626.95, -233.14, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-628.0, -233.86, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-625.7, -237.8, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-626.7, -238.58, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-624.55, -231.06, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-623.13, -232.94, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.29, -234.44, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-619.15, -233.66, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.19, -233.44, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-617.63, -230.58, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-618.33, -229.55, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-619.7, -230.33, 38.05, 125.0),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.95, -228.6, 38.05, 125.0),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-619.79, -227.6, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-620.42, -226.6, 38.05, 305.0),
                rayFire = 'DES_Jewel_Cab',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-623.94, -227.18, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab4',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-624.91, -227.87, 38.05, 36.17),
                rayFire = 'DES_Jewel_Cab3',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            },
            {
                coords = vector4(-623.94, -228.05, 38.05, 216.17),
                rayFire = 'DES_Jewel_Cab2',
                rewards = {
                    { itemName = "ring", amount = { min = 1, max = 2 } },
                    { itemName = "necklace", amount = { min = 1, max = 3 } },
                }
            }
        },

        -- Paintings:
        paintings = {
            {
                model = 'h4_prop_h4_painting_01h',
                coords = vector4(-627.21325683594, -228.30999755859, 38.106048583984, 92.391586303711),
                rewards = {
                    { itemName = "painting_01h", amount = { min = 1, max = 1 } },
                }
            },
            {
                model = 'h4_prop_h4_painting_01d',
                coords = vector4(-622.79998779297, -225.13999938965, 38.106048583984, 339.9309387207),
                rewards = {
                    { itemName = "painting_01d", amount = { min = 1, max = 1 } },
                }
            },
            {
                model = 'h4_prop_h4_painting_01e',
                coords = vector4(-617.0, -233.2200012207, 38.106048583984, 271.89691162109),
                rewards = {
                    { itemName = "painting_01e", amount = { min = 1, max = 1 } },
                }
            },
            {
                model = 'h4_prop_h4_painting_01b',
                coords = vector4(-621.36102294922, -236.33099365234, 38.106048583984, 161.18635559082),
                rewards = {
                    { itemName = "painting_01b", amount = { min = 1, max = 1 } },
                }
            }
        },

        -- Display Cases:
        displayCases = {
            {
                baseModel = "h4_prop_h4_glass_disp_01a",
                displayModel = "h4_prop_h4_diamond_disp_01a",
                rewardModel = "h4_prop_h4_diamond_01a",
                coords = vector4(-617.4622, -227.4347, 37.057, 127.06),
                rewardOffset = vector3(0.0, 0.0, 1.225),
                rewards = {
                    { itemName = "big_diamond", amount = 1 },
                }
            },
            {
                baseModel = "h4_prop_h4_glass_disp_01a",
                displayModel = "h4_prop_h4_diamond_disp_01a",
                rewardModel = "h4_prop_h4_art_pant_01a",
                coords = vector4(-631.7185, -234.6784, 36.9402, -53.705),
                rewardOffset = vector3(0.0, 0.0, 1.25),
                rewards = {
                    { itemName = "panther", amount = 1 },
                }
            },
            {
                baseModel = "h4_prop_h4_glass_disp_01a",
                displayModel = "h4_prop_h4_neck_disp_01a",
                rewardModel = "h4_prop_h4_necklace_01a",
                coords = vector4(-628.8231, -238.7317, 36.8647, -53.705),
                rewardOffset = vector3(0.0, 0.0, 1.2),
                rewards = {
                    { itemName = "diamond_necklace", amount = 1 },
                }  
            }
        },

        -- Statues:
        statues = {
            {
                tableModel = "v_ret_tablesml",
                statueModel = "vw_prop_casino_art_panther_01b",
                coords = vector4(-622.04541015625, -230.70222473145, 37.058856964111, 303.0),
                rewards = {
                    { itemName = "panther_statue", amount = { min = 1, max = 1 } },
                }
            }
        },

        -- Safes:
        safes = {
            {
                model = "h4_prop_h4_safe_01a",
                coords = vector4(-630.88995361328, -228.27618408203, 36.959362030029, 35.38557434082),
                difficulty = 1, -- 1-4 (1 being the easiest, 4 being the hardest)
                rewards = {
                    { itemName = "money", amount = { min = 500, max = 1500 } },
                }
            }
        }
    },
}


Config.missionData = {
    takePhoto = {
        vector3(-632.86, -238.62, 38.07),
        vector3(-623.01, -216.17, 53.54),
        vector3(-622.48, -233.67, 59.16),
        vector3(-623.24, -231.54, 38.06),
        vector3(-626.04, -238.05, 38.06),
        vector3(-629.15, -230.69, 38.06)
    },
    takeEquipment = {
        pedModel = "g_m_y_mexgang_01",
        coords = {
            vector4(591.996, 2782.770, 42.481, 8.644)
        },
        items = {
            {
                name = "hacker_phone",
                amount = 1
            },
            {
                name = "gas_bomb",
                amount = 1
            },
            {
                name = "gas_mask",
                amount = 4
            },
            {
                name = "glass_cutter",
                amount = 1
            },
            {
                name = "painting_knife",
                amount = 1
            },
            {
                name = "bag",
                amount = 1
            },
        }
    },
    takeClothes = {
        pedModel = "s_m_m_autoshop_02",
        coords = {
            vector4(632.676, -3015.434, 6.336, 359.265)
        },
        items = {
            {
                name = "outfit_bag",
                amount = 1
            }
        }
    },
}
```


# Blackmarkets: Player-Run Contraband Operation

{% hint style="warning" %}
**Pre-Installation Note**

This guide assumes you already know how to manage a FiveM server (start resources, run SQL, edit configs). Following the steps out of order is the most common cause of errors.

**Support**

If something isn't working after you've followed every step, open a ticket in our [Discord](https://discord.gg/rainmad) and include your server console output. Don't skip the [Installation](/resources/blackmarkets-player-run-contraband-operation/installation) and [Dependencies](/resources/blackmarkets-player-run-contraband-operation/dependencies) pages.
{% endhint %}

Blackmarket is a player-run dealing operation built around a hidden warehouse. Players order supply that drops somewhere on the map, build up stock, then take a cargo van out for a mobile street sale while police try to find and seize it. Buyers reach vendors through a phone app, and each warehouse is run like a small business: a shared vault, crew permissions, pricing and analytics.

It works on QBCore/Qbox and ESX (auto-detected), and bridges to whatever target, notification, dispatch, phone and vehicle-key resources you already run.

## Features

* **Warehouses:** an instanced underground warehouse per owner, created by an admin, with a laptop that runs the whole operation.
* **Supply orders & drops:** order goods from the vault, wait out a scaling ETA, then collect the drop from a random map location before the police get there.
* **Cargo van:** load stock into a van and take it out for a mobile sale. Weight/slot capacity, a stable plate, and a replacement flow after a seizure or destruction.
* **Van sales:** sell to walk-up customers through a basket UI with a live item-preview camera, and optionally broadcast the sale publicly with a police dispatch.
* **NPC vendor:** scheduled NPC sellers that drive in, park, sell, and depart on a real-world or in-game clock, set up from the admin panel.
* **Police gameplay:** officers can search or seize the van and destroy its cargo, with optional live van tracking and dispatch alerts.
* **Crew & permissions:** hire members with per-action permissions (place orders, drive, set prices, manage the vault, answer the door, and more) from role presets.
* **Shared vault & analytics:** orders are paid from the vault and sales drop back into it, with a 30-day dashboard for revenue, top items, top sellers and activity.
* **Phone app:** buyers browse public vendors, start an anonymous chat, and receive live van locations. Vendors reply and block abusive contacts from the laptop inbox.
* **Door & knock system:** guests knock, the crew admits or rejects them, and each warehouse runs in its own routing bucket.
* **Admin panel:** create and delete warehouses, edit drops and NPC vendors in-world, inspect stock/vault/members, and read a full audit log.
* **Discord logging:** categorized webhook logs for admin, economy, police, social and security events.
* **Black money support:** pay out in cash, a black-money item, an account, or marked bills.

## Preview

{% embed url="<https://youtu.be/9yyRtVoouQ4>" %}

## Dependencies

A framework, `ox_lib`, `oxmysql` and a target/inventory resource are required. See the full list on the [Dependencies](/resources/blackmarkets-player-run-contraband-operation/dependencies) page before installing.

## Next steps

1. [Dependencies](/resources/blackmarkets-player-run-contraband-operation/dependencies)
2. [Installation](/resources/blackmarkets-player-run-contraband-operation/installation)
3. [Configuration](/resources/blackmarkets-player-run-contraband-operation/configuration)


# Dependencies

Install and start every **required** dependency *before* the Blackmarket resource. Optional resources only need to be present if you want the feature they power.

## Required

| Resource                                               | Purpose                         | Notes                                                                                                        |
| ------------------------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Framework**                                          | Player data, money, jobs        | `QBCore` / `Qbox` or `ESX`. Auto-detected by default (`cfg.bridge.framework = 'auto'`).                      |
| [**ox\_lib**](https://github.com/overextended/ox_lib)  | UI, callbacks, locales, caching | Required by the whole resource. Must start before Blackmarket.                                               |
| [**oxmysql**](https://github.com/overextended/oxmysql) | Database                        | All tables are created automatically on first start — no SQL import needed.                                  |
| **Inventory**                                          | Items, weapons, item images     | Uses your server's existing inventory. Every entry in `cfg.items` must exist in it (as an item or a weapon). |
| **Target**                                             | World interactions              | `ox_target` or `qb-target`. Auto-detected (`cfg.bridge.target = 'auto'`).                                    |

## Optional

| Resource                                | Powers                                  | Config key                                                               |
| --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------ |
| **lb-phone** / **yseries**              | The Blackmarket phone app (vendor chat) | `cfg.bridge.phone`                                                       |
| **Dispatch system**                     | Police alerts for drops & public sales  | `cfg.bridge.dispatch`                                                    |
| **Vehicle keys**                        | Hand the van keys to the driver         | `cfg.bridge.vehiclelock`                                                 |
| **Notification / TextUI / Progressbar** | Visual style of prompts and notifies    | `cfg.bridge.notification`, `cfg.bridge.textUI`, `cfg.bridge.progressbar` |

{% hint style="info" %}
The bridge ships built-in adapters for notifications, text UI and progress bars, so the script runs without any of those resources. Point the matching `cfg.bridge.*` key at your own resource to use it instead. See [Phone & Integrations](/resources/blackmarkets-player-run-contraband-operation/phone-and-integrations) for the full list of supported resources.
{% endhint %}

## No external screenshot resource

Member and owner mugshots are generated with native ped-headshot APIs, so you **do not** need `screenshot-basic` or any image-host resource.


# Installation

{% stepper %}
{% step %}

## Install dependencies

Make sure every resource on the [Dependencies](/resources/blackmarkets-player-run-contraband-operation/dependencies) page is installed and starts **before** Blackmarket. At minimum: your framework, `ox_lib`, `oxmysql`, an inventory and a target resource.
{% endstep %}

{% step %}

## Add the resource

1. Download the asset from the FiveM Keymaster (Granted Assets) or your order, and unzip it.
2. Drop the `rm_blackmarkets` folder into your server's `resources` directory (for example `resources/[rainmad]/rm_blackmarkets`).

{% hint style="danger" %}
Do **not** rename the resource folder. It must stay `rm_blackmarkets` — the database tables, state bags and events are all keyed to that name.
{% endhint %}
{% endstep %}

{% step %}

## Start the resource

Add it to your `server.cfg`, after its dependencies:

```cfg
ensure oxmysql
ensure ox_lib
ensure ox_inventory      # or your inventory
ensure ox_target         # or qb-target

ensure rm_blackmarkets
```

{% endstep %}

{% step %}

## Database

There is **nothing to import**. On the first start the resource creates its tables automatically through `oxmysql`:

* `rm_blackmarkets_warehouses`, `rm_blackmarkets_members`, `rm_blackmarkets_inventory`, `rm_blackmarkets_orders`
* `rm_blackmarkets_sales_log`, `rm_blackmarkets_vault_log`, `rm_blackmarkets_audit_log`
* phone tables (only when `cfg.bridge.phone` isn't `false`): `rm_blackmarkets_warehouse_public`, `rm_blackmarkets_conversations`, `rm_blackmarkets_messages`
  {% endstep %}

{% step %}

## Register your items

Every item in `cfg.items` (weapons, ammo, drugs, tools…) must already exist in your **inventory**. Items that are not registered there cannot be ordered, stocked or sold. See [How to Add an Item](/resources/blackmarkets-player-run-contraband-operation/how-to-add-an-item).
{% endstep %}

{% step %}

## Grant admin access

By default, anyone with FiveM's built-in `command` ace (the server console, usually `group.admin`) can open the admin panel. To allow specific staff without that ace, add their identifiers to `cfg.admin.allowedIdentifiers`. Then run the command (default `/bm_admin`) in-game to create your first warehouse.

```lua
cfg.admin = {
    command = 'bm_admin',
    allowedIdentifiers = {
        ['license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'] = true,
    },
}
```

See [Admin Panel](/resources/blackmarkets-player-run-contraband-operation/admin-panel) for what the panel can do.
{% endstep %}

{% step %}

## Configure

Open `cfg.lua` and set your framework/target, currency, items, warehouse coordinates, police jobs and the rest. The [Configuration](/resources/blackmarkets-player-run-contraband-operation/configuration) page walks through every section.

{% hint style="success" %}
After your first start, type `/bm_admin`, create a warehouse, and assign it an owner.
{% endhint %}
{% endstep %}
{% endstepper %}


# Configuration

Everything is configured in **`cfg.lua`**. After editing, restart the resource (`ensure rm_blackmarkets`).

## Locale & bridge

```lua
cfg.locale = 'en' -- locale file in /locales (en.json by default)

cfg.bridge = {
    framework    = 'auto',     -- 'auto' | 'qb' | 'esx'
    target       = 'auto',     -- 'auto' | 'ox_target' | 'qb-target' | false
    notification = 'builtin',  -- builtin | ox_lib | qb | esx | okokNotify | ps-ui | lation_ui | wasabi_notify | zsxui
    textUI       = 'builtin',  -- builtin | ox_lib | esx | qb | okokTextUI | jg-textui
    progressbar  = 'builtin',  -- builtin | ox_lib | qb | esx | wasabi_uikit | lation_ui | zsxui | false
    vehiclelock  = 'auto',     -- auto | false | qb-vehiclekeys | wasabi_carlock | qs-vehiclekeys | ... | jaksam
    dispatch     = false,      -- false | default_dispatch | cd_dispatch | ps-dispatch | rcore_dispatch | ...
    phone        = 'auto',     -- auto | lb-phone | yseries | false
}
```

`'auto'` tries known resources in order and uses the first one that is running. Set an explicit value to force one, or `false` to disable that integration. The full list of supported values for each key is in [Phone & Integrations](/resources/blackmarkets-player-run-contraband-operation/phone-and-integrations).

## NUI

```lua
cfg.nui = {
    primaryColor   = '#f15d38',                  -- accent color of every panel
    itemImagesPath = 'ox_inventory/web/images/', -- where item icons are served from

    overlays = { -- on-screen position of each HUD overlay
        driver       = 'top-right',
        vanInspect   = 'top-right',
        door         = 'top-right',
        seller       = 'top-left',
        customer     = 'right-center',
        customerCart = 'left-center',
    },
}
```

`primaryColor` also accepts an `{ r, g, b, a }` table. `itemImagesPath` is a resource-relative path; the script adds the `nui://` prefix itself, so don't include it. Each overlay accepts any of the nine anchor positions (`top-left`, `center`, `bottom-right`, …).

## Interaction prompt

```lua
cfg.interaction = {
    controlId = 38,    -- key for "press to interact" (38 = E)
    text      = 'E',
    colors = {
        background = '#f15d38ff',
        text       = '#e2e8f0ff',
    },
}
```

## Police

```lua
cfg.police = {
    jobs        = { 'police', 'sasp', 'bcso', 'sheriff' }, -- jobs treated as officers
    countMethod = 'fast',  -- 'fast' (state-based) | 'scan' (per-player check)

    seize  = { progressMs = 5000 },                 -- van seizure cast time
    search = { enabled = true, progressMs = 4000 }, -- van search cast time
}
```

`jobs` decides who counts as police for drop gating, dispatch and the seize/search interactions. `countMethod` controls how the live officer count is measured; leave it on `fast` unless you have a reason to change it.

## Warehouse

The core of each operation. Coordinates here are the **default template** used when an admin creates a warehouse (the entrance/exit are then placed in-world per warehouse).

```lua
cfg.warehouse = {
    coords = {
        door         = vec4(1087.12, -3099.38, -39.35, 270.0), -- on-foot interior door
        laptop       = vec3(1087.74, -3101.30, -39.20),        -- laptop interaction point
        fallbackExit = vec4(-269.41, -956.32, 31.22, 200.0),   -- where players are sent if their warehouse is deleted
        vehicle = {
            park   = vec4(1095.8, -3099.54, -39.35, 90.0),  -- where the van parks inside
            entry  = vec4(1101.68, -3099.54, -39.35, 90.0), -- where the van enters/exits the interior
            camPos = vec3(1087.21, -3102.49, -36.43),
            camRot = vec3(-29.95, 0.00, -57.32),
        },
    },

    crates = { enabled = true, slots = { --[[ decorative crate props ]] } },

    blip = { enabled = true, sprite = 478, color = 5, scale = 0.8, label = locale('blip.warehouse') },

    defaults = { stock = {}, vault = 0 }, -- starting stock/vault for a brand-new warehouse

    vault   = { maxBalance = false },               -- false = no ceiling, or a number cap
    pricing = { default = 2.0, limit = { min = 0.0, max = 5.0 } }, -- sale-price multiplier of supply price
}
```

* **`crates.slots`** are purely cosmetic crate props placed inside the interior.
* **`pricing`** sets the default sale-price multiplier and the min/max range owners may set per item.

## Items

The master catalog. Anything sellable, orderable or stockable must be listed here **and** exist in your inventory.

```lua
cfg.items = {
    { name = 'WEAPON_PISTOL', supplyPrice = 250 },
    { name = 'WEAPON_APPISTOL', supplyPrice = 850, maxPerSupplyOrder = 8 },
    { name = 'coke_brick', supplyPrice = 1500 },
    { name = 'meth', supplyPrice = 80, maxPerSupplyOrder = 100 },
    -- ...
}
```

| Field                                     | Required | Meaning                                                     |
| ----------------------------------------- | -------- | ----------------------------------------------------------- |
| `name`                                    | Yes      | Item/weapon name as registered in your inventory.           |
| `supplyPrice`                             | Yes      | Cost to order one unit (the buy price from the vault).      |
| `label`                                   | No       | Display name override (otherwise taken from the inventory). |
| `weight`                                  | No       | Per-unit weight override for van capacity.                  |
| `minPerSupplyOrder` / `maxPerSupplyOrder` | No       | Per-order quantity limits for this item.                    |
| `propModel`                               | No       | Custom prop shown in the van item-preview camera.           |

Full walkthrough on [How to Add an Item](/resources/blackmarkets-player-run-contraband-operation/how-to-add-an-item).

## Supply orders

```lua
cfg.supplyOrder = {
    maxConcurrentOrders      = 2,    -- active orders per warehouse
    defaultMinPerSupplyOrder = 1,
    defaultMaxPerSupplyOrder = 10,

    deliveryFee   = 1000, -- flat fee added on top of the items
    minOrderTotal = 1000, -- minimum subtotal to place an order (or false)

    scaling = { -- bigger orders take longer and draw more police attention
        maxAtSubtotal      = 50000, -- subtotal at which ETA/dispatch hit their max
        baseEtaSeconds     = 1200,
        maxEtaSeconds      = 4800,
        baseDispatchChance = 0.3,
        maxDispatchChance  = 1.0,
    },

    refundOnCancel = false, -- refund the vault if an order is cancelled

    drop = {
        claimSeconds = 1800, -- time to collect a ready drop before it expires
        locations    = { --[[ vector4 list of possible drop points ]] },
        model        = `prop_cs_cardbox_01`,
        minPoliceCount = 0,  -- min officers online before a drop can be revealed

        dispatch = { enabled = true, code = 'SUPPLY_PICKUP', message = locale('dispatch.supply_pickup'), blip = { sprite = 616, color = 1 } },
        livePoliceTrack = { enabled = false, durationSeconds = 180, updateIntervalMs = 2000, blip = { --[[...]] } },
    },
}
```

* **`scaling`** makes the order ETA and the police-dispatch chance ramp up linearly with the order subtotal, up to `maxAtSubtotal`.
* **`drop.locations`** is the pool the server randomly picks from when an order goes *ready*. Add your own `vec4` points or edit them live from the admin panel ([Admin Panel](/resources/blackmarkets-player-run-contraband-operation/admin-panel)).
* **`livePoliceTrack`** optionally pins a live, updating blip on the suspect during pickup.

## Van

```lua
cfg.van = {
    model             = `speedo4`,
    color             = { primary = 0, secondary = 0 }, -- vehicle color ids
    platePattern      = false,  -- false = random, or a pattern like '11AAA111'
    cargoLostOnDestroy = true,  -- destroyed van also wipes its cargo

    capacity = { mode = 'weight', max = 150000 }, -- 'weight' | 'slots' | false

    exteriorProps = { --[[ decorative props attached to the van ]] },
    orderProp     = { --[[ the box prop shown while carrying an order ]] },

    replacement = {
        requireAfterSeize   = true,  -- must order a new van after a seizure
        requireAfterDestroy = false,
        fee                 = 50000, -- vault cost of a replacement van
        deliveryDelaySeconds = 600,
    },
}
```

* **`capacity.mode`** — `weight` uses each item's weight, `slots` counts item stacks, `false` disables limits.
* **`replacement`** controls whether losing the van (seizure/destruction) forces a paid replacement before the warehouse can operate again.

## Sale

Controls the mobile van sale and the customer experience.

```lua
cfg.sale = {
    publicShare = {
        mode = 'optional', -- 'always' | 'optional' | 'never' — broadcast the sale publicly
        dispatch = { enabled = true, code = 'PUBLIC_SALE', message = locale('dispatch.public_sale') },
        blip     = { color = 1, sprite = 616, scale = 0.9, label = locale('blip.sale') },
    },

    forceFirstPerson = false,
    sellerPose  = { --[[ where/how the seller sits at the van ]] },
    customerCam = { enabled = true, distance = 2.2, height = 0.5, fov = 45.0 },
    itemPreview = { enabled = true, defaultProp = `v_ind_cs_box02`, --[[ zoom/offset for the 3D item preview ]] },
}
```

* **`publicShare.mode`** — `always` forces the sale onto the public map/dispatch, `never` hides it, `optional` lets the seller choose.
* **`itemPreview`** renders the highlighted item as a 3D prop on the van with an optional zoom; use a per-item `propModel` (in `cfg.items`) to override the prop.

## Member presets

Role templates offered when hiring crew. Each preset is a set of permission flags.

```lua
cfg.memberPresets = {
    {
        id = 'manager', label = 'Manager',
        permissions = { placeOrder = true, pickupOrder = true, orderVan = true, vanCargo = true,
            driveVan = true, setSalePrice = true, openSale = true, vaultDeposit = true, vaultWithdraw = true,
            viewFinancials = true, answerDoor = true, manageMembers = true, chatRespond = true },
    },
    {
        id = 'worker', label = 'Worker',
        permissions = { pickupOrder = true, vanCargo = true, driveVan = true, openSale = true,
            vaultDeposit = true, answerDoor = true, chatRespond = true },
    },
}
```

Owners can fine-tune individual permissions per member from the laptop afterwards; presets are just the starting point. The full permission key list is shown in the Members tab and on [Admin Panel](/resources/blackmarkets-player-run-contraband-operation/admin-panel).

## Admin

```lua
cfg.admin = {
    command            = 'bm_admin', -- command that opens the admin panel (or false to disable)
    allowedIdentifiers = {           -- identifiers allowed without an ace permission
        -- ['license:xxxxxxxx'] = true,
    },
}
```

Players with the `command` ace are always allowed. See [Admin Panel](/resources/blackmarkets-player-run-contraband-operation/admin-panel).

## NPC vendor

```lua
cfg.npcVendor = {
    enabled           = true,
    npcModel          = `a_m_m_business_01`,
    pricingMultiplier = 3.0, -- sell price = item price override, else supplyPrice * multiplier

    vendors = { --[[ one or more scheduled NPC vendors ]] },
}
```

This is large enough to have its own page — see [NPC Vendor](/resources/blackmarkets-player-run-contraband-operation/npc-vendor).

## Black money

```lua
cfg.blackMoney = {
    enabled     = false,         -- pay sales in black money instead of cash
    type        = 'item',        -- 'item' | 'account' | 'markedbills'
    item        = 'black_money',
    account     = 'black_money',
    metadataKey = 'worth',       -- metadata key for markedbills worth
}
```

When disabled, sales pay out in regular cash.

## Retention

```lua
cfg.retention = {
    logs          = 30, -- days to keep sales/vault/audit logs
    conversations = 30, -- days to keep phone chats (0 disables pruning)
}
```

A daily cron prunes anything older than these windows. Set a value to `0` to keep records forever.


# How to Add an Item

Every item that can be **ordered, stocked or sold** lives in `cfg.items`. Adding one is two steps: register it in your inventory, then list it here.

{% stepper %}
{% step %}

## Register the item in your inventory

The item or weapon must already exist in your inventory. Items and weapons are usually registered separately — in ox\_inventory, for example, items live in `data/items.lua` and weapons in `data/weapons.lua`. Blackmarket doesn't create them; it only trades what your server already has.
{% endstep %}

{% step %}

## Add it to `cfg.items`

```lua
cfg.items = {
    -- existing entries...

    { name = 'WEAPON_HEAVYPISTOL', supplyPrice = 950, maxPerSupplyOrder = 8 },
    { name = 'lockpick', supplyPrice = 120, label = 'Lockpick', maxPerSupplyOrder = 25 },
}
```

### Fields

| Field               | Required | Default                                    | Description                                                                                                      |
| ------------------- | -------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `name`              | Yes      | —                                          | Item/weapon name, exactly as in your inventory.                                                                  |
| `supplyPrice`       | Yes      | —                                          | Cost to order one unit from the vault. The sale price defaults to `supplyPrice × cfg.warehouse.pricing.default`. |
| `label`             | No       | inventory label                            | Display name override in the UI.                                                                                 |
| `weight`            | No       | inventory weight                           | Per-unit weight used for van capacity (`capacity.mode = 'weight'`).                                              |
| `minPerSupplyOrder` | No       | `cfg.supplyOrder.defaultMinPerSupplyOrder` | Minimum units per order for this item.                                                                           |
| `maxPerSupplyOrder` | No       | `cfg.supplyOrder.defaultMaxPerSupplyOrder` | Maximum units per order for this item.                                                                           |
| `propModel`         | No       | `cfg.sale.itemPreview.defaultProp`         | Prop shown in the van's 3D item-preview camera.                                                                  |

{% hint style="warning" %}
After editing `cfg.items`, **restart the resource**. New items do not appear in already-open laptops or sales until the next session.
{% endhint %}
{% endstep %}
{% endstepper %}

## Set its price

* **Supply price** (the buy cost) is fixed in `cfg.items`.
* **Sale price** (what customers pay) is set per warehouse from the laptop's **Pricing** tab, within the `cfg.warehouse.pricing.limit` range. New items start at `supplyPrice × pricing.default`.


# Admin Panel

The entire script is managed in-game from a single admin panel. There are no other commands; one command opens it, and everything else happens in the UI.

## Opening the panel

| Command     | Default                              | Access      | Opens           |
| ----------- | ------------------------------------ | ----------- | --------------- |
| `/bm_admin` | configurable via `cfg.admin.command` | admins only | The admin panel |

Set `cfg.admin.command = false` to remove the command entirely.

## Who is an admin?

A player is treated as an admin if **either** is true:

1. They have FiveM's built-in `command` ace (the server console, and usually `group.admin`), **or**
2. Their identifier is listed in `cfg.admin.allowedIdentifiers`.

```lua
cfg.admin = {
    command = 'bm_admin',
    allowedIdentifiers = {
        ['license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'] = true,
        ['license:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy'] = true,
    },
}
```

{% hint style="info" %}
You can find a player's `license:` identifier with `/bm_admin` once they're connected — or from txAdmin / your server console. Use the `license:` identifier, not the Steam or Discord one, for the most stable match.
{% endhint %}

Admins also bypass per-warehouse permission checks, so they can inspect and manage any warehouse.

## The dashboard

The panel has four tabs:

{% tabs %}
{% tab title="Warehouses" %}

* **Create a warehouse** through a step-by-step wizard: pick the owner (any online player), place the entrance and the vehicle exit in-world, optionally pre-fill starter stock, seed the vault, and pre-hire crew with a role preset.
* **Inspect** any existing warehouse to see and edit:
  * **Overview** — vault, member count, players inside, active orders; transfer ownership; edit coordinates; teleport in.
  * **Stock** — view warehouse + van stock, wipe either.
  * **Members** — hire/remove crew, set roles & permissions.
  * **Inside** — see who is in the bucket and who is knocking; eject one player or everyone.
  * **Settings** — edit the public storefront (vendor name, description, avatar) as an admin (logged).
* **Vault override** — credit or debit any warehouse's vault (clamped to the configured ceiling/floor, and logged).
* **Force-respawn the van**, **delete** the warehouse, and more.
  {% endtab %}

{% tab title="Drops" %}
Manage the supply-drop location pool (`cfg.supplyOrder.drop.locations`). **Pick a new spot in-world**, then copy the generated `vec4(...)` line into your config. You can also teleport to existing drop points to check them.

{% hint style="warning" %}
Locations you pick in the Drops/NPC tools are written to your **clipboard as config lines** — they are not auto-saved. Paste them into `cfg.lua` and restart for them to persist.
{% endhint %}
{% endtab %}

{% tab title="NPC Vendor" %}
A full wizard to create [NPC Vendors](/resources/blackmarkets-player-run-contraband-operation/npc-vendor): name, schedule (real/game/always-open), items & pricing, and reachable spawn locations validated in-world. It outputs a config block you paste into `cfg.npcVendor.vendors`. You can also **force-start / force-stop** vendors live to test them.
{% endtab %}

{% tab title="Logs" %}
A 30-day audit trail of admin actions (warehouse create/delete, ownership transfers, ejects, vault adjustments, stock wipes, van respawns, coordinate edits, NPC force start/stop, storefront edits). Retention is controlled by `cfg.retention.logs`.
{% endtab %}
{% endtabs %}

## Member permissions reference

Owners (and admins) grant these per member. Presets in `cfg.memberPresets` bundle them; the laptop Members tab fine-tunes each one.

| Key              | Group              | Allows                                            |
| ---------------- | ------------------ | ------------------------------------------------- |
| `placeOrder`     | Orders             | Spend the vault on supply orders                  |
| `pickupOrder`    | Orders             | Collect ready supply drops                        |
| `orderVan`       | Orders             | Order a replacement van after a loss              |
| `vanCargo`       | Stock & van        | Move stock between warehouse and van              |
| `driveVan`       | Stock & van        | Drive the van out for runs                        |
| `setSalePrice`   | Stock & van        | Edit warehouse-wide sale prices                   |
| `openSale`       | Stock & van        | Start a van sale outside                          |
| `viewFinancials` | Vault & financials | See vault balance, revenue and analytics          |
| `vaultDeposit`   | Vault & financials | Add funds to the vault                            |
| `vaultWithdraw`  | Vault & financials | Take funds out of the vault                       |
| `answerDoor`     | People             | Admit/reject knocks, kick guests                  |
| `manageMembers`  | People             | Add, edit, remove members *(owner-only to grant)* |
| `chatRespond`    | People             | Reply to phone-app messages                       |


# NPC Vendor

NPC vendors are server-run sellers that **drive a van to a spot, park, sell from a customer UI, then depart** on a schedule. They give players a baseline place to buy without a live owner online. Everything lives under `cfg.npcVendor`.

{% hint style="success" %}
You don't have to write this config by hand. The admin panel has an **NPC Vendor wizard** that lets you pick locations in-world, set the schedule and items, and then copies a ready-to-paste config block. See [Admin Panel](/resources/blackmarkets-player-run-contraband-operation/admin-panel).
{% endhint %}

## Global settings

```lua
cfg.npcVendor = {
    enabled           = true,                 -- master switch for all NPC vendors
    npcModel          = `a_m_m_business_01`,  -- default ped model
    pricingMultiplier = 3.0,                  -- default sell price = item supplyPrice × this

    vendors = { --[[ list of vendors, see below ]] },
}
```

`pricingMultiplier` turns each item's `cfg.items.supplyPrice` into a sell price. A per-item `price` (below) overrides it.

## A vendor

```lua
vendors = {
    {
        name = 'Paleto Bay',                  -- label shown only in the admin dashboard
        npcModel = `a_m_m_business_01`,       -- optional per-vendor model override
        pricingMultiplier = 3.0,              -- optional per-vendor multiplier override

        locations = {                         -- van parks/sells at one of these (picked per spawn)
            vec4(-280.70, 6034.97, 30.52, 231.17),
        },

        schedule = {
            enabled = true,
            mode = 'real',                    -- 'real' (clock) | 'game' (in-game hour)
            real = {                          -- cron windows when mode = 'real'
                { open = '0 19 * * *', close = '0 23 * * *' },
            },
            game = {                          -- hour windows when mode = 'game'
                -- { startHour = 19, stopHour = 23 },
            },
        },

        items = {                             -- what this vendor sells
            { name = 'WEAPON_PISTOL' },       -- uses pricingMultiplier
            { name = 'WEAPON_SMG', price = 12000 }, -- fixed price override
            { name = 'ammo-9' },
        },
    },
}
```

### Fields

| Field               | Required | Description                                                                                                  |
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `name`              | No       | A label shown only in the admin dashboard.                                                                   |
| `locations`         | Yes      | One or more `vec4` spots. The server picks one each time the vendor spawns and drives the van there by road. |
| `npcModel`          | No       | Per-vendor ped override (else the global `npcModel`).                                                        |
| `pricingMultiplier` | No       | Per-vendor multiplier override (else the global one).                                                        |
| `schedule`          | Yes      | When the vendor is open (see below).                                                                         |
| `items`             | Yes      | List of `{ name, price? }`. `name` must be in `cfg.items`; `price` overrides the multiplier.                 |

## Schedules

* **`mode = 'real'`** uses real-world time. Each window is a pair of cron expressions: `open` and `close`.

  ```lua
  real = {
      { open = '0 19 * * *', close = '0 23 * * *' }, -- every day, 19:00–23:00
      { open = '0 12 * * 6', close = '0 18 * * 6' }, -- Saturdays, 12:00–18:00
  }
  ```
* **`mode = 'game'`** uses the in-game clock with `startHour`/`stopHour` (0–23). Windows that wrap past midnight (e.g. `startHour = 22, stopHour = 4`) are supported.

  ```lua
  game = {
      { startHour = 19, stopHour = 23 },
  }
  ```
* **`enabled = false`** makes the vendor **always open** — it spawns permanently and never departs.

{% hint style="info" %}
When a vendor opens it drives in (`pre-spawn → driving → parking → open`) and when it closes it drives off (`departing`). You can force-start or force-stop any vendor live from the admin panel for testing.
{% endhint %}

## Picking valid locations

A location must be reachable by road. When you pick a spot in the admin wizard it's validated against the nearest road: you'll get an error if there's no road nearby, or the closest one is too far. Pick spots near drivable roads, not deep off-road or inside interiors.


# Discord Log

Blackmarket can post rich, categorized embeds to a Discord webhook. Logging is configured at the top of **`server/discord_log.lua`**.

## Setup

```lua
local config = {
    enabled = false,          -- turn logging on
    webhook = 'WEBHOOK_HERE', -- your Discord webhook URL
    botName = 'Blackmarket',  -- username shown on the embeds
    categories = {            -- toggle whole categories on/off
        admin    = true,
        economy  = true,
        police   = true,
        social   = true,
        security = true,
    },
}
```

1. Create a webhook in **Discord → Channel Settings → Integrations → Webhooks**.
2. Paste its URL into `webhook`.
3. Set `enabled = true`.
4. Restart the resource.

{% hint style="info" %}
You can point each category at a different channel by duplicating the webhook logic, but the simplest setup is one webhook with the categories you care about left `true`.
{% endhint %}

## Categories & events

Each embed is color-coded by category. Turn off a category by setting it to `false`.

| Category     | Color  | Logged events                                                                                                                                                                                         |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **ADMIN**    | red    | Every admin-panel action (create/delete warehouse, transfer ownership, eject, vault adjust, stock/van wipe, force-respawn van, edit coords/storefront, NPC force start/stop, member add/edit/remove). |
| **ECONOMY**  | blue   | Supply order placed/cancelled, drop picked up, order offloaded, replacement van ordered, van sale opened/closed, customer purchase, NPC vendor purchase, vault deposit/withdraw.                      |
| **POLICE**   | orange | Van searched, van cargo seized, van destroyed.                                                                                                                                                        |
| **SOCIAL**   | green  | Member added, member permissions updated, member removed.                                                                                                                                             |
| **SECURITY** | purple | Anti-abuse signals: customer refund lost on mid-purchase disconnect, vault deposit lost on disconnect, permission-denied attempts.                                                                    |

Every embed includes the warehouse (`#id` + owner) and the actor (name + identifier) when they apply, plus event-specific fields (amounts, item lists, ETAs, coordinates…).

{% hint style="warning" %}
If `enabled = true` but no logs appear, double-check the webhook URL is complete and that the channel still exists. A revoked or wrong URL fails silently.
{% endhint %}


# Translate Strings

All player-facing text lives in JSON locale files under **`/locales`**. The script ships with `en.json`.

## Switch language

Set the locale in `cfg.lua`:

```lua
cfg.locale = 'en' -- loads /locales/en.json
```

The value must match a file name in `/locales` (without the `.json`).

## Add a new language

1. Copy `locales/en.json` to a new file, e.g. `locales/fr.json`.
2. Translate the **values** only — never change the keys.
3. Set `cfg.locale = 'fr'` and restart the resource.

```json
{
    "prompt": {
        "open_laptop": "Ouvrir l'ordinateur",
        "knock_door": "Frapper"
    },
    "notify": {
        "no_permission": "Vous n'avez pas la permission pour cette action."
    }
}
```

{% hint style="danger" %}
Only edit the text on the **right** of each `:`. Changing or removing a key (the left side) will break the string and may error in-game.
{% endhint %}

## Placeholders

Some strings contain placeholders that the script fills in at runtime. Keep them exactly as they are, in a spot that still reads naturally:

* `%s` — a string (a name, an amount, an item label).
* `%d` — a whole number.
* `%s%%` — a number followed by a literal percent sign.

The same rule applies wherever a string lives — for example `notify.van_seized` and `ui.orders.order_placed`:

```json
"van_seized": "Van seized. %d items destroyed.",
"order_placed": "Order placed — arriving in ~%s min."
```

## Where text is grouped

The locale file is organized by area so you can find strings quickly:

| Section             | Covers                                                                                                                                                |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt`            | Interaction prompts ("Open Laptop", "Knock"…)                                                                                                         |
| `notify`            | Server notifications                                                                                                                                  |
| `progress`          | Progress-bar labels                                                                                                                                   |
| `command`           | Command help text                                                                                                                                     |
| `blip` / `dispatch` | Map blips and police dispatch messages                                                                                                                |
| `activity`          | Vault/sales activity descriptions                                                                                                                     |
| `editor`            | Placement-tool hints (drop / NPC vendor / warehouse editors)                                                                                          |
| `ui.*`              | Every NUI panel: laptop tabs, orders, stock, pricing, vault, analytics, members, inbox, settings, van, sale, customer, phone app, door, admin, editor |

{% hint style="info" %}
The `ui` section is by far the largest — it holds all the in-panel text. Translate it last, once the gameplay strings (`prompt`, `notify`) are done, so you can test in-game as you go.
{% endhint %}


# Phone & Integrations

Blackmarket bridges to the resources you already run. Every integration is one key in `cfg.bridge` (plus a couple of dedicated config blocks). `'auto'` picks the first supported resource that is running; an explicit name forces one; `false` disables it.

## Phone app

The **Blackmarket** phone app lets buyers browse public vendors, start an anonymous chat (the buyer shows up only under a generated alias), share their live location, and receive the van's location back. Vendors reply from the laptop **Inbox** tab.

```lua
cfg.bridge.phone = 'auto' -- 'auto' | 'lb-phone' | 'yseries' | false
```

| Value      | Phone                                                                                     |
| ---------- | ----------------------------------------------------------------------------------------- |
| `auto`     | Tries `lb-phone`, then `yseries`.                                                         |
| `lb-phone` | [lb-phone](https://docs.lbscripts.com/)                                                   |
| `yseries`  | yseries phone                                                                             |
| `false`    | Disables the phone app (warehouses can still operate; only the buyer-facing app is gone). |

A warehouse only appears in the app after the owner enables **Public listing** in the laptop **Settings** tab (vendor name, description, avatar URL). The phone database tables are created on first start, as long as `cfg.bridge.phone` isn't set to `false`.

{% hint style="info" %}
Conversations and messages are pruned after `cfg.retention.conversations` days (default 30). Set it to `0` to keep them forever.
{% endhint %}

## Notifications

```lua
cfg.bridge.notification = 'builtin'
```

Supported: `builtin`, `ox_lib`, `qb`, `esx`, `okokNotify`, `ps-ui`, `lation_ui`, `wasabi_notify`, `zsxui`.

## Text UI

```lua
cfg.bridge.textUI = 'builtin'
```

Supported: `builtin`, `ox_lib`, `esx`, `qb`, `okokTextUI`, `jg-textui`.

## Progress bar

```lua
cfg.bridge.progressbar = 'builtin'
```

Supported: `builtin`, `ox_lib`, `qb`, `esx`, `wasabi_uikit`, `lation_ui`, `zsxui`, or `false`.

## Target

```lua
cfg.bridge.target = 'auto'
```

Supported: `auto`, `ox_target`, `qb-target`, or `false`.

## Dispatch

Used for police alerts on supply pickups and public sales.

```lua
cfg.bridge.dispatch = false
```

Supported: `false`, `default_dispatch`, `cd_dispatch`, `qs-dispatch`, `ps-dispatch`, `rcore_dispatch`, `sonoran_cad`, `origen_police`, `redutzu-mdt`, `lb-tablet`, `core_dispatch`, `tk_dispatch`, `l2s-dispatch`, `bub-mdt`, `fd_dispatch`, `kartik-mdt`, `piotreq_gpt`, `p_mdt`, `wasabi_mdt`.

The alert codes/messages/blips are configured per feature in `cfg.supplyOrder.drop.dispatch` and `cfg.sale.publicShare.dispatch`.

## Vehicle keys

Hands the van keys to whoever drives it out.

```lua
cfg.bridge.vehiclelock = 'auto'
```

Supported: `auto`, `false`, `qb-vehiclekeys`, `wasabi_carlock`, `qs-vehiclekeys`, `cd_garage`, `Renewed-Vehiclekeys`, `okokGarage`, `t1ger_keys`, `MrNewbVehicleKeys`, `jaksam`.

## Framework

```lua
cfg.bridge.framework = 'auto' -- 'auto' | 'qb' | 'esx'
```

`auto` detects QBCore/Qbox or ESX. Force one only if auto-detection guesses wrong.

## Black money

By default sales pay out in **cash**. To pay in black money instead:

```lua
cfg.blackMoney = {
    enabled     = true,
    type        = 'item',        -- 'item' | 'account' | 'markedbills'
    item        = 'black_money', -- when type = 'item'
    account     = 'black_money', -- when type = 'account'
    metadataKey = 'worth',       -- when type = 'markedbills'
}
```

| `type`        | Pays out as                                                              |
| ------------- | ------------------------------------------------------------------------ |
| `item`        | A stackable inventory item named by `item`.                              |
| `account`     | A framework money account named by `account` (e.g. ESX `black_money`).   |
| `markedbills` | A marked-bills item whose value is stored in metadata key `metadataKey`. |

Make sure the chosen item/account actually exists in your framework/inventory.


# 3D Hacking Device: In-hand phone hacking tool

{% hint style="warning" %}
&#x20;**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.

{% endhint %}


# Installation

## Step 1: Download and Prepare Files

#### **Download the Script from FiveM Keymaster**

* Visit the [FiveM Keymaster](https://keymaster.fivem.net/) site.
* Log in with your account credentials.
* Locate the **3D Hacking Device** script in your **Granted Assets** section.
* Download the script files from Keymaster.

## Step 2: Add Script to Server Configuration

#### **Edit `server.cfg`**

* Open the `server.cfg` file located in the root directory of your FiveM server.
* Add the following line to ensure the **3D Hacking Device** script starts with your server:

```
ensure rm_hackingdevice
```


# Configuration

```lua
lib.locale('en')

cfg = {}

---@type 'auto' | 'qb' | 'esx'
cfg.framework = 'auto'

---@type 'auto' | 'ox_target' | 'qb-target' | false
cfg.target = 'auto'

---@type 'rm_minigames' | 'ox_lib' | 'qb-minigames' | 'ps-ui' | 'bl_ui' | false
cfg.minigame = 'ox_lib'

---@type 'ox_lib' | 'qb' | 'esx' | 'okokNotify' | 'ps-ui'
cfg.notification = 'ox_lib'

---@type 'ox_lib' | 'esx' | 'qb' | 'okokTextUI' | 'jg-textui'
cfg.textUI = 'ox_lib'

---@type 'ox_lib' | 'qb' | 'esx' | false
cfg.progressbar = 'ox_lib'

---@type 'auto' | false | 'qb-vehiclekeys' | 'wasabi_carlock' | 'qs-vehiclekeys' | 'cd_garage' | 'Renewed-Vehiclekeys' | 'okokGarage' | 't1ger_keys' | 'MrNewbVehicleKeys'
cfg.vehiclelock = 'auto'

---@type false | 'default_dispatch' | 'cd_dispatch' | 'qs-dispatch' | 'ps-dispatch' | 'rcore_dispatch' | 'sonoran_cad' | 'origen_police' | 'redutzu-mdt' | 'lb-tablet' | 'core_dispatch' | 'tk_dispatch' | 'l2s-dispatch'
cfg.dispatch = false

cfg.interaction = {
    controlId = 38, ---@type number
    text = 'E', ---@type string
    colors = {
        background = { r = 241, g = 93, b = 56, a = 255 }, ---@type { r: number, g: number, b: number, a: number }
        text = { r = 226, g = 232, b = 240, a = 255 }, ---@type { r: number, g: number, b: number, a: number }
    },
}

cfg.requiredPoliceCount = 0 ---@type number
cfg.enableOldMethodForPoliceCount = false ---@type boolean -- not recommended

-- ========== Hacking Device ==========

cfg.itemName         = 'rm_hacking_device' ---@type string  inventory item name that opens the device
cfg.shouldRemove     = false ---@type boolean  remove the item from the player's inventory on each use
cfg.HackHoldDuration = 1500  ---@type number   ms to lock onto a target while holding Z
cfg.ActionArmWindow  = 10000 ---@type number   ms — after picking an action the user has this long to lock a target with Z
cfg.MaxActionRange   = 80.0  ---@type number   max distance (m) between hacker and target for an action to succeed
cfg.debug            = false ---@type boolean  prints per-action server log when true

cfg.Actions = {
    Vehicle = {
        { id = 'disable_engine', cost = 25,  cooldown = 8000  },
        { id = 'open_doors',     cost = 25,  cooldown = 5000  },
        { id = 'steer_right',    cost = 50,  cooldown = 10000 },
        { id = 'steer_left',     cost = 50,  cooldown = 10000 },
        { id = 'explode_box',    cost = 100, cooldown = 15000 },
        { id = 'unlock_doors',   cost = 30,  cooldown = 5000  },
        { id = 'explode_car',    cost = 150, cooldown = 30000 },
    },
    Human = {
        { id = 'steal_money',    cost = 75, cooldown = 20000 },
        { id = 'fake_dispatch',  cost = 40, cooldown = 30000 },
        { id = 'fake_ring',      cost = 15, cooldown = 5000  },
        { id = 'wipe_gps',       cost = 30, cooldown = 8000  },
        { id = 'drop_weapon',    cost = 70, cooldown = 20000 },
    },
}

cfg.giveCreditCommand = 'givecredit' ---@type string
cfg.giveCreditAlloweds = {
    -- ['steam:00000000a000a00'] = true,
    -- ['license:0aa00a00a00aa000a000000a00000a00a00aa000'] = true,
    -- ['license2:0aa00a00a00aa000a000000a00000a00a00aa000'] = true,
    -- ['fivem:0000000'] = true,
}
```


# Biker Melee (melee weapons on bikes)

{% hint style="warning" %}
&#x20;**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.

{% endhint %}


# Installation

## Step 1: Download and Prepare Files

#### **Download the Script from FiveM Keymaster**

* Visit the [FiveM Keymaster](https://keymaster.fivem.net/) site.
* Log in with your account credentials.
* Locate the **Biker Melee (melee weapons on bikes)** script in your Granted Assets section.
* Download the script files from Keymaster.

## Step 2: Add Script to Server Configuration

#### **Edit `server.cfg`**

* Open the `server.cfg` file located in the root directory of your FiveM server.
* Add the following line to ensure the **Biker Melee (melee weapons on bikes)** script starts with your server:

```
ensure rm_bikermelee
```


# Configuration

```lua
cfg = {}

---@type 'auto' | 'qb' | 'esx'
cfg.framework = 'auto'

---@type 'auto' | 'ox_inventory' | 'qs-inventory' | 'codem-inventory' | 'core_inventory' | 'tgiann-inventory' | 'origen_inventory' | 'one_inventory' | 'jpr-inventory' | 'qb-inventory' | 'ps-inventory'
cfg.inventory = 'auto'

---@type 'ox_lib' | 'qb' | 'esx' | 'okokNotify' | 'ps-ui'
cfg.notification = 'ox_lib'

-- Melee controls & tuning. Control IDs are GTA input indices (see the comments).
-- https://docs.fivem.net/docs/game-references/controls/
cfg.melee = {
    -- ── Attack keys (map naturally to LMB/RMB on keyboard and LT/RT on gamepad) ──
    keyboardLeftAttack  = 25,   -- INPUT_AIM     (RMB)
    keyboardRightAttack = 24,   -- INPUT_ATTACK  (LMB)

    -- ── Weapon select keys ──
    swapWeaponsKey      = 194,  -- INPUT_FRONTEND_RRIGHT (Backspace)
    nextWeaponControl   = 99,   -- INPUT_VEH_SELECT_NEXT_WEAPON ([)
    prevWeaponControl   = 100,  -- INPUT_VEH_SELECT_PREV_WEAPON (])

    -- ── Throw mode keys ──
    throwModeKey        = 47,   -- INPUT_DETONATE (G) — toggle throw mode
    throwReleaseKey     = 24,   -- INPUT_ATTACK (LMB) — release the throw

    -- ── Master on/off (lib.addKeybind key name; players can rebind in pause menu) ──
    toggleKey           = 'J',  -- toggles Biker Melee mode on/off (default on)

    -- ── Behaviour toggles ──
    onlyMeleeOnUnarmed  = false,
    ejectPedOffBike     = false,
    drawWeaponUI        = true,
    weaponUICenter      = true,
    weaponUIVanish      = true,
    swapBetweenGunsAndMelee = false,

    -- ── Physics / tuning ──
    kickForwardVelocity = 0.0,
    kickUpwardVelocity  = 0.0,
    vehicleDamage       = 0.0,
    throwSpeed          = 30.0,
    throwGravity        = 9.81,
    throwMaxSteps       = 35,
    throwTimeStep       = 0.05,
}

-- Melee weapons: inventory item key → in-hand prop + display name + type.
-- wpnType 'blade' deals more damage than 'blunt'.
cfg.meleeWeapons = {
    WEAPON_BAT         = { propModel = `w_me_bat`,          name = 'Bat',         wpnType = 'blunt' },
    WEAPON_CROWBAR     = { propModel = `w_me_crowbar`,      name = 'Crowbar',     wpnType = 'blade' },
    WEAPON_GOLFCLUB    = { propModel = `w_me_gclub`,        name = 'Golfclub',    wpnType = 'blunt' },
    WEAPON_HAMMER      = { propModel = `w_me_hammer`,       name = 'Hammer',      wpnType = 'blunt' },
    WEAPON_HATCHET     = { propModel = `prop_w_me_hatchet`, name = 'Hatchet',     wpnType = 'blade' },
    WEAPON_KNIFE       = { propModel = `w_me_knife_01`,     name = 'Knife',       wpnType = 'blade' },
    WEAPON_MACHETE     = { propModel = `w_me_machette_lr`,  name = 'Machete',     wpnType = 'blade' },
    WEAPON_NIGHTSTICK  = { propModel = `w_me_nightstick`,   name = 'Nightstick',  wpnType = 'blunt' },
    WEAPON_POOLCUE     = { propModel = `w_me_poolcue`,      name = 'Poolcue',     wpnType = 'blunt' },
    WEAPON_SWITCHBLADE = { propModel = `w_me_switchblade`,  name = 'Switchblade', wpnType = 'blade' },
    WEAPON_WRENCH      = { propModel = `w_me_wrench`,       name = 'Wrench',      wpnType = 'blunt' },
}

-- Order the script picks weapons in (first owned = first selected). Every key
-- here must exist in cfg.meleeWeapons above.
cfg.meleeItemPriority = {
    'WEAPON_BAT',
    'WEAPON_CROWBAR',
    'WEAPON_GOLFCLUB',
    'WEAPON_HAMMER',
    'WEAPON_HATCHET',
    'WEAPON_KNIFE',
    'WEAPON_MACHETE',
    'WEAPON_NIGHTSTICK',
    'WEAPON_POOLCUE',
    'WEAPON_SWITCHBLADE',
    'WEAPON_WRENCH',
}

```


# Jobs V: Legal Multiplayer Job Pack

{% hint style="warning" %}
**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.
  {% endhint %}


# Installation

{% stepper %}
{% step %}

## Download and Prepare Files

#### **Download the Script from FiveM Keymaster**

* Visit the [FiveM Keymaster](https://keymaster.fivem.net/) site.
* Log in with your account credentials.
* In the **Granted Assets** section, locate the **Jobs V: Legal Multiplayer Job Pack** scripts.
* Download both archives:
  * `rm_jobsv` — the main script
  * `rm_jobsv_assets` — the asset pack

#### **Place the Folders**

Drop both folders into your server's `resources/` directory, for example under `[rainmad]/`:

```
resources/[rainmad]/rm_jobsv/
resources/[rainmad]/rm_jobsv_assets/
```

{% endstep %}

{% step %}

## Add the Scripts to `server.cfg`

Add these lines to your `server.cfg`, **in this order** (assets must start before the main script):

```
ensure rm_jobsv_assets
ensure rm_jobsv
```

{% endstep %}

{% step %}

## Verify Dependencies

The resource depends on the following — make sure they are started **before** `rm_jobsv`:

* `oxmysql`
* `ox_lib`
  {% endstep %}

{% step %}

## Database

There is **nothing to import manually**. The tables (`rm_jobsv`, `rm_jobsv_payout_buckets`) are created automatically on first boot.
{% endstep %}
{% endstepper %}


# Configuration

Configuration is split across three layers:

* **`shared/main_config.lua`** — global, server-wide settings (framework, money, daily featured, level system, admins, locale strings).
* **`shared/ui_config.lua`** — UI defaults and web locale strings sent to the tablet UI.
* **`shared/jobs/<jobId>.lua`** — per-job content (reward, dialog, taskbar, blips, vehicle, locations).

## Framework

```lua
Config.framework = {
    name = "auto",          -- "esx" | "qb" | "auto" (auto-detects)
    targetScript = "ox_target", -- "ox_target" | "qb-target" | "qtarget"
    useOxNotify = true,     -- send notifications via ox_notify
    debug = false,          -- enable verbose debug + zone outlines
}
```

## Money

```lua
Config.moneyOptions = {
    moneyName = "money",
    moneyIsItem = false,
}
```

## Daily Featured Job

```lua
Config.dailyFeaturedJob = {
    enabled = true,
    bonusMultiplier = 1.25, -- 1.25 = +25% payout on the featured job
    rotationHour = 24,      -- UTC hour to rotate (24 = 00:00 UTC)
}
```

The featured job is **deterministic** — picked from a sorted, alphabetical pool seeded by the UTC date, so all players see the same featured job and a server restart never changes it within the same day.

## Vehicle Cleanup

```lua
Config.vehicleCleanup = {
    enabled = false,
    abandonTimeoutMs = 180000,  -- 3 min: how long until an abandoned vehicle despawns
    distanceThreshold = 100.0,  -- meters: distance from owner before considered abandoned
    checkIntervalMs = 30000,    -- 30 sec: server thread interval
}
```

## General

```lua
Config.general = {
    nearbyPlayersDistance = 5.0, -- crew invite range
    inviteExpireTime = 60,       -- seconds before a crew invite expires
    sqlSaveInterval = 10,        -- minutes between periodic progress flushes
    jobCompleteAutoEnd = 30,     -- seconds before the complete modal auto-closes if leader idle

    level = {
        perLevelExpAmount = 1000, -- account-level XP per level
        maxLevel = 30,
    },
    jobLevel = {
        perLevelExpAmount = 1000, -- per-job XP per level
        maxLevel = 10,
        bonusPerLevel = 0.02,     -- +2% money bonus per per-job level
    },
    jobCenter = {
        pedModel = "s_m_y_uscg_01",
        coords = vector4(-268.823, -956.624, 30.223, 204.099),
    },
}
```

## Admins

```lua
Config.admins = {
    identifiers = {
        ["license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"] = true,
        -- ["steam:110000100000000"] = true,
    },
}
```

Listed identifiers gain access to the in-game admin panel (KPIs, charts, per-job stats, job config, lobby view, and the job editor).

## Bucket Retention

```lua
Config.bucketRetentionDays = 90 -- delete payout buckets older than N days, 0 = never
```

## Per-job Configuration (Shared Structure)

Every job in `shared/jobs/` follows the same skeleton. Example from `shared/jobs/cleaner.lua`:

```lua
Config.jobs.cleaner = {
    disabled = false,
    reward = {
        money = { min = 900, max = 1400 },
        exp = 110,
    },
    webConfig = {
        name = "Cleaner",
        beginnerFriendly = true,
        description = "Keep the city pristine — collect waste and sanitize public areas.",
        reqLevel = 1,             -- account level required to start
        players = "1-2",          -- recommended crew size (string, display only)
        duration = "20 min",      -- average duration (string, display only)
        objectives = { "Mop, wipe and sweep", "Pressure-wash the dirt", "Clear zones" },
        image = "./images/cleaner.png",
    },
    timeBonus = {
        time = 20,                -- complete under N minutes for a bonus
        multiplier = 1.25,        -- +25% reward
        perPlayerReduction = 2,   -- subtract N minutes per extra crew member
    },
    npc = {                       -- dispatcher NPC at the depot
        model = "s_m_m_autoshop_01",
        coords = vector4(-292.370, -985.516, 30.080, 299.325),
    },
    vehicle = {                   -- work vehicle the leader spawns
        model = "bison",
        leaderOnly = true,
        vehicleSpawnCoords = {
            vector4(-297.940, -990.581, 30.583, 342.457),
            vector4(-301.425, -989.447, 30.583, 339.084),
        },
    },
    locations = { ... },          -- job-specific work locations
}
```


# Jobs

Each job below lists what it is and the steps the player goes through during a shift.

### Butcher

Process chickens at the slaughterhouse — catch, slaughter, shackle, then package the cuts for delivery.

{% stepper %}
{% step %}

## Catch the chickens

Approach the chicken pen, catch the chickens, then load them into the cage truck.
{% endstep %}

{% step %}

## Slaughter the chickens

Bring the cage to the slaughterhouse and process each chicken at the station.
{% endstep %}

{% step %}

## Shackle the chickens

Hang the slaughtered chickens on the overhead shackle line for plucking.
{% endstep %}

{% step %}

## Package & deliver

Vacuum-seal the cuts and stack the boxes in the delivery van.
{% endstep %}
{% endstepper %}

### Car Wash

Drive the wash van to stranded customers and detail their vehicles on the spot.

{% stepper %}
{% step %}

## Take the wash van

Spawn the wash van from the depot and head out.
{% endstep %}

{% step %}

## Drive to find a customer

Drive around until dispatch finds a customer.
{% endstep %}

{% step %}

## Spray wash

Spray the vehicle from every side.
{% endstep %}

{% step %}

## Sponge scrub

Scrub the vehicle at the marked spots.
{% endstep %}

{% step %}

## Rinse off

Rinse the vehicle from every side.
{% endstep %}

{% step %}

## Polish & wipe

Polish the vehicle at the marked spots.
{% endstep %}

{% step %}

## Hand the keys back

Return the keys and collect the payment.
{% endstep %}
{% endstepper %}

### Cleaner

Keep public areas tidy — mop stains, wipe windows, sweep trash and pressure-wash dirt zones.

{% stepper %}
{% step %}

## Mop the stains

Use the mop on each stain marker to clean it up.
{% endstep %}

{% step %}

## Wipe the windows

Wipe down every window marker with the cloth.
{% endstep %}

{% step %}

## Sweep the floors

Sweep up the trash piles with the broom.
{% endstep %}

{% step %}

## Pressure-wash the dirt

Use the pressure washer to blast the marked dirt area.
{% endstep %}
{% endstepper %}

### Courier

Drive a delivery van around the city handing parcels to each customer.

{% stepper %}
{% step %}

## Get vehicle from depot

Spawn the delivery van at the loading bay.
{% endstep %}

{% step %}

## Deliver each package

Drive to each drop and hand the parcels to the customer.
{% endstep %}
{% endstepper %}

### Dockworker

Move shipping containers at the port using container handlers.

{% stepper %}
{% step %}

## Take a handler

Spawn a container handler from the depot.
{% endstep %}

{% step %}

## Load containers onto trailers

Pick up each container and place it on a trailer.
{% endstep %}
{% endstepper %}

### Electrician

Diagnose and repair power faults around the city — minigame-driven (terminal, cable, fuse, probe, scope).

{% stepper %}
{% step %}

## Fix the faults

Diagnose and repair every marked electrical fault around the city.
{% endstep %}

{% step %}

## Return to the foreman

All faults repaired — head back to the dispatcher to close out the shift.
{% endstep %}
{% endstepper %}

### Farmer

Plow the field, plant pumpkin seeds, tend the seedlings, then harvest and dump the load.

{% stepper %}
{% step %}

## Plow the field

Drive your tractor with the rake trailer over every marked spot to break the soil.
{% endstep %}

{% step %}

## Plant seeds at marked spots

Step out of the tractor, walk up to each marker and plant by hand.
{% endstep %}

{% step %}

## Water and fertilize the seedlings

Apply water or manure to each seedling that needs it.
{% endstep %}

{% step %}

## Wait for the pumpkins to grow

The seedlings need a little time to mature into pumpkins.
{% endstep %}

{% step %}

## Harvest and load pumpkins

Pull each pumpkin off and drop it into the grain trailer.
{% endstep %}

{% step %}

## Dump the trailer at the dump site

Drive the loaded grain trailer to the dump site and empty it.
{% endstep %}

{% step %}

## Return to the foreman

Head back to the farm foreman to close out the shift.
{% endstep %}
{% endstepper %}

### Firefighter

Drive the fire truck to the scene, run a two-person hose to put out fires, and rescue a stranded cat if one is on site.

{% stepper %}
{% step %}

## Get a fire truck

Spawn a fire truck from the station.
{% endstep %}

{% step %}

## Take the hose from the truck

Grab a hose from the side of the fire truck.
{% endstep %}

{% step %}

## Extinguish the fires

Use the hose to put out every fire at the scene.
{% endstep %}

{% step %}

## Rescue the stranded cat

Pick up the cat and bring it back to safety.
{% endstep %}

{% step %}

## Return to the dispatcher

Head back to the dispatcher to close the shift.
{% endstep %}
{% endstepper %}

### Forklift

Pick the right crates with a forklift and run them down the conveyor belt.

{% stepper %}
{% step %}

## Get a forklift

Spawn a forklift at the loading bay.
{% endstep %}

{% step %}

## Load the belt

Pick up crates with the forklift and place them on the conveyor belt.
{% endstep %}

{% step %}

## Run the conveyor

Start the conveyor to process each set of crates.
{% endstep %}
{% endstepper %}

### Gardener

Mow grass, trim plants and grow flowers across the city's gardens.

{% stepper %}
{% step %}

## Mow grass

Drive the lawn mower over every grass patch on the property.
{% endstep %}

{% step %}

## Cut plants

Trim each marked plant by hand with the shears.
{% endstep %}

{% step %}

## Plant flowers

Plant, water and fertilize the flowers at each marker.
{% endstep %}
{% endstepper %}

### Locksmith

Drive to stranded customers, read their lock pattern, cut a key on the van, and deliver it.

{% stepper %}
{% step %}

## Take the work van

Spawn the locksmith van from the dispatcher.
{% endstep %}

{% step %}

## Service every customer

Drive to each stranded customer, read their lock pattern, cut the key at your van and deliver it.
{% endstep %}

{% step %}

## Return to the dispatcher

All customers served — head back to close out the shift.
{% endstep %}
{% endstepper %}

### Lumberjack

Fell trees with a chainsaw, buck them into logs, load the hauler and dump at the sawmill.

{% stepper %}
{% step %}

## Take a truck

Spawn a hauler truck from the foreman's depot.
{% endstep %}

{% step %}

## Fell a tree

Use your chainsaw to drop a tree and buck it into logs.
{% endstep %}

{% step %}

## Load the trunk

Stack the bucked logs into your truck's trunk.
{% endstep %}

{% step %}

## Dump at the sawmill

Drive the loaded truck to the sawmill and dump the logs.
{% endstep %}
{% endstepper %}

### Miner

Swing a pickaxe at the quarry, load the ore into a hauler, and unload at the crusher.

{% stepper %}
{% step %}

## Take a truck

Spawn a hauler truck from the foreman's depot.
{% endstep %}

{% step %}

## Mine ore from the rocks

Swing your pickaxe to break ore off the rocks.
{% endstep %}

{% step %}

## Load the trunk

Stack the mined ore into your truck's trunk.
{% endstep %}

{% step %}

## Drop ore at the crusher

Drive the loaded truck to the crusher and unload each ore.
{% endstep %}

{% step %}

## Return to the foreman

All ore processed — head back to finish the shift.
{% endstep %}
{% endstepper %}

### Movers

Pack a client's belongings, load the truck, drive to the new home and unpack.

{% stepper %}
{% step %}

## Pack the items

Wrap and box up each item at the client's place.
{% endstep %}

{% step %}

## Load the box into the truck

Carry the packed box to the truck and load it in.
{% endstep %}

{% step %}

## Drive to the new home

Drive the moving truck to the destination address.
{% endstep %}

{% step %}

## Unpack the items

Place each item at its spot in the new home.
{% endstep %}

{% step %}

## Return to the dispatcher

Drive the truck back and finish the shift.
{% endstep %}
{% endstepper %}

### Newsroom

Write the morning edition at the laptop, print copies on the press, then deliver papers around town.

{% stepper %}
{% step %}

## Write pages at the laptop

Use the editor's laptop on the press floor to write each page.
{% endstep %}

{% step %}

## Print copies on the press

Run the printers on the press floor to print each copy.
{% endstep %}

{% step %}

## Deliver newspapers around town

Drive around town and throw papers at each delivery marker.
{% endstep %}
{% endstepper %}

### Pizzeria

Roll dough, sauce, top, bake, and deliver pizzas before they go cold.

{% stepper %}
{% step %}

## Prepare an order

Roll the dough, spread sauce, add toppings and bake the pizza.
{% endstep %}

{% step %}

## Deliver to customers

Drive to each delivery marker and hand the pizza over before it goes cold.
{% endstep %}

{% step %}

## Return to the dispatcher

All deliveries complete — head back to close out the shift.
{% endstep %}
{% endstepper %}

### Salvage Diver

Take a submersible down to the sea floor, attach the rope to sunken crates and surface them.

{% stepper %}
{% step %}

## Get a submersible

Spawn a submersible from the dock to dive.
{% endstep %}

{% step %}

## Recover the crates

Attach the rope to each sunken crate and surface it.
{% endstep %}

{% step %}

## Return to the dispatcher

Head back to the dispatcher to close the shift.
{% endstep %}
{% endstepper %}

### Taxi

Pick up NPC fares, drive them safely to their destination, and keep them happy along the way.

{% stepper %}
{% step %}

## Pick up customer

Drive to the pickup marker and let the customer board.
{% endstep %}

{% step %}

## Drop off customer

Drive carefully to the destination and drop them off.
{% endstep %}
{% endstepper %}

### Tow Truck

Hook broken-down vehicles, winch them onto the flatbed and deliver them to the impound.

{% stepper %}
{% step %}

## Take the work truck

Spawn the flatbed truck from the dispatcher.
{% endstep %}

{% step %}

## Drive to the broken vehicle

Find the marked broken vehicle and hook it up to the flatbed.
{% endstep %}

{% step %}

## Winch the vehicle onto the flatbed

Operate the winch to pull the hooked vehicle onto the bed.
{% endstep %}

{% step %}

## Deliver to the impound

Tow the secured vehicle to the impound drop-off.
{% endstep %}
{% endstepper %}

### Trucker

Spawn the rig, hook up a trailer, haul it to the dropoff and return to the depot.

{% stepper %}
{% step %}

## Take the work truck

Spawn the truck from the dispatcher.
{% endstep %}

{% step %}

## Hook up your trailer

Drive the truck to the trailer marker and back into it.
{% endstep %}

{% step %}

## Deliver to dropoff

Haul the trailer to the dropoff marker.
{% endstep %}

{% step %}

## Return to depot to finish

Drive the truck back to the depot to close the shift.
{% endstep %}
{% endstepper %}


# Exports & Hooks

Jobs V exposes server-side events and exports so you can hook your own systems into the job loop — reward multipliers, custom logging, achievement trackers, whitelisting, and so on.

{% hint style="info" %}
All events and exports below are **server-side only**. The event prefix is the resource folder name — if you renamed the resource, replace `rm_jobsv` with your folder name.
{% endhint %}

### Events

Register these with a standard `AddEventHandler` in any server file of your own resource.

#### `rm_jobsv:jobStarted`

Fires when a player clocks in. In a crew, it fires once **per member**.

| Parameter | Type     | Description                         |
| --------- | -------- | ----------------------------------- |
| `source`  | `number` | Server ID of the player             |
| `jobId`   | `string` | Job identifier, e.g. `"lumberjack"` |

```lua
AddEventHandler("rm_jobsv:jobStarted", function(source, jobId)
    print(("%s started the %s job"):format(GetPlayerName(source), jobId))
end)
```

#### `rm_jobsv:jobStopped`

Fires when a player clocks out — whether the shift was completed, cancelled, or ended by disconnect. Also fires once per crew member.

| Parameter | Type     | Description             |
| --------- | -------- | ----------------------- |
| `source`  | `number` | Server ID of the player |
| `jobId`   | `string` | Job identifier          |

```lua
AddEventHandler("rm_jobsv:jobStopped", function(source, jobId)
    activeWorkers[source] = nil
end)
```

#### `rm_jobsv:shiftCompleted`

Fires when a shift is paid out, **after** the money and EXP have been given. Fires once per crew member with that member's own share.

| Parameter | Type      | Description                                                           |
| --------- | --------- | --------------------------------------------------------------------- |
| `source`  | `number`  | Server ID of the player                                               |
| `jobId`   | `string`  | Job identifier                                                        |
| `payout`  | `number`  | Money paid to this player, including level, prestige and time bonuses |
| `exp`     | `number`  | EXP awarded — `0` if no work was done                                 |
| `worked`  | `boolean` | `false` if the player clocked out without completing any task         |

```lua
AddEventHandler("rm_jobsv:shiftCompleted", function(source, jobId, payout, exp, worked)
    if not worked then return end
    exports["my_resource"]:addReputation(source, jobId, 1)
end)
```

{% hint style="warning" %}
A player can end a shift without doing anything. Always check `worked` before granting extra rewards, otherwise players can farm your reward by starting and stopping the job repeatedly.
{% endhint %}

#### `rm_jobsv:taskProgressed`

Fires every time the player advances their daily task.

| Parameter  | Type     | Description                            |
| ---------- | -------- | -------------------------------------- |
| `source`   | `number` | Server ID of the player                |
| `taskId`   | `string` | Daily task identifier                  |
| `progress` | `number` | Current progress after this increment  |
| `target`   | `number` | Progress required to complete the task |

```lua
AddEventHandler("rm_jobsv:taskProgressed", function(source, taskId, progress, target)
    if progress >= target then
        print(("%s finished daily task %s"):format(GetPlayerName(source), taskId))
    end
end)
```

### Exports

#### `getPlayerExp`

Returns the player's **total account EXP** across all jobs.

```lua
local exp = exports["rm_jobsv"]:getPlayerExp(source)
```

**Returns:** `number` — `0` if the player has no progress yet.

#### `getPlayerLevel`

Returns the player's **account level**, calculated from total EXP and capped at `Config.general.level.maxLevel`.

```lua
local level = exports["rm_jobsv"]:getPlayerLevel(source)

if level < 10 then
    -- deny access to a level-gated feature
end
```

**Returns:** `number` — `1` if the player has no progress yet.

#### `getPlayerJobExp`

Returns the player's progress **for a single job**.

```lua
local data = exports["rm_jobsv"]:getPlayerJobExp(source, "miner")
print(data.exp, data.level)
```

| Field   | Type     | Description                                                 |
| ------- | -------- | ----------------------------------------------------------- |
| `exp`   | `number` | EXP earned in this job                                      |
| `level` | `number` | Per-job level, capped at `Config.general.jobLevel.maxLevel` |

**Returns:** `table` — `{ exp = 0, level = 1 }` if the player has never worked that job.

### Example: job-level whitelist

Combining an export with an event to gate a door behind mining experience:

```lua
RegisterNetEvent("myscript:openMineDoor", function()
    local data = exports["rm_jobsv"]:getPlayerJobExp(source, "miner")

    if data.level < 5 then
        TriggerClientEvent("myscript:notify", source, "You need Miner level 5.")
        return
    end

    TriggerClientEvent("myscript:doorOpened", source)
end)
```


# Custom Plates with Flipper

{% hint style="warning" %}
**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.
  {% endhint %}


# Installation

{% stepper %}
{% step %}

### Download and Prepare Files

#### Download the Script from FiveM Portal

* Visit the [FiveM Portal](https://portal.cfx.re/assets/granted-assets) site.
* Log in with your account credentials.
* Locate the **Custom Plates with Flipper** script in your Granted Assets section.
* Download the script files from Portal.

#### Install and Configure ox\_lib

* Download the latest version of **ox\_lib** from the [official GitHub page](https://github.com/CommunityOx/ox_lib/releases).
* Extract the `ox_lib` files to your `resources` folder.
* In your `server.cfg`, ensure that `ox_lib` starts before the **Custom Plates with Flipper** script by adding the appropriate start lines (see next step for example).
  {% endstep %}

{% step %}

### Add Script to Server Configuration

#### Edit `server.cfg`

* Open the `server.cfg` file located in the root directory of your FiveM server.
* Add the following lines to ensure the required resources start with your server:

{% code title="server.cfg" %}

```
ensure ox_lib
ensure rm_customplate
ensure rm_customplate_assets
```

{% endcode %}
{% endstep %}
{% endstepper %}


# Configuration

```lua
lib.locale('en')

cfg = {}

-- Enables developer commands (/fake_plate, /plate_remover, /plate_flipper).
cfg.debug = false

-- ============================================================
-- BRIDGE CONFIGURATION
-- ============================================================

-- 'auto' detects QBCore or ESX automatically.
---@type 'auto' | 'qb' | 'esx'
cfg.framework = 'auto'

---@type 'ox_lib' | 'qb' | 'esx' | 'okokNotify' | 'ps-ui'
cfg.notification = 'ox_lib'

---@type 'ox_lib' | 'esx' | 'qb' | 'okokTextUI' | 'jg-textui'
cfg.textUI = 'ox_lib'

-- false = no progress bar, actions are instant.
---@type 'ox_lib' | 'qb' | 'esx' | false
cfg.progressbar = 'ox_lib'

cfg.script = {
    -- QB/QBX -> player_vehicles | ESX -> owned_vehicles, The name of the database table where vehicle data is stored
    -- Vehicles table. Must have a 'plate' column.
    -- A 'custom_plate' LONGTEXT column is created automatically if missing.
    dbTableName = 'player_vehicles',

    -- Only the vehicle owner can apply/remove/flip plates (checked via bridge).
    requireOwnership = false,

    -- Player items that open the paint editor. Leave empty ({}) for presser-only setup.
    -- shouldRemoveItem: consume the item on install.
    -- editorSize: paint canvas resolution (matches the plate aspect ratio).
    items = {
        { item = 'rm_plate_rectangle_s', group = 'rectangle_s', shouldRemoveItem = true, editorSize = { width = 400, height = 100  } },
        { item = 'rm_plate_rectangle_m', group = 'rectangle_m', shouldRemoveItem = true, editorSize = { width = 500, height = 150  } },
        { item = 'rm_plate_rectangle_l', group = 'rectangle_l', shouldRemoveItem = true, editorSize = { width = 600, height = 200  } },
        { item = 'rm_plate_square_s',    group = 'square_s',    shouldRemoveItem = true, editorSize = { width = 400, height = 200  } },
        { item = 'rm_plate_square_m',    group = 'square_m',    shouldRemoveItem = true, editorSize = { width = 500, height = 300  } },
        { item = 'rm_plate_square_l',    group = 'square_l',    shouldRemoveItem = true, editorSize = { width = 600, height = 400  } },
    },

    -- Toggleable plate, switched with G in the driver seat. Set to nil to disable.
    -- allowCustomDesign: false = black plate only, no editor option.
    flipperItem = {
        item              = 'rm_plate_flipper',
        shouldRemoveItem  = false,
        allowCustomDesign = true,
    },

    -- Strips all custom plates and flippers from a vehicle. Set to nil to disable.
    removerItem = {
        item             = 'rm_plate_remover',
        shouldRemoveItem = false,
    },

    -- Progress bar durations (ms).
    progressDuration = {
        install = 3000,
        remove  = 2000,
    },

    -- Animation during progress bars. Set to false to skip the anim.
    progressAnim = {
        dict = 'anim@amb@clubhouse@tutorial@bkr_tut_ig3@',
        name = 'machinic_loop_mechandplayer',
    },

    -- Plates spawn within renderDistance and despawn past removeRenderDistance.
    -- The gap is hysteresis to stop flickering at the boundary.
    renderDistance       = 50.0,
    removeRenderDistance = 65.0,

    -- Mechanic workstations. Each is a world prop with a proximity prompt.
    -- color: SetObjectTextureVariant index.
    --   0 = green    1 = red       2 = dark yellow  3 = green
    --   4 = cyan     5 = blue      6 = navy         7 = dark blue
    --   8 = purple   9 = pink     10 = pink         11 = red
    platePressers = {
        {
            coords      = vec3(-227.1, -1330.19, 29.89),
            rotation    = vector3(0.0, 0.0, 90.0),
            allowedJobs = { 'mechanic' },
            color       = 2,
        },
        {
            coords      = vec3(-1082.41, -3283.27, 12.94),
            rotation    = vector3(0.0, 0.0, 0.0),
            allowedJobs = { 'mechanic' },
            color       = 1,
        },
    },

}

```


# Language

```json
{
    "plate_already_exists": "This vehicle already has a custom plate. Remove it first.",
    "no_vehicle_nearby": "No nearby vehicle found.",
    "no_player_nearby": "No nearby player found.",
    "cannot_use_in_vehicle": "You must exit the vehicle first.",
    "not_vehicle_owner": "You do not own this vehicle.",
    "no_item": "You don't have the required item.",
    "progress_install": "Installing plate...",
    "progress_remove": "Removing plate...",
    "second_plate_header": "Custom Plate",
    "second_plate_content": "Would you like to add a **front plate** with the same design?",
    "presser_unauthorized": "You are not authorized to use this presser.",
    "presser_textui": "[E] Plate Presser",
    "presser_menu_title": "Plate Presser",
    "presser_select_size": "Select Plate Size",
    "presser_size_selected": "Selected: %s",
    "presser_size_not_selected": "Not selected",
    "presser_size_shape": "Size & Shape",
    "plate_rectangle_l": "Rectangle Large",
    "plate_rectangle_m": "Rectangle Medium",
    "plate_rectangle_s": "Rectangle Small",
    "plate_square_l": "Square Large",
    "plate_square_m": "Square Medium",
    "plate_square_s": "Square Small",
    "presser_give_editor": "Give Paint Editor",
    "presser_give_editor_desc": "Open editor for closest player",
    "presser_press_plate": "Press Plate",
    "presser_design_ready": "Design ready — press to attach",
    "presser_waiting_design": "Waiting for design...",
    "presser_design_received": "Design received! Open presser menu to press the plate.",
    "flipper_type_header": "Plate Flipper",
    "flipper_type_label": "Flipper Type",
    "flipper_type_custom": "Custom Design",
    "flipper_type_black": "Black Plate",
    "flipper_select_group": "Select Plate Size",
    "flipper_group_label": "Plate Group",
    "flipper_already_installed": "This vehicle already has a flipper installed. Remove it first.",
    "flipper_cooldown": "Please wait before toggling the flipper again.",
    "flipper_use": "[G] Use Flipper",
    "flipper_revert": "[G] Revert Flipper",
    "enable_cursor": "Enable Cursor",
    "translate_mode": "Translate Mode",
    "rotate_mode": "Rotate Mode",
    "toggle_space": "Relative/World",
    "done_editing": "Done Editing",
    "close_gizmo_description": "Close gizmo",
    "ui": {
        "save": "Save",
        "close": "Close",
        "font": "Font",
        "selection": "Selection",
        "brush": "Brush",
        "line": "Line",
        "line_thickness": "Line Thickness",
        "line_type": "Line Type",
        "polygon": "Polygon",
        "curve": "Curve",
        "lines": "Lines",
        "shapes": "Shapes",
        "rect": "Rectangle",
        "square": "Square",
        "circle": "Circle",
        "text": "Text",
        "add_picture_from_url": "Add_picture_from_url",
        "undo": "Undo",
        "redo": "Redo",
        "clear": "Clear",
        "color": "Color",
        "inner_color": "Inner Color",
        "arrows": "Arrows",
        "number_of_points": "Number of Points",
        "bold": "Bold",
        "italic": "Italic",
        "opacity": "Opacity",
        "grid": "Grid",
        "unlocked": "Unlocked",
        "locked": "Locked",
        "hide": "Hide",
        "show": "Show",
        "background_color": "Background Color",
        "no_layer_yet": "No layer yet",
        "toggle_layers_panel": "Toggle layers panel",
        "url": "URL"
    }
}

```


# Exports

All exports are accessible via `exports.rm_customplate:<name>(...)`.

***

### Client Exports

#### `isPlateHidden(vehicle)`

Returns whether the vehicle's real plate is currently hidden by an active flipper.

**Parameters**

| Name      | Type     | Description           |
| --------- | -------- | --------------------- |
| `vehicle` | `number` | Vehicle entity handle |

**Returns**

| Type      | Description                                                          |
| --------- | -------------------------------------------------------------------- |
| `boolean` | `true` if the flipper is installed and toggled on, `false` otherwise |

**Example**

```lua
if exports.rm_customplate:isPlateHidden(cache.vehicle) then
    print('plate is hidden by flipper')
end
```

***

#### `hasCustomPlate(vehicle)`

Returns whether the vehicle has any custom plate data attached (normal custom plate or flipper).

**Parameters**

| Name      | Type     | Description           |
| --------- | -------- | --------------------- |
| `vehicle` | `number` | Vehicle entity handle |

**Returns**

| Type      | Description                                                                              |
| --------- | ---------------------------------------------------------------------------------------- |
| `boolean` | `true` if the vehicle has at least one rendered plate or flipper data, `false` otherwise |

**Example**

```lua
local veh = lib.getClosestVehicle(GetEntityCoords(cache.ped), 5.0, false)
if veh and exports.rm_customplate:hasCustomPlate(veh) then
    print('this vehicle has a custom plate')
end
```

***

#### `getPlateData(vehicle)`

Returns the plate data attached to the vehicle, or `nil` if none. If a flipper is installed it is returned first; otherwise the normal plate data is read from the entity's state bag.

**Parameters**

| Name      | Type     | Description           |
| --------- | -------- | --------------------- |
| `vehicle` | `number` | Vehicle entity handle |

**Returns**

| Type           | Description                                                                                               |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| `table \| nil` | Flipper data if installed, otherwise normal plate array, or `nil` if the vehicle has no custom plate data |

**Return shape**

Normal custom plate (array of plate entries):

```lua
{
    [1] = { base64 = string, offset = { x, y, z }, rotation = { x, y, z }, plateModel = string },
    [2] = { ... },  -- optional front plate
}
```

Black flipper:

```lua
{
    flip   = true,
    black  = true,
    base64 = string,
    active = boolean,
}
```

Custom flipper:

```lua
{
    flip   = true,
    active = boolean,
    plates = {
        [1] = { base64 = string, offset = { x, y, z }, rotation = { x, y, z }, plateModel = string },
        [2] = { ... },  -- optional front plate
    },
}
```

**Example**

```lua
local data = exports.rm_customplate:getPlateData(cache.vehicle)
if data then
    if data.flip then
        print(('flipper active=%s'):format(tostring(data.active)))
    else
        print(('normal plate, %d entry(ies)'):format(#data))
    end
end
```

***

### Server Exports

#### `isPlateHidden(plate)`

Returns whether the vehicle's plate is currently hidden by an active flipper.

**Parameters**

| Name    | Type     | Description                           |
| ------- | -------- | ------------------------------------- |
| `plate` | `string` | Vehicle plate text (e.g. `"ABC1234"`) |

**Returns**

| Type      | Description                                                        |
| --------- | ------------------------------------------------------------------ |
| `boolean` | `true` if a flipper is installed and toggled on, `false` otherwise |

**Example**

```lua
if exports.rm_customplate:isPlateHidden('ABC1234') then
    print('plate hidden')
end
```

***

#### `hasCustomPlate(plate)`

Returns whether the given plate has any custom plate data persisted.

**Parameters**

| Name    | Type     | Description        |
| ------- | -------- | ------------------ |
| `plate` | `string` | Vehicle plate text |

**Returns**

| Type      | Description                                                                              |
| --------- | ---------------------------------------------------------------------------------------- |
| `boolean` | `true` if the cache has data for this plate (normal plate or flipper), `false` otherwise |

**Example**

```lua
if exports.rm_customplate:hasCustomPlate('ABC1234') then
    print('this plate has custom data')
end
```

***

#### `getPlateData(plate)`

Returns the full server-side plate data record, or `nil` if no data exists for the plate.

**Parameters**

| Name    | Type     | Description        |
| ------- | -------- | ------------------ |
| `plate` | `string` | Vehicle plate text |

**Returns**

| Type           | Description                                                    |
| -------------- | -------------------------------------------------------------- |
| `table \| nil` | Plate data table, or `nil` if nothing is stored for this plate |

**Return shape**

Normal custom plate (array of plate entries):

```lua
{
    [1] = { base64 = string, offset = { x, y, z }, rotation = { x, y, z }, plateModel = string },
    [2] = { ... },  -- optional front plate
}
```

Black flipper:

```lua
{
    flip   = true,
    black  = true,
    base64 = string,
    active = boolean,
}
```

Custom flipper:

```lua
{
    flip   = true,
    active = boolean,
    plates = {
        [1] = { base64 = string, offset = { x, y, z }, rotation = { x, y, z }, plateModel = string },
        [2] = { ... },  -- optional front plate
    },
}
```

**Example**

```lua
local data = exports.rm_customplate:getPlateData('ABC1234')
if data then
    if data.flip then
        print(('flipper active=%s'):format(tostring(data.active)))
    else
        print(('normal plate, %d entry(ies)'):format(#data))
    end
end
```


# Commands

### `/rm_flipper_toggle`

Toggles the plate flipper on the vehicle the player is currently driving. Default keybind: **G**.

The flipper must already be installed on the vehicle and the player must be in the driver seat. A 2-second cooldown prevents spam. Plays the flip animation and sound, then attaches the alternate plate.

**Parameters** — none.

**Permission** — open to all players. Validated server-side (ownership check if enabled in `cfg.script.requireOwnership`).

**Example**

```
/rm_flipper_toggle
```

Or simply press **G** while sitting in the driver seat of a flipper-equipped vehicle.

***

### Developer Commands

These commands are registered only when `cfg.debug = true` in `cfg.lua`. Keep them off in production.&#x20;

#### `/fake_plate`

Opens the paint editor for a `rectangle_l` plate on the nearest vehicle, bypassing the item check on the client side. The server still validates ownership and item possession before applying.

**Parameters** — none.

**Example**

```
/fake_plate
```

***

#### `/plate_remover`

Triggers the plate remover flow on the nearest vehicle.

**Parameters** — none.

**Example**

```
/plate_remover
```

***

#### `/plate_flipper`

Triggers the flipper install flow (size/type selection, gizmo placement, editor) on the nearest vehicle, bypassing the item check on the client side.

**Parameters** — none.

**Example**

```
/plate_flipper
```


# 3D Tuning Tablet

{% hint style="warning" %}
**Important Information**<br>

* **Pre-Installation Note:** This guide assumes you have a basic understanding of FiveM server management. If you are not a developer or are unfamiliar with server configurations, please follow the steps closely. Any errors during installation could lead to script malfunctions or server issues.
* **Support:** If you encounter problems after installation, check the "Common Questions" section at the end of this guide for solutions. For further assistance, you may need to consult with a developer or reach out to the script’s support team.
  {% endhint %}


# Installation

{% stepper %}
{% step %}

### Download and Prepare Files

#### Download the Script from FiveM Portal

* Visit the [FiveM Portal](https://portal.cfx.re/assets/granted-assets) site.
* Log in with your account credentials.
* Locate the **3D Tuning Tablet** script in your Granted Assets section.
* Download the script files from Portal.

#### Install and Configure ox\_lib

* Download the latest version of **ox\_lib** from the [official GitHub page](https://github.com/CommunityOx/ox_lib/releases).
* Extract the `ox_lib` files to your `resources` folder.
* In your `server.cfg`, ensure that `ox_lib` starts before the **3D Tuning Tablet** script by adding the appropriate start lines (see next step for example).
  {% endstep %}

{% step %}

### Add Script to Server Configuration

#### Edit `server.cfg`

* Open the `server.cfg` file located in the root directory of your FiveM server.
* Add the following lines to ensure the required resources start with your server:

{% code title="server.cfg" %}

```
ensure ox_lib
ensure rm_3dtuning_assets
ensure rm_3dtuning
```

{% endcode %}
{% endstep %}
{% endstepper %}


# Configuration

```lua
lib.locale('en')

cfg = {}

---@type 'auto' | 'qb' | 'esx'
cfg.framework = 'auto'

---@type 'ox_lib' | 'qb' | 'esx' | 'okokNotify' | 'ps-ui'
cfg.notification = 'ox_lib'

---@type 'ox_lib' | 'esx' | 'qb' | 'okokTextUI' | 'jg-textui'
cfg.textUI = 'ox_lib'

---@type 'ox_lib' | 'qb' | 'esx'
cfg.progressbar = 'ox_lib'

cfg.givenitroCommand = 'givenitro'
cfg.givenitroAlloweds = {
    -- ['steam:00000000a000a00'] = true,
    -- ['license:0aa00a00a00aa000a000000a00000a00a00aa000'] = true,
    -- ['license2:0aa00a00a00aa000a000000a00000a00a00aa000'] = true,
    -- ['fivem:0000000'] = true,
}

cfg.general = {
    databaseTableName = 'player_vehicles', -- QB/QBX -> player_vehicles | ESX -> owned_vehicles
    fakePlate = false, -- Set true if your server uses a fake-plate system (requires fakeplate column)
    requireTurboMod = false, -- Require a turbo mod on the vehicle for nitrous to work

    items = {
        tablet = {
            itemName = 'rm_car_tablet',
            requireOwnership = false,
            allowedJobs = false, -- Or a table like { 'mechanic', 'bennys' }
            allowedClasses = {
                [0] = true, [1] = true, [2] = true, [3] = true, [4] = true,
                [5] = true, [6] = true, [7] = true, [9] = true, [22] = true,
            },
            blacklistedVehicles = {
                [`sultan2`] = true,
            },
            shouldRemove = true,
            progressBar = true,
        },
        nitro_bottles = {
            requireOwnership = false,
            allowedJobs = false,
            -- Each item uses its own bottle texture variant (the physical bottle color).
            -- The nitrous particle flame color is picked at install time via dialog (independent of the item).
            items = {
                { itemName = 'rm_nitro_default', colorVariant = 0 },
                { itemName = 'rm_nitro_white',   colorVariant = 1 },
                { itemName = 'rm_nitro_green',   colorVariant = 3 },
                { itemName = 'rm_nitro_yellow',  colorVariant = 4 },
                { itemName = 'rm_nitro_red',     colorVariant = 5 },
                { itemName = 'rm_nitro_black',   colorVariant = 6 },
                { itemName = 'rm_nitro_dgreen',  colorVariant = 7 },
                { itemName = 'rm_nitro_brown',   colorVariant = 8 },
                { itemName = 'rm_nitro_gray',    colorVariant = 9 },
                { itemName = 'rm_nitro_purple',  colorVariant = 10 },
                { itemName = 'rm_nitro_orange',  colorVariant = 11 },
                { itemName = 'rm_nitro_pink',    colorVariant = 12 },
            },
            allowedClasses = {
                [0] = true, [1] = true, [2] = true, [3] = true, [4] = true,
                [5] = true, [6] = true, [7] = true, [9] = true, [22] = true,
            },
            blacklistedVehicles = {
                [`sultan2`] = true,
            },
            shouldRemove = true,
            progressBar = true,
        },
    },

    nitrous = {
        defaultKey = 'LSHIFT',
        requireTablet = false,
        boost = 75.0, -- Engine power multiplier when nitrous is active
        antiSpamCooldown = 5000, -- ms between activations
        drain = 1.0, -- Drain per tick (100ms)
        minSpeed = 10.0, -- km/h
        minRpm = 0.2, -- 0.0 - 1.0
        purgeOnRelease = true,
        purgeOnReleaseDuration = 1000, -- ms

        commands = {
            nitrobar = 'nitrobar',
            move_nitrobars = 'move_nitrobar',
            reset_nitrobars = 'reset_nitrobar',
        },

        nitro_tanks = {
            max_count = 3, -- Default max tanks per vehicle
            vehicles = {
                [`sultan2`] = 1, -- Per-model override
            }
        },

        installAnim = {
            dict = 'anim@amb@clubhouse@tutorial@bkr_tut_ig3@',
            name = 'machinic_loop_mechandplayer',
            duration = 4000,
        },

        openDoorsOnPlace = true,

        nitro_object = {
            defaultBone = 0, -- Bone the nitrous object is attached to
        },

        ptfx = {
            bones = { "exhaust", "exhaust_2", "exhaust_3", "exhaust_4", "exhaust_5", "exhaust_6", "exhaust_7", "exhaust_8", "exhaust_9", "exhaust_10", "exhaust_11", "exhaust_12", "exhaust_13", "exhaust_14", "exhaust_15", "exhaust_16" },
            colors = {
                [0]  = { size = 0.5, label = 'exhaust_color_blue' },
                [1]  = { size = 0.5, label = 'exhaust_color_white' },
                [2]  = { size = 0.5, label = 'exhaust_color_purple' },
                [3]  = { size = 0.5, label = 'exhaust_color_green' },
                [4]  = { size = 0.5, label = 'exhaust_color_yellow' },
                [5]  = { size = 0.5, label = 'exhaust_color_red' },
                [6]  = { size = 0.5, label = 'exhaust_color_pink' },
                [7]  = { size = 0.5, label = 'exhaust_color_orange' },
                [8]  = { size = 0.5, label = 'exhaust_color_cyan' },
                [9]  = { size = 0.5, label = 'exhaust_color_darkblue' },
                [10] = { size = 0.5, label = 'exhaust_color_darkgreen' },
                [11] = { size = 0.5, label = 'exhaust_color_brown' },
                [12] = { size = 0.5, label = 'exhaust_color_black' },
            },
        }
    },

    purge = {
        defaultKey = 'G',
        bone = 'bonnet',
        sprayCount = 4,
        sprayOffset = { x = 0.5, y = 0.05, z = 0.0 },
        sprayRotation = { left = vector3(40.0, -20.0, 0.0), right = vector3(40.0, 20.0, 0.0) },
        sprayScale = 0.5,
    },

    glowingDiscs = {
        defaultKey = '',
        onlyBackWheels = true,
        ptfx = {
            offset = vector3(-0.02, 0.0, 0.0),
            rotation = vector3(0.0, 0.0, 90.0),
            scale = 0.5,
        },
        customOffsets = {
            [`sultanrs`] = {
                front = { offset_x = 0.0, offset_scale = 1.0 },
                rear  = { offset_x = 0.0, offset_scale = 1.0 },
            }
        }
    },

    drift_mode = {
        defaultKey = 'LCONTROL',
        maxSpeed = 75.0, -- km/h
    },

    drift_hd = {
        defaultKey = 'LCONTROL', -- Same as drift_mode so both can stack
        defaultPreset = 'default',
        vehicles = {
            -- Per-model preset mapping. Presets defined in client/drift/presets.lua
            [`sultan2`] = 'drift_hard',
            [`miata`]   = 'drift_miata',
        },
    },

    sport_mode = {
        defaultKey = '',
        powerMultiplier = 150.0,
        interruptDuration = 200,
    },

    popcorn = {
        defaultKey = '',
        rpm = 0.65,
        ptfx = {
            { size = 0.25, offset = vector3(0.0, -0.1, 0.0), rotation = vector3(0.0, 0.0, -180.0) },
            { size = 0.95, offset = vector3(0.0, -0.1, 0.0), rotation = vector3(0.0, 0.0, 0.0) },
        },
    },

    drift_smoke = {
        defaultKey = '',
        onlyBackWheels = true,
        ptfx = {
            offset = vector3(0.05, 0.0, 0.0),
            rotation = vector3(0.0, 0.0, 0.0),
        }
    },

    throttle_control = {
        defaultKey = '',
        maxRpm = 0.8,
        maxSpeed = 200,
        allowedClasses = {
            [0] = true, [1] = true, [2] = true, [3] = true,
            [4] = true, [5] = true, [6] = true, [7] = true,
        }
    },

    rollback_handbrake = {
        defaultKey = '',
        rollbackConfig = {
            enabled = true,
            duration = 30,
            strength = 10,
        },
        handbrakeTurnConfig = {
            enabled = true,
            strength = 1.5,
        }
    },

    interior_light = { defaultKey = '' },
    neon_light     = { defaultKey = '' },

    shunt = {
        defaultKey = 'LCONTROL',
        magnitude = 18.0,
        upForce = 500 * 100,
        cooldown = 15000, -- ms
        camShakeIntensity = 0.25,
        camShakeDuration = 500, -- ms
        velocityLerpDuration = 500, -- ms
    },

    custom_exhaust = {
        itemName = 'rm_custom_exhaust',
        requireOwnership = false,
        allowedJobs = false,
        allowedClasses = {
            [0] = true, [1] = true, [2] = true, [3] = true, [4] = true,
            [5] = true, [6] = true, [7] = true, [9] = true, [22] = true,
        },
        blacklistedVehicles = {
            [`sultan2`] = true,
        },
        shouldRemove = true,
        progressBar = true,
        installAnim = {
            dict = 'anim@amb@clubhouse@tutorial@bkr_tut_ig3@',
            name = 'machinic_loop_mechandplayer',
            duration = 4000,
        },
        remover = {
            itemName = 'rm_exhaust_remover',
            shouldRemove = true,
            progressBar = true,
        },
        prop = `imp_prop_impexp_exhaust_05`,
        animDict = 'anim@heists@box_carry@',
        animName = 'idle',
        installTime = 5000,
        maxDistance = 5.0,
        throttleLiftRpm = 0.7,
        ptfxDuration = 300, -- ms
        fireCooldown = 100, -- ms between two backfire events on same vehicle
        backfirePtfx = { size = 0.95, offset = vector3(0.0, -0.1, 0.0), rotation = vector3(0.0, 0.0, 0.0) },
        ptfx = {
            bones = { "exhaust", "exhaust_2", "exhaust_3", "exhaust_4", "exhaust_5", "exhaust_6", "exhaust_7", "exhaust_8", "exhaust_9", "exhaust_10", "exhaust_11", "exhaust_12", "exhaust_13", "exhaust_14", "exhaust_15", "exhaust_16" },
            colors = {
                [0]  = { size = 0.5, label = 'exhaust_color_blue' },
                [1]  = { size = 0.5, label = 'exhaust_color_white' },
                [2]  = { size = 0.5, label = 'exhaust_color_purple' },
                [3]  = { size = 0.5, label = 'exhaust_color_green' },
                [4]  = { size = 0.5, label = 'exhaust_color_yellow' },
                [5]  = { size = 0.5, label = 'exhaust_color_red' },
                [6]  = { size = 0.5, label = 'exhaust_color_pink' },
                [7]  = { size = 0.5, label = 'exhaust_color_orange' },
                [8]  = { size = 0.5, label = 'exhaust_color_cyan' },
                [9]  = { size = 0.5, label = 'exhaust_color_darkblue' },
                [10] = { size = 0.5, label = 'exhaust_color_darkgreen' },
                [11] = { size = 0.5, label = 'exhaust_color_brown' },
                [12] = { size = 0.5, label = 'exhaust_color_black' },
            },
        },
    },

    menu = {
        { label = locale('menu_nitrous'),          name = 'nitrous',          icon = 'fire',      enabled = true },
        { label = locale('menu_purge'),            name = 'purge',            icon = 'smog',      enabled = true },
        { label = locale('menu_popcorn'),          name = 'popcorn',          icon = 'bomb',      enabled = true },
        { label = locale('menu_glow'),             name = 'glow',             icon = 'fire',      enabled = true },
        { label = locale('menu_drift'),            name = 'drift',            icon = 'cog',       enabled = true },
        { label = locale('menu_drift_hd'),         name = 'drift_hd',         icon = 'cog',       enabled = true },
        { label = locale('menu_drift_smoke'),      name = 'smoke',            icon = 'smog',      enabled = true },
        { label = locale('menu_sport_mode'),       name = 'sport_mode',       icon = 'cog',       enabled = true },
        { label = locale('menu_throttle_control'), name = 'throttle_control', icon = 'cog',       enabled = true },
        { label = locale('menu_rollback'),         name = 'rollback',         icon = 'cog',       enabled = true },
        { label = locale('menu_neon'),             name = 'neon',             icon = 'lightbulb', enabled = true },
        { label = locale('menu_interior_lights'),  name = 'interior_lights',  icon = 'lightbulb', enabled = true },
        { label = locale('menu_shunt'),            name = 'shunt',            icon = 'bolt',      enabled = true },
    },
}

```


# Language

```json
{
    "translate_mode": "Translate",
    "rotate_mode": "Rotate",
    "toggle_space": "Relative/World",
    "enable_cursor": "Toggle Cursor",
    "done_editing": "Done",
    "close_gizmo_description": "Cancel",

    "menu_nitrous": "NITROUS",
    "menu_purge": "PURGE",
    "menu_popcorn": "POP-CORN",
    "menu_glow": "GLOWING DISCS",
    "menu_drift": "DRIFT+ MODE",
    "menu_drift_hd": "DRIFT MODE+HD",
    "menu_drift_smoke": "DRIFT SMOKE",
    "menu_sport_mode": "SPORT+ MODE",
    "menu_throttle_control": "THROTTLE CONTROL",
    "menu_rollback": "ROLLBACK & HANDBRAKE",
    "menu_neon": "NEON",
    "menu_interior_lights": "INTERIOR LIGHTS",
    "menu_shunt": "SHUNT BOOST",

    "nitrous_enabled": "Nitrous enabled",
    "nitrous_disabled": "Nitrous disabled",
    "purge_enabled": "Purge activated",
    "purge_disabled": "Purge deactivated",
    "drift_smoke_enabled": "Drift smoke enabled",
    "drift_smoke_disabled": "Drift smoke disabled",
    "popcorn_enabled": "Popcorn enabled",
    "popcorn_disabled": "Popcorn disabled",
    "glow_enabled": "Glow brakes enabled",
    "glow_disabled": "Glow brakes disabled",
    "throttle_enabled": "Throttle control enabled",
    "throttle_disabled": "Throttle control disabled",
    "rollback_enabled": "Rollback & Handbrake enabled",
    "rollback_disabled": "Rollback & Handbrake disabled",
    "sport_mode_enabled": "Sport mode enabled",
    "sport_mode_disabled": "Sport mode disabled",
    "drift_enabled": "Drift mode enabled",
    "drift_disabled": "Drift mode disabled",
    "drift_hd_enabled": "Drift HD mode enabled",
    "drift_hd_disabled": "Drift HD mode disabled",
    "interior_light_toggled": "Interior lights toggled",
    "neon_light_toggled": "Neon lights toggled",

    "keybind_nitrous": "Hold Nitrous",
    "keybind_purge": "Hold Purge",
    "keybind_drift_smoke": "Toggle Drift Smoke",
    "keybind_popcorn": "Toggle Popcorn",
    "keybind_glow": "Toggle Glow Brakes",
    "keybind_throttle": "Toggle Throttle Control",
    "keybind_rollback": "Toggle Rollback & Handbrake",
    "keybind_sport_mode": "Toggle Sport Mode",
    "keybind_drift": "Hold Drift Mode",
    "keybind_drift_hd": "Hold Drift HD Mode",
    "keybind_interior_light": "Toggle Interior Lights",
    "keybind_neon": "Toggle Neon Lights",
    "keybind_shunt": "Forward Shunt",

    "toggle_tablet": "Open Vehicle Tablet",

    "nitrous_cooldown": "Nitrous is on cooldown, please wait",
    "nitro_not_in_vehicle": "You must be in a vehicle",
    "nitro_max_tanks": "This vehicle has reached the maximum number of nitrous tanks",
    "vehicle_not_owned": "You must own this vehicle to do that.",
    "vehicle_class_not_allowed": "This vehicle type is not supported",
    "vehicle_blacklisted": "This vehicle is not compatible",
    "vehicle_occupied": "Someone is in the vehicle, cannot install nitrous",
    "vehicle_occupied_exhaust": "Someone is in the vehicle, cannot modify the exhaust",
    "tablet_already_installed": "This vehicle already has a tablet installed",
    "nitro_requires_turbo": "This vehicle requires a turbo modification to use nitrous",
    "exit_tablet": "Exit Tablet",
    "remove_tablet": "Remove Tablet",
    "tablet_not_installed": "This vehicle does not have a tablet installed.",
    "tablet_not_driving": "You must be the driver to use the tablet.",
    "tablet_vehicle_moving": "You must stop the vehicle to use the tablet.",
    "tablet_removed": "Tablet has been removed from the vehicle",

    "givenitro_no_auth": "You have no authorization for this.",
    "givenitro_not_in_vehicle": "You must be in a vehicle.",
    "givenitro_player_not_online": "Player ID %d is not online.",
    "givenitro_player_not_in_vehicle": "Player ID %d is not in a vehicle.",
    "givenitro_success": "Added %d nitrous tank(s) to vehicle.",

    "job_not_allowed": "Your job does not allow you to use this item.",

    "nitrobar_not_in_vehicle": "You must be in a vehicle.",
    "nitrobar_no_nitro": "This vehicle has no nitrous installed.",
    "nitrobar_hidden": "Nitro bar hidden.",
    "nitrobar_shown": "Nitro bar shown.",
    "nitrobar_confirm_position": "Confirm",
    "nitrobar_move_mode": "Drag the nitro bar to your desired position.",
    "nitrobar_position_reset": "Nitro bar position reset.",
    "progress_tablet_opening": "Opening tablet...",
    "progress_tablet_install": "Installing tablet...",
    "nitro_color_dialog_title": "Select Nitrous Color",
    "progress_nitro_install": "Installing nitrous...",

    "shunt_activated": "Shunt boost activated!",
    "shunt_cooldown": "Shunt boost is on cooldown",

    "exhaust_no_vehicle_nearby": "No vehicle nearby",
    "exhaust_carry_textui": "[Enter] Install Exhaust | [ESC] Cancel",
    "exhaust_sound_checkbox": "Sound Effect",
    "exhaust_color_dialog_title": "Select Exhaust Color",
    "exhaust_color_blue": "Blue",
    "exhaust_color_white": "White",
    "exhaust_color_purple": "Purple",
    "exhaust_color_green": "Green",
    "exhaust_color_yellow": "Yellow",
    "exhaust_color_red": "Red",
    "exhaust_color_pink": "Pink",
    "exhaust_color_orange": "Orange",
    "exhaust_color_cyan": "Cyan",
    "exhaust_color_darkblue": "Dark Blue",
    "exhaust_color_darkgreen": "Dark Green",
    "exhaust_color_brown": "Brown",
    "exhaust_color_black": "Black",
    "exhaust_installed": "Custom exhaust installed!",
    "exhaust_removed": "Custom exhaust removed!",
    "exhaust_not_installed": "This vehicle does not have a custom exhaust installed.",
    "progress_exhaust_install": "Installing custom exhaust...",
    "progress_exhaust_remove": "Removing custom exhaust...",
    "tablet_device_connected": "DEVICE CONNECTED"
}
```


# Exports

All exports are accessible via `exports.rm_3dtuning:<name>(...)`.

***

### Client Exports

#### `getNitroData(plate)`

Returns current client-side nitrous state for the given vehicle plate.

**Parameters**

| Name    | Type     | Description                              |
| ------- | -------- | ---------------------------------------- |
| `plate` | `string` | Vehicle plate (auto-trimmed internally). |

**Returns**

| Type           | Description                                               |
| -------------- | --------------------------------------------------------- |
| `table \| nil` | Nitrous state object, or `nil` if vehicle has no nitrous. |

**Return shape**

| Field         | Type                 | Description                                          |
| ------------- | -------------------- | ---------------------------------------------------- |
| `hasnitro`    | `boolean`            | `true` when at least one tank is installed.          |
| `tanks`       | `integer`            | Total number of installed tanks.                     |
| `tankLevels`  | `table<int, number>` | 1-indexed map, each entry `0–100` (percent drained). |
| `currentTank` | `integer`            | Index of the tank currently being consumed.          |

**Example**

```lua
local data = exports.rm_3dtuning:getNitroData('ABC1234')
if data and data.hasnitro then
    print(('tanks=%d currentTank=%d'):format(data.tanks, data.currentTank))
end
```

***

#### `isNitroActive()`

Returns whether the local player is actively holding the nitrous activation key right now.

**Parameters** — none.

**Returns**

| Type      | Description                             |
| --------- | --------------------------------------- |
| `boolean` | `true` while nitrous is being consumed. |

***

#### `getVehicleFeatures(plate)`

Returns the tablet menu toggle states for the given vehicle.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type    | Description             |
| ------- | ----------------------- |
| `table` | Feature-to-boolean map. |

**Return shape** (all fields are `boolean`)

| Field              | Description                   |
| ------------------ | ----------------------------- |
| `nitrous`          | Nitrous enabled.              |
| `purge`            | Purge enabled.                |
| `popcorn`          | Popcorn backfire enabled.     |
| `glow`             | Glowing brake discs enabled.  |
| `drift`            | Drift+ mode enabled.          |
| `drift_hd`         | Drift HD mode enabled.        |
| `smoke`            | Drift smoke enabled.          |
| `sport_mode`       | Sport mode enabled.           |
| `throttle_control` | Throttle control enabled.     |
| `rollback`         | Rollback & handbrake enabled. |
| `neon`             | Neon lights enabled.          |
| `interior_lights`  | Interior lights enabled.      |
| `shunt`            | Shunt boost enabled.          |

***

#### `getCurrentVehicleFeatures()`

Returns the tablet feature toggles of the vehicle the local player is currently inside.

**Parameters** — none.

**Returns**

| Type    | Description                                                                                                                 |
| ------- | --------------------------------------------------------------------------------------------------------------------------- |
| `table` | Same shape as [`getVehicleFeatures`](https://docs.rainmad.com/resources/3d-tuning-tablet/exports#getvehiclefeatures-plate). |

***

#### `getCurrentExhaust()`

Returns custom exhaust data for the vehicle the local player is currently inside.

**Parameters** — none.

**Returns**

| Type           | Description                                                                      |
| -------------- | -------------------------------------------------------------------------------- |
| `table \| nil` | Exhaust data, or `nil` if not inside a vehicle or vehicle has no custom exhaust. |

**Return shape**

| Field   | Type      | Description                            |
| ------- | --------- | -------------------------------------- |
| `color` | `integer` | Selected flame color index (`0–12`).   |
| `sound` | `boolean` | Whether the backfire sound is enabled. |

***

### Server Exports

#### `getNitroData(plate)`

Returns the server-side cached nitrous record for the given plate.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type           | Description                                         |
| -------------- | --------------------------------------------------- |
| `table \| nil` | Cached record, or `nil` if no nitrous is installed. |

**Return shape**

| Field        | Type      | Description                                                          |
| ------------ | --------- | -------------------------------------------------------------------- |
| `hasnitro`   | `boolean` | Always `true` when a record is returned.                             |
| `nos_level`  | `number`  | Current tank drain level (`0–100`, percent drained of current tank). |
| `nos_bottle` | `integer` | Total installed tanks.                                               |
| `color`      | `integer` | Selected particle flame color index (`0–12`).                        |

***

#### `hasTablet(plate)`

Checks whether the given vehicle has a tablet installed.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type      | Description                       |
| --------- | --------------------------------- |
| `boolean` | `true` if a tablet record exists. |

***

#### `getTabletData(plate)`

Returns the tablet offset/rotation record for the given vehicle.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type           | Description                                         |
| -------------- | --------------------------------------------------- |
| `table \| nil` | Tablet placement record, or `nil` if not installed. |

**Return shape**

| Field      | Type      | Description                           |
| ---------- | --------- | ------------------------------------- |
| `offset`   | `vector3` | Local offset from the vehicle origin. |
| `rotation` | `vector3` | Local rotation (pitch, roll, yaw).    |

***

#### `hasExhaust(plate)`

Checks whether the given vehicle has a custom exhaust installed.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type      | Description                               |
| --------- | ----------------------------------------- |
| `boolean` | `true` if a custom exhaust record exists. |

***

#### `getExhaustData(plate)`

Returns the server-side cached custom exhaust record for the given vehicle.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type           | Description                                |
| -------------- | ------------------------------------------ |
| `table \| nil` | Exhaust record, or `nil` if not installed. |

**Return shape**

| Field   | Type      | Description                            |
| ------- | --------- | -------------------------------------- |
| `color` | `integer` | Selected flame color index (`0–12`).   |
| `sound` | `boolean` | Whether the backfire sound is enabled. |

***

#### `getVehicleFeatures(plate)`

Returns the persisted tablet menu toggle states for the given vehicle.

**Parameters**

| Name    | Type     | Description    |
| ------- | -------- | -------------- |
| `plate` | `string` | Vehicle plate. |

**Returns**

| Type           | Description                                                                                                                                                                                    |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `table \| nil` | Same shape as the client [`getVehicleFeatures`](https://docs.rainmad.com/resources/3d-tuning-tablet/exports#getvehiclefeatures-plate), or `nil` if the vehicle has no saved feature state yet. |


# Commands

All commands can be renamed in `cfg.lua` — the names documented below are the defaults.

***

### Client Commands

#### `/nitrobar`

Toggles the nitrous HUD bar on/off for the vehicle the player is currently driving.

**Config key:** `cfg.general.nitrous.commands.nitrobar` (default: `"nitrobar"`)

**Usage:** `/nitrobar`

**Preconditions**

| Check                                | Error message (locale key) |
| ------------------------------------ | -------------------------- |
| Player must be in a vehicle.         | `nitrobar_not_in_vehicle`  |
| Vehicle must have nitrous installed. | `nitrobar_no_nitro`        |

***

#### `/move_nitrobar`

Enters drag mode so the player can reposition the nitrous HUD bar on their screen.

**Config key:** `cfg.general.nitrous.commands.move_nitrobars` (default: `"move_nitrobar"`)

**Usage:** `/move_nitrobar`

**Preconditions**

| Check                                | Error message (locale key) |
| ------------------------------------ | -------------------------- |
| Player must be in a vehicle.         | `nitrobar_not_in_vehicle`  |
| Vehicle must have nitrous installed. | `nitrobar_no_nitro`        |

***

#### `/reset_nitrobar`

Resets the nitrous HUD bar to its default screen position.

**Config key:** `cfg.general.nitrous.commands.reset_nitrobars` (default: `"reset_nitrobar"`)

**Usage:** `/reset_nitrobar`

***

### Server Commands

#### `/givenitro`

Admin command. Adds nitrous tanks to the target player's current vehicle.

**Config key:** `cfg.givenitroCommand` (default: `"givenitro"`)

**Usage:** `/givenitro [playerid] [count]`

**Parameters**

| Name       | Type     | Required | Description                                         |
| ---------- | -------- | -------- | --------------------------------------------------- |
| `playerid` | `number` | Yes      | Target player's server ID.                          |
| `count`    | `number` | No       | Number of tanks to add. Defaults to `1` if omitted. |

**Authorization**

Allowed if **either** of the following is true:

* The caller has the `command` ACE permission, **or**
* One of the caller's identifiers is listed in `cfg.givenitroAlloweds` (`steam:`, `license:`, `license2:`, `fivem:`).

**Preconditions**

| Check                                   | Error message (locale key)        |
| --------------------------------------- | --------------------------------- |
| Target player must be online.           | `givenitro_player_not_online`     |
| Target player must be inside a vehicle. | `givenitro_player_not_in_vehicle` |

**Behavior**

Adds the requested amount of nitrous tanks to the target vehicle, clamped to the vehicle's per-model maximum (`cfg.general.nitrous.nitro_tanks`). The caller receives a success notification.

**Example**

```
/givenitro 12 2
```

Gives `2` nitrous tanks to the player with server ID `12`.




---

[Next Page](/llms-full.txt/1)

