From 6ab09e287b62e86591d98d15ce0238448268a37a Mon Sep 17 00:00:00 2001 From: w0dm4n Date: Tue, 13 Oct 2026 09:04:28 +0200 Subject: [PATCH] index: read the site's own guides (content/guides) The builder read content/api and content/docs only, so the per-job RP pages (rp-jobs, rp-job-*), build-a-job, the readiness gate, tunables and the other site-written guides never reached the MCP: open77_search "CHOOH2" answered two unrelated sections. Site guides are now chunked like the wiki's; a wiki guide keeps its name on a clash, and content without the folder still builds. Embedded snapshot rebuilt from open77-app 8f0a6d8a: 134 guides, 29 from the site. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- index/guides.json | 2 +- index/manifest.json | 11 ++++++----- src/index/builder.ts | 27 +++++++++++++++++++++++++-- tests/index.test.ts | 35 +++++++++++++++++++++++++++++++++-- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 522cf18..54c9fe0 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,11 @@ the Lua language server in VS Code or Cursor completes `Open77.*` from the same ## Where the knowledge comes from The index is built from the public documentation content of -[open77-app](https://github.com/Open2077/open77-app) (`content/api`, `content/docs`), which is -synced from the platform wiki, itself generated from the client and server source. Nothing here is +[open77-app](https://github.com/Open2077/open77-app): `content/api` and `content/docs`, which are +synced from the platform wiki, itself generated from the client and server source, plus the site's +own guides in `content/guides` (the RP job pages, the runtime and operator pages). Nothing here is hand-written knowledge: cards, permissions, reasons, `since`, events and catalogues are extracted -from code; guides are chunked as published. +from code; guides are chunked as published. A wiki guide keeps its name over a site guide. Index builds are published to `https://cdn.open2077.net/dev-index//` with a hashed manifest. The package ships a snapshot of the latest index and refreshes from the CDN at most once a day, diff --git a/index/guides.json b/index/guides.json index aa33898..fa6ba7b 100644 --- a/index/guides.json +++ b/index/guides.json @@ -1 +1 @@ -[{"slug":"README","file":"README.md","title":"Open77 resource documentation","summary":"Build multiplayer gameplay and interfaces as resources: directories containing a manifest, Lua scripts, declared permissions, dependencies and optional web assets.","sections":[{"anchor":"open77-resource-documentation","heading":"Open77 resource documentation","level":1,"text":"Build multiplayer gameplay and interfaces as resources: directories containing a manifest, Lua scripts, declared permissions, dependencies and optional web assets.\n\nDevelopers familiar with FiveM will recognize the client/server split, events, exports, commands,\nand manifest-driven lifecycle. Open77 APIs remain independent and reflect REDengine constraints.","natives":[],"permissions":[],"events":[]},{"anchor":"session-model","heading":"Session model","level":2,"text":"[Custom loading screens](loading-screens.md) covers branded WebUI covers, native\nloading events and HUD hiding. [Changing seats](vehicle-seat-switching.md)\ncovers animated in-cabin moves, automatic driver takeover and Lua controls.\n\nNew building blocks: [player checks and controls](player-utilities.md) and\n[package audio, local and networked](package-audio.md).\n\nThe server selects the resource set for a session. A connecting client downloads that set, verifies\nits signature and content hashes, and activates it before entering the world. Only Open77's trusted\nbootstrap resources load outside the server-provided generation.\n\nThe server is authoritative. Clients render approved state and submit bounded observations or\nrequests; they do not choose canonical loot, life, vehicle, time, weather, or routing state.","natives":[],"permissions":[],"events":[]},{"anchor":"create-a-resource","heading":"Create a resource","level":2,"text":"A resource lives below the server's configured resource root and contains a `open77.lua` manifest:\n\n```lua\nresource \"hello\"\nversion \"1.0.0\"\nauto_start true\n\nclient_script \"client/main.lua\"\nserver_script \"server/main.lua\"\n```\n\nClient entry point:\n\n```lua\nAddEventHandler(\"onClientResourceStart\", function(name)\n if name ~= GetCurrentResourceName() then return end\n\n local state = Open77.character.state()\n print((\"spawned at %.1f, %.1f\"):format(state.position.x, state.position.y))\nend)\n```\n\nServer entry point:\n\n```lua\nRegisterCommand(\"hello\", function(source, args)\n print((\"player %d said hello\"):format(source))\nend, false)\n```\n\nStart the server, connect a client, and invoke `hello` from the Open77 developer console or chat.","natives":["AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName","Open77.character.state","RegisterCommand","server:RegisterCommand"],"permissions":[],"events":[]},{"anchor":"guides","heading":"Guides","level":2,"text":"New client utilities: [screen picking](screen-picking.md) and the reusable\n[ALT/click context menu](context-menu.md).\n\n| Guide | Subject |\n|---|---|\n| [Hacking and counterplay](hacking.md) | Short Circuit, Self-ICE, active/ally purge, server authority, public definitions and an optional lab. |\n| [Ground Slam / Quake](ground-slam.md) | Native grounded/airborne slam, server-owned impact validation and permission-controlled session grants. |\n| [Ability activation leases](ability-activation-leases.md) | Why abilities no longer wait for the server at the moment a key is pressed, what a far-away player will notice, and what an owner will see in the log. |\n| [Server resources](server-resources.md) | Manifests, runtime separation, signing, download, and reload. |\n| [Hub resources in Warden](community-hub-warden.md) | Reviewed installation, updates, uninstall, rollback, preload restarts and retained backups. |\n| [The Players tab in Warden](warden-players.md) | The live roster (ping, health, life state, match), and the per-row admin actions: warn, kick, ban, grant rights, heal, freeze and move — plus why spectate is not adaptable. |\n| [Mods: the complete guide](mods.md) | The three layers (stack, world, own), the life of a world mod from server to game folder, declaring packages, a Nexus vehicle walkthrough, verification and troubleshooting. |\n| [Mods your server requires](server-mods.md) | The operator's reference for the same feature: trust, hosting the bytes, the master's hash vouching, Warden and review. |\n| [Sky hologram advertisements](sky-advertising.md) | Build PNG/DDS art into Towers-of-Light XBM replacers and preload it from a resource. |\n| [Complete server Lua API](server-api.md) | Every server global, `Open77.*` method, permission, constant, and low-level alias. |\n| [FiveM compatibility aliases](fivem-compatibility.md) | The `Citizen` table, `SetTick`, `promise.new`, and the shared-script helpers a copied FiveM resource calls -- plus the three places Open77 deliberately answers differently. |\n| [Dash / Air Dash](dash.md) | Native ground and air dash as a session grant: define, grant, the key, charges, stamina, cooldown and the optional parkour example. |\n| [Connection control](connection-control.md) | Connect events and deferrals, refusing a player with a message, rejection and disconnect reasons, the built-in whitelist and ban list (Warden, console, `Open77.access`), and worked custom resources. |\n| [World queries](world-queries.md) | Client raycast, aim ray, ground height and the object search around the player (`world.query`), plus an entity's own axes, offsets and slots (`world.transform`). |\n| [Replicated state bags](state-bags.md) | `Open77.state.global` / `.player(id)` / `.entity(kind, id)` and the `Player(id).state` / `Entity(id).state` / `GlobalState` / `LocalPlayer.state` shims: keyed state that replicates itself, change handlers, the caps, and why a client submits instead of writing. |\n| [Players around you (client)](client-players.md) | Client enumeration: who this client can see, the player id <-> body mapping, proximity and closest -- and why this roster is not the server's. |\n| [Travel](travel.md) | Moving the local player and knowing when the body has really arrived: the settle watch, why a plain teleport can silently land somebody kilometres away, noclip and the map pick. |\n| [Vectors and quaternions](vectors.md) | `vector2`/`vector3`/`vector4`/`vec`/`quat` on both runtimes: arithmetic, `#(a - b)`, swizzles, distance and lerp, the \"plain tables still work\" guarantee, and what crosses the network. |\n| [Official resource exports](resource-exports.md) | Every client export exposed by the official Lua packages and how to call it safely. |\n| [Cross-resource server exports](server-exports.md) | Publish and call asynchronous server services with caller identity, isolated data, permissions and safe reload behavior. |\n| [Lua modules and require](lua-modules.md) | Local modules, published dependency libraries, per-resource cache, permissions, reload and differences from exports. |\n| [Network callbacks](callbacks.md) | Ask the other side a question and await the answer: `Open77.net.call` / `callClient`, per-resource namespacing, timeouts, limits, failure reasons, and why a callback is still only a request. |\n| [Game data reference](data-reference.md) | NPC templates, vehicle records, seats, flags, weapons, appearances, VFX, SFX, animations, and sprite catalogues. |\n| [Identity and ACL](server-acl.md) | Authentication, restricted commands, and access control. |\n| [Player identity](identity.md) | Durable identifiers, display names, and rename flow. |\n| [Equipment Lua API](../docs/equipment.md) | Validated wardrobe catalogue, local/server APIs, permissions, errors, and appearance replication. |\n| [Perspective](../docs/perspective.md) | First and third person: the ownership arbiter, the server policy, the player's key and persisted preference. |\n| [Photo mode](photo-mode.md) | Exclusive client-resource control of the native photo mode; stock shortcuts are disabled. |\n| [Screenshots and mugshots](screenshots.md) | In-process captures of the game's own frame (`screen.capture`), the upload to a URL (`screen.upload`), the server-requested picture delivered by the client (`players.requestScreenshot`), and the scripted-camera mugshot (`screen.mugshot`): what is captured, the surface formats, the in-flight rule, and where the picture goes. |\n| [Weapon Lua API](weapons-api.md) | Assign standard weapons by TweakDB template, select slots, holster, clear every slot, snapshot, and read the server's synchronous weapon cache. |\n| [Game data catalogues](data-catalogues.md) | `Open77.data.vehicle/weapon/item/npc/localize` on both runtimes: display names, classes, seats and qualities from a record string -- live TweakDB on the client, a shipped catalogue on the server, and the measurement behind that split. |\n| [Player health and stamina](player-stats.md) | Shared client/server reads, server-only setters, maximums, regeneration, and synchronization semantics. |\n| [Freezing a player](player-freeze.md) | Server-authoritative and client-local holds on the player's body: the engine restriction used, exactly what a freeze does and does not stop, cooperative claims, and every release path. |\n| [Blue holocall eyes](holocall-eyes.md) | Native blue eye glow, server-owned Lua leases, duration, lifecycle and multiplayer presentation. |\n| [Cyberware framework](cyberware.md) | Persistent implants, permissions, transactions, temporary loadouts and native projection. |\n| [Gorilla Arms](gorilla-arms.md) | Build native arm grades, self-service installation, charge effects, clinics and practice arenas. |\n| [Loot](loot.md) | Authoritative ground drops and pickup integration. |\n| [Weather](weather.md) | Session time, weather presets, synchronization, and events. |\n| [Time scale](world-time.md) | Slow motion for a scripted beat: `Open77.world.setTimeScale` on the client as a claim the dilation guard stands down for, and per routing bucket from the server; measuring it against the world clock, every release path, and why it is presentation and not a Sandevistan. |\n| [Vehicles](vehicles.md) | Identity, streaming, authority leases, seats, and Lua APIs. |\n| [Vehicle AI](vehicle-ai.md) | Autonomous driving, routes, entity following, NPC drivers and authoritative task events. |\n| [Native map](native-map.md) | Player waypoints, map lifecycle, marker selection and non-travel point picking. |\n| [Armed vehicle spawn catalogue](armed-vehicles.md) | Exact spawn IDs, mounted-weapon profiles, verified examples, and the complete 2.31 record inventory. |\n| [Vehicle weapon Lua API](vehicle-weapons.md) | Client armament queries: mounted/selected weapons, native ammo, heat, trigger mode and turret aim. |\n| [Passenger drive-by](drive-by.md) | Client/server Lua policy, action phases, permissions, cleanup, synchronized windows and protection of the shooter's own cabin. |\n| [Vehicle paint](vehicle-paint.md) | Server-authoritative RGB paint, cross-resource controls, replication, events, and native limitations. |\n| [NPCs](npcs.md) | Implemented server-owned NPCs, templates, streaming, task queues, authority leases, life, events, and Lua APIs. |\n| [NPC AI, combat and voice](npc-behavior.md) | Server control of one spawned NPC's behavior policy: AI, combat, perception and voice, and what it deliberately does not reach. |\n| [NPC record catalogue](npc-catalogue.md) | The 6,582 `Character.*` records of 2.31, searchable. A discovery index, not a spawn allowlist. |\n| [Contextual interactions](interactions.md) | Custom world/NPC prompts, action keys, projection, ownership, and server-safe integration. |\n| [Synchronized attachments](attachments.md) | Props on player bones or vehicles, local transforms, ownership, streaming and lifecycle cleanup. |\n| [Player interactions](player-interactions.md) | Reserved two-player actions, consent, synchronized presentation and server-owned completion. |\n| [Key mappings and device input](keybindings.md) | The `RegisterKeyMapping` engine primitive: named rebindable actions, press/hold callbacks, the pause KEY BINDINGS tab, and machine-global persistence -- plus reading the mouse, the wheel, a gamepad and the cursor directly. |\n| [Proximity zones](zones.md) | Client-side enter/exit hysteresis and the server-side re-validation every caller must apply. |\n| [PolyZone](polyzone.md) | Polygons, boxes, circles/spheres, entity zones, groups, entry/exit callbacks and a native-debug/WebUI zone editor. |\n| [World-anchored POIs](worldui.md) | The marker-plus-prompt facade: one owned handle, transactional creation and cleanup. |\n| [The gamemode kernel](gamemode-kernel.md) | Shared server services through exports, local state-machine conventions, and why the scaffolder remains useful. |\n| [Deathmatch](deathmatch.md) | The shipped free-for-all and arena mode: instances, loadouts, bots, scoring and the operator surface. |\n| [Cordon](cordon.md) | The 64-player battle royale: the closing block cordon, squads and revive at the body, ground loot, bots as contestants, the tunables and what is measured versus designed. |\n| [The UI kit](ui-kit.md) | Progress bars, text hints, alerts, input forms, context menus, keyboard menus and radials: the promise shape, the cancellation contract, the focus release matrix, the styling tokens a resource may override, the server twins -- and asking whether any menu, Open77's or the game's, is on screen. |\n| [Notifications](notifications.md) | Reusable WebUI toasts, client/server exports, queues, positions, progress, and ownership. |\n| [Elevators](elevators.md) | Implemented server-authoritative native lifts, bucket/chunk streaming, late join, ACL commands, and Lua APIs. |\n| [Networked doors](doors.md) | Dynamic discovery, automatic and elevator landing doors, server authority, access rules, and Lua exports. |\n| [Blips](blips.md) | Vanilla map markers, entity attachment, sprites, and the range gate. |\n| [Vanilla device prompts](device-interactions.md) | Turning a vending machine's, ATM's or terminal's own prompt off so a resource can put its own there: the one scripted choke point every device passes through, why `GetActions` is the wrong gate, naming a device by id or by class, the ownership rule, `open77:deviceUsed`, and the server-declared policy. |\n| [Blips](blips.md) | Vanilla map markers, entity attachment, and sprites. |\n| [Vanilla HUD visibility](hud-visibility.md) | Client API for minimap, compass, clock, health, stamina, weapons/ammo, speed, and custom-HUD replacement. |\n| [Blocking player input](input-blocking.md) | Taking a named input away from the player and giving it back: the curated vocabulary, the five mechanisms behind it, what this engine cannot block and why, per-resource ownership and the release rule. |\n| [Scripted cameras](cameras.md) | Put the view where a resource wants it: world position, look-at, attachment, blended activation, shake and `unproject` -- plus the ownership rule, every path that gives the view back, and exactly what this engine will not give a camera. |\n| [Third person](perspective.md) | The playable third-person view: the player's key, server policy, the states that hand the view back, reading the player's aim and what it is on, and the measured limitations. |\n| [RP animations](rp-animations.md) | Synchronized actions, forced local TPP, client/server Lua APIs, commands, ownership and cancellation. |\n| [RP animation catalogue](rp-animation-catalogue.md) | All 12 profiles and 70 selectable clips, prop/placement requirements and validation status. |\n| [The role-play kit](rp-kit.md) | `open77_rp_basics`: cuff, escort and search as server exports, the leash an escort really is, the authority that stops a griefer, the twelve ways a hold ends -- and why carry refuses on this build. |\n| [World props](props.md) | Server-authoritative props and lights: models, streaming, buckets, ownership, and the client projection. |\n| [Jobs and world interaction](world-interaction.md) | Choose and enable job resources, customize the salvage and street-food examples, and understand which world interactions are supported. |\n| [Build your first Lua job](build-a-job.md) | A repair activity from files and configuration to gameplay: a work spot, an animation, a required part, payment and cancellation. |\n| [Actions and carrying](actions.md) | `open77_actions`: put a prompt on a spot, play an animation for a set time, check the player at the start and the end, pay through hooks, keep one player per spot, gate by job and duty, and carry crates into trucks. |\n| [Items, payments and storage](transactions.md) | Connect inventory and economy code, save related transfers together, resolve payment retries and use persistent world containers. |\n| [Furniture: sitting and leaning](furniture.md) | Enable seating on supported city furniture, check a venue's seats and approaches, and use seating from Lua. |\n| [Visual and audio effects](effects.md) | Client-local world/entity VFX and spatialised SFX, the server-replicated effect registry, explosions and script fires. |\n| [Resource audio](sound.md) | Playing an audio file your resource ships: 2D and world-spatial with HRTF falloff, the client exports and server twins, the caps and lifetime rules, and the honest list of what it is not. |\n| [Native screen transitions](screen-transitions.md) | Client Lua fades to black/color, native completion events, resource ownership, cancellation and recovery. |\n| [Privileged debug runtime](debug-runtime.md) | ACL-targeted client Lua execution, native lab commands, and REDscript bridge probes. |\n| [Autonomous agent testing](agent-testing.md) | The MCP server and skill that let an AI agent drive the real game: stack, connection, tools, scenarios, and safety rules. |\n| [Chat](chat.md) | Messages, slash commands, completion, and resource integration. |\n| [Clipboard](clipboard.md) | Write-only client clipboard API and the `/pos` and `/rot` transform commands. |\n| [Client persistent KVP](client-kvp.md) | Endpoint- and resource-isolated local key/value persistence, search, atomic operations, and quotas. |\n| [Integrated voice chat](voice.md) | Native Opus VOIP, proximity/radio/phone channels, Lua APIs, pause settings, lipsync state, and speaker nameplates. |\n| [Voice lipsync](voice-lipsync.md) | Voice-driven male/female mouths, local/F7 and remote players, client Lua controls, resource ownership and diagnostics. |","natives":[],"permissions":[],"events":["open77:deviceUsed"]},{"anchor":"api-reference-coverage","heading":"API reference coverage","level":2,"text":"Open77 has three deliberately separate Lua surfaces. The wiki covers all three without implying\nthat a server method exists in a client VM or that a package export is a native:\n\n| Surface | Reference | Coverage |\n|---|---|---|\n| Client native runtime | The **CLIENT** API reference cards in `index.html` | 443 functions across 59 namespaces, host registrations and the globals the client's Lua preludes define alike; every signature is reviewed and every card has a detailed description. |\n| Dedicated server runtime | The **SERVER** cards in `index.html` and [Complete server Lua API](server-api.md) | 413 functions across 43 namespaces, read from the Lua prelude the server executes: the 64 authoritative vehicle methods and 12 vehicle-AI methods as individually reviewed cards, plus every other `Open77.*` namespace, the `Open77.Promise` and `Open77.EventVerdict` handle types, and the scheduler, event and JSON globals. The guide adds the narrative, the permission tables and the 119 low-level host globals. |\n| Official client packages | [Official resource exports](resource-exports.md) | 129 of the 151 literal client exports the official resource tree publishes, across 25 of its 30 packages. The automated gate reaches the packages directly below `resources/`; the nested trees are not yet gated. |\n\nGenerated cards are separated by runtime, so identical names such as `Open77.vehicles.get` cannot\nconfuse a client projection with server authority. They state whether a call is shared, needs a\nlive game instance, or uses the network backend, and document permissions, ownership, generation\nlifetime, and failure values.\n\nRun both documentation checks after changing a binding or export:\n\n```powershell\npython wiki/tools/extract-api.py --json\npython wiki/tools/audit-api.py\n```\n\nThe generator fails when a registered client function has no verifiable handler body or detailed\ndescription. It also compares the 64 server vehicle cards with the real embedded Lua bootstrap, so\nadding or removing a method cannot silently leave the searchable reference incomplete. Both\nruntimes are read twice over: the host registrations, and the Lua preludes each runtime executes.\nA global a prelude defines -- `vector3`, `TriggerLocalEvent`, `Open77.net.callAwait` -- is\ntherefore a card like any other, and cannot go missing without the build saying so.","natives":[],"permissions":[],"events":[]},{"anchor":"api-conventions","heading":"API conventions","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"engine-ids-are-opaque","heading":"Engine IDs are opaque","level":3,"text":"REDengine identifiers are 64-bit values and may not be exactly representable as Lua numbers. Store,\ncompare, and return them unchanged. Do not pass them through `tonumber`.","natives":[],"permissions":[],"events":[]},{"anchor":"failures-are-values","heading":"Failures are values","level":3,"text":"Most APIs return `value` on success or `nil, reason` on failure. Callers can distinguish an invalid\nrequest from a temporarily unavailable subsystem without wrapping every call in `pcall`.","natives":[],"permissions":[],"events":[]},{"anchor":"permissions-are-explicit","heading":"Permissions are explicit","level":3,"text":"Guarded APIs require manifest permissions:\n\n```lua\npermissions { \"network.events\", \"world.loot\" }\n```\n\nRequest only the capabilities the resource needs.","natives":[],"permissions":["network.events","world.loot"],"events":[]},{"anchor":"client-and-server-runtimes-are-separate","heading":"Client and server runtimes are separate","level":3,"text":"`server_script` files never reach a player's machine. `client_script` files are distributed in the\nsigned resource set, and `shared_script` files execute in both runtimes. Secrets and authoritative\ndecisions belong exclusively in server code.","natives":[],"permissions":[],"events":[]},{"anchor":"license","heading":"License","level":2,"text":"Open77-owned documentation and code follow the repository [license](../LICENSE). Third-party names,\ngame data, and dependencies remain subject to their respective terms.","natives":[],"permissions":[],"events":[]}]},{"slug":"ability-activation-leases","file":"ability-activation-leases.md","title":"Ability activation leases","summary":"Activation leases let clients start an ability without waiting for a network round trip. The server grants a small, expiring activation budget and validates each use against authoritative state and movement history.","sections":[{"anchor":"ability-activation-leases","heading":"Ability activation leases","level":1,"text":"Activation leases let clients start an ability without waiting for a network round trip. The server grants a small, expiring activation budget and validates each use against authoritative state and movement history.","natives":[],"permissions":[],"events":[]},{"anchor":"supported-abilities","heading":"Supported abilities","level":2,"text":"- Cyberware double jump\n- Dash and Air Dash\n- Ground Slam\n- Reflex overdrive\n- Hacking upload initiation\n\nHacking upload duration remains server-timed. Starting an upload still requires line-of-sight confirmation from attacker and target. The confirmation deadline accounts for server-measured connection latency.","natives":[],"permissions":[],"events":[]},{"anchor":"server-authority","heading":"Server authority","level":2,"text":"The server controls charges, stamina, cooldowns, grants, movement limits, damage and knockback. A lease does not change definition fields such as `CooldownMs`, `MaxCharges`, `ChargeRegenMs`, `StaminaCost`, `MaxAirborneMs` or `MaxFallSpeed`.\n\nEach activation is checked against recorded movement at the claimed activation time. Air-use limits are tied to the airborne period, not an independent timer. Suspended implants, movement restrictions, knockdowns and forced motion prevent activation.","natives":[],"permissions":[],"events":[]},{"anchor":"budget-and-expiry","heading":"Budget and expiry","level":2,"text":"The server recalculates budgets twenty times per second. A budget accounts for available charges, activation debt and cooldowns. A lease expires about three seconds after the last server contact; an expired lease cannot authorize further local activations.\n\nResources do not configure lease timing. Configure the ability's normal costs, charges and cooldowns instead.","natives":[],"permissions":[],"events":[]},{"anchor":"corrections-and-retries","heading":"Corrections and retries","level":2,"text":"An activation may start locally and then be cancelled if the server rejects it. The correction restores authoritative movement and ability state. Long connection stalls can therefore interrupt an activation.\n\nDuplicate activation reports are deduplicated: retrying a report does not spend charges or stamina twice and does not grant an additional activation.\n\nWhen a report is older than retained movement history, validation uses a bounded tolerance and records the case in telemetry. The activation remains subject to the server's other checks.","natives":[],"permissions":[],"events":[]},{"anchor":"integration","heading":"Integration","level":2,"text":"Use the supported ability APIs; do not implement a second client-side grant or cooldown system. Handle rejection and cancellation events even when the local animation has already started.\n\nSee [Cyberware](cyberware.md), [Dash](dash.md), [Ground Slam](ground-slam.md), [Reflex overdrive](reflex-overdrive.md) and [Hacking](hacking.md) for definitions, grants and events.","natives":[],"permissions":[],"events":[]}]},{"slug":"actions","file":"actions.md","title":"Actions and carrying","summary":"Use `open77_actions` when an activity needs a prompt, an animation, a duration and a result: repair a terminal, cook a dish, inspect a wreck or carry a crate. It provides the progress bar, checks whether the player can act, prevents two workers using an exclusive spot and cleans up the animation and controls when work ends.","sections":[{"anchor":"actions-and-carrying","heading":"Actions and carrying","level":1,"text":"Use `open77_actions` when an activity needs a prompt, an animation, a duration and a result:\nrepair a terminal, cook a dish, inspect a wreck or carry a crate. It provides the progress bar,\nchecks whether the player can act, prevents two workers using an exclusive spot and cleans up\nthe animation and controls when work ends.\n\nYour Lua resource supplies the requirements and result. The action library does not define your\nrecipes, inventory or pay rates. Connect those through its hooks—the functions it calls before,\nafter or when cancelling work.\n\nThis page is the reference for Lua authors. For an introduction and existing jobs, see\n[Jobs and world interaction](world-interaction.md). For a complete example with installation,\ntest items and payment, follow [Build your first Lua job](build-a-job.md).","natives":[],"permissions":[],"events":[]},{"anchor":"action-target-and-hook","heading":"Action, target and hook","level":2,"text":"| Term | Meaning | Example |\n|---|---|---|\n| Action | What the player does. Defines the label, duration and animation. | Repair, taking six seconds. |\n| Target | Where an action is offered. Several targets can use the same action. | A work spot beside each of three machines. |\n| Hook | A Lua function your resource exports for an action to call. | Check for a spare part or apply a payment. |\n\nThese are configured work spots. To attach a simple prompt directly to another kind of object\nor NPC, see [contextual interactions](interactions.md). Adding a target does not change a vanilla\ndevice's functionality.\n\nSource: [`open77_actions`](https://github.com/Open2077/open77-resources/tree/next/system/open77_actions).","natives":[],"permissions":[],"events":[]},{"anchor":"your-first-action","heading":"Your first action","level":2,"text":"This example adds a repair prompt, animation and an in-memory repaired flag. It does not consume\nitems, pay a reward or save the machine's state across restarts. The next section adds item and\npayment hooks; the [full tutorial](build-a-job.md) includes their setup.\n\n```lua\n-- my_repairs/open77.lua\nresource \"my_repairs\"\nversion \"1.0.0\"\ndependency \"open77_actions >=0.1.0\"\nserver_script \"server/main.lua\"\n```\n\n```lua\n-- my_repairs/server/main.lua\nlocal actions = exports.open77_actions\nlocal broken = { machine_1 = true } -- your own state\n\nlocal function declare()\n actions:define({\n id = \"repair\",\n label = \"Repair the machine\",\n durationMs = 6000, -- the server's clock decides when it is done\n animation = { profile = \"amb_1056d7_kneel_welder\", at = \"target\" },\n progress = { label = \"Repairing\" }, -- the bar; X cancels\n hooks = { validate = \"repairValidate\", commit = \"repairCommit\" },\n })\n actions:target({\n id = \"machine_1\",\n position = { x = -1410.2, y = 958.4, z = 23.4 }, -- where the player stands: /pos on the spot\n yaw = 90.0, -- the way they face while working\n label = \"Broken vending machine\",\n actions = { \"repair\" }, -- the E choice\n })\nend\nCreateThread(declare)\nAddEventHandler(\"open77_actions:ready\", declare) -- it forgets everything when it restarts\n\n-- A hook is the NAME of one of your exports. Any resource can call an export: check the caller.\nexports(\"repairValidate\", function(ctx)\n if GetInvokingResource() ~= \"open77_actions\" then return false, \"not_owner\" end\n if not broken[ctx.target.id] then return false, \"already_fixed\" end -- your own token\n return true\nend)\n\nexports(\"repairCommit\", function(ctx)\n if GetInvokingResource() ~= \"open77_actions\" then return nil, \"not_owner\" end\n if not actions:verify(ctx.opId, \"commit\") then return nil, \"not_owner\" end -- really ours, now\n broken[ctx.target.id] = nil\n return { fixed = ctx.target.id }\nend)\n```\n\nNo client script and no permission are needed: `open77_actions` draws the prompt, the bar and the\ncontrol locks itself. It is opt-in: the operator lists it in `resources.load` and starts it with\n`ensure open77_actions` before your resource. `at = \"target\"` places the body on the target (up to\n5 m away) facing `yaw`, through `Open77.animations.playAt`; workspot animations switch the player\nto third person while they play. Job postures such as `amb_1056d7_kneel_welder` ship in\n`Open77RP.archive`, so players need a client archive that includes them\n([RP animations](rp-animations.md)).","natives":["CreateThread","server:CreateThread","AddEventHandler","server:AddEventHandler","GetInvokingResource","server:GetInvokingResource"],"permissions":[],"events":[]},{"anchor":"what-happens-when-the-player-presses-e","heading":"What happens when the player presses E","level":3,"text":"1. **Check requirements.** The library checks the player's distance, life state, vehicle,\n combat and world instance, then calls `validate` with `ctx.phase = \"begin\"`.\n2. **Reserve the spot and materials.** An exclusive target admits one worker. If present,\n your `reserve` hook holds the required items or money before any animation starts.\n3. **Present the work.** The animation and progress bar run for `durationMs`.\n4. **Apply the result.** The library checks the player again and calls `validate` with\n `ctx.phase = \"complete\"`, then calls `commit` to apply your result.\n5. **Finish.** The library releases the spot and its control claims.\n\nThe server times completion. `maxDistance` defaults to 2.5 m, with 0.75 m of slack at completion.\nUse the hooks to add your server's own requirements and item/payment operations.\n\nAny other end (death, disconnect, **X**, walking off, a car, aiming, being shoved out of the\nanimation, a newer action, a restart of either resource) also releases the spot, stops the\nanimation, gives the controls back and calls your `cancel` hook once. Every message carries an\noperation id (`opId`), so a late answer for an old action never touches the next one.\n\n| Hook | When | Runs | Answers |\n|---|---|---|---|\n| `validate` | steps 1 (`ctx.phase = \"begin\"`) and 4 (`\"complete\"`) | synchronously: no `Wait`, no `await` | `true`, or `false, reason` |\n| `reserve` | step 2 | asynchronously | a table (kept as `ctx.reservation`), or `nil, reason` |\n| `commit` | step 4 | asynchronously | a table (the receipt), or `nil, reason` |\n| `cancel` | once, on any end that is not a commit | asynchronously | ignored |\n| `marker` | at each of the action's `markers` (`ctx.marker`) | synchronously | ignored |\n\nKeep the tables `reserve` and `commit` return under 2 KB. `verify(opId, \"reserve\" | \"commit\")`\nanswers `true` only for your own operation, in that phase.","natives":[],"permissions":[],"events":[]},{"anchor":"take-a-fuse-and-pay-40-eddies","heading":"Take a fuse and pay 40 eddies","level":2,"text":"To consume a part and pay the worker together, use\n[`open77_transactions`](transactions.md): hold the transfer in `reserve`, apply it in `commit`\nand release an unused hold in `cancel`. Retrying the same transaction id returns its existing\nresult instead of applying another payment. A new id can pay again. Add\n`dependency \"open77_transactions >=0.1.0\"` to the manifest, and `reserve = \"repairReserve\"` and\n`cancel = \"repairCancel\"` to the action's hooks:\n\n```lua\nlocal tx = exports.open77_transactions\nlocal function call(name, ...) -- mutating coordinator exports yield: use the promise form\n local promise, why = Open77.exports.call(\"open77_transactions\", name, ...)\n if not promise then return nil, why end\n return promise:await()\nend\n\nexports(\"repairReserve\", function(ctx)\n if GetInvokingResource() ~= \"open77_actions\" then return nil, \"not_owner\" end\n if not actions:verify(ctx.opId, \"reserve\") then return nil, \"not_owner\" end\n local pockets, cash = tx:account(ctx.playerId, \"char\"), tx:account(ctx.playerId, \"cash\")\n if not pockets or not cash then return nil, \"no_account\" end\n local txOpId = \"repairs:\" .. ctx.opId -- one business id per action\n local held, why = call(\"reserve\", { opId = txOpId, kind = \"repair\", lines = {\n { item = \"fuse\", count = 1, from = pockets, to = \"sys:repairs\" }, -- the fuse is used up\n { money = 40, from = \"sys:repairs\", to = cash }, -- the pay\n } })\n if not held then return nil, why end -- insufficient_items: no bar starts\n return { txOpId = txOpId } -- becomes ctx.reservation\nend)\n\nexports(\"repairCommit\", function(ctx)\n if GetInvokingResource() ~= \"open77_actions\" then return nil, \"not_owner\" end\n if not actions:verify(ctx.opId, \"commit\") then return nil, \"not_owner\" end\n local receipt, why = call(\"commit\", ctx.reservation.txOpId)\n if not receipt then return nil, why end\n broken[ctx.target.id] = nil\n return { paid = 40 }\nend)\n\nexports(\"repairCancel\", function(ctx)\n if GetInvokingResource() ~= \"open77_actions\" then return end\n if ctx.reservation and ctx.reason ~= \"in_doubt\" then tx:cancel(ctx.reservation.txOpId, ctx.reason) end\nend)\n```\n\nIf `commit` answers `in_doubt`, `busy` or an export timeout, the library calls it again with the\nsame context (`commitRetries`, 2 by default), and the coordinator replays a commit that already\nhappened. Still unknown after the retries, the action ends `in_doubt`, not `failed`: leave the hold\nalone and settle it with the coordinator's `status`. An answer that says nothing was applied\n(`store_unavailable`, `coordinator_unavailable`, `conflict`) is retried the same way, then ends\n`failed`. The operator must let `my_repairs` write pockets and create the pay (`writers` and mint\nrules, in [Durable transactions](transactions.md)).","natives":["Open77.exports.call","server:Open77.exports.call","GetInvokingResource","server:GetInvokingResource"],"permissions":[],"events":[]},{"anchor":"tell-the-player-why-it-was-refused","heading":"Tell the player why it was refused","level":2,"text":"| When | What the player sees by itself |\n|---|---|\n| a prompt is refused at the start (your `validate` hook, the claim, the distance) | a short sentence for common reasons (\"Too far away.\"), otherwise \"Not possible right now.\" |\n| the action ends after the bar started | a short sentence (\"You moved away.\", \"Someone else took it.\"), otherwise \"Stopped.\"; nothing after **X** or a newer action |\n| your `reserve` hook refuses, or runs out of time | nothing: the action ends before anything was shown |\n| the action commits, or your own `request` call is refused | nothing |\n\nSo tell the player yourself, from the `open77_actions:transition` event. Every resource receives\nit: filter on `op.owner`.\n\n```lua\nlocal RESOURCE = GetCurrentResourceName()\nlocal TEXT = { insufficient_items = \"You need a fuse.\", no_account = \"Your character is not ready.\" }\n\nAddEventHandler(\"open77_actions:transition\", function(op)\n if op.owner ~= RESOURCE then return end\n if op.state == \"failed\" and op.stage == \"reserve\" then\n Open77.chat.send(op.playerId, TEXT[op.reason] or (\"You cannot repair this: \" .. op.reason))\n elseif op.state == \"committed\" then\n Open77.chat.send(op.playerId, (\"Machine repaired: +%d eddies.\"):format(op.receipt.paid))\n end\nend)\n```\n\nThe built-in sentences cover `target_claimed`, `spot_taken`, `too_far`, `carrying`, `weapon_drawn`,\n`not_carrying`, `actor_busy`, `in_combat` and `player_in_vehicle`. To word any other reason, your\nown tokens included, give the action `messages`: a reason and the plain text shown instead, or\n`false` to show nothing when your resource tells the player itself. Up to 96 entries of 200\ncharacters each.\n\n```lua\nactions:define({\n id = \"repair\", label = \"Repair the machine\", durationMs = 6000,\n hooks = { validate = \"repairValidate\", commit = \"repairCommit\" },\n messages = {\n already_fixed = \"This machine already works.\", -- your validate hook's token\n not_eligible = \"Clock in first: /service.\",\n moved = false, -- your own handler words this one\n },\n})\n```","natives":["GetCurrentResourceName","server:GetCurrentResourceName","AddEventHandler","server:AddEventHandler","server:Open77.chat.send"],"permissions":[],"events":[]},{"anchor":"start-an-action-from-your-own-code","heading":"Start an action from your own code","level":2,"text":"A prompt is not required. From a command, an NPC event or your own net event:\n\n```lua\nlocal op, reason, info = actions:request(playerId, \"repair\", \"machine_1\", { supersede = true })\nif not op then Open77.chat.send(playerId, \"Refused: \" .. reason) end\n```\n\n`request` answers at once with a summary (`op.opId`, `op.state`...), or `nil, reason, info` where\n`info.stage` names the step that refused and `info.retryable` is `true` for `target_claimed`,\n`destination_claimed` and `actor_busy`. The options are `supersede` (replace the player's running\naction, which ends `superseded`) and `data` (up to 2 KB, given to hooks as `ctx.data`). With a\n`reserve` hook, `begin` takes the same arguments and also waits for the reservation; it yields, so\ncall it as `Open77.exports.call(\"open77_actions\", \"begin\", ...):await()` from a thread. A resource\nstarts only its own actions; `cancel(opId, reason)` stops one, `status(opId)` and\n`current(playerId)` read the summary.","natives":["server:Open77.chat.send"],"permissions":[],"events":[]},{"anchor":"place-the-prompt-and-choose-its-keys","heading":"Place the prompt and choose its keys","level":2,"text":"The target's `position` is where the player stands, usually a floor point (stand there, type\n`/pos`). The card is drawn `promptHeight` (1 m) above it and fires once the player is within\n`distance` (2 m) and the card is on screen; there is no need to aim at it. The server then checks\nthe action's `maxDistance` (2.5 m): keep it at least as large as the target's `distance`.\n\nA few target fields change where the card is drawn and who sees it, not what the server allows:\n\n```lua\nactions:target({ id = \"station\", position = station, yaw = 55, -- where the worker stands\n promptAt = seat, -- draw the card over the patient's seat instead (within 3 m)\n hideFor = { patientId }, -- the patient gets no card for this target\n keepCrowdOff = true, -- pedestrians keep 0.8 m off the stand point\n actions = { \"operate\" } })\n```\n\n- `promptHeight` (0 to 3 m, 1 m by default) is the card's height above its point. Lower it when\n the point is already at the height of the thing, such as a counter top.\n- `promptAt` draws the card `promptHeight` above another point, within 3 m of `position`. The\n player still stands at `position`, the server still measures `distance` from there, and the card\n is live wherever `position` is in reach. Use it when the card would otherwise sit under the\n worker's own feet, out of view: a ripperdoc's station shows its card over the patient.\n- `lookAt = true` also requires the crosshair on the card. Use it only for spots closer together\n than their `distance`, where the player picks by looking; otherwise the nearest card in reach wins.\n- `hideFor` lists up to 8 player ids who get no card for this target. It only hides the card: an\n intent one of them sends is still judged by the action's own rules. A downed patient is not shown\n the medic's card over their own body. Call `target` again with the same `id` to change the list.\n- `keepCrowdOff` keeps the crowd's pedestrians off the stand point while the target exists: `true`\n for 0.8 m, or a radius from 0.3 to 1.5 m. It needs `open77_pedestrians` running and does nothing\n without it. Walkers stay out; a pedestrian already standing there is not moved.\n\nA target offers up to four choices, on **E**, **F**, **R** and **Y** in order, unless a choice\nnames its own `key` (one letter or digit). **G** (put down), **T** (it opens the chat) and **X**\n(cancel) are never given by default. Each action must be defined first:\n\n```lua\nactions:target({ id = \"machine_2\", position = { x = -1404.8, y = 961.0, z = 23.4 }, yaw = 90,\n label = \"Vending machine\", actions = {\n \"repair\", -- E, with the action's own label\n { action = \"inspect\", label = \"Check the fuse box\" }, -- F\n { action = \"restock\", key = \"Q\", holdSeconds = 1 }, -- Q instead of R, held one second\n } })\n```\n\n- A card shows its first choice's label and key, then \"+N more\". The other choices fire on their\n keys, but the player cannot read them: put the common choice first, give rare ones their own\n target or a command. **F** is also the game's own interact key.\n- Keys work only on the nearest card in reach: a crate next to the machine takes them over.\n- Use plain ASCII in labels: a character such as the euro sign shows as `?`.\n- `target` with an existing `id` replaces that target; `untarget(id)` removes it.","natives":[],"permissions":[],"events":[]},{"anchor":"spots-that-push-the-player-back","heading":"Spots that push the player back","level":3,"text":"`playAt` counts the body as arrived within 0.5 m of the spot, and a body that drifts past that ends\nthe action (`animation_interrupted`). A grill behind its counter, or a bar stool (which stops the\nbody 0.73 m from its anchor), pushes the body back. Raise the radius there, up to 1.5 m:\n\n```lua\nactions:target({ id = \"grill\", position = { x = -397.6, y = 715.1, z = 115.0 }, yaw = 64.0,\n arrivalRadius = 1.0, actions = { \"cook\" } })\n-- or per action, which wins over the target's: animation = { ..., arrivalRadius = 1.0 }\n```","natives":[],"permissions":[],"events":[]},{"anchor":"a-player-already-on-the-spot","heading":"A player already on the spot","level":3,"text":"An action with `animation = { at = \"target\" }` places the actor on the target's stand point.\nWhile another player stands within 0.45 m of that point (and within 2 m above or below it), the\naction is refused with `spot_taken` and the player reads \"Someone is standing there.\": placing the\nbody would put it on top of the other one.\n\n- The check runs after the player's own state, the job and duty checks, the epochs and your\n `validate` hook, so an off-duty player is told that first.\n- A player working this same target is left to the target's claim: the second player reads\n `target_claimed` (\"Someone is already using this.\").\n- A player held in a placed action at another target does not block: a patient seated in a chair\n 0.7 m from the ripperdoc's station does not stop the ripperdoc.\n- A neighbour half a metre off the point, such as a helper beside a bench, does not block either.\n- Actions played `at = \"self\"`, or without an animation, are never refused this way.","natives":[],"permissions":[],"events":[]},{"anchor":"only-on-duty-jobs-grades-and-duty","heading":"Only on duty: jobs, grades and duty","level":2,"text":"The library knows nothing about jobs. Plug in your framework:\n\n```lua\nactions:define({\n id = \"repair\", durationMs = 6000,\n -- asked at the start and before the commit: any export answering true, or nil + reason\n eligibility = { resource = \"rp_jobs\", export = \"eligible\", args = { job = \"technician\", duty = true } },\n -- read at the start; if it changed by the commit (job, grade or duty changed), refused\n epochs = { resource = \"rp_jobs\", read = \"workEpoch\", event = \"rp_jobs:workEpoch\" },\n})\n```\n\nThe eligibility export is called synchronously with `(playerId, args)`; its reason (such as\n`off_duty`) reaches the player, or `not_eligible` when it gives none. Each field also takes a list\nof up to four, which must all pass. When the epoch `event` fires for the player with a new value,\nthe running action ends at once (`stale_job_epoch`) and a carried item is dropped.","natives":[],"permissions":[],"events":[]},{"anchor":"carry-a-crate-to-a-truck","heading":"Carry a crate to a truck","level":2,"text":"Use the carry helper for the visible pickup, carrying pose and put-down interaction. Your\nresource still owns the goods and must save their contents and placement. If you use the RP\npack, start with `rp_salvage` and `rp_cargo` for a complete inventory and vehicle integration.\n\nThe following is an integration sketch. Supply `myTruckNear`, `truckIsFull` and `saveCrate`\nfrom your vehicle access and storage code; they are not Open77 exports. Also register the\n`cratePickup` hook described below. The snippets do not form a standalone cargo job.\n\nA scrapper finishes cutting a wreck and a crate appears beside it. The card reads **E** Pick up\nscrap crate; they lift it and walk to their truck at walking pace, unable to sprint, jump or fight. Others\nsee the crate in their hands; in first person it sits low on the right, under a \"Put down scrap\ncrate\" hint. **G** (rebindable in Settings, Key bindings) sets it down in front of them.\n\n```lua\nactions:carryDefine({\n id = \"crate\",\n model = \"crate.ammo_box\", -- a box at hand scale\n label = \"scrap crate\", -- the card reads \"Pick up scrap crate\"\n hooks = { pickup = \"cratePickup\", putdown = \"cratePutdown\", dropped = \"crateDropped\" },\n})\n\n-- A crate on the ground: its pickup prompt (E) appears by itself.\nlocal itemId = actions:carryPlace(\"crate\", {\n position = { x = 1369.6, y = -1689.4, z = 49.3 }, yaw = 0, bucket = 0,\n data = { crate = \"crate_17\" }, -- your own id: every hook receives it\n})\n\n-- Into the truck, from your own command or prompt.\nRegisterCommand(\"load\", function(source)\n local truckId = myTruckNear(source) -- yours: the vehicle id of a truck this player may use\n if not truckId then return end\n local op, why = actions:carryPutdown(source, { kind = \"vehicle\", vehicleId = truckId,\n offset = { x = 0.0, y = -1.5, z = 1.0 }, -- in the truck's frame: the bed\n claimKey = \"truck:\" .. truckId .. \":slot1\" }) -- one crate per slot\n if not op then Open77.chat.send(source, \"Cannot load: \" .. why) end\nend, false)\n```\n\n`vehicleId` is the engine's 64-bit vehicle id (any whole number up to 2^53), and the player must\nstand within `vehicleReach` (4 m) of the truck. **Custody changes only in your `putdown` hook**:\nthe library moves the prop after your hook commits, and never decides who owns the goods. For a\ndurable record, move the crate with a coordinator `transfer` there (into `veh::cargo` for a\ntruck); if it refuses, return its reason and the player keeps the crate. Lifting moves no value, so\n`cratePickup` can simply check its caller and answer `{ crate = ctx.carry.data.crate }`.\n\n```lua\nexports(\"cratePutdown\", function(ctx)\n if GetInvokingResource() ~= \"open77_actions\" then return nil, \"not_owner\" end\n local dest = ctx.carry.destination -- kind = \"ground\", \"point\" or \"vehicle\"\n if dest.kind == \"vehicle\" and truckIsFull(dest.vehicleId) then\n return nil, \"truck_full\" -- refused: the player keeps carrying it\n end\n saveCrate(ctx.carry.data.crate, dest) -- yours: write where it is now\n return { placement = dest }\nend)\n\nexports(\"crateDropped\", function(ctx) -- ctx.reason says why; ctx.data is the item's data\n if GetInvokingResource() ~= \"open77_actions\" then return end\n saveCrate(ctx.data.crate, { kind = \"point\", position = ctx.position })\nend)\n```\n\n`placement` says where the prop goes: `{ kind = \"ground\" }`, `{ kind = \"point\", position, yaw }`,\n`{ kind = \"vehicle\", vehicleId, offset, rotation }`, or `{ kind = \"remove\" }` once the crate is\nsold or handed to another resource (`rp_salvage` hands truck loads to [`rp_cargo`](https://github.com/Open2077/open77-resources/tree/main/rp/rp_cargo) this way). Without\n`placement`, the destination is used.\n\nA target that offers the built-in put-down is a drop zone with no code of its own: its card shows\nonly to players who carry something, and the item lands on that point. Your `putdown` hook sees\n`ctx.target`, and sells the crate by answering `{ placement = { kind = \"remove\" } }`:\n\n```lua\nactions:target({ id = \"counter\", position = { x = 1375.1, y = -1684.0, z = 49.3 }, yaw = 90,\n label = \"Scrap buyer\", requires = \"carrying\", data = { bay = \"sell\" },\n actions = { { action = \"open77_actions:carry_putdown\", label = \"Sell the crate\" } } })\n```","natives":["RegisterCommand","server:RegisterCommand","server:Open77.chat.send","GetInvokingResource","server:GetInvokingResource"],"permissions":[],"events":[]},{"anchor":"what-happens-to-a-carried-crate","heading":"What happens to a carried crate","level":3,"text":"| Event | What happens to the item |\n|---|---|\n| your `pickup` hook refuses | it goes back exactly where it was |\n| your `putdown` hook refuses, or the destination is no longer valid | the player keeps carrying it |\n| the server teleports the carrier | the hold resumes when the player is back; after 15 s away, it is dropped |\n| death, disconnect, a vehicle, a bucket change, combat, the carry pose broken, your resource stopping, a job change | **dropped** at the player's feet; your `dropped` hook gets `ctx.reason`; custody is unchanged |\n| the truck attachment fails after your commit | retried, then left on the ground (`projection_failed`); the commit stands |\n\nA restart of your resource or of `open77_actions` removes your carry items: place them again from\nyour own record when you start and on `open77_actions:ready`, as `rp_salvage` does.\n`carryPickup(playerId, itemId)` starts a pickup from your code, also from a truck bed (players\ncannot lift from a truck through a prompt). `carrying(playerId)` and `carryGet(itemId)` read the\nstate, `carryRemove(itemId)` deletes an item, `carryDrop(playerId)` forces a drop. Only the\nresource that placed an item may change it.","natives":[],"permissions":[],"events":[]},{"anchor":"keep-a-spot-for-yourself","heading":"Keep a spot for yourself","level":2,"text":"Benches and anything else you run yourself can use the same one-winner claims:\n\n```lua\nlocal token, why = actions:claim(bucket, \"bench:\" .. benchId, 60000) -- or nil, \"target_claimed\"\nactions:renewClaim(token, 60000)\nactions:releaseClaim(token)\n```\n\nA claim lasts its time (1 s to 1 h, 30 s by default) unless renewed, and goes when your resource\nstops or reloads; `claimHolder(bucket, key)` says who holds a key. Players seated on world\nfurniture hold `furniture::` ([Furniture](furniture.md)): claim it\nto keep players off a table your job uses.\n\nOn the server console, `actions.status [playerId]` shows what runs and who carries what,\n`actions.claims [bucket]` lists the claims, and `actions.trace` (`actions.trace off`) logs every\nstep of every action.","natives":[],"permissions":[],"events":[]},{"anchor":"reference","heading":"Reference","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"action-fields","heading":"Action fields","level":3,"text":"| Field | Default | What it does |\n|---|---|---|\n| `id`, `durationMs` | required | up to 48 letters, digits, `_ . -`; 250 to 600,000 ms on the server's clock |\n| `label` | the id | the choice and bar label |\n| `commitAtMs` | `durationMs` | commit earlier; the animation still runs to the end |\n| `maxDistance` | 2.5 m | the server's distance check (0.5 to 25), plus `completeSlack` (0.75 m) at the end |\n| `animation` | none | `{ profile, at = \"self\" or \"target\", yaw, yawOffset, clip, loop = true, required = true, arrivalRadius }`; with `required`, an animation that cannot start fails the action |\n| `progress` | a bar with the label | `{ label, position = \"bottom\", \"top\" or \"center\", style = \"bar\" or \"circle\", color = \"#rrggbb\", cancellable = true }`, or `false` |\n| `claims` | `{ move = true, attack = true }` | controls locked while it runs: `move`, `attack` (firing and punching), `jump`, `sprint`, `walkSpeed` (0.5 to 2.5 m/s); `false` for none |\n| `claim`, `claimTtlMs` | `true`, duration + 30 s | one player per target at a time (`false`: several), for that long (1 s to 1 h) |\n| `cancelOn` | `{ combat = true, vehicle = true, moved = true, weaponDrawn = false }` | which events end it |\n| `moveTolerance` | 1.0 m | without an animation, moving this far ends it (`moved`) |\n| `maxPositionAgeMs` | 2,000 | an older server position is refused (`position_stale`) |\n| `reserveTimeoutMs` | 20,000 | a slower `reserve` hook ends it (`reserve_timeout`) |\n| `commitRetries` | 2 | retries of an unsure `commit` (0 to 5) |\n| `cooldownMs` | 0 | after a commit, the same player waits this long (`actor_busy`) |\n| `supersede`, `requiresTarget`, `whileCarrying` | `false`, `true`, `false` | replace a running action; refuse `request` without a target; allow it while carrying |\n| `markers` | none | up to 8 `{ name, atMs }`: the `marker` hook and event fire then |","natives":[],"permissions":[],"events":[]},{"anchor":"target-fields","heading":"Target fields","level":3,"text":"| Field | Default | What it does |\n|---|---|---|\n| `id`, `position`, `actions` | required | up to 48 characters; where the player stands; 1 to 4 choices |\n| a choice | | an action id, or `{ action, label, key, holdSeconds (0 to 10), icon }` |\n| `yaw`, `bucket` | none, 0 | the way the player faces with `at = \"target\"`; the routing bucket |\n| `label`, `description` | none | the fallback choice label; the choices' description |\n| `distance`, `promptHeight` | 2.0 m, 1.0 m | reach of the card (0.5 to 25); its height above `position` (0 to 3, lower for a counter top) |\n| `markerDistance` | 12 m | from how far the dot is drawn (1 to 120) |\n| `marker`, `color`, `icon` | `dot` | the dot's shape ([Interactions](interactions.md)), `#rrggbb`, a short icon |\n| `lookAt` | `false` | also require the crosshair on the card, for spots closer together than their `distance` |\n| `promptAt` | none | draw the card over this point instead, within 3 m of `position`; the reach is still measured from `position` |\n| `hideFor` | none | up to 8 player ids who get no card for this target; an intent they send is still judged as usual |\n| `keepCrowdOff` | none | `true` (0.8 m) or 0.3 to 1.5 m: the crowd's pedestrians keep off the stand point (needs `open77_pedestrians`) |\n| `arrivalRadius` | 0.5 m | how close `playAt` must bring the body (0.5 to 1.5) |\n| `requires` | none | `\"carrying\"` or `\"empty\"`: offer the card only to players with, or without, an item |\n| `data` | none | your table (up to 1 KB), given to hooks as `ctx.target.data` |","natives":[],"permissions":[],"events":[]},{"anchor":"carry-fields","heading":"Carry fields","level":3,"text":"| Field | Default | What it does |\n|---|---|---|\n| `id`, `model`, `label` | required, required, the id | a prop alias such as `crate.ammo_box`; the name in \"Pick up ...\" |\n| `reach`, `vehicleReach` | 2.0 m, 4.0 m | pickup distance (0.5 to 5); distance to a truck (1 to 8) |\n| `dropAhead` | 0.7 m | how far in front of the player **G** or a drop sets it (0 to 2) |\n| `promptHeight` | 0.2 m | how far the \"Pick up\" card sits above the item's top (0 to 2). The item's target stands on its base, the point the server measures `reach` from, so a card drawn in reach is one the server accepts |\n| `gripMs`, `releaseMs` | 60 % of the pickup clip, 55 % of the put-down clip | when the hands close and open, on the server clock: calibrate them |\n| `pickupProfile`, `holdProfile`, `putdownProfile` | `carry_pickup`, `carry`, `carry_putdown` | the animations; `pickupMs` and `putdownMs` override the clip lengths |\n| `binding` | on the chest, between the hands | `{ bone, offset, rotation, firstPerson }`; another model may need its own offsets; `firstPerson = \"hide\"` hides it in first person |\n| `claims`, `cancelOn` | `{ walkSpeed = 1.2, jump = true, attack = true, sprint = true }`, `{ combat = true, weaponDrawn = true }` | what the carrier cannot do; aiming or a drawn weapon drops it (a vehicle always does) |\n| `hooks`, `eligibility`, `epochs` | none | `validate`, `pickup`, `putdown`, `dropped`, `cancel`; job checks as for actions |","natives":[],"permissions":[],"events":[]},{"anchor":"hook-context","heading":"Hook context","level":3,"text":"| Field | What it holds |\n|---|---|\n| `opId` | the operation id: derive your business ids from it |\n| `action`, `actionId`, `owner` | `\"my_repairs:repair\"`, `\"repair\"`, `\"my_repairs\"` |\n| `playerId`, `bucket`, `phase` | the player, their bucket at the start, and `begin`, `complete`, `reserve`, `commit`, `cancel` or `marker` |\n| `kind` | `action`, `carry_pickup` or `carry_putdown` |\n| `target` | `{ key, id, position, yaw, data }`, or `nil` without a target |\n| `data`, `reservation`, `receipt` | the `data` option of `request`; what `reserve` and `commit` returned |\n| `carry` | carry hooks: `{ itemId, kind, data, destination }` |\n| `reason`, `marker` | `cancel`: why it ended; `marker`: the marker's name |\n| `playbackId`, `startedAtMs`, `generation`, `ownerGeneration` | animation and restart bookkeeping |\n\nThe `dropped` hook gets `{ itemId, kind, playerId, reason, position, data, sessionId }`. The exports\nare `define`, `undefine`, `target`, `untarget`, `request`, `begin`, `cancel`, `status`, `verify`,\n`current`, `claim`, `renewClaim`, `releaseClaim`, `claimHolder`, `carryDefine`, `carryPlace`,\n`carryPickup`, `carryPutdown`, `carryDrop`, `carryRemove`, `carryGet` and `carrying`. All answer at\nonce except `begin`; a refusal answers `nil, reason`.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":3,"text":"Server events every resource receives: filter on `owner`.\n\n| Event | Payload |\n|---|---|\n| `open77_actions:ready` | `{ generation, version }`: `open77_actions` started; declare again |\n| `open77_actions:transition` | `{ opId, action, actionId, owner, playerId, bucket, target, state, reason, stage, receipt, kind, itemId, playbackId, startedAtMs, completeAtMs, trailing }` |\n| `open77_actions:marker` | `{ opId, marker, owner, playerId, playbackId }` |\n| `open77_actions:carry` | `{ phase, itemId, kind, owner, state, carrier }`, plus `playerId`, `reason`, `opId` or `placement` by phase |\n\n`state` goes `validating`, `reserving` (with a `reserve` hook), `presenting`, `committing`, and ends\n`committed`, `cancelled`, `failed` or `in_doubt`. `stage` names the step an end came from:\n`request`, `validate`, `reserve`, `present`, `complete`, `commit`, `cancel` or `carry`. `target` is\nthe target key (`\"my_repairs:machine_1\"`). Carry phases: `placed`, `gripped`, `picked_up`,\n`put_down`, `dropped`, `removed`, `carry_suspended`, `carry_resumed`, `projection_failed`, and\n`pickup_` or `putdown_` when a pickup or put-down ends without a commit.","natives":[],"permissions":[],"events":[]},{"anchor":"why-was-it-refused","heading":"Why was it refused","level":3,"text":"| Reason | What it means | What to do |\n|---|---|---|\n| `target_claimed` | another player holds the spot | wait: it frees up when they finish |\n| `spot_taken` | another player stands on the stand point of an action placed `at = \"target\"` | wait for them to step off ([details](#a-player-already-on-the-spot)) |\n| `destination_claimed` | another item holds that put-down `claimKey` | use another slot |\n| `actor_busy` | the player already runs an action, or `cooldownMs` has not passed | wait, or pass `supersede = true` |\n| `too_far` | beyond `maxDistance`, or the item's `reach` | move closer; keep `maxDistance` at least the target's `distance` |\n| `destination_too_far` | over `reach` + 1 m (5 m at most) from the drop point, or beyond `vehicleReach` | stand closer |\n| `wrong_bucket` | not in the bucket of the target, item or truck | check the target's `bucket` |\n| `position_stale`, `position_unavailable`, `player_not_ready`, `player_unavailable` | no fresh position yet, still loading in, or gone | retry once the player is in the world |\n| `player_not_alive`, `player_in_vehicle`, `in_combat` | dead, in a vehicle, aiming or firing | retry once alive, on foot and not aiming |\n| `carrying` | hands full, and the action has no `whileCarrying` | put the item down, or set `whileCarrying = true` |\n| `not_carrying`, `item_unavailable` | nothing in hands; the item is gone or someone else is lifting it | nothing |\n| `destination_invalid`, `vehicle_unavailable` | a malformed destination; no vehicle with that id | check `kind`, `vehicleId`, `claimKey` |\n| `not_eligible`, or the provider's token | your eligibility export said no | clock in, or get the job |\n| `stale_job_epoch` | job, grade or duty changed since the start | start again |\n| `refused_by_owner` | your hook said no without a reason | return your own token, such as `already_fixed` |\n| `hook_failed` | your hook raised an error | read the server log |\n| `animation_refused`, `attach_failed` | the animation could not start; the carried prop could not be held | check the profile or the model, or set `required = false` |\n| `owner_unavailable`, `not_owner`, `unknown_action`, `unknown_target`, `invalid_*` | a reload since `define`, someone else's action or item, a failed `verify`, a wrong id or table | declare again; fix the call |\n| `cancelled_by_player`, `superseded` | **X** on the bar; a newer action | nothing |\n| `dead`, `disconnected`, `bucket_changed`, `vehicle`, `combat` | the player died, left, changed bucket, got in a car, aimed or fired | nothing |\n| `moved` | moved past `moveTolerance` (an action without animation), or a server move took the body | nothing |\n| `animation_interrupted` | the posture broke: shoved, pushed off the spot, teleported | raise `arrivalRadius` at spots that push back |\n| `claim_lost`, `reserve_timeout` | the claim ran out (`claimTtlMs`); your `reserve` hook was too slow (the player sees nothing) | raise the limit, or make the hook faster |\n| `owner_stopped`, `resource_stopping`, `cancelled` | your resource or `open77_actions` stopped; `cancel(opId)` without a reason | nothing |\n| `in_doubt` | the commit's outcome is still unknown after the retries | settle it with the coordinator's `status` |\n\nA start reason can also end a running action, when the end-of-time checks refuse it (`too_far`,\n`not_eligible`, your token). A `commit` that answers `nil, reason` ends the action `failed` with\nthat reason.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":3,"text":"- 64 actions and 64 targets per resource, 512 targets per server, 128 carry items per resource,\n 4 choices per target, 8 markers per action.\n- A player sees at most the 12 nearest prompts, and their client sends at most six choices a second.\n- The walk pace and the sprint, jump and fighting locks of a carry are applied by the player's\n client; the server sees positions, not inputs. A client too old to block melee or sprinting\n answers `action_not_blockable`, and the carry runs without those two locks.\n- Identical targets stacked on one point (within 5 cm, with the same choices, reach and card), such\n as two crates set down on the same spot, show one card: the target with the smallest key. **E**\n acts on that one, and the next takes the card once it is gone. Two different things on one point\n keep a card each.\n- There are no station \"recipes\": add a stage with your own hooks or a second action, as\n [Build your first Lua job](build-a-job.md) shows.\n\nSee also: [Durable transactions](transactions.md), [Furniture](furniture.md),\n[Interactions](interactions.md), [Player interactions](player-interactions.md),\n[RP animations](rp-animations.md), [Props](props.md), [UI kit](ui-kit.md).","natives":[],"permissions":[],"events":[]}]},{"slug":"agent-testing","file":"agent-testing.md","title":"Autonomous agent testing","summary":"Use the agent testing toolkit to launch the local stack, connect a game client, run actions and inspect results from an external automation harness.","sections":[{"anchor":"autonomous-agent-testing","heading":"Autonomous agent testing","level":1,"text":"Use the agent testing toolkit to launch the local stack, connect a game client, run actions and inspect results from an external automation harness.\n\nTwo pieces, both versioned in the platform repository:\n\n| Piece | Path | Role |\n|---|---|---|\n| MCP server | `scripts/agent-mcp.ps1` | exposes the whole loop as typed `open77_*` tools over MCP (stdio) |\n| Claude Code skill | `.claude/skills/open77-testing/SKILL.md` | teaches an agent *when* and *how* to use them |\n\nThe MCP server is a plain PowerShell 7 script with no dependencies and no build step; it starts\nwith the agent session and ends with it. Screenshots come back as real images in the conversation,\nso a multimodal agent sees what the player sees.","natives":[],"permissions":[],"events":[]},{"anchor":"what-this-means-for-server-owners","heading":"What this means for server owners","level":2,"text":"Open77 development is validated in the running game, not only in unit tests. The loop has been\nproven end to end: stack launch, enrolment with the master, connection, pristine character load,\ncrossing the \"press any key to continue\" screen, a server teleport confirmed by reading the\nplayer's position, and screenshots at every step — including two clients connected to the same\nserver under two distinct accounts, each seeing the other's puppet and nameplate on screen.\n\nThe same toolkit applies to your own RP server and resources. `open77_client_connect` takes the\nserver endpoint, `open77_chat` speaks through the real chat pipeline — the thing RP servers are\nbuilt on — and `open77_scenario` turns a validation that passed once into a repeatable regression\ntest with assertions and automatic failure screenshots. An agent asked to implement or validate a\nfeature is expected to run this loop itself rather than ask a human to launch the game and report\nwhat they see.","natives":[],"permissions":[],"events":[]},{"anchor":"the-loop","heading":"The loop","level":2,"text":"```text\nopen77_status # always first: what is already up, and how much VRAM is left\nopen77_build # only after changing C++ — stops clients, builds, verifies deploy (~4 min)\nopen77_stack_up # master (8090) + dev server (UDP 11778) + one client + debug bridge\nopen77_client_connect # full human login path; returns only when the player is alive in-world\n … act and observe …\nopen77_down # stop_game / stop_server to tear down\n```\n\nChanged only Lua? Skip `open77_build`: `open77_resource_sync` copies the resource and reloads it.\nServer-only resources are served live from the repository — nothing to copy.\n\nFor a complete script-based example, follow [Test RTTI in game](/docs/rtti-testing).\nIt covers process selection, `agent-play.ps1`, direct bridge commands, queued\nresults, multiplayer laboratory access, and hook/reference cleanup checks.","natives":[],"permissions":[],"events":[]},{"anchor":"the-tools","heading":"The tools","level":2,"text":"The authoritative list is the `$script:Tools` table in `scripts/agent-mcp.ps1`, which also carries\nthe full input schema of every tool.\n\n| Tool | Role |\n|---|---|\n| `open77_status` | Stack state: master (8090), dev server (UDP 11778), free VRAM, and every game instance with its bridge, session phase, and position. Always start here. |\n| `open77_build` | Rebuilds and redeploys the client through the repository's `build-all` script (`-Target client` by default). Stops all games first — a mapped DLL cannot be replaced. Takes ~3–4 minutes; tests are skipped by default for fast iteration. |\n| `open77_stack_up` | Brings up the stack: local master, dev server, one game client with a reachable debug bridge. Idempotent. |\n| `open77_client_launch` | Adds a game instance without touching the others, with a VRAM-margin guard (`force` to override). `identity_profile` gives the instance its own account; `player_name` names a freshly created one. |\n| `open77_client_connect` | Connects an instance through the complete human login path — master enrolment, connection, resource download, pristine load, continue screen — and returns only on proof of life (`char.state alive=yes`). Takes 1–3 minutes. |\n| `open77_resource_sync` | Copies and reloads a client-side Lua resource without rebuilding the DLL, and returns the resource generation to prove the reload. Explicit reload is refused during an active session — sync before connecting, or disconnect first. |\n| `open77_cmd` | The main verb: a text command to the instance's debug bridge (one-line OK/ERR reply). Unknown bridge commands are forwarded to the server as slash commands (`tpc`, `goto`, `spawn`, `car`, `revive`, …), subject to the same ACL a player faces. `open77_cmd help` lists the whole bridge table. |\n| `open77_snapshot` | The **data** photograph, as structured JSON: network session, character (position, health, weapon, vehicle, locomotion states), camera, vehicles, and optionally nearby entities. The source of truth for what is happening in-game. |\n| `open77_nettrace` | Packet-level telemetry: sent/received counters plus recent packets as `direction:kind:sequence:bytes:reliable`. The channel for replication desyncs, which a screenshot cannot show. |\n| `open77_chat` | Sends a chat message as this client through the real chat pipeline (a text starting with `/` is treated by the server as a command). |\n| `open77_screenshot` | Captures the game window and returns it as a PNG image — the eye of the loop, for UI, rendering, and catching a modal panel that has swallowed input. |\n| `open77_input` | Keyboard and mouse injection (SendInput): walk with `keys=[\"forward\"]` and a long `hold_ms`, turn the camera with relative `mouse_move_x/y`, use or enter a vehicle with `f`, jump with `space`, fire with `click=left`, aim with `click=right`. Movement keys are physical scan codes, valid on any keyboard layout. |\n| `open77_scenario` | Runs a declarative list of steps — each a tool call with an optional regex `assert` on the result text — stopping at the first failure (unless `continue_on_error`) and screenshotting failures. Returns a per-step pass/fail report. |\n| `open77_logs` | Tail of the client, dev server, or master log, with an optional regex filter. Useful client filters: `shell-transition` (connection), `error`. |\n| `open77_crashinfo` | Crash triage: the most recent `CrashInfo.json`, the client log tail, and the list of still-alive instances — the moment a client dies. |\n| `open77_down` | Cleanly disconnects an instance; `stop_game` also stops the process, `stop_server` stops the dev server and master. |\n\nPrefer commands over input: they are deterministic and do not need window focus.","natives":[],"permissions":[],"events":[]},{"anchor":"how-the-connection-works","heading":"How the connection works","level":2,"text":"The path is **exactly the one a human clicks**; nothing is bypassed.\n\n1. The connect step emits `open77:shell:autoconnect ` through the debug\n bridge.\n2. The shell resource receives the event and refreshes the given master's catalogue, which\n **enrols the identity** with that authority — a certificate is issued by a single authority, so\n a client enrolled elsewhere is rejected by the local server.\n3. The same `startConnection` the interface runs then executes: character bootstrap reset,\n `Open77.network.connect`, signed resource download, character resolution, pristine load, world\n handoff.\n4. Progress is followed on two channels: `net.state` on the bridge (network phase, playerId,\n error) and the `[shell-transition]` log lines (bootstrap, pristine load, handoff).\n5. The game then shows \"press any key to continue\" — sometimes tens of seconds after the handoff.\n The connect step presses Enter **until proof of life**: `char.state` reads `alive=no health=0`\n while the screen holds and `alive=yes health=100` once in-game.","natives":[],"permissions":[],"events":[]},{"anchor":"observing-data-first-pictures-second","heading":"Observing — data first, pictures second","level":2,"text":"Raw state comes before the image. Position, health, network phase, and entities are read through\nthe bridge; the screenshot only verifies what the player *sees*.\n\n| Question | Source |\n|---|---|\n| Everything essential, structured | `open77_snapshot` (JSON: network, character, camera, vehicles) |\n| Are packets flowing, in which direction? | `open77_nettrace` |\n| Does chat work end to end? | `open77_chat`, then a screenshot (the message fades after a few seconds) |\n| Why did this client die? | `open77_crashinfo` |\n| Which network phase is the client in? | `open77_cmd` with `net.state` — `offline/resolving/connecting/handshaking/active/failed/rejected` |\n| Is the player incarnated, and where? | `open77_cmd` with `position` (`ERR player_unavailable` otherwise) |\n| What happened during the connection? | `open77_logs` with `match=shell-transition` |\n| Are the server resources active? | `open77_cmd` with `resource.distribution` |\n| What is on screen? | `open77_screenshot` |\n\nDuring an active session the bridge is deliberately restricted to read-only diagnostics and safety\nhatches. **Acting** in the world goes through server commands — forwarded automatically by\n`open77_cmd` for any unknown command name — under the server's ACL, the same authority path a\nplayer follows.","natives":[],"permissions":[],"events":[]},{"anchor":"repeatable-scenarios","heading":"Repeatable scenarios","level":2,"text":"`open77_scenario` turns a manual validation into a regression test the moment it passes:\n\n```json\n{\"steps\": [\n {\"name\": \"session is active\", \"tool\": \"open77_cmd\",\n \"args\": {\"command\": \"net.state\"}, \"assert\": \"phase=active\"},\n {\"name\": \"player is alive\", \"tool\": \"open77_cmd\",\n \"args\": {\"command\": \"char.state\"}, \"assert\": \"alive=yes\"},\n {\"name\": \"chat delivered\", \"tool\": \"open77_chat\",\n \"args\": {\"text\": \"regression check\"}, \"assert\": \"chat_sent\"}\n]}\n```\n\nEach step is an `open77_*` tool call; a failed assertion is reported with the actual output and a\nPNG of the screen at the moment of failure.","natives":[],"permissions":[],"events":[]},{"anchor":"several-players-at-once","heading":"Several players at once","level":2,"text":"`open77_client_launch` with `identity_profile` gives an instance **its own account**\n(`player_name` names a freshly created one); without it, every instance shares the same identity\nfile, so the same account under the same name. Connect instances **one at a time** — crossing the\ncontinue screen goes through the keyboard, hence the foreground — and pass `pid` to every\nsubsequent call. Two in-world clients is the practical ceiling on a 12 GB graphics card.","natives":[],"permissions":[],"events":[]},{"anchor":"rules-paid-for-by-real-failures","heading":"Rules paid for by real failures","level":2,"text":"Run the checks below to distinguish a completed operation from an accepted command or a partially loaded world.\n\n- **World entry is proven by three things together**: the log line `worldReady matched pristine\n transition`, a readable `position`, and `char.state alive=yes`. An \"active\" session is not an\n incarnation, and `position` already answers in the menu scene.\n- **Never send a server action (teleport, spawn, …) to a client whose `char.state` says\n `alive=no`.** That is the \"press any key to continue\" gate: a `tpc` received there crashed the\n client outright, with `CrashInfo.json` pointing at the teleport destination. This is why the\n connect tool refuses to return before proof of life.\n- **A launched instance takes 60–150 seconds to appear.** A process shows up after roughly a\n minute and its window some ten seconds later. Do not conclude failure early.\n- **VRAM is the real limit on concurrent clients** — roughly 2.7 GiB at the menu and up to\n 4.2 GiB in-world per instance. Lowering graphics settings barely helps, because the streamer\n budgets against the card, not the settings. An instance without VRAM dies silently.\n- **A stray keypress can open a modal panel** (the radio, for instance) that then eats all\n gameplay input. Screenshot to see it; `esc` or `c` closes it.\n- **Deploying a DLL requires every game stopped**; Lua-only changes hot-reload.\n- **Explicit resource reload is refused during an active session** — sync before connecting, or\n disconnect first.\n- **All instances share one save folder**, and autosave starts on world entry. Keep a single\n client in-world when it matters, and back the folder up before a multi-client session.","natives":[],"permissions":[],"events":[]},{"anchor":"installation","heading":"Installation","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"1-declare-the-mcp-server","heading":"1. Declare the MCP server","level":3,"text":"Declare the server in the `.mcp.json` your MCP client reads (a complete template ships as\n`.mcp.json.example` at the repository root):\n\n```json\n{\n \"mcpServers\": {\n \"open77\": {\n \"type\": \"stdio\",\n \"command\": \"pwsh\",\n \"args\": [\"-NoProfile\", \"-ExecutionPolicy\", \"Bypass\",\n \"-File\", \"/scripts/agent-mcp.ps1\"]\n }\n }\n}\n```\n\nUnder Claude Code, a project `.mcp.json` must be approved: add `\"open77\"` to\n`enabledMcpjsonServers` in `.claude/settings.local.json`.\n\nPrerequisites: PowerShell 7 (`pwsh`), the `OP77_GAME_DIR` environment variable pointing at the\ngame installation, and a deployed Open77 client (`./scripts/build-all.sh`).","natives":[],"permissions":[],"events":[]},{"anchor":"2-install-the-skill","heading":"2. Install the skill","level":3,"text":"`.claude/skills/open77-testing/SKILL.md` is versioned: a clone of the repository has it already.\nClaude Code discovers project skills in `.claude/skills/` **at the session root**. If the session\nis opened on the repository itself, nothing to do.\n\nIf the session is opened on a parent folder, make the folder visible from that root — a junction\navoids maintaining two copies:\n\n```powershell\nNew-Item -ItemType Junction -Path '\\.claude\\skills' `\n -Target '\\.claude\\skills'\n```\n\nTo install machine-wide rather than per project, copy the `open77-testing` folder into\n`~/.claude/skills/`.\n\n**Skills are loaded at session start**: after installing, open a new session. Verify with\n`/skills`, or simply ask for a testing task — the agent should load it on its own.","natives":[],"permissions":[],"events":[]},{"anchor":"3-verify","heading":"3. Verify","level":3,"text":"In a new session, on the repository:\n\n```text\nopen77_status\n```\n\nThe reply reports the master (8090), the dev server (11778), free VRAM, and any running game\ninstances. From there the whole loop is two calls: `open77_stack_up`, then\n`open77_client_connect`.","natives":[],"permissions":[],"events":[]},{"anchor":"extending-the-toolkit","heading":"Extending the toolkit","level":2,"text":"The MCP server is a versioned script: adding a tool means adding an entry to the `$script:Tools`\ntable in `scripts/agent-mcp.ps1` — a description, an `inputSchema`, a `handler` — and restarting\nthe session. An agent blocked by a missing tool can therefore create it itself; that is\ndeliberate. Any durable addition deserves a line in the skill and in the reference guide.","natives":[],"permissions":[],"events":[]},{"anchor":"where-the-details-live","heading":"Where the details live","level":2,"text":"See `docs/agent-autonomous-testing.md` for the automation reference and `docs/agent-setup.md` for installation. The MCP server wraps the standalone `agent-play.ps1`, `capture-game.ps1`, `game-input.ps1` and `launch-extra-client.ps1` scripts.","natives":[],"permissions":[],"events":[]}]},{"slug":"armed-vehicles","file":"armed-vehicles.md","title":"Armed vehicle spawn catalogue","summary":"Spawn weapon-equipped vehicles with the `Vehicle.*` records listed below. The catalogue covers Cyberpunk 2077 **2.31 / build 23100**, including Phantom Liberty.","sections":[{"anchor":"armed-vehicle-spawn-catalogue","heading":"Armed vehicle spawn catalogue","level":1,"text":"Spawn weapon-equipped vehicles with the `Vehicle.*` records listed below. The catalogue covers Cyberpunk 2077 **2.31 / build 23100**, including Phantom Liberty.\n\nThe database declares weapon mounts on **172 vehicle records**. This is not a count of supported armed vehicles: inherited lists may include unattached weapons. Quest, transport, broken and hologram variants can require additional assets or logic.\n\nThe **spawn ID** is the full `Vehicle.*` string, not an `Items.*` weapon record, a mount/slot ID, or the numeric network vehicle ID returned after creation. Use [Network vehicles](vehicles.md) for the complete API and [Game data reference](data-reference.md) for other model catalogues.","natives":[],"permissions":[],"events":[]},{"anchor":"armed-model-examples","heading":"Armed model examples","level":2,"text":"The records below provide native mounted weapons. Model appearance and attachments can change the available armament. See [weapon replication and limitations](vehicle-weapons.md#model-specific-armament) before using a model in a gamemode.\n\n| Vehicle | Spawn ID | Armament |\n|---|---|---|\n| Militech Basilisk | `Vehicle.v_militech_basilisk` | Cannon, homing missiles, left/right countermeasures |\n| Herrera Outlaw, armed Heist variant | `Vehicle.v_sport1_herrera_outlaw_heist_player` | Two mounted machine guns |\n| Mizutani Shion Nomad, missile player variant | `Vehicle.v_sport2_mizutani_shion_nomad_player_missiles` | Missile launcher B; three weapon objects attached |\n\nDo not substitute `Vehicle.v_sport1_herrera_outlaw_player` for the armed Heist variant: it is not in this mounted-weapon catalogue. The same caution applies to the ordinary Shion, Type-66, Galena and Colby: use the exact variant ID, not just the family name or a freeroam alias.","natives":[],"permissions":[],"events":[]},{"anchor":"spawn-a-network-vehicle","heading":"Spawn a network vehicle","level":2,"text":"Client HUDs can inspect a live vehicle through the [vehicle weapon Lua API](vehicle-weapons.md), including actual attached weapons, selection and available native ammunition counters.\n\nWith the system resource [`open77_vehicles`](https://github.com/Open2077/open77-resources/tree/main/system/open77_vehicles) running, enter this in the **server console** (replace `1` with the connected player's ID):\n\n```text\nvehicle.create.player 1 Vehicle.v_militech_basilisk\nvehicle.create.player 1 Vehicle.v_sport1_herrera_outlaw_heist_player\nvehicle.create.player 1 Vehicle.v_sport2_mizutani_shion_nomad_player_missiles\n```\n\nRun one line for the vehicle you want. These commands create beside the player; they do not automatically seat the player. From chat, prefix the command with `/` and grant its exact `command.vehicle.create.player` ACL permission. Spawning is restricted, not an unrestricted player command.\n\nA **server-side Lua resource** uses the same spawn ID:\n\n```lua\n-- open77.lua\npermissions { \"world.vehicles\" }\n```\n\n```lua\n-- server/main.lua; choose clear ground and the intended routing bucket.\nlocal vehicleId, reason = Open77.vehicles.create({\n record = \"Vehicle.v_militech_basilisk\",\n position = { x = 405.0, y = -2415.0, z = 183.0 },\n yaw = 0.0,\n bucket = 0,\n})\nassert(vehicleId, reason)\n```","natives":["server:Open77.vehicles.create"],"permissions":["world.vehicles"],"events":[]},{"anchor":"player-variants-at-a-glance","heading":"Player variants at a glance","level":2,"text":"These **18** `_player` variants declare weapon mounts. Except for the armed examples above, treat these records as experimental: a declared mount does not establish that its weapon can be deployed. The Basilisk is listed separately because its ID does not contain `_player`.\n\n| Spawn ID | Declared mount profile | Support |\n|---|---|---|\n| `Vehicle.v_sport1_herrera_outlaw_heist_player` | Outlaw MG | Mounted armament |\n| `Vehicle.v_sport1_rayfield_caliburn_02_player` | MG | Experimental player variant |\n| `Vehicle.v_sport1_rayfield_caliburn_mordred_player` | MG | Experimental player variant |\n| `Vehicle.v_sport1_rayfield_caliburn_player` | MG | Experimental player variant |\n| `Vehicle.v_sport2_mizutani_shion_nomad_02_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_sport2_mizutani_shion_nomad_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_sport2_mizutani_shion_nomad_player_missiles` | MG + missiles (declared) | Mounted armament |\n| `Vehicle.v_sport2_quadra_type66_nomad_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_sport2_quadra_type66_nomad_player_03` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard25_thorton_colby_nomad_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard25_thorton_colby_nomad_player_missiles` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard2_archer_quartz_nomad_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard2_archer_quartz_nomad_player_02` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard2_thorton_galena_nomad_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard2_thorton_galena_nomad_player_missiles` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard3_mahir_supron_kurtz_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_standard3_militech_hellhound_player` | MG + missiles (declared) | Experimental player variant |\n| `Vehicle.v_utility4_chevalier_legatus_player` | MG | Experimental player variant |","natives":[],"permissions":[],"events":[]},{"anchor":"complete-record-inventory","heading":"Complete record inventory","level":2,"text":"All **172** unique source records are listed below, including inherited and special-purpose variants. **Declared** describes the TweakDB mount list, not a guarantee of functional armament. Refer to the armed model examples and inspect attached weapons with the Lua API.","natives":[],"permissions":[],"events":[]},{"anchor":"basilisk-panzer-cannon-missiles-and-countermeasures-7","heading":"Basilisk / Panzer — cannon, missiles and countermeasures (7)","level":3,"text":"Four declared mounts: cannon, homing missile launcher, left and right countermeasure launchers.\n\n| Spawn ID | Validation / caution |\n|---|---|\n| `Vehicle.panam_panzer` | Quest / scene / encounter — firing not checked |\n| `Vehicle.panam_panzer_q202` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_militech_panzer` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_nomad_panzer` | Quest / scene / encounter — firing not checked |\n| `Vehicle.v_militech_basilisk` | Mounted armament |\n| `Vehicle.v_militech_basilisk_militech` | Other variant — firing not checked |\n| `Vehicle.v_militech_basilisk_transport` | Special/broken variant — inspect before use |","natives":[],"permissions":[],"events":[]},{"anchor":"outlaw-heist-dedicated-machine-guns-2","heading":"Outlaw Heist — dedicated machine guns (2)","level":3,"text":"Two declared Outlaw-specific power-weapon mounts: left and right.\n\n| Spawn ID | Validation / caution |\n|---|---|\n| `Vehicle.v_sport1_herrera_outlaw_heist` | Other variant — firing not checked |\n| `Vehicle.v_sport1_herrera_outlaw_heist_player` | Mounted armament |","natives":[],"permissions":[],"events":[]},{"anchor":"machine-gun-mount-records-27","heading":"Machine-gun mount records (27)","level":3,"text":"Two declared standard power-weapon mounts: left A and right A. Actual attachment still depends on the model's configuration.\n\n| Spawn ID | Validation / caution |\n|---|---|\n| `Vehicle.cs_savable_rayfield_caliburn` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_rayfield_caliburn_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_rayfield_caliburn_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.hackable_rayfield_caliburn` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hil_06_objective_car` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q000_nomad_v_sport1_rayfield_caliburn_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q306_oa_rayfield` | Quest / scene / encounter — firing not checked |\n| `Vehicle.v_sport1_rayfield_caliburn` | Other variant — firing not checked |\n| `Vehicle.v_sport1_rayfield_caliburn_02_player` | Experimental player variant |\n| `Vehicle.v_sport1_rayfield_caliburn_courier` | Other variant — firing not checked |\n| `Vehicle.v_sport1_rayfield_caliburn_mordred` | Other variant — firing not checked |\n| `Vehicle.v_sport1_rayfield_caliburn_mordred_player` | Experimental player variant |\n| `Vehicle.v_sport1_rayfield_caliburn_murdered` | Other variant — firing not checked |\n| `Vehicle.v_sport1_rayfield_caliburn_player` | Experimental player variant |\n| `Vehicle.v_sport1_rayfield_caliburn_quest` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_cargo` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_cargo_courier` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_cargo_dogtown` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_flatbed` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_flatbed_poor` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_flatbed_poor_dogtown` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_poor` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_centurion_poor_dogtown` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_legatus_aquila_basic` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_legatus_basic` | Other variant — firing not checked |\n| `Vehicle.v_utility4_chevalier_legatus_player` | Experimental player variant |","natives":[],"permissions":[],"events":[]},{"anchor":"shared-machine-gun-and-missile-mount-records-136","heading":"Shared machine-gun and missile mount records (136)","level":3,"text":"Nine possible mounts: power weapons left/right A, B and C, plus missile launchers A, B and C. This is a shared list of alternatives, NOT nine weapons fitted to every vehicle. Entries such as Caliburn, Centurion and quest variants must not be treated as armed solely because they inherit this list.\n\n| Spawn ID | Validation / caution |\n|---|---|\n| `Vehicle.aldecado_archer_quartz_heat_3` | Quest / scene / encounter — firing not checked |\n| `Vehicle.aldecado_thorton_colby_heat_3` | Quest / scene / encounter — firing not checked |\n| `Vehicle.aldecado_thorton_galena_heat_1` | Quest / scene / encounter — firing not checked |\n| `Vehicle.aldecado_thorton_galena_heat_2` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_archer_quartz_nomad_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_archer_quartz_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_mahir_supron_kurtz` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_quadra_type66_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_quadra_type66_nomad_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_quadra_type66_nomad_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_thorton_colby_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_thorton_colby_nomad_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_thorton_galena_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_thorton_galena_nomad_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_thorton_galena_nomad_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.cs_savable_v_standard3_militech_hellhound_police` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_savable_v_standard3_militech_hellhound_police_siren` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_v_sport2_mizutani_shion_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_v_sport2_mizutani_shion_nomad_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_v_sport2_mizutani_shion_nomad_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.cs_v_sport2_mizutani_shion_nomad_wraith` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_v_sport2_mizutani_shion_nomad_wraith_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_v_sport2_mizutani_shion_nomad_wraith_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.cs_v_standard2_thorton_galena_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.cs_v_standard2_thorton_galena_nomad_broken` | Special/broken variant — inspect before use |\n| `Vehicle.cs_v_standard2_thorton_galena_nomad_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.ep1_hackable_mahir_supron_kurtz` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_archer_quartz_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_militech_hellhound_police_siren` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_mizutani_shion_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_mizutani_shion_nomad_wraith` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_quadra_type66_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_thorton_colby_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.hackable_thorton_galena_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.kurtz_mahir_supron_heat_3` | Quest / scene / encounter — firing not checked |\n| `Vehicle.kurtz_mahir_supron_heat_3_cd` | Quest / scene / encounter — firing not checked |\n| `Vehicle.kurtz_thorton_2` | Quest / scene / encounter — firing not checked |\n| `Vehicle.ma_bls_ina_se1_22_wraith_car` | Quest / scene / encounter — firing not checked |\n| `Vehicle.mitch_vehicle` | Quest / scene / encounter — firing not checked |\n| `Vehicle.mq001_scorpion_vehicle` | Quest / scene / encounter — firing not checked |\n| `Vehicle.mws_mahir_supron_kurtz` | Quest / scene / encounter — firing not checked |\n| `Vehicle.ncpd_hellhound_heat_5` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q000_nomad_v_sport2_mizutani_shion_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q000_nomad_v_sport2_quadra_type66_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q000_nomad_v_standard25_thorton_colby_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q000_nomad_v_standard2_archer_quartz_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q000_nomad_v_standard2_thorton_galena_nomad_quest` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_archer_quartz_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_mizutani_shion_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_mood_scene_car_001` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_mood_scene_car_002` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_mood_scene_car_racer` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_nomad_car_cassidy` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_nomad_car_regular_001` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_nomad_car_teddy` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_quadra_type66_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_thorton_colby_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q114_thorton_galena_nomad` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q202_bob_vehicle` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q202_teddy_vehicle` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q301_wtc_kurt_supron` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q302_protest_kurtz_supron` | Quest / scene / encounter — firing not checked |\n| `Vehicle.q305_holo_hellhound` | Special/broken variant — inspect before use |\n| `Vehicle.q305_maxtac_hellhound` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sq004_raffen_shiv_car_hackable_001` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sq004_raffen_shiv_car_hackable_002` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sq004_raffen_shiv_car_hackable_003` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sq027_car_cassidy` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sq027_car_teddy` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sq027_savable_raffen_shiv_car` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sts_bls_ina_07_ghost_car` | Quest / scene / encounter — firing not checked |\n| `Vehicle.sts_bls_ina_08_pickup_car` | Quest / scene / encounter — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_02_player` | Experimental player variant |\n| `Vehicle.v_sport2_mizutani_shion_nomad_buggy` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_buggy_quest` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_courier` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_ncu` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_player` | Experimental player variant |\n| `Vehicle.v_sport2_mizutani_shion_nomad_player_missiles` | Mounted armament |\n| `Vehicle.v_sport2_mizutani_shion_nomad_quest` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_samum` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_technical` | Other variant — firing not checked |\n| `Vehicle.v_sport2_mizutani_shion_nomad_wraith` | Other variant — firing not checked |\n| `Vehicle.v_sport2_quadra_type66_nomad` | Other variant — firing not checked |\n| `Vehicle.v_sport2_quadra_type66_nomad_courier` | Other variant — firing not checked |\n| `Vehicle.v_sport2_quadra_type66_nomad_kb` | Other variant — firing not checked |\n| `Vehicle.v_sport2_quadra_type66_nomad_player` | Experimental player variant |\n| `Vehicle.v_sport2_quadra_type66_nomad_player_03` | Experimental player variant |\n| `Vehicle.v_sport2_quadra_type66_nomad_quest` | Other variant — firing not checked |\n| `Vehicle.v_sport2_quadra_type66_nomad_tribute` | Other variant — firing not checked |\n| `Vehicle.v_sport2_quadra_type66_nomad_wraith` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_courier` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_courier_expanded` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.v_standard25_thorton_colby_nomad_mule` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_ncu` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_player` | Experimental player variant |\n| `Vehicle.v_standard25_thorton_colby_nomad_player_missiles` | Experimental player variant |\n| `Vehicle.v_standard25_thorton_colby_nomad_prevention` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_quest` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_vulture` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_nomad_wraith` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_pickup_kurtz_cd` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_pickup_kurtz_cd_no_hull` | Other variant — firing not checked |\n| `Vehicle.v_standard25_thorton_colby_pickup_nomad__basic_01_mq304` | Other variant — firing not checked |\n| `Vehicle.v_standard2_archer_quartz_nomad` | Other variant — firing not checked |\n| `Vehicle.v_standard2_archer_quartz_nomad_courier` | Other variant — firing not checked |\n| `Vehicle.v_standard2_archer_quartz_nomad_disabled_interactions` | Special/broken variant — inspect before use |\n| `Vehicle.v_standard2_archer_quartz_nomad_player` | Experimental player variant |\n| `Vehicle.v_standard2_archer_quartz_nomad_player_02` | Experimental player variant |\n| `Vehicle.v_standard2_archer_quartz_nomad_prevention` | Other variant — firing not checked |\n| `Vehicle.v_standard2_archer_quartz_nomad_quest` | Other variant — firing not checked |\n| `Vehicle.v_standard2_archer_quartz_nomad_wraith` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_courier` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_courier_expanded` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_locust` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_player` | Experimental player variant |\n| `Vehicle.v_standard2_thorton_galena_nomad_player_missiles` | Experimental player variant |\n| `Vehicle.v_standard2_thorton_galena_nomad_prevention` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_prevention_2` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_quest` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_red_green` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_scorpion` | Other variant — firing not checked |\n| `Vehicle.v_standard2_thorton_galena_nomad_wraith` | Other variant — firing not checked |\n| `Vehicle.v_standard3_mahir_supron_kurtz` | Other variant — firing not checked |\n| `Vehicle.v_standard3_mahir_supron_kurtz_basic_mq304` | Other variant — firing not checked |\n| `Vehicle.v_standard3_mahir_supron_kurtz_overlander` | Other variant — firing not checked |\n| `Vehicle.v_standard3_mahir_supron_kurtz_player` | Experimental player variant |\n| `Vehicle.v_standard3_mahir_supron_kurtz_prevention` | Other variant — firing not checked |\n| `Vehicle.v_standard3_militech_hellhound` | Other variant — firing not checked |\n| `Vehicle.v_standard3_militech_hellhound_courier` | Other variant — firing not checked |\n| `Vehicle.v_standard3_militech_hellhound_player` | Experimental player variant |\n| `Vehicle.v_standard3_militech_hellhound_police` | Other variant — firing not checked |","natives":[],"permissions":[],"events":[]},{"anchor":"weapon-and-attachment-ids","heading":"Weapon and attachment IDs","level":2,"text":"These are **not vehicle spawn IDs**. They identify the weapon item and its attachment slot when inspecting a vehicle or maintaining the synchronization adapter.\n\n| Weapon item record | Attachment slot |\n|---|---|\n| `Items.Panzer_Cannon` | `AttachmentSlots.PanzerCannon` |\n| `Items.Vehicle_Missile_Launcher_B` | `AttachmentSlots.VehicleMissileLauncherB` |\n| `Items.Vehicle_Missile_Launcher_C` | `AttachmentSlots.VehicleMissileLauncherC` |\n| `Items.Vehicle_Missile_Launcher_A` | `AttachmentSlots.VehicleMissileLauncherA` |\n| `Items.Vehicle_Power_Weapon_Left_A` | `AttachmentSlots.VehiclePowerWeaponLeftA` |\n| `Items.Panzer_Missile_Launcher` | `AttachmentSlots.PanzerHomingMissiles` |\n| `Items.Vehicle_Power_Weapon_Left_B` | `AttachmentSlots.VehiclePowerWeaponLeftB` |\n| `Items.Vehicle_Power_Weapon_Left_C` | `AttachmentSlots.VehiclePowerWeaponLeftC` |\n| `Items.Vehicle_Power_Weapon_Right_C` | `AttachmentSlots.VehiclePowerWeaponRightC` |\n| `Items.Vehicle_Power_Weapon_Right_B` | `AttachmentSlots.VehiclePowerWeaponRightB` |\n| `Items.Vehicle_Power_Weapon_Right_A` | `AttachmentSlots.VehiclePowerWeaponRightA` |\n| `Items.Panzer_Counter_Measures_Launcher` | `AttachmentSlots.PanzerCounterMeasuresLeft` |\n| `Items.Panzer_Counter_Measures_Launcher` | `AttachmentSlots.PanzerCounterMeasuresRight` |\n| `Items.Vehicle_Power_Weapon_OutlawHeist_Left_A` | `AttachmentSlots.VehiclePowerWeaponLeftA` |\n| `Items.Vehicle_Power_Weapon_OutlawHeist_Right_A` | `AttachmentSlots.VehiclePowerWeaponRightA` |","natives":[],"permissions":[],"events":[]},{"anchor":"sources-and-maintenance","heading":"Sources and maintenance","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"machine-readable-catalogue","heading":"Machine-readable catalogue","level":3,"text":"[Download the website JSON catalogue](/data/vehicle-weapons-2.31.json) for tooling\nand agents. It is the same extracted catalogue used by the server: `gameBuild`,\na `mounts` array of 15 definitions and a `vehicles` object mapping each full spawn\nrecord to an ordered array of mount IDs. Its 172 keys are declarations, not\n172 verified spawn/firing tests. Match each vehicle mount ID to `mount.mountId`;\nthe array position plus one is the stable Lua mount index for that model.\n\n| Source field | Interpretation |\n|---|---|\n| `mount`, `mountId` | Vehicle-weapon mount record, distinct from the vehicle spawn record. |\n| `weapon`, `weaponId` | Weapon item identity. Multiple slots may use the same item. |\n| `slot`, `slotId` | Attachment-slot identity; combine it with the item to identify the actual mount. |\n| `cycleTime` | Extracted cycle parameter in seconds, not a per-projectile event interval or cooldown remaining. |\n| `wholeBurstProjectiles` | Native declared burst size: 15 for Panzer missiles, 8 for car missiles. It is not magazine ammunition. |\n| `singleShotProjectiles`, `singleProjectileCycleTime` | Additional native emission parameters; not live state. |\n| `range`, `explosionRadius` | Extracted default data, not guaranteed physical reach or the active attack's blast radius. A zero radius does not prove no explosion. |\n| `shootAnimEvent`, `genericShoot`, `genericTick` | Native configuration metadata; not public Lua commands. |\n| `minYaw`, `maxYaw`, `minPitch`, `maxPitch` | Declared mount limits, not the vehicle's live turret aim. |\n\nFor example, the Panzer cannon declares `cycleTime=0.3`; its homing launcher\ndeclares `cycleTime=2` but emits multiple missiles in a salvo. Countermeasure\nmounts can declare one whole-burst projectile while emitting multiple\nprojectiles. Do not infer network admission or ammunition from a\nsingle catalogue field; the [multiplayer guide](vehicle-weapons.md) separates\nnative behavior from the server's policy.","natives":[],"permissions":[],"events":[]},{"anchor":"provenance-and-regeneration","heading":"Provenance and regeneration","level":3,"text":"- [Machine-readable mount catalogue](../server/src/Open77.Server.Core/Vehicles/vehicle-weapons-2.31.json): 172 vehicle records and 15 mount definitions, extracted from the installed 2.31 base and expansion TweakDB data.\n- [Extraction tool](../scripts/research/vehicle-weapons-catalog/Program.cs): model/mount/item/slot bindings and native salvo metadata; it does not inspect each appearance's mounted entities.\n- [Vehicle weapon synchronization research](../docs/research/vehicle-weapons-and-combat.md): live observations, validation boundaries and remaining work.\n- [Complete vehicle model catalogue](../docs/vehicle-models.md): broader model/template inventory, including unarmed vehicles.\n\nAfter a game update, regenerate the source catalogue and reconcile this inventory. Promote a record to **Mounted armament** only after spawning that exact variant and observing its real attached weapon fire. Archive-dependent vehicles also require the corresponding game content to be installed.","natives":[],"permissions":[],"events":[]}]},{"slug":"attachments","file":"attachments.md","title":"Synchronized attachments","summary":"Attach a server-owned prop to a player or vehicle, optionally at a skeletal slot. Clients follow their rendered parent and animated slot; the server replicates the attachment binding and its revisions.","sections":[{"anchor":"synchronized-attachments","heading":"Synchronized attachments","level":1,"text":"Attach a server-owned prop to a player or vehicle, optionally at a skeletal slot. Clients follow their rendered parent and animated slot; the server replicates the attachment binding and its revisions.\n\nUse [player interactions](player-interactions.md) to coordinate two players. Attaching a prop does not reserve a player or start an animation.","natives":[],"permissions":[],"events":[]},{"anchor":"resource-setup","heading":"Resource setup","level":2,"text":"```lua\nresource 'medical_bag'\nversion '1.0.0'\ndependency 'open77_props >=0.1.0'\npermissions { 'world.props' }\nserver_script 'server.lua'\n```\n\nThe bundled [`open77_props`](https://github.com/Open2077/open77-resources/tree/main/system/open77_props) client projection must be running for synchronized\nprops. The server owns the object and attachment. Another resource may inspect\nit, but cannot mutate or remove it. Resource stop removes its props.","natives":[],"permissions":["world.props"],"events":[]},{"anchor":"attach-a-prop-to-a-hand","heading":"Attach a prop to a hand","level":2,"text":"```lua\n-- Server. playerId is authenticated/validated by your gameplay code.\nlocal p = Open77.players.position(playerId)\nif not p then return end\nlocal propId, reason = Open77.props.create({\n model = 'container.gas_can',\n position = {x=p.x+2, y=p.y, z=p.z},\n bucket = p.bucket,\n scale = 0.5,\n})\nif not propId then print(reason); return end\nlocal ok, err = Open77.props.attach(propId, {\n parentType = 'player', parentId = playerId, bone = 'RightHand',\n offset = {x=0, y=0, z=0},\n rotation = {x=0, y=0, z=0},\n})\nif not ok then Open77.props.remove(propId); print(err) end\n```\n\nThe zero transform is a starting point, not a calibrated grip for every mesh.\nTune the transform for your chosen model. For a back accessory, select a slot\nsuch as `Chest`/`spine` that the target rig actually exposes and adjust the offset.\nNever assume that every named slot exists on both the real player and a replica.","natives":["server:Open77.players.position","Open77.props.create","server:Open77.props.create","Open77.props.attach","server:Open77.props.attach","Open77.props.remove","server:Open77.props.remove"],"permissions":[],"events":[]},{"anchor":"binding-fields","heading":"Binding fields","level":2,"text":"| Field | Meaning |\n| --- | --- |\n| `parentType` | Required: `\"player\"` or `\"vehicle\"`. |\n| `parentId` | Required canonical network ID, positive integer ≤ 2^53−1; **not** a client entity handle. Decimal strings are accepted server-side. |\n| `bone` | Case-sensitive named slot. Omitted/`\"\"` attaches to the parent's root. At most 64 ASCII letters, digits, `_` or `-`. |\n| `offset` | `{x,y,z}` in metres, in the slot's local coordinate system. Missing components default to zero; each component must be finite and within ±20. |\n| `rotation` | `{x,y,z}` in degrees: X roll, Y pitch, Z yaw; each finite and within ±360. Applied as parent rotation × Z × Y × X. |\n| `contact` | Experimental native-item mouth contact: omit for automatic estimation, `false` to disable, or `{x,y,z}` in item-local metres, each finite and within ±2 (missing axes are zero). Requires an `item:Items.*` prop; ordinary meshes return `contact_requires_item`. See [item size and mouth contact](rp-animations.md#item-size-and-mouth-contact). |\n\nNative item props use the game's authored `WeaponRight` attachment on a player,\nwith zero offset/rotation and unit scale. The contact point controls the arm's\nmouth target during supported consumption profiles; it does not move the grip.\nAutomatic contact uses the container's authored local-up end (the can rim) and\nkeeps that end while tilting; smoking uses the end nearest the mouth.\nMatching client/server code and animation assets are required, and visual\nvalidation of this adapter is incomplete.\n\nA vehicle binding normally uses its root, because slots differ between models:\n\n```lua\nlocal ok, err = Open77.props.attach(propId, {\n parentType='vehicle', parentId=vehicleId, bone='',\n offset={x=0,y=0,z=1.8}, rotation={x=0,y=0,z=90},\n})\n```\n\nThe prop and parent must already be in the same routing bucket. A parent has a\nmaximum of 32 attached props across resources. Lights and looping effects are\nnot attachable through this API.","natives":["Open77.props.attach","server:Open77.props.attach"],"permissions":[],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"All methods below require `world.props` and return directly (no promise).\n\n| Method | Result |\n| --- | --- |\n| `Open77.props.attach(id, binding, expectedRevision?)` | `true`, or `nil, reason`. Also reparents an already attached prop. |\n| `Open77.props.detach(id, expectedRevision?)` | `true`, or `nil, reason`. Detaching an already detached owned prop succeeds. |\n| `Open77.props.getAttachment(id)` | Binding table, or `nil` if absent/detached. |\n| `Open77.props.isAttached(id)` | Boolean; check the second result for a permission/runtime error. |\n| `Open77.props.attachedTo(parentType, parentId)` | Array of prop snapshots, sorted by ID, including props owned by other resources. |\n| `Open77.props.setAttachmentTransform(id, offset?, rotation?, expectedRevision?)` | Updates only the local transform. Omitted vectors retain their values; default revision is the current snapshot revision. |\n\n`Open77.props.get(id)` and `all()` include the optional `attachment` field.\n`parentId` in a returned binding is a decimal string. Repeated identical attaches\nare idempotent. An explicit stale `expectedRevision` returns `stale_revision`\nbefore any mutation. Use this to protect item handoffs:\n\n```lua\nlocal prop = Open77.props.get(propId)\nif not prop then return end\nlocal ok, err = Open77.props.attach(propId, {\n parentType='player', parentId=recipientId, bone='RightHand',\n}, prop.revision)\n-- Update gameplay ownership only after ok, in the resource that owns the prop.\n```\n\n`setTransform` is rejected while attached. Detach first to place an object in\nworld space. Changing its bucket to a different bucket while attached is also\nrejected. The server's stored position is an interest/drop anchor near the parent,\n**not an authoritative animated hand position**. To place a dropped item exactly,\ndetach and use a server-validated placement position.","natives":["server:Open77.props.get","Open77.props.attach","server:Open77.props.attach"],"permissions":[],"events":[]},{"anchor":"client-api-and-diagnostics","heading":"Client API and diagnostics","level":2,"text":"`Open77.props.bones(parentType, parentId)` returns an array of named slots for the\ncurrently rendered parent. An unavailable/unstreamed parent or a parent without\nnamed slots returns an empty array; invalid arguments, missing permissions or\nbackend failures return `nil, reason`. Root binding needs no named slot, but the\nparent must still be rendered before its attached prop becomes visible.\n\n```lua\nlocal bones, err = Open77.props.bones('player', playerId)\nif bones then for _, name in ipairs(bones) do print(name) end end\n```\n\nClient-created decorative props also support `Open77.props.attach(localPropId,\nbinding)` and `detach(localPropId)`. These calls affect only props owned by the\ncalling client resource, are not network authority and do not modify a\nserver-owned prop. Their parent IDs are still canonical IDs, not native pointers.\nClient snapshots expose `attachment` and `attachmentStatus` for diagnosis.\nAn attached prop reports `rendered=false` until its parent/slot is bound and its\nown visibility flag permits rendering. A missing slot hides the projection; it\ndoes not silently place the prop at the player's feet.","natives":["Open77.props.bones"],"permissions":[],"events":[]},{"anchor":"lifecycle-and-events","heading":"Lifecycle and events","level":2,"text":"Server event:\n\n```lua\nAddEventHandler('onPropAttachmentChanged', function(id, current, previous, reason, revision)\n -- current/previous are binding tables or nil; revision belongs to this edge.\n -- Do not infer inventory ownership from a client observation.\nend)\n```\n\nPlayer death/disconnection, parent bucket changes and vehicle removal detach\nthe child on the server. Removing the child also emits its terminal attachment\nchange. Resource stop removes the child, rather than leaving an orphan binding.\nStreaming is different: a temporarily absent parent hides the client projection;\nthe binding is retained and follows the new local entity when it streams back in.\nNo local fallback body or native pointer is serialized.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"physics-assets-and-failures","heading":"Physics, assets and failures","level":2,"text":"Attached props use separately authored **visual-only mesh hosts**, with no\nphysical mesh shape. This prevents an attached object from pushing its own\ncarrier. Attach/detach switches host type; changing offset or reparenting an\nalready attached prop does not require a new host. Attached props are not a\nrigid-body simulation and do not collide with walls.\n\nUse curated prop aliases with generated attachment hosts. Arbitrary `.ent` and\nraw `.mesh` paths are not supported for attachment. Server acceptance alone does\nnot prove that a client installed the matching archive or that its current rig\nhas the requested slot. Keep client assets aligned with the resource release.\n\nCommon server refusals: `not_found`, `owned_by_another_resource`, `stale_revision`,\n`parent_unavailable`, `wrong_bucket`, `invalid_attachment_parent`,\n`invalid_attachment_bone`, `invalid_attachment_transform`,\n`attachment_kind_unsupported`, `attachment_parent_limit`.\n\nThe bundled `prop.pickup`/`prop.drop` commands now use this binding mechanism.\nTheir opt-in persistence still saves a dropped prop once at its server position;\nit does not persist a live player attachment across a restart.","natives":[],"permissions":[],"events":[]}]},{"slug":"blips","file":"blips.md","title":"Vanilla blips and mappins","summary":"`Open77.blips` creates native mappins for the HUD, minimap and world map. Visibility depends on the sprite's native profile. Client resources require `ui.vanilla.map`:","sections":[{"anchor":"vanilla-blips-and-mappins","heading":"Vanilla blips and mappins","level":1,"text":"`Open77.blips` creates native mappins for the HUD, minimap and world map. Visibility depends on the sprite's native profile. Client resources require `ui.vanilla.map`:\n\n```lua\npermissions { \"ui.vanilla.map\" }\n```\n\nThe ids returned are 64-bit decimal strings. Keep them as they are: never convert them with `tonumber`.\n\nUse this guide to mark a position or an entity on the player's map, minimap, and HUD.","natives":[],"permissions":["ui.vanilla.map"],"events":[]},{"anchor":"a-first-blip","heading":"A first blip","level":2,"text":"```lua\nlocal blip, reason = Open77.blips.create({\n position = { x = -1442.2, y = 127.4, z = 18.0 },\n sprite = \"objective\",\n title = \"Vehicle Dealership\",\n description = \"Purchase and collect street-legal vehicles.\",\n active = true,\n visibleThroughWalls = false\n})\n\nassert(blip, reason)\nassert(Open77.blips.setPosition(blip, { x = -1440.0, y = 130.0, z = 18.0 }))\nassert(Open77.blips.setSprite(blip, \"VehicleVariant\"))\nassert(Open77.blips.remove(blip))\n```","natives":["Open77.blips.create","Open77.blips.setPosition","Open77.blips.setSprite","Open77.blips.remove"],"permissions":[],"events":[]},{"anchor":"custom-svg-icons-and-colors","heading":"Custom SVG icons and colors","level":2,"text":"**Client only · Experimental.** SVG rendering and per-blip colors require Cyberpunk 2077 2.31 and an OPEN//77 client with the native SVG adapter. See [Custom SVG blips](custom-blip-icons.md) for a complete resource, supported SVG features, limits and troubleshooting.\n\nDeclare the icon in your resource's manifest:\n\n```lua\nfiles { \"icons/fuel.svg\" }\npermissions { \"ui.vanilla.map\" }\n```\n\nCreate the blip in its client script after the player has entered the world:\n\n```lua\nlocal station, reason = Open77.blips.create({\n position = { x = -595.785522, y = -722.328979, z = 8.715637 },\n sprite = \"tech\",\n title = \"Fuel station\",\n icon = { asset = \"icons/fuel.svg\", size = 48 },\n color = \"#FFCC00\"\n})\nassert(station, reason)\n\nassert(Open77.blips.setColor(station, \"#00CCFF80\"))\nassert(Open77.blips.setColor(station, false)) -- source colors\nassert(Open77.blips.setIcon(station, false)) -- native sprite\n```\n\nThe icon is rendered as native Ink geometry, while the mappin retains selection, tracking and tooltips. Use a declared resource-relative SVG path or `{ asset, size }`; URLs and raster images are rejected. Size defaults to 48 and accepts 16–128 native Ink units.\n\n`color` accepts `#RRGGBB`, `#RRGGBBAA` or `false`. It also works without an SVG and affects only that owned blip. Clearing an icon preserves its color override. For SVGs the override replaces RGB and multiplies source alpha. Invalid replacements leave the existing icon unchanged.","natives":["Open77.blips.create","Open77.blips.setColor","Open77.blips.setIcon"],"permissions":["ui.vanilla.map"],"events":[]},{"anchor":"following-an-entity","heading":"Following an entity","level":3,"text":"A blip can follow an Open77 entity instead of a position:\n\n```lua\nlocal playerBlip = assert(Open77.blips.create({\n entity = remotePlayerEntityId,\n sprite = \"remote_player\",\n slot = \"poi_mappin\",\n offset = { x = 0.0, y = 0.0, z = 2.0 }\n}))\n\nOpen77.blips.attachToEntity(playerBlip, anotherEntityId, \"poi_mappin\", {\n x = 0.0, y = 0.0, z = 2.0\n})\n```\n\n`position` and `entity` are mutually exclusive at creation. `setPosition` turns an attached blip into a positional one. In `update`, `entity = false` only detaches when a new `position` is supplied.","natives":["Open77.blips.create","Open77.blips.attachToEntity"],"permissions":[],"events":[]},{"anchor":"a-calculated-gps-route","heading":"A calculated GPS route","level":2,"text":"Tracking a normal static point selects its mappin but does not make REDengine calculate a road\npath. Create a positional blip with `routable = true`, then track it:\n\n```lua\nlocal destination = assert(Open77.blips.create({\n position = { x = -1442.2, y = 127.4, z = 18.0 },\n routable = true,\n title = \"Next checkpoint\"\n}))\n\nlocal ok, changed = Open77.blips.track(destination)\nassert(ok, changed)\n```\n\nThe routable form uses the trusted `Mappins.CustomPositionMappinDefinition` and\n`CustomPositionVariant` pair used by a right-click waypoint. Its sprite is therefore fixed to the\ncustom-waypoint variant. It cannot follow an entity. REDengine owns the road calculation and the\nminimap/HUD path. A generic waypoint does **not** automatically create the orange 3D chevrons used\nby the authored `sq024` street race; those are separate world entities. `update` can move the\ndestination but cannot change `routable`; remove and recreate it to change kind.","natives":["Open77.blips.create","Open77.blips.track"],"permissions":[],"events":[]},{"anchor":"full-api","heading":"Full API","level":2,"text":"| Function | Result | Role |\n|---|---|---|\n| `create(options)` | `id`, or `nil, reason` | Creates a positional or attached blip. |\n| `update(id, patch)` | `boolean, reason?` | Changes several properties in one operation. |\n| `setPosition(id, position)` | `boolean, reason?` | Moves the blip and makes it positional. |\n| `attachToEntity(id, entity, slot?, offset?)` | `boolean, reason?` | Attaches the blip to an Open77 entity. |\n| `setSprite(id, sprite)` | `boolean, reason?` | Accepts a name, an alias, or an integer `0..146`. |\n| `setTitle(id, title)` | `boolean, reason?` | Changes the fullscreen-map title, 128 bytes maximum. |\n| `setDescription(id, description)` | `boolean, reason?` | Changes the fullscreen-map description, 1024 bytes maximum. An empty string hides it. |\n| `setLabel(id, label)` | `boolean, reason?` | Backward-compatible alias of `setTitle`. |\n| `setIcon(id, iconOrFalse)` | `boolean, reason?` | Sets a declared SVG icon or restores the native sprite. |\n| `setColor(id, colorOrFalse)` | `boolean, reason?` | Sets a per-blip RGB/RGBA hex color or restores source colors. |\n| `setActive(id, active)` | `boolean, reason?` | Enables or disables the vanilla mappin. |\n| `setVisibleThroughWalls(id, visible)` | `boolean, reason?` | Changes visibility through walls. |\n| `setRange(id, metres \\| false)` | `boolean, reason?` | Switches the blip off beyond `metres` and back on when the player returns. |\n| `setTrackingAlternative(id, targetIdOrNil)` | `boolean, reason?` | Sets or clears the alternative routing blip. |\n| `track(id)` | `true, changed`, or `false, reason` | Selects this blip as the vanilla GPS destination. |\n| `untrack(id)` | `true, wasTracked`, or `false, reason` | Removes tracking only if this blip is the tracked one. |\n| `get(id)` | `snapshot`, or `nil, reason` | Reads a blip owned by the current resource. |\n| `list()` | `snapshots`, or `nil, reason` | Lists only the current resource's blips. |\n| `sprites()` | `{ {name, value}, ... }` | Returns the current build's 147 variants. |\n| `remove(id)` | `boolean, reason?` | Deletes a blip. |\n| `clear()` | `true` | Deletes every blip owned by the resource. |\n| `waypoint()` | `{ x, y, z }`, `nil`, or `nil, reason` | Where the player's map waypoint is. |\n| `setWaypoint(position)` | `true`, or `false, reason` | Places the player's map waypoint. |\n| `clearWaypoint()` | `true, wasSet`, or `false, reason` | Clears the waypoint, whoever set it. |\n\n`create` options: `position` or `entity`, `sprite`, `title`, `description`, `icon`, `color`, `active`, `visibleThroughWalls`, `range`, `routable`, plus `slot` and `offset` for an entity. `label` remains an alias for `title`; do not provide both. `update` accepts the mutable fields (not `routable`), and the dedicated setters can change text at runtime. The quotas are 128 blips per resource and 512 per client. Stopping, reloading, and leaving the world clean up blips automatically.\n\nA resource can neither read, change, nor delete another resource's blip. The TweakDB type is fixed to `Mappins.DefaultStaticMappin`, or to the single trusted custom-position definition when `routable = true`; downloaded packages cannot inject an arbitrary UI profile.\n\n`title` and `description` are resource-owned text. When the player highlights the blip on the fullscreen map, Open77 replaces the variant-specific tooltip with those exact values. Variant-specific fixer progress, threat, journal, price and travel panels are hidden for Open77 blips. HUD-only variants can still be absent from the fullscreen map; use a map-capable sprite such as `objective`, `quest`, `fast_travel`, `vehicle`, or a service-point variant when map selection is required.","natives":[],"permissions":[],"events":[]},{"anchor":"stable-aliases","heading":"Stable aliases","level":2,"text":"| Alias | Variant |\n|---|---|\n| `default` | `DefaultVariant` |\n| `objective` | `DefaultQuestVariant` |\n| `quest` | `QuestGiverVariant` |\n| `important` | `ExclamationMarkVariant` |\n| `question` | `QuestionMarkVariant` |\n| `fast_travel` | `FastTravelVariant` |\n| `vehicle` | `VehicleVariant` |\n| `loot` | `LootVariant` |\n| `danger` | `HazardWarningVariant` |\n| `vendor` | `OpenVendorVariant` |\n| `apartment`, `stash`, `wardrobe` | matching variants |\n| `bar`, `clothes`, `cyberware`, `drop_point`, `food`, `guns`, `junk`, `meds`, `ripperdoc`, `tech` | matching `ServicePoint*` variants |\n| `race`, `ncart`, `fixer`, `tarot` | matching variants |\n| `ping_door`, `ping_go_here`, `ping_loot`, `remote_player` | matching `CPO_*` variants |\n\nExact names are insensitive to case, spaces, hyphens, and underscores. Aliases are preferable for generic gameplay; full names are useful when a server wants one specific vanilla asset.","natives":[],"permissions":[],"events":[]},{"anchor":"complete-sprite-list-cyberpunk-2077-231","heading":"Complete sprite list (Cyberpunk 2077 2.31)","level":2,"text":"`Count=147` and `Invalid=148` are sentinels and are not accepted. The usable values are:\n\n| ID | Nom | ID | Nom | ID | Nom |\n|---:|---|---:|---|---:|---|\n| 0 | `ActionDealDamageVariant` | 49 | `ExclamationMarkVariant` | 98 | `ServicePointDropPointVariant` |\n| 1 | `ActionFastSoloVariant` | 50 | `FailedCrossingVariant` | 99 | `ServicePointFoodVariant` |\n| 2 | `ActionGenericInteractionVariant` | 51 | `FastTravelVariant` | 100 | `ServicePointGunsVariant` |\n| 3 | `ActionNetrunnerAccessPointVariant` | 52 | `FixerVariant` | 101 | `ServicePointJunkVariant` |\n| 4 | `ActionNetrunnerVariant` | 53 | `FocusClueVariant` | 102 | `ServicePointMedsVariant` |\n| 5 | `ActionScanVariant` | 54 | `GPSForcedPathVariant` | 103 | `ServicePointMeleeTrainerVariant` |\n| 6 | `ActionSoloVariant` | 55 | `GPSPortalVariant` | 104 | `ServicePointNetTrainerVariant` |\n| 7 | `ActionTechieVariant` | 56 | `GangWatchVariant` | 105 | `ServicePointProstituteVariant` |\n| 8 | `AimVariant` | 57 | `GenericRoleVariant` | 106 | `ServicePointRipperdocVariant` |\n| 9 | `AllowVariant` | 58 | `GetInVariant` | 107 | `ServicePointTechVariant` |\n| 10 | `ApartmentVariant` | 59 | `GetUpVariant` | 108 | `SitVariant` |\n| 11 | `ArrowVariant` | 60 | `GrenadeVariant` | 109 | `SmugglersDenVariant` |\n| 12 | `BackOutVariant` | 61 | `GunSuicideVariant` | 110 | `SoloTechieVariant` |\n| 13 | `BountyHuntVariant` | 62 | `HandVariant` | 111 | `SoloVariant` |\n| 14 | `CallVariant` | 63 | `HazardWarningVariant` | 112 | `SpeechVariant` |\n| 15 | `ChangeToFriendlyVariant` | 64 | `HiddenStashVariant` | 113 | `TakeControlVariant` |\n| 16 | `ClientInDistressVariant` | 65 | `HitVariant` | 114 | `TakeDownVariant` |\n| 17 | `ConversationVariant` | 66 | `HuntForPsychoVariant` | 115 | `TarotVariant` |\n| 18 | `ConvoyVariant` | 67 | `ImportantInteractionVariant` | 116 | `TechieVariant` |\n| 19 | `CoolVariant` | 68 | `InvalidVariant` | 117 | `ThieveryVariant` |\n| 20 | `CourierVariant` | 69 | `JackInVariant` | 118 | `UseVariant` |\n| 21 | `CustomPositionVariant` | 70 | `JamWeaponVariant` | 119 | `VehicleVariant` |\n| 22 | `CyberspaceNPC` | 71 | `LifepathCorpoVariant` | 120 | `WanderingMerchantVariant` |\n| 23 | `CyberspaceObject` | 72 | `LifepathNomadVariant` | 121 | `Zzz01_CarForPurchaseVariant` |\n| 24 | `DefaultInteractionVariant` | 73 | `LifepathStreetKidVariant` | 122 | `Zzz02_MotorcycleForPurchaseVariant` |\n| 25 | `DefaultQuestVariant` | 74 | `LootVariant` | 123 | `Zzz03_MotorcycleVariant` |\n| 26 | `DefaultVariant` | 75 | `MinorActivityVariant` | 124 | `Zzz04_PreventionVehicleVariant` |\n| 27 | `DistractVariant` | 76 | `NPCVariant` | 125 | `Zzz05_ApartmentToPurchaseVariant` |\n| 28 | `DropboxVariant` | 77 | `NetrunnerAccessPointVariant` | 126 | `Zzz06_NCPDGigVariant` |\n| 29 | `DynamicEventVariant` | 78 | `NetrunnerSoloTechieVariant` | 127 | `Zzz07_PlayerStashVariant` |\n| 30 | `EffectAlarmVariant` | 79 | `NetrunnerSoloVariant` | 128 | `Zzz08_WardrobeVariant` |\n| 31 | `EffectControlNetworkVariant` | 80 | `NetrunnerTechieVariant` | 129 | `Zzz09_CourierSandboxActivityVariant` |\n| 32 | `EffectControlOtherDeviceVariant` | 81 | `NetrunnerVariant` | 130 | `Zzz10_RemoteControlDrivingVariant` |\n| 33 | `EffectControlSelfVariant` | 82 | `NonLethalTakedownVariant` | 131 | `Zzz11_RoadBlockadeVariant` |\n| 34 | `EffectCutPowerVariant` | 83 | `OffVariant` | 132 | `Zzz12_QuickHackQueueVariant` |\n| 35 | `EffectDistractVariant` | 84 | `OpenVendorVariant` | 133 | `Zzz12_WorldEncounterVariant` |\n| 36 | `EffectDropPointVariant` | 85 | `OutpostVariant` | 134 | `Zzz13_DogtownGateVariant` |\n| 37 | `EffectExplodeLethalVariant` | 86 | `PhoneCallVariant` | 135 | `Zzz14_ServicePointBlackMarketVariant` |\n| 38 | `EffectExplodeNonLethalVariant` | 87 | `QuestGiverVariant` | 136 | `Zzz15_QuickHackDurationVariant` |\n| 39 | `EffectFallVariant` | 88 | `QuestionMarkVariant` | 137 | `Zzz16_RelicDeviceBasicVariant` |\n| 40 | `EffectGrantInformationVariant` | 89 | `QuickHackVariant` | 138 | `Zzz16_RelicDeviceSpecialVariant` |\n| 41 | `EffectHideBodyVariant` | 90 | `ReflexesVariant` | 139 | `Zzz17_NCARTVariant` |\n| 42 | `EffectLootVariant` | 91 | `ResourceVariant` | 140 | `Zzz18_RacingVariant` |\n| 43 | `EffectOpenPathVariant` | 92 | `RetrievingVariant` | 141 | `Zzz19_DelamainTaxiVariant` |\n| 44 | `EffectPushVariant` | 93 | `SOSsignalVariant` | 142 | `Zzz20_DelamainTaxiDestinationVariant` |\n| 45 | `EffectServicePointVariant` | 94 | `SabotageVariant` | 143 | `CPO_PingDoorVariant` |\n| 46 | `EffectShootVariant` | 95 | `ServicePointBarVariant` | 144 | `CPO_PingGoHereVariant` |\n| 47 | `EffectSpreadGasVariant` | 96 | `ServicePointClothesVariant` | 145 | `CPO_PingLootVariant` |\n| 48 | `EffectStoreItemsVariant` | 97 | `ServicePointCyberwareVariant` | 146 | `CPO_RemotePlayerVariant` |\n\nA variant existing in the enum does not guarantee its profile renders on every surface. The `CPO_*` variants, for instance, come from a dormant multiplayer HUD and must be checked visually in the server's context.","natives":[],"permissions":[],"events":[]},{"anchor":"a-blip-that-only-shows-up-close","heading":"A blip that only shows up close","level":2,"text":"`range` switches a blip off once the player is further away than the given number of metres, and\nback on when they return. This visibility gate is independent of SVG styling and color.\n\n```lua\n-- A shop pin that stops cluttering the map from across the city.\nlocal shop = assert(Open77.blips.create({\n position = { x = -1442.2, y = 127.4, z = 18.0 },\n sprite = \"vendor\",\n title = \"Kabuki Market\",\n range = 180\n}))\n\nOpen77.blips.setRange(shop, 400) -- widen it\nOpen77.blips.setRange(shop, false) -- or take the gate off entirely\nprint(Open77.blips.get(shop).range) -- 0 when there is no gate\n```\n\n**It is Open77's own doing, not an engine property.** There is no range field on a mappin. What the\nengine publishes is `SetMappinActive`, so Open77 measures the distance from the player on the game\nthread and drives that one call — only on a transition, and only for blips that asked for a range,\nso a client whose resources never use it pays nothing. An entity-following blip is measured from the\nbody it follows; while that body is not streamed in, the gate keeps whatever state it last wrote\nrather than guessing a distance.\n\n**It is therefore not FiveM's `SetBlipAsShortRange`.** That one hides a blip from the minimap and\nleaves it on the world map. Cyberpunk offers no per-surface control here, so out of range means off\non the HUD, the minimap *and* the world map. The name is `range` and not `shortRange` for exactly\nthat reason, and `shortRange` is refused by name rather than quietly given these different manners.\n\n`range` composes with `active` instead of overriding it: a blip you set inactive stays inactive in\nrange, and a ranged blip you re-activate while out of range stays hidden until you walk back. Limits\nare `0` (no gate, the default) to `4000` metres; anything else is `invalid_range`.","natives":["Open77.blips.create","Open77.blips.setRange","Open77.blips.get"],"permissions":[],"events":[]},{"anchor":"unsupported-fivem-options","heading":"Unsupported FiveM options","level":2,"text":"Use the OPEN//77 options instead of FiveM palette IDs or per-surface visibility flags. Unsupported properties return a reason on both `create` and `update`.\n\n| Option | Reason | Alternative |\n|---|---|---|\n| `colour` | `unsupported_option:colour` | Use `color` or `setColor` with a hex color. |\n| `alpha`, `opacity` | `unsupported_option:alpha` / `:opacity` | Use the alpha bytes in `#RRGGBBAA`. |\n| `scale` | `unsupported_option:scale` | Use `icon.size` for an SVG. |\n| `shortRange` | `unsupported_option:shortRange` | Use `range`; it hides the pin on all surfaces, not just the minimap. |\n| `category` | `unsupported_option:category` | No per-blip category option. |\n| `kind = \"radius\"` | `unsupported_kind:radius` | Use [3D markers](markers.md) for a world-space area, not a map overlay. |\n| Other `kind` values | `unsupported_option:kind` | Create a positional or entity-attached blip. |\n\nNative sprites keep their default colors unless `color` overrides them. Custom SVGs do not add native variant IDs or mount archives. Keep a suitable `sprite` for native behavior and fallback.\n\n`track(id)` selects the blip as the manually tracked mappin and returns `changed = false` when it is already tracked. A road route additionally requires `routable = true` at creation. `untrack(id)` only clears tracking if that blip is selected; it does not remove another resource's destination or a vanilla objective.","natives":[],"permissions":[],"events":[]},{"anchor":"the-players-waypoint","heading":"The player's waypoint","level":2,"text":"`track` and `untrack` above act on **your** blip. The waypoint is the other half: the destination\nthe player chose, which until now no resource could see or move.\n\n```lua\n-- A taxi fare: take the destination the player pinned on their own map.\nlocal to = Open77.blips.waypoint()\nif to then\n Open77.events.emitServer(\"taxi:requestRide\", to)\nend\n\n-- Or route them somewhere the job picked.\nOpen77.blips.setWaypoint({ x = -1540.0, y = -2020.0, z = 24.0 })\n\nAddEventHandler(\"open77:waypointChanged\", function(state)\n if state.present then\n print((\"waypoint moved to %.0f, %.0f\"):format(state.position.x, state.position.y))\n else\n print(\"waypoint cleared\")\n end\nend)\n```\n\nIn this engine a waypoint is not a separate thing: **it is the manually tracked mappin**, the same\nselection `track` writes. `setWaypoint` registers a routable custom-position pin and tracks it, so\nthe vanilla GPS calculates a road route exactly as it does for a waypoint the player set by hand.\n\nFour behaviours worth knowing, each of which exists because the alternative bites:\n\n- **`nil` and `nil, reason` are different answers.** `nil` alone means there is no waypoint; `nil`\n with a reason means the question could not be asked. A GPS script must branch on that, because\n cancelling a fare is correct for only the first.\n- **The pin is yours and dies with you.** It is an ordinary blip owned by the calling resource, so\n a crashed taxi script leaves no permanent arrow on the player's map. It also counts against your\n 128-blip quota -- but calling `setWaypoint` again *moves* the same pin rather than registering\n another, so updating a destination on a timer is safe.\n- **`clearWaypoint` is not owner-scoped, and `untrack` is.** Clearing the waypoint means the\n player's waypoint, whoever set it, because that is what a route-cancel button does. It still\n refuses to *destroy* another resource's pin: only the tracking selection is released.\n- **The event covers waypoints Open77 did not set.** The engine raises nothing when a player\n right-clicks the fullscreen map, so Open77 polls the tracked mappin and publishes the difference.\n That poll is the only channel by which a player-chosen destination reaches a resource at all.\n\n`open77:waypointChanged` carries one table, `{ present, position }`, rather than three loose\ncoordinates: a cleared waypoint has to be a value a handler can read, not an absence of arguments.","natives":["Open77.blips.waypoint","Open77.blips.setWaypoint","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:waypointChanged"]}]},{"slug":"callbacks","file":"callbacks.md","title":"Network callbacks: asking the other side a question","summary":"Network callbacks provide asynchronous request/response communication between client and server. A call returns an `Open77.Promise` that resolves with the response or rejects with an error.","sections":[{"anchor":"network-callbacks-asking-the-other-side-a-question","heading":"Network callbacks: asking the other side a question","level":1,"text":"Network callbacks provide asynchronous request/response communication between client and server. A call returns an `Open77.Promise` that resolves with the response or rejects with an error.\n\nCallbacks serve the same request/response role as FiveM framework callbacks. Validate requests on the receiving side; callbacks do not grant additional authority.\n\nCallbacks ride the existing net-event transport, so they need the same\n`network.events` manifest permission and nothing more. They are **not** a new\ntrust level: read [A callback is a request, never a grant](#a-callback-is-a-request-never-a-grant)\nbefore you write your first handler.","natives":[],"permissions":[],"events":[]},{"anchor":"the-two-directions","heading":"The two directions","level":2,"text":"Client asks, server answers — the shape you will use ninety per cent of the time:\n\n```lua\n-- resources/shop/server.lua\nOpen77.net.register('getStock', function(source, item)\n -- `source` is the authenticated player id. The client cannot forge it.\n if type(item) ~= 'string' or #item > 64 then return nil, 'invalid_item' end\n if not atTheCounter(source) then return nil, 'too_far' end -- re-validate, every time\n return stock[item] or 0, kPrices[item]\nend)\n```\n\n```lua\n-- resources/shop/client.lua\nCreateThread(function()\n local count, price = Open77.net.callAwait('getStock', 'ripperdoc_kit')\n if count == nil then\n Open77.log.warn('stock lookup failed: ' .. tostring(price))\n return\n end\n print(('%d in stock at %d eddies'):format(count, price))\nend)\n```\n\nServer asks, client answers — for anything only the client knows (a UI choice,\na progress bar the player can cancel, a local reading):\n\n```lua\n-- resources/shop/client.lua (`confirm` is your own dialog, not a platform API)\nOpen77.net.register('confirmPurchase', function(item, price)\n return confirm(('Buy %s for %d?'):format(item, price))\nend)\n```\n\n```lua\n-- resources/shop/server.lua\nlocal accepted = Open77.net.callClientAwait(source, 'confirmPurchase', item, price)\nif accepted ~= true then return end -- refused, timed out, or disconnected\n```","natives":["Open77.net.register","server:Open77.net.register","CreateThread","server:CreateThread","Open77.net.callAwait","Open77.log.warn","server:Open77.log.warn","server:Open77.net.callClientAwait"],"permissions":[],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"server","heading":"Server","level":3,"text":"| Call | Meaning |\n|---|---|\n| `Open77.net.register(name, handler)` | Answer a client's `Open77.net.call`. `handler(source, ...)` returns any number of values. Returns `true`, or `nil, reason`. |\n| `Open77.net.unregister(name)` | Stop answering. Returns `true`, or `false, reason`. |\n| `Open77.net.callClient(playerId, name, ...)` | Ask one client a question. Returns an `Open77.Promise`, or `nil, reason`. |\n| `Open77.net.callClientAwait(playerId, name, ...)` | `callClient(...):await()`, for use inside a `CreateThread`. |\n| `Open77.callbacks.*` | Alias table carrying the same four functions. Pick one spelling per resource. |","natives":[],"permissions":[],"events":[]},{"anchor":"client","heading":"Client","level":3,"text":"| Call | Meaning |\n|---|---|\n| `Open77.net.register(name, handler)` | Answer the server's `callClient`. `handler(...)` — no `source`, the caller is always the server. |\n| `Open77.net.unregister(name)` | Stop answering. |\n| `Open77.net.call(name, ...)` | Ask the server. Returns an `Open77.Promise`, or `nil, reason`. |\n| `Open77.net.callAwait(name, ...)` | `call(...):await()`, for use inside a `CreateThread`. |\n| `Open77.callbacks.*` | The same alias table. |\n\nThe promise is the ordinary `Open77.Promise` you already get from\n[`Open77.exports.call`](server-exports.md): `p:status()` returns `pending`,\n`resolved`, `rejected` or `cancelled`, and `p:await()` yields inside a\nscheduler coroutine and returns the handler's values, or `nil, reason`.\n\n`await` outside a scheduler coroutine returns `nil, \"await_requires_scheduler_coroutine\"`\nrather than blocking the frame. Wrap it in `CreateThread`.","natives":[],"permissions":[],"events":[]},{"anchor":"namespacing-a-callback-belongs-to-a-resource","heading":"Namespacing: a callback belongs to a resource","level":2,"text":"`Open77.net.call(\"getStock\")` from resource `shop` reaches the handler that\nresource `shop` registered **on the other side**. The client and server halves\nof one resource talk to each other and nobody else. To reach a different\nresource, name it:\n\n```lua\nlocal promise = Open77.net.call({ resource = 'bank', name = 'balance' })\n```\n\nWhy not FiveM's flat global namespace, where `lib.callback('getStock')` reaches\nwhichever resource happened to register that name? Because two resources that\nboth want `getStock` then collide silently, and whichever started last wins for\neveryone. Open77's exports are already addressed as `(resource, name)` and its\npermissions are per resource; callbacks follow the same rule, with the common\ncase — your own resource — as the default so nothing has to be spelled out.\n\nThe cross-resource form is a **table**, not a second positional argument,\nbecause everything after the name is payload: a positional `resource` argument\ncould never be told apart from a first argument that happens to be a string.\n\nBeing reachable is not the same as being trusted. A cross-resource callback is\nstill a request from a client; see the next section.","natives":["Open77.net.call"],"permissions":[],"events":[]},{"anchor":"a-callback-is-a-request-never-a-grant","heading":"A callback is a request, never a grant","level":2,"text":"**The server is authoritative.** A callback handler is reached because a client\nasked it to be, with arguments the client chose. It must re-validate exactly\nwhat a `RegisterNetEvent` handler must re-validate:\n\n- **distance** — the player must actually be where the action requires;\n- **ownership** — the vehicle, the item, the container must be theirs;\n- **permission** — the job, the gang, the ACL entry;\n- **shape** — type, range and length of every argument, including nested tables.\n\nThe single thing the client cannot choose is **who it is**. Inside a server\nhandler, `source` is the authenticated player id, taken from the transport and\nnever from the payload, exactly as `RegisterNetEvent` sets it.\n\n```lua\nOpen77.net.register('withdraw', function(source, amount)\n -- `source` cannot be forged. `amount` absolutely can be.\n if type(amount) ~= 'number' or amount ~= math.floor(amount)\n or amount < 1 or amount > 100000 then\n return nil, 'invalid_amount'\n end\n if not accounts[source] or accounts[source] < amount then return nil, 'insufficient' end\n accounts[source] = accounts[source] - amount\n return accounts[source]\nend)\n```\n\nOne caveat worth knowing: use the `source` **argument**, not the global, in a\nhandler that yields. The global is set the way `RegisterNetEvent` sets it, which\nmeans another task can overwrite it across a `Wait` or an `await`. The argument\nis yours for the life of the call.","natives":["Open77.net.register","server:Open77.net.register"],"permissions":[],"events":[]},{"anchor":"timeouts","heading":"Timeouts","level":2,"text":"Every call carries a deadline. The default is **10 000 ms**; override it with\nthe table form:\n\n```lua\nlocal promise = Open77.net.call({ name = 'getStock', timeout = 2000 }, 'ripperdoc_kit')\nlocal ok = Open77.net.callClientAwait(source, { name = 'confirm', timeout = 30000 })\n```\n\nAccepted range: **100 ms to 120 000 ms**; anything else fails immediately with\n`invalid_callback_timeout`. A call that runs out of time rejects with\n`callback_timeout` — it is never retried, and the answer, if it ever arrives,\nis dropped.\n\nA **missing handler does not wait for the timeout.** A request for a name\nnobody registered, or for a resource that is not running, is refused on the\nnext frame with `callback_not_found` or `callback_resource_unavailable`.\n\nA resource that stops or reloads answers everything it was still serving with\n`callback_target_stopped`, rather than leaving the other side to wait out its\ndeadline. Its own pending promises die with its VM.\n\nThe answering side has a deadline of its own. A handler that never returns --\nan accidental `while true do Wait(1) end` -- would otherwise hold its slot for\nthe life of the process, and 256 of them would refuse every further question to\nthat resource forever. After 120 s, the longest any asker can still be waiting,\nthe slot is reclaimed with `callback_handler_abandoned` and a warning in the\nlog naming the callback.","natives":["Open77.net.call","server:Open77.net.callClientAwait"],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"Callbacks reuse the net-event envelope, so its caps apply unchanged, minus a\nthree-slot frame header:\n\n| Limit | Value | Reason given |\n|---|---|---|\n| Arguments per call | 29 | `callback_too_many_arguments` |\n| Return values per answer | 29 | `callback_too_many_results` |\n| Payload size | 48 KiB per frame | `callback_payload_too_large` |\n| Table depth | 16 | `callback_arguments_not_serializable` |\n| Table nodes | 4096 | `callback_arguments_not_serializable` |\n| String length | 16 KiB | truncated by the envelope |\n| Registered names per resource | 256 | `callback_registration_limit` |\n| Questions open at once, per resource | 256 | `callback_request_limit` |\n| Questions being served at once, per resource | 256 | `callback_target_busy` |\n| Questions open at once, per host | 4096 | `callback_host_limit` |\n\nExceeding a cap **rejects immediately**; nothing ever blocks or queues. The\nper-resource and per-host numbers mirror the export broker on purpose: one\nrunaway resource cannot starve the others.\n\nValues cross the boundary as JSON, so the same rules as net events apply:\nstrings (including embedded zero bytes), integers, floats, booleans, `nil` and\ntables of those. Functions, userdata and coroutines are not transferable.","natives":[],"permissions":[],"events":[]},{"anchor":"failure-reasons","heading":"Failure reasons","level":2,"text":"All of them are stable snake_case strings. Dispatch failures come back as\n`nil, reason` from `call`/`callClient`; everything else rejects the promise.\n\n| Reason | Meaning |\n|---|---|\n| `permission_denied:network.events` | The manifest does not declare the permission. |\n| `invalid_callback_name` | Empty, over 64 characters, or not a string. |\n| `invalid_callback_resource` | Empty or over 128 characters. |\n| `invalid_callback_timeout` | Outside 100–120 000 ms, or not a number. |\n| `invalid_player_id` | `callClient` needs a positive player id. |\n| `callback_broadcast_unsupported` | `callClient(-1, ...)` — see [Out of scope](#out-of-scope). |\n| `callback_not_found` | No handler with that name on the other side. |\n| `callback_resource_unavailable` | The named resource is missing, stopped, or lacks `network.events`. |\n| `callback_target_busy` | The answering side is already serving its maximum. |\n| `callback_target_stopped` | The answering resource stopped or reloaded mid-call. |\n| `callback_handler_abandoned` | The handler never returned; the answering side reclaimed its slot after 120 s. |\n| `callback_timeout` | No answer before the deadline. |\n| `callback_request_limit` / `callback_host_limit` | Too many questions open. |\n| `callback_too_many_arguments` / `callback_too_many_results` | Above the 29-value budget. |\n| `callback_payload_too_large` | Over 48 KiB once encoded. |\n| `callback_arguments_not_serializable` / `callback_results_not_serializable` | A value the envelope cannot carry. |\n| `callback_failed` | The handler failed and gave no text. |\n| `network_unavailable` | No session; nothing to ask. |\n| `callback_send_failed`, or the transport's own reason (`network_event_queue_full`, …) | The frame could not be handed to the transport. |\n| `resource_preparing` / `resource_stopping` | Called before start-up finished, or after stop began. |\n| `promise_cancelled` | The promise was released, or its resource stopped. |\n| `await_requires_scheduler_coroutine` | `await` outside a `CreateThread`. |\n| `reserved_callback_event` | Resource code tried to emit a `open77:callback:*` frame. |\n\nA handler that raises rejects the promise with **the error text**, and logs the\nfailure on the handler's own side — the asker learns that it failed, the owner\nlearns why.","natives":[],"permissions":[],"events":[]},{"anchor":"reserved-frames","heading":"Reserved frames","level":2,"text":"The transport owns two event names:\n\n```text\nopen77:callback:req the question\nopen77:callback:res the answer\n```\n\nResource code cannot emit them. `TriggerServerEvent`, `Open77.net.emitServer`,\n`TriggerClientEvent` and `Open77.net.emitClient` all refuse the\n`open77:callback:` prefix with `reserved_callback_event`, and the host consumes\nthe frames before any Lua network handler is considered. Without that, any\nresource could forge an answer to another resource's pending question, or\ninvent a question the server never asked. Use the API.\n\nUnlike an ordinary client event, a callback frame is **not broadcast to every\nresource**: it names the resource it is addressed to and goes only there.","natives":[],"permissions":[],"events":[]},{"anchor":"a-worked-example-a-shop","heading":"A worked example: a shop","level":2,"text":"`resources/shop/open77.lua`\n\n```lua\nresource 'shop'\nversion '1.0.0'\nclient_script 'client.lua'\nserver_script 'server.lua'\npermissions { 'network.events' }\n```\n\n`resources/shop/server.lua`\n\n```lua\nlocal kCounter = { x = -1580.0, y = -1290.0, z = 8.0 }\nlocal kCatalog = { medkit = 250, ammo_pistol = 40 }\nlocal stock = { medkit = 12, ammo_pistol = 400 }\n\nlocal function atTheCounter(playerId)\n local at = Open77.players.position(playerId)\n if not at then return false end\n local dx, dy, dz = at.x - kCounter.x, at.y - kCounter.y, at.z - kCounter.z\n return (dx * dx + dy * dy + dz * dz) <= 16.0\nend\n\n-- A read. Still validated: a client can ask from anywhere, at any rate.\nOpen77.net.register('catalog', function(source)\n if not atTheCounter(source) then return nil, 'too_far' end\n local rows = {}\n for item, price in pairs(kCatalog) do\n rows[#rows + 1] = { item = item, price = price, stock = stock[item] or 0 }\n end\n return rows\nend)\n\n-- A write. Every precondition is re-checked here, on the server, at the moment\n-- of the write -- not in the client that asked, and not in the earlier read.\nOpen77.net.register('buy', function(source, item, count)\n if type(item) ~= 'string' or kCatalog[item] == nil then return nil, 'unknown_item' end\n if type(count) ~= 'number' or count ~= math.floor(count)\n or count < 1 or count > 10 then return nil, 'invalid_count' end\n if not atTheCounter(source) then return nil, 'too_far' end\n if (stock[item] or 0) < count then return nil, 'out_of_stock' end\n\n local cost = kCatalog[item] * count\n -- Confirm with the player before taking their money. 30 s, because a human\n -- is reading a dialog; the promise rejects on its own if they wander off.\n local accepted = Open77.net.callClientAwait(source, { name = 'confirm', timeout = 30000 },\n item, count, cost)\n if accepted ~= true then return nil, 'cancelled' end\n\n -- Re-check after the yield: thirty seconds is a long time in a game world.\n if not atTheCounter(source) or (stock[item] or 0) < count then return nil, 'too_far' end\n stock[item] = stock[item] - count\n return count, cost\nend)\n```\n\n`resources/shop/client.lua`\n\n```lua\n-- `confirmDialog` is this resource's own UI; returning false cancels the buy.\nOpen77.net.register('confirm', function(item, count, cost)\n return confirmDialog(('Buy %d %s for %d?'):format(count, item, cost))\nend)\n\n-- A client resource may RegisterCommand of its own since A8, but a key mapping\n-- stays the usual trigger for something with no arguments.\nRegisterKeyMapping('shop', 'Open the shop', 'F6', function()\n CreateThread(function()\n local rows, reason = Open77.net.callAwait({ name = 'catalog', timeout = 5000 })\n if not rows then Open77.log.warn('shop: ' .. tostring(reason)) return end\n for _, row in ipairs(rows) do\n print(('%-14s %5d eddies (%d left)'):format(row.item, row.price, row.stock))\n end\n\n local bought, cost = Open77.net.callAwait('buy', 'medkit', 2)\n if not bought then\n Open77.log.warn('purchase refused: ' .. tostring(cost))\n return\n end\n print(('bought %d for %d'):format(bought, cost))\n end)\nend)\n```\n\nNote what the server does **not** do: it never trusts the price the client read\na moment ago, never trusts that the client is still at the counter after the\nconfirmation dialog, and never trusts `count` because the earlier `catalog`\ncall said the stock was there. A callback makes the round trip convenient; it\nchanges nothing about who decides.","natives":["server:Open77.players.position","Open77.net.register","server:Open77.net.register","server:Open77.net.callClientAwait","RegisterKeyMapping","CreateThread","server:CreateThread","Open77.net.callAwait","Open77.log.warn","server:Open77.log.warn"],"permissions":["network.events"],"events":[]},{"anchor":"out-of-scope","heading":"Out of scope","level":2,"text":"- **Broadcast callbacks.** `Open77.net.callClient(-1, ...)` is refused with\n `callback_broadcast_unsupported`. N answers do not fit one promise, and a\n resource that genuinely wants every client's answer can loop over\n `Open77.players.all()` and collect the promises itself.\n- **Client-to-client callbacks.** There is no peer transport; route through the\n server, which has to validate anyway.\n- **Callbacks between two server resources.** That is\n [`Open77.exports.call`](server-exports.md), which is in-process and does not\n touch the network.\n- **Bundled/system client resources.** Callbacks live in the server-resource\n host, the layer downloaded resources run in.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Cross-resource server exports](server-exports.md) — the same promise type,\n between two server resources.\n- [Complete server Lua API](server-api.md) — every server global and\n `Open77.*` method.\n- [Connection control](connection-control.md) — `source`, identity and the\n connect gate.","natives":[],"permissions":[],"events":[]}]},{"slug":"cameras","file":"cameras.md","title":"Scripted cameras","summary":"Use `camera.script` to control cinematic cameras. For the playable F7 view, shoulder framing, movement FOV and camera shake, use [Third-person camera styles](third-person-camera.md) with `camera.style`.","sections":[{"anchor":"scripted-cameras","heading":"Scripted cameras","level":1,"text":"Use `camera.script` to control cinematic cameras. For the playable F7 view, shoulder framing, movement FOV and camera shake, use [Third-person camera styles](third-person-camera.md) with `camera.style`.\n\nFor an independent feed on a HUD, WebUI or world screen without taking over the player's view, use [Remote cameras and world screens](remote-camera.md) with `camera.capture`.\n\nPut the view where your resource wants it — a character creator orbiting the\nplayer's face, a shop that frames the rack, a dealership turntable, a cinematic\nintro — and be certain the player gets their own view back.\n\nClient only, and everything here is behind the `camera.script` permission —\nexcept [Spectating](#spectating), which is driven from the **server** with\n`players.spectate` and needs no client resource at all.\n\n```lua\nlocal cam = assert(Open77.camera.create({\n position = vector3(pos.x, pos.y - 2.0, pos.z + 1.7),\n lookAt = 0, -- entity 0 is the local player's body\n fov = 45,\n}))\n\nlocal ok, blended = Open77.camera.activate(cam, { blendMs = 600 })\nif not ok then print('no camera: ' .. tostring(blended)); return end\nblended:await() -- the shot is up\n\n-- ... later ...\nOpen77.camera.deactivate({ blendMs = 400 })\nOpen77.camera.destroy(cam)\n```\n\n---","natives":["Open77.camera.create","Open77.camera.activate","Open77.camera.deactivate","Open77.camera.destroy"],"permissions":[],"events":[]},{"anchor":"the-model-in-one-page","heading":"The model in one page","level":2,"text":"**A camera is a definition, not a view.** `create` makes a camera and returns an\nid; nothing is rendered until you `activate` it. You may hold as many\ndefinitions as you like (16 per resource), and exactly one of them can be the\nlive view at a time.\n\n**Ownership is per resource, and the view is per client.** A camera belongs to\nthe resource generation that made it — another resource cannot move it, point\nit, shake it or destroy it. The *view* belongs to at most one resource at a\ntime: a second resource asking for it is **refused, with the holder named**, and\nnever silently served.\n\n```lua\nlocal ok, reason = Open77.camera.activate(cam)\n-- ok == false, reason == \"camera_held_by:open77_creator\"\n```\n\nThat refusal is the whole design. Stealing the view would leave two resources\neach believing they are driving, and the one that is not would keep issuing\nwrites that vanish.\n\n**Everything is world space** — `position` and `lookAt` are world coordinates,\nexcept an attached camera's `offset`, which is in the parent entity's own frame\n(X right, Y forward, Z up). Vectors and plain `{ x =, y =, z = }` tables are\naccepted everywhere, and every vector this API returns is a plain table that\n`vector3()` accepts.\n\n**Failures are values.** Every call returns `nil, reason` or `false, reason`\nwith a stable snake_case token. The complete list is at the bottom of this page.\n\n---","natives":["Open77.camera.activate"],"permissions":[],"events":[]},{"anchor":"what-is-reachable-and-what-is-not","heading":"What is reachable, and what is not","level":2,"text":"Camera controls are limited to the native capabilities listed below.\n\n**Reachable.** A world position, a world orientation, a look-at that tracks a\npoint or an entity every frame, an attachment that follows a moving parent, a\nfield of view, a blended activation and deactivation, a shake, and the\nscreen→world ray.\n\n**Not reachable, and why:**\n\n* **A camera independent of the player's body.** 2.31 has no creatable camera\n object. The only camera component whose transform writes are both accepted and\n *rendered* is the one on the player, and it is parented to the body. Open77\n converts your world pose into that component's local frame every frame, which\n is what makes a world position work at all — but the camera is still a child\n of the player. **If the body goes, the camera goes.**\n* **Streaming follows the player, not the lens.** A camera parked far from the\n body renders whatever is streamed *for the body*: missing geometry, missing\n NPCs, unloaded interiors. `activate` therefore refuses a camera more than\n **250 m** from the player with `invalid_argument`, so the failure is a named\n refusal rather than a grey frame. Move the player if you need to move the\n shot that far.\n* **One view at a time.** Many definitions, one active.\n* **No collision.** A camera placed inside a wall is inside the wall. The\n third-person rig's spring arm does not apply here, deliberately: a cinematic\n camera that refuses to go where it is told is worse than one that clips.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"the-release-rules","heading":"The release rules","level":2,"text":"**A stuck camera is indistinguishable from a crash.** Read this section before\nyou write the shot.\n\nThe view comes back by itself, on the next frame, in every one of these cases.\nYou do not have to handle them, and you cannot opt out of them:\n\n| What happens | Reason your pending promise is rejected with |\n|---|---|\n| Your resource is stopped | `resource_stopped` |\n| Your resource is reloaded | `resource_stopped` (the new generation starts with nothing) |\n| A Lua coroutine in your resource errors | `resource_error` (your camera *definitions* go too — the coroutine that was going to tidy up is dead, so re-`create` after an error) |\n| The player dies | `player_died` |\n| The player's body becomes unreadable for ~half a second | `player_unavailable` |\n| The world unloads, the player disconnects, the session ends | `world_exit` |\n| The client shuts down | `client_shutdown` |\n| You destroy the camera you are looking through | `camera_destroyed` |\n| You activate a different camera of your own | `camera_superseded` |\n| You call `deactivate` | `camera_deactivated` |\n\nTwo consequences worth writing down:\n\n* **A camera does not survive a death.** After a respawn the camera *definition*\n still exists, so you can put the shot back up with one `activate` — but you\n have to ask. That is on purpose: the engine's own death camera writes the same\n component, and competing for it is a fight nobody wins.\n* **Never leave an `await` without a failure branch.** Every release above\n rejects the promise rather than dropping it, precisely so your coroutine wakes\n up and can tidy the rest of your state.\n\n```lua\nlocal ok, blended = Open77.camera.activate(cam, { blendMs = 600 })\nif ok and blended then\n local _, why = blended:await()\n if why then\n -- the shot never happened: the player died, we were reloaded, something\n -- took the view. Put the UI back the way it was.\n closeCreatorUi()\n return\n end\nend\n```\n\n---","natives":["Open77.camera.activate"],"permissions":[],"events":[]},{"anchor":"how-it-composes-with-firstthird-person","heading":"How it composes with first/third person","level":2,"text":"`Open77.perspective` and the arbiter behind it own six presentation values\ntogether — camera, body, input, weapon, effects, audio — and change them as one.\nA scripted camera does not sneak past that; it tells it.\n\nWhile you hold the view, the arbiter reports:\n\n```\ncamera=engine reason=scripted_camera\n```\n\nwhich is the same answer it gives for a cutscene: *stand down, do not compete\nfor the camera, do not take back a camera we did not lend.* The third-person\nrig is switched off for the duration and restored when you let go.\n\nThree things follow:\n\n* **`activate` refuses while a perspective transition is in flight**, with\n `camera_unavailable`. Retry a frame later. Taking the camera mid-transition\n would leave the body and the view disagreeing for as long as the transition\n runs.\n* **Death and ragdoll outrank you.** Both fall back to first person, and the\n registry releases your camera from its own side at the same moment, so the two\n never disagree.\n* **The arbiter never moves the player.** Neither does this API. A scripted\n camera is presentation only: the body stays exactly where gameplay put it,\n which is why a player can walk out from under your shot. If you need them to\n stay put, `Open77.character.setFrozen` is the tool, and it is a separate,\n separately-permissioned decision.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"spectating","heading":"Spectating","level":2,"text":"**Server side**, permission `players.spectate`. One call puts an admin behind\nanother player's shoulder and takes his body out of the world while he is there.\n\n```lua\nOpen77.players.spectate(adminId, suspectId) -- start\nOpen77.players.spectate(adminId, false) -- stop, and give the body back\n```\n\nThree things happen together, and they are one transaction:\n\n1. the spectator is **ghosted** — he cannot block, body-check or be walked into;\n2. his body is **hidden** — nobody sees an admin standing in the fight he is\n filming, and his nameplate goes with him;\n3. his client is told to put a [`follow` camera](#open77camerafollowentity-options---camid-promise--nil-reason)\n on the target.\n\nIf the body cannot be taken away, **nothing** is applied and nothing is sent.\nThat order is the point: the half that can strand a player is the body, so it\ngoes first and it fails closed. The camera is the half that can only fail\ntemporarily — an unstreamed target, a resource already holding the view — and it\nretries by itself every frame until it takes.\n\n```lua\nOpen77.players.spectate(adminId, suspectId, {\n blendMs = 400, -- 0..10000, how long the cut takes\n distance = 5.0, -- metres behind the target\n height = 2.0, -- metres above\n})\n```\n\nReads, both behind `players.life.read`:\n\n```lua\nOpen77.players.spectating(adminId) --> targetId, or 0\nOpen77.players.spectators(suspectId) --> { adminId, ... }\n```","natives":["server:Open77.players.spectate","server:Open77.players.spectating","server:Open77.players.spectators"],"permissions":[],"events":[]},{"anchor":"move-the-ghost-to-the-target","heading":"Move the ghost to the target","level":3,"text":"The world streams around the **spectator's own body**, not around the camera.\nA target on the other side of Night City gives a correctly-aimed camera looking\nat unstreamed space. The spectator is invisible and non-solid by then, so\nlanding on top of somebody costs nothing:\n\n```lua\nOpen77.players.spectate(adminId, suspectId)\nOpen77.players.teleport(adminId, Open77.players.position(suspectId))\n```\n\nDo the same again whenever the target travels far — a vehicle chase across two\ndistricts will outrun the spectator's streaming otherwise.","natives":["server:Open77.players.spectate","server:Open77.players.teleport","server:Open77.players.position"],"permissions":[],"events":[]},{"anchor":"every-way-it-ends","heading":"Every way it ends","level":3,"text":"This is the list that matters, because a camera you cannot give back reads to a\nplayer exactly like a crash, and an invisible admin is worse than a stuck\ncamera. Each of these restores **both** halves:\n\n| It ends when | Reason token |\n|---|---|\n| the caller stops it | whatever the caller passed |\n| the target dies, or is revived or respawned | `target_not_alive` |\n| the spectator dies | `spectator_not_alive` |\n| either side disconnects | `target_gone` / `spectator_gone` |\n| either side changes routing bucket | `bucket_changed` |\n| either side reconnects | `reconnected` / `target_reconnected` |\n| the resource that started it stops, reloads or errors | `owner_released:` |\n| the client's session changes (host-side) | the camera is dropped locally |\n\nThe spectator's body is restored **to what it was**, not to a default: an admin\na jail script had already ghosted stays ghosted, and a body another resource has\nsince hidden stays hidden. Only the ghost and the hide this session made are\nundone.\n\nRetargeting — `spectate(adminId, otherId)` while a session is live — swaps the\nshot without ever putting the body back in the world for a frame.","natives":[],"permissions":[],"events":[]},{"anchor":"refusals","heading":"Refusals","level":3,"text":"| Token | Meaning |\n|---|---|\n| `permission_denied:players.spectate` | the manifest does not declare it |\n| `invalid_player` / `invalid_target` | not a positive integer id |\n| `cannot_spectate_self` | a player cannot watch his own shoulder |\n| `player_not_found` / `target_not_found` | no life state for that id |\n| `spectator_not_alive` / `target_not_alive` | one of them is dead or mid-transition |\n| `different_bucket` | the target's body is not streamed to the spectator at all |\n| `target_is_spectating` | chains are refused: the middle player's camera is already elsewhere |\n| `not_spectating` | stopping a session that does not exist |\n| `invalid_argument` | a blend, distance or height outside its range |\n| `life_unavailable` | the host has no life service (a test harness) |","natives":[],"permissions":[],"events":[]},{"anchor":"what-a-single-client-cannot-prove","heading":"What a single client cannot prove","level":3,"text":"Spectating has no meaning with one client. On one machine you can prove the\nrefusals, the permission gate and that the body comes back; you cannot prove\nthat the picture is of somebody else. **Two clients, two accounts** — see the\nintegrator scenario in the row's report.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"reference","heading":"Reference","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameracreateoptions---camid-nil-reason","heading":"`Open77.camera.create(options) -> camId | nil, reason`","level":3,"text":"| Option | Type | Meaning |\n|---|---|---|\n| `position` | vector3 | World position. Required unless `attachTo` is given. |\n| `attachTo` | entity | Parent the camera to an entity. `0` is the local player's body. |\n| `offset` | vector3 | Offset in the parent's frame (X right, Y forward, Z up). Accepted as `position` too. |\n| `lookAt` | vector3 **or** entity | A world point, or an entity to track. Re-solved every frame. |\n| `rotation` | table | Used when there is no `lookAt`. Either a quaternion `{ x, y, z, w }` or Euler degrees `{ pitch, yaw, roll }`, any subset. |\n| `fov` | number | 5..170 degrees. Omit, or `0`, to leave the engine's field of view alone. |","natives":[],"permissions":[],"events":[]},{"anchor":"open77camerasettransformcamid-position-rotation","heading":"`Open77.camera.setTransform(camId, position?, rotation?)`","level":3,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"open77camerasettransformcamid-position-rotation-fov","heading":"`Open77.camera.setTransform(camId, { position =, rotation =, fov = })`","level":3,"text":"Moves and/or turns a camera. Cheap enough to call every frame — that is how you\nwrite a dolly. Setting a `rotation` clears a previous `lookAt`: an explicit\nrotation is an explicit aim, and it would be a trap to accept the call and let a\nstale look-at quietly override it.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameralookatcamid-position-entity","heading":"`Open77.camera.lookAt(camId, position | entity)`","level":3,"text":"Point the camera at a world point or at an entity. Entity `0` is the local\nplayer. The aim is re-solved **every frame**, so a tracked entity that moves\nstays centred. An entity that streams out holds the last aim rather than\nsnapping the camera to the world origin.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameraattachcamid-entity-offset","heading":"`Open77.camera.attach(camId, entity, offset?)`","level":3,"text":"Parent the camera to an entity: it follows the parent's position *and* rotation,\nso `attach` plus a fixed rotation is a dealership turntable. Entity `0` is the\nlocal player.\n\n`Open77.camera.attach()` with **no arguments** is the older call that restores\nthe player's own camera, and it still means exactly that.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameradetachfromcamid","heading":"`Open77.camera.detachFrom(camId)`","level":3,"text":"The opposite of the scripted `attach`. Not spelled `detach`, because\n`Open77.camera.detach()` already means \"move the player's own camera off their\neyes\" and all of its arguments are optional, so there is no arity left to\noverload. The camera keeps the pose it had; your next `setTransform` places it.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameraactivatecamid-blendms---true-promise-false-reason","heading":"`Open77.camera.activate(camId, { blendMs }) -> true, promise | false, reason`","level":3,"text":"Take the view. `blendMs` interpolates from wherever the view currently is; omit\nit, or pass `0`, for a cut. The second return value is an\n[`Open77.Promise`](fivem-compatibility.md) that resolves when the blend\ncompletes and rejects with a reason if the shot is released first. A cut settles\nbefore the call returns and therefore hands back no promise — there is nothing\nleft to wait for.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameradeactivate-blendms---true-promise-false-reason","heading":"`Open77.camera.deactivate({ blendMs }) -> true, promise | false, reason`","level":3,"text":"Give the view back, blending to the player's own eyes. Only the holder may.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameradestroycamid","heading":"`Open77.camera.destroy(camId)`","level":3,"text":"Destroys a camera. If it is the live one, the view comes back.","natives":[],"permissions":[],"events":[]},{"anchor":"open77camerashakecamid-preset-amplitude-ms","heading":"`Open77.camera.shake(camId?, preset, amplitude?, ms?)`","level":3,"text":"Presets: `hand`, `drunk`, `explosion`, `earthquake`. `amplitude` defaults to\n`1.0` and is capped at `4.0`; `ms` defaults to 500. Additive on top of the\nresolved pose, so it composes with a blend and with a look-at. Omit `camId` to\nshake the camera you are holding.","natives":[],"permissions":[],"events":[]},{"anchor":"open77camerastopshakecamid","heading":"`Open77.camera.stopShake(camId)`","level":3,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameracameras---table","heading":"`Open77.camera.cameras() -> table`","level":3,"text":"Your cameras and whether you hold the view:\n\n```lua\n{ held = true, holder = \"open77_creator\", phase = \"holding\",\n activeCamera = 1, blendRemainingMs = 0,\n cameras = { { id = 1, active = true, attached = false, aim = \"entity\",\n fov = 45.0, shaking = false, shakePreset = \"hand\",\n shakeRemainingMs = 0, position = { x = …, y = …, z = … } } } }\n```\n\n`holder` names whoever has the view, **including somebody else** — it is the one\nquestion a `camera_held_by:` refusal leaves you asking. `phase` is `idle`,\n`blending_in`, `holding` or `blending_out`.","natives":[],"permissions":[],"events":[]},{"anchor":"open77cameraunprojectx-y---origin-direction","heading":"`Open77.camera.unproject(x, y) -> { origin, direction }`","level":3,"text":"The world ray behind a screen point. `x` and `y` are `0..1` with the origin at\nthe **top left** — exactly the frame `Open77.camera.project` returns, so the two\nround-trip:\n\n```lua\nlocal screen = Open77.camera.project(worldPoint)\nlocal ray = Open77.camera.unproject(screen.x, screen.y)\n-- ray.direction now points from ray.origin back at worldPoint\n```\n\nUngated, like `project` and `view`: it reads the camera, it does not move it.","natives":["Open77.camera.project","Open77.camera.unproject"],"permissions":[],"events":[]},{"anchor":"open77camerafollowentity-options---camid-promise-nil-reason","heading":"`Open77.camera.follow(entity, options?) -> camId, promise | nil, reason`","level":3,"text":"Rides an entity: the camera sits behind it, aims at it, and both are re-solved\nevery frame, so the shot stays put while the target walks, turns, drives or\nfalls over. Entity `0` is the local player's own body.\n\n```lua\nlocal cam = assert(Open77.camera.follow(targetEntity, {\n distance = 5.0, -- metres behind, 0..100 (default 4.0)\n height = 2.0, -- metres above, -50..50 (default 1.5)\n side = 0.0, -- metres right, -50..50 (default 0)\n fov = 0, -- 0 leaves the field of view alone\n blendMs = 400,\n}))\n```\n\n`distance`, `height` and `side` are in the **target's own frame**, which is what\nmakes `distance` mean \"behind him\" rather than \"north of him\".\n\n**One camera per resource, reused.** Calling `follow` again with a different\nentity retargets the same camera and returns the same id; it does not create a\nsecond one. That is deliberate — a resource that follows the nearest player once\na second would otherwise exhaust its sixteen-camera budget in sixteen seconds\nand start failing for a reason that has nothing to do with what it asked for. It\nalso means each call supersedes the previous call's blend promise, which then\nsettles as `camera_superseded`.\n\n`Open77.camera.unfollow({ blendMs })` gives the view back **and** destroys the\ncamera. A second call answers `false, \"not_following\"`.\n\nNothing here is a new way to hold the view: `follow` is `create` + `lookAt` +\n`activate`, so every release rule on this page applies to it unchanged.\n\n**The streaming ceiling applies and is not hidden.** The world streams around\nthe *player's body*, not around the lens. Following a target three hundred\nmetres away gives you a correctly-placed camera looking at unstreamed space —\nan empty frame, not an error. Move the body to the target first; the\nserver-side `Open77.players.spectate` shows the pattern.","natives":["Open77.camera.follow"],"permissions":[],"events":[]},{"anchor":"unchanged-calls","heading":"Unchanged calls","level":3,"text":"`detach`, `attach()`, `thirdPerson`, `orbit`, `clearOrbit`, `setFov(degrees)`,\n`view`, `project` and `aimRay` keep their exact previous meaning and their\nprevious permissions. `orbit` / `clearOrbit` stay behind `camera.preview`.","natives":[],"permissions":[],"events":[]},{"anchor":"why-camerascript-and-not-camerapreview","heading":"Why `camera.script` and not `camera.preview`","level":3,"text":"`camera.preview` grants a yaw offset *inside* the third-person rig. It cannot\nmove the view off the player and it cannot take the view away from gameplay,\nwhich is why a clothing shop can be given it without a second thought.\n\nA scripted camera can put the view anywhere, look through a wall, and leave a\nplayer unable to see their own body. Those are different powers, and an operator\nhas to be able to grant one without the other.","natives":[],"permissions":[],"events":[]},{"anchor":"failure-tokens","heading":"Failure tokens","level":3,"text":"| Token | Meaning |\n|---|---|\n| `permission_denied:camera.script` | the manifest does not declare the permission |\n| `camera_unavailable_on_this_host` | the server-side runtime, or a build without the native |\n| `invalid_camera_options` | `create` was not given a table |\n| `invalid_camera_position` / `invalid_camera_offset` / `invalid_camera_look_at` | not a readable vector, or not finite |\n| `invalid_camera_rotation` | neither a quaternion nor Euler degrees |\n| `invalid_camera_fov` / `invalid_argument` | field of view outside 5..170, or a non-finite number |\n| `invalid_camera_id` / `camera_not_found` | no such camera |\n| `camera_not_owned` | it belongs to another resource, or to a previous generation of yours |\n| `camera_held` / `camera_held_by:` | another resource has the view |\n| `camera_not_active` | you do not hold the view |\n| `camera_budget_exhausted` | 16 cameras per resource, 64 per client |\n| `camera_unavailable` | the player is dead or unreadable, or a perspective transition is in flight |\n| `invalid_blend_ms` | not a number in 0..60000 |\n| `invalid_shake_preset` / `unsupported_shake_preset` / `invalid_shake_argument` | see `shake` |\n| `empty_camera_transform` | `setTransform` was given nothing to change |\n| `invalid_screen_point` | `unproject` was given a non-finite coordinate |\n| `invalid_entity` | `follow` was given something that is not an entity id |\n| `invalid_follow_distance` / `invalid_follow_height` / `invalid_follow_side` | outside the range in `follow` |\n| `not_following` | `unfollow` with no follow camera |\n| `camera_superseded` | the blend promise of a `follow` a later `follow` replaced |\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"worked-example-a-character-creator","heading":"Worked example: a character creator","level":2,"text":"This example blends between a face camera and a full-body camera, then restores player control.\n\n```lua\n-- open77_creator/client.lua\nlocal face, body, holding\n\nlocal function shots()\n local me = Open77.character.position() -- the local player's feet\n face = assert(Open77.camera.create({\n position = vector3(me.x, me.y - 0.85, me.z + 1.70),\n lookAt = 0, -- the player's own body\n fov = 32,\n }))\n body = assert(Open77.camera.create({\n position = vector3(me.x, me.y - 2.60, me.z + 1.10),\n lookAt = vector3(me.x, me.y, me.z + 0.95),\n fov = 48,\n }))\nend\n\nlocal function close(reason)\n if not holding then return end\n holding = false\n Open77.camera.deactivate({ blendMs = 450 })\n if face then Open77.camera.destroy(face); face = nil end\n if body then Open77.camera.destroy(body); body = nil end\n Open77.ui.close('creator') -- your own UI, whatever it is\n if reason then print('creator closed: ' .. reason) end\nend\n\nfunction OpenCreator()\n shots()\n local ok, blended = Open77.camera.activate(face, { blendMs = 700 })\n if not ok then\n -- `camera_held_by:` names who to blame; anything else is a\n -- refusal we simply have to respect.\n print('creator cannot take the camera: ' .. tostring(blended))\n return false\n end\n holding = true\n if blended then\n local _, why = blended:await()\n if why then close(why); return false end -- released before we got there\n end\n Open77.ui.open('creator')\n return true\nend\n\n-- A cut to the body shot. Same owner, so it is allowed, and the face shot's\n-- promise rejects with `camera_superseded` rather than hanging.\nfunction ShowBody()\n if not holding then return end\n local ok, blended = Open77.camera.activate(body, { blendMs = 350 })\n if ok and blended then blended:await() end\nend\n\n-- A slow turntable while the body shot is up.\nlocal angle = 0.0\nCreateThread(function()\n while true do\n Wait(16)\n if holding and Open77.camera.cameras().activeCamera == body then\n angle = (angle + 0.6) % 360.0\n local me = Open77.character.position()\n local radians = math.rad(angle)\n Open77.camera.setTransform(body, vector3(\n me.x + math.sin(radians) * 2.6,\n me.y - math.cos(radians) * 2.6,\n me.z + 1.10))\n -- `lookAt` was set at create time and is re-solved every frame, so the\n -- body stays centred as the camera swings.\n end\n end\nend)\n\nfunction CloseCreator() close(nil) end\n\n-- Belt and braces. Every path below already releases the camera inside the\n-- client; this only puts YOUR state back.\nAddEventHandler('onClientResourceStop', function(name)\n if name == Open77.resource.name() then close('resource_stopped') end\nend)\n```\n\nManifest:\n\n```lua\nresource 'open77_creator'\nversion '1.0.0'\nclient_scripts { 'client.lua' }\npermissions { 'camera.script' }\n```\n\n---","natives":["Open77.character.position","Open77.camera.create","Open77.camera.deactivate","Open77.camera.destroy","Open77.camera.activate","CreateThread","server:CreateThread","Wait","server:Wait","Open77.camera.cameras","Open77.camera.setTransform","AddEventHandler","server:AddEventHandler","Open77.resource.name","server:Open77.resource.name"],"permissions":["camera.script"],"events":[]},{"anchor":"integration-checklist","heading":"Integration checklist","level":2,"text":"- Restore the player camera, input, HUD and FOV on completion, cancellation, errors and resource stop.\n- Handle camera ownership refusals instead of overriding another resource's view.\n- Keep targets inside the supported streaming range.\n- For spectating, restore visibility and collision when either player dies, disconnects or changes bucket.\n- Avoid simultaneous control by photo mode, vehicle cameras and scripted cameras.","natives":[],"permissions":[],"events":[]}]},{"slug":"chat","file":"chat.md","title":"Chat, slash commands, and completion","summary":"`open77_chat` provides chat, authenticated slash commands, command suggestions, history and keyboard completion. Its interface text is in English.","sections":[{"anchor":"chat-slash-commands-and-completion","heading":"Chat, slash commands, and completion","level":1,"text":"`open77_chat` provides chat, authenticated slash commands, command suggestions, history and keyboard completion. Its interface text is in English.\n\nSource: [`open77_chat`](https://github.com/Open2077/open77-resources/tree/main/system/open77_chat).\n\nQuick usage:\n\n```text\nT open chat\nEnter send or execute\nEscape close\nArrow Up/Down select a suggestion while typing /\nTab complete the selected command\n```\n\nSlash commands are resolved **on the client first**, then on the server. A line beginning with `/`\nis tokenised, offered to the client command registry through `ExecuteCommand`, and only forwarded to\nthe server's `RegisterCommand` and ACL path when no client resource claims it. Either way it is not\na chat message and is never broadcast to other players.\n\nThat ordering is what makes a client `RegisterCommand` useful: a HUD toggle answers locally in the\nsame frame instead of making a round trip to ask the server about a setting the server does not own.\n`unknown_command` from the client half is not an error — it is the signal to forward — while any\nother refusal (`command_restricted`, an unterminated quote) is shown in chat and the line stops\nthere rather than being sent to a server that would only answer `unknown_command` a second time.\n\nCompletions merge both halves. The composer asks the server for its suggestion list on open and adds\nevery non-restricted client command from `GetRegisteredCommands()`; a command registered with a\n`{ help, parameters }` descriptor shows that text, and one registered without shows which resource\nowns it.\n\nBuilt-in utility commands include:\n\n```text\n/id show the temporary session player ID\n/pos copy the current world position as Lua\n/rot copy the current quaternion and yaw as Lua\n```\n\n`/pos` and `/rot` write only on the requesting client and display their result in chat and through\nthe shared notification UI. See the [clipboard guide](clipboard.md) for the API, permission, output\nformats, limits, and failure reasons.","natives":[],"permissions":[],"events":[]},{"anchor":"server-api-open77chat","heading":"Server API: `Open77.chat`","level":2,"text":"A server resource does not have to know the event names or the message shape to talk in chat.\n`Open77.chat` is the convenience layer over what `open77_chat` already listens to.\n\n```lua\nOpen77.chat.send(playerId, message) -- one player; -1 or nil = everyone\nOpen77.chat.broadcast(message) -- same as send(-1, message)\nOpen77.chat.onMessage(function(playerId, playerName, text) end)\nOpen77.chat.addSuggestion(target, command, help, parameters)\nOpen77.chat.addSuggestions(target, list)\nOpen77.chat.removeSuggestion(target, command)\nOpen77.chat.clear(target)\n```\n\n`message` is a plain string, or the full table the chat UI renders:\n\n```lua\nOpen77.chat.broadcast({\n type = \"system\", -- \"player\" | \"system\"\n author = \"DISPATCH\",\n text = \"Heat on the docks.\",\n color = { 0, 229, 255 },\n})\n```\n\n`send`, `broadcast`, `addSuggestion`, `addSuggestions`, `removeSuggestion` and `clear` return\n`true`, or `false, reason`. `onMessage` returns the handler id, so it can be passed to\n`RemoveEventHandler`; it returns `nil, \"invalid_chat_handler\"` for a non-function. Other reasons:\n`invalid_chat_target`, `invalid_chat_message`, `invalid_chat_command`, `invalid_chat_suggestion`,\nand the host bus's own `resource_preparing` (the call ran at the top level of the script, before\nthe chunk had loaded -- move it into `onResourceStart`, a command or an event handler) and\n`event_queue_limit`.\n\n`playerId` and `target` are **numbers**: an integer id, `-1` for everyone (`send` also takes\n`nil`). A player id that arrived through a host event -- `onPlayerReady`, `onPlayerDisconnected`,\n`onPlayerEnteredVehicle` -- is a string, and the facade refuses it with `invalid_chat_target`:\npass `tonumber(playerId)`. `source` inside a `RegisterNetEvent` or `RegisterCommand` handler is\nalready a number.","natives":["server:Open77.chat.send","server:Open77.chat.broadcast","server:Open77.chat.onMessage","server:Open77.chat.addSuggestion","server:Open77.chat.addSuggestions","server:Open77.chat.removeSuggestion","server:Open77.chat.clear"],"permissions":[],"events":[]},{"anchor":"the-events-the-chat-raises-for-the-server","heading":"The events the chat raises for the server","level":3,"text":"| Event | Payload | Raised |\n|---|---|---|\n| `chat:ready` | `()` | By the `open77_chat` client, through `TriggerServerEvent`, once its UI is up and again whenever it re-requests suggestions. Handle it with `RegisterNetEvent`: `source` is the player, there are no arguments. The usual place to `addSuggestions(source, ...)`; players already connected when a resource starts had their `chat:ready` earlier, so also publish once with `-1` from `onResourceStart`. |\n| `chat:message` | `(playerId, playerName, text)` | Every message the authority accepted, after the `chatMessage` veto ran; what `Open77.chat.onMessage` subscribes to. |\n| `chatMessage` | `(playerId, playerName, text)` | Cancellable, raised before a message is shown: a handler that calls `CancelEvent()` drops it. |","natives":[],"permissions":[],"events":[]},{"anchor":"it-is-transport-not-a-second-authority","heading":"It is transport, not a second authority","level":3,"text":"Every call above is one `TriggerEvent` on the host-wide bus, and `open77_chat` remains the only\nresource that decides anything. Three consequences worth knowing before you build on it:\n\n- **It grants nothing.** The facade never reaches a client itself, so it needs no\n `network.events`; the authority does that, with its own permissions. Any resource could already\n publish these events by hand — this only saves it from knowing their names.\n- **It cannot tell you nobody listened.** If no chat authority is running, the calls still return\n `true`: `TriggerEvent` publishes to the bus, and the bus does not know who cares.\n- **To refuse a message, do not use `onMessage`.** Observation happens *after* the verdict. The\n veto point is the existing cancellable `chatMessage` event:\n\n ```lua\n AddEventHandler(\"chatMessage\", function(playerId, playerName, text)\n if isMuted(playerId) then CancelEvent() end\n end)\n ```","natives":[],"permissions":[],"events":[]},{"anchor":"ordering","heading":"Ordering","level":3,"text":"Delivery is asynchronous: a `send` reaches clients on the next tick boundary, not inside your call.\n\n**Two sends in the same tick arrive in the opposite order.** That is the resource scheduler, not\nthe chat layer — `__open77_tick` collects ready tasks back to front, so the last one scheduled runs\nfirst, and it has always behaved this way for every callback on the server. It only becomes visible\nhere because chat is the one surface where line order is meaningful. If you are printing a block\nthat must read in order, put the lines in different ticks:\n\n```lua\nCreateThread(function()\n for _, line in ipairs(report) do\n Open77.chat.send(playerId, line)\n Wait(0)\n end\nend)\n```","natives":["CreateThread","server:CreateThread","server:Open77.chat.send","Wait","server:Wait"],"permissions":[],"events":[]}]},{"slug":"client-kvp","file":"client-kvp.md","title":"Client persistent KVP","summary":"`Open77.kvp` persists small values on the client. Data is isolated by connection address, resource name and key:","sections":[{"anchor":"client-persistent-kvp","heading":"Client persistent KVP","level":1,"text":"`Open77.kvp` persists small values on the client. Data is isolated by connection address, resource name and key:\n\n```text\nconnection address -> resource name -> key\n```\n\nConnecting to `188.40.140.88:11777` and `localhost:11777` therefore creates two independent\nstores, even if both addresses reach the same machine. Resources cannot name or inspect another\nresource's namespace.\n\n```lua\nlocal ok, reason = Open77.kvp.set(\"character:lastSlot\", 2)\nassert(ok, reason)\n\nlocal slot = Open77.kvp.get(\"character:lastSlot\", 1)\nlocal present = Open77.kvp.has(\"character:lastSlot\")\nlocal newCount = Open77.kvp.increment(\"stats:connections\", 1)\n```","natives":["Open77.kvp.set","server:Open77.kvp.set","Open77.kvp.get","server:Open77.kvp.get","Open77.kvp.has","server:Open77.kvp.has","Open77.kvp.increment","server:Open77.kvp.increment"],"permissions":[],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"| Method | Result | Description |\n|---|---|---|\n| `set(key, value)` | `true` or `false, reason` | Store a string, integer, finite number, or boolean. |\n| `get(key[, default])` | value/default, or `nil, reason` | Read the original Lua type. Missing keys are not errors. |\n| `has(key)` | boolean, optionally `reason` | Check whether the key exists. |\n| `delete(key)` | boolean, optionally `reason` | Delete a key and report whether it existed. |\n| `keys([prefix[, limit]])` | string array or `nil, reason` | Sorted prefix search; default limit is 256. |\n| `find([prefix[, limit]])` | entry array or `nil, reason` | Return `{ key, value, type }` records. |\n| `clear([prefix])` | removed count or `nil, reason` | Delete this resource's matching keys only. |\n| `increment(key[, delta])` | number or `nil, reason` | Atomically create/increment a numeric value. |\n| `setIfAbsent(key, value)` | boolean, optionally `reason` | Redis-style `SETNX`. |\n| `compareAndSet(key, expected, replacement)` | boolean, optionally `reason` | Atomic CAS. `nil` means missing for expected and deletion for replacement. |\n| `stats()` | table or `nil, reason` | Entry/byte usage, quotas, address, and resource namespace. |\n\nFiveM-familiar aliases are also available: `SetResourceKvp`, `GetResourceKvp`, and\n`DeleteResourceKvp`. New code should prefer `Open77.kvp` because it exposes typed results and the\natomic/search operations.","natives":[],"permissions":[],"events":[]},{"anchor":"limits-and-persistence-guarantees","heading":"Limits and persistence guarantees","level":2,"text":"- Keys: 1-256 bytes, with no control characters.\n- String values: at most 64 KiB each.\n- Resource store: at most 4,096 entries and 1 MiB of key/value payload.\n- Files are committed through a same-directory temporary file and an atomic replace.\n- A versioned binary header and CRC32 reject truncated or corrupted files instead of silently\n replacing them.\n- Validation/preflight Lua states cannot write KVP data.\n\nData is stored below `red4ext/plugins/Open77/storage/kvp`. The connection address is reversibly\nhex-encoded before becoming a directory name, preventing path traversal without changing its\nnamespace semantics.\n\nThis is local persistence, not secret storage. The player owns the machine and can inspect or\nremove the files. Never store passwords, server tokens, private keys, or authoritative economy\nstate in client KVP. Use the server database for anything that must resist client modification.","natives":[],"permissions":[],"events":[]},{"anchor":"the-server-twin","heading":"The server twin","level":2,"text":"`Open77.kvp` now exists on the server too, with the same eleven functions, the same quotas and the\nsame reason tokens — see [Resource key/value store](server-api.md#resource-keyvalue-store). The two\nstores are entirely separate: the client's lives on the player's machine and is keyed by server\naddress plus resource, the server's lives in one JSON file under that resource's `data/` directory\non the host. A shared script calling `Open77.kvp.set` therefore writes to whichever side it is\nrunning on, which is almost always what you want and never what you should assume — anything the\nserver must be able to trust belongs in the server store or the database, not in the client's.","natives":[],"permissions":[],"events":[]}]},{"slug":"client-players","file":"client-players.md","title":"Players around you (client)","summary":"`Open77.players` reads the identities, positions and distances of players rendered by the client. These client-side queries require no permission.","sections":[{"anchor":"players-around-you-client","heading":"Players around you (client)","level":1,"text":"`Open77.players` reads the identities, positions and distances of players rendered by the client. These client-side queries require no permission.\n\n```lua\nresource \"proximity_demo\"\nversion \"1.0.0\"\nclient_script \"client/main.lua\"\n-- no permissions block: the enumeration below is ungated\n```","natives":[],"permissions":[],"events":[]},{"anchor":"player-ids-and-entity-handles","heading":"Player IDs and entity handles","level":2,"text":"**This is a streaming-scoped view, not the server roster.** It holds the players *this client* has\nbeen told about — the same roster that draws the nameplates — and nothing more. A player the server\ncounts as connected can be missing from it for a long time: a different routing bucket, too far to\nstream, a proxy body still spawning, a world transition in progress. The list is also per-client, so\ntwo players standing together will not necessarily see the same one.\n\nSo:\n\n| Question | Ask |\n|---|---|\n| Who is standing near me, and where is his body? | `Open77.players` on the client |\n| How many players are on this server? Is this id on the whitelist? Who gets the payout? | the server, over a [net callback](callbacks.md) |\n\nA resource that treats `#Open77.players.all()` as a head count writes a race that a quiet test\nserver will never reproduce and a busy one will hit within the hour. The client cannot be made\nauthoritative about a roster it only receives a slice of; the fix is always to ask the server.","natives":[],"permissions":[],"events":[]},{"anchor":"the-api","heading":"The API","level":2,"text":"| Function | Answers |\n|---|---|\n| `Open77.players.all(options?)` | `{ playerId, ... }`, ascending, the local player included |\n| `Open77.players.localId()` | your own player id |\n| `Open77.players.entity(playerId)` | the entity id of that player's body |\n| `Open77.players.fromEntity(entityId)` | which player wears that body |\n| `Open77.players.nearby(radius, options?)` | everyone with a body within `radius`, nearest first |\n| `Open77.players.closest(options?)` | just the nearest one |\n\nFailures follow the repository convention: `nil, reason`, with stable snake_case tokens. The reason\nmatters here more than usual, because \"he has no body yet\" and \"I have never heard of him\" are\ndifferent facts and a proximity resource has to act differently on each.","natives":[],"permissions":[],"events":[]},{"anchor":"localid-and-why-it-is-not-local","heading":"`localId()` — and why it is not `local()`","level":3,"text":"`local` is a reserved word in Lua, so `Open77.players.local()` is a **syntax error**: the parser\nrefuses the name before anything runs. The function is registered under both spellings, so\n\n```lua\nOpen77.players.localId() -- write this\nOpen77.players[\"local\"]() -- identical: the same function value\n```\n\nBoth return your player id, or `nil, \"no_session\"` when you are not connected to a server.","natives":["Open77.players.localId"],"permissions":[],"events":[]},{"anchor":"alloptions","heading":"`all(options?)`","level":3,"text":"```lua\nfor _, id in ipairs(Open77.players.all()) do\n print(id, id == Open77.players.localId() and \"(me)\" or \"\")\nend\n\n-- only the ones that actually have a body right now\nlocal visible = Open77.players.all({ streamed = true })\n```\n\nAscending by player id, so two calls in the same frame compare cleanly. **Your own id is in the\nlist**, matching FiveM's `GetActivePlayers`; filter it out yourself if you do not want it.\n\nA player who is connected but has no body — his proxy has not spawned, or it was retired for a\ndeath, a re-dress or a world transition — **stays in the list** with no entity. Dropping him would\nmake \"not streamed yet\" indistinguishable from \"left the server\", which is exactly the distinction\nthis API exists to preserve. `{ streamed = true }` is how you ask for only the bodied ones; it is\nthe equivalent of FiveM's `NetworkIsPlayerActive` filter.\n\nA player who leaves is gone from the list immediately, and so is the id of a body that was retired:\nsee [Ids do not outlive bodies](#ids-do-not-outlive-bodies).","natives":["Open77.players.all","server:Open77.players.all","Open77.players.localId"],"permissions":[],"events":[]},{"anchor":"entityplayerid-and-fromentityentityid","heading":"`entity(playerId)` and `fromEntity(entityId)`","level":3,"text":"The two directions of the mapping. Feed the entity into any Open77 entity API —\n`Open77.character.state(entity)` first among them — for the transform, stance, weapon and vehicle.\nFor the same body's own axes — a point two metres in front of it, or where its head is — see\n[an entity's own axes](world-queries.md#an-entitys-own-axes).\n\n```lua\nlocal id = Open77.players.localId()\nlocal entity = Open77.players.entity(id)\nlocal state = Open77.character.state(entity)\nprint((\"%s is at %.1f, %.1f\"):format(id, state.position.x, state.position.y))\n```\n\n`entity()` accepts the player id as a number or as a decimal string. It returns:\n\n| Result | Meaning |\n|---|---|\n| an entity id | that player has a body, and this addresses it |\n| `nil, \"player_not_streamed\"` | he is here, he has no body — do not retry in a loop, wait for him |\n| `nil, \"unknown_player\"` | this client has never heard of that id |\n| `nil, \"invalid_player\"` | the argument was not an id at all |\n\n**Your own body is the entity id `0`.** That is not a failure value: `0` is the reserved id that\nevery Open77 entity API reads as \"the local player\", which is why `Open77.character.state()` with no\nargument and `Open77.character.state(Open77.players.entity(Open77.players.localId()))` are the same\ncall. `fromEntity(0)` gives your player id back.\n\n`fromEntity()` takes the entity as a number or a decimal string — the string form is what a raycast\nhit or the older `Open77.animations._context()` map hands out — and answers `nil, \"unknown_entity\"`\nfor anything that is not a live player body right now.","natives":["Open77.players.localId","Open77.players.entity","Open77.character.state"],"permissions":[],"events":[]},{"anchor":"nearbyradius-options-and-closestoptions","heading":"`nearby(radius, options?)` and `closest(options?)`","level":3,"text":"Centred on your own body, sorted by ascending distance, ties broken by player id so that \"closest\"\ndoes not flicker between two bodies standing on the same spot.\n\n```lua\nfor _, player in ipairs(Open77.players.nearby(15.0)) do\n print((\"%s at %.1f m\"):format(player.name, player.distance))\nend\n\nlocal target = Open77.players.closest({ radius = 3.0 })\nif target then\n print((\"you are next to %s\"):format(target.name))\nend\n```\n\nEach entry is:\n\n| Field | |\n|---|---|\n| `playerId` | the network id |\n| `entity` | the body, ready for `Open77.character.state` |\n| `distance` | metres from the origin |\n| `position` | world position — a plain `{ x, y, z }` table, which **is** a [vector3](vectors.md) |\n| `name` | display name |\n| `isLocal` | true only for your own entry, and only if you asked for it |\n\nOptions, shared by both:\n\n| Option | Default | |\n|---|---|---|\n| `origin` | your own body | measure from somewhere else — a raycast hit, a door, a marker |\n| `includeSelf` | `false` | put your own entry in the result |\n| `limit` | unlimited | `nearby` only, 1..256, applied after sorting |\n| `radius` | unlimited | `closest` only; `nearby` takes it as its first argument |\n\nBecause `position` is a plain x/y/z table, it is already a vector everywhere a vector is accepted:\n\n```lua\nlocal a, b = Open77.players.nearby(50)[1], Open77.players.nearby(50)[2]\nprint(#(vector3(a.position) - vector3(b.position))) -- metres between them\n```\n\nFailure reasons:\n\n| Reason | Meaning |\n|---|---|\n| `no_session` | not connected to a server |\n| `no_local_position` | you are connected but have no body — the \"continue\" screen, or a world transition — so there is no centre to measure from. Pass `origin` to ask anyway |\n| `no_player_in_range` | `closest` only: nobody qualified. `nearby` returns an empty table instead |\n| `invalid_radius` / `invalid_origin` / `invalid_limit` | the argument was malformed |\n\nPlayers without a body are skipped by both: they have no position to sort by. Use `all()` if you\nneed to know they exist.","natives":["Open77.players.nearby","server:Open77.players.nearby","Open77.players.closest","server:Open77.players.closest"],"permissions":[],"events":[]},{"anchor":"ids-do-not-outlive-bodies","heading":"Ids do not outlive bodies","level":2,"text":"A proxy body is destroyed and recreated more often than players expect — on death, on a clothing\nchange, on a world transition. When it goes, the entity id goes with it:\n\n```lua\nlocal entity = Open77.players.entity(otherPlayer) -- 12884901889\n-- ... he dies and respawns ...\nOpen77.players.fromEntity(entity) --> nil, \"unknown_entity\"\nOpen77.players.entity(otherPlayer) --> a different id\n```\n\n**Cache the player id, never the entity id.** The player id is stable for the whole session; the\nentity id is a handle on one particular body. Resolving it again is one table lookup, so there is\nnothing to gain by holding it.","natives":["Open77.players.entity","Open77.players.fromEntity"],"permissions":[],"events":[]},{"anchor":"permission","heading":"Permission","level":2,"text":"None. The roster is what the client is already rendering.\n\nThe two things a roster could leak that a screenshot cannot — life state and stats — are not here:\n`Open77.players.getLifeState`, `isDead`, `allLifeStates`, `getHealthState` and `allHealthStates` stay\nbehind `players.life.read` in the same table, and the stats reads behind `players.stats.read`. Asking\nwho is standing in front of you does not grant you their health bar.","natives":[],"permissions":[],"events":[]},{"anchor":"relation-to-the-server-api","heading":"Relation to the server API","level":2,"text":"The dedicated server has its own `Open77.players` with the same names and different authority — its\nroster is the real one, and it can act on it. See [the server API](server-api.md). A resource that\nneeds a truth the client cannot have asks for it over a [net callback](callbacks.md):\n\n```lua\n-- server: the authoritative answer\nOpen77.net.register(\"population\", function(source)\n return #Open77.players.all()\nend)\n\n-- client, inside a CreateThread, with `network.events` in the manifest\nlocal count = Open77.net.callAwait(\"population\")\n```","natives":["Open77.net.register","server:Open77.net.register","Open77.players.all","server:Open77.players.all","Open77.net.callAwait"],"permissions":[],"events":[]}]},{"slug":"clipboard","file":"clipboard.md","title":"Clipboard API and transform commands","summary":"Copy text to the operating-system clipboard with a client resource. The API is write-only and is intended for explicit actions such as copying coordinates, IDs or configuration snippets.","sections":[{"anchor":"clipboard-api-and-transform-commands","heading":"Clipboard API and transform commands","level":1,"text":"Copy text to the operating-system clipboard with a client resource. The API is write-only and is intended for explicit actions such as copying coordinates, IDs or configuration snippets.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-permission","heading":"Manifest permission","level":2,"text":"Declare `clipboard.write` in the client resource that performs the write:\n\n```lua\nresource \"my_tools\"\nversion \"1.0.0\"\n\nclient_script \"client/main.lua\"\npermission \"clipboard.write\"\n```\n\nThe permission belongs to the resource whose client VM calls the API. A server resource cannot\nwrite directly to a player's clipboard.","natives":[],"permissions":[],"events":[]},{"anchor":"write-text","heading":"Write text","level":2,"text":"```lua\nlocal copied, reason = Open77.clipboard.setText(\n \"position = { x = 1660.068359, y = -723.649170, z = 50.512436 }\"\n)\n\nif not copied then\n print(\"Clipboard write failed: \" .. tostring(reason))\nend\n```\n\n`Open77.clipboard.setText(text)` returns `true` on success. On refusal it returns `false, reason`.\nThe text must be valid UTF-8, cannot contain an embedded NUL, and is limited to 256 KiB.\n\nPossible failure reasons include:\n\n- `permission_denied:clipboard.write`\n- `clipboard_text_too_large`\n- `clipboard_text_contains_nul`\n- `clipboard_invalid_utf8`\n- `clipboard_busy`\n- `clipboard_clear_failed`\n- `clipboard_allocation_failed`\n- `clipboard_lock_failed`\n- `clipboard_write_failed`\n- `clipboard_unavailable_on_this_host`\n\nThe API is synchronous and should be called in response to a deliberate player action. If another\ndesktop application temporarily owns the clipboard, report `clipboard_busy` and let the player\nretry instead of looping every frame.","natives":["Open77.clipboard.setText"],"permissions":[],"events":[]},{"anchor":"built-in-pos-and-rot-commands","heading":"Built-in `/pos` and `/rot` commands","level":2,"text":"The official debug resource ([`open77_debug`](https://github.com/Open2077/open77-resources/tree/main/system/open77_debug)) registers these two authenticated commands; the\nclipboard permission that backs them is declared by [`open77_chat`](https://github.com/Open2077/open77-resources/tree/main/system/open77_chat):\n\n```text\n/pos\n/rot\n```\n\n`/pos` copies the local player's current world position:\n\n```lua\nposition = { x = 1660.068359, y = -723.649170, z = 50.512436 }\n```\n\n`/rot` copies the complete quaternion and the horizontal yaw:\n\n```lua\norientation = { x = 0.000000, y = 0.000000, z = 0.707107, w = 0.707107 }, yaw = 90.000000\n```\n\nThe command is registered on the server, then targets only the authenticated requesting client.\nThe downloaded `open77_chat` client reads `Open77.character.state()`, performs the clipboard write,\nand returns a bounded result. The server never receives the previous clipboard contents. Success or\nfailure is shown both in chat and through a middle-left WebUI notification.\n\nThis split is important for server packages: server network events are delivered to the active\ndownloaded resource generation. A handler placed only in a protected bootstrap resource will not\nreceive a normal server-resource event.","natives":[],"permissions":[],"events":[]},{"anchor":"copy-a-character-transform-from-your-own-resource","heading":"Copy a character transform from your own resource","level":2,"text":"The command implementation is only a convenience. A client resource can produce another format\ndirectly:\n\n```lua\nlocal state, stateError = Open77.character.state()\nif not state or not state.attached then\n print(stateError or \"player_transform_unavailable\")\n return\nend\n\nlocal p = state.position\nlocal q = state.orientation\nlocal text = string.format(\n \"vec4(%.3f, %.3f, %.3f, %.3f) at vec3(%.3f, %.3f, %.3f)\",\n q.x, q.y, q.z, q.w, p.x, p.y, p.z\n)\n\nlocal copied, reason = Open77.clipboard.setText(text)\nassert(copied, reason)\n```\n\n`state.position`, `state.orientation`, and `state.yaw` come from the same character snapshot, so the\ncopied position and rotation describe one coherent sample.","natives":["Open77.character.state","Open77.clipboard.setText"],"permissions":[],"events":[]}]},{"slug":"community-hub-warden","file":"community-hub-warden.md","title":"Manage Workshop resources with Warden","summary":"Use Warden to install, update, remove or roll back resources from the OPEN//77 Workshop. Warden displays a plan before applying changes, including dependencies, permissions and affected files.","sections":[{"anchor":"manage-workshop-resources-with-warden","heading":"Manage Workshop resources with Warden","level":1,"text":"Use Warden to install, update, remove or roll back resources from the OPEN//77 Workshop. Warden displays a plan before applying changes, including dependencies, permissions and affected files.\n\nFor resource manifests and the client/server split, see\n[Server resources](server-resources.md). For assets prepared before the game\nstarts, see [Mods](mods.md). The website side of the Workshop, for creators and\nreaders, is in [the Workshop guide](https://open2077.net/docs/community-hub).","natives":[],"permissions":[],"events":[]},{"anchor":"before-you-start","heading":"Before you start","level":2,"text":"Open Warden and select the **Workshop** tab (subtitle \"Community resources\").\n\nTwo settings in `server.jsonc` decide what the tab can do:\n\n- **Browsing** uses the master your server is registered with (`masterServer`).\n Every listed OPEN//77 server already has one, so search works out of the box.\n **Workshop not connected** means `masterServer` is missing or invalid.\n- **Installing** needs the Workshop file gateway. Add it once and restart:\n\n ```jsonc\n \"warden\": {\n \"enabled\": true,\n \"hubFileGatewayOrigin\": \"https://files.open2077.net\"\n }\n ```\n\n Without it you can browse but every **Create installation plan** answers\n that installation is unavailable. Installation and maintenance also wait\n until startup recovery has finished after each restart.\n\nA Warden login controls this server only; it is separate from your website\ncreator account. Being a creator on the website grants no rights here.\n\n| Permission | Allows |\n| --- | --- |\n| `hub.view` | Browse the Workshop and read release information. |\n| `hub.manage` | Create and apply install, update, uninstall, rollback and storage cleanup plans; see installed packages, history and restart staging. |\n| `hub.publish` | Connect a creator account and export resources from this server into a private creator draft. |\n| `restart.schedule` | Schedule the server restart that staged preload changes need. |\n\nDo not share Warden credentials, and never put creator tokens inside a\nresource archive.","natives":[],"permissions":[],"events":[]},{"anchor":"install-a-creation","heading":"Install a creation","level":2,"text":"1. On the website, open the creation's page and press **Copy link** in the\n **Install with Warden** box. The link looks like\n `https://open2077.net/workshop/auto-taxi`; the short form\n `workshop:auto-taxi` also works.\n2. In Warden's **Workshop** tab, paste the link into the search field and\n press **Search**. Warden looks the creation up on the OPEN//77 master,\n shows it as a single tile and opens its releases. If the address now points\n at a different creation, Warden says so; open the page on the website to\n get the current link.\n3. Pick the release you want. Each release shows its version, state, the\n resources it contains, **Tested on** (the builds the creator declared) and\n its declared dependencies.\n4. Press **Create installation plan**. If the release does not list your\n server build, Warden stops and offers **Acknowledge untested build and\n retry**; see \"Plan options\" below.\n5. Read the plan (see \"What the plan shows\"). Fix any blocker. If you change\n local files or configuration while the plan is open, create a fresh one.\n6. Tick the consent box and press **Install reviewed plan**. The\n **Installation job** panel follows the job; **Cancel installation** stays\n available until it finishes.\n7. The install is done when the job reports **committed**. A finished download\n or private staging is not an install.\n\nOrdinary Lua changes activate in the running server. The package is downloaded,\ninspected and prepared before the runtime switches over. The job keeps running\non the server if you leave the page; the panel reappears when you return to\nthe Workshop tab in the same browser session.\n\nYou can also search by term instead of pasting a link: type a word, pick a\ncategory chip (**All**, **Scripts**, **Gamemodes**, **Maps**, **UI**,\n**Tools**) and a **Sort** (**Trending**, **Newest**, **Recently updated**,\n**Most upvoted**, **Most downloaded**, **Featured**). Results are tiles with\nthe cover image (or the hover clip's poster), category and maturity chips,\ncreator, upvotes, downloads and update date. Press **Releases & install** on a\ntile to open its releases. A showcase tile says **Showcase · nothing to\ninstall**. The small `workshop:` label on every tile is the short link\nyou can paste later.\n\nIn the **Resources** tab, a package installed this way carries a **Workshop ·\nv1.0.1** chip (with its own version), and the summary counts how many\nresources come from the Workshop. Manage them under **Installed from the\nWorkshop**, not by editing their files.","natives":[],"permissions":[],"events":[]},{"anchor":"update","heading":"Update","level":2,"text":"1. Search the same creation and pick the newer release.\n2. Press **Create installation plan**. Warden compares the release with what\n is installed, including files you changed locally, and lists every\n difference.\n3. Review and install as for a first install.\n\nAutomatic updates do not exist: nothing changes without your review.","natives":[],"permissions":[],"events":[]},{"anchor":"roll-back","heading":"Roll back","level":2,"text":"1. In **Installed from the Workshop**, unfold **History, restart staging and\n storage**.\n2. Under **Installation history**, press **Review rollback** next to the job\n you want to return to. The button only appears while that job's backup is\n still retained.\n3. Warden builds a fresh reverse plan against the server as it is now. Review\n it, tick the consent box and press **Apply reviewed rollback** (or **Stage\n reviewed rollback for restart** when preload assets are involved).\n\nRollback keeps current configuration and user files as its review describes;\nit is not a blind restore of every historical byte. It must still satisfy\ncurrent release availability, compatibility and dependencies. It refuses to\nrun if the old release is unavailable or revoked, the original code can no\nlonger be recovered, or a current dependent needs a different version. The\noriginal history and backup stay unchanged.","natives":[],"permissions":[],"events":[]},{"anchor":"uninstall","heading":"Uninstall","level":2,"text":"1. In **Installed from the Workshop**, tick the packages under **Pick**.\n2. Press **Review uninstall**. Warden checks both package dependencies and\n runtime resource dependencies, including dependents you did not select.\n3. Review the removed resources, preserved paths, load rules and any restart\n requirement, tick the consent box and press **Apply reviewed uninstall**.\n\nYour configuration and user files survive removal, which can leave an inert\nresource directory behind. A modified manifest is kept under an uninstalled\nfilename so it can no longer load. Warden never takes over another creation's\nresource name and never silently removes a dependency that came from outside.\nAn uninstall is recorded as its own job.","natives":[],"permissions":[],"events":[]},{"anchor":"installed-from-the-workshop","heading":"Installed from the Workshop","level":2,"text":"This table lists every package Warden installed: **Pick**, **Resources**,\n**Version**, **Workshop status** and **Installed** date. **Refresh** rereads\nit and rechecks each release with the Workshop. The status chip means:\n\n- **published**: the Workshop still lists this exact release;\n- **revoked**: the Workshop withdrew it. Installs and updates of this release\n are blocked; your installed copy stays and an alert asks you to decide\n whether to uninstall;\n- **not listed**: the release is no longer public, which does not confirm a\n revocation;\n- **status unavailable**: the Workshop could not be reached; press\n **Refresh** to retry.\n\nThe folded section **History, restart staging and storage** holds\n**Installation history** (with **Review rollback**), **Restart staging** and\n**Private storage retention**.","natives":[],"permissions":[],"events":[]},{"anchor":"export-your-resources-to-the-workshop","heading":"Export your resources to the Workshop","level":2,"text":"With `hub.publish`, the Workshop tab also shows three creator sections.\n\n1. **Creator account.** Enter a label and press **Connect creator account**.\n Warden shows a code and an **Open creator approval** button. Approve the\n requested scope on the website; only the Warden session that started the\n connection can finish it. **Check authorization** confirms a saved\n connection; **Disconnect creator** ends it.\n2. **Export your resources.** Tick the resources to share, enter a **Release\n version** and press **Preview export**. Read the included and excluded\n files, findings and warnings, tick the consent box and press **Create\n reviewed ZIP**. When it is ready, **Download reviewed ZIP** lets you inspect\n it, and **Send ZIP to creator draft** sends it to your account as a private\n draft after you fill in the license, changelog and release details and tick\n the rights box. **Private draft accepted for editing** means the draft\n exists on the website and is not published; **Open creator draft** takes\n you there.\n3. **Saved creator transfers.** Transfers survive a disconnect or a Warden\n restart. Press **Check or resume reviewed transfer** to continue the same\n upload instead of creating a second draft; **Discard local transfer**\n removes it.\n\nWarden never exports your server identity, signing keys, live configuration,\nlogs or player data. The secret scan is a heuristic; read the file list. The\nfull host-side description is in\n[the Warden export guide](https://github.com/Open2077/open77-base/blob/feat/community-hub/docs/community-hub-warden-export.md).","natives":[],"permissions":[],"events":[]},{"anchor":"details-plan-options","heading":"Details: plan options","level":2,"text":"Every release has a **Plan options** fold above **Create installation plan**:\n\n- **Allow prerelease versions**: needed to install a release with a prerelease\n suffix such as `1.1.0-beta.1`.\n- **Optional dependency project IDs**: optional dependencies you want\n installed as well.\n- **Manually satisfied external resource names**: resources the package\n requires that you already provide yourself.\n- **Release IDs acknowledged as untested on this build**: releases you accept\n although the creator did not list your server build. **Acknowledge untested\n build and retry** fills this in for you. It is your decision, not a\n compatibility guarantee; manifest and dependency rules still apply.\n\nThe fold also lists the selected release ID and its required and optional\ndependencies with their version constraints.","natives":[],"permissions":[],"events":[]},{"anchor":"details-what-the-plan-shows","heading":"Details: what the plan shows","level":2,"text":"A plan is an immutable review that expires after a short time. It shows:\n\n- the **plan hash**. When you accept, you accept that exact hash; Warden\n cannot swap in another release or other options behind it;\n- **blockers**, which must be resolved before the install button appears;\n- whether a **server restart** or a **game restart** is required;\n- **Stop order** and **Start order** of resources;\n- **Proposed load rules**, the `resources.load` rules the plan adds;\n- **Preload assets** and **Operational notes**;\n- for each package, **Requested permissions** and, per resource, the file\n entries: the action for every path, with its reason, size and SHA-256.\n Owned code, untracked files and files you modified each get their own\n action. Read them rather than assuming every local edit is safe to\n replace.\n\n**Discard plan** throws the review away. Maintenance plans (uninstall,\nrollback) show the same fields plus removed runtime resources, restored\npackages and preserved directories.","natives":[],"permissions":[],"events":[]},{"anchor":"details-job-states","heading":"Details: job states","level":2,"text":"The job panel polls the server and ends in one of four states:\n\n- `committed`: the change is live and the installed inventory revision\n advanced;\n- `cancelled`: you cancelled it and recovery has finished;\n- `failed`: the message names the cause; check the server's recovery state\n before retrying;\n- `awaiting_restart`: files are staged privately for a restart (see below).\n\nWhile `cancel_requested` shows, Warden is waiting for the running step or its\nrecovery to finish. A job that was interrupted is recovered from the private\nlocal journal at the next startup. Do not delete private Workshop state or\nsigning identities to force recovery past an error; keep the artifacts and\nresolve the reported conflict.","natives":[],"permissions":[],"events":[]},{"anchor":"details-configuration-and-local-files","heading":"Details: configuration and local files","level":2,"text":"Package authors can declare configuration templates in `open77-hub.json`.\nWarden writes them to their destinations and preserves your existing local\nvalues as the plan describes. Keep your own backups of important server data.","natives":[],"permissions":[],"events":[]},{"anchor":"details-preload-changes-and-restarts","heading":"Details: preload changes and restarts","level":2,"text":"A resource can declare `preload_mod` assets that must be prepared before\nCyberpunk starts. Any install, update, rollback or uninstall that touches such\nassets is staged instead of applied: the button reads **Stage reviewed plan\nfor restart** and the job ends in `awaiting_restart`. Live files,\nconfiguration and inventory stay unchanged while the reviewed files are staged\nprivately.\n\nUnder **History, restart staging and storage › Restart staging**, the pending\njob is listed with its remaining preload assets. **Cancel staged restart**\ndrops it. **Schedule server restart** (needs `restart.schedule`) stops the\nserver after 30 seconds; your service manager must relaunch it. Players must\nrelaunch through the launcher when the required asset set changes. Staging\nsurvives logout and page reload, and while a job is pending no other Workshop\nchange can start.\n\nAt startup, before preload resolution, Warden re-checks master authority,\nrelease metadata, dependencies, the reviewed options, server and game versions,\ninventory, configuration, manifests and the captured resource files. If the\nauthority is offline, the content was revoked or something was edited in\nbetween, the pending job is cancelled and the previous installation boots. Fix\nthe cause and review a new plan; changed conditions never widen the original\nconsent.\n\nAfter the staged paths are applied, preload resolution and the required Lua\nstartup must both succeed before the inventory commits. A startup failure\nrestores the saved state. Server-side tests cover this flow, but they do not\nprove that a given archive loads in the game. Validate your own assets through\nthe launcher and the game before making them a requirement for players.","natives":[],"permissions":[],"events":[]},{"anchor":"details-private-storage-retention","heading":"Details: private storage retention","level":2,"text":"Retention acts on the private records of completed Workshop jobs, never on live\nresource directories. Under **History, restart staging and storage › Private\nstorage retention**, press **Review storage cleanup** to see what stays\nprotected, which duplicate payloads can be removed and which jobs can be\ncompacted. Tick the consent box and press **Apply reviewed cleanup**.\nCompacting a job permanently expires its rollback files; its history receipt\nremains, with rollback no longer available.\n\nThe default policy keeps at least 30 days of completed jobs, the newest three\ncommitted installs per creation, the providers of currently installed\nreleases, and the source jobs that retained maintenance history needs. It also\nprotects active and recovery jobs, malformed or linked data, changed or\nunknown files, unique configuration, user or modified-file backups, and\ndiffering root configuration snapshots. These safeguards can prevent\nreclaiming all the space you asked for.\n\nInspection is bounded at 1,000 live job records, 10,000 files and 500 MiB per\njob snapshot, and 10,000 compact history receipts. Oversized snapshots are\nprotected. Review retention before reaching the job limit; the limit never\npermits deleting unresolved recovery data. Interrupted compaction resumes from\nits receipt. Retention is not a substitute for your backup policy.","natives":[],"permissions":[],"events":[]}]},{"slug":"connection-control","file":"connection-control.md","title":"Connection control: connect events, whitelists and bans","summary":"Control server admission with connection events, deferred checks, whitelists and bans. Resources can reject a connection with a player-facing message or remove an active player. See the [server Lua API](server-api.md#connection-control) for method signatures.","sections":[{"anchor":"connection-control-connect-events-whitelists-and-bans","heading":"Connection control: connect events, whitelists and bans","level":1,"text":"Control server admission with connection events, deferred checks, whitelists and bans. Resources can reject a connection with a player-facing message or remove an active player. See the [server Lua API](server-api.md#connection-control) for method signatures.","natives":[],"permissions":[],"events":[]},{"anchor":"where-the-gate-sits","heading":"Where the gate sits","level":2,"text":"A connection goes through these stages in order. Resources take part in the stages in bold.\n\n1. The transport connects. Nothing is known about the player.\n2. The client sends its hello: protocol, game build, display name, identity public key with a\n proof, and a Master connect ticket.\n3. The server checks the platform facts: protocol and build, identity proof against its Master\n key, signed game and DLC ownership in the connect ticket, identity key stability, capacity. A failure\n here is a refusal resources are told about (**`onPlayerRejected`**) but cannot influence.\n4. The server's own door list, `access.json` next to `server.jsonc`: a ban, or a closed whitelist\n the identity is not on, refuses here with the list's sentence. Warden, the console and\n `Open77.access` all edit the same list. See [The built-in door list](#the-built-in-door-list).\n5. **`onPlayerConnecting`**, the resource gate. Every running resource that holds the\n `players.gate` permission and registered a handler is asked. Refuse, hold, or let through.\n6. The player is admitted: welcome packet, player id, **`playerJoining`** and then\n **`onPlayerConnected(playerId)`**.\n7. The join-time readiness gate (`Open77.ready`) and **`onPlayerReady`**. That gate decides *when a\n resource may act on* an admitted player; this page is about *whether* they get in. See\n [Join-time readiness gate](server-api.md#join-time-readiness-gate).\n8. The session ends: **`onPlayerDisconnected(playerId, reason)`** and **`playerDropped(reason)`**.\n\nThe gate lives in Lua rather than in configuration because the questions it answers belong to the\nserver's operator: who is on the list tonight, is this account banned until Sunday, is this slot\nreserved. The host owns the mechanics (the deadline, combining several resources' answers, what the\nplayer sees) so a resource only has to say yes or no.","natives":[],"permissions":[],"events":[]},{"anchor":"onplayerconnectingplayer-deferrals","heading":"`onPlayerConnecting(player, deferrals)`","level":2,"text":"```lua\n-- open77.lua\npermissions { \"players.gate\" }\n```\n\n```lua\n-- server/main.lua\nAddEventHandler(\"onPlayerConnecting\", function(player, deferrals)\n if player.name == \"Mallory\" then\n deferrals.done(\"You are not welcome here, Mallory.\")\n end\nend)\n```\n\nA handler that returns without deferring accepts the player. `deferrals.done(\"message\")` refuses:\nthe connection is closed and the shell shows exactly that message as the reason.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":["players.gate"],"events":[]},{"anchor":"the-player-table","heading":"The `player` table","level":3,"text":"The player has no player id yet, so `Open77.players.*` cannot be used on them. What is known:\n\n| Field | Type | Meaning |\n|---|---|---|\n| `userId` | string | The account id the Master issued, a GUID. Stable across renames and reinstalls of the same identity file: the key to use for a whitelist or a ban list. Verified by the identity proof before the gate runs. |\n| `name` | string | The display name the client presents. The player can change it, so match on it for convenience, never for security. |\n| `publicKey` | string | The identity public key, base64. |\n| `fingerprint` | string | `sha256:` of the public key. The client console prints the same string for `identity.dump`, so a player can read it to an operator over voice. |\n| `ticket` | boolean | Whether a Master connect ticket was presented and verified. |","natives":[],"permissions":[],"events":[]},{"anchor":"deferrals","heading":"Deferrals","level":3,"text":"| Call | Effect |\n|---|---|\n| `deferrals.defer()` | Hold the player. For an answer that is not immediate: a database row, an HTTP call, a file. |\n| `deferrals.update(message)` | Note progress. Written to the server log as `connecting '': `. The handshake has no progress channel, so the player does not see it. |\n| `deferrals.done()` | Release your hold. When every handler in every participating resource has released, the player is admitted. |\n| `deferrals.done(message)` | Refuse. The player is disconnected with `message` on their screen. One refusal is final, whatever the other resources say. |\n\n`message` is trimmed to 127 bytes of UTF-8 and stripped of control characters. An empty message\nbecomes `Connection refused by the server.`\n\nAn asynchronous check against the database:\n\n```lua\nAddEventHandler(\"onPlayerConnecting\", function(player, deferrals)\n deferrals.defer()\n deferrals.update(\"looking up the ban list\")\n Open77.database.single(\n \"SELECT reason FROM bans WHERE user_id = ? AND (expires_at IS NULL OR expires_at > NOW())\",\n { player.userId },\n function(row)\n if row then\n deferrals.done(\"Banned: \" .. row.reason)\n else\n deferrals.done()\n end\n end)\nend)\n```","natives":["AddEventHandler","server:AddEventHandler","server:Open77.database.single"],"permissions":[],"events":[]},{"anchor":"the-rules-the-host-enforces","heading":"The rules the host enforces","level":3,"text":"| Rule | Detail |\n|---|---|\n| Permission | Only resources whose manifest lists `players.gate` take part. A resource with a handler and no permission is skipped, with one `WRN`: `onPlayerConnecting handler ignored: the manifest lacks the players.gate permission`. |\n| Timing | Handlers run on the resource's next tick, never inside the network code. |\n| Combination | Inside one resource, every handler must accept. Across resources, every participating resource must accept. The first refusal wins and nobody else is waited for. |\n| Deadline | `simulation.connectGateTimeoutSeconds` in `server.jsonc`, default 8, allowed 0.5 to 9. The client abandons the handshake after 10 seconds, so a longer wait would only trade one error for another. At the deadline the player is refused with `connection_gate_timeout`. |\n| Errors | A handler that throws is logged as `onPlayerConnecting handler error: ...` and counts as an acceptance, so a broken script cannot lock every player out. Where refusing is the safe default, catch the error yourself: see [Fail closed](#fail-closed). |\n| Stop and reload | A resource that stops while holding players releases them. |\n| Meanwhile | Packets the client sends while held are ignored. A player who drops while held is forgotten; a late `done()` is harmless. |\n| Nobody listening | When no permitted resource has a handler the gate costs nothing and the player is admitted on the spot. |","natives":[],"permissions":[],"events":[]},{"anchor":"onplayerrejecteduserid-name-code-message","heading":"`onPlayerRejected(userId, name, code, message)`","level":2,"text":"Emitted into every running resource, no permission needed, for every connection refused once its\nhello was read, whether by the platform checks or by a gate. Use it for audit logs, rate limits, or\ntelling an admin who keeps knocking.\n\n| Argument | Meaning |\n|---|---|\n| `userId`, `name` | Taken from the hello. Empty strings when the hello itself could not be read. For `identity_proof_invalid` they are what the client claimed and could not prove. |\n| `code` | The reject reason, lowercased: `protocolmismatch`, `gamebuildmismatch`, `invalidhello`, `serverfull`, `duplicatehello`, `refused`. |\n| `message` | For `refused`, the gate's own text or `connection_gate_timeout`. Otherwise a machine token: `expected_game_build:`, `identity_proof_invalid`, `connect_ticket_required`, `connect_ticket_invalid`, `identity_key_changed`, `server_full`, `duplicate_client_hello`, `client_hello_required`. |\n\n```lua\nlocal knocks = {}\nAddEventHandler(\"onPlayerRejected\", function(userId, name, code, message)\n if code ~= \"refused\" then return end\n knocks[userId] = (knocks[userId] or 0) + 1\n if knocks[userId] == 5 then\n print((\"%s (%s) has been refused 5 times: %s\"):format(name, userId, message))\n end\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"onplayerdisconnectedplayerid-reason","heading":"`onPlayerDisconnected(playerId, reason)`","level":2,"text":"The event every resource already knew now carries the reason. `playerId` arrives as a string, like\nevery host event. `reason` is `connection_closed` when the transport dropped or the player quit,\notherwise the text the disconnect was queued with by `Open77.players.disconnect`, `kick` or `ban`.\n\n```lua\nAddEventHandler(\"onPlayerDisconnected\", function(playerId, reason)\n print((\"player %s left: %s\"):format(playerId, reason))\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"the-fivem-names","heading":"The FiveM names","level":2,"text":"Three connection events exist under their FiveM spelling as well, so a resource ported from a\nFiveM server runs without being rewritten. They are additive: the Open77 events above keep their\nnames, their arguments and their timing, and a resource may listen to either family or both.\n\n| FiveM name | Open77 name | What differs |\n|---|---|---|\n| `playerConnecting(name, setKickReason, deferrals)` | `onPlayerConnecting(player, deferrals)` | Same gate, same tally, same deferrals. The FiveM form receives only the display name; the Open77 form receives the whole `player` table. `source` is not set in either: no player id exists yet. |\n| `playerJoining(oldId)` with `source` | `onPlayerConnected(playerId, playerName)` | `source` is the new player id. `oldId` is always `\"\"`. A resource sees `playerJoining` before `onPlayerConnected`. |\n| `playerDropped(reason)` with `source` | `onPlayerDisconnected(playerId, reason)` | `source` is the player who left, and `reason` is the same text. Both arrive in the same tick. |\n\n`source` is the global a network event handler already reads, set the same way and by the same\nmechanism, so the two families of events mean the same thing by it:\n\n```lua\nAddEventHandler(\"playerDropped\", function(reason)\n local player = source -- the id, exactly as in a RegisterNetEvent handler\n print((\"player %s left: %s\"):format(tostring(player), tostring(reason)))\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"setkickreason-and-cancelevent-exactly-as-in-fivem","heading":"`setKickReason` and `CancelEvent()`, exactly as in FiveM","level":3,"text":"`setKickReason(message)` records a refusal message but does not reject the connection. Call `CancelEvent()` in the same `playerConnecting` handler to reject it. Cancellation takes precedence over a pending deferral; without a message, the reason is `refused`. Recording a reason without cancelling logs a warning for that resource.\n\n```lua\n-- a ported whitelist keeps its shape\nAddEventHandler(\"playerConnecting\", function(name, setKickReason, deferrals)\n if not allowed[name] then\n setKickReason(\"You are not on the whitelist.\")\n CancelEvent()\n end\nend)\n```\n\n`deferrals.done(message)` remains the Open77-native refusal and works in the same handler; the two\nnever disagree, because the first decision wins and the other is answered `gate_already_decided`.","natives":["AddEventHandler","server:AddEventHandler","server:CancelEvent"],"permissions":[],"events":[]},{"anchor":"reading-an-admitted-players-identity","heading":"Reading an admitted player's identity","level":2,"text":"`Open77.players.identity(playerId)` (alias `GetPlayerIdentity`) returns\n`{ userId, name, publicKey, fingerprint, joinedAt }` for an admitted player, or `nil`. `joinedAt`\nis ISO 8601 UTC. Since `2.31.13+op77.101`, the table also includes `license`, `steam` and `gog`\nwhen carried by the verified Master ticket. No permission is needed. The existing `userId`\nidentifies the installation's cryptographic identity and remains the key accepted by\n`Open77.access`; use `license` for account-level persistence across linked devices.\n\n`Open77.players.identifier(playerId)` and `Open77.players.name(playerId)` remain the short forms.","natives":[],"permissions":[],"events":[]},{"anchor":"steam-gog-and-permanent-account-identifiers","heading":"Steam, GOG and permanent account identifiers","level":2,"text":"**Server Lua, since `2.31.13+op77.101`.** Read identifiers in `onPlayerConnected`, after the\nserver has admitted the player. No extra permission or store API call is required in a resource.\n\n**Epic:** server `2.31.15+op77.105` also supports `epic`, a 32-character lowercase\nhexadecimal Epic account ID. The matching Master update is deployed. The server\nadvertises v3 ticket support on each heartbeat, and Master includes the Epic ID\nfor a qualified linked account. Older servers continue receiving v2 tickets and\ncan still admit Epic-linked players, but cannot expose their Epic ID. Updating\nthose servers is optional; existing clients remain compatible.\n\n```lua\nAddEventHandler(\"onPlayerConnected\", function(playerId)\n local ids, reason = Open77.players.identifiers(playerId)\n if not ids then\n print(\"Identity unavailable: \" .. tostring(reason))\n return\n end\n\n local license = ids.license -- permanent Open77 account ID\n local steamId = ids.steam -- nil when Steam is not linked\n local gogId = ids.gog -- nil when GOG is not linked\n local epicId = ids.epic -- nil when Epic is not carried by the ticket\n\n print(\"Open77 license: \" .. tostring(license))\n print(\"Steam: \" .. tostring(steamId))\n print(\"GOG: \" .. tostring(gogId))\n print(\"Epic: \" .. tostring(epicId))\nend)\n```\n\nThe table fields and `GetPlayerIdentifierByType` return **strings without a type prefix**:\n\n| Field / type | Format | Use |\n|---|---|---|\n| `license` | Open77 account GUID as 32 lowercase hexadecimal characters, without dashes | Persistent account key for characters, inventories and progression across linked device identities |\n| `steam` | SteamID in lowercase hexadecimal, without `0x` | Linked Steam account; this is not the decimal SteamID64 representation |\n| `gog` | GOG account ID in decimal | Linked GOG account |\n| `epic` | Epic account ID as 32 lowercase hexadecimal characters | Linked Epic account, carried by a verified v3 ticket |\n| `open77` / `userId` | The existing identity GUID, with dashes | Installation identity; keep using `userId` for APIs such as `Open77.access` that require it |\n\nKeep these values as strings in Lua, JSON and database columns. Do not use `tonumber` to store\nor compare them. `playerId` / `source` identifies only the current connection; a display name\ncan change.\n\nThe FiveM-style compatibility functions expose the same values:\n\n```lua\n-- playerId is an admitted player's session ID.\nlocal license = GetPlayerIdentifierByType(playerId, \"license\")\nlocal steamId, steamReason = GetPlayerIdentifierByType(playerId, \"steam\")\nlocal gogId, gogReason = GetPlayerIdentifierByType(playerId, \"gog\")\nlocal epicId, epicReason = GetPlayerIdentifierByType(playerId, \"epic\")\n\n-- Array entries include their prefix: license:..., steam:..., gog:..., epic:...\nfor _, identifier in ipairs(GetPlayerIdentifiers(playerId) or {}) do\n print(identifier)\nend\n```\n\n`GetPlayerIdentifiers` also includes the existing `open77:`, `name:` and `fingerprint:` entries.\nMissing identifiers are omitted. `GetPlayerIdentifierByType` accepts type names case-insensitively\nand returns `nil, \"identifier_not_linked\"` when `license`, `steam`, `gog` or `epic` is unavailable.\nAn unsupported type returns `nil, \"unknown_identifier_type\"`; a non-string type returns\n`nil, \"invalid_identifier_type\"`. `Open77.players.identifiers` returns `nil, \"invalid_player_id\"`\nfor an invalid session ID, or `nil, \"player_not_found\"` when no player matches it.\n\nThe Master verifies **Cyberpunk 2077 and Phantom Liberty on the same Steam, GOG or Epic account**\nonce and permanently links that store account to the Open77 account. One qualifying store is\nsufficient. Starting with `2.31.13+op77.102`, historical proof expiration does not require another\nstore login or ownership check. The Master still issues a fresh, short-lived signed ticket for\neach connection. These identifiers come from the ticket verified by the server; scripts do not\nreceive raw store tickets, access tokens or passwords. No Discord or Xbox identifier is exposed.\n\nAn Open77 administrator can also approve an account manually, for example when its store cannot\nbe verified automatically. On server runtime `2.31.13+op77.102` or later, this account has a valid\n`license` even when **both `steam` and `gog` are absent**. Resources must handle that case and\nshould use `license` for account-level persistence. Missing IDs remain `nil` in the table and\nare omitted from `GetPlayerIdentifiers`; no placeholder store ID is invented.\n\nStore linking is currently **optional**. Since launcher/Master `2.31.13+op77.103`, players can\nchoose **Skip for now** and still receive a signed ticket accepted by server `.102` or later.\nTheir permanent Open77 `license` remains available, with both store IDs absent if none is\nlinked. Skipping does not attest ownership or create a manual approval. Existing verified\nSteam/GOG IDs remain available. Resources must not require a store ID to identify a player.\n\nManual approval can be removed without deleting genuine store links; while linking is optional,\nits absence does not block connections.\nStore IDs identify linked accounts; they do not report current subscription or refund status.\nAdmission does not continuously monitor already admitted players. The explicit private loopback\n`devLocalAuth` mode has no verified account or store identifiers, including `license`.\n\n`Open77.players.identity(playerId)` and `GetPlayerIdentity(playerId)` expose the same three\nownership fields. Only the separate `endpoint` field in `Open77.players.identifiers` needs\n`players.identity.sensitive`.","natives":["AddEventHandler","server:AddEventHandler","server:Open77.players.identifiers","server:GetPlayerIdentifierByType","server:GetPlayerIdentifiers"],"permissions":[],"events":[]},{"anchor":"removing-and-banning-players-who-are-in","heading":"Removing and banning players who are in","level":2,"text":"| Function | Permission | Effect |\n|---|---|---|\n| `Open77.players.disconnect(playerId, reason?)`, alias `kick` | `players.disconnect` | Closes the session. The reason is shown to the player and delivered to `onPlayerDisconnected`. |\n| `Open77.players.ban(playerId, reason?, durationSeconds?)` | `players.ban` | Records a device ban for this server at the Master and disconnects now. Enforced at the identity stage on the next connect, so it holds with no resource running. |\n\nUse the Master ban for hard bans. Use your own list, as below, for anything you want to edit,\nexpire, explain, or share between servers you run. Details and error codes are in\n[Disconnecting and banning a player](server-api.md#disconnecting-and-banning-a-player).","natives":[],"permissions":[],"events":[]},{"anchor":"wall-clock","heading":"Wall clock","level":2,"text":"The server sandbox has no `os` library, and `GetGameTimer` and `Open77.time.monotonic` restart with\nthe process. For a ban that expires on Sunday:\n\n| Function | Result |\n|---|---|\n| `Open77.time.unix()`, alias `GetUnixTime` | Seconds since 1970-01-01 UTC, fractional. |\n| `Open77.time.utc()`, alias `GetUtcTimestamp` | The same instant as an ISO 8601 string, for logs and files. |","natives":[],"permissions":[],"events":[]},{"anchor":"the-built-in-door-list","heading":"The built-in door list","level":2,"text":"Most servers need no code for this: the server keeps its own whitelist and ban list in\n`access.json` next to `server.jsonc`, enforced before the resource gate and with no resource\nrunning. Every entry is keyed on the Master `userId` and remembers who added it and when.\n\nThis is a server-enforced policy, not a launcher filter. It applies to direct connections,\nMaster-ticket connections and reconnects. A valid ticket or a resource's `deferrals.done()`\ncannot override it: the server checks the verified identity before the resource gate and\nagain immediately before admission.\n\n**Warden**: the *Whitelist & bans* tab (permissions `access.view` to see, `access.edit` to change)\nswitches the whitelist, lists and removes identities, bans with a reason and a duration, lifts bans,\nand shows the recent refusals with the sentence each player saw, each with *Allow* and *Ban*\nbuttons. The *Players* tab gains an *Allow* button per row, so a guest can be let in without\nleaving the table. Bans from this tab disconnect the player at once if they are online; they are\nlocal to this server, unlike `Open77.players.ban`, which records a device ban at the Master.\n\nEnabling the whitelist immediately disconnects all unlisted players, including connections\nstill waiting for a Lua gate. Removing an identity while the whitelist is enabled does the\nsame for **every session** using that identity. Staff and the operator's own game account\nhave no automatic exemption: add those game identities before enabling it. Being logged\ninto Warden is not itself permission to join the game. The refusal explains that the server\nis private and includes the identity to give to an administrator.\n\nThe same policy is checked before processing further player packets and on the server tick,\nso a quiet client or a change made through Lua cannot retain gameplay authority. Lua changes\ntake effect no later than the next tick; Warden and built-in console changes enforce it before\nreturning. Players still listed remain connected.\n\nChanges are atomically saved before success is reported. If saving fails, the previous policy\nremains in force and the caller receives an error. An existing unreadable or malformed\n`access.json` now **stops server startup** instead of silently disabling the whitelist; repair\nor restore that file. A genuinely missing file still means a fresh server with the whitelist\ndisabled. Use Warden, the console or `Open77.access` for live edits; manual disk edits are read\nat startup. Each server should have its own configuration directory, since that directory\nalso owns its `access.json`.\n\n**Console** (also from Warden's live console):\n\n```\nwhitelist state and listed identities\nwhitelist.on | whitelist.off\nwhitelist.add [label]\nwhitelist.remove \nbans active bans\nban.id [seconds|0] [reason]\nunban \n```\n\n**Lua**, with the manifest permission `players.access`, through `Open77.access`:\n\n| Function | Result |\n|---|---|\n| `status()` | `{ whitelistEnabled, whitelist = { { userId, label, addedAt, addedBy } }, bans = { { userId, name, reason, bannedAt, expiresAt?, bannedBy } } }` |\n| `setWhitelist(enabled)` | `true` |\n| `allow(userId, label?)`, `disallow(userId)` | `true`; `disallow` returns `false` when the identity was not listed |\n| `isAllowed(userId)`, `isBanned(userId)` | boolean |\n| `ban(userId, reason?, seconds?, name?)` | `true`; nil or `0` seconds is permanent; disconnects the player if online |\n| `unban(userId)` | `true`, or `false` when there was no ban |\n\nEvery call returns `false, reason` on failure: `permission_denied:players.access`,\n`invalid_user_id`, `access_unavailable`, `access_persistence_failed` (whitelist/allow/disallow/unban),\nor `ban_persistence_failed`. A persistence failure leaves the previous policy unchanged.\nEntries written from Lua carry `resource:` as their\nauthor, so Warden shows which script let someone in.\n\n```lua\n-- A command that bans the speaker's target on the built-in list for a day.\nRegisterCommand(\"dayban\", function(_, args)\n local identity = Open77.players.identity(tonumber(args[1]) or 0)\n if not identity then print(\"usage: dayban [reason]\") return end\n Open77.access.ban(identity.userId, args[2] or \"one day off\", 86400, identity.name)\nend, true)\n```\n\nThe two worked examples below build the same thing by hand, for a server that wants its own rules,\nits own storage, or its own wording.","natives":["RegisterCommand","server:RegisterCommand","server:Open77.players.identity","server:Open77.access.ban"],"permissions":[],"events":[]},{"anchor":"worked-example-a-whitelist","heading":"Worked example: a whitelist","level":2,"text":"```lua\n-- open77.lua\nresource \"my_whitelist\"\nversion \"1.0.0\"\nauto_start true\nserver_script \"server/main.lua\"\npermissions { \"players.gate\", \"filesystem.read\", \"filesystem.write\" }\n```\n\n```lua\n-- server/main.lua\nlocal FILE = \"whitelist.json\" -- lives in this resource's data/ folder\nlocal list = Open77.io.readJson(FILE) or { users = {} }\n\nlocal function save()\n local ok, err = Open77.io.writeJson(FILE, list)\n if not ok then print(\"whitelist: cannot save: \" .. tostring(err)) end\nend\n\nAddEventHandler(\"onPlayerConnecting\", function(player, deferrals)\n if list.users[player.userId] then return end -- on the list: accepted\n deferrals.done((\"This server is private. Ask an admin to add %s.\"):format(player.userId))\nend)\n\nRegisterCommand(\"whitelist.add\", function(_, args)\n local id, label = args[1], args[2] or \"?\"\n if not id then print(\"usage: whitelist.add [label]\") return end\n list.users[id] = label\n save()\n print((\"whitelist: added %s (%s)\"):format(id, label))\nend, true)\n\nRegisterCommand(\"whitelist.remove\", function(_, args)\n if args[1] and list.users[args[1]] then\n list.users[args[1]] = nil\n save()\n print(\"whitelist: removed \" .. args[1])\n end\nend, true)\n```\n\nThe `true` after each command keeps it restricted to the console and to ACL-listed admins. A\nplayer finds their own id with `identity.dump` in the client console; an admin also reads it from\nthe refusal line the server logs, or from `onPlayerRejected`.","natives":["server:Open77.io.readJson","server:Open77.io.writeJson","AddEventHandler","server:AddEventHandler","RegisterCommand","server:RegisterCommand"],"permissions":["players.gate","filesystem.read","filesystem.write"],"events":[]},{"anchor":"worked-example-a-ban-list-with-expiry","heading":"Worked example: a ban list with expiry","level":2,"text":"```lua\npermissions { \"players.gate\", \"players.disconnect\", \"filesystem.read\", \"filesystem.write\" }\n```\n\n```lua\nlocal FILE = \"bans.json\"\nlocal bans = Open77.io.readJson(FILE) or {} -- [userId] = { name, reason, expires }; expires 0 = permanent\n\nlocal function save() Open77.io.writeJson(FILE, bans) end\n\nlocal function activeBan(userId)\n local ban = bans[userId]\n if not ban then return nil end\n if ban.expires ~= 0 and ban.expires <= Open77.time.unix() then\n bans[userId] = nil -- served its time\n save()\n return nil\n end\n return ban\nend\n\nAddEventHandler(\"onPlayerConnecting\", function(player, deferrals)\n local ban = activeBan(player.userId)\n if not ban then return end\n local left = \"permanently\"\n if ban.expires ~= 0 then\n left = (\"for %d more minutes\"):format(math.ceil((ban.expires - Open77.time.unix()) / 60))\n end\n deferrals.done((\"Banned %s: %s\"):format(left, ban.reason))\nend)\n\n-- ban [minutes|0] [reason...]\nRegisterCommand(\"ban\", function(_, args)\n local playerId = tonumber(args[1])\n local identity = playerId and Open77.players.identity(playerId)\n if not identity then print(\"usage: ban [minutes|0] [reason]\") return end\n local minutes = tonumber(args[2]) or 0\n local reason = args.n >= 3 and table.concat(args, \" \", 3, args.n) or \"banned by an admin\"\n bans[identity.userId] = {\n name = identity.name, reason = reason,\n expires = minutes > 0 and (Open77.time.unix() + minutes * 60) or 0,\n }\n save()\n Open77.players.disconnect(playerId, \"Banned: \" .. reason)\nend, true)\n\nRegisterCommand(\"unban\", function(_, args)\n if args[1] and bans[args[1]] then\n bans[args[1]] = nil\n save()\n print(\"unbanned \" .. args[1])\n end\nend, true)\n```\n\nThe shipped resource `resources/dev/open77_gatekeeper` combines both examples with a whitelist\nswitch, a `gate.list` command and audit lines from `onPlayerRejected` and `onPlayerDisconnected`.\nIt does not start by itself: `ensure open77_gatekeeper` at the server console.","natives":["server:Open77.io.readJson","server:Open77.io.writeJson","server:Open77.time.unix","AddEventHandler","server:AddEventHandler","RegisterCommand","server:RegisterCommand","server:Open77.players.identity","server:Open77.players.disconnect"],"permissions":["players.gate","players.disconnect","filesystem.read","filesystem.write"],"events":[]},{"anchor":"fail-closed","heading":"Fail closed","level":2,"text":"The host admits a player whose handler crashed, because a typo in one script must not empty the\nserver. A gate whose safe default is to refuse wraps its own check:\n\n```lua\nlocal function check(player)\n -- return nil to admit, or the sentence the player should read\n if not list.users[player.userId] then return \"This server is private.\" end\nend\n\nAddEventHandler(\"onPlayerConnecting\", function(player, deferrals)\n deferrals.defer()\n local ok, verdict = pcall(check, player)\n if not ok then\n print(\"gate error: \" .. tostring(verdict))\n deferrals.done(\"The server cannot verify your access right now. Try again in a minute.\")\n else\n deferrals.done(verdict) -- nil admits, a string refuses\n end\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"operator-notes","heading":"Operator notes","level":2,"text":"- `server.jsonc`: `\"simulation\": { \"connectGateTimeoutSeconds\": 8 }`.\n- Every refusal is logged at `INF` as `Player '' () refused (): `.\n Progress notes appear as `connecting '': ` under the resource that wrote them.\n- Key lists on `userId`. A display name is chosen by the player.\n- Keep the list in the resource's `data/` folder through `Open77.io`, or in the database through\n `Open77.database`; both survive `ensure` and restarts. A Lua table alone does not.\n- The gate runs on every connect, including reconnects after a crash, so keep the check cheap or\n deferred.","natives":[],"permissions":[],"events":[]},{"anchor":"troubleshooting","heading":"Troubleshooting","level":2,"text":"| Symptom | Cause |\n|---|---|\n| The handler never runs | The manifest lacks `players.gate` (look for the `WRN`), the resource is not running, or the handler was registered with `RegisterNetEvent` instead of `AddEventHandler`. |\n| Everyone is refused with `connection_gate_timeout` | A code path calls `defer()` and never reaches `done()`, for example a database callback that never fires. Every branch after `defer()` must end in `done`. |\n| The player sees `Connection refused by the server.` | `done()` was given an empty string or a non-string value. |\n| The message is cut short | 127 bytes of UTF-8. |\n| A ban by name stopped working | The player renamed themselves. Ban by `userId`. |","natives":[],"permissions":[],"events":[]},{"anchor":"api-summary","heading":"API summary","level":2,"text":"| Name | Permission | Signature |\n|---|---|---|\n| `onPlayerConnecting` (event) | `players.gate` | `(player, deferrals)` |\n| `deferrals.defer` / `update` / `done` | via the event | `()` / `(message)` / `(message?)` |\n| `onPlayerRejected` (event) | None | `(userId, name, code, message)` |\n| `onPlayerConnected` (event) | None | `(playerId, playerName)` |\n| `onPlayerDisconnected` (event) | None | `(playerId, reason)` |\n| `playerConnecting` (event) | `players.gate` | `(name, setKickReason, deferrals)` |\n| `playerJoining` (event) | None | `(oldId)`, `source` = the new player id |\n| `playerDropped` (event) | None | `(reason)`, `source` = the player who left |\n| `Open77.players.identity` / `GetPlayerIdentity` | None | `(playerId) -> { userId, name, publicKey, fingerprint, joinedAt, license?, steam?, gog?, epic? }` or `nil` |\n| `Open77.players.identifiers` | None for account/store IDs | `(playerId) -> { open77, userId, name, fingerprint, joinedAt, license?, steam?, gog?, epic?, endpoint? }` or `nil, reason`; `endpoint` needs `players.identity.sensitive` |\n| `GetPlayerIdentifierByType` | None | `(playerId, type) -> bare string` or `nil, reason` |\n| `GetPlayerIdentifiers` | None | `(playerId) -> array of \"type:value\" strings` or `nil, \"player_not_found\"` |\n| `Open77.players.identifier` / `name` | None | `(playerId)` |\n| `Open77.players.disconnect` / `kick` | `players.disconnect` | `(playerId, reason?)` |\n| `Open77.players.ban` | `players.ban` | `(playerId, reason?, durationSeconds?)` |\n| `Open77.access.status` / `setWhitelist` / `allow` / `disallow` / `isAllowed` / `isBanned` / `ban` / `unban` | `players.access` | the built-in door list, see above |\n| `Open77.time.unix` / `GetUnixTime` | None | `() -> seconds` |\n| `Open77.time.utc` / `GetUtcTimestamp` | None | `() -> ISO 8601 string` |\n| `simulation.connectGateTimeoutSeconds` (config) | | default 8, range 0.5 to 9 |","natives":[],"permissions":[],"events":[]}]},{"slug":"consumables-api","file":"consumables-api.md","title":"Grenades and healing items","summary":"`Open77.consumables` controls the local player's grenade and healing hotkeys, selected item type and **usable charges**. On the server, the same API takes a `playerId` first and sends a correlated request to that player's client.","sections":[{"anchor":"grenades-and-healing-items","heading":"Grenades and healing items","level":1,"text":"`Open77.consumables` controls the local player's grenade and healing hotkeys,\nselected item type and **usable charges**. On the server, the same API takes a\n`playerId` first and sends a correlated request to that player's client.\n\nRequires a client exposing `Open77.consumables`, a server runtime providing the\nserver methods, and [`open77_weapons`](https://github.com/Open2077/open77-resources/tree/main/system/open77_weapons) resource 0.2.0. Treat the API as experimental\nand check compatibility before enabling consumable loadouts.\n\nUse `kind = \"grenade\"` for the grenade hotkey or `\"healing\"` for inhalers and\ninjectors. These are independent kits. Charges are not inventory stack sizes:\nCyberpunk 2.31 consumes a rechargeable stat pool when using these items.\nThe older `Open77.weapons.giveGadget` API still changes inventory membership.","natives":[],"permissions":[],"events":[]},{"anchor":"give-a-kit","heading":"Give a kit","level":2,"text":"```lua\n-- server.lua: the owner relay needs network.events.\nlocal requestId, reason = Open77.consumables.configure(playerId, \"grenade\", {\n record = \"Items.GrenadeFragRegular\",\n count = 5,\n capacity = 5,\n equipped = true,\n recharge = false,\n})\nif not requestId then print(\"Grenade request refused: \" .. reason) end\n\nOpen77.consumables.configure(playerId, \"healing\", {\n record = \"Items.FirstAidWhiffV0\", -- MaxDoc inhaler\n count = 3,\n equipped = true,\n recharge = false,\n})\n```\n\nClient scripts omit `playerId`:\n\n```lua\nlocal requestId, reason = Open77.consumables.setCount(\"grenade\", 7)\n```\n\nThe request ID means **queued**, not applied. Native code reads the actual\ncharge pool, hotkey binding, capacity and regeneration policy before completing.\nWait for completion before issuing another write to the same kit.","natives":["Open77.consumables.configure","server:Open77.consumables.configure","Open77.consumables.setCount","server:Open77.consumables.setCount"],"permissions":[],"events":[]},{"anchor":"methods","heading":"Methods","level":2,"text":"The signatures below are for client Lua. Prefix every argument list with\n`playerId` when calling from server Lua.\n\n| Method | Behavior |\n| --- | --- |\n| `configure(kind, options)` | Set any combination of `record`, `count`, `capacity`, `equipped`, `recharge`. |\n| `setType(kind, record)` | Change type, preserving count and equipped status. |\n| `setCount(kind, count)` | Set usable charges; zero leaves the type selected with no uses available. |\n| `equip(kind, record?)` | Bind the selected type, or supply a new type. |\n| `unequip(kind)` | Clear the hotkey and equipment slots, preserving the selected type and charges. |\n| `setRecharge(kind, enabled)` | Enable or disable native regeneration. |\n| `snapshot(kind)` | Request a fresh asynchronous readback. |\n| `get(kind)` | Read the latest cached state synchronously, or `nil, reason`. |\n| `reset(kind)` | Remove this API's capacity modifiers and restore its captured regeneration policy. |\n\n`count` is an integer from 0 through 999; `capacity` from 1 through 999. An\nexplicit capacity smaller than the count is rejected. Without a capacity, a\nwrite grows the capacity if needed to fit the requested count. Omitted values\npreserve current state. The first managed write defaults to `recharge=false`;\nlater partial writes preserve that policy. An initial kit should specify its\ntype, count and equipped status explicitly.\n\nReset preserves the selected item and clamps excess charges to the restored\nnative capacity. It does not remove unrelated inventory or stat modifiers.\nControls are scoped to the current player body/session and are released on\ndisconnect or body replacement. Apply the desired kit from your game mode's\nspawn lifecycle when creating a new body. This API is not persistent inventory\nstorage. A failed write reports the observed state; it is not a transaction\nthat rolls back every intermediate equipment change.","natives":[],"permissions":[],"events":[]},{"anchor":"types","heading":"Types","level":2,"text":"Types must resolve to an actual item with the matching equipment area and\nnative class. A syntactically valid name alone is insufficient. Arbitrary food,\ndrinks and weapons are rejected.\n\n| Kind | Example record | Native type |\n| --- | --- | --- |\n| `grenade` | `Items.GrenadeFragRegular` | Frag grenade / QuickSlot |\n| `healing` | `Items.FirstAidWhiffV0` | MaxDoc / inhaler |\n| `healing` | `Items.BonesMcCoy70V1` | Bounce Back / injector |\n\nUse MaxDoc for inhalers and Bounce Back for injectors. For other grenade\ntypes, consult the [weapon catalogue](data-reference.md). A record must match\nthe requested kind; inventory stack size does not determine usable throws.","natives":[],"permissions":[],"events":[]},{"anchor":"readback-and-completion","heading":"Readback and completion","level":2,"text":"`get` returns a table with `kind`, `record`, `tweakDbId`, `count`, `capacity`,\n`equipped`, `recharge`, `managed`, `sequence`, `reportedAgeMs` and `source`.\n`source` is `native_readback` on the client and `owner_report` on the server.\nThe age measures time since the last observation/report, not a heartbeat.\nUse `snapshot` when a fresh response is required.\n\nFor a pre-existing native item whose name has not been resolved, `record` can\nbe empty even though `tweakDbId` identifies the selected item. Compare\n`tweakDbId` in that case; an empty name does not mean an empty hotkey. Types\nexplicitly configured through this API retain the supplied record name.\n\n```lua\nlocal state, reason = Open77.consumables.get(playerId, \"healing\")\nif state then\n print(state.record, state.count, state.equipped, state.reportedAgeMs)\nend\n\n-- Server completion: the standard weapon relay also carries consumable writes.\nAddEventHandler(\"open77:weapons:completed\", function(\n playerId, requestId, operation, accepted, reason, state)\n if operation ~= \"consumable\" then return end\n -- Match requestId against your own pending requests.\n if accepted then print(\"Applied\", state.kind, state.count, state.equipped)\n else print(\"Refused\", reason) end\nend)\n```\n\nClient scripts receive native state before completion:\n\n```lua\nAddEventHandler(\"open77:consumables:state\", function(\n requestId, kind, record, tweakDbId, count, capacity,\n equipped, recharge, managed, sequence, uses, change)\n -- Native event arguments are strings. Unsolicited updates use requestId \"0\".\n print(kind, tonumber(count), equipped == \"true\", change)\nend)\n```\n\nClient completion retains the existing native\n`open77:weapons:completed(requestId, operation, accepted, reason, ...)`\nsignature: `accepted` is the string `\"true\"` or `\"false\"`. Read the preceding\nstate event or `get(kind)` for consumable details. Server completion uses a\nboolean and a typed result table.","natives":["Open77.consumables.get","server:Open77.consumables.get","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:weapons:completed","open77:consumables:state"]},{"anchor":"observe-actual-uses-and-apply-healing-policy","heading":"Observe actual uses and apply healing policy","level":2,"text":"```lua\n-- server.lua\nAddEventHandler(\"onConsumableUsed\", function(\n playerId, kind, record, remaining, uses, sequence, tweakDbId)\n -- Host event arguments are strings; convert as needed.\n playerId, remaining, uses = tonumber(playerId), tonumber(remaining), tonumber(uses)\n print(playerId, \"used\", kind, record, \"remaining\", remaining)\nend)\n```\n\nThis event follows the native grenade throw/healing-use path. Changing a count\nthrough Lua does not fabricate a use. The host rejects stale reports and\nsuppresses repeated use sequences. `onConsumableUsed` is a reserved host event:\na resource cannot publish it (`reserved_event`). Each frag, incendiary or\ncutting grenade charge it reports also credits the `open77_weapons` [blast relay](weapons-api.md#explosions-on-every-screen-the-blast-relay),\nwhich lets the matching detonation (`onPlayerExplosion`) move nearby cars and\nknock down NPCs on every screen. Finite managed stock also limits vanilla\nfree-use/refund effects; native regeneration is available with `recharge=true`.\nPerks can modify charge costs and refunds. Check the interactions used by your\ngame mode when combining native perks with finite managed stock.\n\nHealth in Open77 is server-authoritative. A native healing animation and charge\nconsumption do **not** automatically authorize server health. Your game mode\ndecides the heal amount and checks its own inventory, cooldown and life state\nbefore calling `Open77.players.heal(playerId, amount)` with\n`players.damage.apply` permission. A report from the authenticated owner is\nstill client input, not an anti-cheat proof of entitlement. Do not blindly\ngrant health or inventory rewards from this event. Native grenade explosion\ndamage already has its existing replication path; do not create a second\nexplosion from the use callback.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"permissions-and-errors","heading":"Permissions and errors","level":2,"text":"Client reads require `player.weapons.read`; client writes require\n`player.weapons.edit`. Server cached reads require `player.weapons.read`.\nServer requests follow the existing weapons convention: `network.events`\nauthorizes dispatch through the `open77_weapons` owner relay, whose client\nmanifest grants native read/edit capabilities. The server relay does not add\na separate `player.weapons.edit` gate. The official resource handles baseline reporting and subsequent\nnative changes. There is no per-frame Lua polling requirement for consumers.\n\nImmediate errors include invalid kind, record syntax, options/counts, denied\npermissions, missing player or unavailable API. Native completions can fail with\n`invalid_consumable_type`, `no_consumable_type`, `consumable_unavailable`,\n`consumable_busy`, `consumable_readback_mismatch`, `consumable_reset_mismatch`,\n`player_replaced` or `session_ended`. Retry a busy request after the player has\nfinished using the item. A failed readback is not success even if part of the\nequipment change is visible. An old client returns\n`consumables_unavailable_on_this_client` instead of claiming a write succeeded.","natives":[],"permissions":[],"events":[]},{"anchor":"function-reference","heading":"Function reference","level":2,"text":"See the [client consumables API](/docs/api/client/open77-consumables) and\n[server consumables API](/docs/api/server/open77-consumables) for individual\nsignatures and examples.","natives":[],"permissions":[],"events":[]}]},{"slug":"context-menu","file":"context-menu.md","title":"Generic context menu — open77_contextmenu","summary":"`open77_contextmenu` provides a resource-extensible ALT/click menu. It has no Freeroam dependency, built-in admin permissions or default gameplay actions; consumer resources register the available actions.","sections":[{"anchor":"generic-context-menu-open77contextmenu","heading":"Generic context menu — open77_contextmenu","level":1,"text":"`open77_contextmenu` provides a resource-extensible ALT/click menu. It has no Freeroam dependency, built-in admin permissions or default gameplay actions; consumer resources register the available actions.\n\nSource: [`open77_contextmenu`](https://github.com/Open2077/open77-resources/tree/main/system/open77_contextmenu).","natives":[],"permissions":[],"events":[]},{"anchor":"built-in-consumers-not-built-in-actions","heading":"Built-in consumers, not built-in actions","level":2,"text":"`open77_contextmenu` itself has **zero default actions**, including on the sky,\non yourself, or when no consumer is installed. It never depends on Freeroam or\nadmin. Demo actions live only in the separately enabled example/lab resources;\nleave both out of production `resources.load` lists.\n\nThe bundled consumers are independent integrations using the same public exports:\n\n| Consumer | Actions |\n|---|---|\n| [`freeroam`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/freeroam) | Copy a network player's ID; copy a network vehicle's ID/model; open Freeroam or animations from your visible F7 body. |\n| [`open77_admin`](https://github.com/Open2077/open77-resources/tree/main/system/open77_admin) | Player management, heal/revive, kick/ban with reason and confirmation; visual/full vehicle repair, lock/unlock and confirmed deletion; door ID/state, manual open/close, locks, seals and automatic mode; time/weather presets from empty sky. |\n\nAdmin entries require `command.admin` and their exact action ACL. The server\nchecks permissions again when executing, not just when displaying the menu.\nKick, ban and delete open the existing admin forms/confirmation screen, with\n**Cancel selected by default**. Sky actions hand off to the time/weather submenus.\nPermissions are sent automatically after joining and refreshed on ACL changes.\n\nVehicle IDs remain generation-checked Lua integers. Door engine IDs remain strings;\nthe native door API converts a decimal uint64 string to its exact hexadecimal ID.\nNever convert an engine ID to a floating-point number or substitute the nearest door.\n\nThe admin package can cooperate with Freeroam to repair, lock or delete its\nvehicles. Freeroam verifies the calling resource, the operator's live ACL, the\nvehicle's resource owner, routing bucket and 20m proximity. Occupied vehicles\ncannot be deleted or fully/mechanically repaired; visual repair remains available.\nThis does not grant ownership over another gamemode's vehicles.\n\nDoor controls use `command.admin.dev.doors.control` and the player's server-side\nbucket/12m range. A door must already be discovered. Administration preserves\nthe original resource owner. Lift opening stays controlled by the elevator;\nmanual open/close is not offered for lift doors. `command.admin.dev.doors.inspect`\nallows copying the ID and using the existing Developer → Doors inspector.\n\nFor large action sets, use awaited batches of up to five definitions rather than\none large registration burst: validation/copying must fit the client's per-resume\ninstruction budget. Re-register on consumer/context-menu restart; keep callbacks\nin the consumer and retain server checks even when `canInteract` hides an entry.","natives":[],"permissions":[],"events":[]},{"anchor":"player-experience","heading":"Player experience","level":2,"text":"Hold **ALT** to release the mouse from camera control. Click an entity or world\nsurface, your visible F7 body, or empty sky to see registered actions. Click an action to dispatch it. Releasing\nthe activation key, Escape, right-click, death, focus loss or target invalidation\ncloses the menu and restores controls. The activation key is rebindable through\nOpen77's existing key-binding settings.\n\nThe native weapon wheel is blocked for the lifetime of this resource, before the\nfirst ALT press, using `Open77.input.setNativeActionBlocked(\"WeaponWheel\", true)`.\nIt is released on resource stop/reload/disconnect, not after each click (which\ncould reopen the wheel while ALT is still held). Other resources' blocks remain\nindependent. This covers the native action, including its gamepad binding.\nIf your server deliberately uses both interfaces on different keys, set\n`blockNativeWeaponWheel = false` in `shared/config.lua` and rebind the context menu.\n\nThe WebUI uses a compact target-style layout: a small eye and translucent\nicon/label rows beside the clicked point. No target title, distance, branding,\nfooter or persistent help banner. The hovered row has a clearly tinted background,\naccent border and icon; the first row is not pre-selected on open. Keyboard\nselection is separate from pointer hover. Descriptions remain in title/accessibility\nmetadata (native tooltip presentation depends on the WebUI host). Keyboard navigation, loading/error states, viewport-edge\nclamping and reduced-motion support remain available. While idle, its\nsurface is hidden and no raycast runs. While a target menu is open, its original\nray is rechecked at a bounded rate; a moving target leaving that ray closes it.","natives":[],"permissions":[],"events":[]},{"anchor":"install","heading":"Install","level":2,"text":"Copy `resources/system/open77_contextmenu` into your server's resource tree.\nAdd `open77_contextmenu` to an explicit `resources.load` list, before consumers.\nThe resource is otherwise auto-startable. Consumers should declare:\n\n```lua\ndependency \"open77_contextmenu\"\n```\n\nThe consumer must itself start on the client (normally `auto_start true` in its\nmanifest). The example and test lab ship with `auto_start false`: explicitly\nenable the example's manifest when trying it on a private server. Starting only\nits server runtime with `ensure` does not automatically start a client script\nwhose manifest still has `auto_start false`.\n\nRequires the [screen-picking APIs](screen-picking.md). The package refuses activation if they are unavailable. Deploy the manifest and complete `client`, `shared` and `web` directories together, then reconnect clients.\n\nThe package declares `world.query`, `input.actions`, `players.controls` and\n`local.events`. A consuming resource needs only the permissions used by its own\ncallbacks, plus a dependency. Cross-resource arguments are copied data: closures\ncannot be passed as `onSelect` or `canInteract`.","natives":[],"permissions":[],"events":[]},{"anchor":"appearance","heading":"Appearance","level":2,"text":"Edit `resources/system/open77_contextmenu/shared/config.lua`:\n\n```lua\nappearance = {\n accentColor = \"#a78bfa\", -- Example: purple; default Open77 cyan #18d6e7.\n scale = 1.0,\n showGroups = false,\n},\n```\n\nThis is the `appearance` field inside `ContextMenuConfig`, not a separate global.\n`accentColor` accepts a six-digit `#RRGGBB` hex color. It controls the eye when\nactions are available, the selected/hovered row accent, icons and progress spinner.\nInvalid colors fall back to cyan; arbitrary CSS/URLs are not accepted.\nDestructive actions keep their red warning color.\n\n`scale` is clamped to 0.75–1.5; the default uses 27 px action rows and a 208 px\nmenu in the 1920x1080 WebUI canvas. Actual screen size follows the game's WebUI\nscaling. Set `showGroups = true` to restore small category headings; they are\nhidden by default. Descriptions no longer create extra lines.\n\nPublish/reload the resource and reconnect clients after changing configuration.\nThe theme is applied on every menu open. No native client rebuild is needed.","natives":[],"permissions":[],"events":[]},{"anchor":"register-an-action","heading":"Register an action","level":2,"text":"Client script in your own resource:\n\n```lua\nexports(\"canInspectVehicle\", function(context)\n return context.target.vehicleId ~= nil\nend)\n\nexports(\"inspectVehicle\", function(context)\n -- UI data is an observation, not server authorization.\n print(\"Selected vehicle: \" .. tostring(context.target.vehicleId))\n return true\nend)\n\nCreateThread(function()\n local promise, reason = Open77.exports.call(\"open77_contextmenu\", \"register\", {\n id = \"inspect_vehicle\",\n label = \"Inspect vehicle\",\n description = \"Show information about this vehicle.\",\n group = \"Vehicle\",\n icon = \"info\",\n types = { \"vehicle\" },\n networked = true,\n distance = 4.0,\n order = 10,\n canInteract = \"canInspectVehicle\", -- Export in THIS resource.\n onSelect = \"inspectVehicle\", -- Export in THIS resource.\n })\n if not promise then print(reason); return end\n local token, error = promise:await()\n if not token then print(error) end\nend)\n```\n\nRegistration captures the real invoking resource and generation. You cannot\nregister callbacks under someone else's identity. Registering the same `id`\nagain replaces your previous entry and invalidates its old token.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"options","heading":"Options","level":2,"text":"| Option | Contract |\n|---|---|\n| `id` | Required resource-local identifier, at most 64 characters. |\n| `label` | Required plain text, at most 80 characters. |\n| `onSelect` | Required name of a client export in the registering resource. |\n| `canInteract` | Optional export name; must return exactly `true` to show/execute. |\n| `description`, `group` | Optional plain text, maximum 180 / 40 characters. |\n| `icon` | `interact`, `person`, `vehicle`, `info`, `lock`, `tool` or `location`. |\n| `types` | Optional list: `player`, `vehicle`, `npc`, `prop`, `door`, `device`, `item`, `object`, `world`. |\n| `types={\"sky\"}` | Explicitly opts into misses/empty space. A generic action without `types` does not match sky. |\n| `records` | Optional exact record whitelist; targets without a record do not match. |\n| `entities` | Optional list of exact selectors: `{playerId=7}`, `{vehicleId=12}`, `{npcId=3}`, `{propId=\"123\"}` or `{engineEntity=\"123\"}`. Exactly one ID per selector. Matches any selector; combines with other filters using AND. |\n| `networked` | Optional boolean filter; omitted accepts both networked and local targets. |\n| `distance` | Maximum player-to-impact distance, default 3 m, range 0.1–50 m. |\n| `order` | Integer sorting priority, -1000 to 1000; lower first. |\n| `enabled` | Defaults true. |\n| `allowSelf` | Defaults false; opt in to targeting your own body. |\n| `selfOnly` | Defaults false; requires `allowSelf=true` and limits the action to yourself. Prefer `registerSelf`. |\n| `danger` | Defaults false; visually marks a destructive action, not an authorization check. |\n| `data` | Optional copied JSON-like static metadata, available in both callbacks as `context.action.data`. Up to 4 nesting levels and a conservative 2048-byte accounting budget; no functions/cycles/non-finite numbers. |\n\nLimits: 128 registrations total, 32 per resource, 32 entries per filter list.\nConfiguration defaults to a 12 m camera ray, 24 visible actions, 750 ms per\npredicate wait, a 1500 ms total lookup budget and 200 ms target revalidation. Player distance and camera-ray\nlength are deliberately separate. Keep predicates fast, read-only and free of\nnetwork round trips.","natives":[],"permissions":[],"events":[]},{"anchor":"callback-context","heading":"Callback context","level":2,"text":"Callbacks receive the latest [screen-raycast result](screen-picking.md), plus:\n\n- `playerDistance`: distance from the local body to the hit; absent for sky.\n- `screen = { x, y }`: original normalized selection coordinates.\n- `kind`: normal target kind, `self` for yourself, or `sky` for a miss.\n- `hasSurface`: true only for physical hits. False for sky and the F7 presentation approximation.\n- `target`: entity descriptor; unowned geometry uses `{kind=\"world\",networked=false}`;\n sky uses `{kind=\"sky\",networked=false}`. Self keeps `target.kind=\"player\"`,\n `target.isLocalPlayer=true` and the canonical local `playerId`.\n- `action = { id, owner, token, data }`: added for both `canInteract` and `onSelect`.\n\nFor **sky**, `hit=false`: use `origin` and unit `direction` to aim. `position`\nis only the ray endpoint at `maxDistance`, **not a ground point or teleport destination**.\n“Sky” means no collision within the configured ray length, so distant objects or\ncollisionless effects can also yield this context. Distance filtering is skipped\nonly for explicitly sky-enabled actions; no fabricated zero distance is supplied.\n\nFor **self**, the package enables `screenRaycast(..., {self=true})`. The active,\nvisible F7 presentation uses animated body-slot capsules because its physical\ncolliders must remain disabled. `hitSource=\"self_presentation\"` labels this\napproximation; it supplies no invented normal/material. A closer physical obstacle\nblocks it. Hidden/suspended F7 bodies are excluded; this is not an invisible\nself hotspot in first person and is not a combat hitbox or pixel-perfect mesh test.\n\n`onSelect` is called after the menu releases its input, so your callback can\nopen another interface. A false/error result is logged. The package never accepts\nan event name, command, target ID or export name supplied by the browser. Only\nnormalized click coordinates, the current menu epoch and an opaque action token\ncross this boundary; entity identity is resolved again by the native raycast.","natives":[],"permissions":[],"events":[]},{"anchor":"exports-and-lifecycle","heading":"Exports and lifecycle","level":2,"text":"Call all exports using `Open77.exports.call(...):await()` with the dispatch error\ncheck shown above. Do not use FiveM's `exports.resource:method()` syntax.\n\n| Export | Result |\n|---|---|\n| `register(definition)` | Action token, or nil/error. |\n| `registerMany(definitions)` | Dense array of tokens, or nil/error. Entire batch is atomic, even when replacing entries. |\n| `registerPlayers(definitions)` | Shortcut with `types={\"player\"}`; self still requires `allowSelf`. |\n| `registerVehicles(definitions)` | Shortcut with `types={\"vehicle\"}`. |\n| `registerNpcs(definitions)` | Shortcut with `types={\"npc\"}`. |\n| `registerProps(definitions)` | Shortcut with `types={\"prop\"}`. |\n| `registerDoors(definitions)` | Shortcut with `types={\"door\"}`. |\n| `registerWorld(definitions)` | Shortcut for physical geometry without a resolved entity. |\n| `registerSky(definitions)` | Shortcut for empty-space direction actions. |\n| `registerSelf(definitions)` | Shortcut for local player only (`types`, `allowSelf`, `selfOnly` set for you). |\n| `registerModels(records, definitions)` | Exact record whitelist; does not guess missing records. |\n| `registerEntities(selectors, definitions)` | Exact canonical or local engine-ID whitelist. |\n| `update(token, patch)` | Validates a merged definition; returns a **new token**, invalidating the old token. ID cannot change; invalid patches leave the previous action intact. |\n| `get(token)` | Your owned `{token, definition}` copy, or nil/error. |\n| `unregister(token)` | Boolean; only your own actions. |\n| `unregisterMany(tokens)` | Boolean/error; validates all ownership before removing any action. |\n| `clear()` | Removes all your resource's actions. |\n| `setEnabled(token, boolean)` | Enables/disables one owned action. |\n| `list()` | Your actions' token, ID, label and enabled state. |\n| `isOpen()` | Whether targeting/menu input is active. |\n| `isReady()` | Whether the WebUI has completed its ready handshake. |\n| `getVersion()` | Framework API version string, currently `1.1.0`. |\n| `getContext()` | Current selection snapshot or nil; no new raycast/predicate execution. No `action` field. |\n| `getTarget()` | Current target descriptor or nil. |\n| `setTargetingEnabled(boolean)` | Resource-owned disable claim. `false` closes an active menu and blocks reopening; `true` releases only your claim. |\n| `isTargetingEnabled()` | True when no live resource holds a disable claim; independent of readiness/life/focus. |\n| `close()` | Closes the current menu and releases its input. A fresh activation-key press is required to reopen. |\n\nTyped/model/entity shortcuts accept **one definition → one token** or **a dense\ndefinition array → a token array**. They override only their associated filters;\nall other options, validation and quotas are shared with `register`. Duplicate IDs\ninside one batch are rejected. Metadata and returned definitions are copied.\n`update` replaces whole filter arrays/data fields, not individual elements. To\nremove an optional field altogether, register the same ID with a new full definition.\nKeep the new token returned by `update`; stale tokens deliberately stop working.\n\n`getContext` is a local UI observation (revalidated every 200 ms), not guaranteed\nfresh or authorized. Use the freshly retraced callback context for actions.\nDisable claims are released on provider stop/generation change; one consumer\ncannot override another consumer's disable claim.\n\nStopped/reloaded providers lose their registrations. Generation mismatches are\nchecked before display and execution. A provider should register on each start;\nif the context-menu package is restarted independently, register again when its\n`onClientResourceStart` event arrives. Resource manifests use reconnect reload\npolicy for safe WebUI lifecycle.\n\nLocal informational events: `open77:contextmenu:opened()` and\n`open77:contextmenu:closed(reason)` and `open77:contextmenu:selected(context)`.\nThe selected event means dispatch was accepted, not that the provider's operation\nsucceeded. These are not authorization signals. Close reasons include `selected`,\n`cancelled`, `pause`, `input_released`, `target_changed`, `action_unavailable`,\n`provider_closed`, `provider_disabled` and `resource_stopped`.","natives":[],"permissions":[],"events":[]},{"anchor":"consumer-recipes","heading":"Consumer recipes","level":2,"text":"Put the following in a client script of a resource with a dependency on\n`open77_contextmenu`. Add `local.events` to its permissions for lifecycle handlers.\n\n```lua\nlocal function menu(method, ...)\n local promise, dispatchError = Open77.exports.call(\"open77_contextmenu\", method, ...)\n if not promise then return nil, dispatchError end\n return promise:await()\nend\n\nexports(\"inspectMe\", function(ctx)\n print(\"My network player ID: \" .. tostring(ctx.target.playerId))\n return true\nend)\nexports(\"aimDirection\", function(ctx)\n -- E.g. orient a local preview; do not teleport to ctx.position.\n print(Open77.json.encode({origin=ctx.origin, direction=ctx.direction}))\n return true\nend)\nexports(\"inspectCar\", function(ctx)\n print(\"Vehicle \" .. tostring(ctx.target.vehicleId))\n return true\nend)\n\nlocal function registerActions()\n local token, err = menu(\"registerSelf\", {\n id=\"my_character\", label=\"My character\", icon=\"person\", onSelect=\"inspectMe\",\n })\n if not token then print(tostring(err)); return end\n local sky, skyError = menu(\"registerSky\", {\n id=\"direction\", label=\"Point here\", icon=\"location\", onSelect=\"aimDirection\",\n })\n if not sky then print(tostring(skyError)) end\n local car, carError = menu(\"registerVehicles\", {\n id=\"car\", label=\"Inspect vehicle\", icon=\"vehicle\", networked=true,\n distance=4, onSelect=\"inspectCar\", data={mode=\"inspect\"},\n })\n if not car then print(tostring(carError)); return end\n -- Optional: patch text and keep the replacement token.\n local updated, updateError = menu(\"update\", car, {label=\"Vehicle details\"})\n if updated then car=updated else print(tostring(updateError)) end\nend\n\nAddEventHandler(\"onClientResourceStart\", function(name)\n if name==GetCurrentResourceName() or name==\"open77_contextmenu\" then registerActions() end\nend)\n-- No stop handler needed for registrations; framework owns their cleanup.\n```\n\nUseful variations (call from a yielding client handler/thread, using `menu` above):\n\n```lua\n-- Your inventory/modal opens: prevent the target menu until it closes.\nmenu(\"setTargetingEnabled\", false)\n-- ... on inventory close:\nmenu(\"setTargetingEnabled\", true)\n\n-- Only your scripted taxi, not every vehicle. Capture the server's vehicle ID.\nlocal taxiAction, err = menu(\"registerEntities\", {{vehicleId=taxiId}}, {\n id=\"taxi_info\", label=\"Taxi information\", icon=\"vehicle\", distance=4,\n onSelect=\"inspectCar\",\n})\n-- Do not convert engineEntity/propId strings into numbers.\n-- Remove your specific action, or all your actions:\nif taxiAction then menu(\"unregister\", taxiAction) else print(tostring(err)) end\nmenu(\"clear\")\n```","natives":["Open77.exports.call","server:Open77.exports.call","Open77.json.encode","AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName"],"permissions":[],"events":[]},{"anchor":"server-side-actions","heading":"Server-side actions","level":2,"text":"For gameplay effects, your `onSelect` sends a specific request to your own server\nresource, normally only a canonical target ID and the requested operation.\nNever send a caller-chosen player ID as the actor, never let the browser choose\nan arbitrary server command, and never trust the client's range check.\n\nThe server handler must use its authenticated `source`, resolve the current\ncanonical target, check the same bucket, actor/target life and distance, apply\nyour permissions/ownership/cooldown rules, then perform the operation. Client\n`canInteract` improves UX; it is not an ACL or anti-cheat boundary.\n\nNo built-in lock/teleport/delete handlers are shipped: each gamemode has its own\nownership and authorization model. The included example only prints selected\ntarget information and grants no gameplay powers.","natives":[],"permissions":[],"events":[]},{"anchor":"limitations","heading":"Limitations","level":2,"text":"Except for the explicitly supported F7 presentation, physics-less effects/props\nand geometry without entity owners cannot invent a\nnetwork entity ID. Walls block picking. Targeting is a desktop mouse/keyboard\nworkflow, not a controller radial menu. Actions do not attach to a moving object\nafter selection; if it leaves the original ray, select it again.","natives":[],"permissions":[],"events":[]}]},{"slug":"cordon","file":"cordon.md","title":"Cordon — battle royale","summary":"Cordon is a 64-slot battle royale with staging, equipment collection and a moving exclusion zone. Solo, Duo and Squad formats share the same match rules.","sections":[{"anchor":"cordon-battle-royale","heading":"Cordon — battle royale","level":1,"text":"Cordon is a 64-slot battle royale with staging, equipment collection and a moving exclusion zone. Solo, Duo and Squad formats share the same match rules.\n\nSource: [`open77_cordon`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cordon), [`open77_cordon_hud`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cordon_hud).","natives":[],"permissions":[],"events":[]},{"anchor":"joining-and-controls","heading":"Joining and controls","level":2,"text":"The current lobby is Afterlife. Staging players can move and sprint; combat and\nplacement are owned by the mode. Joining an ongoing server leaves you in staging\nuntil a later match.\n\n| Default control | Action |\n|---|---|\n| **Y** | Join or leave the next match's queue. |\n| **M** | Open the tactical map; Escape closes it. |\n| **F**, on an active loot prompt | Request the nearby container's contents. |\n| **END** | Start applying an armor plate. |\n| **F8** | Start using a healing item. |\n| **F**, on a downed squadmate's prompt | Start reviving; tap F again to cancel. |\n\nQueue, map and consumable actions use the key mapping system. Their HUD hints\nshow the effective binding, including changes made in Settings. F remains the\nworld interaction prompt's key. **F6 and F7 are perspective controls**, not\nconsumable defaults: the perspective resource uses F6 and the native client\nuses F7. Rebinding an action to an occupied key can trigger both functions.","natives":[],"permissions":[],"events":[]},{"anchor":"match-flow","heading":"Match flow","level":2,"text":"`staging → deploy → live → extraction → resolved → staging`\n\n- **Staging:** the queue determines participation. A minimum human count and a\n countdown gate deployment; bots do not satisfy the human requirement.\n- **Deploy:** use the map to choose a landing pin. Ground placement is the\n default (`drop=false`), not a ride in an aircraft.\n- **Placement confirmation:** the match phase may already read `live` while an\n individual is still `deploying`. The HUD says DEPLOYING while the server\n waits for the exact native placement acknowledgement. Loot, cordon timing and\n live participation start when the deployment wave completes. Failed placements\n return to staging without inventing a death or ranked finish. A match needs\n at least two confirmed squads to proceed.\n- **Live:** loot, fight and rotate toward the safe area. Dead players with a\n living squadmate may have a revive window; eliminated players watch from\n staging.\n- **Extraction and results:** the surviving contestant or squad receives the\n match result, followed by standings and the return to staging. The implemented\n extraction is a match/result phase. **There is no proven playable Trauma Team\n AV pickup or passenger flight.**\n\nDefault source tunables are **Solo**, **Watson**, **standard** pacing,\n`minPlayers=16`, staging 105 s and deploy 45 s. Development profiles override some\nof these. The standard cordon budget is 45 s initial grace, 300 s of closure and 25 s\nfinal phase; this is about 6 m 10 s of zone timing, not an 18-minute default match.\nEarly wins can end a match sooner.","natives":[],"permissions":[],"events":[]},{"anchor":"the-moving-cordon","heading":"The moving cordon","level":2,"text":"The authoritative safe area is a continuously shrinking rectangle intersected\nwith the map's original allowed cells. Excluded cells and holes stay excluded.\nThe chosen final cell varies between matches, with recent finals avoided; the\nphase endpoints still follow the map's block geometry.\n\nDuring a closure the rectangle moves continuously. A partially covered cell is\npartly dangerous: an entire cell does not remain safe until its last sliver\ncloses. The final refuge remains until the terminal phase ends, then the safe\narea is empty. There is no collision wall and no forced teleport back inside.\n\nThe server evaluates the same geometry for containment and damage. The tactical\nmap draws the moving outline over its coarse cell overview. HUD guidance shows\nthe time until the boundary reaches **your position**, the danger rate and a\ndirection toward safety. Guidance to a nearby safe cell is not a street-level\npathfinder; buildings and elevation can require a different route.\n\nThe world edge uses moving native game VFX driven by that shared sweep. It\nreplaces the old static post/concrete-wall description; the legacy renderer is\nsuppressed while an authoritative sweep is present. The current work does not\nestablish a uniformly legible 300 m boundary in every street or a 64-client GPU\nperformance claim. See [VFX research](../docs/research/vfx-sfx-runtime.md) for\nactual asset, distance and budget measurements.\n\n`cordonEdge=world` enables world effects; `hud` removes them in favor of HUD\nfeedback; `off` removes the optional edge effects. Core outside warnings and\nsafe-direction guidance remain. These switches do not disable server damage.\nAn unreadable position does not establish that a player is outside.","natives":[],"permissions":[],"events":[]},{"anchor":"loot-and-consumables","heading":"Loot and consumables","level":2,"text":"Cordon owns its inventory and validates pickups on the server. Containers have\nphysical visuals and nearby world prompts; seeing a prop is not proof that a\npickup or weapon assignment completed.\n\n| Equipment | Current behavior |\n|---|---|\n| Three weapon slots | Verified assignments populate the kit; the HUD identifies the actual active weapon and magazine/reserve ammunition. |\n| Armor plates | END starts the server's 3 s action. One plate adds up to 25 armor within the equipped shield's capacity. |\n| Healing items | F8 starts the server's 4 s action. Healing is applied on completion, not continuously during the bar. |\n| Inventory counts | Changed by authoritative completion, pickup and death handling, not by a key press. |\n\nConsumables require a living participant with the item. Damage interrupts the\naction. Full health, full armor, missing shield and empty inventory can refuse\nit. The progress bar starts only from the server's action state; FINISHING means\ncompletion has not yet been confirmed. One player cannot use a consumable and\nrevive simultaneously.\n\nWeapon pickup success is reported after the assignment succeeds. The container\nstays claimed while the grant is pending. Explicit refusals known to have made\nno inventory change can restore it; an uncertain timeout does not blindly\nreopen it and duplicate a weapon. The player receives an uncertainty notice.\n\nThe shooting arbiter accepts weapons through the verified inventory path.\nOperator `weapon.give` or an unrelated resource's loot command is not a substitute\nfor a Cordon pickup. Death releases the mode's carried equipment according to\nits death-drop rules; successful revival returns the player without the old kit.\nSupply drops and other unused design tunables must not be interpreted as\nimplemented world events.","natives":[],"permissions":[],"events":[]},{"anchor":"squads-and-revival","heading":"Squads and revival","level":2,"text":"Duo and Squad assign teams at deployment. The current fill iterates queued\nplayers by id and fills available squads; party invite codes are not implemented.\nDo not promise that `/br.party` will group friends. The tactical map and squad\npanel carry squad-only positions and revive state.\n\nA downed squadmate has a default 30 s revive window while a living squadmate can\nanswer it. The prompt is anchored to the captured three-dimensional body\nposition; the dead player's current movement snapshot is not its source.\n\nFace the body and approach until its **F prompt is active**. The interaction must\nbe on screen even though exact reticle focus is not required. Its 2.5 m activation\nrange is measured in 3D to a prompt normally 1 m above the body, so 2.5 m horizontal\nseparation can still be out of range. About 1.5 m on the same floor is comfortably\ninside. The server independently checks healer-to-body reach and squad identity.\n\nTap F once to begin the 5 s action. Releasing the key does not cancel it; tap F\nagain to cancel. Moving away or damage to either party also interrupts it.\nThere is one target per healer. During native recovery confirmation, the HUD\nsays WAITING FOR RECOVERY and additional taps cannot undo the submitted recovery.\nOnly the canonical recovery acknowledgement restores alive state and credits the\nhealer. Default revived health is 30%, with short spawn protection and no old kit.\n\nThe complete squad phase gate also includes looting, fighting and extraction.\nA working revive alone does not close that broader gate. Ghost-flying spectators\nand an observer-visible authored revive animation are not established features;\nthe supported spectator experience is staging watch.","natives":[],"permissions":[],"events":[]},{"anchor":"bots-holdouts-and-unfinished-world-work","heading":"Bots, holdouts and unfinished world work","level":2,"text":"Bot contestants use engine AI, occupy the match roster, are labelled **BOT**, and\ncan win. Any match containing a contestant bot is unrateable. Default fill policy\nis `threshold`: below 24 humans, fill toward 40 contestants. `off` disables fill;\n`always` fills toward capacity. Bot movement requires a client simulation lease;\na headless phantom is not an engine-driven fighting NPC.\n\nGang holdouts are separate from contestant bots and spawn at high-tier loot clusters. They are disabled by default (`holdoutsEnabled=false`). Camp kills use **GANG GUARD** in the feed and do not affect rating. This opt-in policy does not change ordinary NPC behavior in other modes.\n\nPassenger-following AV flight and playable extraction flights are experimental. Boss templates, weather arcs and supply-drop declarations are not supported gameplay guarantees.","natives":[],"permissions":[],"events":[]},{"anchor":"running-a-server","heading":"Running a server","level":2,"text":"Use the tracked [local profile](../server/server.cordon-local.jsonc) or\n[listed profile](../server/server.cordon-listed.jsonc), and inspect their resource\nlists and startup overrides. The mode lives in\n`resources/gamemodes/open77_cordon`; its HUD is\n`resources/gamemodes/open77_cordon_hud`. The setup template is\n`templates/cordon`, but older descriptive text there may describe prior design.\n\nThe local profile uses game 11808, downloads 11809 and Warden 11810. These are local\nprofile ports, not the production Cordon endpoint. Launch from `server/` after\nbuilding with the repository's documented build procedure:\n\n```powershell\n./src/Open77.Server/bin/Release/net10.0/Open77.Server.exe --config server.cordon-local.jsonc\n```\n\nKeep the mode's declared dependencies and the profile's shared resources.\n[`open77_worldui`](https://github.com/Open2077/open77-resources/tree/main/system/open77_worldui) and [`open77_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_interactions) supply the physical revive/loot prompts;\nweapon and loot APIs underpin inventory; the HUD supplies match/combat/map views.\nDo not combine Cordon with a second gamemode that also owns spawn and death.\nDo not put laboratory resources or test control-file hooks on a public server.\n\nUse Warden tunables or commands for runtime changes. Editing a watched resource\ncan reload the match; reconnect-policy client resources need a reconnect to\nreceive the new pack. Test snapshots belong outside the live resource tree.\nPreserve the server's signing identity, database and environment during updates.","natives":[],"permissions":[],"events":[]},{"anchor":"capacity-and-transport","heading":"Capacity and transport","level":3,"text":"Set `network.maximumPlayers=64`. The tracked simulation configuration uses 30 Hz\nticks, 20 Hz snapshots, a 500 m player-interest radius and 60 m hysteresis. Do not\nreduce interest below the supported combat range: an unseen remote player cannot\nbe hit correctly by the client.\n\nKeep these transport environment settings for 64-slot operation:\n\n```text\nOP77_GNS_SEND_RATE_BYTES=4194304\nOP77_GNS_SEND_BUFFER_BYTES=8388608\n```\n\nInterest culling saves bandwidth for spread players, but clustered rosters still\nneed the increased per-connection send budget. The reproduced failure is\n`send_failed:LimitExceeded` in the client's `net.state`; the server can continue\nrunning and counting that session.\n\nThree final headless 64-session cycles completed with the continuous geometry and\nbounded state publisher. All 64 players were covered by state-send invocations\nin each cycle, including outside-state coverage. This measures server behavior;\nit does not prove client receipt, UDP/encryption capacity, 64 real HUDs or GPU\nperformance. Historical measurements and harness limits are in\n[scale research](../docs/research/battle-royale-and-scale.md).","natives":[],"permissions":[],"events":[]},{"anchor":"useful-tunables","heading":"Useful tunables","level":3,"text":"Defaults below are declared source defaults. A profile or persisted override can\nreplace them. A value reaching the resource immediately does not mean an already\ncomputed match schedule is rebuilt.\n\n| Key | Default | Purpose |\n|---|---|---|\n| `format` | `solo` | Next match: solo, duo or squad. |\n| `activeMap` | `watson` | Active surveyed/runtime map; Pacifica remains provisional. |\n| `minPlayers` |16 | Human queue threshold. |\n| `stagingSeconds` / `deploySeconds` |105 /45 | Queue and landing-selection windows. |\n| `drop` |false | Ground insertion by default. |\n| `schedule` |`standard` | `standard`, development `blitz`, or `long`; retired `short` falls back. |\n| `cordonSeconds` / `cordonGrace` / `cordonSteps` |0 | Zero uses the chosen preset; captured when the cordon arms. |\n| `damageScale` |1.0 | Live multiplier for zone damage and its HUD rate. |\n| `cordonEdge` |`world` | World effects, HUD fallback or optional effects off. |\n| `lootDensity` |1.0 | Scale the active map's loot counts, not a guaranteed 900 drops. |\n| `holdoutsEnabled` |false | Experimental camp spawner; keep current validation limits in mind. |\n| `botFill` / `botFillTarget` / `botFillFloor` |`threshold` /40 /24 | Contestant bot fill policy. |\n| `reviveWindowSeconds` |30 | Default opportunity to rescue a downed squadmate. |\n| `ratedMinPlayers` / `ratingK` |16 /32 | Rating admission and update size. |\n\n`gridSize` is a retained unimplemented tunable: actual grid geometry comes from\nthe active map. Use `/br.map` for map authoring, not an unimplemented size knob.","natives":[],"permissions":[],"events":[]},{"anchor":"commands-and-editor","heading":"Commands and editor","level":3,"text":"| Command | Use |\n|---|---|\n| `/br.status` | Match phase, roster, format, map, bot/rating and cordon state. |\n| `/br.where [playerId]` | Server-side position, state, squad, cell and kit; players inspect themselves, operators can inspect an id. |\n| `/br.queue` | Queue controls; use command help for operator/all-player forms. |\n| `/br.start`, `/br.end` | Restricted match controls. |\n| `/br.edge world\\|hud\\|off` | Inspect/change edge presentation through the supported command. |\n| `/br.map.check`, `/br.map` | Survey status and editor help. |\n| `/br.bots` | Restricted bot inspection/control; adding contestants taints rating. |\n| `/br.beside ` | Restricted placement beside a captured body for diagnosis. |\n| `/br.revive ` | Restricted direct server path; does not test physical F input. |\n| `/br.stats`, `/br.stats.of ` | Own career and restricted operator inspection. |\n\nUse the ACL-protected tac-map EDIT commands to record positions on walkable ground. Map data is stored outside the watched resource tree. Validate geometry with `/br.map.check` before deployment.","natives":[],"permissions":[],"events":[]},{"anchor":"rating","heading":"Rating","level":2,"text":"Progression is placement rating and career history; it grants no combat power.\nRatings are format-specific. Under-populated matches and matches containing\ncontestant bots do not update the ladder. Persistence is optional for play;\nwithout a database, history is volatile. Database availability alone does not\nmean a match was rated.","natives":[],"permissions":[],"events":[]},{"anchor":"capturing-spawn-orientation","heading":"Capturing spawn orientation","level":3,"text":"`/br.map add spawn \"Spawn\"` captures the current character position **and body\norientation** from the latest server snapshot. Turn the character toward the\nintended direction before capturing; rotating only the camera is insufficient.\n`/br.map add spawn 0 \"Spawn\"` explicitly overrides the heading with zero degrees.\nThe visual editor's PLACE HERE action uses the same capture path.\n\nVehicle and staging anchors also capture orientation when the heading is omitted:\n`/br.map add veh Vehicle.v_standard2_archer_hella_player` and `/br.map add stage`.\n`/br.map move spawn 1` and `/br.map move stage` recapture position and orientation;\nappend an angle to override it. `/br.map paint spawn 6` captures the current heading\nat each point, while `/br.map paint spawn 6 0` keeps an explicit zero heading.\nPositions older than two seconds remain unavailable. Older hosts without snapshot\nyaw retain the existing \"heading not captured\" marker instead of claiming a capture.","natives":[],"permissions":[],"events":[]}]},{"slug":"cyberware","file":"cyberware.md","title":"Cyberware","summary":"Define and install persistent cyberware through the server-owned implant framework. Supported slots include Gorilla Arms (`arms`), double jump (`legs`) and the [hacking](hacking.md) implants (`operating_system`, `self_ice`, `purge`). Session abilities are documented separately: [Dash](dash.md), [Ground Slam](ground-slam.md) and [Overdrive](reflex-overdrive.md).","sections":[{"anchor":"cyberware","heading":"Cyberware","level":1,"text":"Define and install persistent cyberware through the server-owned implant framework. Supported slots include Gorilla Arms (`arms`), double jump (`legs`) and the [hacking](hacking.md) implants (`operating_system`, `self_ice`, `purge`). Session abilities are documented separately: [Dash](dash.md), [Ground Slam](ground-slam.md) and [Overdrive](reflex-overdrive.md).","natives":[],"permissions":[],"events":[]},{"anchor":"minimal-example","heading":"Minimal example","level":2,"text":"A self-service resource that installs double-jump legs on the player who runs\n`/legs install` and removes them with `/legs remove`. It needs a configured server\ndatabase and the [`open77_appearance`](https://github.com/Open2077/open77-resources/tree/main/system/open77_appearance) character adapter, which binds the player's\ncharacter; the [`open77_cyberware`](https://github.com/Open2077/open77-resources/tree/main/system/open77_cyberware) support resource is running by default.\n\n`open77.lua`:\n\n```lua\nresource \"my_legs\"\nversion \"1.0.0\"\ndependency \"open77_cyberware >=0.1.0\"\nserver_script \"server/main.lua\"\npermissions { \"players.cyberware.define\", \"players.cyberware.read\", \"players.cyberware.manage\" }\n```\n\n`server/main.lua`:\n\n```lua\nCreateThread(function()\n local result, reason = Open77.cyberware.define({\n id = \"myserver.double_jump\", version = 1, slot = \"legs\", profile = \"double_jump\",\n grades = {\n { id = \"training\", normalDamage = 0, chargedDamage = 0, knockbackMeters = 0,\n cooldownMs = 800, chargeMs = 650,\n jumpStaminaCost = 15, maxAirborneMs = 10000, maxFallSpeed = 30 },\n },\n })\n assert(result, reason)\nend)\n\nlocal pending = {}\n\nRegisterCommand(\"legs\", function(source, args)\n local player = tonumber(source)\n if not player or player <= 0 or pending[player] then return end\n local record, reason = Open77.cyberware.current(player)\n if not record then print(\"character not ready: \" .. tostring(reason)); return end\n local options = { expectedRevision = record.revision, operationId = assert(Open77.cyberware.newOperationId()) }\n local result\n if args[1] == \"remove\" then\n options.slot = \"legs\"\n result, reason = Open77.cyberware.remove(player, options)\n else\n result, reason = Open77.cyberware.install(player, \"myserver.double_jump\", \"training\", options)\n end\n if not result then print(\"refused: \" .. tostring(reason)); return end\n if result.ticket then pending[player] = result.ticket; print(\"pending for player \" .. player) end\nend, false)\n\nAddEventHandler(\"onCyberwareOperationCompleted\", function(player, ticket, encoded)\n player = tonumber(player)\n if pending[player] ~= ticket then return end\n pending[player] = nil\n local result = json.decode(encoded)\n print((\"player %d: %s\"):format(player, result.ok and \"done\" or tostring(result.error)))\nend)\n```\n\nThe player types `/legs install`, waits for the `done` line, then uses the ordinary\njump key: jump, release, press again in the air. One second jump per airtime; landing\nrearms it. `/legs remove` puts the native legs back.","natives":["CreateThread","server:CreateThread","server:Open77.cyberware.define","RegisterCommand","server:RegisterCommand","server:Open77.cyberware.current","server:Open77.cyberware.newOperationId","server:Open77.cyberware.remove","server:Open77.cyberware.install","AddEventHandler","server:AddEventHandler"],"permissions":["players.cyberware.define","players.cyberware.read","players.cyberware.manage"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"All calls live under `Open77.cyberware`. Failures return `nil, reason`.\n\n| Call | Permission | Purpose |\n|---|---|---|\n| `define(definition)` | `players.cyberware.define` | Register a definition and its grades (up to 32) |\n| `bind(player, characterKey)` / `unbind(player)` | `players.cyberware.identity` | Bind the authenticated player to a character; `open77_appearance` does this for you |\n| `current(player)` | `players.cyberware.read` | The durable record, or `nil` while it loads |\n| `effective(player)` | `players.cyberware.read` | The record in play, including an active temporary loadout |\n| `activity(player)` | `players.cyberware.read` | The fresh native punch/hold state |\n| `install(player, definitionId, gradeId, options)` | `players.cyberware.manage` | Stage an installation; the slot comes from the definition |\n| `remove(player, options)` | `players.cyberware.manage` | Stage a removal; `options.slot` is `\"arms\"` (default) or `\"legs\"` |\n| `cancel(player, ticket)` | `players.cyberware.manage` | Cancel your own staged operation |\n| `newOperationId()` | `players.cyberware.manage` | A durable operation identity for `options.operationId` |\n| `lease(player, definitionId, gradeId, options)` | `players.cyberware.temporary` | Stage a temporary loadout; `options.durationMs` is 1000–300000 |\n| `releaseLease(player, leaseId)` | `players.cyberware.temporary` | End it early and restore the paid implant |\n| `leaseState(player)` | `players.cyberware.read` | Lifecycle of the current lease, or `nil` |\n\nA definition is `{id, version, slot, profile, grades}`. `id` is 1–96 ASCII letters,\ndigits, `_`, `.` or `-`; `version` is a positive integer; the slot/profile pair is one of\n`arms`/`gorilla_arms`, `legs`/`double_jump`, `operating_system`/`cyberdeck`,\n`self_ice`/`self_ice`, `purge`/`active_purge`. An installed grade is a snapshot: changing\nthe definition later does not rewrite installed implants.","natives":[],"permissions":[],"events":[]},{"anchor":"records","heading":"Records","level":3,"text":"`current` and `effective` return `{revision, arms, legs, operationId, operationSlot}`;\nan empty slot is `nil`. Each implant has `instanceId`, `definition`, `definitionVersion`,\n`profile`, `slot` and its `grade` snapshot. Both slots share one revision, so read\n`current` again before every mutation.","natives":[],"permissions":[],"events":[]},{"anchor":"transactions","heading":"Transactions","level":2,"text":"`install` and `remove` take `{expectedRevision = current.revision, operationId = id}` and\nreturn `{ok = true, ticket = ...}`. **A ticket is pending work, not an installed\nimplant.** Wait for `onCyberwareOperationCompleted(player, ticket, encodedResult)`, match\nyour ticket and require `result.ok == true`. The owner's client equips the native item\nfirst, then the record is committed to storage, then observers see it.\n\nKeep the same `operationId` and `expectedRevision` when you retry: repeating the last\ncommitted operation returns `{ok = true}` with no new ticket, repeating the ID with a\ndifferent intent returns `operation_conflict`. For a paid service, reserve the money\nbefore you submit, finalize on `ok`, refund on failure, and keep your own receipt.\n`cancel` returns `operation_committing` once storage has started; wait for the result\ninstead. Stopping your resource cancels what can still be cancelled and disables\ncombat for implants whose definition is gone, but never deletes an installed implant.","natives":[],"permissions":[],"events":[]},{"anchor":"grade-options","heading":"Grade options","level":2,"text":"The grade schema is shared by every slot. Arms use the punch fields, legs use the jump\nfields; the other fields must still be present and valid.\n\n| Grade field | Range / meaning |\n|---|---|\n| `normalDamage`, `chargedDamage` | Required, 0–300; use 0 for legs |\n| `knockbackMeters` | Required, 0–6; use 0 for legs |\n| `cooldownMs` | Required, 100–600000 ms between admitted punches or second jumps |\n| `chargeMs` | Required, 100–10000; any valid value for legs |\n| `jumpStaminaCost` | Default 0, range 0–300; charged once per admitted second jump |\n| `maxAirborneMs` | Default 10000, range 100–10000; latest point in the airtime a second jump is admitted |\n| `maxFallSpeed` | Default 30, range 0.1–30 m/s; fastest descent that still admits a second jump |\n\nThe full arm fields (`maxChargeMs`, stamina costs, `nonlethal`, `cosmetic`, blocking)\nare on the [Gorilla Arms](gorilla-arms.md#grade-options) page. Legs limits only narrow\nwhen the second jump is admitted; jump height, gravity, fall damage and collision are\nthe native ones.","natives":[],"permissions":[],"events":[]},{"anchor":"temporary-loadouts","heading":"Temporary loadouts","level":2,"text":"`lease(player, definitionId, gradeId, {durationMs = 300000})` gives an arena or an event\nan implant that is restored to the paid one afterwards. One lease per character at a\ntime; a pending purchase or another lease refuses it, and paid installs and removals are\nrefused while a lease runs. `current` keeps the paid record, `effective` shows the lease.\n\n`onCyberwareLeaseChanged(player, encodedState)` carries `id`, `player`, `phase`\n(`pending`, `active`, `restoring`, `ended`), `definition`, `grade`, `expiresAt`, `ticket`\nand `reason`. Expiry, your resource stopping, death, disconnect and a bucket change all\nend a lease and restore the paid implant; restoration is the backend's job even after\nyour resource has stopped. Before granting another loadout wait until\n`leaseState(player) == nil` and `current(player) ~= nil`.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"All events are server-local; clients cannot forge them. Player IDs are strings, JSON\narguments need `json.decode`.\n\n| Event | Meaning |\n|---|---|\n| `onCyberwareOperationCompleted(player, ticket, encodedResult)` | An install, removal or lease operation finished; `result.ok`, `result.error`, optional `result.lease` |\n| `onCyberwareLeaseChanged(player, encodedState)` | A temporary loadout changed phase |\n| `onCyberwareMeleeHit(victim, attacker, encodedSnapshot)` | An accepted Gorilla contact: `sequence`, `incarnation`, `instanceId`, `definition`, `grade`, `charged`, `amount`, `bodyPart`, `lethal` |\n| `onCyberwareMeleeBlocked(victim, attacker, sequence, amount)` | An accepted block; `amount` is 0 or the chip damage |\n| `onCyberwareMotionOutcome(victim, attacker, encodedOutcome)` | The knockback decision for a hit: `requestedDistance`, `outcome` (`pending`, `skipped`, `rejected`), `reason`, `motionId` |\n| `onCyberwareJump(player, encodedResult)` | A second-jump decision: `{sequence, ok, error}` |\n| `onCyberwareActionRejected(player, sequence, error)` | A punch the server refused |\n| `onPlayerMotionChanged(player, id, phase, reason)` | A knockback moved through `pending`, `active`, `ended` |\n\nSkipped knockback reasons are `zero_distance`, `lethal`, `downed`, `motion_unavailable`,\n`body_unavailable` and `bucket_mismatch`; `motion_busy` means the victim is still in a\nprevious reaction. Second-jump refusals are `implant_unavailable`, `stale_incarnation`,\n`stale_action`, `stale_movement`, `movement_timeout`, `bucket_changed`, `motion_busy`,\n`jump_unavailable`, `movement_limit`, `cooldown` and `insufficient_stamina`.","natives":[],"permissions":[],"events":[]},{"anchor":"optional-resources","heading":"Optional resources","level":2,"text":"[`open77_ripperdoc_example`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_ripperdoc_example) (`auto_start false`) is a consent-and-payment clinic. Doctors\nneed the `command.doc` ACL; both players must be alive, in one bucket and within three\nmetres. Arms: `/doc offer training|industrial|cosmetic`, `/doc inspect `,\n`/doc remove `. Legs: `/doc offer training|athlete legs`,\n`/doc inspect legs`, `/doc remove legs`. The patient looks at the\ndoctor and holds **E** to accept, **F** to decline, or types `/implantaccept` /\n`/implantcancel`. Its example prices are 100 credits for training legs, 250 for athlete\nand 25 for a removal, from an in-memory balance of 500 that resets with the resource.\n\n[`open77_cyberware_lab`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cyberware_lab) (`auto_start false`) is a test panel: `/cyberlab` opens it on\nyour own character; pick Gorilla Arms or Double Jump, a grade, and Install or Remove, or\ngrant Dash, Ground Slam or Overdrive from their cards further down. The same controls exist\nas commands: `cyberlab installlegs training `, `cyberlab removelegs\n `, `cyberlab state `. Every player may use the panel and the\ncommands on themselves. Acting on another player (`/cyberlab ui `, any command\nnaming someone else) or on the whole server (`pvp`, `load`) needs the server console or the\nACL right `command.cyberlab.others`; anyone else is refused with `operator_required` and sees\nonly themselves in the panel. Grant the right in your [ACL file](server-acl.md):\n\n```json\n\"permissions\": [ \"command.cyberlab.others\" ]\n```\n\nAdd the resource to `resources.load` and run `ensure open77_cyberware_lab`; after updating\nit or editing its manifest, run `refresh` first so its `acl.read` permission is loaded.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"**Double jump admission.** The native second-jump rules still apply (jump count, fall\nspeed, elevators and vehicles). Open77 buffers an eligible press for up to 750 ms while\nthe server decides; the server needs a ready, alive, unmounted body, a movement sample\nat most 750 ms old, no active knockdown, and enough stamina. At least 150 ms of ground\nrearms the airtime. Stamina is charged on admission, with no refund if the native jump\ndoes not follow. A press whose movement sample has not arrived after 200 ms is refused\nwith `movement_timeout`.\n\n**Knockback is a request.** `Open77.motion.knockdown(player, {x = 0, y = 1, distance = 2})`\n(`players.motion.control`) and `Open77.motion.current(player)` (`players.motion.read`)\nexpose the same native reaction Gorilla hits use. Distance is 0–6 m and collisions\ndecide the real travel; the body must be ready, alive and unmounted; a second request\nduring a reaction is refused; cancelling removes the lease but does not stop momentum or\nthe get-up animation.\n\n**One character binding.** The account comes from the admitted connection and the\ncharacter key from your trusted character workflow; never bind a client-supplied ID.\nStopping the identity adapter releases its bindings.\n\n**Providers must run.** After restarting the core, restart every resource that defines\nimplants. An implant whose definition is not registered stays on the record and stays\nvisible, but cannot punch or double jump until its provider is back.\n\n**Storage.** Records live in the `open77_cyberware_v1` and\n`open77_cyberware_operations_v1` tables as additive JSON; a custom `ICyberwareStore` must\nkeep both slots and `operationSlot` in one atomic revision-and-receipt transaction.","natives":[],"permissions":[],"events":[]},{"anchor":"advanced-client-adapter","heading":"Advanced: client adapter","level":2,"text":"`open77_cyberware` owns the native side. Gameplay resources do not need these calls;\nthey are listed for trusted projection resources replacing the adapter.\n\n| Client call | Permission | Contract |\n|---|---|---|\n| `Open77.cyberware.projectLocal(enabled)` / `localState(request)` | `player.cyberware.project` | Owned arm equipment request; `pending`, `ready`, `failed` |\n| `configureLocal(request, grade)` | `player.cyberware.project` | Configure the owned arm adapter |\n| `captureArms()` / `attackState()` | `player.cyberware.read` | Read the native arm profile and action state |\n| `presentArms(networkPlayer, capturedOrFalse)` | `player.cyberware.project` | Apply or remove an observer's arm appearance |\n| `projectLegs(enabled)` / `legsState(request)` | `player.cyberware.project` | Owned legs request; `pending`, `ready`, `failed` |\n| `configureLegs(request, staminaManaged, maxAirborneMs, maxFallSpeed)` | `player.cyberware.project` | Configure a ready legs request |\n| `legsActivity()` | `player.cyberware.read` | `{request, sequence, phase, grounded, airborneMs, verticalSpeed}` |\n| `approveLegJump(request, sequence, allowed)` | `player.cyberware.project` | Answer a pending native second-jump intent |\n| `releaseLegs()` | `player.cyberware.project` | Release the owned legs |\n| `Open77.motion.knockdown(handle, x, y, distance)` / `state(id)` / `stop(id)` | `player.motion.project` | Native reaction primitives |\n\nRequests are local handles, never network IDs. Any client resource can read the adapter's\nstate through its exports without owning it:\n\n```lua\nCreateThread(function()\n local pending = Open77.exports.call(\"open77_cyberware\", \"legsActivity\")\n local activity = pending and pending:await()\n if activity then print(activity.phase, activity.airborneMs) end\nend)\n```\n\n`capabilities` (client and server) lists the supported profiles, slots, body families and\nthe limits above; `legsActivity` returns `{sequence, phase, grounded, airborneMs,\nverticalSpeed}` with phases `idle`, `pending`, `granted`, `consumed`, `rejected`. The\n`open77:cyberware:*` network events are the platform's own transport and cannot be used\nto forge an implant or a jump.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]}]},{"slug":"dash","file":"dash.md","title":"Native Dash and Air Dash","summary":"Grant native ground or air dashes for a player session. The server controls stamina, charges, cooldowns and the one-Air-Dash-per-airtime limit. Dash grants do not replace installed cyberware.","sections":[{"anchor":"native-dash-and-air-dash","heading":"Native Dash and Air Dash","level":1,"text":"Grant native ground or air dashes for a player session. The server controls stamina, charges, cooldowns and the one-Air-Dash-per-airtime limit. Dash grants do not replace installed cyberware.","natives":[],"permissions":[],"events":[]},{"anchor":"minimal-example","heading":"Minimal example","level":2,"text":"`open77.lua`:\n\n```lua\nresource \"my_dash\"\nversion \"1.0.0\"\nserver_script \"server/main.lua\"\npermissions { \"players.dash.define\", \"players.dash.manage\", \"players.dash.read\" }\n```\n\n`server/main.lua`:\n\n```lua\nCreateThread(function()\n local result, reason = Open77.dash.define({\n id = \"myserver.air_dash\", version = 1, profile = \"dash\",\n config = {\n inputKey = \"ctrl\", allowGround = true, allowAir = true,\n staminaCost = 20, cooldownMs = 700, maxCharges = 1, chargeRegenMs = 2500,\n },\n })\n assert(result and result.ok, reason)\nend)\n\nRegisterCommand(\"dash\", function(source, args)\n local player = tonumber(source)\n if not player or player <= 0 then return end\n local result, reason\n if args[1] == \"off\" then result, reason = Open77.dash.revoke(player)\n elseif args[1] == \"state\" then result, reason = Open77.dash.current(player)\n else result, reason = Open77.dash.grant(player, \"myserver.air_dash\") end\n print(\"dash: \" .. (result and json.encode(result) or tostring(reason)))\nend, false)\n```\n\nThe player types `/dash`, waits until `/dash state` shows `projection.status = \"ready\"`,\ncloses any menu, holds a movement direction and taps **Ctrl**. For an Air Dash, jump\nfirst. With no direction held the dash follows the camera. `/dash off` removes the grant.\n\nEvery config key is optional and takes the default below when omitted; a misspelled key\nmakes the definition `invalid_definition`.","natives":["CreateThread","server:CreateThread","server:Open77.dash.define","RegisterCommand","server:RegisterCommand","server:Open77.dash.revoke","server:Open77.dash.current","server:Open77.dash.grant"],"permissions":["players.dash.define","players.dash.manage","players.dash.read"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"| Call | Permission | Returns |\n|---|---|---|\n| `Open77.dash.define(definition)` | `players.dash.define` | `{ok=true}` or `nil, reason`; versions are immutable |\n| `Open77.dash.grant(player, definitionId)` | `players.dash.manage` | `{ok=true}` or `nil, reason`; native projection is still pending |\n| `Open77.dash.revoke(player)` | `players.dash.manage` | Removes this resource's grant |\n| `Open77.dash.cancel(player, activationId)` | `players.dash.manage` | Cancels that active dash |\n| `Open77.dash.current(player)` | `players.dash.read` | Activity snapshot, or `nil` when the player has no grant |\n| `Open77.dash.capabilities()` | `players.dash.read` | Supported profile and the bounds below |\n\n`current(player)` returns `projection` (`status` is `pending`, `ready` or `removed`, plus\nthe `definition`), `charges`, `cooldownUntil`, `nextChargeAt` (server monotonic\nmilliseconds), `airUsed`, `activation`, `phase` and `ownedByCaller`. A grant needs a\nbound cyberware character (the [`open77_appearance`](https://github.com/Open2077/open77-resources/tree/main/system/open77_appearance) adapter supplies it) and an alive,\nready, unmounted body.","natives":[],"permissions":[],"events":[]},{"anchor":"options","heading":"Options","level":2,"text":"| Field | Default | Accepted values |\n|---|---|---|\n| `inputKey` | `\"ctrl\"` | One lowercase letter or digit, `f1`–`f12`, or `space`, `enter`, `return`, `tab`, `shift`, `ctrl`, `control`, `alt`, `capslock`, `backspace`, `insert`, `delete`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right` |\n| `allowGround`, `allowAir` | `true`, `true` | At least one must be true |\n| `requireDoubleJump` | `false` | Require installed, active double-jump legs; does not install them |\n| `staminaCost` | `20` | 0–300 |\n| `cooldownMs` | `700` | 300–600000 |\n| `maxCharges` | `2` | 1–3 |\n| `chargeRegenMs` | `2500` | 300–600000, charges regenerate one at a time |\n| `landingRearmMs` | `150` | 150–1000 ms of stable ground before the Air Dash rearms |\n| `maxAirborneMs` | `10000` | 100–10000 |\n| `maxFallSpeed` | `30` | 0.1–30 m/s |\n| `movementProfile` | `\"native\"` | Only `\"native\"` |\n| `presentation` | `\"native\"` | `\"native\"` (sound and effects), `\"silent\"` (effects only), `\"none\"` |\n\nSpeed, distance and immunity are the native dash and are not configurable.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"`onDashChanged(player, encodedJson)` fires for every accepted dash as it moves through\n`accepted`, `started`, `completed` or `cancelled`. The payload carries `player`,\n`incarnation`, `activation`, `definition`, `mode` (`ground` or `air`), `phase`, `reason`,\n`directionX`, `directionY`, `serverTimeMs`, `expiresAtMs` and `presentation`.\n\n```lua\nAddEventHandler(\"onDashChanged\", function(player, encoded)\n local event = json.decode(encoded)\n print((\"dash player=%s activation=%s phase=%s reason=%s\")\n :format(player, event.activation, event.phase, tostring(event.reason)))\nend)\n```\n\n`onDashRejected(player, encodedJson)` fires when a tap was refused. The payload has\n`incarnation`, `revision`, `sequence`, `activation = nil`, `ok = false`, `error`,\n`directionX`, `directionY` and `expiresInMs`.\n\n```lua\nAddEventHandler(\"onDashRejected\", function(player, encoded)\n local rejected = json.decode(encoded)\n print((\"dash refused player=%s reason=%s\"):format(player, rejected.error))\nend)\n```\n\nBoth events are server-local: a client cannot trigger them over the network.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"optional-resources","heading":"Optional resources","level":2,"text":"[`open77_parkour_example`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_parkour_example) (`auto_start false`) is a self-service resource built on this\nAPI. Start it with `ensure open77_parkour_example`, then in chat:\n\n```text\n/parkour install basic air -- one charge, 20 stamina, 700 ms cooldown, 2500 ms regen\n/parkour install advanced ground -- three charges, 10 stamina, 450 ms cooldown, 1500 ms regen\n/parkour install advanced combined\n/parkour inspect\n/parkour remove\n```\n\nModes are `ground`, `air` and `combined`; `combined` needs installed double-jump legs\n(an authorized doctor runs `/doc offer training legs`). `server/config.lua`\nholds the binding, presentation, presets, `allowedBuckets` and `canUse(player, preset,\nmode)`, which must return `true`; the resource re-checks it every 250 ms and revokes the\ngrant when it stops passing.\n\n[`open77_cyberware_lab`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cyberware_lab) (`auto_start false`) offers the same presets from the `/cyberlab`\npanel's Dash card and as commands: `/cyberlab dash install basic air`,\n`/cyberlab dash inspect `, `/cyberlab dash test `,\n`/cyberlab dash remove `.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"**Costs are spent on acceptance.** A charge and the stamina go when the server accepts\nthe tap, even if a wall stops the dash a moment later. Cooldown and charge debt survive\nrevoke and regrant, a new definition version and body changes within the session.\n\n**One Air Dash per airtime.** Landing on stable ground for `landingRearmMs` rearms it;\nregenerating a charge does not. Jump then Air Dash and double jump then Air Dash both\nwork; an Air Dash followed by a double jump is limited by the native jump state. Three\ncharges never mean three Air Dashes in one airtime.\n\n**Native movement.** Collisions can shorten or block the travel; the presentation preset\nselects only the adapter's sound and effects, never the animation or the collision.\n\n**Input.** Direction comes from the physical WASD cluster; a custom in-game movement remap\nis not discovered. The tap is ignored while a native menu or a WebUI page owns focus, and\na freshly granted key must be released before its first tap.\n\n**Ownership.** One grant per player per resource. Another resource's grant returns\n`grant_owned`; use that resource to remove it. Stopping your resource removes its\ndefinitions and grants. A disconnect ends the session and a new body needs a fresh\nprojection before the key works again.\n\n**Refusal reasons** (`nil, reason` from a call, or `error` in `onDashRejected`):\n`definition_unavailable`, `definition_owned`, `grant_owned`, `grant_limit`,\n`body_unavailable`, `equipment_required` (legs missing for `requireDoubleJump`),\n`cooldown`, `charges_exhausted`, `insufficient_stamina`, `air_unavailable` (Air Dash\nalready used this airtime), `mode_unavailable`, `motion_busy` (a knockdown or a Ground\nSlam owns the body), `movement_limit`, `stale_movement`, `invalid_direction`, and the\n[hacking](hacking.md) gates `cyberware_suspended` (a Cyberware Malfunction or a freeze)\nand `crippled` (a Cripple Movement).\n\n**Cancellation and recovery.** After a cancel the server keeps the movement envelope\nuntil the client acknowledges the native exit or 1500 ms pass, and for at least 300 ms\nafter any dash is admitted; `current().phase` reads `recovering` meanwhile. The\n`open77:dash:*` network events are the platform's own transport and cannot be used to\nforge a dash.","natives":[],"permissions":[],"events":[]}]},{"slug":"data-catalogues","file":"data-catalogues.md","title":"Game data catalogues — Open77.data","summary":"`Open77.data` resolves TweakDB IDs such as `Items.Preset_Lexington_Default` and `Vehicle.v_sport1_rayfield_caliburn_player` into display names and structured metadata for inventories, shops and garages.","sections":[{"anchor":"game-data-catalogues-open77data","heading":"Game data catalogues — `Open77.data`","level":1,"text":"`Open77.data` resolves TweakDB IDs such as `Items.Preset_Lexington_Default` and `Vehicle.v_sport1_rayfield_caliburn_player` into display names and structured metadata for inventories, shops and garages.\n\n```lua\nlocal car = Open77.data.vehicle(\"Vehicle.v_sport1_rayfield_caliburn_player\")\nprint(car.displayName, car.classLabel, car.manufacturer, car.seats)\n\nlocal gun = Open77.data.weapon(\"Items.Preset_Lexington_Default\")\nprint(gun.displayName, gun.itemType, gun.quality)\n```\n\nBoth runtimes answer these five calls:\n\n| Call | Argument |\n|---|---|\n| `Open77.data.vehicle(record)` | a `Vehicle.*` record |\n| `Open77.data.weapon(record)` | an `Items.*` weapon record |\n| `Open77.data.item(record)` | an `Items.*` clothing or item record |\n| `Open77.data.npc(template)` | see [the one asymmetry](#the-one-asymmetry-npc) |\n| `Open77.data.localize(key)` | `LocKey#12345`, a bare number, or a secondary key |\n\nNo permission is required. This is static game data, the same for every player,\nand gating it would only push resources back to shipping their own copies.","natives":["Open77.data.vehicle","server:Open77.data.vehicle","Open77.data.weapon","server:Open77.data.weapon"],"permissions":[],"events":[]},{"anchor":"a-record-is-a-string-not-a-hash","heading":"A record is a string, not a hash","level":2,"text":"FiveM's `GetHashKey` returns a Jenkins hash and every FiveM resource compares\nthose numbers. **Open77 has no such number.** The `Citizen` alias layer's\n`GetHashKey` already returns a *TweakDB identifier* rather than a GTA hash, and\n`Open77.data` is deliberately consistent with it: you pass the record string, you\nget a table back, and there is nothing to round-trip through a hash function.\n\nA resource ported from FiveM that does `if GetEntityModel(veh) == GetHashKey(\"caliburn\")`\nhas to become `if Open77.vehicles.get(id).record == \"Vehicle.v_sport1_rayfield_caliburn_player\"`.\nThat is a port, not a shim, and pretending otherwise by inventing a hash would\nonly produce numbers that agree with nothing in the engine.\n\nThe closest thing to `GetEntityModel` is the **client's** `recordClass` field: the\nrecord's runtime RTTI class (`gamedataWeaponItem_Record`, `gamedataVehicle_Record`),\nread from the live database. It is always present once a lookup succeeds.","natives":[],"permissions":[],"events":[]},{"anchor":"where-the-answer-comes-from-and-why-the-two-sides-differ","heading":"Where the answer comes from, and why the two sides differ","level":2,"text":"Every answer carries `source`. It is `tweakdb` on a client and `catalogue` on a\nserver, and the difference is not cosmetic.\n\n**A client is inside Cyberpunk 2077.** The TweakDB is already in memory, indexed\nby exactly the record strings Open77 uses, with the engine's own localisation\nbeside it. So the client asks the real database, gets the display name **in the\nplayer's own language**, and ships no catalogue at all.\n\n**A server has no engine.** It answers from a reduced catalogue embedded in the\nhost, generated from `docs/generated/` by `scripts/build-data-catalogue.py` and\nparsed lazily — a server whose resources never call `Open77.data.item` never\nparses the item catalogue.","natives":[],"permissions":[],"events":[]},{"anchor":"performance","heading":"Performance","level":3,"text":"Client queries read the live TweakDB and use the player's localization. The server lazily loads an embedded catalogue and indexes records for lookup. Avoid distributing duplicate catalogues as Lua tables: they consume each resource's memory and startup instruction budget.","natives":[],"permissions":[],"events":[]},{"anchor":"fields","heading":"Fields","level":2,"text":"Only the fields a source actually contains are returned. **An absent field means\nthe answer was not available, not that the value is empty** — on the client in\nparticular, every field is read through a checked RTTI accessor, and a record\nclass that does not expose one simply leaves it out.\n\nAlways present once a lookup succeeds:\n\n| Field | Meaning |\n|---|---|\n| `record` / `template` | the key you asked for |\n| `kind` | `vehicle`, `weapon`, `item` or `npc` |\n| `source` | `tweakdb` (client, live) or `catalogue` (server, shipped) |\n| `recordClass` | *client only* — the runtime RTTI class |\n| `build` | *server only* — the catalogue's game build, `2.31` |\n\nCommonly present: `displayName`, `class`, `classLabel`, `manufacturer`, `seats`,\n`seatsSource`, `price`, `itemType`, `equipArea`, `quality`, `slot`, `tags`,\n`localeKey`, `tweakDbId`.","natives":[],"permissions":[],"events":[]},{"anchor":"derived-fields-say-so","heading":"Derived fields say so","level":3,"text":"`seatsSource` is the pattern to copy. On a **client** it is `record`, meaning the\nseat count was read from the vehicle record. On a **server** it is `class`,\nmeaning it was derived from the chassis class in the record name — no repository\nsource holds a real seat count, bikes report one and everything else four, and\nfour is in any case the ceiling of Open77's own four-seat wire model\n(`VehicleSeat`). A field that had to be inferred is labelled rather than\nlaundered.\n\nVehicle `displayName` on the server is derived the same way, from the record path.\nThe client's is the real localised name.","natives":[],"permissions":[],"events":[]},{"anchor":"localisation","heading":"Localisation","level":2,"text":"`Open77.data.localize(key)` is the `GetLabelText` equivalent and is **client\nonly**. It accepts `LocKey#12345`, a bare number — so a `localeKey` read off a\nserver catalogue row can be passed straight in — or a secondary key string.\n\nOn a server it always fails with `localization_client_only`. The locale tables\nlive in the game's own resources, which a dedicated server does not have, and\nreturning the key would let it pass for a translation. The pattern is:\n\n```lua\n-- server\nlocal gun = Open77.data.weapon(record)\nTriggerClientEvent(\"shop:row\", playerId, record, gun.localeKey, gun.quality)\n\n-- client\nAddEventHandler(\"shop:row\", function(record, localeKey, quality)\n local name = Open77.data.localize(localeKey)\n or (Open77.data.weapon(record) or {}).displayName\n or record\n ...\nend)\n```","natives":["Open77.data.weapon","server:Open77.data.weapon","server:TriggerClientEvent","AddEventHandler","server:AddEventHandler","Open77.data.localize","server:Open77.data.localize"],"permissions":[],"events":[]},{"anchor":"the-one-asymmetry-npc","heading":"The one asymmetry: `npc`","level":2,"text":"`Open77.data.npc` takes different keys on the two sides, and there is no way\naround it.\n\n* **Server**: an **entity template path**, `base\\characters\\...\\npc_base.ent`.\n That is what the generated NPC catalogue is keyed by, and it is what\n `Open77.npcs.create` takes.\n* **Client**: a `Character.*` **TweakDB record**. A template path is not a record\n and cannot be resolved against TweakDB, so the client answers\n `record_wrong_kind` for one.\n\nBoth are documented rather than papered over: a call that cannot be answered says\nwhich kind it wanted.","natives":[],"permissions":[],"events":[]},{"anchor":"failures","heading":"Failures","level":2,"text":"`nil, reason`, with stable snake_case tokens.\n\n| Reason | Meaning |\n|---|---|\n| `invalid_kind` | not one of the four kinds |\n| `invalid_record` | empty, over 256 characters, or not a string |\n| `record_unknown` | the key is well formed and nothing has it |\n| `record_wrong_kind` | *client* — an `Items.*` record passed to `vehicle`, and so on |\n| `tweakdb_unavailable` | *client* — asked before the database is up |\n| `records_unavailable` | *client* — this host has no record backend |\n| `localization_client_only` | *server* — `localize` has nowhere to look |\n| `localization_unavailable` | *client* — the engine declined the key |","natives":[],"permissions":[],"events":[]},{"anchor":"regenerating-the-server-catalogues","heading":"Regenerating the server catalogues","level":2,"text":"```bash\npython scripts/build-data-catalogue.py\n```\n\nIt reads `docs/generated/weapons-2.31.csv`, `clothing-2.31.csv`,\n`npc-templates-2.31.csv` and `docs/vehicle-models.md`, and writes four TSVs into\n`server/src/Open77.Server.Core/Data/`, which the server embeds. The vehicle class\nand manufacturer derivation is *imported* from\n`resources/system/open77_admin/tools/build-catalog.py` rather than reimplemented,\nso the two catalogues cannot drift into two different answers for one record.\n\nRepetitive columns are dictionary-encoded (`@dict` lines at the top of each file),\nwhich is what keeps both the file and the parsed heap proportional to a column's\ndistinct values rather than to its rows: 1 991 item rows share seven item-type\nstrings.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"* [Game data reference](data-reference.md) — the curated record names themselves.\n* [Weapon Lua API](weapons-api.md) — `Open77.weapons`, including the server-side cache.\n* [Vehicle model catalog](../docs/vehicle-models.md) — the full 1 372-record list.","natives":[],"permissions":[],"events":[]}]},{"slug":"data-reference","file":"data-reference.md","title":"Game data reference","summary":"Find identifiers for Open77 APIs: NPCs, vehicles, seats, damage indexes, weapons, appearances, effects, sounds, animations and map sprites. Catalogues target Cyberpunk 2077 **2.31** unless stated otherwise.","sections":[{"anchor":"game-data-reference","heading":"Game data reference","level":1,"text":"Find identifiers for Open77 APIs: NPCs, vehicles, seats, damage indexes, weapons, appearances, effects, sounds, animations and map sprites. Catalogues target Cyberpunk 2077 **2.31** unless stated otherwise.","natives":[],"permissions":[],"events":[]},{"anchor":"support-levels","heading":"Support levels","level":2,"text":"Do not treat every identifier found in the game database as a supported multiplayer asset.\n\n| Level | Meaning |\n|---|---|\n| **Open77-supported** | Exposed by an Open77 runtime catalogue or used by an official resource. Intended for normal resources. |\n| **Runtime-validated** | Resolved against the live 2.31 TweakDB or archives, but still requires an in-game spawn and cleanup test. |\n| **Extracted candidate** | Found in cooked data. Quest logic, missing dependencies, special rigs, or build drift can make it unsafe. |\n\nRecord names and Open77 entity IDs are opaque values. Preserve their spelling and never pass a\n64-bit entity ID through `tonumber`.","natives":[],"permissions":[],"events":[]},{"anchor":"npc-records-and-aliases","heading":"NPC records and aliases","level":2,"text":"`Open77.npcs.create({ record = \"Character.Judy\", x = ..., y = ..., z = ... })` accepts exact\n`Character.*` record IDs without catalogue registration. Every observing client must have the\nrecord and its assets installed. Optional legacy aliases remain available for compatibility:\n\n```lua\nfor _, template in ipairs(Open77.npcs.templates()) do\n print(template.name, template.record, template.observerRecord, template.defaultAppearance)\nend\n```\n\n| Supported alias | Authoritative record | Observer record | Intended use |\n|---|---|---|---|\n| `civilian_female_relaxed_01` | `Character.Panam` | `Character.Panam` | Non-hostile human with locomotion, look-at, workspot, and equipment capabilities. |\n| `hostile_female_ranged_lab` | `Character.cpz_maelstrom_grunt1_ranged1_lexington_wa` | same as authoritative record | Ranged hostile test puppet with the combat capability enabled. |\n\nRaw `.ent` paths and numeric hashes are not accepted. The extracted database is a discovery tool,\nnot an allowlist; custom `Character.*` records work too. See [NPCs](npcs.md) for validation,\nasynchronous spawn failures, appearance/rig caveats and the full API.","natives":["server:Open77.npcs.templates"],"permissions":[],"events":[]},{"anchor":"complete-extracted-npc-database","heading":"Complete extracted NPC database","level":3,"text":"| Dataset | Entries | Contents |\n|---|---:|---|\n| [NPC catalogue overview](../docs/generated/npc-templates-2.31.md) | 6,668 records / 1,524 templates | Categories, risk classes, and the complete template table. |\n| [Character records CSV](../docs/generated/npc-records-2.31.csv) | 6,668 | `Character.*`, TweakDBID, template, appearances, affiliation, equipment, vendor and quest metadata. |\n| [Templates CSV](../docs/generated/npc-templates-2.31.csv) | 1,524 | Records grouped by `.ent`, appearances, categories, gender, and risk. |\n| [Templates JSON](../docs/generated/npc-templates-2.31.json) | 1,524 | Machine-readable version for tooling. |\n| [Entity appearances CSV](../docs/generated/npc-entity-appearances-2.31.csv) | 1,524 | Direct/effective appearances, archive ownership, includes, and `.app` resources. |\n| [Appearance resources CSV](../docs/generated/npc-appearance-resources-2.31.csv) | 9,242 | Appearance resource, archive, appearance name, and parent. |\n\nRisk values in the NPC catalogue are actionable: prefer `candidate`, audit `special_rig` and\n`special_vendor`, and do not promote `unsafe_quest_or_scene`, `restricted_child`, `deny_player`, or\n`missing_template` without dedicated engine and gameplay validation.","natives":[],"permissions":[],"events":[]},{"anchor":"vehicle-models","heading":"Vehicle models","level":2,"text":"Vehicle creation takes a TweakDB record, not an entity template hash:\n\n```lua\nlocal id = Open77.vehicles.create(\n {\n record = \"Vehicle.v_standard2_archer_hella_player\",\n position = { x = 100.0, y = 200.0, z = 30.0 },\n yaw = 90.0\n }\n)\n```\n\nPrefer `*_player` records. The [complete vehicle model catalogue](../docs/vehicle-models.md) contains\n1,372 records with a non-zero `entityTemplatePath`, including **89 player/garage variants**. It also\ndocuments records that resolve structurally but should not be spawned.\n\nFor mounted weapons, see the [armed vehicle spawn catalogue](armed-vehicles.md): tested Basilisk, Outlaw and Shion records, player variants, and the full inventory of records declaring weapon mounts, with validation caveats.\n\nThe official [freeroam](https://github.com/Open2077/open77-resources/tree/main/gamemodes/freeroam) resource provides these convenient command aliases. The record in the right\ncolumn is the portable value to store in another resource or database.\n\n| Alias | Vehicle record | Alias | Vehicle record |\n|---|---|---|---|\n| `hella` | `Vehicle.v_standard2_archer_hella_player` | `bandit` | `Vehicle.v_standard2_archer_bandit_player` |\n| `quartz` | `Vehicle.v_standard2_archer_quartz_player` | `caliburn` | `Vehicle.v_sport1_rayfield_caliburn_player` |\n| `mordred` | `Vehicle.v_sport1_rayfield_caliburn_mordred_player` | `aerondight` | `Vehicle.v_sport1_rayfield_aerondight_player` |\n| `outlaw` | `Vehicle.v_sport1_herrera_outlaw_player` | `turbo` | `Vehicle.v_sport1_quadra_turbo_player` |\n| `type66` | `Vehicle.v_sport2_quadra_type66_player` | `shion` | `Vehicle.v_sport2_mizutani_shion_player` |\n| `porsche` | `Vehicle.v_sport2_porsche_911turbo_player` | `alvarado` | `Vehicle.v_sport2_villefort_alvarado_player` |\n| `deleon` | `Vehicle.v_sport2_villefort_deleon_player` | `cortes` | `Vehicle.v_standard2_villefort_cortes_player` |\n| `colby` | `Vehicle.v_standard2_thorton_colby_player` | `galena` | `Vehicle.v_standard2_thorton_galena_player` |\n| `supron` | `Vehicle.v_standard25_mahir_supron_player` | `maimai` | `Vehicle.v_standard2_makigai_maimai_player` |\n| `hozuki` | `Vehicle.v_standard2_mizutani_hozuki_player` | `thrax` | `Vehicle.v_standard2_chevalier_thrax_player` |\n| `kusanagi` | `Vehicle.v_sportbike1_yaiba_kusanagi_player` | `arch` | `Vehicle.v_sportbike2_arch_player` |\n| `jackie` | `Vehicle.v_sportbike2_arch_jackie_player` | `apollo` | `Vehicle.v_sportbike3_brennan_apollo_player` |","natives":["server:Open77.vehicles.create"],"permissions":[],"events":[]},{"anchor":"vehicle-seats-doors-windows-and-state-bits","heading":"Vehicle seats, doors, windows, and state bits","level":3,"text":"| Seat | Canonical name | FiveM-style index |\n|---|---|---:|\n| Driver | `seat_front_left` / `driver` | `-1` |\n| Front passenger | `seat_front_right` / `frontPassenger` | `0` |\n| Rear left | `seat_back_left` / `rearLeft` | `1` |\n| Rear right | `seat_back_right` / `rearRight` | `2` |\n\n| Index | Door name | Window name |\n|---:|---|---|\n| `0` | `frontLeft` or `front_left` | `frontLeft` |\n| `1` | `frontRight` or `front_right` | `frontRight` |\n| `2` | `backLeft` or `back_left` | `backLeft` |\n| `3` | `backRight` or `back_right` | `backRight` |\n| `4` | `trunk` | — |\n| `5` | `hood` | — |\n\nDoors use a 6-bit mask; windows and broken tires use 4-bit masks. Bit `n` corresponds to index `n`.\nThe authoritative vehicle flags are:\n\n| Flag | Value | Flag | Value |\n|---|---:|---|---:|\n| `engineOn` | `1` | `locked` | `2` |\n| `destroyed` | `4` | `exploded` | `8` |\n| `invulnerable` | `16` | `immortal` | `32` |\n| `lightsOn` | `64` | `highBeams` | `128` |\n| `sirenOn` | `256` | | |\n\nMotion dynamics add `onGround = 1`, `reversing = 2`, and `hornActive = 4`. See\n[Vehicles](vehicles.md) for the authoritative API, damage arrays, glass/light masks, authority, and\nstreaming semantics.","natives":[],"permissions":[],"events":[]},{"anchor":"npc-and-elevator-constants","heading":"NPC and elevator constants","level":2,"text":"| NPC group | Values |\n|---|---|\n| AI mode | `tasks = 0`, `frozen = 1`, `native = 2`; `observer = 3` is protocol-only and rejected for creation/update. |\n| Damage policy | `mortal = 0`, `immortal = 1`, `invulnerable = 2` |\n| State flags | `alive = 1`, `ragdoll = 2`, `despawnWhenUnobserved = 4`, `persistent = 8` |\n| Task channel | `movement = 0`, `look = 1`, `action = 2`, `fullBody = 3` |\n| Task status | `queued = 0`, `suspended = 1`, `executing = 2`, `success = 3`, `failure = 4`, `cancelled = 5`, `interrupted = 6` |\n\nElevator phases are `idle = 0`, `moving = 1`, and `paused = 2`. Elevator state flags are\n`powered = 1`, `locked = 2`, `interactionAllowed = 4`, and `doorsClosed = 8`.","natives":[],"permissions":[],"events":[]},{"anchor":"items-weapons-effects-sounds-and-animations","heading":"Items, weapons, effects, sounds, and animations","level":2,"text":"| Dataset | Entries | Runtime confidence |\n|---|---:|---|\n| [Weapons and weapon items](../docs/generated/weapons-2.31.csv) | 1,925 | Extracted from 2.31 TweakDB; use the `canonical`, `deprecated`, `can_drop`, and `usage` columns to filter. |\n| [Canonical weapon notes](../docs/generated/weapons-2.31-canonical.md) | curated | Recommended records grouped for normal gameplay use. |\n| [VFX assets](../docs/generated/vfx-assets-2.31.csv) | 1,070 | Archive-discovered paths; individual runtime validation is still required. |\n| [SFX event seed](../docs/generated/sfx-events-wolvenkit-seed.csv) | 17,586 | WolvenKit 1.6 seed data; explicitly requires 2.31 runtime validation. |\n| [Animation names](../docs/data/emote-animations.txt) | 23,044 | Extracted animation name candidates. |\n| [Animation sets](../docs/data/emote-animsets.txt) | 4,690 | Animation-set resource paths. |\n| [Blip sprites](blips.md#complete-sprite-list-cyberpunk-2077-231) | complete Open77 list | Validated native sprite names exposed by the blip API. |\n\nFor effects, prefer the runtime catalogue when available:\n\n```lua\nlocal catalog, reason = Open77.vfx.catalog()\nif catalog then\n for alias, path in pairs(catalog) do print(alias, path) end\nend\n```\n\nAn extracted row becoming visible in a CSV does not grant a resource permission or make it safe.\nThe calling resource still needs the relevant manifest permission, and server-owned gameplay state\nmust always be created through the authoritative server API.","natives":["Open77.vfx.catalog"],"permissions":[],"events":[]},{"anchor":"promotion-checklist","heading":"Promotion checklist","level":2,"text":"Before adding an extracted candidate to a production resource:\n\n1. Resolve it on the exact supported game build.\n2. Spawn it through Open77 with one client, then remove it cleanly.\n3. Repeat with two clients and a late joiner in the same routing bucket.\n4. Verify streaming out/in, resource stop, player disconnect, and server restart behavior.\n5. Check animations, collision, audio, damage, and authority transfer where applicable.\n6. Promote it to a small resource-owned allowlist instead of accepting arbitrary client strings.","natives":[],"permissions":[],"events":[]}]},{"slug":"deathmatch","file":"deathmatch.md","title":"Deathmatch — running the Kabuki Arena PvP mode","summary":"Configure and run the Kabuki Arena PvP mode. This guide covers installation, match settings, dependencies and troubleshooting.","sections":[{"anchor":"deathmatch-running-the-kabuki-arena-pvp-mode","heading":"Deathmatch — running the Kabuki Arena PvP mode","level":1,"text":"Configure and run the Kabuki Arena PvP mode. This guide covers installation, match settings, dependencies and troubleshooting.\n\nThe mode is two resources: `open77_deathmatch` (authoritative) and\n`open77_deathmatch_hud` (presentation). The template that installs them is\n`templates/deathmatch`.\n\nThe configuration reference includes options that may not be connected to gameplay in every package. Use `resources/gamemodes/open77_deathmatch/shared/config.lua` as the authoritative configuration schema; do not treat an option's presence as a gameplay guarantee.\n\nSource: [`open77_deathmatch`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_deathmatch), [`open77_deathmatch_hud`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_deathmatch_hud).","natives":[],"permissions":[],"events":[]},{"anchor":"what-players-get","heading":"What players get","level":2,"text":"Two formats with genuinely different lifecycles, which is why the mode does not\ntry to run them through one state machine.\n\n| | Free-for-all | Arena (1v1 · 2v2 · 3v3) |\n|---|---|---|\n| Entry | Instant: hold **E** on the lobby ring and you are alive and armed inside a live instance. | Press **E**, pick a size, queue. Matched when the roster fills. |\n| Waiting | **Never.** | The queue — the only wait in the mode, and the format demands it. |\n| Instance | Persistent, auto-scaled, capacity 12. | Allocated per match, released on the result. |\n| Death | Respawn after a delay, same round. | Eliminated for the round; spectate your team. |\n| Round | Kill limit or timer, then standings, then the next round with the same players. | Team wiped or timer; round point, sides swapped. |\n| Match | Never ends. | Best-of-*N*, then everyone returns to the lobby. |\n| Loadout | One rotating weapon, identical for everyone. | A kit chosen in the buy window; both sides get the same list. |\n\n**Free-for-all never queues.** The only refusal it can produce is\n`instances_full`, and that means *every instance on this server is at capacity* —\na server-full condition, not matchmaking. If players see it, raise\n`ffaCeiling` or accept that the box is full; there is no queue to lengthen.\n\n**Joiners fill before they spread.** A player lands in the fullest instance that\nstill has room, and a new instance opens only when none does. A 2-of-12 instance\nis a dead lobby, and two of them are worse than one busy one.","natives":[],"permissions":[],"events":[]},{"anchor":"running-one","heading":"Running one","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"from-the-setup-wizard","heading":"From the setup wizard","level":3,"text":"Start `Open77.Server` with no `server.jsonc` (or `--setup`) and pick the\n**Open77 Deathmatch** template. It installs the resource set below and proposes\nthe tags `deathmatch`, `pvp`, `ffa`, `arena`, `english`.\n\n`requiresDatabase` is `false`: the mode plays completely without persistence.\nSee [Persistence](#persistence-and-one-warning) before turning the bridge on.","natives":[],"permissions":[],"events":[]},{"anchor":"from-the-tracked-dev-profile","heading":"From the tracked dev profile","level":3,"text":"`server/server.deathmatch-local.jsonc` is a masterless loopback-only profile for\ndevelopment and for the autonomous test loop:\n\n```powershell\ndotnet run --project src/Open77.Server -c Release -- --config server.deathmatch-local.jsonc\n```\n\nThen connect a client to `127.0.0.1:11798` with no master.\n\n**Its ports are shifted by +20 — 11798 game, 11799 download, 11800 Warden — and\nthat is not tidiness.** A workstation running several stacks at once has already\nclaimed 11778/11779/11780, and two stacks on one port do not fail loudly: the\nsecond bind loses, or a client resolves the default port and quietly attaches to\nsomebody else's server. You then debug your gamemode against a process that is\nnot running it. The download cache directory is separated for the same reason —\ntwo servers publishing different resource sets through one cache surfaces as a\nsignature or chunk mismatch on a *client*, nowhere near the server that caused\nit.","natives":[],"permissions":[],"events":[]},{"anchor":"the-resource-set","heading":"The resource set","level":2,"text":"```\nopen77_shell open77_pause open77_watermark open77_chat\nopen77_nameplates open77_notifications open77_death open77_appearance\nopen77_weapons\nopen77_interactions open77_worldui open77_groundcircle open77_zones\nopen77_voice open-voice\nopen77_deathmatch open77_deathmatch_hud\n```\n\nThe four in the middle are the way into the mode, and each one is load-bearing:\n\n| Resource | What breaks without it |\n|---|---|\n| [`open77_worldui`](worldui.md) | No station at all. It owns the ring, the prompt card and the action key. |\n| [`open77_interactions`](interactions.md) | Required by both the gamemode and `open77_worldui`. If missing, startup fails with `missing_dependency:open77_interactions` or a repeated `Automatic resource start failed` message. Add it to the resource set. |\n| [`open77_groundcircle`](https://github.com/Open2077/open77-resources/tree/main/system/open77_groundcircle) | The station works and is invisible. On 2.31 the native 3D interaction ring **does not draw at all**; the ground circle is the entire visual, and it follows real ground per vertex. |\n| [`open77_zones`](zones.md) | No client-side boundary hysteresis. The server still re-derives every containment claim, so this is a smoothness loss rather than an authority one. |\n\nTwo more rules about the set:\n\n- **`freeroam` must never be in it.** Both are gamemodes and both own spawn and\n death; `freeroam`'s `autoRespawn` answers every `kill → respawn` placement this\n mode issues, so players respawn at the freeroam spawn point mid-round. One\n server, one gamemode — the resource list is how that is expressed.\n- **[`open77_npcs`](https://github.com/Open2077/open77-resources/tree/main/system/open77_npcs) is not required for bots.** It is the reference resource for\n the [NPC API](npcs.md); the API itself is a server global available to any\n resource holding the `world.npcs` permission.\n\nEditing the set on a running server: `open77_interactions` carries\n`reload_policy \"reconnect\"`, so a change touching it reaches connected players\nonly when they reconnect.","natives":[],"permissions":[],"events":[]},{"anchor":"buckets-and-instances","heading":"Buckets and instances","level":2,"text":"Every instance owns a routing bucket, which is what keeps two fights from seeing\neach other. Population is disabled and entity lockdown relaxed per bucket.\n\n| Range | Use |\n|---|---|\n| `4100` | Lobby. The one place in the mode with no combat. |\n| `4200 – 4231` | Free-for-all instances (32). |\n| `4240 – 4287` | Arena matches (48). |\n\nAllocation is first-free from the range, with the owner tracked, and the bucket\nis released on reap — two instances can never share one.\n\n**The instance ceiling is a bucket-range choice, not a performance or prop\nlimit.** That is a change from the original design: instances were going to\ncarry a prop perimeter, and props are capped at 256 per resource, which would\nhave capped instances too. The wall turned out to be client-side (below), so the\nquota stopped gating anything.\n\nObjects belonging to a match—props, markers and NPCs—must use that match's routing bucket. This keeps instances using the same world coordinates isolated.\n\n**Reap.** An instance that reaches zero players is not torn down at once: a\nplayer crossing between rounds or a single reconnect would churn the bucket.\nIt lingers for `emptyLingerSeconds`, then releases. `keepOneWarm` holds the last\ninstance open; it is off by default, because the wall build it was meant to\nspare the first joiner no longer exists.","natives":[],"permissions":[],"events":[]},{"anchor":"tunables","heading":"Tunables","level":2,"text":"Declared in `resources/gamemodes/open77_deathmatch/shared/config.lua` and rendered by the\nWarden **Tuning** tab, so retuning a running server never means editing Lua and\nnever means an SSH session. Bounds are enforced on every write — from the panel,\nfrom the console, and from the mode's own code — so a value the panel accepts is\na value the mode can run.\n\n**When a change takes effect.** Every key is declared `apply = \"live\"`, and that\nis not laziness. This mode runs several rounds at once, and `promote()` is\nper-*resource*, not per-match — promoting when instance B opens would move\ninstance A's finish line too. So the kernel **captures** the whole set onto an\ninstance when the instance opens, and every rule inside that instance reads its\nown capture for the instance's whole life. The operator consequence is worth\nknowing before you touch a slider mid-evening: **your change reaches instances\ncreated after it, and never a round already in flight.**","natives":[],"permissions":[],"events":[]},{"anchor":"instances","heading":"Instances","level":3,"text":"| Key | Type | Default | Range | What it does |\n|---|---|---|---|---|\n| `ffaCapacity` | integer | `12` | 2 – 24 | Players per free-for-all instance. Twelve because fourteen surveyed spawn marks leave the headroom the maximin spawn choice needs to mean anything; raise it and spawns start colliding before the marks run out. |\n| `ffaCeiling` | integer | `32` | 1 – 32 | How many FFA instances may run at once, bounded by the bucket range above. Reaching it is what produces `instances_full`. **Lowering it never closes an instance — it refuses the next one.** |\n| `arenaCeiling` | integer | `48` | 1 – 48 | The same ceiling for queued arena matches. |\n| `emptyLingerSeconds` | integer | `60` s | 0 – 600 | How long an emptied instance keeps its bucket before releasing it. Too short and a reconnect churns buckets; too long and idle instances hold the range. |\n| `keepOneWarm` | boolean | `false` | — | Never reap the last FFA instance. **Off by default, and the reason is a measurement:** it was meant to spare the first joiner a wall build, and there is no wall build — the wall is a client clamp, so opening an instance is a table insert and two bucket calls. It survives only because it also keeps that bucket's population and lockdown state settled. |","natives":[],"permissions":[],"events":[]},{"anchor":"free-for-all-round","heading":"Free-for-all round","level":3,"text":"| Key | Type | Default | Range | What it does |\n|---|---|---|---|---|\n| `roundSeconds` | integer | `300` s | 60 – 1800 | Maximum length of one FFA round. Captured when the round starts. |\n| `killLimit` | integer | `25` | 1 – 250 | First player to this many kills ends the round. |\n| `respawnDelayMs` | integer | `3000` ms | 500 – 15000 | Delay between a counted death and the arena respawn. |\n| `standingsSeconds` | integer | `10` s | 3 – 60 | How long the standings are shown between rounds. The instance never ends, so this is a pause, not a result screen. |\n\nThere is deliberately **no minimum-player setting and no join countdown** for\nfree-for-all. A lone player in a fresh instance is playing, not waiting.","natives":[],"permissions":[],"events":[]},{"anchor":"arena","heading":"Arena","level":3,"text":"| Key | Type | Default | Range | What it does |\n|---|---|---|---|---|\n| `arenaBestOf` | integer | `5` | 1 – 9, odd | Round wins needed to take a match. Odd, so there is no draw to explain. |\n| `arenaRoundSeconds` | integer | `120` s | 30 – 600 | Time limit on one elimination round before it is scored on survivors. |\n| `arenaBuySeconds` | integer | `20` s | 5 – 90 | Time to pick a kit before a round. Both teams get the same list. |","natives":[],"permissions":[],"events":[]},{"anchor":"combat","heading":"Combat","level":3,"text":"| Key | Type | Default | Range | What it does |\n|---|---|---|---|---|\n| `spawnProtectionSeconds` | number | `5.0` s | 0 – 15 | Invulnerability after a respawn. Five seconds is long for a twelve-player free-for-all and short for a contested spawn; this is the slider that decides which. |\n| `spawnProtectionBreaksOnFire` | boolean | `true` | — | Drop a player's protection the moment their own outgoing damage is credited. Turning this off re-enables the classic abuse — shooting out of a shield — so it exists to be measured against, not to be flipped. |\n| `boundsBackstopSeconds` | number | `5.0` s | 1 – 30 | How long a player must be held **continuously** outside their zone before the server places them back inside. The wall itself is enforced on the client; this is the authority behind it, and a backstop placement costs 100 points so the wall is never a strategy. |\n\nDamage multipliers are **not** in this list, and that is a platform constraint\nrather than an oversight: combat policy is host-global, so\n`setDamageMultiplier` and friends apply to every instance at once. Per-instance\ndamage tuning is not available, and faking it in the damage arbiter is not\nsomething to do without measuring first.","natives":[],"permissions":[],"events":[]},{"anchor":"bots","heading":"Bots","level":3,"text":"| Key | Type | Default | Range | What it does |\n|---|---|---|---|---|\n| `fillWithBots` | boolean | **`false`** | — | Fill underpopulated free-for-all instances with bots. Disabled by default. |\n| `botFillTarget` | integer | `6` | 0 – 23 | How many bodies — players plus bots — a backfilled instance aims for. Bots give way as humans arrive. |\n| `botDifficulty` | enum | `standard` | `relaxed` · `standard` · `veteran` | Reaction delay, hit chance by range band, burst length and aggression. The first thing anyone says about a new bot is that it is too good or too stupid; this is the answer to both. |","natives":[],"permissions":[],"events":[]},{"anchor":"bots-what-they-are-and-are-not","heading":"Bots — what they are and are not","level":2,"text":"Bots exist because the sizes this mode is built for cannot be tested with real\nclients on one workstation: roughly 2.7 GiB of VRAM per client at the menu\nrising to 4.2 GiB in play, and a fourth client on a 12 GiB card died on its own\nwith 155 MiB free. Six clients for a 3v3 is not happening.\n\nThey **move with the engine's own navigation and shoot on the server**. Native\nattack and combat tasks are not in the stable API, so the gun is a server tick\nthat rolls a hit against range and line of sight and applies damage directly —\nno engine gunplay, nothing experimental.\n\nTwo rules that are not tunable:\n\n- **Bots are labelled**, on the scoreboard and in the kill feed. A player who\n cannot tell whether they lost that duel to a person has been cheated of the\n only thing the mode sells.\n- **Bots never write to the ladder.** A round containing one is scored, shown,\n and then not persisted. Excluded at the write, not filtered at the read.\n\n- **Bots fight with Cyberpunk's own combat AI.** The mode does not pick their\n targets, walk them, or subtract anybody's health on a timer. It spawns a stock\n hostile gang record, stays out of its way, and replicates the result. So a bot\n acquires, takes cover, aims, draws its weapon and shoots — and it knows what it\n can and cannot see, because the engine's raycast is doing that work and not a\n server-side approximation of it.\n\nWhat to expect as an owner:\n\n- **A roster is three gangs, and the rivalry is load-bearing.** Bots alternate\n between Maelstrom, Valentinos and Tyger Claws. That is not decoration: whether\n two bots fight each other is a question the engine's attitude matrix answers\n about their two records, and one faction is one side. A single-gang roster\n gives you bots that ignore each other and only ever attack players.\n- **`civilian_female_relaxed_01` is not on the roster and must not be added.** It\n resolves to `Character.Panam`, tagged `Invulnerable` in TweakDB — quest\n protection that no setting can lift. It used to be half the roster, which is\n the whole of \"I empty a magazine into a bot and nothing happens\".\n- **Bots can act on things the mode did not tell them to.** Native combat is not\n opt-in and cannot be switched off, so a bot may chase a player toward the\n arena edge or take an interest in a passing vanilla NPC. The mode leashes them\n — a nudge back inside first, a placement if that is ignored — but a bot briefly\n out of position is expected behaviour, not a fault.\n- **A bot alone in an instance is frozen, and that is correct.** Native\n navigation needs a client authority lease; with no ready client, tasks suspend\n and resume when one arrives. It is not a fault, and it does not need reporting.\n- **Two players may see the same bot in slightly different places.** Every client\n runs its own copy of a bot's AI, so their paths can diverge. Health cannot: the\n server ledger is the only thing that writes a health number, so a bot dies at\n the same moment for everybody.","natives":[],"permissions":[],"events":[]},{"anchor":"one-map-four-formats","heading":"One map, four formats","level":2,"text":"The map is a set of axis-aligned boxes — not a cylinder, which would either clip\na gallery six metres up or swallow the street outside, and not a polygon prism,\nwhich needs an ordered ring and a winding test and buys nothing at this scale. A\npoint is inside a zone if it is inside any of that zone's boxes: six\ncomparisons, trivially correct, and capturable by a surveyor who walks to one\ncorner and then the opposite one.\n\nCutting volumes is how one surveyed map serves four formats, and it is why a\nsecond map is out of scope rather than merely unbuilt:\n\n| Format | Zone | Volumes | Why |\n|---|---|---|---|\n| Free-for-all | `full` | the whole footprint | Vertical play and four stair chokes are what make the space interesting at 8–12 players. |\n| 3v3 | `full` | the whole footprint | A real site fight with two approaches per team. |\n| 2v2 | `mid` | market and lower | Cutting the gallery removes overwatch and shortens the fight. |\n| 1v1 | `duel` | lower only | A duel wants one room and no third angle. |\n\nUntil the Kabuki survey is captured the mode runs on **provisional** geometry —\nboxes circumscribing the cylinders the previous version already enforced, and\nthe eight walked promenade marks. Every provisional entry says so in the config,\nand the startup log says so once.","natives":[],"permissions":[],"events":[]},{"anchor":"the-wall","heading":"The wall","level":2,"text":"The arena boundary is a **client-side clamp validated on the server**, not a\nbarrier you can build.\n\nThe obvious approach was a ring of solid props, and it was tested. Props **are**\nsolid — a player walked 0.47 m into a barrier ring where the same walk with the\nring cleared covered 7.91 m, a seventeen-fold difference in the same spot. But\n`visible = false` is *accepted* and the prop **renders anyway**, so no\narrangement of props produces an invisible wall. A jersey barrier is also about\na metre tall, which the owner cleared by jumping: a boundary needs height, not\nmerely presence.\n\nSo: the client projects a player who has left every volume back inside, the\nserver keeps its leash as a **backstop** rather than as the mechanism (a clamp\nthat can be disabled is not authority), and the boundary is **drawn**, because\nan unmarked wall reads as a bug. If you want to decorate the line with props,\nthat is supported and purely cosmetic — **do not expect props to stop anyone\ninvisibly, and do not file it as a bug when they do not.** One practical note if\nyou do: [`open77_props`](props.md) is not in this template's set, and a\nserver-created prop is only projected onto clients by that resource's client\nhalf, so props added without it exist on the server and appear to nobody.","natives":[],"permissions":[],"events":[]},{"anchor":"commands-and-access-control","heading":"Commands and access control","level":2,"text":"Available to any player:\n\n| Command | Effect |\n|---|---|\n| `/dm` | Opens the panel. |\n| `/deathmatch.join` | Registers for the next match. |\n| `/deathmatch.leave` | Leaves the queue, the instance or the match. |\n| `/deathmatch.status` | Prints the authoritative phase and population. |\n\nThe station in the world is the intended way in; the commands survive as an\nadmin and power-user fallback.\n\n`/dm.where` is also open to everyone, and **self-only for a player** — it takes\na format, never another player's id, because an id argument would let anyone\nread anyone's position. From the server console, which has no position of its\nown, the id is required instead:\n\n```text\ndm.where [ffa|3v3|2v2|1v1] # in game, about yourself\ndm.where [ffa|3v3|2v2|1v1] # from the console\n```\n\nIt prints what the *server* sees: position, bucket, readiness, life state, the\ncontaining volume, the verdict and the reason. Nearly every confusing failure in\nthis project was answered in one line by a command like this, after being\nguessed at for far longer. It is the first thing to run when a player says the\nmode is broken.\n\nRestricted to ACL principals — grant these from the Warden **Access** page,\nnot by hand-editing `acl.jsonc`:\n\n| Command | Effect |\n|---|---|\n| `/dm.survey start\\|mark\\|box\\|list\\|dump\\|clear` | Map capture: opens a session, appends spawn marks and volume corners, prints the whole set as pasteable Lua. Coordinates are never accepted from a browser payload — every one is read from `Open77.players.position`. Marks are captured **while walking the deck continuously**; nothing is interpolated, offset or averaged, because nearby XY does not imply nearby walkable ground. |\n| `/dm.bot add [n]` · `fill` · `clear` | Bot population inside your instance. *(phase 3)* |\n\n`Open77.players.position` carries **no heading**, so `dm.survey mark` cannot see\nwhich way you were facing: pass the yaw yourself (`dm.survey mark `,\nread from the debug bridge) or the mark records `0.0` and every player placed on\nit faces the same arbitrary direction.\n\nSee [Identity and ACL](server-acl.md) for how principals are authenticated.","natives":[],"permissions":[],"events":[]},{"anchor":"persistence-and-one-warning","heading":"Persistence, and one warning","level":2,"text":"The mode plays completely with `database.enabled: false`, and nothing is gated\nbehind persistence — no unlocks, no power curve. Career stats and per-format ELO\nare additive.\n\nThe database bridge is configured per server, not per resource. Enabling it also activates database-backed behavior in other resources, including character persistence. Check their configuration and join flows together.","natives":[],"permissions":[],"events":[]},{"anchor":"when-something-looks-broken","heading":"When something looks broken","level":2,"text":"| Symptom | Cause |\n|---|---|\n| The log prints `Resource 'open77_interactions' was not found` once a second, forever. | `open77_worldui` is in the set and `open77_interactions` is not. The server does not give up and does not start the set partially. Add it. |\n| The station is not visible, but holding **E** in the right place works. | `open77_groundcircle` is missing. The native 3D ring does not draw on 2.31; the ground circle is the whole visual. |\n| Two stations are close together and neither prompt appears between them. | `open77_worldui` activates at `radius + 0.5 m`. Stations closer than the sum of their activations leave a dead band where neither is live. Move them apart — an overlap is arbitrated, a dead band just looks broken. |\n| A green station shows a cyan card. | The prompt colour defaults to `#00E5FF` regardless of style. Pass `color` explicitly. |\n| The station follows a player into a match and offers to queue from another dimension. | Furniture must be torn down when the player leaves the bucket. `maxDistance` is not the lever; ownership is. |\n| Players respawn somewhere else entirely, mid-round. | `freeroam` is in the resource set. Its `autoRespawn` is answering this mode's placements. |\n| Players see `instances_full`. | Every instance is at capacity. It is a server-full message, not a queue — raise `ffaCeiling` or add capacity. |\n| A player was invulnerable for a few seconds and nobody could tell. | Spawn protection is working. Until the HUD ring lands *(phase 7)* it is invisible, and invisible invulnerability is indistinguishable from a hit-registration bug. |\n| A bot stands still in an empty instance. | Expected. Native navigation needs a client authority lease; tasks resume when a client is present. |\n| A tunable change did nothing to the round being played. | Also expected. Numbers are captured per instance at creation; the change applies to instances opened after it. |","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [The gamemode kernel](gamemode-kernel.md) — modular server services and the\n conventions every mode's state machine implements.\n- [World-anchored POIs](worldui.md) and [Proximity zones](zones.md) — the two\n shared client services this mode's entry station is built from.\n- [NPCs](npcs.md) — the API the bots are built on, including what is not yet in it.\n- [World props](props.md) — models, buckets and ownership, for decorating the\n boundary.\n- [`docs/writing-a-gamemode.md`](../docs/writing-a-gamemode.md) — for owners who want to\n change more than a number.","natives":[],"permissions":[],"events":[]}]},{"slug":"debug-runtime","file":"debug-runtime.md","title":"Privileged in-game Lua laboratory","summary":"The bundled `open77_debug` resource provides ACL-controlled tools for client Lua, native diagnostics and the REDscript polling bridge.","sections":[{"anchor":"privileged-in-game-lua-laboratory","heading":"Privileged in-game Lua laboratory","level":1,"text":"The bundled `open77_debug` resource provides ACL-controlled tools for client Lua, native diagnostics and the REDscript polling bridge.\n\nThis is intentionally not a public gameplay API. It is available only inside the bundled local\n`open77_debug` resource. A downloaded server resource cannot enable it by adding a permission to\nits manifest.\n\nSource: [`open77_debug`](https://github.com/Open2077/open77-resources/tree/main/system/open77_debug).","natives":[],"permissions":[],"events":[]},{"anchor":"admin-command","heading":"Admin command","level":2,"text":"Run this from the in-game Open77 terminal or chat:\n\n```text\nclient.exec \n```\n\nThe command is registered as restricted. The server checks the authenticated caller against ACL\npermission `command.client.exec`, sends the source to the one requested session ID, and correlates\nthe reply with the original admin. The shipped `acl.jsonc` declares **no principal at all**\n(`\"principals\": []`), so nobody has access until one is added; a principal holding\n`\"permissions\": [\"*\"]` does grant it. See [Server ACL](server-acl.md).\n\nExamples:\n\n```text\nclient.exec 1 return 6 * 7\nclient.exec 1 return Open77.character.state()\nclient.exec 1 return Open77.debug.command('vehicle.list')\nclient.exec 1 return Open77.debug.command('vehicle.status 4294967297')\nclient.exec 1 return Open77.debug.command('vehicle.glass 4294967297 windows_front_left')\nclient.exec 1 return Open77.debug.redscript('debug.trace:hello')\n```\n\nUse single quotes inside the Lua expression because the server command tokenizer reconstructs the\nsource from the remaining whitespace-separated arguments.\n\nWhen submitting through the external debug bridge, `server_command_sent` means\nthe request was sent, not that the Lua finished. Read `net.commands 10` for the\ncorrelated `result_request` response. If the Lua queues a resource event, also\ncheck that resource's completion log or state. [Test RTTI in game](/docs/rtti-testing)\nshows this sequence with `agent-play.ps1` and native lifetime counters.","natives":["Open77.character.state","Open77.debug.command","Open77.debug.redscript"],"permissions":[],"events":[]},{"anchor":"position-and-rotation-clipboard-commands","heading":"Position and rotation clipboard commands","level":2,"text":"Any authenticated player can copy their own live transform from chat:\n\n```text\n/pos\n/rot\n```\n\n`/pos` writes a reusable Lua field such as\n`position = { x = -1442.200000, y = 127.400000, z = 18.000000 }`. `/rot` writes the complete\nquaternion plus horizontal yaw. The server command targets the authenticated caller, while the\ndownloaded [`open77_chat`](https://github.com/Open2077/open77-resources/tree/main/system/open77_chat) client reads the transform and writes the clipboard. The server receives a\nbounded success result but never sees prior clipboard contents. The result appears in chat and in a\nmiddle-left WebUI notification.\n\nOrdinary client resources can use the same write-only API after declaring the permission:\n\n```lua\npermission \"clipboard.write\"\n\nlocal ok, reason = Open77.clipboard.setText(\"text copied by my resource\")\n```\n\nSee the [clipboard guide](clipboard.md) for limits, failure reasons, complete output formats, and\nthe client/server routing model.","natives":["Open77.clipboard.setText"],"permissions":[],"events":[]},{"anchor":"client-debug-namespace","heading":"Client debug namespace","level":2,"text":"Only `open77_debug`, on the trusted local host, can successfully call these functions:\n\n```lua\nlocal ok, result = Open77.debug.eval(\"return Open77.camera.view()\", \"camera-probe\")\nlocal ok, result = Open77.debug.command(\"vehicle.list\")\nlocal ok, result = Open77.debug.redscript(\"debug.trace:glass-probe\")\n```\n\n- `eval(source, label?)` compiles a text-only Lua chunk in the existing `open77_debug` VM. Returned\n primitives and serializable tables are converted to bounded text.\n- `command(commandLine)` calls the registered native laboratory table directly on the game thread.\n It does not forward unknown commands to the server. The vehicle laboratory is registered here,\n including `vehicle.glass`, `vehicle.tire`, `vehicle.body`, `vehicle.part`, and repair operations.\n- `redscript(command)` queues an opaque command through `Open77ScriptBridge.reds`. REDscript polls\n and executes it from a genuine script frame. `debug.trace:` is the initial observable probe;\n further `debug.*` handlers can be added on the REDscript side without changing the C++ bridge.","natives":["Open77.debug.eval","Open77.camera.view","Open77.debug.command","Open77.debug.redscript"],"permissions":[],"events":[]},{"anchor":"safety-and-limits","heading":"Safety and limits","level":2,"text":"- ACL authorization happens before Lua source is dispatched.\n- Execution is targeted, never broadcast.\n- Source is limited to 32 KiB, result text to 8 KiB, and replies time out after 10 seconds.\n- The updated client allows 96 MiB of Lua memory per resource, 1,500,000 instructions per resume,\n and a 6 ms frame budget shared by each client host. Older clients retain 32 MiB / 500,000 / 2 ms;\n see [runtime quotas](/docs/resource-runtime#sandbox-and-quotas).\n Infinite loops fail with `Open77 script execution budget exceeded`.\n- The normal sandbox remains intact: no `io`, `os`, `debug`, `package`, `load`, bytecode, filesystem\n escape, raw REDengine pointer, or foreign resource global is exposed.\n- The server-distributed copy of `open77_debug` is excluded from the downloaded client host. Its\n server script still registers `client.exec`; only the trusted bundled client copy evaluates it.\n\nKeep `debug.runtime` out of ordinary resource manifests. If a deployment should not expose this\nlaboratory at all, remove `open77_debug` from the server resource set or remove\n`command.client.exec` from every ACL principal.","natives":[],"permissions":[],"events":[]}]},{"slug":"device-interactions","file":"device-interactions.md","title":"Overriding a vanilla device prompt","summary":"Disable a native device prompt before replacing it with a resource-owned interaction. Supported targets include vending machines, ATMs, terminals, computers and door panels.","sections":[{"anchor":"overriding-a-vanilla-device-prompt","heading":"Overriding a vanilla device prompt","level":1,"text":"Disable a native device prompt before replacing it with a resource-owned interaction. Supported targets include vending machines, ATMs, terminals, computers and door panels.\n\n```lua\npermissions { \"world.devices\" }\n```\n\n```lua\n-- One machine, by the id `Open77.world.nearby` reports.\nlocal machine = Open77.world.nearest(8, \"device\")\nOpen77.world.setDeviceInteractionEnabled(machine.engineEntity, false)\n\n-- Every machine of a kind, which is what a shop actually wants.\nOpen77.world.setDeviceInteractionEnabled(\"VendingMachine\", false)\n\n-- Give it back.\nOpen77.world.clearDeviceInteraction(\"VendingMachine\")\n```\n\nThen draw your own with [contextual interactions](interactions.md) — [`open77_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_interactions) has a\n`class` target that takes the same class name, so the two halves of this row line up by design.","natives":["Open77.world.nearest","Open77.world.setDeviceInteractionEnabled","Open77.world.clearDeviceInteraction"],"permissions":["world.devices"],"events":[]},{"anchor":"interaction-policy","heading":"Interaction policy","level":2,"text":"The API intercepts the native method that builds a device's interaction choices:\n\n```\nScriptableDeviceComponentPS::DetermineInteractionState(\n interactionComponent: gameinteractionsComponent,\n context: script_ref) -> Void\n```\n\nA denied policy publishes an empty choice set through `PushChoicesToInteractionComponent` and rejects use through `InteractiveDevice::OnInteractionUsed`. The policy affects interaction prompts and actions, not the device's power state or quickhacks.","natives":[],"permissions":[],"events":[]},{"anchor":"what-it-is-not","heading":"What it is not","level":2,"text":"| | |\n|---|---|\n| **Not a power switch** | The device's lights, screens, sounds and networked behaviour are untouched. Only the player's prompt and its execution are refused. |\n| **Not a quickhack block** | Quickhacks are not device interactions; they run through the vision-mode controller, which the multiplayer policy already refuses for its own reasons. |\n| **Not authority** | This is presentation policy on one client, like a HUD claim or an input block. Any rule the policy seems to enforce — who may use the ATM, what the machine costs — has to be re-derived by the server handler that receives the intent your own prompt produces. |\n| **Not a security boundary** | If `open77_devices` stops, every server-declared policy lifts at once and every vanilla prompt comes back. That is the correct failure — a device nobody can ever use again is the state this design refuses to allow — but it means you must not build a lock out of it. |","natives":[],"permissions":[],"events":[]},{"anchor":"naming-a-device","heading":"Naming a device","level":2,"text":"`target` is either an engine entity id or a class name.\n\n- **An engine entity id** is what `Open77.world.nearby(radius, \"device\")` reports as\n `engineEntity`. A decimal number, or the `0x` string spelling `Open77.doors` uses — they resolve\n to the same device, so a resource that got its id from one API and one that got it from the other\n cannot end up holding two policies on one machine.\n- **A class name** matches case-insensitively against **both** names the engine has for a device:\n the object class (`VendingMachine`) and the controller persistent state\n (`VendingMachineControllerPS`). The caller reads the first from `world.nearby`; the hook runs on\n the second. A caller should not have to know which, so both are matched.\n\nA class name accepts letters, digits and underscore only. `\"Vending*\"` is refused with\n`invalid_device_target`: a typo must not become a wildcard that disables half the city.\n\nThe second argument is required and must be a boolean. A missing one is refused with\n`invalid_device_state` rather than read as \"disable\" — that is the one mistake that is hard to\nnotice afterwards.","natives":[],"permissions":[],"events":[]},{"anchor":"ownership","heading":"Ownership","level":2,"text":"Per resource, additive in the refusing direction — every active policy must allow an interaction for\nit to happen. The same rule [door interaction policies](doors.md) already use.\n\n- Two resources may refuse the same device. One releasing does not lift the other's.\n- `clearDeviceInteraction(target)` drops **this** resource's policy for that key. It is not the same\n as `setDeviceInteractionEnabled(target, true)`: an explicit allow is still a policy you hold,\n while a cleared key is one you no longer have an opinion about.\n- Clearing something you never set is not an error.\n- **Everything a resource holds is released when it stops**, reloads or crashes. A device left\n un-interactable by a resource that no longer exists would be a vending machine nobody could ever\n use again, which is why that release sits in the host beside the ones for doors, blips, markers,\n props and cameras.\n\n`Open77.world.deviceInteractionEnabled(target)` answers the **effective** verdict across every\nresource, not your own claim. `Open77.world.deviceInteractionPolicies()` lists who holds what, which\nis how an admin resource proves a claim really came back.","natives":[],"permissions":[],"events":[]},{"anchor":"open77deviceused","heading":"`open77:deviceUsed`","level":2,"text":"Every use of a vanilla device prompt reaches every client resource, whether it was allowed or\nrefused:\n\n```lua\nAddEventHandler(\"open77:deviceUsed\", function(engineEntity, className, choice, allowed)\n -- allowed is the string \"true\" or \"false\"\n if className == \"VendingMachine\" and allowed == \"false\" then\n -- our own shop, on a machine whose vanilla prompt we took away\n end\nend)\n```\n\n`choice` is the choice's TweakDB name as the engine spells it, and may be empty.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:deviceUsed"]},{"anchor":"declaring-it-from-the-server","heading":"Declaring it from the server","level":2,"text":"`Open77.world.setDeviceInteractionEnabled` is a **client** capability: the policy has to be held on\nthe machine that renders the prompt. Doing the fan-out from a gamemode's own client script means\nevery gamemode reimplements the same thing and gets late joins wrong, so the bundled\n[`open77_devices`](https://github.com/Open2077/open77-resources/tree/main/system/open77_devices) package owns the declaration, the wire and the client claim:\n\n```lua\n-- server, in your gamemode\nexports.open77_devices:disable(\"VendingMachine\")\nexports.open77_devices:disable(\"VendingMachine\", { bucket = 4 }) -- one world only\nexports.open77_devices:enable(\"VendingMachine\")\nexports.open77_devices:clear() -- drop everything we declared\n```\n\nA late joiner and a player who changes routing bucket are both answered. A declaring resource that\nstops takes its policies with it.\n\nThe package also re-publishes the use report host-wide, so a shop can live entirely in a server\nresource:\n\n```lua\nAddEventHandler(\"open77_devices:used\", function(playerId, engineEntity, className, choice, allowed)\n -- A CLAIM by that client, rate-limited to 10/s, never authority: validate\n -- distance, bucket and eligibility yourself before opening a shop.\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"presentation-limits","heading":"Presentation limits","level":2,"text":"The use policy and prompt rendering are separate. Lua can report the effective policy and rejected actions, but cannot confirm that the native prompt disappeared. Check the intended device classes in your gamemode; enforce gameplay permissions again on the server.","natives":[],"permissions":[],"events":[]},{"anchor":"refusal-reasons","heading":"Refusal reasons","level":2,"text":"| Reason | Meaning |\n|---|---|\n| `permission_denied:world.devices` | The manifest does not declare it. |\n| `invalid_device_target` | Not an engine entity id and not a plain class name. |\n| `invalid_device_state` | The second argument was missing or not a boolean. |\n| `device_policy_limit` | 256 policies for one resource. |\n| `device_backend_unavailable` | No game backend on this host (a test harness, or a server VM). |","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Contextual interactions](interactions.md) — the prompt you put there instead, with the same\n `class` target spelling.\n- [World queries](world-queries.md) — `Open77.world.nearby(radius, \"device\")`, where the ids come\n from.\n- [Networked doors](doors.md) — the same claim shape for doors, which additionally have a server\n authority behind them.","natives":[],"permissions":[],"events":[]}]},{"slug":"doors","file":"doors.md","title":"Networked world doors","summary":"`open77_doors` synchronizes native doors through a server-owned registry. Automatic doors use routing-bucket proximity; elevator landing doors follow the cabin. Native animations, sound and collision are retained.","sections":[{"anchor":"networked-world-doors","heading":"Networked world doors","level":1,"text":"`open77_doors` synchronizes native doors through a server-owned registry. Automatic doors use routing-bucket proximity; elevator landing doors follow the cabin. Native animations, sound and collision are retained.\n\nSource: [`open77_doors`](https://github.com/Open2077/open77-resources/tree/main/system/open77_doors).","natives":[],"permissions":[],"events":[]},{"anchor":"installation","heading":"Installation","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"context-menu-administration","heading":"Context menu administration","level":3,"text":"With [`open77_admin`](https://github.com/Open2077/open77-resources/tree/main/system/open77_admin) and [`open77_contextmenu`](https://github.com/Open2077/open77-resources/tree/main/system/open77_contextmenu), ALT-click a streamed door to copy\nits exact ID, inspect its state, open/close it manually, change its lock/seal,\nor restore automatic proximity opening. The menu framework defines no actions;\nthe admin package supplies them. See [context menu integrations](context-menu.md).\n\nDoor ID/inspector access requires `command.admin.dev.doors.inspect`; controls\nrequire `command.admin.dev.doors.control` (and `command.admin` for the UI).\nThe restricted command is `/admin.dev.doors.control `.\nIts server-only cooperative `open77_doors.adminControl(playerId, id, action)`\nexport accepts **only** `open77_admin`, rechecks the actual operator's ACL,\nand uses the server's routing bucket and 12m distance limit. It does not let a\nclient choose a bucket or claim somebody else's resource-owned door.\nManual opening still honors locks/seals and auto-close; unlock/unseal first when\nneeded. Lift opening remains elevator-controlled and is not offered manually.","natives":[],"permissions":[],"events":[]},{"anchor":"runtime-requirements","heading":"Runtime requirements","level":3,"text":"Requires client and server **2.31.13+op77.58** or later, including the native door projection bridge.\n\nUse a client containing the native door projection bridge and a server supporting\n[server exports](server-exports.md). Add this dependency to your gamemode:\n\n```lua\ndependency 'open77_doors >=1.0.0'\n```\n\nFreeroam includes it. The service depends on [`open77_elevators`](https://github.com/Open2077/open77-resources/tree/main/system/open77_elevators). If your server\nuses an explicit `resources.load` allowlist, include both system resources.\nOther gamemodes can opt in without importing Freeroam.\n\nNo new network protocol version is required. Deploying only Lua is **not** enough\nfor clients which do not have the new projection bridge.","natives":[],"permissions":[],"events":[]},{"anchor":"discovery-and-authority","heading":"Discovery and authority","level":2,"text":"The client periodically discovers real `Door` entities and their live\n`DoorControllerPS` within 80 metres. It proposes an opaque entity ID, position,\ndoor type and optional elevator topology. The server validates player proximity,\nrate and capacity, selects the routing bucket, and owns all subsequent state.\n\n- IDs are **strings** such as `0xFC85EAE29622BAC2`, never numeric Lua values.\n- The same world door in two buckets has two independent states.\n- Discovered doors start closed, unlocked and unsealed. A player's quest/save\n locks are not imported into the multiplayer world.\n- Automatic doors open within 3.5 m of an authorized player, remain open within\n 5 m, and close 1.2 seconds after the last qualifying player leaves.\n- Interactive doors submit a request; they cannot change everyone else's state\n directly. Closing through a player standing within 1.5 m is rejected.\n- Landing doors open only when the linked elevator is powered, idle, at that\n floor and not marked `doorsClosed`. An unresolved link remains closed and\n discovery retries when the elevator registry becomes available.\n- Late streaming applies the current state quietly. Later changes animate;\n an unchanged state does not restart its animation or sound.\n\nDiscovery is not a world attestation: a dedicated server cannot independently\nprove an arbitrary game-record hash. Security-sensitive servers should disable\ndiscovery and register their approved doors from server-owned data. Static\nscenery without a live door controller is not moved or removed. An NPC's local\nproximity does not open a door by itself: a network NPC goes through the\n[NPC passage](#npc-passage) rule instead.\n\nAn interactive door that the player may open starts opening on that player's\nscreen as soon as it is used, before the server accepts the request; a refusal\ncloses it again. This is the `door` family of [prediction](prediction.md).\nAutomatic, lift, locked and sealed doors never predict.\n\nFor player elevator trips use native buttons or the elevator resource's\n`requestCall` / `requestFloor` exports: native arrival confirms completion to\nthe server. Administrative timed `goTo` operations keep their existing deadline\nsemantics; do not configure a deadline shorter than the native travel time.\nDoor synchronization does not repair a quest-specific cabin's authored geometry\nor floor markers.","natives":[],"permissions":[],"events":[]},{"anchor":"calling-the-server-api","heading":"Calling the server API","level":2,"text":"Browsable function reference: [server door exports](https://open2077.net/docs/api/server/resource-open77-doors)\nand [client door exports](https://open2077.net/docs/api/client/resource-open77-doors).\n\nThe service exposes asynchronous **server exports**, not a second mutable\nclient-side `Open77.doors` registry. The native client namespace remains useful\nfor inspecting local entities. Use exports for synchronized state:\n\n```lua\nlocal function doors(method, ...)\n local pending, error = Open77.exports.call('open77_doors', method, ...)\n if not pending then return nil, error end\n return pending:await()\nend\n\nCreateThread(function()\n local id = '0xFC85EAE29622BAC2' -- replace with a captured world door ID\n local door, error = doors('get', id, 0)\n if not door then print(error or 'Door has not been discovered'); return end\n\n -- Claim the discovered door for this resource. Ownership uses the caller\n -- identity supplied by the runtime, never an owner string in the arguments.\n local owned, reason = doors('register', {\n id = id, bucket = 0, position = door.position,\n })\n if not owned then print(reason); return end\n local ok, reason = doors('configure', id, 0, {\n automatic = true, autoClose = true,\n openRadius = 3.5, closeRadius = 5.0, closeDelay = 1.2,\n defaultAccess = false,\n })\n if not ok then print(reason); return end\n -- Example: grant the authenticated server player ID 12 access.\n doors('setAccess', id, 0, 12, true)\nend)\n```\n\nRun awaited calls from a thread, event or command handler, not at file scope.\nDo not forward arbitrary client arguments to privileged server exports.","natives":["Open77.exports.call","server:Open77.exports.call","CreateThread","server:CreateThread"],"permissions":[],"events":[]},{"anchor":"server-exports","heading":"Server exports","level":3,"text":"| Export | Arguments | Result / purpose |\n| --- | --- | --- |\n| `get` | `id, bucket=0` | Door snapshot or nil. |\n| `list` | `bucket=nil, offset=0, limit=16` | `{doors, total, nextOffset?}`; limit 1–16. |\n| `near` | `position, bucket, radius` | `{doors, total, truncated}`; nearest 16, radius ≤120 m. |\n| `register` | `descriptor` | New or claimed door snapshot; fails if another resource owns it. |\n| `configure` | `id, bucket, patch` | Atomic validated update; `true` or `false, reason`. |\n| `setOpen` | `id, bucket, boolean` | Set a non-lift door target state. Disable automatic mode for persistent manual control. |\n| `setLocked` | `id, bucket, boolean` | Lock/unlock; locking closes the door. |\n| `setSealed` | `id, bucket, boolean` | Seal/unseal; sealing closes the door. |\n| `setAutomatic` | `id, bucket, boolean` | Enable/disable server player-proximity opening. |\n| `setAccess` | `id, bucket, playerId, booleanOrNil` | Grant, deny, or remove a player override. |\n| `setActions` | `id, bucket, actions` | Declare which [door actions](#door-actions-force-pay-and-hack) (force, pay, hack) the door accepts; `nil` or `{}` withdraws them all. |\n| `resolveAction` | `ticket, accept, reason?` | Owner verdict on a pending action. `true` means the door opened (charge the player); `false, reason` means nothing opened, including your own refusal, which answers `false, \"refused_by_owner\"`. |\n| `setNpcPassage` | `id, bucket, mode` | Which network NPCs may open the door: `'public'` (default), `'resource'`, `'always'` or `'never'`; `nil` restores `'public'`. See [NPC passage](#npc-passage). |\n| `npcStats` | — | `{accepted, refused = {reason = count}}`: NPC openings admitted and refused since the service started. |\n| `linkElevator` | `id, bucket, elevatorId, floor` | Link a registered landing door to a valid same-bucket lift and zero-based floor. |\n| `remove` | `id, bucket` | Release a door owned by this resource. Discovery may subsequently adopt it again. |\n| `setDiscoveryEnabled` | `boolean` | Stop/start accepting new discovered doors; existing registered doors remain managed. |\n| `changes` | `cursor` | `{cursor, reset, events}`; up to eight events per page. |\n\n`register` descriptors require `id` and `position={x,y,z}` for a new door;\n`bucket` defaults to zero. Optional `automatic`, `autoClose`, `lift`,\n`elevatorId` and `elevatorFloor` describe its initial topology. Claiming an\nexisting door preserves topology and state; configure it separately.\n\n`configure` accepts only `open`, `locked`, `sealed`, `automatic`, `autoClose`,\n`defaultAccess`, `openRadius`, `closeRadius`, `closeDelay` and `npcPassage`\n(the same values as `setNpcPassage`). Radii are 1–12 m,\nclosing radius must not be smaller than opening radius, and delay is 0.2–60 s.\nAn invalid field rejects the entire patch. Locked/sealed doors cannot stay open.\nLift doors reject direct open/automatic control, including from their owner.\n\nAn explicit per-player denial overrides `defaultAccess`. A global lock or seal\nblocks everyone, including granted players. This is a shared physical door:\nonce it opens for an authorized player, another player can follow through it.\nDoor permissions are not an anti-tailgating collision boundary.\n\nOwners are resource name + runtime generation. A restart/stop releases its\ndoors and rules. Player-specific grants are cleared on disconnect. The registry\nis in memory; initialize durable rules from your own resource/database at start.","natives":[],"permissions":[],"events":[]},{"anchor":"observing-server-changes","heading":"Observing server changes","level":3,"text":"`changes(cursor)` offers a bounded data-only feed compatible with isolated\nserver Lua VMs. Events contain `cursor`, `kind` (`changed`, `removed`, `request`,\n`action`, `npc_request`), `door`, `reason` and optional `value`. For `request`,\nreason is the authenticated player ID and value is the requested open boolean;\nfor `action`, reason is the player ID and value is the action record described\nbelow; for `npc_request`, reason is the NPC ID and value is the player whose\nclient simulates it. Read subsequent pages using the returned cursor. When\n`reset` is true, rescan `list` and resume from the returned cursor: older events\nexpired or the service restarted. Door state changes are not published on the\nhost-wide event bus; door actions are (`open77:doors:action`, below).\n\nList pagination is not a transactional snapshot. Rescan if the registry changes\nwhile traversing pages. Pages are bounded to fit the runtime's export budget.","natives":[],"permissions":[],"events":["open77:doors:action"]},{"anchor":"door-actions-force-pay-and-hack","heading":"Door actions: force, pay and hack","level":3,"text":"A locked door, or one closed to a player by `defaultAccess = false`, refuses an\nordinary open request. The game offers three other ways in: **force** (the Body\nand Tech skill checks), **pay** (the paid door prompt) and **hack** (the door\nquickhack and the breach minigame). On a network door each of them is a request\nto the server carrying its kind. **Nothing is allowed by default**: a door\naccepts only the actions its owning resource declared with `setActions`, and\nwithout a declaration the client does not offer them on a locked door. Declare\nthem from the resource that `register`ed the door, and depend on\n`open77_doors >=1.1.0`. Players need a client whose native door bridge carries\naction intents; an older client offers none of these on a locked network door,\nso declaring actions never breaks it.\n\n```lua\nCreateThread(function()\n local id = '0xFC85EAE29622BAC2' -- a door this resource registered\n local ok, reason = doors('setActions', id, 0, {\n force = true, -- accepted by the door service itself\n hack = { approval = 'owner', range = 25 },\n pay = { price = 500 }, -- always approved by the owner\n })\n if not ok then print(reason) end\nend)\n```\n\n| Field | Values | Meaning |\n|---|---|---|\n| `force`, `hack` | `true` or a table | Offer the action. `true` means `{ approval = 'auto' }`. |\n| `pay` | table with `price` | Offer the paid prompt. `price` is an integer 1–1,000,000,000 in your own currency. |\n| `approval` | `'auto'` (force/hack default) or `'owner'` (required for pay) | `auto`: the service applies it after its own checks. `owner`: your resource decides each request. |\n| `keepUnlocked` | boolean, default `false` | `false`: the lock returns when the door next shuts. `true`: the lock stays open, as in single player. |\n| `range` | 1–60 m (hack only, default 30) | Remote reach of a door hack. Force and pay use the 6 m manual range. |\n\nAn invalid field rejects the whole declaration. Lift doors take no actions.\n\n**Checks before an action does anything.** The player is in the door's bucket\nand within range, the door is neither sealed nor a lift door, the action is\ndeclared, and the player is not explicitly denied with `setAccess(..., false)`:\nan action bypasses the lock and `defaultAccess`, never an explicit per-player\ndenial. Requests share the ordinary limit of six per second. An action on a door\nthe player could open anyway is an ordinary opening: it is not charged and never\nreaches the owner.\n\n**What an accepted action does.** It unlocks and opens the door (an automatic\ndoor then follows proximity as usual), and the player holds it open like a\ngranted player for ten seconds. Unless `keepUnlocked` is set, the lock returns\nwhen the door shuts. Another player can still walk through while it is open.\n\n**Owner approval.** An `owner` action creates a *ticket* and publishes one\nhost-wide event; the requesting client gets its answer only when you resolve it:\n\n```lua\nlocal balances = {} -- your own ledger, by player ID\nlocal function isPolice(playerId) return false end -- replace with your job check\n\nAddEventHandler('open77:doors:action', function(a)\n if a.owner ~= GetCurrentResourceName() or a.stage ~= 'requested' then return end\n if a.kind == 'pay' then\n local balance = balances[a.player] or 0\n if balance < a.price then\n doors('resolveAction', a.ticket, false, 'insufficient_funds')\n return\n end\n balances[a.player] = balance - a.price -- reserve first\n if not doors('resolveAction', a.ticket, true) then\n balances[a.player] = balances[a.player] + a.price -- nothing opened: refund\n end\n elseif a.kind == 'force' then\n doors('resolveAction', a.ticket, isPolice(a.player), 'not_police')\n end\nend)\n```\n\n`resolveAction(ticket, true)` re-checks range, seal, declaration and price before\napplying, and answers `true` only if the door actually opened; `false, reason`\nmeans nothing opened, so never keep a charge on `false`. A refusal you pass\n(`accept = false`) answers `false, \"refused_by_owner\"`, never `true`, so an owner\nthat charges on `true` cannot charge a refused player. A ticket answers once,\nonly to the door's owner, and expires after five seconds (`action_timeout`). One\npending action per player, 256 in total. A refusal reason you pass is sent to the\nclient when it matches `[A-Za-z0-9_.:-]{1,48}`, otherwise `action_refused`.\n\nThe event payload is one table: `stage` (`requested`, `accepted`, `refused`),\n`kind`, `player`, `id`, `bucket`, `owner`, and `ticket`/`price` for owner actions,\n`reason` for `accepted` (`auto`/`owner`) and `refused`. `auto` actions publish only\n`accepted`. Any resource can publish an event with this name; that is harmless,\nbecause only a live ticket resolves. The same records appear in `changes` as\n`action` entries.\n\n**What the client does not decide.** The Body/Tech check result, the wallet and\nthe RAM pool are the player's own client state and are not trusted: a passed\nskill check is only a force *request*. On a network door no local cost is\nspent (no eddies for pay, no RAM for the door quickhack), so a refusal costs the\nplayer nothing. Charge your own ledger when you approve. The paid prompt still\ndisplays the price authored on the door's payment record, which is not\nnecessarily your `price`: use the same value or tell the player. The pry-open\nanimation of a Body check is not played; the door's own opening animation\nfollows the server's acceptance.","natives":["CreateThread","server:CreateThread","AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName"],"permissions":[],"events":["open77:doors:action"]},{"anchor":"npc-passage","heading":"NPC passage","level":3,"text":"A network NPC ([`Open77.npcs.create`](npcs.md)) walks through network doors the\nway it does in single player: its own AI asks the door to open when its path\ncrosses it. Exactly one client simulates each network NPC, and only that client\nforwards the request, as a request *for that NPC*. The service then decides with\nits own data, never the requesting player's position or rights:\n\n- the requesting client is the NPC's current simulator, with a live lease and\n the same authority epoch ([`Open77.npcs.presence`](/docs/api/server/open77-npcs#presence)\n answers this; the service declares `world.npcs` and `npcs.foreign` to read it);\n- the NPC is alive, in the door's bucket and within 6 m of it (its canonical\n position, the one the server accepted from the simulator);\n- the door is not a lift door, not sealed, and its NPC passage rule admits it.\n\n| `npcPassage` | Who opens the door |\n|---|---|\n| `'public'` (default) | Any network NPC, on a door that is unlocked and open to everyone (`defaultAccess` true). |\n| `'resource'` | As `public`, and the owning resource's own NPCs also pass its locked or private doors. |\n| `'always'` | Any network NPC, locked or private doors included. |\n| `'never'` | No NPC. |\n\n```lua\nCreateThread(function()\n local hideout, backRoom = '0xFC85EAE29622BAC2', '0x1B2C3D4E5F607182' -- captured door IDs\n local door = doors('get', hideout, 0)\n if not door then return end\n -- The gang's own NPCs walk in and out of the locked hideout; nobody else does.\n doors('register', { id = hideout, bucket = 0, position = door.position })\n doors('configure', hideout, 0, { locked = true, npcPassage = 'resource' })\n -- A shop's back room (already registered by this resource) stays shut to every NPC.\n local ok, reason = doors('setNpcPassage', backRoom, 0, 'never')\n if not ok then print(reason) end\nend)\n```\n\nAn admitted NPC opens the door for everyone, like a player. When a `resource` or\n`always` rule lets an NPC through a locked door, the lock is lifted only until\nthe door shuts. The NPC holds the doorway open while it stands within the door's\n`closeRadius`, for at most ten seconds per request, and a player's close request\nis refused while it is in the threshold (`doorway_occupied`). Once it has walked\nthrough, the door's own timer closes it (`closeDelay` after the last NPC or\nplayer left). A dead NPC, or one that left, holds nothing.\n\nA refused NPC request changes nothing (no reply, no revision), so the door stays\nshut without a flicker. The refusal reason is counted in `npcStats` (`locked`,\n`npc_passage_denied`, ...):\n\n```lua\nRegisterCommand('doornpcstats', function()\n local stats, reason = doors('npcStats')\n if not stats then print(reason); return end\n print('NPC openings admitted: ' .. stats.accepted)\n for why, count in pairs(stats.refused) do print(why, count) end\nend, true)\n```\n\nA locked door also removes the NPC path through it in the engine, so an NPC's AI\nnever walks up to it on its own. A `moveTo` whose destination is behind one is\ntherefore probed first: the NPC walks to the closest reachable point, in front\nof the door, and asks the door to open for it, admitted or refused by the rule\nabove. A `resource` or `always` rule opens it and the walk goes on through;\notherwise the NPC stays there, re-checking once a second, and after about five\nseconds the task fails with reason `path_blocked`. It never passes through a\nclosed door (see [NPC tasks](npcs.md#move-follow-and-patrol)). Requests are\nlimited to two per NPC per second (counted only once the sender proved it\nsimulates that NPC, so nobody can use up another client's budget) and twelve per\nsimulating client per second; at most eight NPCs hold one door at a time.\nAdmitted openings appear in `changes` as `npc_request`.\n\nNPC passage needs `open77_doors >=1.2.0` and players on a client with the\nNPC door bridge. The server advertises the rule in every snapshot\n(`npcPassage`); an older server advertises none, and a newer client then sends no\nNPC request at all. An older client never sends one: its NPCs keep stopping at\nmanaged doors. Vanilla crowd NPCs are not network NPCs and never open a managed\ndoor.","natives":["CreateThread","server:CreateThread","RegisterCommand","server:RegisterCommand"],"permissions":[],"events":[]},{"anchor":"client-exports-and-events","heading":"Client exports and events","level":2,"text":"Call `Open77.exports.call('open77_doors', name, ...)` and await as above:\n\n| Export | Arguments | Result |\n| --- | --- | --- |\n| `get` | `id` | Interested server snapshot or nil; not proof the native object is streamed. |\n| `list` | `offset=0, limit=16` | `{doors, total, nextOffset?}` within this client's interest. |\n| `requestOpen` | `id, boolean` | Queue player intent; server acceptance arrives separately. |\n\nClient events:\n\n```lua\nAddEventHandler('open77:doors:streamedIn', function(id, state) end)\nAddEventHandler('open77:doors:updated', function(id, state) end)\nAddEventHandler('open77:doors:streamedOut', function(id) end)\nRegisterNetEvent('open77:doors:requestResult', function(id, accepted, reason) end)\n```\n\nHere \"streamed\" means server interest, not REDengine object residency. An\ninterested state can arrive before the native door; the service retries after\nthe object streams. Snapshots contain ID, bucket, position, revision, open,\nlocked, sealed, automatic, autoClose, lift, elevatorId and elevatorFloor, plus\n`canOpen` (this player may open it with an ordinary request), `npcPassage` (the\ndoor's [NPC passage](#npc-passage) rule) and, when the owner declared door\nactions this player may attempt, `actions`: `{ force = true, hack = true,\npay = price }` with only the declared keys.\n\nNative `Open77.doors.near(radius)` and `Open77.doors.state(id)` now also expose\n`networkInstance`, `elevatorId` and `elevatorFloor` alongside existing local\nfields. Local state may briefly lag its authoritative target during transport.\nExisting local door mutations return `false, 'server_authority_required'` for\nmanaged doors. Internal projection methods (`setNetworkEnabled`,\n`applyNetworkState`, `forgetNetworkState`, `takeNetworkRequests`,\n`setNetworkPredictionPolicy`, `setNetworkActionPolicy`, `requestNetworkOpen`,\n`resolveNetworkRequest`) are restricted to the `open77_doors` system resource\nand are not gameplay APIs.","natives":["AddEventHandler","server:AddEventHandler","RegisterNetEvent","server:RegisterNetEvent"],"permissions":[],"events":["open77:doors:streamedIn","open77:doors:updated","open77:doors:streamedOut","open77:doors:requestResult"]},{"anchor":"limits-and-diagnostics","heading":"Limits and diagnostics","level":2,"text":"The service bounds the registry to 4096 doors, interest to 256 nearest doors\nper player, discovery to 20 proposals/second/player, and requests to\n6/second/player. Inactive unowned doors expire after ten minutes out of interest.\nState sends retry on queue backpressure; bucket resets precede the new state.\n\nTypical rejections: `too_far`, `access_denied`, `doorway_occupied`,\n`elevator_controlled`, `proximity_controlled`, `not_owner`, `invalid_lift`,\n`invalid_hysteresis`, `topology_conflict`, `registry_full`, `rate_limited`.\nDoor actions add `action_not_allowed`, `invalid_action`, `sealed`,\n`action_pending`, `action_busy`, `action_timeout`, `price_changed`,\n`unknown_ticket`, `unknown_door`, `refused_by_owner` and the owner's own refusal\nreason. NPC requests, counted in `npcStats` rather than answered, add\n`not_npc_authority`, `unknown_npc`, `npc_not_alive`, `wrong_bucket`, `locked`,\n`npc_passage_denied` and `invalid_npc_request`; an invalid rule value is refused\nwith `invalid_npc_passage`.\n\nFor local validation, `server/server.doors-local.jsonc` enables the explicit\n`open77_doors_test` laboratory. It is masterless and loopback-only. Never deploy\nthat profile as a public server configuration.\n\nThe profile uses your local `acl.jsonc`. Grant only the required test commands\nto the test account (`command.doors.test.rule`, and optionally\n`command.elevator.goto` / `command.elevator.list`). Do not publish identities,\nprivate keys or a permissive test ACL with a server release.","natives":[],"permissions":[],"events":[]}]},{"slug":"drive-by","file":"drive-by.md","title":"Vehicle drive-by","summary":"Control handheld combat from a vehicle and read that action from client or server Lua. Passengers use window combat; drivers use the game's native car or motorcycle combat pose. Open77 replicates aim, weapons, shots and reload.","sections":[{"anchor":"vehicle-drive-by","heading":"Vehicle drive-by","level":1,"text":"Control handheld combat from a vehicle and read that action from client or\nserver Lua. Passengers use window combat; drivers use the game's native car\nor motorcycle combat pose. Open77 replicates aim, weapons, shots and reload.\n\nAvailable in **Open77 Unstable `2.31.21-unstable+op77.118`**, protocol **1.43**.\nUse the matching client, server and animation assets. Passenger support first\nshipped in .117 (protocol 1.42); car/motorcycle drivers and the additional state\nfields below require .118. Stable does not include this feature yet.\n\nSupported passenger seats are `front_right`, `back_left` and `back_right`.\nThe driver uses `front_left`, including on motorcycles. Draw a suitable handheld\nweapon using the normal game controls. Enabling drive-by permits that action;\nit does not equip a weapon, start shooting or force an animation. Driver-operated\nmounted weapons have their own [vehicle weapon APIs](vehicle-weapons.md).","natives":[],"permissions":[],"events":[]},{"anchor":"functions-and-permissions","heading":"Functions and permissions","level":2,"text":"All functions belong to `Open77.players`. Use a **network player ID**, not a\nnative entity handle. There are no drive-by global aliases.\n\n| Runtime | Function | Manifest permission | Result |\n|---|---|---|---|\n| Server | `setDriveByEnabled(playerId, enabled)` | `players.driveby` | `true` on acceptance; otherwise `false, reason`. |\n| Server | `getDriveByState(playerId)` | `players.life.read` | State table, or `nil, reason`. |\n| Server | `isDriveByEnabled(playerId)` | `players.life.read` | Policy boolean, or `nil, reason`. |\n| Server | `isInDriveBy(playerId)` | `players.life.read` | Action boolean, or `nil, reason`. |\n| Client | `setLocalDriveByEnabled(enabled)` | `players.driveby` | `true` on acceptance; otherwise `false, reason`. |\n| Client | `getDriveByState(playerId?)` | `players.read` | State table, or `nil, reason`. |\n| Client | `isDriveByEnabled(playerId?)` | `players.read` | Policy boolean, or `nil, reason`. |\n| Client | `isInDriveBy(playerId?)` | `players.read` | Action boolean, or `nil, reason`. |\n\nServer reads require a positive connected player ID. On the client, omit the ID\nor pass integer `0` to read the local player. Pass no argument rather than `nil`.\nRemote client reads require a player known to that client.\n\n`enabled` and `active` answer different questions. An enabled player can be on\nfoot; an exiting player can already be disabled. Test a boolean read against\n`nil` to detect an error: a returned `false` is a valid answer.","natives":[],"permissions":[],"events":[]},{"anchor":"server-example-an-admin-policy-command","heading":"Server example: an admin policy command","level":2,"text":"Use server policy for gameplay rules such as a race, a safe zone or a restrained\nplayer. Decide the rule from server-owned state before changing it.\n\nCreate `open77.lua`:\n\n```lua\nresource \"driveby_rules\"\nversion \"1.0.0\"\nauto_start true\n\nserver_script \"server/main.lua\"\npermissions { \"players.driveby\", \"players.life.read\" }\n```\n\nThen `server/main.lua`:\n\n```lua\nRegisterCommand(\"driveby\", function(source, args)\n local playerId = tonumber(args[1])\n local action = args[2]\n if not playerId or playerId <= 0 or playerId % 1 ~= 0\n or (action ~= \"off\" and action ~= \"on\" and action ~= \"status\") then\n print(\"Usage: driveby \")\n return\n end\n\n if action ~= \"status\" then\n local ok, reason = Open77.players.setDriveByEnabled(playerId, action == \"on\")\n if not ok then\n print(\"Cannot change drive-by: \" .. tostring(reason))\n return\n end\n end\n\n local state, reason = Open77.players.getDriveByState(playerId)\n if not state then\n print(\"Cannot read drive-by: \" .. tostring(reason))\n return\n end\n print((\"Player %d: allowed=%s, phase=%s, available=%s\")\n :format(playerId, tostring(state.enabled), state.phase, tostring(state.available)))\nend, true)\n```\n\nRun `driveby 12 off`, `driveby 12 on` or `driveby 12 status` in the dedicated\nserver console, replacing `12` with the player's ID. Players running this\nrestricted command need the ACL right `command.driveby`; see\n[command permissions](server-acl.md). The example prints results to the server log.\n\nFor an existing gamemode, put these calls at the start and end of its activity:\n\n```lua\nlocal function restrictPassenger(playerId)\n local ok, reason = Open77.players.setDriveByEnabled(playerId, false)\n if not ok then print(reason) end\n return ok\nend\n\nlocal function releasePassenger(playerId)\n local ok, reason = Open77.players.setDriveByEnabled(playerId, true)\n if not ok then print(reason) end\n return ok\nend\n```\n\nIf a respawn or revive is pending, the server can return\n`transition_in_progress`. Re-evaluate your rule and retry after that transition\nfinishes.","natives":["RegisterCommand","server:RegisterCommand","server:Open77.players.setDriveByEnabled","Open77.players.getDriveByState","server:Open77.players.getDriveByState"],"permissions":["players.driveby","players.life.read"],"events":[]},{"anchor":"client-example-a-local-restriction","heading":"Client example: a local restriction","level":2,"text":"Use the client setter for a local mode or UI that should temporarily suppress\npassenger combat. The server's policy still applies.\n\nFor this client-only example, create `open77.lua`:\n\n```lua\nresource \"driveby_local_demo\"\nversion \"1.0.0\"\nauto_start true\n\nclient_script \"client/main.lua\"\npermissions { \"players.driveby\", \"players.read\" }\n```\n\nThen `client/main.lua`:\n\n```lua\nRegisterCommand(\"driveby_local\", function(_, args)\n if args[1] ~= \"off\" and args[1] ~= \"on\" then\n print(\"Usage: driveby_local \")\n return\n end\n\n local ok, reason = Open77.players.setLocalDriveByEnabled(args[1] == \"on\")\n if not ok then\n print(\"Cannot change local drive-by: \" .. tostring(reason))\n return\n end\n\n local enabled, readReason = Open77.players.isDriveByEnabled()\n if enabled == nil then\n print(\"Cannot read local policy: \" .. tostring(readReason))\n else\n print(\"Drive-by currently allowed: \" .. tostring(enabled))\n end\nend, false)\n```\n\nRun `driveby_local off`, then `driveby_local on` in the client console.\n`on` releases this resource's restriction; the effective result can remain\n`false` while a server rule or another client resource blocks drive-by.\n\nCall the client setter from a running resource callback or thread. A call made\nat script top level while the resource is still preparing returns\n`false, \"resource_not_running\"`.","natives":["RegisterCommand","server:RegisterCommand","Open77.players.setLocalDriveByEnabled","Open77.players.isDriveByEnabled","server:Open77.players.isDriveByEnabled"],"permissions":["players.driveby","players.read"],"events":[]},{"anchor":"read-the-action-and-its-phases","heading":"Read the action and its phases","level":2,"text":"The usual sequence is `none` → `entering` → `active` → `exiting` → `none`.\n`isInDriveBy` and the state's `active` field include **entering and exiting**.\nUse `state.phase == \"active\"` for the window-combat loop specifically; even that\nphase does not mean the player fired a shot on this frame.\n\nFor example, add this to a client resource with `players.read` to report local\nphase changes without printing every frame:\n\n```lua\nCreateThread(function()\n local previous\n while true do\n local state, reason = Open77.players.getDriveByState()\n local status = state and (state.available and state.phase or \"unavailable\")\n or (\"error: \" .. tostring(reason))\n if status ~= previous then\n print(\"Passenger action: \" .. status)\n previous = status\n end\n Wait(250)\n end\nend)\n```\n\nTo inspect another player, pass their network ID. This helper works on either\nruntime with its corresponding read permission:\n\n```lua\nlocal function describePassenger(playerId)\n local state, reason = Open77.players.getDriveByState(playerId)\n if not state then return nil, reason end\n if not state.available or state.phase ~= \"active\" then return nil end\n\n return (\"Player %d: window combat in vehicle %d, seat %s\")\n :format(playerId, state.vehicle, state.seat)\nend\n```\n\n| State field | Meaning |\n|---|---|\n| `enabled` | Effective policy visible to this runtime. |\n| `available` | Action data is available. Remote client observations also require a fresh, presented occupant body. It does not imply an active action. |\n| `active` | `true` for `entering`, `active` or `exiting`. |\n| `phase` | `none`, `entering`, `active` or `exiting`. |\n| `vehicle` | Canonical Open77 vehicle ID; absent for `none`. |\n| `seat` | `front_left` for a driver; `front_right`, `back_left` or `back_right` for a passenger. Absent for `none`. |\n| `mode` | Protocol 1.43: `passenger`, `car_driver` or `bike_driver`. Interpret only when `active` is true; inactive data defaults to `passenger`. |\n| `driverYaw`, `driverPitch`, `driverRoll` | Protocol 1.43: native driver aim angles in degrees relative to the seated pose; zero for passengers and inactive data. These are animation inputs, not world-space entity rotation. |\n| `sequence` | Action sequence; zero for `none`. |\n| `duration` | Native entry/exit duration in seconds; zero for `none` and `active`. |\n| `elapsed` | Entry/exit progress in seconds, clamped to `duration`; zero outside those transitions. |\n\nThe server validates actions against the player's current seat, life and routing\nbucket. Missing or stale action data produces `phase == \"none\"` with\n`available == false`; it does not prove the player performed an exit animation.\nThese reads are observations, not a history or a per-shot event stream. The\nclient's fresh local `none` state may still have `available == true`.","natives":["CreateThread","server:CreateThread","Open77.players.getDriveByState","server:Open77.players.getDriveByState","Wait","server:Wait"],"permissions":[],"events":[]},{"anchor":"identify-a-driver-or-motorcycle-rider","heading":"Identify a driver or motorcycle rider","level":3,"text":"The same reads and restrictions cover all three modes. Driver actions have\n`phase == \"active\"` while handheld combat is active, with `duration == 0` and\n`elapsed == 0`; the timed `entering`/`exiting` phases belong to passenger window\ncombat. A driver can use either first- or third-person camera locally; observers\nreceive the native third-person pose.\n\n```lua\n-- Server callback; requires players.life.read.\nRegisterCommand(\"riderstate\", function(playerId)\n local state, reason = Open77.players.getDriveByState(playerId)\n if not state then return print(reason) end\n if not state.available or not state.active then return end\n\n if state.mode == \"bike_driver\" then\n print((\"Rider %d on motorcycle %d: yaw %.1f, pitch %.1f\")\n :format(playerId, state.vehicle, state.driverYaw, state.driverPitch))\n elseif state.mode == \"car_driver\" then\n print(\"Armed driver in vehicle \" .. state.vehicle)\n else\n print(\"Passenger window combat: \" .. state.seat)\n end\nend, true)\n```","natives":["RegisterCommand","server:RegisterCommand","Open77.players.getDriveByState","server:Open77.players.getDriveByState"],"permissions":[],"events":[]},{"anchor":"combining-resources-and-cleanup","heading":"Combining resources and cleanup","level":2,"text":"Each resource owns one restriction per player on its runtime:\n\n- `false` holds that resource's restriction; `true` releases only that hold.\n- Repeated `false` calls do not add a counter. If several features in the same\n resource need a hold, combine their conditions before releasing it.\n- Stopping or reloading a resource releases its own holds. Other owners remain\n effective; disconnect clears the connection's restrictions.\n- Server restrictions survive death, respawn and routing-bucket changes for the\n current connection. A pending revive/respawn can defer resource-stop cleanup.\n- The local client combines its own resource restrictions with server policy.\n Server reads and remote client reads cannot reveal another client's local-only\n restrictions. Use the server setter for an authoritative gameplay rule.\n\nDisabling a passenger already in window combat requests a native exit. The\nstate can remain `exiting` briefly after `enabled` becomes `false`. A successful\nsetter acknowledges the policy change, not immediate animation completion.\nFor a driver, disabling requests the native return to ordinary driving; the\nremote driver action clears when that state ends.","natives":[],"permissions":[],"events":[]},{"anchor":"windows-and-damage","heading":"Windows and damage","level":2,"text":"Open77 opens the passenger's window for replicated drive-by presentation, keeps\nit open through the exit, and restores the authored window state afterward.\nEach passenger has an independent window. Car-driver combat opens both front\nwindows; motorcycles do not receive a window overlay. Your resource does not\nneed to toggle vehicle windows or replay weapon shots to implement drive-by.\n\nThe occupant's ranged shots are filtered against their own vehicle and its\noccupants. The server also rejects ranged hits between occupants of the same\nvehicle. Exterior targets continue through the normal combat rules. Drive-by\ndoes not grant general invulnerability to the vehicle or its passengers.","natives":[],"permissions":[],"events":[]},{"anchor":"failure-handling","heading":"Failure handling","level":2,"text":"| Reason | What to do |\n|---|---|\n| `permission_denied:players.driveby` | Declare the write permission in the resource manifest. |\n| `permission_denied:players.read` / `permission_denied:players.life.read` | Use the read permission for the correct runtime. |\n| `expected_boolean` (client) / `invalid_argument` (server) | Pass a Lua boolean to the setter. |\n| `expected_player_id` (client) | Pass a nonnegative integer; omit the argument or use `0` for self. |\n| `player_unavailable` / `player_not_found` | Recheck that the network player exists on this runtime. |\n| `session_unavailable` (client) | Wait for an active multiplayer session before reading. |\n| `resource_not_running` (client setter) | Call from a running callback/thread, not resource preparation. |\n| `transition_in_progress` (server setter) | Re-evaluate the rule after respawn/revive completes, then retry. |\n| `owner_limit` | Reduce concurrent resource owners; each player supports up to 64 blocking resources per runtime. |\n\nSee the [client players reference](/docs/api/client/open77-players) and\n[server players reference](/docs/api/server/open77-players) for individual\nfunction signatures and examples.","natives":[],"permissions":[],"events":[]}]},{"slug":"effects","file":"effects.md","title":"Visual and audio effects","summary":"Play world VFX, entity effects and spatial audio from client Lua with `world.effects`. Handles belong to the calling resource and are released on stop, reload, world exit or plugin unload.","sections":[{"anchor":"visual-and-audio-effects","heading":"Visual and audio effects","level":1,"text":"Play world VFX, entity effects and spatial audio from client Lua with `world.effects`. Handles belong to the calling resource and are released on stop, reload, world exit or plugin unload.\n\n```lua\npermissions { \"world.effects\" }\n```","natives":[],"permissions":["world.effects"],"events":[]},{"anchor":"two-effect-systems-and-they-are-not-interchangeable","heading":"Two effect systems, and they are not interchangeable","level":2,"text":"This is the single easiest thing to get wrong in this API, so it comes first. Open77 has **two** effect surfaces behind the same `world.effects` permission, and picking the wrong one produces a feature that works perfectly for the person testing it and does not exist for anybody else.\n\n> **A third thing shares the word \"sound\" and is not either of these.** `Open77.sfx` plays the *game's own* Wwise events, by name, out of Cyberpunk's banks. [`open77_sound`](sound.md) plays audio *files a resource ships*, through a browser surface, with real `volume` and `pitch` options — which is exactly why it has them and `Open77.sfx` does not. Reach for `Open77.sfx` when you want the game's vocabulary: a weapon click, a menu blip, an alarm. Reach for `open77_sound` when you want your own audio.\n\n| | Client-local | Replicated |\n|---|---|---|\n| Tables | `Open77.vfx.*`, `Open77.sfx.*` | `Open77.effects.*` |\n| Runtime | `client_script` | `server_script` |\n| Who sees it | **Only the player whose client ran the call.** | Every player in range, in the same bucket. |\n| Authority | The resource on that one machine. | The server. |\n| Owned by | The calling client resource. | The calling server resource. |\n| Use it for | HUD-adjacent feedback, a local preview, an effect only the acting player should perceive. | Anything that is part of the shared world: an explosion, a fire, a burning wreck. |\n\nThe two do not talk to each other. A client-local `Open77.vfx.play` is never replicated, no matter what triggered it, and a server-side `Open77.effects.play` is not a broadcast of a client call — the server decides who receives it and the projection is built independently on each client.\n\nThe rest of this page keeps them apart: sections up to and including [Inspection and cleanup](#inspection-and-cleanup) are the **client-local** API, unchanged; [Replicated effects](#replicated-effects) is the server-authoritative one.\n\n[Screen effects](#screen-effects) is the one surface with a half on each side and belongs to neither column above, because a full-screen post-process belongs to exactly one pair of eyes: the client half is `Open77.vfx.screen`, the server half is `Open77.effects.screen(playerId, ...)`, and neither is replicated to anybody else.\n\nLooping replicated effects live in the same registry as [world props](props.md) — they stream, bucket and expire exactly like a prop, because they are one.","natives":[],"permissions":[],"events":[]},{"anchor":"world-vfx","heading":"World VFX","level":2,"text":"**Client-local.** Seen only by the player whose client ran this call.\n\n```lua\nlocal smoke, reason = Open77.vfx.play(\"smoke.steam\", {\n position = { x = -1440.0, y = 130.0, z = 18.0 },\n orientation = { x = 0.0, y = 0.0, z = 0.0, w = 1.0 },\n ignoreTimeDilation = false,\n duration = 15.0,\n})\n\nassert(smoke, reason)\nassert(Open77.vfx.stop(smoke))\n```\n\nThe first argument accepts a curated alias returned by `Open77.vfx.catalog()` or a cooked `base\\\\...\\\\name.effect`/`dlc\\\\...\\\\name.effect` path. A raw path is advanced and build-dependent: the file being present does not guarantee that the effect is safe, visible, looped, or meaningful outside its original quest/entity context.\n\nThe curated set is **60 aliases in 16 families**, named `family.thing.variant`: `blood.*` (1), `cyber.*` (6 — character status-effect sheets, katana trail and idles, and a generator blast: the closest 2.31 authors to a cyberware look on a body), `electric.*` (7), `explosion.*` (6), `fire.*` (5), `glass.*` (1), `impact.*` (4), `laser.*` (1), `neon.*` (2), `smoke.*` (6), `sparks.*` (4), `steam.*` (2), `vehicle.*` (6), `water.*` (3), `weather.*` (3). `Open77.vfx.catalog()` returns the live list on the **client**; `admin.props.catalog` and the **Props** tab of the admin panel both print the mirror, the panel grouped by family. `fx.catalog` from `open77_effects` answers client-side only. The source of truth is `kVfxCatalog` in `client/src/api/Effects.cpp`, mirrored by name into `resources/system/open77_admin/shared/config.lua`.\n\nTwo quirks worth knowing. `explosion.frag` names the *barrel* blast — the grenade's own blast is authored into the projectile attack rather than a depot effect, so `explosion.grenade` is the separate indicator effect. And `vehicle.skid` and `vehicle.skid.smoke` deliberately share one path, for API compatibility with the original alias.\n\n`duration` is optional: `0` keeps the handle until explicit/resource cleanup; the accepted range is 0–600 seconds. A resource owns at most 192 effects and the client holds at most 512.","natives":["Open77.vfx.play","Open77.vfx.stop"],"permissions":[],"events":[]},{"anchor":"authored-slot-attachment","heading":"Authored slot attachment","level":3,"text":"The client adapter `Open77.vfx.attach(handle, localEntity, slot)` binds a world\neffect to an actual slot on the receiving client's body. It requires\n`world.effects` and ownership of the effect handle. Pass a decimal-string local\nOpen77 entity ID (`\"1\"` for the local player), never a server player ID.\n\n```lua\nlocal attached, reason = Open77.vfx.attach(smoke, localPuppetId, \"RightHand\")\nif not attached then Open77.vfx.stop(smoke) end\n```\n\nAn absent body returns `entity_unavailable`; an unknown effect handle or slot\nreturns `not_found`. The native engine follows the authored slot transform.\nReplacement/removal of that body retires the attached instance. `attach` is\ndistinct from `playEntity`'s `instance` name. This adapter is currently under\nin-game validation; it is not yet the durable server attachment API.","natives":["Open77.vfx.attach","Open77.vfx.stop"],"permissions":[],"events":[]},{"anchor":"entity-authored-vfx","heading":"Entity-authored VFX","level":2,"text":"**Client-local.** Some effects are names authored by an entity template rather than depot paths—weapon muzzle flashes are a common example:\n\n```lua\nlocal flash = Open77.vfx.playEntity(\"muzzle_flash\", {\n entity = remotePuppetId, -- decimal-string Open77 entity id; omitted = local player\n instance = \"shot_42\",\n persistOnDetach = false,\n breakAllLoops = true,\n breakAllOnDestroy = true,\n duration = 0.15,\n})\n```\n\nThe effect name must exist on that entity's template. Open77 queues `entSpawnEffectEvent`; stopping queues `entKillEffectEvent`. An unknown authored name normally produces no visual result rather than a Lua error.\n\n`anchor` defaults to `\"body\"`. Set `anchor=\"weaponRight\"` to resolve that\nbody's current native weapon in `AttachmentSlots.WeaponRight`; no weapon pointer\nor raw native entity ID is exposed. For example:\n\n```lua\nlocal handle, reason = Open77.vfx.playEntity(\"spy_perk_charge\", {\n entity=\"1\", anchor=\"weaponRight\", persistOnDetach=true, duration=8,\n})\n```\n\nThe effect retains weak references to the original body and weapon. Holstering,\nweapon replacement, body replacement, expiration and resource cleanup retire\nthe handle; native stop targets the original weapon, never its replacement.\n`persistOnDetach` does not override this ownership cleanup. Existing quotas and\n`world.effects` permission apply. Blackboard parameters are not exposed yet;\nan accepted event is not proof of visible FPP particles.","natives":["Open77.vfx.playEntity"],"permissions":[],"events":[]},{"anchor":"spatialised-sfx","heading":"Spatialised SFX","level":2,"text":"**Client-local.** Heard only on the client that made the call.\n\n```lua\nlocal sound, reason = Open77.sfx.play(\"event_name_from_catalog\", {\n entity = remotePuppetId, -- emitter entity; omitted = local player\n emitter = \"\", -- optional authored emitter name\n tag = \"my_resource\",\n seekTime = 0.0,\n unique = true,\n duration = 8.0,\n})\n\nassert(sound, reason)\nassert(Open77.sfx.stop(sound))\n```\n\nAudio is attached to an existing entity and therefore follows it in 3D. `stop` queues `SoundStopEvent` for the same event name. Because REDengine stopping is name-based, two simultaneous identical events on the same entity may be stopped together; use `unique = true` where the Wwise event supports it.","natives":["Open77.sfx.play","Open77.sfx.stop"],"permissions":[],"events":[]},{"anchor":"two-dimensional-sfx","heading":"Two-dimensional SFX","level":3,"text":"**Client-local.** A user-interface click has nowhere in the world to come from, so `Open77.sfx.play` — which always wants an entity — is the wrong door for it.\n\n```lua\nlocal ok, reason = Open77.sfx.play2d(\"ui_menu_onpress\")\n```\n\n`play2d` posts the event through `gameGameAudioSystem::Play` with no entity and no emitter, which is the same call vanilla scripts make for menu sounds. It is not spatialised, it is not attached to anything, and it does not follow the player.\n\nIt returns `true` rather than a handle, and there is nothing to stop it with: a frontend one-shot has no emitter to address a stop to. It also consumes no effect quota, precisely because nothing is tracked. `Open77.sfx.clear()` and resource shutdown have nothing to release here.\n\nRefusals: `permission_denied:world.effects`, `invalid_sfx_event`, `effects_backend_unavailable`, plus `world_unavailable`, `native_unavailable` and `invocation_failed` from the facade.","natives":["Open77.sfx.play2d"],"permissions":[],"events":[]},{"anchor":"a-voice-over-line","heading":"A voice-over line","level":3,"text":"**Client-local.** A bark is not a sound-bank event: it is a `voContext` name the engine resolves against the entity's own voiceset — the same word vanilla scripts pass to `GameObject.PlayVoiceOver` (`greeting`, `fear_beg`, `start_combat`). `Open77.sfx.play` cannot play one, and `Open77.sfx.playVoice` plays nothing else.\n\n```lua\n-- The engine's own SoundPlayVo event, queued on the entity.\nlocal ok, reason = Open77.sfx.playVoice(\"greeting\", {\n entity = npcEntity, -- required; a puppet, not the local player's UI\n ignoreFrustum = true, -- default: still speaks when off-screen\n ignoreDistance = false, -- default: the engine's distance cull applies\n})\n```\n\nThis is the executor behind the server's `Open77.npcs.speak` (see [NPC behaviour](npc-behavior.md#speech-one-line-on-demand)): the bundled `open77_effects` resource calls it when the server's one-shot arrives, on every client that has the body streamed, because a voice-over is audible only from a puppet instance and every viewer owns its own. Used directly, it is a purely local line — nobody else hears it.\n\nIt returns `true` rather than a handle; a line cannot be stopped by handle and consumes no quota. Refusals: `permission_denied:world.effects`, `invalid_voice` (not an identifier, or longer than 64), `options_must_be_a_table`, `invalid_entity`, `entity_unavailable` (not streamed on this client), `voice_disabled` (the NPC's replicated policy has its voice off — `Open77.npcs.setVoiceEnabled(id, false)` on the server), plus `world_unavailable`, `native_unavailable` and `invocation_failed` from the facade. **A name the voiceset does not carry is dropped by the engine without a word**, and `true` cannot tell you that.","natives":["Open77.sfx.playVoice"],"permissions":[],"events":[]},{"anchor":"there-is-no-volume-and-no-pitch-and-that-is-the-engine","heading":"There is no volume and no pitch, and that is the engine","level":3,"text":"This gets asked, so here is the measurement rather than an opinion. Neither `play` nor `play2d` accepts a `volume` or a `pitch`, because REDengine's audio surface does not carry either:\n\n- `gameaudioeventsPlaySound` — the event `Open77.sfx.play` posts — has exactly five fields: `soundName`, `emitterName`, `audioTag`, `seekTime`, `playUnique`.\n- `gameGameAudioSystem::Play` — the native `play2d` calls — takes `(CName eventName, EntityID, CName emitterName)`. Three parameters, no floats.\n- No class or event in the whole `game/audio` surface declares a volume or pitch field.\n\nThe one float channel that exists is `gameGameAudioSystem::Parameter(name, value, entity, emitter)`, a **Wwise RTPC**: its valid names are authored inside the sound bank that shipped the event, and they are per-family rather than general. Open77 uses it for vehicle engine curves, where the bank documents the name. There is no `volume` RTPC and no `pitch` RTPC to set.\n\nSo loudness is a property of the Wwise event, chosen when the bank was authored. Pick a quieter event; there is no gain to turn down.","natives":[],"permissions":[],"events":[]},{"anchor":"inspection-and-cleanup","heading":"Inspection and cleanup","level":2,"text":"**Client-local.** This lists what one client is playing for itself; replicated effects are listed with `Open77.effects.all()` on the server.\n\n```lua\nfor _, effect in ipairs(Open77.vfx.list()) do\n print(effect.id, effect.kind, effect.name, effect.entity, effect.remaining)\nend\n\nOpen77.vfx.clear() -- only this resource's VFX\nOpen77.sfx.clear() -- only this resource's SFX\n```\n\nHandles are decimal strings so their full 64-bit identity survives Lua number conversion. A resource cannot stop another resource's handle.","natives":["Open77.vfx.list","Open77.vfx.clear","Open77.sfx.clear"],"permissions":[],"events":[]},{"anchor":"screen-effects","heading":"Screen effects","level":2,"text":"**Client-local**, permission `vfx.screen`. Cyberpunk's own full-screen\npost-process: the drunk wobble, the drugged smear, the burning edges, the\nlow-health pulse, the blackout.\n\n```lua\npermissions { \"vfx.screen\" }\n\nlocal handle = Open77.vfx.screen(\"drunk\", { strength = 0.5, duration = 30 })\n-- ... later ...\nOpen77.vfx.stop(handle)\n```\n\n`vfx.screen` is **not** `world.effects`, and the split is deliberate: putting\nsmoke in a street and covering somebody's eyes are different powers. Two of the\naliases below (`blackout`, `health.critical`) hide most of what the player can\nsee, so an operator has to be able to grant the first without the second.\n\nThe handle is an ordinary effect handle. `Open77.vfx.stop`, `Open77.vfx.list`\nand `Open77.vfx.clear` all work on it, because there is one effect registry on\nthe client rather than two, and one stop path rather than two that can each fail\nto reach a looping overlay.","natives":["Open77.vfx.screen","Open77.vfx.stop"],"permissions":["vfx.screen"],"events":[]},{"anchor":"the-vocabulary","heading":"The vocabulary","level":3,"text":"`Open77.vfx.screenCatalog()` returns `{ [alias] = engineEffectName }`.\n\n| Alias | What it looks like | Loops? |\n|---|---|---|\n| `drunk.light` / `drunk.medium` / `drunk.heavy` | the alcohol wobble, three authored tiers | yes |\n| `drugged.light` / `drugged.medium` / `drugged.heavy` | the drug/poison smear, three tiers | yes |\n| `health.low` / `health.critical` | the vanilla low-health overlay | yes |\n| `pain` | the generic hit flinch | no |\n| `damage.fire` | the burning-damage indicator | no |\n| `damage.emp` | the EMP-damage indicator | no |\n| `burning` | the burning status overlay | yes |\n| `electrocuted` | the electrocution status overlay | yes |\n| `bleeding` | the bleeding status overlay | yes |\n| `blinded` | the flash/blind overlay | yes |\n| `drowning` | the out-of-oxygen overlay | yes |\n| `exhausted` | the stamina-exhaustion overlay | yes |\n| `blink` | eyes closing, once | no |\n| `blackout` | eyes closed and held | yes |\n\n**A looping alias needs a `duration` or a `stop`.** `duration` is in seconds,\n`0..600`; `0` means \"hold it until something ends it\". Resource stop, reload,\nerror, world exit and disconnect all end it, so the worst case is an effect that\nlasts as long as the resource — never one that outlives the session.","natives":[],"permissions":[],"events":[]},{"anchor":"strength-picks-a-tier-not-an-intensity","heading":"`strength` picks a tier, not an intensity","level":3,"text":"```lua\nOpen77.vfx.screen(\"drunk\", { strength = 0.2 }) -- drunk.light\nOpen77.vfx.screen(\"drunk\", { strength = 0.5 }) -- drunk.medium\nOpen77.vfx.screen(\"drunk\") -- drunk.heavy\n```\n\n`strength` is `0..1` and selects among the tiers **the game authored** for\n`drunk`, `drugged` and `health`. Omitting it means `1.0`, which is the heaviest\ntier — \"the effect\". An alias with only one tier ignores it.\n\nThere is no continuous amount, unlike FiveM's `AnimpostfxPlay`. The drunk\noverlay's own level rides a blackboard the player's own script owns\n(`vfx_fullscreen_drunk_level`), and writing another system's blackboard on a\nguess is the class of thing that ends a process. The API promises what is true.","natives":["Open77.vfx.screen"],"permissions":[],"events":[]},{"anchor":"unknown-names-are-refused-never-forwarded","heading":"Unknown names are refused, never forwarded","level":3,"text":"```lua\nOpen77.vfx.screen(\"sandevistan\") --> nil, \"unsupported_screen_effect\"\nOpen77.vfx.screen(\"status_drunk_level_1\") --> nil, \"unsupported_screen_effect\"\n```\n\nSpelling the *engine's* own name is refused too — that would be exactly the way\naround the curated table. The reason the gate is this strict: an authored effect\nname the player entity does not carry is a **silent no-op** in the engine.\nNothing renders and nothing is reported, so a typo that reached the engine would\nbe indistinguishable from a broken feature, and a name that reached a dormant\nchannel has already cost this project a client crash (see the notification row's\n`unsupported_channel`).\n\nEvery alias in the table was read out of the installed game's own shipped data\nwith no client running: the string constants in `r6/cache/final.redscripts`\n(inside the `PlayerPuppet` functions that play them) and in `r6/cache/tweakdb.bin`\n(as the VFX list of a `BaseStatusEffect` record applied to the player). The\nprovenance for each one is in `kScreenFxCatalog` in\n`client/src/api/Effects.cpp`, and the measurements are in\n[`docs/research/vfx-sfx-runtime.md`](../docs/research/vfx-sfx-runtime.md).\n\nNames deliberately **left out** although they exist: `status_braindance`,\n`status_sandstorm`, `status_cement_dust`, `status_smoke_bomb`, `status_berserk`,\n`status_knockdown`, `status_wounded_*` and the `status_*_resistance` family —\nnone has a player binding that could be read offline, and the only way to find\nout is to fire an unknown name into a live client. `johnny_sickness*` is left\nout for the opposite reason: Open77 actively suppresses that pipeline.","natives":["Open77.vfx.screen"],"permissions":[],"events":[]},{"anchor":"from-the-server","heading":"From the server","level":3,"text":"**Replicated? No — and that is the point.** A drunk screen belongs to the person\nwho drank. `Open77.effects.screen` takes a `playerId` rather than a position,\nand reaches that player's client directly, whether or not it is running any\nresource of its own.\n\n```lua\n-- server, permission `players.screenfx`\nOpen77.effects.screen(playerId, \"drunk\", { strength = 0.5, duration = 30 })\nOpen77.effects.screen(playerId, \"blackout\", { duration = 3 })\nOpen77.effects.screen(playerId, false) -- clear every one of them\nOpen77.effects.clearScreen(playerId) -- the same thing, spelled out\nOpen77.effects.screenCatalog() -- the alias list\n```\n\nThe effects the server plays are owned by the **host**, not by any resource on\nthat client, so `Open77.vfx.clear()` in a client resource cannot wipe them by\naccident and no client resource can claim or inherit them. A client that changes\nsession drops them; nothing on the far side would ever say to.\n\nThe server validates the alias against a mirror of the client table and answers\n`unsupported_screen_effect` at the call that made the mistake. The **client's**\ntable remains the authority: a server newer than a client still gets the\nrefusal from that client, which is exactly right.","natives":["server:Open77.effects.screen","server:Open77.effects.clearScreen","server:Open77.effects.screenCatalog"],"permissions":[],"events":[]},{"anchor":"failure-tokens","heading":"Failure tokens","level":3,"text":"| Token | Meaning |\n|---|---|\n| `permission_denied:vfx.screen` | client: the manifest does not declare it |\n| `permission_denied:players.screenfx` | server: same |\n| `unsupported_screen_effect` | the alias is not in the table |\n| `invalid_screen_effect` | not a string, empty, or longer than 64 bytes |\n| `invalid_strength` | not a finite number in `0..1` |\n| `invalid_duration` | not a finite number in `0..600` |\n| `effects_backend_unavailable` | a host without the native |\n| `quota_exceeded` | 192 effects per resource, 512 per client |\n| `entity_unavailable` | the local body cannot be read right now |\n| `network_unavailable` | server: no route to that player |","natives":[],"permissions":[],"events":[]},{"anchor":"replicated-effects","heading":"Replicated effects","level":2,"text":"Everything above happens on one machine. This section is the other half: effects created by a **server** resource and projected onto every client that should perceive them.\n\n```lua\npermissions { \"world.effects\" }\n```\n\nThe same permission string, declared in a `server_script` resource's manifest, grants `Open77.effects`. A server resource cannot reach `Open77.vfx` or `Open77.sfx` — those exist only in the client runtime — and a client resource cannot reach `Open77.effects`.\n\nServer effects use the authoritative registry's bounds, defaults and quotas below. An accepted call confirms server state, not rendered output. Effect compatibility depends on the target and its installed assets.","natives":[],"permissions":["world.effects"],"events":[]},{"anchor":"one-shot-open77effectsplay","heading":"One-shot: `Open77.effects.play`","level":3,"text":"A one-shot has no registry entry and nothing to stop. The server broadcasts it to the players in range and it plays itself out.\n\n```lua\nlocal ok, reason = Open77.effects.play(\"explosion.frag\", {\n position = { x = -1448.2, y = 96.1, z = 17.5 },\n orientation = { x = 0.0, y = 0.0, z = 0.0, w = 1.0 },\n bucket = 0,\n range = 150.0,\n sound = \"wwise_event_name\",\n})\n```\n\n| Option | Type | Meaning |\n|---|---|---|\n| `position` | `{ x, y, z }` | Where it happens. Required. Any axis past ±1,000,000 is `invalid_position`. |\n| `orientation` | `{ x, y, z, w }` | Quaternion, default identity. It must be roughly unit-length; an all-zero one degenerates the client's transform and is rejected. |\n| `bucket` | integer | Routing bucket, default `0`. Only players in it are candidates. |\n| `range` | number | Metres. Who sees and hears it. Range 1–500, default `150`. |\n| `sound` | string | Optional Wwise event played spatialised at the same point, at most 256 bytes. |\n\nA one-shot never enters the registry: an explosion has no state to reconcile, no revision to bump and nothing to stream back in. `range` is therefore an interest radius, not a volume control — a player outside it is never told the effect happened. Delivery is allowed to be unreliable, because a one-shot that arrives late is worse than one that does not arrive, so never build state on the assumption that every client played it.","natives":["server:Open77.effects.play"],"permissions":[],"events":[]},{"anchor":"explosions-open77effectsexplosion","heading":"Explosions: `Open77.effects.explosion`","level":3,"text":"An explosion is not a fourth effect system. It is the one-shot above, plus the\ndamage authority, plus the vehicle ledger, in one call — so that a bomb does not\nhave to be assembled by hand from three APIs that can disagree about who was\nstanding where.\n\n```lua\npermissions { \"world.effects\", \"world.explosions\", \"world.vehicles\" }\n```\n\n```lua\nlocal blast, reason = Open77.effects.explosion({ x = -1448.2, y = 96.1, z = 17.5 }, {\n radius = 12.0,\n damage = 120.0,\n force = 3.0,\n vfx = \"explosion.frag\",\n sound = nil,\n bucket = 0,\n vehicles = true,\n attacker = source,\n})\n\nassert(blast, reason)\nprint((\"%d hurt, %d wrecked\"):format(#blast.players, #blast.vehicles))\n```\n\n| Option | Type | Meaning |\n|---|---|---|\n| `radius` | number | Metres. Greater than 0 and at most 200. Default `8`. |\n| `damage` | number | Damage **at the centre**, falling off linearly to zero at the rim. `0`–`10000`, default `0`. Anything above zero requires `world.explosions`. |\n| `force` | number | Scales the ragdoll impulse on a **lethal** hit. `0`–`100`, default `1`. |\n| `vfx` | string | Effect alias or depot path, default `explosion.frag`. |\n| `sound` | string | Optional Wwise event, played spatialised at the centre. |\n| `bucket` | integer | Routing bucket, default `0`. An explosion is an event in one bucket. |\n| `vehicles` | boolean | Destroy the cars in the blast. Requires `world.vehicles`, default `false`. |\n| `attacker` | integer | The player credited with the kills. Default `0`, an unattributed death. |\n\nIt answers `{ players = { ids }, vehicles = { ids } }` — exactly who this call hit\nand what it wrecked — or `nil, reason`. There is no effect handle, because a\none-shot enters no registry.","natives":["server:Open77.effects.explosion"],"permissions":["world.effects","world.explosions","world.vehicles"],"events":[]},{"anchor":"three-grants-because-the-three-halves-are-not-equally-dangerous","heading":"Three grants, because the three halves are not equally dangerous","level":4,"text":"| You hold | You can |\n|---|---|\n| `world.effects` | Draw the blast. `damage` must be absent or zero. |\n| `world.effects` + `world.explosions` | Hurt everyone inside the radius. |\n| `world.effects` + `world.vehicles` | Pass `vehicles = true` and destroy the cars in it. |\n\n`world.explosions` is a separate string from `players.stats.apply` on purpose.\nThat one lets a resource hurt a player it has **already named**, one at a time,\nwhich is a relationship the operator can reason about. This one hurts everyone\nstanding in a circle, including players the resource never enumerated and has no\nbusiness knowing about — and an operator reading a manifest should see that\ndifference before installing anything.\n\nThe vehicle half is an explicit `vehicles = true` rather than \"destroy cars if\nthe caller happens to hold `world.vehicles`\", because a blast that silently did\nhalf of what you asked is worse than one that refuses.","natives":[],"permissions":[],"events":[]},{"anchor":"what-it-reuses-and-why-that-matters","heading":"What it reuses, and why that matters","level":4,"text":"Nothing here is a new authority.\n\n- The picture is `EffectAuthorityService.Play`, the same one-shot\n `Open77.effects.play` goes through. The interest radius is twice the blast\n with a floor of 60 m, so somebody outside the damage but within earshot is\n still told it happened.\n- The player damage is the stats authority's scripted-damage funnel, the same\n one `Open77.players.damage` uses, with the attack kind set to `explosion`. So\n god mode, the life-phase interlocks, `Open77.combat.setKindDamageMultiplier(\"explosion\", …)`\n and kill attribution all apply without being restated here. A player the\n authority refuses — protected, downed, mid-respawn — is skipped, never fatal to\n the blast.\n- The vehicle destruction is [`Open77.vehicles.explode`](vehicles.md), unchanged.\n Occupants stay occupants: nobody is ejected, no seat is freed, and the server\n sends no damage to a body inside the car. A vehicle already blown up is quietly\n skipped rather than re-detonated, and an owner report that arrives immediately\n afterwards claiming a pristine car does not undo it, because health merges by\n minimum and the destruction bits by OR.\n\nTwo ways to blow up a car that disagreed about occupants would be a real bug, so\nthere is only one.","natives":[],"permissions":[],"events":[]},{"anchor":"two-limits-worth-knowing-before-you-design-around-them","heading":"Two limits worth knowing before you design around them","level":4,"text":"`force` controls the ragdoll direction when an explosion kills a player. It does not apply knockback to survivors.\n\n**A stale body is not a target.** A player whose last accepted snapshot fails the\nfreshness rule is skipped rather than damaged at a position the server is not\nsure of — otherwise an area weapon kills whoever used to be standing there.","natives":[],"permissions":[],"events":[]},{"anchor":"onexplosion","heading":"`onExplosion`","level":4,"text":"Every explosion publishes one host-wide event. It is a reserved name: a resource\nthat cannot cause an explosion cannot claim one happened.\n\n```lua\nAddEventHandler(\"onExplosion\", function(x, y, z, radius, damage, bucket, by, attacker,\n players, vehicles)\n -- players and vehicles are COUNTS.\nend)\n```\n\nIt carries counts rather than names, and the call's own return value carries the\nids. A listener that is entitled to know which bodies were in the blast can run\nthe same proximity read every resource already has; the event does not hand that\nout to every resource on the server just because it happened to be listening.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"fires-open77effectsfire","heading":"Fires: `Open77.effects.fire`","level":3,"text":"A fire is an explosion that does not finish. FiveM's `StartScriptFire` /\n`RemoveScriptFire`, in one call that lights the flames, streams them to everyone\nwho should see them, and burns whoever stands in them until it goes out.\n\n```lua\npermissions { \"world.effects\", \"world.explosions\", \"world.vehicles\" }\n```\n\n```lua\nlocal fireId, reason = Open77.effects.fire({ x = -1448.2, y = 96.1, z = 17.5 }, {\n radius = 4.0,\n damagePerSecond = 5.0,\n durationMs = 30000,\n vfx = \"fire.medium\",\n bucket = 0,\n vehicles = false,\n attacker = source,\n})\n\nassert(fireId, reason)\n-- … later, or never: `durationMs = 0` burns until somebody puts it out.\nOpen77.effects.removeFire(fireId)\n```\n\n| Option | Type | Meaning |\n|---|---|---|\n| `radius` | number | Metres. Greater than 0 and at most **50**. Default `4`. |\n| `damagePerSecond` | number | Health per second for anyone inside the radius. `0`–`1000`, default `5`. Anything above zero requires `world.explosions`. |\n| `durationMs` | integer | How long it burns. `0` means \"until removed\"; the ceiling is one hour. Default `30000`. |\n| `vfx` | string | Effect alias or depot path, default `fire.medium`. The catalogue ships `fire.tiny`, `fire.small`, `fire.medium`, `fire.large` and `fire.gas`. |\n| `bucket` | integer | Routing bucket, default `0`. A fire burns in one bucket. |\n| `vehicles` | boolean | Burn the cars in it too. Requires `world.vehicles`, default `false`. |\n| `attacker` | integer | The player credited with the kills. Default `0`, an unattributed death. |\n\nIt answers the fire id as a **decimal string**, or `nil, reason`.","natives":["server:Open77.effects.fire","server:Open77.effects.removeFire"],"permissions":["world.effects","world.explosions","world.vehicles"],"events":[]},{"anchor":"the-id-is-the-looping-effects-id-and-that-is-the-whole-design","heading":"The id is the looping effect's id, and that is the whole design","level":4,"text":"A fire is not a fourth registry. It is exactly one entry from the\n[looping-effect registry](#looping-open77effectscreate) — same identity,\nownership, revisioning and streaming, which is why a player who walks away and\ncomes back still finds it burning — plus a timer on the owning resource that\ndamages whoever is standing in it.\n\nSo `fireId` **is** the effect id:\n\n```lua\nlocal fireId = Open77.effects.fire(position, { radius = 4 })\nlocal entry = Open77.effects.get(fireId) -- the flames, as a looping effect\n```\n\n`onEffectCreated` announces it, `Open77.effects.all()` lists it beside every other\nlooping effect, and the bundled `open77_effects` client resource projects it\nwithout knowing that E7 exists. Two ids for one object would have been two things\nthat could disagree about whether it is still there.\n\nThe consequence is worth stating plainly: `Open77.effects.remove(fireId)` is a\nlegal thing for the owning resource to do, and it **puts the fire out**. The next\ntick finds the registry entry gone and stops the burning with the reason\n`effect_removed`, rather than leaving an invisible fire hurting people. Expiry\nworks the other way round — the resource's own deadline is the authority and it\nremoves the entry — which is why the effect carries no TTL of its own. One clock,\none reason, never a race between two of them.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.effects.fire` | `(position, options?)` | Fire id as a decimal string, or `nil, reason`. |\n| `Open77.effects.removeFire` | `(fireId)` | `true`, or `nil, reason` (`not_found`, `owned_by_another_resource`). |\n| `Open77.effects.fires` | `(bucket?)` | This resource's burning fires, ascending by id. |\n\n```lua\nfor _, burning in ipairs(Open77.effects.fires()) do\n print(burning.id, burning.radius, burning.damagePerSecond,\n burning.hurt, -- how many distinct players it has burned\n burning.remainingMs) -- nil for a fire that burns until removed\nend\n```\n\n`fires()` answers **this resource's** fires and nobody else's: they are its to put\nout, and the host-wide `onFire` below is how the rest of the server learns about\nsomebody else's.","natives":["server:Open77.effects.fire","server:Open77.effects.get","server:Open77.effects.fires"],"permissions":[],"events":[]},{"anchor":"damage-is-flat-inside-the-radius-unlike-an-explosion","heading":"Damage is flat inside the radius, unlike an explosion","level":4,"text":"An explosion is a wavefront and [falls off linearly](#explosions-open77effectsexplosion);\na fire is a volume you are inside or outside of. A player at the rim burns exactly\nas fast as one at the centre. A falloff would have made the edge of a fire nearly\nfree, so somebody could stand in the flames taking two per cent damage while the\npicture said otherwise.\n\nThree limits follow from how the tick works, and each of them is a decision rather\nthan an accident:\n\n- **One damage interval is one second**, so `damagePerSecond` is the literal unit.\n- **The clock starts on the first tick the fire is alive for**, not at the call.\n A resource's top-level chunk runs before its VM has ever ticked, so a fire lit\n at load dates itself from the first tick rather than from a clock reading of\n zero — otherwise, on a server that had been up for an hour, it would expire\n the moment it was noticed. Nothing is billed on the tick that arms it.\n- **A billed interval is capped at five seconds.** A server that hitched — a long\n database call, a debugger, a stalled tick — must not settle a minute of burning\n in one instant and kill everybody standing near a campfire.\n- **A stale body is not a target**, the same rule the blast follows: a player whose\n last accepted snapshot fails the freshness test is skipped rather than burned at\n a position the server is not sure of.\n\nDamage goes through the same scripted-damage funnel as everything else, with the\nattack kind `environment` — 2.31's damage vocabulary has no fire kind, and\ninventing one would have meant a wire change for a label. A burned body therefore\nreads as killed by the world. God mode, the life-phase interlocks, the\n`environment` damage multiplier and kill attribution all apply without being\nrestated, and a player the authority refuses is skipped rather than putting the\nfire out for everybody else.","natives":[],"permissions":[],"events":[]},{"anchor":"cars","heading":"Cars","level":4,"text":"`vehicles = true` burns the cars in the radius at the same rate, converted through\none stated rule: `damagePerSecond` is in player health points, whose full bar is\n100, and a car's health is a 0–1 pool. **A fire that takes ten seconds to kill a\nhealthy player takes ten seconds to wreck a healthy car.** A car that reaches zero\nis exploded through [`Open77.vehicles.explode`](vehicles.md) — C12's call, the one\nE6 uses too. There is exactly one way to blow up a car in Open77 and this is not a\nsecond one.\n\nThe gradual damage is the same write `Open77.vehicles.setHealth` performs, and\ncanonical health merges by minimum against an owner report, so a projection that\ntook its snapshot before the burn cannot undo it.","natives":[],"permissions":[],"events":[]},{"anchor":"three-grants-and-deliberately-no-worldfires","heading":"Three grants, and deliberately no `world.fires`","level":4,"text":"| You hold | You can |\n|---|---|\n| `world.effects` | Light flames that are only a picture. `damagePerSecond` must be zero. |\n| `world.effects` + `world.explosions` | Burn everyone inside the radius. |\n| `world.effects` + `world.vehicles` | Pass `vehicles = true` and burn the cars in it. |\n\nThe same three strings as an explosion, and no new one. The capability describes\nthe **reach** — area damage to players the resource never enumerated — and a fire\nreaches exactly as far as a blast repeated once a second. A second string for the\nsame reach would let an operator believe they had withheld something they had\nalready granted.\n\nOne further ceiling: **64 burning fires per resource**. That is much lower than the\nlooping-effect registry's 512, because every fire costs a proximity sweep over\nevery player every second — the effect registry's ceiling bounds memory, this one\nbounds the tick. The reason is `fire_limit`.","natives":[],"permissions":[],"events":[]},{"anchor":"onfire","heading":"`onFire`","level":4,"text":"Every fire publishes one host-wide event when it starts and one when it stops.\nReserved, like `onExplosion`: a resource that cannot light a fire cannot claim one\nis burning.\n\n```lua\nAddEventHandler(\"onFire\", function(fireId, state, reason, x, y, z,\n radius, damagePerSecond, bucket, by, hurt)\n -- state is \"started\" or \"stopped\"\n -- reason is \"started\", \"removed\", \"expired\" or \"effect_removed\"\n -- hurt is a COUNT of distinct players burned, never a roster\nend)\n```\n\n| `reason` | When |\n|---|---|\n| `started` | the fire was lit |\n| `removed` | `Open77.effects.removeFire` |\n| `expired` | `durationMs` elapsed |\n| `effect_removed` | the looping effect was removed out from under it, or released with the resource |\n\nThe FiveM spelling **`fireEvent`** carries the same eleven values and is published\nalongside, the way `playerDropped` accompanies `onPlayerDisconnected`. Both names\nare reserved.\n\nA fire that goes out because its resource stopped publishes nothing: a resource on\nits way out cannot tell other resources anything, which is the same rule\n`onExplosion` follows. The flames still go, and `onEffectRemoved(id,\n\"resource_stopped\", resource)` is what announces that.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"onparticleeffect","heading":"`onParticleEffect`","level":4,"text":"Every **world-positioned** server effect publishes one host-wide event: the\none-shots of `Open77.effects.play`, the registry entries of\n`Open77.effects.create`, and the flames a fire is made of.\n\n```lua\nAddEventHandler(\"onParticleEffect\", function(effect, x, y, z, loop, bucket, by)\n -- loop is false for a one-shot, true for a registry entry or a fire\nend)\n```\n\n`playOn`, `attach` and `sound` raise nothing, and that is deliberate: an\nentity-bound effect has no world position the server knows, because the target owns\nthe transform. Publishing an invented origin would be worse than publishing\nnothing — a listener reading it would believe the server knew where the effect was.\nA resource that wants those reads the entity.\n\nThe FiveM spelling is **`ptFxEvent`**, with the same seven values, and both names\nare reserved. Publication is best-effort: a resource whose effect drew correctly is\nnever told it failed because the host-wide queue was full.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"looping-open77effectscreate","heading":"Looping: `Open77.effects.create`","level":3,"text":"A looping effect is a registry entry. It streams by distance and bucket, it can be patched, and it stops when you say so or when its TTL runs out.\n\n```lua\nlocal fire, reason = Open77.effects.create({\n effect = \"fire.small\",\n position = { x = -1460.2, y = 99.9, z = 14.8 },\n bucket = 0,\n streamingRadius = 90.0,\n ttlMs = 0,\n})\n\nassert(fire, reason)\n\nOpen77.effects.update(fire, { position = { x = -1460.2, y = 99.9, z = 15.2 } })\nOpen77.effects.remove(fire)\n```\n\n| Field | Type | Meaning |\n|---|---|---|\n| `effect` | string | Curated alias, or a raw cooked `.effect` path, at most 256 bytes. Same rules as the client-local API above. |\n| `position` | `{ x, y, z }` | Where it burns. Required. |\n| `orientation` | `{ x, y, z, w }` | Quaternion, default identity, validated as for a one-shot. |\n| `bucket` | integer | Routing bucket, default `0`. |\n| `visible` | boolean | Default `true`. `false` keeps the entry and hides the effect. |\n| `streamingRadius` | number | Metres at which it streams in. Range 10–2000, default `90`. |\n| `streamingHysteresis` | number | Extra metres before it streams out. Range 0 to `streamingRadius`, default `20`. |\n| `ttlMs` | integer | Lifetime in milliseconds. `0` means \"until removed\"; the ceiling is seven days. |\n\nA looping effect is a registry entry **in the shape of** a prop — same identity, ownership, revisioning and streaming semantics, because a fire a player walks away from and back to must still be burning. The rules are therefore written once, in [world props](props.md#streaming), rather than twice.\n\nIt is a **separate registry** with its own ceilings, though, and that is the useful part: at most **2,048 looping effects**, of which one resource may own **512**. Filling the effect registry does not consume the room a prop needs, and vice versa.\n\n`update` is sparse — name only the fields you want changed, and pass `ttlMs = 0` to clear an expiry without recreating the entry. Position, orientation, bucket, visibility and the two streaming distances are all patchable. **The effect name is not**: a different name is a different VFX resource, which is a remove and a create.","natives":["server:Open77.effects.create","server:Open77.effects.update","server:Open77.effects.remove"],"permissions":[],"events":[]},{"anchor":"entity-bound-playon-and-sound","heading":"Entity-bound: `playOn` and `sound`","level":3,"text":"```lua\nlocal target = { kind = \"player\", id = tostring(playerId) }\nOpen77.effects.playOn(target, \"muzzle_flash\", { duration = 0.15 })\nOpen77.effects.playOn(target, \"fire.small\", { duration = 2, slot = \"RightHand\" })\nOpen77.effects.sound(target, \"event_name\", { unique = true })\n```\n\nWithout `slot`, `playOn` plays an **entity-authored** effect: a name the target's template defines. With `slot`, it plays a world-effect alias/depot path and attaches the instance to that actual authored slot. `sound` plays a Wwise event on the resolved entity.\n\nBoth are fire-and-forget and return acceptance, not a visual receipt or durable handle. Typed targets use `kind = \"player\" | \"npc\" | \"vehicle\" | \"prop\"` and that registry's network `id`. A bare ID now explicitly means a **player**; migrate NPC/vehicle callers to typed targets. Local handles and engine EntityIDs are never server targets. Invalid/missing targets are rejected instead of broadcasting to bucket zero. Receivers resolve independently and discard one-shots whose body is not streamed. For `playOn`, `duration` is in seconds, 0–60. Use `attach` below for persistent effects that must resume after streaming.\n\nFor `playOn` and `sound`, the backend captures target bucket/body lifetime at acceptance and drops publication if that binding changes before fanout. When a current cyberware body binding exists, the wire target carries `lifetimeMode=\"cyberware\"` and its incarnation. The receiver resolves immediately, then checks the public incarnation export for that already-present handle, with at most 256 pending checks and a 100 ms deadline. An independent frame task polls these checks, separate from the 125 ms attachment loop and any attachment export waits. It never awaits an unresolved export, waits for a body to stream, or retries a dropped one-shot. A mismatched binding, replaced handle, unavailable export or deadline expires without native playback. Sound action IDs are consumed before this check.\n\nTargets without a current body binding use `lifetimeMode=\"compatibility\"`. Server token checks still apply, but a delayed delivery cannot be checked against the receiver's exact body incarnation. Cached bindings and handles do not uniquely identify a replaced native body, especially the local handle `1`.\n\nThe client helper `Open77.vfx.resolveTarget({kind=\"player\", id=\"2\"})` returns the current receiver-local handle as a decimal string, or `nil, reason`. It requires `world.effects`, distinguishes self from remote players, and does not fall through between registries. Resolve immediately before use; do not persist the returned handle.\n\n`sound` also accepts `{ actionId = \"swing:42:impact\", excludePlayers = { attackerId, victimId } }` for optional native/network audio deduplication. Exclude only listeners whose native audio already covers this event. Exclusions are at most 32 positive player IDs (numbers or decimal strings), apply before bucket fanout, and do not change the sound's spatial target. Omitted exclusions send to the ordinary target bucket.\n\nAn optional `actionId` is a nonempty string of at most 192 UTF-8 bytes, scoped to the calling resource and server lifetime. Repeating it within 60 seconds returns acceptance without broadcasting again; it does not refresh expiry, even if other options differ. Use a distinct action ID per intended sound. Server deduplication retains at most 4096 IDs per resource and 8192 overall, refusing new IDs at capacity until expiry. IDs survive resource restarts within this window. Receivers also suppress repeated wire identities for 60 seconds with a 8192-entry bound, consuming identity before resolving the target so an unstreamed one-shot is not replayed later. This is bounded replay suppression, not an exactly-once guarantee across reconnects, client resource reloads or expiry. `unique` still controls the native emitter behavior; it is independent of `actionId`. Existing calls without `actionId` keep their previous behavior and permission (`world.effects`).\n\n`sound` accepts `duration` in seconds (0.05–60, default 5). This bounds the native emitter lease after the one-shot; choose a longer value for longer authored audio. Projection events (`oneshot`, `upsert`, `remove`, `snapshot`) are reserved to the backend and cannot be forged through `TriggerClientEvent` or local `TriggerEvent`.\n\nAn authored name that the target's template does not define normally produces no visual result rather than a failure, exactly as it does client-side.\n\nWorld VFX use aliases/depot paths, entity-authored VFX use template effect names, and SFX use Wwise events. A depot path passed to `playOn` requires a slot; it cannot resolve as an entity-authored name.","natives":["server:Open77.effects.playOn","server:Open77.effects.sound"],"permissions":[],"events":[]},{"anchor":"server-api-reference","heading":"Server API reference","level":3,"text":"Every method requires `world.effects`.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.effects.play` | `(name, opts)` | `boolean, reason?` — one-shot, no handle to keep. |\n| `Open77.effects.explosion` | `(position, options)` | `{ players, vehicles }`, or `nil, reason`. Also needs `world.explosions` to do damage and `world.vehicles` to wreck cars. |\n| `Open77.effects.fire` | `(position, options?)` | Fire id as a decimal string, or `nil, reason`. The id **is** the looping effect's id. Same two extra grants as `explosion`. |\n| `Open77.effects.removeFire` | `(fireId)` | `true`, or `nil, reason`. |\n| `Open77.effects.fires` | `(bucket?)` | This resource's burning fires, ascending by id. |\n| `Open77.effects.create` | `(definition)` | Effect ID as a decimal string, or `nil, reason`. |\n| `Open77.effects.attach` | `(typedTarget, effect, options)` | Owned durable attached-effect ID, or `nil, reason`. |\n| `Open77.effects.update` | `(id, patch)` | `boolean, reason?` |\n| `Open77.effects.remove` | `(id)` | `boolean, reason?` |\n| `Open77.effects.all` | `(bucket?)` | Array of looping-effect snapshots, optionally filtered to one bucket. |\n| `Open77.effects.catalog` | `()` | Curated effect aliases. |\n| `Open77.effects.playOn` | `(entityOrPlayerId, name, opts)` | `boolean, reason?` |\n| `Open77.effects.sound` | `(entityOrPlayerId, event, opts)` | `boolean, reason?` |\n\nIDs are decimal strings for the same reason handles are: their full 64-bit identity has to survive Lua number conversion. No low-level PascalCase aliases are published for `Open77.effects`; the namespaced table is the whole surface.","natives":[],"permissions":[],"events":[]},{"anchor":"server-events","heading":"Server events","level":3,"text":"```lua\nAddEventHandler(\"onEffectCreated\", function(id, resource, effect) end)\nAddEventHandler(\"onEffectRemoved\", function(id, reason, resource) end)\n```\n\nBoth require `world.effects` — the same string reading one costs, because a lifecycle event\nthat announced a effect to a resource that cannot list one would route around that capability.\n`reason` is `removed`, `expired`, `resource_stopped`, or the free text (at most 64\ncharacters) the caller passed to the remove call.\n\nThe same two transitions also reach the generic `onEntityCreated(kind, id, resource)` and\n`onEntityRemoved(kind, id, reason)` with `kind` = `\"effect\"`, for a resource that additionally\ndeclares `world.entities.observe`. The mirror is raised by the same statement, so the two feeds\ncannot disagree; see [entity lifecycle events](server-api.md#entity-lifecycle-events) for the\nauthority rule and for why there is no `Updated` counterpart.\n\nThree more events are host-wide rather than per-registry, and none of them can be\npublished by a resource: [`onExplosion`](#onexplosion), [`onFire`](#onfire) and\n[`onParticleEffect`](#onparticleeffect), each with a FiveM-named twin\n(`explosionEvent`, `fireEvent`, `ptFxEvent`). They carry counts and coordinates\nrather than rosters, so listening to them tells a resource that something\nhappened and where, never who was standing in it.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"failure-reasons","heading":"Failure reasons","level":3,"text":"The vocabulary is shared with [world props](props.md#failure-reasons), with this permission in place of that one.\n\n| Reason | Meaning |\n|---|---|\n| `permission_denied:world.effects` | The manifest does not declare `world.effects`. |\n| `permission_denied:world.explosions` | `explosion` was called with a positive `damage`, or `fire` with a positive `damagePerSecond`, and the manifest does not declare `world.explosions`. |\n| `permission_denied:world.vehicles` | `explosion` or `fire` was called with `vehicles = true` and the manifest does not declare `world.vehicles`. |\n| `invalid_radius` / `invalid_damage` / `invalid_force` | An explosion argument outside its range: radius in (0, 200], damage in [0, 10000], force in [0, 100]. For a fire, radius in (0, 50] and `damagePerSecond` in [0, 1000]. |\n| `invalid_duration` | A fire's `durationMs` is negative or past the one-hour ceiling. `0` is legal and means \"until removed\". |\n| `fire_limit` | This resource already has 64 fires burning. Lower than the effect registry's 512 because each fire costs a per-second proximity sweep. |\n| `quota_exceeded` | The per-resource or global ceiling for registry entries or sound action identities is full. |\n| `target_unavailable` | The typed target has lost the state required to capture its lifetime. |\n| `not_found` | No looping effect with that ID, or it expired or was already removed. |\n| `owned_by_another_resource` | The entry exists but belongs to a different resource. |\n| `invalid_position` | Non-finite coordinates, or an axis past ±1,000,000. |\n| `unknown_alias` | The name is not in `Open77.effects.catalog()` and is not a depot path. |\n| `entity_spawn_failed` | The anchor entity could not be created on the projecting client. |\n| `world_unavailable` | No world is loaded on the projecting client, or it is mid-transition. |\n\nThe registry additionally rejects a non-unit orientation quaternion, a `range` outside 1–500, a `duration` outside 0–60 seconds, a streaming radius outside 10–2000, and a target ID of `0`.\n\nFailures are values: `nil, reason` or `false, reason`, never a raise.","natives":[],"permissions":[],"events":[]},{"anchor":"reference-resource-and-terminal-commands","heading":"Reference resource and terminal commands","level":3,"text":"The bundled [`open77_effects`](https://github.com/Open2077/open77-resources/tree/main/system/open77_effects) resource carries both halves: the client exports documented above, and a server half that owns the admin commands. Type these into the developer terminal opened with `²` in Cyberpunk.\n\n```text\nfx.play explosion.frag -1448.2 96.1 17.5 0\nfx.here fire.small\nfx.loop fire.small -1460.2 99.9 14.8 0\nfx.list 0\nfx.stop 1\nfx.catalog\n```\n\n| Command | Form | Access |\n|---|---|---|\n| `fx.play` | ` [bucket]` — one-shot at world coordinates | restricted — `command.fx.play` |\n| `fx.here` | `` — one-shot at the caller's own position and bucket | restricted — `command.fx.here` |\n| `fx.loop` | ` [bucket]` — register a looping effect | restricted — `command.fx.loop` |\n| `fx.list` | `[bucket]` | public |\n| `fx.stop` | `` — retire a looping effect | restricted — `command.fx.stop` |\n| `fx.catalog` | — the curated alias list | public |\n\n`fx.here` needs an in-game caller with a fresh position snapshot; the dedicated console has no body and the command refuses rather than guessing an origin. The resource owns only what its own commands create — a gameplay resource calling the same API keeps its own effects.\n\nWorld registry creation uses `effect` and `ttlMs`; `playOn` remains a one-shot API even when its native loop flag is true. Use `attach` for an attachment that must cancel, expire and survive stream-out/in.","natives":[],"permissions":[],"events":[]},{"anchor":"durable-attachments","heading":"Durable attachments","level":3,"text":"```lua\nlocal fx, reason = Open77.effects.attach({kind='player', id=tostring(player)},\n 'electric.industrial_arm', {\n slot='RightHand', -- observer's authored body slot\n localAnchor='weaponRight', -- only the target player's own view\n localSlot='right_hand_start', -- verified slot on the held Gorilla weapon\n ttlMs=1500,\n streamingRadius=90,\n streamingHysteresis=20,\n })\n-- Release/rejection/holster policy belongs to the gameplay resource:\nif fx then Open77.effects.remove(fx) end\n```\n\n`world.effects` authorizes creation. `update` and `remove` require the creating resource. `get`/`all` expose the captured `target.kind`, decimal `target.id`, incarnation, slots, `startedAt` and `expiresAt`. Target kinds are `player`, `npc`, `vehicle` and `prop`; IDs never cross registries. The server captures target lifetime and position. Scripts cannot replace the target, move its attachment through `position`/`bucket`, or supply their own incarnation.\n\n`slot` is mandatory. `localAnchor` defaults to `body`; `weaponRight` is available only for player targets. `localSlot` defaults to `slot`. There is no implicit root slot or fallback when the weapon/slot is unavailable. The local native equivalent is `Open77.vfx.attach(handle, localBody, slot, 'body'|'weaponRight')`; omitting its fourth argument preserves body attachment.\n\nFor player targets, optional `localEvent` selects an entity-authored event in the\ntarget player's own view. Other clients retain the world effect and body `slot`:\n\n```lua\nlocal id = Open77.effects.attach({kind='player',id=player}, 'electric.industrial_arm', {\n slot='RightHand', localAnchor='weaponRight', localEvent='spy_perk_charge', ttlMs=2500,\n})\n```\n\n`localEvent` is a nonempty authored name (at most 128 UTF-8 bytes, no controls),\nnot a depot path; it is rejected for non-player targets. The owner projector\nuses native `playEntity` with `breakAllLoops=false`, persistence enabled and the\nremaining server lease. Native weapon/body replacement still cleans it up. No\nworld-effect fallback appears on the owner when the weapon is unavailable.\nTyped incarnation, streaming, ordering, removal and snapshots apply to both\nviews. Local event/anchor selection is immutable for an attachment; remove and\nrecreate it to change these options. Native event stopping is name-based, so\noverlapping identical events on the same native object can stop together;\ncoordinate these instances in server resources. No Gorilla-specific behavior\nis required by this API.\n\nOptional `soundEvent` adds an authored spatial sound to the same attachment lease.\nIt accepts a nonempty event name of at most 128 UTF-8 bytes without controls.\n`soundOnOwner` defaults to `true`; set it to `false` to omit playback on the\ntarget player's own client. Other observers still hear the body-positioned sound.\nThese options are immutable and available through `get`/`all` and snapshots.\n\n```lua\nlocal id = Open77.effects.attach({kind='player',id=player}, 'electric.industrial_arm', {\n slot='RightHand', localAnchor='weaponRight', localEvent='spy_perk_charge',\n soundEvent='w_cyb_strongarms_spy_perk_charge', soundOnOwner=true, ttlMs=2500,\n})\n```\n\nAudio uses the resolved target body, independently of the visual anchor. Its\nnative duration is bounded by the remaining lease, up to 600 seconds. Removal,\nexpiry, stream-out and incarnation loss stop the captured native sound handle;\nstream-in can start it again for the remaining lease. Visual retries do not\nrestart an already playing sound. Native stopping addresses the original body,\nand identical sound events on that body may stop together. Lease extensions can\nrestart audio after its original native deadline, like particles. Authored sound\nevents determine whether emission loops; the lease does not make a one-shot loop.\nServer resources should coordinate overlapping events and measure any duplicate\nwith the owner's existing native audio before choosing owner exclusion.\n\nLeases default to 60 seconds and must be greater than zero and at most 600,000 ms. `update(id,{ttlMs=...})` renews the lease from server time. Target loss, player death/readiness loss, body incarnation change, bucket change, expiry and creating-resource stop retire the record. Client stream-out releases particles while retaining unexpired desired state; stream-in reacquires the current local body. Holstering a weapon releases its native handle, and an unexpired record can project again when the required weapon returns. The gameplay resource must remove charge effects when charging ends.\n\nRenewing a lease currently keeps the graph until its original native deadline; if the server lease extends beyond it, the projector recreates the missing instance. For bounded charge presentation, create once with the remaining maximum hold time plus a small delivery margin, then remove on release. This avoids renewal-induced particle restarts.\n\nThe wire adds epoch, sequence, server time and chunk metadata to the existing projection events. Snapshot assembly preserves newer deltas; tombstones reject late resurrection. Effect IDs stay strings in Lua. Client expiry uses the minimum observed local-minus-server timestamp offset, which includes network latency and is not a precise synchronized clock.\n\nBound characters compare the cyberware projection incarnation through an optional export. Other players use server life/readiness tokens, not native incarnation readback. Native weak-reference checks stop attachments when the body or weapon changes. NPC, vehicle and prop IDs are unique within a server epoch; stream proxies are resolved again after stream-in. FPP weapon anchors and durable attachment recovery remain experimental.","natives":["server:Open77.effects.attach","server:Open77.effects.remove"],"permissions":[],"events":[]},{"anchor":"exhaustive-references","heading":"Exhaustive references","level":2,"text":"For full-screen quest-style fades rather than entity/world VFX, use the\n[native screen transition API](screen-transitions.md).\n\n- [`docs/generated/vfx-assets-2.31.csv`](../docs/generated/vfx-assets-2.31.csv) lists all 1,070 `.effect` paths found in the local 2.31 cooked-archive inventory.\n- [`docs/generated/sfx-events-wolvenkit-seed.csv`](../docs/generated/sfx-events-wolvenkit-seed.csv) lists 17,586 distinct Wwise event names from 17,684 WolvenKit database rows. Its source declares game version 1.6; entries therefore require runtime validation on 2.31.\n\nThese catalogues reference identifiers only. Open77 does not redistribute game assets or audio banks.","natives":[],"permissions":[],"events":[]}]},{"slug":"elevators","file":"elevators.md","title":"Network elevators","summary":"Synchronize elevators through the dedicated server while retaining native platform movement, sounds, collision and floor markers.","sections":[{"anchor":"network-elevators","heading":"Network elevators","level":1,"text":"Synchronize elevators through the dedicated server while retaining native platform movement, sounds, collision and floor markers.\n\nFor synchronized landing doors, enable [networked doors](doors.md). The\n[`open77_doors`](https://github.com/Open2077/open77-resources/tree/main/system/open77_doors) service discovers each landing's native lift/floor relationship\nand projects its server-approved state; it never opens a landing just because a\nplayer is near an empty shaft. Cabin inner-door animation stays with the lift.\n\n> Quest-specific elevators and landing-door variants may require additional configuration. Use the reference package and validate the selected lift before exposing it to players.","natives":[],"permissions":[],"events":[]},{"anchor":"architecture","heading":"Architecture","level":2,"text":"```text\nbutton / Lua request\n |\n v\nserver validation + deadline + revision + bucket\n |\n v reliable ElevatorState\nclient WorldQuery -> streamed LiftDevice -> native MoveTo / Pause / Unpause / TeleportTo\n```\n\nThe server advances an elevator even when no client streams it. A client entering its interest\narea receives the current authoritative phase and remaining time. Old revisions are ignored.\n\nElevators are static world entities and are **adopted**, not spawned. Identity includes the routing\nbucket, so one native `LiftDevice` can be idle at floor 0 in bucket 0 and moving to floor 4 in\nbucket 42 without leaking state between instances.\n\nSpatial interest uses 128 m chunks, streams in at 225 m and streams out at 275 m. The hysteresis\nprevents repeated create/remove traffic near a chunk boundary.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-permissions","heading":"Manifest permissions","level":2,"text":"```lua\npermissions {\n \"world.elevators\", -- server adoption and mutation\n \"elevators.read\", -- client streamed snapshots\n \"elevators.request\" -- client button/call intentions\n}\n```\n\n`world.elevators` is server-only. Client resources cannot call native movement, choose markers,\nset deadlines or report an arrival as canonical.","natives":[],"permissions":["world.elevators","elevators.read","elevators.request"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"adopt-a-native-elevator","heading":"Adopt a native elevator","level":3,"text":"Use the inspector in game to obtain the `LiftDevice` entity hash and its position:\n\n```lua\nlocal id, reason = Open77.elevators.adopt({\n engineEntity = \"0x0123456789ABCDEF\",\n position = { x = -1200.0, y = 450.0, z = 20.0 },\n bucket = 0,\n initialFloor = 0,\n floorCount = 3,\n flags = Open77.elevators.flags.powered\n | Open77.elevators.flags.interactionAllowed,\n})\nassert(id, reason)\n```\n\nREDengine hashes are opaque unsigned 64-bit values. Keep them as hexadecimal strings; never pass\nthem through `tonumber`. The dedicated server does not load REDengine, so type validation happens\nwhen a streamed client resolves the hash. A non-lift hash remains abstract and is never projected.\n\nThe adopting resource owns the elevator until `remove`, resource stop or server shutdown. The same\n`engineEntity` may be adopted once per bucket. Re-adopting the same structural definition from the\nsame resource is idempotent: it returns the existing ID and preserves its phase and deadline, which\nmakes transactional resource reloads safe. A different owner, position or floor count is rejected.","natives":["server:Open77.elevators.adopt"],"permissions":[],"events":[]},{"anchor":"read-snapshots","heading":"Read snapshots","level":3,"text":"```lua\nlocal lift = Open77.elevators.get(id)\nfor _, other in ipairs(Open77.elevators.all(0)) do\n print(other.id, other.engineEntity, other.phase, other.activeFloor)\nend\n```\n\nServer snapshots contain:\n\n- `id`, `resource`, `engineEntity`, `bucket`, `chunkX`, `chunkY`, `floorCount`;\n- `x`, `y`, `z`, `phase`, `activeFloor`, `originFloor`, `targetFloor`;\n- `travelMs`, `pausedRemainingMs`, `flags`, `revision`.\n\nPhases are `idle`, `moving` and `paused`.","natives":["Open77.elevators.get","server:Open77.elevators.get","Open77.elevators.all","server:Open77.elevators.all"],"permissions":[],"events":[]},{"anchor":"move-pause-and-recover","heading":"Move, pause and recover","level":3,"text":"```lua\nOpen77.elevators.goTo(id, 2, { travelMs = 12000 })\nOpen77.elevators.call(id, 1, { travelMs = 8000 })\nOpen77.elevators.pause(id)\nOpen77.elevators.resume(id)\nOpen77.elevators.teleport(id, 0) -- administration/recovery\n```\n\n`goTo` and `call` schedule the same authoritative native trip. `travelMs` is bounded from 100 ms\nto one hour. `teleport` cancels the schedule and aligns all projections to one exact floor marker.","natives":["server:Open77.elevators.goTo","server:Open77.elevators.call","server:Open77.elevators.pause","server:Open77.elevators.resume","server:Open77.elevators.teleport"],"permissions":[],"events":[]},{"anchor":"flags","heading":"Flags","level":3,"text":"```lua\nlocal lift = Open77.elevators.get(id)\nlift.flags = lift.flags | Open77.elevators.flags.locked\nOpen77.elevators.setFlags(id, lift.flags)\n```\n\n| Flag | Meaning |\n|---|---|\n| `powered` | Player requests are accepted when set. |\n| `locked` | Player requests are denied when set. |\n| `interactionAllowed` | Enables bounded button/call requests. |\n| `doorsClosed` | Closes cabin doors; set automatically during normal movement. |\n\nAuthoritative arrival clears `doorsClosed`. More detailed roleplay access policy belongs in the\nowning server resource and its ACL-restricted commands.","natives":["Open77.elevators.get","server:Open77.elevators.get","server:Open77.elevators.setFlags"],"permissions":[],"events":[]},{"anchor":"server-events","heading":"Server events","level":3,"text":"```lua\nAddEventHandler(\"onElevatorStateChanged\", function(\n id, revision, bucket, phase, activeFloor, targetFloor\n) end)\n\nAddEventHandler(\"onElevatorRemoved\", function(id, revision, reason) end)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"The client surface is read-only except for bounded requests:\n\n```lua\nlocal lift = Open77.elevators.get(id)\nlocal streamedStates = Open77.elevators.all()\nlocal closeBy = Open77.elevators.nearby(80.0)\n\nlocal submitted, reason = Open77.elevators.request(id, 1, \"call\")\nlocal submitted, reason = Open77.elevators.request(id, 2, \"goto\")\n```\n\n`nearby(radius)` returns the streamed elevators within `radius` metres of the local player.\nThe radius is clamped to `1..300` and defaults to `100` when omitted.\n\nClient requests accept only `call` and `goto`; `pause` and `resume` are server-Lua-only mutations.\nThe submission result only means that the packet was queued; the later `ElevatorState` is the\nauthority decision. The server validates session, life state,\nbucket, distance (18 m), flags, floor range and current phase.\n\nClient snapshots add `position`, `remainingMs`, `streamed` and `applied`. While a lift is moving,\n`remainingMs` is extrapolated from the most recent authoritative heartbeat, so it decreases between\npackets; it is still corrected by every server resynchronization. The reference resource\npublishes convenience events:\n\n```lua\nAddEventHandler(\"open77:elevator:streamedIn\", function(id, snapshot) end)\nAddEventHandler(\"open77:elevator:updated\", function(id, snapshot) end)\nAddEventHandler(\"open77:elevator:streamedOut\", function(id) end)\n```","natives":["Open77.elevators.get","server:Open77.elevators.get","Open77.elevators.all","server:Open77.elevators.all","Open77.elevators.nearby","Open77.elevators.request","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:elevator:streamedIn","open77:elevator:updated","open77:elevator:streamedOut"]},{"anchor":"reference-resource-and-commands","heading":"Reference resource and commands","level":2,"text":"[`resources/system/open77_elevators`](https://github.com/Open2077/open77-resources/tree/main/system/open77_elevators) is auto-started and is both a working package and an example for other\ndevelopers. Server resources use the native `Open77.elevators` namespace directly; client-side\nconvenience exports are also provided. Its restricted commands use the normal Open77 ACL:\n\n```text\nelevator.list [bucket]\nelevator.adopt [bucket] [initialFloor]\nelevator.adopt.player [initialFloor]\nelevator.goto [travelMs]\nelevator.teleport \nelevator.pause \nelevator.resume \nelevator.power \nelevator.lock \nelevator.remove \n```\n\nThe chat receives descriptions and parameter completion after `chat:ready`. The client developer\nconsole can inspect current projections with:\n\n```text\nresource.emit open77:elevators:probe\nresource.emit open77:elevators:nearby 100\n```\n\n`nearby` lists streamed native `LiftDevice` hashes and exact positions, including unmanaged lifts,\nwhich makes the output directly usable with `elevator.adopt`.","natives":[],"permissions":[],"events":[]},{"anchor":"native-behavior-and-late-join","heading":"Native behavior and late join","level":2,"text":"A trip uses the vanilla `MovingPlatformMovementDynamic` in time mode and the floor's native\n`NodeRef`. This retains physical cabin motion, engine sounds and transport of actors standing in\nthe cabin. It is not a sequence of transform teleports.\n\nFor a late join, Open77 starts the full canonical curve, then uses the native `Pause/Unpause` time\ncursor on the next script frame to seek to elapsed progress. A paused server state remains paused.\nAt authoritative arrival, every client receives `idle` and performs a one-shot exact marker\nalignment to remove residual drift.\n\nIf REDengine unloads and later recreates the `LiftDevice` while the Open77 state remains in range,\nthe new native instance is detected, its topology is re-read, and the current state is projected\nagain even when the server revision did not change. Time spent waiting for native topology is\ndeducted before the movement cursor is positioned.\n\nManaged `LiftControllerPS.OnGoToFloor` and `OnCallElevator` actions are intercepted before their\nvanilla local mutation and converted to `ElevatorRequest`. Unmanaged single-player lifts keep their\nnormal behavior outside the Open77 interest set. Open77 maps both the streamed `LiftDevice` entity\nand its persistent `LiftControllerPS` identity, because some vanilla variants use different IDs for\nmovement and button actions.\n\nAt departure, Open77 closes the configured cabin doors and sends `LiftDepartedEvent` to every floor\nterminal so all landing doors lock. At authoritative arrival, the native floor event opens only the\nactive landing and only the front/left/right cabin sides declared by that floor's\n`ElevatorFloorSetup`. Local save authorization cannot make two clients disagree on a managed\nlanding door; access has already been decided by the server request path.","natives":[],"permissions":[],"events":[]},{"anchor":"security-and-limits","heading":"Security and limits","level":2,"text":"- A client cannot provide an engine entity hash, marker, duration, curve, door state or revision.\n- Invalid packet enums, floors, positions, times and flags are rejected by both codecs.\n- Elevator requests are soft-limited to 8 packets per authenticated session per second.\n- A well-formed request that races a stream-out, resource reload or bucket transition is denied\n without disconnecting the player; only malformed protocol payloads are connection violations.\n- Server mutation is resource-owned and protected by `world.elevators` plus command ACLs.\n- At most 2048 elevators can be adopted per server.\n- Floor indexes are vanilla per-lift indexes in `0..floorCount-1`; they are not universal floor IDs.\n- Quest lifts may have extra workspots, layers or scripted doors and need explicit runtime testing.","natives":[],"permissions":[],"events":[]}]},{"slug":"fivem-compatibility","file":"fivem-compatibility.md","title":"FiveM compatibility aliases","summary":"Port FiveM resources using Open77's compatibility aliases for threads, timers, events, commands, key mappings, exports and promises. This guide lists supported aliases and the APIs needed where engine behavior differs.","sections":[{"anchor":"fivem-compatibility-aliases","heading":"FiveM compatibility aliases","level":1,"text":"Port FiveM resources using Open77's compatibility aliases for threads, timers, events, commands, key mappings, exports and promises. This guide lists supported aliases and the APIs needed where engine behavior differs.\n\nEverything here is an **alias or an addition**. No function that existed before changed meaning,\nand nothing widens the sandbox: `io`, `os`, `debug`, `package` and `load` are still absent, and\n`LoadResourceFile` cannot leave the calling resource.\n\nThe second half of the page is the porting notes that are *not* aliases — where a FiveM idiom maps\nonto something shaped differently here: the [damage feedback events](#damage-feedback-events-client),\n[keeping an NPC on task](#keeping-an-npc-on-task), [draw calls](#porting-fivem-draw-calls),\n[manifest keys](#manifest-keys), and the streaming loops that have\n[nothing to wait on](#what-is-missing-and-why).\n\nThree places where Open77 deliberately answers differently from FiveM are called out below in\ntheir own sections. Read those before porting: they are silent traps otherwise.","natives":[],"permissions":[],"events":[]},{"anchor":"availability-at-a-glance","heading":"Availability at a glance","level":2,"text":"| Name | Client | Server | Maps to |\n|---|---|---|---|\n| `Citizen.CreateThread` / `Citizen.Wait` | yes | yes | the existing `CreateThread` / `Wait` — the same function value |\n| `Citizen.SetTimeout` / `Citizen.ClearTimeout` | yes | yes | the existing `SetTimeout` / `ClearTimeout` |\n| `Citizen.SetTick` / `Citizen.ClearTick` | yes | yes | `SetTick` / `ClearTick` below |\n| `Citizen.Trace` | yes | yes | the resource log at debug level — `DBG` on both sides. (Until A17 landed the server wrote it as `INF`; `Open77.log.debug/info/warn/error` now reach the server log as `DBG/INF/WRN/ERR`, with ANSI escapes and FiveM `^N` colour codes stripped) |\n| `Citizen.Await` | yes | yes | `promise:await()` |\n| `Citizen.CreateThreadNow` | **no** | **no** | see [What is missing](#what-is-missing-and-why) |\n| `SetTick(fn)` / `ClearTick(id)` | yes | yes | a scheduler task looping with an implicit `Wait(0)` |\n| `GetGameTimer()` | yes | yes | monotonic milliseconds |\n| `promise.new()` | yes | yes | the existing `Open77.Promise` type |\n| `IsDuplicityVersion()` | `false` | `true` | — |\n| `LoadResourceFile(res, path)` | yes | yes | `Open77.resource.readFile` / `Open77.io.read` |\n| `SaveResourceFile(res, path, data)` | no | yes | `Open77.io.write` |\n| `GetHashKey(str)` / `joaat(str)` | yes | yes | **TweakDBID**, not a Jenkins hash |\n| `DoesEntityExist(kind, id)` | yes | yes | the registry that owns that kind |\n| `SetEntityInvincible(kind, id, enabled)` | no | yes | `Open77.players.setGodMode` for `\"player\"`; every other kind is refused **by name** — see [below](#setentityinvinciblekind-id-enabled) |\n| `IsPlayerAceAllowed(playerId, perm)` | no | yes | `Open77.acl.isAllowed` |\n| `IsPrincipalAceAllowed(userId, perm)` | no | yes | the same check, addressed by account id |\n| `add_ace allow` | no | yes | `Open77.acl.grant(userId, permission)` against a user, `Open77.acl.definePermission(role, …)` against a group. Both need a **scoped** manifest capability; see [the ACL guide](server-acl.md#changing-the-acl-from-a-resource-at-runtime) |\n| `remove_ace allow` | no | yes | `Open77.acl.revoke` |\n| `add_principal ` | no | yes | `Open77.acl.addRole(userId, role)`; `remove_principal` is `removeRole` |\n| `AddExplosion(x, y, z, type, damage, audible, invisible, shake)` | no | yes | `Open77.effects.explosion(position, options)` — server-side, because the damage goes through the stats authority. `type` is a `vfx` alias rather than a native enum |\n| `explosionEvent` | no | yes | `onExplosion`, host-wide, carrying counts rather than names |\n| `StartScriptFire(x, y, z, maxChildren, isGasFire)` | no | yes | `Open77.effects.fire(position, options)` — server-side, same reason as the blast. `maxChildren` has no equivalent: a fire is one radius, not a spreading tree. `isGasFire` is a `vfx` alias (`fire.gas`) |\n| `RemoveScriptFire(handle)` | no | yes | `Open77.effects.removeFire(fireId)`; `Open77.effects.fires()` lists what this resource still has burning |\n| `GetScriptFireCoords` / `IsEntityOnFire` | no | partly | `Open77.effects.fires()` answers the first for a resource's own fires; there is no per-entity burning flag, because nothing in the engine replicates one |\n| `fireEvent` | no | yes | `onFire`, host-wide, with `started`/`stopped` and a count rather than names |\n| `ptFxEvent` | no | yes | `onParticleEffect`, host-wide, for every world-positioned server effect. Entity-bound `playOn`/`attach` raise nothing: the target owns the transform |\n| `GetPlayerIdentifierByType(playerId, type)` | no | yes | Bare `open77`, `userId`, `name`, `fingerprint`, `license`, `steam` or `gog` value |\n| `GetPlayerIdentifiers(playerId)` | no | yes | Available identifiers as `\"type:value\"` strings; table form: `Open77.players.identifiers` |\n| `RegisterCommand(name, fn, restricted?)` | yes | yes | server: ACL `command.`; client: `Open77.runtime.registerCommand` |\n| `ExecuteCommand(line)` | yes | yes | client: the local registry only; server: the console, see below |\n| `GetRegisteredCommands()` | yes | yes | `Open77.runtime.commands()` |\n| `GetPlayerPing(playerId)` | no | yes | `Open77.players.ping` |\n| `GetPlayerEndpoint(playerId)` | no | yes | `Open77.players.endpoint`, behind `players.identity.sensitive` |\n| `GetConvar` / `GetConvarInt` / `GetConvarBool` / `SetConvar` | no | yes | the `convars` block, then this resource's tunables |\n| `GetResourceMetadata(name, key)` | no | yes | `Open77.resource.metadata`; no third `index` argument |\n| `StartResource` / `StopResource` | no | yes | `Open77.resource.start` / `stop`, behind `resources.control` |\n| `GetNumResources()` / `GetResourceByFindIndex(i)` | no | yes | `Open77.resource.list()`; the find index is zero-based |\n| `CancelEvent()` / `WasEventCanceled()` | no | yes | the cancellable bus (`TriggerCancellableEvent`), and inside a `playerConnecting` handler the FiveM refusal exactly: `setKickReason(msg)` then `CancelEvent()` — see [Connection control](connection-control.md#setkickreason-and-cancelevent-exactly-as-in-fivem) |\n| `TriggerLatentClientEvent(name, target, bytesPerSecond, ...)` | no | yes | the same function under the FiveM name (`Open77.net.emitLatent`): up to 4 MiB cut into paced 40 KiB frames — [Latent client events](server-api.md#latent-chunked-client-events) |\n| `SetHttpHandler(fn)` | no | yes | `Open77.http.listen(\"/\", fn)` under the FiveM name; the listener is an operator opt-in and the route lives under `//` — [Serving HTTP](server-api.md#serving-http) |\n| `GetPlayerTimeOnline(playerId)` / `GetPlayerLastMsg(playerId)` | no | yes | `Open77.players.sessionStats(id).sessionSeconds * 1000` and `Open77.players.get(id).ageMs` — milliseconds, the units FiveM documents |\n| `GetPlayerLocale(playerId)` | no | yes | `Open77.players.locale` under the FiveM name: the engine's own language setting, reported by the client (`Open77.session.locale()` there). `GetCurrentLanguage` has no counterpart — read `code` from the same table |\n| `SetTimeScale(scale)` | yes | per bucket | `Open77.world.setTimeScale(scale, options)` on the client — a claim per resource, eased and timed, released on stop; `Open77.world.setTimeScale(bucket, scale, options)` on the server replicates one beat to a routing bucket. Zero is refused (it freezes input with the world) — [World time](world-time.md) |\n| `SetGravityLevel` | partly | no | only `Open77.chute.arm(gravity, …)`, which rewrites the local player's gravity while airborne and shields the landing; there is no neutral world-gravity knob |\n| `FreezeEntityPosition(vehicle, true)` | yes | yes | client `Open77.vehicles.setFrozen` (local chassis physics, `vehicles.performance`); server `Open77.vehicles.setFrozen` pins the canonical pose and every client's projection follows. The control lock is the separate `setUndriveable`, and an impound wants both — [Freezing a car](vehicles.md#freezing-a-car). Players: `Open77.players.setFrozen` / `Open77.character.setFrozen` |\n| `GetGroundZFor_3dCoord(x, y, z)` | yes | yes | client `Open77.world.groundZ(x, y, fromZ?)` (a static-geometry ray); server `Open77.world.groundZ({ x, y }, options)` is an **observation relayed from the nearest connected client**, `nil, \"no_observer\"` when nobody is near — never an invented height — [World queries](world-queries.md) |\n| `SetPedToRagdoll(ped, ms, …)` / `ClearPedTasksImmediately(ped)` | no | yes | `Open77.players.ragdoll(id, { durationMs })` (the engine's knockdown, clamped to 3000 ms; needs the motion service, i.e. a server with a database) and `Open77.players.clearTasks(id)` — [Player freeze](player-freeze.md#putting-a-player-down-and-clearing-everything-ragdoll-and-cleartasks) |\n| fall-damage toggle (`SetPedCanRagdoll` family) | no | yes | `Open77.players.setFallDamage(id, false)` — fall damage **only**, not god mode — [Player stats](player-stats.md#fall-damage) |\n| `NetworkGetEntityOwner` / `NetworkRequestControlOfEntity` | no | yes | `Open77.vehicles.owner(id)` / `Open77.npcs.owner(id)` and `Open77.vehicles.requestAuthority(id, playerId)` — a request the election validates, refused by name (`driven`) rather than a forced steal |\n| `PlayPedAmbientSpeechNative(ped, context, …)` | no | yes | `Open77.npcs.speak(id, voice)` — a `voContext` name against the NPC's own voiceset; `true` means queued on every viewer's puppet, and a name the voiceset lacks is silent without a word — [NPC behaviour](npc-behavior.md#speech-one-line-on-demand) |\n| `SetPedComponentVariation` on an **NPC** | no | plumbing | `Open77.npcs.setEquipment(id, { slot = record })` replicates a canonical clothing set, but measured on 2.31 a `Character.*` rig renders none of it: `true` is not a visible outfit — [NPCs](npcs.md) |\n| `GiveWeaponComponentToPed` / grenades in a throwable slot | yes | yes | `Open77.weapons.setComponent` / `removeComponent` / `components` and `giveGadget` / `takeGadget` / `gadgets`, with `onGadgetConsumed` on the owner's report — [Weapon Lua API](weapons-api.md) |\n| `screenshot-basic` (`requestScreenshot`, `requestScreenshotUpload`, `requestClientScreenshot`) | yes | yes | client `Open77.screen.capture` + `Open77.screen.upload` (the game's own back buffer, never the desktop); server `Open77.players.requestScreenshot(id, { url })`, delivered by the client to a URL — the picture never crosses the game transport — [Screenshots](screenshots.md) |\n| `RegisterPedheadshot` (mugshots) | yes | no | `Open77.screen.mugshot(entity, options)` — a remote player or an NPC is a framed face; the **local player in first person has no body to photograph** — [Screenshots](screenshots.md) |\n| `RequestModel` / `HasModelLoaded` / `RequestAnimDict` / `SetModelAsNoLongerNeeded` | — | — | **not needed**, and not provided — see [What is missing](#what-is-missing-and-why) |\n| `SetBlockingOfNonTemporaryEvents` / `SetPedKeepTask` / `SetEntityAsMissionEntity` | no | yes | three NPC settings that already exist under other names — see [Keeping an NPC on task](#keeping-an-npc-on-task) |\n| `DrawText`, `DrawText3D`, `DrawMarker`, `DrawLine`, `DrawSprite` | yes | — | the UI kit and the world-drawing namespaces — see [Porting FiveM draw calls](#porting-fivem-draw-calls) |\n| `onResourceStarting` / `playerEnteredScope` / `playerLeftScope` | no | yes | the same names, host-wide and reserved: `onResourceStarting(name)` is delivered inline while the resource still reads `starting` (a `CancelEvent()` inside it is refused by name, never ignored), and the scope pair is published under both spellings (`onPlayerEnteredScope` too) — [Scope events](server-api.md#scope-events) |\n\nFailures follow the repository convention: `nil, reason` for a function that returns a value,\n`false, reason` for a predicate, with stable snake_case reason tokens.","natives":[],"permissions":[],"events":[]},{"anchor":"threads-and-ticks","heading":"Threads and ticks","level":2,"text":"`Citizen.CreateThread`, `Citizen.Wait`, `Citizen.SetTimeout` and `Citizen.ClearTimeout` are the\n*same function values* as the bare globals — `Citizen.CreateThread == CreateThread` is `true` — so\nthere is no second scheduler and no second set of budgets.\n\n```lua\nCitizen.CreateThread(function()\n while true do\n Citizen.Wait(500)\n Citizen.Trace(\"still here\")\n end\nend)\n```","natives":["Citizen.CreateThread","server:Citizen.CreateThread","Citizen.Wait","server:Citizen.Wait","Citizen.Trace","server:Citizen.Trace"],"permissions":[],"events":[]},{"anchor":"settickfn---id-and-cleartickid---boolean","heading":"`SetTick(fn) -> id` and `ClearTick(id) -> boolean`","level":3,"text":"A tick is an ordinary scheduler task looping with an implicit `Wait(0)`: it runs once per host\ntick (per frame on the client, per scheduler pass on the server), is bounded by the same per-frame\nbudget as any other task, and is counted against the same task cap. Stopping the resource stops it.\n\n```lua\nlocal id = SetTick(function()\n -- runs every frame\nend)\n\nClearTick(id) -- true the first time, false afterwards\n```\n\n`SetTick` returns `nil, \"invalid_tick_function\"` when the argument is not a function, and\n`nil, \"task_limit_or_stopping\"` when the resource is at its task cap or shutting down.\n\n**Errors inside a tick do not kill the VM, and are not ignored either.** Each failing pass is\nlogged at error level with the Lua error; after **five consecutive** failures the tick logs\n`tick cancelled after 5 consecutive failures` and cancels itself. One successful pass resets the\ncount. The reason for cancelling rather than logging forever: a tick that throws every frame\notherwise fills the log and keeps spending its slice of the frame budget for the whole session.\n\nCancellation takes effect on the next pass, on both sides: `fn` never runs again after `ClearTick`\nreturns.","natives":["SetTick","server:SetTick","ClearTick","server:ClearTick"],"permissions":[],"events":[]},{"anchor":"getgametimer","heading":"`GetGameTimer()`","level":3,"text":"Monotonic milliseconds, on both sides. The client reads the same clock as\n`Open77.time.monotonic()`, which reports seconds; the server returns the value the host hands its\nscheduler each tick.\n\nLike FiveM's, the epoch is arbitrary and is **not** wall clock: only the difference between two\nreadings means anything, and the client and the server share no origin. For a wall clock the\nserver has `GetUnixTime()` and `GetUtcTimestamp()`.","natives":[],"permissions":[],"events":[]},{"anchor":"promises","heading":"Promises","level":2,"text":"`promise.new()` builds the **same** object `Open77.exports.call` returns, so there is one promise\ntype in the runtime rather than two.\n\n```lua\nlocal p = promise.new()\n\nCreateThread(function()\n Wait(250)\n p:resolve(\"done\") -- or p:reject(\"no_such_thing\")\nend)\n\nCreateThread(function()\n local value, reason = Citizen.Await(p) -- identical to p:await()\n print(value, reason)\nend)\n```\n\n| Method | Result |\n|---|---|\n| `p:resolve(...)` | `true`, or `false, \"promise_already_settled\"` / `\"promise_cancelled\"` |\n| `p:reject(reason)` | same shape; a non-string reason becomes `\"promise_rejected\"` |\n| `p:await()` | the resolved values, or `nil, reason` when rejected |\n| `p:status()` | `\"pending\"`, `\"resolved\"`, `\"rejected\"`, or `\"cancelled\"` |\n| `p:next(onResolved, onRejected)` | a scheduler task id, or `nil, reason` |\n\nFour things to know before porting promise-heavy code:\n\n- **`await` needs a managed coroutine.** Called from script load, or from anything that is not a\n scheduler task, it returns `nil, \"await_requires_scheduler_coroutine\"` rather than parking the\n VM. `Citizen.Await(p)` is exactly `p:await()` and behaves identically.\n- **`:next` is not chainable.** It returns the id of the task it schedules, not a new promise, so\n `p:next(a):next(b)` does not work. Sequences belong in a `CreateThread` with `:await()`. The id\n is useful to cancel a continuation that is still waiting (`ClearTimeout`).\n- **`:next` recognises a rejection by shape**: the `nil, ` pair `:await()` returns\n for one. That is this codebase's failure convention everywhere, so a promise deliberately\n resolved with a leading `nil` followed by a string is read as rejected. Resolve with a table, or\n use `:await()` directly, if that distinction matters.\n- **On the client a pending promise has one consumer.** The first `:await()`/`:next()` woken by a\n settle takes the value, and the promise then reports `cancelled`; awaiting an *already settled*\n promise can be repeated. This is the existing client export-promise behaviour, not something the\n alias layer introduced. On the server a `promise.new()` promise keeps its value and can be\n awaited repeatedly. Code that awaits once is portable across both.\n- **Resolved values cross a serialization boundary on the client** (the same one exports use), so\n a value that cannot be serialized is refused with `false, \"promise_value_not_serializable\"`\n rather than silently dropped. The server passes Lua values through untouched.","natives":["CreateThread","server:CreateThread","Wait","server:Wait","Citizen.Await","server:Citizen.Await"],"permissions":[],"events":[]},{"anchor":"shared-scripts","heading":"Shared scripts","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"isduplicityversion","heading":"`IsDuplicityVersion()`","level":3,"text":"`true` in the server VM, `false` in the client VM. This is the branch a `shared_script` uses:\n\n```lua\nif IsDuplicityVersion() then\n RegisterCommand(\"ping\", function(source) print(source) end, false)\nelse\n RegisterNetEvent(\"app:pong\", function() print(\"pong\") end)\nend\n```","natives":["IsDuplicityVersion","server:IsDuplicityVersion","RegisterCommand","server:RegisterCommand","RegisterNetEvent","server:RegisterNetEvent"],"permissions":[],"events":[]},{"anchor":"loadresourcefileresourcename-path-and-saveresourcefile","heading":"`LoadResourceFile(resourceName, path)` and `SaveResourceFile(...)`","level":3,"text":"`LoadResourceFile` reads a file belonging to the **calling** resource and nothing else. Naming\nanother resource returns `nil, \"cross_resource_read_denied\"` — it does not quietly read your own\nfile, because that would make ported code look like it works.\n\n| | Client | Server |\n|---|---|---|\n| Root | the resource directory (`Open77.resource.readFile`) | the resource's `data/` directory (`Open77.io.read`) |\n| Permission | none | `filesystem.read` |\n| Size cap | 1 MiB | the `Open77.io` cap |\n| Escapes | `nil, \"invalid_resource_path\"` | `nil, \"path_outside_resource\"` |\n\n`SaveResourceFile(resourceName, path, data)` exists on the **server only**, maps to\n`Open77.io.write`, needs `filesystem.write`, and returns `true` or `false, reason`. There is no\nclient equivalent: a client resource writes through `Open77.kvp`, which is the persistence the\nclient actually has.","natives":[],"permissions":[],"events":[]},{"anchor":"gethashkeystr-joaatstr-a-tweakdbid-not-a-gta-hash","heading":"`GetHashKey(str)` / `joaat(str)` — a TweakDBID, not a GTA hash","level":3,"text":"**Hash compatibility.** In FiveM `GetHashKey` is the\nJenkins one-at-a-time hash (`joaat`) of a GTA model or entry name. Open77's records are TweakDB\nstrings — `\"Vehicle.v_sport2_quadra_turbo_r\"`, `\"Items.Preset_Lexington_Chrome\"` — so the only\nhash that resolves to anything in this game is the one REDengine computes itself:\n\n```text\nTweakDBID = CRC-32/ISO-HDLC(name) in the low 32 bits\n | (length(name) & 0xFF) in byte 4\n```\n\nBoth runtimes compute the identical value (the client in C++, the server in Lua), so a hash\ncrossing the wire means the same thing on both sides:\n\n```lua\nGetHashKey(\"Vehicle.v_sport2_quadra_turbo_r\") --> 133740073284 (0x1E23879144)\nGetHashKey(\"a\") --> 8199323203\nGetHashKey(\"\") --> 0\n```\n\n`joaat` is the same function under the FiveM name, kept so ported code reads naturally — but it is\n**not** the Jenkins hash the name suggests. A hash constant copied out of a FiveM resource names a\nGTA model and matches nothing here; port the *string*, not the number.","natives":["GetHashKey","server:GetHashKey"],"permissions":[],"events":[]},{"anchor":"doesentityexistkind-id","heading":"`DoesEntityExist(kind, id)`","level":3,"text":"FiveM has one opaque entity handle space. Open77 has one registry per kind, and they do not share\nan id space, so the kind has to be named:\n\n```lua\nDoesEntityExist(\"vehicle\", vehicleId) --> boolean\nDoesEntityExist(vehicleId) --> false, \"kind_required\"\n```\n\nThe one-argument FiveM form cannot be answered and is refused rather than guessed at.\n\n| `kind` | Client | Server |\n|---|---|---|\n| `\"vehicle\"` | `Open77.vehicles.get` | `Open77.vehicles.get` |\n| `\"npc\"` | `Open77.npcs.get` | `Open77.npcs.get` |\n| `\"elevator\"` | `Open77.elevators.get` | `Open77.elevators.get` |\n| `\"prop\"` | the resource's own prop list | `Open77.props.get` |\n| `\"effect\"` | the resource's own VFX/SFX lists | `Open77.effects.get` |\n| `\"player\"` | the replicated health roster | `GetPlayers()` |\n| `\"loot\"` | `false, \"kind_unavailable_on_client\"` | `Open77.loot.get` |\n\nReasons: `\"kind_required\"`, `\"id_required\"`, `\"invalid_entity_id\"`, `\"unknown_entity_kind\"`,\n`\"kind_unavailable_on_client\"`.\n\nEach kind answers through the registry that already owns it, **with that registry's permissions**:\na resource that may not read vehicles gets `false`, the same answer an absent id gets. If you need\nto tell \"denied\" from \"gone\", call the namespace function directly — it returns the reason.\n\nThe client has no loot registry it can be asked about (the server owns that list), so the client\nsays so instead of answering `false`, which would read as \"the drop is gone\".","natives":["DoesEntityExist","server:DoesEntityExist"],"permissions":[],"events":[]},{"anchor":"setentityinvinciblekind-id-enabled","heading":"`SetEntityInvincible(kind, id, enabled)`","level":3,"text":"The same divergence, on the one write FiveM scripts make through an entity handle. Server only,\nbecause damage immunity is a server ledger here:\n\n```lua\nSetEntityInvincible(\"player\", playerId, true) --> true (Open77.players.setGodMode)\nSetEntityInvincible(\"player\", playerId, false) --> true\nSetEntityInvincible(playerId, true) --> false, \"kind_required\"\nSetEntityInvincible(\"vehicle\", vehicleId, true) --> false, \"unsupported_entity_kind\"\n```\n\nFor `\"player\"` it **is** `Open77.players.setGodMode` — the same `players.stats.apply` permission\n(refused as `permission_denied:players.stats.apply` before any id is looked at), the same combat\nledger bit, read back as `Open77.players.get(id).godMode`. Every other kind is refused **by name**\nrather than accepted and ignored: a vehicle's damage is its own scopes (`setEngineHealth`, the body\nzones), an NPC's is its `damagePolicy`, and a ported script that expects an indestructible car\nshould find that out here rather than on the first hit. A name that is no kind at all is\n`unknown_entity_kind`; a non-boolean `enabled`, or a player the ledger does not hold yet, is\n`invalid_argument`.\n\nTwo neighbours it is not: `Open77.players.setFallDamage(id, false)` switches fall damage off and\nnothing else, and the grav-chute's shield is a blanket *Invulnerable* that lives only while the\nchute is armed — [Player stats](player-stats.md#fall-damage) draws the line.","natives":["server:SetEntityInvincible"],"permissions":[],"events":[]},{"anchor":"ace-checks-server","heading":"ACE checks (server)","level":3,"text":"`IsPlayerAceAllowed(playerId, permission)` is `Open77.acl.isAllowed` under the FiveM name: same\n`acl.read` permission, same session player id, same answer.\n\n`IsPrincipalAceAllowed(userId, permission)` asks the same question addressed by **account id**.\nFiveM principals are durable; Open77 resolves permissions through the connected session, so a user\nwho is not connected cannot be answered and the call returns\n`false, \"principal_not_connected\"` rather than a bare `false` that would read as \"denied\".","natives":[],"permissions":[],"events":[]},{"anchor":"player-identifiers-server","heading":"Player identifiers (server)","level":3,"text":"```lua\nGetPlayerIdentifierByType(src, \"open77\") --> \"11111111-2222-3333-4444-555555555555\"\nGetPlayerIdentifierByType(src, \"userId\") --> the same installation identity GUID\nGetPlayerIdentifierByType(src, \"name\") --> \"Valerie\"\nGetPlayerIdentifierByType(src, \"fingerprint\") --> \"sha256:...\"\nGetPlayerIdentifierByType(src, \"license\") --> \"aaaaaaaa111122223333bbbbbbbbbbbb\"\nGetPlayerIdentifierByType(src, \"steam\") --> \"11000010000002a\" (hexadecimal, if linked)\nGetPlayerIdentifierByType(src, \"gog\") --> \"12345678901234567\" (decimal, if linked)\n\nGetPlayerIdentifiers(src)\n-- Example with both stores linked (either store may be absent):\n--> { \"open77:1111...\", \"name:Valerie\", \"fingerprint:sha256:...\",\n-- \"license:aaaaaaaa111122223333bbbbbbbbbbbb\",\n-- \"steam:11000010000002a\", \"gog:12345678901234567\" }\n```\n\n`GetPlayerIdentifierByType` returns the **bare** value; `GetPlayerIdentifiers` returns the FiveM\nshape, an array of `\"type:value\"` strings. Iterate safely with\n`for _, id in ipairs(GetPlayerIdentifiers(src) or {}) do ... end`.\nThe array read returns `nil, \"player_not_found\"` for an unknown session. The typed read returns\n`nil, \"identifier_not_linked\"` when the requested `license`, `steam` or `gog` value is unavailable.\nType names are case-insensitive; unsupported types return `nil, \"unknown_identifier_type\"`.\n\n`license`, `steam` and `gog` require server runtime `2.31.13+op77.101` or later. Use `license`\nas the permanent Open77 account key across linked devices; keep all identifiers as strings.\nOne Steam or GOG account owning both Cyberpunk 2077 and Phantom Liberty is verified once and\nlinked permanently. Since runtime `2.31.13+op77.102`, an account manually approved by an Open77\nadministrator can also connect with `license` present and both store identifiers absent.\nResources must support this case. No Discord or Xbox identifier is exposed.\nStore linking is currently optional; players who choose **Skip for now** also have `license`\nwithout either store ID. Existing verified store links remain available.\nSee [verified account and store identifiers](connection-control.md#steam-gog-and-permanent-account-identifiers)\nfor the server event example, formats and ownership semantics.","natives":["server:GetPlayerIdentifierByType","server:GetPlayerIdentifiers"],"permissions":[],"events":["open77:1111..."]},{"anchor":"commands-on-the-client","heading":"Commands on the client","level":3,"text":"`RegisterCommand` exists on **both** runtimes now, with the same handler shape\n`(source, args, rawCommand)`. The client passes `source = 0`, because on the client there is no\nother party to attribute a command to; a resource that wants the local session id calls\n`Open77.session.playerId()`.\n\n```lua\n-- client script\nRegisterCommand(\"hud\", function(_, args)\n Open77.hud.setVisible(args[1] ~= \"off\")\nend, false, { help = \"Show or hide the HUD.\", parameters = { { name = \"state\", optional = true } } })\n```\n\nThe fourth argument is an Open77 addition, not a FiveM one: it is the completion entry the chat\ncomposer shows. A port that passes three arguments is accepted unchanged and simply gets a generic\ncompletion line.\n\n**`restricted = true` on the client refuses rather than pretends.** The command is registered and\nlisted, and then declines to run with `command_restricted`. The client has no ACL — it cannot\nanswer an authority question — so enforcing nothing while claiming to enforce something would be\nthe worse failure. That is also what FiveM effectively does for a player who does not hold the ace.\nAn admin-only command belongs on the server, where `command.` is a real check.\n\n`ExecuteCommand(line)` differs from FiveM's deliberately: **it resolves against the client registry\nonly and reports whether it matched**, rather than silently forwarding an unrecognised line to the\nserver. `false, \"unknown_command\"` is the signal a caller uses to decide to forward, and that is\nexactly what the chat composer does — it tries the client first, forwards on `unknown_command`, and\nshows any other refusal. A composer that could not tell \"no client command\" from \"the client\ncommand failed\" would either swallow errors or spend a net event on every keystroke.\n\n`GetRegisteredCommands()` returns `{ name, resource, restricted, help?, parameters? }` on the\nclient — two fields more than FiveM's `{ name, resource }`, because the composer needs them to\nbuild a completion. The server's returns the same shape plus `source`, which says whether a command\ncame from a Lua resource or from the operator console.","natives":["RegisterCommand","server:RegisterCommand","Open77.hud.setVisible"],"permissions":[],"events":[]},{"anchor":"convars-and-why-tunables-are-better","heading":"Convars, and why tunables are better","level":3,"text":"`GetConvar`, `GetConvarInt`, `GetConvarBool` and `SetConvar` read a `convars` block of the server's\nconfiguration, then fall through to **the calling resource's own declared tunables by exact key**,\nthen to the caller's default. A getter never raises. `SetConvar` is a server-wide, in-memory\noverride that does not survive a restart.\n\nThey exist so a port runs. They are not the mechanism to reach for when writing something new:\n`Open77.tunables` is typed, range-checked, validated on every write, retunable by the operator from\nthe Warden panel while the server runs, persisted, scoped to one resource, and it notifies its owner\nwhen a value changes. The fall-through to tunables is deliberate — it lets a ported resource be\nmigrated one key at a time instead of all at once. See\n[the server API reference](server-api.md#convars).","natives":[],"permissions":[],"events":[]},{"anchor":"executecommand-does-not-carry-rcon","heading":"`ExecuteCommand` does not carry rcon","level":3,"text":"This is the one place Open77 refuses to copy FiveM, and it is worth reading before porting an admin\nresource. In FiveM, `ExecuteCommand` runs as the console: full authority, with nothing anywhere\ndeclaring that the resource holds it.\n\nHere it is split in two. `runtime.commands` in the manifest lets a resource run another resource's\n**ordinary** command and nothing else — a restricted command and every resource-lifecycle verb are\nrefused. `resources.control` raises that to the operator's authority and is separately required by\n`StartResource` / `StopResource`. An operator installing a resource therefore sees the escalation in\nthe manifest before running it.\n\nCommands are also **queued**, not run inline, and report acceptance rather than completion: Lua runs\ninside the server's tick, and a resource stopping itself synchronously would free the Lua state\ndoing the stopping. The reply goes to the server log.","natives":[],"permissions":[],"events":[]},{"anchor":"player-identifiers-ping-and-endpoint-server","heading":"Player identifiers, ping and endpoint (server)","level":3,"text":"`GetPlayerPing(playerId)` takes no capability — latency is not identity, and every scoreboard in the\ngenre shows it. It answers `nil, \"ping_unavailable\"` on a transport that cannot measure, rather than\na plausible zero.\n\n`GetPlayerEndpoint(playerId)` **does** take one: `players.identity.sensitive`. An endpoint is an IP\naddress. FiveM gates nothing here; a port that quietly logged addresses will start reporting\n`permission_denied:players.identity.sensitive` until its manifest says what it is doing.\n\n`Open77.players.identifiers(playerId)` is the table companion to the array `GetPlayerIdentifiers`\nreturns: `{ open77, userId, name, fingerprint, joinedAt, license?, steam?, gog? }`, plus\n`endpoint` only when `players.identity.sensitive` is held. Account and store identifiers need\nno permission. Unavailable fields are omitted and read as `nil` in Lua.","natives":[],"permissions":[],"events":[]},{"anchor":"txadmin-events-under-open77-names","heading":"txAdmin events, under Open77 names","level":3,"text":"A ported script that listens for `txAdmin:events:scheduledRestart` or\n`txAdmin:events:serverShuttingDown` finds the same information under the\n`open77:admin:` prefix. Rename the handler; the shapes are close but not identical.\n\n| txAdmin | Open77 | Difference |\n|---|---|---|\n| `txAdmin:events:scheduledRestart` | `open77:admin:scheduledRestart(secondsRemaining, reason)` | Arguments, not a table. Raised on each rung of the warning ladder. |\n| `txAdmin:events:serverShuttingDown` | `open77:admin:serverShuttingDown(reason)` | `restart`, `reset` or `shutdown`. |\n| `txAdmin:events:announcement` | `open77:admin:announcement(text)` | Same. |\n| `txAdmin:events:playerWarned` | `open77:admin:playerWarned(playerId, author)` | **No reason and no operator name** — see below. |\n| `txAdmin:events:playerKicked` | `open77:admin:playerKicked(playerId, author)` | Same. |\n| `txAdmin:events:playerBanned` | `open77:admin:playerBanned(playerId, author, durationSeconds)` | Same, plus the duration. |\n| `txAdmin:events:healedPlayer` | `open77:admin:playerHealed(playerId, author)` | Raised by Warden's **heal** action only — see below. |\n\nTwo differences will bite a straight port. **The moderation reason is not carried**, and\n`author` is a channel (`warden` or `resource`) rather than a staff name: these events reach\nevery resource on the server, and a host-wide event carrying either would be a moderation log\nfor anything that cared to listen. A script that displayed the reason must get it from its own\nadmin resource instead.\n\nAnd **`healedPlayer` fires only for Warden's own heal**. txAdmin has it because healing is\nsomething its panel does — and since the [Players tab](warden-players.md) grew a heal action,\nWarden's panel does too, so `open77:admin:playerHealed(playerId, author)` is raised when an\noperator heals from it. It is **not** raised when a resource calls `Open77.players.heal`: that\nis a gameplay native any resource may call, and announcing every call of it as an administrative\nact would be both a firehose and a lie. An admin resource that heals on command should publish\nits own event, under its own namespace — `open77:admin:` is reserved to the platform precisely so\nthat \"the operator's surface did this\" stays a claim only the platform can make.\n\nThe full contract, including the `Open77.runtime.scheduleRestart` lever, is in\n[Admin events](server-api.md#admin-events).","natives":[],"permissions":[],"events":[]},{"anchor":"damage-feedback-events-client","heading":"Damage feedback events (client)","level":3,"text":"FiveM's `gameEventTriggered` with `CEventNetworkEntityDamage` is one firehose every combat HUD\nfilters. Open77 splits it into three client events, all raised from the server's authoritative\nhealth verdict — the `PlayerHealthState` broadcast, never the shooter's local raycast — so a\nhitmarker drawn from `hitConfirmed` is a hit the ledger credited:\n\n| Event | Raised on | Arguments |\n|---|---|---|\n| `open77:localDamaged` | the **victim's** client, when the local player lost health | `attackerId, amount, dirX, dirY, dirZ, bodyPart, health, maxHealth` |\n| `open77:hitConfirmed` | the **attacker's** client, when the server credited a hit by the local player on a *player* | `victimId, amount, bodyPart, lethal` |\n| `open77:playerDamaged` | every client that has the victim's proxy — the observer feed for nameplates and kill feeds | `victimId, attackerId, amount, bodyPart, health, maxHealth` |\n\n```lua\nAddEventHandler(\"open77:hitConfirmed\", function(victimId, amount, bodyPart, lethal)\n showHitmarker(lethal == \"1\") -- a string, not a boolean\nend)\nAddEventHandler(\"open77:localDamaged\", function(attackerId, amount, dirX, dirY, dirZ)\n showDamageDirection(tonumber(dirX), tonumber(dirY), tonumber(dirZ))\nend)\n```\n\nThree traps, each found while building a combat HUD rather than imagined:\n\n- **`hitConfirmed` is attacker-side and player-victim only.** It is raised from the player-health\n drain, so a hit on a server-owned NPC produces no hitmarker at all. For NPCs the shooter's client\n gets `open77:npcHit(npcId, damage, hitZ, weaponTdbId, attackKind)` instead, and a gamemode that\n credits the hit server-side emits its own marker event from there.\n- **Every argument is a string**, like every engine-raised event: `lethal` arrives as `\"1\"` /\n `\"0\"`, and `amount`, `health`, `maxHealth` and the direction need `tonumber`.\n- **The server event of the same name has a different list.** Server-side `open77:playerDamaged`\n carries `(victimId, attackerId, amount, attackKind, weaponTdbId, bodyPart, remainingHealth,\n maxHealth, lethal, downedHit)`; a handler copied from one side to the other reads the wrong\n column. Both lists live in [Player stats](player-stats.md#events).","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:localDamaged","open77:hitConfirmed","open77:playerDamaged"]},{"anchor":"keeping-an-npc-on-task","heading":"Keeping an NPC on task","level":3,"text":"Three FiveM natives keep a ped from wandering off, and each is a setting that already exists here\nunder another name. Nothing was built for them:\n\n| FiveM | Open77 | What it actually does |\n|---|---|---|\n| `SetBlockingOfNonTemporaryEvents(ped, true)` | `Open77.npcs.setPerceptionEnabled(id, false)` | Stops autonomous stimulus acquisition, including Open77's own target seeding. It does **not** drop a target already acquired — add `Open77.npcs.setCombatEnabled(id, false)` for that. `setAIEnabled(id, false)` is the wider switch: the native agent stops too, and the NPC's simulation lease is revoked. |\n| `SetPedKeepTask(ped, true)` | the task's own `timeoutMs = 0` | A task without a timeout runs until it succeeds, fails or is cancelled; the channel keeps it across ownership moves. There is no separate \"keep\" bit because nothing here clears a task behind a script's back. |\n| `SetEntityAsMissionEntity(entity, true, true)` | `persistent = true` and `despawnWhenUnobserved = false` on `Open77.npcs.create` (and `setPersistent` on vehicles) | **Cleanup policy only**: the NPC survives its resource stopping and is not despawned when no client observes it. It is not saved anywhere — server persistence is the resource's own storage. |\n\nThe three switches are documented with their side effects in\n[NPC behaviour](npc-behavior.md) and the task options in [NPCs](npcs.md#tasks).","natives":[],"permissions":[],"events":[]},{"anchor":"porting-fivem-draw-calls","heading":"Porting FiveM draw calls","level":3,"text":"FiveM draws with per-frame natives — a thread that calls `DrawText` every tick keeps the text on\nscreen. Nothing here is per-frame: every drawing surface takes a description once and returns a\nhandle, and the loop goes away with the port.\n\n| FiveM | Open77 | The difference that matters |\n|---|---|---|\n| `DrawText` / `SetTextFont` / `SetTextEntry` in a tick | `exports.open77_uikit:textUI({ text, position, key, icon })` | One persistent hint slot per resource (`top` / `center` / `bottom`); a second call **replaces** it; `hideTextUI` removes it. Four resources may show one at once. |\n| the `DrawText3D` pattern (project a coordinate every frame, draw text at it) | `exports.open77_uikit:drawText3D({ position \\| entity, text, sublabel, maxDistance, ttl })` → `updateText3D` / `clearText3D` | A native world anchor of render style `card`, projected by the host with the distance band, viewport cull and occlusion the interaction prompts already pay for. **Budgeted:** the kit refuses 8 per owner (`text3d_owner_limit`) and 24 in all (`text3d_limit`), under the anchor quota of 32 per resource / 128 global — draw the nearest eight, not one per entity. |\n| `DrawMarker` | `Open77.markers.*` | A shape/style vocabulary, ground circles through [`open77_groundcircle`](https://github.com/Open2077/open77-resources/tree/main/system/open77_groundcircle). |\n| `DrawLine`, `DrawPoly`, `DrawBox` (zone debug) | `Open77.debugDraw.set(tag, geometry)` / `clear(tag)` | Resource-owned lines and triangles under a tag, replaced atomically per call and kept until cleared; permission `world.debug`. |\n| `DrawSprite`, `DrawRect` | an `` or a `
` on a WebUI page | Screen-space art is a page; a rectangle pair every cutscene draws for bars is `exports.open77_uikit:showCinematicBars(true)`. |\n\n`Open77.anchors` underneath can render a `card`, `ring`, `dot` or `page` at a world point when the\nkit's text is not enough. The kit's own porting notes are in\n[UI kit — porting a `DrawText3D` loop](ui-kit.md#porting-a-drawtext3d-loop).","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-keys","heading":"Manifest keys","level":3,"text":"A FiveM `fxmanifest.lua` ports to `open77.lua` mostly by renaming the file; the keys that carry\nmeaning here are handled, the rest are named once and ignored:\n\n| FiveM key | Open77 |\n|---|---|\n| `exports { ... }` / `server_exports { ... }` | Accepted as-is: each side pre-registers the listed **global functions** before the resource is `Running`, through the same registration a scripted `exports()` call performs. A listed name with no global function refuses the start (`manifest_export_missing:`). |\n| `ui_page 'html/index.html'` | An alias of `web_ui_page`, with the same rule that the file is declared in `web_files`. When both are written, the later line wins. |\n| `fx_version`, `game`, `games`, `lua54`, `use_experimental_fxv2_oal`, `author`, `description`, `provide`, `provides`, `escrow`, `escrow_ignore` | **Accepted and ignored**, and named once per start so the acceptance is never mistaken for support: `INF\\|my_resource\\|manifest_ignored_keys=fx_version,lua54`. |\n| `client_script '@ox_lib/init.lua'` (cross-resource includes) | **Refused by name**, `cross_resource_include_refused:@ox_lib/init.lua`, at manifest parsing on both sides. Scripts run in their own VM; share code with `dependency` plus `require('@resource/module')` on the client and exports on both. |\n\nThe full manifest reference is [Server resources](server-resources.md#declarative-exports-and-ported-manifests).","natives":[],"permissions":[],"events":[]},{"anchor":"what-is-missing-and-why","heading":"What is missing, and why","level":2,"text":"**`Citizen.CreateThreadNow` is unavailable.** Both schedulers start tasks on the next pass. Use `Citizen.CreateThread`, or call non-yielding code directly.\n\n`Citizen.InvokeNative`, `Citizen.CreateUThread`, `Citizen.Wait` inside a non-managed coroutine, and\nthe `msgpack` surface are not provided: Open77 has no GTA native table to invoke, and the event\npayload encoding is not msgpack.\n\n**`RequestModel`, `HasModelLoaded`, `RequestAnimDict`, `HasAnimDictLoaded`, `SetModelAsNoLongerNeeded` and `RequestNamedPtfxAsset` are unavailable.** The server registry controls vehicle and NPC streaming. `create` returns before the client projection is attached; check whether the entity is streamed on the current client:\n\n| A ported loop waited on | Ask instead |\n|---|---|\n| `RequestModel(model)` … `while not HasModelLoaded(model) do Wait(0) end` before a vehicle | `Open77.vehicles.whenStreamed(id, timeoutMs):await()` — resolves with the `get(id)` snapshot once `streamed == true`, rejects `timeout`; the state is `Open77.vehicles.get(id).streamed`, the transition is `open77:vehicleCreated` |\n| the same before a ped | `Open77.npcs.whenReady(id, timeoutMs):await()` — resolves with `{ id, entity }` the instant `onNpcReady` fires (attached **and** behaving); the state is `Open77.npcs.isStreamedIn(id)`; the server has the same helper, resolved by the first client that reports the body |\n| `RequestAnimDict` before `TaskPlayAnim` | nothing: `Open77.animations.play` resolves a clip from the catalogue itself |\n| `RequestNamedPtfxAsset` before a particle | nothing: `Open77.vfx.*` names a curated effect and the engine owns the asset |\n\nDelete the loop rather than emulating it: a `HasModelLoaded` that always answered `true` would\nhave ported code spawning a blip on an entity that is not there yet.","natives":[],"permissions":[],"events":["open77:vehicleCreated"]},{"anchor":"verifying-a-port","heading":"Verifying a port","level":2,"text":"A shared script that prints this block should produce the same output on both runtimes, except for\n`duplicity`. It is what the client and server test suites assert:\n\n```lua\nprint(\"parity duplicity=\" .. tostring(IsDuplicityVersion()))\nprint(\"parity hash=\" .. tostring(GetHashKey(\"Vehicle.v_sport2_quadra_turbo_r\")))\nprint(\"parity joaat=\" .. tostring(joaat(\"x\") == GetHashKey(\"x\")))\nprint(\"parity citizen=\" .. tostring(Citizen.CreateThread == CreateThread) ..\n \",\" .. tostring(Citizen.CreateThreadNow == nil))\nprint(\"parity timer=\" .. type(GetGameTimer()))\nlocal _, kindError = DoesEntityExist(1)\nprint(\"parity kind=\" .. tostring(kindError))\nlocal _, crossError = LoadResourceFile(\"somewhere_else\", \"note.txt\")\nprint(\"parity cross=\" .. tostring(crossError))\nlocal settled = promise.new()\nsettled:resolve(\"parity\")\nprint(\"parity promise=\" .. tostring(settled:status()))\nlocal _, awaitError = Citizen.Await(promise.new())\nprint(\"parity await=\" .. tostring(awaitError))\nprint(\"parity tick=\" .. type(SetTick) .. \",\" .. type(ClearTick))\n```\n\n```text\nparity duplicity=false (client) | parity duplicity=true (server)\nparity hash=133740073284 | parity hash=133740073284\nparity joaat=true | parity joaat=true\nparity citizen=true,true | parity citizen=true,true\nparity timer=number | parity timer=number\nparity kind=kind_required | parity kind=kind_required\nparity cross=cross_resource_read_denied| parity cross=cross_resource_read_denied\nparity promise=resolved | parity promise=resolved\nparity await=await_requires_scheduler_coroutine (both)\nparity tick=function,function | parity tick=function,function\n```","natives":["IsDuplicityVersion","server:IsDuplicityVersion","GetHashKey","server:GetHashKey","GetGameTimer","server:GetGameTimer","DoesEntityExist","server:DoesEntityExist","LoadResourceFile","server:LoadResourceFile","Citizen.Await","server:Citizen.Await"],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Complete server Lua API](server-api.md) — every server global and `Open77.*` namespace.\n- [Cross-resource server exports](server-exports.md) — where the promise type comes from.\n- [Server resources](server-resources.md) — manifests, permissions, and the resource lifecycle.","natives":[],"permissions":[],"events":[]}]},{"slug":"furniture","file":"furniture.md","title":"Furniture: sitting and leaning","summary":"Let players sit together at a café, use stools in a bar or wait on a bench. With seating enabled, supported furniture gets a nearby prompt: **E** to sit, lean or lie down, **X** to stand up. A seat already used by another player is unavailable.","sections":[{"anchor":"furniture-sitting-and-leaning","heading":"Furniture: sitting and leaning","level":1,"text":"Let players sit together at a café, use stools in a bar or wait on a bench. With seating enabled,\nsupported furniture gets a nearby prompt: **E** to sit, lean or lie down, **X** to stand up. A seat\nalready used by another player is unavailable.\n\nYou can enable this without writing Lua or installing a job, inventory or economy. Two resources\nprovide it: `open77_furniture` holds a catalogue of city furniture locations and poses;\n`open77_seating` adds prompts, player placement and occupancy rules. When `open77_pedestrians`\nis running, it also coordinates seats used by those NPCs.\n\nThe catalogue includes about 38,000 furniture entries, but only supported playable poses are\noffered. This is not automatic support for every visible chair, spawned prop or custom interior.\nUse the coverage section below and check the furniture at your venue.\n\nTo connect seating to a café or another job, see [Jobs and world interaction](world-interaction.md).\n\nSource: [`open77_furniture`](https://github.com/Open2077/open77-resources/tree/next/system/open77_furniture), [`open77_seating`](https://github.com/Open2077/open77-resources/tree/next/system/open77_seating).","natives":[],"permissions":[],"events":[]},{"anchor":"enable-seating","heading":"Enable seating","level":2,"text":"Add the resources to `resources.load` ([selecting resources](server-resources.md)), then start them\nfrom the server console, in this order:\n\n```text\n# server.jsonc\nresources.load: [ ..., \"open77_animations\", \"open77_props\", \"open77_interactions\", \"open77_uikit\",\n \"open77_actions\", \"open77_furniture\", \"open77_seating\" ]\n\n# server console\nensure open77_furniture\nensure open77_actions\nensure open77_seating\n```\n\n- These three do not start on their own (`auto_start false`). A name missing from `resources.load`\n is never started, and nothing is logged. Without `open77_furniture`, every request is refused.\n- If your server runs the crowd ([`open77_pedestrians`](https://github.com/Open2077/open77-resources/tree/main/system/open77_pedestrians)), seating uses it on its own: a seat a\n pedestrian uses gets no prompt, and no pedestrian walks onto a seat a player uses.\n\nPlayers can now sit with the prompts, with `/sit` (the nearest free seat within 3 m) and `/stand`.","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-player-sees","heading":"What the player sees","level":2,"text":"1. Up to three free seats within 3 m show a prompt, nearest first. A seat in use shows none.\n2. **E**: the body is placed beside the seat (behind a chair, in front of a wall), then sits down.\n The camera switches to third person, and a banner says \"Press X to stand up\".\n3. **X** or `/stand`: the player stands, and is put back where they stood before sitting if the body\n ended somewhere odd. X can be rebound in Settings, Key bindings (\"Stand up from a seat\").\n4. Drawing a weapon, dying, getting into a car or changing routing bucket also stands the player up,\n and the banner says why (\"You stood up: combat\"). A player teleported away is never pulled back.","natives":[],"permissions":[],"events":[]},{"anchor":"which-seats-are-offered","heading":"Which seats are offered","level":2,"text":"| Furniture | Prompt | The body starts from | Offered by default |\n|---|---|---|---|\n| Chairs, lean-back chairs, benches with chair poses | Sit on the chair | 1.2 m behind the seat | about 14,500 |\n| Bar stools | Sit on the stool | 1.2 m behind the stool | about 1,600 |\n| Bar seats | Sit on the bar seat | where the player stands | 18 |\n| Walls | Lean on the wall | 1 m in front of the wall | about 50 |\n| Counters | Lean on the counter | 1 m back from the counter | about 30 |\n| Beds | Lie down on the bed | where the player stands | 4 |\n\nBy default, seating offers entries from supported player-pose families: about 16,000 pieces of\nfurniture. This does not mean every individual location has a clear approach and exit; check\nthe seats you want to use. Set `open77_seating_verification` to `all` to offer about 5,600 more, mostly wall\nleans and chairs, at the risk of a player clipping into the furniture. If the spot beside a seat is\nblocked by a step or a wall, the player is told \"Something is in the way.\" and put back.\n\nA bench row seats several players side by side, at least 0.6 m apart. Couches, armchairs, ledges,\nsteps, curbs, rails and tables have no player pose and are never offered.\n\n**Beds.** One double-bed family (four beds in the city) has a lying pose verified for players. Its\nbeds get a \"Lie down on the bed\" prompt and `/sit` takes them; the player lies down from where they\nstand, and a bed holds one player. Other bed families stay unverified until they are checked.","natives":[],"permissions":[],"events":[]},{"anchor":"sit-a-player-from-your-own-resource","heading":"Sit a player from your own resource","level":2,"text":"Seat a player on furniture you chose: the interrogation chair of your police station, a stool in\nyour bar. Get its id with the `furniture.near` console command, then:\n\n```lua\n-- server\nlocal CHAIR = \"9160494166749203069\" -- a furniture id, from furniture.near\n\nRegisterCommand(\"interrogate\", function(source, args)\n -- check here that `source` is a police officer\n local seat, why = exports.open77_seating:sit(tonumber(args[1]), CHAIR)\n if not seat then Open77.chat.send(source, \"Cannot seat the suspect: \" .. why) end\nend)\n\nAddEventHandler(\"open77_seating:seated\", function(e)\n if e.furnitureId == CHAIR then Open77.chat.send(e.playerId, \"The detective is on his way.\") end\nend)\n```\n\n- The player must already be within 3 m of the seat: `sit` never brings a player across the room.\n- `sit` answers at once. The player is seated a moment later, when `open77_seating:seated` fires; if\n they cannot get there, `open77_seating:stood` fires instead, with the reason.\n- `sitNearest(playerId)` picks the nearest free seat for you, like `/sit`.","natives":["RegisterCommand","server:RegisterCommand","server:Open77.chat.send","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"keep-players-off-seats-during-a-job","heading":"Keep players off seats during a job","level":2,"text":"Seating shares the one-winner claims of [`open77_actions`](actions.md). A seated player holds the key\n`furniture::` in their routing bucket. Claim the same key and no\nplayer can sit there: they are told \"This seat is in use.\"\n\n```lua\n-- server: close the bar stools while the bartender cleans the counter\nlocal counter, bucket = { x = 0.0, y = 0.0, z = 0.0 }, 0 -- your counter and its routing bucket\nlocal version = exports.open77_furniture:status().version\nlocal stools = exports.open77_furniture:nearby(counter, 6, { kind = \"barstool\", playable = true, limit = 8 })\nlocal tokens = {}\nfor _, stool in ipairs(stools or {}) do\n local token = exports.open77_actions:claim(bucket, \"furniture:\" .. version .. \":\" .. stool.id, 60000)\n if token then tokens[#tokens + 1] = token end -- nil, \"target_claimed\" while someone sits there\nend\n-- ... the cleaning job, then:\nfor _, token in ipairs(tokens) do exports.open77_actions:releaseClaim(token) end\n```\n\nA claim lasts its time to live (60 s here, one hour at most): call `renewClaim(token, ttlMs)` for a\nlonger job. A claim never stands up a player already seated, so claim before the job starts. Your\nclaims are released when your resource stops.","natives":[],"permissions":[],"events":[]},{"anchor":"reference","heading":"Reference","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"settings","heading":"Settings","level":3,"text":"Set these in the `convars` block of `server.jsonc`; `open77_seating` reads them when it starts.\n\n| Convar | Default | Meaning |\n|---|---|---|\n| `open77_seating_verification` | `measured` | `measured`: verified seats only. `all`: every seat with a player pose. `unverified`: only the seats that are not verified |\n| `open77_seating_postures` | `all` | the postures prompts, `/sit` and the exports offer: a comma list of `sit`, `lean` and `lie`. `sit,lean` turns the bed prompts off |\n| `open77_seating_exit` | `approach` | after standing up, put the body back where it stood before sitting (`approach`), only when it ended somewhere odd (`check`), or never (`none`) |\n| `open77_seating_buckets` | `all` | routing buckets where players can sit, a comma list such as `0,3` |\n| `open77_seating_trace` | `0` | `1` prints every sit, seat and stand in the server log |","natives":[],"permissions":[],"events":[]},{"anchor":"exports","heading":"Exports","level":3,"text":"| Server export | Answers |\n|---|---|\n| `open77_seating:sit(playerId, furnitureId, options?)` | the seat, or `nil, reason`. `options`: `spot` (one place on a bench row), `approach` (`back`, `front`, `left`, `right`, `player`, `none`), `exit` (`approach`, `check`, `none`) |\n| `open77_seating:sitNearest(playerId, options?)` | the nearest free seat within 3 m, or `nil, reason` |\n| `open77_seating:stand(playerId)` | `true`, or `nil, \"not_seated\"` |\n| `open77_seating:current(playerId)` | the player's seat, or `nil` when standing |\n| `open77_seating:occupants(bucket, furnitureId)` | the ids of the players sitting on that furniture |\n| `open77_furniture:nearby(position, radius?, filter?)` | furniture within `radius` m (10 by default, 50 at most), nearest first, read from memory without a world query; or `nil, reason`. `filter`: `kind`, `posture`, `verification`, `playable`, `vertical`, `limit` (5 by default, 32 at most), `poses` |\n| `open77_furniture:get(id)` | one piece of furniture with all its poses (`poseList`), or `nil, \"unknown_furniture\"` |\n| `open77_furniture:status()` | `indexed`, `version`, `furniture`, `spots` |\n\nA seat has `playerId`, `furnitureId`, `spot`, `kind`, `posture`, `profile`, `state` (`approaching`,\nthen `seated`), `bucket` and `anchor`. A piece of furniture has `id`, `kind`, `posture`, `distance`,\n`x`, `y`, `z`, `yaw` and its best `pose` (`profile`, `verification`, `arrivalRadius`: 0.5 m for\nchairs and leans, 0.75 m for bar seats, 1 m for bar stools and the bed). Catalogue lookups answer\n`nil, \"indexing\"` for the first seconds after it starts.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":3,"text":"| Event | Fields |\n|---|---|\n| `open77_seating:seated` | `playerId`, `furnitureId`, `spot`, `kind`, `posture` (`sit`, `lean`, or `lie` on a bed), `profile`, `bucket`, `playbackId` |\n| `open77_seating:stood` | `playerId`, `furnitureId`, `spot`, `bucket`, `reason` |\n\n`reason` is `stood` (X, `/stand` or your `stand` call), `cancelled_by_player`, `moved`, `dead`,\n`disconnected`, `vehicle`, `combat`, `bucket_changed`, `claim_lost`, `furniture_changed`,\n`resource_stopping`, `anchor_unreached` or `animation_interrupted`. When the player never sat down:\n`approach_blocked`, `approach_failed`, `approach_timeout`, `animation_refused` or `actor_busy`.","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-player-is-told","heading":"What the player is told","level":3,"text":"| Reason | Banner |\n|---|---|\n| `seat_taken`, `npc_occupied` | Someone is already sitting there. |\n| `target_claimed` | This seat is in use. |\n| `too_far` | Too far away. |\n| `already_seated` | You are already seated. |\n| `not_seated` | You are not seated. |\n| `actor_busy` | Finish what you are doing first. |\n| `in_combat` | Not while fighting. |\n| `player_in_vehicle` | Not from a vehicle. |\n| `no_playable_pose` | There is no free seat here. |\n| `approach_blocked` | Something is in the way. |\n| `approach_failed` | You cannot get there. |\n| `indexing` | Seats are not ready yet. |\n\nAny other reason shows as \"Refused: \" followed by the reason, such as `furniture_unavailable`,\n`player_not_alive` or `bucket_not_enabled`. Your own `sit` calls return the reason to you; only a\nfailure after the request was accepted, such as `approach_blocked`, reaches the player's screen.","natives":[],"permissions":[],"events":[]},{"anchor":"console-commands","heading":"Console commands","level":3,"text":"Restricted commands; the server console can always run them.\n\n| Command | Does |\n|---|---|\n| `furniture.near [count] [radius] [kind]` | lists the furniture nearest a player, with ids |\n| `seating.near [count] [radius]` | lists the seats nearest a player and who holds each |\n| `seating.status`, `seating.claims` | the catalogue version and settings; every seated player and claim |\n| `seating.sit ` | seats a player within 4.5 m, skipping the verified-only and sit-or-lean filters (beds work); occupancy still applies |\n| `seating.stand ` | stands a player up |\n| `seating.verification `, `seating.exit ` | changes that setting until the next restart |","natives":[],"permissions":[],"events":[]},{"anchor":"operator-notes","heading":"Operator notes","level":2,"text":"- **List `open77_furniture` next to `open77_seating`.** The catalogue runs on the server only, and a\n client refuses a resource that depends on a server-only one, so seating does not name it as a\n dependency: it calls its exports and answers `furniture_unavailable` while it is not running.\n- **Restarting `open77_seating`** stands every seated player up where they are and removes the\n prompts. `ensure open77_actions` restarts seating too, since seating depends on it.\n- **Restarting `open77_furniture`** leaves seated players seated. New requests are refused until the\n catalogue is indexed again. If a new catalogue no longer has a seat, the player on it stands up.\n- **The game's own crowd is not coordinated.** Servers that run `open77_pedestrians` suppress it.","natives":[],"permissions":[],"events":[]}]},{"slug":"gamemode-kernel","file":"gamemode-kernel.md","title":"The gamemode kernel and shared server services","summary":"Organize gamemode rules in server resources and expose reusable services through [server exports](server-exports.md). Open77 does not ship a universal `open77_gamemode` resource.","sections":[{"anchor":"the-gamemode-kernel-and-shared-server-services","heading":"The gamemode kernel and shared server services","level":1,"text":"Organize gamemode rules in server resources and expose reusable services through [server exports](server-exports.md). Open77 does not ship a universal `open77_gamemode` resource.\n\nKeep tightly coupled match state in one resource. Extract reusable services when they need an independent lifecycle and a clear request interface.","natives":[],"permissions":[],"events":[]},{"anchor":"shared-server-resources-are-supported","heading":"Shared server resources are supported","level":2,"text":"The server runtime now provides `exports(name, fn)`,\n`Open77.exports.call(resource, name, ...):await()`, `GetInvokingResource()` and\ngeneration identity. See [server exports](server-exports.md) for a runnable\ntwo-resource example and the precise lifecycle contract.\n\nShared services can own scores, lobbies or bucket allocation. Values cross resource boundaries by copy. Synchronous exports must not yield; asynchronous calls are deferred and awaited. Validate callers and arguments, and keep state changes atomic before yielding.\n\n`TriggerEvent` remains local to a VM. The host still fans lifecycle/player events\ninto resources. Export registration does not create a network entry point.\nClient services such as `open77_zones` and `open77_worldui` remain client-only\npackages; adding server exports does not move their presentation logic to the server.","natives":[],"permissions":[],"events":[]},{"anchor":"the-scaffolder-remains-useful","heading":"The scaffolder remains useful","level":2,"text":"`scripts/new-resource.ps1 -Kind gamemode` creates a resource with a guarded state machine, reload-safe roster handling and a `.status` command.\n\nUse generated code for tightly coupled state-machine rules, and server exports\nfor reusable services with a clear ownership boundary. See\n[writing a gamemode](../docs/writing-a-gamemode.md) section 7. No forced migration\nor new dependency is added to existing gamemodes by the runtime change.","natives":[],"permissions":[],"events":[]},{"anchor":"the-contract-every-gamemodes-server-should-implement","heading":"The contract every gamemode's server should implement","level":2,"text":"Apply these lifecycle and authority conventions to each gamemode. See [Writing a gamemode](writing-a-gamemode.md) for examples.\n\n| Convention | Why | Where it lives |\n|---|---|---|\n| **Reload-safe roster adoption.** Use `Open77.players.all()` for the current roster and retain lazy `ensurePlayer` handling at player-event boundaries. | A reload must not lose track of connected players. | `ensurePlayer` in [`resources/gamemodes/pursuit/server/main.lua`](https://github.com/Open2077/open77-resources/blob/main/gamemodes/pursuit/server/main.lua) and `resources/gamemodes/race/server/main.lua` |\n| **`tonumber` every player ID.** IDs arrive from net events and lifecycle handlers as strings. | A raw string key silently diverges from the numeric IDs used everywhere else. | Every `AddEventHandler(\"onPlayer...\", ...)` in both resources |\n| **Guarded state transitions, one function.** A single `transition(playerId, target, detail)` that checks an explicit table of allowed edges and logs a refusal instead of corrupting state. | An invalid transition is more useful as a log line than as silent corruption. | `transition` in both resources |\n| **Move players with `Open77.players.teleport`.** Never a raw transform write. Kill -> respawn was the ONLY placement primitive before `teleport` existed -- `Open77.players.respawn` refuses unless the player is already dead -- and the modes that still do it are unconverted, not exemplary. | A raw transform write over distance drops the player into unstreamed world and the engine's fall-under-world failsafe silently returns them to the save's spawn. `teleport` carries the fade and waits for the client to report the body settled, without costing a death. | `placeAt` in both resources (kill -> respawn, pre-`teleport`); [Moving a player](server-api.md#moving-a-player) |\n| **Re-derive every client-reported condition on the server.** A zone `enter` event, a checkpoint claim, a queue intent -- all are hints. Re-check them against `Open77.players.position` with a few metres of grace before granting anything. | The client is never the authority (writing a gamemode, section 4). | `containsPlayer` (Pursuit) / `checkpointReached` (Race) |\n| **Never judge a single position sample.** Every rule is \"held continuously for N seconds,\" evaluated on a fixed tick; an unreadable position freezes an accumulator, never resets it. | `Open77.players.position` is a replicated snapshot, not a live read (writing a gamemode, section 2.5). | Pursuit's win-condition tick; Race's per-second heat-state check |\n| **One `.where`-style diagnostic, early.** Print exactly what the server sees for one player -- position, bucket, the rule's own verdict and reason. | Nearly every confusing failure in this project was answered in one line by such a command (writing a gamemode, section 6). | `pursuit.where`, `race.where` |\n| **Isolate a round in its own routing bucket.** Allocate from a reserved range, disable ambient population, release on resolution. | Two rounds must never see each other, and neither should inherit the lobby's crowd. | Pursuit's per-match bucket pool; Race's single heat bucket |","natives":[],"permissions":[],"events":[]},{"anchor":"what-this-means-for-a-new-mode","heading":"What this means for a new mode","level":2,"text":"Run the scaffolder and build the state machine your mode needs. Extract a\nreusable service when its contract justifies a separate lifecycle; declare the\ndependency for startup ordering and use generation-bound server exports to call\nit. Keep player readiness and authority checks intact across the new boundary.\nExisting shared client presentation services are documented separately:\n\n- [Proximity zones](zones.md) (`open77_zones`)\n- [World-anchored POIs](worldui.md) (`open77_worldui`)","natives":[],"permissions":[],"events":[]}]},{"slug":"gizmos","file":"gizmos.md","title":"Entity gizmos (client Lua)","summary":"`Open77.gizmos` is a resource-owned, native 3D manipulation service. Use it for placement editors, spawn-point tools, vehicle staging, scene composition or a custom editor that listens to transform changes. Handles are drawn by the native overlay, not a WebUI page. Opening a gizmo does not pause the server.","sections":[{"anchor":"entity-gizmos-client-lua","heading":"Entity gizmos (client Lua)","level":1,"text":"`Open77.gizmos` is a resource-owned, native 3D manipulation service. Use it for\nplacement editors, spawn-point tools, vehicle staging, scene composition or a\ncustom editor that listens to transform changes. Handles are drawn by the native\noverlay, not a WebUI page. Opening a gizmo does not pause the server.","natives":[],"permissions":[],"events":[]},{"anchor":"permission-and-execution-side","heading":"Permission and execution side","level":2,"text":"```lua\npermissions { 'gizmos.edit' }\n```\n\nAll methods require this permission, including queries. The service exists on the\n**client** only. It is available to bundled and server-downloaded client resources;\nhandles and events belong to one exact resource VM and generation. A resource\ncannot control another resource's handle, including a same-named resource on a\ndifferent host. Handles are invalid after resource stop/reload or world teardown.\n\nThe permission grants local editor access, **not server authority**. Never accept\na client transform on the server without checking staff rights, ownership,\ndistance, routing bucket, target lifetime and value bounds.","natives":[],"permissions":["gizmos.edit"],"events":[]},{"anchor":"quick-start-a-virtual-placement-point","heading":"Quick start: a virtual placement point","level":2,"text":"```lua\nlocal G = Open77.gizmos\nlocal handle\n\n-- Call from your editor's open action, after releasing any WebUI cursor focus.\nlocal function editPoint(position)\n if handle then G.destroy(handle); handle = nil end\n local why\n handle, why = G.create({\n transform = { position = position },\n mode = 'translate', space = 'world',\n snap = { translation = 0.25, rotation = 15, scale = 0.1 },\n timeoutMs = 120000,\n })\n if not handle then return false, why end\n local ok, error = G.activate(handle)\n if not ok then G.destroy(handle); handle = nil end\n return ok, error\nend\n\nAddEventHandler('open77:gizmo:change', function(state)\n if state.id ~= handle then return end\n -- Update a placement ghost or numeric inspector here.\n -- state.transform.position, .rotation and .scale are full values.\nend)\n\nAddEventHandler('open77:gizmo:commit', function(state)\n if state.id ~= handle then return end\n local p = state.transform.position\n print(('Selected point: %.3f, %.3f, %.3f'):format(p.x, p.y, p.z))\n G.destroy(handle)\n handle = nil\nend)\n```\n\nA virtual target transforms data only. It does not spawn or move an entity.\nThe transform defaults to identity rotation and unit scale; position defaults\nto `{x=0,y=0,z=0}`. Give it an explicit position in front of your editor camera.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:gizmo:change","open77:gizmo:commit"]},{"anchor":"target-identities-and-capabilities","heading":"Target identities and capabilities","level":2,"text":"```lua\nlocal target = { kind = 'vehicle', id = vehicleId }\nlocal caps, error = Open77.gizmos.capabilities(target)\nlocal id, error = Open77.gizmos.create({ target = target })\n```\n\n| `kind` | `id` refers to |\n| --- | --- |\n| `transform` | No entity; omit `id` or use `0`; requires `transform` |\n| `entity` | Receiver-local Open77 entity-registry handle; `0` is own player |\n| `puppet` | Receiver-local entity handle, checked to be a puppet |\n| `player` | Network player ID; `0` means own player |\n| `npc` | Server NPC ID |\n| `vehicle` | Server vehicle ID |\n| `prop` | Server prop ID |\n| `localProp` | Local decorative prop ID owned by the calling resource |\n\nIDs accept **unsigned decimal strings** (full 64-bit) or exact safe numbers up to\n`9007199254740991`. Snapshot target IDs are always decimal strings. Keep IDs from\nexisting APIs as strings; never use `tonumber` to round-trip a network ID. Engine\npointers, TweakDB record names and engine EntityIDs are not accepted identities.\n\nThe target must be streamed/attached when created and activated. The same native\nincarnation is checked before changes and at every active tick; a replacement is\nnot silently edited. Attached props and occupied vehicles are refused. Vehicle\noccupancy checks include native NPC passengers and mounting relations, not only\nnetwork player seats; a generic `entity` alias cannot bypass them. An unavailable\noccupancy query is a refusal, not evidence that a car is empty. A player\nmust leave vehicles/workspots/stand-in animations before entering the editor.\n\nCapabilities are returned as:\n\n```lua\n{\n translate = {x=true, y=true, z=true},\n rotate = {x=false, y=false, z=true},\n scale = {x=false, y=false, z=false},\n preview = true, commit = false, networked = true,\n}\n```\n\n`preview` means the native adapter can move the target locally. `commit` means\nit can retain that change locally after accepting. `networked` means the target\nhas a replicated identity. A network target's `commit=false` does **not** disable\nthe `commit()` method: acceptance emits a proposal for your server logic and\nrequests restoration of the local preview. An own-player teleport is queued:\nthe commit event is a proposal, not proof that the native body has finished\nrestoring or that a server accepted the proposed placement.\n\nFor own-player previews, the client keeps sending the last normal position as\nan idle keepalive, never the editor position. When editing ends, movement stays\nblocked briefly until independent native reads confirm restoration. Starting a\nnew gizmo during this interval returns `gizmo_restore_pending`. A newer normal\nteleport takes priority (`travel_superseded`); a restoration that fails for five\nseconds ends a network session with `gizmo_restore_failed` rather than publishing\nthe preview pose. Completion is reported separately through `settled`/`error`;\nthe validation status above remains applicable.\n\nVehicle previews similarly fence native motion capture and use baseline\nkeepalives for parked owners. Restoration keeps the editor's physics hold until\nthe destination is observed, then observes the resumed rigid body before\nreopening motion capture. A server freeze or observer mask remains independent.\nAn unsuccessful five-second vehicle restoration ends a network session with\n`gizmo_vehicle_restore_failed`. `commit`, `cancel` and `deactivated` events do\nnot acknowledge completion of these asynchronous restoration fences; wait for\n`settled`/`error` and read `nativeTransform` as described below. Motion fencing is\nnot a collision sandbox: avoid previewing a vehicle through nearby actors, and\nreserve a clear editing area on the server. Local collision isolation is still\nunder investigation.\n\nAn explicit server vehicle placement (`Open77.vehicles.setTransform`) takes\npriority over an active preview: `cancel` and `deactivated` carry\n`reason='transform_superseded'`, and no commit proposal is emitted. If placement\narrives just after Enter/`commit()`, while native restoration is still pending,\nthe fence switches to the new server destination instead of restoring the old\norigin. This is not a server lock: your editor must still validate ownership,\nrevision and occupancy before accepting any proposed transform.\n\nAn NPC `enterVehicle` task also takes priority: it cancels a vehicle preview with\n`vehicle_entry_requested`, then waits for native restoration before attempting\nthe mount. This guard is in the client NPC task driver, not a global seat lock;\nreserve the vehicle on your server when other players or native AI could board.\nWith `preview=false`, only editor data changes: the native car is never moved or\nfrozen, and accepting it still requires your own server validation. There is no\nasynchronous `settled` event for that callback-only mode.\n\nCapabilities are refreshed before activation. If the target's capabilities or\nauthority model change during an edit, writes/commit fail with\n`target_capabilities_changed`, and the next tick cancels that edit. Explicitly\nreactivate after inspecting the new capabilities; a stale handle never retains\nthe former ability to commit locally.\n\nCurrent native adapters provide XYZ translation; puppets, players and managed\nprops support yaw rotation only. Vehicle and generic non-puppet root transforms\ncan use quaternion rotation. Arbitrary native entity scaling is not exposed as\na working capability: REDengine render proxies and physics do not share a safe\nlive scale setter. Virtual targets support all three scale axes and uniform\nscale, letting an author connect the result to their own rebuild/resize API.\nDo not treat a scaling handle as evidence that a physical collider was resized.","natives":["Open77.gizmos.capabilities","Open77.gizmos.create"],"permissions":[],"events":[]},{"anchor":"options","heading":"Options","level":2,"text":"```lua\n{\n target = {kind='transform', id='0'}, -- default target\n transform = { -- virtual targets only\n position = {x=1, y=2, z=3},\n rotation = {x=0, y=0, z=0, w=1}, -- normalized quaternion, not Euler\n scale = {x=1, y=1, z=1},\n },\n mode = 'translate', -- translate | rotate | scale\n space = 'world', -- world | local\n axes = {x=true, y=true, z=true},\n snap = {translation=0, rotation=0, scale=0},\n pivotOffset = {x=0, y=0, z=0}, -- target-local offset\n minimumPosition = false, -- false clears a bound\n maximumPosition = false, -- or {x=...,y=...,z=...}\n minimumScale = 0.01,\n maximumScale = 100,\n preview = true,\n visible = true,\n size = 100, -- 32..240 logical pixels at 1080p\n timeoutMs = 300000, -- 1000..1800000, starts at activation\n}\n```\n\nAll values must be finite. Positions are bounded to ±1,000,000; quaternion length\nmust be within 0.001 of one. Scale components must be 0.01..100 and positive.\nUnknown option keys, malformed vectors and invalid enum names are errors, not\nignored fallbacks. Sparse `axes`/`snap` patches preserve unspecified values.\n\nTranslation snap is in metres (0..1000); rotation is in degrees (0..180); scale\nsnap is a relative factor (0..10). Zero disables that snap. Snapping is relative\nto the transform at drag start, avoiding accumulated rounding errors. Position\nbounds are world-space. Scale uses **local axes even in world mode**, because\nnon-uniform world scale would require shear outside the transform format.\n\n`axes` selects pointer handles, while capability checks constrain both pointer\nand programmatic edits. `setTransform` intentionally does not snap the supplied\nnumeric value or restrict it to the selected manipulation mode.","natives":[],"permissions":[],"events":[]},{"anchor":"api-reference","heading":"API reference","level":2,"text":"Queries return a value or `nil, reason`. Mutations return `true` or `false, reason`.\n\n| Method | Result / effect |\n| --- | --- |\n| `create(options)` | Returns opaque string handle; does not capture input |\n| `get(id)` | Full snapshot of one owned handle |\n| `list()` | Array of all this VM's handles, including completed handles |\n| `active()` | This VM's active snapshot, or `nil` (never another owner's) |\n| `capabilities([target])` | Capability object; omitted target means virtual |\n| `activate(id)` | Acquire exclusive input, read fresh target pose, reset history |\n| `update(id, patch)` | Change mutable options; refused during a pointer drag |\n| `setTransform(id, patch)` | Edit active transform; one undo entry; refused mid-drag |\n| `undo(id)` / `redo(id)` | Apply previous/next accepted edit in bounded history |\n| `commit(id)` | Accept proposal, release input and emit result |\n| `cancel(id)` | Restore initial preview and release input |\n| `deactivate(id)` | Alias for cancellation, not implicit acceptance |\n| `destroy(id)` | Cancel if active, remove handle and free its quota |\n\n`update` accepts `mode`, `space`, `axes`, `snap`, `pivotOffset`, position/scale\nbounds, `visible`, and `size`. Target, initial transform, preview policy and\ntimeout are fixed at creation. An active gizmo cannot be hidden. Unsupported\nmodes fail with `mode_not_supported_by_target`.\n\n`setTransform` accepts a sparse `{position, rotation, scale}` patch. Present\nvectors/quaternions must be complete. It validates capabilities and bounds and\nreports native write failures before changing the logical transform.\n\nThere can be eight handles per VM, 64 total and **one active gizmo per client**.\nUndo/redo retains 32 edits; one pointer drag is one edit, not one per frame.\nAccepting/cancelling does not destroy the handle. Re-activation resets the edit\nbaseline/history; destroy completed handles when your editor no longer needs them.","natives":[],"permissions":[],"events":[]},{"anchor":"built-in-controls","heading":"Built-in controls","level":2,"text":"| Input | Action |\n| --- | --- |\n| Left-drag axis arrow / plane | Constrained translation |\n| Left-drag centre square | Camera-plane translation or uniform scale |\n| Left-drag rotation ring | Rotation around the selected axis |\n| `T` / `R` / `S` | Translation / rotation / scale, if supported |\n| `L` | Toggle local/world axes |\n| Hold `Shift` | Precision movement (0.1 multiplier) |\n| Hold `Alt` | Temporarily bypass snap |\n| `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` | Undo / redo |\n| `Enter` | Accept current transform, including an active drag |\n| `Escape` while dragging | Revert only this drag; keep editor open |\n| `Escape` otherwise | Cancel entire edit and release input |\n\nAn inactive handle captures no input. Activation refuses an existing keyboard or\ncursor owner. A WebUI menu taking focus, focus loss, player invalidation or camera\nloss cancels the editor and releases only its own restrictions. Other resources'\naction blocks, server freezes and observer physics must remain intact.","natives":[],"permissions":[],"events":[]},{"anchor":"events-and-snapshot-shape","heading":"Events and snapshot shape","level":2,"text":"Register with `AddEventHandler('open77:gizmo:', function(state) ... end)`.\nEvery event supplies **one snapshot**, with the same fields as `get(id)`.\n\n| Event suffix | Meaning |\n| --- | --- |\n| `created` | Handle allocated |\n| `activated` | Input acquired; fresh baseline captured |\n| `configured` | Options changed |\n| `hover` | Hovered handle changed |\n| `dragStart` | Pointer drag begins |\n| `change` | Valid transform applied; also used by undo/redo and drag cancellation |\n| `dragEnd` | Drag ended; reason `released`, `cancelled` or `committed` |\n| `undo` / `redo` | History operation completed |\n| `commit` | User/script accepted; network authority is still the server's job |\n| `settled` | Asynchronous local puppet placement or own-player/vehicle restoration was observed on the same native body |\n| `cancel` | Edit cancelled; asynchronous restoration may still be pending |\n| `targetLost` | Stream-out, invalidation, incarnation or capability change |\n| `error` | Native restoration/release reported failure |\n| `deactivated` | Editor/cursor released after commit/cancel/error; native restoration may still hold movement |\n| `destroyed` | Handle removed |\n\nSnapshot fields: `id`, `phase`, `reason`, `revision`, `target`, `capabilities`,\n`transform`, `initial`, `nativeTransform`, `mode`, `space`, `axes`, `snap`, `pivotOffset`,\n`minimumPosition`, `maximumPosition`, `minimumScale`, `maximumScale`, `size`,\n`timeoutMs`, `active`, `dragging`, `visible`, `preview`, `hover`, `handle`,\n`undoCount`, `redoCount`. Absent position bounds are `false`. Handle names are\n`none`, `x`, `y`, `z`, `xy`, `yz`, `zx`, `screen`, `uniform`.\n\nPhases are `idle`, `editing`, `committed`, `cancelled`, `failed`. Native pointers,\nVM cookies and incarnation tokens are never included. `revision` orders snapshots\nfor one handle and advances on events; it is not a server entity revision.\n\nEvents are local and delivered on the resource host's next dispatch. The native\nqueue is bounded to 512 snapshots; consecutive `change`/`hover` events can be\ncoalesced, and an abusive create/destroy loop can overflow old lifecycle events.\nUse `get`/`list` for current state, not an assumption that every rendered frame\nproduces a separate event. Stop/reload clears the retiring VM's pending events.\n\nLocal puppet commits use a queued native NPC teleport after releasing the editor's\nmover hold. `commit`/`deactivated` acknowledge acceptance, not native completion.\nWait for `settled` (`native_transform_settled`) or `error`\n(`gizmo_puppet_placement_failed` / `target_replaced`) before treating that placement\nas observed. The watch lasts at most five seconds and blocks new activation with\n`gizmo_restore_pending`, but does not keep the player's input captured. Destroying\nthe handle or stopping its resource ends retries and future completion delivery.\nOwn-player and vehicle previews also emit `settled` after native restoration,\nincluding on cancellation. Vehicles must pass both frozen-placement and resumed\nphysics readbacks. `reason` identifies what was observed:\n\n| Reason | Meaning |\n| --- | --- |\n| `native_transform_settled` | Local placement/restoration completed |\n| `native_preview_restored` | Network preview restored; this does **not** accept the proposed edit on the server |\n| `authority_transform_settled` | A newer normal travel/server placement superseded the destination and was observed |\n\n`nativeTransform` is `false` until successful asynchronous completion, then a\ntransform table with independently read native position/orientation and the\nknown unchanged scale. It is included in `get()` and events, and cleared on\nreactivation. **`transform` remains the edit proposal** (or initial baseline on\ncancel); do not use it as proof of the final native position. For example:\n\n```lua\nAddEventHandler('open77:gizmo:settled', function(state)\n print(state.reason, state.nativeTransform.position.x)\n -- Server acceptance needs its own response, even after this event.\nend)\n```\n\nNative timeouts report `gizmo_restore_failed` / `gizmo_vehicle_restore_failed`;\nbody replacement reports `target_replaced`. Failed readbacks have no\n`nativeTransform`. Stopping the resource/destroying the handle suppresses its\nfuture events; network preview cleanup still finishes safely. No `settled` event\nis generated for virtual edits, callback-only edits, synchronous prop edits or\nnetwork NPC/remote-player previews. Completion is an observation, not a promise\nthat an autonomous NPC or a physics vehicle will remain stationary afterwards.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:gizmo:settled"]},{"anchor":"authoritative-server-integration","heading":"Authoritative server integration","level":2,"text":"See the complete opt-in\n[server-authorized prop editor](https://github.com/Open2077/open77-resources/blob/main/examples/open77_gizmos_editor/README.md)\nfor the client/server implementation and runnable rejection tests. Its policy\nhas automated coverage and a one-client/server pointer-driven acceptance run.\nThis does not by itself prove cross-client presentation or persistence.\n\n1. The server checks permission and creates a short-lived edit ticket bound to\n player identity, resource, target and its current revision/routing bucket.\n2. It reserves/freezes the target as appropriate and asks that client to open it.\n3. The client previews locally. Edited player motion is replaced by baseline\n keepalives; parked-owner vehicle motion likewise uses baseline keepalives,\n while locally simulated NPCs send stationary baseline keepalives and suspend\n client task advancement. Terminal NPC task reports are also baseline-fenced;\n they must never submit the edited body as canonical placement.\n **This is not a server freeze**. Other clients\n continue receiving canonical state.\n A newer server NPC/prop pose, attachment/body change, NPC task or authority\n change preempts the preview. Cancellation runs before normal replication\n applies that state, so the old preview cannot overwrite it. Observer motion\n from another NPC authority likewise preempts an edit when the pose changes.\n4. `commit` sends the ticket and transform back. Treat this as untrusted input.\n5. The server revalidates the ticket, identity, target, revision, finite values,\n bounds and distance, then calls its existing `players`/`npcs`/`vehicles`/`props`\n mutation API. It acknowledges success or failure and always releases its lease.\n6. Cancellation, disconnect, resource stop and ticket expiry release the server\n lease. A client-side timeout is not sufficient server cleanup.\n\nDo not stream every `change` over the network by default. Keep previews local;\nsend accepted results or a deliberately rate-limited collaborative-editor stream.\nNative cancellation attempts restoration but cannot recreate a streamed-out or\ndeleted target. Such failures still release input and surface an error event.","natives":[],"permissions":[],"events":[]},{"anchor":"common-errors","heading":"Common errors","level":2,"text":"- `permission_denied:gizmos.edit`: add the manifest permission to a trusted editor.\n- `gizmos_unavailable_on_this_host`: client feature absent / server or validation host.\n- `gizmo_not_owned`, `gizmo_not_found`: wrong VM/generation or stale/destroyed handle.\n- `gizmo_input_busy`: another editor or menu owns input; release its focus first.\n- `gizmo_restore_pending`: own-player/vehicle restoration or local puppet placement is still being observed; retry later.\n- `game_not_focused`, `player_not_alive_in_world`: wait for a valid in-world player.\n- `target_not_streamed`, `target_not_attached`, `target_replaced`: query the target anew.\n- `local_prop_not_owned_or_missing`: local props can only be edited by their owner.\n- `target_vehicle_occupied`, `target_is_attached`: leave occupants/attachments intact.\n- `target_vehicle_occupancy_unavailable`: native occupancy could not be verified; retry when the vehicle is fully attached, never assume it is empty.\n- `mode_not_supported_by_target`, `*_axis_unsupported`: use capability reporting.\n- `target_capabilities_changed`: the active target's editing/authority model changed; cancellation follows, then inspect capabilities before reactivating.\n- `position_out_of_bounds`, `scale_out_of_bounds`: value outside configured limits.\n- `drag_in_progress`, `gizmo_not_active`, `nothing_to_undo`, `nothing_to_redo`:\n state-dependent refusal; refresh your inspector from `get`.\n\nNo method silently grants admin rights, changes a routing bucket, detaches an\noccupant, deletes a target or persists server data.","natives":[],"permissions":[],"events":[]}]},{"slug":"gorilla-arms","file":"gorilla-arms.md","title":"Gorilla Arms","summary":"Install native Gorilla Arms through the [Cyberware framework](cyberware.md). The server stores the implant, validates normal and charged punches, and controls damage and knockback.","sections":[{"anchor":"gorilla-arms","heading":"Gorilla Arms","level":1,"text":"Install native Gorilla Arms through the [Cyberware framework](cyberware.md). The server stores the implant, validates normal and charged punches, and controls damage and knockback.","natives":[],"permissions":[],"events":[]},{"anchor":"minimal-example","heading":"Minimal example","level":2,"text":"A self-service resource: an ACL-authorized player installs a free implant on their own\ncharacter with `/gorilla_install` and removes it with `/gorilla_remove`. It needs a\nconfigured database, the [`open77_appearance`](https://github.com/Open2077/open77-resources/tree/main/system/open77_appearance) character adapter and the [`open77_cyberware`](https://github.com/Open2077/open77-resources/tree/main/system/open77_cyberware)\nsupport resource (both are running by default).\n\n`resources/gamemodes/my_gorilla/open77.lua`:\n\n```lua\nresource \"my_gorilla\"\nversion \"1.0.0\"\nauto_start false\ndependency \"open77_cyberware >=0.1.0\"\nserver_script \"server/main.lua\"\npermissions {\n \"players.cyberware.define\",\n \"players.cyberware.read\",\n \"players.cyberware.manage\"\n}\n```\n\n`server/main.lua`:\n\n```lua\nlocal ready = false\nlocal pending = {}\n\nCreateThread(function()\n local result, reason = Open77.cyberware.define({\n id = \"myserver.gorilla\", version = 1,\n slot = \"arms\", profile = \"gorilla_arms\",\n grades = {\n { id = \"street\", normalDamage = 15, chargedDamage = 35,\n knockbackMeters = 2, normalKnockbackMeters = 0.5,\n cooldownMs = 800, chargeMs = 650, maxChargeMs = 10000,\n normalStaminaCost = 20, chargedStaminaCost = 35,\n nonlethal = true, cosmetic = false }\n }\n })\n ready = result ~= nil\n if not ready then print(\"Definition failed: \" .. tostring(reason)) end\nend)\n\nlocal function change(source, removing)\n local player = tonumber(source)\n if not player or player < 1 or player % 1 ~= 0 then\n print(\"Run this command as a connected player.\")\n return\n end\n if not ready or pending[player] then return end\n local record, reason = Open77.cyberware.current(player)\n if not record then print(\"Character not ready: \" .. tostring(reason)); return end\n if removing and not record.arms then print(\"No arm implant installed.\"); return end\n if not removing and record.arms then print(\"Remove the existing implant first.\"); return end\n local operation, error = Open77.cyberware.newOperationId()\n if not operation then print(error); return end\n local options = { expectedRevision = record.revision, operationId = operation }\n local result\n if removing then\n result, error = Open77.cyberware.remove(player, options)\n else\n result, error = Open77.cyberware.install(player, \"myserver.gorilla\", \"street\", options)\n end\n if not result then print(\"Request refused: \" .. tostring(error)); return end\n if result.ticket then\n pending[player] = { ticket = result.ticket, operationId = operation, options = options }\n print(\"Procedure pending for player \" .. player)\n else\n print(\"Operation already completed for player \" .. player)\n end\nend\n\nRegisterCommand(\"gorilla_install\", function(source) change(source, false) end, true)\nRegisterCommand(\"gorilla_remove\", function(source) change(source, true) end, true)\n\nAddEventHandler(\"onCyberwareOperationCompleted\", function(player, ticket, encoded)\n player = tonumber(player)\n local transaction = player and pending[player]\n if not transaction or transaction.ticket ~= ticket then return end\n local result = json.decode(encoded)\n pending[player] = nil\n if result and result.ok == true then\n print(\"Procedure completed for player \" .. player)\n else\n print(\"Procedure failed: \" .. tostring(result and result.error))\n end\nend)\n```\n\nAdd the resource to your server's resource set, run `ensure my_gorilla` from the\nconsole, grant the two restricted commands through your [command ACL](server-acl.md),\nand type `/gorilla_install` as that player. When the completion line prints, draw fists\n(the melee weapon slot) and punch: a tap is a normal punch, a hold of at least\n`chargeMs` released before `maxChargeMs` is a charged punch. `/gorilla_remove` restores\nthe native arms through the same staged transaction. Installing arms does not enable\nPvP: your server's combat scope, team and life rules decide who may be hit.","natives":["CreateThread","server:CreateThread","server:Open77.cyberware.define","server:Open77.cyberware.current","server:Open77.cyberware.newOperationId","server:Open77.cyberware.remove","server:Open77.cyberware.install","RegisterCommand","server:RegisterCommand","AddEventHandler","server:AddEventHandler"],"permissions":["players.cyberware.define","players.cyberware.read","players.cyberware.manage"],"events":[]},{"anchor":"grade-options","heading":"Grade options","level":2,"text":"A definition owns up to 32 grades. The installed grade is a snapshot: editing the\ndefinition later does not change implants already installed.\n\n| Grade field | Range / behaviour |\n|---|---|\n| `normalDamage`, `chargedDamage` | Required, 0–300 |\n| `knockbackMeters` | Required, 0–6 m, requested reaction distance for a charged punch |\n| `normalKnockbackMeters` | Optional 0–6 m; omitted means 35% of `knockbackMeters` |\n| `cooldownMs` | Required, 100–600000 ms between admitted punches |\n| `chargeMs` | Required, 100–10000 ms of hold before a punch counts as charged |\n| `maxChargeMs` | Default 10000; at least `chargeMs`, at most 60000. A longer hold expires until released and rearmed |\n| `normalStaminaCost`, `chargedStaminaCost` | Optional 0–300. Omitted keeps the native stamina price; set, the server prices it and suppresses the native debit |\n| `nonlethal`, `cosmetic` | Default false. Nonlethal caps damage to keep the victim alive; cosmetic sets damage and knockback to zero |\n| `blockDamageMultiplier` | Default 0, range 0–1; 0 is a full block, otherwise chip damage |\n| `blockAngleDegrees` | Default 45, range 0–180; 0 disables frontal blocking |\n\nGrades do not choose arm skins, elemental damage or attack animation speed.\n`cooldownMs` gates admission; lowering it does not make the native punch faster.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"| Event | Meaning |\n|---|---|\n| `onCyberwareOperationCompleted(player, ticket, encodedResult)` | Your install or removal finished; require `result.ok == true` |\n| `onCyberwareMeleeHit(victim, attacker, encodedSnapshot)` | An accepted, unblocked contact, including zero-damage ones |\n| `onCyberwareMeleeBlocked(victim, attacker, sequence, amount)` | An accepted block; `amount` is 0 for a full block or the chip damage |\n| `onCyberwareMotionOutcome(victim, attacker, encodedOutcome)` | Whether the hit requested, skipped or was refused a knockback |\n| `onCyberwareActionRejected(player, sequence, error)` | A punch the server refused, for example `cooldown`, `insufficient_stamina`, `cyberware_suspended`, `weapon_glitched` |\n\nPlayer IDs arrive as strings; decode JSON arguments with `json.decode`. The hit\nsnapshot carries `sequence`, `incarnation`, `instanceId`, `definition`, `grade`,\n`charged`, `amount`, `bodyPart` and `lethal`. See [Cyberware](cyberware.md#events)\nfor the motion-outcome fields.","natives":[],"permissions":[],"events":[]},{"anchor":"charge-effects-and-sound","heading":"Charge effects and sound","level":2,"text":"The support resource `open77_cyberware` draws a charge effect while a player holds a\npunch and plays an impact sound on an accepted hit. It opts definitions in by their\nfull ID in `resources/system/open77_cyberware/server/presentation-config.lua`; the\nshipped example definitions are already listed. Add yours to its `definitions` table:\n\n```lua\n[\"myserver.gorilla\"] = {\n street = { enabled = true, chargedOnly = false }\n},\n```\n\nSet an entry to `false` to silence one definition, or the table's `enabled = false` to\nswitch the extra presentation off entirely. Per-grade keys: `effect`, `slot`,\n`localAnchor`, `localSlot`, `localEvent`, `chargeSoundEvent`, `soundOnOwner`,\n`impactSoundEvent`, `impactSoundDuration`, `impactSoundOnAttacker`,\n`impactSoundOnVictim`, `soundOnZeroDamage` and `chargedOnly`. Defaults are the\n`electric.industrial_arm` effect on the observer's `RightHand`, the native\n`spy_perk_charge` event on the owner's `weaponRight`, the `w_cyb_strongarms_spy_perk_charge`\ncharge sound and the `w_cyb_npc_strongarms_hit_face` impact sound for five seconds. One\neffect lease is created per hold and removed on release, rejection, holster, disconnect\nor resource stop. The whole policy is a server script on public APIs\n(`Open77.cyberware.activity`, `Open77.effects.attach/remove`): replace it without\ntouching installation or damage.","natives":[],"permissions":[],"events":[]},{"anchor":"optional-clinic-and-arena","heading":"Optional clinic and arena","level":2,"text":"[`open77_ripperdoc_example`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_ripperdoc_example) (`auto_start false`) is a consent-and-payment clinic. Grant\ndoctors `command.doc`. With both players alive, in one bucket and within three metres:\n`/doc offer training` (or `industrial`, `cosmetic`), `/doc inspect `,\n`/doc remove `. The patient looks at the doctor and holds **E** to accept or\npresses **F** to decline (`/implantaccept` and `/implantcancel` do the same in chat).\nPrices, grades, range and consent time are in its `server/config.lua`; its credit\nbalance is an in-memory demonstration that resets with the resource.\n\n[`open77_cyberarena_example`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cyberarena_example) (`auto_start false`) hands out temporary loadouts inside a\nconfigured bucket: `/cyberarena join [grade]`, `/cyberarena loadout `,\n`/cyberarena leave`. Leaving restores the paid implant. Training and industrial grades are\nnonlethal, cosmetic does no damage, lethal is an explicit choice.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"- Installed arms are the native `Items.StrongArms` for both body families, with the\n patient's own skin and customization keys. You cannot select other arm meshes.\n- Native and server damage do not stack: the server prices one hit per admitted action.\n Missing action samples do not earn charge time.\n- Knockback is a bounded native reaction (0–6 m requested, collisions decide the real\n travel). A victim still in a previous knockdown takes the damage but is not thrown\n again (`motion_busy` in the motion outcome).\n- Blocking needs a fresh defender observation, a melee weapon drawn, positive stamina\n and the right facing. Chip damage never triggers knockback, and block stamina is not\n configurable.\n- Restart your definition provider after restarting the core: a saved implant stays\n visible but cannot punch while its definition is not registered. If `activity(player)`\n stays `nil` for a ready implant, check the provider before reinstalling.\n- A [Weapon Glitch](hacking.md) hack refuses Gorilla arming with `weapon_glitched`;\n a Cyberware Malfunction refuses it with `cyberware_suspended`. The punch then lands\n as an ordinary melee hit.","natives":[],"permissions":[],"events":[]}]},{"slug":"ground-slam","file":"ground-slam.md","title":"Ground Slam / Quake","summary":"Grant Ground Slam to players using blunt melee weapons. Players can activate it on the ground or in the air; the server validates each impact and controls damage, knockback and ability costs.","sections":[{"anchor":"ground-slam-quake","heading":"Ground Slam / Quake","level":1,"text":"Grant Ground Slam to players using blunt melee weapons. Players can activate it on the ground or in the air; the server validates each impact and controls damage, knockback and ability costs.","natives":[],"permissions":[],"events":[]},{"anchor":"minimal-example","heading":"Minimal example","level":2,"text":"`open77.lua`:\n\n```lua\nresource \"my_quake\"\nversion \"1.0.0\"\nserver_script \"server/main.lua\"\npermissions { \"players.abilities.define\", \"players.abilities.manage\", \"players.abilities.read\" }\n```\n\n`server/main.lua`:\n\n```lua\nCreateThread(function()\n local result, reason = Open77.abilities.define({\n id = \"myserver.quake\", version = 1, profile = \"ground_slam\",\n config = {\n inputKey = \"l\",\n damage = 35, knockbackMeters = 1.5, cosmetic = false, nonlethal = true,\n staminaCost = 20, cooldownMs = 5000, radius = 4,\n },\n })\n assert(result and result.ok, reason)\nend)\n\nRegisterCommand(\"quake\", function(source, args)\n local player = tonumber(source)\n if not player or player <= 0 then return end\n local result, reason\n if args[1] == \"off\" then result, reason = Open77.abilities.revoke(player)\n elseif args[1] == \"cancel\" then result, reason = Open77.abilities.cancel(player)\n elseif args[1] == \"state\" then result, reason = Open77.abilities.current(player)\n else result, reason = Open77.abilities.grant(player, \"myserver.quake\") end\n print(\"quake: \" .. (result and json.encode(result) or tostring(reason)))\nend, false)\n```\n\nThe player types `/quake`, waits until `/quake state` reports `status = \"ready\"`, draws\na blunt melee weapon, closes any menu, then presses **L** on the ground or in the air.\nThe key must be released after the grant: a key held through the grant or through a\nmenu does not fire. `/quake off` revokes the grant, `/quake cancel` interrupts a slam in\nprogress while keeping the grant.\n\nEvery config key is optional. Omitted keys take the defaults below; a misspelled key\nmakes the whole definition `invalid_definition`. Set `cosmetic = false` whenever\n`damage` or `knockbackMeters` is not zero.","natives":["CreateThread","server:CreateThread","server:Open77.abilities.define","RegisterCommand","server:RegisterCommand","server:Open77.abilities.revoke","server:Open77.abilities.cancel","server:Open77.abilities.current","server:Open77.abilities.grant"],"permissions":["players.abilities.define","players.abilities.manage","players.abilities.read"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"| Call | Permission | Returns |\n|---|---|---|\n| `Open77.abilities.define(definition)` | `players.abilities.define` | `{ok=true}` or `nil, reason` |\n| `Open77.abilities.grant(player, definitionId)` | `players.abilities.manage` | `{ok=true}` or `nil, reason`; the native projection can still be pending |\n| `Open77.abilities.revoke(player)` | `players.abilities.manage` | Removes this resource's grant and cancels its active slam |\n| `Open77.abilities.cancel(player)` | `players.abilities.manage` | Cancels this resource's active slam, keeps the grant |\n| `Open77.abilities.current(player)` | `players.abilities.read` | Grant snapshot or `nil, reason` |\n\nA definition is `{id, version, profile = \"ground_slam\", config = {...}}`. `id` is 1–96\nASCII letters, digits, `_`, `.` or `-`; `version` is a positive integer. A version is\nimmutable once defined: to change a slam, define a new version. Definitions belong to\nthe resource that defined them, and a grant belongs to the resource that granted it.\n\n`current(player)` returns `player`, `incarnation`, `revision`, `definition`, `status`\n(`pending`, `ready` or `removed`), `activation` and `cooldownRemainingMs`. Only `ready`\nmeans the player can use the key.","natives":[],"permissions":[],"events":[]},{"anchor":"options","heading":"Options","level":2,"text":"| Field | Default | Accepted values |\n|---|---|---|\n| `inputKey` | `\"g\"` | One ASCII letter or digit, or a named key: `space`, `enter`, `return`, `tab`, `shift`, `ctrl`, `control`, `alt`, `capslock`, `backspace`, `insert`, `delete`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `f1`–`f12`. Case-insensitive. |\n| `allowGround`, `allowAir` | `true`, `true` | At least one must be true |\n| `requiredArms`, `requiredLegs` | `false`, `false` | Require active Gorilla Arms / double-jump legs |\n| `cosmetic` | `true` | Requires `damage = 0` and `knockbackMeters = 0` |\n| `nonlethal` | `true` | Damage cannot kill |\n| `damage` | `0` | 0–300 |\n| `knockbackMeters` | `0` | 0–6 |\n| `staminaCost` | `20` | 0–1000 |\n| `cooldownMs` | `5000` | 1000–120000 |\n| `radius` | `4` | 0.5–12 m |\n| `innerRadius` | `1` | 0 up to, but excluding, `radius`; full damage inside it |\n| `edgeMultiplier` | `0.25` | 0–1, damage multiplier at the edge of `radius` |\n| `heightBonusPerMeter` | `0` | 0–20, extra damage per metre of fall |\n| `maxHeight` | `10` | 0–30 m, caps the height used for the bonus |\n| `maxActivationMs` | `5000` | 1000–10000 |\n| `maxFallSpeed` | `30` | 1–60 m/s, fastest descent that can still slam |\n| `geometryDeadlineMs` | `750` | 250–1500 |\n| `maxFloorDelta` | `0.75` | 0.1–1 m between the slammer's floor and a target's floor |\n| `reaction` | `\"knockdown\"` | `\"knockdown\"` or `\"none\"` |\n| `impactEffect`, `impactSound` | `nil`, `nil` | Effect catalogue key (up to 96 chars) / sound event (up to 128 chars); ASCII letters, digits, `_.-` |\n| `effectOnOwner`, `soundOnOwner` | `false`, `false` | Also play the effect / sound for the slammer |\n\nThe shipped [`open77_cyberware_lab`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cyberware_lab) uses `impactEffect = \"impact.ground_slam\"` and\n`impactSound = \"w_cyb_strongarms_hit_back\"`.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"Five server-local events fire with `(player, encodedJson)`. Decode the second argument\nwith `json.decode`.\n\n| Event | When |\n|---|---|\n| `onAbilityActivation` | The server accepted a slam and charged stamina and cooldown |\n| `onAbilityPhase` | The slam moved through `windup`, `descent`, `contact`, `impact`, `recovery`, `complete` |\n| `onAbilityImpact` | Impact resolved; `targets` lists each hit player with `accepted`, `amount`, `error`, `motionId`, `motionError` |\n| `onAbilityCancelled` | The slam ended early; `reason` says why |\n| `onAbilityMotionOutcome` | A knockback reaction was requested, started or ended for a target |\n\nEvery payload carries `player`, `activation`, `incarnation`, `definition`, `phase`,\n`reason`, `position`, `bucket`, `mode` (`ground` or `air`), `phaseSequence`, `elapsedMs`,\n`serverTime` and the immutable `config`.\n\n```lua\nAddEventHandler(\"onAbilityImpact\", function(player, encoded)\n local event = json.decode(encoded)\n for _, target in ipairs(event.targets or {}) do\n print((\"%s slammed %s: accepted=%s amount=%s error=%s\")\n :format(player, target.player, tostring(target.accepted), tostring(target.amount), tostring(target.error)))\n end\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"optional-lab","heading":"Optional lab","level":2,"text":"`open77_cyberware_lab` is off by default (`auto_start false`) and open to every connected\nplayer once started. It binds **L** and ships four presets: `harmless` (no damage),\n`combat` (35 nonlethal damage, 1.5 m knockback), `gorilla` (combat, requires Gorilla\nArms) and `parkour` (combat, requires double-jump legs). All presets cost 20 stamina,\ncool down for five seconds and use a four-metre radius. `/cyberlab` opens a panel with a\nGround Slam card; the same controls exist as commands:\n\n```text\n/cyberlab slam grant harmless\n/cyberlab slam state \n/cyberlab slam cancel \n/cyberlab slam revoke \n/cyberlab slam diagnostics \n```\n\nOmit the player to target yourself; the server console must name one. The presets and\nthe binding live in `resources/gamemodes/open77_cyberware_lab/server/ground-slam.lua`.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"**Costs are spent on acceptance.** Stamina is charged and the cooldown starts when the\nserver accepts the slam, even if it is interrupted or hits nobody. There is no refund.\n\n**One grant per player per resource.** Granting the same definition twice is a no-op.\nGranting a new version replaces the projection but does not reset a running cooldown.\nA grant made by another resource cannot be revoked or replaced: the call returns\n`grant_owned`. Stopping your resource removes its definitions and grants; disconnecting,\ndying, changing body or changing routing bucket ends any slam in progress.\n\n**Grant refusals** return `nil, reason`: `definition_unavailable`, `definition_owned`,\n`grant_owned`, `grant_limit`, `body_unavailable` (dead, mounted or not ready),\n`equipment_required` (`requiredArms` / `requiredLegs` not met). **A refused tap** does\nnothing for the player and raises no server event; the reason is readable on the client\nas `lastServerError` in the `slamActivity` export below: `cooldown`,\n`insufficient_stamina`, `fall_speed_limit`, `mode_unavailable`, `motion_busy` (a\nknockdown or a dash owns the body), `cyberware_suspended` (a Cyberware Malfunction or a\nfreeze from a [hack](hacking.md)) and `crippled` (a Cripple Movement hack).\n\n**Native eligibility still applies.** The player must have a blunt melee weapon drawn;\nGround Slam does not install one. Ordinary landings and vanilla quick melee never trigger\nit. Cosmetic slams run the same eligibility and contact checks as damaging ones.\n\n**Who gets hit is the server's decision.** Targets pass the usual combat scope, team,\nPvP, god-mode and life checks, and each damaging target needs matching floor-and-contact\ngeometry from both the slammer's and the target's client. Missing, stale or disagreeing\nevidence fails closed: no damage. Damage falls off from `innerRadius` to `radius` by\n`edgeMultiplier`; `heightBonusPerMeter` adds to it up to `maxHeight`. `maxHeight` caps the\nbonus, it is not a safe-fall height: native fall damage still applies. The check is\nclient evidence with a bounded budget (four native queries per frame, 32 rays per\nchallenge, 64 queued challenges), not a server physics simulation, so two colluding\nmodified clients can lie to each other.\n\n**Presentation.** `impactEffect` is drawn by the shipped [`open77_cyberware`](https://github.com/Open2077/open77-resources/tree/main/system/open77_cyberware) adapter as one\ninstance at the contact point plus a ring of three around it, for observers within 90 m;\nthe slammer sees it only with `effectOnOwner`. `impactSound` plays once as a three-second\nspatial one-shot, excluding the slammer unless `soundOnOwner`. The slammer's own camera\nstagger and rumble come from the native landing, not from these fields.","natives":[],"permissions":[],"events":[]},{"anchor":"advanced-client-adapter","heading":"Advanced: client adapter","level":2,"text":"The client half is `open77_cyberware`, a system resource that is already running. Its\n`Open77.abilities` client methods (`configureSlam`, `slamState`, `requestSlam`,\n`approveSlam`, `cancelSlam`, `slamActivity`, `presentSlam`, `releaseSlam`) are owned by\nthat resource and need `player.abilities.project` / `player.abilities.read`. Gameplay\nresources should use the server API and the player's key, not these handles. Two\nread-only exports are available to any client resource:\n\n```lua\nCreateThread(function()\n local pending = Open77.exports.call(\"open77_cyberware\", \"slamActivity\")\n local activity = pending and pending:await()\n if activity then print(activity.phase, activity.elapsedMs, activity.grounded) end\nend)\n```\n\n`slamActivity` returns the current native activity (`phase`, `elapsedMs`, `grounded`,\n`position`, `verticalSpeed`, a bounded `history` of phase receipts) or `nil, reason`;\n`slamPresentation` returns the most recent presentation receipt, also on an observer.\nThe `open77:abilities:*` network events are the platform's own transport: a resource\ncannot forge a slam or damage an arbitrary player by sending them.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]}]},{"slug":"hacking","file":"hacking.md","title":"Hacking and counterplay","summary":"Build multiplayer hacking abilities with server-owned definitions, grants and target validation. The following sections describe the supported implants, upload rules and Lua interfaces.","sections":[{"anchor":"hacking-and-counterplay","heading":"Hacking and counterplay","level":1,"text":"Build multiplayer hacking abilities with server-owned definitions, grants and target validation. The following sections describe the supported implants, upload rules and Lua interfaces.\n\nDefault keys: hold **F8** to upload on the aimed player, **INSERT** to purge yourself,\n**DELETE** to purge the aimed ally, **END** to cancel your own upload. Players rebind them\nin Pause → Settings → KEY BINDINGS; the defaults live in\n[`resources/system/open77_hacking/client/config.lua`](https://github.com/Open2077/open77-resources/blob/main/system/open77_hacking/client/config.lua).","natives":[],"permissions":[],"events":[]},{"anchor":"minimal-example","heading":"Minimal example","level":2,"text":"A resource that defines one Short Circuit deck, one Self-ICE and one purge implant, and\ninstalls them on the player who runs `/hackkit deck`, `/hackkit ice` or `/hackkit purge`.\nIt needs the [`open77_appearance`](https://github.com/Open2077/open77-resources/tree/main/system/open77_appearance) character adapter and a configured database, like every\n[cyberware](cyberware.md) resource. Hacking implants are ordinary implants: the hacking\ndefinition and a cyberware definition with the same ID, version and grade IDs.\n\n`open77.lua`:\n\n```lua\nresource \"my_hacking\"\nversion \"1.0.0\"\ndependency \"open77_hacking >=0.1.0\"\nserver_script \"server/main.lua\"\npermissions {\n \"players.hacking.define\", \"players.hacking.read\", \"players.hacking.activate\",\n \"players.cyberware.define\", \"players.cyberware.read\", \"players.cyberware.manage\"\n}\n```\n\n`server/main.lua`:\n\n```lua\nlocal inert = { normalDamage = 0, chargedDamage = 0, knockbackMeters = 0, cooldownMs = 100, chargeMs = 100 }\nlocal kit = {\n deck = { id = \"myserver.short_circuit\", slot = \"operating_system\", profile = \"cyberdeck\" },\n ice = { id = \"myserver.self_ice\", slot = \"self_ice\", profile = \"self_ice\" },\n purge = { id = \"myserver.purge\", slot = \"purge\", profile = \"active_purge\" },\n}\n\nCreateThread(function()\n assert(Open77.hacking.define({ id = kit.deck.id, version = 1, grades = {\n { id = \"training\", range = 20, uploadMs = 2000, staminaCost = 20, cooldownMs = 5000,\n damage = 25, statusMs = 750, recoveryMs = 4000, nonlethal = true },\n }}))\n assert(Open77.hacking.defineIce({ id = kit.ice.id, version = 1, grades = {\n { id = \"training\", charges = 1, rechargeMs = 15000 },\n }}))\n assert(Open77.hacking.definePurge({ id = kit.purge.id, version = 1, grades = {\n { id = \"training\", staminaCost = 15, cooldownMs = 8000, allowSelf = true, allowAlly = true,\n range = 10, cancelUploads = true, removeStatuses = true },\n }}))\n for _, item in pairs(kit) do\n local grade = { id = \"training\" }\n for k, v in pairs(inert) do grade[k] = v end\n assert(Open77.cyberware.define({ id = item.id, version = 1, slot = item.slot,\n profile = item.profile, grades = { grade } }))\n end\nend)\n\nRegisterCommand(\"hackkit\", function(source, args)\n local player, item = tonumber(source), kit[args[1] or \"deck\"]\n if not player or player <= 0 or not item then return end\n local record, reason = Open77.cyberware.current(player)\n if not record then print(\"character not ready: \" .. tostring(reason)); return end\n local result, error = Open77.cyberware.install(player, item.id, \"training\",\n { expectedRevision = record.revision, operationId = assert(Open77.cyberware.newOperationId()) })\n print(\"hackkit: \" .. (result and json.encode(result) or tostring(error)))\nend, false)\n```\n\nThe attacker runs `/hackkit deck`, waits for the installation to complete (see\n[transactions](cyberware.md#transactions)), aims at a visible player within 20 m and\nholds **F8** until the bar fills. The target sees an `INCOMING SHORT CIRCUIT` bar and a\nwarning, and can step behind cover, leave range, damage the hacker, or press **INSERT**\nif `/hackkit purge` was installed. `/hackkit ice` makes the next upload bounce off\nSelf-ICE. Your server's combat scope and team rules must allow the attacker to damage\nthe target, exactly as for any other attack.","natives":["CreateThread","server:CreateThread","server:Open77.hacking.define","server:Open77.hacking.defineIce","server:Open77.hacking.definePurge","server:Open77.cyberware.define","RegisterCommand","server:RegisterCommand","server:Open77.cyberware.current","server:Open77.cyberware.install","server:Open77.cyberware.newOperationId"],"permissions":["players.hacking.define","players.hacking.read","players.hacking.activate","players.cyberware.define","players.cyberware.read","players.cyberware.manage"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"All calls live under `Open77.hacking` (and `Open77.statuses` for the two status calls).\nFailures return `nil, reason`; mutations return `{ok = true, actionId = ...}`.\n\n| Call | Permission | Purpose |\n|---|---|---|\n| `define(definition)` | `players.hacking.define` | Hack definition and grades |\n| `defineIce(definition)` | `players.hacking.define` | Self-ICE definition and grades |\n| `definePurge(definition)` | `players.hacking.define` | Purge definition and grades |\n| `state(playerId)` | `players.hacking.read` | Cooldowns, ICE charges, uploads, statuses, and the running gates `cyberwareSuspendedMs`, `frozenMs`, `malfunctionMs`, `malfunctionBlocks`, `crippledMs`, `crippleHeavy`, `blindedMs`, `weaponGlitchedMs` |\n| `action(actionId)` | `players.hacking.read` | A running action or its terminal receipt |\n| `statuses(playerId)` / `Open77.statuses.list(playerId)` | `players.hacking.read` | Statuses currently on a player |\n| `start(actor, target, definition, grade, options)` | `players.hacking.activate` | Start an upload from script |\n| `purge(actor, target, definition, grade, options)` | `players.hacking.activate` | Purge from script, through the same rules as the key |\n| `cancel(actionId)` | `players.hacking.cancel` | Cancel your own upload |\n| `configureProtection(playerId, {safeArea = bool, resistance = number})` | `players.hacking.policy` | Safe-area flag and electrical resistance (0–1) for a player's current body |\n| `clearProtection(playerId)` | `players.hacking.policy` | Remove only your protection scope |\n| `Open77.statuses.apply(actor, target, definition, grade)` | `players.statuses.apply` | Apply a grade's status directly, subject to the same eligibility |\n| `Open77.statuses.remove(actionId)` | `players.statuses.purge` | Remove your status; damage already committed stays |\n| `capabilities()` | `players.hacking.read` | The kinds, fields and caps below |\n\n`start` and `purge` require `options = {operationId = \"...\"}`; reuse the ID when you\nretry the same call, a reused ID with different arguments is refused. Definitions belong\nto the resource that registered them; stopping it revokes them and cancels its uploads\nbut leaves installed implants on the record.","natives":[],"permissions":[],"events":[]},{"anchor":"options","heading":"Options","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"hack-grade","heading":"Hack grade","level":3,"text":"| Field | Default | Accepted values |\n|---|---|---|\n| `kind` | `\"short_circuit\"` | `short_circuit`, `overheat`, `malfunction`, `cripple`, `reboot_optics`, `weapon_glitch` |\n| `range` | `20` | 1–80 m |\n| `uploadMs` | `2000` | 500–15000 |\n| `staminaCost` | `20` | 0–300 |\n| `cooldownMs` | `5000` | 250–600000 |\n| `damage` | `25` | 0–300, the direct electrical hit of every kind |\n| `statusMs` | `750` | 0–2000; the Short Circuit disruption. Ignored by the other kinds |\n| `recoveryMs` | `4000` | `statusMs + 500` up to 60000; no attacker can hack this victim again inside it |\n| `lockHacking` | `false` | Short Circuit also blocks the victim's own hacking for `statusMs` |\n| `nonlethal`, `cosmetic` | `false` | Nonlethal never kills; cosmetic commits no damage |\n| `friendlyFire`, `allowSafeArea` | `false` | Grade eligibility; the server's combat veto still wins |\n| `interruptOnDamage`, `interruptDamage` | `true`, `0` | Whether accepted damage above the threshold (0–300) interrupts the hacker |\n| `evidenceFreshMs`, `challengeMs` | `500`, `1000` | 100–750 / 250–2000 ms for the line-of-sight evidence |\n| `effects` | `nil` | The outcome block below |\n| `burn`, `malfunction`, `cripple`, `optics`, `glitch` | `nil` | One block, matching `kind`; a block on the wrong kind is `invalid_definition` |","natives":[],"permissions":[],"events":[]},{"anchor":"what-each-kind-does","heading":"What each kind does","level":3,"text":"| Kind | On the victim | Server refuses | Block |\n|---|---|---|---|\n| `short_circuit` | Electrical hit, `statusMs` disruption | Hacking if `lockHacking` | — |\n| `overheat` | Direct hit, then a burn ticked by the server | — | `burn = {totalDamage 0–300 (40), durationMs 1000–15000 (5000), tickMs 250–1000 (500)}` |\n| `malfunction` | Listed abilities stop answering | Dash, Ground Slam, Gorilla arming, hacking and purge with `cyberware_suspended` | `malfunction = {durationMs 1000–30000 (5000), blocks = subset of dash, ground_slam, gorilla, hacking (all four)}` |\n| `cripple` | The victim's client slows itself: speed x0.75, or x0.4 with `heavy`, no dodge or double jump | Dash and Ground Slam with `crippled` | `cripple = {durationMs 1000–30000 (5000), heavy false}` |\n| `reboot_optics` | The victim's client applies the native blind: static, scanner off, accuracy x0.01 | Nothing | `optics = {durationMs 1000–15000 (5000)}` |\n| `weapon_glitch` | The victim's client jams the held weapon: it cannot fire | Gorilla arming with `weapon_glitched` | `glitch = {durationMs 1000–15000 (5000)}` |\n\nEvery block is optional (defaults in brackets). A burn is split evenly over\n`durationMs / tickMs` ticks, the first landing one `tickMs` after impact; `totalDamage = 0`\nis a burn with no damage. A malfunction that lists `hacking` also denies the victim its\nown INSERT; an ally's DELETE still works.","natives":[],"permissions":[],"events":[]},{"anchor":"the-effects-block","heading":"The `effects` block","level":3,"text":"Any grade can add outcomes to its connected upload:\n\n```lua\n{ id = \"stun\", range = 20, uploadMs = 2000, staminaCost = 20, cooldownMs = 6000,\n statusMs = 750, recoveryMs = 6000, nonlethal = true,\n effects = {\n damage = 10, -- replaces `damage`; 0–300\n freezeMs = 2000, -- hold the victim; 1–10000, at most recoveryMs\n knockdown = true, -- victim to the floor; needs recoveryMs >= 1500\n disableCyberwareMs = 5000 -- Dash, Ground Slam, Gorilla, hacking and purge refused; 1–30000\n } }\n```\n\n`knockdown` and `freezeMs` share one hold: the victim goes to the floor once (about three\nseconds); a longer freeze keeps the ability gate running, and the victim's own client\nlocks the body from the third second to the deadline. `disableCyberwareMs` is bound to\nthe character, so a reconnect does not clear it; a Malfunction status, by contrast, is\nlifted by purge, death, reconnect and a bucket change.","natives":[],"permissions":[],"events":[]},{"anchor":"self-ice-and-purge-grades","heading":"Self-ICE and purge grades","level":3,"text":"| Grade | Field | Default | Accepted values |\n|---|---|---|---|\n| ICE | `charges` | `1` | 1–8 uploads blocked before recharging |\n| ICE | `rechargeMs` | `15000` | 1000–600000 |\n| Purge | `staminaCost` | `15` | 0–300 |\n| Purge | `cooldownMs` | `8000` | 250–600000 |\n| Purge | `allowSelf`, `allowAlly` | `true`, `false` | At least one |\n| Purge | `range` | `10` | 1–40 m, for ally purge |\n| Purge | `cancelUploads`, `removeStatuses` | `true`, `true` | At least one |\n\nAlly purge needs both players on the same positive combat team. A purge charges its cost\nonly when it finds something to cancel or remove, and never heals damage already dealt.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"`onHackingTransition(encodedJson)` is the ledger of every hack. Decode the argument with\n`json.decode`: `actionId`, `owner`, `attacker`, `target`, `attackerIncarnation`,\n`targetIncarnation`, `definition`, `grade`, `kind`, `phase`, `reason`, `startedAt`,\n`deadline`, `statusEndsAt`, `amount`, `lockHacking`, `from`, `to` and, on a malfunction,\n`blocks`. Phases: `challenge`, `upload_started`, `upload_progress`, `blocked`,\n`interrupted`, `impact`, `completed`, `status_applied`, `status_tick` (one per burn\ntick, `amount` = damage committed), `status_expired`, `status_purged`, `effect_applied`\nand `effect_refused`.\n\n```lua\nAddEventHandler(\"onHackingTransition\", function(encoded)\n local t = json.decode(encoded)\n if t.phase == \"impact\" or t.phase == \"effect_applied\" then\n print((\"%s hacked %s: %s %s %s\"):format(t.attacker, t.target, t.kind, t.phase, tostring(t.reason)))\n end\nend)\n```\n\nDo not infer a second hit from `status_applied` or `completed`; only `impact` and\n`status_tick` are damage. Callbacks in one tick can arrive out of order, so correlate on\n`actionId` rather than on arrival.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"optional-lab","heading":"Optional lab","level":2,"text":"[`open77_hacking_lab`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_hacking_lab) (`auto_start false`) ships every kind as a grade of `lab.short_circuit`\nplus ICE and purge, and installs them through the cyberware APIs. Grant testers the\n`command.hacklab` ACL. With two connected, alive players `1` and `2` in bucket `0`:\n\n```text\n/hacklab attacker 1 training hacking-a-01 -- or overheat, malfunction, malfunction_motion,\n -- cripple, cripple_heavy, reboot_optics,\n -- weapon_glitch, stun, freeze_long, cosmetic\n/hacklab protected 2 training hacking-b-ice-01\n/hacklab ally 2 training hacking-b-purge-01\n/hacklab scope 0 1 2 -- allow PvP between the two; /hacklab unscope \n/hacklab state 2\n/hacklab purge 2 -- self purge from the server; /hacklab purge 2 1 for an ally\n/hacklab protection 2 on 0 -- safe area; \"off 0.5\" = 50% resistance\n/hacklab remove 2 self_ice hacking-b-ice-remove-01\n```\n\nThe last argument of an install or removal is the operation ID; use a new one for each\nnew operation. The grades are in `resources/gamemodes/open77_hacking_lab/server/config.lua`.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"**One status of each kind per victim.** A second Overheat, Malfunction, Cripple, Reboot\nOptics or Weapon Glitch inside the first lands its direct hit and `effects` but its\nstatus is refused, not refreshed: `effect_refused` with the running deadline in\n`statusEndsAt` (`status_stacked` from `statuses.apply`). Statuses of different kinds\ncoexist. With `recoveryMs >= durationMs` nobody is even admitted while the status runs.\n\n**Two halves.** The slow, the blind and the jam are applied by the victim's own client;\nthe ability refusals (`cyberware_suspended`, `crippled`, `weapon_glitched`, `frozen`)\nare the server's. A modified client can ignore its own slow, blind or jam, but it cannot\ndash, slam or arm Gorilla Arms through the gate. Reboot Optics has no server half at all.\n\n**Line of sight is client evidence.** The server issues short-lived challenges to both\nparticipants and each client traces the same segment against static and dynamic\ncollision. A missing, stale or failed trace blocks the upload. This is a shared-evidence\nmodel, not a server-side collision query: two colluding modified clients can defeat it.\n\n**The victim is always warned.** Every incoming upload produces a toast, a chat warning\nand the native `INCOMING ` bar. If the victim's notification cannot be delivered the\nupload fails closed. Repeated challenges do not repeat the warning.\n\n**Damage is one ledger write.** The direct hit is electrical (`hacking_impact`, no blood,\nno native hit reaction); `resistance` and safe areas reduce it. Burn ticks are thermal\nand ignore electrical resistance; a tick the combat rules refuse ends the burn. Purge,\ndeath, revive, reconnect, bucket change, the attacker leaving and the provider stopping\nall end a status; committed damage stays.\n\n**Cooldowns follow the character.** Hack, purge and recovery timers are bound to the\ndurable character across reconnects for the life of the server process; they do not\nsurvive a server restart.\n\n**Protection scopes combine.** Safe-area flags union and electrical resistance takes the\nmaximum across providers, so one resource cannot clear another's protection. Scopes\nexpire with the body they were set on.","natives":[],"permissions":[],"events":[]},{"anchor":"advanced-presentation","heading":"Advanced: presentation","level":2,"text":"`open77_hacking` renders every kind with fixed identifiers; replace its\n`server/presentation.lua` to change what observers see, and `client/config.lua`\n(`HackingInput.feedback = {flash = true, sound = true}`) to switch the victim's flash and\nlocal sound off.\n\n| Kind | Observers and attacker | Victim's own screen | Everyone in range |\n|---|---|---|---|\n| Short Circuit | Electrocuted effect on `RightHand` and `Chest`, `electric.arc` on `RightHand`, 1.5 s (to status end, max 2 s) | `status_electrocuted`, electric-blue flash | `quickhack_shortcircuit` |\n| Overheat | `fire.tiny` and `smoke.steam` on `Chest` for the burn | `hacks_overheat_lvl1`, heat-orange flash | `quickhack_overheat` |\n| Malfunction | EMP and electrocuted effects on `Chest`, `sparks.cable` on `RightHand` for the suppression | `hacks_cyberware_malfunction`, violet flash | `quickhack_cyberware_malfunction` |\n| Cripple | No body layer; the slow itself | `hacking_glitch_low`, crippled HUD icon, violet-blue flash | `quickhack_locomotion_malfunction` |\n| Reboot Optics | `electric.arc` on `Chest` for the blind | `status_blinded`, `hacking_glitch_low`, blinded HUD icon, white-cyan flash | `quickhack_optics_malfunction` |\n| Weapon Glitch | Electrocuted effect and `sparks.cable` on `RightHand` for the jam | `hacks_weapon_malfunction`, jammed-weapon HUD icon, amber flash | `quickhack_weapon_malfunction` |\n\nThe attacker's bar shows ` UPLOAD` while holding, then ` IMPACT`; the victim's\nshows `INCOMING `. The bar is drawn at 1.5x while it carries a hacking header.\nClient resources can observe a streamed target with `Open77.hacking.observe(playerId)`\nand drive the bar with `present({actionId, phase, progress, kind})` / `clear()` behind\n`player.hacking.project`; the victim-side statuses use `Open77.hacking.nativeStatus`\n(`player.hacking.status`) and `Open77.movement.lock` (`player.movement.lock`). None of\nthese grant hacking authority, and the `open77:hacking:*` network events cannot be sent\nby a resource.","natives":[],"permissions":[],"events":[]}]},{"slug":"holocall-eyes","file":"holocall-eyes.md","title":"Blue holocall eyes","summary":"Apply the native blue eye-glow effect to multiplayer players, including the local third-person body. The server owns this cosmetic state; clients display it on streamed bodies.","sections":[{"anchor":"blue-holocall-eyes","heading":"Blue holocall eyes","level":1,"text":"Apply the native blue eye-glow effect to multiplayer players, including the local third-person body. The server owns this cosmetic state; clients display it on streamed bodies.\n\nIt does not open the vanilla phone, play phone audio, set quest facts, restrict\nmovement or change the player's saved eye customization. It needs a client and\nserver build containing this API; a successful Lua call confirms canonical\nstate, not a native rendering acknowledgement.\n\nPublished in client/server **2.31.13+op77.62**, protocol **1.25**. Use matching\nupdated builds for the server setter and its client-side projection.","natives":[],"permissions":[],"events":[]},{"anchor":"quick-start","heading":"Quick start","level":2,"text":"Declare the permissions your resource needs:\n\n```lua\npermissions { \"players.holocall.control\", \"players.holocall.read\" }\n```\n\nIn a **server** script, after validating your own call rules:\n\n```lua\nlocal ok, reason = Open77.players.setHoloCallEyes(playerId, true)\nif not ok then print(\"Cannot enable call eyes: \" .. tostring(reason)) end\n\n-- When the call ends; releases only this resource's request.\nOpen77.players.setHoloCallEyes(playerId, false)\n```\n\n`playerId` is the connected **network player ID**, not a character record,\nuser UUID, engine entity ID or local puppet handle. The player must be ready\nand alive to enable the effect. Being in a vehicle is allowed.","natives":["server:Open77.players.setHoloCallEyes"],"permissions":["players.holocall.control","players.holocall.read"],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"| Function | Runtime | Permission | Result |\n| --- | --- | --- | --- |\n| `Open77.players.setHoloCallEyes(playerId, enabled, options?)` | Server | `players.holocall.control` | `true`, or `false, reason` |\n| `Open77.players.getHoloCallEyes(playerId)` | Server | `players.holocall.read` | enabled boolean, or `nil, reason` |\n| `Open77.players.getHoloCallEyes(playerId?)` | Client | `players.holocall.read` | replicated enabled boolean, or `nil, reason` |\n\n`enabled` must be an actual boolean. The only option is `durationMs`: an integer\nfrom **0 to 600000**, default **0** (until explicitly released or cleaned up).\nA false/disable request must use 0 or omit the duration. Unknown options,\nnumeric strings, fractional durations and invalid IDs are rejected.\n\n```lua\n-- Automatic release of this resource's lease after 30 seconds.\nassert(Open77.players.setHoloCallEyes(playerId, true, { durationMs = 30000 }))\n```\n\nCalling true again renews this resource's duration without restarting the glow.\nResources hold independent leases: one resource cannot extinguish another's\nactive request. Use the getter if you need the effective OR-combined state.\n\nClient reads default to the local player. They are available only after the\ninitial server snapshot and concern the current routing bucket. A false client\nresult for an unknown/other-bucket player does not reveal their global state.\nThere is **no client setter**. Route requests through your own server validation;\ndo not forward a client-chosen target ID without authorization.","natives":["server:Open77.players.setHoloCallEyes"],"permissions":[],"events":[]},{"anchor":"state-changes-and-cleanup","heading":"State changes and cleanup","level":2,"text":"Server resources can observe effective changes:\n\n```lua\nAddEventHandler(\"onPlayerHoloCallEyesChanged\", function(playerId, enabled)\n print((\"Player %d call eyes: %s\"):format(playerId, tostring(enabled)))\nend)\n```\n\nBoth parameters are typed: integer player ID and boolean enabled. The event\nreports the combined state, not every redundant lease renewal.\n\nThe platform automatically removes requests on death, disconnect, duration\nexpiry, or owning resource VM stop/reload/failure. Respawning/reconnecting does\nnot resurrect the previous call. Reapply from the phone resource if your\ngameplay rules explicitly resume it. No resource-stop handler is required just\nto release the glow.\n\nFull reliable snapshots restore the state for late joiners and routing-bucket\nchanges. Stream-out removes the local effect; stream-in, F7 activation and body\nreconstruction rebind it to the new presentation. Do not manually move lights,\nspam a per-frame effect command or call the private replication events.\n\nThe blue native effect slot is managed by this API. Avoid simultaneously\nstarting `eye_glow_blue` through generic VFX APIs on the same body.\n\nUse a client and server build that both include this API. No additional\ndownloaded client resource or third-party eye mod is needed. Native effects\nremain subject to rendering distance, streamed-body availability and the\nclient's existing VFX budget; the getter reports synchronized intent, not a\nguarantee that an off-screen or occluded eye is currently drawn.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"errors","heading":"Errors","level":2,"text":"| Reason | Meaning |\n| --- | --- |\n| `permission_denied:players.holocall.control` / `.read` | Missing manifest capability. |\n| `invalid_options`, `invalid_argument`, `invalid_duration`, `invalid_player` | Invalid type, option or numeric range. |\n| `player_unavailable` | No connected/observable target. |\n| `player_not_ready`, `player_not_alive` | Enabling before gameplay readiness or while dead. |\n| `resource_stopping` | The calling VM is retiring. |\n| `quota_exceeded` | Cosmetic state or per-player resource lease bound reached. |\n| `holocall_unavailable` | Server embedding does not provide this service. |\n| `state_unavailable` | Client has not received a snapshot for its current session. |\n\nThe API controls only the authored blue appearance. Other glow colors, eye\nmaterials, call voice routing and facial/lip animation are separate systems.","natives":[],"permissions":[],"events":[]}]},{"slug":"hud-visibility","file":"hud-visibility.md","title":"Vanilla HUD visibility","summary":"Use `Open77.hud` to hide selected native HUD modules and replace them with resource-owned interfaces. Client resources require `ui.vanilla.hud`:","sections":[{"anchor":"vanilla-hud-visibility","heading":"Vanilla HUD visibility","level":1,"text":"Use `Open77.hud` to hide selected native HUD modules and replace them with resource-owned interfaces. Client resources require `ui.vanilla.hud`:\n\n```lua\npermissions { \"ui.vanilla.hud\" }\n```\n\nThis is a client-only presentation API. It does not grant a server resource direct access to a\nplayer's HUD; a server gamemode should send an event to its client resource and let that resource\nown the visibility claim.","natives":[],"permissions":["ui.vanilla.hud"],"events":[]},{"anchor":"cinematic-display","heading":"Cinematic display","level":2,"text":"For a clean cinematic view including resource WebUIs, watermark, world labels\nand letterbox bars, use **`Open77.hud.setCinematic(true[, height])`**, then\n`Open77.hud.setCinematic(false)` to release your resource's claim. This requires\nthe same `ui.vanilla.hud` permission. See [cinematic display](third-person-camera.md#cinematic-display)\nfor ranges, ownership, cleanup and the deliberate focused-menu exception.\nFreeroam exposes this as `/cinematic [on|off]`.","natives":[],"permissions":[],"events":[]},{"anchor":"multiplayer-defaults","heading":"Multiplayer defaults","level":2,"text":"During an authenticated Open77 session, the platform automatically removes solo-game input hints,\nthe phone/radio/quick-slot cluster, and the lower-right standing/crouching silhouette. Those fixed\nmultiplayer restrictions are separate from `Open77.hud` and are restored when the multiplayer\npolicy ends.\n\n`Open77.hud` controls the remaining optional modules that a resource may replace with its own UI.\n\nThree of those fixed restrictions overlap with a component name — the quick-slot cluster is\n`hubMenu`, the phone row is `phone`, and the scanner is `scanner`. During a multiplayer session\nthe platform hides all three whether or not a resource claims them, and releasing your claim will\nnot bring them back until the session ends. Outside multiplayer, the claim is the only thing\nhiding them.\n\n`state()` and `isVisible()` report **claims**, not what is on screen: they answer \"does any\nresource hide this\", and they do not know about the multiplayer policy. A component can therefore\nread `true` while the session keeps it off the screen.","natives":[],"permissions":[],"events":[]},{"anchor":"components","heading":"Components","level":2,"text":"| Component | Vanilla UI controlled |\n|---|---|\n| `minimap` | Map panel, geometry, player marker, mappins, GPS lines, frame and location label |\n| `compass` | Compass strip in the top-right map area |\n| `clock` | Time display above the minimap |\n| `health` | Player health, armor, memory and attached buff bar |\n| `stamina` | Player stamina bar |\n| `weapon` | Complete active-weapon presentation: loaded/reserve ammunition, weapon icon, and legacy charge/trigger indicator |\n| `speedometer` | Digital and analog vehicle speed displays |\n| `questTracker` | The tracked-objective panel and its yellow GPS route |\n| `phone` | The standalone phone/messages HUD row |\n| `scanner` | The scan overlay **and scanner activation** — see the note below |\n| `vanillaNotifications` | Cyberpunk's own side-popup notification stack |\n| `crosshair` | Every weapon crosshair, including the melee and cyberware variants |\n| `hubMenu` | The bottom-left quick-slot / D-pad hub cluster |\n\nComponent names are matched case-insensitively, so `questTracker` and `questtracker` are the same\ncomponent. These aliases are also accepted by `setVisible` and `isVisible`:\n\n| Alias | Component |\n|---|---|\n| `map`, `radar` | `minimap` |\n| `time` | `clock` |\n| `hp` | `health` |\n| `ammo`, `weapons`, `weaponAmmo` | `weapon` |\n| `speed` | `speedometer` |\n| `quest`, `tracker`, `objectives` | `questTracker` |\n| `vision`, `visionMode` | `scanner` |\n| `notifications`, `notification`, `toasts` | `vanillaNotifications` |\n| `reticle`, `reticule` | `crosshair` |\n| `hub` | `hubMenu` |\n\nSnapshots always use the canonical names above.","natives":[],"permissions":[],"events":[]},{"anchor":"all","heading":"`all`","level":3,"text":"`all` is a **selector**, not a component: it claims or releases every component at once for the\ncalling resource. It never appears in `components()` or in a `state()` snapshot, because there is\nno widget called \"all\".\n\n```lua\nOpen77.hud.setVisible(\"all\", false) -- this resource now hides every component\nOpen77.hud.setVisible(\"all\", true) -- and releases every claim it holds\n```\n\n`isVisible(\"all\")` answers `true` only when **every** component is visible. A `false` there means\nat least one component is hidden by someone — not necessarily by you.","natives":["Open77.hud.setVisible"],"permissions":[],"events":[]},{"anchor":"two-components-that-do-more-than-hide-a-widget","heading":"Two components that do more than hide a widget","level":3,"text":"`scanner` is the one claim that also refuses an input. Hiding only the overlay would leave an\ninvisible scanner still holding aim snap and the movement restriction — a player walking slowly\nwith no UI explaining why. So the claim hides the scan overlay **and** refuses scanner activation,\nexactly as the multiplayer policy does. If you want to refuse the input without hiding anything,\nuse the `Scanner` entry of `Open77.actions` instead.\n\n`vanillaNotifications` is the same stack [`Open77.hud.notify`](#vanilla-toasts) writes to. A\nresource that hides this component is hiding its own vanilla toasts too.","natives":[],"permissions":[],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"```lua\nlocal ok, effectiveOrReason = Open77.hud.setVisible(component, visible)\nlocal visible, reason = Open77.hud.isVisible(component)\nlocal state, reason = Open77.hud.state()\nlocal components, reason = Open77.hud.components()\n```\n\n`setVisible(component, false)` adds a hide claim owned by the calling resource. Passing `true`\nreleases that resource's claim; it does not override another resource that still hides the same\ncomponent. On success, the second result is the effective visibility after all claims are combined.\n\nEvery claim is released automatically when the owning resource stops or reloads. Static widgets\nrestore the state observed before the first hide; contextual health and stamina widgets ask their\nvanilla controller to evaluate visibility again.\n\n```lua\nlocal ok, effective = Open77.hud.setVisible(\"health\", false)\nif not ok then\n error(effective)\nend\n\n-- Render the custom health WebUI here.\n\nlocal state = assert(Open77.hud.state())\nprint(state.health) -- false\n\n-- Optional: releasing explicitly avoids waiting for resource shutdown.\nOpen77.hud.setVisible(\"health\", true)\n```","natives":["Open77.hud.setVisible","Open77.hud.isVisible","Open77.hud.state","Open77.hud.components"],"permissions":[],"events":[]},{"anchor":"replace-the-complete-supported-hud","heading":"Replace the complete supported HUD","level":2,"text":"Use `components()` instead of copying the component list into a resource. This automatically picks\nup future supported components:\n\n```lua\nlocal hidden = assert(Open77.hud.components())\n\nfor _, component in ipairs(hidden) do\n local ok, effectiveOrReason = Open77.hud.setVisible(component, false)\n assert(ok, effectiveOrReason)\nend\n\n-- Create and update the custom WebUI here.\n\nlocal function restoreVanillaHud()\n for _, component in ipairs(hidden) do\n Open77.hud.setVisible(component, true)\n end\nend\n```\n\nExplicit restoration is useful during a gamemode transition. Resource stop and reload remain the\nfinal safety net, so a crashed UI resource cannot leave its claims behind.","natives":["Open77.hud.components","Open77.hud.setVisible"],"permissions":[],"events":[]},{"anchor":"multiple-resources","heading":"Multiple resources","level":2,"text":"Visibility is combined as an AND policy: a component is visible only when no resource currently\nhides it. For example, if both a racing HUD and a cinematic resource hide `minimap`, releasing the\nracing claim does not reveal the map until the cinematic resource releases its claim too.\n\n```lua\nlocal accepted, effective = Open77.hud.setVisible(\"minimap\", true)\nif accepted and not effective then\n print(\"another resource still hides the minimap\")\nend\n```\n\nThe complete `minimap` component includes its geometry, player marker, mappins, GPS route, frame,\nand location label. Hide `compass` or `clock` independently when those elements should remain.","natives":["Open77.hud.setVisible"],"permissions":[],"events":[]},{"anchor":"vanilla-toasts","heading":"Vanilla toasts","level":2,"text":"The same table also writes to Cyberpunk's own notification pipelines. These look native in a way a\nweb toast never will, and they cost nothing: no browser surface, no resource dependency.\n\n```lua\nOpen77.hud.notify(text [, options]) -- vanilla side popup\nOpen77.hud.log(text) -- a line in the activity log\nOpen77.hud.menu(preset) -- a vanilla menu notification\nOpen77.hud.clearNotify([channel]) -- \"ingame\" (default) or \"menu\"\n```\n\nThey need the same `ui.vanilla.hud` permission as the visibility calls, return `true` on success,\nand `false, reason` on failure.","natives":["Open77.hud.notify","Open77.hud.log","Open77.hud.menu","Open77.hud.clearNotify"],"permissions":[],"events":[]},{"anchor":"what-works-and-what-does-not","heading":"What works, and what does not","level":3,"text":"| Call | Pipeline | Status on 2.31 |\n|---|---|---|\n| `notify(text)` | `UIInGameNotificationEvent` — the side popup | **Works.** Confirmed on screen. |\n| `log(text)` | `gameActivityLogSystem.AddLog` | **Works.** Confirmed on screen. |\n| `menu(preset)` | `UIMenuNotificationEvent` | **Works**, but only while the menu layer that owns the queue is mounted. |\n| `help(text, ms)` | `MPDisplayOnscreenMessage` | **Unsupported.** Refused with `unsupported_channel`. |\n| `subtitle(text, ms)` | `MPDisplayNarrationEventEntry` | **Unsupported.** Refused with `unsupported_channel`. |\n\n`help` and `subtitle` are bound, validate their arguments, and then stop at the facade. They are not\nmissing by omission: the globals behind them are Cyberpunk's dormant multiplayer (CPO) HUD, which\nassumes a context the single-player build never mounts. Calling `MPDisplayOnscreenMessage` closed\nthe process with an access violation reading `0x0` (crash report\n`Cyberpunk2077-20260804-173018-14844-21448`), and the two neighbouring globals from the same group\nare not invoked on principle. The names stay bound so a resource written against them keeps working\nthe day the channel is real; until then, use `notify` and expect `false, \"unsupported_channel\"` from\nthe other two.","natives":[],"permissions":[],"events":[]},{"anchor":"the-in-game-popup","heading":"The in-game popup","level":3,"text":"`notify` queues a five-second side popup. The duration is the engine's and cannot be changed — that\nis why `notify` takes no `ms` argument while `help` does.\n\n```lua\nlocal ok, reason = Open77.hud.notify(\"BOUNTY CLAIMED\")\nif not ok then print(reason) end\n\n-- Replace what is on screen instead of queueing behind it.\nOpen77.hud.notify(\"ARREST WARRANT\", { replace = true })\n\n-- A vanilla preset: the engine shows the preset's own localized title.\nOpen77.hud.notify(nil, { preset = \"combat_blocked\" })\n```\n\n**A preset replaces your text.** Called with `\"Refused: already_fixed\"` and the `action_blocked`\npreset, the popup showed only the localized \"ACTION IMPOSSIBLE\". To tell the player *why*, use the\ndefault `generic` preset, which draws your text in the same banner.\n\n`options.preset` accepts `generic` (the default), `action_blocked`, `combat_blocked`,\n`cant_save_action`, `cant_save_combat`, `cant_save_quest`, `cant_save_death`, `save_slots_full`,\n`save_space_full`, `photo_mode_disabled`, `sandevistan_in_call` and `expansion_installed`.\n\n`menu(preset)` accepts `vendor_not_enough_money`, `not_enough_money`, `inventory_action_blocked`,\n`crafting_not_enough_materials`, `upgrade_level_too_low`, `no_perk_points`, `perks_locked`,\n`perks_max_level`, `no_attribute_points`, `in_combat`, `in_combat_explicit`, `crafting_quickhack`,\n`crafting_ammo_cap`, `player_level_too_low`, `inventory_full`, `face_unequip_blocked`,\n`tutorial_unequip_blocked` and `no_junk`. A menu preset carries its own localized title, so it takes\nno text. Two vanilla presets are deliberately absent: one needs a real gameplay payload, the other\nrenders no title at all on 2.31.\n\nThere is one vanilla slot per channel. `clearNotify` empties it whoever filled it, and hiding the\n`vanillaNotifications` component hides the whole stack — including your own toasts.","natives":["Open77.hud.notify"],"permissions":[],"events":[]},{"anchor":"which-toast-to-reach-for","heading":"Which toast to reach for","level":3,"text":"| You want | Use |\n|---|---|\n| A toast that looks like the game's own | `Open77.hud.notify` |\n| A toast you control — colour, icon, duration, stacking, dismissal | [`open77_notifications`](notifications.md) |\n\n`Open77.hud.notify` is free and native-looking but takes the engine's styling and its five seconds.\n`open77_notifications` is a WebUI service with its own definition format, updates and dismissal, at\nthe cost of a resource dependency and a browser surface.\n\nRefusals: `permission_denied:ui.vanilla.hud`, `invalid_notification_text`,\n`invalid_notification_preset`, `invalid_notification_duration`, `invalid_notification_channel`,\n`options_must_be_a_table`, `unsupported_channel`, plus the facade's own `wrong_thread`,\n`world_unavailable`, `ui_system_unavailable`, `activity_log_unavailable` and `invocation_failed`.","natives":[],"permissions":[],"events":[]},{"anchor":"debug-commands","heading":"Debug commands","level":2,"text":"When the privileged [`open77_debug`](https://github.com/Open2077/open77-resources/tree/main/system/open77_debug) resource is loaded, an authorized player can test the policy\nwithout client execution:\n\n```text\n/hud minimap hide\n/hud crosshair hide\n/hud all hide\n/hud minimap show\n/hud state\n/hudtest hide\n/hudtest show\n```\n\nCalls fail with `permission_denied:ui.vanilla.hud`, `invalid_hud_arguments`,\n`invalid_hud_component`, or `hud_unavailable_on_this_host`. This API is presentation-only: it\ndoes not modify health, stamina, ammunition, vehicle speed, or any replicated gameplay state.","natives":[],"permissions":[],"events":[]},{"anchor":"loading-screen-integration","heading":"Loading-screen integration","level":2,"text":"To hide the HUD only during a native load, see [custom loading\nscreens](loading-screens.md). The example owns and releases its HUD claims,\nkeeps a WebUI cover ready, and closes it when native loading finishes.","natives":[],"permissions":[],"events":[]}]},{"slug":"identity","file":"identity.md","title":"Player identity and username","summary":"Each installation has a persistent cryptographic identity. The Master verifies the username used by chat, nameplates, presence events and server scripts.","sections":[{"anchor":"player-identity-and-username","heading":"Player identity and username","level":1,"text":"Each installation has a persistent cryptographic identity. The Master verifies the username used by chat, nameplates, presence events and server scripts.","natives":[],"permissions":[],"events":[]},{"anchor":"stable-id-and-display-name","heading":"Stable id and display name","level":2,"text":"Identifiers have different jobs:\n\n| Value | Lifetime | Use |\n|---|---|---|\n| `license` | Permanent Open77 account | Characters, inventories and progression across linked devices |\n| `userId` / `open77` | Persistent installation identity | Existing identity-based APIs, including built-in access lists and ACLs |\n| `steam` / `gog` / `epic` | Linked store account | Store identity supplied by the verified Master ticket; Epic needs v3 |\n| `playerId` / `source` | One connection | Addressing the player during the current session |\n| `displayName` | Editable profile field | Chat and presentation only |\n\nFor account-level persistence, store `license` as a string. Use `userId` where an existing API\nrequires the installation identity; the two values are not interchangeable. A player can rename\ntheir profile and receives a new `playerId` after reconnecting. Existing data keyed by `userId`\nneeds an explicit migration before switching keys.\n\n`license`, `steam` and `gog` are available to server Lua since `2.31.13+op77.101`. See\n[Steam, GOG and permanent account identifiers](connection-control.md#steam-gog-and-permanent-account-identifiers)\nfor a complete join handler, formats and missing-identifier behavior.\n\nServer `2.31.15+op77.105` adds Epic identifiers, with the matching Master update\ndeployed. Older servers continue receiving v2 tickets: players can still join,\nbut `epic` is absent there. Updating a server to read Epic IDs is optional.\nA missing store ID never removes the Open77 `license`.\n\nSteam/GOG ownership is verified once and remains linked to the Open77 account. Since\n`2.31.13+op77.102`, an account manually approved by an Open77 administrator can play without a\nlinked store: `license` is present, while `steam` and `gog` may both be absent. Use the Open77\naccount key for progression rather than requiring a store ID.\n\nLinking is currently optional: players can skip it and still receive their permanent Open77\n`license`. An absent Steam/GOG ID is a supported account state, not a failed connection.","natives":[],"permissions":[],"events":[]},{"anchor":"changing-the-username","heading":"Changing the username","level":2,"text":"The server browser shows the current username in its **Identity** field. Pressing **Save** sends a freshly signed enrollment request to the Master. The Master accepts the update only when the private key already attached to the `userId` signed it.\n\nNames contain 1 through 32 UTF-8 bytes and cannot contain control characters. A username cannot be changed during an active game session.\n\nThe private key never leaves the Windows identity store. The request, response, and saved profile contain no reusable password.","natives":[],"permissions":[],"events":[]},{"anchor":"what-a-game-server-verifies","heading":"What a game server verifies","level":2,"text":"The Master issues an Ed25519 certificate covering all three public profile values:\n\n```text\nuserId || P-256 public key || displayName\n```\n\nDuring connection, the game server verifies that certificate and a fresh P-256 session proof bound to its challenge. Changing only the name in a modified client invalidates the Master certificate, so the forged name is rejected before the session becomes active.","natives":[],"permissions":[],"events":[]},{"anchor":"server-lua","heading":"Server Lua","level":2,"text":"Server resources receive the verified values through the normal player API:\n\n```lua\nAddEventHandler(\"onPlayerConnected\", function(playerId, playerName)\n local player = tonumber(playerId) or 0\n print(GetPlayerIdentifier(player)) -- installation userId\n print(GetPlayerName(player)) -- Master-verified displayName\n print(GetPlayerIdentifierByType(player, \"license\")) -- permanent Open77 account ID\nend)\n```\n\nThe connect event is `onPlayerConnected`, and it passes the player ID as its first\nargument. `source` is **not** set here: it is populated only for handlers reached through\na network event, never for a plain `TriggerEvent` dispatch.\n\nThe username-editing Lua bridge is reserved for the trusted local server-browser package. Downloaded server resources cannot rewrite a player's identity.","natives":["AddEventHandler","server:AddEventHandler","server:GetPlayerIdentifier","server:GetPlayerName","server:GetPlayerIdentifierByType"],"permissions":[],"events":[]},{"anchor":"language","heading":"Language","level":2,"text":"The game's language is a setting of the player's own game, not of their account: the\nengine stores it as three variables of the `/language` settings group -- `OnScreen` (text),\n`VoiceOver` (audio) and `Subtitles` -- whose values are CName codes such as `en-us`,\n`fr-fr`, `jp-jp`, `kr-kr`, `pt-br`, `zh-cn` (the very values the vanilla settings screen\ncompares against; it is `ru-ru` that triggers the Russian voice-over disclaimer and `ar-ar`\nthat flips the layout right-to-left). Open77 reads them where they live and reports them on\nboth runtimes.\n\n**Client**, permission `session.locale`:\n\n```lua\nlocal locale, reason = Open77.session.locale()\n-- { code = \"fr-FR\", gameLanguage = \"fr-fr\", voiceLanguage = \"en-us\",\n-- subtitles = \"fr-fr\", chromium = \"en-US\", revision = 1 }\n```\n\n`code` is a BCP-47 tag derived from `OnScreen` (the four languages the engine spells by\ncountry are mapped: `jp`→`ja`, `kr`→`ko`, `cz`→`cs`, `ua`→`uk`; the region is upper-cased).\nThe three raw engine codes ride beside it, so a resource that wants the audio language, or\nmeets a code the mapping has not seen, always has the source. `chromium` is the operating\nsystem's UI language -- what a WebUI page's `navigator.language` (the JS `Open77.getLocale()`)\nhas always answered. It is **not** the game's language and is kept for contrast only.\n\n`nil, \"locale_not_reported\"` until the script bridge has answered once: the settings\ncontainer is captured by the health-bar controller, so the first answer follows the first\ngameplay frame by a moment. The read is asked again, rate-limited, on every call, and at\nevery world-ready.\n\n**Server**, permission `players.locale.read`:\n\n```lua\npermissions { \"players.locale.read\" }\n\nAddEventHandler(\"onPlayerReady\", function(playerId)\n CreateThread(function()\n Wait(5000) -- the report follows world-ready by a few seconds\n local locale = Open77.players.locale(playerId)\n local language = locale and locale.code:sub(1, 2) or \"en\"\n Open77.chat.send(tonumber(playerId), ({ fr = \"Bienvenue !\", de = \"Willkommen!\" })[language] or \"Welcome!\")\n end)\nend)\n```\n\nThe server never asks the engine: the client **host** -- not a resource -- pushes the value\nonce per world-ready on the reserved `open77:session:locale` net event, which the server\nconsumes before any resource route and caches per session. `TriggerServerEvent` refuses the\nname on the client and `TriggerEvent` refuses it on the server (`reserved_event`), so what\n`players.locale` answers is what the player's own game said and nothing else. It is a\nreport, not a ledger: a modified client can lie about its language, which costs nobody\nanything. The table adds `ageMs` (the age of the report) and `revision` (how many reports\nthis session has produced). `GetPlayerLocale(playerId)` is the FiveM spelling of the same\nfunction.\n\nFour refusals, on purpose distinct: `invalid_player_id`; `player_not_found` for a player who\nis not connected; `not_reported` for one whose client has not pushed yet -- a client that\nnever reached world-ready, or an older build; `locale_unavailable` in an embedding with no\ntransport.\n\n**A language change re-reports.** The engine only allows changing the language at the main\nmenu (the `/language` variables are pre-game only), so a player who changes it leaves the\nworld to do so; the next world-ready pushes the new value, `revision` moves, and `ageMs`\nrestarts. Nothing announces it: read the value when you need it rather than caching it for\nthe session.","natives":["Open77.session.locale","AddEventHandler","server:AddEventHandler","CreateThread","server:CreateThread","Wait","server:Wait","server:Open77.players.locale","server:Open77.chat.send"],"permissions":["players.locale.read"],"events":["open77:session:locale"]}]},{"slug":"input-blocking","file":"input-blocking.md","title":"Blocking player input","summary":"Block individual input actions or the complete control stream from a client resource. Use resource-owned claims for menus, cutscenes, progress bars and gameplay restrictions.","sections":[{"anchor":"blocking-player-input","heading":"Blocking player input","level":1,"text":"Block individual input actions or the complete control stream from a client resource. Use resource-owned claims for menus, cutscenes, progress bars and gameplay restrictions.\n\n```lua\npermissions { \"input.actions\" } -- per-action blocking\npermissions { \"input.blockAll\" } -- taking the whole control stream\n```\n\nThis is a client-only API. A server gamemode sends an event to its client resource and\nlets that resource own the claim, exactly as it does for HUD visibility and blips.","natives":[],"permissions":["input.actions"],"events":[]},{"anchor":"input-paths","heading":"Input paths","level":2,"text":"Actions use different native input paths. Firing and aiming are polled by the weapon state\nmachine; movement is handled by locomotion. The engine has no general per-action block list.\nThe API reports unsupported or already-enforced actions explicitly:\n\n| You ask for | You get |\n|---|---|\n| an action that is not in the vocabulary | `false, \"unknown_action\"` |\n| an action this engine cannot refuse | `false, \"action_not_blockable\"` |\n| an action Open77 already blocks unconditionally | `false, \"action_already_enforced\"` |\n\nQuery `Open77.input.blockableActions()` for available actions instead of hard-coding names.","natives":[],"permissions":[],"events":[]},{"anchor":"the-vocabulary","heading":"The vocabulary","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"blockable-a-resource-may-claim-and-release-these","heading":"Blockable — a resource may claim and release these","level":3,"text":"| Action | What it stops | Mechanism | Confidence |\n|---|---|---|---|\n| `Movement` | walking, running, jumping, dodging, climbing — all together | `restriction` | proven |\n| `Map` | the fullscreen map shortcut | `action` | proven |\n| `Hub` | every RPG hub page: inventory, crafting, perks, stats, journal | `flow` | measured |\n| `FastTravel` | the player's own fast travel, with the map still usable | `flow` | measured |\n| `Attack` | firing a ranged weapon | `condition` | **inferred — verify it** |\n| `WeaponWheel` | the radial weapon wheel; the bundled context menu holds this one for its lifetime | `condition` | proven |\n\n`blockableActions()` includes a `confidence` field. Treat `inferred` actions as experimental.\nDo not rely on `Attack` input blocking alone to enforce a safe zone; validate combat rules on the server.","natives":[],"permissions":[],"events":[]},{"anchor":"already-enforced-nothing-to-claim","heading":"Already enforced — nothing to claim","level":3,"text":"These are blocked by Open77 for the whole session. `isActionBlocked` reports them as\nblocked; `setActionBlocked` refuses with `action_already_enforced`.\n\n| Action | Why |\n|---|---|\n| `PhotoMode` | the stock activation request is detoured at plugin load. `Open77.photoMode` owns activation |\n| `Scanner`, `Quickhack` | the vision-mode controller is refused while the multiplayer policy is active. Tab still reaches WebUI and key mappings |\n| `CallVehicle` | every summon phase and both `QuickSlotsManager` entry points |\n| `Journal` | the quest log is refused on both hub scenarios |","natives":[],"permissions":[],"events":[]},{"anchor":"not-blockable-on-231","heading":"Not blockable on 2.31","level":3,"text":"`Jump` · `Sprint` · `Crouch` · `Dodge` · `Slide` · `Aim` · `Reload` · `Melee` ·\n`NextWeapon` · `Interact` · `EnterVehicle` · `ExitVehicle` ·\n`Consumable` · `Grenade` · `Inventory` · `Phone` · `Radio`\n\nThree reasons cover almost all of them, and each is written up with its evidence in\n[`docs/research/input-action-blocking.md`](../docs/research/input-action-blocking.md):\n\n- **polled, not dispatched** — no action hook ever sees them (`Jump`, `Crouch`, `Aim`,\n `Reload`, `Melee`);\n- **the only engine record is unusable** — the finer locomotion restrictions\n (`Tier2Locomotion` and its variants, which also carry the inhaler and grenade tags) are\n stripped every frame by Open77's own safe-area reconciliation, which is what lets players\n run in a lobby. A claim there would be removed on the next frame, forever;\n- **the name is documented but never verified** — the per-page fullscreen names behind\n `Inventory` were never checked against this build, and a wrong name is a silent no-op.\n Use `Hub`.\n\nUse `Movement` or `blockAll` where you wanted `Jump` or `Sprint`, and `Hub` where you\nwanted `Inventory`.","natives":[],"permissions":[],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"```lua\nlocal ok, reason = Open77.input.setActionBlocked(action, blocked)\nlocal blocked, reason = Open77.input.isActionBlocked(action)\nlocal ok, reason = Open77.input.blockAll(spec)\nlocal actions, reason = Open77.input.blockableActions()\nlocal held, reason = Open77.input.blocks()\n```\n\n`isActionBlocked` returns the **effective** state, combining every resource's claims — not\nyour own claim. A resource that released its block still reads `true` while another\nresource holds one.\n\n```lua\nlocal cuffs = {}\n\nfunction cuffs.apply()\n local ok, reason = Open77.input.setActionBlocked(\"Movement\", true)\n if not ok then\n print(\"cuffs: \" .. reason) -- never assume; the vocabulary can change\n return\n end\n Open77.input.setActionBlocked(\"Hub\", true)\nend\n\nfunction cuffs.release()\n Open77.input.setActionBlocked(\"Movement\", false)\n Open77.input.setActionBlocked(\"Hub\", false)\nend\n```","natives":["Open77.input.setActionBlocked","Open77.input.isActionBlocked","Open77.input.blockAll","Open77.input.blockableActions","Open77.input.blocks"],"permissions":[],"events":[]},{"anchor":"blockall-the-whole-control-stream","heading":"`blockAll` — the whole control stream","level":2,"text":"```lua\nOpen77.input.blockAll() -- take everything\nOpen77.input.blockAll({ except = { \"Movement\" } }) -- everything but walking\nOpen77.input.blockAll(false) -- release this resource's claim\n```\n\nIt consumes the entire gameplay action stream on the player puppet and asserts every\nblockable lever above, minus `except`. This is the same lever the death state already uses,\nwhich is why two things are true of it for free:\n\n- **chat and voice keep working.** Open77 chat and voice run on focused browser input, not\n on the gameplay action stream, so a blocked player can still talk. That is deliberate and\n it is what makes `blockAll` usable on a live player.\n- **vanilla menus are not closed or prevented.** Menu shortcuts arrive on a different\n controller, and the pause menu arrives with them — a blocked player must always be able to\n reach settings, accessibility and disconnect. Block `Map` and `Hub` explicitly when you\n want those gone.\n\n`except` names actions from the vocabulary, never raw engine action names. The stream\nconsume is all-or-nothing at the engine level — the engine's action type cannot separate a\npress from its release, and the consumer is not per-action — so `except` lifts the\nper-action levers and cannot spare a raw action from the consume. An unknown or unblockable\nname in `except` is refused, and nothing is taken: a half-applied `blockAll` is the one\nstate a player cannot talk his way out of.\n\n`input.blockAll` is a separate permission from `input.actions` because it is a different\nblast radius, and it deserves its own line in a manifest audit.","natives":["Open77.input.blockAll"],"permissions":[],"events":[]},{"anchor":"ownership-and-release","heading":"Ownership and release","level":2,"text":"Blocking is a loan.\n\n- Two resources may block the same action. A release by one does not lift the other's.\n- `setActionBlocked(action, false)` removes only the calling resource's claim.\n- **Everything a resource holds is released when it stops**, reloads, or is torn down —\n action claims and the `blockAll` claim alike. That is the final safety net, and it is the\n same rule that already governs pages, blips, markers, props, anchors, key mappings and\n HUD claims.\n- Releasing something you never claimed is not an error, so a resource that tidies up on\n every path can do so unconditionally.\n\n`Open77.input.blocks()` shows who holds what, which is how an admin resource proves a\nclaim really came back:\n\n```lua\nlocal held = assert(Open77.input.blocks())\nfor _, row in ipairs(held.actions) do\n print(row.action .. \" <- \" .. table.concat(row.owners, \", \"))\nend\nfor _, row in ipairs(held.blockAll) do\n print(\"blockAll <- \" .. row.owner .. \" except \" .. table.concat(row.except, \", \"))\nend\n```","natives":["Open77.input.blocks"],"permissions":[],"events":[]},{"anchor":"compatibility","heading":"Compatibility","level":2,"text":"`Open77.input.setNativeActionBlocked(\"OpenMapMenu\", blocked)` keeps working unchanged. It\nnow claims `Map` for the same owner, so the two APIs cannot disagree about who holds the\nmap. It still names the *engine* action rather than a vocabulary entry, and it still\nanswers `unsupported_native_action` for anything else.","natives":[],"permissions":[],"events":[]},{"anchor":"human-verification","heading":"Human verification","level":2,"text":"This row cannot be proven without someone at the keyboard: every check is a key pressed\nand an effect seen. Two minutes, from a resource holding `input.actions` and\n`input.blockAll`:\n\n| # | Do this | Must happen | Must still work |\n|---|---|---|---|\n| 1 | `setActionBlocked(\"Movement\", true)`, then push the movement stick / press W | V does not move | camera still looks around |\n| 2 | `setActionBlocked(\"Movement\", false)`, press W | V walks again | — |\n| 3 | `setActionBlocked(\"Map\", true)`, press the map key | no fullscreen map | the pause menu still opens on Escape |\n| 4 | `setActionBlocked(\"Hub\", true)`, press the inventory key | no inventory, no crafting, no perks | the pause menu still opens; settings and disconnect still reachable |\n| 5 | Open the map, aim at a fast-travel point with `setActionBlocked(\"FastTravel\", true)` | the confirm does nothing | the map still opens and still pans |\n| 6 | `setActionBlocked(\"Attack\", true)`, fire a pistol — **this is the unproven one** | no shot, no ammunition spent | melee still swings (it is not blockable) |\n| 7 | `blockAll()`, then try to move, shoot, interact, enter a vehicle | nothing responds | **open chat and type — it must work**; **hold the voice key and speak — it must transmit** |\n| 8 | `blockAll(false)` | everything responds again | — |\n| 9 | Stop the resource while it still holds `blockAll` | the player is free within one frame | — |\n\nStep 7's two \"must still work\" checks are the point of the whole design: a player who\ncannot act must still be able to say so.\n\nStep 9 is the one that matters most. If it ever fails, a player is stranded, and the fix is\nin the release path, not in the block.","natives":[],"permissions":[],"events":[]}]},{"slug":"interactions","file":"interactions.md","title":"Contextual world interactions","summary":"`open77_interactions` attaches client-side action prompts to world positions or streamed entities. A transparent WebUI displays the marker, distance and action card; an allowlisted input key triggers the calling resource's local event.","sections":[{"anchor":"contextual-world-interactions","heading":"Contextual world interactions","level":1,"text":"`open77_interactions` attaches client-side action prompts to world positions or streamed entities. A transparent WebUI displays the marker, distance and action card; an allowlisted input key triggers the calling resource's local event.\n\nThis is not Cyberpunk's native interaction prompt. The point, distance label, action card, colours,\ncopy and hold progress are rendered by the package WebUI while the target remains anchored to its\n3D world position.\n\nThe prompt is fully data-driven. `Utiliser` is only an example: labels such as `Parler à Jackie`,\n`Consulter les offres`, `Ouvrir le coffre`, or `Réparer le véhicule` are accepted, together with a\ncustom description, key, short icon, colour, press/hold behavior, and up to four choices.\n\nSource: [`open77_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_interactions).","natives":[],"permissions":[],"events":[]},{"anchor":"add-the-dependency","heading":"Add the dependency","level":2,"text":"```lua\nresource \"jobs\"\nversion \"1.0.0\"\ndependency \"open77_interactions >=0.1.0\"\n\nclient_script \"client/main.lua\"\n```\n\nAdd `permission \"network.events\"` only when the client handler itself calls\n`TriggerServerEvent`. The interaction service cannot send a server event on another resource's\nbehalf.\n\nExports are asynchronous because every resource has an isolated Lua VM. Call them from a\n`CreateThread` and await the returned promise:\n\n```lua\nlocal interaction\n\nCreateThread(function()\n local promise, callError = Open77.exports.call(\"open77_interactions\", \"create\", {\n id = \"job_center\",\n position = { x = -1464.7, y = -124.3, z = 6.2 },\n distance = 2.2,\n markerDistance = 12.0,\n marker = \"arrow\",\n markerScale = 1.0,\n markerNearScale = 2.0,\n markerAnimated = true,\n label = \"Consulter les offres\",\n description = \"Pôle emplois de Night City\",\n key = \"E\",\n icon = \"JOB\",\n color = \"#FFD84A\",\n event = \"jobs:open\",\n data = { office = \"city_center\" },\n })\n assert(promise, callError)\n\n local result, awaitError = promise:await()\n assert(result and result.ok, awaitError or (result and result.error))\n interaction = result.handle\nend)\n\nAddEventHandler(\"jobs:open\", function(context)\n print(context.interactionId, context.choiceId, context.distance)\nend)\n```\n\nThe service obtains the calling resource and its generation from the runtime. A resource cannot\nforge an owner name, update another resource's handles, or retain stale handles after a restart.\nStopped owners are swept automatically.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"attach-to-a-player-or-npc","heading":"Attach to a player or NPC","level":2,"text":"`entity` accepts the same opaque Open77 entity handle as `Open77.character.state`. Keep a 64-bit\nhandle as returned; do not pass it through `tonumber`.\n\n```lua\nlocal npcEntity = Open77.npcs.entity(npcId)\nif npcEntity then\n CreateThread(function()\n local promise = assert(Open77.exports.call(\"open77_interactions\", \"create\", {\n id = \"talk_to_receptionist\",\n entity = npcEntity,\n offset = { x = 0.0, y = 0.0, z = 1.85 },\n distance = 2.5,\n label = \"Parler à la réceptionniste\",\n description = \"Demander un rendez-vous\",\n key = \"F\",\n icon = \"DIALOG\",\n event = \"clinic:reception\",\n }))\n local result = promise:await()\n assert(result and result.ok, result and result.error)\n end)\nend\n```\n\nAn entity interaction disappears while its entity is not streamed and follows its rendered world\nposition when it returns. Create it after `Open77.npcs.isStreamedIn(npcId)` becomes true, or update\nit with the new local entity handle after a stream-out/stream-in cycle.","natives":["Open77.npcs.entity","CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"multiple-custom-actions-and-hold","heading":"Multiple custom actions and hold","level":2,"text":"```lua\nlocal promise = Open77.exports.call(\"open77_interactions\", \"create\", {\n id = \"apartment_door\",\n position = { x = -1448.2, y = 96.1, z = 17.5 },\n distance = 2.0,\n priority = 20,\n choices = {\n {\n id = \"knock\",\n label = \"Frapper à la porte\",\n description = \"Prévenir les occupants\",\n key = \"E\",\n icon = \"DOOR\",\n color = \"#00E5FF\",\n event = \"apartment:knock\",\n },\n {\n id = \"force\",\n label = \"Forcer la serrure\",\n description = \"Maintenir pendant 1,5 seconde\",\n key = \"G\",\n icon = \"LOCK\",\n color = \"#FFD84A\",\n holdSeconds = 1.5,\n event = \"apartment:forceLock\",\n data = { difficulty = 4 },\n },\n },\n})\n```\n\nKeys must be unique inside one interaction. Supported keys are `A`–`Z`, `0`–`9`, `SPACE`,\n`ENTER`/`RETURN`, and the four arrow keys. Input is suppressed whenever another WebUI owns\nkeyboard focus, so typing in chat cannot trigger a world action.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"updating-and-removing","heading":"Updating and removing","level":2,"text":"```lua\nlocal function call(name, ...)\n local promise, reason = Open77.exports.call(\"open77_interactions\", name, ...)\n assert(promise, reason)\n return promise:await()\nend\n\nCreateThread(function()\n assert(call(\"setVisible\", interaction, false).ok)\n assert(call(\"update\", interaction, {\n label = \"Le guichet est fermé\",\n description = \"Revenez à 08:00\",\n color = \"#FF4D5A\",\n }).ok)\n assert(call(\"setVisible\", interaction, true).ok)\n\n local current = call(\"get\", interaction)\n local owned = call(\"all\")\n assert(call(\"remove\", interaction).ok)\n -- call(\"clear\") removes every interaction owned by this resource.\nend)\n```\n\n`setEnabled(false)` suspends every interaction owned by the caller without affecting other\nresources. `isEnabled()` returns that caller-specific state.","natives":["Open77.exports.call","server:Open77.exports.call","CreateThread","server:CreateThread"],"permissions":[],"events":[]},{"anchor":"definition-reference","heading":"Definition reference","level":2,"text":"| Field | Meaning |\n|---|---|\n| `id` | Stable owner-local ID, maximum 96 characters. Generated when omitted. |\n| `position` | Static `{ x, y, z }` target. Exactly one of `position` or `entity` is required. |\n| `entity` | Streamed Open77 entity handle followed through `Open77.character.state`. |\n| `offset` | World offset from the position/entity origin; defaults to zero. |\n| `distance` | Activation radius, 0.25–25 m; defaults to 2.5 m. |\n| `markerDistance` | Maximum marker display distance, from `distance` to 250 m; defaults to at least 12 m. Only the winning interaction is rendered. |\n| `showDistance` | Backward-compatible alias of `markerDistance`. |\n| `marker` | WebUI motif: `dot` (default), `ring`, `diamond`, `arrow`, `chevron`, `exclamation`, `info`, `vehicle` (`car` alias), `person`, `door`, or `shop`. `arrow` points down toward the projected world position. |\n| `markerScale` | Marker scale from 0.6 to 2.0; defaults to 1.0. |\n| `markerNearScale` | Proximity multiplier from 1.0 to 4.0; defaults to 2.0. The marker progressively grows from `markerScale` at `markerDistance` to this multiplier at interaction range. |\n| `markerAnimated` | Enables the marker's subtle vertical animation; defaults to `true`. |\n| `requireLookAt` | Requires the projection to be close to the screen centre; defaults to `true`. |\n| `focusRadius` | Normalized screen-centre radius, 0.02–1.0; defaults to 0.18. |\n| `priority` | Tie-breaker from -1000 to 1000; higher wins. |\n| `visible` | Presentation flag; defaults to `true`. |\n| `cooldown` | Client presentation debounce in seconds, 0–60. Never use it as server security. |\n| `label`, `description`, `key`, `icon`, `color`, `event`, `data`, `holdSeconds` | Single-choice shorthand. |\n| `choices` | Array of 1–4 complete choice records; replaces the shorthand. |\n\nChoice labels are arbitrary UTF-8 text up to 96 bytes, descriptions up to 160 bytes, and icons are\nshort textual marks up to 12 bytes. Colours use `#RRGGBB`. Events and IDs use letters, numbers,\nunderscore, colon, dash, and dot.\n\nThe arbiter renders one target at a time. An active target wins over a merely visible one, then\n`priority`, then distance. This prevents overlapping cards from fighting each other every frame.","natives":[],"permissions":[],"events":[]},{"anchor":"targets-rules-instead-of-places","heading":"Targets: rules instead of places","level":2,"text":"Everything above describes one prompt bolted to one place. A shop, a job step or a vehicle\ninteraction is not that: it is a rule — *every* vending machine, *every* vehicle, *every* player —\nand a server that had to place one interaction per object would have to enumerate the world first.\n\nA **target** is that rule. It is registered once, re-resolved against what this client can currently\nsee, and every match is *materialised* as an ordinary interaction. That is the whole design: the\narbiter, the anchor budget, the card, the hold, the cooldown and the owner sweep are reused\nunchanged, and a target cannot behave differently from a hand-written interaction, because by the\ntime anything draws it, it **is** one. `get`, `all`, `setEnabled` and the one-target-at-a-time\narbitration all see materialised matches exactly as they see `create`.\n\n```lua\nCreateThread(function()\n local promise = assert(Open77.exports.call(\"open77_interactions\", \"addModel\",\n { \"Vending_Machine*\" },\n {\n id = \"vending\",\n distance = 2.0,\n label = \"Acheter une boisson\",\n key = \"E\",\n icon = \"SHOP\",\n marker = \"shop\",\n event = \"shops:vending\",\n }))\n local result = promise:await()\n assert(result and result.ok, result and result.error)\nend)\n\nAddEventHandler(\"shops:vending\", function(context)\n -- context.entity / context.engineEntity / context.class name what was hit.\n TriggerServerEvent(\"shops:requestVending\", context.engineEntity)\nend)\n```","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call","AddEventHandler","server:AddEventHandler","TriggerServerEvent"],"permissions":[],"events":[]},{"anchor":"three-cadences-and-they-are-not-interchangeable","heading":"Three cadences, and they are not interchangeable","level":3,"text":"| What | How often | Where |\n|---|---|---|\n| Where a prompt is **drawn** | every rendered frame | the plugin, through the anchor the materialised entry owns |\n| What it **says**, and whether a key fired | every Lua tick (33 ms) | this resource |\n| **Which** things match a rule, and whether `canInteract` still agrees | every 250 ms | this resource |\n\nA world query and a `canInteract` predicate are both far too expensive for the tick, and neither\nanswer changes meaningfully inside a quarter of a second. Nothing about the slower cadence makes a\nprompt lag: a match anchored to an entity is followed per frame by the plugin the instant it exists.\nThe only thing the resolve cadence bounds is how quickly a prompt *appears* on something new.\n\nThe winning match — the one a key is about to fire on — is re-checked on a faster clock, at most\nevery 500 ms, so a prompt that stops being allowed disappears rather than waiting out a full pass.","natives":[],"permissions":[],"events":[]},{"anchor":"the-target-kinds","heading":"The target kinds","level":3,"text":"| `kind` | Matches | Anchored to | Source |\n|---|---|---|---|\n| `model` | anything whose TweakDB record **or** RTTI class matches `models` | the entity when one is known, else the queried point | `Open77.world.nearby`, `Open77.vehicles.all`, `Open77.npcs.all` |\n| `class` | anything whose RTTI class matches `classes` | same | `Open77.world.nearby` |\n| `globalVehicle` | every streamed vehicle | the vehicle entity | `Open77.vehicles.all` |\n| `globalPlayer` | every nearby player but you | the player's body entity | `Open77.players.nearby` |\n| `globalNpc` | every streamed puppet that is not a player body | the entity for an Open77 NPC, else the queried point | `Open77.npcs.all` + `Open77.world.nearby(r, \"puppet\")` |\n| `zone` | one prompt, only while you stand inside a volume | the zone's own centre | the declaration |\n\n`addModel`, `addClass`, `addGlobalVehicle`, `addGlobalPlayer`, `addGlobalPed`, `addZone`,\n`addBoxZone` and `addSphereZone` are sugar: each is `addTarget` with `kind` filled in, so there is\none implementation and one set of refusal tokens.\n\nSelectors (`models`, `classes`) are matched **case-insensitively and exactly**, with one optional\ntrailing `*` for a prefix — `\"Vending_Machine*\"`. They are deliberately not Lua patterns: a resource\nmust not be able to hand this service a pattern that backtracks for a millisecond per entity.","natives":[],"permissions":[],"events":[]},{"anchor":"what-model-can-really-see","heading":"What `model` can really see","level":3,"text":"Unlike FiveM's uniform model hash, model identity in Cyberpunk 2.31 depends on the candidate's source:\n\n| Source | RTTI class name | TweakDB record |\n|---|---|---|\n| `Open77.world.nearby` | yes (`className`) | **no** |\n| `Open77.vehicles.all` | joined from the world query when both see it | yes (`record`) |\n| `Open77.npcs.all` | no | yes (`record`) |\n\nThere is no generic \"entity handle to record id\" call in the client Lua surface at all. So:\n\n- **`class` is exact and complete.** It matches the RTTI class name the world query reports, and\n that is what actually gets you vending machines, ATMs and chairs today — REDengine models those\n as device and prop classes.\n- **`model` matches either name a candidate can carry**: the record when the source knows one\n (vehicles and Open77-spawned NPCs), the class name otherwise. A `model` target aimed at a prop\n therefore degenerates to a class match, and that is not a bug to be worked around — it is the only\n identity the engine hands a script for that object.\n\nIf you want a record match and only a record match, put the record in `canInteract`'s declarative\n`record` selector, which never falls back to the class name.","natives":[],"permissions":[],"events":[]},{"anchor":"global-vehicles-and-why-there-are-no-bones","heading":"Global vehicles, and why there are no bones","level":3,"text":"Vehicle interaction prompts cannot target arbitrary named bones such as trunk, hood or doors. Use the part-targeting support described below.\n\n1. **Cyberpunk authors slot names per entity.** An earlier row in this campaign established it, and\n nothing in the client Lua surface lists an entity's slots. The only native slot walk in the\n client (`DescribeOccupantAnchor`, `client/src/api/Vehicles.cpp`) reads `entSlotComponent::slots`\n off the live entity and is reachable only from the debug bridge. A fixed `trunk`/`hood`\n vocabulary would be a vocabulary this engine does not have.\n2. **`Open77.vehicles.doors` is not a slot table.** `{ frontLeft = 0, … trunk = 4, hood = 5 }` are\n *bit indices* into the snapshot's `doors` mask. They say which bit means which door. They carry\n no transform.\n3. **Even a hand-measured offset would point the wrong way.** `Api::WorldAnchors::Resolve` adds an\n anchor's offset to the entity's `GetWorldPosition()` in **world axes**, without rotating it into\n the entity's frame. An offset meaning \"behind the car\" would mean \"north of the car\" the moment\n the car turned.\n\nWhat is offered instead is real, and it is what a trunk interaction actually needs: **`part`**.\n\n```lua\nOpen77.exports.call(\"open77_interactions\", \"addGlobalVehicle\", {\n id = \"trunk\",\n distance = 3.0,\n choices = {\n { id = \"open\", label = \"Ouvrir le coffre\", key = \"E\", part = \"trunk\",\n canInteract = { vehicle = { partOpen = false, locked = false } },\n event = \"garage:openTrunk\" },\n { id = \"close\", label = \"Fermer le coffre\", key = \"E\", part = \"trunk\",\n canInteract = { vehicle = { partOpen = true } },\n event = \"garage:closeTrunk\" },\n },\n})\n```\n\n`part` is one of `frontLeft`, `frontRight`, `backLeft`, `backRight`, `trunk`, `hood`. It is a\n**state selector, not an anchor**: the card still sits on the vehicle, and the payload gains `part`,\n`partIndex` and `partOpen`, decoded from the snapshot's door mask. That is enough to build the\nopen/close pair above, which is the interaction people actually want, without claiming a position\nthe engine will not give.\n\n`part` may be set on the target (applying to every choice) or per choice, and a choice's own `part`\nwins. It is refused on any kind but `globalVehicle` (`part_requires_global_vehicle`).","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"caninteract","heading":"`canInteract`","level":3,"text":"Every target and every choice may carry `canInteract`. It has two forms, and the reason there are\ntwo is the export boundary: **functions never cross it** (see\n[Cross-resource exports](resource-exports.md), \"What crosses, and what does not\"), so a closure\ncannot be handed to this service the way ox_target takes one.\n\n**A string names one of your own exports.** It is invoked with `Open77.exports.callSync`, which runs\nit inline, in your VM, with your own upvalues — a closure in everything but spelling.\n\n```lua\n-- jobs/client/main.lua\nexports(\"canRepair\", function(payload)\n return payload.vehicle.locked == false and payload.vehicle.health < 100.0\nend)\n\nOpen77.exports.call(\"open77_interactions\", \"addGlobalVehicle\", {\n id = \"repair\", distance = 3.0, label = \"Reparer\", key = \"E\",\n event = \"jobs:repair\", canInteract = \"canRepair\",\n})\n```\n\n**A table is a declarative rule**, evaluated in this service with no call at all. Prefer it whenever\nit fits:\n\n```lua\ncanInteract = {\n groups = { \"police\", \"ems\" }, -- any of, from the player's own state bag\n notGroups = { \"banned\" }, -- none of\n minDistance = 0.0,\n maxDistance = 1.5,\n state = { [\"job.onDuty\"] = true }, -- equality against the local player's bag\n class = { \"VendingMachine*\" },\n record = { \"Vehicle.v_standard2_*\" },\n vehicle = { locked = false, occupied = false, driven = false, partOpen = false },\n}\n```\n\nEvery key is optional; all present keys must hold. `state` keys may be dotted to read one level\ninto a bag value, and take at most eight entries.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"the-evaluation-rule","heading":"The evaluation rule","level":4,"text":"**`canInteract` is evaluated at prompt-resolution time. It is never evaluated per frame, and never\nper tick.** Concretely: once when a match is materialised (so at most every 250 ms per match), and\nagain at most every 500 ms while that match is the entry the arbiter chose. The result is cached on\nthe materialised entry.\n\nFour consequences worth knowing before you write one:\n\n- **It must not wait.** `Wait`, `promise:await()` and anything built on them fail the call with\n `export_yielded`. This is enforced by the runtime, not by this document: `callSync` has no\n scheduler underneath it.\n- **It must not ask the server.** `Open77.net.call` is a network round trip, and a predicate that\n makes one — even once per resolve pass, even per vehicle on a busy street — is a performance bug\n and a source of prompts that flicker as answers arrive late. If a predicate needs server truth,\n have the server write that truth into the player's state bag once and read it with the `state` or\n `groups` rule, which is free.\n- **It fails closed.** A predicate that raises, that tries to wait, or that belongs to a resource\n that has since stopped **refuses** the prompt. It never grants one by accident. The refusal is\n printed once per ten seconds, naming the owner and the export.\n- **The payload carries what it needs.** `entity`, `distance`, the class, the record, the vehicle's\n own state and the target's `data` are all in the payload already, so the common predicate needs no\n lookup of its own.\n\nWhen no choice on a materialised match is allowed, the whole match leaves the arbitration rather\nthan drawing a card that refuses to do anything — and so stops shadowing whatever is behind it.\n\nIf one resolve pass spends more than 8 ms in predicates, the service says so, once per ten seconds.\nThat message means somebody's `canInteract` is too slow, not that the service is busy.","natives":[],"permissions":[],"events":[]},{"anchor":"group-gates","heading":"Group gates","level":3,"text":"`groups` on a target is the cheap gate: a target whose groups the player does not have resolves to\nnothing at all, before any candidate is examined.\n\n```lua\nOpen77.exports.call(\"open77_interactions\", \"addGlobalVehicle\", {\n id = \"impound\", groups = { \"police\" }, distance = 3.0,\n label = \"Mettre en fourriere\", key = \"G\", event = \"police:impound\",\n})\n```\n\n**Membership is read from the player's own state bag, and Open77 does not gain a third permission\nmodel for this.** The two that exist were both considered:\n\n- The **ACL** (`Open77.acl.isAllowed`, `Open77.acl.roles`) is the right authority — and it is\n **server-only**. There is no client binding for it at all, and a target gate runs on the client.\n Routing every gate through a callback would put a network round trip in the resolve pass, which is\n exactly what the `canInteract` rule above forbids.\n- **State bags** are already replicated, server-written, client-readable *synchronously*, and are\n where an RP framework puts a player's job in the first place.\n\nSo the gate reads `Open77.state.localPlayer()` and accepts any of:\n\n| Bag key | Shape | Example |\n|---|---|---|\n| `groups` | array of names | `{ \"police\", \"ems\" }` |\n| `groups` | set of names | `{ police = true }` |\n| `group` | one name | `\"police\"` |\n| `job` | one name | `\"police\"` |\n| `job` | table with `name` | `{ name = \"police\", grade = 3 }` |\n\nNames are matched exactly and are case-sensitive. The server writes them:\n\n```lua\n-- server side, manifest requires state.write\nOpen77.state.player(playerId):set(\"job\", { name = \"police\", grade = 3 })\n```\n\nThe ACL keeps its job, which is the one that matters: when the intent arrives, the server calls\n`Open77.acl.isAllowed(source, \"job.police.impound\")` before doing anything. The bag decides what the\nplayer *sees*; the ACL decides what the player *gets*.","natives":["Open77.exports.call","server:Open77.exports.call","Open77.state.player","server:Open77.state.player"],"permissions":[],"events":[]},{"anchor":"zones","heading":"Zones","level":3,"text":"A `zone` target materialises one prompt, at the volume's own centre, only while the player is inside\nit. Three shapes:\n\n```lua\n-- A box: centre, size, optional heading in degrees about Z.\nOpen77.exports.call(\"open77_interactions\", \"addBoxZone\", {\n id = \"counter\", position = { x = -1462.0, y = -120.0, z = 6.2 },\n size = { x = 4.0, y = 2.0, z = 3.0 }, heading = 35.0,\n distance = 3.0, label = \"Commander\", key = \"E\", event = \"bar:order\",\n})\n\n-- A sphere (a cylinder, really: `maxHeight` bounds it vertically).\nOpen77.exports.call(\"open77_interactions\", \"addSphereZone\", {\n id = \"yard\", position = { x = 0, y = 0, z = 0 }, radius = 12.0, maxHeight = 4.0,\n distance = 6.0, label = \"Garer\", key = \"E\", event = \"garage:park\",\n})\n\n-- A polygon in the XY plane, with an explicit height band.\nOpen77.exports.call(\"open77_interactions\", \"addZone\", {\n id = \"lot\",\n zone = { shape = \"poly\", minZ = 4.0, maxZ = 9.0,\n points = { { x = 0, y = 0 }, { x = 10, y = 0 }, { x = 10, y = 8 }, { x = 2, y = 6 } } },\n distance = 8.0, label = \"Deposer\", key = \"E\", event = \"jobs:drop\",\n})\n```\n\nContainment is the gate on whether the prompt exists at all; `distance`, `requireLookAt` and\n`focusRadius` still govern the card, exactly as for every other target. Set `distance` to cover the\npart of the zone you want the card readable from (it is capped at 25 m, like any interaction), or\ngive the target its own `position` to anchor the card somewhere other than the volume's centre.\n\nZone targets use synchronous `contains(shape, point)` queries over normalized shape tables during the resolve pass. Avoid asynchronous export calls in this path.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"declaring-targets-from-the-server","heading":"Declaring targets from the server","level":3,"text":"A job resource should describe its targets once and have every client apply them. Doing that per\nclient from a client script means every gamemode reimplements the same fan-out and gets late joins\nwrong.\n\n```lua\n-- jobs/open77.lua\nresource \"jobs\"\nversion \"1.0.0\"\ndependency \"open77_interactions >=0.2.0\"\nserver_script \"server/main.lua\"\nclient_script \"client/main.lua\"\npermissions { \"network.events\" }\n\n-- jobs/server/main.lua\nCreateThread(function()\n local ok, reason = exports.open77_interactions:define({\n {\n id = \"atm\",\n kind = \"model\",\n models = { \"AtmMachine\" },\n distance = 1.6,\n label = \"Retirer de l'argent\",\n key = \"E\",\n icon = \"ATM\",\n event = \"bank:withdraw\",\n canInteract = { maxDistance = 1.4 },\n },\n {\n id = \"repair\",\n kind = \"globalVehicle\",\n groups = { \"mechanic\" },\n distance = 3.0,\n label = \"Reparer\",\n key = \"R\",\n event = \"jobs:repair\",\n },\n })\n if not ok then print(\"interactions: \" .. tostring(reason)) end\nend)\n```\n\nThe wire is an ordinary authenticated net event, `open77:interactions:targets`, not a new protocol\nmessage. The client half trusts the `owner` in it precisely because it can only have come from the\nserver; a client cannot forge one. Applying a declaration is idempotent: it retracts that owner's\nprevious set first, so a re-`define` replaces rather than accumulates.\n\nLate joins and reloads are covered from both ends. The server pushes on `onPlayerConnected`, and\nthis resource asks for the standing declarations with `open77:interactions:sync` whenever it starts.\n\n`undefine(id)` withdraws one; `undefine()` and `clear()` withdraw all of them. A declaring resource\nthat **stops or reloads** is retracted automatically, within two seconds, by a generation sweep —\nthe client half could never notice this on its own, because a server-only job resource has no client\nVM whose stop it could observe.\n\nTwo rules follow from the split, and both are load-bearing:\n\n- **A server declaration's `canInteract` export name resolves against the declaring resource's\n *client* VM.** The predicate runs on the client, so the export must be published there. A\n declarative table needs no client half at all.\n- **A server-declared target is not removed when the declaring resource's client half stops.** It\n belongs to the server and only the server retracts it. A client-declared target, as before, goes\n with its client VM's stop or reload.\n\nA declaration is bounded: at most 32 targets per resource and roughly 16 KB per declaration\n(`declaration_too_large`), because the server fans it out to every client on every join.","natives":["CreateThread","server:CreateThread"],"permissions":["network.events"],"events":["open77:interactions:targets","open77:interactions:sync"]},{"anchor":"target-definition-reference","heading":"Target definition reference","level":3,"text":"Everything in the [Definition reference](#definition-reference) above is accepted and behaves\nidentically — `distance`, `markerDistance`, `marker`, `markerScale`, `color`, `priority`,\n`requireLookAt`, `focusRadius`, `cooldown`, `offset`, `visible`, `label`/`key`/`icon`/`event`/`data`\nand `choices`. A target is validated by the *same* normaliser a hand-written interaction goes\nthrough, so it can never accept a presentation `create` would reject. On top of it:\n\n| Field | Meaning |\n|---|---|\n| `kind` | Required. One of `model`, `class`, `globalVehicle`, `globalPlayer`, `globalNpc`, `zone`. |\n| `id` | Stable owner-local id, maximum 64 characters. Generated when omitted. |\n| `models` | `model` only. One selector or an array of up to 32, matched against record then class. |\n| `classes` | `class` only. One selector or an array of up to 32, matched against the RTTI class. |\n| `zone` | `zone` only. `{ shape = \"sphere\"\\|\"box\"\\|\"poly\", … }`; see [Zones](#zones). |\n| `part` | `globalVehicle` only. `frontLeft`, `frontRight`, `backLeft`, `backRight`, `trunk`, `hood`. Also settable per choice. |\n| `groups` | Up to 32 group names. The target resolves to nothing unless the player has one. |\n| `canInteract` | Export name (string) or declarative rule (table). Also settable per choice. |\n| `maxMatches` | How many matches may be live at once, 1–32; defaults to 8. |\n\nA materialised match keeps its own `interactionId`, which is `:` — stable for\nas long as that thing is matched, so `open77:interaction` handlers can key off it.","natives":[],"permissions":[],"events":["open77:interaction"]},{"anchor":"the-target-payload","heading":"The target payload","level":3,"text":"A materialised match adds these to the [event payload](#event-payload-and-server-authority), and\nthey are also exactly what a `canInteract` predicate receives:\n\n| Field | Present for | Meaning |\n|---|---|---|\n| `targetId` | all | The target's own id. |\n| `kind` | all | The target kind that matched. |\n| `entity` | when known | The Open77 entity handle — the same domain as `Open77.players.entity`. |\n| `engineEntity` | when known | The raw engine entity handle the world query reported. |\n| `class` | when known | The RTTI class name. |\n| `record` | when known | The TweakDB record (vehicles and Open77 NPCs). |\n| `distance`, `position` | all | Where it was, at resolution time. |\n| `data` | all | The target's own `data` table. |\n| `playerId`, `playerName` | `globalPlayer` | Who it is. |\n| `npcId` | Open77 NPCs | The replicated NPC id. |\n| `vehicleId`, `vehicle` | vehicles | `{ id, entity, record, locked, health, speed, doors, windows, occupants, occupied, driver }`. |\n| `part`, `partIndex`, `partOpen` | `part` targets | The selected door bit and whether it reads open. |\n| `zoneId` | `zone` | The zone target's id. |\n| `choiceId` | per-choice predicates | Which choice is being asked about. |","natives":[],"permissions":[],"events":[]},{"anchor":"refusal-tokens","heading":"Refusal tokens","level":3,"text":"`addTarget` and its sugar answer `{ ok = false, error = \"\" }`; the server's `define` answers\n`false, \"\"`. Every presentation token from `create` still applies. On top of them:\n\n| Token | Meaning |\n|---|---|\n| `invalid_target_kind` | `kind` missing or not one of the six. |\n| `invalid_target_id` | Not 1–64 characters of `[A-Za-z0-9_:.-]`. |\n| `duplicate_target_id` | This owner already has a target with that id. |\n| `invalid_models`, `invalid_classes` | Empty, over 32 entries, or an unusable selector. |\n| `invalid_zone`, `invalid_zone_shape`, `invalid_zone_position`, `invalid_zone_size`, `invalid_zone_radius`, `invalid_zone_heading`, `invalid_zone_points`, `invalid_zone_height` | The zone shape is not usable. |\n| `invalid_groups` | Empty, over 32 entries, or a name outside `[A-Za-z0-9_:.-]`. |\n| `invalid_can_interact` | Not an export name and not a well-formed declarative rule. |\n| `invalid_max_matches` | Not an integer in 1–32. |\n| `part_requires_global_vehicle` | `part` on any other kind. |\n| `invalid_vehicle_part` | Not one of the six door bits. |\n| `target_limit` | 64 targets across every resource. |\n| `target_not_found`, `not_owner` | `removeTarget`/`getTarget` against something you do not own. |\n| `definitions_must_be_a_table`, `invalid_target_count`, `declaration_too_large`, `no_declaration` | Server `define`/`undefine`. |","natives":[],"permissions":[],"events":[]},{"anchor":"budgets","heading":"Budgets","level":3,"text":"| Limit | Value | Why |\n|---|---|---|\n| Targets | 64, across every resource | |\n| Materialised matches | 96, across every resource | Deliberately below the 256 interaction cap, so a rule matching a crowded street can never starve the interactions a gamemode placed on purpose. |\n| Matches per target | 8 by default, 32 maximum | |\n| Selectors per target | 32 | |\n| World scan radius | the widest live `markerDistance`, capped at 120 m | |\n| Resolve cadence | 250 ms | |\n| Winner re-check | 500 ms | |\n| Predicate budget | 8 ms per resolve pass before the service complains | |","natives":[],"permissions":[],"events":[]},{"anchor":"event-payload-and-server-authority","heading":"Event payload and server authority","level":2,"text":"Every selected choice emits its custom `event` and the common `open77:interaction` event with:\n\n```lua\n{\n interactionId = \"job_center\",\n handle = 1,\n owner = \"jobs\",\n choiceId = \"primary\",\n key = \"E\",\n position = { x = 0, y = 0, z = 0 },\n distance = 1.4,\n data = {},\n interactionData = {},\n}\n```\n\nThis payload is client presentation evidence, not authority. If an action changes shared state,\nthe owner may submit only the minimum intent:\n\n```lua\n-- client/main.lua; manifest requires network.events\nAddEventHandler(\"jobs:open\", function(context)\n TriggerServerEvent(\"jobs:requestOpen\", context.interactionId)\nend)\n\n-- server/main.lua\nRegisterNetEvent(\"jobs:requestOpen\", function(interactionId)\n local playerId = source -- authenticated by Open77; never accept it from the payload\n if interactionId ~= \"job_center\" then return end\n\n local position = Open77.players.position(playerId)\n if not position then return end\n -- Recheck routing bucket, distance, role/ACL, cooldown, and authoritative\n -- job-centre state here before opening or mutating anything.\n TriggerClientEvent(\"jobs:openMenu\", playerId, { office = \"city_center\" })\nend)\n```\n\nThe WebUI never receives input focus and builds all copy with DOM `textContent`; labels cannot\ninject HTML. The service intentionally does not claim line-of-sight or server-side proximity.\n\n**One target kind already does this round trip for you.** A choice used on a `globalNpc` target\nthat resolved to an Open77 NPC (the payload carries `npcId`) is also reported to the server by\nthis resource, on the reserved `open77:npcs:` transport, as the minimum intent — which NPC, which\nprompt, which choice. The server re-derives the NPC, the routing bucket and the distance from its\nown copy of the player (refusing a report from a player it cannot place, from another bucket, or\nfrom more than 40 m away — 25 m is the widest prompt, plus a two-second snapshot at sprint speed)\nand only then publishes **`onNpcInteracted(npcId, playerId, interactionId, choiceId, distance)`**\nto every server resource, with `distance` the server measured. A shop with an NPC clerk therefore\nneeds no client half and no net event of its own:\n\n```lua\n-- shop/server/main.lua\nCreateThread(function()\n exports.open77_interactions:define({\n { id = \"clerk\", kind = \"globalNpc\", distance = 2.0, label = \"Browse\", key = \"E\", event = \"shop:browse\" },\n })\nend)\nAddEventHandler(\"onNpcInteracted\", function(npcId, playerId, interactionId, choiceId, distance)\n if clerks[npcId] == nil or tonumber(distance) > 3.0 then return end -- your rule, on the server's number\n TriggerClientEvent(\"shop:openMenu\", tonumber(playerId), clerks[npcId].catalogue)\nend)\n```\n\nSee [NPC events](npcs.md#events) for the event and its arguments.","natives":["AddEventHandler","server:AddEventHandler","TriggerServerEvent","RegisterNetEvent","server:RegisterNetEvent","server:Open77.players.position","server:TriggerClientEvent","CreateThread","server:CreateThread"],"permissions":[],"events":["open77:interaction"]},{"anchor":"a-target-grants-nothing","heading":"A target grants nothing","level":3,"text":"A prompt is presentation, and a **target** makes that easy to forget, because it looks like a rule\nthe system is enforcing. It is not. Every part of a target is evaluated on a machine the player\nowns:\n\n| What the target seems to enforce | What it actually is | What the server must do |\n|---|---|---|\n| `groups` / `canInteract` | a client read of the local player's own state bag, or a client-side predicate | call `Open77.acl.isAllowed(source, …)`, or re-read the player's authoritative role |\n| `distance` / `markerDistance` | a client distance check against a client-read position | recompute from `Open77.players.position(source)` |\n| `models` / `classes` / `kind` | what a client-side world query reported | re-resolve the target object server-side, or accept only an id you can verify |\n| `vehicle.locked`, `partOpen`, `occupied` | a snapshot the client replicated in | read the authoritative vehicle state |\n| `entity`, `engineEntity`, `vehicleId`, `playerId` | numbers the client put in an event | treat as a *claim*; verify the player may act on that object at all |\n\nA modified client can call `TriggerServerEvent` with any payload it likes, with no prompt on screen\nat all. The correct shape of a target handler is therefore the same as any other: accept the\nsmallest possible intent, and re-derive everything else.\n\n```lua\n-- server/main.lua\nRegisterNetEvent(\"garage:openTrunk\", function(vehicleId)\n local playerId = source -- authenticated by Open77; never accept it from the payload\n if type(vehicleId) ~= \"number\" then return end\n if not Open77.acl.isAllowed(playerId, \"job.mechanic.trunk\") then return end\n -- Then: is that vehicle real, in this player's bucket, within reach, and is\n -- its trunk actually shut? The prompt answered none of those questions.\nend)\n```","natives":["RegisterNetEvent","server:RegisterNetEvent","server:Open77.acl.isAllowed"],"permissions":[],"events":[]}]},{"slug":"keybindings","file":"keybindings.md","title":"Key mappings","summary":"Register a named action, default key and Lua callback with `RegisterKeyMapping`. Players can rebind it under **Pause → Settings → KEY BINDINGS**. Bindings persist across servers and launcher restarts.","sections":[{"anchor":"key-mappings","heading":"Key mappings","level":1,"text":"Register a named action, default key and Lua callback with `RegisterKeyMapping`. Players can rebind it under **Pause → Settings → KEY BINDINGS**. Bindings persist across servers and launcher restarts.\n\nDevelopers coming from FiveM will recognize the call. Open77 has no client command bus, so the\nmapping runs a Lua callback the resource supplies directly, rather than a registered command string.\n\nA binding is observe-only: pressing its key fires the action but never hides the key from the game,\nand it is suppressed whenever a page owns the keyboard (chat open, the pause menu open, a rebind in\nprogress), so typing never triggers an action.","natives":[],"permissions":[],"events":[]},{"anchor":"permission","heading":"Permission","level":2,"text":"Every entry point requires the `input.actions` permission — the same one that gates\n`Open77.input.isDown`:\n\n```lua\nresource \"flashlight\"\nversion \"1.0.0\"\n\nclient_script \"client/main.lua\"\n\npermissions { \"input.actions\" }\n```","natives":[],"permissions":["input.actions"],"events":[]},{"anchor":"declare-a-mapping","heading":"Declare a mapping","level":2,"text":"`RegisterKeyMapping` is a bare global, mirrored as `Open77.input.registerKeyMapping`. The positional\nform is the common case:\n\n```lua\n-- RegisterKeyMapping(id, name, defaultKey, onPressed [, onReleased])\nRegisterKeyMapping(\"flashlight\", \"Toggle flashlight\", \"F\", function()\n toggleFlashlight()\nend)\n```\n\nThe callback receives the mapping `id` as its only argument. `RegisterKeyMapping` returns\n`true, \"\"` on success — the effective key is the player's saved rebind if there is one,\notherwise the default — or `false, \"\"`.\n\nRe-registering the same `id` replaces the previous entry (reload-safe) and keeps the player's rebind.","natives":["RegisterKeyMapping"],"permissions":[],"events":[]},{"anchor":"press-and-hold","heading":"Press and hold","level":2,"text":"By default the action fires once on the key-down edge. Supply a release callback — or set `hold` in\nthe table form — to receive the key-up edge as well. Use the table form when you need `hold`\nexplicitly:\n\n```lua\nOpen77.input.registerKeyMapping({\n id = \"sprint\",\n name = \"Hold to sprint\",\n key = \"SHIFT\", -- `defaultKey` is accepted as an alias\n hold = true,\n onPressed = function() startSprint() end,\n onReleased = function() stopSprint() end,\n})\n```\n\nIn the positional form, passing a fifth argument (the release callback) implies hold mode.","natives":["Open77.input.registerKeyMapping"],"permissions":[],"events":[]},{"anchor":"key-names","heading":"Key names","level":2,"text":"Keys use the same vocabulary as `Open77.input.isDown`, normalized to upper case:\n\n- a single alphanumeric — `A`–`Z`, `0`–`9`\n- The French AZERTY number row: `&`, `é`, `\"`, `'`, `(`, `-`, `è`, `_`, `ç`, `à`, `)`, `=`.\n Common punctuation is accepted too: `+ [] {} ; : , . / \\\\` and `` ` ~ ! ? @ # $ % ^ * < > | ù ² ``.\n- `F1`–`F12`\n- `SPACE`, `ENTER`/`RETURN`, `TAB`, `SHIFT`, `CTRL`/`CONTROL`, `ALT`, `CAPSLOCK`, `BACKSPACE`,\n `INSERT`, `DELETE`, `HOME`, `END`, `PAGEUP`, `PAGEDOWN`, and the four arrow keys `UP`, `DOWN`,\n `LEFT`, `RIGHT`.\n\nThe pause page's capture, the Lua layer, and the native dispatch all agree on this set, so a key\nchosen in the rebinding UI maps to exactly what a resource registered.\n\nLetters are normalized to uppercase; accented characters and punctuation are preserved.\nThese are **logical keys on the player's current Windows layout**, not US physical labels:\non French AZERTY, `é` and `2` refer to the same top-row key. No Shift chord is required.\nThe menu displays the captured character, including when CEF provides no `event.code`.\nUse UTF-8 Lua files, for example `RegisterKeyMapping(\"slot2\", \"Second slot\", \"é\", callback)`.\n\n**Only keyboard keys can be bound.** A mapping's key is persisted, listed in the pause menu and\npolled by the engine's own dispatcher, and that dispatcher reads keys. Mouse buttons and gamepad\ncontrols are readable — see the next section — but they are not bindable, and the vocabulary was\ndeliberately left alone so that a key saved in `keybinds.json` can never name a control nothing\npolls.","natives":[],"permissions":[],"events":[]},{"anchor":"reading-a-device-directly","heading":"Reading a device directly","level":2,"text":"`Open77.input` also reads the mouse, the wheel, a gamepad and the cursor. Same table, same\n`input.actions` permission: a mouse button is the same kind of read as a key, and a second\npermission string would only mean every menu manifest grows a line.\n\n| Call | Answers |\n|---|---|\n| `Open77.input.isDown(control)` | A key, a mouse button or a gamepad button. `boolean`, or `false, reason`. |\n| `Open77.input.axis(name)` | A stick, a trigger, the cursor, or a cumulative counter. `number`, or `nil, reason`. |\n| `Open77.input.pointer()` | `x, y, source` — the pointer as 0..1 across the image, overlay-aware. |\n| `Open77.input.devices()` | Every mouse and gamepad control, and whether this machine can answer for it. |\n| `Open77.input.isCaptured()` | Whether a WebUI owns the keyboard. |","natives":[],"permissions":[],"events":[]},{"anchor":"control-names","heading":"Control names","level":3,"text":"| Group | Names |\n|---|---|\n| Mouse buttons | `mouse1`/`mouseLeft`, `mouse2`/`mouseRight`, `mouse3`/`mouseMiddle`, `mouse4`, `mouse5` |\n| Mouse axes | `mouseX`, `mouseY`, `mouseDeltaX`, `mouseDeltaY`, `mouseWheel`, `mouseWheelX` |\n| Gamepad buttons | `padA`, `padB`, `padX`, `padY`, `padUp`, `padDown`, `padLeft`, `padRight`, `padStart`, `padBack`, `padLeftShoulder` (`padLB`), `padRightShoulder` (`padRB`), `padLeftThumb`, `padRightThumb` |\n| Gamepad axes | `padLeftStickX`/`Y`, `padRightStickX`/`Y`, `padLeftTrigger`, `padRightTrigger` — each also spelled without the `pad` prefix (`leftStickX`, `rightTrigger`, …) |\n\nNames are case-insensitive. `Open77.input.devices()` is the authoritative list for the machine the\nresource is running on, and it is the list to build a settings screen from rather than the table\nabove.","natives":[],"permissions":[],"events":[]},{"anchor":"these-are-physical-reads","heading":"These are physical reads","level":3,"text":"Everything here reports **the device**, not the game's interpretation of it. It does not know the\nplayer's Cyberpunk key bindings, it does not know whether the game or a menu is currently consuming\nthe control, and it does not know about in-game controller remapping. In FiveM's vocabulary that\nmakes every one of these the `GetDisabledControlNormal` flavour rather than the `GetControlNormal`\none.\n\nTo get the enabled flavour, combine it with the two questions Open77 can answer about who owns the\ninput:\n\n```lua\nlocal function gameplayInput(control)\n if Open77.input.isCaptured() then return false end -- a WebUI has the keyboard\n if Open77.session.isMenuOpen() then return false end -- some modal owns the screen\n return Open77.input.isDown(control) == true\nend\n```\n\nGamepad reads are also zero — and buttons false — while the game window is not in the foreground.\nThe keyboard already behaved that way; this only extends the rule to the pad.","natives":["Open77.input.isCaptured","Open77.session.isMenuOpen","Open77.input.isDown"],"permissions":[],"events":[]},{"anchor":"a-missing-device-is-named-never-answered-zero","heading":"A missing device is named, never answered zero","level":3,"text":"An unplugged gamepad refuses by name. This matters more than it looks: an axis that always reads\nzero cannot be told from a stick held at centre, so a menu built on a silent zero looks broken\nrather than unsupported.\n\n```lua\nlocal turn, reason = Open77.input.axis(\"padRightStickX\")\nif turn == nil then\n -- reason == \"gamepad_not_connected\" on a machine with no pad\n turn = 0\nend\n```\n\nAsking for an axis by `isDown`, or a button by `axis`, is a real mistake and each is named:\n`control_is_an_axis` and `control_is_a_button`. An unknown name is `unsupported_action_key`.\n\nGamepad support is **XInput**, slot 0. Controllers that present themselves as XInput devices — the\nXbox pads, and a DualSense or DualShock through Steam Input or DS4Windows — are read; one that does\nnot is invisible and reports as not connected. Sticks come back with XInput's own dead zones removed\nand the remainder rescaled, so a stick at rest reads exactly `0` and at the rim exactly `1`; stick Y\nis positive **up**.","natives":["Open77.input.axis"],"permissions":[],"events":[]},{"anchor":"the-wheel-and-the-motion-counters-are-cumulative","heading":"The wheel and the motion counters are cumulative","level":3,"text":"`mouseWheel`, `mouseWheelX`, `mouseDeltaX` and `mouseDeltaY` count from the moment the client\nstarted and are **not consumed by reading them**. Difference two samples:\n\n```lua\nlocal previous = Open77.input.axis(\"mouseWheel\") or 0\nCreateThread(function()\n while true do\n Wait(0)\n local now = Open77.input.axis(\"mouseWheel\") or 0\n if now ~= previous then\n scrollList(now - previous) -- positive is away from the player\n previous = now\n end\n end\nend)\n```\n\nA consume-on-read wheel would be shorter to use and wrong: two resources reading it would each\nreceive part of the same scroll, and neither could tell.","natives":["Open77.input.axis","CreateThread","server:CreateThread","Wait","server:Wait"],"permissions":[],"events":[]},{"anchor":"the-pointer-and-which-space-it-is-in","heading":"The pointer, and which space it is in","level":3,"text":"`Open77.input.pointer()` returns `x, y` as **0..1 across the rendered image, origin top left**, plus\na `source`. That is the same frame `Open77.camera.project` answers in and `Open77.camera.unproject`\nconsumes, so hit-testing a projected world point is a subtraction:\n\n```lua\nlocal x, y = Open77.input.pointer()\nlocal target = Open77.camera.project({ x = 100.0, y = 220.0, z = 15.0 })\nif x and target and target.onScreen then\n local dx, dy = x - target.x, y - target.y\n if dx * dx + dy * dy < 0.02 * 0.02 then showTooltip() end\nend\n```\n\nCoordinates are normalized rather than pixel-based. DPI scaling can make the window's client rectangle differ from the render buffer; normalized values use a consistent coordinate space.\n\n`source` says which regime produced it:\n\n| `source` | Meaning |\n|---|---|\n| `overlay` | A WebUI surface owns the pointer. This is Open77's own virtual cursor, the one CEF and the drawn pointer share — the only meaningful position while a page is up. |\n| `system` | Nobody owns it, so this is the OS cursor mapped into the game's client area. |\n\nDuring ordinary gameplay Cyberpunk recentres the OS cursor every frame, so `system` reads near\n`0.5, 0.5`. That is where the cursor is, not a failed read.","natives":["Open77.input.pointer","Open77.camera.project"],"permissions":[],"events":[]},{"anchor":"rebinding-ui","heading":"Rebinding UI","level":2,"text":"The pause menu's **KEY BINDINGS** tab lists every registered action across every running resource\nand rebinds them. It is only a viewer: it reads the registry and writes back through the same\nmanagement API any resource may call.\n\nThis includes both bundled client resources and resources downloaded from the current server.\nCallbacks stay in their owning resource; the pause menu can list, rebind and reset them across\nthe two client layers. Stopped/disconnected resources disappear from the list, while their saved\nchoices are retained for the next session. There are no demo bindings in the shipped pause menu.\n\n| Call | Effect |\n|---|---|\n| `Open77.input.mappings()` | Array of every registered mapping: `{ resource, id, name, key, defaultKey, hold, rebound }`. |\n| `Open77.input.rebind(resource, id, key)` | Set and persist a new key for any mapping. Returns `true, key` or `false, reason`. |\n| `Open77.input.reset(resource, id)` | Restore a mapping to its default key and drop the saved override. Returns `true, defaultKey`. |\n| `Open77.input.keyFor(id)` | Effective key of one of this resource's own mappings, or `nil`. |\n| `Open77.input.unregisterKeyMapping(id)` | Remove a mapping this resource declared. Returns a boolean. |\n\nThe engine raises the local event **`open77:keybinds:changed`** (no arguments) after any register,\nrebind, reset, or unregister, so a viewer can reload its list:\n\n```lua\nAddEventHandler(\"open77:keybinds:changed\", function()\n -- refresh a custom keybind display\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:keybinds:changed"]},{"anchor":"registration-reference","heading":"Registration reference","level":2,"text":"| Field | Meaning |\n|---|---|\n| `id` | Unique within the resource. Letters, numbers, `_`, `.`, `:`, `-`, up to 64 characters. |\n| `name` | Human label shown in the KEY BINDINGS tab. Defaults to `id`; capped at 96 characters. |\n| `key` | Default key from the vocabulary above. `defaultKey` is an accepted alias. |\n| `hold` | When true, `onReleased` fires on key-up in addition to `onPressed` on key-down. |\n| `onPressed` | Called on the key-down edge with the mapping `id`. |\n| `onReleased` | Called on the key-up edge (hold mode only) with the mapping `id`. |\n\nA resource may register up to 64 mappings. Bindings never fire while another WebUI owns keyboard\nfocus, and pressing a mapped key does not stop the game from also seeing it — choose keys that do\nnot clash with an in-world action, or let players rebind.","natives":[],"permissions":[],"events":[]},{"anchor":"persistence","heading":"Persistence","level":2,"text":"Rebinds are stored in a single **machine-global** file, `…/storage/keybinds.json`, deliberately\noutside the per-server KVP layout so a rebind follows the player to every server and survives a\nrelaunch. The engine owns and validates the format; a corrupt file degrades to defaults rather than\nrefusing bindings. A mapping inherits its saved key at registration time, so the player's choice is\napplied the moment the resource declares the action on the next session.\n\nIf writing the preferences fails, `rebind`/`reset` returns `false, \"keybind_save_failed\"` and\nkeeps the previous effective key. Updating one client layer does not discard the other's overrides.","natives":[],"permissions":[],"events":[]}]},{"slug":"loading-screens","file":"loading-screens.md","title":"Custom loading screens","summary":"Client Lua can observe Cyberpunk's native loading-screen lifecycle, including the screen sometimes opened by a long-distance teleport. Use the state to show your own WebUI or temporarily hide your resource's HUD. The API is **read-only**: it does not force a loading screen, skip it, finish streaming or teleport anyone.","sections":[{"anchor":"custom-loading-screens","heading":"Custom loading screens","level":1,"text":"Client Lua can observe Cyberpunk's native loading-screen lifecycle, including the\nscreen sometimes opened by a long-distance teleport. Use the state to show your\nown WebUI or temporarily hide your resource's HUD. The API is **read-only**:\nit does not force a loading screen, skip it, finish streaming or teleport anyone.\n\nThis API requires an updated Open77 client. Feature-detect it on older clients;\nno network protocol or dedicated-server update is required.\n\n| What you want to change | Use |\n| --- | --- |\n| Artwork, logo, tips or progress shown during a native load | A resource WebUI driven by the events on this page. |\n| Your gamemode HUD during a teleport load | Hide its WebUI while `active` is true; restore its normal visibility policy afterwards. |\n| A scripted fade before a teleport or cinematic | [Native screen transitions](screen-transitions.md). |\n| Open77's initial connection or resource download screen | The [launcher and connection flow](launcher.md); this API does not replace it. |\n\nCustomization adds your own cover over the native screen. It does not replace\nCyberpunk's loading assets or change how long the engine needs to load.\n\n```lua\n-- open77.lua\nclient_script 'client.lua'\npermissions { 'screen.read' }\n```\n\n| Client function | Result |\n| --- | --- |\n| `Open77.screen.isLoading()` | `true`/`false`, or `nil, reason` if unavailable. |\n| `Open77.screen.loadingState()` | State table, or `nil, reason` if unavailable. |\n\nBoth observe the same process-wide state, regardless of which resource caused\nthe transition. A resource reload does not reset that state.\n\n| State field | Meaning |\n| --- | --- |\n| `active` | The native loading lifecycle is active, including opening and delayed closing. |\n| `id` | Opaque string identifying this loading cycle within this client process. `\"0\"` before its first observed cycle. |\n| `revision` | Increasing change number; useful for discarding older snapshots. |\n| `kind` | Native screen type: `\"unknown\"`, `\"splash\"`, `\"initial\"` or `\"fastTravel\"`. It can become known after the start event. |\n| `progressKnown` | Whether the native progress bar has reported a value during this cycle. |\n| `progress` | Number from 0 to 1 when known; otherwise `nil`. This is the engine's value and may move backwards between native stages. |\n| `elapsedMs` | Real elapsed milliseconds since the cycle began. Frozen after it ends. |\n\nAt idle, the table retains the most recent cycle's ID, kind and progress. A new\ncycle clears its progress. **100% is not a finished event**: wait for `active`\nto become false. No estimated timer is used to close the screen.\n\n`kind` describes a native screen, **not the cause**. A teleport may use\n`\"unknown\"`; `\"fastTravel\"` is also used during Open77's initial world load.\n`active` is not a pixel-level visibility test: a brief loading transition may\ncomplete before a full-screen illustration is drawn. Background streaming,\nquest fades, your WebUI and Open77's connection/download screen are separate.\nFor quest fades use [`isFaded` / `nativeState`](screen-transitions.md).","natives":[],"permissions":["screen.read"],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"These local client events each receive **one state table** and require\n`screen.read` on the receiving resource:\n\n| Event | Payload | When |\n| --- | --- | --- |\n| `open77:loadingScreen:started` | `(state)` | Once when a new native loading cycle starts. |\n| `open77:loadingScreen:changed` | `(state)` | After an observed active/kind/progress change. Intermediate progress updates are coalesced. |\n| `open77:loadingScreen:finished` | `(state)` | Once when the native delayed-hide callback completes the cycle. |\n\nStart and finish are each followed by `changed`. A short cycle can produce both\nedges in one Lua tick. Payloads describe the event's instant; a state query can\nalready be newer. Do not assume callbacks execute during a blocked engine\nframe: they are delivered on the next client scripting tick. These events are\nnot network events and cannot be generated through `TriggerEvent` or a server\nmessage. The legacy shell-only `open77:loading:progress` remains unchanged.","natives":[],"permissions":[],"events":["open77:loadingScreen:started","open77:loadingScreen:changed","open77:loadingScreen:finished","open77:loading:progress"]},{"anchor":"hide-your-own-webui-while-loading","heading":"Hide your own WebUI while loading","level":2,"text":"Integrate this in the resource that owns `hudPage`. Recompute visibility from\nyour resource's normal policy so a closed menu is not accidentally reopened.\n\n```lua\nif Open77.screen and Open77.screen.loadingState then\n local loading, revision = false, -1\n\n local function renderHudVisibility()\n if hudEnabled and not loading then hudPage:show()\n else hudPage:hide() end\n end\n\n local function apply(state)\n if not state or state.revision < revision then return end\n revision = state.revision\n loading = state.active\n -- Also call renderHudVisibility when hudEnabled changes.\n renderHudVisibility()\n end\n\n AddEventHandler('open77:loadingScreen:changed', apply)\n -- Essential: the resource can start after the started event.\n local state, err = Open77.screen.loadingState()\n if state then apply(state) else print(err) end\nend\n```\n\nDo not make gameplay authority depend on a client's report of loading state.\nIt is presentation information, not proof that a player or destination is ready.","natives":["AddEventHandler","server:AddEventHandler","Open77.screen.loadingState"],"permissions":[],"events":["open77:loadingScreen:changed"]},{"anchor":"cover-the-native-screen-and-hide-the-vanilla-hud","heading":"Cover the native screen and hide the vanilla HUD","level":2,"text":"Create `resources/my_loading_screen/` with these three files. The HTML is kept\nready and transparent between loads, so Chromium does not need to start when\nthe engine begins loading. The page takes no keyboard or mouse focus.","natives":[],"permissions":[],"events":[]},{"anchor":"1-declare-the-resource","heading":"1. Declare the resource","level":3,"text":"```lua\n-- open77.lua\nresource 'my_loading_screen'\nversion '1.0.0'\nauto_start true\nreload_policy 'reconnect'\nclient_script 'client.lua'\nweb_ui_page 'web/index.html'\nweb_ui_auto_create false\nfiles { 'web/index.html' }\npermissions { 'screen.read', 'webui.modal', 'ui.vanilla.hud' }\n```","natives":[],"permissions":["screen.read","webui.modal","ui.vanilla.hud"],"events":[]},{"anchor":"2-forward-the-loading-state-to-your-page","heading":"2. Forward the loading state to your page","level":3,"text":"```lua\n-- client.lua\nif not Open77.screen or not Open77.screen.loadingState then\n print('[my_loading_screen] Native loading observation is unavailable.')\n return\nend\n\nAddEventHandler('onClientResourceStart', function(name)\n if name ~= GetCurrentResourceName() then return end\n\n local page, reason = Open77.webui.create({\n entry = 'web/index.html', layer = 'modal', zIndex = 100,\n visible = true, transparent = true, fps = 30,\n })\n if not page then print(reason); return end\n\n local ready, active, revision = false, false, -1\n local function apply(state)\n if not state or state.revision < revision then return end\n revision = state.revision\n if state.active ~= active then\n active = state.active\n -- Releases only this resource's HUD claims when loading ends.\n Open77.hud.setVisible('all', not active)\n end\n if ready then page:send('loading:state', state) end\n end\n local function refresh()\n local state, err = Open77.screen.loadingState()\n if state then apply(state) else print(err) end\n end\n\n AddEventHandler('open77:loadingScreen:changed', apply)\n page:on('ui:ready', function()\n ready = true\n refresh()\n end)\n -- Also handles a resource activated after the loading cycle started.\n refresh()\nend)\n```\n\nCreate the page from `onClientResourceStart`, when presentation effects are\npermitted. Register the listener before the initial state query. Wait for\n`ui:ready` before sending to JavaScript; this avoids losing the first update.","natives":["AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName","Open77.webui.create","Open77.hud.setVisible","Open77.screen.loadingState"],"permissions":[],"events":["open77:loadingScreen:changed"]},{"anchor":"3-design-the-cover","heading":"3. Design the cover","level":3,"text":"```html\n\n\n\n\nMy server loading screen\n\n
\n

MY SERVER

\n

Loading your destination

\n \n

Loading…

\n

Explore Night City with your crew.

\n
\n\n\n```\n\nChange `MY SERVER`, the heading, the tip and the CSS variables to match your\nserver. To add artwork, ship `web/background.webp` and `web/logo.png`, include\nboth in the manifest's `files` list, then use `url('./background.webp')` in the\ncover's background and `\"My` in its markup.\nAsset paths are relative to `web/index.html`.\n\nUse a CSS spinner or an indeterminate `` while `progressKnown` is\nfalse. Keep the cover visible at 100% until `active` becomes false. Never use\na fixed timeout or the progress percentage to decide that the load has ended.","natives":[],"permissions":[],"events":[]},{"anchor":"4-enable-it-on-the-server","heading":"4. Enable it on the server","level":3,"text":"Add `my_loading_screen` to the server configuration's `resources.load` array,\nthen reconnect a client to receive the resource and its files. Keep any other\nconfigured resources in that array. Use a long-distance teleport that actually\ntriggers Cyberpunk's loading screen to check the cover and HUD restoration;\nshort teleports may not open one.\n\nThe repository also includes the optional\n[`resources/examples/open77_loading_screen`](https://github.com/Open2077/open77-resources/tree/main/examples/open77_loading_screen) resource. Enable it using\n`examples/open77_loading_screen` in `resources.load`; its resource name is\n`open77_loading_screen`. Run only one loading cover at a time.\n\nStopping or reloading the resource destroys its page and releases its HUD\nclaims automatically. It does not alter other resources' WebUIs; those should\nuse the loading events in their own visibility policy.\n\nUse a separate resource for temporary vanilla HUD claims if your existing\nresource already keeps components hidden: releasing `all` releases every HUD\nclaim owned by that resource. See [HUD ownership](hud-visibility.md).\n\nServer WebUIs remain subject to normal session presentation gates: this does\nnot allow a downloaded resource to replace Open77's initial connection screen.\nAvoid `setCinematic(true)` for this use: it also suppresses unfocused WebUIs,\nincluding your own loading overlay. See [WebUI configuration](/docs/resource-runtime#webui).","natives":[],"permissions":[],"events":[]},{"anchor":"errors-and-diagnostics","heading":"Errors and diagnostics","level":2,"text":"| Symptom | Check |\n| --- | --- |\n| No overlay appears | Confirm the resource is loaded, the client supports `loadingState`, and the teleport opens a native loading cycle. Check Lua/WebUI creation errors and the `ui:ready` handshake. |\n| Your HUD is still over the loading artwork | Hide that resource's WebUI with `changed`; `Open77.hud` only controls native HUD claims. |\n| Cover stays at 100% | Wait for `finished` / `active == false`; progress is not a completion signal. |\n| Cover vanishes in cinematic mode | Release the cinematic claim or coordinate with its owner; cinematic mode can hide an unfocused WebUI. |\n\n- `permission_denied:screen.read`: declare the read permission.\n- `loading_screen_unavailable`: the guarded native observer is unavailable or\n has not observed the loading layer yet. Do not interpret `nil` as `false`.\n- `loading_screen_unavailable_on_this_host`: no client backend on this host.\n\nThe local debug command `session.loading` returns the same state as the Lua\nquery. There is no arbitrary engine pointer or native-call API exposed to Lua.","natives":[],"permissions":[],"events":[]}]},{"slug":"local-puppet-visibility","file":"local-puppet-visibility.md","title":"Local player visibility","summary":"Client Lua can hide the avatar seen on the player's own screen, including the third-person double, with `Open77.players.setLocalPuppetVisible(visible)`. Requires a client exposing both local-puppet visibility functions. Treat the API as experimental and check that the functions exist on older clients. The server visibility API is separate; no new server visibility call is required.","sections":[{"anchor":"local-player-visibility","heading":"Local player visibility","level":1,"text":"Client Lua can hide the avatar seen on the player's own screen, including the\nthird-person double, with `Open77.players.setLocalPuppetVisible(visible)`.\nRequires a client exposing both local-puppet visibility functions. Treat the API\nas experimental and check that the functions exist on older clients. The\nserver visibility API is separate; no new server visibility call is required.\n\n```lua\n-- open77.lua\npermissions { \"players.local.visibility\", \"network.events\" }\n\n-- client.lua\nRegisterNetEvent(\"disguise:visible\", function(visible)\n local setVisible = Open77.players.setLocalPuppetVisible\n if not setVisible then\n print(\"Local avatar visibility is unavailable on this client\")\n return\n end\n local ok, reason = setVisible(visible)\n if not ok then print(reason) end\nend)\n-- Your server sends false when the disguise starts and true when it ends.\n```\n\nThe setter requires one strict boolean and returns `true` for an accepted\npolicy, or `false, reason`. Rendering follows on the game tick. The getter takes\nno arguments and returns the combined resource policy, or `nil, reason`.\n`true` means that no resource holds a hide; it does not force the camera into\nthird person or guarantee that a body is currently on screen.\n\nEach resource instance owns its request. Calling `true`, stopping or reloading\na resource releases only its own hide. If another resource still requests\n`false`, the avatar stays hidden. Death, player replacement and disconnect\nrelease the local lifecycle's requests; reapply the disguise from your spawn\nlogic if appropriate. Initial script loading cannot mutate this state; use a\nrunning callback or thread.\n\nThe native body, its held weapon and the third-person presentation are hidden\nlocally. The camera keeps its selected view and can switch FPP/TPP while the\nhide is held. Movement and collision remain unchanged. This does not remove\ngameplay effects or separately spawned props. It does not change what other\nplayers see: server `Open77.players.setVisible(playerId, visible)` controls\nthat independently.","natives":["RegisterNetEvent","server:RegisterNetEvent"],"permissions":["players.local.visibility","network.events"],"events":[]},{"anchor":"prop-hunt-example","heading":"Prop Hunt example","level":2,"text":"```lua\n-- server.lua: add permission \"players.life.visibility\" to the manifest.\n-- Call only after the server has validated and applied the disguise.\nlocal function setDisguised(playerId, disguised)\n local ok, reason = Open77.players.setVisible(playerId, not disguised)\n if not ok then return false, reason end\n TriggerClientEvent(\"prophunt:localDisguise\", playerId, disguised)\n return true\nend\n\n-- client.lua\nRegisterNetEvent(\"prophunt:localDisguise\", function(disguised)\n local setVisible = Open77.players.setLocalPuppetVisible\n if not setVisible then\n print(\"This client needs the local-puppet visibility update\")\n return\n end\n local ok, reason = setVisible(not disguised)\n if not ok then print(\"Local disguise refused: \" .. tostring(reason)) end\nend)\n```\n\nUse the server's own disguise state to attach/show the prop. The local call\ndoes not replicate a disguise or grant invisibility to other clients. A\nsuccessful server visibility change does not guarantee that an outdated client\nsupports the local call; enforce your minimum client version for the mode.\n\nErrors include `expected_boolean`, `unexpected_arguments`,\n`permission_denied:players.local.visibility`, `resource_not_running`,\n`player_not_ready`, `visibility_owner_quota` and `game_unavailable_on_this_host`.\nAn owner may always release its existing request even while no player is ready.","natives":["server:Open77.players.setVisible","server:TriggerClientEvent","RegisterNetEvent","server:RegisterNetEvent"],"permissions":[],"events":[]},{"anchor":"function-reference","heading":"Function reference","level":2,"text":"See [setLocalPuppetVisible](/docs/api/client/open77-players#setlocalpuppetvisible)\nand [isLocalPuppetVisible](/docs/api/client/open77-players#islocalpuppetvisible).\nCustom bodies and vehicle cameras may require additional mode-specific checks.","natives":[],"permissions":[],"events":[]}]},{"slug":"loot","file":"loot.md","title":"Server loot and ground drops","summary":"Ground loot is server-authoritative. Native containers and drops display the server's items; Take and Take All send pickup requests checked against the drop, routing bucket and player distance. The local inventory changes only after authorization.","sections":[{"anchor":"server-loot-and-ground-drops","heading":"Server loot and ground drops","level":1,"text":"Ground loot is server-authoritative. Native containers and drops display the server's items; Take and Take All send pickup requests checked against the drop, routing bucket and player distance. The local inventory changes only after authorization.\n\nThe official [`open77_loot`](https://github.com/Open2077/open77-resources/tree/main/system/open77_loot) resource must stay started. Its manifest asks for `network.events` and\n`world.loot`, and its client is distributed with the server's resource set.\n\nVanilla loot suppression is a client session policy, independent of this resource. A server\nwithout `open77_loot`, or one that stops it, does **not** enable solo loot: unmanaged choices and\nlocal pickup/consume callbacks remain blocked. Managed drops keep their native presentation and\nserver pickup requests. Outside multiplayer, normal solo behavior remains the fallback unless\nloot authority has been explicitly enabled for a local test.\n\nUse this guide to place items in the world from a server resource, and to credit them to a player's\ninventory when they are picked up.","natives":[],"permissions":[],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"Server resources that declare `world.loot` get `Open77.loot`:\n\n```lua\npermissions { \"network.events\", \"world.loot\" }\n```\n\n```lua\nlocal dropId, reason = Open77.loot.create({\n item = \"Items.money\", -- TweakDB record\n quantity = 250,\n position = { x = -1450.2, y = 117.8, z = 12.4 },\n bucket = 0, -- routing bucket\n radius = 2.0, -- 0.5 to 10 metres\n label = \"Eurodollars\",\n visualItem = \"Items.MoneyShard\", -- record used only for the 3D entity\n ttlMs = 300000 -- optional, seven days maximum\n})\n\nassert(dropId, reason)\n\nOpen77.loot.update(dropId, {\n quantity = 300,\n position = { x = -1450.0, y = 118.1, z = 12.4 },\n radius = 2.5\n})\n\nlocal drop = Open77.loot.get(dropId)\nlocal bucketDrops = Open77.loot.all(0)\nOpen77.loot.remove(dropId)\n```\n\nA player's authoritative position is available server-side too:\n\n```lua\nlocal position = Open77.players.position(playerId)\n-- { x, y, z, bucket }, or nil when the player or snapshot is unavailable.\n```\n\nA drop belongs to the resource that created it. Another resource can neither change nor delete it,\nand stopping the owning resource cleans up its drops. The registry is capped at 4,096 entries,\nquantities at 1,000,000, and positions at the world bounds the protocol accepts.\n\n`item` and `visualItem` play distinct roles. `item` is the authoritative content actually granted\nonce the pickup is validated. `visualItem` is a TweakDB record that owns a `dropObject` and serves\nonly to create the physical `gameItemDropObject`. For a weapon, leaving `visualItem` empty uses the\nweapon's own record directly. Since `Items.money` is abstract currency, Open77 automatically picks\n`Items.MoneyShard` (`smallItemDrop`, entity `money`) when no override is supplied.\n\nAfter an accepted pickup, every server VM receives:\n\n```lua\nAddEventHandler(\"onLootPickup\", function(playerId, dropId, item, quantity, ownerResource)\n if ownerResource ~= GetCurrentResourceName() then return end\n\n -- Write the authoritative persistent inventory here (database, weight,\n -- transaction log, and so on). The client never decides the outcome.\n print(playerId, dropId, item, quantity)\nend)\n```\n\nThe pickup is atomic within the registry: two players cannot consume the same id. Validation uses a\n`PlayerSnapshot` received less than two seconds ago, requires the same routing bucket, and accepts\nat most `radius + 0.75 m` to absorb network latency.","natives":["server:Open77.loot.create","server:Open77.loot.update","server:Open77.loot.get","server:Open77.loot.all","Open77.loot.remove","server:Open77.loot.remove","server:Open77.players.position","AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName"],"permissions":["network.events","world.loot"],"events":[]},{"anchor":"server-events","heading":"Server events","level":2,"text":"```lua\nAddEventHandler(\"onLootCreated\", function(id, resource, item) end)\nAddEventHandler(\"onLootRemoved\", function(id, reason, resource) end)\n```\n\nBoth require `world.loot` — the same string reading one costs, because a lifecycle event\nthat announced a loot to a resource that cannot list one would route around that capability.\n`reason` is `removed`, `expired`, `resource_stopped`, `picked_up`, `moved_bucket`, or the free text (at most 64\ncharacters) the caller passed to the remove call.\n\nThe same two transitions also reach the generic `onEntityCreated(kind, id, resource)` and\n`onEntityRemoved(kind, id, reason)` with `kind` = `\"loot\"`, for a resource that additionally\ndeclares `world.entities.observe`. The mirror is raised by the same statement, so the two feeds\ncannot disagree; see [entity lifecycle events](server-api.md#entity-lifecycle-events) for the\nauthority rule and for why there is no `Updated` counterpart.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"testing-from-the-open77-terminal","heading":"Testing from the Open77 terminal","level":2,"text":"These commands are genuinely declared in `open77_loot/server/main.lua` with `RegisterCommand`, just\nas in a FiveM resource:\n\n```text\nloot.create.player 1 Items.money 250 1.0 2.0 Items.MoneyShard\nloot.create Items.money 250 -1450.2 117.8 12.4 0 2.0\nloot.create.player 1 Items.Preset_Lexington_Default 1\nloot.list 0\nloot.remove 1\n```\n\nType these lines into the developer terminal opened with `²` in Cyberpunk. `loot.list` is public.\nThe mutations `loot.create`, `loot.create.player`, and `loot.remove` are registered with\n`restricted=true` and require the ACL permissions `command.loot.create`,\n`command.loot.create.player`, and `command.loot.remove` respectively (or a suitable wildcard).\n\n`loot.create` expects ` [bucket] [radius] [visualItem]`. Decimal numbers\nuse a dot. The drop is replicated to the bucket's players immediately; its prompt should disappear on\nevery client as soon as a pickup is accepted.\n\n`loot.create.player` uses the player's latest fresh snapshot and adds one metre in Z by default, so\nthe item falls in front of them. Its full form is\n`loot.create.player [offsetZ] [radius] [visualItem]`.\n\nThe representation is a real native entity produced by `LootManager.SpawnItemDrop`: the record's\nmesh, a `gameItemDropObject` wrapper, placement, physics, highlight, and REDengine UI. Open77 binds\nthe native `EntityID` to the server id when the child object appears, then fills the native inventory\nwith `item` and `quantity`. There is no separate Open77 anchor or prompt any more.","natives":[],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"Gameplay clients read the projection through `open77_loot`'s exports:\n\n```lua\nCreateThread(function()\n local promise, err = Open77.exports.call(\"open77_loot\", \"all\")\n if not promise then return print(err) end\n local drops = assert(promise:await())\n for _, drop in ipairs(drops) do\n print(drop.id, drop.item, drop.quantity)\n end\nend)\n\n-- The native prompt already performs this. Useful for a custom UI.\nCreateThread(function()\n local promise = Open77.exports.call(\"open77_loot\", \"requestPickup\", 42)\n if promise then print(promise:await()) end\nend)\n```\n\nAvailable exports:\n\n- `get(id)` — returns the local drop, or `nil`;\n- `all()` — snapshot sorted by id;\n- `requestPickup(id)` — sends the request to the server.\n\nThis local event lets you show feedback without touching authority:\n\n```lua\nAddEventHandler(\"open77:loot:result\", function(id, accepted, reason, item, quantity)\n if not accepted then\n print(\"Pickup refused: \" .. tostring(reason))\n end\nend)\n```\n\nDo not call `Open77.loot.upsert`, `acceptPickup`, or `setAuthorityEnabled` directly from a gameplay\nresource. Those are the internal primitives of the distributed projection, guarded by `world.loot`.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:loot:result"]},{"anchor":"the-boundary-between-native-ui-and-server-authority","heading":"The boundary between native UI and server authority","level":2,"text":"- `Inventory` choices stay visible only for entities bound to a server drop;\n- `Inventory.OnInteractionUsed` is intercepted before its local `RemoveItem` and becomes a\n `open77:loot:pickup` request;\n- the native pickup condition is re-enabled only for bound `gameItemDropObject`s;\n- legacy placed pickups (`HealthConsumable`, `VirtualItem_TEMP`) and the `Loot` choices of\n inspectable objects;\n- the container role of bodies, bags, ground items, and static containers;\n- the vanilla backpack's \"drop\" action;\n- the normal held-weapon drop path when an NPC dies;\n- drops already present when authority activates, purged before the server projection.\n\nThe global purge runs only when authority activates. After that, a pickup, a removal, or an update\nempties only the inventory of the entity concerned. Cyberpunk then receives its normal\n`OnInventoryEmptyEvent` and cleanly removes the HUD, the interaction, and the visual without\nrecreating the other ground items.\n\nOpen77 items therefore use the same presentation as vanilla: the physical mesh resolved by the\nTweakDB record falls with the game's physics, and the `Inventory` component builds the list and the\nitem card. That entity holds no authority though: removal and granting only happen after the server\nanswers positively.","natives":[],"permissions":[],"events":["open77:loot:pickup"]},{"anchor":"scope","heading":"Scope","level":2,"text":"**A drop holds one stack** — one `item` and one `quantity`. Containers holding several stacks, and\ncorpses carrying an authoritative inventory, belong to the container API and are not part of this\none.\n\n**Persistent inventory is yours to write.** This subsystem decides *who may take what*, and stops\nthere. `onLootPickup` is where you credit an account, apply weight, or write a transaction log.\n\n**Quest collectibles that bypass `Inventory`** are not intercepted. A handful of highly specialised\npickups take neither the `Inventory` path nor the standard loot conditions; those still grant\nlocally. If you meet one, its class can be identified with the Open77 inspector.","natives":[],"permissions":[],"events":[]}]},{"slug":"lua-modules","file":"lua-modules.md","title":"Lua modules and require","summary":"Split resource code into modules with `require`. A module returns its public value, typically a function table. Libraries such as [PolyZone](polyzone.md) execute inside the importing resource.","sections":[{"anchor":"lua-modules-and-require","heading":"Lua modules and require","level":1,"text":"Split resource code into modules with `require`. A module returns its public value, typically a function table. Libraries such as [PolyZone](polyzone.md) execute inside the importing resource.\n\n```lua\nlocal PZ = assert(require('@polyzone'))\nlocal zone = PZ.CircleZone:Create({x=100, y=200, z=20}, 15, {name='meeting_point'})\nprint(zone:isPointInside({x=105, y=200, z=20})) -- true\n```\n\nThis loads PolyZone into **your resource's client Lua VM**. It does not download\na package from the internet, call a remote service, or share your zones with\nanother resource or player.","natives":[],"permissions":[],"events":[]},{"anchor":"availability","heading":"Availability","level":2,"text":"`require` is a **client-side** global. The dedicated-server sandbox does not\nexpose it. For server code, declare ordered `server_script` entry points; use\n[server exports](server-exports.md) to call another server resource.\n\nLocal modules already exist. The `@dependency` syntax requires the newer client\nimplementation introduced with PolyZone in client `2.31.13+op77.67`.\nInstalling a Lua package alone cannot add this capability to an older client.","natives":[],"permissions":[],"events":[]},{"anchor":"load-a-file-from-your-own-resource","heading":"Load a file from your own resource","level":2,"text":"Example resource layout:\n\n```text\ntaxi/\n open77.lua\n client/main.lua\n shared/fares.lua\n```\n\nPublish the module as a downloadable file, not a second entry point:\n\n```lua\n-- taxi/open77.lua\nresource 'taxi'\nversion '1.0.0'\nclient_script 'client/main.lua'\nfiles { 'shared/fares.lua' }\n```\n\n```lua\n-- taxi/shared/fares.lua\nlocal Fares = {}\n\nfunction Fares.calculate(metres)\n return math.floor(10 + metres * 0.02)\nend\n\nreturn Fares\n```\n\n```lua\n-- taxi/client/main.lua\nlocal Fares, reason = require('shared.fares')\nassert(Fares, reason)\nprint(Fares.calculate(1500)) -- 40\n```\n\nThe name is relative to the **resource root**, not to `client/main.lua`.\nUse dots for directories and omit `.lua`.\n\n| Import | Resolution, in order |\n|---|---|\n| `require('shared.fares')` | Your resource's `shared/fares.lua`, then `shared/fares/init.lua` |\n| `require('@my_library')` | The dependency's published `init.lua`, then `init/init.lua` |\n| `require('@my_library/helpers.math')` | The dependency's published `helpers/math.lua`, then `helpers/math/init.lua` |\n\nDo not write `require('shared/fares.lua')` or an absolute disk path. Module\nnames accept letters/digits, underscores, hyphens and dot-separated segments;\ntraversal such as `..` is rejected. The slash after `@my_library` separates the\nresource name from the module name; nested module directories still use dots.\n\n`files` makes a file available to clients. It does not execute it. Conversely,\n`client_script` executes an entry point automatically. Listing the same module\nas an entry point and importing it can execute its initialization twice.","natives":[],"permissions":[],"events":[]},{"anchor":"import-a-dependency-such-as-polyzone","heading":"Import a dependency such as PolyZone","level":2,"text":"The server must install and load both resources. The caller declares its\ndependency and the permissions its own code needs:\n\n```lua\n-- my_job/open77.lua\nresource 'my_job'\nversion '1.0.0'\ndependency 'polyzone >=1.0.0'\nclient_script 'client/main.lua'\npermissions { 'world.query' }\n```\n\n```lua\n-- my_job/client/main.lua\nlocal PZ, reason = require('@polyzone')\nassert(PZ, reason)\n\nlocal garage = PZ.BoxZone:Create({x=100, y=200, z=20}, 12, 8, {\n name='garage', heading=30, minZ=19, maxZ=24,\n})\n\ngarage:onPlayerInOut(function(inside)\n print(inside and 'Entered garage' or 'Left garage')\nend, 250)\n```\n\nHere `world.query` is needed to read the player's position, not to use\n`require` itself. A purely mathematical module needs no engine permission.\nAdd `world.debug` only if you enable PolyZone debug drawing.\n\nThe dependency must be declared and running, and its module must be published\nunder `files`. `require` neither installs nor starts a missing dependency.\nImports always execute with the **caller's permissions, memory budget and\nresource lifetime**, not the provider's privileges.","natives":[],"permissions":["world.query"],"events":[]},{"anchor":"publish-your-own-library","heading":"Publish your own library","level":2,"text":"```lua\n-- my_library/open77.lua\nresource 'my_library'\nversion '1.0.0'\nauto_start true\nfiles { 'init.lua', 'helpers/math.lua' }\n```\n\n```lua\n-- my_library/helpers/math.lua\nreturn { double = function(value) return value * 2 end }\n```\n\n```lua\n-- my_library/init.lua\nreturn { math = assert(require('@my_library/helpers.math')) }\n```\n\nUse the provider-qualified form for sibling imports inside a reusable library.\n`require('helpers.math')` would search the **consumer's** resource, even when\nthat call is written inside `my_library/init.lua`.\n\nThe consumer declares `dependency 'my_library >=1.0.0'` and uses\n`local Library = assert(require('@my_library'))`. It does not list\n`@my_library/init.lua` as a manifest script. A provider may use its own\n`@my_library` name without declaring a dependency on itself.\n\nEverything in `files` is client-distributed content. Never publish credentials,\nprivate configuration or server-only logic there.","natives":[],"permissions":[],"events":[]},{"anchor":"a-library-needs-no-script-of-its-own","heading":"A library needs no script of its own","level":3,"text":"The manifest above is complete. `files` alone is a valid resource, and so is\n`files` plus a `server_script` for a library that also publishes server-side\n`exports()`:\n\n```lua\n-- bt_lib/open77.lua: server exports plus client modules, no client entry point\nresource 'bt_lib'\nversion '0.1.0'\nauto_start true\nserver_script 'server/main.lua'\nfiles { 'init.lua', 'client/raycast.lua', 'client/format.lua' }\n```\n\nThe server delivers every resource that has client content, and `files` is\nclient content. On the client such a **passive library** gets an idle VM, reaches\n`Running` without executing anything, and a dependant's `require('@bt_lib/...')`\nresolves against its declared files. A `server_script` never reaches a client;\nit does not have to be paired with a `client_script`, empty or otherwise.\n\nThe only manifest that is refused is one with neither a script nor `files`:\n`no_client_scripts` on the client, \"Resource contains no scripts or files.\" on\nthe server. `web_files` alone do not count; nothing could open them.\n\n**Builds up to 2.31.13+op77.82 are stricter.** Their client refuses any\ndelivered resource without a `client_script` or `shared_script`, logs\n`no_client_scripts`, and that single refusal fails the whole activation:\n`server resource activation failed: discovery:... failed=1`, and the client\nstays on \"Verifying resources\". Fixed in the release that follows op77.82. Until\nyour players run it, add an empty `client_script 'client/noop.lua'` to the\nlibrary; it costs nothing and can be removed afterwards.","natives":[],"permissions":[],"events":[]},{"anchor":"cache-ownership-and-reload","heading":"Cache, ownership and reload","level":2,"text":"The first successful import executes the module synchronously. Later imports\nof the same resolved file in the same Lua VM return its cached value:\n\n```lua\nlocal first = assert(require('@polyzone'))\nlocal again = assert(require('@polyzone/init'))\nprint(first == again) -- true: both resolve to polyzone/init.lua\n```\n\nIf a module returns nothing or nil, the cached result is `true`. Return a table\nexplicitly when exposing an API. A module may deliberately return `false`;\ncheck `value == nil` if false is a valid result for your library.\n\nTwo consuming resources have **separate module instances**. Within one VM,\nmutating the returned table affects other imports of that module in that VM;\nit does not affect another resource's copy.\n\nKeep module initialization synchronous: do not call `Wait` or await an export\nat file scope during `require`. Return functions that can later run in\nscheduler coroutines. Circular imports return `nil, 'circular_module_dependency'`.\n\nStopping/reloading the consumer destroys its VM, cached modules and owned\ntasks/handlers. Native objects still follow their individual API cleanup rules;\nPolyZone clears its own blips and debug drawings. Dropping one Lua variable is\nnot equivalent to destroying a zone: call `zone:destroy()` when finished.\n\nUpdating the provider does not rewrite tables already held by consumers.\nReload the consuming resources to adopt the new library code. A fresh\n`require('@provider')` still checks that the dependency is declared and running;\na cached value does not bypass those checks.","natives":[],"permissions":[],"events":[]},{"anchor":"require-or-exports","heading":"Require or exports?","level":2,"text":"| Need | Use | Where the code runs |\n|---|---|---|\n| Split your client code into files | Local `require` | Your resource's VM |\n| Reuse constructors, helpers, zones or callback-based Lua objects | `require('@library')` | A separate library copy in each caller's VM |\n| Ask another resource to open its UI or perform a service operation | `Open77.exports.call(...)` | The provider's VM, asynchronously |\n| Send information between client and server | Network events | Across the transport, with server validation |\n\nExports copy supported values across the VM boundary; they cannot transfer a\nlive Lua function or a working zone object with methods. That is why PolyZone\nuses `require` for its classes. Its server-side `TriggerZoneEvent` service is\nan export instead. See [resource exports](resource-exports.md) and\n[server exports](server-exports.md).\n\nImported client code does not become authoritative. For money, inventory or\naccess checks, validate the action on the server even when a client zone says\nthe player is inside.","natives":[],"permissions":[],"events":[]},{"anchor":"troubleshooting","heading":"Troubleshooting","level":2,"text":"```lua\nlocal library, reason = require('@my_library')\nif library == nil then\n print('Library could not be loaded: '..tostring(reason))\n return\nend\n```\n\n| Result | What to check |\n|---|---|\n| `module_dependency_not_declared` | Add the provider's resource name to the caller's `dependency` declaration. |\n| `module_dependency_not_running` | Install/load the provider and fix any startup error before importing it. |\n| `module '...' not found in resource VFS` | Check spelling, resource-root paths, downloaded files and the provider's `files` entries. |\n| `invalid_module_name` | Omit `.lua`, use dot-separated folders, and avoid paths/URLs. An old client can also reject the new `@` syntax. |\n| `circular_module_dependency` | Remove the import cycle; extract shared definitions into a third module. |\n| Syntax/runtime error text | Fix the module identified in the message; its first execution did not complete. |\n| Attempt to call nil `require` | This is server-side code, not the client module runtime. |\n\nOrdinary module lookup/loading failures return `nil, reason`; invalid argument\ntypes can raise a Lua argument error. No `package.path`, native DLL loader,\nHTTP importer or unrestricted filesystem access is exposed.\n\nSee the [client API reference](/docs/api/client/globals#require) for the call\nsignature and [PolyZone](polyzone.md) for the complete zone API.","natives":[],"permissions":[],"events":[]}]},{"slug":"markers","file":"markers.md","title":"3D world markers","summary":"Use `Open77.markers` in a client resource to place checkpoints, rental locations, objectives and other indicators in the world. Markers are native REDengine meshes with depth testing and a translucent gradient, not WebUI overlays or map pins.","sections":[{"anchor":"3d-world-markers","heading":"3D world markers","level":1,"text":"Use `Open77.markers` in a client resource to place checkpoints, rental locations, objectives and other indicators in the world. Markers are native REDengine meshes with depth testing and a translucent gradient, not WebUI overlays or map pins.\n\nThe eight-shape catalogue, RGBA colors, transforms and loading diagnostics require client **2.31.13+op77.83 or newer**. The meshes ship with the client; server developers do not need Blender, WolvenKit or a separate asset pack.","natives":[],"permissions":[],"events":[]},{"anchor":"create-a-marker","heading":"Create a marker","level":2,"text":"Request the permission in your resource manifest:\n\n```lua\npermissions { \"world.markers\" }\n```\n\nRun this in your client script once the world and local character are ready. Use `open77:worldReady` for a new session and check `Open77.character.state().attached` when starting a resource in an already-loaded world; see [resource lifecycle](world-drawing.md#rebuild-on-start-not-only-on-worldready).\n\n```lua\nlocal rentalMarker, reason = Open77.markers.create({\n position = { x = 381, y = -2396, z = 182.06 }, -- replace with your location\n shape = \"cylinder\",\n radius = 1.5,\n height = 1.2,\n color = { r = 0, g = 220, b = 255, a = 180 },\n maxDistance = 80,\n})\nassert(rentalMarker, reason)\n```\n\nKeep the returned decimal-string handle unchanged; do not convert it with `tonumber`. Creation is asynchronous: receiving a handle does not mean the mesh has finished loading.\n\nA marker only draws a location. It does not create an interaction prompt, collision, map pin or rental action. Pair it with [interactions](interactions.md), [PolyZone](polyzone.md) or [proximity zones](zones.md), and validate purchases and permissions on the server. Use [blips](blips.md) for the map and minimap.","natives":["Open77.markers.create"],"permissions":["world.markers"],"events":["open77:worldReady"]},{"anchor":"shapes","heading":"Shapes","level":2,"text":"`Open77.markers.shapes()` returns the following stable string identifiers. It needs no permission because it only reads the static catalogue. Numeric marker types from other games are not supported.\n\n| Shape | Geometry |\n| --- | --- |\n| `ring` | Thin horizontal ring with a hollow centre |\n| `cylinder` | Open cylindrical wall with an upward fade |\n| `checkpoint` | Vertical circular checkpoint frame |\n| `arrow` | Extruded arrow pointing down; rotate it to point elsewhere |\n| `chevron` | Extruded directional chevron |\n| `cone` | Downward-pointing cone |\n| `diamond` | Low-poly diamond |\n| `sphere` | Rounded sphere |\n\n```lua\nfor _, shape in ipairs(Open77.markers.shapes()) do\n print(shape)\nend\n```\n\nPosition is the mesh's bottom origin, not its centre. There is no automatic terrain snap: place ground markers slightly above the floor to avoid z-fighting. Most meshes have a unit width and height before scaling; the ring is only `0.035` units thick.","natives":["Open77.markers.shapes"],"permissions":[],"events":[]},{"anchor":"options-and-dimensions","heading":"Options and dimensions","level":2,"text":"`create(options)` and `update(id, patch)` use the same fields. Only `position` is required on creation; updates leave omitted fields unchanged.\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `position` | Required on create | World position `{x, y, z}`, in metres |\n| `shape` | `\"ring\"` | One of the eight shape names |\n| `style` | `\"interaction\"` | Palette: cyan `interaction`, gold `objective`, green `spawn`, red `danger` |\n| `radius` | `1.5` | Horizontal half-width, `0.1..50` metres |\n| `height` | `1.5` | Vertical multiplier, `0.01..100` |\n| `scale` | `{x=1, y=1, z=1}` | Additional XYZ multipliers, `0.01..100` on each axis |\n| `rotation` | `{x=0, y=0, z=0}` | Local XYZ Euler angles in degrees; Z is yaw |\n| `color` | Style palette | `{r, g, b, a}` integer channels `0..255`; omitted alpha defaults to `180` |\n| `visible` | `true` | Explicit visibility |\n| `minDistance` | `0` | Hide below this distance from the current camera, in metres; must be non-negative |\n| `maxDistance` | `100` | Hide beyond this camera distance, `1..500` metres |\n\nHorizontal dimensions are `2 * radius * scale.x` and `2 * radius * scale.y`. Vertical size is `height * scale.z` times the mesh's authored height. Effective dimensions are capped at 200 metres, and `minDistance` must be less than `maxDistance`.\n\nAn explicit RGBA color overrides the style palette. Alpha `0` is invisible; `255` is the maximum opacity before the mesh gradient. Colors are independent per marker. Hex strings are not accepted by this native API.\n\n```lua\n-- Change only this marker's color and dimensions.\nassert(Open77.markers.update(rentalMarker, {\n height = 2.0,\n scale = { x = 1.2, y = 1.0, z = 1.0 },\n rotation = { x = 0, y = 0, z = 45 },\n color = { r = 255, g = 60, b = 100, a = 220 },\n}))\n\n-- Restore a style palette. Omitting color would keep the custom color.\nassert(Open77.markers.update(rentalMarker, { style = \"objective\", color = false }))\nassert(Open77.markers.update(rentalMarker, { visible = false }))\nassert(Open77.markers.update(rentalMarker, { visible = true }))\n```\n\nInvalid patches fail atomically, without partially changing the marker. Color, shape and visibility changes can rebuild the native entity asynchronously while retaining the Lua handle. Do not animate colors every frame. Small moves, dimensions and rotation can update an attached mesh in place; moves more than 25 metres from its native anchor recreate it to keep streaming bounds local.","natives":["Open77.markers.update"],"permissions":[],"events":[]},{"anchor":"functions-and-snapshots","heading":"Functions and snapshots","level":2,"text":"All functions below are in `Open77.markers`. Except for `shapes()`, they require `world.markers` and operate on the calling resource's markers.\n\n| Function | Result |\n| --- | --- |\n| `create(options)` | Decimal-string handle, or `nil, reason` |\n| `update(id, patch)` | `true`, or `false, reason` |\n| `get(id)` | One owned snapshot, or `nil, reason` |\n| `list()` | Array of owned snapshots, or `nil, reason` |\n| `remove(id)` | `true`, or `false, reason` |\n| `clear()` | Removes this resource's markers; returns `true`, or `false, reason` |\n| `shapes()` | Array of supported shape names |\n\nSnapshots contain `id`, `position`, `shape`, `style`, `radius`, `height`, `scale`, `rotation`, `color`, `customColor`, `minDistance`, `maxDistance`, `visible`, `rendered`, `failed`, and an optional `error` string. `color` is the effective RGBA color; `customColor` indicates an explicit override.\n\n`rendered` means the native mesh is attached and enabled. It does **not** guarantee that the marker is on screen or unoccluded. A marker may be outside the view, behind a wall, hidden by its distance range or still loading. Use `failed` and `error` to distinguish a loading failure from normal visibility changes.\n\n```lua\n-- Read again after creation to inspect asynchronous loading.\nlocal state, reason = Open77.markers.get(rentalMarker)\nif not state then\n print(\"Marker unavailable: \" .. tostring(reason))\nelseif state.failed then\n print(\"Marker failed: \" .. tostring(state.error))\nend\n\n-- Explicit cleanup when this location is no longer needed.\nassert(Open77.markers.remove(rentalMarker))\nrentalMarker = nil\n-- Open77.markers.clear() removes all markers owned by this resource.\n```\n\nSee the [Lua API reference](/docs/api/client/open77-markers) for individual function signatures.","natives":["Open77.markers.get","Open77.markers.remove","Open77.markers.clear"],"permissions":[],"events":[]},{"anchor":"errors-and-troubleshooting","heading":"Errors and troubleshooting","level":2,"text":"Immediate failures return a reason as the second return value. Native loading failures appear later in the snapshot's `error` when `failed` is true.\n\n| Reason | Meaning / action |\n| --- | --- |\n| `permission_denied:world.markers` | Add the manifest permission to the calling resource. |\n| `world_unavailable` | Wait for the world to be ready before creating the marker. |\n| `markers_backend_unavailable` | No client marker backend is available in this host. |\n| `position_required`, `invalid_position`, `invalid_scale`, `invalid_rotation`, `invalid_color`, `invalid_argument` | Check field types and ranges; colors must be integer RGBA bytes. |\n| `unsupported_shape`, `unknown_marker_style` | Use a supported shape or palette name. |\n| `invalid_marker_id` | The handle is malformed. |\n| `not_found` | The marker is gone or, for `get`, is not owned by this resource. |\n| `owned_by_another_resource` | An update or removal targeted another resource's marker. |\n| `quota_exceeded` | Reduce active markers or allow pending native cleanup to finish. |\n| `marker_assets_missing`, `marker_appearance_missing` | The client could not load the required mesh/material or appearance. Update or repair the client installation. |\n| `marker_streaming_timeout`, `entity_spawn_failed` | Native loading did not finish or entity creation failed; inspect client diagnostics. |\n| `unsupported_material_runtime` | The runtime does not match the supported material implementation. Use a compatible client/game build. |\n\nIf `failed` is false but nothing is visible, check the position and ground offset, alpha, `visible`, camera distance and world occlusion before recreating the marker.","natives":[],"permissions":[],"events":[]},{"anchor":"ownership-limits-and-replication","heading":"Ownership, limits and replication","level":2,"text":"The limit is **64 logical markers per resource** and **256 native material slots across the client**. Retiring entities keep their slots until render jobs and cleanup finish. A replacement can use a spare slot while the old entity retires; when the pool is full, it waits for safe cleanup. Repeatedly creating, recoloring and removing markers can therefore exhaust the pool temporarily.\n\nResource stop/reload and disconnect clean up native markers, including pending spawns. Recreate session markers after world readiness; also handle a resource started while the world is already loaded. Do not reuse handles from a previous resource lifetime.\n\nMarkers are local and are **not automatically replicated**. A server resource can send validated definitions to relevant clients, but visual markers are never proof of a player's position, routing bucket, permission or completed interaction.\n\nThe optional [`open77_markers`](https://github.com/Open2077/open77-resources/tree/main/system/open77_markers) package exports the same seven functions. Its markers belong to that facade package, not automatically to each resource calling its exports. Prefer the native API directly when you need your own resource's quota and automatic cleanup. The separate [`open77_worldui`](https://github.com/Open2077/open77-resources/tree/main/system/open77_worldui) POI facade still exposes only ring/cylinder presets and prompt colors; use this native API for the full shape and RGBA options.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Drawing in the world](world-drawing.md): choose between native markers, anchors, prompts and overlays.\n- [World queries](world-queries.md): find ground positions and raycast hits.\n- [Contextual interactions](interactions.md): add player prompts and choices.\n- [Blips and map pins](blips.md): display a separate icon on the map or minimap.","natives":[],"permissions":[],"events":[]}]},{"slug":"mods","file":"mods.md","title":"Mods: the complete guide","summary":"Configure the mods required by a server and understand how the launcher installs them. For trust and review rules, see [Server-required mods](server-mods.md); for Lua packages, see [Server resources](server-resources.md).","sections":[{"anchor":"mods-the-complete-guide","heading":"Mods: the complete guide","level":1,"text":"Configure the mods required by a server and understand how the launcher installs them. For trust and review rules, see [Server-required mods](server-mods.md); for Lua packages, see [Server resources](server-resources.md).","natives":[],"permissions":[],"events":[]},{"anchor":"three-layers-of-mods","heading":"Three layers of mods","level":2,"text":"A player's game folder is managed by the launcher as three layers. Knowing which layer a file\nbelongs to answers most questions before they are asked.\n\n| Layer | What it holds | Who decides | Installed when |\n|---|---|---|---|\n| **Stack** | Open//77 itself: RED4ext, redscript, the Open77 plugin, the WebUI host, the optional packages of the signed index (CET, compat shims, …) | The platform's signed mod-stack index, per channel and generation | Every launcher start-up on the official directory source |\n| **World** | What one server requires: loaders (ArchiveXL, TweakXL), a world's archives and tweaks, a vehicle, a script fix | The server, through `requiredMods` and resource `preload_mod` declarations | On Connect, laid over the stack |\n| **Own** (Solo profile) | The player's personal mods, untouched by Open//77 | The player | When the player switches to *Solo* in the mod menu |\n\nThe launcher keeps a journal of every file it placed, tagged with its layer\n(`%LOCALAPPDATA%\\Open77\\modstore\\state\\projection.json`, one entry per file with `layer: stack`\nor `layer: world`). Two rules follow from it:\n\n- A **world** projection keeps every stack file exactly as it is and only removes the previous\n world's files. Joining another world swaps world files; it never touches Open//77.\n- The **stack** projection at launcher start-up owns the whole folder and removes world files, so\n a world's archives never leak into another world or into the player's own game.\n\nWorld mods are therefore re-laid on every Connect. They are not re-downloaded: blobs are\ncontent-addressed and stay in the player's store (`modstore\\blobs\\`), so a returning player\nonly pays the projection.","natives":[],"permissions":[],"events":[]},{"anchor":"life-of-a-world-mod","heading":"Life of a world mod","level":2,"text":"The path from your disk to the player's, with the file or log line to look at for each step.\n\n1. **The server declares it.** Either a resource carries `preload_mod \"dist/package.zip\"` or an\n operator committed a hosted package from Warden. At start-up the server inspects each archive,\n stages it under `server/.open77/mods/` by hash and announces the set:\n\n ```text\n [INF] Required mods: 5 declared, digest=eccac11f…, enabled=True, unsecured=True.\n [INF] [resource:aventador_svj] started generation=1\n ```\n\n Every resource-owned package becomes a required mod named `-assets`; the digest covers\n the whole set and changes whenever a package does.\n\n2. **The server hosts the bytes** beside its resource endpoint, immutable and content-addressed:\n\n ```text\n GET http://:/mods/v1/blobs/\n ```\n\n The port is the one the server announces in its heartbeat (`11779` on the development server,\n next to game port `11778`). A server whose HTTP listener is down shows up in the launcher as\n *This world's mods could not be downloaded*.\n\n3. **The master relays the list.** The catalogue row carries `requiredMods` (id, version, sha256,\n sizeBytes, url), the digest and the `unsecured` flag. The launcher and the in-game browser read\n the same row.\n\n4. **The launcher decides.** On Connect it asks the master for a verdict on every hash, applies the\n capability cap (executable content needs a verified verdict, or the player's consent on an\n unsecured world), shows the consent gate, then fetches whatever the store lacks, hashing every\n byte against the announced value. Each archive is inspected — stored or freshly downloaded — and\n the package is laid over the stack:\n\n ```text\n unsecured world: accepting unreviewed executable content in 'tweakxl assets' ('red4ext/plugins/TweakXL/TweakXL.dll')\n modstack: world layer keeps 291 stack file(s) in place\n world 'Open77 Development': projected 54 file(s), removed 0, kept 291 in place.\n ```\n\n5. **The game starts with a contract.** The launcher passes the target and the digest it installed\n for this boot:\n\n | Variable | Meaning |\n |---|---|\n | `OP77_CONNECT` | `host:port` to auto-connect to |\n | `OP77_CONNECT_MASTER` | The directory source the ticket came from (`alpha`, `development`); the in-game shell enrols the identity with that master |\n | `OP77_WORLD_SERVER` | The world's server id |\n | `OP77_WORLD_MODS_DIGEST` | The required-mod digest this boot was prepared for |\n\n REDengine mounts `archive/pc/mod` at boot, RED4ext loads the plugins, redscript compiles\n `r6/scripts`, TweakXL applies `r6/tweaks`. Nothing in this layer can be added mid-session.\n\n6. **The shell checks the contract.** When a player picks a world from the in-game browser, the\n shell compares the world's digest with `Open77.session.launcherContext().modsDigest`. A match\n connects; a mismatch shows the *Restart required* prompt, which hands off to the launcher through\n `open77://connect?server=…&endpoint=…&relaunch=` and quits the game. See\n [Joining from the in-game server browser](server-mods.md#joining-from-the-in-game-server-browser).","natives":[],"permissions":[],"events":[]},{"anchor":"declaring-mods-on-a-server","heading":"Declaring mods on a server","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"a-resource-that-owns-its-package","heading":"A resource that owns its package","level":3,"text":"The recommended shape for anything a world needs: the package lives with the resource that needs\nit, the manifest states the loaders it depends on, and selecting the resource declares everything.\n\n```lua\n-- resources/assets/maps/kujira_world/open77.lua\nresource \"kujira_world\"\nversion \"1.0.0\"\nauto_start true\ndependency \"archivexl >=1.27.1\"\ndependency \"tweakxl >=1.11.4\"\npreload_mod \"dist/kujira.zip\"\nserver_script \"server/main.lua\"\n```\n\n- Third-party packages live under `resources/assets/` (`loaders/`, `maps/`, `vehicles/`, `mods/`);\n everything but `loaders/` is ignored by git, so a checkout never carries someone else's archive.\n See [Layout of the resources folder](server-resources.md#layout-of-the-resources-folder).\n- The archive is a `.zip` or `.7z` whose entries are **game-root relative** paths\n (`archive/pc/mod/…`, `r6/tweaks/…`, `r6/scripts/…`). Nexus archives usually are already; a\n wrapping folder (`MyMod/archive/pc/mod/…`) must be stripped.\n- Up to eight packages per resource, 1 byte to 64 MiB each; every declared path must exist.\n- A dependency must itself be in `resources.load`. The loaders ship as resources\n ([`resources/assets/loaders/archivexl`](https://github.com/Open2077/open77-resources/tree/main/libraries/archivexl), [`resources/assets/loaders/tweakxl`](https://github.com/Open2077/open77-resources/tree/main/libraries/tweakxl)) that wrap the upstream release zip, so several worlds\n share one copy and the server starts them first.\n- `requiredMods.enabled` must be `true`. Executable content (a `.dll`, a `.reds`, CET Lua, `.asi`)\n in a preload is refused unless the server is [unsecured](server-mods.md#unsecured-mode) or the\n hash is verified.\n- Changing a package needs a **server restart** and a **fresh launcher boot** of the game. `ensure`\n reloads the Lua, not the depot.\n\nThe load list in `server.jsonc` selects it:\n\n```jsonc\n\"resources\": {\n \"load\": [\"freeroam\", \"archivexl\", \"tweakxl\", \"kujira_world\"]\n}\n```","natives":[],"permissions":[],"events":[]},{"anchor":"a-hosted-package-from-warden","heading":"A hosted package from Warden","level":3,"text":"For an operator without access to the resource tree: *Warden → Mods → upload*, read the plan\n(file, root, inert/executable, adds/replaces), commit, restart. Warden writes the `requiredMods`\nentry for you. Full walkthrough in\n[Add a mod from the Warden panel](server-mods.md#add-a-mod-from-the-warden-panel).","natives":[],"permissions":[],"events":[]},{"anchor":"an-indexed-package","heading":"An indexed package","level":3,"text":"A package the platform distributes from its signed index is pinned by `id` and `minVersion` and\ncosts the operator nothing. The index carries what Open//77 is licensed to redistribute; a package\nmissing from it makes the world unjoinable, so check the index before pointing at it.","natives":[],"permissions":[],"events":[]},{"anchor":"what-a-package-may-contain","heading":"What a package may contain","level":3,"text":"The launcher confines every file to a known root and classifies the package by its most privileged\nfile.\n\n| Root | Typical content | Class |\n|---|---|---|\n| `archive/pc/mod` | `.archive`, `.xl` | inert |\n| `r6/tweaks` | TweakXL `.tweak` / `.yaml` | inert |\n| `engine/config` | INI overrides | inert |\n| `r6/input`, `r6/config` | input and config XML | inert |\n| `r6/scripts` | redscript `.reds` | executable |\n| `red4ext/plugins`, `bin/x64/plugins` | `.dll`, `.asi` | executable |\n| `bin/x64/plugins/cyber_engine_tweaks/mods` | CET Lua | executable |\n| `engine/tools`, `bin/x64`, `mods` | tooling, loaders | executable |\n\nAnything outside these roots, a path-traversal entry, or bytes that do not hash to the announced\nvalue refuses the package.","natives":[],"permissions":[],"events":[]},{"anchor":"worked-example-a-nexus-vehicle-on-the-freeroam-server","heading":"Worked example: a Nexus vehicle on the freeroam server","level":2,"text":"Adding *Lamborghini Aventador SVJ* (Nexus 11176) to the development world, spawnable with `/car`.\nThe mod requires ArchiveXL, TweakXL, RED4ext, redscript and the author's *Vehicle manufacturer\nfix* (Nexus 28744); CET is listed only for its spawn command and is not needed here.\n\n1. **Download the archives** from the author's page (the launcher never rehosts what the author\n did not license; keep the licence in mind before making it a public requirement). Inspect them:\n\n ```text\n KRNLNIK Lamborghini Aventador SVJ-11176-1-4.zip\n archive/pc/mod/aventador_svj.xl\n archive/pc/mod/KRNLNIK_Lamborghini_Aventador_SVJ.archive\n r6/tweaks/krnlnik/lamborghini_aventador_svj.tweak\n r6/tweaks/krnlnik/lamborghini_aventador_svj_ICONS.tweak\n VMF-28744-1-0.zip\n r6/scripts/VehicleManufacturerFix.reds\n ```\n\n Both are game-root relative. Repack without directory entries if you want the package minimal;\n the layout is what matters.\n\n2. **Create one resource per package.** The fix first, because the car depends on it:\n\n ```lua\n -- resources/assets/mods/vehicle_manufacturer_fix/open77.lua\n resource \"vehicle_manufacturer_fix\"\n version \"1.0.0\"\n auto_start true\n preload_mod \"dist/vmf.zip\"\n server_script \"server/main.lua\"\n ```\n\n ```lua\n -- resources/assets/vehicles/aventador_svj/open77.lua\n resource \"aventador_svj\"\n version \"1.4.0\"\n auto_start true\n dependency \"archivexl >=1.27.1\"\n dependency \"tweakxl >=1.11.4\"\n dependency \"vehicle_manufacturer_fix >=1.0.0\"\n preload_mod \"dist/aventador_svj.zip\"\n server_script \"server/main.lua\"\n ```\n\n A `server/main.lua` that prints on `onResourceStart` is enough; the package does the work.\n\n3. **Select them and restart.** Add `\"vehicle_manufacturer_fix\", \"aventador_svj\"` to\n `resources.load`. The `.reds` file is executable content, so this only boots on an unsecured\n server. Restart and read the boot log: `Required mods: 5 declared` with a new digest, one\n `started` line per resource.\n\n4. **Keep third-party bytes out of git.** The repository ignores `resources/assets/maps/kujira_world/`,\n `resources/assets/mods/vehicle_manufacturer_fix/` and `resources/assets/vehicles/aventador_svj/` for that reason; the\n manifests are tiny, the archives are someone else's work.\n\n5. **Connect through the launcher.** The consent gate reads *5 packages, 3 executable*; accept, and\n the log shows `projected 54 file(s), removed 0, kept 291 in place`. The game boots with the\n archive mounted and the tweak records registered.\n\n6. **Spawn it.** The freeroam `/car` command accepts any `Vehicle.*` record when\n `Config.vehicles.allowCustomModels` is `true` ([`resources/gamemodes/freeroam/shared/config.lua`](https://github.com/Open2077/open77-resources/blob/main/gamemodes/freeroam/shared/config.lua)):\n\n ```text\n /car Vehicle.aventador_svj_01\n ```\n\n The record name comes from the mod's tweak file. `unknown_model` means the record did not match\n `^Vehicle%.[%w_]+$` or the flag is off; an invisible or wrong vehicle means TweakXL did not load\n the record — check its log (below).","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-launcher-does-with-the-files","heading":"What the launcher does with the files","level":2,"text":"- **Store.** `%LOCALAPPDATA%\\Open77\\modstore\\`: `blobs\\<2 hex>\\` (archives and every file\n inside them), `pkg\\\\\\modpkg.json` (stack package manifests), `state\\projection.json`\n (the journal). Nothing here is ever served to anyone.\n- **Projection.** Files are hardlinked from the store into the game folder when the volume allows\n it, copied otherwise (`--force-copy` forces copies). Overwriting a hardlinked file in place — a\n local build deployed by hand — mutates the store's blob; the launcher notices at the next\n verification (*not in the store yet*) and re-fetches it. A placed file that no longer hashes as\n recorded counts as an unmanaged copy: an exclusive file is replaced by the stack's version (the\n log says so), a contested one is reported and left alone.\n- **Journal and commit.** A projection writes `projection.pending.json`, moves files, then commits.\n An interrupted run is recovered on the next plan (*a pending journal was found*).\n- **Removal.** Only files the launcher placed and that still hash as recorded are removed. A\n package that `replaces` a shipped file restores it on removal; prove that round trip on a test\n client before requiring such a package.\n- **The Solo profile** projects the player's own set and takes Open//77's out; *browse servers*\n puts it back. World files never appear in Solo.","natives":[],"permissions":[],"events":[]},{"anchor":"developer-builds-and-the-local-directory-source","heading":"Developer builds and the local directory source","level":3,"text":"The launcher's *Directory source* menu offers the official master and *Local development*\n(`http://127.0.0.1:8090`). On the local preset the launcher skips the stack update and the launcher\nself-update (a release launcher only trusts the production signing key), refuses the browser sign-in\n(it goes through the production website) in favour of the e-mail/password form, and still fetches\nand lays world mods. A `dotnet build` launcher never self-updates either; it reports version\n`1.0.0`, which every release would otherwise out-rank.\n\nBecause the stack is not managed on that preset, branch builds deployed into the game folder stay\nin place. If the stack ever needs rebuilding, the headless CLI does it without the GUI:\n\n```powershell\n$env:OP77_LAUNCHER_MASTER_URL = \"https://master.open2077.net/\"\nOpen77Launcher.exe --mods-apply --commit --game-dir \"\"\n```","natives":[],"permissions":[],"events":[]},{"anchor":"verifying-that-a-mod-loaded","heading":"Verifying that a mod loaded","level":2,"text":"In order of cost, stop at the first that fails.\n\n1. **Launcher log** (`%LOCALAPPDATA%\\Open77\\launcher.log`) at the Connect:\n `world '': projected N file(s), removed M, kept K in place.` No such line means nothing\n was laid; a `world mod fetch failed: ` line names why.\n2. **Files on disk** in the game folder, at the paths the archive declared\n (`archive\\pc\\mod\\.archive`, `r6\\tweaks\\…`, `r6\\scripts\\…`, `red4ext\\plugins\\`).\n3. **Loader logs** after the game booted: `red4ext\\logs\\red4ext-*.log` lists the plugins it\n loaded (ArchiveXL, TweakXL, Open77); `red4ext\\plugins\\ArchiveXL\\ArchiveXL.log` lists every\n `.xl` extension and archive it registered; `red4ext\\plugins\\TweakXL\\TweakXL.log` lists the\n tweak files it applied and any record error. A redscript compile error shows a dialog at boot\n naming the script.\n4. **In game.** A world mod: go where the geometry is. A vehicle: `/car `. A tweak: the\n record exists (`Game.GetVehicleSystem` via CET, or a server-side `Open77.vehicles.create` with\n the record).\n5. **Server side.** `Required mods: N declared` at boot, and each `GET /mods/v1/blobs/`\n answering `200` with the archive's size.","natives":[],"permissions":[],"events":[]},{"anchor":"troubleshooting","heading":"Troubleshooting","level":2,"text":"| Symptom | Cause | Fix |\n|---|---|---|\n| *This world's mods could not be downloaded* / log `world mod fetch failed: … Unreachable` | The server's resource HTTP listener is not reachable (server down, port blocked, wrong public URL in its heartbeat) | Check the server process and `GET /mods/v1/blobs/` from the player's side; fix the public URL |\n| Game boots vanilla after a Connect (no Open77 menu) | The stack was removed from the game folder | Launchers after op77.27 lay worlds over the stack; on older ones run the stack apply above or reinstall from the launcher |\n| Connected, but the world's content is missing and nothing complains | The mods were not projected on this Connect (launchers up to op77.27 only installed a package the first time it was downloaded) | Update the launcher; verify with the `projected` log line |\n| `identity_proof_invalid` right after a launcher-started auto-connect | The identity was enrolled with a different master than the one the server verifies against (the page's remembered directory source overrode `OP77_CONNECT_MASTER`) | Fixed in the shell (the launcher's master is pinned); on older clients pick the same master in the in-game menu before connecting |\n| *Restart required* prompt in the in-game browser | This boot was prepared for another world's digest | Accept; the launcher relaunches into the world with its mods |\n| `mods_relaunch_required` with no launcher installed | The game was started without the launcher | Install the launcher, or start the game through it |\n| `required_mods_refused` / a package named as *contains code* | Executable content without a verified verdict on a secured server | Request a review, or run the server unsecured for a trusted community |\n| `required_mods_consent` loops | The player declined the consent gate | Expected; the world is not joined |\n| `mod_attestation_unavailable` | The master could not be reached for verdicts | Retry; check the directory source |\n| Hash mismatch in the log | The server serves different bytes than it announced | Restart the server so it re-stages; if it persists, the package on disk changed |\n| `/car … → unknown_model` | Record name malformed or `allowCustomModels` off | Use `Vehicle.` exactly; enable the flag |\n| Vehicle spawns as the default model or invisible | TweakXL did not register the record, or the archive is not mounted | Check `TweakXL.log` and `ArchiveXL.log`; confirm the files are on disk |\n| redscript error dialog at boot | A `.reds` in a world package does not compile against this game build | Remove the package or update it; the dialog names the file |\n| Server refuses to start: `requiredMods` / `preload_mod` error | Disabled `requiredMods` with a preload, an executable preload on a secured server, a missing package path, an over-size archive | Read the named directive; the boot log points at the resource |","natives":[],"permissions":[],"events":[]},{"anchor":"reference","heading":"Reference","level":2,"text":"| Where | Path |\n|---|---|\n| Server staging | `server/.open77/mods/` |\n| Server hosting | `http://:/mods/v1/blobs/` |\n| Server config | `server.jsonc` → `requiredMods { enabled, unsecured, packages }`, `resources.load` |\n| Resource manifest | `resources//open77.lua` → `preload_mod`, `dependency` |\n| Launcher store | `%LOCALAPPDATA%\\Open77\\modstore\\{blobs,pkg,state}` |\n| Launcher log | `%LOCALAPPDATA%\\Open77\\launcher.log` |\n| Launcher settings | `%LOCALAPPDATA%\\Open77\\launcher-settings.json` (`master`, `modChannel`, worlds joined) |\n| Game folder roots | `archive/pc/mod`, `r6/tweaks`, `r6/scripts`, `red4ext/plugins`, `bin/x64/plugins`, `engine/config` |\n| Loader logs | `red4ext/logs/`, `red4ext/plugins/ArchiveXL/ArchiveXL.log`, `red4ext/plugins/TweakXL/TweakXL.log` |\n| Boot contract | `OP77_CONNECT`, `OP77_CONNECT_MASTER`, `OP77_WORLD_SERVER`, `OP77_WORLD_MODS_DIGEST`; `Open77.session.launcherContext()` |\n| Deep link | `open77://connect?server=&endpoint=&relaunch=` |","natives":[],"permissions":[],"events":[]}]},{"slug":"native-map","file":"native-map.md","title":"Native map and player waypoints","summary":"`Open77.map` is a **client-side** API for the native world map: read player waypoints, pick destinations, control the map camera and add resource-owned WebUI tabs. Pair it with [vehicle AI](vehicle-ai.md) to use a waypoint as an autonomous driving destination.","sections":[{"anchor":"native-map-and-player-waypoints","heading":"Native map and player waypoints","level":1,"text":"`Open77.map` is a **client-side** API for the native world map: read player waypoints, pick destinations, control the map camera and add resource-owned WebUI tabs. Pair it with [vehicle AI](vehicle-ai.md) to use a waypoint as an autonomous driving destination.\n\nThe original waypoint and point-picker APIs require client **2.31.13+op77.54** or later. The standalone screen, view controls and custom tabs are **experimental** and require a compatible client exposing these additions. Do not use the original minimum version as a compatibility check for the newer functions. Map customization is local to each client; it does not change server state.\n\n```lua\npermissions { \"map.read\", \"map.control\", \"network.events\" }\n```\n\n`map.read` permits snapshots and observation events. `map.control` permits screen customization, tabs, opening and camera control. `network.events` is needed only for examples that send a destination to the server.","natives":[],"permissions":["map.read","map.control","network.events"],"events":[]},{"anchor":"standalone-map","heading":"Standalone map","level":2,"text":"Open **Pause → City map**, use the player's normal map binding, or call `Open77.map.open()`. The native map is framed with margins and a blurred backdrop. Only a **Map** tab appears by default; resources can add tabs without replacing the native header.\n\nDrag to pan, use the wheel to zoom and the native track-waypoint action (right-click by default) to place or remove a destination. Escape returns to gameplay. Opening the screen does not pause multiplayer simulation or grant invulnerability. It does not open the solo character hub.\n\nOnly a native map-ray hit or a selectable marker can provide a destination. Clicking the header, margins or empty geometry does not reuse an old hit as a new waypoint. Resource blips are separate from the player's manual destination.","natives":[],"permissions":[],"events":[]},{"anchor":"customize-the-map-screen","heading":"Customize the map screen","level":2,"text":"Call customization functions after resource preparation, for example inside `onClientResourceStart`. Calling them during script preparation returns `nil, \"resource_preparing\"`. Check support before using them on clients with different feature sets:\n\n```lua\nif not Open77.map or type(Open77.map.addTab) ~= 'function' then\n print('This menu requires a client with native map tabs.')\n return\nend\n```\n\n| Function | Result | Behavior |\n|---|---|---|\n| `setTitle(text)` | `true` or `nil, reason` | Change the upper-left title. |\n| `setAccentColor(\"#RRGGBB\")` | `true` or `nil, reason` | Change the selected tab accent. |\n| `resetAppearance()` | `true` or `nil, reason` | Release this resource's title and accent overrides, keeping its tabs. |\n| `addTab({id, label, url?, page?, order?})` | `qualifiedId, page` or `nil, reason` | Add a tab using a resource-local file or an existing owned WebUI page. |\n| `removeTab(id)` | `true` or `nil, reason` | Remove an owned tab; its page remains hidden, not destroyed. |\n| `selectTab(id)` | `true` or `nil, reason` | Select an owned tab or `\"map\"` on an already-open screen, on the next tick. |\n| `setTabLabel(id, label)` | `true` or `nil, reason` | Rename an owned tab without reloading its page. |\n| `getScreen()` | `screen` or `nil, reason` | Read appearance and tabs, even while closed; requires `map.read`. |\n\nAll seven mutations require `map.control`. A successful `selectTab` acknowledges the request, not completed activation; observe the events below. It does not open the map. Point-picker sessions stay on Map and reject switches with `map_picking`.","natives":[],"permissions":[],"events":[]},{"anchor":"titles-colors-and-ownership","heading":"Titles, colors and ownership","level":3,"text":"Titles contain 1–120 UTF-8 bytes; labels contain 1–40 UTF-8 bytes. Neither accepts control characters or a pipe (`|`). Accent colors must be exactly `#RRGGBB`. The accent changes the selected native tab, not roads or markers; resource pages receive it in events and apply it through their own CSS.\n\nTitle and accent have separate resource-owned overrides. The latest setter wins. `resetAppearance()` or resource stop restores the preceding resource's override, or the defaults: `OPEN//77 / NIGHT CITY` and `#22D8E2`. Updates take effect while the screen is open.\n\nTab IDs contain 1–48 ASCII letters, digits, underscores or hyphens. `map` is reserved. A returned ID is qualified as `resource_name:localId`; mutations accept either the caller's local ID or its qualified ID. A resource cannot select, rename or remove another resource's tab. Map always comes first and cannot be removed, replaced or renamed.\n\nThere are at most **five resource tabs in total, plus Map**. `order` is an integer from -1000 to 1000, default 0. Custom tabs sort by order, then qualified ID.","natives":[],"permissions":[],"events":[]},{"anchor":"add-a-resource-page","heading":"Add a resource page","level":3,"text":"Supply exactly one of `url` or `page`:\n\n- `url` is a file declared in the calling resource's `web_files`, not an HTTP URL, a `file://` path or another resource's origin. It creates a hidden, opaque menu-layer page with a reference size of 1280×572.\n- `page` is an existing, current-generation WebUI page owned by the caller. It must be hidden, unfocused and unbound, without custom `drawBounds`, world-screen or HUD-suppression mode. Its existing origin, CSP, permissions and browser policy still apply. Use this form when you need custom page creation settings.\n\nThe selected WebUI replaces only the map content rectangle. It is an isolated resource surface, not an iframe sharing another resource's bridge. Native map input cannot operate underneath it.\n\nThis example registers a Jobs tab and sends its initial state from Lua:\n\n```lua\n-- open77.lua\nresource 'city_menu'\nversion '1.0.0'\nclient_scripts { 'client.lua' }\nweb_files { 'web/**' }\npermissions { 'map.read', 'map.control' }\n```\n\n```lua\n-- client.lua\nlocal jobsTab, jobsPage\n\nlocal function sendJobsState()\n if jobsPage then\n jobsPage:emit('jobs:state', { message = 'Choose a job from your resource.' })\n end\nend\n\nAddEventHandler('onClientResourceStart', function()\n if not Open77.map or type(Open77.map.addTab) ~= 'function' then\n print('This menu requires a client with native map tabs.')\n return\n end\n assert(Open77.map.setTitle('NIGHT CITY ROLEPLAY'))\n assert(Open77.map.setAccentColor('#FFB347'))\n jobsTab, jobsPage = Open77.map.addTab({\n id = 'jobs', label = 'Jobs', url = 'web/jobs.html', order = 10\n })\n if not jobsTab then\n print('Map tab unavailable: ' .. tostring(jobsPage))\n jobsPage = nil\n Open77.map.resetAppearance()\n return\n end\n jobsPage:on('jobs:refresh', sendJobsState)\nend)\n\nAddEventHandler('open77:map:tabEntered', function(event)\n if event.id == jobsTab then sendJobsState() end\nend)\n\nAddEventHandler('open77:map:tabLeft', function(event)\n if event.id == jobsTab then print('Jobs tab left: ' .. event.reason) end\nend)\n```\n\n```html\n\n\n\n\n \n Jobs\n \n \n\n\n

Jobs

\n

Loading…

\n\n\n```\n\n```css\n/* web/jobs.css */\n:root { --accent: #22d8e2; color-scheme: dark; }\nbody { margin: 0; padding: 2rem; background: #101820; color: #fff; }\nh1 { color: var(--accent); }\n```\n\n```js\n// web/jobs.js\nlet polling;\nfunction stopPolling() {\n clearInterval(polling);\n polling = undefined;\n}\nfunction refresh() { Open77.emit('jobs:refresh', {}); }\nfunction applyTheme(screen) {\n document.documentElement.style.setProperty('--accent', screen.accentColor);\n}\nOpen77.on('jobs:state', data => {\n document.getElementById('status').textContent = data.message;\n});\nOpen77.on('open77:map:tabState', event => {\n applyTheme(event.screen);\n stopPolling();\n if (event.active) {\n refresh();\n polling = setInterval(refresh, 10000);\n }\n // Pause resource-owned audio here when event.active is false.\n});\nOpen77.on('open77:map:screenChanged', applyTheme);\n// Register listeners first, then request the current state, including on reload.\nOpen77.emit('open77:map:ready', {});\n```\n\nThe ready handshake prevents losing initial state if selection happens before the page finishes loading. It is handled by the host; no Lua handler for `open77:map:ready` is needed. Treat `tabState` idempotently: both selection and the ready reply may report the same active state. Validate actual job selection and rewards on the server; receiving a browser event is not authorization.","natives":["AddEventHandler","server:AddEventHandler","Open77.map.setTitle","Open77.map.setAccentColor","Open77.map.addTab","Open77.map.resetAppearance"],"permissions":["map.read","map.control"],"events":["open77:map:tabEntered","open77:map:tabLeft","open77:map:tabState","open77:map:screenChanged","open77:map:ready"]},{"anchor":"tab-lifecycle-and-events","heading":"Tab lifecycle and events","level":3,"text":"| Receiver | Event | Payload |\n|---|---|---|\n| Owning Lua resource (`map.control`) | `open77:map:tabEntered`, `open77:map:tabLeft` | `{id, localId, active, reason, screen}` |\n| Lua resources with `map.read` | `open77:map:tabChanged` | `{previous, id, reason}` |\n| Owning Lua resource | `open77:map:tabRemoved` | `{id, reason=\"page_unavailable\"}` |\n| Registered page JavaScript | `open77:map:tabState` | `{id, localId, active, reason, screen}` |\n| Registered page JavaScript | `open77:map:screenChanged` | `screen` |\n\nReasons include `selected`, `closed`, `removed`, `resource_stopped`, `page_unavailable` and `ready` (the JavaScript handshake). Entry means the tab was selected, not that asynchronous page work finished. `tabRemoved` reports an unavailable page; it is not a general acknowledgement of every explicit `removeTab` call.\n\n`screen` contains `{title, accentColor, open, activeTab, revision, tabs}`. The built-in tab is `{id=\"map\", label=\"Map\", type=\"native\"}`; resource entries contain `{id, label, resource, type=\"webui\"}`. Use `getScreen()` for a fresh snapshot.\n\nWhile bound, the map screen owns visibility, focus and content bounds. **Do not call `page:show()`, `page:hide()` or `page:setFocus()`**; they return false for bound pages. Use `selectTab()` and `removeTab()` instead. Normal page events, callbacks and messages continue to work. See [the WebUI bridge](resource-runtime.md#webui).\n\nPages retain their DOM and state between visits. Hiding a tab does not stop its timers or audio; stop that work on inactive `tabState`. Escape closes the whole screen and returns input to gameplay, even without a page-specific Escape handler.\n\nRemoving an active tab returns to Map. `removeTab` leaves its page hidden and available for reuse; call `page:destroy()` when it is no longer needed. Resource stop removes tabs, destroys its pages and releases appearance overrides. If that resource also owns the map session through `open` or `pickPoint`, stopping it releases that session too. Destroying a registered page, or a page becoming unavailable, removes its tab and returns to Map when necessary. Disconnect closes the screen.","natives":[],"permissions":[],"events":["open77:map:tabEntered","open77:map:tabLeft","open77:map:tabChanged","open77:map:tabRemoved","open77:map:tabState","open77:map:screenChanged"]},{"anchor":"tab-errors","heading":"Tab errors","level":3,"text":"| Error | Action |\n|---|---|\n| `resource_preparing` | Register or mutate tabs after resource preparation. |\n| `permission_denied:map.control` / `permission_denied:map.read` | Declare the required capability. |\n| `invalid_tab`, `invalid_tab_id`, `invalid_label`, `invalid_title`, `invalid_accent`, `invalid_order` | Check the ID, text, hex-color and ordering limits. |\n| `duplicate_tab`, `tab_limit` | Reuse/remove an existing tab or reduce the number registered. |\n| `specify_page_or_url`, `invalid_resource_url`, `undeclared_entry` | Supply one supported source and declare local web files. |\n| `stale_or_unowned_page`, `page_must_be_hidden_and_unbound` | Use a current, owned page that meets the binding requirements. |\n| `tab_not_found`, `not_owner` | Use an existing tab owned by the caller, or `map` for selection. |\n| `map_closed`, `map_picking` | Select only when the screen is open and no picker is active. |\n\nWebUI creation can also return its normal source, permission and quota errors. See the [API reference](/docs/api/client/open77-map) for signatures and return values.","natives":[],"permissions":[],"events":[]},{"anchor":"view-controls","heading":"View controls","level":2,"text":"`open()` and `pickPoint()` reserve a map session for their resource. Wait for `open77:map:viewChanged` with `ready=true`, or inspect `getView().ready`, before controlling its camera. Camera mutations require `map.control` and ownership of an open, ready map. Unlike tab registration and tab selection, camera control cannot take over a user-opened or another resource's map.\n\n| Function | Result | Behavior |\n|---|---|---|\n| `getView()` | `view` or `nil, reason` | Read `{ready, zoomLevel, zoomLevels, zoom, cameraMode}` with `map.read`. |\n| `focus({x,y,z})` | request ID or `nil, reason` | Move the map camera, not the player. |\n| `recenter()` | request ID or `nil, reason` | Center the map on the local player. |\n| `setZoomLevel(level)` | request ID or `nil, reason` | Select an integer level from `0` to `zoomLevels - 1`. |\n| `setCameraMode(mode)` | request ID or `nil, reason` | Select `\"top_down\"` or `\"perspective\"`. |\n\nCoordinates must be finite and within ±16,000 on each axis. `zoomLevels` comes from the installed game. `zoom` is a native scalar, not a distance in meters. `state().view` contains the view snapshot and `state().renderer` is `\"native_ink\"`.\n\n```lua\nlocal configureView = false\nAddEventHandler('open77:map:viewChanged', function(view)\n if not configureView or not view.ready then return end\n configureView = false -- prevent a loop when the camera mode changes\n local request, reason = Open77.map.setCameraMode('perspective')\n if not request then print(reason) end\nend)\nlocal request, reason = Open77.map.open({position = {x=600, y=-2200, z=180}})\nconfigureView = request ~= nil\nif not request then print(reason) end\n```\n\nThe requesting resource receives `open77:map:commandResult` with `{requestId, status}`. Status is `applied`, `map_not_ready` or `invalid_zoom_level`; `applied` means the native call ran, not that an animated camera transition finished. `open77:map:viewChanged` broadcasts readiness, discrete zoom and camera-mode changes to resources with `map.read`.\n\nImmediate failures include `map_unavailable`, `map_closed`, `not_owner`, `map_not_ready`, `invalid_position`, `invalid_zoom_level`, `invalid_camera_mode`, `map_queue_full` and permission errors. At most 16 view commands can wait. Do not enqueue camera changes every frame. Closing or releasing the map discards pending commands.","natives":["AddEventHandler","server:AddEventHandler","Open77.map.setCameraMode","Open77.map.open"],"permissions":[],"events":["open77:map:viewChanged","open77:map:commandResult"]},{"anchor":"resource-blips-and-gps","heading":"Resource blips and GPS","level":2,"text":"The existing [blip APIs](blips.md) create and update resource markers, labels, tracking and GPS. They require `ui.vanilla.map`, independently of map-session ownership. `Open77.blips.setWaypoint(position)` sets a scripted destination; `Open77.blips.waypoint()` reads tracked navigation and `Open77.blips.clearWaypoint()` clears it. In contrast, `Open77.map.getWaypoint()` reads only the player's manual waypoint, not a tracked resource blip, quest or POI.","natives":[],"permissions":[],"events":[]},{"anchor":"read-a-destination","heading":"Read a destination","level":2,"text":"```lua\nlocal waypoint, reason = Open77.map.getWaypoint()\nif reason then print(reason); return end\nif waypoint then\n print(waypoint.position.x, waypoint.position.y, waypoint.position.z)\n -- id is a STRING; kind=\"waypoint\", source=\"player\"\nend\n```\n\nNo waypoint returns `nil` without an error. Missing/stale world data returns\n`nil, \"map_unavailable\"`. Reading never changes a quest or moves the player.\n\n`getSelectedMarker()` reads the highlighted map marker, not the navigation\ndestination. Its kind is `waypoint`, `quest`, `poi`, `resource` or `unknown`.\n`isOpen()` returns a boolean, or `nil, reason`. `state()` returns `open`,\n`picking`, `waypoint`, `selectedMarker`, `revision` and `sampleAgeMs`.","natives":["Open77.map.getWaypoint"],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"```lua\nAddEventHandler(\"open77:map:waypointChanged\", function(change)\n print(change.action) -- placed, moved or removed\n local point = change.waypoint and change.waypoint.position\n -- change.previous contains the old marker.\nend)\n```\n\nIndividual events `open77:map:waypoint:placed`, `:moved` and `:removed` carry\nthe same table. Engine ID churn at identical coordinates is not movement.\nRead a snapshot on startup: historical placement events are not synthesized.\nChanges use the script-bridge polling cadence, not frame-perfect input timestamps.\nTracking a quest/POI instead clears the current manual destination from this API.\n\nOther read events:\n\n| Event | Payload |\n|---|---|\n| `open77:map:opened` | `{open=true}` |\n| `open77:map:closed` | `{open=false}` |\n| `open77:map:markerSelected` | `{marker=markerOrNil}` |","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:map:waypointChanged","open77:map:waypoint:placed","open77:map:opened","open77:map:closed","open77:map:markerSelected"]},{"anchor":"native-point-picking-without-travel","heading":"Native point picking without travel","level":2,"text":"```lua\nlocal requestId, reason = Open77.map.pickPoint({\n position = {x=-2140, y=-600, z=8}, -- optional focus\n timeoutMilliseconds = 60000, -- 1000..300000\n})\nif not requestId then print(reason) end\n\nAddEventHandler(\"open77:map:pointPicked\", function(result)\n if result.requestId ~= requestId then return end\n if result.status == \"selected\" then\n print(json.encode(result.position))\n end\nend)\n```\n\nUse the vanilla **place/track waypoint** action to select a world point or marker.\nThe map then closes. Fast travel, native taxi confirmation and Open77 admin\ndouble-click teleport are suppressed during picking. An empty-map selection\nmay become the vanilla waypoint; existing navigation is not guaranteed preserved.\n\nOnly the requester receives `pointPicked`, containing `requestId`, `status`,\n`position` and `marker`. Status is `selected`, `cancelled`, `timeout` or\n`ui_unavailable`; non-selection results have no position. Escape cancels.\n`cancelPick()` and `close()` return `true` or `false, reason` and request\nasynchronous closure. They cannot close another resource's or a user-opened map.\n\n`open(options)` opens/focuses the map without picker mode. Both opening APIs\nreturn an accepted request ID, not proof that the UI opened. Observe `opened`\nor `open77:map:requestFailed` (`requestId`, `reason`). Native UI restrictions\nremain enforced; conflicts return `map_busy`. Resource shutdown releases ownership\nwithout delivering an old completion to the resource's new instance.","natives":["Open77.map.pickPoint","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:map:pointPicked","open77:map:requestFailed"]},{"anchor":"taxi-destination-sharing","heading":"Taxi destination sharing","level":2,"text":"```lua\n-- Passenger client: call after an explicit \"share destination\" action.\nlocal waypoint = Open77.map.getWaypoint()\nif waypoint then\n TriggerServerEvent(\"myTaxi:destination\", waypoint.position)\nend\n```\n\nOn the server, validate finite/bounded coordinates, rate-limit requests and verify\nthat `source` is the taxi's passenger. Do not trust a supplied driver ID,\nbucket or fare. Forward the validated point to the authorized driver and use\nthe [blip/GPS API](blips.md), or [vehicle AI](vehicle-ai.md) `driveTo`.","natives":["Open77.map.getWaypoint","TriggerServerEvent"],"permissions":[],"events":[]},{"anchor":"distance-and-routing-limits","heading":"Distance and routing limits","level":2,"text":"There is no verified arbitrary road-route distance API yet. Straight-line\ndistance is not a taxi route/fare. Native map travel distance and AutoDrive's\ncurrent-path length have different contexts; neither is exposed as a substitute.\nA selected world point also need not be drivable (roofs, water, interiors).","natives":[],"permissions":[],"events":[]}]},{"slug":"notifications","file":"notifications.md","title":"WebUI notifications","summary":"`open77_notifications` displays resource-owned WebUI toasts. Client resources can create notifications directly; server resources can request them through the package's server interface.","sections":[{"anchor":"webui-notifications","heading":"WebUI notifications","level":1,"text":"`open77_notifications` displays resource-owned WebUI toasts. Client resources can create notifications directly; server resources can request them through the package's server interface.\n\nThe WebUI is transparent and never captures input. Copy is inserted with DOM `textContent`, queues\nare bounded, and notifications disappear automatically when their owner stops or reloads.\n\nSource: [`open77_notifications`](https://github.com/Open2077/open77-resources/tree/main/system/open77_notifications).\n\n> **Not the only toast in the build.** `Open77.hud.notify`, `log` and `menu` write to Cyberpunk's\n> *own* notification pipelines — see [vanilla toasts](hud-visibility.md#vanilla-toasts). Reach for\n> those when you want something that looks like the game's own popup and costs no dependency and no\n> browser surface; reach for `open77_notifications` when you need to control colour, icon, duration,\n> stacking or dismissal.","natives":[],"permissions":[],"events":[]},{"anchor":"add-the-dependency","heading":"Add the dependency","level":2,"text":"```lua\nresource \"jobs\"\nversion \"1.0.0\"\ndependency \"open77_notifications >=1.0.0\"\n\nclient_script \"client/main.lua\"\nserver_script \"server/main.lua\"\n```\n\n`dependency` takes a resource name and, optionally, version constraints after a space (`>=`, `<=`, `>`,\n`<`, `=` / `==`, against a 1-to-3-part number such as `1.0.0`); a plain `dependency \"open77_notifications\"`\nis enough when any version will do. A server whose load list lacks the package refuses to start the\nresource, which is the point: a toast sent to a client without the package is dropped without a word.\nDeclare it for a server-only resource too, since `Open77.notifications.*` renders through the package.\n\nExports cross isolated Lua VMs and therefore return a promise. Call them from a scheduler coroutine.","natives":[],"permissions":[],"events":[]},{"anchor":"local-client-notification","heading":"Local client notification","level":2,"text":"```lua\nCreateThread(function()\n local promise, callError = Open77.exports.call(\"open77_notifications\", \"show\", {\n id = \"job_started\",\n type = \"success\",\n title = \"Mission acceptee\",\n message = \"Rendez-vous au garage de Kabuki.\",\n icon = \"JOB\",\n position = \"middle_left\",\n durationMs = 5000,\n progress = true,\n color = \"#54E38E\",\n data = { mission = \"garage_intro\" },\n })\n assert(promise, callError)\n\n local result, awaitError = promise:await()\n assert(result and result.ok, awaitError or (result and result.error))\n print(\"notification handle\", result.handle)\nend)\n```\n\nSupported types are `info`, `success`, `warning`, and `error`. Each type supplies a default colour;\n`color` overrides it with a `#RRGGBB` value.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"update-dismiss-and-clear","heading":"Update, dismiss, and clear","level":2,"text":"```lua\nlocal function notifications(name, ...)\n local promise, reason = Open77.exports.call(\"open77_notifications\", name, ...)\n assert(promise, reason)\n return promise:await()\nend\n\nCreateThread(function()\n local shown = notifications(\"show\", {\n id = \"download\",\n type = \"info\",\n title = \"Synchronisation\",\n message = \"Telechargement des donnees...\",\n durationMs = 0, -- persistent until update/dismiss/clear\n progress = false,\n })\n\n Wait(3000)\n notifications(\"update\", shown.handle, {\n type = \"success\",\n message = \"Synchronisation terminee.\",\n durationMs = 3500,\n progress = true,\n })\n\n -- notifications(\"dismiss\", shown.handle)\n -- notifications(\"clear\") removes every toast owned by this resource.\nend)\n```\n\n`setEnabled(false)` clears and suppresses the caller's notifications. `list()` returns that caller's\nactive entries; resources cannot inspect, update, or dismiss another owner's handles.\n\nThe read-only `readiness()` export returns `{ready=boolean}`. It becomes true only\nafter the notification WebUI's readiness callback, and resets on resource teardown.\nGameplay requiring an admitted warning should check readiness and then check the\n`show` result; disabled owners or full queues can still reject a notification.\nReadiness/acceptance indicate presentation admission, not proof of rendered pixels.","natives":["Open77.exports.call","server:Open77.exports.call","CreateThread","server:CreateThread","Wait","server:Wait"],"permissions":[],"events":[]},{"anchor":"server-targeted-notification","heading":"Server-targeted notification","level":2,"text":"Server resources use the owner-aware API built into the server Lua runtime. The resource name is\nattached by the runtime; scripts cannot impersonate another owner:\n\n```lua\nRegisterNetEvent(\"jobs:completed\", function(jobId)\n local playerId = source\n -- Validate the authoritative job state before notifying the player.\n local id, reason = Open77.notifications.send(playerId, {\n type = \"success\",\n title = \"Mission terminee\",\n message = \"Votre paiement a ete transfere.\",\n icon = \"E$\",\n durationMs = 6000,\n position = \"middle_left\",\n })\n assert(id, reason)\nend)\n```\n\nServer methods are:\n\n| Method | Purpose |\n|---|---|\n| `Open77.notifications.send(playerId, definition)` | Send to one authenticated session ID and return its owner-local ID. |\n| `Open77.notifications.broadcast(definition)` | Send to every connected player. |\n| `Open77.notifications.update(id, patch)` | Update a notification previously returned by this server resource. |\n| `Open77.notifications.dismiss(id)` | Remove one notification owned by this server resource. |\n| `Open77.notifications.clear([playerId])` | Clear this owner's notifications for one player or everyone. |\n\nServer IDs are owner-scoped. Client resources never receive an API that can forge a server-owned\ntoast. Server records expire with timed notifications and are cleared when their owner stops.","natives":["RegisterNetEvent","server:RegisterNetEvent","server:Open77.notifications.send"],"permissions":[],"events":[]},{"anchor":"definition-reference","heading":"Definition reference","level":2,"text":"| Field | Meaning |\n|---|---|\n| `id` | Optional stable owner-local ID, maximum 96 characters. |\n| `replace` | Replace an existing notification with the same `id` when `true`. |\n| `type` / `kind` | `info`, `success`, `warning`, or `error`; defaults to `info`. |\n| `title` | Optional heading, maximum 96 UTF-8 bytes. |\n| `message` / `text` | Required body, maximum 384 UTF-8 bytes. |\n| `icon` | Optional short textual badge, maximum 16 UTF-8 bytes. |\n| `position` | `middle_left` (default), `top_left`, `top_center`, `top_right`, `bottom_left`, `bottom_center`, or `bottom_right`. |\n| `durationMs` / `duration` | Lifetime in milliseconds. `0` is persistent; timed values are 750-120000. |\n| `progress` | Shows the lifetime bar when timed; defaults to `true`. |\n| `color` | Optional `#RRGGBB` accent override. |\n| `data` | Caller-owned serializable context returned by the removal event. |\n\nThe client holds at most 32 notifications and at most eight per position. Adding a ninth toast to a\nposition evicts its oldest toast. Only the notification owner can mutate its entries.","natives":[],"permissions":[],"events":[]},{"anchor":"events-and-test-command","heading":"Events and test command","level":2,"text":"`open77:notificationRemoved` is emitted locally with `handle`, `id`, `owner`, `reason`, and `data`.\nRemoval reasons include `expired`, `dismissed`, `queue_limit`, `owner_stopped`, and server cleanup.\n\nAuthenticated administrators can validate the complete server-to-client route with:\n\n```text\n/notification.test success\n/notification.test warning\n/notification.test error\n```","natives":[],"permissions":[],"events":["open77:notificationRemoved"]}]},{"slug":"npc-behavior","file":"npc-behavior.md","title":"NPC AI, combat and voice control","summary":"Control individual spawned NPCs from server Lua with `Open77.npcs`. Behavior settings apply to the selected NPC, not its gang, other NPCs using the same record, player characters or global audio.","sections":[{"anchor":"npc-ai-combat-and-voice-control","heading":"NPC AI, combat and voice control","level":1,"text":"Control individual spawned NPCs from server Lua with `Open77.npcs`. Behavior settings apply to the selected NPC, not its gang, other NPCs using the same record, player characters or global audio.\n\nAvailable in client and server **2.31.13+op77.55**, using protocol **1.24**. Both sides must\nbe updated to apply the behavior policy. Find spawn IDs in the [NPC record catalogue](npc-catalogue.md).","natives":[],"permissions":[],"events":[]},{"anchor":"spawn-a-passive-silent-npc","heading":"Spawn a passive, silent NPC","level":2,"text":"```lua\n-- Server manifest: permission \"world.npcs\"\nlocal npc, reason = Open77.npcs.create({\n record = \"Character.cpz_maelstrom_grunt1_ranged1_lexington_wa\",\n position = { x = 381.0, y = -2400.0, z = 182.0 },\n aiMode = Open77.npcs.ai.tasks,\n behavior = {\n combatEnabled = false,\n voiceEnabled = false,\n },\n})\nif not npc then print(reason); return end\n\n-- Optional: pause both the native AI agent and Open77 task execution.\nlocal ok, error = Open77.npcs.setAIEnabled(npc, false)\nif not ok then print(error) end\n\n-- Resume the selected aiMode, without changing the passive/silent options.\nOpen77.npcs.setAIEnabled(npc, true)\n```\n\nThe record and its assets must exist on each client. A successful `create` returns a\ncanonical server ID, not proof that the engine has spawned the body. See\n[NPC lifecycle and readiness](npcs.md).","natives":["server:Open77.npcs.create","server:Open77.npcs.setAIEnabled"],"permissions":[],"events":[]},{"anchor":"independent-controls","heading":"Independent controls","level":2,"text":"All four options default to `true`. Omitting an option preserves its current value when updating.\n\n| Option | Server helper | Effect of `false` |\n| --- | --- | --- |\n| `aiEnabled` | `Open77.npcs.setAIEnabled(id, false)` | Disables the native AI agent and its perception; suspends Open77 tasks and revokes its simulation lease. Rendering/collision are not disabled. |\n| `combatEnabled` | `Open77.npcs.setCombatEnabled(id, false)` | Clears tracked threats, disables threat acquisition/reactions and blocks aggression and outgoing hit processing. The movement agent remains available for scripted tasks. |\n| `perceptionEnabled` | `Open77.npcs.setPerceptionEnabled(id, false)` | Disables autonomous sensory/stimulus acquisition, including Open77's automatic target seeding. It does not clear a previously assigned combat target; use `combatEnabled=false` to stop combat. |\n| `voiceEnabled` | `Open77.npcs.setVoiceEnabled(id, false)` | Disables the NPC's native voiceset lines and grunts, including combat/search barks that bypass scripts. Script-triggered voiceovers and native dialogue routed through subtitles are also filtered. Footsteps, weapons and other sound effects are not muted. |\n\nEach helper requires a **boolean**, not `0`, `1`, or `\"false\"`. Use one atomic update for\nseveral options:\n\n```lua\nlocal ok, reason = Open77.npcs.setBehavior(npc, {\n aiEnabled = true,\n combatEnabled = false,\n perceptionEnabled = false,\n voiceEnabled = false,\n})\nlocal behavior = Open77.npcs.getBehavior(npc)\n-- Equivalent read: Open77.npcs.get(npc).behavior\n```\n\n`setBehavior` and the four setters return `true`, or `false` with a validation reason\nwhen supplied data is invalid. Unknown/not-owned NPCs and missing permission return `false`.\n`getBehavior` returns a detached table or `nil` when unavailable. Malformed options return\n`npc_behavior_invalid`; unknown option names return `npc_behavior_unknown_option`.\n\n`Open77.npcs.update(id, { behavior = {...} })` accepts the same partial object. Changing the\nloadout/appearance, changing bucket, or reviving the NPC does not reset these settings.\nThey are not stored in client KVP and do not constitute disk persistence across server restarts.\n\nTwo further options govern damage rather than AI: `vehicleContactEnabled` (default `false`,\n[below](#vehicle-contact-damage)) and `hitPricing` (default `\"resource\"`,\n[below](#who-prices-player-hits-hitpricing)). They are set the same way, at creation or later,\nand read back from `getBehavior`:\n\n```lua\nlocal npc, reason = Open77.npcs.create({\n record = \"Character.cpz_maelstrom_grunt1_ranged1_lexington_wa\",\n position = { x = 381.0, y = -2400.0, z = 182.0 },\n damagePolicy = Open77.npcs.damage.mortal,\n behavior = { vehicleContactEnabled = true, hitPricing = \"platform\" },\n})\nif not npc then print(reason); return end\nprint(Open77.npcs.getBehavior(npc).hitPricing) --> platform\n```","natives":["server:Open77.npcs.setBehavior","server:Open77.npcs.getBehavior","Open77.npcs.get","server:Open77.npcs.get","server:Open77.npcs.create"],"permissions":[],"events":[]},{"anchor":"vehicle-contact-damage","heading":"Vehicle contact damage","level":2,"text":"`behavior.vehicleContactEnabled` defaults to `false`. The NPC's creating server resource may opt\nin with `Open77.npcs.setBehavior(id, { vehicleContactEnabled = true })`. This replaces native\nvehicle-hit damage pricing with server-priced contact damage, and it keeps the NPC's damage\npolicy (`invulnerable`, `immortal` or `mortal`). The client simulating the NPC reports the\ncontact; the server checks its current lease, world readiness, revision, bucket and the\nvehicle's recent trajectory, with a two-second contact cooldown per NPC. Damage uses the same\nspeed curve as car impacts on players (the `combat.vehicleHitDamage*` server options), derived\nfrom accepted positions. Nothing scans the world for contacts every frame.\n\nAn accepted contact adopts an existing native fall or requests a native NPC knockdown. Identical\nragdoll trajectories on every screen are not guaranteed.","natives":[],"permissions":[],"events":[]},{"anchor":"who-prices-player-hits-hitpricing","heading":"Who prices player hits: `hitPricing`","level":2,"text":"An NPC's health and death belong to the server. No client lets the game change a network NPC's\nhealth or kill it on its own: a shot plays its hit reaction, and the NPC loses health or dies only\nwhen the server's ledger says so, on every client at once.\n\nBy default the **resource** that created an NPC prices player hits on it, typically from the\nclient event `open77:npcHit` it forwards to its own server script and a weapon table. A resource\nthat prices nothing leaves players unable to kill its NPCs: they react to shots but never lose\nhealth.\n\nA resource can instead **opt in** to the platform's pricing for a **mortal** NPC\n(`damagePolicy = Open77.npcs.damage.mortal`). The shooter's client then sends the engine's own\nprice for each hit, and the server applies it with `Open77.npcs.applyDamage` semantics.\n`onNpcDamaged` and `onNpcDied` receive the source `\"player:\"` and the cause `\"player_hit\"`.\n\n```lua\n-- A guard that players may kill, with no hit pricing of your own.\nlocal guard = Open77.npcs.create({\n record = \"Character.cpz_maelstrom_grunt1_ranged1_lexington_wa\",\n position = { x = 381.0, y = -2400.0, z = 182.0 },\n damagePolicy = Open77.npcs.damage.mortal,\n behavior = { hitPricing = \"platform\" },\n})\n-- or later: Open77.npcs.setBehavior(guard, { hitPricing = \"platform\" })\n```\n\n**Resources that create mortal NPCs and do not price `open77:npcHit` should opt in**, or players\ncannot kill those NPCs. The opt-in exists because the amount is still the shooter's own claim:\nevery report must ride shots the server itself admitted (see below), but the server has no weapon\ndamage table, so a modified client could still claim one maximum per admitted shot. Price hits in\nyour resource when that matters.\n\nThe server checks each report before applying it:\n\n- the shooter is alive, has a weapon drawn according to its own recent player snapshot, has a\n fresh position in the NPC's routing bucket and is within 150 m of it;\n- the report names at least one shot (a spent round or a melee swing) that the server admitted\n for that drawn weapon at the weapon class's fire rate, fired within the last 3 s (1.5 s for a\n swing) from within 150 m (12 m for melee) of the NPC, and not yet used against this NPC. Each\n shot prices a given NPC once. The server option `combat.handheldDischarges` (default `true`)\n runs this admission; turning it off turns platform pricing off;\n- the NPC is alive, mortal and opted in;\n- one report is worth at most the NPC's maximum health;\n- a player may apply at most 40 reports and 6 × maximum health per second across all NPCs;\n- an NPC may receive at most 24 reports and 2 × maximum health per second from all players.\n\nImmortal and invulnerable NPCs are never priced this way.\n\n| `hitPricing` | Player hits on a mortal NPC |\n| --- | --- |\n| `\"resource\"` (default) | Never priced by the platform; only what the resource applies changes the NPC. `open77:npcHit` still fires on the shooter's client. |\n| `\"platform\"` | Priced by the server as above. `open77:npcHit` still fires as well; do not also price it, or each shot counts twice. |\n\nThe value must be one of these two strings; anything else returns `npc_behavior_invalid`.\n`Open77.npcs.getBehavior(id).hitPricing` reads it back. The bundled Cordon, Deathmatch and\nFreeroam PvP bots state `\"resource\"` explicitly.\n\nDamage no player caused is always reported by the client that simulates the NPC: environment,\nfalls, fire, explosions without a player instigator, and the NPC's own grenade. Vehicle collisions\nare not player hits; opt in to [vehicle contact damage](#vehicle-contact-damage) for those.","natives":["server:Open77.npcs.create","server:Open77.npcs.setBehavior"],"permissions":[],"events":["open77:npcHit"]},{"anchor":"server-configuration","heading":"Server configuration","level":3,"text":"Both options live in the `combat` section of `server.jsonc` and are read at startup:\n\n```jsonc\n\"combat\": {\n \"handheldDischarges\": true, // default: admit handheld shots, required by \"platform\" pricing\n \"handheldVehicleDamage\": false // default: see Armed vehicles & Lua API\n}\n```\n\n`handheldDischarges` (default `true`) makes the server bind each living body and admit fire\ndeclarations against the drawn catalogued weapon and a per-class cadence. A receipt damages\nnothing by itself; platform-priced NPC hits require one. With `false`, no binding is sent and\nplatform-priced NPCs cannot be priced from a player's hit. `handheldVehicleDamage` is described in\n[Armed vehicles & Lua API](vehicle-weapons.md#handheld-guns-on-cars-another-player-drives).","natives":[],"permissions":[],"events":[]},{"anchor":"speech-one-line-on-demand","heading":"Speech: one line, on demand","level":2,"text":"`Open77.npcs.speak(id, voice, options)` is the ON switch beside `setVoiceEnabled`'s OFF: one\nvoice-over line from the NPC's own voiceset, queued on every client that has the body\nstreamed. It is the counterpart of FiveM's `PlayPedAmbientSpeechNative`. What is proven is the\nplumbing — the `SoundPlayVo` event reaches each viewer's puppet, and a muted NPC refuses — not\nthe sound: the test loop has no audio capture, so whether a given line is *heard* on a given\nrecord is something to check by ear before shipping it.\n\n```lua\n-- A guard greets whoever walks up, and warns them off when they linger.\nlocal guard = Open77.npcs.create({ record = \"Character.Judy\", position = gate })\nAddEventHandler(\"onNpcInteracted\", function(npcId, playerId)\n if npcId ~= guard then return end\n local ok, reason = Open77.npcs.speak(guard, \"greeting\")\n if not ok then print(\"guard stayed silent: \" .. reason) end\nend)\nOpen77.npcs.speak(guard, \"rep_ask_to_leave\", { ignoreDistance = true })\n```\n\n`voice` is a **`voContext` name** — the same word vanilla passes to\n`GameObject.PlayVoiceOver`, resolved by the engine against the puppet's voiceset\n(`Character.*.voiceTag`) — not a Wwise event; `Open77.effects.sound` remains the way to play a\nsound bank event on an NPC. `Open77.npcs.voices()` lists the generic barks that can be\npointed at in the game's own 2.31 sources, each with the file and line that plays it:\n\n| Name | When vanilla plays it |\n|---|---|\n| `greeting` | look-at reaction to a friendly passer-by |\n| `bump` | bumped into by the player |\n| `fear_beg`, `fear_run`, `fear_foll` | a civilian threatened: begging, fleeing, complying |\n| `stlh_curious`, `stlh_curious_grunt`, `stlh_investigate`, `stlh_search`, `stlh_patrol_back`, `stlh_call`, `stlh_death` | the stealth ladder: noticing, investigating, searching, giving up, calling a friend, finding one dead |\n| `start_alerted`, `start_combat`, `combat_ended`, `danger`, `enemy_warning`, `combat_target_hit`, `combat_target_sight_lost`, `crowd_combat` | alert and combat state changes |\n| `attack_short`, `attack_long`, `enemy_melee_charge`, `battlecry_curse` | attack shouts |\n| `hit_reaction_light`, `hit_reaction_heavy`, `vo_any_damage_hit` | taking hits |\n| `grenade`, `grenade_throw`, `coop_reports_kill` | grenades and kill calls |\n| `rep_ask_to_leave`, `rep_ask_to_holster`, `rep_final_warning`, `rep_call_grd`, `rep_complies` | a guard's escalation ladder |\n| `hurry_up`, `phone_start`, `taunt_hidden_player`, `pedestrian_hit`, `vehicle_bump` | miscellany |\n\nThe list is documentation, not an allowlist: any identifier is accepted (letters, digits,\nunderscore, at most 64), because a record may carry lines no vanilla script calls. **A name the\nvoiceset does not have is silent, and nothing can report that** — the engine drops it without\na word, so `true` means \"queued on every viewer\", not \"heard\". Test the names you ship on the\nrecords you ship; a character whose record has no `voiceTag` never speaks.\n\nOptions: `ignoreFrustum` (default `true`) plays even when the NPC is off-screen;\n`ignoreDistance` (default `false`) skips the engine's distance cull. Refusals, by name:\n\n| Reason | Meaning |\n|---|---|\n| `invalid_voice` | Not an identifier, empty, or longer than 64. |\n| `invalid_npc_id`, `npc_not_found`, `npc_not_owned` | Bad id, unknown id, another resource's NPC. |\n| `npc_dead` | A dead body has nothing to say. |\n| `voice_disabled` | `setVoiceEnabled(id, false)` is in force; lift it first. |\n| `npc_not_streamed` | No client has reported the body ready yet — nobody could hear it. |\n| `npc_voice_busy` | A line was accepted on this NPC less than 400 ms ago. |\n\nRequires `world.npcs` and ownership, like every NPC mutator. The line rides the existing\neffect one-shot to every client in the NPC's routing bucket; a client that does not have the\nbody streamed resolves no target and drops it, which is the same rule an entity-bound sound\nfollows. On the client the bundled [`open77_effects`](https://github.com/Open2077/open77-resources/tree/main/system/open77_effects) resource queues the engine's own\n`SoundPlayVo` event on the puppet through `Open77.sfx.playVoice`, which a client resource may\nalso use for a purely local bark (see [Effects](effects.md)).","natives":["server:Open77.npcs.create","AddEventHandler","server:AddEventHandler","server:Open77.npcs.speak"],"permissions":[],"events":[]},{"anchor":"tasks-authority-and-replication","heading":"Tasks, authority and replication","level":2,"text":"`aiMode` and behavior are different controls. `frozen` suspends Open77's task scheduler;\n**use `setAIEnabled(id, false)` when the native agent must also stop**. Re-enabling AI does\nnot change `aiMode`: a frozen task scheduler stays frozen until its mode is changed.\nTasks resume with renewed timing rather than expiring because of the paused interval.\n\nThe server validates resource ownership and replicates the full policy in reliable NPC state,\nincluding to late joiners, observers and newly streamed incarnations. Engine mutations run on\nthe game thread. An unavailable native/component is logged as `behavior pending`, not reported\nas a successful application. Applying these options requires the matching updated client and\nserver; the existing 1.24 wire layout is unchanged, but older clients ignore the new policy.\n\nClients with `npcs.read` can inspect `Open77.npcs.get(id).behavior` and `.all()`, but cannot\noverride the server policy. Custom network event handlers must validate ownership themselves;\nnever forward an arbitrary client-supplied NPC ID to these setters.\n\nIf your gamemode accepts NPC hit reports, re-check current canonical behavior before applying\ndamage: ignore reports when `aiEnabled` or `combatEnabled` is false. A delayed report is not\npermission to damage a player after a server-side policy change.","natives":[],"permissions":[],"events":[]},{"anchor":"boundaries","heading":"Boundaries","level":2,"text":"- These APIs control Open77-created NPCs, not arbitrary ambient world handles.\n- Voice suppression is prospective. It does not promise to interrupt every line already playing,\n or audio emitted outside the game's voice/dialogue pipelines.\n- Disabling AI is not time dilation, a pose freeze, ragdoll, invulnerability or hiding a character.\n Use the dedicated animation and damage APIs for those effects.\n- Perception off does not override explicit script commands. Combat off does not prevent your\n own Lua code from deliberately calling a damage API or playing a custom attack animation.\n- Vehicle auto-driving belongs to `Open77.vehicles.ai`; disabling a seated NPC's agent is not a\n substitute for stopping the vehicle's drive command.","natives":[],"permissions":[],"events":[]}]},{"slug":"npc-catalogue","file":"npc-catalogue.md","title":"NPC record catalogue","summary":"Browse **6,582 `Character.*` records** from Cyberpunk 2077 **2.31**, including Phantom Liberty. The index describes game data, not guaranteed multiplayer compatibility. The downloadable CSV also includes 86 internal `TEST.*` records, which the spawn API does not accept. Custom `Character.*` records require their assets on every client.","sections":[{"anchor":"npc-record-catalogue","heading":"NPC record catalogue","level":1,"text":"Browse **6,582 `Character.*` records** from Cyberpunk 2077 **2.31**, including Phantom Liberty. The index describes game data, not guaranteed multiplayer compatibility. The downloadable CSV also includes 86 internal `TEST.*` records, which the spawn API does not accept. Custom `Character.*` records require their assets on every client.","natives":[],"permissions":[],"events":[]},{"anchor":"use-a-record","heading":"Use a record","level":2,"text":"Pass the exact record ID to the server, not the entity template path or its hash:\n\n```lua\n-- Server manifest: permission \"world.npcs\"\nlocal id, reason = Open77.npcs.create({\n record = \"Character.cpz_maelstrom_grunt1_ranged1_lexington_wa\",\n position = { x = 381.0, y = -2400.0, z = 182.0 },\n behavior = { combatEnabled = false, voiceEnabled = false },\n})\nif not id then print(reason) end\n```\n\nCreating an ID does not confirm that the body spawned. Check readiness and spawn-failure\nevents as explained in [Server-owned NPCs](npcs.md). For passive characters, task control\nand silent NPCs, read [AI, combat and voice control](npc-behavior.md).","natives":["server:Open77.npcs.create"],"permissions":[],"events":[]},{"anchor":"understand-the-warnings","heading":"Understand the warnings","level":2,"text":"The extraction classifies records structurally, not through in-game testing:\n\n| Classification | Meaning |\n| --- | --- |\n| Candidate | A starting point for testing; not guaranteed to work or be damageable. |\n| Quest / scene | May require quest setup, special graphs or invulnerability settings. |\n| Special rig | Animals, robots, photo-mode and other nonstandard rigs need extra care. |\n| Vendor | May depend on vendor-specific setup. |\n| Child | Special child records; not ordinary combat NPCs. |\n| Player | Player-body records; not ordinary NPC spawn candidates. |\n| Missing template | No entity template was resolved by this extraction. |\n\nAn appearance is not a record ID. Appearance names and entity paths help investigate assets;\nthey do not certify rig compatibility. Reusing a quest character for armed combat can be unsafe.\nThe `tweakdb_ep1.bin` source describes the extracted database, **not a reliable per-record DLC\nrequirement**: it also contains base-game entries. Ensure the record and assets exist on every\nobserving client. This catalogue excludes third-party mod archives.","natives":[],"permissions":[],"events":[]},{"anchor":"download-the-data","heading":"Download the data","level":2,"text":"Use the [compact JSON catalogue](/data/npc-records-2.31.json) for tooling and agents, or the\n[complete extraction CSV](/data/npc-records-2.31.csv) for all raw fields. The JSON records the\ngame version, source path and CSV SHA-256 so tools can identify the exact dataset.\n\n`Open77.npcs.templates()` still returns the small set of **legacy aliases**, not this catalogue.\nNeither the catalogue nor an alias registration is required to spawn a valid `Character.*` record.","natives":[],"permissions":[],"events":[]}]},{"slug":"npcs","file":"npcs.md","title":"Server-owned NPCs","summary":"Create server-owned NPCs and control their identity, routing bucket, health, tasks and simulation authority. Clients project nearby NPCs into the world but cannot create or mutate their canonical state.","sections":[{"anchor":"server-owned-npcs","heading":"Server-owned NPCs","level":1,"text":"Create server-owned NPCs and control their identity, routing bucket, health, tasks and simulation authority. Clients project nearby NPCs into the world but cannot create or mutate their canonical state.\n\nThe reference implementation is [`resources/system/open77_npcs`](https://github.com/Open2077/open77-resources/blob/main/system/open77_npcs/README.md).\n\nSpawn directly from `Character.*` record IDs, without registering a\nvirtual template. Browse the [NPC record catalogue](npc-catalogue.md) for IDs, appearances\nand compatibility warnings. See [AI, combat and voice control](npc-behavior.md) to make a\nhostile NPC passive, disable its perception or silence combat/search voice lines.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-permissions","heading":"Manifest permissions","level":2,"text":"Server mutation requires `world.npcs`. Client inspection requires `npcs.read`.\n\n```lua\npermissions { \"world.npcs\", \"npcs.read\" }\n```","natives":[],"permissions":["world.npcs","npcs.read"],"events":[]},{"anchor":"architecture","heading":"Architecture","level":2,"text":"- NPC IDs are opaque, generation-aware 64-bit server IDs.\n- The server streams NPCs only to players in the same routing bucket and inside the configured\n radius. Hysteresis prevents rapid stream-in/stream-out near the boundary.\n- One ready client receives a short simulation lease and executes native REDengine commands. The\n lease carries an epoch; reports from an old owner or epoch are rejected.\n- Other clients interpolate authoritative motion and never run a competing task.\n- Late joiners receive the complete NPC state and active tasks.\n- Movement, look, action and full-body channels execute independently. Starting a look-at does not\n cancel movement, and starting movement does not stop an active full-body animation.\n- A resource may inspect and mutate only NPCs it owns.\n- Stopping a resource removes its non-persistent NPCs. Persistent NPCs must be removed explicitly\n or restored/managed by server code after a resource restart.","natives":[],"permissions":[],"events":[]},{"anchor":"character-records-and-legacy-aliases","heading":"Character records and legacy aliases","level":2,"text":"Spawn directly with `Open77.npcs.create({ record = \"Character.Judy\", position = ... })`.\nNo catalogue registration is required. Vanilla, DLC and custom `Character.*` records use the\nsame authoritative lifecycle and replication path. Their record and required assets must exist\non every observing client; the headless server cannot inspect a player's TweakDB.\n\nUse exact, case-sensitive textual IDs (not hashes or `.ent` paths): `Character.` followed by\nASCII letters, digits, `_`, `-` or `.`, at most 255 bytes in total. `record` takes precedence over\n`template`; an invalid explicit record is rejected, never replaced with a default NPC.\nFor compatibility, `template = \"Character.Judy\"` and the first argument of the low-level\n`CreateNpc` (with its existing positional arguments) also accept records. Existing aliases and\nthe old default when **both fields are absent** remain available.\n`Open77.npcs.templates()` lists these optional legacy aliases, not all spawnable characters:\n\n```lua\nfor _, template in ipairs(Open77.npcs.templates()) do\n print(template.name, template.record)\nend\n```\n\n| Alias | Faction | Intended use |\n|---|---|---|\n| `civilian_female_relaxed_01` | Aldecaldos | Relaxed civilian projection and scripted locomotion. **Not shootable** — see the warning below. |\n| `hostile_female_ranged_lab` | Maelstrom | Ranged gang combatant. |\n| `gang_valentinos_ranged_01` | Valentinos | Ranged gang combatant. |\n| `gang_tygerclaws_ranged_01` | Tyger Claws | Ranged gang combatant. |\n\nAn alias contains its predefined record, observer record and capability list. An advertised\ncapability describes the underlying rig/template; it does not make an unstable task public.\n\n`civilian_female_relaxed_01` resolves to `Character.Panam`, whose `Invulnerable` tag prevents normal combat targeting and damage. Do not arm this template: its weapon graph can crash the client. Use it only as a passive background character; use combat-compatible records for damageable NPCs.\n\n**Native combat relations are explicit.** A native gang record runs engine combat AI on\nevery client streaming it. For `aiMode = Open77.npcs.ai.native`, the client makes native\nNPCs hostile to the local player and, by default, to other native NPCs in their bucket.\nChoosing the same gang template does not make a team. Idle NPCs acquire the nearest\nhostile NPC within the existing combat radius; default player acquisition uses engine\nperception.\n\nResources may opt into a shared team and nearby player acquisition through loadout metadata:\n\n```lua\nloadout = {\n combat = { group = \"my_resource_guards\", acquirePlayers = true },\n}\n```\n\nEqual nonempty `combat.group` values make native NPC pairs friendly and exclude them\nfrom each other's automatic target selection. Groups are case-sensitive, at most 64\nASCII letters, digits, `_`, `-` or `.`; prefix them with the resource name to avoid\ncollisions in a shared bucket. Missing or invalid groups preserve free-for-all behavior.\nThis controls engine relations, not a server damage arbiter: a gamemode should enforce\nits friendly-fire rules in damage handling too.\n\n`acquirePlayers` defaults to `false`. When explicitly `true`, an idle NPC can acquire a\nliving local player in the same bucket within 30 metres, provided a chest-height\nStatic/Dynamic world raycast is clear and no closer hostile NPC was selected. The engine\nstill decides movement and firing. This is a perception aid, not an AI task or a damage\ngrant; gamemode shields and damage validation remain authoritative.\n\nMetadata without a top-level `weapon` never enters the player-proxy equipment path.\nGang records retain their native inventory. A nested `combat.weapon` is ignored.\nDeploy a client containing this policy before enabling cooperative camps: older clients\nignore the metadata and retain the default hostility sweep.","natives":["server:Open77.npcs.templates"],"permissions":[],"events":[]},{"anchor":"complete-231-research-catalogue","heading":"Complete 2.31 research catalogue","level":3,"text":"The research catalogue helps find records and appearances for Cyberpunk 2077 2.31 + Phantom Liberty.\nIt does not gate the runtime API:\n\n- [6,668 `Character.*` records](../docs/generated/npc-records-2.31.csv), including template,\n gameplay metadata, crowd appearances and structural risk classification;\n- [1,524 unique `.ent` templates](../docs/generated/npc-templates-2.31.md), all verified present and\n readable in the installed base-game/EP1 archives;\n- [all `.ent` appearance bindings](../docs/generated/npc-entity-appearances-2.31.csv);\n- [all definitions from the referenced `.app` resources](../docs/generated/npc-appearance-resources-2.31.csv);\n- [detailed machine-readable `.ent`/`.app` graph](../docs/generated/npc-entity-appearances-2.31.json).\n\nThe `category` and `risk` columns are research filters, not a runtime allowlist. In\nparticular, `candidate` means only that no obvious quest/player/vendor/special-rig blocker was found\nin the extracted fields. Access to a record does not guarantee that its quest logic, special rig,\nanimations, appearance or equipment supports every NPC task. Test the records you ship; prefer\nnative inventory over forcing player-proxy equipment onto an unrelated character graph.\n\nSee the [methodology, source hashes, limitations and validation backlog](../docs/research/npc-templates-and-appearances-catalog.md).","natives":[],"permissions":[],"events":[]},{"anchor":"create-and-inspect","heading":"Create and inspect","level":2,"text":"```lua\nlocal npcId, reason = Open77.npcs.create({\n record = \"Character.Judy\",\n position = { x = -1378.0, y = 1262.0, z = 123.0 },\n yaw = 90.0,\n bucket = 0,\n appearance = nil,\n loadout = {},\n aiMode = Open77.npcs.ai.tasks,\n damagePolicy = Open77.npcs.damage.immortal,\n health = 100,\n maxHealth = 100,\n streamingRadius = 180,\n streamingHysteresis = 40,\n despawnWhenUnobserved = false,\n persistent = false,\n})\n\nif not npcId then error(reason) end\n\nlocal npc = Open77.npcs.get(npcId)\nlocal owned = Open77.npcs.all() -- this resource only\nlocal inBucket = Open77.npcs.all(12) -- optional bucket filter\n```\n\n`get` returns the template, appearance, loadout JSON, position, bucket, streaming values, health,\nflags, AI/damage modes, revisions, active task and current authority lease.\n\nLimits are enforced server-side: 4096 NPCs globally, 512 per resource and 64 tasks per NPC.","natives":["server:Open77.npcs.create","Open77.npcs.get","server:Open77.npcs.get","Open77.npcs.all","server:Open77.npcs.all"],"permissions":[],"events":[]},{"anchor":"server-api-reference","heading":"Server API reference","level":3,"text":"All functions in this table require `world.npcs`. IDs are opaque 64-bit Lua integers. Mutation\nfunctions return `true` only when the NPC exists and belongs to the calling resource. Invalid\ntask parameters, IDs or enum values raise a Lua error; callers should treat these as resource\nbugs rather than normal gameplay failures.\n\n`create` is the exception: an invalid record ID, unknown alias or out-of-range field is a **normal\nrejection**, not an error. It returns `nil, reason` — for example\n`nil, \"npc_record_invalid\"` or `nil, \"npc_template_not_found\"` — so always check both return values.\nA returned ID confirms canonical server creation, **not** successful engine spawning. Client-side\nrecord lookup and asynchronous spawning can still fail; use `onNpcReady` and `onNpcSpawnFailed`.\n\n| Function | Parameters | Return |\n|---|---|---|\n| `Open77.npcs.create` | `definition` | `npcId`, or `nil, reason` when the subsystem/permission is unavailable |\n| `Open77.npcs.get` | `npcId` | owned NPC snapshot or `nil` |\n| `Open77.npcs.all` | optional `bucket` | array of owned NPC snapshots |\n| `Open77.npcs.templates` | none | array of optional legacy alias snapshots (not an exhaustive catalogue) |\n| `Open77.npcs.update` | `npcId, fields` | boolean |\n| `Open77.npcs.setTransform` | `npcId, transform` | boolean |\n| `Open77.npcs.setBucket` | `npcId, bucket` | boolean |\n| `Open77.npcs.setAppearance` | `npcId, appearance` | boolean |\n| `Open77.npcs.setLoadout` | `npcId, loadout` | boolean |\n| `Open77.npcs.setEquipment` | `npcId, { slot = \"Items.*\", ... }` | `true`, or `nil, reason` |\n| `Open77.npcs.getEquipment` | `npcId` | slot-to-record table, or `nil` |\n| `Open77.npcs.speak` | `npcId, voice, optional options` | `true`, or `nil, reason` |\n| `Open77.npcs.voices` | none | array of `{ name, context, source }` |\n| `Open77.npcs.setHealth` | `npcId, health, optional maxHealth` | boolean |\n| `Open77.npcs.setDamagePolicy` | `npcId, policy` | boolean |\n| `Open77.npcs.setAiMode` | `npcId, mode` | boolean |\n| `Open77.npcs.setRagdoll` | `npcId, enabled` | boolean |\n| `Open77.npcs.setAttitude` | `npcId, attitude, optional options` | boolean, or `false, reason` |\n| `Open77.npcs.getAttitude` | `npcId` | attitude table or `nil` |\n| `Open77.npcs.setGroup` | `npcId, group` | boolean, or `false, reason` |\n| `Open77.npcs.getGroup` | `npcId` | group string or `nil` |\n| `Open77.npcs.setRelationship` | `groupA, groupB, attitude` | boolean, or `false, reason` |\n| `Open77.npcs.getRelationship` | `groupA, groupB` | attitude string or `nil` |\n| `Open77.npcs.target` | `npcId` | `{ kind, id }` or `nil` |\n| `Open77.npcs.applyDamage` | `npcId, amount, optional source, optional cause` | boolean |\n| `Open77.npcs.kill` | `npcId, optional reason` | boolean |\n| `Open77.npcs.revive` | `npcId, optional health` | boolean |\n| `Open77.npcs.remove` | `npcId` | boolean |\n| `Open77.npcs.presence` | `npcId` | `{ id, resource, owned, bucket, x, y, z, alive, authorityPlayerId, epoch, leaseMs? }`, or `nil, reason` |\n\n`presence` is the single, constant-time read a rule needs when a client acts **for** an NPC:\nwho simulates it (`authorityPlayerId`, `0` when nobody; `epoch`; `leaseMs` only while a lease is\nlive), whether it is `alive` (alive flag and health above zero), and its canonical place and\nbucket. `owned` says whether the caller created it and `resource` names who did. Unlike `get`, it\nanswers for another resource's NPC too when the caller also declares `npcs.foreign` (otherwise\n`nil, \"permission_denied:npcs.foreign\"`), exactly like `all({ includeForeign = true })`. Other\nreasons: `invalid_npc_id`, `npc_not_found`, `npcs_unavailable`. The\n[door service](doors.md#npc-passage) uses it to let the simulating client open a door for its NPC.\n\n```lua\n-- open77.lua: permissions { \"world.npcs\", \"npcs.foreign\" }\n-- Let a client speak for an NPC only while it simulates it, near the target.\nlocal function speaksFor(player, npcId, target)\n local p = Open77.npcs.presence(npcId)\n if not p or not p.alive or p.authorityPlayerId ~= player or not p.leaseMs then return false end\n local dx, dy, dz = p.x - target.x, p.y - target.y, p.z - target.z\n return dx * dx + dy * dy + dz * dz <= 36\nend\n```\n\nAn NPC snapshot contains:\n\n| Field | Type | Meaning |\n|---|---|---|\n| `id`, `revision`, `taskRevision` | integer | Canonical identity and monotonic revisions. |\n| `resource` | string | Owning resource. |\n| `template`, `record`, `observerRecord` | string | Legacy alias, or `\"record\"` for direct spawning. Both record fields contain the exact Character ID for direct spawns. |\n| `appearance`, `loadout` | string | Appearance name and canonical loadout JSON. |\n| `x`, `y`, `z`, `yaw` | number | Canonical transform. |\n| `bucket` | integer | Routing bucket. |\n| `streamingRadius`, `streamingHysteresis` | number | Interest thresholds in metres. |\n| `health`, `maxHealth` | number | Canonical health. |\n| `flags`, `aiMode`, `damagePolicy` | integer | Values from the constant tables below. |\n| `currentTaskId` | integer | Preferred active task for compact replication, or `0`. |\n| `authorityPlayerId`, `authorityEpoch` | integer | Current simulation lease owner and epoch, or `0`. |\n\nA template snapshot contains `name`, `record`, `observerRecord`, `defaultAppearance` and a\n`capabilities` array.","natives":["server:Open77.npcs.presence"],"permissions":["world.npcs","npcs.foreign"],"events":[]},{"anchor":"state-mutation","heading":"State mutation","level":2,"text":"```lua\nOpen77.npcs.setTransform(npcId, { position = { x = 1, y = 2, z = 3 }, yaw = 180 })\nOpen77.npcs.setBucket(npcId, 7)\nOpen77.npcs.setAppearance(npcId, \"appearance_name\")\nOpen77.npcs.setLoadout(npcId, { weapon = \"Items.Preset_Lexington_Default\" })\nOpen77.npcs.setHealth(npcId, 80, 100)\nOpen77.npcs.setDamagePolicy(npcId, Open77.npcs.damage.mortal)\nOpen77.npcs.setAiMode(npcId, Open77.npcs.ai.tasks)\nOpen77.npcs.setRagdoll(npcId, true)\nOpen77.npcs.remove(npcId)\n```\n\n`Open77.npcs.update(id, fields)` can atomically change appearance, loadout, AI mode, damage policy,\nhealth, maximum health and ragdoll, and takes an `equipment` table (below). The named setters\nabove are convenience wrappers.\n\nMoving an NPC between buckets or teleporting it revokes the current simulation lease before the\nnew state is broadcast.","natives":["server:Open77.npcs.setTransform","server:Open77.npcs.setBucket","server:Open77.npcs.setAppearance","server:Open77.npcs.setLoadout","server:Open77.npcs.setHealth","server:Open77.npcs.setDamagePolicy","server:Open77.npcs.setAiMode","server:Open77.npcs.setRagdoll","server:Open77.npcs.remove"],"permissions":[],"events":[]},{"anchor":"equipment-dressing-an-npc","heading":"Equipment: dressing an NPC","level":2,"text":"`Open77.npcs.setEquipment(id, set)` dresses an NPC in `Items.*` clothing records, on every\nclient that streams it, through the same puppet equipment path the loadout's `weapon` key\nalready uses. It is the server-side counterpart of FiveM's `SetPedComponentVariation` on a\nped — a uniform for a job NPC, a costume for an event.\n\n```lua\n-- A shopkeeper in a work jacket and jeans. The table is the WHOLE desired set.\nlocal npc = Open77.npcs.create({ record = \"Character.Judy\", position = counter })\nlocal ok, reason = Open77.npcs.setEquipment(npc, {\n OuterChest = \"Items.Jacket_01_basic_01\",\n Legs = \"Items.Pants_01_basic_01\",\n Feet = \"Items.Shoes_01_basic_01\",\n})\nif not ok then print(\"could not dress the clerk: \" .. reason) end\n\n-- Read it back; the same table also rides `json.decode(Open77.npcs.get(npc).loadout).equipment`.\nlocal worn = Open77.npcs.getEquipment(npc) --> { OuterChest = ..., Legs = ..., Feet = ... }\n\n-- Swap one piece: send the set again with that slot changed. A slot you leave\n-- out is a slot you want empty.\nOpen77.npcs.setEquipment(npc, { OuterChest = \"Items.Jacket_02_basic_01\", Legs = \"Items.Pants_01_basic_01\" })\n\n-- Beside other fields, in one update.\nOpen77.npcs.update(npc, { equipment = { Head = \"Items.Hat_01_basic_01\" }, health = 100 })\n\n-- Undress what you dressed.\nOpen77.npcs.setEquipment(npc, {})\n```\n\nSlots are the client's clothing vocabulary: `Head`, `Face`, `InnerChest`, `OuterChest`,\n`Legs`, `Feet`, `Outfit`, `UnderwearTop`, `UnderwearBottom`. The held weapon is **not** a\nclothing slot; it stays on `setLoadout(id, { weapon = ... })`. Values are `Items.*` record\nnames (`Items.` followed by an identifier, at most 200 characters); the catalogue of clothing\nrecords is `docs/generated/clothing-2.31.csv`.\n\nWhat the server promises, and refuses by name:\n\n| Reason | Meaning |\n|---|---|\n| `npc_equipment_invalid` | The set is not a table (or, inside `update`, `equipment` is not one). |\n| `npc_equipment_slot_invalid` | A key is not one of the nine clothing slots. |\n| `npc_equipment_record_invalid` | A value is not an `Items.*` identifier of at most 200 characters. |\n| `npc_not_found`, `npc_not_owned` | The id is unknown, or belongs to another resource. |\n\nA refused set changes nothing. An accepted set is published as one revision, rides the NPC's\nloadout envelope to every viewer and to late joiners, and survives a later `setLoadout` that\ndoes not mention `equipment` — a resource re-sending a weapon does not strip the uniform. A\n`setLoadout` that does state `equipment` (an empty table included) replaces it.\n\n**What the client does with it.** Every viewer dresses its own puppet instance, one changed\nslot at a time (`GiveItem` + `AddItemToSlot`, the transaction-system path a vanilla puppet\nequips its own loadout with), and only ever empties a slot Open77 itself filled: a\ncharacter's own look lives in its appearance, not in its attachment slots, so nothing native\nis stripped. The client never runs `InitializeSlots` for clothing — a `Character.*` record\ndeclares its attachment slots in TweakDB (`Puppet_Base` declares thirty-three, the nine\nclothing slots included) and the engine builds the container from that list at spawn;\nre-initialising it would wipe the weapon the AI holstered. A slot the record does not declare\n(a mech, an exo, a player puppet record) is refused on the client with a log line\n`[npc] equipment refused ... reason=slot_unavailable`; a record whose list cannot be read gets\nnothing (`slots_unknown`). Success is the log line\n`[npc] equipment applied npc= slot= record=` per slot.\n\n**Honest limits.** The server has no TweakDB: existence of the record, and whether the\ngarment mesh binds to that character's rig, are decided on each client against the live\ngame. Clothing records are authored for the player bodies; on a vanilla civilian body some\ngarments will fit and some will clip or float, and only a look at the screen tells which —\ntest the records you ship on the records you ship. There is no NPC-side equipment read on\nthe client Lua surface; `Open77.npcs.getEquipment` (server) and the client log are the\nreadbacks.","natives":["server:Open77.npcs.create","server:Open77.npcs.setEquipment","Open77.npcs.get","server:Open77.npcs.get","server:Open77.npcs.getEquipment","server:Open77.npcs.update"],"permissions":[],"events":[]},{"anchor":"constants","heading":"Constants","level":3,"text":"```lua\nOpen77.npcs.flags.alive\nOpen77.npcs.flags.ragdoll\nOpen77.npcs.flags.despawnWhenUnobserved\nOpen77.npcs.flags.persistent\nOpen77.npcs.ai.tasks\nOpen77.npcs.ai.frozen\nOpen77.npcs.ai.native\nOpen77.npcs.damage.mortal\nOpen77.npcs.damage.immortal\nOpen77.npcs.damage.invulnerable\n```\n\n`tasks` is the stable default. `frozen` immediately revokes the simulation lease and suspends every\ntask channel; returning to `tasks` resumes them with fresh timeout/duration accounting. A dead NPC\ncannot receive a lease, motion report or new task until it is revived. `native` is reserved for\ntemplates whose autonomous REDengine behaviour has been explicitly validated.","natives":[],"permissions":[],"events":[]},{"anchor":"tasks","heading":"Tasks","level":2,"text":"For native AI pause, passive NPCs, sensory acquisition and per-NPC voice suppression, see\n[NPC AI, combat and voice control](npc-behavior.md). These options are set with\n`behavior = {...}` at creation or `Open77.npcs.setBehavior(id, {...})` later; snapshots expose\nthe current `behavior` table. They survive loadout changes and stream-out/stream-in.\n\nTasks are server queues partitioned into movement, look, action and full-body channels. Priority is\nevaluated within a channel. One task may execute in each channel at the same time. A timeout of `0`\nmeans no timeout. Task/channel combinations are validated server-side; for example `moveTo` is\nmovement-only, `lookAt` is look-only and `playAnimation` is full-body-only. `wait` may be assigned\nto any channel and blocks only that channel.\n\n| Helper | Channel | Behaviour |\n|---|---:|---|\n| `moveTo` | movement | Native navigation to one position. |\n| `follow` | movement | Re-paths toward a player, NPC or fixed position. |\n| `patrol` | movement | Sequences arbitrary positions, waits and optional loops. |\n| `wander` | movement | Deterministic roaming around a centre. |\n| `face` | look | Rotates the body toward a point. |\n| `lookAt` | look | Continuously aims the look-at target at a player, NPC or point. |\n| `wait` | action by default | Server-timed delay. |\n| `hold` | movement | Holds the current position. |\n| `playAnimation` | full body | Plays a reviewed named workspot animation. |\n| `attack` | action | Engages a player or an owned NPC. Composes with a movement task. |\n| `guard` | movement | Holds an area, and fights from it without being pulled home. |\n| `flee` | movement | Breaks contact with a player, NPC or fixed point. |","natives":[],"permissions":[],"events":[]},{"anchor":"move-follow-and-patrol","heading":"Move, follow and patrol","level":3,"text":"```lua\nlocal move = Open77.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, {\n speed = \"walk\", acceptanceRadius = 1.0, timeoutMs = 30000,\n})\n\nlocal follow = Open77.npcs.tasks.follow(npcId, { type = \"player\", id = playerId }, {\n speed = \"run\", distance = 2.0, onTargetLost = \"wait\",\n})\nlocal followNpc = Open77.npcs.tasks.follow(npcId, { type = \"npc\", id = otherNpcId })\n\nlocal patrol = Open77.npcs.tasks.patrol(npcId, {\n { x = 10, y = 20, z = 30, waitMs = 500 },\n { x = 16, y = 20, z = 30, waitMs = 1000 },\n}, { speed = \"walk\", loop = true, backAndForth = false })\n\nlocal wander = Open77.npcs.tasks.wander(npcId, {\n x = 10, y = 20, z = 30, radius = 15, speed = \"walk\", seed = 42,\n})\n```\n\n`walk`, `run` and `sprint` are supported movement speeds. Patrol accepts 1 to 64 points. The\ncurrent `onTargetLost` policy waits for the target to become available again.\n\nA walk never arrives by teleport. Before a `moveTo` starts, the client simulating the NPC asks the\nAI navigation for a path. A destination it cannot reach (a locked door in the way, a wall, a point\noff the walkable mesh) is not handed to the engine as is: the NPC walks to the closest reachable\npoint, asks a managed door in front of it to open ([NPC passage](doors.md#npc-passage)), and\nre-checks once a second. If the way opens it walks on; if not, after about five seconds the task\nends with `failure` and reason `path_blocked`. Standing within `acceptanceRadius` + 0.5 m of an\nunreachable point counts as arrived: `success`, reason `nearest_reachable`.\n\n| Task end reason | Meaning |\n|---|---|\n| `path_blocked` | The destination stayed unreachable (the NPC stopped where the path ends), the engine tried to place the body instead of walking it three times, or a `moveTo` made no progress for five seconds three times. |\n| `nearest_reachable` | Success next to a destination that cannot itself be stood on. |\n| `report_jump` | The server refused the position the simulator reported for the task's end (the NPC could not have walked there); the task ends where the server last saw the NPC. |\n\n```lua\nlocal move = Open77.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, { speed = \"walk\" })\nAddEventHandler(\"onNpcTaskState\", function(id, taskId, status, reason)\n if tonumber(taskId) ~= move then return end\n if status == \"failure\" and reason == \"path_blocked\" then\n print((\"NPC %s cannot reach its destination\"):format(id))\n elseif status == \"success\" and reason == \"nearest_reachable\" then\n print((\"NPC %s stopped next to an unreachable point\"):format(id))\n end\nend)\n```\n\nA native command that still moves the body farther in one frame than its gait allows (the\nengine's own answer to \"no path\" is to place the body on the destination) is cancelled and undone\non the simulating client before anything about it is published. The same guard covers `patrol`,\n`wander`, `follow`, `flee`, `guard` and the walk of `enterVehicle`.","natives":["server:Open77.npcs.tasks.moveTo","server:Open77.npcs.tasks.follow","server:Open77.npcs.tasks.patrol","server:Open77.npcs.tasks.wander","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"look-hold-and-animation","heading":"Look, hold and animation","level":3,"text":"```lua\nOpen77.npcs.tasks.face(npcId, { x = 1, y = 2, z = 3 }, {\n tolerance = 3, speed = 180, timeoutMs = 5000,\n})\nOpen77.npcs.tasks.lookAt(npcId, { type = \"player\", id = playerId })\nOpen77.npcs.tasks.wait(npcId, 1500)\nOpen77.npcs.tasks.hold(npcId, { durationMs = 5000 })\nOpen77.npcs.tasks.playAnimation(npcId, \"emote_smoke\", { loop = false })\n```\n\nNamed full-body animations remain active until they are cancelled, preempted, timed out or the NPC\nstreams out. REDengine does not expose a reliable completion signal for every workspot clip, so use\n`timeoutMs` or explicit cancellation when the animation must end deterministically.","natives":["server:Open77.npcs.tasks.face","server:Open77.npcs.tasks.lookAt","server:Open77.npcs.tasks.wait","server:Open77.npcs.tasks.hold","server:Open77.npcs.tasks.playAnimation"],"permissions":[],"events":[]},{"anchor":"scenarios-workspots","heading":"Scenarios: workspots","level":3,"text":"A named animation is one clip. A **workspot** is the engine's authored scenario system -- a body\nmounted into a device that plays a whole behaviour with its own props and its own clip set. It is\nwhat the vanilla world uses for a ped smoking on a corner or sitting on a kerb, and it is what\n`tasks.workspot` drives.\n\n```lua\nfor _, profile in ipairs(Open77.npcs.tasks.workspots()) do\n print(profile.id, profile.label, profile.category)\nend\n\nOpen77.npcs.tasks.workspot(npcId, \"smoke\", { durationMs = 8000 })\nOpen77.npcs.tasks.workspot(npcId, \"sit\", { loop = true })\n```\n\nThe catalogue is the twelve shipped RP profiles -- `smoke`, `cigar`, `drink`, `phone`, `dance`,\n`handsup`, `meditate`, `sit`, `clap`, `cry`, `think`, `stretch` -- and it is the **same list**\n`Open77.animations.list()` offers for players. One catalogue, two callers. Each entry names the\nvanilla `.workspot` resource it plays, so a profile is traceable back to the authored asset rather\nthan to a clip name someone typed.\n\nA reference that is not a catalogue id is played as a **raw clip** through the generic device, the\nsame path `playAnimation` uses. That is the escape hatch and it is not the supported form: a raw\nclip's animset may not be loaded by the generic device, in which case the engine simply does not\nplay it.\n\n| Option | |\n|---|---|\n| `durationMs` | ends the scenario by **succeeding**. A scenario that ran its course is not a failure, which is why this is not `timeoutMs` |\n| `loop` | `true` has no end; the task runs until cancelled or preempted |\n| `clip` | a specific clip from the profile's set, instead of its default |\n\n| Reason | Meaning |\n|---|---|\n| `npc_task_invalid_workspot` | the reference was not a string, or a profile was named together with a clip name |\n| `npc_task_invalid_duration` | `durationMs` was negative or above one hour |","natives":["server:Open77.npcs.tasks.workspots","server:Open77.npcs.tasks.workspot"],"permissions":[],"events":[]},{"anchor":"getting-into-a-vehicle","heading":"Getting into a vehicle","level":3,"text":"```lua\nOpen77.npcs.tasks.enterVehicle(npcId, vehicleId, \"driver\")\nOpen77.npcs.tasks.enterVehicle(npcId, vehicleId, 0, { warp = true })\nOpen77.npcs.tasks.exitVehicle(npcId)\n```\n\nMovement channel -- walking to a car is locomotion, so it replaces a standing `moveTo` rather than\ncomposing with it. Seats use the `Open77.vehicles.seats` numbering (`-1` driver, `0` front\npassenger, `1` rear left, `2` rear right) and also accept `driver`, `frontPassenger`, `rearLeft`,\n`rearRight` by name.\n\nThe NPC approaches to within `approachDistance` (default 3 m), then mounts. `warp = true` skips the approach. The task reports `executing` until the native mounting relation confirms the NPC is seated.\n\n`exitVehicle` is idempotent: a body already out of the car has done what was asked and the task\nsucceeds with reason `not_in_vehicle`. Passing `vehicleId` narrows it to one car, and a body\nsitting in a different one then fails with `vehicle_mismatch` instead of being pulled out of it.\n\n| Reason | Meaning |\n|---|---|\n| `npc_task_invalid_target` | no `vehicleId`, or zero |\n| `npc_task_invalid_seat` | outside `-1..2`, or an unknown seat name |\n| `vehicle_not_streamed` | no client has that car streamed in, so there is nothing to walk to |\n| `vehicle_mount_refused` | the engine refused the seat |","natives":["server:Open77.npcs.tasks.enterVehicle","server:Open77.npcs.tasks.exitVehicle"],"permissions":[],"events":[]},{"anchor":"driving","heading":"Driving","level":3,"text":"An NPC that is the attached driver of a vehicle can be given driving orders **by ped id**:\n\n```lua\nOpen77.vehicles.ai.attachDriver(vehicleId, { npcId = npcId })\n\nOpen77.npcs.tasks.driveTo(npcId, { x = 100, y = 200, z = 10 }, { speed = 20, style = \"aggressive\" })\nOpen77.npcs.tasks.driveWander(npcId)\nOpen77.npcs.tasks.chase(npcId, { playerId = source })\nOpen77.npcs.tasks.setDriverAbility(npcId, 0.9)\nOpen77.npcs.tasks.stopDriving(npcId)\n```\n\nThese are **not** a second driving system. They resolve the vehicle the NPC drives and forward to\n[`Open77.vehicles.ai`](server-api.md), which owns the vanilla\n`AIVehicleDriveToPointAutonomousCommand` pipeline; two stacks that could disagree about where a car\nis going would be worse than one. Every one of them returns `nil, \"npc_not_driving\"` until the NPC\nis actually attached as a driver, and returns the driving state when it is.\n\nTwo things do not map from GTA and are named rather than faked:\n\n* **`driveWander` is `joinTraffic`**, the engine's own lane-following mode. Cyberpunk traffic AI\n drives authored lanes; there is no free-roam wander to expose.\n* **`setDriverAbility` is a mapping, not a skill parameter.** 2.31 has no driver-ability field, so a\n 0..1 ability is mapped onto the two levers that do exist: below `0.34` is `cautious`, above `0.67`\n is `aggressive`, otherwise `normal`; the speed cap becomes `8 + ability * 24` m/s. It returns the\n resulting driving state so a caller reads what was applied instead of assuming.\n\n`attachDriver` refuses an NPC that holds any live task (`driver_npc_has_tasks`), so finish or cancel\nan `enterVehicle` before attaching.","natives":["server:Open77.vehicles.ai.attachDriver","server:Open77.npcs.tasks.driveTo","server:Open77.npcs.tasks.driveWander","server:Open77.npcs.tasks.chase","server:Open77.npcs.tasks.setDriverAbility","server:Open77.npcs.tasks.stopDriving"],"permissions":[],"events":[]},{"anchor":"generic-queue-and-cancellation","heading":"Generic queue and cancellation","level":3,"text":"```lua\nlocal taskId = Open77.npcs.tasks.enqueue(npcId, \"moveTo\", {\n x = 1, y = 2, z = 3, speed = \"walk\",\n}, { channel = Open77.npcs.channels.movement, priority = 10, timeoutMs = 30000 })\n\nlocal task = Open77.npcs.tasks.get(npcId, taskId)\nlocal tasks = Open77.npcs.tasks.all(npcId)\nOpen77.npcs.tasks.cancel(npcId, taskId, \"script_cancel\")\nOpen77.npcs.tasks.clear(npcId, Open77.npcs.channels.movement, \"new_route\")\n```\n\nOnly the task names listed above are accepted. Unsupported types and malformed targets, paths,\ndurations or animation names are rejected before replication.\n\nThe five driving verbs are the exception to the shape above: they are commands, not queued tasks,\nbecause the job they drive lives in the vehicle AI service and already has its own lifecycle. They\nreturn a driving state rather than a task id.","natives":["server:Open77.npcs.tasks.enqueue","server:Open77.npcs.tasks.get","server:Open77.npcs.tasks.all","server:Open77.npcs.tasks.cancel","server:Open77.npcs.tasks.clear"],"permissions":[],"events":[]},{"anchor":"task-api-reference","heading":"Task API reference","level":3,"text":"| Function | Parameters | Return |\n|---|---|---|\n| `Open77.npcs.tasks.enqueue` | `npcId, type, parameters, optional options` | task ID |\n| `Open77.npcs.tasks.moveTo` | `npcId, position, optional options` | task ID |\n| `Open77.npcs.tasks.follow` | `npcId, target, optional options` | task ID |\n| `Open77.npcs.tasks.patrol` | `npcId, points, optional options` | task ID |\n| `Open77.npcs.tasks.wander` | `npcId, optional options` | task ID |\n| `Open77.npcs.tasks.face` | `npcId, target, optional options` | task ID |\n| `Open77.npcs.tasks.lookAt` | `npcId, target, optional options` | task ID |\n| `Open77.npcs.tasks.wait` | `npcId, durationMs, optional options` | task ID |\n| `Open77.npcs.tasks.hold` | `npcId, optional options` | task ID |\n| `Open77.npcs.tasks.playAnimation` | `npcId, animation, optional options` | task ID |\n| `Open77.npcs.tasks.workspot` | `npcId, profile or clip, optional options` | task ID |\n| `Open77.npcs.tasks.workspots` | `optional query` | array of catalogue profiles |\n| `Open77.npcs.tasks.enterVehicle` | `npcId, vehicleId, optional seat, optional options` | task ID |\n| `Open77.npcs.tasks.exitVehicle` | `npcId, optional options` | task ID |\n| `Open77.npcs.tasks.driving` | `npcId` | driving state or `nil, reason` |\n| `Open77.npcs.tasks.driveTo` | `npcId, position, optional options` | driving state or `nil, reason` |\n| `Open77.npcs.tasks.driveWander` | `npcId, optional options` | driving state or `nil, reason` |\n| `Open77.npcs.tasks.chase` | `npcId, target, optional options` | driving state or `nil, reason` |\n| `Open77.npcs.tasks.stopDriving` | `npcId` | driving state or `nil, reason` |\n| `Open77.npcs.tasks.setDriverAbility` | `npcId, ability` | driving state or `nil, reason` |\n| `Open77.npcs.tasks.attack` | `npcId, target, optional options` | task ID |\n| `Open77.npcs.tasks.guard` | `npcId, position, optional radius, optional options` | task ID |\n| `Open77.npcs.tasks.flee` | `npcId, from, optional options` | task ID |\n| `Open77.npcs.tasks.get` | `npcId, taskId` | task snapshot or `nil` |\n| `Open77.npcs.tasks.all` | `npcId` | array of task snapshots |\n| `Open77.npcs.tasks.cancel` | `npcId, taskId, optional reason` | boolean |\n| `Open77.npcs.tasks.clear` | `npcId, optional channel, optional reason` | number cancelled |\n\nCommon options are `priority` (signed integer) and `timeoutMs` (`0` disables the timeout). Generic\n`enqueue` also accepts `channel`. A task snapshot contains `npcId`, `id`, `resource`, `type`, the\nJSON string `parameters`, `channel`, `priority`, `timeoutMs`, `status`, `revision` and `reason`.\nStatuses are `queued`, `suspended`, `executing`, `success`, `failure`, `cancelled` and\n`interrupted`.","natives":[],"permissions":[],"events":[]},{"anchor":"attitude-and-relationship-groups","heading":"Attitude and relationship groups","level":2,"text":"`setAttitude` decides who an NPC will fight. It is **per NPC and per target**, and that is not a\ndesign preference -- it is the only shape the engine can express for one incarnation. A\n`gameAttitudeAgent` carries `SetAttitudeTowards(otherAgent, attitude)`, an override on one pair of\nagents, which is what Open77 writes. The engine's other lever, a global group matrix reached through\n`gameCAttitudeManager.SetAttitudeRelation`, is process-wide and shared with vanilla content, so\nOpen77 does not touch it; `docs/research/npc-behavior-control.md` records that measurement in full.\n\n```lua\n-- Hostile to every player and to every other NPC of this resource.\nOpen77.npcs.setAttitude(npcId, \"hostile\")\n\n-- ...except this one player, who is an ally.\nOpen77.npcs.setAttitude(npcId, \"friendly\", { towards = { playerId = playerId } })\n\n-- ...and this NPC, which it simply ignores.\nOpen77.npcs.setAttitude(npcId, \"neutral\", { towards = { npcId = otherNpcId } })\n\n-- Take the exception back; the default applies again.\nOpen77.npcs.setAttitude(npcId, nil, { towards = { playerId = playerId } })\n```\n\nRows resolve in a fixed order, most specific first:\n\n1. the per-entity row (`{ playerId = n }` or `{ npcId = n }`);\n2. the per-group row (`{ group = \"name\" }`);\n3. the relationship row declared by `setRelationship` for the two NPCs' groups;\n4. the default set by `setAttitude` with no `towards`;\n5. otherwise the NPC behaves exactly as it did before any of this existed.\n\nTwo rules are worth reading twice. An NPC that states **nothing** replicates the same loadout bytes\nit always did and is resolved by the pre-existing rules, so adding this API changed no running\nresource. And player **eligibility is a hard cap, not a default**: a dead player, or one in another\nrouting bucket, is never made a target by a directive. A directive can only ever make an NPC\nfriendlier toward an ineligible player, never hostile.\n\nAttitude survives a wholesale `setLoadout`, the way `behavior` does, because it is policy rather\nthan equipment. `combat.group` does not: it is an ordinary loadout field with pre-existing meaning.","natives":["server:Open77.npcs.setAttitude"],"permissions":[],"events":[]},{"anchor":"relationship-groups","heading":"Relationship groups","level":3,"text":"Groups are the readable way to run more than two sides. `setGroup` writes the same `combat.group`\nfield a loadout has always been able to carry -- two NPCs sharing a non-empty group are allies --\nand `setRelationship` declares what happens between two groups.\n\n```lua\nOpen77.npcs.setGroup(copId, \"police\")\nOpen77.npcs.setGroup(gangerId, \"valentinos\")\nOpen77.npcs.setRelationship(\"police\", \"valentinos\", \"hostile\")\nOpen77.npcs.setRelationship(\"police\", \"medics\", \"friendly\")\n```\n\nThe matrix is **per resource**, symmetric, and dies with the resource that declared it: one\nresource's groups never reach another's bodies, exactly as its NPC reads and writes never do. The\nresulting row is projected onto every owned NPC in either group and replicated with it, so every\nobserver resolves the pair without asking the server -- and an NPC that joins a group later picks up\nthe standing row. When two NPCs state attitudes about each other that disagree, **the more hostile\nside wins**: a one-sided friendship produces a body that is shot at and does not shoot back.","natives":["server:Open77.npcs.setGroup","server:Open77.npcs.setRelationship"],"permissions":[],"events":[]},{"anchor":"combat-tasks","heading":"Combat tasks","level":3,"text":"The combat tasks are ordinary queue entries on the existing channels, so they compose with the\nmovement and look tasks already there.\n\n```lua\n-- Engage. Action channel: a standing `moveTo` or `patrol` is NOT cancelled -- the engine\n-- suspends it for the fight and resumes it afterwards.\nOpen77.npcs.tasks.attack(npcId, { playerId = playerId })\nOpen77.npcs.tasks.attack(npcId, { npcId = rivalId }, { reacquireMs = 3000 })\n\n-- Hold a doorway. Standing order: it ends on cancel or timeout, never on its own.\nOpen77.npcs.tasks.guard(npcId, { x = 10, y = 20, z = 30 }, 12, { speed = \"run\" })\n\n-- Break contact.\nOpen77.npcs.tasks.flee(npcId, { playerId = playerId }, { distance = 60 })\n```\n\n`attack` forces the pair hostile and seeds the target into the puppet's own target tracker, then\nre-seeds on `reacquireMs`. It issues **no** locomotion or shooting command: the engine's combat AI\nowns the body from there, which is why an attacking NPC can still take cover and manoeuvre. It\nreports `success` when the target dies, streams out or is removed, and `failure` with\n`attack_target_unavailable` when the target could not be resolved at all. Cancelling drops the\nseeded threat but leaves the attitude alone -- that was a deliberate statement, and a task must not\noverwrite it.\n\n`guard` is a leash, not a patrol. Whenever the body is further than `radius` from the anchor **and\nis not already in a fight** it walks back; while it holds a hostile threat it is left entirely\nalone. It does not by itself make anything hostile: pair it with `setAttitude` or a group.\n\n`flee` re-measures a live target every cycle, so an NPC keeps breaking contact as the thing it flees\nmoves, and reports `success` once `distance` metres separate them. It drops the tracked threat on\nthat body once, which is best-effort and deliberately shallow: the attitude is untouched, so a\nstill-hostile NPC may re-acquire the same target by perception.\n`setAttitude(npcId, \"neutral\", { towards = ... })` before the flee is the composable answer.\n\n**There is no `cover` task.** Native cover commands require authored `NodeRef` positions that Open77 cannot create or enumerate. NPCs can use native cover during combat. The `guard` task uses a `moveTo` leash rather than a native guard-area command, which has the same `NodeRef` restriction.","natives":["server:Open77.npcs.tasks.attack","server:Open77.npcs.tasks.guard","server:Open77.npcs.tasks.flee"],"permissions":[],"events":[]},{"anchor":"health-damage-and-death","heading":"Health, damage and death","level":2,"text":"```lua\nOpen77.npcs.applyDamage(npcId, 25, \"resource:arena\", \"firearm\")\nOpen77.npcs.kill(npcId, \"admin\")\nOpen77.npcs.revive(npcId, 100)\n```\n\n- `mortal` allows health to reach zero.\n- `immortal` applies damage but clamps health to 1.\n- `invulnerable` rejects damage.\n\nHealth and death are canonical server state. Client hit detection should request a bounded server\naction; it must not directly mutate a projection.","natives":["server:Open77.npcs.applyDamage","server:Open77.npcs.kill","server:Open77.npcs.revive"],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"Server resource events:\n\n```lua\nAddEventHandler(\"onNpcCreated\", function(npcId, resource, template) end)\nAddEventHandler(\"onNpcUpdated\", function(npcId, revision) end)\nAddEventHandler(\"onNpcRemoved\", function(npcId, reason, resource) end)\nAddEventHandler(\"onNpcTaskState\", function(npcId, taskId, status, reason) end)\nAddEventHandler(\"onNpcAuthorityChanged\", function(npcId, playerId, epoch, reason) end)\nAddEventHandler(\"onNpcDamaged\", function(npcId, source, amount, health, cause) end)\nAddEventHandler(\"onNpcDied\", function(npcId, source, cause) end)\nAddEventHandler(\"onNpcTargetChanged\", function(npcId, kind, targetId, previousKind, previousTargetId) end)\nAddEventHandler(\"onNpcInteracted\", function(npcId, playerId, interactionId, choiceId, distance) end)\n```\n\nThe same creation and removal also reach `onEntityCreated(kind, id, resource)` and\n`onEntityRemoved(kind, id, reason)` with `kind` = `\"npc\"`, for a resource that declares\n`world.entities.observe` and wants every registry under one name. The mirror is raised by the\nsame statement as the event above it, so the two can never disagree — see\n[entity lifecycle events](server-api.md#entity-lifecycle-events).\n\n`onNpcTargetChanged` is delivered **only to the resource that owns the NPC**, unlike the six above\nit, because NPC reads are owner scoped everywhere else in this API and an event telling every\nresource which player another resource's bodyguard had just drawn on would be a read across that\nboundary. `kind` is `none`, `player` or `npc`, and `targetId` is `0` when `kind` is `none`. All five\narguments are strings, as every server resource event's arguments are.\n\n`onNpcInteracted` is a **player's** act on an NPC — a choice used on a `globalNpc`\n[interaction target](interactions.md#event-payload-and-server-authority) — and is delivered\nhost-wide like `onNpcDamaged`: the owner is not the only resource entitled to know that a player\npressed a key on its clerk. The bundled prompt reports the use to the server; the server refuses\na report from a player it cannot place, from another routing bucket, or from more than 40 m away,\nand publishes the event only for an accepted one, with `distance` being **its own** measurement\nbetween the player's last fresh snapshot and the NPC's canonical position (metres, two decimals).\n`interactionId` is the target's materialised id (`:`) and `choiceId` the\nchoice; a refused report produces no event at all, so a forged one can only be silent. Apply your\nown rule on `distance` — 40 m is the ceiling that keeps a sprinting player's stale snapshot from\nbreaking a real prompt, not the reach of your shop.\n\n```lua\n-- A vendor: one NPC per stall, opened by the prompt the interactions resource shows on it.\nlocal stalls = {} -- npcId (string) -> catalogue\n\nCreateThread(function()\n local npcId = Open77.npcs.create({ record = \"Character.Judy\", position = { x = -1378.0, y = 1262.0, z = 123.0 } })\n stalls[tostring(npcId)] = \"weapons\"\n exports.open77_interactions:define({\n { id = \"stall\", kind = \"globalNpc\", distance = 2.0, label = \"Browse\", key = \"E\", event = \"market:browse\" },\n })\nend)\n\nAddEventHandler(\"onNpcInteracted\", function(npcId, playerId, interactionId, choiceId, distance)\n local catalogue = stalls[npcId]\n if catalogue == nil or tonumber(distance) > 3.0 then return end\n TriggerClientEvent(\"market:open\", tonumber(playerId), catalogue)\nend)\n```\n\nClient resource events:\n\n```lua\nAddEventHandler(\"onNpcStreamIn\", function(npcId, revision) end)\nAddEventHandler(\"onNpcReady\", function(npcId, entity) end)\nAddEventHandler(\"onNpcSpawnFailed\", function(npcId, record, reason) end)\nAddEventHandler(\"onNpcChanged\", function(npcId, revision) end)\nAddEventHandler(\"onNpcTaskChanged\", function(npcId, taskId) end)\nAddEventHandler(\"onNpcAuthorityChanged\", function(npcId, playerId) end)\nAddEventHandler(\"onNpcStreamOut\", function(npcId, reason) end)\n```\n\n**No `RequestModel` / `HasModelLoaded` loop is needed.** Streaming is implicit; `create` returns\nbefore the body exists anywhere, `isStreamedIn(id)` says whether this client has it and `onNpcReady`\nis the transition. `Open77.npcs.whenReady` folds both into one promise, on both runtimes:\n\n```lua\n-- Client, requires npcs.read. Resolves with { id, entity } the instant isStreamedIn(id)\n-- answers true (the same instant onNpcReady fires: attached AND behaving), before the call\n-- returns when it already is; rejects with `timeout` (default 15000 ms, clamped 1..120000).\nOpen77.npcs.whenReady(npcId, 8000):next(function(body)\n Open77.animations.play(body.entity, \"wave\")\nend, function(reason) print(\"vendor never appeared: \" .. reason) end)\n\n-- Server, requires world.npcs. Resolves with the Open77.npcs.owner(id) table as soon as at\n-- least one client reports a ready projection (readyClients > 0) -- the election's own\n-- `onNpcAuthorityChanged(..., \"projectionready\")` event, with a 250 ms poll as the safety\n-- net -- and rejects with `timeout`.\nlocal guard = assert(Open77.npcs.create({ template = \"civilian_female_relaxed_01\", position = post }))\nOpen77.npcs.whenReady(guard, 10000):next(function(owner)\n Open77.npcs.tasks.patrol(guard, route, { loop = true })\nend, function(reason) print((\"guard %d never streamed: %s\"):format(guard, reason)) end)\n```\n\nThe one-shot handler is removed on resolve, on timeout and with the resource. `nil, reason` only\nfor a bad id, a bad timeout or a missing permission; an unknown id is waited for and times out.","natives":["AddEventHandler","server:AddEventHandler","CreateThread","server:CreateThread","server:Open77.npcs.create","server:TriggerClientEvent","Open77.npcs.whenReady","server:Open77.npcs.whenReady","Open77.animations.play","server:Open77.animations.play","server:Open77.npcs.owner","server:Open77.npcs.tasks.patrol"],"permissions":[],"events":[]},{"anchor":"client-read-only-api","heading":"Client read-only API","level":2,"text":"`onNpcSpawnFailed(npcId, record, reason)` is a **local diagnostic**, not a server-authoritative\nfailure. Reasons are `npc_record_invalid`, `npc_record_not_found`, `npc_record_not_character`,\n`npc_spawn_failed` and `npc_spawn_timeout` (30 seconds). A failed projection is not retried every\nframe: it remains failed until a new incarnation (stream-out/in, recreation, record/appearance\nchange). Transient spawn-service saturation is retried at most once every two seconds. Never\ntrust a client failure notification to delete canonical server state automatically.\n\n```lua\nlocal npc = Open77.npcs.get(npcId)\nlocal visible = Open77.npcs.all()\nlocal ready = Open77.npcs.isStreamedIn(npcId)\nlocal entity = Open77.npcs.entity(npcId) -- local Open77 entity handle or nil\nlocal taskId = Open77.npcs.currentTask(npcId) -- canonical current task ID or nil\n```\n\nSnapshots expose `streamed` and `locallyAuthoritative`. The local entity handle is ephemeral: do\nnot cache it across stream-out, reconnect or resource reload.\n\nClient snapshots contain `id`, `revision`, `entity`, `template`, `record`, `appearance`, `flags`, `bucket`,\n`aiMode`, `damagePolicy`, `health`, `maxHealth`, `currentTaskId`, `taskRevision`,\n`authorityPlayerId`, `authorityEpoch`, `streamed` and `locallyAuthoritative`. Without `npcs.read`,\n`all()` returns an empty array, `get()`/`entity()`/`currentTask()` return `nil`, and\n`isStreamedIn()` returns `false`.","natives":["Open77.npcs.get","server:Open77.npcs.get","Open77.npcs.all","server:Open77.npcs.all","Open77.npcs.isStreamedIn","Open77.npcs.entity","Open77.npcs.currentTask"],"permissions":[],"events":[]},{"anchor":"current-limitations","heading":"Current limitations","level":2,"text":"- Server persistence storage is resource-defined; `persistent=true` only changes cleanup policy.\n- Records outside `Character.*`, raw REDengine handles and client-side canonical mutation are\n unsupported. A valid Character record may still depend on unavailable DLC/assets or quest logic.\n- Native attack/shoot/melee/combat tasks are not in the stable API yet. Use server-authoritative\n scripted damage until animation, targeting and hit validation are proven safe for each rig.\n- Native authored patrol paths (`NodeRef`) are not exposed; Open77 patrols sequence `moveTo` tasks.\n- A client authority lease is required for native navigation. With no ready client, tasks suspend\n and resume when authority becomes available.","natives":[],"permissions":[],"events":[]}]},{"slug":"package-audio","file":"package-audio.md","title":"Package audio: 2D and spatial sounds","summary":"Play `.mp3` and PCM `.wav` files bundled with a resource, without a WebUI surface. Decoding is asynchronous. For named engine/Wwise events, see [Visual and audio effects](effects.md).","sections":[{"anchor":"package-audio-2d-and-spatial-sounds","heading":"Package audio: 2D and spatial sounds","level":1,"text":"Play `.mp3` and PCM `.wav` files bundled with a resource, without a WebUI surface. Decoding is asynchronous. For named engine/Wwise events, see [Visual and audio effects](effects.md).\n\nAvailable in client/server **2.31.13+op77.62**, protocol **1.25**. Local playback\nneeds the updated client; network playback needs both the updated client and server.\nSharing protocol 1.25 with an older build does not make these Lua APIs available there.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-and-first-sound","heading":"Manifest and first sound","level":2,"text":"```lua\nfiles { 'audio/lock.mp3', 'audio/click.wav' }\npermissions { 'audio.play', 'audio.network' }\n```\n\n`audio.play` is the client capability; `audio.network` is the server capability.\nInclude only those your scripts need. Paths must be declared in `files`, relative\nto the resource, and remain inside it. URLs, absolute paths and traversal are rejected.\n\n```lua\n-- Client: private UI sound.\nlocal id, reason = Play2DSound('audio/click.wav', {volume=0.5})\nif not id then print(reason); return end\n\n-- Client: sound at a fixed world location, heard only by this client.\nlocal lock = Play3DSound('audio/lock.mp3', {x=10,y=20,z=30},\n {volume=0.7, minDistance=1, maxDistance=15})\n```\n\nBoth return a resource-owned numeric handle, or `nil, reason`. Allocation success\ndoes not mean decoding finished: use `GetSoundState(id)` to inspect loading/errors.","natives":[],"permissions":["audio.play","audio.network"],"events":[]},{"anchor":"networked-playback","heading":"Networked playback","level":2,"text":"```lua\n-- Server: every ready player in bucket 0 can hear it within 15 metres.\nlocal id = assert(Play3DSound('audio/lock.mp3', {x=10,y=20,z=30},\n {network=true, bucket=0, volume=0.7, maxDistance=15, duration=1.2}))\n\n-- Server: private non-spatial notification for one player in their current bucket.\nlocal notice = Play2DSound('audio/click.wav', {\n network=true, target=playerId, bucket=GetPlayerRoutingBucket(playerId), duration=0.3\n})\n```\n\nServer playback is networked by default; `network=false` is rejected there.\nClient playback is local by default; `network=true` on the client returns\n`nil, 'network_audio_requires_server'`. A client cannot authorize a broadcast by\nadding an option. For a lock action, request the gameplay action on the server;\nafter validating ownership/distance/permissions, the server creates the sound.\n\nThe server owns the sound handle, timeline and audience. Late joiners receive the\ncurrent timeline rather than restarting from zero; players leaving the bucket lose\nthe sound. Pauses and seeks are included in the next reliable snapshot. Starting a\nsound may be delayed by asset decoding; the client catches up when decoding completes.\nThis is game-event synchronization, **not sample-accurate music synchronization**.\nRepeated changes are coalesced into snapshots at up to 20 Hz; a new resource that\nwas not ready for the first snapshot is retried by the periodic snapshot (up to 5s).","natives":["server:GetPlayerRoutingBucket"],"permissions":[],"events":[]},{"anchor":"playback-options","heading":"Playback options","level":2,"text":"| Option | Default | Contract |\n| --- | --- | --- |\n| `volume` | 1 | 0–1 |\n| `loop` | false | Loop entire clip |\n| `paused` | false | Allocate without starting playback |\n| `offset` | 0 | Initial seconds, finite and nonnegative |\n| `minDistance` | 1 | Full-gain radius, metres |\n| `maxDistance` | 30 | Silence radius, greater than minDistance, up to 1000m |\n| `network` | client false / server true | Runtime authority described above |\n| `bucket` | 0 | Server audience routing bucket |\n| `target` | 0 | Server-only optional player ID; bucket still applies |\n| `duration` | 600 | Server timeline length in seconds, greater than 0 and at most 600; **required for loops** |\n\nUse the actual clip duration for server loops to keep late-join phase consistent.\nFor non-looped sounds, supplying the actual duration also makes the server's ended\nstate meaningful; without it, the server cannot know the decoder's media length.\nClient `getState().duration` is the actual decoded duration.\n\nSpatial sounds use distance attenuation and camera-relative stereo panning. Stereo\nassets are downmixed to a point source. No automatic wall occlusion, HRTF, Doppler,\nvehicle attachment or environmental reverb is implied. Update the position through\n`SetSoundPosition` when a source moves; that does not restart the sound.","natives":[],"permissions":[],"events":[]},{"anchor":"handles-and-playback-control","heading":"Handles and playback control","level":2,"text":"| Global | `Open77.audio` method | Arguments |\n| --- | --- | --- |\n| `Play2DSound` | `play2D` | asset, options? |\n| `Play3DSound` | `play3D` | asset, position, options? |\n| `PlaySound` | `play` | id — resume; restart an ended local clip |\n| `PauseSound` | `pause` | id — preserve cursor |\n| `StopSound` | `stop` | id — stop and rewind |\n| `SeekSound` | `seek` | id, seconds |\n| `SetSoundVolume` | `setVolume` | id, volume |\n| `SetSoundPosition` | `setPosition` | id, `{x,y,z}` — spatial only |\n| `GetSoundState` | `getState` | id |\n| `DestroySound` | `destroy` | id — release permanently |\n\nControls return `true` on success, or a failure plus reason. Local and server handles\nbelong to different runtimes; never pass a local handle to a server expecting it to\nidentify a network sound. Other resources cannot mutate a handle they do not own.\n\n```lua\nlocal state, why = GetSoundState(id)\nif state then\n print(state.status, state.position, state.duration, state.error)\nend\nassert(PauseSound(id))\nassert(SeekSound(id, 0.2))\nassert(PlaySound(id))\n-- Release after use. Do not allocate a new sound every frame.\nassert(DestroySound(id))\n```\n\nClient states: `loading`, `playing`, `paused`, `ended`, `error`. `cached` indicates\nreuse of decoded PCM; `error` contains a stable diagnostic code, not a filesystem\npath. Server state describes desired playback, not an acknowledgement that every\nclient has working speakers or decoded the asset.","natives":["GetSoundState","server:GetSoundState","PauseSound","server:PauseSound","SeekSound","server:SeekSound","PlaySound","server:PlaySound","DestroySound","server:DestroySound"],"permissions":[],"events":[]},{"anchor":"limits-and-lifecycle","heading":"Limits and lifecycle","level":2,"text":"- 16 handles per resource/owner and 64 overall, local and network sharing the client\n budget. Finished local handles remain reusable until destroyed or resource stop.\n- 16 MiB encoded file, 32 MiB decoded PCM per clip, up to 600 seconds; mono/stereo\n PCM16 at 8–96 kHz. Windows Media Foundation must support the decoder.\n- Bounded decoded cache and active-voice memory budget. Decode failures are visible;\n invalid audio cannot hold a Lua VM or the game thread waiting.\n- Resource stop, disconnect and game-world teardown release owned playback. Server\n non-looping sounds are collected 30 seconds after their configured duration ends.","natives":[],"permissions":[],"events":[]},{"anchor":"compatibility-limits","heading":"Compatibility limits","level":2,"text":"Output-device recovery is not automatic after an audio output failure. Decoder and device support depends on the installed Windows media components.","natives":[],"permissions":[],"events":[]}]},{"slug":"pause-menu","file":"pause-menu.md","title":"Pause menu customization","summary":"Use `Open77.pauseMenu` from a **client resource** to give the OPEN//77 Escape menu your server's accent color and logo. The server logo appears **beside the OPEN//77 logo**, never instead of it. Menu actions remain unchanged.","sections":[{"anchor":"pause-menu-customization","heading":"Pause menu customization","level":1,"text":"Use `Open77.pauseMenu` from a **client resource** to give the OPEN//77 Escape menu your server's accent color and logo. The server logo appears **beside the OPEN//77 logo**, never instead of it. Menu actions remain unchanged.\n\nRequires client **2.31.13+op77.85** or later. No server binary update is required. These settings do not change the native map, HUD, launcher or website branding.","natives":[],"permissions":[],"events":[]},{"anchor":"quick-start","heading":"Quick start","level":2,"text":"Create a resource with this structure, then enable it in your [server resources](server-resources.md):\n\n```text\nserver_branding/\n open77.lua\n client.lua\n assets/\n server-logo.png\n```\n\nIn `open77.lua`, declare the permission, logo and downloadable asset:\n\n```lua\nresource \"server_branding\"\nversion \"1.0.0\"\nauto_start true\n\nclient_script \"client.lua\"\npermissions { \"pause_menu.customize\" }\n\npause_menu_logo \"assets/server-logo.png\"\nfiles { \"assets/server-logo.png\" }\n```\n\nIn `client.lua`, set the accent:\n\n```lua\nlocal ok, reason = Open77.pauseMenu.setAccentColor(\"#FFCC00\")\nif not ok then\n print(\"Pause menu branding: \" .. tostring(reason))\nend\n```\n\nThe manifest supplies the logo without a `setLogo()` call. The accent applies to menu highlights, keyboard focus and settings controls. Updates appear on the next menu refresh, within one second while the menu is open.","natives":["Open77.pauseMenu.setAccentColor"],"permissions":[],"events":[]},{"anchor":"logo-sources","heading":"Logo sources","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"resource-asset","heading":"Resource asset","level":3,"text":"`pause_menu_logo` accepts a path relative to its own resource. Declare that file in `files` or `web_files` so the server includes it in the client download.\n\nLocal images support **PNG, JPEG and WebP**, up to **1 MiB**. SVG is not supported for local assets. Use a transparent PNG or WebP for a logo without a rectangular background. Wide logos work well; the menu preserves the image's proportions.\n\nAbsolute filesystem paths, traversal such as `../`, and paths into another resource are refused.","natives":[],"permissions":[],"events":[]},{"anchor":"image-url","heading":"Image URL","level":3,"text":"Replace the local logo directive with a direct HTTP(S) image URL:\n\n```lua\npause_menu_logo \"https://cdn.example.com/server-logo.png\"\n```\n\nThe resource still needs `pause_menu.customize`, but the remote image needs no `files` entry. The URL must return an image, not a website or sharing page. HTTPS is recommended. TLS, browser mixed-content restrictions and the player's WebUI blocklist still apply; no referrer is sent.\n\nIf an image cannot load, its logo slot is hidden and a diagnostic is logged. The OPEN//77 logo and menu remain available. A successful `setLogo()` validates the reference; it does not guarantee that a remote image will finish downloading.","natives":[],"permissions":[],"events":[]},{"anchor":"lua-functions","heading":"Lua functions","level":2,"text":"All functions below belong to `Open77.pauseMenu` and run on the client.\n\n| Function | Behavior |\n| --- | --- |\n| `setAccentColor(\"#RRGGBB\")` | Assign this resource's accent. Use exactly six hexadecimal digits; short hex and CSS color names are not accepted. |\n| `setLogo(pathOrUrl)` | Assign or override this resource's logo using the same path and URL rules as the manifest. |\n| `setAccentColor(nil)` | Release this resource's accent only. An empty string also releases it. |\n| `setLogo(nil)` | Release this resource's logo only. An empty string also releases it. |\n| `resetAppearance()` | Release both settings owned by this resource, including its manifest logo. |\n| `getAppearance([knownRevision])` | Read the effective `{ revision, accentColor, logo }`. If the revision is unchanged, return `nil` without an error. |\n\nSetters return `true`, or `nil, reason` on failure. Changing or resetting the appearance requires `pause_menu.customize`. The read-only `getAppearance()` does not require this permission.","natives":[],"permissions":[],"events":[]},{"anchor":"change-branding-while-running","heading":"Change branding while running","level":3,"text":"```lua\nlocal ok, reason = Open77.pauseMenu.setLogo(\"assets/server-logo.png\")\nif not ok then\n print(\"Logo: \" .. tostring(reason))\nend\n\n-- Or use a remote image.\n-- Open77.pauseMenu.setLogo(\"https://cdn.example.com/event-logo.png\")\n\nOpen77.pauseMenu.setAccentColor(\"#A855F7\")\n```","natives":["Open77.pauseMenu.setLogo","Open77.pauseMenu.setAccentColor"],"permissions":[],"events":[]},{"anchor":"release-your-customization","heading":"Release your customization","level":3,"text":"```lua\n-- Release both fields owned by this resource.\nOpen77.pauseMenu.resetAppearance()\n\n-- Or release one field, retaining the other.\n-- Open77.pauseMenu.setLogo(nil)\n-- Open77.pauseMenu.setAccentColor(nil)\n```\n\nResetting your resource does not delete another resource's branding. The effective appearance falls back to the previous active owner, or the default cyan (`#22D8E2`) and no server logo if there is no other owner.","natives":["Open77.pauseMenu.resetAppearance","Open77.pauseMenu.setLogo","Open77.pauseMenu.setAccentColor"],"permissions":[],"events":[]},{"anchor":"read-the-current-appearance","heading":"Read the current appearance","level":3,"text":"```lua\nlocal appearance, reason = Open77.pauseMenu.getAppearance()\nif appearance then\n print(\"Accent: \" .. appearance.accentColor)\n local revision = appearance.revision\n\n -- A later read can skip an unchanged result.\n local changed, readError = Open77.pauseMenu.getAppearance(revision)\n if readError then\n print(readError)\n elseif changed then\n print(\"New accent: \" .. changed.accentColor)\n end\nelseif reason then\n print(reason)\nend\n```\n\n`logo` is an empty string when no server logo is selected, an HTTP(S) URL for a remote image, or a potentially large image data URI for a local asset. Avoid logging or repeatedly forwarding the whole value.","natives":["Open77.pauseMenu.getAppearance"],"permissions":[],"events":[]},{"anchor":"ownership-and-lifecycle","heading":"Ownership and lifecycle","level":2,"text":"The most recent active assignment wins **per field**: one resource can own the accent while another owns the logo. Keep both in one branding resource when they should change together.\n\nTop-level customization calls are staged until the resource starts successfully. A failed start or reload cannot replace the active appearance. Stopping or reloading a resource, or disconnecting, releases its claims. A successful restart reapplies `pause_menu_logo`; `resetAppearance()` is not a permanent edit to the manifest.","natives":[],"permissions":[],"events":[]},{"anchor":"troubleshooting","heading":"Troubleshooting","level":2,"text":"| Error or symptom | What to check |\n| --- | --- |\n| `permission_denied:pause_menu.customize` | Add the permission to the resource manifest. It is required for the manifest logo too. |\n| `invalid_accent` | Use a string such as `#FFCC00`, not `#FC0`, `yellow` or an RGB table. |\n| `invalid_branding_value` | Pass a string, `nil` or an empty string, not a table or number. |\n| `logo_not_declared` | Add the local file to `files` or `web_files`. |\n| `invalid_logo_url_or_path` / `invalid_logo_path` | Use a valid HTTP(S) URL or a safe path inside the owning resource. |\n| `invalid_logo_image_use_png_jpeg_webp` | Check the local image's format, extension and contents. |\n| `pause_menu_unavailable` | Use a client resource with the supported client, not a server script. |\n| Lua reports success but the logo is hidden | Check the client log, remote URL, image response and WebUI blocklist. |\n| A different color or logo appears | Another active resource may have made a more recent assignment. |\n\nA malformed manifest logo or missing permission can prevent the resource from activating. Correct the manifest or declared asset before retrying.","natives":[],"permissions":[],"events":[]},{"anchor":"related-guides","heading":"Related guides","level":2,"text":"- [Server branding](server-branding.md): launcher and website icon/banner.\n- [Native map](native-map.md): map title, accent and custom tabs.\n- [WebUI keyboard input](webui-input.md): consume Escape in your own UI.\n- [Resource runtime](resource-runtime.md): manifests, permissions and lifecycle.","natives":[],"permissions":[],"events":[]}]},{"slug":"perspective","file":"perspective.md","title":"Third person","summary":"Open77's third-person view renders a local character body using the replication system. The player entity retains gameplay authority: aiming, hit detection and network state are unchanged.","sections":[{"anchor":"third-person","heading":"Third person","level":1,"text":"Open77's third-person view renders a local character body using the replication system. The player entity retains gameplay authority: aiming, hit detection and network state are unchanged.\n\nNothing changes for other players. The stand-in body is local: nobody else ever sees it, and the\nsame bytes leave your client whether you are in first or third person.\n\n```lua\npermissions { \"perspective.policy\" } -- server policy, or a client-side forced view\n```","natives":[],"permissions":["perspective.policy"],"events":[]},{"anchor":"hide-the-local-avatar","heading":"Hide the local avatar","level":2,"text":"Use [setLocalPuppetVisible](local-puppet-visibility.md) to hide the body and held\nweapon without leaving third person. The same request keeps the native body\nhidden when switching to first person. Server `Open77.players.setVisible`\ncontrols observer visibility separately.","natives":[],"permissions":[],"events":[]},{"anchor":"the-players-side","heading":"The player's side","level":2,"text":"| | |\n|---|---|\n| Key | `f6` by default, reassignable, and remembered. `f7` also works and always will: it lives in the client itself, so it toggles the view even on a server that ships no perspective resource at all. Rebinding `f7` is not possible without a new build. |\n| Look around without turning | **Hold the middle mouse button.** The body freezes its facing where it stood and the camera swings around it, so you can look behind yourself while still walking forward. Let go and the body catches up. Your aim is unaffected — the character keeps facing wherever the mouse points, so the shot goes where the reticle is, not where the body appears to look. |\n| Remembered per server | The preference is stored per connection address, so two servers keep two independent choices. |\n| Survives | Reconnecting, a resource reload, restarting the game. |\n| While a panel has the keyboard | The key does nothing while chat, the pause menu or an admin surface owns input, so it can never fire underneath a text field. |\n\nA player who has never asked gets first person.","natives":[],"permissions":[],"events":[]},{"anchor":"enable-disable-or-temporarily-force-a-view-client","heading":"Enable, disable or temporarily force a view (client)","level":2,"text":"Requires the client update introducing `setThirdPerson` and\n`clearThirdPersonOverride`. On older clients, check that the function exists\nbefore calling it. Configuring a [camera style](third-person-camera.md) does\nnot enable or lock the perspective by itself.\n\n```lua\n-- Ordinary switch: the player can still change the view with F7.\nOpen77.perspective.setThirdPerson(true) -- request third person\nOpen77.perspective.setThirdPerson(false) -- request first person\n\n-- Requires permissions { \"perspective.policy\" } in the resource manifest.\nOpen77.perspective.setThirdPerson(true, true) -- force third person, block F7 -> FPP\nOpen77.perspective.setThirdPerson(false, true) -- force first person, block F7 -> TPP\n\n-- Release only this resource's lock, keeping the player's previous preference.\nOpen77.perspective.clearThirdPersonOverride()\n```\n\n`setThirdPerson(enabled[, force])` takes strict booleans (`force=false` by\ndefault). It returns `true` when the request is accepted, or `false, reason`.\n`force=false` also releases this resource's previous lock before requesting the\nnew view. It does **not** unlock another resource's lock or override server\npolicy. To forbid third person rather than merely switch away, use\n`setThirdPerson(false, true)`.\n\nA forced view is temporary and exclusive to the requesting resource instance.\nAnother resource's forced request returns `perspective_owned`. Ordinary\nopposing requests (including F7) are refused without changing the pre-lock\npreference. `clearThirdPersonOverride()` is idempotent, needs no capability,\nand cannot clear someone else's lock. Stop/reload, coroutine error and host\nteardown release the owner's lock automatically; the selected camera **style**\nand the server policy are separate and are not changed by this API.\n\nWorld safety and native vehicle/cutscene cameras, then the server's\n`disabled`/`forced` policy, take priority over this local override. A conflicting\nserver policy rejects a new lock with `refused_by_policy`. If the server policy\nchanges while a lock exists, the policy wins; the still-owned lock resumes when\nthe policy permits it again. No Lua per-frame loop is needed.\n\n`Open77.perspective.state()` includes `resourceForced` and\n`resourcePerspective` (`\"tps\"`, `\"fpp\"`, or `\"none\"`). These describe the\nrequested lock, not a guarantee that it currently owns a vehicle/cutscene\ncamera; inspect `mode`, `camera`, `reason` and `settled` for the actual view.\nAn active local lock reports `reason=\"resource_forced\"` and `allowed=false`.\n\nOther errors include `invalid_perspective_arguments`,\n`permission_denied:perspective.policy` (forced calls only), and\n`perspective_unavailable_on_this_host`. The API is client-local: use the existing\nserver `setPolicy` API below for all players, or a server event to ask a selected\nclient resource to apply a local lock. It is not a networked player animation.","natives":["Open77.perspective.setThirdPerson","Open77.perspective.clearThirdPersonOverride"],"permissions":["perspective.policy"],"events":[]},{"anchor":"server-policy","heading":"Server policy","level":2,"text":"Four values. The platform default is `allowed`, and a server whose resources declare nothing\nsends nothing — every client simply keeps `allowed`. That is the intended behaviour, not a\nfallback.\n\n| Policy | Meaning |\n|---|---|\n| `disabled` | Third person is not available here. |\n| `allowed` | The player chooses. Unasked players get first person. |\n| `default` | The player chooses. **As shipped this behaves exactly like `allowed`** — the unasked default it is supposed to change is a build-time constant nothing writes yet, so it is still first person. Pass `default` if you want to state the intent; do not rely on it to put anyone in third person. |\n| `forced` | The server pins a view. The player's own request is still **remembered**, so lifting the pin gives them back what they wanted rather than dropping them somewhere arbitrary. |\n\n```lua\n-- server_script; the manifest needs \"network.events\" and \"perspective.policy\"\nAddEventHandler(\"onResourceStart\", function(name)\n if name ~= GetCurrentResourceName() then return end\n Open77.perspective.setPolicy(\"allowed\")\nend)\n\n-- A stealth round where the camera must not see around corners:\nOpen77.perspective.setPolicy(\"forced\", \"fpp\")\n\n-- Back to the players' choice, third person for anyone who never asked:\nOpen77.perspective.setPolicy(\"default\", \"tps\")\n```\n\nCall it from the resource's **start path**. A reload replaces the VM and the policy lives in that\nVM, so a resource that declared its policy from somewhere else comes back declaring nothing.\n\n**Let one resource own the policy.** If two declare one, both answer a joining client and the last\npacket wins — the same caveat `Open77.notifications` carries.","natives":["AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName","server:Open77.perspective.setPolicy"],"permissions":[],"events":[]},{"anchor":"when-the-view-hands-itself-back","heading":"When the view hands itself back","level":2,"text":"Third person is not a mode that overrides everything. Several world states take the view back\nautomatically, and give it back afterwards. This is by design: the vanilla body and the native\ncameras are the fallback, and they are always allowed to win.\n\n| State | What happens |\n|---|---|\n| In a vehicle | The engine owns the view, and it obeys the player's own vanilla preference. |\n| Scenes and scripted sequences | Native camera. |\n| Photo mode | Native camera. |\n| Death and ragdoll | Native camera. |\n| Sliding, and vaulting | First person for the length of the move, then straight back. |\n| The pre-game UI, including character creation | The engine owns the screen for as long as it needs it. |\n\nEach handover returns the player's real body and every effect that belongs to it — muzzle flash,\nshells, footsteps, audio — without the player doing anything.\n\nSliding can hand the view back to first person when the third-person rig has no compatible slide clip. This changes presentation only, not movement or player state.\n\nVaulting is on the list for the same kind of reason and has never come off it.\n\nThe search for a real slide clip is closed rather than merely unfinished: the shipped game has no\nslide animation authored for this body, in its data, in its animation catalogue, or in its\nscripts. If one is ever added, the handover comes off again — the machinery that drives it is\nstill in place and still running.","natives":[],"permissions":[],"events":[]},{"anchor":"the-crosshair","heading":"The crosshair","level":2,"text":"**Third person draws its own.** The vanilla reticle belongs to the weapon's HUD and is anchored to\nthe first-person aiming camera, so with a third-person camera it has nothing to anchor to and is\nsimply not drawn — which is why aiming with a gun used to leave the screen with no crosshair at\nall. The client bootstrap [`open77_reticle`](https://github.com/Open2077/open77-resources/tree/main/system/open77_reticle) draws a centred overlay while aiming with a\nweapon in third person, including native F7 on servers without [`open77_perspective`](https://github.com/Open2077/open77-resources/tree/main/system/open77_perspective).\nIt follows the game's aiming state, not a hard-coded mouse button. It hides when\naim is released, the weapon is holstered, a menu captures input, or an engine/first-person\ncamera takes over. Vanilla hip-fire and first-person HUD behaviour are unchanged.\nThe overlay takes no clicks and cannot swallow input.","natives":[],"permissions":[],"events":[]},{"anchor":"reading-the-aim-from-a-resource","heading":"Reading the aim from a resource","level":2,"text":"`Open77.character` answers what the player is doing with a weapon, and what is in front of it. It\nworks the same in both perspectives.\n\n| Call | Answers |\n|---|---|\n| `Open77.character.isAiming()` | Aiming down sights. `boolean`, or `nil, reason`. |\n| `Open77.character.isFiring()` | The trigger is **held** — the `IsPedShooting` equivalent, not a shot event. |\n| `Open77.character.aimState()` | `aiming`, `firing`, `weaponDrawn` and `stateSequence`, in one snapshot. |\n| `Open77.character.aimedEntity()` | What is under the crosshair, or a bare `nil` for scenery. |\n\nThe first three require the `player.aim.read` permission. `aimedEntity()` requires **both**\n`player.aim.read` and `world.query`: where the player is looking is a fact about the player, and\nwhat is there is a world entity read, so granting the first must not quietly hand out the second.\n\n```lua\npermissions { \"player.aim.read\", \"world.query\" }\n```","natives":[],"permissions":["player.aim.read","world.query"],"events":[]},{"anchor":"do-not-read-perspectivestateaiming-for-this","heading":"Do not read `perspective.state().aiming` for this","level":3,"text":"That field exists and is not the same thing. It has exactly two writers: a trigger pull, and the\nthird-person self-view's per-frame gate — and the second is skipped entirely whenever the\nself-view session is not active, which is to say **in first person nothing writes it at all**. A\nresource reading it in FPP sees whatever the last shot said, for as long as the player does not\nshoot again. It was published so a player refused a hip-fire shot could see why; it is not an aim\npoll, and it never was one.\n\n`Open77.character.isAiming()` reads the player's own weapon animation graph instead. The call costs\nno raycast, advances no sequence and disturbs no ballistics.","natives":[],"permissions":[],"events":[]},{"anchor":"aiming-is-a-latched-mirror-and-statesequence-is-how-you-tell","heading":"`aiming` is a latched mirror, and `stateSequence` is how you tell","level":3,"text":"Nothing samples the aim on a timer. The observer records whatever the animation graph last pushed,\nand between two pushes the bit simply keeps its previous value. So `aiming == true` on its own\ncannot distinguish *aiming right now* from *the last thing this feature ever said was aim, some\nminutes ago* — and the second reads exactly like the defect where an aim pose never comes down.\n\n`aimState().stateSequence` rises on every observed change. Difference it across two reads and the\nquestion is settled:\n\n```lua\nlocal before = Open77.character.aimState()\nWait(500)\nlocal after = Open77.character.aimState()\nif after.aiming and after.stateSequence == before.stateSequence then\n -- held for at least half a second, or the graph has gone quiet\nend\n```\n\nIt is evidence, never a gate. Nothing times an aim out, because a player may hold one as long as\nthey like.","natives":["Open77.character.aimState","Wait","server:Wait"],"permissions":[],"events":[]},{"anchor":"what-aimedentity-answers-with","heading":"What `aimedEntity()` answers with","level":3,"text":"The engine's own look-at target, not a physics ray — a physics ray answers with geometry and carries\nno entity reference at all. Three outcomes, kept apart on purpose:\n\n| Result | Meaning |\n|---|---|\n| a table | Something is under the crosshair. |\n| a **bare** `nil`, no second value | Pointing at scenery, or at nothing. An answer. |\n| `nil, reason` | The question could not be asked — permission, or no target system. |\n\nThe table carries `engineEntity`, `className`, `kind`, `family`, `position` and `distance`, plus\n`entity` and one of `playerId` / `vehicleId` / `npcId` when the thing is an Open77 body. `family` is\nthe raw class family and `kind` is that family refined by ownership — the same refinement\n`Open77.camera.aimRay({ entities = true })` applies, from the same classifier, so a vanilla crowd\nped reads `populationNpc` through both and a server-spawned one reads `npc`. A resource branching on\n`kind == \"npc\"` therefore never acts on a body the server does not own.\n\n```lua\nlocal target = Open77.character.aimedEntity()\nif target == nil then return end -- nothing there\nif target.kind == \"player\" then\n reportAimedAt(target.playerId)\nelseif target.kind == \"trafficVehicle\" then\n -- vanilla traffic: no vehicleId, and no server write will reach it\nend\n```","natives":["Open77.character.aimedEntity"],"permissions":[],"events":[]},{"anchor":"limitations","heading":"Limitations","level":2,"text":"Known presentation limitations on Cyberpunk 2077 2.31:\n\nThe firing weapon and the weapon drawn on the third-person body can have different muzzle positions. Damage and native effects use the actual firing weapon; the displayed weapon can appear offset.\n\n**You have to be aiming for the body to raise its weapon.** Press the trigger from the hip and the stand-in keeps the gun down. This is a rule now, and it was not always one: for a while the firing pose was withheld because the stand-in could not resolve which weapon family it was holding, and a withheld pose looks exactly like a forbidden shot. Repairing the family resolution took the block away with the defect -- which is how you learn a behaviour was an accident. It was asked for back, so it is written down where it will survive the next repair. What it withholds is the *pose*: the shot still fires, still costs ammo, and still cannot cross cover that blocks the barrel -- that gate is separate and unchanged.\n\nAnimations without a compatible clip are suppressed on the third-person rig to avoid displaying its bind pose.\n\n**The body does not crouch.** The camera anchor does dip, so the framing is right while the body\nis not.\n\n**Interaction prompts follow the camera, not the body.** The engine decides which interaction to\noffer from where the *camera* is looking, and in third person the camera sits about three metres\nbehind you. Standing against a car door does not necessarily offer it. A body-anchored prompt for\nvehicle entry exists and ships **disabled**; the engine's own prompt is unchanged.\n\n**Two things this view will not do.** Cinematics, braindance, finishers, cyberware animations,\nphoto mode and mirrors are out of scope by design and fall back cleanly to the native camera. And\nthe toggle is client-local: a server can set a policy, but it cannot watch a specific player's\ncurrent view.\n\nSteep downward aim can displace the camera framing, particularly near -80 degrees. The body remains visible, but the requested framing is not guaranteed.\n\n**Your character wears what your character owns.** A body that owns no clothing appears in\nunderwear, because that is what it is wearing — a V straight out of the creator wears exactly\nthat, and first person never shows it. Dress the character and the third-person body follows.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"| | |\n|---|---|\n| [Vehicles](vehicles.md) | Seats, authority and the vehicle APIs. |\n| [Interactions](interactions.md) | Prompts, choices and layers. |\n| [Chat](chat.md) | Input focus, which the perspective key shares. |","natives":[],"permissions":[],"events":[]},{"anchor":"aiming-here-is-the-older-looser-read","heading":"`aiming` here is the older, looser read","level":2,"text":"`perspective.state()` carries an `aiming` field and costs no capability, while\n`Open77.character.isAiming()` answers the same question behind\n`player.aim.read`. Both spellings ship, and which one you should use is not a\nmatter of taste.\n\n**Prefer `Open77.character.isAiming()`.** The field here has two writers -- a\ntrigger pull, and the third-person self-view's per-frame gate -- and the second\nis short-circuited away whenever that session is inactive, so **in first person\nnothing writes it**. A resource reading it in first person sees whatever the\nlast shot said until the player shoots again. The gated read observes combat\ndirectly and does not have that hole.\n\nThe older field stays ungated because it already shipped that way, and\ntightening a surface underneath resources that already call it would break\ninstalls to fix an inconsistency nobody is exploiting. The newer read is gated\nbecause a new surface starts closed: relaxing a capability later costs nothing,\nand adding one later breaks every manifest that was written without it.","natives":[],"permissions":[],"events":[]}]},{"slug":"photo-mode","file":"photo-mode.md","title":"Photo mode","summary":"Control native photo mode through the client-only `Open77.photoMode` API. Vanilla keyboard and controller shortcuts are disabled; resources explicitly open and close the mode.","sections":[{"anchor":"photo-mode","heading":"Photo mode","level":1,"text":"Control native photo mode through the client-only `Open77.photoMode` API. Vanilla keyboard and controller shortcuts are disabled; resources explicitly open and close the mode.\n\nThis is a client-only presentation API. It has no permission because it does not change canonical\nserver state, spawn an entity, or expose game data.","natives":[],"permissions":[],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"```lua\nlocal ok, reason = Open77.photoMode.open()\nlocal ok, reason = Open77.photoMode.close()\nlocal ok, reason = Open77.photoMode.setEnabled(enabled)\nlocal active, reason = Open77.photoMode.isActive()\n```\n\n`open()` calls REDengine's real photo-mode activation routine. It is idempotent: calling it while\nphoto mode is already active succeeds. The engine may still refuse the request when the current\ngameplay state does not permit photo mode.\n\n`close()` calls the native deactivation routine and also succeeds when photo mode is already\nclosed. `setEnabled(true)` is equivalent to `open()`; `setEnabled(false)` is equivalent to\n`close()`.\n\n`isActive()` reads the engine's current state. It returns `nil, reason` when the photo-mode system\nor its RTTI query is unavailable.\n\nA client resource may `RegisterCommand` of its own -- see the\n[FiveM compatibility page](fivem-compatibility.md) -- but a key is the better trigger for a toggle.\nIt also needs `permissions { \"input.actions\" }` in the manifest:\n\n```lua\nRegisterKeyMapping(\"photo\", \"Toggle photo mode\", \"F9\", function()\n local active, readError = Open77.photoMode.isActive()\n if active == nil then\n print(\"photo mode read failed: \" .. readError)\n return\n end\n\n local ok, error = Open77.photoMode.setEnabled(not active)\n if not ok then\n print(\"photo mode request failed: \" .. error)\n end\nend)\n```\n\nPhoto mode is one local engine state, not a resource-owned lease. Two resources can therefore\nopen or close the same state. A gamemode should nominate one client resource as its photo-mode\ncontroller.","natives":["Open77.photoMode.open","Open77.photoMode.close","Open77.photoMode.setEnabled","Open77.photoMode.isActive","RegisterKeyMapping"],"permissions":[],"events":[]},{"anchor":"errors","heading":"Errors","level":2,"text":"| Error | Meaning |\n|---|---|\n| `photo_mode_unavailable_on_this_host` | The API was called outside a client host with a game backend. |\n| `photo_mode_system_unavailable` | No active REDengine game instance exposes the photo-mode system. |\n| `photo_mode_hook_unavailable` | Open77 could not establish the exclusive activation hook, so it refuses to claim the API contract. |\n| `photo_mode_native_unavailable` | The required native or RTTI method is absent on this game build. |\n| `photo_mode_activation_refused` | REDengine rejected activation in the current gameplay state. |\n| `photo_mode_query_failed` | The native active-state query could not execute. |\n\nThe API does not simulate a key and does not edit `inputUserMappings.xml`. Rebinding or pressing\nthe old shortcut cannot bypass the native activation policy.","natives":[],"permissions":[],"events":[]}]},{"slug":"player-freeze","file":"player-freeze.md","title":"Freezing a player","summary":"Freeze a player's position with a server-authoritative state or a client-local claim. Use this for restraints, cutscenes and interfaces that require the player to remain stationary.","sections":[{"anchor":"freezing-a-player","heading":"Freezing a player","level":1,"text":"Freeze a player's position with a server-authoritative state or a client-local claim. Use this for restraints, cutscenes and interfaces that require the player to remain stationary.\n\nThere are two halves, and they are not alternatives:\n\n| | Call | Permission | Who decides |\n|---|---|---|---|\n| **Server** | `Open77.players.setFrozen(playerId, frozen)` | `players.life.freeze` | Authoritative. Rides the replicated life state, every observer agrees. |\n| **Client** | `Open77.character.setFrozen(frozen)` | `player.freeze` | Presentation-local. Your body, your machine, no round trip. |\n\nThe server's freeze outranks the local one. Releasing a local hold while the server holds the\nplayer changes nothing, and the local call says so in its second return value.","natives":[],"permissions":[],"events":[]},{"anchor":"the-mechanism","heading":"The mechanism","level":2,"text":"Both halves apply the same thing: `GameplayRestriction.NoMovement`, the engine's own restriction\nand the very record vanilla `GroundDeathEvents` applies on death. That matters for three reasons.\n\n- **Nothing fights the hold.** The locomotion state machine *polls* the restriction, so there is\n no per-frame contest with an input path, and no animation plays. Suspending the mover component\n instead would leave the body sliding, which is why `Movement.hpp` warns against it.\n- **Nothing slides on anybody else's screen.** A frozen player keeps sending snapshots at the\n normal rate; their position simply stops changing. The interpolator already refuses to\n extrapolate past 0.25 s, so no remote flag is needed and none was added.\n- **It composes.** Open77 owns exactly one stack of the restriction and asks exactly one question\n to decide whether to hold it: soft death **or** the server freeze **or** any local claim. A\n restriction that was already standing before Open77 wanted one is never ours and is never\n removed.","natives":[],"permissions":[],"events":[]},{"anchor":"what-a-freeze-stops-and-what-it-deliberately-does-not","heading":"What a freeze stops, and what it deliberately does not","level":2,"text":"This list is the usability of the feature. Get it wrong and a frozen player is either still\nwalking or unable to say why he is stuck.\n\n| Still works | Stopped |\n|---|---|\n| Looking around -- mouse and controller camera | Walking, running, sprinting, crouch-moving |\n| Chat | Jumping, dodging |\n| Voice | Sliding, vaulting |\n| Opening menus and the inventory | Every other locomotion input, pressed or held |\n| Interaction prompts | |\n| **Taking damage, and dying** | |\n\nThe precedent is the death restriction, which has spared chat and voice since it shipped: a player\nwho cannot move must still be able to say so.\n\nA freeze does **not** holster a weapon, block firing, or eject anyone from a vehicle. It is a\nprimitive, not a policy -- compose it with the control-blocking surface when a scene needs more.","natives":[],"permissions":[],"events":[]},{"anchor":"a-hold-is-a-claim-not-a-value","heading":"A hold is a claim, not a value","level":2,"text":"Both halves are keyed by the calling resource. A jail script and a cutscene script may hold the\nsame player at once, and `setFrozen(false)` drops only your own claim: the player stays frozen\nwhile anybody else still holds him. `isFrozen` answers for all holders, not just yours.\n\n```lua\n-- client, permission player.freeze\nlocal ok, stillHeld = Open77.character.setFrozen(true)\n-- ... show the menu ...\nlocal ok, stillHeld = Open77.character.setFrozen(false)\nif stillHeld then\n -- somebody else -- or the server -- is still holding him. Not our business.\nend\n```","natives":["Open77.character.setFrozen"],"permissions":[],"events":[]},{"anchor":"nothing-can-strand-a-player","heading":"Nothing can strand a player","level":2,"text":"A freeze whose owner is gone would be unrecoverable, so every path that could produce one releases\nit:\n\n| Path | What happens |\n|---|---|\n| The resource stops, reloads or crashes | The host drops that resource's claims, on both sides |\n| The player dies | Server claims and local claims are both dropped |\n| The player respawns | The flag is cleared again, belt and braces |\n| The player changes routing bucket | The claims were about a body in another world |\n| The player disconnects | Server claims dropped; a reconnecting session starts free |\n| The session ends on the client | Every local claim is dropped |\n\nBecause death and respawn clear it outright, a gamemode that wants a player held *through* a\nrespawn must re-apply the freeze afterwards.\n\nThe server call is refused with `transition_in_progress` while a revive or respawn is in flight,\nexactly like `setGhosted` -- the revision the client is acking must not move under it. A resource\n*stop* during one still thaws: it corrects the state without publishing, and the transition's own\nnext publish carries the corrected flag. A refusal there would be precisely the stranding this\nguards against.","natives":[],"permissions":[],"events":[]},{"anchor":"putting-a-player-down-and-clearing-everything-ragdoll-and-cleartasks","heading":"Putting a player down, and clearing everything: ragdoll and clearTasks","level":2,"text":"Two more body primitives live beside the freeze, both server-side and both\ncomposed from services that already ship rather than built new.\n\n| Function | Permission | Purpose |\n|---|---|---|\n| `Open77.players.ragdoll(playerId, options?)` | `players.motion.control` | Put the body on the ground for a bounded time; it gets up on its own. FiveM's `SetPedToRagdoll`. |\n| `Open77.players.clearTasks(playerId)` | any of `players.motion.control`, `players.animations.control`, `players.life.freeze` | End everything **this resource** put on the body -- its ragdoll, its animation, its freeze. FiveM's `ClearPedTasksImmediately`. |","natives":[],"permissions":[],"events":[]},{"anchor":"a-ragdoll-is-the-knockdown-with-a-clock","heading":"A ragdoll is the knockdown, with a clock","level":3,"text":"`Open77.motion.knockdown` already puts a living player on the ground through\nthe engine's own knockdown status -- the record vanilla uses, a physical fall\nthe observers replicate as a hit reaction. What it demanded from every caller\nwas a direction and a distance, and what it lacked was a duration. `ragdoll`\nsupplies the defaults and the clock:\n\n```lua\nOpen77.players.ragdoll(id) -- 3 s, in place, facing forward\nOpen77.players.ragdoll(id, { durationMs = 1200 }) -- up again after 1.2 s\nOpen77.players.ragdoll(id, { direction = 90 }) -- a heading in degrees (0 = north)\nOpen77.players.ragdoll(id, { direction = { x = 0, y = 1 }, distance = 2 }) -- a 2 m shove\n```\n\n- `direction` defaults to the body's own facing, read from the server's last\n accepted transform (`Open77.players.get(id).heading`); a player who has never\n reported one falls \"north\". With the default `distance` of `0` the direction\n only decides the way the body faces as it goes down -- it is never a\n displacement.\n- `durationMs` counts from the moment the owning client reports the body down,\n so \"1200\" is 1.2 s *on the ground*, not 1.2 s from the request. It is clamped\n to the engine's own knockdown -- **3000 ms** -- because the native status the\n client applies removes itself after three seconds and a longer promise could\n not be kept. The effective number comes back as `durationMs`.\n- The result is the knockdown's: `{ ok = true, id = ..., durationMs = ... }`,\n or `nil, reason`. `Open77.motion.cancel(id, result.id)` stands the body up\n early, and `onPlayerMotionChanged(playerId, id, phase, reason)` reports\n `pending`, `active` and `ended` -- `reason = \"duration_elapsed\"` is the clock.\n- Refusals are the knockdown's too: `motion_busy` while a motion is already on\n the body (including the 1.5 s recovery window after one ends, so hit spam\n cannot chain ragdolls), `body_unavailable` for a player who is not alive, on\n foot and ready, `invalid_direction` / `invalid_duration` for bad arguments —\n and `motion_unavailable` when the motion service is unavailable. Motion leases\n do not require the implant database. The server still requires a ready\n canonical body, an alive life state, an open readiness session and matching\n incarnation and routing bucket.\n\n**Physics ragdoll is not exposed to Lua.** Scripted knockdowns use an alive-only motion lease, separate from the death and respawn ragdoll path. The lease ends as soon as the player leaves the `alive` phase.\n\n```lua\n-- A stun grenade: everybody within 6 m goes down for two seconds.\nRegisterNetEvent(\"stun:detonated\", function(x, y, z)\n for _, id in ipairs(Open77.players.nearby({ x = x, y = y, z = z }, 6.0)) do\n local read = Open77.players.get(id)\n if read and read.alive and not read.inVehicle then\n Open77.players.ragdoll(id, { durationMs = 2000 })\n end\n end\nend)\n```","natives":["server:Open77.players.ragdoll","RegisterNetEvent","server:RegisterNetEvent","Open77.players.nearby","server:Open77.players.nearby","server:Open77.players.get"],"permissions":[],"events":[]},{"anchor":"cleartasks-releases-what-this-resource-holds-and-only-that","heading":"clearTasks releases what this resource holds, and only that","level":3,"text":"Three services keep per-owner claims -- the motion lease, the animation\nplayback, the freeze -- and each already refuses to release another owner's.\n`clearTasks` asks all three in one call and reports per half:\n\n```lua\nlocal result, reason = Open77.players.clearTasks(id)\n-- result = { motion = \"cancelled\", animation = \"stopped\", freeze = \"released\", clear = true }\n```\n\nEach half answers `cancelled` / `stopped` / `released` when something of yours\nended, `none` when you held nothing, `not_owned` when another resource holds it\n(and it is left alone -- a jail script's freeze does not end because a\ncutscene script cleaned up), or the owning service's own reason when it refused\n(`transition_in_progress` on a freeze during a revive). `clear` is `true` when\nnothing of yours is left on the body. `nil, reason` only for a bad id or a\nresource holding none of the three capabilities -- then nothing could have been\nput on the player in the first place.\n\nThe animation half covers every playback shape the animation service owns for\nthat player: an emote, a placed posture (`playAt`), a raw clip and a sequence.\nEverything it releases is something the server already releases when your\nresource stops; the call simply lets a script do it on purpose, mid-life.\n\n```lua\n-- /free : whatever this resource was doing to them, stop it.\nRegisterCommand(\"free\", function(source, args)\n local target = tonumber(args[1])\n if not target then return end\n local result = Open77.players.clearTasks(target)\n if result and not result.clear then\n print((\"still held: freeze=%s\"):format(result.freeze))\n end\nend, true)\n```","natives":["server:Open77.players.clearTasks","RegisterCommand","server:RegisterCommand"],"permissions":[],"events":[]},{"anchor":"on-the-wire","heading":"On the wire","level":2,"text":"The flag is `PlayerLifeFlags.Frozen` (`1 << 1`) on the canonical life state, beside `Ghosted`\n(`1 << 0`). The life-state flags field has always been serialized and never validated, so defining\na bit is not a protocol change: a client that predates it decodes the value and ignores the bit,\nwhich degrades to \"no freeze\" rather than a disconnect. Read it as\n`Open77.players.getLifeState(playerId).frozen`.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Complete server Lua API](server-api.md) -- the `Open77.players.setFrozen` row and the\n permission summary.\n- [Player health and stamina](player-stats.md) -- a frozen player still takes damage, and\n the fall-damage toggle that lives beside these body primitives.","natives":[],"permissions":[],"events":[]}]},{"slug":"player-interactions","file":"player-interactions.md","title":"Player interactions","summary":"`Open77.playerInteractions` coordinates two-player actions with participant reservations, consent, presentation readiness, a shared start and cleanup. Gameplay effects such as item transfer, healing or payment remain the resource's responsibility.","sections":[{"anchor":"player-interactions","heading":"Player interactions","level":1,"text":"`Open77.playerInteractions` coordinates two-player actions with participant reservations, consent, presentation readiness, a shared start and cleanup. Gameplay effects such as item transfer, healing or payment remain the resource's responsibility.\n\nAn accepted interaction confirms server admission, not successful native animation playback. Handle presentation failure and cancellation events.","natives":[],"permissions":[],"events":[]},{"anchor":"setup","heading":"Setup","level":2,"text":"The server must load [`open77_player_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_player_interactions), [`open77_animations`](https://github.com/Open2077/open77-resources/tree/main/system/open77_animations) and the\nmatching client animation/proxy archives. The default server configuration loads\nthe new system resource. Existing custom configurations must add it explicitly.\n\n```lua\nresource 'medical_actions'\nversion '1.0.0'\ndependency 'open77_player_interactions >=1.0.0'\npermissions {\n 'players.interactions.read',\n 'players.interactions.control',\n 'world.props', -- only if this resource also manages attached props\n}\nserver_script 'server.lua'\nclient_script 'client.lua'\n```\n\n`read` permits inspection; `control` permits server creation and cancellation of\nowned interactions. Client resources can inspect, accept/decline their own\ninvitation or cancel an action they participate in. A client cannot create an\nauthoritative pair or accept for somebody else. Use your normal authenticated\nserver event or command for gameplay requests.","natives":[],"permissions":["players.interactions.read","players.interactions.control","world.props"],"events":[]},{"anchor":"lifecycle","heading":"Lifecycle","level":2,"text":"```text\nrequest → offered → preparing → scheduled → active → completed\n ↓ ↓ ↓ ↓\n cancelled\n```\n\nBoth players are reserved immediately, including during the invitation. Only\nthe target can accept. `consent=false` skips the invitation, not validation or\nreadiness. Use it only for gameplay whose authorization is already established.\nBoth clients acknowledge preparation before the server schedules a common start\n500 ms ahead. Preparation times out after eight seconds. Observers reconcile the\nsame state; snapshot repair handles late joins and missed terminal updates.\n\nThis is scheduled gameplay coordination, not a frame-perfect cinematic timeline.\nNative `give`/`heal` workspots already play their entry while preparing and are\nrestarted at the shared start; readiness is not an engine-side paused preload.\nStreaming, frame rate and animation entry/loop timing can differ between clients.\nDo not time an inventory transaction from a hand pose or a local animation event.\n\n`completed` is emitted once by the server coordinator. Terminal interactions\nleave the active registry; keep terminal information in your resource if needed.\nClient events are visual notifications, **never authority to grant a reward**.\n\nThe system reserves only this interaction/animation service. Your own inventory,\nhealth, vehicle, job and economy systems must still check their business rules.","natives":[],"permissions":[],"events":[]},{"anchor":"request-options","heading":"Request options","level":2,"text":"| Field | Default and constraints |\n| --- | --- |\n| `durationMs` | `4000`; integer 500–600000. Starts at the shared scheduled time, not at invitation. |\n| `startDistance` | `3` metres; finite 0.25–10. Checked against server-observed positions. |\n| `breakDistance` | `5` metres; finite, at least `startDistance`, at most 20. |\n| `inviteTimeoutMs` | `15000`; integer 1000–60000. |\n| `consent` | `true`; boolean. |\n| `actorAnimation` | Optional RP profile ID for `give`, `heal` or `custom`. |\n| `targetAnimation` | Optional RP profile ID for `give`, `heal` or `custom`. |\n\nUnknown fields are rejected. Profile IDs must exist in the RP catalogue.\n`carry`/`escort` use their fixed paired presentation and reject profile overrides\nwith `paired_animation_fixed`. Prop local offsets/rotations belong to the\n[attachment API](attachments.md), not to these options.\n\nKinds:\n\n| Kind | Presentation and gameplay responsibility |\n| --- | --- |\n| `give` | Item-offering profiles on both bodies; your server code commits the item transfer. |\n| `heal` | Examination and wounded/resting profiles; your server code applies healing. |\n| `carry` | Carried-body pose plus carrier arm layers; actor can move and target follows. No damage or dead-ragdoll pickup. |\n| `escort` | Side-by-side guided movement with walking poses. No vanilla takedown or damage is started. |\n| `custom` | Optional specified RP profiles under the same reservation/consent protocol. |\n\nParticipants must be incarnated, alive, on foot, nearby and in the same bucket.\nAn injured/downed player may be represented by your gamemode while still alive;\nthis API does not attach a dead ragdoll or override engine death. Moving away\nfrom the starting spot cancels stationary `give`/`heal`/`custom` actions; a carried\npair can move together but separation still cancels it. Death, disconnect,\nbucket change, entering a vehicle, resource stop, timeout, rejection and native\npresentation failure release reservations. Local presentation temporarily uses\nthe same F7 body as observers without changing the saved camera preference.\n\nFor carry/escort, only the visual stand-in is mounted to the rendered parent;\nthe real target retains its engine movement controller and follows through the\nguarded native travel facility. Input restrictions and mutual proxy-collision\nsuppression are scoped to the interaction. Release searches nearby walkable\nground; if no safe drop exists, participants can separate before their mutual\ncollision is restored. Existing gamemode ghost/input policies are preserved.\nThese are presentation building blocks, not hand IK, obstacle pathfinding or\nan authoritative collision solver. Your resource must validate the action's\nlocation and gameplay rules (including line of sight when required).\n`give` and `heal` turn each local participant toward the other before starting\nthe workspot, without changing their positions. Choose a suitable distance for\nthe selected profiles; facing alone cannot calibrate a grip between two bodies.","natives":[],"permissions":[],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"Calls return directly, not promises. Failure is `nil, reason`.\n\n| Method | Success result |\n| --- | --- |\n| `request(actorId, targetId, kind, options?)` | State table. The calling resource owns this interaction. |\n| `get(interactionId)` | Active state, or `nil` when absent. |\n| `current(playerId)` | The player's reserved interaction, or `nil`. |\n| `isReserved(playerId)` | Boolean. |\n| `list()` | Active interactions owned by this resource. |\n| `cancel(interactionId, reason?)` | Terminal state. Cannot cancel another resource's action. |\n\nThese methods are under `Open77.playerInteractions`. Cancellation reasons accept\n1–64 ASCII letters/digits/underscores; invalid custom reasons become `cancelled`.\nInteraction IDs are opaque 32-character hexadecimal strings. Never reuse one as\na player ID or native entity handle.\n\nState fields: `id`, `kind`, decimal-string `actor`/`target`, `phase`, `reason`,\n`bucket`, `options`, `epoch`, `revision`, `serverTimeMs`, `startsAtMs`, `endsAtMs`.\nTimes are server monotonic milliseconds, **not Unix timestamps**. Start/end are\nzero before scheduling. Epoch/revision make old messages distinguishable; clients\nmust not manufacture or edit authoritative state.\n\nServer events, delivered to the owning resource:\n\n```lua\nAddEventHandler('onPlayerInteractionChanged', function(state) end)\nAddEventHandler('onPlayerInteractionStarted', function(state) end)\nAddEventHandler('onPlayerInteractionCompleted', function(state) end)\nAddEventHandler('onPlayerInteractionCancelled', function(state) end)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"give-an-attached-item","heading":"Give an attached item","level":2,"text":"This example assumes that the same server resource owns the prop and its\ngameplay ownership table. For separate inventory resources, call their server\nexports; do not try to mutate a foreign prop directly.\n\n```lua\nlocal pending = {}\n\nlocal function giveItem(actor, target, propId)\n -- Validate inventory ownership, permissions and item availability here.\n local prop = Open77.props.get(propId)\n if not prop or not prop.attachment or\n prop.attachment.parentType ~= 'player' or\n tonumber(prop.attachment.parentId) ~= actor then return nil, 'not_carried' end\n local state, err = Open77.playerInteractions.request(actor, target, 'give')\n if not state then return nil, err end\n pending[state.id] = {propId=propId, revision=prop.revision}\n return state\nend\n\nAddEventHandler('onPlayerInteractionCompleted', function(state)\n local transfer = pending[state.id]\n pending[state.id] = nil -- consume before calling other code\n if not transfer then return end\n -- Revalidate your inventory transaction here, before changing either owner.\n local ok, err = Open77.props.attach(transfer.propId, {\n parentType='player', parentId=state.target, bone='RightHand',\n }, transfer.revision)\n if not ok then print('Handoff refused: '..tostring(err)); return end\n -- Commit your resource's authoritative item owner after successful CAS.\nend)\n\nAddEventHandler('onPlayerInteractionCancelled', function(state)\n pending[state.id] = nil -- no item transfer on decline/death/disconnect\nend)\n```\n\nDo not add a timer that blindly grants the item after four seconds: the action\nmay have been declined, cancelled, or never prepared. If your final transaction\nneeds persistence, implement its own idempotency key and database transaction;\nthe in-memory coordinator is not a durable inventory transaction log.","natives":["server:Open77.props.get","server:Open77.playerInteractions.request","AddEventHandler","server:AddEventHandler","Open77.props.attach","server:Open77.props.attach"],"permissions":[],"events":[]},{"anchor":"medical-and-guided-movement-examples","heading":"Medical and guided-movement examples","level":2,"text":"Server-side medical action (also declare `players.stats.read` and\n`players.stats.apply`). Replace the commented checks with your resource's job,\nitem and line-of-sight rules before exposing this to players:\n\n```lua\nlocal treatments = {}\nlocal function examine(medic, patient)\n -- Validate medic permissions and reserve the required medical item first.\n local state, err = Open77.playerInteractions.request(medic, patient, 'heal', {\n durationMs=8000, startDistance=2, breakDistance=3,\n })\n if not state then return nil, err end\n treatments[state.id] = true\n return state\nend\n\nAddEventHandler('onPlayerInteractionCompleted', function(state)\n if not treatments[state.id] then return end\n treatments[state.id] = nil\n -- Revalidate/consume the reserved medical item before granting health.\n local patient = tonumber(state.target)\n local health = Open77.stats.getHealth(patient)\n if not health then return end\n local ok, err = Open77.stats.setHealth(patient,\n math.min(health.maximum, health.value + 25))\n if not ok then print('Treatment refused: '..tostring(err)) end\nend)\nAddEventHandler('onPlayerInteractionCancelled', function(state)\n treatments[state.id] = nil -- release your medical-item reservation too\nend)\n```\n\nCarry/escort use the same invitation and cancellation UI. The caller must select\nan eligible, alive target; a gamemode's injured state is separate from engine\ndeath. The actor can walk, but neither participant can attack or jump during the\naction. The target cannot walk independently until release.\n\n```lua\n-- Server, after checking your gameplay authorization:\nlocal state, err = Open77.playerInteractions.request(helperId, injuredId, 'carry', {\n durationMs=60000, consent=true,\n})\n-- Use kind='escort' for side-by-side walking under the same lifecycle.\n-- Stop before the duration expires (only the owning resource can do this):\n-- Open77.playerInteractions.cancel(state.id, 'destination_reached')\n```\n\nCancellation is a release, not successful completion. If reaching a destination\nshould complete a job, validate and commit that job in your own server code;\ndo not reinterpret every participant cancellation as a reward.","natives":["server:Open77.playerInteractions.request","AddEventHandler","server:AddEventHandler","Open77.stats.getHealth","server:Open77.stats.getHealth","server:Open77.stats.setHealth","Open77.playerInteractions.cancel","server:Open77.playerInteractions.cancel"],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"Client facade calls return an **Open77.Promise** routed to the bundled resource.\nCall `:await()` from a `CreateThread` coroutine. Distinguish a dispatch/rejection\nerror from an absent state. Methods under `Open77.playerInteractions`:\n\n| Method | Promise resolves to |\n| --- | --- |\n| `list()` | Locally synchronized states in the current bucket. |\n| `get(id)` | State or nil. |\n| `current(playerId?)` | State for that player; defaults to the local player. |\n| `isReserved(playerId?)` | Boolean. |\n| `respond(id, accepted)` | Request ID, or `nil, reason`; target only, boolean response. |\n| `accept(id)` / `decline(id)` | Aliases for `respond`. |\n| `cancel(id?)` | Request ID; defaults to your current interaction. Participants only. |\n| `result(requestId)` | Cached server result, or nil while pending/expired. Kept 30 seconds. |\n\nA request ID means the message was submitted, **not** that the server accepted\nit. Observe `onPlayerInteractionResult` for the authoritative reply. The bundled\nchat command `/interaction accept`, `/interaction decline`, `/interaction cancel`\nlets a player respond even without a custom UI.\n\n```lua\nAddEventHandler('onPlayerInteractionOffered', function(state)\n -- Show your UI here. Accept only after the player's explicit choice.\nend)\n\nlocal function acceptFromUi(id)\n CreateThread(function()\n local promise, err = Open77.playerInteractions.accept(id)\n if not promise then print(err); return end\n local requestId, rejected = promise:await()\n if not requestId then print(rejected); return end\n print('Waiting for server response: '..requestId)\n end)\nend\n\nAddEventHandler('onPlayerInteractionResult', function(result)\n -- result.requestId correlates the response with your UI action.\n if not result.ok then print(result.error) end\nend)\n```\n\nClients also receive `onPlayerInteractionChanged`, `Started`, `Completed`,\n`Cancelled`, and `onPlayerInteractionPresentationFailed(id, reason)`. The latter\nreports a local native failure; the server remains responsible for releasing the\npair. Never rebroadcast these events as authority. The `open77:playerInteractions:`\nnetwork namespace and `_present` native are reserved for the system adapter.","natives":["AddEventHandler","server:AddEventHandler","CreateThread","server:CreateThread","Open77.playerInteractions.accept"],"permissions":[],"events":[]},{"anchor":"failure-handling-and-limits","heading":"Failure handling and limits","level":2,"text":"Common refusals include `invalid_participants`, `player_not_ready`,\n`player_not_alive`, `player_in_vehicle`, `position_unavailable`, `wrong_bucket`,\n`too_far`, `player_reserved`, `animation_busy`, `invalid_kind`, `invalid_duration`,\n`invalid_distance`, `unknown_animation`, `paired_animation_fixed`,\n`owned_by_another_resource`, `not_invited`, `not_participant`, `invalid_phase`\nand `stale_preparation`. Preserve reasons in developer logs; show a short,\nappropriate message to players.\n\nThe coordinator permits at most 256 interactions globally and 64 per resource.\nPlayer replies are rate-limited and bound to the authenticated connection.\nResource reload cancels its outstanding reservations. Snapshot repair does not\nreplay completed gameplay callbacks or restore transactions after server restart.","natives":[],"permissions":[],"events":[]}]},{"slug":"player-model-catalogue","file":"player-model-catalogue.md","title":"Player morph catalogue","summary":"Browse **6,582 `Character.*` records** extracted from Cyberpunk 2077 **2.31** with Phantom Liberty. Each ID can be passed to the player-model API; this catalogue is a discovery index, **not a guarantee that every record is playable**. The 86 internal `TEST.*` records are excluded because they are outside the API namespace.","sections":[{"anchor":"player-morph-catalogue","heading":"Player morph catalogue","level":1,"text":"Browse **6,582 `Character.*` records** extracted from Cyberpunk 2077 **2.31** with\nPhantom Liberty. Each ID can be passed to the player-model API; this catalogue\nis a discovery index, **not a guarantee that every record is playable**. The 86\ninternal `TEST.*` records are excluded because they are outside the API namespace.\n\nThe website provides searchable records, categories, asset details and a Copy ID\nbutton. The [complete record list](https://github.com/Open2077/open77-base/blob/main/docs/generated/player-models-2.31.md)\nalso lists every ID in one Markdown table for repository users and AI agents.","natives":[],"permissions":[],"events":[]},{"anchor":"morph-and-restore","heading":"Morph and restore","level":2,"text":"```lua\n-- Server resource manifest:\n-- permissions { \"players.model.control\", \"players.model.read\" }\nlocal ok, revision = SetPlayerModel(playerId, \"Character.Rogue\")\nif not ok then print(\"Morph refused: \" .. tostring(revision)) end\n\n-- Optional explicit appearance: it must belong to the chosen template.\n-- SetPlayerModel(playerId, record, { appearance = \"exact_appearance_name\" })\n\n-- When the roleplay/admin action ends:\nlocal restored, reason = ResetPlayerModel(playerId)\n```\n\nWait for `onPlayerModelReady` before assuming the body is visible. A record may\nvalidate but fail to load its appearance or support a requested action. See\n[Player models](player-models.md) for ownership, events, permissions and failures.\nCustom `Character.*` records do not need to be added to this catalogue; install\ntheir assets on every observing client.","natives":["server:SetPlayerModel","server:ResetPlayerModel"],"permissions":["players.model.control","players.model.read"],"events":[]},{"anchor":"choosing-a-body","heading":"Choosing a body","level":2,"text":"| Starting point | Record | Model notes |\n| --- | --- | --- |\n| Rogue | `Character.Rogue` | Humanoid local/remote presentation and vehicle seating. |\n| Johnny | `Character.JohnnyNPC_Puppet_Photomode` | Humanoid local/remote presentation and vehicle seating. |\n| Adam Smasher | `Character.Smasher` | Special skeleton; vehicle poses can T-pose or intersect the car. |\n\nQuest, scene, player-body, child, animal and robot records can have incompatible rigs or behavior.\nExtraction labels describe database records, not supported player actions.\nThe visual body does not grant NPC AI, faction, boss powers, health or weapons.\nAppearance names are not record IDs. The extracted database source is not a\nreliable per-record DLC indicator: `tweakdb_ep1.bin` contains base-game entries too.","natives":[],"permissions":[],"events":[]},{"anchor":"in-game-admin-menu","heading":"In-game admin menu","level":2,"text":"Open `/admin` → **Self** → **Morph / original character**:\n\n- Search all records or browse categories, 20 results per page.\n- Confirm a record, or enter a custom `Character.*` ID directly.\n- Optionally set an appearance override, or clear it to use the record's default.\n- Choose **Unmorph — original character** to restore the current player appearance.\n\nOpening the menu requires `command.admin`. The server checks\n`command.admin.self.morph`, `command.admin.self.unmorph` and\n`command.admin.self.morph.search` separately. A read-only catalogue grant cannot\nmorph anyone. These actions affect only the operator, not a supplied player ID.\nMorphing remains server-authoritative and replicated. The admin cannot replace\nor reset an override owned by another resource. Revoking the morph permission,\ndeath, disconnect or stopping the admin resource releases its own override.","natives":[],"permissions":[],"events":[]},{"anchor":"downloads-and-reproducibility","heading":"Downloads and reproducibility","level":2,"text":"- [Searchable catalogue JSON](/data/npc-records-2.31.json): all 6,582 `Character.*` IDs and extracted metadata.\n- [Raw extraction CSV](/data/npc-records-2.31.csv): 6,668 entries, including the excluded internal records.\n- [Complete morph list, Markdown](/data/player-models-2.31.md): all accepted-namespace IDs in one file.\n\nThe admin index and Markdown list are generated from the same CSV by\n`resources/system/open77_admin/tools/build-models.py`. Their source SHA-256 is\nrecorded in the generated files. Run the generator with `--check` to detect drift.\nNeither the documentation nor the menu is a runtime allowlist.","natives":[],"permissions":[],"events":[]}]},{"slug":"player-models","file":"player-models.md","title":"Player models: NPC impersonation","summary":"Select an NPC model as a player's visible body while retaining player identity and gameplay state. Find 6,582 `Character.*` IDs in the [morph catalogue](player-model-catalogue.md).","sections":[{"anchor":"player-models-npc-impersonation","heading":"Player models: NPC impersonation","level":1,"text":"Select an NPC model as a player's visible body while retaining player identity and gameplay state.\nFind 6,582 `Character.*` IDs in the [morph catalogue](player-model-catalogue.md).\n\nNPC impersonation is experimental. Movement, vehicle poses and life transitions depend on the\nmodel's rig; inclusion in the catalogue does not guarantee compatibility with every player action.\n\nA server resource can select a `Character.*` record as a player's visible body.\nThe player retains their network identity, controls, health, equipment inventory and\nsaved appearance. The NPC is a presentation body, not an independently controlled AI\nactor and not a second player. Resetting the model restores the current player appearance.\n\nModels use their own native rig/graph and appearance. Player wardrobe meshes and\nplayer-specific Mirror animation clips are not applied to an arbitrary NPC skeleton.\nThe record is resolved from the client's installed game/mod data, without a virtual\ncatalogue and without changing shared TweakDB records.\n\nInternally, the client builds a process-cached presentation record from the\nordinary proxy base and copies only the requested NPC's visual identity. The\nrequested record remains the API identity. This prevents importing a boss's\nstat packages or quest loadout along with its skin; it does not guarantee that\nevery authored template or special skeleton supports every player action.","natives":[],"permissions":[],"events":[]},{"anchor":"vehicle-compatibility","heading":"Vehicle compatibility","level":3,"text":"The original player keeps the actual seat and driving authority; the visible\nNPC follows a presentation-only seat pose and never takes ownership of the car.\n\nWhile a model is active, the native vehicle camera stays in third person where\nsupported. If the passenger camera remains in first person, a vehicle-following\nthird-person fallback provides mouse orbit and three distances through the\nplayer's existing vehicle-camera binding. Reset restores the prior view;\nprotected scenes and resource-owned scripted cameras retain priority.\n\n**Special rigs may not support vehicle seats.** Adam Smasher uses `man_massive`;\nseats without a matching animation binding can display a T-pose or clip the body\nthrough the vehicle. The API does not replace incompatible models automatically.","natives":[],"permissions":[],"events":[]},{"anchor":"permissions-and-ownership","heading":"Permissions and ownership","level":2,"text":"```lua\npermissions { \"players.model.control\", \"players.model.read\" }\n```\n\n`players.model.control` permits server setters. `players.model.read` permits\nqueries, and client record validation. A resource **generation** exclusively owns\neach active override. A second resource cannot replace or reset it; it receives\n`model_owned_by_other_resource`. Stopping/restarting the owner releases its overrides.\nAllow client requests only after checking your own gameplay/ACL rules server-side.\nThere is deliberately no client model setter and no unrestricted native access.","natives":[],"permissions":["players.model.control","players.model.read"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"| Function | Result |\n| --- | --- |\n| `SetPlayerModel(playerId, record, options?)` | `true, revision` or `false, reason` |\n| `ResetPlayerModel(playerId)` | `true` or `false, reason` |\n| `GetPlayerModel(playerId)` | state table, `nil` for original model, or `nil, reason` |\n| `IsPlayerModelReady(playerId)` | boolean or `nil, reason` |\n\nAliases: `Open77.players.setModel`, `.resetModel`, `.getModel`, `.isModelReady`.\nPlayer IDs must be positive integers; numeric strings are not accepted.\n\nOptions (unknown fields and wrong types are rejected):\n\n| Field | Default | Meaning |\n| --- | --- | --- |\n| `appearance` | `\"\"` | Native appearance CName; empty keeps the template default. Maximum 128 ASCII characters. |\n| `durationMs` | `0` | Integer 0–86,400,000; zero lasts until reset or owner/disconnect cleanup. |\n| `resetOnDeath` | `false` | If true, remove the override on death; otherwise retain it for respawn. |\n\nRecord strings must start with `Character.`, have 11–255 ASCII characters and\ncontain only letters, digits, `_`, `.` and `-`. Appearance strings allow the same\ncharacters plus spaces. Asset paths, escape sequences and arbitrary native fields\nare not supported. Record availability is checked on the client, not inferred by\nthe server from the name. A missing model therefore fails asynchronously.\n\n```lua\nlocal ok, revisionOrError = SetPlayerModel(playerId, \"Character.Rogue\", {\n durationMs = 60000,\n resetOnDeath = false,\n})\nif not ok then print(\"Model refused: \" .. revisionOrError) end\n-- A true setter result means accepted, NOT already visible.\n```\n\nThe server state table contains `player`, `record`, `appearance`, `revision`,\n`ready`, `resetOnDeath`, and `remainingMs` for a timed override. `ready` is the\ncurrent owner's native presentation acknowledgement; it is not a promise that\nevery observer has streamed the player. Setting identical options again extends\nthe duration without respawning the body or changing its revision.","natives":["server:SetPlayerModel"],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":3,"text":"```lua\nAddEventHandler(\"onPlayerModelChanged\", function(player, record, appearance, revision, reason)\n -- record is nil when restoring the original model.\nend)\nAddEventHandler(\"onPlayerModelReady\", function(player, revision)\n -- Check this revision when coordinating an asynchronous operation.\nend)\nAddEventHandler(\"onPlayerModelFailed\", function(player, revision, reason)\n -- The authoritative override is cleared; the original player remains usable.\nend)\n```\n\nThese events are server-side. Change reasons include `set`, `reset`, `expired`,\n`died`, `resource_stopped`, `disconnected`, `projection_timeout` and native failure\nreasons. An acknowledgement for an old revision/connection is ignored. The\n`open77:playerModel:*` transport belongs to the platform; do not publish it from Lua.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"| Function | Result |\n| --- | --- |\n| `GetPlayerModel(playerId?)` | current state table, `nil` for original model, or `nil, reason` |\n| `IsPlayerModelReady(playerId?)` | this client's projection readiness, or `nil, reason` |\n| `IsPlayerModelValid(record)` | record exists and is a Character record; `false, reason` otherwise |\n\nAliases: `Open77.players.getModel`, `.isModelReady`, `.isModelValid`. Omitted/nil\nplayer ID selects the local player. Zero is not an explicit player ID.\n\nThe client table contains `player`, `record`, `appearance`, `revision`, `entity`,\n`ready`, `status` and optional `failure`. `entity` is a local Open77 entity handle,\nnot a network player ID; it changes when a body is recreated and must not be\npersisted or sent to another client as an identity. Use the network player ID.\n\nStatuses: `preparing`, `active`, `parked`, `not_streamed`, `failed`.\nReadiness and availability are different: `IsPlayerModelValid` does not load or\nanimate a body and cannot prove its locomotion, seat or appearance compatibility.\n\n```lua\nlocal model, error = GetPlayerModel()\nif error then\n print(\"Model state unavailable: \" .. error)\nelseif model and model.ready then\n print((\"Visible model: %s (revision %s)\"):format(model.record, model.revision))\nend\n```","natives":["GetPlayerModel","server:GetPlayerModel"],"permissions":[],"events":[]},{"anchor":"replication-and-lifecycle","heading":"Replication and lifecycle","level":2,"text":"- Selection is server-owned and scoped to the player's routing bucket.\n- Late join/reconciliation receives bounded, atomic paged snapshots. Older\n revisions and messages from another connection cannot restore stale models.\n- Streaming or a bucket change recreates the presentation from the current state.\n- Local impersonation requires third person. A conflicting forced first-person\n policy fails explicitly; normal perspective preference is retained for reset.\n- Invalid/unavailable native models and preparation failures must recover to the\n original body. An unacknowledged request times out after 20 seconds while the\n player is alive and gameplay-ready.\n- Models do not grant NPC stats, weapons, boss abilities, faction or hostility.\n Gameplay authority remains on the player and server.\n\nCommon errors: `invalid_player`, `player_unavailable`, `player_not_ready`,\n`player_not_alive`, `invalid_record`, `invalid_appearance`, `invalid_duration`,\n`invalid_options`, `model_owned_by_other_resource`, `quota_exceeded`,\n`record_not_found`, `record_not_character`, `third_person_blocked`,\n`projection_timeout`, `state_unavailable`, and permission-denial strings.\nNative record preparation can also report `model_record_limit`,\n`proxy_record_unavailable`, `model_clone_failed`, `model_record_conflict`,\n`model_template_missing`, `model_flat_failed`, or `model_record_failed`.","natives":[],"permissions":[],"events":[]},{"anchor":"integration-checks","heading":"Integration checks","level":2,"text":"Check the intended models with the movements, vehicle seats and animations used by your gamemode.\nHandle asynchronous preparation failures and always provide a reset action. See the\n[admin morph workflow](player-model-catalogue.md#in-game-admin-menu) for model selection and restoration.","natives":[],"permissions":[],"events":[]}]},{"slug":"player-stats","file":"player-stats.md","title":"Player health and stamina","summary":"The server owns player health, stamina, regeneration and maximum values. One versioned state per connected player is replicated to clients; resources do not need a separate synchronization service.","sections":[{"anchor":"player-health-and-stamina","heading":"Player health and stamina","level":1,"text":"The server owns player health, stamina, regeneration and maximum values. One versioned state per connected player is replicated to clients; resources do not need a separate synchronization service.\n\nThe two runtimes deliberately expose the same read API. Mutations exist only\non the server.","natives":[],"permissions":[],"events":[]},{"anchor":"permissions","heading":"Permissions","level":2,"text":"```lua\npermission \"players.stats.read\" -- client and server reads\npermission \"players.stats.apply\" -- server mutations only\n```\n\nThe older `players.damage.read` / `players.damage.apply` permissions remain\naccepted for compatibility, and `players.life.read` may also read stats. New\nresources should use `players.stats.*` and request `combat.config` separately\nwhen they also change PvP policy.","natives":[],"permissions":[],"events":[]},{"anchor":"shared-read-api","heading":"Shared read API","level":2,"text":"These functions have the same names and return shape in client and server Lua:\n\n```lua\nlocal stats = Open77.stats.get(playerId)\nlocal health = Open77.stats.getHealth(playerId) -- alias: health\nlocal stamina = Open77.stats.getStamina(playerId) -- alias: stamina\n```\n\nOn the client, `playerId` may be omitted to read the local player. The server\nalways requires it because a server resource has no implicit local player.\nUnknown or not-yet-replicated players return `nil`.\n\n`get` returns:\n\n```lua\n{\n playerId = 7,\n revision = 42,\n staminaAuthorityRevision = 9,\n armor = 0,\n godMode = false,\n lastAttacker = 12,\n lastDamage = 25,\n lastAttackKind = \"ranged\",\n lastBodyPart = \"torso\",\n health = {\n pool = \"health\",\n value = 75, current = 75,\n maximum = 150, max = 150,\n fraction = 0.5, percentage = 50,\n regenEnabled = true,\n regenPerSecond = 5,\n regenerating = true\n },\n stamina = {\n pool = \"stamina\",\n value = 60, current = 60,\n maximum = 120, max = 120,\n fraction = 0.5, percentage = 50,\n regenEnabled = true,\n regenPerSecond = 20,\n regenerating = false\n }\n}\n```\n\nAll values and regeneration rates are points, not percentages. `revision`\nchanges whenever any canonical stat or its configuration changes.","natives":["Open77.stats.get","server:Open77.stats.get","Open77.stats.getHealth","server:Open77.stats.getHealth","Open77.stats.getStamina","server:Open77.stats.getStamina"],"permissions":[],"events":[]},{"anchor":"server-mutation-api","heading":"Server mutation API","level":2,"text":"Every mutation returns `true`, or `false, reason`.\n\n| Function | Purpose |\n|---|---|\n| `Open77.stats.set(playerId, pool, value)` | Set `\"health\"` or `\"stamina\"` in points. |\n| `Open77.stats.setMax(playerId, pool, maximum)` | Set the canonical maximum and clamp the current value. |\n| `Open77.stats.restore(playerId, pool)` | Fill the selected pool to its maximum. |\n| `Open77.stats.setRegenEnabled(playerId, pool, enabled)` | Enable or disable server regeneration. |\n| `Open77.stats.setRegenRate(playerId, pool, pointsPerSecond)` | Set its authoritative rate; zero disables it. |\n| `setHealth`, `setHealthMax`, `restoreHealth` | Explicit health aliases. |\n| `setHealthRegenEnabled`, `setHealthRegenRate` | Explicit health-regeneration aliases. |\n| `setStamina`, `setStaminaMax`, `restoreStamina` | Explicit stamina aliases. |\n| `setStaminaRegenEnabled`, `setStaminaRegenRate` | Explicit stamina-regeneration aliases. |\n\nExample:\n\n```lua\nlocal ok, reason = Open77.stats.setHealthMax(playerId, 200)\nif not ok then return print(reason) end\n\nassert(Open77.stats.setHealth(playerId, 200))\nassert(Open77.stats.setHealthRegenRate(playerId, 8))\nassert(Open77.stats.setHealthRegenEnabled(playerId, true))\n\nassert(Open77.stats.setStaminaMax(playerId, 160))\nassert(Open77.stats.setStamina(playerId, 80))\nassert(Open77.stats.setStaminaRegenRate(playerId, 30))\n```\n\nSetting health to zero uses the player-life authority and therefore performs a\nreal attributed soft death rather than merely writing a native pool. Direct\ndamage, healing, armor and god mode remain available through the compatible\n`Open77.players.damage`, `heal`, `setArmor`, and `setGodMode` methods.","natives":["server:Open77.stats.setHealthMax","server:Open77.stats.setHealth","server:Open77.stats.setHealthRegenRate","server:Open77.stats.setHealthRegenEnabled","server:Open77.stats.setStaminaMax","server:Open77.stats.setStamina","server:Open77.stats.setStaminaRegenRate"],"permissions":[],"events":[]},{"anchor":"fall-damage","heading":"Fall damage","level":2,"text":"```lua\npermission \"players.life.falldamage\" -- server: switch a player's fall damage off and on\npermission \"players.life.read\" -- read it back\n```\n\n| Function | Purpose |\n|---|---|\n| `Open77.players.setFallDamage(playerId, enabled)` | `false` makes landings harmless for this player; `true` restores the vanilla behaviour. `true`, or `false, reason`. |\n| `Open77.players.isFallDamageEnabled(playerId)` | Whether landings still hurt him. `Open77.players.get(playerId).fallDamage` carries the same bit. |\n\n**Fall damage only.** This is FiveM's fall-damage toggle and nothing wider: a\nplayer whose fall damage is off is still shot, stabbed, burned, blown up and run\nover exactly as before, and still dies from all of it. It is not `setGodMode`\n(the combat ledger's damage immunity) and it is not the grav-chute's shield\n(`Open77.chute`, a blanket *Invulnerable* god mode that exists because the\nchute needs *everything* off for the seconds it is armed). A FiveM script that\nreaches for `SetEntityInvincible` wants `setGodMode`; one that reaches for the\nfall-damage toggle wants this.\n\n**Why it is a life flag and not a stat.** On 2.31 a hard landing is not a number\nthe engine subtracts from a pool. The player's own locomotion state machine\ngrades the landing from its vertical speed and, for the three grades that hurt,\nfires a self-targeted area attack -- `Attacks.HardLanding`, then\n`VeryHardLanding`, then `DeathLanding` -- through the ordinary damage pipeline,\non the landing client only. So the only place fall damage can be refused is\nthat client, inside the damage pipeline, and the only honest way to refuse\n*fall* damage rather than *all* damage is to recognise those three records and\nnothing else. That is what the client does while the server's bit is up: the\nthree landing records aimed at its own body are dropped before they are priced;\nevery other hit is untouched; and the stumble, the camera hit and the grunt\nstill play, because they come from the locomotion state and a player told \"no\nfall damage\" still expects to feel the fall. Observers need do nothing -- a\nlanding is resolved only on the body that landed.\n\n**Release paths.** The bit rides the replicated life state, so it survives a\nproxy re-stream and travels with the player across a bucket change (it concerns\nhis own client, not the observers he left). Death drops it (a dead body takes no\nlandings, and the respawn starts vanilla unless asked otherwise), a reconnect\ndrops it, and the resource that switched it off switches it back on when it\nstops, reloads or crashes -- nothing else could, because there is no proximity\nnotion to auto-clear against. Refused with `transition_in_progress` during a\nrevive or respawn, like every other life bit; **allowed while dead**, so a\ngamemode that drops players from the sky can shield them before the respawn it\nis about to issue rather than racing the first landing:\n\n```lua\n-- A parkour round: nobody dies to the course, everybody still dies to each other.\nAddEventHandler(\"parkour:roundStart\", function()\n for _, id in ipairs(Open77.players.all()) do\n Open77.players.setFallDamage(id, false)\n end\nend)\n\n-- Re-arm on death: Kill zeroed the bit, and the spawn point is a rooftop.\nAddEventHandler(\"onPlayerLifeStateChanged\", function(playerId, revision, phase)\n if phase ~= \"dead\" then return end\n Open77.players.setFallDamage(playerId, false) -- allowed while dead; rides the respawn\n Open77.players.respawn(playerId, { position = spawns.rooftop, heading = 90 })\nend)\n\nAddEventHandler(\"parkour:roundEnd\", function()\n for _, id in ipairs(Open77.players.all()) do\n Open77.players.setFallDamage(id, true)\n end\nend)\n```\n\nOn the wire the flag is `PlayerLifeFlags.FallShield` (`1 << 3`), beside\n`Ghosted` (`1 << 0`), `Frozen` (`1 << 1`) and `Invisible` (`1 << 2`). The flags\nfield has always been serialized and never validated, so assigning the bit is\nnot a protocol change: a client that predates it decodes the value and ignores\nthe bit, which degrades to \"vanilla fall damage\" rather than a disconnect.","natives":["AddEventHandler","server:AddEventHandler","Open77.players.all","server:Open77.players.all","server:Open77.players.setFallDamage","server:Open77.players.respawn"],"permissions":[],"events":[]},{"anchor":"synchronization-model","heading":"Synchronization model","level":2,"text":"The server sends health, stamina, maximums, regeneration configuration and the\ncombined revision in one reliable `PlayerHealthState` message. Late join and\nrouting-bucket changes replay the same durable state. The native client also\nkeeps the newest snapshot across REDengine world reloads and reapplies it when\nthe new local player/stat-pool systems become available.\n\nCyberpunk consumes stamina locally while sprinting, dodging or attacking. The\nowning client samples that native decrease and sends only the bounded positive\ndelta consumed. The server accepts only monotonic sequences and the current\nauthority revision, then subtracts the delta from its current canonical value.\nIt never accepts a client-side refill or maximum change. Consumption deltas and\nserver regeneration commute, so neither can erase the other under latency.\nExplicit stamina writes, maximum changes and life transitions advance the\nauthority revision, making older in-flight observations harmless. Changing a\nregeneration rate or toggle keeps the epoch because it commutes with pending\nconsumption too.\n\nBoth native Health and Stamina regeneration are disabled during a multiplayer\nsession. Server ticks are the only regeneration source, and clients project\nthe canonical values back onto the engine pools. Disconnecting removes Open77's\ntemporary maximum and regeneration overrides.","natives":[],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"```lua\nAddEventHandler(\"open77:playerStatsChanged\", function(\n playerId, health, maxHealth, stamina, maxStamina, revision, reason\n) end)\n```\n\nThe existing `open77:playerHealthChanged` and damage-feedback events remain\navailable for compatibility. Use the new event when a system needs to react to\neither pool or to configuration-driven revisions.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:playerStatsChanged","open77:playerHealthChanged"]},{"anchor":"damage-feedback-events-client","heading":"Damage feedback events (client)","level":3,"text":"Three client events carry the server's damage verdict to Lua — raised from the\nauthoritative `PlayerHealthState` broadcast, never from the shooter's local\nraycast, so a hitmarker drawn from `hitConfirmed` is a hit the ledger credited.\nThey are the counterpart of FiveM's `gameEventTriggered` /\n`CEventNetworkEntityDamage`. Arguments are scalar **strings**, like every\nengine-raised event.\n\n```lua\n-- The local player lost health: direction and amount for a damage indicator.\n-- Raised on the VICTIM's client only.\nAddEventHandler(\"open77:localDamaged\", function(attackerId, amount, dirX, dirY, dirZ,\n bodyPart, health, maxHealth) end)\n\n-- The server credited a hit by the local player on a PLAYER: the authoritative\n-- hitmarker. Raised on the ATTACKER's client only. `lethal` is \"1\" or \"0\".\nAddEventHandler(\"open77:hitConfirmed\", function(victimId, amount, bodyPart, lethal) end)\n\n-- Any player whose proxy this client has took damage: nameplate bars, kill feeds.\nAddEventHandler(\"open77:playerDamaged\", function(victimId, attackerId, amount,\n bodyPart, health, maxHealth) end)\n```\n\nTwo traps. **`hitConfirmed` is player-victim only** — it is raised from the\nplayer-health drain, so a hit on a server-owned NPC produces no hitmarker; the\nshooter's client gets `open77:npcHit(npcId, damage, hitZ, weaponTdbId, attackKind)`\nfor that. And **the server event of the same name has a different list**:\nserver-side `open77:playerDamaged` carries `(victimId, attackerId, amount,\nattackKind, weaponTdbId, bodyPart, remainingHealth, maxHealth, lethal,\ndownedHit)`, so a handler copied across reads the wrong column.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:localDamaged","open77:hitConfirmed","open77:playerDamaged"]}]},{"slug":"player-utilities","file":"player-utilities.md","title":"Player checks and controls","summary":"Read native player state and apply client-local controls. Player IDs are network IDs, not entity handles. Omit the ID to target the local player. Failed reads return `nil, reason`; failed controls return `false, reason`.","sections":[{"anchor":"player-checks-and-controls","heading":"Player checks and controls","level":1,"text":"Read native player state and apply client-local controls. Player IDs are network IDs, not entity handles. Omit the ID to target the local player. Failed reads return `nil, reason`; failed controls return `false, reason`.\n\nThe original checks and controls are available in client **2.31.13+op77.62**, protocol **1.25**. These are client APIs;\nserver resources request their own client's actions through permissioned events.\n\n```lua\npermissions { 'players.read', 'players.controls' }\n```","natives":[],"permissions":["players.read","players.controls"],"events":[]},{"anchor":"local-avatar-visibility","heading":"Local avatar visibility","level":2,"text":"[Local player visibility](local-puppet-visibility.md) explains\n`Open77.players.setLocalPuppetVisible(visible)` and\n`Open77.players.isLocalPuppetVisible()`, including the\n`players.local.visibility` permission and resource cleanup. Hide the native body\nand third-person double while keeping the selected camera, for example in Prop\nHunt. These functions require a compatible client independently of the utility\nversion listed above.","natives":[],"permissions":[],"events":[]},{"anchor":"passenger-drive-by","heading":"Passenger drive-by","level":2,"text":"[Passenger drive-by](drive-by.md) covers `setLocalDriveByEnabled`,\n`getDriveByState`, `isDriveByEnabled` and `isInDriveBy`, plus the server policy\n`setDriveByEnabled`. It includes client/server examples, permissions, phases and\ncleanup. Use the Unstable `.117` client and server, protocol 1.42.","natives":[],"permissions":[],"events":[]},{"anchor":"read-only-checks","heading":"Read-only checks","level":2,"text":"Every global below also exists under `Open77.players` with the indicated method.\n\n| Global | Method | Meaning |\n| --- | --- | --- |\n| `IsPlayerSwimming([playerId])` | `isSwimming` | Native surface swimming or diving |\n| `IsPlayerDiving([playerId])` | `isDiving` | Native underwater swimming state |\n| `IsPlayerAiming([playerId])` | `isAiming` | Graph-produced aim state, including toggle aim |\n| `IsPlayerShooting([playerId])` | `isShooting` | Graph trigger state; not confirmation of damage or every projectile |\n| `IsPlayerReloading([playerId])` | `isReloading` | Reload state |\n| `IsPlayerGrounded([playerId])` | `isGrounded` | Movement ground contact |\n| `IsPlayerCrouching([playerId])` | `isCrouching` | Crouched locomotion |\n| `IsPlayerSliding([playerId])` | `isSliding` | Sliding locomotion |\n| `IsPlayerSprinting([playerId])` | `isSprinting` | Sprint state, not a velocity threshold |\n| `IsPlayerVaulting([playerId])` | `isVaulting` | Vault state |\n| `IsPlayerJumping([playerId])` | `isJumping` | Jump/double-jump phase |\n| `IsPlayerFalling([playerId])` | `isFalling` | Falling phase |\n| `IsPlayerInVehicle([playerId])` | `isInVehicle` | Native mounted state |\n| `IsPlayerDriver([playerId])` | `isDriver` | Mounted in the driver's seat |\n| `IsPlayerPassenger([playerId])` | `isPassenger` | Mounted in a non-driver seat |\n\nRemote checks require a streamed, presentation-ready player and return\n`nil, 'player_not_streamed'` otherwise. Swimming/diving are currently local-only:\ntheir PSM field is not replicated, so querying a remote player returns\n`nil, 'state_not_replicated'` rather than inventing a false result. Remote values are\npresentation snapshots and must **not** authorize server-side rewards/damage.\n\n```lua\nlocal swimming, reason = IsPlayerSwimming()\nif swimming == nil then return end -- unavailable is not the same as false\nif swimming then Open77.log.info('Player is swimming') end\n```","natives":["IsPlayerSwimming","Open77.log.info","server:Open77.log.info"],"permissions":[],"events":[]},{"anchor":"resource-owned-controls","heading":"Resource-owned controls","level":2,"text":"These client calls affect the local player. Server scripts can signal their own client\nresource using the normal permissioned event API. They are gameplay controls, not\nserver-side anti-cheat enforcement.\n\n| Global / `Open77.players` method | Argument | Block bit |\n| --- | --- | --- |\n| `FreezePosition` / `freezePosition` | `true` requests native forced-freeze locomotion | 1 |\n| `FreezeRotation` / `freezeRotation` | `true` applies native camera/turn restriction | 2 |\n| `AllowJump` / `allowJump` | `false` blocks jumping | 4 |\n| `AllowAim` / `allowAim` | `false` blocks aim entry and exits held aim | 8 |\n| `AllowRunning` / `allowRunning` | `false` requests native forced walking | 16 |\n| `AllowShoot` / `allowShoot` | `false` blocks firearm firing decisions, including an ongoing automatic firing sequence | 32 |\n| `AllowCrouch` / `allowCrouch` | `false` requests native standing | 64 |\n| `AllowDodge` / `allowDodge` | `false` blocks ground/air dodge entry | 128 |\n| `AllowWeapons` / `allowWeapons` | `false` applies the native no-weapons restriction | 256 |\n| `AllowInteraction` / `allowInteraction` | `false` blocks native world interactions | 512 |\n\nOnly actual Lua booleans are accepted: the string `'false'` is rejected.\n`allow=true` releases **this resource's** block; it cannot override another resource\nor a vanilla restriction. `freeze=false` follows the same ownership rule.\n`Open77.players.resetControls()` releases every block owned by the caller.\n`Open77.players.getControlMask()` returns the combined Open77 block bitmask; it is not\nan inventory of vanilla restrictions. All require `players.controls`.\n\n```lua\nassert(AllowJump(false))\nassert(AllowShoot(false))\n-- When this activity ends:\nassert(Open77.players.resetControls())\n```\n\nThe host releases blocks on resource stop, session teardown, death and player-body\nreplacement. The native query has a two-second watchdog so abandoned runtime state\ndoes not leave a player locked indefinitely. These controls do not freeze a vehicle,\nstop a moving platform, cancel server teleports, or cancel projectiles already fired.","natives":["AllowJump","AllowShoot","Open77.players.resetControls"],"permissions":[],"events":[]},{"anchor":"compatibility-limits","heading":"Compatibility limits","level":2,"text":"Movement controls restrict native locomotion and input; they are not physics anchors. Airborne, moving-platform and traversal behavior can differ. Combat checks return `nil, 'state_unavailable'` until a graph sample exists. Swimming and diving use native state and do not synthesize missing remote-player data.\n\n`IsPointOnRoad` is **not implemented**. The exposed road/lane natives inspected so far\nquery the current player/vehicle; they do not classify arbitrary world coordinates.\nAn asphalt material or pedestrian navmesh hit would not be a correct replacement.","natives":[],"permissions":[],"events":[]}]},{"slug":"polyzone","file":"polyzone.md","title":"PolyZone","summary":"Use the PolyZone API in an Open77 client resource with ordinary `{x, y, z}` coordinates. Queries and watchers run inside the importing resource. Names follow [PolyZone 2.6.2](https://github.com/mkafrin/PolyZone); engine integration and the editor are Open77-specific.","sections":[{"anchor":"polyzone","heading":"PolyZone","level":1,"text":"Use the PolyZone API in an Open77 client resource with ordinary `{x, y, z}` coordinates. Queries and watchers run inside the importing resource. Names follow [PolyZone 2.6.2](https://github.com/mkafrin/PolyZone); engine integration and the editor are Open77-specific.\n\nSource: [`polyzone`](https://github.com/Open2077/open77-resources/tree/main/libraries/polyzone).","natives":[],"permissions":[],"events":[]},{"anchor":"install-and-import","heading":"Install and import","level":2,"text":"New to `require`? Read [Lua modules and require](lua-modules.md) for local\nmodules, published dependencies, caching and the difference from exports.\n\nAdd `polyzone` and your resource to the server's `resources.load`. Declare:\n\n```lua\ndependency 'polyzone >=1.0.0'\npermissions { 'world.query', 'world.debug', 'ui.vanilla.map', 'network.events', 'local.events' }\n```\n\nOnly declare permissions you use: mathematical containment needs none;\nplayer/entity reads need `world.query`; debug drawing needs `world.debug`;\ndebug map blips need `ui.vanilla.map`; network/local event operations need the\ncorresponding event permission.\n\n```lua\nlocal PZ = assert(require('@polyzone'))\nlocal PolyZone, BoxZone = PZ.PolyZone, PZ.BoxZone\nlocal CircleZone, EntityZone, ComboZone = PZ.CircleZone, PZ.EntityZone, PZ.ComboZone\n-- Optional alternative: PZ.installGlobals()\n```\n\nThis requires the client update adding dependency modules and native geometry;\ninstalling the Lua folder alone does not upgrade an older client. The import\nreturns `nil, reason` on a missing/stopped/undeclared dependency.","natives":[],"permissions":["world.query","world.debug","ui.vanilla.map","network.events","local.events"],"events":[]},{"anchor":"shapes","heading":"Shapes","level":2,"text":"```lua\nlocal plaza = PolyZone:Create({\n {x=100,y=100}, {x=115,y=100}, {x=115,y=110},\n {x=108,y=110}, {x=108,y=120}, {x=100,y=120},\n}, {name='plaza', minZ=20, maxZ=28, data={job='taxi'}})\n\nlocal garage = BoxZone:Create({x=150,y=100,z=20}, 12, 6, {\n name='garage', heading=45, minZ=19, maxZ=25,\n scale={1,1,1}, offset={0,0,0},\n})\n\nlocal area = CircleZone:Create({x=120,y=120,z=20}, 15, {name='area'})\nlocal sphere = CircleZone:Create({x=120,y=120,z=20}, 15, {name='sphere',useZ=true})\n```\n\n- Polygon: concave XY winding-number containment; optional inclusive Z limits.\n Concave shapes are supported; use simple, non-self-intersecting polygons.\n Edge classification follows winding-number rules; it is not universally\n inclusive. Z is independent of the Z values of polygon vertices.\n- Box: `length` is local Y, `width` is local X. Heading is degrees rotating\n around +Z. Box faces and Z limits are inclusive. `scale`/`offset` accept\n three symmetric values `{length,width,height}` or six asymmetric values\n `{forward,back,left,right,up,down}`. Offsets are metres, scales multipliers.\n- Circle: unbounded in Z by default. `useZ=true` makes a sphere. The radius\n comparison is strict: a point exactly on the radius is outside.\n\nCommon options: `name`, `data`, `debugPoly`, `debugBlip`, `debugColors`\n(`walls`, `outline`, `grid`, RGB arrays), `debugColor` (circle RGB), and the\nOpen77 extension `debugDistance` (default 250 metres).","natives":[],"permissions":[],"events":[]},{"anchor":"methods","heading":"Methods","level":3,"text":"| Class | Methods |\n|---|---|\n| All zones | `:new(...)`, `:Create(...)`, `:isPointInside(point)`, `:onPointInOut(getPoint,callback,intervalMs?)`, `:onPlayerInOut(callback,intervalMs?)`, `:setPaused(bool)`, `:isPaused()`, `:destroy()`, `:draw(force?)`, `:addEvent(name)`, `:removeEvent(name)`, `:addDebugBlip()` |\n| Polygon / box | `:getBoundingBoxMin()`, `:getBoundingBoxMax()`, `:getBoundingBoxSize()`, `:getBoundingBoxCenter()`, `:TransformPoint(point)` |\n| Box | `:getHeading()`, `:setHeading(degrees)`, `:setCenter(point)`, `:getLength()`, `:setLength(metres)`, `:getWidth()`, `:setWidth(metres)` |\n| Circle / sphere | `:getCenter()`, `:setCenter(point)`, `:getRadius()`, `:setRadius(metres)` |\n| Entity | Box containment plus `:getCenter()`, `:onEntityDamaged(callback)`; the entity's transform overrides manual placement setters |\n| Combo | `:AddZone(zone)`, `:RemoveZone(nameOrPredicate)`, `:getZones(point)`, `:isPointInside(point,name?)`, `:isPointInsideExhaustive(point,outArray?)`, `:onPointInOutExhaustive(getPoint,callback,intervalMs?)`, `:onPlayerInOutExhaustive(callback,intervalMs?)`, `:addEvent(eventName,zoneName?)`, `:printInfo()` |\n\n`getBoundingBox*` refers to the box's unrotated local-axis bounds placed at\nits centre, as upstream does; it is not a world-axis AABB of the rotated box.\nUse `TransformPoint` on corners when you need world corners.\n\nUtilities: `PolyZone.getPlayerPosition()`, `PolyZone.getPlayerHeadPosition()`,\n`PolyZone.rotate(origin,point,degrees)`, `PolyZone.drawPoly(zone,force?)`,\n`BoxZone.calculateMinAndMaxZ(minZ,maxZ,scaleZ,offsetZ)` and\n`PolyZone.ensureMetatable(value)` (rehydrates methods without exposing metatable\nmutation; serialized callbacks, references and debug registrations are not restored).\nPrefer constructors for saved configuration. Open77 extensions: `PZ.list()`,\n`PZ.destroyAll()`, `PZ.serialize(zone)` and watcher cancellation handles.","natives":[],"permissions":[],"events":[]},{"anchor":"entry-exit-and-groups","heading":"Entry / exit and groups","level":2,"text":"```lua\nlocal all = ComboZone:Create({plaza, garage, area}, {name='job_areas'})\nlocal cancel = all:onPlayerInOutExhaustive(function(inside, position, current, entered, left)\n for _, zone in ipairs(entered or {}) do print('enter '..zone.name) end\n for _, zone in ipairs(left or {}) do print('exit '..zone.name) end\nend, 250)\n```\n\nPolling defaults to 500 ms; `0` checks each frame. These are sampled positions,\nnot swept collision tests: a player can cross a thin zone between polls.\nBase shapes emit only state transitions from an initial outside state; Combo\nalso emits its first outside sample. The normal Combo callback is\n`callback(inside, point, zone)` and does not report overlapping member changes\nwhile the combined flag stays true. The exhaustive callback above does.\n`entered`/`left` are nil when there is no change in that direction.\n\n`setPaused(true)` pauses callbacks, not explicit containment queries. `cancel()`\nremoves only that watcher. Destroying a Combo destroys its remaining children;\n`RemoveZone` returns the removed child without destroying it. Avoid sharing\nchildren between independently owned groups if destroying one must not affect\nthe other. Cyclic groups are rejected. Member priority follows insertion order.\n\nMissing player incarnation or a streamed-out entity produces an outside state,\nnot a fake position at world origin. It can emit one exit and later re-enter.\nHead position is nil when the native Head slot is unavailable.","natives":[],"permissions":[],"events":[]},{"anchor":"moving-entities","heading":"Moving entities","level":2,"text":"```lua\n-- Typed network references survive a change of local presentation handle.\nlocal trunk = EntityZone:Create({kind='vehicle',id=vehicleId}, {\n name='vehicle_area', useZ=true, offset={1,1,0.5},\n})\nlocal npcArea = EntityZone:Create({kind='npc',id=npcId}, {useZ=true})\nlocal playerArea = EntityZone:Create({kind='player',id=playerId}, {useZ=true})\nlocal localPlayerArea = EntityZone:Create(0, {useZ=true})\n```\n\nA numeric value is a generation-checked **local entity handle**, not a network\nvehicle ID or raw REDengine pointer. `0` denotes the local player. Prefer typed\nIDs for replicated entities. Headings and Z envelopes follow the entity;\npitch/roll are included when computing the eight-corner vertical envelope.\nXY containment remains a yaw-oriented box, matching the EntityZone convention.\n\nDimensions come from currently loaded static/skinned component mesh bounds,\nincluding camera-hidden body parts so switching to FPP does not remove them.\nThey are reference mesh bounds, not an animation-deformed collision hull or GTA\nmodel dimensions. If bounds are unavailable, the\nzone is outside and exposes `zone.error`. You can supply explicit local-space\n`dimensions={min={x,y,z},max={x,y,z}}` to define a stable interaction volume.\nNo new asset streaming is requested by the geometry query.\n\n`onEntityDamaged(function(died, attackerId, weapon, isMelee, details) ... end)`\nobserves canonical health decreases at 50 ms intervals. `details` includes\nhealth and revision. This is **not a per-hit GTA event**: damage followed by\nhealing between samples can be missed, and several hits may collapse into one\nobservation. `attackerId` is a network player ID when available; `weapon` is nil\nwhen the protocol provides no weapon. Do not treat these as GTA handles/hashes.\nUse server combat events for authoritative damage logic.","natives":[],"permissions":[],"events":[]},{"anchor":"grid-and-debug-performance","heading":"Grid and debug performance","level":2,"text":"Polygons support `useGrid` (default true), `lazyGrid` (default true), and\n`gridDivisions` (default 30, range 1–256). Full interior cells skip the winding\ntest; edge cells still use exact containment. Eager construction yields to the\nresource scheduler. `debugGrid=true` builds the grid eagerly.\n\nCombos use a sparse broadphase. Moving entities and nested combos are checked\ndynamically; resizing/repositioning a static member invalidates the index.\nThere is no permanent polling coroutine when a VM has no watchers or debug zones.\n\nDebug wireframes/walls use Open77's native compositor, not a continuously updated\nCEF page. They are developer overlays, **visible through scenery**, not depth-tested\nmeshes. Geometry is bounded by quotas and expires if not refreshed. Large debug\ngrids display a bounded subset of cells; containment still uses the full grid.\nAn oversized debug shape can exceed the drawing quota without affecting its\ncontainment queries. Debug geometry is composited below WebUI menus.\nKeep `debugPoly`/`debugGrid` disabled in normal player resources.","natives":[],"permissions":[],"events":[]},{"anchor":"zone-filtered-events","heading":"Zone-filtered events","level":2,"text":"```lua\n-- Client, in a resource importing PolyZone:\nplaza:addEvent('taxi:announcement')\nAddEventHandler('taxi:announcement', function(text) print(text) end)\n\n-- Server, in a coroutine / command / event handler:\nlocal pending, reason = Open77.exports.call('polyzone', 'TriggerZoneEvent', 'taxi:announcement', 'Taxi available')\nif pending then\n local ok, error = pending:await()\n if not ok then print(error) end\nelse print(reason) end\n```\n\nThe provider broadcasts `__PolyZone__:` and the consumer delivers the\nlocal event only if the player is inside. There is deliberately no arbitrary\nclient-to-server broadcast relay. Client zone checks are conveniences, **not\nsecurity boundaries**: the server must validate proximity, buckets and job rules\nbefore awarding money/items or authorizing actions.","natives":["AddEventHandler","server:AddEventHandler","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"editor","heading":"Editor","level":2,"text":"Grant `command.pzcreate`, `command.pzadd`, `command.pzundo`, `command.pzfinish`,\n`command.pzlast`, `command.pzcancel`, `command.pzcomboinfo`, `command.pzedit`\nto trusted developers using the server ACL.\n\n- `/pzcreate poly|box|circle [name] [length-or-radius] [width]`: begin at your feet.\n- `/pzadd`, `/pzundo`: add/remove a polygon point (maximum 128 editor vertices).\n- `/pzedit`: show/hide the WebUI editor; F9 releases/reacquires focus.\n- F10: add a vertex while walking. Fields edit size, heading, height or sphere mode.\n- `/pzfinish`: produce Lua, keep a local KVP backup and request a server save.\n- `/pzlast [name]`: repeat the last completed box/circle at your current position.\n- `/pzcancel`: discard the draft. `/pzcomboinfo`: print active Combo counts.\n\nExports append to `polyzone/data/polyzone_created_zones.txt`; the server checks\n`command.pzfinish` again and limits size/frequency. Generated code is never\nexecuted automatically. Use Copy to paste it into your own resource.\nThe editor is a local authoring tool; the server ACL protects persistent saves.","natives":[],"permissions":[],"events":[]},{"anchor":"new-engine-api-and-lifecycle","heading":"New engine API and lifecycle","level":2,"text":"`Open77.world.entityGeometry(handleOrTypedReference)` (`world.query`) returns\n`{attached,entity,position,forward,orientation,bounds?,head?,damage?}` or `nil,reason`.\n`bounds` contains `min`, `max`, `source='loaded_mesh_bounds'`. `orientation` is\n`{x,y,z,w}`. Missing bounds/head/health are omitted, never fabricated.\n\n```lua\nlocal ok, reason = Open77.debugDraw.set('my-area', {\n lines={{a={x=0,y=0,z=20},b={x=5,y=0,z=20},color='#19d5e0ff'}},\n triangles={}, ttl=0.5, maxDistance=250, thickness=1.5,\n})\nOpen77.debugDraw.clear('my-area') -- clear() clears only this resource's geometry\n```\n\n`world.debug` is needed to set geometry. Coordinates must be finite and bounded\nto ±1,000,000. Colours use `#RRGGBB` or `#RRGGBBAA`. Limits: 512 lines and 256\ntriangles per batch, 32 tags per resource, 128 total batches, 8,192 total input\nvertices. TTL is 0.05–10 seconds, maximum distance 1–5,000 metres, thickness\n0.5–8 pixels. Replacing a tag is atomic; invalid input returns `nil,reason`.\n\n`require('@provider/module')` runs only an explicitly published `files` entry of\na declared, running dependency. It executes in the **caller's** VM, budget and\npermissions. Imports are cached by resolved module path; circular imports are\nrejected. Each consumer therefore owns independent zones and callbacks. No\nLua function or mutable zone object crosses a VM boundary.\n\nStopping/reloading a consumer cancels its tasks/handlers and releases its blips\nand debug drawings. Startup failure, task failure and validation cleanup also\nrelease native drawings; TTL is a backstop. Reload consumers to pick up changed\nlibrary code: existing Lua references do not silently change implementation.\nNo wire-protocol change or dedicated-server C# change is required for this port.","natives":["Open77.debugDraw.set","Open77.debugDraw.clear"],"permissions":[],"events":[]}]},{"slug":"prediction","file":"prediction.md","title":"Prediction","summary":"Prediction makes an action feel instant for the player who performs it. When you hit someone, open a door or crash into a car, **your own screen shows the result straight away** instead of waiting a round trip for the server. The server still decides: it **confirms** the action, and the real reaction takes over from the predicted one without a visible change, or it **refuses** it, and the predicti","sections":[{"anchor":"prediction","heading":"Prediction","level":1,"text":"Prediction makes an action feel instant for the player who performs it. When you hit\nsomeone, open a door or crash into a car, **your own screen shows the result straight\naway** instead of waiting a round trip for the server. The server still decides: it\n**confirms** the action, and the real reaction takes over from the predicted one without a\nvisible change, or it **refuses** it, and the prediction is **rolled back smoothly**: a\nknocked-down body gets back up, a pushed car eases back to where it really is, a door closes\nagain.\n\nA prediction only changes what the acting player sees before the verdict. It never deals\ndamage, never moves anything on other players' screens and never grants authority: every\noutcome is still the server's. Client and server must run the same Open77 build.","natives":[],"permissions":[],"events":[]},{"anchor":"what-is-built-in-and-what-the-resources-add","heading":"What is built in and what the resources add","level":2,"text":"| Layer | What it provides | What it needs |\n|---|---|---|\n| **Open77 client** (native) | Car against car (`carContact`), car against a network NPC opted in with [`behavior.vehicleContactEnabled`](npc-behavior.md#vehicle-contact-damage) (`carContact`), car against player (`playerContact`), door (`door`, on the network doors of the bundled [`open77_doors`](https://github.com/Open2077/open77-resources/tree/main/system/open77_doors) service) and blast (`blast`) prediction. | Nothing: always on. Compiled defaults: every family on, and no new prediction starts above a 250 ms round trip. |\n| **`open77_cyberware`** (bundled system resource, already running) | Melee (`melee`), Ground Slam (`slam`) and quickhack (`hack`) knockdown prediction. | The gamemode's combat policy, published with the local client event [`open77_prediction:policy`](#melee-ground-slam-and-quickhack-predictions); Freeroam publishes one. Without it these three never predict. |\n| **`open77_prediction`** (bundled platform resource, starts automatically) | Only the operator layer: the per-family switches, the round-trip ceiling, the [`open77.prediction` state bag key](#the-published-policy), [telemetry](#operator-controls), the `prediction` admin command and the [`restrict` export](#gamemode-restrictions) for gamemodes. | Nothing depends on it. Without it, clients keep their compiled defaults and the server has no switches and no telemetry. |","natives":[],"permissions":[],"events":[]},{"anchor":"do-i-need-open77prediction-no-it-is-optional","heading":"Do I need open77_prediction? No, it is optional","level":2,"text":"`open77_prediction` is an **extra**. Prediction works without it, for three reasons:\n\n1. **The predictions are native client code, not a resource.** Showing the result early,\n adopting it when the server confirms and rolling it back when the server refuses all happen\n in the Open77 client.\n2. **The client ships safe defaults.** Without the resource, every family is on and no new\n prediction starts above a 250 ms round trip.\n3. **The server still decides everything that counts.** Every hit, damage value, push and death\n is the server's call, so the game stays correct and fair with or without the resource.\n\nWhat you lose without it is only **control and visibility**: the per-family switches, the\nping ceiling setting, gamemode `restrict`, telemetry and the `prediction` admin command. Keep\nit when you want to turn a family off, tune the ceiling or watch refutation rates.\n\nIt is loaded by default on every server. It only goes missing when a\n[`resources.load` allowlist](#server-setup) omits it.\n\nMelee, Ground Slam and quickhack prediction need the gamemode's\n[combat policy](#melee-ground-slam-and-quickhack-predictions) either way; that requirement is\nindependent of this resource.","natives":[],"permissions":[],"events":[]},{"anchor":"prediction-families","heading":"Prediction families","level":2,"text":"Each kind of prediction is a **family** with its own switch.\n\n| Family | What the acting player sees early |\n|---|---|\n| `melee` | A melee hit knocks the victim down on the attacker's screen before the server's verdict. |\n| `slam` | A Ground Slam knocks nearby players down on the attacker's screen before the server's verdict. |\n| `hack` | A knockdown quickhack drops its target on the hacker's screen before the server's verdict. |\n| `door` | A network door starts opening as soon as it is used, before the server accepts the request. |\n| `blast` | Cars and players another client simulates react to the shooter's own blast before its relay returns. |\n| `carContact` | A remote car struck by the local driver starts moving at once instead of about 250 ms later; an opted-in network NPC the driver hits starts falling at once. |\n| `playerContact` | A player hit by the local driver starts falling at once, before the victim's fall cue returns. |\n\nEvery prediction ends in one of three ways, which is what the counters report:\n\n- **adopted**: the server's own reaction took it over or confirmed it;\n- **refuted**: the server refused it, or did not answer in time, and it was rolled back;\n- **skipped**: the policy stopped it before it started, because its family is switched off\n (`skippedDisabled`) or the round trip is above the latency ceiling (`skippedLatency`).\n\nA late verdict makes a refusal far more visible, so a client whose measured round trip is above\nthe **latency ceiling** (250 ms by default) starts no new prediction; one already playing\nfinishes normally. An unknown round trip, before the first measurement, does not block anything.","natives":[],"permissions":[],"events":[]},{"anchor":"api-at-a-glance","heading":"API at a glance","level":2,"text":"**Prediction**\n\n- [`Open77.prediction.stats`](/docs/api/client/open77-prediction#stats): the live prediction\n policy, this client's round trip and each family's counters.\n- [`Open77.prediction.setPolicy`](/docs/api/client/open77-prediction#setpolicy): the switches and\n latency ceiling this client applies. Needs the dedicated `prediction.policy` permission; the\n bundled `open77_prediction` resource is its only intended caller. See\n [Advanced](#advanced-native-calls).\n- [`Open77.motion.predictAction`](/docs/api/client/open77-motion#predictaction),\n [`refutePrediction`](/docs/api/client/open77-motion#refuteprediction) and\n [`predictionStats`](/docs/api/client/open77-motion#predictionstats), **experimental**:\n start, roll back and count the melee, Ground Slam and quickhack knockdown predictions.\n- `open77_prediction`: the [`prediction` admin command](#operator-controls), the gamemode\n export [`restrict`](#gamemode-restrictions) and the seven\n [prediction families](#prediction-families), published to every client through the\n [`open77.prediction` state bag key](#the-published-policy).\n- [`resources.load` allowlists](#server-setup) must list `open77_prediction`, or the server\n has no switches and no telemetry.\n\n**NPCs**\n\n- [`Open77.npcs.presence`](/docs/api/server/open77-npcs#presence): where an NPC is and which\n client simulates it, in one constant-time read. See [NPC spawning](npcs.md#server-api-reference).\n- [`behavior.vehicleContactEnabled`](npc-behavior.md#vehicle-contact-damage): opts an NPC in to\n server-priced damage when a car hits it.\n- [`behavior.hitPricing`](npc-behavior.md#who-prices-player-hits-hitpricing): `\"platform\"` lets\n the server price player hits on a mortal NPC instead of the resource that created it.\n- [`moveTo` end reasons](npcs.md#move-follow-and-patrol) `path_blocked`, `nearest_reachable`\n and `report_jump`: a walk never arrives by teleport, and the task says why it stopped.\n\n**Doors**\n\n- [`setNpcPassage`](doors.md#npc-passage) and `configure(..., { npcPassage = ... })` with the\n modes `public`, `resource`, `always` and `never`: which network NPCs may open a door.\n- [`npcStats`](doors.md#npc-passage): NPC door openings admitted and refused.\n- [`resolveAction(ticket, false)`](doors.md#door-actions-force-pay-and-hack) answers\n `false, \"refused_by_owner\"`: `true` only ever means the door opened.\n\n**Weapons and explosions**\n\n- [`Open77.weapons.applyBlast`](/docs/api/client/open77-weapons#applyblast): pushes the cars this\n client simulates and knocks down its NPC copies for a server-relayed blast.\n- [`relayBlast`](weapons-api.md#explosions-on-every-screen-the-blast-relay): relays a blast\n another server resource admitted, so every screen sees cars move and NPCs fall.\n- [`open77_blasts`](weapons-api.md#explosions-on-every-screen-the-blast-relay) convar: `off`\n disables the blast relay.\n- [`onPlayerExplosion`](weapons-api.md#gadgets-grenades-in-the-quick-slots): the server event for\n a detonation a player's client observed.\n- [`onVehicleWeaponExplosion`](vehicle-weapons.md#current-damage-policy): the server event for a\n mounted weapon's admitted explosion.\n- [`onConsumableUsed`](consumables-api.md#observe-actual-uses-and-apply-healing-policy): a\n reserved host event that also credits grenade charges to the blast relay.\n\n**Server configuration**\n\n- [`combat.handheldDischarges`](npc-behavior.md#server-configuration) (default `true`): the\n server admits each handheld shot, which platform-priced NPC hits require.\n- [`combat.handheldVehicleDamage`](vehicle-weapons.md#handheld-guns-on-cars-another-player-drives)\n (default `false`): the server applies handheld gun hits on cars another player drives.","natives":[],"permissions":[],"events":[]},{"anchor":"server-setup","heading":"Server setup","level":2,"text":"`open77_prediction` is a **platform resource**: it ships with every Open77 server and starts\nautomatically, so its code is not in your `resources` folder. Do not copy it into your\nresources; the bundled one is the one that runs. Its source is published for reference only,\nread-only:\n[open77_prediction on GitHub](https://github.com/Open2077/open77-resources/tree/main/system/open77_prediction).\n\n**If `server.jsonc` selects resources with a `resources.load` allowlist, add it** to keep the\nswitches, the admin command and the telemetry. A resource the list does not select is never\nstarted, and nothing is logged about it:\n\n```jsonc\n\"resources\": {\n \"load\": [\n \"open77_shell\",\n \"open77_chat\",\n \"open77_prediction\", // add this line to an exact list\n \"freeroam\"\n ]\n}\n```\n\nA wildcard rule such as `\"open77_*\"` already selects it. `load` is read at startup, so restart\nthe server after editing it. See\n[Selecting which resources load](server-resources.md#selecting-which-resources-load).\nClient and server must run the same Open77 build: the native prediction code ships in the\nclient and the verdicts come from the server.","natives":[],"permissions":[],"events":[]},{"anchor":"operator-controls","heading":"Operator controls","level":2,"text":"The seven family switches, the latency ceiling and the telemetry interval are\n[operator tunables](tunables.md) of `open77_prediction`. Change them in Warden, with\n`tunable.set open77_prediction ` at the console, or with the restricted\n`prediction` command (ACL `command.prediction`):\n\n```text\nprediction status: policy, gamemode restrictions, telemetry totals\nprediction off blast turn one family off (melee, slam, hack, door, blast, carContact, playerContact)\nprediction on blast turn it back on\nprediction ping 200 latency ceiling in milliseconds, 0 to 5000; 0 removes it\nprediction telemetry 120 report interval in seconds, 0 to 3600; 0 turns reports off\n```\n\n| Tunable | Default | Meaning |\n|---|---|---|\n| `melee`, `slam`, `hack`, `door`, `blast`, `carContact`, `playerContact` | `true` | Off: this family never predicts; the server's reaction is unchanged. |\n| `maxPingMs` | `250` | Above this round trip a client starts no new prediction. `0` removes the ceiling. |\n| `telemetrySeconds` | `60` | How often each client reports its counters. `0` turns reports off; below 15 s counts as 15 s. |\n\nChanges are saved and reach every client at once, late joiners included; they apply to the next\nprediction.\n\n**Telemetry.** Each client reports only the counters that changed, once per interval, and nothing\nwhen nothing happened. The server logs one line per window:\n\n```text\ntelemetry 60s clients=12 reports=12 dropped=0 | melee p=40 a=37 r=3 off=0 lag=2 rr=8% | ...\n```\n\n`p`, `a` and `r` are predicted, adopted and refuted; `off` and `lag` are the two kinds of skip;\n`rr` is refuted / (adopted + refuted). Up to five `high refutation player= ping= ...`\nlines follow for a family with at least five verdicts of which half or more were refuted.\nTelemetry is informational only and never an authority input. The resource's server exports\n`policy()` (the effective policy) and `telemetry()` (cumulative totals and the last window)\nreturn the same data as tables.","natives":[],"permissions":[],"events":[]},{"anchor":"gamemode-restrictions","heading":"Gamemode restrictions","level":2,"text":"A gamemode can only **narrow** the operator's policy, for as long as it runs: turn families off\nand lower the ceiling (1–5000 ms). It cannot turn on a family the operator turned off.\n\n```lua\n-- Server script of a gamemode: no blast or car-contact prediction, and a lower ceiling.\nlocal function restrictPrediction()\n local promise, reason = Open77.exports.call(\"open77_prediction\", \"restrict\", {\n families = { blast = false, carContact = false },\n maxPingMs = 150,\n })\n if not promise then print(\"open77_prediction unavailable: \" .. tostring(reason)); return end\n local ok, failure = promise:await()\n if not ok then print(\"prediction restriction refused: \" .. tostring(failure)) end\nend\n\nAddEventHandler(\"onResourceStart\", function(name)\n if name == GetCurrentResourceName() then CreateThread(restrictPrediction) end\nend)\n-- Restrictions do not survive a stop or restart of open77_prediction; it announces itself again.\nAddEventHandler(\"open77_prediction:ready\", function() CreateThread(restrictPrediction) end)\n```\n\nA restriction is keyed by the calling resource and replaced by its next call. `restrict(nil)`,\n`clearRestriction()` or the caller's stop withdraws it. It survives a reload of\n`open77_prediction`. The effective policy is the operator's switches with every running\nrestriction applied, and the lowest ceiling wins. Refusals: `unknown_caller`,\n`invalid_restriction`, `invalid_prediction_family`, `invalid_ping_ceiling` and\n`restriction_limit` (32 restricting resources).","natives":["Open77.exports.call","server:Open77.exports.call","AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName","CreateThread","server:CreateThread"],"permissions":[],"events":[]},{"anchor":"the-published-policy","heading":"The published policy","level":2,"text":"The server publishes the effective policy in the global [state bag](state-bags.md) under the key\n`open77.prediction`: `{ v = 1, families = { melee = true, ... }, maxPingMs, telemetrySeconds }`.\nEvery client applies it at once, late joiners included. The key is cleared when\n`open77_prediction` stops, and clients fall back to their defaults.\n\n```lua\n-- Client: read the policy the server publishes, and follow its changes.\nlocal policy = Open77.state.global:get(\"open77.prediction\")\nif policy then print(\"blast prediction\", policy.families.blast, \"ceiling\", policy.maxPingMs) end\n\nOpen77.state.onChange(Open77.state.global, \"open77.prediction\", function(_, _, value)\n print(\"prediction policy changed; ceiling\", value and value.maxPingMs)\nend)\n```","natives":["Open77.state.onChange","server:Open77.state.onChange"],"permissions":[],"events":[]},{"anchor":"melee-ground-slam-and-quickhack-predictions","heading":"Melee, Ground Slam and quickhack predictions","level":2,"text":"The three action families are started by the bundled [`open77_cyberware`](https://github.com/Open2077/open77-resources/tree/main/system/open77_cyberware) resource, and only\nwhere the gamemode has published its combat policy. Without that policy they **fail closed**:\nthey never predict. This is deliberate. Only the gamemode knows whether a hit can land at all:\nits PvP switch, the bucket it plays in, its safe zones and its damage multipliers. A client\nguessing without them would predict knockdowns the server then refuses, and the attacker would\nsee the victim fall and get back up. The client must not predict a hit the server will refuse.\n\nThe gamemode publishes the policy from a client script with the local event\n`open77_prediction:policy`: sent when the gamemode starts, sent again on\n`open77_prediction:requestPolicy`, and withdrawn with `{ enabled = false }` when it stops.\nNothing goes over the network and no permission is needed. Freeroam does this for the street\nbucket and its spawn safe zones. A complete example resource is published for reference; copy\nits `client/main.lua` into your gamemode and fill the values from your own configuration:\n[prediction_policy_example on GitHub](https://github.com/Open2077/open77-resources/tree/main/rp/prediction_policy_example).\n\n| Field | Meaning |\n|---|---|\n| `owner` | Your resource name. The policy is dropped when that resource stops. |\n| `enabled` | `true` when PvP is on. `false` or a missing policy means no melee, Slam or hack prediction. |\n| `bucket` | The routing bucket these rules cover. Players in other buckets are not predicted. |\n| `safeZoneRadius`, `safeZones` | No prediction when the attacker or the victim is inside one of these spheres. |\n| `damageMultiplier`, `meleeMultiplier`, `explosionMultiplier`, `headshotMultiplier` | Your damage scales. A contact that might be lethal after them is not predicted. |\n\n```lua\n-- Client script of a gamemode: allow action predictions in bucket 0, outside the safe zones.\nlocal function publish()\n TriggerEvent(\"open77_prediction:policy\", {\n owner = GetCurrentResourceName(),\n enabled = true,\n bucket = 0,\n safeZoneRadius = 30,\n safeZones = { { x = -1448.0, y = 96.0, z = 17.0 } },\n damageMultiplier = 1, meleeMultiplier = 1, explosionMultiplier = 1, headshotMultiplier = 1,\n })\nend\nAddEventHandler(\"open77_prediction:requestPolicy\", publish)\nAddEventHandler(\"onClientResourceStart\", function(name)\n if name == GetCurrentResourceName() then publish() end\nend)\nAddEventHandler(\"onClientResourceStop\", function(name)\n if name == GetCurrentResourceName() then TriggerEvent(\"open77_prediction:policy\", { enabled = false }) end\nend)\n```\n\nKeep the multipliers equal to your server's damage policy: a contact that might be lethal is not\npredicted, so the victim is never shown getting up from a hit that killed them. Both players must\nbe alive and in the policy's bucket, outside the safe zones and not already knocked down. The\noperator switches and the latency ceiling of `open77_prediction` apply on top of this policy.","natives":["TriggerEvent","server:TriggerEvent","GetCurrentResourceName","server:GetCurrentResourceName","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"advanced-native-calls","heading":"Advanced: native calls","level":2,"text":"Resources rarely need these directly; the bundled resources call them.\n\n| Call | Permission | Purpose |\n|---|---|---|\n| [`Open77.prediction.stats()`](/docs/api/client/open77-prediction#stats) | none | `{ maxPingMs, ping, owner?, families }` with each family's `enabled`, `predicted`, `adopted`, `refuted`, `skippedDisabled` and `skippedLatency`. |\n| [`Open77.prediction.setPolicy(policy)`](/docs/api/client/open77-prediction#setpolicy) | `prediction.policy` | Sets what this client predicts: `{ families = { [name] = boolean }, maxPingMs = 0..5000 }`, or `nil` for the compiled defaults. Leave it to `open77_prediction`: a second caller replaces the server's policy on that client. |\n| [`Open77.motion.predictAction(source, entity, x, y, expectedDamage)`](/docs/api/client/open77-motion#predictaction) | `player.motion.project` | **Experimental.** Starts a melee, slam or hack knockdown prediction on a remote player's proxy; returns a request ID. |\n| [`Open77.motion.refutePrediction(request)`](/docs/api/client/open77-motion#refuteprediction) | `player.motion.project` | **Experimental.** Blends a refused prediction back to the live pose. |\n| [`Open77.motion.predictionStats()`](/docs/api/client/open77-motion#predictionstats) | `player.motion.project` | **Experimental.** JSON counters of the three action families. |\n\n```lua\n-- Client: print this client's prediction counters.\nRegisterCommand(\"predstats\", function()\n local stats, reason = Open77.prediction.stats()\n if not stats then print(reason); return end\n print((\"ping %d ms, ceiling %d ms\"):format(stats.ping, stats.maxPingMs))\n for name, family in pairs(stats.families) do\n print(name, family.enabled, family.predicted, family.adopted, family.refuted)\n end\nend, false)\n```","natives":["RegisterCommand","server:RegisterCommand","Open77.prediction.stats"],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"- Prediction is presentation only. Damage, positions, deaths and door states remain the\n server's, and telemetry is informational.\n- \"Refuted\" follows each family's own rules, so rates are comparable over time within a family,\n not across families.\n- The round trip is the transport's smoothed measurement: a short spike may not stop a\n prediction, and a recovered connection may keep skipping for a moment.\n- Telemetry is reported by clients and bounded by the server; treat it as a health signal, not\n as evidence against a player.","natives":[],"permissions":[],"events":[]}]},{"slug":"props","file":"props.md","title":"Server-owned world props","summary":"Create server-owned props, lights and effects that stream to nearby players. The server controls identity, transform, routing bucket and lifetime. Client resources can query projections or create local-only decorations.","sections":[{"anchor":"server-owned-world-props","heading":"Server-owned world props","level":1,"text":"Create server-owned props, lights and effects that stream to nearby players. The server controls identity, transform, routing bucket and lifetime. Client resources can query projections or create local-only decorations.\n\nOne registry holds three kinds. A prop, a light and a looping effect are all \"a thing at a\ntransform that streams and can be removed\", so they share `Open77.props` and are told apart by\n`kind`. Looping effects keep their own author-facing entry point in\n[Visual and audio effects](effects.md) and land in this same registry regardless.","natives":[],"permissions":[],"events":[]},{"anchor":"model-and-collision-limits","heading":"Model and collision limits","level":2,"text":"- Use a curated alias or raw `.mesh` depot path. Each curated mesh uses a generated host; `prop.catalog` shows the models available to the running client.\n- Create, move, remove and carry state is replicated. Late joiners receive a snapshot; out-of-range props are not projected.\n- Lights update color and enabled state without respawning. Persistent effects resume after stream-in and stop when removed.\n- Collision depends on the mesh. Of 184 generated hosts, 116 contain collision; the remaining hosts are visual only. Check `prop.catalog` or `docs/generated/prop-hosts.json` per alias. Objects such as `barrier.hesco`, `container.shipping`, `street.hydrant` and `industrial.forklift` may not block movement.\n- Detached physical hosts reject `collision = false`: their visible component also provides collision. Attached props use visual hosts without physics and can intersect scenery.\n- Raw `.ent` templates are disabled by default with `template_backend_disabled` because some decorative entity roots can crash the client.\n- An alias without a resolvable mesh falls back to a marker cylinder. Inspect the client catalogue after changing model data.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-permissions","heading":"Manifest permissions","level":2,"text":"```lua\npermissions { \"world.props\" }\n```\n\nServer mutation requires `world.props`. The same string on a **client** manifest grants the\nread-only projection view and the local decorative API; a client resource that omits it gets an\nempty `Open77.props.list()` and `permission_denied:world.props` from\n`Open77.props.create` on the client.","natives":[],"permissions":["world.props"],"events":[]},{"anchor":"architecture","heading":"Architecture","level":2,"text":"- Prop IDs are **decimal strings**, so their full 64-bit identity survives Lua number\n conversion. Store, compare and return them unchanged; never pass one through `tonumber`.\n- A prop is streamed only to players in the same routing bucket and inside its interest radius.\n Hysteresis keeps a player standing on the boundary from cycling between stream-in and\n stream-out.\n- A prop belongs to the resource that created it. **A resource may read every prop in the\n registry, but may only mutate or remove the ones it created**; another resource's prop answers\n `owned_by_another_resource`.\n- Stopping or reloading a resource removes **everything** it created, with the removal reason\n `resource_stopped`. Nothing a resource mints may outlive it, or the world accumulates props no\n script can ever remove again.\n- Replication rides the same JSON net-event path as ground loot, not a binary opcode. Props are\n created, retransformed and removed — they do not move every frame, and that is exactly what\n makes the cheap path correct for them.\n- Every prop is a real streamed engine entity with a real VRAM cost. The registry holds at most\n **8,192 props**, of which one resource may own **2,048**; both ceilings answer with\n `quota_exceeded`.","natives":[],"permissions":[],"events":[]},{"anchor":"create-a-prop","heading":"Create a prop","level":2,"text":"The short form is a model and a place to put it:\n\n```lua\nlocal id, reason = Open77.props.create({\n model = \"furniture.chair.metal\",\n position = { x = -1440.0, y = 130.0, z = 18.0 },\n yaw = 90.0,\n})\n\nassert(id, reason)\n```\n\nThe full definition:\n\n```lua\nlocal id, reason = Open77.props.create({\n model = \"crate.small\",\n position = { x = -1448.2, y = 96.1, z = 17.5 },\n yaw = 180.0,\n scale = { x = 1.0, y = 1.0, z = 1.0 },\n appearance = \"\",\n bucket = 0,\n physics = \"static\",\n collision = true,\n kind = \"prop\",\n streamingRadius = 120.0,\n streamingHysteresis = 30.0,\n ttlMs = 0,\n})\n```\n\n| Field | Type | Default | Meaning |\n|---|---|---|---|\n| `model` | string | required | A curated alias, or a raw depot `.mesh` path. At most 256 bytes. An alias draws the model it names through its authored host entity; a raw `.ent` path is refused with `template_backend_disabled` (§Status). See [Models](#models-aliases-and-raw-depot-paths). |\n| `position` | `{ x, y, z }` | required | World position in metres. Non-finite values, or any axis past ±1,000,000, give `invalid_position`. |\n| `yaw` | number | `0` | Heading in degrees. Pitch and roll are not exposed. |\n| `scale` | `{ x, y, z }` | `{1,1,1}` | Each axis 0.01–100. Non-unit scale is honoured by the mesh back-end only; see [Models](#models-aliases-and-raw-depot-paths). |\n| `appearance` | string | `\"\"` | Template appearance name, at most 128 bytes. Empty means the template's default. |\n| `bucket` | integer | `0` | Routing bucket. A player only ever sees props in their own bucket. |\n| `physics` | string | `\"static\"` | `static`, `kinematic`, `dynamic` or `none`. No effect: the spawned object has no `entPhysicalMeshComponent`. See [model limits](#model-and-collision-limits). |\n| `collision` | boolean | `true` | Detached hosts retain their authored collision; disabling a solid host's collision is unsupported (§Status). Attached hosts are always visual-only and non-blocking. |\n| `visible` | boolean | `true` | `false` keeps the registry entry and hides the object. |\n| `kind` | string | `\"prop\"` | `prop`, `light` or `effect`. See [Lights](#lights) and [Effects in this registry](#effects-in-this-registry). |\n| `light` | table | — | Accepted only when `kind == \"light\"`; supplying it for any other kind is rejected. See [Lights](#lights). |\n| `streamingRadius` | number | `120` | Distance in metres at which the prop streams in. Range 10–2000. |\n| `streamingHysteresis` | number | `30` | Extra distance before it streams out. Range 0 to `streamingRadius`. |\n| `ttlMs` | integer | `0` | Lifetime in milliseconds. `0` means \"until removed\"; the ceiling is seven days. |\n| `persistent` | boolean | `false` | **Not implemented as an API field.** The authoritative registry has no persistence field, and a stopped resource loses every prop it created: write your own scene table and recreate them on start. What *does* exist is the bundled resource's opt-in — see [Persistence](#persistence-the-bundled-resources-opt-in). |\n\n`static` is the default for `physics` on purpose: a prop that shoves the player around is a\ndeliberate choice, never something you get by forgetting a field.\n\nEvery bound above is enforced by the server registry, not by the caller. A value outside one is a\nfailure with a reason, never a silently clamped prop somewhere you did not ask for.","natives":["Open77.props.create","server:Open77.props.create"],"permissions":[],"events":[]},{"anchor":"models-aliases-and-raw-depot-paths","heading":"Models: aliases and raw depot paths","level":2,"text":"`model` accepts a **curated alias** or a **raw cooked depot path**. This is deliberately the same\nsplit as the VFX catalogue, and it carries the same warning.\n\nA curated alias is the supported surface. It has been reviewed, it resolves to a path that has\nbeen seen to work on this build, and it keeps resolving when the underlying path is corrected.\n`Open77.props.catalog()` returns the real list, each entry pairing an alias with the model it\nresolves to, and **is the only list worth trusting at runtime** — it is what the client actually\nhosts, where the table below is what the client was asked to host.\n\nThe catalogue is **185 aliases in 29 families**, named `family.thing.variant`. Of those,\n184 use generated spawn-safe hosts and `race.checkpoint` keeps its audited authored\ndevice template. Rather than reproduce all 185 here, the families and their sizes:\n\n| Family | n | What is in it |\n|---|---|---|\n| `furniture.*` | 18 | chairs, stools, benches, tables, counters, a sofa, a bed, shelving, cabinets |\n| `office.*` | 6 | desks, office chairs, a file cabinet, a lobby reception desk |\n| `kitchen.*` | 5 | counter, fridges, stove, coffee machine |\n| `bathroom.*` | 3 | toilet, shower, mirror |\n| `crate.*` | 7 | loot crates, delivery and cargo crates, cardboard, ammo box |\n| `container.*` | 12 | barrel, locker, safe, toolbox, keg, tanks, jugs, bucket, freight, shipping container |\n| `barrier.*` | 10 | blockades, hesco, jersey and pedestrian barriers, a swing gate, tire blocker |\n| `fence.*` | 4 | railings and a wire-fence reinforcement |\n| `sign.*` | 6 | generic plates, a direction arrow, a street sign, a homeless sign, kiosk frame |\n| `street.*` | 11 | lamp posts, hydrant, parking meter, traffic light, poles, boxes, awning, AC unit |\n| `bin.*` | 5 | dumpsters and trash cans |\n| `garbage.*` | 5 | bags, cardboard, street and industrial trash, a mattress |\n| `debris.*` | 5 | rubble, corrugated sheet, construction and sand piles, rebar |\n| `pallet.*` | 3 | cargo pallets |\n| `industrial.*` | 11 | forklift, pallet truck, trolleys, generator, gas pump, racks, machinery, vents |\n| `pipe.*` | 4 | pipe runs by gauge, plus a waste pipe |\n| `electronics.*` | 12 | monitors, vending machines, arcade, jukebox, server, register, fuse box, camera |\n| `light.*` | 9 | lanterns, ceiling and fluorescent fixtures, spotlight, desk and hanging lamps, candle, disco ball |\n| `vegetation.*` | 5 | planters, flower pots, a palm attachment |\n| `market.*` | 5 | market stands, kiosk, shop shelving |\n| `food.*` | 6 | street food, cans, packaged drink and snacks, a bottle, a beer tap |\n| `medical.*` | 7 | cart, IV stand, monitor arm, devices, container, morgue table, body bag |\n| `military.*` | 5 | cases, weapon rack, checkpoint, security gate |\n| `door.*` | 4 | swing gate, elevator doors, glass door, shuttle door |\n| `recreation.*` | 5 | billiard and roulette tables, gym equipment, tent, sleeping bag |\n| `music.*` | 3 | electric guitar, amplifier, piano |\n| `decor.*` | 4 | sculptures, a painting, a vase |\n| `tool.*` | 3 | shovel, welder, fire axe |\n| `race.*` | 2 | the visual-only sq024 road chevron host and the authored two-sided checkpoint gate |","natives":[],"permissions":[],"events":[]},{"anchor":"model-scale","heading":"Model scale","level":3,"text":"Catalogue dimensions come from each mesh's authored `boundingBox`. Some assets are much smaller than their names suggest.\n\n**`sign.*` direction signage is wall-mounted and small.** `sign.arrow.left` is\n8 x 0 x 10 cm — a flat plate with no thickness, because the whole `directions_*`\nfamily in the depot is interior wall signage meant to sit flush on a surface.\nSpawned free-standing at your feet it is essentially invisible, and it reads as\n\"the prop did not appear\". The asset is correct; place it against a wall, or use\n`sign.rect.blank` / `sign.street` when you want something that stands up on its own.\n\n**A prop that draws something tiny is indistinguishable from one that draws\nnothing.** `electronics.vending_machine` pointed for a long time at\n`vending_machine_device_a.mesh`, which is the interactive device panel rather than\nthe machine, and measured 31 cm. In game that is not a scale bug, it is an absence:\nnobody looks at 31 cm of plastic and thinks \"that vending machine is too small\".\nIt was found by measuring the catalogue, not by looking at it, and no amount of\nspawning things and eyeballing them would have named the cause. If a prop seems\nmissing, check its mesh extent before assuming the spawn failed.\n\nFor the exact alias strings: `admin.props.catalog`, or the **Props** tab of the admin panel,\nwhere the buttons are grouped by family. `prop.catalog` from the bundled `open77_props` resource\nanswers client-side only — on a dedicated server it says so rather than printing an empty list.\nThe source of truth is `kModelAliases` in `client/src/api/Props.cpp`, mirrored by name into\n`resources/system/open77_admin/shared/config.lua`.\n\nEvery path in that table was checked verbatim against the cooked archive listing. **None of them\nis individually validated in game** — every row still reads\n`discoverable_not_individually_validated`, so archive presence proves the file exists and nothing\nmore.\n\nA raw path — `base\\...\\name.ent` or `base\\...\\name.mesh` — is **advanced and build-dependent**.\nThe file being present in the 2.31 archives does not guarantee that the object is safe, visible,\ncorrectly collided, or meaningful outside the quest or interior it was authored for. Archive\npresence proves discoverability, nothing else. An alias that is not in the catalogue fails with\n`unknown_alias`; a path the engine will not resolve fails with `invalid_model`.\n\nThe path also picks the back-end, which is why `scale` behaves differently between the two:\n\n| Model string | Back-end | Consequence |\n|---|---|---|\n| `…\\name.ent`, or an alias resolving to one | Runtime TweakDB record pointing at the cooked template | You get the object the artist authored — materials, appearances, collision. Scale is the template's. |\n| `…\\name.mesh`, or an alias resolving to one | An Open77-owned host entity with the mesh written onto its `entMeshComponent` | Needs no record, so it escapes the per-session record ceiling, and it accepts a non-unit `scale`. Collision and material fidelity are then Open77's to supply. |\n\nTemplate records are provisioned lazily, memoised for the session, and **do not survive a game\nrestart** — a known property of runtime TweakDB records. They are re-provisioned on the next\nsession automatically. A failure to mint one returns `record_provisioning_failed`; a record that\nmints but produces no entity returns `entity_spawn_failed`.","natives":[],"permissions":[],"events":[]},{"anchor":"lights","heading":"Lights","level":2,"text":"A light is a prop with `kind = \"light\"`. That is the whole point of the shared registry: a light\nstreams, buckets, expires and cleans up through exactly the same path as a crate, and there is\nno second lifetime to get wrong.\n\n```lua\nlocal lamp, reason = Open77.props.create({\n kind = \"light\",\n model = \"light.spot\",\n position = { x = -1460.2, y = 99.9, z = 24.8 },\n yaw = 0.0,\n bucket = 0,\n streamingRadius = 60.0,\n light = {\n spot = true,\n color = { x = 1.0, y = 0.35, z = 0.1 },\n intensity = 40.0,\n radius = 12.0,\n innerAngle = 20.0,\n outerAngle = 45.0,\n enabled = true,\n },\n})\n```\n\n| Light field | Type | Meaning |\n|---|---|---|\n| `spot` | boolean | `true` for a spot light, `false`/absent for a point light. Spot is what makes the two angles meaningful. Changing it **respawns** the prop, because point and spot are different light types and the render proxy is built from that. |\n| `color` | `{ x, y, z }` | Linear colour, each channel 0–1. The keys really are `x`/`y`/`z`, not `r`/`g`/`b`: the field goes through the shared vector reader, and an `{r,g,b}` table is rejected as `invalid_color`. |\n| `intensity` | number | Emission strength, 0–10000. |\n| `radius` | number | Falloff radius in metres, 0.1–500. |\n| `innerAngle`, `outerAngle` | number | Cone angles in degrees, 0–180. Ignored for a point light. |\n| `enabled` | boolean | Whether the lamp is lit. Toggling it does **not** respawn the prop — the live component is rewritten in place — so this is the field to use for a switch. |\n\nThe light table rides to the client as opaque JSON, capped at 2 KiB. The server bounds the\npayload and proves it parses; it does not pretend to know the engine's field ranges, because the\nclient owns the component-write contract. Every field above maps to a real field on REDengine's\n`entLightComponent` in 2.31 — none of it is simulated on Open77's side.\n\nA light's parameters are written to the live component, and unlike a mesh those writes reach the\nrenderer: the colour a resource asks for is the colour the frame shows. What cannot be changed\nafter the fact is the light *component itself* — a light host is authored with its component\nalready wired, because one appended to a compiled package is attached, reported as enabled, and\nemits nothing.\n\nSupplying a `light` table with any `kind` other than `\"light\"` is rejected rather than ignored.\n\n**A light's render proxy reads its fields when it is built.** Fields that cannot be patched on a\nlive proxy make `Open77.props.update` respawn the entity rather than silently doing nothing —\nthe prop keeps its ID, but it will visibly blink. Turning a light off without removing its\nregistry entry is `Open77.props.update(id, { visible = false })`.","natives":["Open77.props.create","server:Open77.props.create"],"permissions":[],"events":[]},{"anchor":"effects-in-this-registry","heading":"Effects in this registry","level":2,"text":"`kind = \"effect\"` is the registry's third discriminator: a host entity that a looping world VFX\nis anchored to. Authors should not reach for it directly. Use `Open77.effects.create`, which\ntakes an effect name rather than a model path.\n\nA looping effect is kept in its **own** server registry, shaped exactly like this one — same\nidentity, ownership, revisioning and streaming — with its own ceilings, so filling it does not\nconsume the room a prop needs. It lands on the client as a `kind = \"effect\"` entry, which is why\nit streams and cleans up through one code path here. See\n[Visual and audio effects](effects.md#replicated-effects).\n\nThe distinction that matters, and the one worth re-reading before writing any effect code, is\nbetween **client-local** effects (`Open77.vfx` / `Open77.sfx`, decorative, seen by one player)\nand **replicated** effects (`Open77.effects`, server-authoritative, seen by everyone in range).\nThat page covers both and keeps them apart.","natives":[],"permissions":[],"events":[]},{"anchor":"streaming","heading":"Streaming","level":2,"text":"Three conditions decide whether a given player has a given prop:\n\n1. The player's routing bucket equals the prop's `bucket`. No radius makes a prop cross buckets.\n2. The player is within `streamingRadius` metres — the prop streams in.\n3. The player passes `streamingRadius + streamingHysteresis` metres — the prop streams out.\n\nThe gap between 2 and 3 is what stops a player pacing on the boundary from thrashing the\nstreamer. Interest is recomputed against the last player snapshot the server received, and each\nchange is sent to the players it concerns rather than broadcast to the whole bucket — an RP\nserver will hold thousands of props, and a bucket-wide broadcast does not survive that.\n\nA player who joins late, or who returns inside the radius, receives the prop in a snapshot; no\nresource needs to replay anything.","natives":[],"permissions":[],"events":[]},{"anchor":"mutation","heading":"Mutation","level":2,"text":"```lua\nOpen77.props.update(id, { appearance = \"worn\", collision = false, visible = true })\nOpen77.props.setTransform(id, { position = { x = -1442.2, y = 127.4, z = 18.0 }, yaw = 45.0 })\nOpen77.props.setBucket(id, 12)\nOpen77.props.remove(id)\n```\n\n`update` patches `appearance`, `physics`, `collision`, `visible`, `bucket`, `light`,\n`streamingRadius`, `streamingHysteresis` and `ttlMs`. It is sparse: fields you do not name are\nleft alone. Passing `ttlMs = 0` clears an expiry that was already set, which is how you cancel a\nTTL without recreating the prop.\n\n**`model` and `kind` are immutable.** Remove the prop and create a replacement to change either field; the replacement receives a new ID. Change scale with `setTransform`.\n\n**One of these mutations is cheap and the rest are not.** Position and yaw are the one change the\nengine will take on a live entity, so moving a prop is a transform write with no respawn and no\nvisible pop. Scale, and any light parameter the render proxy only reads when it is built, respawn\nthe entity behind the same ID — the API does that rather than accepting the patch and quietly\nrendering nothing.\n\n`setBucket` moves visibility scope, and it is not a removal: the next interest pass sees the\nmismatch and hands every viewer that lost the prop an explicit stream-out, while every player in\nthe new bucket and inside the radius gains it.\n\n**`setTransform` is refused on an attached prop**, with `prop_attached`. The follow tick would\noverwrite the write within 100 ms, and a mutation that silently does not take is worse than one\nthat says why. Detach first.","natives":["Open77.props.update","server:Open77.props.update","Open77.props.setTransform","server:Open77.props.setTransform","server:Open77.props.setBucket","Open77.props.remove","server:Open77.props.remove"],"permissions":[],"events":[]},{"anchor":"attachment","heading":"Attachment","level":2,"text":"Choose between transform following and equipment-slot attachment:\n\n| You want | Use | What it really is |\n|---|---|---|\n| A crate carried by a player, a jerrycan riding on a car roof, a briefcase that follows an NPC | `Open77.props.attach` | The server recomputes the prop's transform from the target's, ten times a second, and replicates it like any other moving prop. |\n| A bottle **in the hand**, held through the animation, that looks right in first and third person | `Open77.heldItems.hold` | A real item in a real equipment slot, given to the body through the engine's own transaction system. |\n\nThe dividing line is physical. `props.attach` **follows**; `heldItems.hold` **holds**. A followed\nprop hovers near the body and does not swing with an animation, because nothing binds it to a\nbone. A held item does, because the engine owns it — but it must be an *item record*, not an\narbitrary `.ent`, and it can only go in one of ten slots.","natives":[],"permissions":[],"events":[]},{"anchor":"open77propsattach-follow-a-target","heading":"`Open77.props.attach` — follow a target","level":3,"text":"```lua\n-- A crate carried a metre above a player, dropped if they die or disconnect.\nOpen77.props.attach(crateId, { kind = \"player\", id = source })\n\n-- A jerrycan on the roof of a car, turning with it.\nOpen77.props.attach(canId, { kind = \"vehicle\", id = vehicleId }, {\n offset = { x = 0.0, y = -0.4, z = 1.6 },\n yaw = 90.0,\n trackYaw = true,\n})\n\nOpen77.props.detach(crateId)\n```\n\n| Option | Default | Meaning |\n|---|---|---|\n| `offset` | `{ x = 0, y = 0, z = 1 }` | Metres **in world axes**, added to the target's position. Capped at 16 m. |\n| `yaw` | the prop's current yaw | The prop's yaw while attached. |\n| `trackYaw` | `false` | Add `yaw` to the target's heading instead of using it as an absolute. |\n| `detachOnDeath` | `true` | A dead or downed target drops what it was carrying. |\n| `followBucket` | `true` | The prop follows a routing-bucket change. Turn it off and a carried prop is left behind, invisible to its carrier. |\n\n`kind` is `\"player\"`, `\"npc\"` or `\"vehicle\"`. A prop cannot follow another prop: that is a chain\nthe tick would have to order and check for cycles, and nothing needs it yet.\n\n`props.attach` follows the target at an offset. It does not place the prop in a hand or bind it to the target's animation.\n\nTwo limitations are worth stating plainly:\n\n- **The offset is in world axes, not the target's frame.** Walk north with a crate offset one\n metre east of you and it stays one metre east however you turn. Rotating the offset needs the\n target's heading, and a player's position snapshot carries position and routing bucket only.\n- **`trackYaw` has no effect on player targets.** Their heading is not available to the attachment path. The prop keeps its specified yaw. Vehicle targets provide orientation and support yaw tracking.","natives":["Open77.props.attach","server:Open77.props.attach","Open77.props.detach","server:Open77.props.detach"],"permissions":[],"events":[]},{"anchor":"lifecycle-what-happens-when-the-target-goes-away","heading":"Lifecycle: what happens when the target goes away","level":3,"text":"A detach is always possible and always happens. No attached prop is ever left following a ghost.\n\n| The target… | What the prop does | Reason reported |\n|---|---|---|\n| disconnects, despawns, or stops publishing a position (loading screen, streamed out) | stops following and **stays exactly where the last tick put it** | `target_lost` |\n| dies or is downed | same, unless the attachment set `detachOnDeath = false` | `target_down` |\n| changes routing bucket | **follows it there**, still attached | — |\n| moves somewhere the world bounds reject | stops following, prop keeps the last valid transform | `target_lost` |\n\nAnd on this side of the attachment:\n\n- **The prop is removed** — the attachment goes with it. Nothing to clean up.\n- **The owning resource stops or restarts** — every prop it created is removed outright,\n attached or not. That is the existing `world.props` guarantee and it is stronger than a detach.\n- **Another resource tries to attach or detach it** — `owned_by_another_resource`. A resource may\n read every prop in the world and may only make *its own* follow anything.\n\nA prop is never deleted by the follow tick. A lost carrier drops a crate; it does not evaporate\none a resource paid a quota slot for.","natives":[],"permissions":[],"events":[]},{"anchor":"cost","heading":"Cost","level":3,"text":"The follow tick runs at **10 Hz**, whatever the server tick rate, and writes nothing at all when\nthe target has not moved more than a centimetre. A carried prop standing still costs zero\nbandwidth; a carried prop on a sprinting player costs ten upserts a second to the players who can\nactually see it, because it streams by its followed position like any other prop.","natives":[],"permissions":[],"events":[]},{"anchor":"open77helditemshold-put-an-item-in-a-hand","heading":"`Open77.heldItems.hold` — put an item in a hand","level":3,"text":"```lua\nOpen77.heldItems.hold(source, \"Items.Preset_Nue_Default\") -- right hand\nOpen77.heldItems.hold(source, \"Items.GenericCraftingMaterial1\", { slot = \"Head\" })\nOpen77.heldItems.release(source)\nOpen77.heldItems.requestSnapshot(source)\n```\n\nHeld equipment is client-owned REDengine state, so this is a **relay** with the same contract as\n`Open77.clothing` and `Open77.weapons`: the call returns a request id immediately and the verified\noutcome arrives later on `open77:helditem:completed`.\n\n```lua\nlocal requestId = Open77.heldItems.hold(source, \"Items.Preset_Nue_Default\")\n\nAddEventHandler(\"open77:helditem:completed\", function(player, id, operation, accepted, reason, result)\n if id ~= requestId then return end\n print((\"held %s: %s %s\"):format(operation, tostring(accepted), reason))\nend)\n```\n\n**The slot vocabulary is the ten equipment slots**, and only those:\n\n`Head`, `Face`, `InnerChest`, `OuterChest`, `Legs`, `Feet`, `Outfit`, `UnderwearTop`,\n`UnderwearBottom`, `WeaponRight`.\n\n`WeaponRight` is the right hand and the default. `Open77.heldItems.slots()` returns the list.\nThese are the `AttachmentSlots.*` TweakDB records the client compiles in\n`client/src/game/Slot.hpp`; there is no `back` and no `hip`, and there never was.\n\nThe price of being real:\n\n- **An item record, not an entity.** `Items.Something`, not `base/…/crate.ent`. If what you want\n to show has no item record, `props.attach` is the tool, not this.\n- **The client half must be running.** [`open77_helditems`](https://github.com/Open2077/open77-resources/tree/main/system/open77_helditems) owns the `open77:helditem:request`\n handler; without it a request times out after ten seconds rather than failing fast, exactly as a\n weapon request does without `open77_weapons`.\n- **It fights `Open77.weapons` over `WeaponRight`.** Both write the same slot. Pick one per\n gamemode, or holster before you hand somebody a briefcase.\n- **The body must be incarnated.** A request against a player still on the \"continue\" screen is\n refused with `player_not_ready` rather than written; writing equipment to a body that is not\n there is the crash class the testing rules warn about.\n- **Nothing persists it.** A world entry rebuilds the body from the server's equipment record,\n which knows nothing about held items. Re-assert after `open77:equipment:ready` if it must\n survive.","natives":["server:Open77.heldItems.hold","server:Open77.heldItems.release","server:Open77.heldItems.requestSnapshot","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:helditem:completed","open77:helditem:request","open77:equipment:ready"]},{"anchor":"what-neither-of-these-is","heading":"What neither of these is","level":3,"text":"There is no engine **parenting** of a prop to a bone — no `entHardTransformBinding` written on a\nprop's root component. That would need a new native, a field on the projection envelope and an\nanswer for what happens when the carrier streams out of a watcher's range mid-carry. It has been\nscoped twice and declined twice; the reasoning is in\n`docs/research/props-and-object-spawning.md`.","natives":[],"permissions":[],"events":[]},{"anchor":"inspection-and-cleanup","heading":"Inspection and cleanup","level":2,"text":"```lua\nlocal prop = Open77.props.get(id) -- canonical snapshot, or nil\nlocal every = Open77.props.all() -- every prop in the registry\nlocal here = Open77.props.all(12) -- ... filtered to one bucket\n\nfor _, entry in ipairs(every) do\n print(entry.id, entry.kind, entry.model, entry.bucket, entry.revision)\nend\n\nfor _, entry in ipairs(Open77.props.catalog()) do\n print(entry.alias, entry.model)\nend\n\nOpen77.props.clear() -- only this resource's props\n```\n\nA snapshot carries `id`, `revision`, `resource`, `kind`, `model`, `appearance`, `position`,\n`yaw`, `scale`, `bucket`, `physics`, `collision`, `visible`, `streamingRadius` and\n`streamingHysteresis`; a light also carries its `light` table, and a prop with a TTL carries its\nexpiry. `revision` is monotonic and is what a projection compares against — it is not a\ntimestamp, and the counter is shared across the registry, so it orders changes rather than\ncounting a prop's own edits.\n\nRecords may arrive with a nested `position` table or with flattened `x`/`y`/`z` depending on\nwhere you read them; the bundled resource accepts both rather than trusting one shape, and code\nthat walks snapshots should do the same.\n\n`clear()` removes everything this resource owns, returns how many went, and touches nothing\nelse. It is not a way to tidy up after another resource, and there is deliberately no call that\nis.\n\nA prop leaves the registry for exactly three reasons, and the removal carries which one:\n`removed` for an explicit call, `expired` when its TTL comes due, and `resource_stopped` when its\nowner stops or reloads.","natives":["server:Open77.props.get","server:Open77.props.all","server:Open77.props.catalog","Open77.props.clear","server:Open77.props.clear"],"permissions":[],"events":[]},{"anchor":"finding-the-prop-next-to-you","heading":"Finding the prop next to you","level":2,"text":"`Open77.props.list()` gives you every prop this resource owns and leaves the distance\nmaths to you. When the question is \"what am I standing next to?\", `nearest` answers it\ndirectly:\n\n```lua\nlocal prop, distance = Open77.props.nearest({ x = px, y = py, z = pz })\nif prop then\n print((\"nearest is %s, %.2f m away\"):format(prop.model, distance))\nend\n\nlocal prop = Open77.props.nearest(position, 25) -- widen the search to 25 m\n```\n\nIt returns the prop snapshot and its distance in metres, or `nil` when nothing is in\nrange. `nil` is an ordinary answer rather than a failure — \"nothing nearby\" is a normal\nstate of the world, so there is no error string to check for it.\n\nThe radius defaults to **10 m** and must be a positive number. A radius of `0` or a\nnegative one is rejected with `invalid_radius` rather than treated as an unbounded\nsearch; searching everything is what `list()` is for, and silently widening a caller's\nrange check would make that check a lie.\n\n`nearest` sees exactly what `list()` sees — the props this resource owns, plus any it has\nprojected. It is a convenience over the same data, not a way to discover props belonging\nto somebody else, and the two functions returning different sets would be a trap rather\nthan a feature.\n\nTies go to whichever prop the registry lists first, so repeated calls with identical\ninputs give identical answers.","natives":["Open77.props.nearest"],"permissions":[],"events":[]},{"anchor":"client-side-props-that-never-replicate","heading":"Client-side props that never replicate","level":2,"text":"There is no separate `local` table. A prop created from a **client** resource with\n`Open77.props.create` is already local-only: it lives on that machine, is owned by the\ncalling resource, counts against the per-owner (256) and global (1024) quotas, and is\nnever sent to anybody. Replication is a different verb entirely — `project(serverId,\noptions)`, which only the bundled projection resource calls when the server hands it a\nrecord.\n\nSo the client-side `create` *is* the local-only prop. An earlier draft of this API\nplanned an `Open77.props.local_.create` / `.remove` pair as its counterpart; it was not\nbuilt, because there is no non-local client create for it to be the counterpart of, and\nhaving two names for one behaviour would imply that plain `create` replicates.","natives":[],"permissions":[],"events":[]},{"anchor":"server-api-reference","heading":"Server API reference","level":2,"text":"Every method requires `world.props`.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.props.create` | `(definition)` | Prop ID as a decimal string, or `nil, reason`. |\n| `Open77.props.update` | `(id, patch)` | `boolean, reason?` |\n| `Open77.props.setTransform` | `(id, { position?, yaw?, scale? })` | `boolean, reason?` |\n| `Open77.props.setBucket` | `(id, bucket)` | `boolean, reason?` |\n| `Open77.props.attach` | `(id, { kind, id }, { offset?, yaw?, trackYaw?, detachOnDeath?, followBucket? })` | `boolean, reason?` — the prop follows the target. See [Attachment](#attachment). |\n| `Open77.props.detach` | `(id)` | `boolean, reason?` — stops the follow and leaves the prop where it stands. Succeeds on a prop that was not attached. |\n| `Open77.props.remove` | `(id)` | `boolean, reason?` |\n| `Open77.props.get` | `(id)` | Canonical snapshot, or `nil`. |\n| `Open77.props.all` | `(bucket?)` | Array of prop snapshots, optionally filtered to one bucket. Reading is not restricted to your own props. |\n| `Open77.props.catalog` | `()` | The curated alias list: entries carrying an alias and the model it resolves to. |\n| `Open77.props.clear` | `()` | Remove every prop this resource owns. |\n\n`Open77.props.get` returns `attachment` as a table — `kind`, `id`, `offset`, `yaw`, `trackYaw`,\n`detachOnDeath`, `followBucket` — or leaves the field absent when the prop follows nothing, so a\nplain `if record.attachment then` is the whole test.\n\nLow-level aliases are `CreateProp`, `UpdateProp`, `SetPropTransform`, `SetPropBucket`,\n`AttachProp`, `DetachProp`, `RemoveProp`, `GetProp`, and `GetProps`. Prefer the namespaced wrappers: they accept structured\noption tables and validate them.","natives":[],"permissions":[],"events":[]},{"anchor":"server-events","heading":"Server events","level":2,"text":"```lua\nAddEventHandler(\"onPropCreated\", function(id, resource, model) end)\nAddEventHandler(\"onPropRemoved\", function(id, reason, resource) end)\n```\n\nBoth require `world.props` — the same string reading one costs, because a lifecycle event\nthat announced a prop to a resource that cannot list one would route around that capability.\n`reason` is `removed`, `expired`, `resource_stopped`, or the free text (at most 64\ncharacters) the caller passed to the remove call.\n\nThe same two transitions also reach the generic `onEntityCreated(kind, id, resource)` and\n`onEntityRemoved(kind, id, reason)` with `kind` = `\"prop\"`, for a resource that additionally\ndeclares `world.entities.observe`. The mirror is raised by the same statement, so the two feeds\ncannot disagree; see [entity lifecycle events](server-api.md#entity-lifecycle-events) for the\nauthority rule and for why there is no `Updated` counterpart.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"failure-reasons","heading":"Failure reasons","level":2,"text":"| Reason | Meaning |\n|---|---|\n| `permission_denied:world.props` | The manifest does not declare `world.props`. |\n| `quota_exceeded` | The per-resource or global prop ceiling is full. Remove something first. |\n| `not_found` | No prop with that ID, or it expired or was already removed. |\n| `owned_by_another_resource` | The prop exists but belongs to a different resource. |\n| `invalid_position` | Non-finite coordinates, or outside the world bounds the protocol accepts. |\n| `invalid_model` | The model string is malformed, or the depot path does not resolve. |\n| `unknown_alias` | The alias is not in `Open77.props.catalog()`. |\n| `record_provisioning_failed` | A runtime TweakDB record for the template could not be minted. |\n| `entity_spawn_failed` | The record or host entity resolved but the engine produced no entity. |\n| `world_unavailable` | No world is loaded on the projecting client, or it is mid-transition. |\n| `prop_attached` | `setTransform` on a prop that is following a target. Detach first. |\n| `invalid_attachment_kind` | Not `player`, `npc` or `vehicle`. A prop cannot follow another prop. |\n| `invalid_attachment_target` | No target id was named, or it was zero. |\n| `invalid_attachment_offset` | A non-finite offset, or one further than 16 m from the target. |\n| `invalid_item_record` | `Open77.heldItems.hold` was given something that is not an item record. |\n| `invalid_slot` | Not one of the ten equipment slots. |\n| `player_not_ready` | The body is not incarnated yet, so there is no equipment system to write to. |\n| `request_timeout` | The client half of `Open77.heldItems` did not answer within ten seconds. Is `open77_helditems` running? |\n\nFailures are values, not errors: every mutating call returns `nil, reason` or `false, reason`\nrather than raising, so a caller can tell an invalid request from a temporarily unavailable\nsubsystem without wrapping every line in `pcall`.","natives":[],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"A client resource **projects and reads**. It has no way to mint a replicated prop, by design.\n\nLocal props are decorative: a placement ghost, a build cursor, a preview the player is dragging\naround. They are never replicated, never visible to anyone else, and they are charged against the\ncalling resource's own quota so a busy gamemode cannot starve the projection of real props.\n\nOn the client, `Open77.props.create` **is** that local path — there is no separate `local_` table.\nThe name is the same as the server's because the two `Open77` tables are different objects on\ndifferent machines, exactly as they are for every other Open77 API; what differs is what the call\nmeans on each side. On the server it mints an authoritative, replicated prop. On the client it\nmakes a decorative one that never leaves the machine.\n\n```lua\nlocal ghost, reason = Open77.props.create({\n model = \"crate.small\",\n position = { x = -1442.2, y = 127.4, z = 18.0 },\n yaw = 0.0,\n collision = false,\n})\n\nassert(ghost, reason)\nOpen77.props.remove(ghost)\n```\n\n```lua\n-- Your own local props, with what the renderer actually did with each.\nfor _, prop in ipairs(Open77.props.list()) do\n print(prop.id, prop.kind, prop.model, prop.backend, prop.rendered, prop.failed)\nend\n```\n\n`list()` is scoped to the calling resource's own props. It deliberately does **not** include\nserver-owned props: those belong to the session, not to any resource, so asking a resource-scoped\nquestion about them would return an empty table and read as \"there are no props\" rather than\n\"wrong question\". To see projected server props, use the `open77_props` resource's `all` export,\nwhich returns the records as the server sent them.\n\nTwo events are raised as the projection changes. The bundled `open77_props` resource raises them,\nand a client `TriggerEvent` fans out to **every** running client resource, so any resource can\nlisten without depending on an export:\n\n```lua\nAddEventHandler(\"open77:props:added\", function(id, record)\n -- record is the server prop, now projected locally.\n print(\"projected\", id, record.model, record.kind)\nend)\n\nAddEventHandler(\"open77:props:removed\", function(id, reason)\n -- reason names why: a server removal, an expiry, a stream-out, or\n -- \"projection_failed\" when the native layer refused to build it.\n print(\"dropped\", id, reason)\nend)\n```\n\n`added` fires once per prop, when its record first arrives and is projected — not again on every\nupdate. Both fire for server-owned props only. A local prop is yours already: you have its ID\nfrom `create`.","natives":["Open77.props.create","server:Open77.props.create","Open77.props.remove","server:Open77.props.remove","Open77.props.list","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:props:added","open77:props:removed"]},{"anchor":"catalogue","heading":"Catalogue","level":2,"text":"The generated inventory lives at `docs/generated/props-2.31.csv`, alongside the VFX, clothing,\nNPC and weapon catalogues. Its columns are `category`, `path`, `kind` (`ent` or `mesh`),\n`source`, `game_version` and `runtime_status`.\n\n`runtime_status` starts at `discoverable_not_individually_validated` for every row, exactly as\nthe VFX catalogue does, and is promoted only by a sampler that actually spawns entries and\nrecords which of them render. **Archive presence is not proof of playability.**\n\nThe catalogue carries identifiers and provenance only. Open77 does not redistribute game assets.","natives":[],"permissions":[],"events":[]},{"anchor":"reference-resource-and-terminal-commands","heading":"Reference resource and terminal commands","level":2,"text":"The bundled [`open77_props`](https://github.com/Open2077/open77-resources/tree/main/system/open77_props) resource is the reference\nimplementation. Its client half mirrors the registry into the native projection layer and\nacknowledges each projection back to the server; nothing there runs on a tick, and a prop is\nprojected once when its record arrives rather than polled. Its server half registers the admin\ncommands below with `RegisterCommand`, exactly as `open77_loot` does.\n\n```text\nprop.create furniture.chair.metal -1440.0 130.0 18.0 90 0\nprop.here crate.small\nprop.list 0\nprop.near 25\nprop.remove 1\nprop.clear\nprop.catalog\n```\n\nType these into the developer terminal opened with `²` in Cyberpunk.\n\n| Command | Form | Access |\n|---|---|---|\n| `prop.create` | ` [yaw] [bucket]` | restricted — `command.prop.create` |\n| `prop.here` | ` [yaw]` — at the caller's own position and bucket | restricted — `command.prop.here` |\n| `prop.list` | `[bucket]` | public |\n| `prop.near` | `[radius]` — sorted by distance, caller's bucket only | public |\n| `prop.remove` | `` | restricted — `command.prop.remove` |\n| `prop.clear` | — removes every prop this resource owns | restricted — `command.prop.clear` |\n| `prop.catalog` | — the curated alias list | public |\n\n`prop.here` and `prop.near` need an in-game caller with a fresh position snapshot: the dedicated\nconsole has no body, so they refuse rather than guessing an origin. Listings are capped at 100\nlines.\n\nThese commands own only what they create. A gameplay resource calling the same API keeps its\nown props, and `prop.clear` will not touch them.","natives":[],"permissions":[],"events":[]},{"anchor":"persistence-the-bundled-resources-opt-in","heading":"Persistence: the bundled resource's opt-in","level":2,"text":"By default every prop dies with the server process — the registry is in-memory, and that is the\ndocumented contract above. The bundled `open77_props` resource adds an **opt-in** on top: a\n`persist` tunable (Warden → Tuning → open77_props, or `tunable.set open77_props persist true`\nfrom the console), **off by default**, which saves every prop created by its own `prop.*` /\n`light.*` commands to the server's database and recreates them at boot.\n\nThe contract, measured against a real server-process restart:\n\n- **Off (the default) costs nothing**: no database conversation is ever opened, one log line at\n boot says so, and behaviour is exactly the in-memory contract above.\n- **On**, every prop created by the resource's commands is written through — model, kind,\n position, yaw, bucket, and a light's full parameter table. A restart recreates them with the\n same transforms; `prop.move` and `light.toggle` update the saved row; a carried prop is saved\n once, where the carry ends.\n- **Deletion is durable**: `prop.remove` deletes the prop's row, `prop.clear` empties the saved\n scene. A removed prop does not resurrect at the next boot. Flipping the tunable off deletes\n nothing already saved — switching back on resumes where you left off.\n- A saved row that no longer restores (say its alias left the catalogue) is kept and named in\n the boot log, once per boot, rather than silently discarded.\n\nOnly props created through the bundled resource's commands are persisted here. Gamemodes must save and restore their own programmatic props to avoid duplicate spawns.\n\nPersistence needs the server's database bridge (`database.enabled` plus\n`OP77_DATABASE_CONNECTION`); with the tunable on and no database, the resource says so once and\nstays inert. The table is `open77_props_persisted`, created on first use.","natives":[],"permissions":[],"events":[]},{"anchor":"current-limitations","heading":"Current limitations","level":2,"text":"- **68 of the 184 generated hosts have no collision**, listed per alias in\n `docs/generated/prop-hosts.json` under `collision`. Their meshes ship no collision shapes,\n so no component choice makes them solid; they need collision authored or a substituted mesh.\n The other 116 stop a player — verified in game by walking into them, against controls drawn\n from the original hollow 67 that were walked straight through. The additional\n `race.route_arrow` host is intentionally visual-only.\n- **A narrow prop can be walked around rather than through.** `container.barrel` blocks\n head-on but a long approach drifts past it. That is geometry, not a missing collider.\n- `physics = \"dynamic\"` names a simulated prop, which is a continuously moving networked entity\n and therefore needs an authority-holder model and a binary opcode that the current JSON\n replication path does not have. Do not build on it until that lands.\n- [Synchronized attachments](attachments.md) expose player/vehicle parents, skeletal slots\n and local position/rotation. Attached props use visual-only hosts; they are not simulated\n rigid bodies. `prop.pickup` / `prop.drop` use the binding, without a server teleport timer.\n- Interaction prompts on props are not exposed. Anchor a prompt yourself through\n [contextual interactions](interactions.md) if you need one now.\n- `persistent` is not implemented as a field on `Open77.props.create`. A gameplay resource's\n props die with it; keep your own scene table and recreate them on start. Props placed through\n the bundled resource's commands can opt in to surviving restarts — see\n [Persistence](#persistence-the-bundled-resources-opt-in).\n- There is no flicker or animation on a light. An earlier draft of this page documented a\n `flicker` field; it never existed in the shipped reader and has been removed rather than left\n as a promise.\n- A light prop carries the geometry of the entity its host was cloned from, so it is not\n invisible. A lightless donor with a spawnable root would fix that.\n- Standalone props are placed with a yaw. Attachments additionally expose local pitch and roll.","natives":[],"permissions":[],"events":[]}]},{"slug":"reflex-overdrive","file":"reflex-overdrive.md","title":"Reflex overdrive","summary":"Reflex overdrive increases the owner's movement, attack and reload speeds through temporary stat modifiers. It does not slow other players, bullets or the simulation clock.","sections":[{"anchor":"reflex-overdrive","heading":"Reflex overdrive","level":1,"text":"Reflex overdrive increases the owner's movement, attack and reload speeds through temporary stat modifiers. It does not slow other players, bullets or the simulation clock.\n\nThe effect uses stat modifiers, not global or per-entity time dilation.\n\n- **It does not slow other players.** No packet in this feature can change\n anyone else's clock.\n- **It does not slow bullets.** Hitscan stays instant at its firing time.\n- **It changes no clock at all** — not the world's, not the owner's, not any\n entity's. It applies stat modifiers on the owner's own body and removes them\n again.\n\nDo not use vanilla Sandevistan time dilation as a multiplayer speed boost. It changes the acting client's whole simulation while the server and other clients continue at normal speed.\n\nGenuine shared slow motion, in an isolated arena bucket with an authoritative\ntime domain, is a separate and separately gated experiment. Nothing in this page\nis a step toward it, and nothing here should be presented to players as \"bullet\ntime\".","natives":[],"permissions":[],"events":[]},{"anchor":"turn-it-on-first","heading":"Turn it on first","level":2,"text":"[`open77_reflex`](https://github.com/Open2077/open77-resources/tree/main/system/open77_reflex) must be named in your server's `resources.load`. That array is an\nallowlist: a resource present on disk but absent from it is never started, never\nmentioned in the log, and produces no warning to grep for. Nothing on this page\nhappens until the name is there.\n\n```jsonc\n\"resources\": {\n \"load\": [ \"...\", \"open77_cyberware\", \"open77_reflex\" ]\n}\n```\n\nIt depends on [`open77_effects`](https://github.com/Open2077/open77-resources/tree/main/system/open77_effects), so list that too. A dependency your list does not\nresolve is worse than a silent omission: the automatic start aborts on the first\nunresolved name and **no resource starts at all**, which looks like a dead server\nrather than a configuration error. Grep the log for `Automatic resource start\nfailed` before believing a list is complete.","natives":[],"permissions":[],"events":[]},{"anchor":"public-server-lua-api","heading":"Public server Lua API","level":2,"text":"| Call | Resource permission | Result |\n|---|---|---|\n| `Open77.reflex.define(definition)` | `players.reflex.define` | Register an immutable versioned definition |\n| `Open77.reflex.grant(player, definitionId)` | `players.reflex.manage` | Request this resource's session grant and native projection |\n| `Open77.reflex.revoke(player)` | `players.reflex.manage` | Remove this resource's overdrive grant |\n| `Open77.reflex.cancel(player)` | `players.reflex.manage` | End the player's active overdrive early |\n| `Open77.reflex.current(player)` | `players.reflex.read` | Activity and projection, or nil when absent |\n| `Open77.reflex.capabilities()` | `players.reflex.read` | Supported profile, tiers and the advertised boundary |\n\nMutations return `{ok=true}` or `nil, reason`. A successful grant means pending\nnative work: check `current(player).projection.status == \"ready\"` before\npresenting it as usable. `current(player).ownedByCaller` is a server-derived\nboolean for the calling resource, accurate across projection restarts. Grants\nrequire the existing authenticated cyberware character binding and an alive,\nready, unmounted body. They are session capabilities, not purchased equipment;\nserver makers own persistence, jobs, progression, prices and zones.\n\n```lua\n-- Manifest permissions: players.reflex.define, players.reflex.manage, players.reflex.read\nassert(Open77.reflex.define({\n id = \"myserver.overdrive\", version = 1, profile = \"reflex_overdrive\",\n config = {\n tier = \"reflex\", inputKey = \"x\", presentation = \"native\",\n durationMs = 6000, cooldownMs = 20000,\n maxCharges = 1, chargeRegenMs = 30000,\n staminaCost = 25, heatCost = 0,\n },\n}))\n-- Call only after your server's access/consent/payment checks:\nlocal pending, reason = Open77.reflex.grant(player, \"myserver.overdrive\")\nlocal activity = Open77.reflex.current(player)\n-- Remove only this resource's capability. Paid implants remain installed:\nlocal removed, error = Open77.reflex.revoke(player)\n```\n\n`profile` is exactly `reflex_overdrive`. Input accepts lowercase physical\nletters/digits, `f1` through `f12`, and supported modifier/navigation names such\nas `ctrl`, `shift`, `space`. Use a deliberate binding that fits your server's\nother controls.\n\n`inputKey` is the **default** of the `reflex_overdrive` action, not the key the\nplayer is stuck with: the client registers it through `RegisterKeyMapping`, so a\nplayer rebinds it under Pause › Settings › KEY BINDINGS and the rebind follows\nthem across servers. A server that ships a different default never overrides a\nsaved rebind.","natives":["server:Open77.reflex.define","server:Open77.reflex.grant","server:Open77.reflex.current","server:Open77.reflex.revoke"],"permissions":[],"events":[]},{"anchor":"configuration-and-bounds","heading":"Configuration and bounds","level":2,"text":"| Configuration | Supported bounds |\n|---|---|\n| `tier` | `reflex` or `reflex_heavy` — see the table below |\n| `durationMs` | 500–15000, and never longer than `cooldownMs` |\n| `cooldownMs` | 1000–600000, **and at least `durationMs`** |\n| `maxCharges` | 1–3 |\n| `chargeRegenMs` | 1000–600000, sequential charge regeneration |\n| `staminaCost` | 0–300 canonical points |\n| `heatCost` | 0–100, spent through a resource-owned heat ledger |\n| `presentation` | `native`, `silent` or `none` |\n\nA definition outside these bounds is **refused**, not trimmed: an operator\nshould see the mistake rather than wonder why a 60-second overdrive lasts 15.\n\nTwo rules are worth calling out because they are deliberate:\n\n- **`cooldownMs` must be at least `durationMs`**, and the cooldown starts when\n the boost *ends*, not when it starts. Chaining activations into a permanent\n overdrive is the failure this feature is bounded against.\n- **A definition cannot name a stat.** It picks a tier; the client owns the two\n plans and their per-stat ceilings. Putting a stat plan on the wire would make\n the wire the place to invent a speed cheat.","natives":[],"permissions":[],"events":[]},{"anchor":"the-two-tiers","heading":"The two tiers","level":2,"text":"| Stat | `reflex` | `reflex_heavy` | Ceiling |\n|---|---|---|---|\n| `MaxSpeed` | ×1.25 | ×1.55 | ×1.6 |\n| `AttackSpeed` | ×1.15 | ×1.40 | ×1.5 |\n| `ReloadSpeedPercentBonus` | +20 | +40 | +50 |\n| `Evasion` | +8 | +18 | +25 |\n| `StaminaCostReduction` | +0.5 | +1.2 | +2.0 |\n\nFor scale: the weakest installed vanilla Sandevistan runs the world at 0.30 —\nabout a 3.3× relative advantage — and the Apogee at 0.15, about 6.7×. Neither is\na shared-world number. `reflex_heavy` is 1.55×.\n\nThe modifiers are applied through `gameStatsSystem` with tracked handles, the\nsame mechanism the Cripple Movement lock uses in the opposite direction, and are\nremoved by handle on every release path.","natives":[],"permissions":[],"events":[]},{"anchor":"what-it-looks-like-on-other-peoples-screens","heading":"What it looks like, on other people's screens","level":2,"text":"A faster body is not a readable ability. Without a presentation, the only thing\nan observer gets is a player who happens to be quick, and the player who just\nlost a fight has no idea what beat him. So the overdrive ships with one, and\n`presentation` in the definition selects it:\n\n| `presentation` | Effect |\n|---|---|\n| `native` | The full cue: visuals and the two sounds |\n| `silent` | The visuals, no sound |\n| `none` | Nothing at all |\n\nWhat `native` actually draws, correlated with the phases below:\n\n| When | On the user's body, for everyone nearby | Sound at the user's position |\n|---|---|---|\n| Activation (`accepted`) | a bright spark burst at the chest, 900 ms | `w_cyb_berserker_activate` |\n| While boosted (`active`) | a blue volumetric glow wrapping the body, an electrical layer at the chest, a spark layer at each foot, and a trail from each heel while moving; `reflex_heavy` adds an arc at the right hand | — |\n| End (`completed` / `cancelled`) | a smaller burst at the chest, 700 ms | `w_cyb_berserker_deactivate` |\n\nThe sound is positioned on the body, so a player standing next to the user hears\nwhich direction it came from. An activation the client refuses between `accepted`\nand `active` drops its burst and stays silent: the end cue only fires for a boost\nthat really reached a body.\n\n**Be clear with your players about what this is.** It is an *Open77*\npresentation rather than single-player Sandevistan time dilation. The native preset uses the Open77 effect catalogue and does not slow the world or apply a full-screen observer effect. The owner sees the same body effects in first and third person.","natives":[],"permissions":[],"events":[]},{"anchor":"visual-cues","heading":"Visual cues","level":3,"text":"The body effects combine blue light, close-range sparks and motion-dependent trails. Visibility varies with movement, distance and lighting. Audio cues are emitted on the body; audibility depends on the game's mix and listener position.","natives":[],"permissions":[],"events":[]},{"anchor":"at-a-distance-the-plate-does-the-work","heading":"At a distance, the plate does the work","level":3,"text":"Body VFX may become difficult to see at a distance. Use the nameplate marker to communicate overdrive state beyond the effect's visible range.\n\nSo a boosted player's nameplate is marked for as long as the boost runs:\n\n```text\nCyberwareA -> CyberwareA // OVERDRIVE\n(white, the usual plate) (amber, while boosted)\n```\n\nIt is raised with the sustained layers and lowered on every path that ends them,\nso a plate can never claim a boost that has already finished. Nothing marks the\nboosted player's own plate: he is not an observer of himself.\n\n**One thing to know if you drive nameplates yourself.** A nameplate override\nbelongs to whichever resource claims it first. If your own resource is setting\nplates, it keeps them and this marker simply does nothing -- the body effects\nstill play, and the client says so once in its log rather than on every boost. If\nyou want both, set the marker yourself from your own nameplate code: listen for\n`onReflexChanged` and mark the player on `active`, clear on `completed` and\n`cancelled`.\n\nEverything is bounded and owned: at most four effect handles per activation and\n24 across the resource, a lease that can never outlive the 15-second ceiling, and\nrelease on the end of the boost, on death, on disconnect, on a bucket or body\nchange, and on a watchdog behind all of them.\n\nThe whole presentation lives in one replaceable file,\n`resources/system/open77_reflex/server/presentation.lua`. Server makers who want\na different look can set `presentation = \"none\"` in their definition and author\ntheir own from [`Open77.effects`](effects.md) — the events it listens to,\n`onReflexChanged` and its phases, are public.","natives":[],"permissions":[],"events":[]},{"anchor":"lifecycle-and-every-way-it-ends","heading":"Lifecycle, and every way it ends","level":2,"text":"The server admits an activation, spends the charge, the cooldown and the costs,\nand hands the client a deadline. Phases reach a resource as `onReflexChanged`:\n\n| Phase | Meaning |\n|---|---|\n| `accepted` | The server admitted the request and charged for it |\n| `active` | The client reported the modifiers on the body |\n| `completed` | It ended normally — the client released it, or the server's deadline did |\n| `cancelled` | It ended early; `reason` says why |\n\n`onReflexRejected` carries a refused request with its reason:\n`stale_grant`, `stale_action`, `body_unavailable`, `cyberware_suspended`,\n`crippled`, `already_active`, `motion_busy`, `cooldown`, `charges_exhausted`,\n`insufficient_stamina`, `heat_unavailable`, `insufficient_heat`.\n\nA refused request costs nothing: no charge, no cooldown, no stamina.\n\nThe boost is released by **all** of: the deadline; an explicit `cancel`; the\nowning resource stopping; death, hard or soft; mounting a vehicle; entering a\nworkspot; losing the body; a session change (reconnect, disconnect, lab to\nsession); and a watchdog that removes any overdrive modifier found on a body the\nclient's own machine does not hold. That last rule is why a crash or a failed\nremoval cannot leave a player permanently faster than everyone else.\n\nAn open menu is deliberately **not** a release: a player who opens the map\nmid-overdrive keeps the boost they paid for.","natives":[],"permissions":[],"events":[]},{"anchor":"what-it-composes-with","heading":"What it composes with","level":2,"text":"Unlike Dash and Ground Slam, an overdrive is **not** a movement owner, so it\ndoes not take the movement envelope and is not mutually exclusive with them. The\npoint of a reflex implant is to dash and fight under it. It is still:\n\n- refused while a forced motion owns the body (a knockdown, a slam recovery);\n- refused while a netrunner has suspended the player's cyberware\n (`cyberware_suspended`) — an implant that was just shut down must not answer;\n- refused while the player is crippled (`crippled`) — the slow cannot be\n answered with a boost;\n- refused in a vehicle, at grant time and at activation time.","natives":[],"permissions":[],"events":[]},{"anchor":"diagnostics","heading":"Diagnostics","level":2,"text":"`reflex.overdrive.state` is on the debug bridge's session allowlist. It is a\npure read and carries the sampled `MaxSpeed`, which is the only way to see\nwhether a granted boost actually reached the body:\n\n```powershell\n.\\scripts\\debug-bridge.ps1 -ProcessId -Command 'reflex.overdrive.state'\n```\n\nThe write forms (`reflex.overdrive`, `reflex.overdrive.clear`) are solo-lab only:\na self-applied boost in a session would be a self-granted advantage.","natives":[],"permissions":[],"events":[]},{"anchor":"optional-lab-workflow","heading":"Optional lab workflow","level":2,"text":"[`open77_cyberware_lab`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/open77_cyberware_lab) exposes the whole flow through the same public APIs, with\ntwo replaceable presets — `street` (tier `reflex`, 6 s, 20 s cooldown, one\ncharge) and `combat` (tier `reflex_heavy`, 4 s, 25 s cooldown, two charges):\n\n```text\n/cyberlab reflex grant \n/cyberlab reflex inspect \n/cyberlab reflex cancel \n/cyberlab reflex revoke \n/cyberlab reflex grant street -- grants to yourself\n```\n\nThe presets, the access policy and the input key live in\n`resources/gamemodes/open77_cyberware_lab/server/config.lua` under\n`CyberwareLab.reflex`. Replace them, or remove the lab entirely and register\ndefinitions from your own resource.","natives":[],"permissions":[],"events":[]},{"anchor":"in-the-lab-panel","heading":"In the lab panel","level":2,"text":"`/cyberlab` has an **Overdrive** card beside Ground Slam: the grant's projection\nstatus and charges, the two presets with their duration, cooldown, charges and\nstamina cost, the default key and where to rebind it, and Grant / Revoke / Cancel\nboost. It rides the same request-and-action route as the slam card, rate-limited\nper issuer, with a data push after every action.","natives":[],"permissions":[],"events":[]},{"anchor":"behavior-and-limitations","heading":"Behavior and limitations","level":2,"text":"A `MaxSpeed` multiplier above 1 increases locomotion speed without changing the clock. Modifiers are removed on expiry, cancellation, death, vehicle entry, reconnect, resource stop or a suspended-cyberware refusal.\n\nThe nameplate marker supplements body effects for distant observers and in bright lighting.\n\nKnow what that means before you tune a preset: **beyond about 10 m, and in\ndaylight sooner, the particles are gone and the plate is the entire signal.** The\nattached layers are a close-range affair. If you replace the plate with something\nof your own, you have replaced the only thing an observer can read at range.\n\nThe multiplayer time-dilation guard releases native dilation while a session is active. It is inactive in single-player. A zero `dilationSetsRefused` counter is normal: the guard releases dilation rather than rejecting the original call.\n\nThe weapon equipment API does not install cyberware operating systems: equipping `Items.AdvancedSandevistanApogee` through `equipment.equip` returns `invalid_record`. Use the supported cyberware APIs instead.\n\n**Still absent by design**, so nobody goes looking: no shared slow motion, no\ndodgeable bullets, no zones. Other players are never slowed and no bullet is ever\nslowed — the advantage is entirely the boosted player's own stats.","natives":[],"permissions":[],"events":[]}]},{"slug":"remote-camera","file":"remote-camera.md","title":"Remote cameras and world screens","summary":"Render another viewpoint without replacing the player's camera. Use remote cameras for a security monitor, a picture-in-picture HUD, a world-space screen or a feed behind a transparent WebUI.","sections":[{"anchor":"remote-cameras-and-world-screens","heading":"Remote cameras and world screens","level":1,"text":"Render another viewpoint without replacing the player's camera. Use remote cameras for a security monitor, a picture-in-picture HUD, a world-space screen or a feed behind a transparent WebUI.\n\nAvailable from **`2.31.13+op77.82`**, this feature is **experimental**. Rendering, source switching and long-session cleanup have known limitations; see [limits and troubleshooting](#limits-and-troubleshooting) before building a public resource around it.","natives":[],"permissions":[],"events":[]},{"anchor":"choose-the-right-api","heading":"Choose the right API","level":2,"text":"| Need | API | Runtime |\n|---|---|---|\n| Create a local capture and display it yourself | `Open77.remoteCamera` | Client |\n| Share camera definitions, authorize viewers and manage remote replication interest | `exports.open77_remote_camera:*` | Server |\n| Move or replace the player's view | [Scripted cameras](cameras.md), not remote cameras | Client |\n| Customize the playable shoulder camera | [Third-person camera styles](third-person-camera.md) | Client |\n\nEach viewer renders the feed on their own client. The server distributes definitions and access rules, **not video frames**. Native client handles and server camera IDs are different identifiers and cannot be substituted for each other.","natives":[],"permissions":[],"events":[]},{"anchor":"prerequisites","heading":"Prerequisites","level":2,"text":"Use matching client/server builds and the Open77 archive containing the capture-slot assets. A client resource calling `Open77.remoteCamera` needs `camera.capture`; add `local.events` to subscribe to the lifecycle events below. Aim raycasts need `world.query`, and parent-frame conversions need `world.transform`.\n\nFor provider-managed cameras, start [`open77_remote_camera`](https://github.com/Open2077/open77-resources/tree/main/system/open77_remote_camera) and declare this dependency in the consuming resource's `open77.lua`:\n\n```lua\ndependency \"open77_remote_camera >=0.3.0\"\n```\n\nA server-only consumer delegates capture to the provider and does not need client capture permissions. A custom client renderer still needs its own permissions and cleanup.","natives":[],"permissions":[],"events":[]},{"anchor":"minimal-client-hud","heading":"Minimal client HUD","level":2,"text":"Example `open77.lua`:\n\n```lua\nresource \"security_monitor\"\nversion \"1.0.0\"\nclient_script \"client.lua\"\npermissions { \"camera.capture\", \"local.events\" }\n```\n\nExample `client.lua`, invoked after the player has entered the world:\n\n```lua\nlocal C = Open77.remoteCamera\nlocal cameraId, bindingId\n\nlocal function closeMonitor()\n if cameraId then\n -- Retires the source and all of its bindings asynchronously.\n C.destroy(cameraId)\n cameraId, bindingId = nil, nil\n end\nend\n\nlocal function openMonitor()\n if cameraId then return end\n local reason\n cameraId, reason = C.create({\n position = { x = -699.094, y = 2164.29, z = 54.7051 },\n rotation = { yaw = 135, pitch = 0, roll = 0 },\n fov = 60,\n range = 100,\n })\n if not cameraId then print(\"Camera: \" .. tostring(reason)); return end\n\n bindingId, reason = C.bindHud(cameraId, {\n x = 80, y = 100, width = 640, height = 360,\n })\n if not bindingId then\n print(\"HUD binding: \" .. tostring(reason))\n closeMonitor()\n end\nend\n\nRegisterCommand(\"monitor\", openMonitor, false)\nRegisterCommand(\"monitor_off\", closeMonitor, false)\nAddEventHandler(\"onResourceStop\", function(name)\n if name == GetCurrentResourceName() then closeMonitor() end\nend)\n```\n\nThese local commands do not authorize access to a server-owned camera or create distant replication interest. Use the server service for a shared surveillance system. Opening a source returns an allocated handle, not a guarantee that a frame is ready.","natives":["RegisterCommand","server:RegisterCommand","AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName"],"permissions":["camera.capture","local.events"],"events":[]},{"anchor":"client-api-reference","heading":"Client API reference","level":2,"text":"All 15 functions below belong to `Open77.remoteCamera`. The [native API reference](/docs/api/client/open77-remotecamera) contains their parameter types, permissions, return values and examples.\n\n| Function | Purpose |\n|---|---|\n| `create(view)` | Allocate an independent source; returns an opaque camera handle or `nil, reason`. |\n| `update(cameraId, patch)` | Update the source pose, FOV or range. |\n| `get(cameraId)` | Read the requested pose, state, reason, dimensions and capture demand. |\n| `list()` | List this resource's sources, including retiring ones. |\n| `destroy(cameraId)` | Retire a source and all of its bindings. |\n| `capabilities()` | Read output format, supported presentations and admission limits. |\n| `budget()` | Read global and resource-local capture/binding usage. |\n| `worldModels()` | List world-panel profiles, sizes and placement bases. |\n| `bindHud(cameraId, rect)` | Bind a viewport rectangle in pixels. |\n| `bindWorld(cameraId, placement)` | Bind a depth-tested world panel in metres. |\n| `bindWebUI(cameraId, page, rect)` | Bind a native underlay behind this resource's transparent page. |\n| `updateBinding(bindingId, patch)` | Change source, visibility or placement without replacing the handle. |\n| `getBinding(bindingId)` | Read one binding's state and requested options. |\n| `bindings(cameraId?)` | List owned bindings, optionally filtered by source. |\n| `unbind(bindingId)` | Retire a binding while retaining the source. |\n\nCreators/getters return a value or `nil, reason`; mutations return `true` or `false, reason`. Every handle belongs to one resource generation. Another resource cannot use it, and reloading the owner invalidates it.","natives":[],"permissions":[],"events":[]},{"anchor":"source-options-and-events","heading":"Source options and events","level":3,"text":"`view` is `{position = {x, y, z}, rotation = {yaw, pitch, roll}, fov, range}`. Position is required, in world metres; angles are degrees. Rotation defaults to zero, FOV to 60 (range 1–120), and capture range to 100 metres (range 1–500).\n\nPatches are sparse by field, **not by axis**: a supplied position must include all three axes; a supplied rotation replaces the whole rotation, with omitted angles becoming zero.\n\nSources progress through `pending`, `active`, `failed`, `destroying` and terminal `removed` states. Bindings also have `hidden` and `unavailable` states. Subscribe from the owning client resource:\n\n```lua\nAddEventHandler(\"open77:remoteCamera:stateChanged\", function(event)\n print(event.cameraId, event.state, event.reason)\nend)\nAddEventHandler(\"open77:remoteCamera:bindingChanged\", function(event)\n print(event.bindingId, event.cameraId, event.state, event.reason)\nend)\n```\n\n`active` means the native object exists, not that a GPU frame is fresh or distant scenery is fully streamed. A source only requests capture while at least one visible binding needs it.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:remoteCamera:stateChanged","open77:remoteCamera:bindingChanged"]},{"anchor":"world-screens-and-parenting","heading":"World screens and parenting","level":3,"text":"Given an owned client `cameraId`, create a screen with a world-space pose:\n\n```lua\nlocal panel, reason = Open77.remoteCamera.bindWorld(cameraId, {\n position = { x = -695, y = 2160, z = 54 },\n rotation = { yaw = 90, pitch = 0, roll = 0 },\n width = 1.6, height = 0.9,\n model = \"surface\",\n})\nif not panel then print(reason) end\n```\n\n`surface` is a frameless plane placed in front of an existing monitor or wall; `panel` includes the stock housing. This is a depth-tested overlay, **not a replacement for an arbitrary prop's material or UVs**. Foreground geometry occludes it. Read `worldModels()` and apply the selected profile's `basis.yawOffset` once: `surface` uses 0 degrees and `panel` uses 90 degrees.\n\nOptional `parent = {type = \"prop\"|\"localProp\"|\"player\"|\"vehicle\"|\"entity\", id = ...}` makes the pose parent-local. Server-managed screens accept only canonical `prop`, `player` or `vehicle` parents. Keep prop IDs as their decimal strings; raw engine entity IDs are not network IDs. A missing parent makes a presentation unavailable.\n\nUse `parent = false` with an explicit world-space position and rotation to detach. A server `updateScreen` that changes or clears its parent requires both vectors in the same patch.","natives":["Open77.remoteCamera.bindWorld"],"permissions":[],"events":[]},{"anchor":"transparent-webui","heading":"Transparent WebUI","level":3,"text":"Given an owned client `cameraId`, create a page and its underlay:\n\n```lua\nlocal page, pageError = WebUI.create({\n entry = \"web/monitor.html\",\n layer = \"hud\", transparent = true, visible = true,\n})\nif not page then print(pageError); return end\nlocal underlay, reason = Open77.remoteCamera.bindWebUI(cameraId, page, {\n x = 80, y = 100, width = 640, height = 360,\n})\nif not underlay then page:destroy(); print(reason) end\n```\n\nDeclare the page assets with `web_files { \"web/**\" }` and keep the feed area transparent in CSS. The page must belong to the calling resource. The rectangle uses viewport pixels and does not follow DOM layout automatically; update its position when the layout changes. On close, unbind the presentation and destroy the page; destroy the source too when it is no longer used.\n\nThe feed is a native GPU underlay: it is **not** an HTML video, an image URL, a JavaScript-readable pixel stream or a transferable texture. See [WebUI resource setup](resource-runtime.md#webui) for page lifetime and input handling.","natives":["Open77.remoteCamera.bindWebUI"],"permissions":[],"events":[]},{"anchor":"server-service","heading":"Server service","level":2,"text":"The provider exposes 19 synchronous server exports through `exports.open77_remote_camera:name(...)`. [Cross-resource exports](server-exports.md) also describes the asynchronous `Open77.exports.call` form and transport errors.\n\n| Export | Contract |\n|---|---|\n| `create(description)` | Return a camera snapshot with an allocated `id`; requires `key`, `position`, `bucket`. |\n| `configure(cameraId, patch)` | Update a camera and reconcile its dependents; `key` cannot be renamed. |\n| `get(cameraId)` | Return an owned camera snapshot. |\n| `list()` | List owned cameras. |\n| `remove(cameraId)` | Remove the camera, its screens, views and leases. |\n| `setAccess(cameraId, playerId, {view = boolean})` | Grant or revoke explicit viewer access. |\n| `acquire(playerId, cameraId)` | Return an interest lease for custom rendering; does not open a picture. |\n| `release(leaseId)` | Release an independent interest lease. |\n| `createScreen(cameraId, placement)` | Return a server screen ID. |\n| `updateScreen(screenId, patch)` | Change camera, placement, dimensions or audience. |\n| `getScreen(screenId)` | Read definition and per-viewer states/reasons. |\n| `listScreens(cameraId?)` | List owned screens, optionally by camera. |\n| `removeScreen(screenId)` | Remove a screen and its viewer references. |\n| `openView(playerId, cameraId, rect, surface?)` | Return a view ID; surface is `\"hud\"` (default) or `\"webui\"`. |\n| `updateView(viewId, patch)` | Update camera, rectangle, visibility or surface; viewer stays fixed. |\n| `getView(viewId)` | Read the view and its asynchronous state. |\n| `listViews(playerId?)` | List owned views, optionally by viewer. |\n| `closeView(viewId)` | Remove a view and its interest reference. |\n| `changes(cursor)` | Return the owner-scoped `{epoch, cursor, events, reset}` change feed. |\n\nRecords belong to the invoking resource generation, determined by the runtime, not by a supplied owner name. Value-returning calls fail with `nil, reason`; mutators fail with `false, reason`. Missing records return `not_found`. Export dispatch errors can raise separately; handle them when a dependency may stop or reload.","natives":[],"permissions":[],"events":[]},{"anchor":"shared-camera-example","heading":"Shared camera example","level":3,"text":"Call these helpers from an authorized **server-side** workflow after checking the viewer's readiness. Supply the authoritative bucket, never a client-supplied bucket or a client-selected viewer ID:\n\n```lua\nlocal service = exports.open77_remote_camera\n\nlocal function openSecurityView(playerId, bucket)\n local camera, reason = service:create({\n key = \"security:\" .. tostring(playerId),\n bucket = bucket,\n position = { x = -699.094, y = 2164.29, z = 54.7051 },\n rotation = { yaw = 135, pitch = 0, roll = 0 },\n fov = 60, range = 100,\n })\n if not camera then return nil, reason end\n local granted, grantError = service:setAccess(camera.id, playerId, { view = true })\n if not granted then service:remove(camera.id); return nil, grantError end\n local viewId, viewError = service:openView(playerId, camera.id, {\n x = 80, y = 100, width = 640, height = 360,\n }, \"hud\")\n if not viewId then service:remove(camera.id); return nil, viewError end\n return { cameraId = camera.id, viewId = viewId }\nend\n\nlocal function closeSecurityView(session)\n -- This example owns a dedicated source per viewer, so removal also\n -- removes its grant, view and interest. Keep shared sources separately.\n return service:remove(session.cameraId)\nend\n```\n\nRetain one returned session per viewer, reject duplicate opens, and call the close helper when your UI or workflow ends. Resource-generation cleanup is a fallback, not a reason to leave unused views open. For a shared source, close only its view with `closeView`, release any explicit leases, and revoke access when appropriate instead of deleting the camera for all viewers.","natives":[],"permissions":[],"events":[]},{"anchor":"access-streaming-and-lifecycle","heading":"Access, streaming and lifecycle","level":3,"text":"`openView` and `acquire` require an explicit `setAccess` grant. World-screen placements use `access = \"granted\"` by default or `\"public\"`, plus a proximity `radius` (default 40 metres). Viewers must be ready, in the camera's bucket and within screen radius. Public proximity does not grant access to a separate private HUD view.\n\nOne camera/viewer pair shares one replication-interest point across its views, screens and explicit leases. Provider-managed presentations also share one native source for that pair. An interest-only lease allocates no capture, and a custom renderer must reconcile its own sources. Interest admission and native capture have separate budgets; neither proves complete scenery.\n\nUse `getScreen`, `getView` and `changes` to surface asynchronous failures. Keep the returned journal cursor; if `reset` is true or the provider epoch changes, rebuild your owned state from list/get calls. Resource stop/reload retires the old generation's records. Server removal is synchronous, but interest reconciliation and native teardown are asynchronous.\n\nOptional camera `housing = {model, offset?, yaw?}` creates a server-owned prop that follows source position and yaw, not pitch/roll. Its default offset is `{x=0, y=-0.35, z=0}`. Inspect the camera snapshot for the housing `propId` or refusal reason; `configure(id, {housing=false})` removes it.","natives":[],"permissions":[],"events":[]},{"anchor":"placement-editor-and-commands","heading":"Placement editor and commands","level":2,"text":"The editor never opens automatically. Grant `command.remote-camera.editor` through the normal [ACL system](server-acl.md), then run `/remote-camera.editor` as a ready player. Viewer commands require their matching `command.` permission; the built-in admin role covers `command.*`.\n\n1. Create an owned source or select a shared one. **Camera from player view** copies a starting pose; a shared source is read-only in the placement editor.\n2. Use **Snap to aim**, then **F4**, to place the panel on the aimed surface. Adjust position, rotation, physical size and depth offset.\n3. **F6 / CAMERA** continuously follows the player's view for source placement. **F7 / SURFACE** continuously snaps the screen to the centre ray. Turn the active mode off to return to the editor.\n4. Choose public or granted access and radius, then **Save placement**. An unsaved preview is local only. **Copy Lua** exports a server helper with explicit bucket/viewer/parent inputs; **Copy JSON** exports placement data.\n\n| Command | Purpose |\n|---|---|\n| `/remote-camera.editor` | Open a draft; also supports `open `, `reopen `, `list`, `delete `, `snap`, `close`, `sync`. |\n| `/remote-camera.cameras [filter]` | List provider-owned cameras and their bucket/housing status. |\n| `/remote-camera.hud [x y width height]` | View a camera in a HUD rectangle; default `80 100 640 360`. |\n| `/remote-camera.webui [x y width height]` | View it behind the provider's transparent page. |\n| `/remote-camera.off` | Close command-opened views and revoke the grants those commands created. |\n| `/remote-camera.slots [n]` | Inspect or change the capture policy; requires `command.remote-camera.slots`. |\n\nDefinitions persist in resource KVP storage. Runtime parent IDs and numeric player IDs are session-local: restored placements use their world-space fallback, require parent reselection, and **do not restore private grants from saved numeric player IDs**. An authorized operator must grant access again. Commands/editor operate on provider-owned records; another resource's cameras remain owned by that resource.","natives":[],"permissions":[],"events":[]},{"anchor":"limits-and-troubleshooting","heading":"Limits and troubleshooting","level":2,"text":"- Output is fixed at **640 × 360**. There is no per-camera resolution, aspect-ratio or FPS setting. Each capture is an additional scene render.\n- Default assets contain **eight capture slots**. `budget().cameras` includes `limit`, `capacity`, `used`, `live`, `retiring`; retiring allocations count against the limit. Native capacity and a higher operator policy do not prove additional slot assets exist.\n- `camera_slots_exhausted` means no allocation is available. Do not retry creation every frame. Missing/incompatible slot assets can fail creation or later configuration.\n- `stale_id` / `stale_binding_id` means the native handle has expired. Server `not_found` concerns a different, server-owned record.\n- An `active` but empty/incomplete feed may lack streamed scenery or replicated entities. Check the binding reason, viewer bucket/access, interest lease and capture demand separately.\n- Switching an established HUD/WebUI source can leave the previous image mounted. `visible=false` on a world binding stops capture demand but may leave its mesh showing a frozen or shared-source image; it is not a reliable removal guarantee.\n- Lighting/exposure can differ from the gameplay camera. World-panel hiding, failed-save rollback, long-session slot reclamation and delayed teardown stability have unresolved gaps, including intermittent engine crashes. Keep concurrent feeds low and treat this release as experimental.\n\nFor implementation details and ongoing constraints, see the [remote-camera technical notes](https://github.com/Open2077/open77-base/blob/main/docs/research/remote-camera.md). Use [screenshots](screenshots.md) when you need an actual captured image rather than a live native underlay.","natives":[],"permissions":[],"events":[]}]},{"slug":"resource-exports","file":"resource-exports.md","title":"Official resource exports","summary":"Use official resource exports for shared services such as interfaces, lifecycle management and gameplay helpers. These exports are separate from runtime-native methods such as `Open77.vehicles.get`.","sections":[{"anchor":"official-resource-exports","heading":"Official resource exports","level":1,"text":"Use official resource exports for shared services such as interfaces, lifecycle management and gameplay helpers. These exports are separate from runtime-native methods such as `Open77.vehicles.get`.\n\nThe package catalogue on this page lists **client exports**. Server resources can\nalso publish and call their own exports using the same asynchronous surface; see\n[Cross-resource server exports](server-exports.md). The two registries are separate.\nServer-authoritative mutation remains in server\nscripts through the [server Lua API](server-api.md), net events, or a package's documented server\ninterface.","natives":[],"permissions":[],"events":[]},{"anchor":"calling-an-export","heading":"Calling an export","level":2,"text":"There are two forms, and the difference is not style: one waits, the other does not.","natives":[],"permissions":[],"events":[]},{"anchor":"synchronous-exportsresourcename","heading":"Synchronous — `exports.:(...)`","level":3,"text":"The FiveM spelling works, on the client and on the server, and it returns the\nexport's values directly:\n\n```lua\nlocal fuel = exports.open77_fuel:level(vehicleId)\nlocal ok, reason = exports['open77-garage']:store(vehicleId)\n```\n\nA resource name containing a hyphen only works through the bracket form, exactly\nas in FiveM. `exports.res.name(...)` (a dot instead of a colon) works too; the\nproxy recognises the `self` the colon inserts and drops it.\n\nThe call runs **inline, on this thread**: every resource VM on the client runs on\nthe game thread and every server VM runs on the host scheduler thread, so the\ncallee's function body executes before the call expression returns. Nothing is\nqueued, nothing yields, no tick boundary passes. That is what makes it usable\nfrom a getter — and what makes the following true:\n\n- **The callee must not yield.** `Wait`, `promise:await()` and anything built on\n them fail the call with `export_yielded`. There is no scheduler underneath a\n synchronous callee to yield to. Use the asynchronous form for slow work.\n- **Failure raises.** This is the one place Open77 does not return `nil, reason`.\n FiveM's exports raise, ported resources are written for that, and a raise is\n also the only way to keep \"the export returned nil\" distinguishable from \"the\n call failed\". Wrap it in `pcall` when you want to handle failure yourself.\n- **Recursion is capped at 8 frames.** A → B → A is fine and works; the ninth\n nested frame fails with `export_recursion_limit`.\n- **A resource may call its own export.** It is an ordinary nested call, with the\n same copy of arguments and results as any other, and it counts toward the cap.\n\n`Open77.exports.callSync(resource, name, ...)` is the same path without the\nsugar, including the raise, and is what you want when the export name is in a\nvariable.","natives":[],"permissions":[],"events":[]},{"anchor":"asynchronous-open77exportscall","heading":"Asynchronous — `Open77.exports.call(...)`","level":3,"text":"Unchanged, and still the right tool whenever the callee may be slow, may wait on\nthe network or a database, or may not exist yet:\n\n```lua\nlocal promise, reason = Open77.exports.call(\"open77_notifications\", \"show\", {\n type = \"success\",\n title = \"Garage\",\n message = \"Vehicle stored.\"\n})\nif not promise then error(reason) end\n\nlocal result = promise:await()\nif not result.ok then print(result.error) end\n```\n\nIt returns `nil, reason` instead of raising, it runs the callee on the callee's\nown scheduler where it is free to `Wait`, and `:await()` needs a managed task\n(`CreateThread`, a handler, a command). Every export in the catalogue below is\ncallable both ways.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"what-crosses-and-what-does-not","heading":"What crosses, and what does not","level":3,"text":"Both forms copy. Arguments and results are transferred by value through the same\nserialiser: nil, booleans, finite numbers, 64-bit integers, binary strings and\nnested plain tables with string or integer keys. A table you hand to another\nresource is **not** the table it receives, so a callee mutating it cannot touch\nyours, and metatables, functions, userdata and coroutines never travel. Nothing\nabout running inline weakens that: the callee gets a fresh coroutine in its own\nLua state, with its own globals, its own instruction budget and its own\npermissions. Synchronous and asynchronous calls differ only in *when* the callee\nruns, never in what it can reach.\n\n`GetInvokingResource()` and `GetInvokingResourceGeneration()` name the immediate\ncaller for the duration of the call and are restored afterwards, including when\nthe callee raises.","natives":[],"permissions":[],"events":[]},{"anchor":"refusal-reasons","heading":"Refusal reasons","level":3,"text":"| Reason | Meaning |\n| --- | --- |\n| `export_resource_unavailable` | No resource of that name is running. |\n| `export_target_stopped` | It exists but is stopping, preparing, or not running. |\n| `export_not_found` | It is running but publishes no export of that name. |\n| `export_recursion_limit` | The chain already has 8 synchronous frames. |\n| `export_yielded` | The callee tried to `Wait` or `await`. |\n| `export_budget_exhausted` | The callee burned its own instruction or time budget. |\n| `export_raised` (server) / `... raised: ` (client) | The callee raised; the original message is appended. |\n| `export_arguments_not_serializable`, `export_result_not_serializable` | A value could not be copied. |\n| `resource_preparing` | Called at file scope while this VM was still loading. |\n\nA callee that exhausts its budget fails **that call**, with its own counter reset\nfor the next one; the caller is not killed for it, and on the client the wall\ntime the callee spent is credited back to the caller's own deadline.\n\nHandles returned by UI packages are resource-owned. Another resource cannot update or dismiss\nthem, and they are cleaned automatically when the owning generation stops or reloads.","natives":[],"permissions":[],"events":[]},{"anchor":"export-catalogue","heading":"Export catalogue","level":2,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"open77contextmenu","heading":"`open77_contextmenu`","level":3,"text":"Generic ALT/click framework, no built-in gameplay actions. Client-only exports:\n\n| Exports | Purpose |\n|---|---|\n| `register`, `registerMany` | Register one definition or an atomic batch. |\n| `registerPlayers`, `registerVehicles`, `registerNpcs`, `registerProps`, `registerDoors`, `registerWorld` | Typed registration helpers; one definition or a batch. |\n| `registerSelf`, `registerSky` | Visible F7 self / explicit empty-space direction actions. |\n| `registerModels`, `registerEntities` | Exact record or canonical/local ID filters plus definitions. |\n| `update`, `get`, `setEnabled`, `list` | Update (new token), inspect, enable/disable or list owned actions. |\n| `unregister`, `unregisterMany`, `clear` | Remove only caller-owned actions. |\n| `isOpen`, `isReady`, `getVersion`, `getContext`, `getTarget` | Framework status and current selection snapshot. |\n| `setTargetingEnabled`, `isTargetingEnabled`, `close` | Resource-scoped disable claims and input lifecycle. |\n\nActions belong to their invoking resource/generation; callbacks are provider\nexport names, not closures. Self/sky filters are opt-in, and sky coordinates are\na ray endpoint, not a physical impact. Provider stop cleans up actions/claims.\nSee [Context menu](context-menu.md) for contracts, installation and server security.","natives":[],"permissions":[],"events":[]},{"anchor":"open77animations","heading":"`open77_animations`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `list` | `list(query?)` | Matching profile definitions. |\n| `get` | `get(profileId)` | Profile or nil. |\n| `request` | `request(profileId, options?)` | Server-accepted local playback, or nil/error. |\n| `sequence` | `sequence(steps, options?)` | Server-accepted local sequence, or nil/error. |\n| `cancel` | `cancel(playbackId?)` | Queue self-cancellation; omitted ID also abandons pending requests. |\n| `state` | `state(playerId?)` | Active canonical state; local player by default. |\n\nThe preferred client facade is `Open77.animations.(...):await()` with the\nsame arguments/results. Preserve the initial `nil, error` dispatch check before\nawaiting. See [RP animations](rp-animations.md) for examples, permissions, lifecycle\nand the precise native playback validation limits.","natives":[],"permissions":[],"events":[]},{"anchor":"open77appearance","heading":"`open77_appearance`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `open` | `open(mode?)` | Requests the server-authorized appearance workflow. |\n| `capture` | `capture()` | Current native appearance snapshot. |\n| `isOpen` | `isOpen()` | Whether the appearance UI is active. |\n| `revision` | `revision()` | Last canonical appearance revision. |\n| `characterKey` | `characterKey()` | Durable character key associated with the synchronized appearance. |\n\nSee the package README at [`resources/system/open77_appearance/README.md`](https://github.com/Open2077/open77-resources/blob/main/system/open77_appearance/README.md) for the transaction lifecycle.","natives":[],"permissions":[],"events":[]},{"anchor":"open77equipment","heading":"`open77_equipment`","level":3,"text":"There is no `open77_clothing` package. Client-side clothing lives in [`resources/open77_equipment`](https://github.com/Open2077/open77-resources/tree/main/standalone/open77_equipment),\nand the wardrobe it can hide lives in [`resources/open77_wardrobe`](https://github.com/Open2077/open77-resources/tree/main/standalone/open77_wardrobe); `Open77.clothing` is a\n**server** namespace whose requests this package answers.\n\n| Export | Signature | Result |\n|---|---|---|\n| `slots` | `slots()` | The nine canonical slots and their REDengine attachment slots. |\n| `records` | `records(options?)` | Filtered, bounded catalogue query: `slot`, `family`, `restricted`, `limit`. |\n| `info` | `info(record)` | Metadata for one exact catalogue record. |\n| `registry` | `registry()` | What is currently in every slot. |\n| `equip` | `equip(record, slot?, options?)` | Validates and equips one local record. |\n| `unequip` | `unequip(slot)` | Empties one slot without deleting the inventory item. |\n| `apply` | `apply(slots, options?)` | Applies a whole slot-to-record table in one transaction. |\n| `beginPreview` | `beginPreview()` | Claims temporary native mutation for the fitting room. |\n| `endPreview` | `endPreview()` | Releases the claim and re-applies the real state. |\n\nThe two preview exports are **not general-purpose**: they refuse any caller other than\n`open77_wardrobe_ui`, checked with `GetInvokingResource()`. The fitting room owns temporary native\nmutations; it never owns server intent.\n\nThe dedicated runtime separately exposes asynchronous server methods under `Open77.clothing`; they\nare not client package exports. They target a player and complete through\n`open77:clothing:completed`, which this package answers. See\n[Complete server Lua API](server-api.md#player-clothing).","natives":[],"permissions":[],"events":["open77:clothing:completed"]},{"anchor":"open77wardrobe","heading":"`open77_wardrobe`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `beginPreview` | `beginPreview()` | Claims the wardrobe preview for the fitting room. |\n| `endPreview` | `endPreview()` | Releases the claim and restores the replicated wardrobe. |\n\nSame ownership rule as `open77_equipment`: both exports refuse a caller other than\n`open77_wardrobe_ui`. Everything else this package does — projecting other players' outfits onto\ntheir puppets through `Open77.puppets.setWardrobe` — happens on events, not on exports.","natives":[],"permissions":[],"events":[]},{"anchor":"open77weapons","heading":"`open77_weapons`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `slots` | `slots()` | `{ 1, 2, 3 }`. |\n| `assign` | `assign(record, slot, options?)` | Native asynchronous request ID. |\n| `setActive` / `activate` | `setActive(slot)` or `setActive(record, options?)` | Native asynchronous request ID. |\n| `remove` / `unequip` | `remove(slot)` | Native asynchronous request ID. |\n| `holster` | `holster()` | Native asynchronous request ID. |\n| `setAmmo` | `setAmmo(slot, amounts)` | Sets bounded reserve/magazine amounts through the native asynchronous request path. |\n| `snapshot` / `all` | `snapshot()` | Native asynchronous request ID. |\n\nThe package also owns the authenticated target-client relay used by server\n`Open77.weapons.*` calls. Completion, slot-state fields, permissions, errors,\nand authority rules are documented in [Weapon Lua API](weapons-api.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77perspective","heading":"`open77_perspective`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `get` | `get()` | Current preferred perspective mode. |\n| `set` | `set(mode)` | Requests `first` or `third` person through the ownership arbiter. |\n| `toggle` | `toggle()` | Toggles the preferred mode. |\n| `state` | `state()` | Current preference, effective mode, ownership and availability. |\n| `key` | `key()` | Current local toggle key. |\n| `setKey` | `setKey(key)` | Validates and persists the local toggle key. |\n\nSee [Perspective](perspective.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77vehiclepicker","heading":"`open77_vehiclepicker`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `open` | `open(options?)` | Opens the picker for a declared roster/list. |\n| `close` | `close()` | Closes it and releases UI focus. |\n| `selection` | `selection(listId)` | Current persisted key for a known list. |\n| `roster` | `roster(listId)` | Read-only roster data for a custom gamemode UI. |\n| `isOpen` | `isOpen()` | Whether the picker currently owns its surface. |","natives":[],"permissions":[],"events":[]},{"anchor":"open77chat","heading":"`open77_chat`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `addMessage` | `addMessage(message)` | Adds one structured message to the local chat UI. |\n| `clear` | `clear()` | Clears visible messages. |\n| `addSuggestion` | `addSuggestion(command, help, parameters?)` | Adds or replaces slash-command completion metadata. |\n| `removeSuggestion` | `removeSuggestion(command)` | Removes one completion entry. |\n| `setEnabled` | `setEnabled(enabled)` | Enables/disables chat for the calling resource context. |\n| `isEnabled` | `isEnabled()` | Returns the current enable state. |\n\nMessage and suggestion schemas are documented in [Chat](chat.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77death","heading":"`open77_death`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `isDead` | `isDead(playerId?)` | Canonical death state for the local or selected player. |\n| `getState` | `getState(playerId?)` | Canonical life-state snapshot. |\n| `getLocalDeathContext` | `getLocalDeathContext()` | Local death context, with the last package snapshot as fallback. |\n| `all` | `all()` | All currently known player life states. |","natives":[],"permissions":[],"events":[]},{"anchor":"open77effects","heading":"`open77_effects`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `playVfx` | `playVfx(effect, options?)` | Starts a world VFX owned by the caller. |\n| `playEntityVfx` | `playEntityVfx(effect, options?)` | Starts an entity-attached VFX. |\n| `playSfx` | `playSfx(event, options?)` | Starts an SFX event, optionally spatialized or attached. |\n| `stop` | `stop(handle)` | Stops a caller-owned effect handle. |\n| `catalog` | `catalog()` | Returns the runtime effect catalogue. |\n| `looping` | `looping()` | Server-owned looping effects this client is currently projecting. |\n\nSee [Visual and audio effects](effects.md) and [Game data reference](data-reference.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77props","heading":"`open77_props`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `get` | `get(id)` | One projected server prop record, by its server ID. |\n| `all` | `all()` | Every server prop this client is currently projecting, ordered by ID. |\n\nRead-only by design. Creating or mutating a replicated prop exists only on the dedicated-server\n`Open77.props` API — a client cannot mint a prop anybody else can see. There is deliberately no\n`handle` export: the projection layer is keyed by the server's own ID, so a second identity could\nonly ever get out of step with it.\n\nSee [Server-owned world props](props.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77devices","heading":"`open77_devices`","level":3,"text":"**Server exports only.** The client half owns the device-interaction claim for the whole\nserver-declared policy and exposes nothing a caller would reach; the client API it uses is\n`Open77.world.setDeviceInteractionEnabled`, documented in\n[vanilla device prompts](device-interactions.md).\n\n| Export | Signature | Result |\n|---|---|---|\n| `disable` | `disable(target, options?)` | Turns the vanilla prompt off for that engine entity id or device class on every client. `options.bucket` scopes it to one routing bucket. `true`, or `false, reason`. |\n| `enable` | `enable(target, options?)` | Declares an explicit allow. Not the same as `clear`: this is still a policy this resource holds. |\n| `clear` | `clear(target?)` | Withdraws one declaration, or all of them when `target` is omitted. |\n| `list` | `list()` | What this resource currently has declared. |\n\nLate joins and routing-bucket changes are answered; a declaring resource that stops takes its\npolicies with it. The package also re-publishes every vanilla device use host-wide as\n`open77_devices:used(playerId, engineEntity, className, choice, allowed)` -- a rate-limited **claim**\nby that client, never authority.","natives":[],"permissions":[],"events":[]},{"anchor":"open77elevators","heading":"`open77_elevators`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `get` | `get(id)` | One streamed authoritative elevator snapshot. |\n| `all` | `all()` | All streamed elevator snapshots. |\n| `requestFloor` | `requestFloor(id, floor)` | Requests a `goto` action. |\n| `requestCall` | `requestCall(id, floor)` | Requests a `call` action. |\n\nSee [Elevators](elevators.md). Elevator IDs are opaque even though this compatibility package\ncurrently normalizes them before calling the native API.","natives":[],"permissions":[],"events":[]},{"anchor":"open77fuel","heading":"`open77_fuel`","level":3,"text":"The canonical [state-bag sample](state-bags.md#the-fuel-sample-open77_fuel): fuel is a\nserver-owned `fuel` key on every vehicle's bag, burned from the replicated speed and engine\nstate, with the engine cut at zero.\n\n| Export | Signature | Result |\n|---|---|---|\n| `level` | `level(vehicleId)` | The replicated litres, read from the bag mirror; `nil, reason` for an unknown or unstreamed car. |\n| `current` | `current()` | The litres of the car the local player is sitting in, or `nil, \"not_in_vehicle\"`. |\n\nThe client also re-emits every bag change as `open77:fuel:changed(vehicleId, litres, previous)`.\nThere is no client `set`: the client never writes a bag.\n\n**Server exports**, on the same package, for scripts porting `GetVehicleFuelLevel` /\n`SetVehicleFuelLevel`: `level(vehicleId)`, `set(vehicleId, litres)`, `refuel(vehicleId, litres?)`\n(to the brim when the amount is omitted), `capacity()` and `configure({ capacity,\nlitresPerHundredKm, idleLitresPerMinute, multiplier })`. Every write is clamped into the tank and\nanswers `litres | nil, reason`; the host-wide `open77:fuel:empty(vehicleId)` fires once per empty.","natives":[],"permissions":[],"events":[]},{"anchor":"open77groundcircle","heading":"`open77_groundcircle`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `create` | `create(definition)` | Creates a caller-owned HUD ground circle and returns its handle. |\n| `remove` | `remove(handle)` | Removes one caller-owned circle. |\n| `clear` | `clear()` | Removes every circle owned by the caller; answers with `removed`. |\n| `list` | `list()` | Snapshots of every circle owned by the caller. |\n\nA screen overlay rather than a world entity: the circle is sampled in world space, projected point by\npoint with `Open77.camera.project` and stroked as an SVG polyline on a transparent HUD surface, so it\ndepends on no depot asset and cannot report itself as rendered while drawing nothing. The trade-offs\nare that it is not occluded by geometry, receives no light, exists only for the local player, and is\nsampled on the Lua tick rather than per frame, so it trails a fast camera whip.\n\n`create` deliberately reads the same field names [`open77_worldui`](worldui.md) reads --- `id`,\n`position`, `radius`, `maxDistance`, `style`, `groundOffset`, `color` --- and *ignores* the prompt\nhalf of that definition rather than rejecting it, so one definition table can be handed to either\nresource. It also accepts `width` (0.5--8.0), `opacity` (0.05--1.0), `fill` (0.0--0.6) and `visible`.\n`radius` is clamped to 0.1--50.0 and `maxDistance` to 1.0--500.0, matching `Open77.markers`. The limit\nis 32 circles across every resource combined (`circle_limit`).\n\nIt is a diagnostic and a fallback for a native marker that is not producing pixels, not a replacement\nfor one. For a ground ring that must be frame-tight, use `Open77.anchors` with `render = \"ring\"`,\nwhich is projected natively on the frame that draws it.","natives":[],"permissions":[],"events":[]},{"anchor":"open77interactions","heading":"`open77_interactions`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `create` | `create(definition)` | Creates a caller-owned contextual interaction and returns its handle. |\n| `update` | `update(handle, patch)` | Applies a partial update. |\n| `remove` | `remove(handle)` | Removes one caller-owned interaction. |\n| `setVisible` | `setVisible(handle, visible)` | Convenience visibility update. |\n| `get` | `get(handle)` | Snapshot of one caller-owned interaction. |\n| `all` | `all()` | Snapshots of every interaction owned by the caller. |\n| `clear` | `clear()` | Removes all caller-owned interactions. |\n| `setEnabled` | `setEnabled(enabled)` | Enables/disables rendering and input for the caller. |\n| `isEnabled` | `isEnabled()` | Returns the caller enable state. |\n\nTargeting — standing rules that materialise interactions on whatever the world currently holds,\nrather than one prompt bolted to one place:\n\n| Export | Signature | Result |\n|---|---|---|\n| `addTarget` | `addTarget(definition)` | Registers a caller-owned target rule and returns its handle. |\n| `removeTarget` | `removeTarget(handle)` | Removes one caller-owned target and every prompt it materialised. |\n| `clearTargets` | `clearTargets()` | Removes every client-declared target owned by the caller. |\n| `getTarget` | `getTarget(handle)` | Snapshot of one caller-owned target, including its live match count. |\n| `allTargets` | `allTargets()` | Snapshots of every target owned by the caller. |\n| `addModel` | `addModel(models, options)` | `addTarget` with `kind = \"model\"`. |\n| `addClass` | `addClass(classes, options)` | `addTarget` with `kind = \"class\"`. |\n| `addGlobalVehicle` | `addGlobalVehicle(options)` | `addTarget` with `kind = \"globalVehicle\"`. |\n| `addGlobalPlayer` | `addGlobalPlayer(options)` | `addTarget` with `kind = \"globalPlayer\"`. |\n| `addGlobalPed` | `addGlobalPed(options)` | `addTarget` with `kind = \"globalNpc\"`. `addGlobalNpc` is the same export. |\n| `addZone` | `addZone(options)` | `addTarget` with `kind = \"zone\"`. |\n| `addBoxZone` | `addBoxZone(options)` | `addZone` with `position` + `size` (+ `heading`) folded into a box shape. |\n| `addSphereZone` | `addSphereZone(options)` | `addZone` with `position` + `radius` folded into a sphere shape. |\n\n**Server exports** (a declaration made once, applied by every client — see\n[Cross-resource server exports](server-exports.md)):\n\n| Export | Signature | Result |\n|---|---|---|\n| `define` | `define(target \\| { target, ... })` | Publishes this resource's targets to every client. `true`, or `false, reason`. |\n| `undefine` | `undefine(id?)` | Withdraws one target, or all of them when `id` is omitted. |\n| `clear` | `clear()` | Withdraws every target this resource declared. |\n| `list` | `list()` | The ids this resource currently has declared. |\n\nDefinitions, marker types, choices, distances, entity attachment, target kinds, `canInteract`,\ngroup gates, and responses are documented in [Contextual interactions](interactions.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77loot","heading":"`open77_loot`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `get` | `get(id)` | One streamed canonical loot drop. |\n| `all` | `all()` | Copy of all streamed drops. |\n| `requestPickup` | `requestPickup(id)` | Submits a bounded pickup request to the server. |\n\nSee [Loot](loot.md). Clients cannot create or award authoritative loot.","natives":[],"permissions":[],"events":[]},{"anchor":"open77markers","heading":"`open77_markers`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `create` | `create(definition)` | Creates a native marker and returns its ID. |\n| `update` | `update(id, patch)` | Updates mutable marker fields. |\n| `remove` | `remove(id)` | Removes one marker. |\n| `clear` | `clear()` | Removes resource-owned markers. |\n| `list` | `list()` | Lists current resource-owned markers. |\n\nThe lower-level method schema is in the generated `Open77.markers.*` reference.","natives":[],"permissions":[],"events":[]},{"anchor":"open77nameplates","heading":"`open77_nameplates`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `setEnabled` | `setEnabled(enabled)` | Enables/disables caller-owned overrides. |\n| `isEnabled` | `isEnabled()` | Current enable state. |\n| `set` | `set(playerId, options)` | Sets a nameplate override for one player. |\n| `remove` | `remove(playerId)` | Removes one override. |\n| `clear` | `clear()` | Removes every caller-owned override. |\n\n**Invisible players never get a plate, whatever the override says.** When the\nserver hides a player with\n[`Open77.players.setVisible(id, false)`](server-api.md#player-visibility), his\nnameplate is withheld along with his body, on every surface that reads the\nroster -- the native overlay, a streamed page, and `Open77.nameplates` snapshots\nalike. This is not a style choice a resource can override: the plate is\nprojected from the body's world position, so leaving it up would draw the exact\nlocation that hiding the body exists to conceal. `options.visible` still governs\neverything else.\n\nThe plate returns on its own when the player is made visible again, or when any\nof the automatic releases fires (death, respawn, bucket change, disconnect,\nreconnect, or the hiding resource stopping). Client code never has to reconcile\nit: the roster is filtered from the replicated life flag, which is the single\nsource of truth -- **2.31 exposes no readable hidden flag on the engine side, so\nno client can be asked whether a body is really hidden right now.**","natives":[],"permissions":[],"events":[]},{"anchor":"open77notifications","heading":"`open77_notifications`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `show` | `show(definition)` | Displays a caller-owned notification and returns its handle. |\n| `update` | `update(handle, patch)` | Updates a visible or queued notification. |\n| `dismiss` | `dismiss(handle)` | Dismisses one caller-owned notification. |\n| `clear` | `clear()` | Dismisses every caller-owned notification. |\n| `list` | `list()` | Snapshots of caller-owned notifications. |\n| `setEnabled` | `setEnabled(enabled)` | Enables/disables notifications for the caller. Disabling also clears them. |\n| `isEnabled` | `isEnabled()` | Returns the caller enable state. |\n\nSee [Notifications](notifications.md) for types, positions, duration, progress, actions, replacement,\nand the server-to-client envelope.","natives":[],"permissions":[],"events":[]},{"anchor":"open77uikit","heading":"`open77_uikit`","level":3,"text":"Every dialog here **waits for a human**, so it must be called through\n`Open77.exports.call(...)`; the synchronous `exports.open77_uikit:alert(...)`\nspelling fails a waiting callee with `export_yielded`. A dispatch failure is\n`nil, reason`; a widget that actually ran resolves with\n`{ ok, outcome, value }`, where `outcome` is `\"ok\"`, `\"cancelled\"` or\n`\"timeout\"`. **A cancel is an outcome, never a rejection.**\n\n| Export | Signature | Result |\n|---|---|---|\n| `progress` | `progress(definition)` | **Waits.** Runs a timed bar, blocking the actions named in `disable` through `Open77.input`. |\n| `progressActive` | `progressActive()` | `boolean, owner`. |\n| `cancelProgress` | `cancelProgress()` | Ends the caller's own bar early. |\n| `textUI` | `textUI(definition or text)` | Shows or replaces the caller's persistent hint. |\n| `hideTextUI` | `hideTextUI()` | Removes it. Idempotent. |\n| `textUIState` | `textUIState()` | `{ open, text, position, total }`. |\n| `alert` | `alert(definition)` | **Waits.** A confirm/cancel panel; `value` is `\"confirm\"`. |\n| `input` | `input(definition)` | **Waits.** A form; `value` carries the fields keyed by id and indexed by position. |\n| `context` | `context(definition)` | Registers a browse menu under `definition.id`, owner-scoped. |\n| `showContext` | `showContext(id, options?)` | **Waits.** Opens it, with in-dialog navigation through an option's `menu` field. |\n| `hideContext` | `hideContext()` | Closes the caller's open dialog. |\n| `menu` | `menu(definition)` | Registers a keyboard menu with checkbox and side-scroll rows. |\n| `showMenu` | `showMenu(id, options?)` | **Waits.** Opens it; the answer carries `values` even on a close. |\n| `hideMenu` | `hideMenu()` | Closes the caller's open dialog. |\n| `radial` | `radial(definition)` | Registers an eight-sector wheel. |\n| `showRadial` | `showRadial(id, options?)` | **Waits.** Opens it. |\n| `hideRadial` | `hideRadial()` | Closes the caller's open dialog. |\n| `drawText3D` | `drawText3D(definition)` | A string floating over a world point or entity, drawn natively through the anchor facade. Returns a handle; 8 per owner, 24 in all. |\n| `updateText3D` | `updateText3D(handle, patch)` | Changes the text, colours, scale, distance band or position of a caller-owned text. |\n| `clearText3D` | `clearText3D(handle?)` | Removes one text, or every text the caller drew (`true, count`). Idempotent. |\n| `listText3D` | `listText3D()` | The caller's texts with the host's last projection: `distance`, `onScreen`, `inRange`, `expiresIn`. |\n| `showCinematicBars` | `showCinematicBars(enabled, options?)` | Claims or releases the letterbox: two bars plus the vanilla HUD hidden under the kit's own loan. |\n| `setCinematic` | `setCinematic(enabled, options?)` | The same claim under the parity ledger's name. |\n| `cinematicState` | `cinematicState()` | `{ active, hudHidden, mine, holders, heightPct, color }`. |\n| `close` | `close()` | Cancels every widget the caller holds; returns `true, count`. |\n| `state` | `state()` | Diagnostic snapshot: surface readiness, focus, held blocks, live widgets. |\n\nOne focus-taking dialog exists across the whole session: a second request is\nrefused with `dialog_active` rather than queued. The kit spends exactly **one**\nWebUI surface for all of it.\n\nThe server twins -- `progress`, `alert`, `input`, `context`, `menu`, `radial`,\n`textUI`, `hideTextUI`, `drawText3D`, `updateText3D`, `clearText3D`,\n`showCinematicBars` / `setCinematic` and `close`, each taking a `playerId`\nfirst -- are server exports, not client ones, and live on the same resource. See\n[The UI kit](ui-kit.md) for definitions, the focus release matrix, the styling\ntokens a resource may override, failure reasons and a worked job step.","natives":[],"permissions":[],"events":[]},{"anchor":"open77rpbasics","heading":"`open77_rp_basics`","level":3,"text":"**Server exports only.** The role-play kit publishes no client exports: its client half owns a\ncontrol-block claim and nothing a caller would want to reach.\n\n| Export | Signature | Result |\n|---|---|---|\n| `cuff` | `cuff(officerId, targetId, options?)` | Freezes the target, blocks his controls and plays `handsup`. `true`, or `false, reason`. |\n| `escort` | `escort(officerId, targetId, options?)` | Lets him walk with everything but movement taken away, tethered to the officer. `true`, or `false, reason`. |\n| `search` | `search(officerId, targetId, options?)` | Starts a timed frisk; returns a ticket string, or `false, reason`. The report arrives as `open77_rp_basics:searched`. |\n| `carry` | `carry(officerId, targetId)` | Always `false, \"carry_unsupported\"` -- nothing on 2.31 can parent one player to another. Present so a port gets a named refusal instead of a silent no-op. |\n| `release` | `release(targetId, byPlayerId, reason?)` | Releases what this kit holds. The holding officer, an `rp.admin`, or the console (`0`). `true`, or `false, reason`. |\n| `state` | `state(targetId)` | The standing hold on one player, or nil. |\n| `held` | `held()` | Every standing hold. |\n| `capabilities` | `capabilities()` | The verb catalogue, with `supported` and a reason for the one that is not. Read this instead of hard-coding what a build can do. |\n\nAuthority is the ACL (`rp.cuff`, `rp.escort`, `rp.search`, `rp.admin`), not the caller: an export\ncarries no player identity. See [the role-play kit](rp-kit.md) for the gate, the leash, and the\ntwelve ways a hold ends.","natives":[],"permissions":[],"events":[]},{"anchor":"open77sound","heading":"`open77_sound`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `play` | `play(id, file, options?)` | Plays a file the **calling** resource ships. Returns the id, or `nil, reason`. |\n| `stop` | `stop(id)` | Stops one caller-owned sound. |\n| `stopAll` | `stopAll()` | Stops every caller-owned sound; returns how many. |\n| `setVolume` | `setVolume(id, volume)` | 0..1, ramped so it does not click. |\n| `setPosition` | `setPosition(id, position)` | Moves a spatial sound; `sound_not_spatial` on a 2D one. |\n| `isPlaying` | `isPlaying(id)` | Whether a caller-owned sound is still playing. |\n| `list` | `list()` | `{ id, file, loop, volume, spatial }` for caller-owned sounds. |\n| `preload` | `preload(file)` | Ships and decodes a clip ahead of its first play. |\n\n`file` must be declared in the **calling** resource's `files` manifest entry — a\nresource can only play audio it ships itself, and that is enforced by the host,\nnot by convention. `options`: `position`, `entity`, `volume`, `loop`, `pitch`,\n`offset`, `maxDistance`, `refDistance`.\n\nSee [Sound](sound.md) for the spatial model, the caps, the server twins\n(`Open77.sound.*`), and the plain list of what this system is **not** — it is\nnot Wwise, and `Open77.sfx.*` remains the way to play the game's own audio\nevents.","natives":[],"permissions":[],"events":[]},{"anchor":"open77vehicles","heading":"`open77_vehicles`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `get` | `get(id)` | One streamed canonical vehicle snapshot. |\n| `all` | `all()` | All streamed canonical vehicle snapshots. |\n| `getPlayerSeat` | `getPlayerSeat(playerId?)` | One replicated seat assignment; omitted ID selects the local player. |\n| `isPlayerExitLocked` | `isPlayerExitLocked(playerId?)` | Replicated exit-lock boolean, or `nil` without an assignment. |\n\nSee [Vehicles](vehicles.md) for server mutation and native presentation methods.","natives":[],"permissions":[],"events":[]},{"anchor":"open-voice","heading":"`open-voice`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `getState` | `getState()` | Current canonical mode, distance, cycle key and HUD visibility. |\n| `getModes` | `getModes()` | Server-published reach presets. |\n| `requestCycle` | `requestCycle()` | Requests the next server-owned mode; no distance is sent by the client. |\n| `setHudVisible` | `setHudVisible(visible)` | Locally shows or hides the passive voice HUD. |\n\nSee [Integrated voice chat](voice.md). Mode changes remain authoritative on the\nserver; these client exports cannot submit arbitrary reach.","natives":[],"permissions":[],"events":[]},{"anchor":"open77voice","heading":"`open77_voice`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `status` | `status()` | Native capture/render/device and packet snapshot. |\n| `getPushToTalkKey` | `getPushToTalkKey()` | Current local PTT binding. |\n| `setPushToTalkKey` | `setPushToTalkKey(key)` | Applies a supported PTT binding without restarting. |\n| `devices` | `devices(flow?)` | Input/output endpoint catalogue. |\n| `getProximityDistance` | `getProximityDistance()` | Canonical reach plus the full native status snapshot. |\n| `setProximityDistance` | `setProximityDistance(distance)` | Request a custom reach; server policy clamps it. |\n| `getProximityMode` | `getProximityMode()` | Current `whisper`, `normal`, `shout`, or `custom` mode and distance. |\n| `setProximityMode` | `setProximityMode(name, notify?)` | Request a configured reach preset. |\n| `cycleProximityMode` | `cycleProximityMode(notify?)` | Select the next configured preset. |\n| `setPlayerVolume` | `setPlayerVolume(playerId, gain)` | Local per-talker gain. |\n| `setPlayerBlocked` | `setPlayerBlocked(playerId, blocked)` | Local per-talker block. |\n| `setChannelVolume` | `setChannelVolume(channelId, gain)` | Local current/future channel-route gain. |\n\nServer scripts in the same package additionally publish helpers for radio/phone channel creation,\nmembership, authoritative mute/deaf, and proximity. Those helpers execute only in the dedicated\nruntime; use [Integrated voice chat](voice.md) for both surfaces and the server-authority rules.","natives":[],"permissions":[],"events":[]},{"anchor":"open77weather","heading":"`open77_weather`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `isReady` | `isReady()` | Whether the first authoritative environment snapshot arrived. |\n| `requestSync` | `requestSync()` | Requests an immediate resynchronization. |\n| `getState` | `getState()` | Projected server time and canonical weather state. |\n\nSee [Weather](weather.md). This package intentionally exposes no client mutation command.","natives":[],"permissions":[],"events":[]},{"anchor":"open77worldui","heading":"`open77_worldui`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `create` | `create(definition)` | Creates a caller-owned POI (marker plus optional prompt) and returns its handle. |\n| `remove` | `remove(handle)` | Removes one caller-owned POI, both halves. |\n| `list` | `list()` | Snapshots of every POI owned by the caller. |\n| `dump` | `dump()` | Diagnostic: logs and returns the native marker registry, including `rendered`. |\n\nMode-agnostic; both official consumers ([`resources/gamemodes/pursuit`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/pursuit), [`resources/gamemodes/race`](https://github.com/Open2077/open77-resources/tree/main/gamemodes/race)) call it\nunmodified. See [World-anchored POIs](worldui.md).","natives":[],"permissions":[],"events":[]},{"anchor":"open77zones","heading":"`open77_zones`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `create` | `create(definition)` | Creates a caller-owned proximity zone with enter/exit hysteresis. |\n| `remove` | `remove(handle)` | Removes one caller-owned zone. |\n| `contains` | `contains(handle)` | The service's last-polled containment verdict for one caller-owned zone. |\n\nClient-only, local presentation signal -- never proof of position. See\n[Proximity zones](zones.md) for the polling model and the server-side\nre-validation every caller must apply.","natives":[],"permissions":[],"events":[]},{"anchor":"open77example","heading":"`open77_example`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `hello` | `hello(name?)` | Minimal export example used by the starter package. |\n\nThis export is instructional and should not be used as a production dependency.","natives":[],"permissions":[],"events":[]},{"anchor":"open77equipment-2","heading":"`open77_equipment`","level":3,"text":"These client exports forward to the local `Open77.equipment` adapter. They do not\nsubmit a durable server clothing transaction; authoritative presentation can\nsubsequently replace a local edit. See [Equipment and wardrobe](../docs/equipment.md).\n\n| Export | Signature | Result |\n|---|---|---|\n| `slots` | `slots()` | Supported slot names and attachment slots. |\n| `records` | `records(options?)` | Record metadata; optional slot, family, restricted and limit filters. |\n| `info` | `info(record)` | Metadata for one record, or `nil, reason`. |\n| `registry` | `registry()` | Worn slot values: record strings or `false` for empty. |\n| `equip` | `equip(record, slot?, options?)` | Native mutation accepted, or `nil, reason`; not visual completion. |\n| `unequip` | `unequip(slot)` | Removes one local worn slot without deleting inventory. |\n| `apply` | `apply(slots, options?)` | Sequential slot mutation; can partially apply before an error. |\n| `beginPreview` | `beginPreview()` | `true` only for `open77_wardrobe_ui` when presentation is ready and no preview is held. |\n| `endPreview` | `endPreview()` | Releases the calling preview owner and reapplies authoritative presentation. |\n\nPreview ownership is checked with `GetInvokingResource()`. Other callers receive\n`preview_owner_denied`; unavailable initial presentation returns\n`presentation_not_ready`. Owner resource stop also ends its preview. Preview\nacceptance does not prove that native clothing has finished rendering.","natives":[],"permissions":[],"events":[]},{"anchor":"open77wardrobe-2","heading":"`open77_wardrobe`","level":3,"text":"| Export | Signature | Result |\n|---|---|---|\n| `beginPreview` | `beginPreview()` | `true` for `open77_wardrobe_ui` only when authoritative wardrobe presentation is ready and no preview is held. |\n| `endPreview` | `endPreview()` | Releases the calling preview owner and restores pending or current authoritative wardrobe state. |\n\nThe same `preview_owner_denied` and `presentation_not_ready` guards apply.\nStopping the preview owner also restores authoritative state. These coordination\nexports do not create a persistent outfit or expose arbitrary preview ownership.","natives":[],"permissions":[],"events":[]},{"anchor":"reference-coverage","heading":"Reference coverage","level":2,"text":"This catalogue covers official client-resource exports. Additional system-resource exports are documented in their feature guides. Use explicit export names so resource tooling can discover them.","natives":[],"permissions":[],"events":[]}]},{"slug":"rp-animation-catalogue","file":"rp-animation-catalogue.md","title":"RP animation catalogue","summary":"","sections":[{"anchor":"rp-animation-catalogue","heading":"RP animation catalogue","level":1,"text":"\n\nUse the [role-play animation API](rp-animations.md) with a matching client, server and RP archive. This catalogue lists profile IDs, clips, source workspots, props and placement requirements. Asset bindings do not guarantee correct presentation for every clip or body; treat experimental variants accordingly.\n\nEach action uses an Open77 workspot derived from its vanilla source, not the historical single cigar device.\nSelectable clips have isolated loop containers; authored rig, prop and exit data are preserved.\nDurations and loop timing are not inferred from clip names. All actions are stationary.\n\n| ID | Action | Category | Placement | Expected prop |\n| --- | --- | --- | --- | --- |\n| `give` | Offer an item | interactions | standing | None |\n| `examine` | Kneel and examine | interactions | ground | None |\n| `wounded` | Wounded seated breathing | interactions | ground | None |\n| `smoke` | Smoke a cigarette | consumables | standing | cigarette |\n| `cigar` | Smoke a cigar | consumables | standing | cigar |\n| `drink` | Drink from a can | consumables | standing | can |\n| `phone` | Use a phone | social | standing | phone |\n| `dance` | Dance | dance | standing | None |\n| `handsup` | Hands up | gestures | standing | None |\n| `meditate` | Meditate | relaxation | standing | None |\n| `sit` | Sit on the ground | relaxation | ground | None |\n| `clap` | Applaud | gestures | standing | None |\n| `cry` | Cry | emotions | standing | None |\n| `think` | Think | emotions | standing | None |\n| `stretch` | Stretch | relaxation | standing | None |\n| `chair` | Sit as if on a chair | postures | chair | None |\n| `lean` | Lean back against a wall | postures | wall | None |\n| `lie` | Lie down | postures | bed | None |\n| `armscrossed` | Arms crossed | postures | standing | None |\n| `armscrossed2` | Arms crossed, relaxed | postures | standing | None |\n| `handsback` | Hands behind back | postures | standing | None |\n| `handships` | Hands on hips | postures | standing | None |\n| `handsfront` | Hands in front | postures | standing | None |\n| `onehip` | Hand on hip | postures | standing | None |\n| `idle` | Casual standing | postures | standing | None |\n| `wait` | Wait around | postures | standing | None |\n| `bored` | Bored | emotions | standing | None |\n| `fidget` | Fidget with hands | gestures | standing | None |\n| `rubhands` | Rub hands | gestures | standing | None |\n| `sad` | Feeling down | emotions | standing | None |\n| `nervous` | Nervous | emotions | standing | None |\n| `angry` | Angry gestures | emotions | standing | None |\n| `cheer` | Cheer | gestures | standing | None |\n| `cheer2` | Cheer, folded arms | gestures | standing | None |\n| `crowdwave` | Crowd wave | gestures | standing | None |\n| `preach` | Speak to a crowd | social | standing | None |\n| `calmdown` | Calm down | gestures | standing | None |\n| `order` | Order food | social | standing | None |\n| `drunk` | Drunk | emotions | standing | None |\n| `paranoid` | Paranoid | emotions | standing | None |\n| `taichi` | Tai chi | relaxation | standing | None |\n| `headphones` | Dance to headphones | dance | standing | headphones |\n| `call` | Phone call | social | standing | phone |\n| `call2` | Phone call, hand on hip | social | standing | phone |\n| `phonecheck` | Check phone | social | standing | phone |\n| `record` | Record on phone | social | standing | phone |\n| `bottle` | Drink from a bottle | consumables | standing | bottle |\n| `takeout` | Eat takeout | consumables | standing | takeout |\n| `smokefolded` | Smoke, arm folded | consumables | standing | cigarette |\n| `tablet` | Use a tablet | work | standing | tablet |\n| `tablet2` | Work on tablet | work | standing | tablet |\n| `tablet3` | Tablet in one hand | work | standing | tablet |\n| `repair` | Kneel and repair | work | ground | screwdriver |\n| `mechanic` | Kneel with a tool | work | ground | tool |\n| `scavenge` | Scavenge | work | ground | None |\n| `kneelcry` | Cry on knees | emotions | ground | None |\n| `cower` | Cower | emotions | ground | None |\n| `arrested` | Lie face down | postures | ground | None |\n| `sitafraid` | Sit with knees held | postures | ground | None |\n| `sitsmoke` | Smoke on the ground | consumables | ground | cigarette |\n| `laptop` | Use laptop on ground | work | ground | laptop |\n| `handpan` | Play a handpan | music | ground | handpan |\n| `leanrelaxed` | Lean against wall, relaxed | postures | wall | None |\n| `leanleft` | Lean on left side | postures | wall | None |\n| `wallcall` | Phone call against wall | social | wall | phone |\n| `wallsmoke` | Smoke against wall | consumables | wall | cigarette |\n| `seated` | Sit upright | seated | chair | None |\n| `seated2` | Sit, relaxed hands | seated | chair | None |\n| `seatedcheer` | Cheer while seated | seated | chair | None |\n| `seatedsleep` | Doze in a chair | seated | chair | None |\n| `seatedstretch` | Stretch while seated | seated | chair | None |\n| `seatedthink` | Think while seated | seated | chair | None |\n| `guitar` | Play guitar | music | chair | guitar |\n| `barlean` | Lean on a counter | postures | counter | None |\n| `barsit` | Sit at a bar | seated | barstool | None |\n| `bedsidelie` | Lie on side | relaxation | bed | None |","natives":[],"permissions":[],"events":[]},{"anchor":"offer-an-item-give","heading":"Offer an item (`give`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_item__give__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\give.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__to__stand__rh_item__01__turn0__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__to__stand__rh_item__01__turn0__01`","natives":[],"permissions":[],"events":[]},{"anchor":"kneel-and-examine-examine","heading":"Kneel and examine (`examine`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__kneel_ground__inspect__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\examine.workspot`.\n\nDefault clip: `kneel__rk_on_ground__01__inspect_ground__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `kneel__rk_on_ground__01__inspect_ground__01`","natives":[],"permissions":[],"events":[]},{"anchor":"wounded-seated-breathing-wounded","heading":"Wounded seated breathing (`wounded`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__sit_ground_wall_lean_back__wounded__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\wounded.workspot`.\n\nDefault clip: `sit_ground_lean180__rh_on_belly__01__deep_breath__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_ground_lean180__rh_on_belly__01__deep_breath__01`","natives":[],"permissions":[],"events":[]},{"anchor":"smoke-a-cigarette-smoke","heading":"Smoke a cigarette (`smoke`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_cigarette__smoke__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\smoke.workspot`.\n\nDefault clip: `stand__rh_cigarette__01__smoke__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_cigarette__01__drop_ash__01`\n- `stand__rh_cigarette__01__drop_ash__04`\n- `stand__rh_cigarette__01__look_around__01`\n- `stand__rh_cigarette__01__look_around__02`\n- `stand__rh_cigarette__01__shuffle__01`\n- `stand__rh_cigarette__01__shuffle__02`\n- `stand__rh_cigarette__01__shuffle__04`\n- `stand__rh_cigarette__01__smoke__01`\n- `stand__rh_cigarette__01__smoke__02`\n- `stand__rh_cigarette__01__smoke__03`\n- `stand__rh_cigarette__01__wipe_forehead__01`","natives":[],"permissions":[],"events":[]},{"anchor":"smoke-a-cigar-cigar","heading":"Smoke a cigar (`cigar`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_cigar__stand_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\cigar.workspot`.\n\nDefault clip: `stand__rh_cigar__weight_right__01__smoke__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_cigar__weight_right__01__look_left__01`\n- `stand__rh_cigar__weight_right__01__scratch_ball__01`\n- `stand__rh_cigar__weight_right__01__scratch_nose__01`\n- `stand__rh_cigar__weight_right__01__smoke__01`\n- `stand__rh_cigar__weight_right__01__smoke__02`\n- `stand__rh_cigar__weight_right__01__smoke__03`","natives":[],"permissions":[],"events":[]},{"anchor":"drink-from-a-can-drink","heading":"Drink from a can (`drink`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_can__drink__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\drink.workspot`.\n\nDefault clip: `stand__rh_can__01__drink__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_can__01__drink__01`\n- `stand__rh_can__01__drink__03`\n- `stand__rh_can__01__drink__04`\n- `stand__rh_can__01__shuffle__01`\n- `stand__rh_can__01__spill__01`\n- `stand__rh_can__01__spill__02`","natives":[],"permissions":[],"events":[]},{"anchor":"use-a-phone-phone","heading":"Use a phone (`phone`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__phone_use__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\phone.workspot`.\n\nDefault clip: `stand__2h_phone__03__tap_phone__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_phone__03__shuffle__01`\n- `stand__2h_phone__03__tap_phone__01`","natives":[],"permissions":[],"events":[]},{"anchor":"dance-dance","heading":"Dance (`dance`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__dance__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\dance.workspot`.\n\nDefault clip: `stand__dance__02__dancing__02`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__dance__02__dancing__02`\n- `stand__dance__02__dancing__03`\n- `stand__dance__02__dancing__04`\n- `stand__dance__02__dancing__05`\n- `stand__dance__02__dancing__07`\n- `stand__dance__02__dancing__08`","natives":[],"permissions":[],"events":[]},{"anchor":"hands-up-handsup","heading":"Hands up (`handsup`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__hands_up__03.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\handsup.workspot`.\n\nDefault clip: `stand__2h_up__03__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_up__03__look_around__01`\n- `stand__2h_up__03__look_left__01`\n- `stand__2h_up__03__look_right__01`\n- `stand__2h_up__03__shuffle__01`","natives":[],"permissions":[],"events":[]},{"anchor":"meditate-meditate","heading":"Meditate (`meditate`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__meditate__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\meditate.workspot`.\n\nDefault clip: `stand__meditate__01__breathe__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__meditate__01__breathe__01`\n- `stand__meditate__01__breathe__02`\n- `stand__meditate__01__look_left__01`\n- `stand__meditate__01__raise_hands__01`\n- `stand__meditate__01__shuffle__01`","natives":[],"permissions":[],"events":[]},{"anchor":"sit-on-the-ground-sit","heading":"Sit on the ground (`sit`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__sit_ground_lean_front__sit_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\sit.workspot`.\n\nDefault clip: `sit_ground__2h_elbow_on_knees__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_ground__2h_elbow_on_knees__01__shuffle__01`\n- `sit_ground__2h_elbow_on_knees__01__shuffle__02`\n- `sit_ground__2h_elbow_on_knees__01__shuffle__03`\n- `sit_ground__2h_elbow_on_knees__01__shuffle__04`","natives":[],"permissions":[],"events":[]},{"anchor":"applaud-clap","heading":"Applaud (`clap`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__cheer__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\clap.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__2h_clap__happy__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__2h_clap__broad__01`\n- `stand__2h_on_sides__01__2h_clap__happy__01`\n- `stand__2h_on_sides__01__2h_clap__narrow__01`","natives":[],"permissions":[],"events":[]},{"anchor":"cry-cry","heading":"Cry (`cry`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__cry__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\cry.workspot`.\n\nDefault clip: `stand__rh_on_forehead__01__cry__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_on_forehead__01__blow_nose__02`\n- `stand__rh_on_forehead__01__cry__01`\n- `stand__rh_on_forehead__01__cry__02`\n- `stand__rh_on_forehead__01__rub_tears__01`","natives":[],"permissions":[],"events":[]},{"anchor":"think-think","heading":"Think (`think`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__think__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\think.workspot`.\n\nDefault clip: `stand__rh_on_chin__01__rub_chin__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_on_chin__01__rub_chin__01`\n- `stand__rh_on_chin__01__rub_forehead__ 01`\n- `stand__rh_on_chin__01__shuffle__01`\n- `stand__rh_on_chin__01__shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"stretch-stretch","heading":"Stretch (`stretch`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stretch__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\stretch.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__stretch_arms__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__stretch_arms__01`\n- `stand__2h_on_sides__01__stretch_arms__02`\n- `stand__2h_on_sides__01__stretch_arms__03`\n- `stand__2h_on_sides__01__stretch_arms__04`\n- `stand__2h_on_sides__01__stretch_muscle_01`\n- `stand__2h_on_sides__01__stretch_muscle_02`\n- `stand__2h_on_sides__01__stretch_muscle_03`\n- `stand__2h_on_sides__01__stretch_muscle_04`\n- `stand__2h_on_sides__01__stretch_muscle_05`\n- `stand__2h_on_sides__01__stretch_muscle_06`\n- `stand__2h_on_sides__01__stretch_muscle_07`\n- `stand__2h_on_sides__01__stretch_muscle_08`\n- `stand__2h_on_sides__01__stretch_muscle_09`\n- `stand__2h_on_sides__01__stretch_muscle_10`\n- `stand__2h_on_sides__01__stretch_neck__01`","natives":[],"permissions":[],"events":[]},{"anchor":"sit-as-if-on-a-chair-chair","heading":"Sit as if on a chair (`chair`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair_lean_back__sit_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\chair.workspot`.\n\nDefault clip: `sit_chair_lean180__2h_on_lap__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair_lean180__2h_on_lap__01__deep_breath__01`\n- `sit_chair_lean180__2h_on_lap__01__long__01`\n- `sit_chair_lean180__2h_on_lap__01__rub_lips__01`\n- `sit_chair_lean180__2h_on_lap__01__rub_lips__02`\n- `sit_chair_lean180__2h_on_lap__01__rub_lips__03`\n- `sit_chair_lean180__2h_on_lap__01__rub_lips__04`\n- `sit_chair_lean180__2h_on_lap__01__rub_temples__01`\n- `sit_chair_lean180__2h_on_lap__01__rub_temples__02`\n- `sit_chair_lean180__2h_on_lap__01__rub_temples__03`\n- `sit_chair_lean180__2h_on_lap__01__scratch_ear__02`\n- `sit_chair_lean180__2h_on_lap__01__scratch_ear__03`\n- `sit_chair_lean180__2h_on_lap__01__scratch_ear__04`\n- `sit_chair_lean180__2h_on_lap__01__scratch_ear__05`\n- `sit_chair_lean180__2h_on_lap__01__scratch_ear__06`\n- `sit_chair_lean180__2h_on_lap__01__scratch_face__01`\n- `sit_chair_lean180__2h_on_lap__01__scratch_face__02`\n- `sit_chair_lean180__2h_on_lap__01__scratch_face__03`\n- `sit_chair_lean180__2h_on_lap__01__scratch_face__04`\n- `sit_chair_lean180__2h_on_lap__01__scratch_neck__01`\n- `sit_chair_lean180__2h_on_lap__01__scratch_neck__02`\n- `sit_chair_lean180__2h_on_lap__01__scratch_neck__03`\n- `sit_chair_lean180__2h_on_lap__01__scratch_neck__04`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__01`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__02`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__03`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__04`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__05`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__06`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__07`\n- `sit_chair_lean180__2h_on_lap__01__shuffle__08`\n- `sit_chair_lean180__2h_on_lap__01__sigh__01`\n- `sit_chair_lean180__2h_on_lap__01__yawn__01`\n- `sit_chair_lean180__2h_on_lap__01__yawn__02`\n- `sit_chair_lean180__2h_on_lap__01__yawn__03`\n- `sit_chair_lean180__2h_on_lap__01__yawn__04`\n- `sit_chair_lean180__2h_on_lap__01__yawn__05`\n- `sit_chair_lean180__2h_on_lap__01__yawn__06`","natives":[],"permissions":[],"events":[]},{"anchor":"lean-back-against-a-wall-lean","heading":"Lean back against a wall (`lean`)","level":2,"text":"Workspot: `base\\workspots\\common\\wall\\generic__stand_wall_lean_back__stand_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\lean.workspot`.\n\nDefault clip: `stand_wall_lean180__arms_crossed_front__01__shuffle__02`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand_wall_lean180__arms_crossed_front__01__look_around__01`\n- `stand_wall_lean180__arms_crossed_front__01__shuffle__02`\n- `stand_wall_lean180__arms_crossed_front__01__shuffle__03`\n- `stand_wall_lean180__arms_crossed_front__01__shuffle__04`","natives":[],"permissions":[],"events":[]},{"anchor":"lie-down-lie","heading":"Lie down (`lie`)","level":2,"text":"Workspot: `base\\workspots\\common\\bed\\generic__lie_double_bed__lie_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\lie.workspot`.\n\nDefault clip: `lie_bed_270__sleep__01__deep_breathing__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `lie_bed_270__sleep__01`\n- `lie_bed_270__sleep__01__deep_breathing__01`","natives":[],"permissions":[],"events":[]},{"anchor":"arms-crossed-armscrossed","heading":"Arms crossed (`armscrossed`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_arms_crossed__stand_around__02.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\armscrossed.workspot`.\n\nDefault clip: `stand__arms_crossed_front__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__arms_crossed_front__01__look_around__01`\n- `stand__arms_crossed_front__01__shuffle__01`\n- `stand__arms_crossed_front__01__shuffle__02`\n- `stand__arms_crossed_front__01__shuffle__05`\n- `stand__arms_crossed_front__01__shuffle__06`\n- `stand__arms_crossed_front__01__shuffle__07`\n- `stand__arms_crossed_front__01__shuffle__08`","natives":[],"permissions":[],"events":[]},{"anchor":"arms-crossed-relaxed-armscrossed2","heading":"Arms crossed, relaxed (`armscrossed2`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_arms_crossed__stand_around__03.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\armscrossed2.workspot`.\n\nDefault clip: `stand__arms_crossed_front__02__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__arms_crossed_front__02__look_around__01`\n- `stand__arms_crossed_front__02__shuffle__01`\n- `stand__arms_crossed_front__02__shuffle__02`\n- `stand__arms_crossed_front__02__shuffle__03`\n- `stand__arms_crossed_front__02__shuffle__04`","natives":[],"permissions":[],"events":[]},{"anchor":"hands-behind-back-handsback","heading":"Hands behind back (`handsback`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__05.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\handsback.workspot`.\n\nDefault clip: `stand__2h_back__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_back__01__look_around__01`\n- `stand__2h_back__01__rub_forehead__01`\n- `stand__2h_back__01__rub_nose__01`\n- `stand__2h_back__01__shuffle__01`\n- `stand__2h_back__01__shuffle__02`\n- `stand__2h_back__01__shuffle__03`","natives":[],"permissions":[],"events":[]},{"anchor":"hands-on-hips-handships","heading":"Hands on hips (`handships`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__03.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\handships.workspot`.\n\nDefault clip: `stand__2h_on_hip__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_hip__01__look_around__01`\n- `stand__2h_on_hip__01__shrug__02`\n- `stand__2h_on_hip__01__shuffle__01`\n- `stand__2h_on_hip__01__shuffle__04`\n- `stand__2h_on_hip__01__shuffle__05`\n- `stand__2h_on_hip__01__shuffle__06`\n- `stand__2h_on_hip__01__shuffle__07`\n- `stand__2h_on_hip__01__shuffle__08`\n- `stand__2h_on_hip__01__shuffle__09`","natives":[],"permissions":[],"events":[]},{"anchor":"hands-in-front-handsfront","heading":"Hands in front (`handsfront`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__04.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\handsfront.workspot`.\n\nDefault clip: `stand__2h_front__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_front__01__check_shoes__01`\n- `stand__2h_front__01__dust_off__01`\n- `stand__2h_front__01__look_around__01`\n- `stand__2h_front__01__look_around__02`\n- `stand__2h_front__01__rub_nose__01`\n- `stand__2h_front__01__scratch_face__01`\n- `stand__2h_front__01__scratch_neck__01`\n- `stand__2h_front__01__shuffle__01`\n- `stand__2h_front__01__shuffle__02`\n- `stand__2h_front__01__shuffle__03`\n- `stand__2h_front__01__shuffle__04`\n- `stand__2h_front__01__shuffle__05`","natives":[],"permissions":[],"events":[]},{"anchor":"hand-on-hip-onehip","heading":"Hand on hip (`onehip`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__10.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\onehip.workspot`.\n\nDefault clip: `stand__rh_on_hip__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_on_hip__01__shuffle__01`\n- `stand__rh_on_hip__01__shuffle__02`\n- `stand__rh_on_hip__01__shuffle__03`","natives":[],"permissions":[],"events":[]},{"anchor":"casual-standing-idle","heading":"Casual standing (`idle`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\idle.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__cracking_knuckles__01`\n- `stand__2h_on_sides__01__scratch_neck__01`\n- `stand__2h_on_sides__01__shuffle__01`\n- `stand__2h_on_sides__01__shuffle__04`\n- `stand__2h_on_sides__01__sigh__01`\n- `stand__2h_on_sides__01__sigh__02`\n- `stand__2h_on_sides__01__sigh__03`","natives":[],"permissions":[],"events":[]},{"anchor":"wait-around-wait","heading":"Wait around (`wait`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__wait__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\wait.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__look_around__01`\n- `stand__2h_on_sides__01__rub_hands__02`\n- `stand__2h_on_sides__01__rub_hands__03`\n- `stand__2h_on_sides__01__scratch_neck__03`\n- `stand__2h_on_sides__01__wait_look_right__01`\n- `stand__2h_on_sides__01__wait_lookaround__06`","natives":[],"permissions":[],"events":[]},{"anchor":"bored-bored","heading":"Bored (`bored`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__06.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\bored.workspot`.\n\nDefault clip: `stand__2h_on_sides__02__slow_hours__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__02__slow_hours__01`\n- `stand__2h_on_sides__02__slow_hours__02`\n- `stand__2h_on_sides__02__slow_hours__03`\n- `stand__2h_on_sides__02__slow_hours__04`\n- `stand__2h_on_sides__02__slow_hours__05`","natives":[],"permissions":[],"events":[]},{"anchor":"fidget-with-hands-fidget","heading":"Fidget with hands (`fidget`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__07.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\fidget.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__gesture_hold__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__gesture_hold__01__lh_fingers__01`\n- `stand__2h_on_sides__01__gesture_hold__01__lh_fingers__02`\n- `stand__2h_on_sides__01__gesture_hold__01__rh_fingers__01`\n- `stand__2h_on_sides__01__gesture_hold__01__rh_fingers__02`\n- `stand__2h_on_sides__01__gesture_hold__01__shuffle__01`","natives":[],"permissions":[],"events":[]},{"anchor":"rub-hands-rubhands","heading":"Rub hands (`rubhands`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__stand_around__09.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\rubhands.workspot`.\n\nDefault clip: `stand__2h_on_sides__03__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__03__rub_hands__01`\n- `stand__2h_on_sides__03__shuffle__01`\n- `stand__2h_on_sides__03__shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"feeling-down-sad","heading":"Feeling down (`sad`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__wait_sad__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\sad.workspot`.\n\nDefault clip: `stand__lh_crossed_front__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__lh_crossed_front__01__look_around__01`\n- `stand__lh_crossed_front__01__look_around__02`\n- `stand__lh_crossed_front__01__look_around__03`\n- `stand__lh_crossed_front__01__look_around__04`\n- `stand__lh_crossed_front__01__shuffle__01`\n- `stand__lh_crossed_front__01__shuffle__02`\n- `stand__lh_crossed_front__01__shuffle__04`","natives":[],"permissions":[],"events":[]},{"anchor":"nervous-nervous","heading":"Nervous (`nervous`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__wait_nervous__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\nervous.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__deep_breath__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__deep_breath__01`\n- `stand__2h_on_sides__01__wait_station_nervous_04`","natives":[],"permissions":[],"events":[]},{"anchor":"angry-gestures-angry","heading":"Angry gestures (`angry`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__angry__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\angry.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__fuck_you__angry__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__fuck_you__angry__01`\n- `stand__2h_on_sides__01__fuck_you__angry__02`\n- `stand__2h_on_sides__01__impatient__01`\n- `stand__2h_on_sides__01__impatient__02`\n- `stand__2h_on_sides__01__impatient__03`\n- `stand__2h_on_sides__01__shuffle__03`\n- `stand__2h_on_sides__01__shuffle__05`\n- `stand__2h_on_sides__01__this_or_that__angry__01`\n- `stand__2h_on_sides__01__threaten__angry__01`\n- `stand__2h_on_sides__01__threaten__angry__03`\n- `stand__2h_on_sides__01__what_is_this__angry__02`\n- `stand__2h_on_sides__shout__angry__01`","natives":[],"permissions":[],"events":[]},{"anchor":"cheer-cheer","heading":"Cheer (`cheer`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__cheer__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\cheer.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__check_phone__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__check_phone__01`\n- `stand__2h_on_sides__01__demonstrate_positive__01`\n- `stand__2h_on_sides__01__demonstrate_positive__02`\n- `stand__2h_on_sides__01__demonstrate_positive__04`\n- `stand__2h_on_sides__01__demonstrate_positive__09`\n- `stand__2h_on_sides__01__scratch_neck__04`\n- `stand__2h_on_sides__01__shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"cheer-folded-arms-cheer2","heading":"Cheer, folded arms (`cheer2`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__cheer__02.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\cheer2.workspot`.\n\nDefault clip: `stand__arms_crossed_front__01__2h_clap__happy__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__arms_crossed_front__01__2h_clap__happy__01`\n- `stand__arms_crossed_front__01__cheer__happy__01`\n- `stand__arms_crossed_front__01__go_away__happy__01`\n- `stand__arms_crossed_front__01__rub_eyes__01`\n- `stand__arms_crossed_front__01__thumb_up__happy__01`\n- `stand__arms_crossed_front__01__thumb_up__neutral__01`\n- `stand__arms_crossed_front__01__yes__happy__01`","natives":[],"permissions":[],"events":[]},{"anchor":"crowd-wave-crowdwave","heading":"Crowd wave (`crowdwave`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__crowd_wave__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\crowdwave.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__crowd_wave__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__crowd_wave__01`\n- `stand__2h_on_sides__01__crowd_wave__02`","natives":[],"permissions":[],"events":[]},{"anchor":"speak-to-a-crowd-preach","heading":"Speak to a crowd (`preach`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__preach__02.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\preach.workspot`.\n\nDefault clip: `stand__preach__01__breathe__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__preach__01__breathe__01`\n- `stand__preach__01__look_around__01`\n- `stand__preach__01__look_right__01`\n- `stand__preach__01__preach__01`\n- `stand__preach__01__preach__02`\n- `stand__preach__01__preach__03`\n- `stand__preach__01__preach__04`\n- `stand__preach__01__shuffle__01`","natives":[],"permissions":[],"events":[]},{"anchor":"calm-down-calmdown","heading":"Calm down (`calmdown`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__preach__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\calmdown.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__calm_down__disgusted__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__calm_down__disgusted__01`\n- `stand__2h_on_sides__01__talk__neutral__05`","natives":[],"permissions":[],"events":[]},{"anchor":"order-food-order","heading":"Order food (`order`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__order__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\order.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__order_food__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__order_food__01`\n- `stand__2h_on_sides__01__order_food__02`\n- `stand__2h_on_sides__01__order_food__03`","natives":[],"permissions":[],"events":[]},{"anchor":"drunk-drunk","heading":"Drunk (`drunk`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__drunk__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\drunk.workspot`.\n\nDefault clip: `stand__drunk__01__check_pockets__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__drunk__01__check_pockets__01`\n- `stand__drunk__01__gag_reflex__01`\n- `stand__drunk__01__look_back__01`\n- `stand__drunk__01__rub_face__01`\n- `stand__drunk__01__scratch__01`\n- `stand__drunk__01__scratch_ball__01`","natives":[],"permissions":[],"events":[]},{"anchor":"paranoid-paranoid","heading":"Paranoid (`paranoid`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__paranoid__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\paranoid.workspot`.\n\nDefault clip: `stand__paranoid__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__paranoid__01__2h_head__01`\n- `stand__paranoid__01__2h_wave__01`\n- `stand__paranoid__01__chase_away_gesture__01`\n- `stand__paranoid__01__fist_wave__01`\n- `stand__paranoid__01__headache__01`\n- `stand__paranoid__01__look_around__01`\n- `stand__paranoid__01__look_down__01`\n- `stand__paranoid__01__look_left__01`\n- `stand__paranoid__01__look_right__01`\n- `stand__paranoid__01__panic__01`\n- `stand__paranoid__01__point_front__01`\n- `stand__paranoid__01__rh_rubs_eye__01`\n- `stand__paranoid__01__scratches_arm__01`\n- `stand__paranoid__01__shoulders_shrug__1`","natives":[],"permissions":[],"events":[]},{"anchor":"tai-chi-taichi","heading":"Tai chi (`taichi`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__tai_chi__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\taichi.workspot`.\n\nDefault clip: `stand__tai_chi__01__breathe__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__tai_chi__01__breathe__01`\n- `stand__tai_chi__01__tai_chi_front__01`","natives":[],"permissions":[],"events":[]},{"anchor":"dance-to-headphones-headphones","heading":"Dance to headphones (`headphones`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_headphones__dance__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\headphones.workspot`.\n\nDefault clip: `stand__2h_on_sides__01__wait_dance_headphones_01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_on_sides__01__wait_dance_headphones_01`\n- `stand__2h_on_sides__01__wait_dance_headphones_02`","natives":[],"permissions":[],"events":[]},{"anchor":"phone-call-call","heading":"Phone call (`call`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__phone_talk__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\call.workspot`.\n\nDefault clip: `stand__rh_phone__03__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_phone__03__look_up__01`\n- `stand__rh_phone__03__no__01`\n- `stand__rh_phone__03__nod__01`\n- `stand__rh_phone__03__nod__02`\n- `stand__rh_phone__03__nod__03`\n- `stand__rh_phone__03__shuffle__01`\n- `stand__rh_phone__03__shuffle__02`\n- `stand__rh_phone__03__talk__01`\n- `stand__rh_phone__03__talk__02`\n- `stand__rh_phone__03__talk__03`","natives":[],"permissions":[],"events":[]},{"anchor":"phone-call-hand-on-hip-call2","heading":"Phone call, hand on hip (`call2`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground__phone_talk__03.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\call2.workspot`.\n\nDefault clip: `stand__lh_hip_rh_phone__01__talk_phone__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__lh_hip_rh_phone__01__talk_phone__01`\n- `stand__lh_hip_rh_phone__01__talk_phone__02`","natives":[],"permissions":[],"events":[]},{"anchor":"check-phone-phonecheck","heading":"Check phone (`phonecheck`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_cellphone__check_phone__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\phonecheck.workspot`.\n\nDefault clip: `stand__2h_phone__03__play_games__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_phone__03__play_games__01`\n- `stand__2h_phone__03__play_games__02`\n- `stand__2h_phone__03__play_games__03`\n- `stand__2h_phone__03__play_games__04`\n- `stand__2h_phone__03__scratch_nose__01`","natives":[],"permissions":[],"events":[]},{"anchor":"record-on-phone-record","heading":"Record on phone (`record`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_cellphone__record__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\record.workspot`.\n\nDefault clip: `stand__2h_phone__02__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_phone__02__adjust_phone__01`\n- `stand__2h_phone__02__look__01`\n- `stand__2h_phone__02__look_around__01`\n- `stand__2h_phone__02__rub_face__01`\n- `stand__2h_phone__02__shuffle__01`\n- `stand__2h_phone__02__step_forward__01`","natives":[],"permissions":[],"events":[]},{"anchor":"drink-from-a-bottle-bottle","heading":"Drink from a bottle (`bottle`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_bottle__drink__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\bottle.workspot`.\n\nDefault clip: `stand__rh_bottle__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_bottle__01__drink__01`\n- `stand__rh_bottle__01__drink__02`\n- `stand__rh_bottle__01__drink__03`\n- `stand__rh_bottle__01__scratch__01`\n- `stand__rh_bottle__01__shuffle__01`\n- `stand__rh_bottle__01__shuffle__02`\n- `stand__rh_bottle__01__shuffle__03`\n- `stand__rh_bottle__01__shuffle__04`","natives":[],"permissions":[],"events":[]},{"anchor":"eat-takeout-takeout","heading":"Eat takeout (`takeout`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_asian_takeout__eat__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\takeout.workspot`.\n\nDefault clip: `stand__2h_chinesetakeout__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_chinesetakeout__01__eat__01`\n- `stand__2h_chinesetakeout__01__eat__02`\n- `stand__2h_chinesetakeout__01__eat__03`\n- `stand__2h_chinesetakeout__01__shuffle__01`\n- `stand__2h_chinesetakeout__01__shuffle__02`\n- `stand__2h_chinesetakeout__01__shuffle__03`","natives":[],"permissions":[],"events":[]},{"anchor":"smoke-arm-folded-smokefolded","heading":"Smoke, arm folded (`smokefolded`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_cigarette__smoke__04.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\smokefolded.workspot`.\n\nDefault clip: `stand__rh_cigarette_lh_crossed__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__rh_cigarette_lh_crossed__01__look_at_rh__01`\n- `stand__rh_cigarette_lh_crossed__01__shuffle__01`\n- `stand__rh_cigarette_lh_crossed__01__smoke__01`\n- `stand__rh_cigarette_lh_crossed__01__smoke__02`\n- `stand__rh_cigarette_lh_crossed__01__smoke__03`\n- `stand__rh_cigarette_lh_crossed__01__smoke__04`\n- `stand__rh_cigarette_lh_crossed__01__smoke_long__01`","natives":[],"permissions":[],"events":[]},{"anchor":"use-a-tablet-tablet","heading":"Use a tablet (`tablet`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_tablet__stand_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\tablet.workspot`.\n\nDefault clip: `stand__2h_tablet__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_tablet__01__look_around__01`\n- `stand__2h_tablet__01__look_left__01`\n- `stand__2h_tablet__01__look_right__01`\n- `stand__2h_tablet__01__rub_neck__01`\n- `stand__2h_tablet__01__shuffle__02`\n- `stand__2h_tablet__01__shuffle__03`\n- `stand__2h_tablet__01__shuffle__04`\n- `stand__2h_tablet__01__shuffle__05`\n- `stand__2h_tablet__01__shuffle__06`\n- `stand__2h_tablet__01__use_tablet__01`\n- `stand__2h_tablet__01__use_tablet__02`\n- `stand__2h_tablet__01__use_tablet__04`","natives":[],"permissions":[],"events":[]},{"anchor":"work-on-tablet-tablet2","heading":"Work on tablet (`tablet2`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_tablet__use__02.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\tablet2.workspot`.\n\nDefault clip: `stand__2h_tablet__02__use_tablet__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__2h_tablet__02__use_tablet__01`\n- `stand__2h_tablet__02__use_tablet__02`\n- `stand__2h_tablet__02__use_tablet__03`\n- `stand__2h_tablet__02__use_tablet__04`\n- `stand__2h_tablet__02__use_tablet__05`\n- `stand__2h_tablet__02__use_tablet__06`","natives":[],"permissions":[],"events":[]},{"anchor":"tablet-in-one-hand-tablet3","heading":"Tablet in one hand (`tablet3`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__stand_ground_tablet__use__03.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\tablet3.workspot`.\n\nDefault clip: `stand__lh_tablet__01__slow_hours__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand__lh_tablet__01__slow_hours__01`\n- `stand__lh_tablet__01__slow_hours__02`\n- `stand__lh_tablet__01__slow_hours__03`\n- `stand__lh_tablet__01__use_tablet__01`\n- `stand__lh_tablet__01__use_tablet__02`","natives":[],"permissions":[],"events":[]},{"anchor":"kneel-and-repair-repair","heading":"Kneel and repair (`repair`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__kneel_ground_lean_front_screwdriver__repair__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\repair.workspot`.\n\nDefault clip: `kneel__rh_screwdriver__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `kneel__rh_screwdriver__01__look_back__01`\n- `kneel__rh_screwdriver__01__repair_01`\n- `kneel__rh_screwdriver__01__repair_02`\n- `kneel__rh_screwdriver__01__repair_03`\n- `kneel__rh_screwdriver__01__scratch_neck__01`\n- `kneel__rh_screwdriver__01__shuffle__01`\n- `kneel__rh_screwdriver__01__shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"kneel-with-a-tool-mechanic","heading":"Kneel with a tool (`mechanic`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__kneel_ground_tool__fix__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\mechanic.workspot`.\n\nDefault clip: `kneel__rh_tool_fix__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `kneel__rh_tool_fix__01__gesture__01`\n- `kneel__rh_tool_fix__01__gesture__02`\n- `kneel__rh_tool_fix__01__long__01`\n- `kneel__rh_tool_fix__01__shuffle__01`\n- `kneel__rh_tool_fix__01__shuffle__02`\n- `kneel__rh_tool_fix__tool_stuck__01`","natives":[],"permissions":[],"events":[]},{"anchor":"scavenge-scavenge","heading":"Scavenge (`scavenge`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__kneel_ground__scavenge__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\scavenge.workspot`.\n\nDefault clip: `kneel__2h_on_lap__01__scavenge_part_drone_01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `kneel__2h_on_lap__01__scavenge_part_drone_01`\n- `kneel__2h_on_lap__01__scavenge_part_drone_02`\n- `kneel__2h_on_lap__01__scavenge_part_drone_03`","natives":[],"permissions":[],"events":[]},{"anchor":"cry-on-knees-kneelcry","heading":"Cry on knees (`kneelcry`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__kneel_ground__cry__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\kneelcry.workspot`.\n\nDefault clip: `kneel_lean0__cry__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `kneel_lean0__cry__01`","natives":[],"permissions":[],"events":[]},{"anchor":"cower-cower","heading":"Cower (`cower`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__kneel_ground_lean_front__afraid__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\cower.workspot`.\n\nDefault clip: `kneel_lean0__lh_on_head__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `kneel_lean0__lh_on_head__01__cover_ears__01`\n- `kneel_lean0__lh_on_head__01__lie_down__01`\n- `kneel_lean0__lh_on_head__01__look_around__01`\n- `kneel_lean0__lh_on_head__01__scared__01`\n- `kneel_lean0__lh_on_head__01__shuffle__01`\n- `kneel_lean0__lh_on_head__01__take_cover__01`","natives":[],"permissions":[],"events":[]},{"anchor":"lie-face-down-arrested","heading":"Lie face down (`arrested`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__lie_ground_lean_front__arrested__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\arrested.workspot`.\n\nDefault clip: `lie_ground_0__2h_on_head__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `lie_ground_0__2h_on_head__01__shuffle__01`\n- `lie_ground_0__2h_on_head__01__shuffle__03`\n- `lie_ground_0__2h_on_head__01__shuffle__06`","natives":[],"permissions":[],"events":[]},{"anchor":"sit-with-knees-held-sitafraid","heading":"Sit with knees held (`sitafraid`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__sit_ground_lean_front__scared__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\sitafraid.workspot`.\n\nDefault clip: `sit_ground_lean0__2h_around_legs__01__scared_shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_ground_lean0__2h_around_legs__01__long__01`\n- `sit_ground_lean0__2h_around_legs__01__react__01`\n- `sit_ground_lean0__2h_around_legs__01__react__02`\n- `sit_ground_lean0__2h_around_legs__01__scared_shuffle__01`\n- `sit_ground_lean0__2h_around_legs__01__scared_shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"smoke-on-the-ground-sitsmoke","heading":"Smoke on the ground (`sitsmoke`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__sit_ground_cigarette__smoke__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\sitsmoke.workspot`.\n\nDefault clip: `sit_ground__rh_cigarette__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_ground__rh_cigarette__01__shuffle__01`\n- `sit_ground__rh_cigarette__01__shuffle__02`\n- `sit_ground__rh_cigarette__01__smoke__01`\n- `sit_ground__rh_cigarette__01__smoke__02`\n- `sit_ground__rh_cigarette__01__smoke__03`","natives":[],"permissions":[],"events":[]},{"anchor":"use-laptop-on-ground-laptop","heading":"Use laptop on ground (`laptop`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__sit_ground_laptop__use__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\laptop.workspot`.\n\nDefault clip: `sit_ground_lean0__2h_on_laptop__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_ground_lean0__2h_on_laptop__01__head_flick_up__01`\n- `sit_ground_lean0__2h_on_laptop__01__look_left__01`\n- `sit_ground_lean0__2h_on_laptop__01__look_left__02`\n- `sit_ground_lean0__2h_on_laptop__01__look_right__01`\n- `sit_ground_lean0__2h_on_laptop__01__look_up__01`\n- `sit_ground_lean0__2h_on_laptop__01__shuffle__01`\n- `sit_ground_lean0__2h_on_laptop__01__type__01`\n- `sit_ground_lean0__2h_on_laptop__01__type__02`\n- `sit_ground_lean0__2h_on_laptop__01__type__03`","natives":[],"permissions":[],"events":[]},{"anchor":"play-a-handpan-handpan","heading":"Play a handpan (`handpan`)","level":2,"text":"Workspot: `base\\workspots\\common\\ground\\generic__sit_ground_handpan__play__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\handpan.workspot`.\n\nDefault clip: `sit_ground__play_handpan__play__01__phrase__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_ground__play_handpan__play__01__phrase__01`\n- `sit_ground__play_handpan__play__01__phrase__02`\n- `sit_ground__play_handpan__play__01__phrase__03`\n- `sit_ground__play_handpan__play__01__phrase__04`\n- `sit_ground__play_handpan__play__01__phrase__05`\n- `sit_ground__play_handpan__play__01__phrase__06`\n- `sit_ground__play_handpan__play__01__phrase__07`\n- `sit_ground__play_handpan__play__01__phrase__08`\n- `sit_ground__play_handpan__play__01__phrase__10`","natives":[],"permissions":[],"events":[]},{"anchor":"lean-against-wall-relaxed-leanrelaxed","heading":"Lean against wall, relaxed (`leanrelaxed`)","level":2,"text":"Workspot: `base\\workspots\\common\\wall\\generic__stand_wall_lean_back__stand_around__02.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\leanrelaxed.workspot`.\n\nDefault clip: `stand_wall_lean180__2h_on_sides__01__clean_clothes__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand_wall_lean180__2h_on_sides__01__clean_clothes__01`\n- `stand_wall_lean180__2h_on_sides__01__clean_clothes__02`\n- `stand_wall_lean180__2h_on_sides__01__clean_clothes__03`\n- `stand_wall_lean180__2h_on_sides__01__rub_neck__01`\n- `stand_wall_lean180__2h_on_sides__01__sigh_heavy__01`\n- `stand_wall_lean180__2h_on_sides__01__sigh_heavy__02`\n- `stand_wall_lean180__2h_on_sides__01__stretch_shoulders__01`","natives":[],"permissions":[],"events":[]},{"anchor":"lean-on-left-side-leanleft","heading":"Lean on left side (`leanleft`)","level":2,"text":"Workspot: `base\\workspots\\common\\wall\\generic__stand_wall_lean_left__stand_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\leanleft.workspot`.\n\nDefault clip: `stand_wall_lean270__rh_on_hip__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand_wall_lean270__rh_on_hip__01__shuffle__01`\n- `stand_wall_lean270__rh_on_hip__01__shuffle__02`\n- `stand_wall_lean270__rh_on_hip__01__tap_wall__01`","natives":[],"permissions":[],"events":[]},{"anchor":"phone-call-against-wall-wallcall","heading":"Phone call against wall (`wallcall`)","level":2,"text":"Workspot: `base\\workspots\\common\\wall\\generic__stand_wall_lean_back_cellphone__talk__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\wallcall.workspot`.\n\nDefault clip: `stand_wall_lean180__rh_phone__01__talk_phone__02`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand_wall_lean180__rh_phone__01__talk_phone__02`\n- `stand_wall_lean180__rh_phone__01__talk_phone__03`","natives":[],"permissions":[],"events":[]},{"anchor":"smoke-against-wall-wallsmoke","heading":"Smoke against wall (`wallsmoke`)","level":2,"text":"Workspot: `base\\workspots\\common\\wall\\generic__stand_wall_lean_back_cigarette__smoke__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\wallsmoke.workspot`.\n\nDefault clip: `stand_wall_lean180__rh_cigarette__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand_wall_lean180__rh_cigarette__01__drop_ash__01`\n- `stand_wall_lean180__rh_cigarette__01__puff__01`\n- `stand_wall_lean180__rh_cigarette__01__shuffle__01`\n- `stand_wall_lean180__rh_cigarette__01__shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"sit-upright-seated","heading":"Sit upright (`seated`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair__sit_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\seated.workspot`.\n\nDefault clip: `sit_chair__2h_on_lap__01__look_around__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair__2h_on_lap__01__deep_breath__01`\n- `sit_chair__2h_on_lap__01__look_around__01`\n- `sit_chair__2h_on_lap__01__shuffle__01`\n- `sit_chair__2h_on_lap__01__shuffle__02`\n- `sit_chair__2h_on_lap__01__shuffle__03`\n- `sit_chair__2h_on_lap__01__shuffle__04`\n- `sit_chair__2h_on_lap__01__sigh__01`","natives":[],"permissions":[],"events":[]},{"anchor":"sit-relaxed-hands-seated2","heading":"Sit, relaxed hands (`seated2`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair__sit_around__02.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\seated2.workspot`.\n\nDefault clip: `sit_chair__2h_on_lap__02__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair__2h_on_lap__02__shuffle__01`\n- `sit_chair__2h_on_lap__02__shuffle__02`\n- `sit_chair__2h_on_lap__02__shuffle__03`\n- `sit_chair__2h_on_lap__02__shuffle__04`\n- `sit_chair__2h_on_lap__02__shuffle__05`","natives":[],"permissions":[],"events":[]},{"anchor":"cheer-while-seated-seatedcheer","heading":"Cheer while seated (`seatedcheer`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair__cheer__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\seatedcheer.workspot`.\n\nDefault clip: `sit_chair__2h_on_lap__01__2h_clap__happy__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair__2h_on_lap__01__2h_clap__happy__01`\n- `sit_chair__2h_on_lap__01__2h_clap__happy__02`\n- `sit_chair__2h_on_lap__01__cheer__happy__01`\n- `sit_chair__2h_on_lap__01__describe_front__02`\n- `sit_chair__2h_on_lap__01__shuffle__06`\n- `sit_chair__2h_on_lap__01__what_is_this__angry__01`\n- `sit_chair__2h_on_lap__01__what_is_this__neutral__02`\n- `sit_chair__2h_on_lap__01__yawn__01`","natives":[],"permissions":[],"events":[]},{"anchor":"doze-in-a-chair-seatedsleep","heading":"Doze in a chair (`seatedsleep`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair__sleep__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\seatedsleep.workspot`.\n\nDefault clip: `sit_chair__2h_on_lap__01__rub_eyes__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair__2h_on_lap__01__rub_eyes__01`\n- `sit_chair__2h_on_lap__01__sleep__long__01`\n- `sit_chair__2h_on_lap__01__sleep_bus__01`","natives":[],"permissions":[],"events":[]},{"anchor":"stretch-while-seated-seatedstretch","heading":"Stretch while seated (`seatedstretch`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair__stretch__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\seatedstretch.workspot`.\n\nDefault clip: `sit_chair__2h_on_lap__01__stretch_muscle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair__2h_on_lap__01__stretch_muscle__01`\n- `sit_chair__2h_on_lap__01__stretch_muscle__02`\n- `sit_chair__2h_on_lap__01__stretch_muscle__03`\n- `sit_chair__2h_on_lap__01__stretch_muscle__04`\n- `sit_chair__2h_on_lap__01__stretch_muscle__05`\n- `sit_chair__2h_on_lap__01__stretch_muscle__06`","natives":[],"permissions":[],"events":[]},{"anchor":"think-while-seated-seatedthink","heading":"Think while seated (`seatedthink`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair_lean_front__think__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\seatedthink.workspot`.\n\nDefault clip: `sit_chair_lean0__rh_on_chin__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair_lean0__rh_on_chin__01__consider__01`\n- `sit_chair_lean0__rh_on_chin__01__head_flick_up__01`\n- `sit_chair_lean0__rh_on_chin__01__long__01`\n- `sit_chair_lean0__rh_on_chin__01__nod__01`\n- `sit_chair_lean0__rh_on_chin__01__shuffle__01`\n- `sit_chair_lean0__rh_on_chin__01__shuffle__02`","natives":[],"permissions":[],"events":[]},{"anchor":"play-guitar-guitar","heading":"Play guitar (`guitar`)","level":2,"text":"Workspot: `base\\workspots\\common\\chair\\generic__sit_chair_lean_front_guitar__play__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\guitar.workspot`.\n\nDefault clip: `sit_chair_lean0__2h_guitar__no_pick_fingers__01__play__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_chair_lean0__2h_guitar__no_pick_fingers__01__play__01`\n- `sit_chair_lean0__2h_guitar__no_pick_ponpon__01__play__01`\n- `sit_chair_lean0__2h_guitar__no_pick_ponpon__01__play__02`\n- `sit_chair_lean0__2h_guitar__pick_beast__01__play__01`\n- `sit_chair_lean0__2h_guitar__pick_beast__01__play__02`\n- `sit_chair_lean0__2h_guitar__pick_chippin__01__play__01`","natives":[],"permissions":[],"events":[]},{"anchor":"lean-on-a-counter-barlean","heading":"Lean on a counter (`barlean`)","level":2,"text":"Workspot: `base\\workspots\\common\\bar\\generic__stand_bar_lean_front__stand_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\barlean.workspot`.\n\nDefault clip: `stand_bar_lean0__2h_on_bar__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `stand_bar_lean0__2h_on_bar__01__clean_desk_l_side__01`\n- `stand_bar_lean0__2h_on_bar__01__clean_desk_l_side__02`\n- `stand_bar_lean0__2h_on_bar__01__clean_desk_r_side__01`\n- `stand_bar_lean0__2h_on_bar__01__look_l_r__01`\n- `stand_bar_lean0__2h_on_bar__01__shuffle__01`\n- `stand_bar_lean0__2h_on_bar__01__shuffle__02`\n- `stand_bar_lean0__2h_on_bar__01__shuffle__03`\n- `stand_bar_lean0__2h_on_bar__01__shuffle__04`\n- `stand_bar_lean0__2h_on_bar__01__shuffle__05`\n- `stand_bar_lean0__2h_on_bar__01__shuffle__06`\n- `stand_bar_lean0__2h_on_bar__01__stretch_arms__01`\n- `stand_bar_lean0__2h_on_bar__01__tapping__01`\n- `stand_bar_lean0__2h_on_bar__01__tapping__02`\n- `stand_bar_lean0__2h_on_bar__01__yawn__01`","natives":[],"permissions":[],"events":[]},{"anchor":"sit-at-a-bar-barsit","heading":"Sit at a bar (`barsit`)","level":2,"text":"Workspot: `base\\workspots\\common\\bar\\generic__sit_bar__sit_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\barsit.workspot`.\n\nDefault clip: `sit_bar__2h_on_bar__01__breathe__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `sit_bar__2h_on_bar__01__breathe__01`\n- `sit_bar__2h_on_bar__01__lean_forward__01`\n- `sit_bar__2h_on_bar__01__long__01`\n- `sit_bar__2h_on_bar__01__shuffle__01`\n- `sit_bar__2h_on_bar__01__tap_on_bar__01`","natives":[],"permissions":[],"events":[]},{"anchor":"lie-on-side-bedsidelie","heading":"Lie on side (`bedsidelie`)","level":2,"text":"Workspot: `base\\workspots\\common\\bed\\generic__lie_bed_lean_left__lie_around__01.workspot`.\n\nOpen77 playback workspot: `cyberm\\workspots\\rp\\bedsidelie.workspot`.\n\nDefault clip: `lie_bed_270__lh_on_head__01__shuffle__01`.\n\nRig bindings: male and female base rigs. Experimental presentation.\n\nSelectable authored clips:\n\n- `lie_bed_270__lh_on_head__01__shuffle__01`","natives":[],"permissions":[],"events":[]},{"anchor":"discovery-inventory","heading":"Discovery inventory","level":2,"text":"The complete extracted common-workspot inventory is [rp-workspots.json](../docs/data/rp-workspots.json).\nIt records workspot hashes, source animsets, rigs and animation-node roles. It is not a playback allowlist.\nThe older [raw clip list](../docs/data/emote-animations.txt) also contains quest and special-rig clips.","natives":[],"permissions":[],"events":[]}]},{"slug":"rp-animations","file":"rp-animations.md","title":"Role-play animations","summary":"Play server-authoritative RP actions for inventory use, jobs and social gestures. Use upper-body profiles such as `smoke_walk`, `drink_walk` and `wave` while moving, or full-body workspots such as `smoke`, `phone` and `dance` for stationary actions.","sections":[{"anchor":"role-play-animations","heading":"Role-play animations","level":1,"text":"Play server-authoritative RP actions for inventory use, jobs and social gestures.\nUse upper-body profiles such as `smoke_walk`, `drink_walk` and `wave` while moving,\nor full-body workspots such as `smoke`, `phone` and `dance` for stationary actions.\n\nRequires matching client, server and RP archive support for the selected profile. Clip and prop compatibility can vary by body and appearance. Repeated reconnects can leave stale presentation objects or a camera hold; handle lifecycle failures. Furniture is not spawned automatically.\n\nEach profile selects compatible clips and either a workspot or an upper-body\nlayer. Start with [walking actions and items](#upper-body-actions-for-jobs-and-inventory)\nfor a minimal server Lua example. Arbitrary names from the general game inventory\nare not accepted by this system.\n\nSee the [profile and clip catalogue](rp-animation-catalogue.md) for 76 profiles and 456 selectable clips. The `chair`, `lean` and `lie` profiles support anchored placement; see [Portable workspots](#portable-workspots-sit-lean-and-lie-anywhere). Male and female bindings are included, but not every clip is compatible with every body or proxy graph.\n\nTo build a job action out of a profile, a prop attached to the hands and a progress bar, with complete server examples, see [Animated actions](animated-actions.md); a whole roleplay server built on these profiles is published as [the roleplay pack in Open2077/open77-resources](https://github.com/Open2077/open77-resources/tree/main/rp).","natives":[],"permissions":[],"events":[]},{"anchor":"how-it-works","heading":"How it works","level":2,"text":"1. **Choose a profile.** A resource or the Freeroam `/anim` menu selects an allowed\n profile and, optionally, one of its exact clip variants. A client requests an\n action for itself; a server resource can start one for a player it controls.\n2. **The server owns the action.** It validates the player, resource ownership,\n options and duration, assigns a `playbackId`, and distributes state within the\n routing bucket. Store this ID when a delayed callback will stop the action.\n3. **Clients present the accepted state.** For workspots, the local player temporarily uses the\n F7 third-person body; replicated bodies receive the same selected profile and\n step. Native mounting and profile changes are asynchronous. A native failure\n is reported separately from the accepted server request.\n\nWorkspot profiles are stationary and cancel when the player moves. Layer profiles\nkeep walking, running and turning available while animating the upper body.\nEntering a vehicle, death and combat can interrupt an action. Neither profile kind\nreplaces the locomotion or combat controller.","natives":[],"permissions":[],"events":[]},{"anchor":"upper-body-actions-for-jobs-and-inventory","heading":"Upper-body actions for jobs and inventory","level":2,"text":"Run [`open77_animations`](https://github.com/Open2077/open77-resources/tree/main/system/open77_animations) and [`open77_props`](https://github.com/Open2077/open77-resources/tree/main/system/open77_props) with matching client, server and archive\nsupport. The server runtime owns playback and temporary item lifetime; the native\nclient animates the body and attaches the item. Your RP resource owns inventory\nvalidation, consumption, job permissions and rewards.\n\n```lua\n-- open77.lua\nresource 'my_job_actions'\nserver_script 'server.lua'\npermissions { 'players.animations.control' }\n```\n\n```lua\n-- server.lua: call from your validated job or inventory handler.\nlocal function drink(playerId)\n local action, reason = Open77.animations.play(playerId, 'drink_walk', {\n loop = false,\n })\n if not action then return nil, reason end\n return action.playbackId\nend\n```\n\nThis plays one drink cycle with the default can while the player can move. The\nplatform removes its temporary can when the action ends; no prop-spawn call or\ncleanup timer is required. It does not remove anything from the player's inventory\nor change thirst. Server acceptance does not prove that presentation succeeded.\n\n| Profile | Action | Default item |\n| --- | --- | --- |\n| `smoke_walk` | Smoke a cigarette | `Items.crowd_cigarette_i_stick` |\n| `cigar_walk` | Smoke a cigar | `Items.crowd_cigar` |\n| `drink_walk` | Drink from a can | `Items.crowd_soda_can_a` |\n| `bottle_walk` | Drink from a bottle | `Items.crowd_bottle_i_beer` |\n| `hold_item_walk` | Hold an item | `Items.crowd_soda_can_a` |\n| `call_walk` | Talk on the phone | `Items.crowd_talking_cellphone` |\n| `phone_walk` | Use the phone with both hands | `Items.crowd_cellphone` |\n| `wave`, `point`, `thumbsup` | Brief gesture | None |\n| `handsup_walk`, `handsback_walk`, `armscrossed_walk` | Maintain a pose while moving | None |\n\nUse `Open77.animations.list()` on the server to discover profiles supported by\nthe installed build. Profiles with `kind='layer'` use the upper-body path. Select\nthese by profile ID: a raw clip name shared with a workspot resolves to the\nstationary version. An `upperBody=true` option does not convert arbitrary clips.","natives":["Open77.animations.play","server:Open77.animations.play"],"permissions":["players.animations.control"],"events":[]},{"anchor":"animation-owned-items-one-server-call","heading":"Animation-owned items: one server call","level":3,"text":"| `options.item` | Behavior |\n| --- | --- |\n| Omitted | Use the layer profile's default item, if any. |\n| `'Items.crowd_soda_can_a'` | Select this native item. Server only; also requires `world.props`. |\n| `false` | Suppress the animation-owned item. An independently held item is preserved. |\n\nThe item option is available on `play`, each `sequence` step, and `playClip` when\nit resolves to a layer. Workspots reject it with `item_requires_layer`. Invalid\nrecord syntax returns `invalid_item_record`; client requests for a custom record\nreturn `item_requires_server`. Clients may request `item=false`.\n\nNative items use the game's authored hand attachment. A valid record name does\nnot guarantee that its size or grip suits a chosen animation. Custom mesh props\nremain available through [attachments](attachments.md).\n\nFor native `Items.*`, `options.item` selects the record; it does not expose position\nor rotation adjustments. The native attachment supports zero offsets/rotations\nand unit scale only. For a custom mesh prop, the server can set `bone`, `offset`\nand `rotation` with `Open77.props.attach`, and use `item=false` on the animation.\nPlayer-facing adjustment controls must send requests to your server resource,\nwhich validates and applies them. Animation poses and mouth contact are separate\nfrom prop attachment and cannot generally be corrected by moving the prop alone.","natives":[],"permissions":[],"events":[]},{"anchor":"item-size-and-mouth-contact","heading":"Item size and mouth contact","level":3,"text":"The experimental contact adapter adjusts the arm during `drink_walk`,\n`bottle_walk`, `smoke_walk` and `cigar_walk`, using the item's visible geometry\nand the character's mouth. It preserves the authored grip. In first person,\nthe arm keeps the held item visible at the lower right between sips and smoothly\nfollows the camera during contact. `hold_item_walk` only holds the item. Matching\nclient, server and animation assets are required; visual validation is incomplete.\n\nOmitting `itemContact` selects automatic contact estimation. For cans and bottles,\nthe main mesh's bounds select the end along the item's authored local up direction\n(the can's rim). This remains the selected end while the container tilts; choosing\nwhichever end is nearest the mouth could select the bottle's bottom. Smoking\nitems use the end nearest the mouth. Bounds cannot identify every custom model's opening.\nFor an unusual item, your inventory definition can supply a measured mouth point:\n\n```lua\n-- Server manifest: players.animations.control, world.props\n-- itemDefinition belongs to your validated inventory/job configuration.\nlocal action, reason = Open77.animations.play(playerId, 'bottle_walk', {\n item = itemDefinition.record,\n itemContact = itemDefinition.mouthPoint,\n})\nif not action then print(reason); return end\n-- mouthPoint is nil (automatic), or { x=..., y=..., z=... } for this model.\n```\n\n| `options.itemContact` | Behavior |\n| --- | --- |\n| Omitted | Estimate contact automatically from visible geometry. |\n| `{x,y,z}` | Contact point in metres in the native item's local frame. Missing axes default to zero; supplied axes must be finite and within ±2 metres. |\n| `false` | Disable mouth fitting. The first-person holding pose still keeps the item visible. |\n\nThe option is server-only and requires `world.props`, including when set to\n`false`. Use it on `play`, on `playClip` when the clip resolves to a layer, or on\neach sequence step. Workspots reject it with `item_requires_layer`; client\nrequests return `item_requires_server`. Malformed points, unknown fields and\nout-of-range coordinates return `invalid_item_contact` before replacing the\ncurrent action. Omit the option on a subsequent action or step to restore\nautomatic contact. Changing the point preserves the animation-owned prop ID\nwhen the item record stays the same.\n\nThis adjusts the arm's contact target. It does not change the item's grip offset,\nconsume inventory or make an incompatible shape fit the fingers. An independently\nowned held item keeps its own attachment settings: set `contact` on that native\nitem's `Open77.props.attach` binding instead. Contact settings travel with the\nprop attachment; they are not fields of the animation playback snapshot.","natives":["Open77.animations.play","server:Open77.animations.play"],"permissions":[],"events":[]},{"anchor":"hold-drink-then-hold-again","heading":"Hold, drink, then hold again","level":3,"text":"For input-driven transitions, call `play` from the same resource when the player\nchooses an action. Keep the latest returned playback ID for delayed cancellation:\n\n```lua\n-- Server manifest also needs world.props for an explicit item selection.\nlocal function setDrinkAction(playerId, drinking)\n local profile = drinking and 'drink_walk' or 'hold_item_walk'\n return Open77.animations.play(playerId, profile, {\n item = 'Items.crowd_soda_can_a',\n })\nend\n-- setDrinkAction(playerId, false): hold.\n-- setDrinkAction(playerId, true): drink until the next input or cancellation.\n```\n\nChanging between these actions preserves the same prop ID while its item record\nstays the same. Each call returns a new playback ID. For an automatic timed chain:\n\n```lua\nlocal action, reason = Open77.animations.sequence(playerId, {\n { profile = 'hold_item_walk', durationMs = 3000 },\n { profile = 'drink_walk' }, -- One measured clip cycle for a layer step.\n { profile = 'hold_item_walk', durationMs = 3000 },\n}, { loop = false })\nif not action then print(reason); return end\n-- To cancel this particular action from the same resource:\n-- Open77.animations.stop(playerId, action.playbackId)\n```\n\nThe default can is shared across the three steps and removed when the sequence\nfinishes. With a custom item, specify `item` on each step. A sequence coordinates\none player's actions; it does not automatically coordinate a handover between\nplayers. Use the existing [player interactions](player-interactions.md) API for\ncarrying or escorting another player.\n\nAn independently held item can outlive an animation through the server\n`Open77.heldItems.hold` / `release` API. Its owning resource must manage its release.\nAnimation cleanup preserves that item and suppresses the default while the hand\nis occupied. The animation-owned item is cleaned up on completion, cancellation,\ndeath, disconnect, bucket changes and resource shutdown.\n\nSee the public [RP examples and held-action guide](https://github.com/Open2077/open77-resources/blob/main/rp/docs/held-actions.md)\nfor inventory, bar and phone integration.","natives":["Open77.animations.play","server:Open77.animations.play","Open77.animations.sequence","server:Open77.animations.sequence","Open77.animations.stop","server:Open77.animations.stop"],"permissions":[],"events":[]},{"anchor":"third-person-and-first-person-support","heading":"Third-person and first-person support","level":3,"text":"Upper-body profiles animate the third-person body while locomotion continues.\nSelected smoking, drinking and phone poses also animate the neck/head; the\nhands-behind-back pose includes spine movement. Contact with the mouth and clothing\ncan vary by body and appearance.\n\nFirst-person arm support is experimental and needs a compatible client and player\nanimation archive. The can, bottle, cigarette, cigar and hold profiles have visible\nfirst-person hands/items on the tested female and male bodies. Third-person can\ncontact reaches the lips on both tested bodies; third-person smoking contact has\nbeen checked on the female body. Items stay visible at the lower right between\nsips. Other appearances, custom shapes and remaining third-person combinations\nremain under validation; phone and other gestures do not have a first-person\nadapter. Third-person support does not imply first-person support for every profile.\n\n`hold_item_walk` remains active with its item during ground sprinting.\nHolding, drinking and holding again preserves the same item; completion removes\nthe animation-owned item. Punching interrupts the layer and its item, and a later\nplay creates a fresh presentation. Server acceptance alone does not establish\nthat a particular body, item or animation phase rendered correctly.\n\n| Task | Client resource | Server resource |\n| --- | --- | --- |\n| Find profiles | `list(query?)`, `get(profileId)` via a Promise | `list(query?)`, `get(profileId)` synchronously |\n| Start an action | `request(profileId, options?)` for yourself | `play(playerId, profileId, options?)` |\n| Chain actions | `sequence(steps, options?)` | `sequence(playerId, steps, options?)` |\n| Stop | `cancel(playbackId?)` for yourself | `stop(playerId, playbackId?)` for this resource's action |\n| Place an action at a pose | -- | `playAt(playerId, profileId, position, yaw?, options?)`, torn down by `stopAt(playbackId)` |\n| Observe state | `state(playerId?)` via a Promise | `current(playerId)` synchronously |\n\nAll methods above belong to `Open77.animations`. The older client\n`play(entity, rawClip)`, `playSelf`, `stop`, `stopSelf` and `current` are **local\npresentation APIs**, not the synchronized RP API. They must not be confused with\nserver methods that have the same names.","natives":[],"permissions":[],"events":[]},{"anchor":"installation-and-player-commands","heading":"Installation and player commands","level":2,"text":"Run the `open77_animations` resource on the server so every joining client receives\nits presentation service and catalogue. It is auto-started when discovered and is\nincluded in the Freeroam template. Other server templates can include the same\nresource without depending on Freeroam. The client needs an Open77 build containing\nthe RP API and **Open77RP.archive**, alongside the matching base and mirror\narchives; copying Lua alone does not add native playback support. See the\n[content build guide](../scripts/animations/README.md).\n\nIf an existing server profile uses an explicit `resources.load` list, add\n`\"open77_animations\"` to that list as well. `auto_start` does not override a\nresource-selection filter, and changing the Freeroam template does not update\npreviously created server profiles. Keep the profile's other resources enabled.\n\nWith the official chat package, players can use:\n\n```text\n/anim\n/anim list\n/anim smoke\n/anim info smoke\n/anim smoke 2\n/anim stop\n```\n\n`info` lists the exact clips and their 1-based variant numbers. Omitting a variant\nuses the profile's default, which need not be its first alphabetically sorted clip.\nCommands always target the authenticated caller, not a player ID in the arguments.\n`/anim` (or `/anim menu`) opens Freeroam's compact **Animations** library. It shares\nthe existing WebUI surface but has its own layout: 76 families, 456 variants and\n11 categories. Search by label, exact clip or common RP terms; save exact variants\nas favourites, revisit successfully started actions in Recent, or enable All\nvariants. The browser renders at most 60 cards per page. Select a family and use\nthe variant arrows, then **Play**, or double-click a card. Looping and timed\n5/10/30-second playback are available. The menu releases\ninput before the temporary TPP starts. **Stop** or `/anim stop` cancels the caller's\naction, including an in-flight UI request; moving interrupts workspot playback.\nExperimental variants may have body-specific presentation differences.\nStart/menu/list/info commands are limited to one every 500 ms per player.\n\nOther gamemodes can provide their own menu by receiving the server-sourced\n`open77_animations:menu` event. The platform resource does not depend on Freeroam.\nWithout a menu provider, use the explicit text commands above. UI command events\nmust not use `open77:animations:*`, which is reserved for authoritative protocol\nmessages and rejected by `TriggerClientEvent`.","natives":[],"permissions":[],"events":[]},{"anchor":"quick-start-your-first-client-action","heading":"Quick start: your first client action","level":2,"text":"After enabling `open77_animations` on the server, create a resource such as\n`my_emotes` with these two files. Add your resource to the server's selected\nresources too; keep the existing gamemode, wardrobe and other entries intact.\n\n`open77.lua`:\n\n```lua\nresource 'my_emotes'\nversion '1.0.0'\ndependency 'open77_animations >=1.0.0'\nclient_script 'client.lua'\npermissions { 'input.actions' }\n```\n\n`client.lua`. A client resource may `RegisterCommand` of its own — see the\n[FiveM compatibility page](fivem-compatibility.md) — but for an emote a key is the better trigger,\nwhich is what `input.actions` is for:\n\n```lua\nRegisterKeyMapping('my_smoke', 'Smoke', 'K', function()\n CreateThread(function()\n local pending, dispatchError = Open77.animations.request('smoke', {\n loop = false,\n durationMs = 15000,\n })\n if not pending then print(dispatchError); return end\n local playback, rejected = pending:await()\n if not playback then print(rejected); return end\n print('Server accepted action: ' .. playback.playbackId)\n end)\nend)\n```\n\nJoin the world, stand still on foot, then press `K`. The request schedules\n15 seconds of smoking; no manual F7 toggle is needed. Use `/anim stop` or move to\ncancel earlier. If using your own WebUI, release its input focus before requesting\nplayback, as the Freeroam **Play & Close** button does. A message saying the server\naccepted the action is not a guarantee that native rendering succeeded.\n\nFor an NPC interaction or other server-owned activity, use the server `play`\nexample below and declare `players.animations.control` in that resource. Do not\ncall the client Promise signature from a server script.","natives":["RegisterKeyMapping","CreateThread","server:CreateThread","Open77.animations.request"],"permissions":["input.actions"],"events":[]},{"anchor":"client-lua-api","heading":"Client Lua API","level":2,"text":"Declare `dependency 'open77_animations'` in your client resource manifest. These\nmethods are asynchronous facades over that resource's exports, not server Lua\nmethods. They work in the same downloaded resource host as the service. A local\nbootstrap resource in a separate host cannot call across that boundary.\n\n| Method | Promise result |\n| --- | --- |\n| `list(query?)` | Matching profile definitions, copied for the caller |\n| `get(profileId)` | Profile definition, or nil if absent |\n| `request(profileId, options?)` | Server-accepted playback state for the local player |\n| `sequence(steps, options?)` | Server-accepted sequence for the local player |\n| `cancel(playbackId?)` | `true` when local cancellation was queued |\n| `state(playerId?)` | Last active canonical state; defaults to the local player |\n\nCall from a managed coroutine or event handler. There are two possible error\nstages: dispatch can return `nil, reason` instead of a Promise; `:await()` can return\n`nil, reason` from a rejected request or stopped resource.\n\n```lua\nCreateThread(function()\n local pending, err = Open77.animations.request('smoke', {\n durationMs = 15000, loop = false,\n })\n if not pending then print(err); return end\n local playback, rejected = pending:await()\n if not playback then print(rejected); return end\n print('Accepted RP action: ' .. playback.playbackId)\n\n Wait(4000)\n local stop, dispatchError = Open77.animations.cancel(playback.playbackId)\n if not stop then print(dispatchError); return end\n local cancelled, cancelError = stop:await()\n if not cancelled then print(cancelError) end\nend)\n```\n\n`request` shares server `play` options. `sequence` shares server sequence options\nbelow. Client calls cannot select another player. The facade does not require a\npresentation capability in the caller: only `open77_animations` uses the internal\n`animations.presentation` bindings. Do not call underscore-prefixed methods yourself.\n\nAccepted means **the server accepted the action**, not that a native device is\nalready mounted. `cancel` stops local presentation immediately and sends a reliable\nrequest for that playback ID; `onPlayerAnimationChanged` reports the authoritative\nstop. Omitting the ID also abandons pending requests. Each client start carries a\ncorrelation ID in its authoritative state: an abandoned local action is suppressed\neven if the state broadcast or a snapshot arrives **before** the request response.\nIts exact playback ID is sent back for cancellation. Other players and unrelated\nserver-started actions are not suppressed by that pending request.\n\nA late acceptance is cancelled even if its original caller timed out or its\nresource generation stopped. The 10-second timeout is enforced by the service,\nincluding when the caller has not resumed its Promise. At most 32 unanswered start\nrequests are retained. These pre-response safeguards require the matching updated\nserver and client resource; legacy states without a correlation ID can only be\nassociated with a pending request when its response arrives.\n\n```lua\nAddEventHandler('onPlayerAnimationChanged', function(playerId, state)\n -- On the client, state is a Lua table, not the server event's JSON string.\n print(playerId, state.active, state.reason)\nend)\nAddEventHandler('onAnimationPlaybackFailed', function(playerId, playbackId, reason)\n print('Native animation could not continue:', playerId, playbackId, reason)\nend)\n```\n\n`onAnimationResult(requestId, ok, error, value)` additionally reports receipt of a\nstart/sequence response. Prefer the Promise for associating your own request with\nits result. These local notification events are observations, not authority for\ngranting server rewards or inventory items.\n\nLate join and stream-in retain the server-selected profile, current step and\nplayback ID. A recreated remote body receives the current action, not an old\nincarnation's handles. Snapshots replace visibility atomically after all pages\narrive and never cross routing buckets. They are bounded by both the network's\n48 KiB envelope and the client's 1,024-value JSON decoder. A maximum-size roster\nof 1,024 players with 16-step sequences fits within the 128-page assembly limit.\nCanonical state is committed atomically; snapshot change notifications drain in\nbatches of 32 on subsequent service ticks. A newer live event supersedes a queued\nnotification for that player. `state()` can therefore see the committed snapshot\nbefore every corresponding notification has been delivered.","natives":["CreateThread","server:CreateThread","Open77.animations.request","Wait","server:Wait","Open77.animations.cancel","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"server-lua-api","heading":"Server Lua API","level":2,"text":"`Open77.animations` is provided by the server runtime. No resource export or\n`open77_animations` dependency is needed for the server methods below.\n\nAdd the permissions your resource actually needs to its `open77.lua`:\n\n```lua\npermissions {\n 'players.animations.control',\n 'players.animations.read',\n}\n```\n\n| Method | Result | Permission |\n| --- | --- | --- |\n| `list(query?)` | Array of matching profile definitions | None |\n| `get(profileId)` | Profile definition, or nil when absent | None |\n| `play(playerId, profileId, options?)` | Accepted playback state | `players.animations.control` |\n| `sequence(playerId, steps, options?)` | Accepted playback state | `players.animations.control` |\n| `stop(playerId, playbackId?)` | `true`, including when already stopped | `players.animations.control` |\n| `playAt(playerId, profileId, position, yaw?, options?)` | Accepted playback state with an `anchor` | `players.animations.control` |\n| `stopAt(playbackId)` | `true`, including when the handle no longer names a running action | `players.animations.control` |\n| `current(playerId)` | Playback state, or nil when inactive | `players.animations.read` |\n\nFailures return `nil, error`. Successful lookup of a missing profile or inactive\nplayer returns nil without an error. IDs are case-sensitive. Search is a\ncase-insensitive substring match across ID, label and category; its maximum length\nis 128 characters. A search can match several profiles.\n\n```lua\nlocal profile = Open77.animations.get('smoke')\nlocal matches = Open77.animations.list('social')\n\nlocal playback, err = Open77.animations.play(playerId, 'smoke', {\n loop = false,\n durationMs = 15000,\n})\nif not playback then\n print('Cannot start animation: ' .. tostring(err))\n return\nend\n\n-- Keep this ID if a timer or later callback will stop this particular action.\nlocal playbackId = playback.playbackId\nlocal stopped, stopError = Open77.animations.stop(playerId, playbackId)\n```","natives":["Open77.animations.get","server:Open77.animations.get","Open77.animations.list","server:Open77.animations.list","Open77.animations.play","server:Open77.animations.play","Open77.animations.stop","server:Open77.animations.stop"],"permissions":["players.animations.control","players.animations.read"],"events":[]},{"anchor":"duration-clips-and-sequences","heading":"Duration, clips and sequences","level":3,"text":"`play` options are `clip`, `durationMs`, `loop` and `item`. The item option is for\nlayer profiles only, as described above. Omit `clip` to select the\nprofile default. A variant must belong to that exact profile. Unknown options,\nnon-integer durations and cross-profile clips are rejected, not silently corrected.\n\n`loop` defaults to true. With that default, omitted or zero `durationMs` keeps the\nserver action active until cancellation. With `loop = false`, the default duration\nis the measured clip length when supplied by the profile, otherwise 5,000 ms.\nOne-shot layer gestures also use their measured duration when looping.\nA nonzero duration must be between 1,000 and 600,000 ms.\n\nDurations schedule server playback. The 5,000 ms fallback does not establish the\nnative clip length. Layer profiles expose measured `clipDurationsMs`; workspot\nseeking and repetition have different presentation constraints.\n\n```lua\nlocal playback, err = Open77.animations.sequence(playerId, {\n { profile = 'handsup', durationMs = 4000 },\n { profile = 'clap', durationMs = 6000 },\n { profile = 'think', durationMs = 8000 },\n}, { loop = false })\n```\n\nA sequence contains 1–16 steps. Each step accepts `profile`, `clip`, `item` and\n`durationMs`; its duration defaults to the measured clip length when available,\notherwise 5,000 ms, and must be nonzero. The sum cannot\nexceed 600,000 ms. The only sequence option is `loop`, default false. Different\nprofiles may appear in one sequence; native profile changes require asynchronous\nworkspot exit/re-entry and are not promised to be seamless blends.","natives":["Open77.animations.sequence","server:Open77.animations.sequence"],"permissions":[],"events":[]},{"anchor":"addressing-a-clip-by-name-taskplayanims-shape","heading":"Addressing a clip by name (`TaskPlayAnim`'s shape)","level":3,"text":"Every other engine names an animation directly — FiveM's\n`TaskPlayAnim(dict, name, ...)` — and a caller that knows the clip should not have to\nknow which action happens to carry it. `playClip` is that shape:\n\n```lua\n-- Server. Same permission, readiness, ownership and duration rules as play().\nlocal playback, err = Open77.animations.playClip(\n playerId, 'stand__dance__02__dancing__03', { durationMs = 15000, loop = false })\n```\n\n```lua\n-- Client, for the local player.\nlocal playback, err = Open77.animations.requestClip(\n 'stand__dance__02__dancing__03', { loop = true }):await()\n```\n\nDiscovery is `clips` and `clip`, on both runtimes and with no capability required:\n\n```lua\nfor _, row in ipairs(Open77.animations.clips('dance') or {}) do\n print(row.profile, row.clip) -- dance stand__dance__02__dancing__03\nend\nprint(Open77.animations.clip('stand__dance__02__dancing__03').id) -- dance\n```\n\nThe clip is resolved to its owning profile from the same generated catalogue on\nboth halves, so `playClip(id, clip, opts)` and `play(id, profile, { clip = clip })`\nstart the identical action and produce the identical playback state. Nothing new\ngoes on the wire.","natives":["server:Open77.animations.playClip","Open77.animations.requestClip","Open77.animations.clips","server:Open77.animations.clips","Open77.animations.clip","server:Open77.animations.clip"],"permissions":[],"events":[]},{"anchor":"what-raw-playback-can-and-cannot-mean-here","heading":"What \"raw playback\" can and cannot mean here","level":4,"text":"**Cyberpunk has no play-a-clip-by-name native.** A body plays an authored clip only\nafter it is mounted into a *device* — an entity carrying a\n`workWorkspotResourceComponent` — and `SendJumpToAnimEnt` then jumps it to a node\n**inside that device's workspot tree**. The device and its tree are bound together\nwhen the archive is built, not at runtime.\n\nThe addressable set includes the catalogue workspots and the shipped layer\nprofiles. `Open77.animations.clips()` returns the installed build's supported\nprofile/clip pairs. Select a layer by profile ID when its clip is also used by a\nworkspot; clip-first lookup selects the full-body version for shared names.\n\n- Download the [clip-name inventory](/data/emote-animations.txt) (23,044 names)\n or the [workspot discovery index](/data/rp-workspots.json) (4,510 names across\n 494 vanilla workspots). These public files require no repository access and\n contain identifiers, not animation assets. The inventories can overlap and\n are **discovery lists, not playback allowlists**. Angle-bracket names in the\n workspot index are engine markers. A name absent from the installed playback\n catalogue is refused with `unknown_clip`. For usable actions, start with the\n [profile catalogue](rp-animation-catalogue.md) and `Open77.animations.clips()`.\n- The legacy client-local `Open77.animations.play(entity, clip)` looks like it takes\n any name. It does not: it mounts the body into the single generic device\n `cyberm\\entities\\workspot_anim.ent`, which binds exactly one vanilla workspot\n (`base\\workspots\\common\\ground\\generic__stand_cigar__stand_around__01.workspot`),\n so only that tree's nodes resolve. Anything else is a silent no-op.\n\nAdding a clip means adding a **device**: a new entry in\n`scripts/animations/rp-profiles.json`, a regenerated workspot and `.ent` from\n`scripts/animations/build-devices.ps1`, and a repacked `Open77.archive`. See\n[`scripts/animations/README.md`](../scripts/animations/README.md).","natives":[],"permissions":[],"events":[]},{"anchor":"flags-that-do-not-exist","heading":"Flags that do not exist","level":4,"text":"FiveM's `TaskPlayAnim` flags have no workspot equivalent, and the service refuses\nthem **by name** rather than with a blanket `invalid_options`, so a port can see\nwhich concept is missing instead of assuming a bad value:\n\n| Passed option | Reason returned | Why |\n| --- | --- | --- |\n| `upperBody` | `unsupported_option:upperBody` | Choose a shipped layer profile such as `smoke_walk`; this flag cannot convert a workspot. |\n| `blendIn`, `blendOut`, `blendMs` | `unsupported_option:blendIn` … | The engine *jumps* a mounted body to an authored node; it does not blend a clip in over milliseconds. |\n| `holdLastFrame` | `unsupported_option:holdLastFrame` | Not measured on 2.31. Unverified behaviour is not an API. |\n| `flags`, `dict`, `playbackRate` | `unsupported_option:` | No equivalent concept. |\n\nSupported options include `loop`, `durationMs`, `clip` on `play` or a sequence\nstep, and `item` on layer actions as described above.","natives":[],"permissions":[],"events":[]},{"anchor":"scenarios-what-playat-covers-and-what-it-does-not","heading":"Scenarios: what `playAt` covers and what it does not","level":4,"text":"`TaskStartScenarioInPlace` on a *world* chair is still not offered, and the reason has\nnot changed: Cyberpunk's scenario equivalent is a workspot, a workspot is only playable\nthrough a device bound to it at asset-build time, and nothing on 2.31 enumerates\nworkspot-bearing world entities near a point -- the same wall that stopped the cover\ncommands in an earlier wave. Mounting a player into the bench they are standing next\nto would need that bench's device handle, and there is no way to ask for it.\n\nWhat *is* shipped is the other half of that native, `TaskStartScenarioAtPosition`:\nOpen77 brings its **own** device to the pose. See the next section.","natives":[],"permissions":[],"events":[]},{"anchor":"portable-workspots-sit-lean-and-lie-anywhere","heading":"Portable workspots: sit, lean and lie anywhere","level":3,"text":"Three profiles exist for one reason: the pose they carry is authored against a piece\nof furniture that will not be there. Each ships its own device and derived workspot in\n`Open77RP.archive`, like every other profile, and each is invisible -- the device is\nthe chair, and it renders nothing.\n\n| Profile | Vanilla source | Body pose |\n| --- | --- | --- |\n| `chair` | `chair\\generic__sit_chair_lean_back__sit_around__01.workspot` | seated at chair height, leaning back, hands on lap; 37 clips |\n| `lean` | `wall\\generic__stand_wall_lean_back__stand_around__01.workspot` | standing, back against a wall, arms crossed; 4 clips |\n| `lie` | `bed\\generic__lie_double_bed__lie_around__01.workspot` | lying on the back at mattress height; 2 clips |\n\nThey are ordinary profiles: `play(playerId, 'chair')` sits the player down exactly\nwhere they stand, on nothing. What makes them *portable* is `playAt`, which moves the\nbody to a pose the server chooses, spawns the device there and plays the posture on\nit -- the body arrives first, the chair appears under it, then it sits:\n\n```lua\n-- open77.lua: permissions { 'players.animations.control' }\n-- A chair prop spawned with Open77.props has no workspot of its own. Put the\n-- posture on its seat, facing the way the chair faces.\nlocal chairPos, chairYaw = { x = -1441.2, y = 129.6, z = 18.05 }, 90.0\nlocal placed, err = Open77.animations.playAt(playerId, 'chair', chairPos, chairYaw, {\n durationMs = 30000, loop = false,\n})\nif not placed then print('cannot seat: ' .. tostring(err)); return end\nseated[playerId] = placed.playbackId -- the handle stopAt() takes\n\n-- Later, whoever sat them down:\nOpen77.animations.stopAt(seated[playerId])\n```\n\n`position` is `{ x, y, z }` (or a three-element array); `yaw` is degrees about Z, the\nsame convention as `Open77.props.create`, and defaults to 0. `options` are exactly\nthose of `play`: `clip`, `durationMs`, `loop`. The accepted state carries an `anchor =\n{ x, y, z, yaw }` field, and its `playbackId` is the handle.\n\nAny catalogue profile accepts a pose, not only the three above: `playAt(id, 'smoke',\nbarCounter, yaw)` walks a smoker to the counter.\n\n**Limits, stated plainly:**\n\n- **Reach is bounded at 5 m** from the player's current position, refused with\n `anchor_too_far`. A placement moves the body, and past furniture-distance that is\n a teleport dressed as an animation, reachable with the animation permission alone.\n Journeys belong to [`Open77.players.teleport`](server-api.md#moving-a-player), which\n costs `players.teleport` and settles the body; call it first, then `playAt`.\n- **Placement is server-controlled.** `playAt` moves the player to the anchor without a fade, using its yaw and the normal readiness/settle checks. The posture is published to clients and its duration starts only after arrival. `onPlayerAnimationChanged` reports acceptance before arrival; this is not a rendering acknowledgement.\n- **The move watchdog is measured against the anchor**, not the starting point, and it\n is armed five seconds after acceptance. A body that never reaches its anchor in\n that window ends with reason `anchor_unreached` (the placement channel refused or\n the client never landed); one that arrived and then left ends with `moved`, as\n before. A placement the channel cannot even dispatch is refused at once with\n `anchor_move_refused` (the body is not ready, not alive, or in a vehicle).\n- **The pose is the caller's problem.** `z` is where the *device* goes, and the\n authored clip plays relative to it: for `chair` that is the floor under the seat,\n for `lie` the floor under the bed. Put a `chair` at seat height and the body floats.\n- **Nothing is enumerated.** `playAt` does not find chairs; it accepts a pose the\n server already knows -- a prop it spawned, a point from its own furniture table.\n- **Acceptance is server authority, not proof.** The client still mounts the anchored\n device with `gameWorkspotSlidingBehaviour.PlayAtResourcePosition` (runtime-tunable:\n `emote.tune anchorslide 0|1|2`, `emote.tune anchorslidetime `), but with the\n body already on the anchor the slide is zero either way. Observe\n `onPlayerAnimationChanged` and the `anchor_unreached` reason, not the return value.\n- `stopAt` on a handle whose action already ended succeeds, exactly as `stop` on an\n idle player does; another resource's handle is refused with `animation_owned`.\n Resource stop, restart and disposal tear placed actions down like every other.","natives":["server:Open77.animations.playAt","server:Open77.animations.stopAt"],"permissions":["players.animations.control"],"events":[]},{"anchor":"playback-state","heading":"Playback state","level":3,"text":"Accepted playback and `current` expose these fields:\n\n| Field | Meaning |\n| --- | --- |\n| `epoch` | Server animation-service generation |\n| `revision` | Monotonically increasing state revision within that epoch |\n| `playerId`, `playbackId` | Player and this specific accepted action |\n| `clientRequestId` | Internal client-start correlation; empty for server API/command actions. Not an ownership token or a value to pass to `cancel` |\n| `active`, `reason` | Whether active and the transition/cancellation reason |\n| `bucket` | Routing bucket in which this action began |\n| `steps`, `loop` | Validated profile/clip/duration sequence and repeat flag |\n| `startedAtMs`, `serverTimeMs` | Server monotonic timestamps, not wall-clock dates |\n| `cycle`, `step` | Zero-based current repetition and step |\n| `elapsedMs`, `remainingMs` | Timing within the current step; indefinite playback has no finite completion |","natives":[],"permissions":[],"events":[]},{"anchor":"ownership-cancellation-and-events","heading":"Ownership, cancellation and events","level":3,"text":"A resource controls only the actions started by its own VM generation. A new\naction from that owner replaces its old action with a new playback ID. Another\nresource cannot replace or stop it. Resource stop/restart/disposal cancels its\nactions; a replacement VM does not inherit the old owner's lease.\n\nPass `playbackId` to delayed stops. A stop for an older action returns\n`stale_playback` and leaves the replacement running. Omitting it stops the current\naction owned by the calling resource. The player's self-cancellation network path\nis allowed to stop their own server-controlled animation.\n\nThe server cancels on disconnect, loss of readiness, death, vehicle occupancy,\nrouting-bucket changes and completion of a non-looping sequence. Movement beyond\n0.5 m from the start cancels stationary workspots; layer profiles allow movement.\nThe client reports native playback failures and interruptions through\nself-cancellation. Native interruption handling remains experimental.\n\n```lua\nAddEventHandler('onPlayerAnimationChanged', function(playerId, stateJson)\n local state = json.decode(stateJson)\n if not state.active then\n print(('Player %s stopped: %s'):format(playerId, state.reason))\n end\nend)\n```\n\nThe event currently passes a player ID string and a JSON state string. State\nmessages are restricted to players in the same routing bucket. The reserved\n`open77:animations:` event namespace belongs to the platform; resources must use\nthe API, not forge authoritative events with `TriggerClientEvent`.\n\nCommon errors include `unknown_profile`, `invalid_clip`, `invalid_options`,\n`invalid_duration`, `invalid_sequence`, `invalid_step`, `sequence_too_long`,\n`player_unavailable`, `player_not_ready`, `player_not_alive`, `player_in_vehicle`,\n`position_unavailable`, `animation_owned`, `stale_playback`, `resource_stopping`\nand `permission_denied:`. `playAt` adds `invalid_anchor` and\n`anchor_too_far`; `stopAt` adds `invalid_playback`. A placed action can also end\nwith reason `anchor_unreached`.\n\nClient-side errors also include `export_resource_unavailable`, `player_not_ready`,\n`network_unavailable`, `too_many_requests`, `request_timeout`, `cancelled`,\n`session_changed`, `invalid_response`, and `resource_stopped`. Internal native\nfailures are reported separately through `onAnimationPlaybackFailed`.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"local-view-and-development-diagnostics","heading":"Local view and development diagnostics","level":2,"text":"The workspot local path requests third person temporarily and reuses the F7\npresentation body. It waits for that body to be active before starting the workspot;\nits walking, aiming and combat presentation stand down while the workspot owns it.\nStopping releases the temporary animation-camera priority immediately. Returning\nto first person restores the real player before hiding the animation proxy; an\nexisting F7 or resource-owned third-person request is preserved. Native workspot\ndetachment and safe device cleanup continue independently in the background.\nAn already-playing local RP pose uses the native instant exit when cancelled;\nF7 locomotion resumes after confirmed detachment, without waiting for the separate\ncallback-lifetime quarantine. This avoids leaving the visible body behind when\nthe player starts moving. Pending mounts retain conservative cleanup.\nThe player's stored first/third-person preference is not overwritten. Native safety\nstates still take priority, and a forced-first-person server policy is refused.\n\nDeveloper bridge commands, for local rendering experiments only:\n\n```text\nemote.profile player smoke\nemote.state player\nemote.stop player\n```\n\nThese commands do not broadcast RP state. Existing client\n`Open77.animations.play(entity, rawClip)` and `playSelf` remain legacy local APIs,\nnot the synchronized request API. Do not use them to implement RP replication.\n\nNative local failures retain their `status` and diagnostic `detail` in\n`emote.state player` after the temporary view has been released, until a new\nprofile request or world exit. This history does not mean the body is still\nplaying or mounted. Details distinguish proxy readiness, player movement/stance,\ncamera policy and workspot playback failures. The native log records the same\nfailure once; the downloaded resource also logs `RP playback failed` when it\nemits `onAnimationPlaybackFailed`. Observe the downloaded resource's events in\nits resource host, not a separate bootstrap host.","natives":[],"permissions":[],"events":[]}]},{"slug":"rp-kit","file":"rp-kit.md","title":"The role-play kit","summary":"`open77_rp_basics` is a sample role-play resource combining player freeze, input blocking, synchronized animations, weapon queries and placement. It exposes cuff, escort and search actions:","sections":[{"anchor":"the-role-play-kit","heading":"The role-play kit","level":1,"text":"`open77_rp_basics` is a sample role-play resource combining player freeze, input blocking, synchronized animations, weapon queries and placement. It exposes cuff, escort and search actions:\n\n```lua\nexports.open77_rp_basics:cuff(officerId, targetId)\nexports.open77_rp_basics:escort(officerId, targetId)\nexports.open77_rp_basics:search(officerId, targetId)\n```\n\nand one that this engine cannot do, which is therefore present and refuses:\n\n```lua\nexports.open77_rp_basics:carry(officerId, targetId) --> false, \"carry_unsupported\"\n```\n\nUse the resource as a dependency or adapt its source. Preserve ownership tracking and cleanup so an interrupted action cannot leave a player frozen.\n\nSource: [`open77_rp_basics`](https://github.com/Open2077/open77-resources/tree/main/system/open77_rp_basics).","natives":[],"permissions":[],"events":[]},{"anchor":"what-each-verb-is-physically","heading":"What each verb is, physically","level":2,"text":"| Verb | The body | The controls | Ends when |\n|---|---|---|---|\n| `cuff` | held by `Open77.players.setFrozen` — the engine's own `GameplayRestriction.NoMovement`, so every observer agrees and nothing slides | the whole gameplay action stream, on the target's own client | see [How a hold ends](#how-a-hold-ends) |\n| `escort` | free: he walks himself | the action stream **minus** movement | the same list, plus the tether's own endings |\n| `search` | held, for `searchMs` (4 s by default) | the whole stream, for the same beat | on its own, or by any of the same paths |\n| `carry` | — | — | it never starts |\n\n`cuff` loops the `handsup` RP profile. `escort` does not play a profile because walking cancels stationary RP animations. Stating that is better than shipping a pose that flickers off at the first step.","natives":[],"permissions":[],"events":[]},{"anchor":"escort-is-a-leash","heading":"`escort` is a leash","level":3,"text":"There is no way to attach one player to another on 2.31, so an escort is not an attachment. The\ntarget walks under his own power with everything but movement taken away, and a 2 Hz tick watches\nthe gap: past `tetherDistance` (5 m) he is placed back `tetherPullDistance` (1.6 m) from his\nescort, at the escort's height, and not again for `tetherCooldownMs` (2 s).\n\nA tether pull uses `Open77.players.teleport`, which resolves after the client reports the body settled. The cooldown prevents overlapping placements and `settle_superseded` refusals.\n\nTwo tether outcomes end the hold rather than retrying:\n\n- the target got into a vehicle — `teleport` answers `player_in_vehicle` and a leash that cannot\n move its prisoner is not holding him;\n- either party died or changed routing bucket.\n\nEverything else — a stale position, a body still streaming — pauses the leash for a tick. It never\nends a hold on thin evidence.","natives":[],"permissions":[],"events":[]},{"anchor":"search-is-a-frisk-and-reports-a-cache","heading":"`search` is a frisk, and reports a cache","level":3,"text":"The result carries `Open77.weapons.get(targetId)` verbatim, **including its freshness fields**.\nThat read is a cache of what the owner's client reported and never authority: a player nobody has\nheard from answers `weapons_unreported`, which is a different fact from \"carrying nothing\". The\nkit passes that distinction through rather than flattening it, because on an RP server the\ndifference is an argument nobody can settle afterwards.\n\n```lua\nAddEventHandler(\"open77_rp_basics:searched\", function(officerId, targetId, ticket, result)\n if result.weapons == nil then\n -- `result.reason` is \"weapons_unreported\": not proof he is unarmed.\n return\n end\n -- result.weapons.slots / active / drawn / magazine / reportedAgeMs / loadoutAgeMs / source\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"authority-what-stops-a-griefer","heading":"Authority: what stops a griefer","level":2,"text":"An export carries no player identity — `source` is nil inside one, by design — so the officer is\nan **argument**, and an argument proves nothing. Every verb therefore re-derives authority from\nthe ACL:\n\n| Permission | Verb |\n|---|---|\n| `rp.cuff` | `cuff` |\n| `rp.escort` | `escort` |\n| `rp.search` | `search` |\n| `rp.admin` | `release` on somebody else's hold |\n\nGrant `rp.*` to a police role once. Until something is granted, every verb answers\n`not_authorised`, and that is the intended state of a fresh install: a resource that can hold one\nplayer still on another player's say-so does not arrive switched on. It is also `auto_start false`\nfor the same reason.\n\nBefore an action, the server checks that both players are ready, alive, in the same routing bucket and within `maxDistance` (3 m), using positions no older than two seconds. Dead or unready targets are rejected to avoid unsafe native actions.","natives":[],"permissions":[],"events":[]},{"anchor":"how-a-hold-ends","heading":"How a hold ends","level":2,"text":"This is the feature. Twelve paths, each implemented, each with a test:\n\n| Reason | What happened |\n|---|---|\n| `released` | the officer asked, through `release` or `rp.free` |\n| `freed_by_admin` | somebody holding `rp.admin` freed him |\n| `expired` | the hold outlived `maxHoldMs` (15 minutes by default) |\n| `superseded` | the same officer replaced the hold with a different verb |\n| `officer_left` / `target_left` | that player disconnected |\n| `officer_dead` / `target_dead` | that player's life phase left `alive` |\n| `officer_bucket_changed` / `target_bucket_changed` | that player moved to another world |\n| `target_mounted` | escort only: he got into a vehicle, which a tether cannot follow |\n| `resource_stopping` | this resource stopped, reloaded, or hit a runtime error |\n\n**`expired` is the one that matters.** The other eleven are named paths that can each be missed —\nan event that does not arrive, a handler that raises, a world state nobody enumerated. The\ndeadline is underneath all of them: a 2 Hz sweep releases anything past its expiry, every record\nis released through one function, and every platform call in that function is wrapped so that one\nrefusal cannot skip the next. Nothing this kit does can strand a player indefinitely.\n\nTwo further safety nets are the platform's, not the kit's, and they hold even if this VM dies\nbetween instructions:\n\n- **freeze claims and input claims are per-resource and are dropped when the owning resource\n stops**, on every path including a crash;\n- **the freeze is dropped on death and on respawn** by the life state machine itself.","natives":[],"permissions":[],"events":[]},{"anchor":"releasing-exactly-what-was-taken","heading":"Releasing exactly what was taken","level":3,"text":"Two resources claiming the same thing must not fight. A freeze is a *claim*, not a value:\n`setFrozen(false)` drops only the calling resource's hold, and a player stays frozen while anybody\nelse still holds him. The same is true of every input block. So the kit writes down what it took,\nbeside the record that asked for it, and gives back exactly that:\n\n```\nrecord.claims = { freeze = true, animation = \"\", client = true }\n```\n\nA cuff that becomes an escort releases the freeze and the animation and re-issues the client block\nin its walking shape — one claim each, released once each. The test suite asserts the counters\nreturn to zero on every one of the twelve endings above.","natives":[],"permissions":[],"events":[]},{"anchor":"the-client-half","heading":"The client half","level":2,"text":"`client/main.lua` owns the control block and nothing else. Input blocking is a client-only API by\ndesign: the server decides *that* a player is held, the client decides *what that takes away*.\n\nIt takes `Open77.input.blockAll` (with `except = { \"Movement\" }` while escorting) plus `Map` and\n`Hub` by name. It deliberately does **not** take the pause menu, and it cannot take chat or voice\n— those run on focused browser input rather than on the gameplay action stream. A player somebody\nelse just cuffed must still be able to say so, reach settings, and disconnect.\n\nOne thing the client half must get right on its own: a client-side resource reload drops its claim\nwhile the server still holds the player. On start it sends `open77:rpbasics:sync` and the server\nre-issues whatever is standing.","natives":[],"permissions":[],"events":["open77:rpbasics:sync"]},{"anchor":"configuration","heading":"Configuration","level":2,"text":"The defaults live in `shared/rules.lua` and are what a caller gets:\n\n| Key | Default | Meaning |\n|---|---|---|\n| `maxDistance` | 3.0 m | how close the officer must be to start a verb |\n| `tetherDistance` | 5.0 m | how far an escorted player may drift |\n| `tetherPullDistance` | 1.6 m | where a pull puts him |\n| `tetherCooldownMs` | 2000 | the floor between two pulls |\n| `maxHoldMs` | 900000 | the ceiling on any hold |\n| `searchMs` | 4000 | how long a frisk takes |\n| `positionMaxAgeMs` | 2000 | older than this and a position is not evidence |\n\n`options.holdMs` on `cuff` / `escort` / `search` shortens one hold. It can never lengthen one past\n`maxHoldMs`: the ceiling is the operator's protection against a resource that forgets.","natives":[],"permissions":[],"events":[]},{"anchor":"composing-the-verbs-the-row-named-but-the-kit-does-not-export","heading":"Composing the verbs the row named but the kit does not export","level":2,"text":"- **drag** is `escort` on a player who cannot walk. On 2.31 that is not buildable: a downed player\n is not `alive`, and `teleport` refuses a body that is not alive, so the leash has nothing to\n pull. Use `escort` once he is up.\n- **rob** is `search` plus your own inventory transaction. The kit deliberately stops at the\n report: inventory is a framework concern, not a native one, and a kit that moved items would be\n making decisions that belong to the gamemode.","natives":[],"permissions":[],"events":[]},{"anchor":"least-privilege-as-a-worked-example","heading":"Least privilege, as a worked example","level":2,"text":"The manifest asks for nine capabilities and a test asserts it asks for no more. Two absences are\ndeliberate and worth copying:\n\n- **`world.vehicles` is not requested**, although knowing whether the target is seated would make\n the tether tidier. The teleport refusal `player_in_vehicle` answers the same question after the\n fact, and it costs nothing. A capability you can do without is a capability you do not ask for.\n- **`players.animations.read` is not requested.** The kit tracks its own playback ids and never\n needs to ask what somebody else started.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Freezing a player](player-freeze.md) — the mechanism under `cuff`, and why a freeze is a claim.\n- [Blocking player input](input-blocking.md) — what `blockAll` really takes, and what it cannot.\n- [Role-play animations](rp-animations.md) — the `handsup` profile and why it is stationary.\n- [Cross-resource server exports](server-exports.md) — how another resource calls this one.\n- [Props](props.md#attachment) — the attachment that exists, and the parenting that does not.","natives":[],"permissions":[],"events":[]}]},{"slug":"rtti","file":"rtti.md","title":"RTTI calls and hooks","summary":"`Open77.rtti` lets a client resource call reflected native and scripted game functions with supported signatures, without a binding written per function. RTTI is the engine's runtime type information. The bridge uses it to discover function signatures and convert Lua values: a name is resolved at runtime, its parameters are read from the engine's own type information, and values are converted to t","sections":[{"anchor":"rtti-calls-and-hooks","heading":"RTTI calls and hooks","level":1,"text":"`Open77.rtti` lets a client resource call reflected native and scripted game\nfunctions with supported signatures, without a binding written per function.\nRTTI is the engine's runtime type information. The bridge uses it to discover\nfunction signatures and convert Lua values: a name is resolved at runtime, its parameters are read\nfrom the engine's own type information, and values are converted to the game's\ntypes on the way in and back on the way out.","natives":[],"permissions":[],"events":[]},{"anchor":"requirements","heading":"Requirements","level":2,"text":"Use a client build that exposes `Open77.rtti`; these functions run in the client\nresource runtime. Feature detection is available with `type(Open77.rtti) == \"table\"`.\n\nPermission: `rtti.native`. This is a privileged client API: a permitted resource\ncan invoke engine behavior directly. The dedicated server has no REDengine backend.\nCalls and hooks require a running resource; top-level candidate loading and stop\nhandlers return `resource_not_active`. Register them in an event or thread.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest","heading":"Manifest","level":2,"text":"```lua\nresource \"my_resource\"\nversion \"1.0.0\"\nclient_script \"client.lua\"\npermissions { \"rtti.native\" }\n```","natives":[],"permissions":["rtti.native"],"events":[]},{"anchor":"function-reference","heading":"Function reference","level":2,"text":"| Function | Result |\n| --- | --- |\n| `Open77.rtti.resolve(class, fn)` | `{ name, static, returnType, params = { {name, type}... } }`, or `nil, reason` |\n| `Open77.rtti.call(class, fn, instance, ...)` | the return value (`nil` for void), or `nil, reason` |\n| `Open77.rtti.hook(class, fn, handler)` | a hook id, or `nil, reason` |\n| `Open77.rtti.unhook(id)` | `true` if this resource removed the hook |\n| `Open77.rtti.release(ref)` | `true`; drops a pinned object reference |\n\n`class` is `nil` (or `\"\"`) for a global function, looked up by its decorated\nname. `fn` is the function name; when a class has overloads, pass the decorated\nname the `resolve` result reports. `instance` is a ref for a method and `nil`\nfor a static function. Ambiguous short method names return `ambiguous_function`.\nSupply every declared parameter, including optional parameters; automatic\ndefaults and `out` parameters are not supported. Signatures above 15 parameters\nare refused before entering the engine.","natives":[],"permissions":[],"events":[]},{"anchor":"calling-functions","heading":"Calling functions","level":2,"text":"Run calls inside a resource callback or thread. A successful void or null-handle\nreturn is `nil` without a reason; check the second result to distinguish failure.\n\n```lua\nAddEventHandler(\"example:rtti:inspect\", function()\n local signature, reason = Open77.rtti.resolve(nil, \"AbsF\")\n if not signature then\n print(reason)\n return\n end\n print(signature.name, signature.returnType) -- AbsF, Float\n\n local value, callError = Open77.rtti.call(nil, \"AbsF\", nil, -3.5)\n if callError then\n print(callError)\n return\n end\n print(value) -- 3.5\nend)\n```\n\nFor globals, use the full name exposed by RTTI. Scripted globals may include the\nclass and parameter suffix, such as `MathHelper::EulerNumber;`. Class methods\naccept a full decorated name or an unambiguous short name. Resolving metadata\nsucceeds even for signatures the bridge cannot marshal; it does not guarantee\nthat a call or hook can be installed.","natives":["AddEventHandler","server:AddEventHandler","Open77.rtti.resolve","Open77.rtti.call"],"permissions":[],"events":[]},{"anchor":"hooks","heading":"Hooks","level":2,"text":"```lua\nAddEventHandler(\"example:install\", function()\n local id = assert(Open77.rtti.hook(nil, \"AbsF\", function(ctx, value)\n return ctx.callOriginal(value) + 10\n end))\n assert(Open77.rtti.call(nil, \"AbsF\", nil, -3) == 13)\n assert(Open77.rtti.unhook(id))\nend)\n```\n\nThe handler is called as `handler(ctx, ...args)`:\n\n- `ctx.callOriginal()` continues the chain with the arguments the hook received.\n `ctx.callOriginal(...)` continues it with the arguments given instead.\n- `ctx.instance` is the instance ref for a method, `nil` for a static function.\n- `ctx.name` is the function's decorated name.\n- The handler's return value is the call's result.\n- Returning **nothing** passes through: the original runs (for a function with a\n result) and its result is used, or, after a `callOriginal`, that result stands.\n For a void function with no `callOriginal` the original is skipped.\n- A Lua error is logged. Before the original, it falls back to the original;\n afterwards, it preserves the original's result without repeating side effects.\n Invalid return types receive the same native fallback.\n\nWithin one resource host, hooks on a function run in registration order. The\nbootstrap host and downloaded-resource host keep separate subscriptions; stopping\na resource removes only its own hooks. A call made to\nthe same function from inside its own hook reaches the original directly, so a\nhook that calls the function it hooks does not recurse.\n\n`ctx.callOriginal` is valid only while its hook runs and at most once per handler.\nCalling it later or twice raises an error. `nil` has the same pass-through meaning\nas no return value, so it cannot override a non-null handle result with null.\n\nHooks cover SDK `CStack` calls through `ExecuteScripted` / `ExecuteNative` on the\ngame thread, including `Open77.rtti.call`. Other VM entry points, engine stack\nlayouts, direct native calls and worker-thread calls bypass them. Registering a\nhook does **not** prove that every natural game call to that function is observed.\nCallbacks are synchronous, cannot yield, and use the resource's Lua execution quota.","natives":["AddEventHandler","server:AddEventHandler","Open77.rtti.hook","Open77.rtti.call","Open77.rtti.unhook"],"permissions":[],"events":[]},{"anchor":"values","heading":"Values","level":2,"text":"| Game type | Lua |\n| --- | --- |\n| `Bool` | boolean |\n| `Int8`..`Int64`, `Uint8`..`Uint32`, enums | integer; a value outside the declared width is rejected |\n| `Uint64` | integer preserving all 64 bits; high-half values appear negative in Lua |\n| `Float`, `Double` | finite number within the type's range |\n| `CName`, `String`, `CString` | string |\n| `TweakDBID`, `EntityID` / `entEntityID` | integer (the 64-bit pattern) |\n| strong handles to `IScriptable`-derived objects | ref (userdata), or `nil` for null |\n\nStructs, arrays, `Pointer`, `WeakHandle` and `script_ref` types are not converted. A call\nthat needs one returns `nil, \"unsupported_type:\"`.\nEmbedded NUL bytes in text are rejected. New CName strings are registered in the\nengine's name pool. Resolution can describe unsupported signatures, but calls\nand hook installation reject them before invoking the engine.","natives":[],"permissions":[],"events":[]},{"anchor":"refs","heading":"Refs","level":2,"text":"A ref is a pinned game object: the bridge holds a reference to it, so the object\nstays valid while the ref exists.\n\n- Refs delivered to a hook (`ctx.instance`, object arguments and results from\n `ctx.callOriginal`) last only as\n long as that hook dispatch. Using one after the dispatch returns fails with\n `ref_released`. Do not keep them in your own state.\n- A ref returned by `call` stays pinned until you `release` it or the owning\n resource stops/reloads. Garbage collection alone does not release it.\n Releasing an expired ref is harmless. Method instances and handle arguments\n are checked against the function's declared class.\n\n```lua\nAddEventHandler(\"example:rtti:object\", function()\n local ref, reason = Open77.rtti.call(nil, \"CompareBuilder::Make;\", nil)\n if reason then\n print(reason)\n return\n end\n local value, methodError = Open77.rtti.call(\"CompareBuilder\", \"Get;\", ref)\n Open77.rtti.release(ref)\n if methodError then print(methodError) else print(value) end\nend)\n```","natives":["AddEventHandler","server:AddEventHandler","Open77.rtti.call","Open77.rtti.release"],"permissions":[],"events":[]},{"anchor":"errors","heading":"Errors","level":2,"text":"Bridge validation and backend failures return `nil, reason`. Invalid Lua API\nargument kinds checked by Lua itself can raise an error; use `pcall` when accepting\nuntrusted input. `unhook` returns `false` for an unknown hook or one owned by\nanother resource. Common reason codes:\n\n- `permission_denied:rtti.native`\n- `function_not_found`\n- `argument_count:`\n- `integer_out_of_range:`\n- `unsupported_type:`\n- `instance_required` (a method called without an instance)\n- `ref_released`\n- `resource_not_active`, `ambiguous_function`, `unsupported_out_parameter:`\n- `instance_type_mismatch`, `ref_type_mismatch`, `static_instance_must_be_nil`\n- `number_out_of_range:`, `embedded_nul`\n- `not_resolved`, `rtti_unavailable`, `native_call_failed`","natives":[],"permissions":[],"events":[]},{"anchor":"debugging-in-the-game","heading":"Debugging in the game","level":2,"text":"Use [autonomous agent testing](agent-testing.md) to target the correct running\nclient. The read-only debug bridge command `rtti.state` reports pinned references,\nfunction sites with hooks, host subscribers, active dispatches, call/dispatch\ncounts, fallbacks and whether the SDK detours are installed.\n\n```powershell\npwsh -NoProfile -File scripts/debug-bridge.ps1 -ProcessId -Command 'rtti.state'\n```\n\nAfter a resource releases its refs and removes its hooks, the corresponding\ncounts should return to their baseline. Detours can remain installed with zero\nsites until the plugin unloads.\n\nThe platform includes the opt-in `open77_rtti_test` resource for repeatable\nengine probes. Copy it to the trusted bootstrap of an isolated test installation,\nstart the game, and send these commands through its debug bridge:\n\n```text\nresource.start open77_rtti_test\nresource.emit rtti:test:run all\nrtti.state\nresource.stop open77_rtti_test\nrtti.state\n```\n\nThe resource logs `[rtti-test] PASS`, `FAIL` and a final `DONE` line in that\nclient's game log. Deliberate hook errors exercise fallback behavior; use the\nfinal failure count and lifetime counters to assess the run.\n\nIn a multiplayer test session, resource mutations go through the server's\nACL-authorized debug laboratory. Replace the player ID with the target player's\nserver ID; it is different from the Windows process ID:\n\n```text\nclient.exec 1 return Open77.debug.command('resource.emit rtti:test:run all')\nnet.commands 10\nrtti.state\n```\n\nA queued command is not its completion result. Read the server response journal,\nthe matching client log and `rtti.state`. See [privileged debug runtime](debug-runtime.md)\nfor the laboratory permissions and [autonomous agent testing](agent-testing.md)\nfor connection and world-readiness checks.","natives":["Open77.debug.command"],"permissions":[],"events":[]}]},{"slug":"screen-picking","file":"screen-picking.md","title":"Screen picking and cursor utilities","summary":"Use client-side screen-picking and cursor APIs to select a world point or entity from a 2D interface. Check API availability when supporting clients without these helpers.","sections":[{"anchor":"screen-picking-and-cursor-utilities","heading":"Screen picking and cursor utilities","level":1,"text":"Use client-side screen-picking and cursor APIs to select a world point or entity from a 2D interface. Check API availability when supporting clients without these helpers.\n\nFor a ready-made ALT/click interface, use [open77_contextmenu](context-menu.md).","natives":[],"permissions":[],"events":[]},{"anchor":"coordinates-and-permissions","heading":"Coordinates and permissions","level":2,"text":"Coordinates are **normalized viewport coordinates**: `(0,0)` is top-left,\n`(1,1)` bottom-right and `(.5,.5)` the centre. Do not pass desktop pixels,\nCEF backing-texture pixels or engine NDC coordinates. Browser `clientX/innerWidth`\nand `clientY/innerHeight` follow this convention on a fullscreen surface.\n\n`Open77.camera.screenRay` and `screenRaycast` require `world.query`.\n`Open77.input.cursor` requires `input.actions`. These queries do not exist on\nthe dedicated server. They return `nil, reason` when unavailable or invalid.","natives":[],"permissions":[],"events":[]},{"anchor":"screen2dpointto3dspace-screenray","heading":"Screen2DPointTo3DSpace / screenRay","level":2,"text":"```lua\nlocal ray, reason = Open77.camera.screenRay(0.5, 0.5, 25)\n-- Identical global alias:\nlocal sameRay = Screen2DPointTo3DSpace(0.5, 0.5, 25)\n```\n\nReturns `{ origin={x,y,z}, direction={x,y,z}, position={x,y,z}, maxDistance }`.\nThe origin is the active camera, direction is a unit world-space vector, and\nposition is the point `origin + direction * maxDistance`. This is **not a hit**:\nunprojecting a pixel defines a ray, not a unique surface position.\n\nDistance defaults to 100 metres and must be finite, within `[0.1,1000]`.\nBoth screen coordinates must be finite numbers within `[0,1]`. Strings are not\ncoerced. The native projection is checked against the unprojected ray; inconsistent\ncamera state returns `camera_projection_mismatch` rather than a guessed direction.","natives":["Open77.camera.screenRay"],"permissions":[],"events":[]},{"anchor":"screenraycast","heading":"screenRaycast","level":2,"text":"```lua\nlocal hit, reason = Open77.camera.screenRaycast(0.5, 0.5, 12, {\n static = true, dynamic = true, self = true -- Optional visible F7 body picking.\n})\nif not hit then print(reason); return end\nif not hit.hit then return end\nprint(hit.position.x, hit.distance, hit.material)\nif hit.target then\n print(hit.target.kind, hit.target.engineEntity, hit.target.vehicleId)\nend\n```\n\nUses the same arguments as `screenRay`, plus optional boolean collision filters.\nBoth filters default to true; at least one must remain enabled. It selects the\nnearest blocking physical hit, not the nearest object to the cursor. Static,\ndynamic, terrain, collider, destructible, vehicle and NPC-hitbox categories are\nincluded; the two options restrict actor mobility, not the target's Lua kind.\nQuery-only player sensors are excluded.\n\n`self` is a third strict boolean, **false by default**. With `self=true` and\n`dynamic=true`, the active visible F7 body is also tested using capsules around\nits native animated body slots. This presentation object intentionally has no\nphysical colliders. A closer physical hit wins; hidden/suspended proxies are\nexcluded. No colliders, actor movement, animation or gameplay state are changed.\nThis is a local UI picking approximation, not mesh-perfect selection, combat\ncollision or a hidden first-person self button. It requires the client with\ncontext-menu 1.1.0 support, beyond the first screen-query implementation.\n\nAll ray fields remain present. A miss has `hit=false` and `position` at the\nmaximum distance. A hit additionally returns:\n\n| Field | Meaning |\n|---|---|\n| `position`, `normal` | World-space impact and normal. |\n| `distance` | Camera-to-impact distance in metres. |\n| `material` | Engine material name, possibly empty. |\n| `entityLookupAvailable` | Whether the native physics-owner resolver is available. |\n| `target` | Entity descriptor, or nil for geometry without an entity owner. |\n| `hitSource` | `self_presentation` for an opt-in F7 visual hit; absent for ordinary physics. |\n\nAn F7 visual hit has `hit=true`, distance, position and target, but **no physical\nnormal or material**. Its `target.kind` is `player`, `isLocalPlayer=true`, and\n`playerId` is the current local network player. Use the canonical player ID for\nresource requests, not the F7 proxy's ephemeral engine handle.\n\nThe descriptor contains `engineEntity` as a **decimal string**, `className`,\n`kind` and `networked`. Supported classifications include player, vehicle, NPC,\nprop, door, device, item and object. Recognized network entities additionally\ncarry the corresponding `playerId`, `vehicleId`, `npcId` or `propId`.\n`propId` is a **decimal string**, matching the props API; the other canonical IDs\nare Lua integers. An NPC's `record`, when available, is its actual `Character.*`\nrecord, not the virtual template label.\n`entity`, when present, is an Open77 **local** registry handle encoded as a string.\nNames/records are optional; `isLocalPlayer=true` identifies the local body,\nincluding its F7 presentation proxy when hit.\n\nDo not interchange these identifiers. Engine and registry IDs refer to this\nclient's streamed incarnation, not a portable network target. Keep their strings\nintact through JavaScript; converting a 64-bit ID to `Number` can corrupt it.\n\nStatic world geometry can legitimately have no entity. Visual effects and props\nwithout physics collision (apart from the explicit F7 option) are not promised as pickable targets. There is no\nnearest-neighbour fallback, no through-wall selection and no authority granted\nby a successful client raycast.","natives":["Open77.camera.screenRaycast"],"permissions":[],"events":[]},{"anchor":"cursor-and-webui-focus","heading":"Cursor and WebUI focus","level":2,"text":"```lua\nlocal cursor, reason = Open77.input.cursor()\n-- { x, y, pixelX, pixelY, width, height, inBounds, captured }\nif cursor and cursor.inBounds then\n local hit = Open77.camera.screenRaycast(cursor.x, cursor.y, 12)\nend\n\npage:show()\npage:setFocus(true, true) -- Keyboard + cursor; game input blocked by default.\nlocal ownsFocus = page:hasFocus()\npage:setFocus(false, false)\npage:hide()\n```\n\n`cursor()` samples the actual foreground game window, not an arbitrary desktop\nwindow. It returns `nil, game_not_focused` after Alt+Tab; coordinates can be outside\n`[0,1]` if the pointer is outside the client area (`inBounds=false`). It does not\nshow or capture the cursor itself. Use a resource-owned WebUI surface for that.\n`captured` is true if a WebUI already owns keyboard **or cursor** input; check\nit before opening a new modal so cursor-only interfaces are not interrupted.\nWebUI has a virtual pointer: for an exact click on its fullscreen page, pass\n`event.clientX / innerWidth` and `event.clientY / innerHeight` from that click,\ninstead of sampling the OS cursor after the message arrives.\n\n`page:hasFocus()` reads current keyboard/cursor ownership and presentation state.\nIt does not steal focus, and is false when another surface owns input. Releasing\nfocus on your page must not release another page's focus. Surface teardown\nautomatically releases its capture.","natives":["Open77.input.cursor","Open77.camera.screenRaycast"],"permissions":[],"events":[]},{"anchor":"disable-the-native-weapon-wheel","heading":"Disable the native weapon wheel","level":2,"text":"```lua\n-- Client, permission: input.actions. Reserve before ALT is pressed.\nlocal ok, reason = Open77.input.setNativeActionBlocked(\"WeaponWheel\", true)\n-- Release only this resource's claim when your feature is disabled:\nOpen77.input.setNativeActionBlocked(\"WeaponWheel\", false)\n```\n\nThe native weapon-wheel state cannot enter while blocked. An already-open wheel\ntakes its normal cancellation/cleanup path, without selecting an item. Claims\nfrom several resources compose: releasing yours never overrides another owner.\nThe host releases claims on stop, reload and disconnect. `OpenMapMenu` is also\nsupported; unknown names return `false, \"unsupported_native_action\"`.\nThis blocks the native action, including rebound keys and gamepad, not a physical\nALT key or every weapon command. `SwitchItem` and other radial menus are unchanged.\nCombine it with a focused WebUI to consume gameplay input during your own menu.","natives":["Open77.input.setNativeActionBlocked"],"permissions":[],"events":[]},{"anchor":"cost-and-trust","heading":"Cost and trust","level":2,"text":"Trace on a click or at a bounded rate while interacting. Do not scan the entire\nworld each frame. These are synchronous game-thread queries, not a server physics\nengine. Server effects must validate authenticated source, canonical IDs, bucket,\ndistance, life state, cooldown and gameplay permissions independently.\n\nCommon errors: `permission_denied:world.query`, `permission_denied:input.actions`,\n`screen_query_unavailable_on_this_host`, `invalid_screen_coordinates_or_distance`,\n`invalid_collision_options`, `camera_unavailable`, `camera_projection_mismatch`,\n`game_not_focused`, `cursor_unavailable`, `viewport_unavailable`.","natives":[],"permissions":[],"events":[]}]},{"slug":"screen-transitions","file":"screen-transitions.md","title":"Native screen transitions","summary":"Native screen transitions require client **2.31.13+op77.56** or later. They are client-local and do not require a matching server feature.","sections":[{"anchor":"native-screen-transitions","heading":"Native screen transitions","level":1,"text":"Native screen transitions require client **2.31.13+op77.56** or later. They are client-local and do not require a matching server feature.\n\n`Open77.screen` exposes Cyberpunk's **native quest fade manager** to client Lua.\nIt does not create a WebUI overlay, load a quest, apply a Relic status effect or\nchange network routing. This is a new client capability; on older clients first\ncheck `Open77.screen` and `Open77.screen.catalog` exist, then inspect `catalog()`.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-and-quick-start","heading":"Manifest and quick start","level":2,"text":"```lua\nresource \"transitions\"\nversion \"1.0.0\"\nclient_script \"client.lua\"\npermissions { \"screen.effects\" }\n```\n\n```lua\n-- Fade to black, remain covered for 750 ms, then return to the game.\nlocal id, reason = Open77.screen.transition(\"fade\", {\n durationMs = 500,\n holdMs = 750,\n fadeInMs = 500,\n})\nif not id then print(\"Transition unavailable: \" .. reason) end\n```\n\nAll durations are **integer milliseconds**, not seconds. Unknown options, NaN,\ninfinity, numeric strings and values outside the documented ranges are rejected.","natives":["Open77.screen.transition"],"permissions":["screen.effects"],"events":[]},{"anchor":"api","heading":"API","level":2,"text":"All eight functions require `screen.effects`. They are client-local. A server\ncan drive a fade directly with [`Open77.players.fade`](#fading-a-player-from-the-server),\nor ask a client resource to run a richer transition over an ordinary targeted\nnetwork event.\n\n| Function | Result | Purpose |\n| --- | --- | --- |\n| `Open77.screen.fadeOut(options?)` | `id, promise` or `nil, reason` | Apply a native black/color fade and hold until this resource returns the image or the safety deadline expires. The promise settles when the screen is covered. |\n| `Open77.screen.fadeIn(id, options?)` | `true, promise` or `false, reason` | Return the image for this resource's active transition. Options: `durationMs` only. Can reverse an outgoing fade. The promise settles when the image is back. |\n| `Open77.screen.transition(\"fade\", options?)` | `id, promise` or `nil, reason` | Run the complete fade/hold/return sequence. The promise settles when it has finished. |\n| `Open77.screen.state(id)` | table or `nil, reason` | **Owner-scoped.** Read this resource generation's active or recent completed transition. |\n| `Open77.screen.isFaded()` | `true`/`false` or `nil, reason` | **Unowned.** Is the screen covered right now, whoever covered it. |\n| `Open77.screen.nativeState()` | table or `nil, reason` | **Unowned.** The engine's own fade state: `faded`, `fading`, `out`, `busy`, `ready`, `remainingMs`. |\n| `Open77.screen.cancel(id)` | `true` or `false, reason` | Restore the image immediately, including during the return fade. |\n| `Open77.screen.catalog()` | array or `nil, reason` | Read supported presets and whether their native backend is available on this executable. |\n\nIDs are opaque strings. They are not player IDs or native pointers. A different\nresource, another host or a reloaded generation cannot act on an old ID.","natives":[],"permissions":[],"events":[]},{"anchor":"owned-and-unowned-reads","heading":"Owned and unowned reads","level":3,"text":"`state(id)` is about **your transition**; `isFaded()` and `nativeState()` are\nabout **the screen**. The second pair is what a spawn or teleport flow needs:\nthe screen may already be black because another resource faded it, because the\nserver did, or because a quest or loading fade took it. Stacking a second fade\non top of that is how a player ends up watching two fades in a row.\n\n```lua\nif not Open77.screen.isFaded() then\n Open77.screen.fadeOut({durationMs = 400})\nend\n```\n\n`nativeState().fading` separates *on its way to black* from *already black*, so\na caller that must not interrupt an incoming fade can wait instead of racing it.\nBoth return `nil, reason` rather than a confident `false` when the guarded\nbackend is unavailable: a wrong `false` here is exactly what stacks two fades.","natives":["Open77.screen.isFaded","Open77.screen.fadeOut"],"permissions":[],"events":[]},{"anchor":"options","heading":"Options","level":3,"text":"| Key | Default | Range / availability |\n| --- | --- | --- |\n| `durationMs` | 500 | 0–10000; outgoing fade, or the return duration passed to `fadeIn`. Zero is an instant cut. |\n| `holdMs` | 250 | 0–30000; `transition` only. Starts after the native outgoing fade completes. |\n| `fadeInMs` | 500 | 0–10000; `transition` only. |\n| `timeoutMs` | At least 15000 | 1000–60000; the entire transition's real-time safety deadline. Must exceed planned outgoing/hold/return time by at least 500 ms. If omitted it grows to fit the sequence plus 2000 ms. |\n| `color` | `{r=0,g=0,b=0,a=255}` | Integer channels 0–255; alpha must be 255. Available on `fadeOut` and `transition`. Omitted channels keep their defaults. |\n\nFor example, `color={r=255,g=255,b=255}` selects the native white-tinted fade.\nThe engine controls its rendering: non-black colors can retain a blurred/tinted\nscene rather than produce a flat, opaque RGB rectangle. Use black to conceal the\n3D world, not a colored fade as an occlusion guarantee. Use flashes sparingly;\nthe API deliberately does not implement a repeating strobe preset.","natives":[],"permissions":[],"events":[]},{"anchor":"awaiting-a-fade","heading":"Awaiting a fade","level":2,"text":"`fadeOut`, `fadeIn` and `transition` return an ordinary\n[`Open77.Promise`](resource-exports.md) as a **second** value, so a caller can\nfade, act, and fade back without hand-rolling a timer. The first return value is\nunchanged: `local id = Open77.screen.fadeOut(...)` still works exactly as before.\n\n```lua\nCreateThread(function()\n local id, covered = Open77.screen.fadeOut({durationMs = 400, timeoutMs = 8000})\n if not id then return print(covered) end -- failure is still nil, reason\n local state, reason = covered:await()\n if not state then return print(\"fade ended early: \" .. reason) end\n -- The screen is black here, on the engine's own timing.\n doSomethingInvisible()\n Open77.screen.fadeIn(id, {durationMs = 400})\nend)\n```\n\nThe promise resolves with the same state table the event carries. It **rejects**\n-- `nil, reason` from `:await()` -- if the transition ends any other way:\ncancelled, timed out, a vanilla fade taking over, an error in this resource's\nLua, or the resource stopping. That is deliberate. A promise that could stay\npending forever would leave the coroutine that was going to call `fadeIn`\nparked, and a fade nobody returns is indistinguishable from a crash to the\nplayer looking at it.\n\n`cancel` returns no promise: it is already terminal when it returns.","natives":["CreateThread","server:CreateThread","Open77.screen.fadeOut","Open77.screen.fadeIn"],"permissions":[],"events":[]},{"anchor":"fading-a-player-from-the-server","heading":"Fading a player from the server","level":2,"text":"`Open77.players.fade(playerId, out, durationMs, options?)` covers or uncovers one\nplayer's screen from server Lua. Permission: **`players.screen`**.\n\n```lua\n-- Bracket a move. Requires players.screen (and whatever moves the player).\nOpen77.players.fade(source, true, 400)\nOpen77.players.fadeOut(source, 400, {timeoutMs = 8000}) -- the same call, named\nOpen77.players.fadeIn(source, 400)\n```\n\nThe relay frame is consumed by the client host itself, so **the target does not\nneed to run a client resource of its own** -- which is the point: a teleport or\na spawn should not require every server to ship a companion script. It drives\nthe same native quest fade `Open77.screen` uses, through the same arbitration:\nif a client resource already owns a fade, the server's request is refused rather\nthan stealing it, and the resource's own `fadeIn`/`cancel` continue to work.\n\n`options.timeoutMs` (1000-60000, default 15000) is a ceiling the **client**\nenforces on real time, not a promise the server keeps. A server that fades a\nplayer out and then drops the route, crashes or simply forgets still gives the\nimage back. There is deliberately no \"fade out forever\" spelling of this call.\n\nThe frame is reserved transport: a resource holding only `network.events` cannot\nsend it by name, and a peer cannot forge one for another player.","natives":["server:Open77.players.fade","server:Open77.players.fadeOut","server:Open77.players.fadeIn"],"permissions":[],"events":[]},{"anchor":"holding-black-during-a-server-operation","heading":"Holding black during a server operation","level":2,"text":"Do not assume that receiving an ID means the screen is black. Wait for the\nowner-scoped local event, request the server operation, then return the image\nafter the server acknowledges completion. Add `network.events` to the manifest\nwhen using network events.\n\n```lua\nlocal transitionId\n\nRegisterNetEvent(\"demo:prepareTransition\", function()\n local reason\n transitionId, reason = Open77.screen.fadeOut({\n durationMs = 600,\n timeoutMs = 8000,\n })\n if not transitionId then print(reason) end\nend)\n\nAddEventHandler(\"open77:screen:black\", function(state)\n if state.id ~= transitionId then return end\n TriggerServerEvent(\"demo:screenCovered\")\nend)\n\nRegisterNetEvent(\"demo:operationComplete\", function()\n if transitionId then\n Open77.screen.fadeIn(transitionId, {durationMs = 600})\n end\nend)\n\nlocal function ended(state)\n if state.id == transitionId then transitionId = nil end\nend\nAddEventHandler(\"open77:screen:finished\", ended)\nAddEventHandler(\"open77:screen:cancelled\", ended)\nAddEventHandler(\"open77:screen:failed\", ended)\n```\n\nThe server must validate the sender, the pending operation, destination and\npermissions itself. A client's `screenCovered` message is **not authorization**\nto teleport or change bucket. If the server never responds, the client safety\ndeadline restores the image. `fadeIn` does not extend that deadline.","natives":["RegisterNetEvent","server:RegisterNetEvent","Open77.screen.fadeOut","AddEventHandler","server:AddEventHandler","TriggerServerEvent","Open77.screen.fadeIn"],"permissions":[],"events":["open77:screen:black","open77:screen:finished","open77:screen:cancelled","open77:screen:failed"]},{"anchor":"events-and-state","heading":"Events and state","level":2,"text":"Subscribe with `AddEventHandler`, not `RegisterNetEvent`:\n\n| Event | Meaning |\n| --- | --- |\n| `open77:screen:covered` | Native outgoing fade has completed, including non-black colors. |\n| `open77:screen:black` | Same boundary, only when all RGB channels are zero. |\n| `open77:screen:finished` | Native return fade has completed. |\n| `open77:screen:cancelled` | Requested cancellation, safety timeout, world loss or a vanilla transition taking over. |\n| `open77:screen:failed` | A native operation/restoration failed. |\n\nThe event argument and `state(id)` share this shape:\n\n```lua\n{\n id = \"12\",\n generation = 1,\n phase = \"covered\", -- fading_out, covered, fading_in, finished, cancelled, failed\n reason = \"\", -- completed, requested, timeout, native_interrupted, ...\n backend = \"native_quest_fade\",\n timing = \"native_state\",\n automatic = false,\n elapsedMs = 650,\n remainingMs = 0, -- remaining native fade duration, not remaining hold time\n timeoutMs = 8000,\n black = true,\n color = {r=0,g=0,b=0,a=255},\n}\n```\n\nCompletion comes from the native manager's direction and remaining duration,\nnot an estimated Lua `Wait`. It is not a GPU presentation fence or proof that\nworld streaming has completed. Hold and safety deadlines use monotonic real\ntime; pause/time dilation cannot extend the safety deadline while the game\ncontinues ticking. No in-process API can recover while the entire game thread\nis hung or suspended.\n\nOnly the owner generation receives these events, and only the owner generation\ncan read `state(id)`. That is *event* and *transition* scoping, not secrecy\nabout the screen: `isFaded()` and `nativeState()` are unowned and report a fade\nany resource, the server or the engine itself put up. Recent terminal states are\nbounded to 64 entries globally; stopped resources lose their history. Errors in\na resource's Lua startup or coroutine, resource stop/reload, session teardown\nand the safety deadline release its own fade. An intentional `pcall`-caught\nLua error is handled by your script; the safety deadline still applies.","natives":[],"permissions":[],"events":["open77:screen:covered","open77:screen:black","open77:screen:finished","open77:screen:cancelled","open77:screen:failed"]},{"anchor":"arbitration-and-limits","heading":"Arbitration and limits","level":2,"text":"- Only one Open77 transition can be active: another request returns\n `screen_busy`. That single slot is shared with the server relay, which is why\n `isFaded()` exists -- check it before adding a fade of your own.\n- A fade/loading operation already owned by the game returns `native_screen_busy`.\n- A new native game request takes priority. Open77 drops ownership and will not\n later clear that vanilla fade during cleanup.\n- The backend is guarded for the verified Cyberpunk 2.31 executable. Unsupported\n native layouts fail closed with `screen_unavailable`; no raw pointer is exposed.\n- These are the quest manager's native transitions, including its own native UI\n and input supervision. The API does not request a teleport, damage, audio\n effect, camera change or network operation. The native\n minimap disappears during black, but Open77's CEF health HUD, watermark and\n command hints remain visible. Hide your own WebUI explicitly if your transition\n needs that; this API does not take ownership of other resources' interfaces.\n- The catalogue currently contains **`fade` only**. `glitch` returns\n `unsupported_screen_preset`: the loading-screen glitch has not yet been\n isolated safely. Existing GlitchBegone/Relic protections remain unchanged.\n\nCommon failures include `permission_denied:screen.effects`,\n`screen_unavailable_on_this_host`, `screen_unavailable`, `screen_busy`,\n`native_screen_busy`, `invalid_screen_option:`, `invalid_screen_color`,\n`screen_timeout_too_short`, `not_owner`, `transition_not_active`,\n`transition_not_found` and `already_fading_in`.\n\nA rejected promise carries the transition's own reason -- `requested`,\n`timeout`, `native_interrupted`, `world_unavailable`, `restore_failed` -- or\n`resource_error` / `resource_stopped` when the release came from this resource\nfailing or stopping. One reason is ours rather than the engine's:\n`never_covered`, when a `fadeOut` promise's transition returned the image\nwithout ever reaching black (the engine calls that `completed`, which would read\nas success).\n\nThe server relay adds `permission_denied:players.screen` and\n`reserved_screen_event`.\n\nSee also [visual/audio effects](effects.md), [native HUD visibility](hud-visibility.md)\nand [native implementation research](../docs/research/screen-transitions-and-fades.md).","natives":[],"permissions":[],"events":[]},{"anchor":"loading-screen-integration","heading":"Loading-screen integration","level":2,"text":"For the screen opened by a long-distance teleport or another native load, use\n[custom loading screens](loading-screens.md): state queries, start/progress/end\nevents, a WebUI cover and temporary HUD hiding. Quest fades and loading cycles\nare separate signals.","natives":[],"permissions":[],"events":[]}]},{"slug":"screenshots","file":"screenshots.md","title":"Screenshots and mugshots","summary":"Capture the rendered game frame, upload an image or create a player mugshot. Client and server entry points have separate permissions:","sections":[{"anchor":"screenshots-and-mugshots","heading":"Screenshots and mugshots","level":1,"text":"Capture the rendered game frame, upload an image or create a player mugshot. Client and server entry points have separate permissions:\n\n| Function | Runtime | Permission | What it does |\n|---|---|---|---|\n| `Open77.screen.capture(options)` | client | `screen.capture` | The frame (or a region of it) as JPEG or PNG bytes, through a promise |\n| `Open77.screen.surface()` | client | `screen.capture` | The back buffer's size and format, so a region can be sized first |\n| `Open77.screen.upload(capture, url, options)` | client | `screen.upload` | POSTs a capture to a URL, multipart or as a base64 JSON envelope |\n| `Open77.screen.mugshot(target, options)` | client | `screen.capture` + `camera.script` + `world.transform` | A head-and-shoulders picture of a body through a scripted camera, view restored |\n| `Open77.players.requestScreenshot(playerId, options)` | server | `players.screenshot` | Asks one client for a picture, delivered by that client to a URL; resolves with the receipt |\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"what-is-captured-and-where-the-pixels-come-from","heading":"What is captured, and where the pixels come from","level":2,"text":"Open77's developer-console overlay already hooks the game's `IDXGISwapChain::Present`\nand holds the swap chain, the D3D12 device, the direct queue and a fence. A capture is one\nmore command list on that same queue: the back buffer about to be presented is transitioned\n`PRESENT -> COPY_SOURCE`, one `CopyTextureRegion` copies the requested region into a\n`READBACK` heap buffer, the buffer is transitioned back, the list is executed and a fence is\nsignalled. **The render thread never waits.** A worker thread waits on the fence, maps the\nbuffer, converts the rows to 24-bit BGR and encodes them with Windows Imaging Component\n(JPEG with the quality option, or PNG). Every result carries what that cost: `copyMicros` is\nthe render-thread cost of recording and submitting the copy, `encodeMicros` the worker's\nreadback and encode, `totalMicros` request to promise.\n\nTwo copy points exist, and `includeOverlay` picks one:\n\n- **`false` (default): the game alone** -- the copy is submitted BEFORE the overlay's own\n command list, so it holds what the engine rendered: world, HUD, vanilla menus, photo mode.\n Open77's nameplates, WebUI pages, world markers and the console are not in it.\n- **`true`: the game plus Open77's UI** -- the copy is submitted AFTER the overlay pass. On a\n frame where the overlay drew nothing the two are the same picture.\n\n**Never the desktop, never another window.** The bytes come from the swap chain the game\npresents, inside the game's own Present. There is no `PrintWindow`, no screen grab, no window\nenumeration; an overlay that belongs to another process cannot be in the picture.","natives":[],"permissions":[],"events":[]},{"anchor":"the-surface-format-is-read-not-assumed","heading":"The surface format is read, not assumed","level":3,"text":"The swap chain's format is read from the buffer's description on every Present.\n`Open77.screen.surface()` reports it (`format`, e.g. `R8G8B8A8_UNORM`) together with the\nconversion family (`pixel`) and whether captures are possible (`supported`):\n\n| DXGI format | `pixel` | Conversion |\n|---|---|---|\n| `R8G8B8A8_*` | `rgba8` | exact, swizzled to BGR |\n| `B8G8R8A8_*`, `B8G8R8X8_*` | `bgra8` | exact |\n| `R10G10B10A2_*` | `rgb10a2` | top eight bits of each channel. Under an SDR output this is exact; under an HDR10 output the values are PQ-encoded and the picture comes out dim and desaturated -- it is still the frame, not a conversion error |\n| `R16G16B16A16_FLOAT` | `rgba16f` | linear scRGB clamped to 0..1 and sRGB-encoded: midtones right, highlights clipped |\n| anything else | `unsupported` | every capture refuses with `format_unsupported` |\n\nThe active format depends on the game's HDR setting. The client logs `Screenshot surface: WxH FORMAT (pixel)` once per format at the first Present.","natives":[],"permissions":[],"events":[]},{"anchor":"ceilings-and-the-in-flight-rule","heading":"Ceilings and the in-flight rule","level":3,"text":"- **One capture in flight per resource.** A second `capture` before the first settled is\n `capture_in_flight`, never a queue; await each one. Eight captures across the whole client\n (`capture_limit`): each pins a readback buffer of its region's size until the encoder is\n done with it.\n- A region past an edge is `region_invalid`, never clamped -- a caller that asked for 64x64\n and silently got 40x64 would have to notice on its own.\n- The encoded picture is capped at `maxBytes` (8 MiB by default, 16 MiB at most):\n `capture_too_large`.\n- Nothing can be captured while the overlay is not initialised (no swap chain hooked, or the\n window is mid-resize): `device_unavailable`. `surface().ready` says so beforehand.\n- `settleFrames` (0..30) skips that many Presents before the copy. A cut camera needs the\n engine to have rendered a frame *through* it before the pixels mean anything; `mugshot`\n uses 3.\n- `scale` (0.05..1, default 1) shrinks the encoded picture to that fraction of the region,\n through the encoder's own Fant scaler after the conversion -- the one place a downscale is\n free. `width`/`height` in the result are the scaled size; `regionWidth`/`regionHeight`\n what was copied. A 540 px mugshot at `scale = 0.25` leaves as a 135 px, ~4 KiB thumbnail.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"open77screencaptureoptions---promise-nil-reason","heading":"`Open77.screen.capture(options) -> Promise | nil, reason`","level":2,"text":"```lua\nCreateThread(function()\n local shot, reason = Open77.screen.capture({\n format = \"jpeg\", -- or \"png\"\n quality = 85, -- 1..100, jpeg only\n region = { x = 0, y = 0, w = 640, h = 360 }, -- pixels; omit for the whole frame\n includeOverlay = false, -- true: nameplates, pages and the console too\n settleFrames = 0, -- Presents to skip first\n maxBytes = 4 * 1024 * 1024,\n scale = 1, -- 0.05..1: shrink the output through the encoder's scaler\n })\n if not shot then return print(\"refused: \" .. tostring(reason)) end\n local picture, why = shot:await()\n if not picture then return print(\"failed: \" .. tostring(why)) end\n print((\"%dx%d %s, %d bytes, copy %d us, encode %d us, source %s\"):format(\n picture.width, picture.height, picture.format, picture.sizeBytes,\n picture.copyMicros, picture.encodeMicros, picture.sourceFormat))\nend)\n```\n\nThe promise is the same `Open77.Promise` every other awaitable hands out (`:await()`,\n`:next()`, `:status()`); it resolves with:\n\n| Field | Meaning |\n|---|---|\n| `bytes` | the encoded picture, a binary string |\n| `sizeBytes` | `#bytes` |\n| `width`, `height` | of the picture (the region, or the whole frame, times `scale`) |\n| `regionWidth`, `regionHeight` | of the region copied, before scaling |\n| `format` | `jpeg` or `png` |\n| `screenWidth`, `screenHeight` | the back buffer's size |\n| `sourceFormat` | the DXGI format the frame was read in |\n| `includeOverlay` | which copy point was used |\n| `copyMicros`, `encodeMicros`, `totalMicros` | the measured costs |\n\nA resource that stops, reloads or errors has its pending captures cancelled and every promise\nrejected with `resource_stopped` / `resource_error`; nothing is left waiting on a picture\nthat will never come.\n\n**Refusals**, all by name: `permission_denied:screen.capture`,\n`screenshot_unavailable_on_this_host`, `device_unavailable`, `format_unsupported`,\n`region_invalid`, `invalid_quality`, `invalid_settle_frames`, `invalid_max_bytes`, `invalid_scale`,\n`invalid_capture_option:` (an unknown key -- a typo is visible the first time it runs),\n`capture_in_flight`, `capture_limit`. **Rejections**: `capture_too_large`,\n`encode_failed:`, `gpu_timeout`, `cancelled`, `resource_stopped`.\n\n---","natives":["CreateThread","server:CreateThread","Open77.screen.capture"],"permissions":[],"events":[]},{"anchor":"open77screenuploadcapture-bytes-url-options---promise-nil-reason","heading":"`Open77.screen.upload(capture | bytes, url, options) -> Promise | nil, reason`","level":2,"text":"The bytes have to go somewhere, and the client is the party that has them. `upload` POSTs a\ncapture result (its `bytes`, `format`, `width` and `height` travel along) or a bare string\nof image bytes to an absolute `http://` or `https://` URL over WinHTTP, on a thread of its\nown, and resolves with the receipt.\n\n```lua\nlocal upload = Open77.screen.upload(picture, \"https://rp.example.net/mdt/evidence\", {\n encoding = \"multipart\", -- default; or \"json\"\n field = \"files[]\", -- multipart file field (screenshot-basic's default)\n filename = \"evidence.jpg\",\n fields = { reportId = \"4711\" }, -- extra text parts / JSON fields (up to 16)\n headers = { Authorization = \"Bearer token\" }, -- up to 16, no CR/LF\n timeoutMs = 15000, -- 1000..60000\n})\nlocal receipt, why = upload:await()\n-- receipt = { status = 200, body = \"...\", bodyTruncated = false, sentBytes = 20481, elapsedMs = 130 }\n```\n\nTwo encodings:\n\n- **`multipart`** -- `multipart/form-data` with every `fields` entry as a text part and the\n picture as one file part under `field` with its `image/jpeg` / `image/png` content type.\n This is what image hosts, Discord webhooks and `screenshot-basic` targets expect.\n- **`json`** -- `application/json`: `{ \"format\", \"width\", \"height\", \"sizeBytes\", ...fields,\n \"data\": \"\" }`. For a target that cannot take a binary body -- the server's own\n [`Open77.http.listen`](server-api.md#serving-http) route decodes UTF-8 text and caps bodies\n at 16 KiB, so a thumbnail travels there this way. A JPEG's first four base64 characters are\n always `/9j/`, a PNG's first five `iVBOR`, which is a cheap sanity check on the receiving\n side.\n\nRules: the URL carries no credentials (`user:pass@host` is `invalid_url`); redirects are never\nfollowed, so the host named is the only host reached; `Content-Type`, `Content-Length` and\n`Host` are the client's and cannot be set; the response body is kept up to 64 KiB\n(`bodyTruncated` says when it was cut). Two uploads in flight per resource\n(`upload_in_flight`), eight per client (`upload_limit`), 16 MiB per upload (`bytes_too_large`).\n\n**Refusals**: `permission_denied:screen.upload`, `screenshot_unavailable_on_this_host`,\n`bytes_empty`, `bytes_too_large`, `invalid_url`, `invalid_upload_option:`,\n`invalid_header`, `invalid_field`, `too_many_headers`, `too_many_fields`, `upload_in_flight`,\n`upload_limit`. **Rejections**: `request_failed::` (`connect`, `send`,\n`status`, `read` -- `12029` is \"cannot connect\", `12007` \"name not resolved\"), `timeout`,\n`cancelled`.\n\nThere is no host allow-list on the client, and that is deliberate: a client resource is\nserver-authored code, and the server that wrote it already chose where its pictures go. The\npermission string in the manifest is the consent an operator sees.\n\n---","natives":["Open77.screen.upload"],"permissions":[],"events":[]},{"anchor":"open77playersrequestscreenshotplayerid-options---promise-nil-reason-server","heading":"`Open77.players.requestScreenshot(playerId, options) -> Promise | nil, reason` (server)","level":2,"text":"The server half. **The picture never travels over the game transport**: a net event carries\n48 KiB of JSON and there is no client-to-server latent path (`TriggerLatentClientEvent` runs\none way), so a JPEG cannot ride the callback envelope. The request does: it goes to the\nplayer's client *host* under the reserved name `open77:screen:capture` -- answered without any\nclient resource or client permission, like `Open77.world.groundZ` -- and the client captures,\nPOSTs the bytes to `options.url` itself, and answers with the upload's receipt. The promise\nresolves with that receipt.\n\n```lua\n-- open77.lua: permissions { \"players.screenshot\" }\nRegisterCommand(\"report\", function(source, args)\n local accused = tonumber(args[1])\n local shot, reason = Open77.players.requestScreenshot(accused, {\n url = \"https://mdt.example.net/api/evidence\", -- REQUIRED: where the client POSTs\n quality = 80,\n region = { x = 0, y = 0, w = 1280, h = 720 },\n fields = { reporter = tostring(source), accused = tostring(accused) },\n headers = { Authorization = \"Bearer \" .. GetConvar(\"mdt_token\", \"\") },\n timeoutMs = 15000, -- the client's HTTP timeout\n timeout = 20000, -- this promise's own deadline\n })\n if not shot then return print(\"refused: \" .. tostring(reason)) end\n local receipt, why = shot:await()\n if not receipt then return print(\"failed: \" .. tostring(why)) end\n print((\"evidence: HTTP %d, %dx%d, %d bytes, %d ms\"):format(\n receipt.status, receipt.width, receipt.height, receipt.sizeBytes, receipt.elapsedMs))\nend, true)\n```\n\n`options` is the union of the client's `capture` and `upload` options plus `url`; the client\nvalidates every one of them again and rejects with the same names, so a server never has to\nguess what a client accepts. The receipt is\n`{ status, body, bodyTruncated, sentBytes, elapsedMs, width, height, sizeBytes, format }`.\n`await` runs from a scheduler coroutine (a command, an event handler, `CreateThread`); at\nfile scope it answers `await_requires_scheduler_coroutine` rather than blocking the server.\n\nWhere the picture goes is the operator's decision:\n\n- **An image host or a webhook** -- the `screenshot-basic` model. `multipart`, the target's\n own field name, an `Authorization` header.\n- **This server's own `Open77.http.listen` route** -- self-contained, no third party, but\n the listener decodes text and caps bodies at 16 KiB, so it takes **thumbnails only, as\n `encoding = \"json\"`** (a 96x96 JPEG at quality 80 is ~4 KiB, ~6 KiB as base64; a full\n mugshot at `scale = 0.25` lands in the same budget). A route that stores the `data` field\n decoded is an MDT mugshot backend with no external service.\n- One capture in flight *per asking server resource* on each client, kept apart from that\n client's own resources' captures: a server that asks twice gets `capture_in_flight`, a\n client resource capturing at the same moment does not.\n\n**Refusals before the wire**: `permission_denied:players.screenshot`, `invalid_player_id`,\n`player_not_found`, `invalid_options`, `invalid_url`, `invalid_capture_option:`,\n`invalid_quality`, `region_invalid`, `format_unsupported`, `invalid_scale`, `invalid_upload_option:`,\n`invalid_header`, `invalid_field`, `invalid_callback_timeout`, `network_unavailable`,\n`resource_stopping`, `callback_request_limit`. **Rejections**, the client's own names:\n`capture_in_flight`, `device_unavailable`, `format_unsupported`, `capture_too_large`,\n`encode_failed:`, `request_failed::`, `timeout`, `capture_timeout`,\n`callback_timeout`, `callback_resource_unavailable`.\n\n---","natives":["RegisterCommand","server:RegisterCommand","server:Open77.players.requestScreenshot","server:GetConvar"],"permissions":["players.screenshot"],"events":["open77:screen:capture"]},{"anchor":"open77screenmugshottarget-options---promise-nil-reason","heading":"`Open77.screen.mugshot(target, options) -> Promise | nil, reason`","level":2,"text":"FiveM's `RegisterPedheadshot`, composed from the [scripted cameras](cameras.md) and\n`capture`: a camera is created `distance` metres in front of the target's head, looking at\nit with `fov`; it takes the view with a cut (`blendMs = 0`); after `settleFrames` Presents one\nframe is captured through it; then the camera is deactivated and destroyed **whatever\nhappened** -- a failed capture still hands the view back. The centre of the frame is cropped\nto a square (`crop` of the shorter screen edge, default 0.5) unless a `region` is given.\n\n```lua\n-- open77.lua: permissions { \"screen.capture\", \"camera.script\", \"world.transform\" }\nCreateThread(function()\n local shot, reason = Open77.screen.mugshot(0, { -- 0 = the local player; or an entity id\n fov = 35, distance = 0.7, headHeight = 1.62, -- the framing\n crop = 0.5, quality = 85, settleFrames = 3,\n scale = 0.25, -- a thumbnail: 135 px at 1080p\n })\n if not shot then return print(\"refused: \" .. tostring(reason)) end\n local picture, why = shot:await()\n if picture then\n print((\"mugshot %dx%d, %d bytes, camera at %.2f,%.2f,%.2f\"):format(\n picture.width, picture.height, picture.sizeBytes,\n picture.camera.position.x, picture.camera.position.y, picture.camera.position.z))\n end\nend)\n```\n\n`target` is what `Open77.character.frame` accepts: an Open77 entity id (`0` or `nil` = the\nlocal player) or `{ engineEntity = ... }`. The head is the body's own head slot\n(`Open77.character.bonePosition(target, \"head\")`) when it publishes one, else `headHeight`\nup the body's own up axis. The result is the capture table plus\n`camera = { position, lookAt, fov }`.\n\nThree refusals of its own: **`camera_busy`** while anybody holds the view or a blend is\nrunning -- a mugshot never steals a camera; **`target_not_streamed`** for a body this client\ncannot see; **`not_incarnated`** before the player has a body. Everything `capture` and\n`Open77.camera.create` refuse (`camera_out_of_range` past 250 m, for one) passes through.\n\nWhat the picture *shows* depends on the body the engine renders through that camera. Another\nplayer's puppet and an NPC are full bodies with heads. For the **local player** the\nperspective arbiter stands down while a scripted camera holds the view and leaves the engine's\nown body in place -- so the picture is whatever body the player's current perspective renders,\nwhich in first person may be a body without a rendered head. The acceptance probe measures\nthis on the local player and reports the picture; a mugshot for an MDT is of *someone else*\nanyway, which is the case that is clean.\n\n---","natives":["CreateThread","server:CreateThread","Open77.screen.mugshot"],"permissions":["screen.capture","camera.script","world.transform"],"events":[]},{"anchor":"cost","heading":"Cost","level":2,"text":"The render-thread cost is one command allocator, one command list, one readback buffer and\none fence created per capture, the barrier pair, the copy and a signal: `copyMicros` in\nevery result, expected in the hundreds of microseconds at most. The encode runs off the game\nthread; a full-frame JPEG at 85 takes tens of milliseconds, a full-frame PNG a few hundred.\nBetween captures nothing runs but one mutex acquisition per Present.\n\nUse `copyMicros` and `encodeMicros` from capture results to profile cost on the target hardware. Resolution, output format and image quality affect capture latency.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"reference","heading":"Reference","level":2,"text":"| Client function | Returns | Permission |\n|---|---|---|\n| `Open77.screen.capture(options)` | `Promise` | `screen.capture` |\n| `Open77.screen.surface()` | `{ ready, width, height, format, pixel, supported }` | `screen.capture` |\n| `Open77.screen.upload(capture, url, options)` | `Promise` | `screen.upload` |\n| `Open77.screen.mugshot(target, options)` | `Promise` | `screen.capture`, `camera.script`, `world.transform` |\n\n| Server function | Returns | Permission |\n|---|---|---|\n| `Open77.players.requestScreenshot(playerId, options)` | `Promise` | `players.screenshot` |\n\nRelated: [Scripted cameras](cameras.md), [Photo mode](photo-mode.md),\n[Network callbacks](callbacks.md), [Serving HTTP](server-api.md#serving-http).","natives":[],"permissions":[],"events":[]}]},{"slug":"server-acl","file":"server-acl.md","title":"Public identity, server commands, and ACL","summary":"Register server commands with `RegisterCommand` and control access through ACL rules. Commands entered in the in-game developer terminal (`²`) are forwarded over the authenticated session when they are not handled locally.","sections":[{"anchor":"public-identity-server-commands-and-acl","heading":"Public identity, server commands, and ACL","level":1,"text":"Register server commands with `RegisterCommand` and control access through ACL rules. Commands entered in the in-game developer terminal (`²`) are forwarded over the authenticated session when they are not handled locally.\n\nUse this guide to whitelist a player, restrict a command to specific people, and understand what\nidentity the server actually trusts.","natives":[],"permissions":[],"events":[]},{"anchor":"exporting-your-public-identity","heading":"Exporting your public identity","level":2,"text":"In the Open77 terminal:\n\n```text\nidentity.dump\n```\n\nThe reply gives the absolute path and the SHA-256 fingerprint:\n\n```text\nOK identity_public_dumped path=\".../red4ext/plugins/Open77/exports/identity-.json\" fingerprint=sha256:...\n```\n\nThe file holds the `userId`, the P-256 public key, its fingerprint, and a ready-to-copy\n`aclPrincipal` object. It never holds the private key, the DPAPI blob, the session proof, or the\n`%LOCALAPPDATA%/Open77/identity-v1.dat` file. Do not copy that last one to the server.","natives":[],"permissions":[],"events":[]},{"anchor":"whitelisting-a-player","heading":"Whitelisting a player","level":2,"text":"The server loads the relative file configured in `server.jsonc`:\n\n```json\n\"accessControl\": {\n \"file\": \"acl.jsonc\"\n}\n```\n\nCopy the exported `aclPrincipal` value into `principals`:\n\n```json\n{\n \"version\": 1,\n \"principals\": [\n {\n \"name\": \"owner\",\n \"userId\": \"00000000-0000-0000-0000-000000000000\",\n \"publicKey\": \"base64...\",\n \"permissions\": [\n \"command.loot.*\"\n ]\n }\n ]\n}\n```\n\nRestart the server, or type `acl.reload` in its administration console. `acl.list` reports the path\nactually loaded; `acl.check ` is there for diagnosis.\n\nThe comparison is made on the 64 bytes of public key certified during the handshake. If the entry\nalso carries a `userId`, that must match too. The displayed nickname and the temporary `playerId`\nnever take part in authorisation.","natives":[],"permissions":[],"events":[]},{"anchor":"changing-the-acl-from-a-resource-at-runtime","heading":"Changing the ACL from a resource, at runtime","level":2,"text":"The read half above is the whole story for a server whose rights are decided once\nand edited by hand. A job or gang model is not that server: \"this player is on\nshift as police dispatch\" is a fact that changes ten times an evening, and an\noperator should not be in the loop for any of them.\n\n`Open77.acl` therefore has a write half — native-parity row A12, FiveM's\n`add_ace`, `add_principal` and `remove_ace`.\n\n```lua\npermissions {\n \"acl.read\", -- the existing read half\n \"acl.grant:job.police.*\", -- may hand out rights under this prefix\n \"acl.define:job.police.*\", -- may define roles whose rights are under it\n}\n```\n\n```lua\nlocal userId = Open77.players.identity(source).userId\n\nassert(Open77.acl.definePermission(\"dispatcher\", { \"job.police.dispatch\" }))\nassert(Open77.acl.addRole(userId, \"dispatcher\"))\nassert(Open77.acl.grant(userId, \"job.police.impound\"))\n\n-- Off shift.\nassert(Open77.acl.removeRole(userId, \"dispatcher\"))\nassert(Open77.acl.revoke(userId, \"job.police.impound\"))\n```\n\n| Function | Capability | Meaning |\n|---|---|---|\n| `Open77.acl.grant(userId, permission)` | `acl.grant:` | Add one permission. |\n| `Open77.acl.revoke(userId, permission)` | `acl.grant:` | Take it back. |\n| `Open77.acl.addRole(userId, role)` | `acl.grant:` | Put the user in a role. |\n| `Open77.acl.removeRole(userId, role)` | `acl.grant:` | Take them out of it. |\n| `Open77.acl.definePermission(role, permissions)` | `acl.define:` | Define, retune, or (with `nil`) delete a runtime role. |\n| `Open77.acl.grants(userId)` | `acl.read` | What the runtime layer holds for one user. |\n\nEvery writer answers `true`, or `nil, reason`. A grant takes effect on the very\nnext `isAllowed` — there is no reload and no restart, because the swap is\nin-process.","natives":["server:Open77.players.identity","server:Open77.acl.definePermission","server:Open77.acl.addRole","server:Open77.acl.grant","server:Open77.acl.removeRole","server:Open77.acl.revoke"],"permissions":["acl.read"],"events":[]},{"anchor":"the-subject-is-a-userid-never-a-playerid","heading":"The subject is a userId, never a playerId","level":3,"text":"The read half takes a `playerId` because it asks a question about a seat that is\noccupied right now. The write half takes the **master-issued `userId`**, the\nstring `Open77.players.identity(playerId).userId` answers, because a grant\noutlives the session that earned it and a seat number does not. It also means you\ncan promote somebody who is offline.","natives":[],"permissions":[],"events":[]},{"anchor":"the-delegation-scope-is-the-security-model","heading":"The delegation scope is the security model","level":3,"text":"There is no bare `acl.grant` or `acl.define`. The capability *is* the pattern,\nand a resource may hand out exactly what is inside it:\n\n```text\npermission \"acl.grant:job.police.*\"\n\nOpen77.acl.grant(user, \"job.police.dispatch\") -- true\nOpen77.acl.grant(user, \"command.kick\") -- nil, \"permission_not_delegable\"\nOpen77.acl.grant(user, \"*\") -- nil, \"permission_not_delegable\"\n```\n\nThis is the answer to the question A12 exists to raise: *may a resource grant a\npermission it does not itself hold?* It may grant whatever the operator wrote into\nits manifest, and nothing else — and the operator reads that delegation off the\nmanifest before installing the resource, the same way `runtime.commands` and\n`resources.control` are readable there.\n\n**`acl.grant:*` is not a wildcard scope.** It is dropped, and a resource whose\nevery entry was `*` is answered `scope_not_delegable`. Owner-equivalence comes\nfrom `acl.jsonc` or it does not come at all.\n\nFour more refusals close the escalations that follow from the first:\n\n| Reason | What it stops |\n|---|---|\n| `role_exceeds_scope` | `addRole` is checked against the role's **entire reach**, not its name. Without it a resource scoped to `job.police.*` would collect `command.*` by adding your `moderator` role — the same escalation one indirection later. |\n| `operator_principal` | No runtime call may name a subject your document binds to `*` or to the `owner` role, in either direction. |\n| `role_defined_by_operator` | A role you defined in `acl.jsonc` is yours. A resource cannot retune what it means. |\n| `role_reserved` | `owner` and `admin` are the server's, under any scope. |\n\nThe capability that gates granting is a *manifest* permission, and manifest\npermissions are not in the ACL. So there is no string a resource can grant to\nanyone, itself included, that yields `acl.grant`. That one is structural rather\nthan a check.","natives":["server:Open77.acl.grant"],"permissions":[],"events":[]},{"anchor":"runtime-grants-live-in-a-sibling-not-in-your-file","heading":"Runtime grants live in a sibling, not in your file","level":3,"text":"Writes go to **`acl.runtime.jsonc`**, next to `acl.jsonc`, written atomically\nthrough a temporary so a crash mid-write cannot truncate it. Your document is\nnever touched.\n\nThat is a security property, not tidiness. The overlay is **additive only** — it\nhas no subtractive form at all — so no sequence of calls a resource can make, not\neven a compromised one holding the widest scope you can write, takes a right away\nfrom your document. A revoke that names one of your grants answers\n`operator_grant` instead of appearing to work. Had runtime grants been merged into\n`acl.jsonc`, a resource able to write that file would also be able to rewrite the\nowner's principal, and the whole model would rest on the refusals above being\nexhaustive rather than on the layout making the attack unrepresentable.\n\nThree consequences worth knowing:\n\n- **Effective rights are the union.** `isAllowed` checks your document first and\n the overlay second, and the overlay can only ever say yes.\n- **A complete reset is `rm acl.runtime.jsonc`.** One file, machine-owned, with a\n header saying so. Nothing of yours is caught in it.\n- **The overlay cannot bind a public key.** A runtime grant binds the\n master-issued user id only — the same strength as a userId-only principal in\n your document, which it already permits, and it keeps anything key-shaped out\n of a machine-managed file. A runtime role that collides with one you later\n define in `acl.jsonc` is dropped rather than merged: yours wins.","natives":[],"permissions":[],"events":[]},{"anchor":"onaclchanged","heading":"`onAclChanged`","level":3,"text":"Every accepted mutation publishes one host-wide event, on a reserved name a\nresource cannot forge.\n\n```lua\nAddEventHandler(\"onAclChanged\", function(revision, by, operation)\n -- revision: a counter, 1 per accepted mutation\n -- by: the resource that caused it\n -- operation: \"grant\" | \"revoke\" | \"addRole\" | \"removeRole\" | \"definePermission\"\nend)\n```\n\nIt names **neither the subject nor the permission**, and that is deliberate: the\nevent reaches every running resource, so a payload carrying either would tell the\nwhole server what the ACL says while bypassing `acl.read`. A listener that only\nneeds to drop a cache has everything it needs in the revision; one that is\nentitled to the detail calls `Open77.acl.isAllowed`, which is gated.\n\nOne boundary to know: the event is published by the resource's own VM, so a grant\nmade in a resource's **top-level chunk** — before the host has activated that VM\non the event bus — persists and takes effect, but publishes nothing. Grant from a\nthread or an event handler if a listener has to see it. The same rule governs\n`Open77.environment.publishChange`.\n\nThe Warden panel and the `acl.reload` console command write and reload\n`acl.jsonc` itself and do **not** publish `onAclChanged`; the event is about the\nruntime layer.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"declaring-a-restricted-command","heading":"Declaring a restricted command","level":2,"text":"`RegisterCommand(name, handler, true)` declares a command that only a player holding the ACL\nright `command.` may run; the server refuses the others before the handler runs, and the\ndedicated console is always allowed. Grant the right in `acl.jsonc` (`command.settime`, or\n`command.*`), or let a resource grant it at runtime as described above. The refusal is reported\nto the caller through `open77:command:result`; a resource that wants to phrase its own refusal\nin chat registers the command unrestricted and rechecks `Open77.acl.isAllowed(source,\n\"command.\")` itself, as below.","natives":[],"permissions":[],"events":["open77:command:result"]},{"anchor":"reading-effective-rights-from-a-server-resource","heading":"Reading effective rights from a server resource","level":3,"text":"Declare `permission \"acl.read\"` in the resource manifest, then use:\n\n```lua\nlocal allowed, error = Open77.acl.isAllowed(source, \"command.admin.moderate.kick\")\nlocal roles, error = Open77.acl.roles(source)\n```\n\nThese APIs are server-only and read the compiled ACL for an authenticated active\nsession. `isAllowed` accepts a concrete permission, not a wildcard; it returns\nfalse for an absent resolver/session. Invalid IDs (including 0), invalid names\nand missing manifest capability return `false, reason`. `roles` returns the\nmatching role labels, or an empty array for an unknown session. Neither writes\nroles or grants rights. Continue registering privileged commands as restricted.\n\nThe standalone [`open77_admin`](https://github.com/Open2077/open77-resources/tree/main/system/open77_admin) resource uses these bindings for `/admin` and\nrechecks commands at execution. See its README and role examples for deployment.\nIts `/pos` and `/rot` utilities intentionally require no admin ACL.\n\n```lua\nRegisterCommand(\"garage.delete\", function(source, args, rawCommand)\n -- source is the playerId of the authenticated session, or 0 for the server console.\nend, true)\n```\n\nFrom a client, this command requires `command.garage.delete`. An exact permission, `*`, or a\ntrailing wildcard such as `command.garage.*` will grant it. The dedicated console uses `source=0`\nand stays authorised for local administration.\n\nThe transport caps a line at 32 tokens and each token at 256 UTF-8 bytes, validates the command\nname, and reuses the network limit of 32 events per second. Refusals and results come back to the\nOpen77 terminal through `open77:command:result`.","natives":["server:Open77.acl.isAllowed","server:Open77.acl.roles","RegisterCommand","server:RegisterCommand"],"permissions":[],"events":["open77:command:result"]}]},{"slug":"server-api","file":"server-api.md","title":"Complete server Lua API","summary":"Reference for the dedicated server's Lua runtime. These methods are available in `server_script` files and the server half of `shared_script` files. Client APIs are listed separately in the [API reference](/docs/api).","sections":[{"anchor":"complete-server-lua-api","heading":"Complete server Lua API","level":1,"text":"Reference for the dedicated server's Lua runtime. These methods are available in `server_script` files and the server half of `shared_script` files. Client APIs are listed separately in the [API reference](/docs/api).\n\nPrefer the `Open77.*` names below. FiveM-style globals remain available where listed for familiar\nresource code and as the low-level implementation surface.\n\nFor passenger combat, see [Passenger drive-by](drive-by.md):\n`Open77.players.setDriveByEnabled(playerId, enabled)` controls server policy;\n`getDriveByState`, `isDriveByEnabled` and `isInDriveBy` read it.\nWrites require `players.driveby`; reads require `players.life.read`.\nUse the Unstable `.117` client and server, protocol 1.42.","natives":[],"permissions":[],"events":[]},{"anchor":"runtime-scheduler-events-commands-and-json","heading":"Runtime, scheduler, events, commands, and JSON","level":2,"text":"| Function | Signature | Result / purpose |\n|---|---|---|\n| `CreateThread` | `(function)` | Schedule a coroutine in this resource. |\n| `GetGameTimer` | `()` | Process-monotonic milliseconds used by the server scheduler. |\n| `Wait` | `(milliseconds)` | Yield the current managed coroutine; accepted range is 0–86,400,000 ms. |\n| `SetTimeout` | `(milliseconds, function)` | Schedule a one-shot callback and return its timer ID. |\n| `ClearTimeout` | `(timerId)` | Cancel a scheduled timeout. |\n| `AddEventHandler` | `(event, handler)` | Register a local handler and return its ID. |\n| `RemoveEventHandler` | `(handlerId)` | Remove a local or network handler. |\n| `TriggerEvent` | `(event, ...)` | Publish host-wide: every running resource that handles `event` receives it. `true`, or `false, reason`. |\n| `TriggerLocalEvent` | `(event, ...)` | The pre-bus behaviour: this VM's handlers only, nothing marshalled, no reserved-name filter. |\n| `TriggerCancellableEvent` | `(event, ...)` | Publish host-wide as cancellable and return a verdict handle, or `nil, reason`. |\n| `CancelEvent` | `()` | Inside a cancellable handler, veto the event. `true`, or `false, \"not_in_cancellable_event\"`. |\n| `WasEventCanceled` | `()` | Inside a cancellable handler, whether an earlier handler already vetoed it. |\n| `RegisterNetEvent` | `(event, handler?)` | Register an authenticated client event; requires `network.events`. |\n| `TriggerClientEvent` | `(event, playerId|-1, ...)` | Send to one session or broadcast; requires `network.events`. |\n| `RegisterCommand` | `(name, handler, restricted?)` | Register a server/chat command. Restricted commands require ACL `command.`. |\n| `GetCurrentResourceName` | `()` | Return this manifest's resource name. |\n| `GetResourceState` | `(resourceName)` | Return the current server resource state. |\n| `print` | `(...)` | Write a resource-prefixed server log entry. |\n| `json.encode` | `(value)` | Serialize a bounded Lua value to JSON. |\n| `json.decode` | `(text)` | Decode JSON into safe Lua values. |\n\nLimits are 1,024 scheduled tasks and 2,048 handlers per resource. Network events accept at most 32\narguments in a 48 KiB JSON envelope. During a network handler, global `source` is set from the\nauthenticated connection, never from client payload data.","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-sandbox-provides","heading":"What the sandbox provides","level":3,"text":"A server script runs in Lua 5.4 with the standard `math`, `string`, `table`, `utf8` and `coroutine`\nlibraries, the `json` table above, `print`, and the globals in this guide. It has **no** `os`, `io`,\n`debug`, `package`, `require`, `load`, `loadfile`, `dofile` or `collectgarbage`: each of those is\n`nil`, so `os.time()` raises `attempt to index a nil value`. Wall-clock time is `Open77.time.unix()` /\n`GetUnixTime()` (fractional seconds) or `Open77.time.utc()` (ISO 8601); elapsed time is\n`Open77.time.monotonic()` or `GetGameTimer()`; random numbers are `math.random`; persistence is\n`Open77.kvp` and the database API, never a file. Split a script across files with more\n`server_script` lines in the manifest, not `require`.","natives":[],"permissions":[],"events":[]},{"anchor":"the-host-wide-event-bus","heading":"The host-wide event bus","level":3,"text":"`TriggerEvent` is the server counterpart of the client bus: the event reaches every running\nresource that registered a handler for that name, including the publisher itself. This is what\nlets a framework be a core plus modules — `chat:message`, `esx:playerLoaded`-style events and\n`chatMessage` all work across resources.\n\nDelivery is **queued, never re-entrant**. Publishing appends to the host's queue; the host drains\nit at the next tick boundary and pushes the event into each VM in resource-name order, so one\nresource's C function never re-enters another resource's Lua state and one resource's error\ncannot abort the fan-out. Ordinary events are then scheduled in each target's own scheduler, so a\nhandler may `Wait`.\n\nArguments cross **by value**, through the same marshaller as server exports: at most 32 values,\n4,096 nodes, depth 16, 48 KiB total. Functions, userdata, cyclic tables and non-finite numbers are\nrefused. No Lua object is shared between VMs, so mutating a table after publishing it changes\nnothing for the receivers. Failure tokens: `invalid_event_name`, `reserved_event`,\n`resource_stopping`, `resource_preparing`, `event_argument_limit`,\n`event_payload_not_serializable`, `event_queue_limit`.\n\n`source` is **not** propagated across the bus. A publisher that wants the receivers to know which\nplayer caused the event passes the id as an argument, the way `chat:message(source, name, text)`\ndoes. Handlers registered with `RegisterNetEvent` also receive host-wide events, exactly as on the\nclient, so a handler that acts on authority must validate `source` rather than assume it.\n\n`TriggerLocalEvent` keeps the behaviour `TriggerEvent` had before the bus existed: this VM's\nhandlers only, no marshalling, no reserved-name filter. Use it for a resource's own internal\nsignalling. `Open77.events.emitLocal` is the namespaced alias.\n\n**Reserved names.** The platform's own vocabulary cannot be published host-wide by a resource;\n`TriggerEvent` and `TriggerCancellableEvent` answer `false, \"reserved_event\"` (`nil,\n\"reserved_event\"` for the cancellable form). Reserved are the exact names `onResourceStart`,\n`onResourceStop`, `onResourceStarting`, `onResourceListRefresh`, `onPlayerConnecting`,\n`onPlayerConnected`, `onPlayerDisconnected`,\n`onPlayerReady`, `onPlayerRejected`, `onPlayerBucketChange`, `onPlayerAnimationChanged`,\n`onPlayerMotionChanged`, `onPlayerLifeStateChanged`, `onPlayerLifeTransitionFailed`,\n`onEntityBucketChange`, `onRoutingBucketPolicyChange`, `onLootPickup`, `onTunableChanged`,\n`onEnvironmentChanged`, `onPropCreated`, `onPropRemoved`, `onEffectCreated`, `onEffectRemoved`,\n`onLootCreated`, `onLootRemoved`, `onEntityCreated`, `onEntityRemoved`,\n`playerDropped`, `playerJoining`, `onPlayerEnteredScope`, `onPlayerLeftScope`,\n`playerEnteredScope`, `playerLeftScope`, `open77:combatAnomaly`, plus the prefixes `__open77`,\n`onVehicle`, `onNpc`, `onElevator`, `onCyberware`, `onAbility`, `open77:resource:`,\n`open77:player`, `open77:clothing:`, `open77:weapons:`, `open77:vehicles:ai:` and\n`open77:admin:`. Everything a\nresource legitimately publishes stays open, including `open77:weather:*`, `open77:loot:*`,\n`open77:appearance:*` and `chat:*`. `TriggerLocalEvent` is deliberately unfiltered: inside its own\nVM a resource can only fool itself.\n\nNo permission is required to publish or to receive, exactly as on the client and in FiveM. What\nprotects the platform is the reserved list, not a capability.","natives":[],"permissions":[],"events":["open77:combatAnomaly","open77:player"]},{"anchor":"cancellable-events","heading":"Cancellable events","level":3,"text":"Only events published with `TriggerCancellableEvent` can be vetoed; an ordinary `TriggerEvent`\nignores `CancelEvent()`. Cancellable handlers run **inline** during the drain, in resource-name\norder and then in registration order, so `Wait` is not available inside one. Every handler still\nruns after a veto — FiveM semantics — and a later handler reads the verdict with\n`WasEventCanceled()`.\n\nThe publisher reads the verdict through the handle it got back. It settles one tick later, when\nevery running resource has been offered the event:\n\n```lua\nlocal verdict = TriggerCancellableEvent(\"chatMessage\", source, name, text)\nif verdict ~= nil and verdict:await() then return end -- a handler vetoed it\n```\n\n| Handle method | Result |\n|---|---|\n| `verdict:await()` | `true` when a handler cancelled, `false` otherwise. Needs a managed coroutine; answers `false, reason` outside one. |\n| `verdict:canceled()` | The verdict once settled, or `nil, status` while still pending. |\n| `verdict:status()` | `pending`, `settled` or `unknown` (released or expired). |\n\nA verdict that nobody reads is forgotten 30 seconds after it settles.","natives":["server:TriggerCancellableEvent"],"permissions":[],"events":[]},{"anchor":"resource-lifecycle-events","heading":"Resource lifecycle events","level":3,"text":"Eight events describe the resource lifecycle, and every running resource receives all of\nthem. They differ in what they carry and in *when* they run, not in who hears them.\n\n| Event | Signature | Emitted |\n|---|---|---|\n| `onResourceStarting` | `(resourceName)` | **Inline**, from inside the host's start of that resource, before its VM exists: `GetResourceState(resourceName)` reads `starting` inside the handler, and its dependencies are already running. Not a veto — see below. Never delivered to the resource itself (it has no VM yet; on a reload the outgoing VM is excluded). |\n| `onResourceStart` | `(resourceName)` | Once per start, into every running resource. The resource that started gets it from its own VM before any other resource is told; every other resource gets it on the next tick. |\n| `onResourceStop` | `(resourceName, reason)` | Once per stop, into every running resource. The resource that stopped gets it from its own VM, with `reason` (`manual`, `restart`, `reload`, `dependency_stopped`, `runtime_error`, `shutdown`), while it can still run code. |\n| `onResourceListRefresh` | `()` | The manifest tree was re-read: the console's `refresh`, or a change under a watched tree. No arguments, the FiveM shape. Queued like `onResourceStart`, after the starts, stops and reloads the refresh caused have announced themselves. |\n| `open77:resource:starting` | `(resourceName)` | Beside `onResourceStarting`, inline as well. |\n| `open77:resource:started` | `(resourceName, revision)` | Beside `onResourceStart`, with a revision. |\n| `open77:resource:stopped` | `(resourceName, revision)` | Beside `onResourceStop`, with a revision. |\n| `open77:resource:refreshed` | `(revision)` | Beside `onResourceListRefresh`, with the lifecycle revision current at the refresh. A refresh is a re-read, not a transition, so the revision is not incremented by it. |\n\n**`onResourceStarting` cannot cancel a start.** `CancelEvent()` returns `false, \"not_in_cancellable_event\"`. Handlers execute inline, in registration order, under `pcall`, before the resource runs. `Wait` is not available: an attempted yield is logged as an error and delivery continues to the next handler.\n\n```lua\n-- A resource that hands a shared lock to a sibling before the sibling's own scripts run.\nAddEventHandler(\"onResourceStarting\", function(name)\n if name ~= \"shop_inventory\" then return end\n print(GetResourceState(name)) -- \"starting\": its VM does not exist yet\n releaseInventoryLock() -- runs before shop_inventory's server/main.lua\nend)\n\n-- An admin panel that re-reads the package set only when the tree was re-read.\nAddEventHandler(\"onResourceListRefresh\", function()\n rebuildResourceTable(Open77.resource.list())\nend)\n```\n\nGuard on the name when you mean your own resource, which is the common case (never for\n`onResourceStarting`, which a resource cannot receive about itself):\n\n```lua\nAddEventHandler(\"onResourceStart\", function(name)\n if name ~= GetCurrentResourceName() then return end\n -- this resource is up\nend)\n```\n\nA reload is a stop and a start for every other resource, and neither for the resource\nbeing reloaded: its outgoing VM already ran `onResourceStop(name, \"reload\")` and its\nincoming VM already ran `onResourceStart(name)`. No resource is ever told about its own\ntransition twice.\n\nUse local handlers for optional cross-resource lifecycle adapters; peer events in the\n`open77:resource:` namespace are discarded, so a client cannot forge one. A queued event\ndescribes the transition, so check `GetResourceState(name)` when current availability\nmatters (a stop/start can occur before the next tick). Revisions increase for every host\ntransition. Remember the newest revision per resource and ignore older notifications:\nscheduler order is not a lifecycle guarantee.","natives":["AddEventHandler","server:AddEventHandler","GetResourceState","server:GetResourceState","server:Open77.resource.list","GetCurrentResourceName","server:GetCurrentResourceName"],"permissions":[],"events":["open77:resource:starting","open77:resource:started","open77:resource:stopped","open77:resource:refreshed"]},{"anchor":"feature-apis-at-a-glance","heading":"Feature APIs at a glance","level":3,"text":"The [cyberware API](cyberware.md) separates definition, identity, management and\nread permissions. Use the [Gorilla Arms walkthrough](gorilla-arms.md) for a complete\nresource example; installation completion is asynchronous.\nIt supports coexisting `arms` / `gorilla_arms` and `legs` / `double_jump`\ndefinitions. Install chooses the definition's slot; remove defaults to arms and\naccepts `options.slot=\"legs\"`. Both use one character revision/receipt ledger.\n`onCyberwareJump(player, encodedResult)` reports second-jump admission as\n`{sequence,ok,error}`, not native movement completion. See the linked reference\nfor limits, additive persistence migration, client projection APIs and the\noriginal protocol 1.27 boundary. Current combined protocol and validation are\nrecorded in the [Dash checkpoint](../docs/dash-checkpoint.md).\n\nThe [Dash / Air Dash API](dash.md) adds `Open77.dash.define`, `grant`, `revoke`,\n`cancel`, `current` and `capabilities`. It uses separate `players.dash.define`,\n`players.dash.manage` and `players.dash.read` permissions, session capabilities\nand the existing cyberware identity. It never purchases or replaces an implant.\n`current(player).ownedByCaller` reports exact caller ownership across projection\nrestarts without exposing the resource owner name.\n`onDashChanged` reports correlated activation phases; `onDashRejected` reports\nadmission failure.\n\nThe [reflex overdrive API](reflex-overdrive.md) adds `Open77.reflex.define`,\n`grant`, `revoke`, `cancel`, `current` and `capabilities`, under separate\n`players.reflex.define`, `players.reflex.manage` and `players.reflex.read`\npermissions. It is a bounded real-time speed/handling buff on its owner: it\nslows no bullet, slows no other player, and changes no clock anywhere. A\ndefinition picks one of two client-owned stat tiers and its economy; it can\nnever name a stat or a modifier. `onReflexChanged` reports correlated activation\nphases; `onReflexRejected` reports admission failure.\n\nThe [Ground Slam API](ground-slam.md) provides `Open77.abilities.define`, `grant`,\n`revoke`, `cancel` and `current`, controlled by `players.abilities.define`,\n`players.abilities.manage` and `players.abilities.read`. Combined movement\nadmission includes Dash/Slam recovery and forced motion; grants compose with\nthe existing implant ledger.\n\nThe [hacking API](hacking.md) adds permission-controlled `Open77.hacking` and\n`Open77.statuses` services for definitions, admission, Self-ICE, purge, statuses\nand owned protection policies. Matching implants share the existing cyberware\nledger in `operating_system`, `self_ice` and `purge` slots. The optional lab is\ndisabled by default. `onHackingTransition` carries platform-owned upload/status\nnotifications; it cannot be synthesized through resource/network events.","natives":[],"permissions":[],"events":[]},{"anchor":"namespaced-equivalents-of-the-globals","heading":"Namespaced equivalents of the globals","level":3,"text":"The namespaced equivalents of the runtime globals listed at the top of this guide are:\n\n| Function | Signature |\n|---|---|\n| `Open77.runtime.luaVersion` | `()` |\n| `Open77.time.monotonic` | `()` — monotonic seconds |\n| `Open77.time.unix` / `GetUnixTime` | `()` — wall-clock seconds since 1970 UTC, fractional |\n| `Open77.time.utc` / `GetUtcTimestamp` | `()` — the same instant as an ISO 8601 string |\n| `Open77.resource.name` | `()` |\n| `Open77.resource.state` | `(resourceName)` |\n| `Open77.events.on` / `off` / `emit` | Same as `AddEventHandler`, `RemoveEventHandler`, `TriggerEvent` |\n| `Open77.events.emitLocal` | Same as `TriggerLocalEvent` |\n| `Open77.events.emitCancellable` | Same as `TriggerCancellableEvent` |\n| `Open77.events.cancel` / `wasCanceled` | Same as `CancelEvent`, `WasEventCanceled` |\n| `Open77.net.on` / `emitClient` | Same as `RegisterNetEvent`, `TriggerClientEvent` |\n| `Open77.net.register` / `unregister` | `(name, handler(source, ...))` — answer a client's `Open77.net.call`; `source` is the authenticated player id |\n| `Open77.net.callClient` / `callClientAwait` | `(playerId, name \\| {name=, timeout=}, ...)` — ask one client a question, returns an `Open77.Promise` |\n| `Open77.callbacks.*` | Alias table for the four callback functions above |","natives":[],"permissions":[],"events":[]},{"anchor":"entity-lifecycle-events","heading":"Entity lifecycle events","level":3,"text":"Five registries create and remove server-owned entities, and each announces its own\ncreations and removals. A sixth pair, `onEntityCreated` / `onEntityRemoved`, summarises all\nfive for a resource that cares about the host rather than one domain — FiveM's\n`entityCreated` and `entityRemoved`.\n\n| Kind | Created | Removed | Capability |\n|---|---|---|---|\n| `vehicle` | `onVehicleCreated(id, resource, record)` | `onVehicleRemoved(id, reason)` | none |\n| `npc` | `onNpcCreated(id, resource, template)` | `onNpcRemoved(id, reason, resource)` | none |\n| `prop` | `onPropCreated(id, resource, model)` | `onPropRemoved(id, reason, resource)` | `world.props` |\n| `effect` | `onEffectCreated(id, resource, effect)` | `onEffectRemoved(id, reason, resource)` | `world.effects` |\n| `loot` | `onLootCreated(id, resource, item)` | `onLootRemoved(id, reason, resource)` | `world.loot` |\n| any | `onEntityCreated(kind, id, resource)` | `onEntityRemoved(kind, id, reason)` | `world.entities.observe`, **plus** the kind's own |\n\n`reason` is the registry's own token — `removed`, `expired`, `resource_stopped`, and for loot\nalso `picked_up` and `moved_bucket` — or the free text (≤ 64 characters) the caller passed to\n`Open77.props.remove(id, reason)` and its siblings. `onVehicleRemoved` carries a numeric\nengine reason and, alone in the table, no owner: it predates the three-argument convention and\nwidening it would change a payload resources already read.\n\n**The per-registry event is authoritative; the generic one is a projection of it.** Both are\nraised by the same statement inside the host, so they cannot disagree about an id, an owner or\na reason — there is no second observer of the registry that could be stopped, filtered or\nreordered on its own. When a resource handles both, the specific event runs first and the\nmirror second; `EntityLifecycleEventsLuaTests` pins that order, so a scheduler change fails a\ntest rather than silently reordering a spawn.\n\n```lua\n-- open77.lua\npermissions { \"world.props\", \"world.entities.observe\" }\n\n-- server/main.lua: one ledger, whatever spawned it\nAddEventHandler(\"onEntityCreated\", function(kind, id, resource)\n print((\"%s %s appeared, owned by %s\"):format(kind, id, resource))\nend)\n\nAddEventHandler(\"onEntityRemoved\", function(kind, id, reason)\n if reason == \"expired\" then print((\"%s %s timed out\"):format(kind, id)) end\nend)\n```\n\n**The mirror carries no authority of its own.** `world.entities.observe` is an opt-in, not a\nkey: an event about a prop still costs `world.props`, because reading one does\n(`Open77.props.list()` answers `permission_denied:world.props` without it) and a lifecycle\nevent that announced the prop anyway would route around that capability. So the generic feed\nis exactly the union of what a resource could already see, under one name — never more. A\nresource holding only `world.entities.observe` sees vehicles and NPCs, whose per-registry\nevents were never gated, and nothing else.\n\nThe opt-in exists because the feed is every registry at once. A resource that wants NPC\nlifecycle should not be handed every ground drop on the server as a side effect, and a\nhost-wide firehose nobody asked for is a cost paid by every VM on every spawn.\n\n**There is no `onPropUpdated`, `onEffectUpdated` or `onLootUpdated`**, and no generic\n`onEntityUpdated`. An attached prop or effect republishes as it follows its target, so an\nupdate event would be a per-tick firehose for a value `Open77.props.get(id)` answers on\ndemand. Creation and removal are the two transitions a resource cannot poll for without\nmissing one. `onVehicleUpdated` and `onNpcUpdated` remain, revision-carrying, for the two\nregistries that shipped with them.\n\n**FiveM's `entityCreating` is deliberately absent.** It is a cancellable *pre*-event, and\nanswering it would mean running another resource's Lua handler from inside the create call —\nre-entering a second `lua_State` from a first one's C function, which is the single invariant\nthe whole event bus exists to prevent. A resource that must veto a creation owns the create\ncall instead: the entity is its own.\n\nAll eight new names are reserved. A forged `onEntityRemoved` is the cheaper attack of the\npair: it makes a cleanup or inventory resource forget an object that is still in the world.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":["world.props","world.entities.observe"],"events":[]},{"anchor":"network-callbacks","heading":"Network callbacks","level":2,"text":"A callback is a net event with an answer: the client asks with\n`Open77.net.call`, this VM answers from `Open77.net.register`, and the reverse\ndirection runs through `Open77.net.callClient`. It needs the same\n`network.events` permission as a net event and grants nothing more — a handler\nmust re-validate distance, ownership and permission exactly as a\n`RegisterNetEvent` handler does. See [Network callbacks](callbacks.md) for both\ndirections, the per-resource namespacing, timeouts, limits and failure reasons.","natives":[],"permissions":[],"events":[]},{"anchor":"reload-surviving-resource-state","heading":"Reload-surviving resource state","level":2,"text":"`Open77.state` holds **one value per resource**, so that a reload can pick up where the previous VM\nleft off. It is not a key-value store, and it is not persistence: think of it as the note a resource\nleaves for its own successor.\n\n| Function | Signature | Result / purpose |\n|---|---|---|\n| `Open77.state.save` | `(value)` | Store; `nil` clears. `true`, or `false, reason`. |\n| `Open77.state.load` | `()` | The last stored value, or `nil` on a fresh start. |\n| `Open77.state.clear` | `()` | Drop the stored value; the same as `save(nil)`. |\n\nNo permission is required. Three properties decide how it is used:\n\n- **Call `save` when the state changes, not on stop.** A reload prepares the successor VM *before*\n stopping this one, so a write from a stopping resource is refused and returns `false`.\n- **It survives a reload, and deliberately not a stop.** `stop`, `restart` and `refresh` drop it, so\n an operator keeps a way to say \"come up as you would at boot\", and the server re-asserts its\n `startup.commands` for a resource that starts carrying nothing.\n- **The value is round-tripped through JSON by the host.** Store plain data: functions, coroutines\n and metatables do not survive, and cycles are rejected with `unserialisable_state`.\n\n```lua\nlocal round = Open77.state.load() or { number = 0, scores = {} }\nround.number = round.number + 1\nOpen77.state.save(round)\n```","natives":["server:Open77.state.load","server:Open77.state.save"],"permissions":[],"events":[]},{"anchor":"cyberware-and-living-motion","heading":"Cyberware and living motion","level":2,"text":"Requires paired protocol **1.26** clients/server and the shipped native Gorilla\nadapter; this release supports `slot=\"arms\"`, `profile=\"gorilla_arms\"`. It does\nnot expose an implemented legs/jump power. Server mutations are synchronous\nadmission calls returning a result table or `nil, reason`, not Promises. Native\nprojection, persistence and restoration complete later. Read methods return a\ntable or nil when unavailable; distinguish nil readiness from an empty `arms`.\n\n| Function | Signature | Permission | Purpose |\n|---|---|---|---|\n| `Open77.cyberware.current` | `(playerId)` | `players.cyberware.read` | Read the ready durable implant record. |\n| `Open77.cyberware.effective` | `(playerId)` | `players.cyberware.read` | Read the ready implant including an active temporary loadout. |\n| `Open77.cyberware.activity` | `(playerId)` | `players.cyberware.read` | Read fresh authoritative Gorilla charge activity. |\n| `Open77.cyberware.leaseState` | `(playerId)` | `players.cyberware.read` | Read a temporary loadout lease and restoration phase. |\n| `Open77.cyberware.newOperationId` | `()` | `players.cyberware.manage` | Create an operation ID to retain across durable retries. |\n| `Open77.cyberware.define` | `(definition)` | `players.cyberware.define` | Register resource-owned Gorilla Arms grades. |\n| `Open77.cyberware.bind` | `(playerId, character)` | `players.cyberware.identity` | Bind the authenticated player to a server-selected durable character. |\n| `Open77.cyberware.unbind` | `(playerId)` | `players.cyberware.identity` | Release this resource's character binding and runtime grants. |\n| `Open77.cyberware.install` | `(playerId, definition, grade, options)` | `players.cyberware.manage` | Stage a durable Gorilla Arms installation. |\n| `Open77.cyberware.remove` | `(playerId, options)` | `players.cyberware.manage` | Stage a durable Gorilla Arms removal. |\n| `Open77.cyberware.cancel` | `(playerId, ticket)` | `players.cyberware.manage` | Cancel an owned installation/removal before durable commit. |\n| `Open77.cyberware.lease` | `(playerId, definition, grade, options?)` | `players.cyberware.temporary` | Stage a temporary implant without overwriting paid state. |\n| `Open77.cyberware.releaseLease` | `(playerId, lease)` | `players.cyberware.temporary` | Release this resource's temporary loadout and restore paid arms. |\n| `Open77.motion.current` | `(playerId)` | `players.motion.read` | Read an authoritative living-motion lease. |\n| `Open77.motion.knockdown` | `(playerId, options)` | `players.motion.control` | Request bounded native living-player motion. |\n| `Open77.motion.cancel` | `(playerId, id)` | `players.motion.control` | Cancel this resource's authoritative motion lease. |\n\n`newOperationId()` returns a string; definition/identity/manage calls return\n`{ok=true,error=nil,ticket?,lease?}`. `install` and `remove` require\n`options={expectedRevision=record.revision,operationId=retainedOperationId}`.\nRetain the same ID and contents across retries. Correlate\n`onCyberwareOperationCompleted(playerId,ticket,encodedResult)` with the pending\nplayer and ticket; decode the JSON result and check `ok`/`error`. A queued ticket\nis not a committed purchase or visual proof. A completed idempotent retry may\nreturn success without a new ticket. Do not refund submitted storage work merely\nbecause native presentation is delayed; `cancel` can return `operation_committing`.\n\n`current()` reads durable `{revision,arms,operationId}`; `effective()` substitutes\nan active temporary arms snapshot while retaining the durable revision/receipt.\n`lease` accepts `durationMs=1000..300000` (default300000), returns\n`{ok=true,lease,ticket}`, and restores paid state on release/expiry/lifecycle loss.\n`onCyberwareLeaseChanged(playerId,encodedState)` carries\n`id,player,phase,definition,grade,expiresAt,ticket,reason`. Wait for matching\n`active` before enabling combat; `ended` after body loss alone does not prove\npaid native restoration. Identity adapters alone select the authenticated user's\ncharacter. Restart a stopped definition provider before expecting its persisted\nimplants to grant combat again.\n\n`Open77.motion.knockdown` returns `{ok=true,id}` after admission, not damage or\nactual movement. `onPlayerMotionChanged(playerId,id,phase,reason)` reports\n`pending`, `active`, `ended`; the owner native acknowledgement starts the bounded\nsix-second server ownership window. Requested travel0..6 m is collision-dependent,\nnot an exact-distance teleport. Cancellation requests cleanup without instantly\nfreezing momentum. The snapshot validator uses ACK-time bounds rather than the\nexact native launch origin. See the runtime-separated searchable cards for\nnative client projection primitives; server scripts never send local entity\nhandles over the network.\n\nThe related public foundations are\n`Open77.combat.createScope({bucket,players})` / `removeScope(id)` with\n`combat.scope.control`, and `Open77.effects.attach(target,effect,options)`,\n`playOn(target,event,options?)`, `sound(target,event,options?)` with `world.effects`.\nScopes do not bypass team/life/Lua veto rules. Effects use typed network target\nIDs and native lifetime guards. `sound` supports an opaque `actionId` and\n`excludePlayers` to avoid adding network sound for listeners already hearing the\nnative contact. These are configurable presentation rules, not extra damage.\n\nLow-level globals backing related namespaced APIs:\n\n| Global | Signature | Permission and result |\n|---|---|---|\n| `AttachEffect` | `(targetId,kind,effect,slot,localAnchor,localSlot,ttlMs?,radius,hysteresis,localEvent?,soundEvent?,soundOnOwner?)` | `world.effects`; decimal effect ID or nil/reason. Prefer `Open77.effects.attach`. |\n| `SetPlayerDownedDamageable` | `(playerId,enabled)` | `players.stats.apply`; boolean/reason. Also exposed as `Open77.stats.setDownedDamageable`. |\n| `VehicleDrivingCommand` | `(operation,vehicleId,options?)` | `world.vehicles`; driving-state table or nil/reason (state may be nil when absent). Prefer [vehicle AI methods](vehicle-ai.md). |","natives":[],"permissions":[],"events":[]},{"anchor":"cross-resource-exports","heading":"Cross-resource exports","level":2,"text":"Server exports use the same asynchronous publish/call/await surface as client\nexports, but only reach other server VMs in the same host. See\n[Cross-resource server exports](server-exports.md) for the complete two-resource\nexample, value limits, permissions, cancellation and reload behavior.\n\n| Function | Signature | Result / purpose |\n|---|---|---|\n| `exports` | `(name, function)` | Publish or replace a server export; true or nil/reason. Indexed rather than called it is also the synchronous proxy: `exports.other:name(...)` and `exports['other-resource']:name(...)` run that export inline and return its values, or raise. Neither `exports` nor a proxy accepts assignment. |\n| `Open77.exports.callSync` | `(resourceName, exportName, ...)` | The same synchronous call without the sugar, for when the export name is in a variable. Raises on failure rather than answering `nil, reason`; wrap it in `pcall`. |\n| `GetInvokingResource` | `()` | Immediate caller resource name inside an exported coroutine; nil outside it. |\n| `GetInvokingResourceGeneration` | `()` | Immediate caller's VM generation inside an exported coroutine; nil outside it. |\n| `GetCurrentResourceGeneration` | `()` | Opaque generation identity of this server VM, changed on restart/reload. |\n| `Open77.resource.generation` | `(resourceName?)` | This VM's generation when omitted; another running server resource's generation, or 0 if unavailable. |\n| `Open77.exports.call` | `(resourceName, exportName, ...)` | Promise for a deferred server export; nil/reason if dispatch is refused. Call only from a running resource, not during preparation. |\n| `Open77.Promise.await` | `()` | Use `promise:await()`: copied return values, or nil/reason on rejection. A pending call requires a managed coroutine. |\n| `Open77.Promise.status` | `()` | Use `promise:status()`: pending, resolved, rejected or cancelled. |\n\nThe provider retains its own native permissions and data ownership. Validate\n`GetInvokingResource()` before exposing privileged operations. Arguments never\nsupply caller identity, and the exported coroutine does not inherit player\n`source`. Exports are the request/response channel; `TriggerEvent` is the\nfire-and-forget broadcast channel and reaches every resource.","natives":[],"permissions":[],"events":[]},{"anchor":"resource-local-file-io","heading":"Resource-local file IO","level":2,"text":"`Open77.io` is server-only persistent text storage rooted at the calling resource's\n`data/` directory. Paths are relative to that directory; absolute paths, `..` escapes and\nreparse-point traversal are rejected. The Lua standard `io` library remains removed from the\nsandbox. This API cannot read another resource, the server configuration, or rewrite its own\nmanifest and scripts.\n\n| Function | Permission | Result / purpose |\n|---|---|---|\n| `Open77.io.read` | `filesystem.read` | `(path) -> contents` or `nil, reason`. |\n| `Open77.io.readJson` | `filesystem.read` | `(path) -> value` or `nil, reason`; decodes with the bounded Open77 JSON codec. |\n| `Open77.io.exists` | `filesystem.read` | `(path) -> boolean` or `nil, reason`. |\n| `Open77.io.list` | `filesystem.read` | `(directory?) -> { { name, type, size? }, ... }` or `nil, reason`; non-recursive and sorted. |\n| `Open77.io.stat` | `filesystem.read` | `(path) -> { name, type, size?, modifiedUtc }` or `nil, reason`. |\n| `Open77.io.write` | `filesystem.write` | `(path, contents) -> boolean, reason?`; atomically replaces a UTF-8 text file. |\n| `Open77.io.writeJson` | `filesystem.write` | `(path, value) -> boolean, reason?`; encodes then writes atomically. |\n| `Open77.io.append` | `filesystem.write` | `(path, contents) -> boolean, reason?`. |\n| `Open77.io.makeDirectory` | `filesystem.write` | `(path) -> boolean, reason?`; creates missing parents. |\n| `Open77.io.remove` | `filesystem.write` | `(path) -> boolean, reason?`; removes one file, never a directory tree. |\n| `Open77.io.move` | `filesystem.write` | `(source, destination, overwrite?) -> boolean, reason?`. |\n| `Open77.io.copy` | `filesystem.read` + `filesystem.write` | `(source, destination, overwrite?) -> boolean, reason?`. |\n\nEach file is limited to 4 MiB, a directory listing to 2,048 entries, and a relative path to\n512 UTF-8 bytes. `readJson` / `writeJson` additionally inherit the JSON codec's 48 KiB and depth\nlimits. Stable refusals include `permission_denied:filesystem.read`,\n`permission_denied:filesystem.write`, `invalid_path`, `path_outside_resource`,\n`reparse_point_denied`, `not_found`, `not_a_file`, `not_a_directory`, `already_exists`,\n`file_too_large`, `too_many_entries`, and `io_error`.\n\n```lua\nlocal course = assert(Open77.io.readJson(\"courses/watson-loop.json\"))\ncourse.revision = (course.revision or 0) + 1\nassert(Open77.io.writeJson(\"courses/watson-loop.json\", course))\n```","natives":["server:Open77.io.readJson","server:Open77.io.writeJson"],"permissions":[],"events":[]},{"anchor":"resource-keyvalue-store","heading":"Resource key/value store","level":2,"text":"`Open77.kvp` is the small typed store between \"parse a file yourself\" and \"stand up MariaDB\": a\njail timer, a shop's till, a vote count. The surface is the client store's, function for function\nand reason token for reason token, so a shared script behaves the same wherever it runs.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.kvp.set` | `(key, value)` | `true`, or `false, reason`. Values are string, integer, number or boolean. |\n| `Open77.kvp.get` | `(key, default?)` | The stored value, else `default`, else `nil`. |\n| `Open77.kvp.has` | `(key)` | `boolean`. |\n| `Open77.kvp.delete` | `(key)` | `true` when removed, `false` when absent. |\n| `Open77.kvp.find` | `(prefix?, limit?)` | Array of `{ key, value, type }`, ordinal-sorted. |\n| `Open77.kvp.keys` | `(prefix?, limit?)` | The same, keys only. |\n| `Open77.kvp.clear` | `(prefix?)` | How many were removed. |\n| `Open77.kvp.increment` | `(key, delta?)` | The new number. |\n| `Open77.kvp.setIfAbsent` | `(key, value)` | `true` when it inserted. |\n| `Open77.kvp.compareAndSet` | `(key, expected, replacement)` | `true` when it exchanged. |\n| `Open77.kvp.stats` | `()` | `{ entries, bytes, maximumEntries, maximumBytes, maximumKeyBytes, maximumValueBytes, resource }` |\n\n`SetResourceKvp` / `GetResourceKvp` / `DeleteResourceKvp` are the same three bare globals the\nclient publishes. FiveM's typed variants (`SetResourceKvpInt`, `GetResourceKvpFloat`, ...) are\ndeliberately absent: each is one line of Lua in a porting shim, and spelling the two Open77\nruntimes identically is worth more than matching FiveM on a side that never had the API.\n\n**It takes no capability, and that is the point.** The store is one JSON file under the calling\nresource's own `data/` directory and the file name is a constant: no caller-supplied path reaches\nit, so a resource cannot address another's store. That is strictly less reach than `Open77.io`,\nwhich does take a path and does sit behind `filesystem.read` / `filesystem.write`. Gating the safer\nsurface would only push authors toward the more dangerous one.\n\nQuotas are the client's numbers exactly — 256-byte keys, 64 KiB values, 4,096 entries, 1 MiB of\npayload — because \"the server happened to allow a larger value\" is a bug that only appears in\nproduction. Refusals: `invalid_key`, `value_too_large`, `invalid_number`, `unsupported_value_type`,\n`nil_value`, `entry_quota_exceeded`, `byte_quota_exceeded`, `invalid_limit`, `delta_not_numeric`,\n`value_not_numeric`, `integer_overflow`, `number_overflow`, `storage_read_failed`,\n`storage_write_failed`, `storage_corrupt`. A refused write leaves the store exactly as it was.\n\nThree properties worth knowing before you rely on it:\n\n- **Integer and number stay apart.** A counter read back from disk is still an integer, and\n `compareAndSet` compares typed — an integer `1` does not satisfy an expectation written `1.0`.\n- **`increment` and `compareAndSet` are atomic** in the sense that matters here: the server Lua\n runtime is one thread, so a read-modify-write cannot interleave with another resource's.\n- **Writes are coalesced onto the tick** and flushed again when the resource stops, so an orderly\n stop, reload or restart loses nothing and a hard kill loses at most one tick of mutations. A\n store that cannot be parsed reports `storage_corrupt` and is never overwritten with a fresh one,\n so a bad file is a problem you can look at rather than state you have already lost.\n\n```lua\n-- Claim a jail cell without a database, and count the arrest.\nif Open77.kvp.setIfAbsent(\"cell.3\", GetPlayerIdentifier(source)) then\n Open77.kvp.set(\"cell.3.releaseAt\", GetUnixTime() + 600)\n Open77.kvp.increment(\"stats.arrests\")\nend\n\n-- Release it only if we are still the occupant.\nOpen77.kvp.compareAndSet(\"cell.3\", GetPlayerIdentifier(source), nil)\n```\n\nReach for `Open77.database` instead when the data must be queried, joined, shared between servers,\nor survive the resource directory being redeployed.","natives":["Open77.kvp.setIfAbsent","server:Open77.kvp.setIfAbsent","server:GetPlayerIdentifier","Open77.kvp.set","server:Open77.kvp.set","server:GetUnixTime","Open77.kvp.increment","server:Open77.kvp.increment","Open77.kvp.compareAndSet","server:Open77.kvp.compareAndSet"],"permissions":[],"events":[]},{"anchor":"convars","heading":"Convars","level":2,"text":"`GetConvar`, `GetConvarInt`, `GetConvarBool` and `SetConvar` exist so a resource ported from FiveM\nruns without its configuration layer being rewritten first. **They are a compatibility shim, and\n`Open77.tunables` above is the better mechanism on every axis that matters:** a tunable is declared\nwith a type and a range, validated on every write, retunable by the operator from the Warden panel\nwhile the server runs, persisted across a restart, scoped to the declaring resource, and it tells\nits owner when it changed. A convar is an untyped string with none of that.\n\nReach for a convar when you are porting. Reach for a tunable when you are writing.\n\n| Function | Signature | Result |\n|---|---|---|\n| `GetConvar` | `(name, default?)` | The string, or `default`. |\n| `GetConvarInt` | `(name, default?)` | The integer, or `default`. |\n| `GetConvarBool` | `(name, default?)` | The boolean, or `default`. |\n| `SetConvar` | `(name, value)` | `true`, or `false, reason`. |\n\n`Open77.convars.get / getInt / getBool / set` are the same four function values under namespaced\nnames. None of them takes a capability.\n\n**A getter never raises.** An unset convar, or one holding a value that does not parse as the type\nasked for, answers the caller's default. A ported script depends on that without ever saying so,\nand a strict parse would turn a missing setting into a crash on the first frame.\n\nResolution order, and the third step is the useful one:\n\n1. an in-memory `SetConvar` override, **server-wide** — a write in one resource is visible to a read\n in another, which is what the FiveM code being ported assumes, and exactly why a convar is a poor\n place for anything two resources might both want to own;\n2. the `convars` block of `server.jsonc`;\n3. **this resource's own declared tunables, by exact key.** A port that reads\n `GetConvar(\"patrol_radius\")` finds the operator's tunable of that name, so a resource migrates to\n the better mechanism one key at a time instead of all at once;\n4. the caller's default.\n\n```jsonc\n// server.jsonc\n\"convars\": {\n \"sv_hostname\": \"Night City RP\",\n \"sv_maxPlayers\": \"48\",\n \"sv_scriptHookAllowed\": \"false\"\n}\n```\n\n`SetConvar` is **in memory only**: the value is gone on the next restart and the configured block\ncomes back. Making a write durable would mean writing to the operator's tracked configuration file\nfrom Lua, which is precisely the power tunables took the trouble to confine to a validated\ndeclaration. Keep credentials out of the block — `server.jsonc` is tracked, and every other secret\nin this schema lives in an environment variable.\n\nLimits: 512 overrides, 4,096-byte values, names matching `[A-Za-z0-9_.:-]` up to 128 bytes, matched\ncase-insensitively as FiveM matches them. Refusals: `invalid_convar_name`,\n`invalid_convar_value`, `convar_value_too_long`, `convar_limit`.","natives":[],"permissions":[],"events":[]},{"anchor":"runtime-and-resource-control","heading":"Runtime and resource control","level":2,"text":"What an admin panel needs: run a command, see what commands exist, see what resources exist, and\nstart, stop or restart one.\n\n| Function | Capability | Result |\n|---|---|---|\n| `Open77.runtime.executeCommand` | `runtime.commands` | Queue one console line. `true`, or `false, reason`. |\n| `Open77.runtime.commands` | — | Every registered command, with its owner and source. |\n| `Open77.resource.list` | — | Every discovered resource and its state. |\n| `Open77.resource.metadata` | — | One resource's manifest, or one field of it. |\n| `Open77.resource.start` / `stop` / `restart` | `resources.control` | Queue a lifecycle verb. |\n\n`ExecuteCommand`, `GetRegisteredCommands`, `GetResourceMetadata`, `StartResource`, `StopResource`,\n`GetNumResources` and `GetResourceByFindIndex` are the FiveM spellings of the same functions.","natives":[],"permissions":[],"events":[]},{"anchor":"runtime-control-permissions","heading":"Runtime control permissions","level":3,"text":"**`runtime.commands` grants a plain resource's authority.** A resource holding it may invoke another\nresource's ordinary command — reusing something a sibling already wrote, which is the common and\nharmless case — and nothing else. A command declared `restricted` is refused, and so is every\nresource-lifecycle verb.\n\n**`resources.control` grants the operator's.** It raises a queued line to the authority the in-game\noperator escalation already uses, and it is separately required by `start`, `stop` and `restart`.\nThis is the admin-panel capability.\n\nFiveM's `ExecuteCommand` runs as the console — rcon authority, with nothing anywhere declaring that\na resource holds it. That is the behaviour deliberately not copied. Here the manifest says it, so an\noperator installing a resource sees the escalation before running it, and a resource that only\nwanted to call a sibling's command never gets the lifecycle verbs at all.\n\nThe escalated surface is also *narrower* than rcon. It reaches the resource lifecycle and any Lua\ncommand; it does not reach the ACL, ban, routing-bucket or phantom verbs, which live in a different\ndispatcher the scripting host has no handle on.\n\n`list` and `metadata` take no capability. They report the operator's own installation — which\nresources exist, their version, their declared permissions — which any resource can already infer\nfrom `exports` and dependency declarations. The power in this area is the control verbs.","natives":[],"permissions":[],"events":[]},{"anchor":"everything-here-is-queued","heading":"Everything here is queued","level":3,"text":"Lua runs inside the host's tick. A resource stopping itself synchronously would free the Lua state\ndoing the stopping, and dispatching a command synchronously would re-enter a VM and reset an\ninstruction budget mid-flight — the accounting the whole sandbox rests on. So a line is queued and\ndrained at the next tick boundary, on the game-loop thread and outside every VM, which is exactly\nwhere a line typed at the console is dispatched.\n\n**These calls therefore report acceptance, not completion.** Read `Open77.resource.list()` on a\nlater tick for the outcome; the reply to a queued line goes to the server log, naming the resource\nthat asked, because a resource quietly restarting another one is something an operator reading a log\nmust be able to see. At most 64 lines may be queued in one tick; the 65th answers\n`command_queue_full`.\n\n```lua\n-- An admin panel's refresh.\nfor _, entry in ipairs(Open77.resource.list()) do\n print(entry.name, entry.version, entry.state)\nend\n\nif Open77.resource.metadata(\"garage\", \"version\") ~= \"1.0.0\" then\n assert(Open77.resource.restart(\"garage\")) -- queued; check the list next tick\nend\n```\n\n`Open77.runtime.commands()` spans every running VM plus the resource-lifecycle verbs the platform\nowns, distinguished by the `source` field (`resource` or `console`). **The ACL, ban, routing-bucket\nand phantom console verbs are not listed**, because they live in a hand-written dispatcher with no\nregistry to read. That is a known gap rather than a silent omission, and the `source` field is what\ntells a reader this list has two origins.","natives":["server:Open77.resource.list","server:Open77.resource.metadata","server:Open77.resource.restart"],"permissions":[],"events":[]},{"anchor":"join-time-readiness-gate","heading":"Join-time readiness gate","level":2,"text":"The barrier that stops one resource acting on a player another resource is not finished with.\nThe host owns the combined readiness barrier across every participant and VM\ngeneration; asynchronous server exports do not replace this lifecycle contract.\nFull rationale and worked example in\n[docs/lua-resources.md](../docs/lua-resources.md#the-join-time-readiness-gate).\n\n**The rule: do not teleport, spawn, kill or force a respawn on a player until their gate has\nopened.** Reading state, rosters and HUD payloads are unaffected.\n\n| Function | Signature | Result / purpose |\n|---|---|---|\n| `Open77.ready.isReady` | `(playerId)` | Whether anything is still holding this player. A player the host does not know is ready. |\n| `Open77.ready.participate` | `({ timeoutMs?, reason? })` | Declare once, at load: every player who connects from now on arrives with one hold in this resource's name. |\n| `Open77.ready.hold` | `(playerId, reason?)` | Take or refresh this resource's hold. Returns the player's `session`, or `nil, reason`. |\n| `Open77.ready.release` | `(playerId, session?, note?)` | Clear this resource's hold. A `session` that no longer matches is dropped. |\n| `Open77.ready.status` | `(playerId)` | `{ known, ready, session, ageMs, holds = { { resource, reason, ageMs, remainingMs } } }`. |\n\n`onPlayerReady(playerId, detail)` is emitted into **every** running resource when the last hold\nclears. `playerId` arrives as a string like every host event. `detail` is `cleared`, `no_holds`,\n`resource_reloaded`, `resource_stopped`, or `timeout:`.\n\nIt is a barrier lifting, not a trigger: it says nothing about whether the player's world is up, so\nkeep your own readiness signal and ask the gate for permission. Every hold carries a deadline, so a\nresource that never releases costs one degraded join and one `WRN` naming it, never a stuck server.\n`ready` at the console lists who is holding whom. No permission is required.","natives":[],"permissions":[],"events":[]},{"anchor":"what-a-connection-can-tell-you","heading":"What a connection can tell you","level":2,"text":"Three reads about the link itself rather than the character on the end of it.\n\n| Function | Permission | Signature | Purpose |\n|---|---|---|---|\n| `Open77.players.ping(playerId)` | None | `-> milliseconds \\| nil, reason` | Round-trip latency, measured by the server on its own transport rather than reported by the client. Not gated: latency is not identity, every scoreboard in the genre shows it, and a resource denied it would simply time a round trip of its own. |\n| `Open77.players.endpoint(playerId)` | `players.identity.sensitive` | `-> \"ip:port\" \\| nil, reason` | The remote address. **This is a player's IP address.** |\n| `Open77.players.identifiers(playerId)` | None, plus `players.identity.sensitive` for `endpoint` | `-> table \\| nil, reason` | `open77` and `userId` (the same installation identity GUID), `name`, `fingerprint`, `joinedAt`, plus verified `license`, `steam` and `gog` when available since `2.31.13+op77.101`. `license` is the permanent Open77 account GUID without dashes; `steam` is lowercase hexadecimal and `gog` decimal. All are strings without type prefixes. `endpoint` requires the capability. See [account and store identifiers](connection-control.md#steam-gog-and-permanent-account-identifiers). |\n| `GetPlayerIdentifierByType(playerId, type)` | None | `-> string \\| nil, reason` | Bare identifier, with case-insensitive type: `open77`, `userId`, `name`, `fingerprint`, `license`, `steam`, `gog`. Missing ownership fields return `identifier_not_linked`; unsupported types return `unknown_identifier_type`; non-string types return `invalid_identifier_type`. |\n| `GetPlayerIdentifiers(playerId)` | None | `-> array \\| nil, reason` | Available identifiers as `type:value` strings, including `license:`, `steam:` and `gog:` since `2.31.13+op77.101`. Missing entries are omitted. Unknown session: `player_not_found`. |\n| `GetPlayerPing` | None | `(playerId) -> milliseconds \\| nil, reason` | FiveM's spelling of `Open77.players.ping`, the same function under both names. |\n| `GetPlayerEndpoint` | `players.identity.sensitive` | `(playerId) -> \"ip:port\" \\| nil, reason` | FiveM's spelling of `Open77.players.endpoint`, the same function under both names. |\n| `Open77.players.sessionStats(playerId)` | None, plus `players.identity.history` for the history half | `-> table \\| nil, reason` | The live session: `playerId`, `userId`, `connectedAtUtc` (the same `JoinedAtUtc` `identifiers` reports as `joinedAt`), `sessionSeconds`, `lastSeenUtc`. With the capability, also `firstSeenUtc`, `previousSeenUtc`, `joinCount` and `totalPlaySeconds` from the identity directory the server already persists -- absent without it, never nil-filled. |\n| `Open77.players.lastSeen(identifier)` | `players.identity.history` | `-> table \\| nil, reason` | When a durable identifier was last on this server, online or not: `userId`, `name`, `online`, `lastSeenUtc`, `firstSeenUtc`, `previousSeenUtc?`, `joinCount`, `totalPlaySeconds`, plus the live fields while connected. `identity_unknown` for somebody never admitted here, `history_unavailable` on a server without a directory. |\n| `GetPlayerTimeOnline` | None | `(playerId) -> milliseconds \\| nil, reason` | FiveM's spelling of the live session length, in the milliseconds FiveM documents. |\n| `GetPlayerLastMsg` | None | `(playerId) -> milliseconds \\| nil, reason` | FiveM's spelling of the age of the newest packet the server holds for the player -- the rich read's `ageMs`. `no_packet_yet` before the first snapshot. |\n| `Open77.players.locale(playerId)` | `players.locale.read` | `-> { code, gameLanguage, voiceLanguage, subtitles, chromium, ageMs, revision } \\| nil, reason` | The language the player's own game runs in, as their client reported it at world-ready: `code` a BCP-47 tag (`en-US`, `fr-FR`, `pt-BR`), the three raw engine codes beside it. `not_reported` until the report lands. See [identity.md](identity.md#language). |\n| `GetPlayerLocale` | `players.locale.read` | `(playerId) -> table \\| nil, reason` | FiveM's spelling of `Open77.players.locale`, the same function under both names. |\n\nTwo things about this surface are deliberate and worth knowing before you build on it.\n\n**A transport that cannot measure latency says so.** `ping` reads the live connection, so it\nanswers `nil, \"ping_unavailable\"` rather than `0` whenever there is nothing to read -- a\ntransport that cannot measure at all, which is the case for the phantom transport the bots run\non, or one that has not sampled yet. That is a different refusal from `player_not_found` and\nfrom `invalid_player_id`, and the three are kept apart on purpose: a bare nil collapses them\ninto \"no\". A zero would be indistinguishable from a perfect link, so the surface never produces\none it did not measure.\n\n**`endpoint` is absent from `identifiers`, not nil, without the capability.** A caller that\nholds `players.identity.sensitive` gets the field; one that does not gets a table with no\n`endpoint` key at all. That is the shape the rich player read already uses, and it lets a\nresource tell \"you may not ask\" apart from \"there is nothing there\" -- a distinction a\nnil-filled field destroys.","natives":[],"permissions":[],"events":[]},{"anchor":"connection-control","heading":"Connection control","level":2,"text":"Who gets in, decided in Lua. The guide, with a whitelist and a ban list to copy, is\n[Connection control](connection-control.md).\n\n| Event / function | Permission | Signature | Purpose |\n|---|---|---|---|\n| `onPlayerConnecting` | `players.gate` | `(player, deferrals)` | Emitted for every hello that passed the platform checks, before a player id exists. `player = { userId, name, publicKey, fingerprint, ticket }`. Return to accept; `deferrals.defer()` to hold, `deferrals.update(message)` to log progress, `deferrals.done()` to release, `deferrals.done(message)` to refuse with that text on the player's screen (127 bytes of UTF-8). One refusal wins; every participating resource must release to admit. |\n| `onPlayerRejected` | None | `(userId, name, code, message)` | Every refused connection, platform or gate. `code` is the lowercased reject reason (`protocolmismatch`, `gamebuildmismatch`, `invalidhello`, `serverfull`, `duplicatehello`, `refused`); `message` the machine token (`server_full`, `identity_proof_invalid`, `connect_ticket_required`, `connection_gate_timeout`, ...) or the gate's own text. `userId` and `name` are empty when the hello could not be read. |\n| `onPlayerConnected` | None | `(playerId, playerName)` | An admitted player has a session. Not an incarnation: see the readiness gate above before acting on them. |\n| `onPlayerDisconnected` | None | `(playerId, reason)` | `reason` is `connection_closed`, or the text given to `disconnect`, `kick` or `ban`. |\n| `playerConnecting` | `players.gate` | `(name, setKickReason, deferrals)` | The same gate as `onPlayerConnecting`, under the FiveM name and with the FiveM argument order, so a ported whitelist runs unchanged. It votes in the same tally; a resource may register either name or both. `deferrals` is the same table. `source` is **not** set: no player id exists yet, and the connection token is not one. `setKickReason(message)` records a message and refuses nobody — the same as in FiveM, where the refusal comes from `CancelEvent`. Open77 has `CancelEvent`, but it only means something inside a `TriggerCancellableEvent` dispatch, and the connection gate is not one: called here it answers `false, \"not_in_cancellable_event\"`. **Refuse with `deferrals.done(message)`.** The runtime says this once per resource in the log the first time `setKickReason` is called. |\n| `playerJoining` | None | `(oldId)` with `source` | FiveM's join event, emitted when a player is assigned a session, and seen by a resource before `onPlayerConnected`. `source` is the new player id. `oldId` is always the empty string: FiveM puts the temporary connection id there, and an Open77 player is given one id and keeps it. |\n| `playerDropped` | None | `(reason)` with `source` | FiveM's leave event, emitted for every disconnection beside `onPlayerDisconnected`. `source` is the player who left; `reason` is the same text `onPlayerDisconnected` receives. Both arrive in the same tick and either is a complete account of the leave — do not sequence work across the two. |\n| `onPlayerLifeStateChanged` | None | `(playerId, revision, phase, reason)` | The authoritative life state machine moved. `phase` is `alive`, `dead`, `revivepending`, `respawnpending` or `recovering`; `revision` increases per player and lets you drop an out-of-order notification; `reason` is the token that caused the move (`registered`, `restored`, `resync_requested`, `transition_timeout`, or the reason given to `kill`, `revive` or `respawn`). |\n| `onPlayerLifeTransitionFailed` | None | `(playerId, revision, reason)` | A life transition was not applied. `reason` is `timeout` when the client never acknowledged, otherwise the lowercased client result. The player keeps the phase they had. |\n| `Open77.players.identity` / `GetPlayerIdentity` | None | `(playerId)` | `{ userId, name, publicKey, fingerprint, joinedAt, license?, steam?, gog? }` for an admitted player, or `nil`. Ownership fields come from the verified Master ticket since `2.31.13+op77.101`. |\n| `Open77.time.unix` / `GetUnixTime` | None | `()` | Wall-clock seconds since 1970 UTC, fractional. For expiring bans; the sandbox has no `os`. |\n| `Open77.time.utc` / `GetUtcTimestamp` | None | `()` | The same instant as an ISO 8601 string. |\n| `Open77.access.status` | `players.access` | `()` | The server's built-in door list, shared with Warden and the console: `{ whitelistEnabled, whitelist = { { userId, label, addedAt, addedBy } }, bans = { { userId, name, reason, bannedAt, expiresAt?, bannedBy } } }`. |\n| `Open77.access.setWhitelist` | `players.access` | `(enabled)` | Switch the built-in whitelist. `true`, or `false, reason`. |\n| `Open77.access.allow` / `disallow` | `players.access` | `(userId, label?)` / `(userId)` | Add an identity to the built-in whitelist, or take it off. |\n| `Open77.access.isAllowed` / `isBanned` | `players.access` | `(userId)` | Read the built-in list. |\n| `Open77.access.ban` | `players.access` | `(userId, reason?, seconds?, name?)` | Ban an identity on the built-in list (`seconds` nil or 0 = permanent) and disconnect them if online. |\n| `Open77.access.unban` | `players.access` | `(userId)` | Lift a built-in ban. |\n\nThe gate answers within `simulation.connectGateTimeoutSeconds` (default 8, allowed 0.5 to 9); a\ngate that never answers refuses the player with `connection_gate_timeout`. A handler error is\nlogged and counts as an acceptance. A resource with a handler but without `players.gate` is\nignored with one `WRN`.\n\nWho can *see* whom is a separate pair of events, `onPlayerEnteredScope` / `onPlayerLeftScope`\n(and FiveM's `playerEnteredScope` / `playerLeftScope`): see [Scope events](#scope-events) under\nrouting buckets, because scope is the bucket narrowed to the interest radius.","natives":[],"permissions":[],"events":[]},{"anchor":"operator-tunable-settings","heading":"Operator-tunable settings","level":2,"text":"Settings a server owner may retune from the Warden admin panel while the server runs, without a\nrestart. The resource declares what is tunable; the panel can only move values inside that\ndeclaration, and never edits Lua. Server-side only.\n\n| Function | Signature | Result / purpose |\n|---|---|---|\n| `Open77.tunables.declare` | `(table)` | Declare this resource's tunables and return a live proxy. Raises if the declaration is malformed. |\n| `Open77.tunables.get` | `(key)` | The current value, or `nil, reason` for an undeclared key. |\n| `Open77.tunables.set` | `(key, value)` | Write one. Returns `ok, message, pending`. Same validation and persistence as a panel write. |\n| `Open77.tunables.capture` | `()` | A frozen plain table of every current value, to pin onto one match. |\n| `Open77.tunables.pending` | `()` | Key → value for changes waiting on a boundary. |\n| `Open77.tunables.promote` | `()` | Adopt everything waiting; returns the list of keys that moved. |\n\nDeclaration fields: `value` (required default), `type` (`number`, `integer`, `boolean`, `string`,\n`enum`), `min`, `max`, `step`, `choices`, `unit`, `apply` (`live`, `next_round`, `next_match`),\n`label`, `description`, `group`, `order`. `min`/`max`/`type`/`choices` are enforced on every write;\n`step` and `unit` are presentation only. Limits: 128 tunables per resource, 64 KiB of declaration,\n32 choices, 256-character strings.\n\n**The proxy returned by `declare` reads through to the host on every access.** Read it at the point\nof use; a value hoisted into a file-scope local is captured at load time and never updates, while\nthe panel goes on reporting the new number. An undeclared key raises rather than returning `nil`.\n\nValues are owned by the host, so they survive a reload **and** a stop, and come back after a\nrestart from `tunables.json` next to `server.jsonc`. A key declared `next_round` or `next_match` is\nstored and persisted immediately but does not reach `Open77.tunables.get` until the resource calls\n`promote()`; for a mode running several rounds at once, `capture()` per match is the only correct\nform. The owning resource — and no other — receives `onTunableChanged(key, value, pending)` after\nevery accepted write.","natives":[],"permissions":[],"events":[]},{"anchor":"notifications","heading":"Notifications","level":2,"text":"These methods route to the official [`open77_notifications`](https://github.com/Open2077/open77-resources/tree/main/system/open77_notifications) client package. IDs and mutation rights\nare isolated per calling server resource.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.notifications.send` | `(playerId, definition)` | Owner-local notification ID, or `nil, reason`. |\n| `Open77.notifications.broadcast` | `(definition)` | Owner-local broadcast ID, or `nil, reason`. |\n| `Open77.notifications.update` | `(id, patch)` | `boolean, reason?` |\n| `Open77.notifications.dismiss` | `(id)` | `boolean, reason?` |\n| `Open77.notifications.clear` | `(playerId?)` | Clear this owner's notifications for one player or everyone. |\n\nSee [notifications](notifications.md) for every definition field and queue limit.","natives":[],"permissions":[],"events":[]},{"anchor":"perspective-policy","heading":"Perspective policy","level":2,"text":"Whether players on this server may use third person. The calling server resource must declare\n`network.events`; the policy is broadcast to every client and answered again to anyone who joins\nlater.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.perspective.setPolicy` | `(policy, perspective?)` | `true`, or `false, reason`. |\n| `Open77.perspective.policy` | `()` | `policy, perspective, declared` — what **this** resource set. |\n\n`policy` is `disabled`, `allowed`, `default` or `forced`; `perspective` is `fpp` or `tps` and is\nrequired for `forced`, because a pin with nothing pinned would quietly become first person.\n\nThe client arbiter ranks a server policy above the player's own preference and every world state\nabove both. A `forced` policy does not erase what the player asked for: lifting the pin returns\nthem to it.\n\nCall `setPolicy` from the resource's **start path** — a reload replaces the VM, and the policy\nlives in it. A server on which no resource ever calls this sends nothing and every client keeps the\nplatform default `allowed`. If two resources declare a policy, both answer a joining client and the\nlast write wins; one owner, normally the gamemode, is the remedy.\n\nSee [perspective](../docs/perspective.md) for the priority table, the player-facing key and\npreference, and the measured limitations.","natives":[],"permissions":[],"events":[]},{"anchor":"synchronized-rp-animations","heading":"Synchronized RP animations","level":2,"text":"`Open77.animations` supplies server-owned player actions. See the\n[RP guide](rp-animations.md) and [complete profile/clip catalogue](rp-animation-catalogue.md)\nfor ownership, sequence durations, events, local TPP and development validation status.\n\n| Method | Signature | Permission / result |\n|---|---|---|\n| `Open77.animations.list` | `(query?)` | No permission; profile array. |\n| `Open77.animations.get` | `(profileId)` | No permission; profile or nil. |\n| `Open77.animations.play` | `(playerId, profileId, options?)` | `players.animations.control`; accepted state. |\n| `Open77.animations.playAt` | `(playerId, profileId, position, yaw?, options?)` | `players.animations.control`; place a nearby player at a world pose and start the profile, returning accepted state with `playbackId` and `anchor`. |\n| `Open77.animations.sequence` | `(playerId, steps, options?)` | `players.animations.control`; accepted state. |\n| `Open77.animations.stop` | `(playerId, playbackId?)` | `players.animations.control`; true or nil/error. |\n| `Open77.animations.stopAt` | `(playbackId)` | `players.animations.control`; stop an owned placed action by its playback handle. |\n| `Open77.animations.current` | `(playerId)` | `players.animations.read`; active state or nil. |\n| `Open77.animations.clip` | `(clip)` | No permission; the profile that owns a clip name, or nil. |\n| `Open77.animations.clips` | `(query?)` | No permission; every addressable clip as `{ clip, profile }`. |\n| `Open77.animations.playClip` | `(playerId, clip, options?)` | `players.animations.control`; `play` addressed by clip name, the way `TaskPlayAnim` is. |\n\n`play` and `sequence` return `nil, error` on rejection. An accepted server action\ndoes not prove native rendering has started. Client methods are a separate,\nPromise-based self-request API; do not use their signatures on the server.","natives":[],"permissions":[],"events":[]},{"anchor":"cyberware","heading":"Cyberware","level":2,"text":"The authoritative half of the [cyberware API](cyberware.md). It separates definition, identity,\nmanagement, temporary loadouts and reads into five permissions, because the resource that *defines*\nan implant, the one that *sells* it and the one that *reads* it are rarely the same resource.\n\n| Function | Permission | Signature | Result / purpose |\n|---|---|---|---|\n| `Open77.cyberware.define` | `players.cyberware.define` | `(definition)` | Register a definition for this resource. `arms`/`gorilla_arms` and `legs`/`double_jump` coexist. |\n| `Open77.cyberware.bind` | `players.cyberware.identity` | `(playerId, characterKey)` | Begin loading this authenticated user's selected character. |\n| `Open77.cyberware.unbind` | `players.cyberware.identity` | `(playerId)` | Release this resource's binding and its runtime projection. |\n| `Open77.cyberware.current` | `players.cyberware.read` | `(playerId)` | The committed record, or `nil` while loading or projecting. |\n| `Open77.cyberware.effective` | `players.cyberware.read` | `(playerId)` | The record gameplay should use, including an active temporary loadout. |\n| `Open77.cyberware.activity` | `players.cyberware.read` | `(playerId)` | The live projector observation — charge, hold, phase — or `nil`. |\n| `Open77.cyberware.leaseState` | `players.cyberware.read` | `(playerId)` | The temporary lease, or `nil` when none remains. |\n| `Open77.cyberware.lease` | `players.cyberware.temporary` | `(playerId, definition, grade, options?)` | Stage a temporary loadout; `{ok=true,lease=…,ticket=…}`. |\n| `Open77.cyberware.releaseLease` | `players.cyberware.temporary` | `(playerId, leaseId)` | Release it and restore the paid-for state. |\n| `Open77.cyberware.newOperationId` | `players.cyberware.manage` | `()` | A fresh 32-character durable operation ID; retain it across retries. |\n| `Open77.cyberware.install` | `players.cyberware.manage` | `(playerId, definition, grade, options?)` | Stage an installation; completion is asynchronous. |\n| `Open77.cyberware.remove` | `players.cyberware.manage` | `(playerId, options?)` | Stage a removal; defaults to arms, accepts `options.slot=\"legs\"`. |\n| `Open77.cyberware.cancel` | `players.cyberware.manage` | `(playerId, ticket)` | Cancel this resource's staging and request rollback. |\n\n`current` is what the player owns; `effective` is what they are currently running. A lease changes\nthe second and never the first, which is what lets an arena hand out an implant without charging\nanyone or rewriting a durable record.\n\n**Install and remove complete asynchronously.** Pass one `options.operationId` from\n`newOperationId()` and reuse it on every retry — that is what makes the operation idempotent across\na retry, a reload or a reconnect. `cancel` answers `operation_committing` once the SQL commit has\nbegun: past that point the operation is going to land, and cancelling is no longer meaningful.\n\nCyberware events, queued into every running server resource:\n\n| Event | Arguments | Meaning |\n|---|---|---|\n| `onCyberwareOperationCompleted` | `(playerId, ticket, encodedResult)` | A staged install or remove finished. `ticket` matches the one the staging call returned. |\n| `onCyberwareLeaseChanged` | `(playerId, encodedState)` | A temporary loadout was granted, replaced or released. |\n| `onCyberwareJump` | `(playerId, encodedResult)` | `{sequence, ok, error}` — second-jump **admission**, not native movement completion. |\n| `onCyberwareMeleeHit` | `(victimId, attackerId, encodedHit)` | An accepted Gorilla contact: `{sequence, incarnation, instanceId, definition, grade, charged, amount, bodyPart, lethal}`. Zero-knockback and cosmetic contacts still emit. |\n| `onCyberwareMotionOutcome` | `(victimId, attackerId, encodedOutcome)` | What the hit's knockback did: `outcome` is `pending`, `rejected` or `skipped`, with a `reason` such as `lethal`, `downed`, `zero_distance`, `bucket_mismatch` or `motion_unavailable`. |\n| `onCyberwareMeleeBlocked` | `(victimId, attackerId, sequence, amount)` | A contact the victim blocked. |\n| `onCyberwareActionRejected` | `(playerId, sequence, reason)` | Server admission refused a swing: `insufficient_stamina`, `cooldown`, `charge_expired` or `not_charged`. Repeats of the same rejection are suppressed. |\n\n`encodedResult`, `encodedHit`, `encodedState` and `encodedOutcome` arrive as JSON strings; decode\nthem with `json.decode`. Every payload describes what the **server admitted**, never what a client\nrendered. The `open77:cyberware:*` projection namespace is platform-owned and cannot be forged with\npublic Lua event dispatch.","natives":[],"permissions":[],"events":[]},{"anchor":"forced-motion","heading":"Forced motion","level":2,"text":"Experimental. `Open77.motion` asks the native layer to move a player's body without teleporting it:\nthe server admits the request and the owning client performs it.\n\n| Function | Permission | Signature | Result / purpose |\n|---|---|---|---|\n| `Open77.motion.knockdown` | `players.motion.control` | `(playerId, { x, y, distance, durationMs? })` | Request a planar knockdown; `{ok=true,id=…}`, or `nil, reason`. `durationMs` (1-3000) ends the active lease that many milliseconds after the owner reports the body down, with `reason = \"duration_elapsed\"`; absent or 0 keeps the native three seconds. |\n| `Open77.motion.cancel` | `players.motion.control` | `(playerId, id)` | Cancel a request this resource owns. |\n| `Open77.motion.current` | `players.motion.read` | `(playerId)` | The current record, or `nil`. |\n\nThe direction is normalised by the backend and the distance is bounded to 0–6 metres. **Native\nphysics decides the actual displacement: this is not an exact-distance teleport, and an accepted\nrequest is not proof of movement.** The body must be ready, alive, unmounted and in its original\nincarnation and bucket; overlapping requests are refused.\n\n`current` returns `id`, `player`, `incarnation`, `bucket`, `directionX`, `directionY`, `distance`,\n`createdAt`, `expiresAt` and `phase`, with times in server-monotonic milliseconds.\n\n`onPlayerMotionChanged(playerId, id, phase, reason)` reports the three phases. Admission starts\n`pending`; `active` means the **owner acknowledged the native observation**, not that the server saw\nan animation or a displacement, and it opens a bounded six-second reaction and recovery window;\n`ended` closes the record. Cancellation and resource stop both send an ended record and release only\nthe requesting resource's native ownership.\n\nThe client half — `Open77.motion.knockdown`, `state` and `stop` — is a different API in a different\nruntime with different arguments; see the generated client reference.","natives":[],"permissions":[],"events":[]},{"anchor":"player-clothing","heading":"Player clothing","level":2,"text":"These asynchronous methods target the official [`open77_equipment`](https://github.com/Open2077/open77-resources/tree/main/standalone/open77_equipment) client relay. The calling server\nresource must declare `network.events`. Request IDs are prefixed with the resource name, and only\nthat resource VM can match the completion.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.clothing.equip` | `(playerId, record, options?)` | Request ID, or `nil, reason`. |\n| `Open77.clothing.unequip` | `(playerId, slot)` | Request ID, or `nil, reason`. |\n| `Open77.clothing.set` | `(playerId, wardrobe, options?)` | Request ID, or `nil, reason`. |\n| `Open77.clothing.clear` | `(playerId)` | Request ID, or `nil, reason`. |\n| `Open77.clothing.requestSnapshot` | `(playerId)` | Request ID, or `nil, reason`. |\n\nListen for the resource-local `open77:clothing:completed` event. Its arguments are\n`playerId, requestId, operation, accepted, reason, result`. Dispatch failures are returned\nimmediately; unanswered requests complete with `request_timeout` after 10 seconds. Full record,\nslot and result semantics follow the relay's equipment registry. `set` accepts a\nslot-to-record table, for example `{outer_chest=\"Items.Jacket_01_basic_01\"}`;\n`false` empties a slot and omitted slots stay unchanged. A snapshot returns rows\nwith `slot`, `attachmentSlot`, `equipped`, and optional `record`/`tweakDbId`.\nThese writes follow normal presentation persistence; completion alone is not\nproof of observer appearance. An active wardrobe outfit can hide equipment.\nSee the [installed clothing catalog](../docs/research/clothing-items-catalog.md).","natives":[],"permissions":[],"events":["open77:clothing:completed"]},{"anchor":"wanted-level-and-ncpd-dispatch","heading":"Wanted level and NCPD dispatch","level":2,"text":"| Function | Signature | Result |\n|---|---|---|\n| `Open77.players.setWanted` | `(playerId, level)` | `{ level, requested, maxLevel }`, or `nil, reason`. |\n| `Open77.players.setMaxWanted` | `(playerId, level)` | `{ level, maxLevel }`, or `nil, reason`. |\n| `Open77.players.getWanted` | `(playerId)` | `{ level, maxLevel, enabled }`, or `nil, reason`. |\n| `Open77.world.setPreventionEnabled` | `(bucket, enabled)` | `{ bucket, enabled }`, or `nil, reason`. |\n\nLevels run 0 to 5. `setWanted(id, 0)` is `ClearPlayerWantedLevel`. The host event\n`onPlayerWantedChanged(playerId, level, previous)` fires when the value changes.\n\n```lua\n-- open77.lua: permissions { \"players.wanted\", \"players.wanted.read\", \"world.prevention\" }\nOpen77.players.setMaxWanted(playerId, 3)\nlocal applied = Open77.players.setWanted(playerId, 5)\nprint(applied.requested, applied.level) -- 5 3 (the ceiling was applied here)\n\nAddEventHandler(\"onPlayerWantedChanged\", function(player, level, previous)\n print((\"player %d: %d -> %d\"):format(player, previous, level))\nend)\n\nOpen77.world.setPreventionEnabled(0, false) -- no dispatch anywhere in bucket 0\n```","natives":["server:Open77.players.setMaxWanted","server:Open77.players.setWanted","AddEventHandler","server:AddEventHandler","server:Open77.world.setPreventionEnabled"],"permissions":["players.wanted","players.wanted.read","world.prevention"],"events":[]},{"anchor":"read-this-before-building-a-chase-on-it","heading":"Read this before building a chase on it","level":3,"text":"**The engine has no per-player wanted level.** Measured on 2.31, and it is not a\ndetail: `PreventionSystem` holds ONE heat stage, for the one player its client is\nrunning. `ChangeHeatStage(stage, reason)`, `GetHeatStage()` and\n`SetMinMaxResetHeatLevels(min, max, isDefault)` take no target, the state is\nscalar on a singleton system, that system holds exactly one `player` handle, and\nevery consumer — the wanted bar, sense components, vehicles, minimap pins — reads\none global `UI_WantedBar` blackboard.\n\nA per-player number exists here only because **every Open77 player runs their own\ngame**: the server keeps the value and tells each client its own. Three\nconsequences follow, and none of them is visible in the shape of the API:\n\n- **The police are local.** The NCPD units a heat stage spawns belong to the\n client that spawned them. They are not Open77 entities, they are not\n replicated, and no other player sees them. Two wanted players standing together\n are two private pursuits.\n- **The server never reads the game.** There is no value channel back from\n REDscript, only a per-command acknowledgement, so `getWanted` and\n `onPlayerWantedChanged` report a decision this server made — never an\n observation. If something in the client's own world clears the heat, the server\n does not find out.\n- **Population suppression wins.** While a client has world population\n suppression on, `VehicleSpawnPolicy` holds the prevention block flags\n (`SetSystemLock`, `SetBlockOnFootSpawn`, `SetBlockVehicleSpawn`), so a heat\n stage would be set and no unit would ever arrive. The client refuses the raise\n with `population_suppressed` rather than reporting a success nobody can see.\n Clearing to 0 is always allowed. `Open77.world.setPreventionEnabled` is a\n *different* switch (`TogglePreventionSystem`) and deliberately never touches\n those three flags, so the two cannot fight.","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-vanilla-route-does-that-this-does-not","heading":"What the vanilla route does that this does not","level":3,"text":"The game's own path is a `SetWantedLevel` **request object**, not a method, and\nits fields are private: `new SetWantedLevel()` and `QueueRequest` compile, while\nevery read *and* write of `wantedLevel` fails `[UNRESOLVED_MEMBER]`, even from\ninside an `@addMethod` body. So Open77 goes through `ChangeHeatStage`, and the\nrequest's `forceGreyStars`, `resetGreyStars` and\n`forcePlayerPositionAsLastCrimePoint` behaviour is **not** reproduced.\n\n`SetWantedLevelFact` is not used either: the fact is something the system\n*publishes* and never reads. Forcing it to zero left the star on screen and the\nfact back at zero.\n\nClient resources reach the same levers directly through `Open77.prevention`\n(`setWanted`, `setMaxWanted`, `setEnabled`, `state`) under the `world.prevention`\npermission. `state()` is a **request record**, not an engine reading — its fields\nare named `requestedLevel`, `levelAcknowledged` and so on for that reason.","natives":[],"permissions":[],"events":[]},{"anchor":"player-appearance","heading":"Player appearance","level":2,"text":"A saved face and body, restored on a player without that player asking for it.\nRelayed exactly like clothing, for the same reason: the addressed client owns the\nREDengine player object, so the server asks and the client answers.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.appearance.capture` | `(playerId)` | Request ID, or `nil, reason`. |\n| `Open77.appearance.apply` | `(playerId, snapshot, options?)` | Request ID, or `nil, reason`. |\n\nThe calling resource must declare **`players.appearance.relay`** as well as\n`network.events`. That is a permission of its own, not a corner of\n`player.appearance.edit` (a *client*-side grant over the local player) or of\nclothing (garments a player can see and take off again): a snapshot is the output\nof the character creator, the closest thing this platform holds to a likeness of\nthe person playing. A resource that can read one can fingerprint every player on\nthe server, and one that can write one can make somebody wear a face they did not\nchoose. Nothing in the relay writes a snapshot to a log.\n\nListen for the resource-local `open77:appearance:relayCompleted` event with\n`playerId, requestId, operation, accepted, reason, result`. Request IDs carry both\nthe resource name and an `appearance` segment, so an appearance answer can never\ncomplete a clothing or weapon request. Dispatch failures return immediately;\nan unanswered request completes with `request_timeout` after 10 seconds rather\nthan leaving the caller waiting.\n\n`capture` answers `{ snapshot, family, revision, characterKey }`. The `snapshot` is\nthe network form — schema version, game build, catalogue digest, gender, and the\noption identities and values — with the editor's local metadata stripped. It is the\nsame shape `apply` takes, so a capture can be stored and handed straight back.\n\n`apply` does not reach the native layer behind [`open77_appearance`](https://github.com/Open2077/open77-resources/tree/main/system/open77_appearance)'s back. The\nsnapshot becomes that player's new appearance **record** — the server's word about\nwhat they wear — and the resource's ordinary reconciliation puts it on the body.\nThat is the road a stored appearance already takes at world entry, so a relayed\nface survives death, a bucket change and a reload instead of being quietly undone\nby the next reconcile.\n\nTwo limits worth knowing before you build on it:\n\n- **It does not change the body family.** A different family is a player *reload*,\n not an appearance change, and a caller restoring a face has not asked for that. A\n snapshot whose gender disagrees with the body on the player is refused with\n `body_family_mismatch`.\n- **It does not persist.** The platform's stored appearance row is untouched; a\n resource that wants the change to outlive the session owns that persistence.\n\nRefusals from the client are named: `appearance_busy` (an editor, the character\ncreator or a fitting-room preview is open), `player_not_ready`,\n`appearance_restore_failed`, `appearance_record_unavailable`. Completion reports\n`apply_timeout` when the record was set but the body had not settled within eight\nseconds, and `appearance_superseded` when a newer apply replaced it.\n\n```lua\n-- open77.lua: permissions { \"network.events\", \"players.appearance.relay\" }\nlocal stored = {}\n\nAddEventHandler(\"open77:appearance:relayCompleted\",\n function(player, requestId, operation, accepted, reason, result)\n if not accepted then print(\"appearance \" .. operation .. \" failed: \" .. reason); return end\n if operation == \"capture\" then stored[player] = result.snapshot end\n end)\n\nOpen77.appearance.capture(playerId)\n-- ...later, on a fresh session:\nOpen77.appearance.apply(playerId, stored[playerId])\n```","natives":["AddEventHandler","server:AddEventHandler","Open77.appearance.capture","server:Open77.appearance.capture","Open77.appearance.apply","server:Open77.appearance.apply"],"permissions":["network.events","players.appearance.relay"],"events":["open77:appearance:relayCompleted"]},{"anchor":"player-weapons","heading":"Player weapons","level":2,"text":"These asynchronous methods target the official [`open77_weapons`](https://github.com/Open2077/open77-resources/tree/main/system/open77_weapons) client relay.\nThe calling resource declares `network.events` and depends on\n`open77_weapons >=0.1.0`.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.weapons.assign` | `(playerId, record, slot, options?)` | Request ID, or `nil, reason`. |\n| `Open77.weapons.setActive` / `activate` | `(playerId, slot)` or `(playerId, record, options?)` | Request ID, or `nil, reason`. |\n| `Open77.weapons.remove` / `unequip` | `(playerId, slot)` | Request ID, or `nil, reason`. |\n| `Open77.weapons.holster` | `(playerId)` | Request ID, or `nil, reason`. |\n| `Open77.weapons.requestSnapshot` | `(playerId)` | Request ID, or `nil, reason`. |\n\nListen for `open77:weapons:completed` with\n`playerId, requestId, operation, accepted, reason, result`. Replies are matched\nto the authenticated target and calling resource; unanswered requests complete\nwith `request_timeout` after 10 seconds. See\n[Weapon Lua API](weapons-api.md) for options, result schemas, permissions,\nerrors, and inventory-authority rules.","natives":[],"permissions":[],"events":["open77:weapons:completed"]},{"anchor":"reading-what-a-player-is-carrying","heading":"Reading what a player is carrying","level":3,"text":"`Open77.weapons.get(playerId)` (permission `player.weapons.read`) answers the three slots, the\nactive slot, whether the weapon is drawn and the magazine. Its low-level alias is\n`GetPlayerWeapons`.\n\n**It is a cache of what the owner reported, never authority**, and it reports **two** ages rather\nthan one because the two halves refresh at different rates. `reportedAgeMs` dates `active`,\n`drawn` and `magazine`, which ride the 20 Hz player snapshot, and carries `fresh` under the same\ntwo-second rule `Open77.players.get` applies. `loadoutAgeMs` dates `slots`, which is pushed only\nwhen it changes -- minutes there is normal and is not a fault. `source` separates a `report` from\na `snapshot`, and a player nobody has heard from fails `weapons_unreported`, which is a different\nanswer from carrying nothing. A clear `WeaponValid` bit means \"not reported\", never \"unarmed\".\n\nHost event `onPlayerWeaponChanged(playerId, slot, record, drawn)` fires on a change.\n`Open77.weapons.clear(playerId)` empties all three slots as one relay operation rather than three\nremovals; an already-empty slot is not a failure. Full schemas are in\n[Weapon Lua API](weapons-api.md).","natives":[],"permissions":[],"events":[]},{"anchor":"record-catalogues","heading":"Record catalogues","level":2,"text":"`Open77.data.vehicle / weapon / item / npc(record)` answer what a 2.31 record is. The low-level\nalias behind all four is `GetRecordData`, called as `GetRecordData(kind, record)`.\n\nA `record` is a TweakDB **string**, never a Jenkins hash -- the same choice the `Citizen` alias\nlayer already made, and a porting creator should expect it: there is no number to compare and no\n`GetHashKey` to round-trip through. `source` tells you where the answer came from: `catalogue` on\nthe server, `tweakdb` on a client, which reads the live database and gets the display name in the\nplayer's own language. `Open77.data.localize` is client-only and fails `localization_client_only`\non the server rather than hand back a key that could pass for a translation. One asymmetry worth\nknowing: server `npc()` takes an entity template path, client `npc()` takes a `Character.*`\nrecord. See [Record catalogues](data-catalogues.md).","natives":[],"permissions":[],"events":[]},{"anchor":"held-items","heading":"Held items","level":2,"text":"An item in one of the body's ten equipment slots, given through the engine's own\ntransaction system, so it is genuinely held rather than following the body. The\nother half of the attachment surface: `Open77.props.attach` **follows**, this\n**holds**. These asynchronous methods target the official [`open77_helditems`](https://github.com/Open2077/open77-resources/tree/main/system/open77_helditems)\nclient relay; the calling resource declares `network.events`.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.heldItems.hold` | `(playerId, record, { slot? })` | Request ID, or `nil, reason`. |\n| `Open77.heldItems.release` | `(playerId, { slot? })` | Request ID, or `nil, reason`. |\n| `Open77.heldItems.slots` | `()` | The ten slot names, sorted. |\n| `Open77.heldItems.requestSnapshot` | `(playerId)` | Request ID, or `nil, reason`. |\n\nThe slot vocabulary is the ten `AttachmentSlots.*` equipment records: `Head`,\n`Face`, `InnerChest`, `OuterChest`, `Legs`, `Feet`, `Outfit`, `UnderwearTop`,\n`UnderwearBottom`, `WeaponRight`. `WeaponRight` is the right hand and the\ndefault. What is held must be an item **record**, not an entity.\n\nListen for `open77:helditem:completed` with\n`playerId, requestId, operation, accepted, reason, result`. Unanswered requests\ncomplete with `request_timeout` after 10 seconds. Failures are\n`invalid_item_record`, `invalid_slot`, `invalid_player`, `player_not_ready` and\nwhatever the client equipment system refused. `Open77.weapons` writes the same\n`WeaponRight` slot, so pick one per gamemode. See\n[props](props.md#open77helditemshold--put-an-item-in-a-hand) for the full\ncontract and its limits.","natives":[],"permissions":[],"events":["open77:helditem:completed"]},{"anchor":"players-and-authoritative-life","heading":"Players and authoritative life","level":2,"text":"| Function | Permission | Signature | Result / purpose |\n|---|---|---|---|\n| `Open77.players.name` | None | `(playerId)` | Display name or `nil`. |\n| `Open77.players.identifier` | None | `(playerId)` | Persistent installation identity GUID (`userId`) or `nil`; use `license` from `identifiers` for account-level persistence. |\n| `Open77.players.identity` / `GetPlayerIdentity` | None | `(playerId)` | `{ userId, name, publicKey, fingerprint, joinedAt, license?, steam?, gog? }` or `nil`; the fingerprint matches the client's `identity.dump`. Ownership fields require `2.31.13+op77.101` or later. |\n| `Open77.players.position` | None | `(playerId)` | `{ x, y, z, bucket }` or `nil`. |\n| `Open77.players.all` / `GetPlayers` | None | `()` | Array of every authenticated player id, ascending. The host's roster, so a gamemode no longer needs its own copy built from join/leave events. |\n| `Open77.players.inBucket` / `GetPlayersInBucket` | None | `(bucket)` | Ids of the authenticated players whose routing bucket is `bucket`, ascending. `false, invalid_bucket` for a negative or oversized bucket. |\n| `Open77.players.positions` / `GetPlayersPositions` | None | `()` | `{ [playerId] = { x, y, z, bucket } }` for every player with a snapshot younger than two seconds. Players without one are absent, not `nil` entries. One call replaces a loop of `position` over the roster. |\n| `Open77.players.get` | None | `(playerId)` | Everything the host can answer about one player synchronously, including a **dated** transform. `nil, reason` for an unknown player. See [The rich read](#the-rich-read-and-the-freshness-decision). |\n| `Open77.players.nearby` | None | `(anchor, radius?, options?)` | Players within `radius` of a point or of another player, nearest first, each with its `distance`. |\n| `Open77.players.closest` | None | `(anchor, options?)` | The nearest entry `nearby` would return, or a bare `nil` when nobody matches. |\n| `Open77.players.distance` | None | `(a, b)` | Metres between two players, two points, or one of each. `nil, reason` otherwise. |\n| `Open77.players.getVehicleSeat` | `world.vehicles` | `(playerId)` | Canonical seat assignment or `nil`. |\n| `Open77.players.warpIntoVehicle` | `world.vehicles` | `(playerId, vehicleId, seat, options?)` | Authoritatively assign a seat; alias of the vehicle API. |\n| `Open77.players.forceOutOfVehicle` | `world.vehicles` | `(playerId, vehicleId?)` | Force native exit, overriding exit lock. |\n| `Open77.players.setVehicleExitLocked` | `world.vehicles` | `(playerId, locked, vehicleId?)` | Set or clear the durable no-exit policy. |\n| `Open77.players.disconnect` | `players.disconnect` | `(playerId, reason?)` | Queue a reasoned disconnect; `boolean, reason?`. |\n| `Open77.players.kick` | `players.disconnect` | `(playerId, reason?)` | Alias of `disconnect`. |\n| `Open77.players.ban` | `players.ban` | `(playerId, reason?, durationSeconds?)` | Persist a server-scoped device ban through the master and disconnect now. |\n| `Open77.players.getLifeState` | `players.life.read` | `(playerId)` | Canonical life snapshot or `nil`. `phase` is `alive`, `dead`, `revivepending`, `respawnpending`, `recovering` — **no underscore**, unlike the client. |\n| `Open77.players.isDead` | `players.life.read` | `(playerId)` | Whether phase is dead/revive-pending/respawn-pending. Resolved from the enum, so it is immune to the spelling split — prefer it over comparing `phase`. |\n| `Open77.players.kill` | `players.life.kill` | `(playerId, options?)` | `boolean, reason?` |\n| `Open77.players.revive` | `players.life.revive` | `(playerId, options?)` | `boolean, reason?` |\n| `Open77.players.respawn` | `players.life.respawn` | `(playerId, options)` | `boolean, reason?`. **Only for a player who is already dead** — it refuses with `invalid_state` otherwise. To move a living player, use `teleport`. |\n| `Open77.players.teleport` | `players.teleport` | `(playerId, position, options?)` | **Promise**, or `nil, reason`. Moves a living player with no life transition. |\n| `Open77.players.setHeading` | `players.teleport` | `(playerId, yaw)` | `boolean, reason?`. Fire and forget. |\n| `Open77.players.requestLifeResync` | `players.life.resync` | `(playerId)` | `boolean, reason?` |\n| `Open77.players.setGhosted` / `SetPlayerGhosted` | `players.life.ghost` | `(playerId, ghosted, options?)` | Toggle the server-owned non-solid spawn state and its optional automatic clear policy. |\n| `Open77.players.isGhosted` / `IsPlayerGhosted` | `players.life.read` | `(playerId)` | Whether the canonical life state is currently ghosted. |\n| `Open77.players.setFrozen` / `SetPlayerFrozen` | `players.life.freeze` | `(playerId, frozen)` | Hold the player's body where it stands, or release this resource's hold. `boolean, reason?` |\n| `Open77.players.isFrozen` / `IsPlayerFrozen` | `players.life.read` | `(playerId)` | Whether the canonical life state is currently frozen, by any resource. |\n| `Open77.players.setVisible` / `SetPlayerVisible` | `players.life.visibility` | `(playerId, visible)` | Hide a player's body from every other client, or show it again. Presentation only; see [Player visibility](#player-visibility) below. |\n| `Open77.players.isVisible` / `IsPlayerVisible` | `players.life.read` | `(playerId)` | Whether the canonical life state says this player is rendered. Answered from the server bit, never probed from a client. |\n| `Open77.players.setFallDamage` | `players.life.falldamage` | `(playerId, enabled)` | Switch one player's fall damage off (`false`) or back on. Fall damage **only** -- every other source of damage lands as before; this is not `setGodMode`. `boolean, reason?`. See [Fall damage](player-stats.md#fall-damage). |\n| `Open77.players.isFallDamageEnabled` | `players.life.read` | `(playerId)` | Whether landings still hurt this player. `Open77.players.get(playerId).fallDamage` carries the same bit. |\n| `Open77.players.ragdoll` | `players.motion.control` | `(playerId, { durationMs?, direction?, distance? }?)` | Put the body on the ground for up to 3 s; it gets up on its own. The engine's knockdown with a clock and a default facing; `{ok=true,id=…,durationMs=…}`, or `nil, reason`. See [ragdoll and clearTasks](player-freeze.md#putting-a-player-down-and-clearing-everything-ragdoll-and-cleartasks). |\n| `Open77.players.clearTasks` | any of `players.motion.control`, `players.animations.control`, `players.life.freeze` | `(playerId)` | End this resource's motion lease, animation playback and freeze claim on one player, reporting each half; another resource's holds are `not_owned` and untouched. `{ motion, animation, freeze, clear }`, or `nil, reason`. |\n| `Open77.players.spectate` / `SpectatePlayer` | `players.spectate` | `(playerId, targetId\\|false, options?)` | Put one player behind another player's shoulder: ghost, hide, and a follow camera on the target, as one transaction that fails closed. `targetId` false ends it and gives the body back, as do death, disconnect, reconnect, a bucket change and this resource stopping. `options`: `blendMs`, `distance`, `height`. See [Spectating](cameras.md#spectating). |\n| `Open77.players.spectating` / `GetPlayerSpectateTarget` | `players.life.read` | `(playerId)` | Who this player is watching, or `0`. |\n| `Open77.players.spectators` / `GetPlayerSpectators` | `players.life.read` | `(playerId)` | Everyone watching this player, ascending. |\n| `Open77.players.fade` / `FadePlayerScreen` | `players.screen` | `(playerId, out, durationMs?, options?)` | Cover (`out` true) or uncover one player's screen with the native quest fade. The client consumes the relay itself, so the target needs no client resource; `options.timeoutMs` (1000-60000, default 15000) is a ceiling the **client** enforces on real time, so a forgotten fade-in costs a pause, not a black screen. See [screen transitions](screen-transitions.md). |\n| `Open77.players.fadeOut` | `players.screen` | `(playerId, durationMs?, options?)` | `fade(playerId, true, ...)`. |\n| `Open77.players.fadeIn` | `players.screen` | `(playerId, durationMs?)` | `fade(playerId, false, ...)`. |\n| `Open77.players.getHealth` | `players.damage.read` | `(playerId)` | Health/armor/god-mode snapshot or `nil`. |\n| `Open77.players.damage` | `players.damage.apply` | `(playerId, amount, options?)` | Apply authoritative damage. |\n| `Open77.players.heal` | `players.damage.apply` | `(playerId, amount)` | Apply authoritative healing. |\n| `Open77.players.setHealth` | `players.damage.apply` | `(playerId, health)` | Set health; zero passes through life authority. |\n| `Open77.players.setMaxHealth` | `players.damage.apply` | `(playerId, maxHealth)` | Update the canonical maximum. |\n| `Open77.players.setArmor` | `players.damage.apply` | `(playerId, armor)` | Update canonical armor. |\n| `Open77.players.setGodMode` | `players.damage.apply` | `(playerId, enabled)` | Toggle canonical damage immunity. |\n| `Open77.players.setRegen` | `players.damage.apply` | `(playerId, pointsPerSecond)` | Set authoritative regeneration. |","natives":[],"permissions":[],"events":[]},{"anchor":"the-rich-read-and-the-freshness-decision","heading":"The rich read and the freshness decision","level":3,"text":"The commonest thing a server script does before it acts is a distance check, and\nuntil `get`, `nearby`, `closest` and `distance` existed every gamemode wrote that\ncheck again: a loop over `Open77.players.position`, a square root, and a\nworkaround for the two-second rule.\n\n**`Open77.players.position` is unchanged, deliberately.** It still answers `nil`\nonce the player's last accepted snapshot is more than two seconds old, and that\nsilence is load-bearing: the server refuses to restamp a rejected or replayed\nsnapshot precisely so stale state cannot be laundered into freshness, and loot\npickup, damage context, interest reconciliation and voice routing all arbitrate\nthrough the same accessor. Bundled resources already read that `nil` as \"stale\".\nChanging it would have been a silent behaviour change in every one of them.\n\nWhat was wrong was not the rule but the *reporting*. A script that only wants to\nknow how far away somebody is received `nil` for \"this player vanished\" and `nil`\nfor \"I cannot date this reading\", and had to guess. So the always-answering\nbehaviour lives on the new names:\n\n```lua\nlocal read = Open77.players.get(playerId) -- never silent on staleness\nif read and read.position and (read.ageMs or 0) < 5000 then\n -- your threshold, not ours\nend\n```\n\n`read.fresh` is true exactly when `Open77.players.position` would return a position. A player without an accepted snapshot has neither `position` nor `ageMs`, which differs from a stale reading. Position freshness is not a readiness or liveness check: use the player's readiness gate before placement or gameplay actions.","natives":["server:Open77.players.get"],"permissions":[],"events":[]},{"anchor":"open77playersgetplayerid","heading":"`Open77.players.get(playerId)`","level":4,"text":"Always present: `playerId`, `bucket`, `fresh`, `ready`. Present when the player\nhas ever reported: `ageMs`, `position` (`{ x, y, z }`), `heading` and its alias\n`yaw`, `velocity` (`{ x, y, z }`), `speed`, `stateFlags`, `flags`, and\n`supportKind`/`supportId` when the player is standing on a moving support.\nPresent when the host can resolve them: `name`, `identifier`, `userId`.\n\n`flags` names the snapshot's state bits: `animationValid`, `sprinting`,\n`crouched`, `sliding`, `vaulting`, `grounded`, `weaponValid`, `weaponEquipped`,\n`combatValid`, `aiming`, `triggerDown`, `reloading`. The three `*Valid` bits sit\nbeside the bits they qualify on purpose -- a client only fills a block when its\nvalidity bit is set, so `grounded == false` under `animationValid == false` means\n\"not reported\", not \"airborne\". `stateFlags` is the raw wire value, so a bit we\nhave not named is never a blocker.\n\nThree groups are **absent rather than false** unless the calling resource already\nholds the permission that guards them elsewhere. Surfacing them here without it\nwould be a way around the gate on `getVehicleSeat`, `getHealth` and\n`getLifeState`:\n\n| Fields | Permission | Note |\n|---|---|---|\n| `inVehicle`, plus `vehicleId`/`seat`/`entering`/`exiting`/`exitLocked` when seated | `world.vehicles` | With the permission `inVehicle` is always present and may be `false`; without it the key is absent. `inVehicle` is the server's canonical seat reservation, which exists before the client has finished mounting. Authoritative, not snapshot-gated. |\n| `health`, `maxHealth`, `armor`, `godMode` | `players.stats.read` (or legacy `players.damage.read` / `players.life.read`) | Authoritative, not snapshot-gated. |\n| `alive`, `dead`, `lifePhase`, `ghosted` | `players.life.read` | `lifePhase` uses the server spelling -- `alive`, `dead`, `revivepending`, `respawnpending`, `recovering`, no underscore. Prefer `dead` over comparing the string. |\n\nThe read itself needs **no permission**. That is not an assumption about what\nother runtimes do -- the client's life reads *are* gated, behind\n`players.life.read`, and `Open77.world`'s geometry reads are gated behind\n`world.query`. It is the *server's own* standing policy for this family: a\nserver resource can already learn a player's id, name and position from\n`Open77.players.all`, `name` and `position`, none of which asks for anything,\nbecause a player's presence is not more sensitive than what those already expose.\n`get` adds no new fact to that set without also adding its permission. Gating\nthe whole read instead would have made `get` strictly harder to use than the\n`position` call it replaces, which only pushes resources back to the old one.\n\nA rich read is not a state bag. Placement, motion and life are the server's\nobservations of a player and belong here; a job, a duty flag, a shift or anything\nelse a resource *decides* about a player belongs in that player's state bag,\nwhere it is named, replicated and owned by whoever set it. If you find yourself\nwanting a field here that no snapshot could ever carry, it is a bag field.\n\nNot included, and why: anything the server would have to ask a client for. There\nis no synchronous armour-piece list, weapon record, stance or animation name on\nthe server, and inventing one from the last snapshot would be a guess wearing a\nfield name. `position`, `velocity` and `flags` come from the snapshot and are\ntherefore dated by `ageMs`; everything in the permission table above is the\nserver's own record and is current.","natives":[],"permissions":[],"events":[]},{"anchor":"nearby-closest-distance","heading":"`nearby`, `closest`, `distance`","level":4,"text":"```lua\nlocal crowd = Open77.players.nearby(shopDoor, 25) -- a point\nlocal witnesses = Open77.players.nearby(robberId, 40) -- or a player\nlocal target = Open77.players.closest(playerId, { radius = 10 })\nlocal metres = Open77.players.distance(cop, robber)\n```\n\n`anchor` is a player id (an integer) or a point (a table with numeric `x`, `y`,\n`z`). The two are never ambiguous. A `vector3` **is** a point -- the shared\nvector type is a plain table with real `x`/`y`/`z` fields -- so vectors are\naccepted everywhere a point is, and the `{ x, y, z }` tables these reads return\ngo straight back into `vector3()`.\n\n`nearby` returns an array sorted nearest first, ties broken by player id so two\nidentical calls answer in the same order. Each entry carries `playerId`,\n`distance`, `bucket`, `fresh`, and `ageMs`/`name`/`position`/`heading`/`speed`\nwhere available. It is deliberately *not* the whole of `get`: filling the life,\ndamage and vehicle fields would mean asking three services once per player per\ncall for fields a proximity scan does not read. Call `get` on the one you picked.\n\nAn omitted or `nil` `radius` means no radius limit, which is how `closest` ranks\na whole bucket. `options`:\n\n| Option | Default | Meaning |\n|---|---|---|\n| `bucket` | the anchor player's own bucket; **every bucket** for a point anchor | An integer narrows to that routing bucket. `false` means every bucket, and is how you opt back out when the anchor is a player. |\n| `includeSelf` | `false` | Whether a player anchor appears in its own result. Ignored for a point anchor, which excludes nobody. |\n| `limit` | unlimited | Keep only the first *n* after sorting. |\n| `maxAgeMs` | unlimited | Drop players whose last reading is older than this. The default keeps stale readings, because a player whose snapshots stalled is still somewhere and silence is not an answer; `maxAgeMs` is how you ask for a threshold of your own. |\n| `radius` | -- | `closest` only, since it takes no positional radius. |\n\n`closest` returns a bare `nil` -- no second value -- when the search succeeded and\nnobody matched, and `nil, reason` when it failed. That is what separates an empty\nstreet from a bad argument.\n\nFailure tokens, stable across all four: `invalid_player_id`, `invalid_argument`,\n`invalid_position`, `invalid_radius`, `invalid_options`, `invalid_bucket`,\n`invalid_limit`, `invalid_max_age`, `player_not_found`, `position_unknown`\n(the player exists but has never reported a transform) and `sessions_unavailable`\n(an embedding that does not replicate).","natives":["Open77.players.nearby","server:Open77.players.nearby","Open77.players.closest","server:Open77.players.closest","server:Open77.players.distance"],"permissions":[],"events":[]},{"anchor":"freezing-a-player","heading":"Freezing a player","level":3,"text":"`Open77.players.setFrozen(playerId, frozen)` holds a player's body where it stands. It is the\nprimitive behind cuffs, a menu that must not be walked out of, a progress bar, a safe zone and a\nscripted beat. The client applies the engine's own `GameplayRestriction.NoMovement` -- the same\nrecord vanilla death applies -- so there is no animation fighting the hold, and the body does not\nslide on any observer's screen: a frozen player keeps sending snapshots, their position simply\nstops changing.\n\nBe precise about what a freeze is, because the usability of the feature is exactly this list.\n\n| Still works | Stopped |\n|---|---|\n| Looking around (mouse and controller camera) | Walking, running, sprinting, crouch-moving |\n| Chat and voice | Jumping, dodging, sliding, vaulting |\n| Opening menus and the inventory | Every other locomotion input, pressed or held |\n| Interaction prompts | |\n| **Taking damage, and dying** | |\n\nIt does **not** holster a weapon, block firing, or eject anyone from a vehicle. A freeze is a\nprimitive, not a policy: compose it with the control-blocking surface when a scene needs more.\nThe precedent is the death restriction, which has spared chat and voice since it shipped.\n\n**The hold is a claim, not a value.** It is keyed by the calling resource, so a jail script and a\ncutscene script may hold the same player at once, and `setFrozen(id, false)` drops only your own\nclaim -- he stays frozen while anybody else still holds him. `isFrozen` answers for all of them.\n\n**Nothing can strand a player.** A freeze is released by death, respawn, a bucket change,\ndisconnect, and by the resource that set it stopping, reloading or crashing. Death and respawn\nclear it outright, so a gamemode that wants a player held through a respawn must re-apply it\nafterwards. As with `setGhosted`, the call is refused with `transition_in_progress` while a revive\nor respawn is in flight -- a resource stop during one still thaws, because a refusal there would\nbe exactly the stranding this guards against.\n\n```lua\n-- Cuffs: two resources may hold the same suspect; each releases only its own hold.\nlocal ok, reason = Open77.players.setFrozen(playerId, true)\nif not ok then return print(\"could not freeze: \" .. tostring(reason)) end\n\nSetTimeout(30000, function()\n Open77.players.setFrozen(playerId, false)\n -- Still true here if another resource has a hold of its own on him.\n print(\"still held:\", Open77.players.isFrozen(playerId))\nend)\n```\n\nThe flag rides the canonical life state, so it is reliable, it survives a proxy re-stream, and it\nis readable as `Open77.players.getLifeState(playerId).frozen`. A client that predates the flag\ndecodes it and ignores it rather than disconnecting.\n\nFor a hold that needs no server round trip -- a local menu, a local progress bar -- the client has\n`Open77.character.setFrozen(frozen)` under the `player.freeze` permission. It is\npresentation-local, and the server's freeze outranks it: releasing a local hold while the server\nholds the player changes nothing. Both halves, the engine lever behind them and every release path\nare written up in [Freezing a player](player-freeze.md).","natives":["server:Open77.players.setFrozen","SetTimeout","server:SetTimeout","server:Open77.players.isFrozen"],"permissions":[],"events":[]},{"anchor":"player-visibility","heading":"Player visibility","level":3,"text":"`Open77.players.setVisible(playerId, false)` hides a player's body on every\nother client. It is the presentation half of the pair whose other half is\n`setGhosted`, and the two are deliberately independent: **visibility and\ncollision are separate levers**, so a player can be invisible and still solid\n(still shootable, still blocking a door), or ghosted and perfectly visible. That\nis what makes \"invisible but still simulated\" the default rather than a special\nmode. Admin invisibility and spectating are the intended uses.\n\n```lua\n-- An admin drops out of sight but stays a real body in the world.\nlocal ok, reason = Open77.players.setVisible(admin, false)\nif not ok then print(\"setVisible refused: \" .. reason) end\n\n-- ...and back.\nOpen77.players.setVisible(admin, true)\n```\n\nBoth calls return `true`, or `false, reason` — `permission_denied:players.life.visibility`,\n`player_not_found`, `invalid_argument`, `life_unavailable`, or\n`transition_in_progress` while a revive or respawn is mid-flight.\n\n**What it does and does not change.**\n\n| | Invisible player |\n|---|---|\n| Body rendered by other clients | **no** — the proxy's skinned meshes are hidden |\n| Nameplate on other clients | **no** — withheld with the body, and a resource cannot opt out |\n| Collision, aiming, being shot | unchanged — use `setGhosted` for that |\n| Audible in proximity voice | **yes, unchanged** — see below |\n| Present in `Open77.players.all()` and the client roster | **yes, unchanged** — see below |\n| Map markers a resource draws itself | unchanged — the resource decides |\n\n**Proximity voice is untouched: an invisible player is still heard.** Voice\nattenuation is computed from replicated positions and has no connection to this\nflag. Silencing him would be a second, unrelated policy — and a surprising one,\nsince a spectating admin usually wants to be able to talk. Mute him explicitly\nthrough the voice API if that is what the gamemode wants.\n\n**He also still appears in every roster.** `Open77.players.all()` on the server,\nand the client-side player enumeration, both list him: this row changes\npresentation, not membership. Hiding a player from a roster is a different\ndecision with different consequences (it would break scoreboards, chat targeting\nand admin tooling), so a resource that wants him omitted filters on the flag\nitself.\n\n**The answer comes from the server bit, and is never probed from the engine.**\nCyberpunk 2.31 exposes no readable \"is hidden\" flag on `entIVisualComponent`\n(the RTTI has `autoHideDistance`, `renderSceneLayerMask` and `forceLODLevel`, and\nnothing else), so no client can be asked whether a body is *actually* hidden\nright now. The life flag is the only truth: `isVisible` reads it, and each client\nre-asserts the hide on a 1 Hz beat so a render-proxy rebuild — which is exactly\nwhat dressing a garment performs — cannot quietly put the body back on screen.\n\n**Arbitration with the dressing phase.** A remote body is also hidden while it is\nbeing clothed, through the same engine lever and the same per-entity ledger. The\nrule is: **the body stays hidden while at least one owner holds it, and a release\nonly shows the body when it is the last hold.** So dressing an invisible player\nfinishes without revealing him, and showing a player who is still being dressed\ndoes not reveal a half-dressed puppet — the body appears when both are done, in\neither order.\n\n**Nothing strands a player invisible.** The flag is released on death, on\nrespawn, on a routing-bucket change, on disconnect, on reconnect, and when the\nresource that set it stops or reloads. A release refused because a life\ntransition is in flight is queued and retried, not dropped. This matters more\nthan it does for ghosting, which auto-clears on separation: invisibility has no\nsuch fallback, and no client can be asked to recover one.","natives":["server:Open77.players.setVisible"],"permissions":[],"events":[]},{"anchor":"unified-health-and-stamina-api","heading":"Unified health and stamina API","level":3,"text":"New resources should use `players.stats.read` and `players.stats.apply`. The\nlegacy `players.damage.*` permissions and `Open77.players` health methods remain\naccepted aliases. The client exposes the same read names and the same\nnested pool shape, but no setters. See [Player health and stamina](player-stats.md)\nfor the full synchronization model.\n\n| Function | Permission | Signature | Result / purpose |\n|---|---|---|---|\n| `Open77.stats.get` / `GetPlayerStats` | `players.stats.read` | `(playerId)` | Combined canonical health/stamina snapshot or `nil`. |\n| `Open77.stats.getHealth` / `health` | `players.stats.read` | `(playerId)` | Health pool `{ value, current, maximum, max, fraction, percentage, regenEnabled, regenPerSecond, regenerating }`. |\n| `Open77.stats.getStamina` / `stamina` | `players.stats.read` | `(playerId)` | Stamina pool with the same shape. |\n| `Open77.stats.set` | `players.stats.apply` | `(playerId, pool, value)` | Set `health` or `stamina` in points. |\n| `Open77.stats.setMax` | `players.stats.apply` | `(playerId, pool, maximum)` | Set a canonical maximum and clamp the current value. |\n| `Open77.stats.restore` | `players.stats.apply` | `(playerId, pool)` | Fill the selected pool. |\n| `Open77.stats.setRegenEnabled` | `players.stats.apply` | `(playerId, pool, enabled)` | Toggle authoritative regeneration. |\n| `Open77.stats.setRegenRate` | `players.stats.apply` | `(playerId, pool, pointsPerSecond)` | Set the rate; zero disables regeneration. |\n| `Open77.stats.setHealth` / `SetPlayerHealth` | `players.stats.apply` | `(playerId, value)` | Explicit health setter; zero routes through life authority. |\n| `Open77.stats.setHealthMax` / `SetPlayerMaxHealth` | `players.stats.apply` | `(playerId, maximum)` | Explicit health maximum setter. |\n| `Open77.stats.restoreHealth` / `RestorePlayerHealth` | `players.stats.apply` | `(playerId)` | Fill health. |\n| `Open77.stats.setHealthRegenEnabled` / `SetPlayerRegenEnabled` | `players.stats.apply` | `(playerId, enabled)` | Toggle health regeneration. |\n| `Open77.stats.setHealthRegenRate` / `SetPlayerRegen` | `players.stats.apply` | `(playerId, rate)` | Set health regeneration rate. |\n| `Open77.stats.setStamina` / `SetPlayerStamina` | `players.stats.apply` | `(playerId, value)` | Explicit stamina setter. |\n| `Open77.stats.setStaminaMax` / `SetPlayerMaxStamina` | `players.stats.apply` | `(playerId, maximum)` | Explicit stamina maximum setter. |\n| `Open77.stats.restoreStamina` / `RestorePlayerStamina` | `players.stats.apply` | `(playerId)` | Fill stamina. |\n| `Open77.stats.setStaminaRegenEnabled` / `SetPlayerStaminaRegenEnabled` | `players.stats.apply` | `(playerId, enabled)` | Toggle stamina regeneration. |\n| `Open77.stats.setStaminaRegenRate` / `SetPlayerStaminaRegen` | `players.stats.apply` | `(playerId, rate)` | Set stamina regeneration rate. |\n\nThe same setters are also reachable under `Open77.players`, for a resource that keeps every\nplayer-facing call in one namespace. They are aliases of the `Open77.stats` entries above, not a\nsecond implementation:\n\n| Function | Permission | Signature | Alias of |\n|---|---|---|---|\n| `Open77.players.restoreHealth` | `players.stats.apply` | `(playerId)` | `Open77.stats.restoreHealth` |\n| `Open77.players.setRegenEnabled` | `players.stats.apply` | `(playerId, enabled)` | `Open77.stats.setHealthRegenEnabled` |\n| `Open77.players.setStamina` | `players.stats.apply` | `(playerId, value)` | `Open77.stats.setStamina` |\n| `Open77.players.setMaxStamina` | `players.stats.apply` | `(playerId, maximum)` | `Open77.stats.setStaminaMax` |\n| `Open77.players.restoreStamina` | `players.stats.apply` | `(playerId)` | `Open77.stats.restoreStamina` |\n| `Open77.players.setStaminaRegen` | `players.stats.apply` | `(playerId, rate)` | `Open77.stats.setStaminaRegenRate` |\n| `Open77.players.setStaminaRegenEnabled` | `players.stats.apply` | `(playerId, enabled)` | `Open77.stats.setStaminaRegenEnabled` |\n\n`Open77.players.setDownedDamageable(playerId, enabled)` (low-level `SetPlayerDownedDamageable`)\nrequires `players.damage.apply` and decides whether a downed player can still be damaged: on for a\nmode with finishing blows, off for one where a downed player is safe until revived.\n\nLife option fields are:\n\n- `kill`: `killer`, `cause`, `weapon`, `impulse = { x, y, z }`;\n- `revive`: `health`, `graceMs`;\n- `respawn`: required `position = { x, y, z }`, plus `heading`, `bucket`, `health`, `graceMs`;\n- `damage`: `attacker`, `cause`/`kind`, `weapon`.","natives":[],"permissions":[],"events":[]},{"anchor":"disconnecting-and-banning-a-player","heading":"Disconnecting and banning a player","level":3,"text":"Declare the capability explicitly in the server resource manifest:\n\n```lua\npermission \"players.disconnect\"\npermission \"players.ban\"\n```\n\nThen disconnect an authenticated session ID with a reason shown to that client:\n\n```lua\nlocal ok, error = Open77.players.disconnect(playerId, \"Banned: cheating\")\nif not ok then\n print(\"disconnect failed\", error)\nend\n\n-- FiveM-style low-level spelling and short namespaced alias:\nDropPlayer(playerId, \"Kicked by an administrator\")\nOpen77.players.kick(playerId, \"Server maintenance\")\n\n-- Permanent when durationSeconds is omitted; otherwise temporary.\nlocal banned, banError = Open77.players.ban(playerId, \"Cheating\", 86400)\n-- FiveM-style low-level spelling:\nBanPlayer(playerId, \"Cheating\", 86400)\n```\n\nThe reason is trimmed, must contain no control characters, and is limited to 127 UTF-8 bytes so\nthe GNS transport can deliver it without truncation. Omitting it uses `Disconnected by server.`.\nThe closure is deferred until the current server Lua callback has returned; repeated calls for the\nsame player in one tick are idempotent and the first reason wins.\n\n`Open77.players.ban` and `BanPlayer` resolve the player's authenticated device identity, submit a\nserver-scoped ban to the master, and queue the immediate disconnect. The optional duration is a\nstrictly positive number of seconds; omitting it creates a permanent ban. Ban reasons accept up to\n512 UTF-8 bytes. Central publication is best-effort and asynchronous, while the local kick is\nqueued immediately.\n\nNormal failures return `false, reason`, where `reason` is one of\n`permission_denied:players.disconnect`, `invalid_player_id`, `invalid_reason`,\n`player_not_found`, or `server_unavailable`.\n\nBan failures return `permission_denied:players.ban`, `invalid_player_id`, `invalid_reason`,\n`invalid_duration`, `server_unavailable`, or `ban_failed`.\n\nLow-level aliases are `GetPlayerName`, `GetPlayerIdentifier`, `GetPlayerPosition`, `GetPlayers`,\n`GetPlayersInBucket`, `GetPlayersPositions`, `DropPlayer`, `BanPlayer`, `GetPlayerLifeState`, `IsPlayerDead`, `KillPlayer`, `RevivePlayer`, `RespawnPlayer`,\n`RequestPlayerLifeResync`, `GetPlayerHealth`, `DamagePlayer`, `HealPlayer`, `SetPlayerHealth`,\n`SetPlayerMaxHealth`, `SetPlayerArmor`, `SetPlayerGodMode`, `SetPlayerRegen`, `GetPlayerRead`,\n`GetPlayersNearby`, `GetPlayerDistance`, and `GetPlayerHoloCallEyes` (the read half of\n[holocall eyes](holocall-eyes.md), the same function as `Open77.players.getHoloCallEyes`). Prefer the namespaced wrappers because they accept\nstructured option tables.\n\nVehicle-seat low-level aliases are `SetPlayerIntoVehicle`, `ForcePlayerOutOfVehicle`,\n`SetPlayerVehicleExitLocked`, and `GetPlayerVehicleSeat`; their full contract is documented under\n[Vehicles](#player-seats).","natives":["server:Open77.players.disconnect","server:DropPlayer","server:Open77.players.kick","server:Open77.players.ban","server:BanPlayer"],"permissions":[],"events":[]},{"anchor":"moving-a-player","heading":"Moving a player","level":2,"text":"`Open77.players.teleport(playerId, position, options?)` is the placement primitive.\nIt moves a **living** player with no life transition: health, inventory, weapons and\nvehicle occupancy are untouched. It returns a promise that resolves when the client\nreports the body settled at the destination. (Clients 65 to 67 moved the body but\nnever answered: their settle watch was never ticked, so every promise ended in\n`settle_timeout` about fifteen seconds later. Client 68 answers -- `settled` or\n`near` in well under two seconds on a loaded floor.)\n\n```lua\nCreateThread(function()\n local placed, reason = Open77.players.teleport(source, { x = 1669.75, y = -739.12, z = 49.86 }, {\n heading = 180,\n bucket = 4, -- changed before the move, so nobody in the old bucket sees the new place\n fade = true, -- default; fadeOutMs / fadeInMs default to 400\n }):await()\n if not placed then\n print(\"teleport refused: \" .. tostring(reason))\n return\n end\n print((\"landed %s at %.2f %.2f %.2f\"):format(placed.state, placed.x, placed.y, placed.z))\nend)\n```\n\nResolves with `{ x, y, z, state }`. `state` is `settled` when the body was grounded on\nthe point for three consecutive frames, or `near` when the window closed with the body on\nthe point but never reported grounded — a mark on a prop, or a very slow stream-in. Both\nare successes; `near` is the weaker claim.\n\nRejects, all stable snake_case:\n\n| Reason | Meaning |\n|---|---|\n| `player_not_ready` | The player has no life record or the readiness gate is closed. Placement is refused to prevent a client crash before the body is ready. |\n| `player_not_alive` | The life phase is not `alive`. A move is not a resurrection; use `revive` or `respawn`. |\n| `player_in_vehicle` | The body is mounted. See below. |\n| `dismount_failed` | `dismount = true` was asked for and the body was still in its seat when the ejection budget closed. Refused rather than teleporting a mounted body. |\n| `invalid_position` | Not three finite numbers, or outside the world by orders of magnitude. |\n| `settle_timeout` | The client never got the body to stand at the destination inside the budget, or never answered at all. |\n| `settle_superseded` | A second teleport for the same player started before this one finished. There is one body, so there is one placement. |","natives":["CreateThread","server:CreateThread","server:Open77.players.teleport"],"permissions":[],"events":[]},{"anchor":"why-not-respawn","heading":"Why not `respawn`","level":3,"text":"`Open77.players.respawn` **cannot move a living player**. `PlayerLifeService.Respawn`\nrefuses unless the phase is already `Dead`, which is why the only sanctioned placement in\nthe platform before this one was a kill → respawn: a whole life transaction — a death, a\nragdoll, a resurrect, a health write and a grace window — to move a body four metres.\n\nWhat that transaction carried and a bare transform write does not is the part that\nmatters, and `teleport` keeps it: the client watches the body and only then reports it\nplaced. A direct write over any real distance drops the player through a floor that has\nnot streamed in, the engine's own fall-under-world failsafe returns them to the save's\nspawn kilometres away, and nothing notices. The client re-issues the teleport every\n250 ms while the body sinks, which resets the fall before the failsafe can fire and keeps\nthe streaming prefetch aimed at the mark.","natives":[],"permissions":[],"events":[]},{"anchor":"a-player-in-a-vehicle","heading":"A player in a vehicle","level":3,"text":"Refused with `player_in_vehicle`, by default and on purpose.\n\n`gameTeleportationFacility::Teleport` moves the player object; it has no working vehicle\nvariant (for a non-player entity the call reports success and the entity never moves), so\nthere is no way to carry the car along. Moving the occupant alone leaves the seat behind,\nand the platform's older kill → respawn primitive dealt with a mounted player by\n**unmounting first** — that is, by losing the vehicle.\n\nChoose whether to reject mounted players or eject them before placement:\n\n```lua\nOpen77.players.teleport(source, point, { dismount = true })\n```\n\nWith `dismount`, the client stops the seat workspot and unmounts the occupant behind the\nfade, retrying at 100 ms for up to 750 ms — the same cadence and budget the death\ntransaction pays for the same transition — and refuses with `dismount_failed` if the body\nis still seated when that closes. It never teleports a mounted body.\n\nSilently ejecting by default would destroy the vehicle state this API promises to keep.","natives":["server:Open77.players.teleport"],"permissions":[],"events":[]},{"anchor":"setheading","heading":"`setHeading`","level":3,"text":"`Open77.players.setHeading(playerId, yaw)` turns a living player to face a yaw. It is the\ncheap half: a heading streams nothing and cannot fall through a floor, so it dispatches\nand returns `true` rather than confirming. It refuses with the same `player_not_ready` /\n`player_not_alive` rules, plus `invalid_heading`.","natives":[],"permissions":[],"events":[]},{"anchor":"combat-policy","heading":"Combat policy","level":2,"text":"The global combat policy functions below require `combat.config`. Scoped PvP\ngrants use the separate `combat.scope.control` permission.\n\n| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.combat.onDamage` | `(handler)` | Add a synchronous damage arbiter and return it. `false` cancels; a number rewrites damage. |\n| `Open77.combat.offDamage` | `(handler)` | Remove a previously installed arbiter. |\n| `Open77.combat.setFriendlyFire` | `(enabled)` | Enable or disable global player-versus-player damage; equal nonzero teams remain protected. |\n| `Open77.combat.setTeam` | `(playerId, teamId)` | Assign a non-negative team. |\n| `Open77.combat.setDamageMultiplier` | `(multiplier)` | Set global multiplier, range 0–100. |\n| `Open77.combat.setHeadshotMultiplier` | `(multiplier)` | Set headshot multiplier, range 0–100. |\n| `Open77.combat.setWeaponDamageMultiplier` | `(weaponTdbId, multiplier)` | Override one weapon, range 0–100. |\n| `Open77.combat.setKindDamageMultiplier` | `(ranged|melee|explosion, multiplier)` | Override one attack kind. |\n\nLow-level aliases are `SetCombatFriendlyFire`, `SetCombatTeam`, `SetCombatDamageMultiplier`,\n`SetCombatHeadshotMultiplier`, `SetCombatWeaponMultiplier`, and `SetCombatKindMultiplier`.","natives":[],"permissions":[],"events":[]},{"anchor":"scoped-pvp","heading":"Scoped PvP","level":3,"text":"`Open77.combat.createScope({bucket=7, players={firstPlayer, secondPlayer}})` returns\na scope ID string, or `nil, reason`. `Open77.combat.removeScope(id)` returns\n`true`, or `nil, reason`. Both require `combat.scope.control`; `combat.config`\nalone does not grant them. Scopes belong to the calling resource instance.\n\nA scope permits its explicit roster to damage one another in that bucket while\nglobal PvP remains disabled. It changes no global settings and never overrides\nsame-team, god-mode, life, range or Lua damage-veto checks. It does not disable\ndamage that an existing global PvP policy already permits. The resource still\nowns admission/consent and may use `onDamage` for additional arena rules.\n\nCreation requires 2–32 distinct living registered players already in the\nspecified bucket. A player can belong to only one scope. Limits are 64 scopes\nglobally and eight per resource instance. Resource stop/disposal releases its\nscopes. Disconnect, bucket exit and body registration remove that participant;\nfewer than two remaining participants destroys the scope. Reconnecting or\nreturning to the bucket does not restore admission. Remove and recreate a scope\nto change its roster; an ID from an earlier resource instance cannot be reused.\n\nTypical refusals: `invalid_scope`, `player_unavailable`, `player_scoped`,\n`scope_limit`, `scope_owned`, `scope_unavailable`, `resource_stopping`.\nThis API supplies combat permission only; it does not teleport players, switch\nteams, alter implants or provide a temporary equipment loadout.","natives":[],"permissions":[],"events":[]},{"anchor":"routing-buckets","heading":"Routing buckets","level":2,"text":"| Function | Signature | Result / purpose |\n|---|---|---|\n| `Open77.routingBuckets.getPlayer` | `(playerId)` | Player bucket, default `0`. |\n| `Open77.routingBuckets.setPlayer` | `(playerId, bucket)` | `boolean`; moves authoritative visibility scope. |\n| `Open77.routingBuckets.getEntity` | `(entityId)` | Entity bucket, default `0`. |\n| `Open77.routingBuckets.setEntity` | `(entityId, bucket)` | `boolean`. |\n| `Open77.routingBuckets.setLockdownMode` | `(bucket, mode)` | Mode: `inactive`, `relaxed`, `strict`, or `full`. |\n| `Open77.routingBuckets.setPopulationEnabled` | `(bucket, enabled)` | Toggle ambient population policy for the bucket. |\n\nThe corresponding globals are `GetPlayerRoutingBucket`, `SetPlayerRoutingBucket`,\n`GetEntityRoutingBucket`, `SetEntityRoutingBucket`, `SetRoutingBucketEntityLockdownMode`, and\n`SetRoutingBucketPopulationEnabled`.","natives":[],"permissions":[],"events":[]},{"anchor":"scope-events","heading":"Scope events","level":3,"text":"**What \"scope\" means here.** Player B is *in player A's scope* when A's client currently holds B\nin its roster — when the server has sent A a `PlayerJoined` for B and no `PlayerLeft` since. That\nset is A's routing bucket, narrowed to the player interest radius when the server has one:\n`simulation.playerInterestRadius` in `server.jsonc`, in metres, with\n`simulation.playerInterestHysteresis` (default 60 m) on the way out. **The radius is off by\ndefault (`0`)**, and every mode before Cordon runs that way; then scope is simply the whole\nbucket and the events below fire on joins, bucket transfers and disconnects only. Cordon runs a\n500 m radius — never below the longest ranged damage the server honours — and with a radius the\nsame events also fire when two players walk apart or back together.\n\nNothing is measured for these events: the server raises exactly one edge per roster message it\nalready sends, so the feed is **balanced** — every `entered` has a matching `left`, from the join\nto the disconnect — and a resource that keeps a per-viewer set from it is never left holding a\nplayer whose client has already been told to forget them.\n\n| Event | Signature | Emitted |\n|---|---|---|\n| `onPlayerEnteredScope` | `(playerId, forPlayerId, reason)` | `playerId` entered the scope of `forPlayerId`. `reason` is `joined` (a hello: the joiner is handed the whole bucket and the bucket is handed the joiner, so it fires in both directions for every pair), `bucket` (a routing-bucket transfer, both directions for every new peer) or `interest` (the radius, only when it is on). |\n| `onPlayerLeftScope` | `(playerId, forPlayerId, reason)` | `playerId` left the scope of `forPlayerId`. `reason` is `disconnected` (one edge per viewer that still has a client; the leaver's own view is not reported), `bucket` or `interest`. |\n| `playerEnteredScope` / `playerLeftScope` | `(data)` with `data.player`, `data[\"for\"]` | The same edges in FiveM's shape: one table, both ids as strings, exactly what a ported script indexes. |\n\nAll ids are strings, as every host event's arguments are. The edges are queued by the host\nand delivered on its next tick (a bucket edge can be raised from inside a Lua\n`SetPlayerRoutingBucket`, and nothing is ever delivered from the caller's stack), so within a\ntick no order is promised between the two spellings, nor between a join's scope edges and\n`onPlayerConnected` / `onPlayerDisconnected`. A `joined` edge concerns a player already in the\nregistry; a `disconnected` edge concerns one already gone from `Open77.players.get`, so a\nhandler must not look them up. All four names are reserved and cannot be published by a\nresource.\n\n```lua\n-- Proximity chat that only relays to players who can actually see the speaker.\nlocal canSee = {} -- canSee[viewer][player] = true\n\nAddEventHandler(\"onPlayerEnteredScope\", function(player, viewer, reason)\n canSee[viewer] = canSee[viewer] or {}\n canSee[viewer][player] = true\nend)\nAddEventHandler(\"onPlayerLeftScope\", function(player, viewer, reason)\n if canSee[viewer] then canSee[viewer][player] = nil end\n if reason == \"disconnected\" then canSee[player] = nil end -- the leaver's own view is gone\nend)\n\nRegisterNetEvent(\"chat:local\", function(text)\n local speaker = tostring(source)\n for viewer, seen in pairs(canSee) do\n if seen[speaker] then TriggerClientEvent(\"chat:addMessage\", tonumber(viewer), { text = text }) end\n end\nend)\n```\n\nA ported FiveM resource needs no change:\n\n```lua\nAddEventHandler(\"playerEnteredScope\", function(data)\n print((\"%s entered the scope of %s\"):format(data.player, data[\"for\"]))\nend)\n```","natives":["AddEventHandler","server:AddEventHandler","RegisterNetEvent","server:RegisterNetEvent","server:TriggerClientEvent"],"permissions":[],"events":[]},{"anchor":"time-and-weather","heading":"Time and weather","level":2,"text":"Every call requires `world.environment` — the same capability name the client side of this API\nuses. The full guide, including the wire protocol, the presets and the operator commands, is\n[Synchronised time and weather](weather.md).\n\n| Function | Signature | Result / purpose |\n|---|---|---|\n| `Open77.environment.setTime` | `(hour, minute, second, bucket?)` | Sets the authoritative clock. State table, or `nil, reason`. |\n| `Open77.environment.setTimeFrozen` | `(frozen, bucket?)` | Stops or resumes the clock at its current reading. |\n| `Open77.environment.setTimeRate` | `(rate, bucket?)` | Game seconds per real second, `0`–`120`. |\n| `Open77.environment.setWeather` | `(preset, transitionSeconds?, bucket?)` | Applies a preset over a transition of `0`–`300` s. |\n| `Open77.environment.setWeatherFrozen` | `(frozen, bucket?)` | Pins the preset by stopping the weighted random scheduler. |\n| `Open77.environment.getState` | `(bucket?)` | The canonical state, including both frozen flags. |\n| `Open77.environment.clearBucket` | `(bucket)` | Retires a per-bucket override; its players return to the default sky. |\n| `Open77.environment.publishChange` | `(state)` | The authority's own door onto `onEnvironmentChanged`. No other resource needs it. |\n\n```lua\n-- Night, frozen, and a clear sky that stays clear.\nlocal state, reason = Open77.environment.setTime(23, 0, 0)\nif not state then return print(\"no environment authority: \" .. reason) end\nOpen77.environment.setTimeFrozen(true)\nOpen77.environment.setWeather(\"sunny\", 10)\nOpen77.environment.setWeatherFrozen(true)\n```\n\n`getState()` answers the same table every setter returns:\n\n| Field | Meaning |\n|---|---|\n| `scope`, `bucket` | `default` and `nil`, or `bucket:` and `` for an override. |\n| `hour`, `minute`, `second`, `secondsOfDay` | The authoritative clock at the moment of the call. |\n| `rate`, `frozen` / `timeFrozen` | Game seconds per real second, and whether the clock is stopped. |\n| `weather`, `weatherPreset`, `weatherPriority` | Open77 name, REDengine `24h_weather_*` value, submission priority. |\n| `transitionSeconds`, `weatherTransitionRemainingMs` | The transition requested, and what is left of it. |\n| `weatherFrozen`, `randomWeather`, `nextWeatherInMs` | Whether the weighted scheduler is pinned, and its deadline. |\n| `revision`, `weatherRevision`, `authorityEpoch` | The versioning clients use to reject a stale snapshot. |\n| `buckets` | Every routing bucket currently holding an override, ascending. |\n\nFailure tokens: `permission_denied:world.environment`, `environment_unavailable`, `invalid_time`,\n`unknown_weather`, `invalid_transition`, `transition_must_be_between_0_and_300`,\n`rate_must_be_between_0_and_120`, `invalid_bucket`, `unknown_bucket`,\n`too_many_environment_overrides`, `invalid_argument`.","natives":["Open77.environment.setTime","server:Open77.environment.setTime","Open77.environment.setTimeFrozen","server:Open77.environment.setTimeFrozen","Open77.environment.setWeather","server:Open77.environment.setWeather","Open77.environment.setWeatherFrozen","server:Open77.environment.setWeatherFrozen"],"permissions":[],"events":[]},{"anchor":"per-bucket-overrides","heading":"Per-bucket overrides","level":3,"text":"A routing bucket is a separate world, so a race at night and a freeroam at noon is one server.\nPassing `bucket` to any setter creates that bucket's override, seeded from the default environment\nas it reads at that moment, and updates it afterwards. `clearBucket` retires it.\n\n```lua\nOpen77.environment.setTime(23, 0, 0, 5) -- bucket 5 only\nOpen77.environment.setWeather(\"rain\", 0, 5)\nOpen77.environment.clearBucket(5) -- back to the default sky\n```\n\nTwo consequences worth knowing before reaching for it. A player who changes bucket is re-synced\nindividually, so the move is seamless; and while **any** override exists, the authority stops\nbroadcasting and addresses every connected player individually, because a broadcast would reach\nthe overridden bucket too and overwrite it. A server that never creates one pays nothing.","natives":["Open77.environment.setTime","server:Open77.environment.setTime","Open77.environment.setWeather","server:Open77.environment.setWeather","server:Open77.environment.clearBucket"],"permissions":[],"events":[]},{"anchor":"onenvironmentchanged","heading":"`onEnvironmentChanged`","level":3,"text":"A platform event: it reaches every running resource, needs no capability to receive, and cannot be\npublished by a resource — `TriggerEvent(\"onEnvironmentChanged\", …)` answers `false,\n\"reserved_event\"`. The payload is exactly what `getState()` returns, plus `reason`, so a HUD can\nrender from it without asking a second question.\n\n```lua\nAddEventHandler(\"onEnvironmentChanged\", function(state)\n print((\"%s at %02d:%02d (%s)\"):format(state.weather, state.hour, state.minute, state.reason))\nend)\n```\n\nIt fires on a real change only — never on the 5-second heartbeat and never on a client's sync\nrequest.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"ground-loot","heading":"Ground loot","level":2,"text":"Every method requires `world.loot`. Drops are authoritative and owned by their creating resource.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.loot.create` | `(definition)` | Open77 loot ID, or `nil, reason`. |\n| `Open77.loot.update` | `(id, patch)` | `boolean` |\n| `Open77.loot.remove` | `(id)` | `boolean` |\n| `Open77.loot.get` | `(id)` | Drop snapshot or `nil`. |\n| `Open77.loot.all` | `(bucket?)` | Array of drop snapshots. |\n\nDefinitions accept `item`, `quantity`, `position`, `bucket`, `radius`, `label`, `visualItem`/`model`,\nand `ttlMs`. Low-level aliases are `CreateLootDrop`, `UpdateLootDrop`, `RemoveLootDrop`,\n`GetLootDrop`, and `GetLootDrops`. See [loot](loot.md) for pickup validation and client projection.","natives":[],"permissions":[],"events":[]},{"anchor":"vehicles","heading":"Vehicles","level":2,"text":"Every method in this section requires `world.vehicles`. IDs are server-assigned Open77 IDs; do not\nsubstitute REDengine entity pointers or local spawn handles.\n\nThe [vehicle guide](vehicles.md#complete-lua-api-inventory) lists the complete server and client\nsurfaces, the package exports, exact snapshot fields, bit indexes, and examples.","natives":[],"permissions":[],"events":[]},{"anchor":"lifecycle-and-state","heading":"Lifecycle and state","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.create` | `(definition)` | Create and return an authoritative vehicle ID. |\n| `Open77.vehicles.update` | `(id, patch)` | Patch health, flags, colours, openings and all durable damage channels. |\n| `Open77.vehicles.get` | `(id)` | Full canonical vehicle snapshot or `nil`. |\n| `Open77.vehicles.all` | `(bucket?)` | All canonical vehicles, optionally filtered by bucket. |\n| `Open77.vehicles.setTransform` | `(id, transform)` | Set authoritative position and yaw. |\n| `Open77.vehicles.remove` | `(id)` | Remove the authoritative vehicle. |\n| `Open77.vehicles.getDamage` | `(id)` | `{ body, glass, lights, tires, detachedParts }` or `nil`. |\n| `Open77.vehicles.setDamage` | `(id, damage)` | Replace combined damage fields. |\n| `Open77.vehicles.repair` | `(id, scope?)` | Scope: `glass`, `body`, `lights`, `tires`, `visual`, `mechanical`, or `full`. |\n| `Open77.vehicles.setPaint` | `(id, paint)` | Set authoritative primary/secondary RGB paint. |\n| `Open77.vehicles.getPaint` | `(id)` | `{ applied, primary, secondary }` or `nil`. |\n| `Open77.vehicles.resetPaint` | `(id)` | Restore the vehicle's original paint. |\n| `Open77.vehicles.setLocked` | `(id, locked)` | Atomically set or clear the durable entry lock. |\n| `Open77.vehicles.lock` / `unlock` | `(id)` | Entry-lock convenience methods. |\n| `Open77.vehicles.isLocked` | `(id)` | Canonical entry-lock boolean or `nil`. |\n| `Open77.vehicles.triggerHorn` / `honk` | `(id, durationMs?)` | Reliably sound the horn through the owner or every parked-vehicle viewer. |\n| `Open77.vehicles.setHealth` / `getHealth` | `(id, health)` / `(id)` | The one normalized `0..1` health pool. |\n| `Open77.vehicles.setEngineHealth` / `getEngineHealth` | `(id, health)` / `(id)` | The same pool under its FiveM name. |","natives":[],"permissions":[],"events":[]},{"anchor":"engine-lights-and-siren","heading":"Engine, lights and siren","level":3,"text":"These write **one named bit** of the durable flags and leave the rest alone. `flags` and `update`\nare unchanged and keep working exactly as they did; these are another way into the same canonical\nfield, not a replacement for it.\n\n| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.setEngine` | `(id, on)` | Start or stop the engine. |\n| `Open77.vehicles.startEngine` / `stopEngine` | `(id)` | Aliases that read as actions. |\n| `Open77.vehicles.isEngineOn` | `(id)` | Canonical engine boolean, or `nil, reason`. |\n| `Open77.vehicles.setDrivable` | `(id, drivable)` | Take the car out of service, or put it back. |\n| `Open77.vehicles.setUndriveable` | `(id, undriveable)` | Inverted spelling, for a `SetVehicleUndriveable` port. |\n| `Open77.vehicles.isDrivable` | `(id)` | Canonical in-service boolean, or `nil, reason`. |\n| `Open77.vehicles.setLights` | `(id, mode)` | `\"off\"`, `\"on\"`, `\"high\"`, or a boolean. |\n| `Open77.vehicles.setHighBeams` | `(id, on)` | Raise or drop the beams without going dark. |\n| `Open77.vehicles.getLights` / `areLightsOn` | `(id)` | The mode as a word, or whether anything is lit. |\n| `Open77.vehicles.setSiren` | `(id, on)` | Light bar **and** sound together. |\n| `Open77.vehicles.isSirenOn` | `(id)` | Canonical siren boolean, or `nil, reason`. |\n\nEach electrical write also records a **ten-second intent** that outranks the physics owner's\ndurable report for the bits it names, and is released the moment that owner reports the same value.\nWithout it a setter races the 500 ms owner report that was already in flight and loses at random --\nthe mechanism that made Pursuit's cop sirens go dark five seconds after every spawn.\n\n`setDrivable(id, false)` cuts the engine in the same canonical revision and locks the simulated\ndriver's throttle and brake on every projection. Putting the car back in service does not restart\nthe engine; that is `setEngine`. Like the entry lock, the bit is server-authored and no owner\nreport can clear it.\n\n**The siren's light bar and sound cannot be separated**: the only native this build exposes is\n`vehicleBaseObject.ToggleSiren(Bool)`, with no channel argument. **Indicators and the interior\nlight are not supported by this API**. An authority release or revoke switches\nevery electrical bit off by platform design, so re-assert on `onVehicleAuthorityChanged` if a\nparked car must stay lit. Full reasoning and the worked examples are in the\n[vehicle guide](vehicles.md#engine-lights-and-siren).\n\nPer-vehicle gameplay data a server invents -- fuel, keys, a plate, an owner -- does not belong in\nthese flags. Put it in a [state bag](state-bags.md) on the vehicle instead.","natives":[],"permissions":[],"events":[]},{"anchor":"transform-and-motion","heading":"Transform and motion","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.getPosition` | `(id)` | `{ x, y, z }`, which is a vector3. |\n| `Open77.vehicles.getHeading` / `getYaw` | `(id)` | Heading in degrees, the same units as the player read. |\n| `Open77.vehicles.getSpeed` | `(id)` | Metres per second. |\n| `Open77.vehicles.getSpeedKph` | `(id)` | Kilometres per hour, the unit speed limits use. |\n| `Open77.vehicles.getVelocity` | `(id)` | `{ x, y, z }` in metres per second. |\n| `Open77.vehicles.getDriver` | `(id)` | The driving player id, or `nil`. |\n\n`Open77.vehicles.get` carries all of it, so these are shortcuts rather than extra work:\n`position`, `heading` and `yaw` (one value, both names), `orientation` as `{ x, y, z, w }`,\n`velocity`, `angularVelocity`, `speed`, `speedKph`, `onGround`, `reversing`, `moving`,\n`driverPlayerId`, and the derived `exploded` / `destroyed` booleans. `x`, `y` and `z` are\nunchanged and still there.\n\n**Motion is the last thing the physics owner reported.** A vehicle with no owner is not being\nsimulated by anyone, so losing the lease -- a driver stepping out, disconnecting, or the lease\nexpiring -- zeroes the velocity, the speed and the dynamics bits rather than leaving a parked car\nclaiming the speed it had when its driver got out. A server `setTransform` clears them for the same\nreason. `physicsOwner == 0` and `moving == false` both say so, and `moving` is exactly\n`physicsOwner ~= 0 and speed > 0.1`.\n\n`driverPlayerId` is absent, not zero, when nobody is driving -- and absent while an entry animation\nis still running, because a reserved seat is not yet a driver. `heading` is extracted from the full\nquaternion rather than from `qz`/`qw` alone, so a car on a slope reports the heading it is actually\nfacing.\n\n**Only server-spawned vehicles are in the registry**, so none of this answers for a vanilla traffic\ncar a player climbed into. Adopting vanilla traffic is a separate capability that does not exist\nyet.","natives":[],"permissions":[],"events":[]},{"anchor":"performance-ceilings","heading":"Performance ceilings","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.setPerformance` | `(id, profile)` | Cap top speed and pickup, or clear with `nil`. |\n| `Open77.vehicles.clearPerformance` | `(id)` | Hand the vehicle back its rated performance. |\n| `Open77.vehicles.getPerformance` | `(id)` | The ceiling in force, or `nil`. |\n\n```lua\nOpen77.vehicles.setPerformance(id, { topSpeedKph = 60.0, accelerationScale = 0.5, taperKph = 10.0 })\n```\n\n| Field | Default | Meaning |\n|---|---|---|\n| `topSpeedKph` | `0` | Speed ceiling in km/h. Zero means no ceiling. |\n| `accelerationScale` | `1` | Throttle authority at every speed, in `(0, 1]`. This is how a car is made to *pick up* more slowly rather than only to top out lower. |\n| `taperKph` | `12` | How many km/h below the ceiling the throttle starts easing off, so the car settles into its limit instead of slamming into it. Must be `3..200`. |\n\nA profile that asks for nothing -- no ceiling and full throttle authority -- is stored as no\nprofile at all, which is the same thing `clearPerformance` does. The taper is deliberately not part\nof that test: a roll-off width around a ceiling that does not exist changes nothing.\n\n**The taper is never zero.** Below about 3 km/h the roll-off is short enough that the limiter hunts\naround the ceiling, which reads as stutter, and the client-local governor has refused that band\nsince it shipped. The server form enforces the same envelope -- `topSpeedKph` `0..1000`,\n`accelerationScale` in `(0, 1]`, `taperKph` `3..200` -- on the Lua boundary, in the registry and on\nboth sides of the wire, so a server cap cannot be a way around a rule that exists because somebody\nmeasured it. Refusals are `invalid_top_speed`, `invalid_acceleration`, `invalid_taper` and\n`vehicle_not_found`.\n\n**This is the server-authoritative form of the client-local governor.** The client-side\n`Open77.vehicles.setPerformance` (permission `vehicles.performance`) is unchanged and still works;\nit takes the same three numbers, and the difference is only who decides them. A tuning shop, or a\nspeed limit inside an instance, cannot be a decision the client makes.\n\nThe ceiling is replicated to **every viewer**, not only to the client that currently owns the\nphysics. Ownership changes every time a driver steps in or out, and a ceiling chased across those\ntransitions would be missing for exactly the first second of every drive -- the second a speed\nlimit has to hold. Each client applies it to its own projection; the native governor only has\nthrottle to clamp on the machine actually simulating the car, so sending it wide costs a no-op on\nobservers.\n\nIt also **survives the owner's durable report**, by construction rather than by a window: that\nreport carries no performance field, so the merge has nothing to overwrite. The one race that does\nexist is on the client, where the ceiling routinely arrives before the projection has attached -- a\nserver-created vehicle has been measured taking 3.7 s to stream in -- so the client holds the\nintent on the replica and applies it when the entity appears, and again if the projection\nre-attaches.\n\nRead the ceiling back on the client from the vehicle snapshot's own drivetrain telemetry\n(`speed`, `speedKph`, `rpm`); the server read carries it as `get(id).performance`.","natives":["Open77.vehicles.setPerformance","server:Open77.vehicles.setPerformance"],"permissions":[],"events":[]},{"anchor":"explode-and-locking-for-one-player","heading":"Explode, and locking for one player","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.explode` | `(id)` | Detonate the vehicle. |\n| `Open77.vehicles.setLockedForPlayer` | `(id, playerId, locked)` | Decide one player's lock; `nil` clears the exception. |\n| `Open77.vehicles.clearLockedForPlayer` | `(id, playerId)` | Drop one player's exception. |\n| `Open77.vehicles.setLockedForAll` | `(id, locked)` | Set the canonical lock **and** revoke every exception. |\n| `Open77.vehicles.lockForAll` / `unlockForAll` | `(id)` | Aliases that read as actions. |\n| `Open77.vehicles.isLockedForPlayer` | `(id, playerId)` | What that one player sees, or `nil, reason`. |\n\n**What `explode` does.** It writes the canonical ledger: health to zero, `destroyed` and `exploded`\nraised, one revision. Every client that has the vehicle streamed then reconciles its own projection\nthrough the damage path that already replays a wreck to a late joiner, which ends in the engine's\nown explosion on that machine. The blast, the sound and the physics are REDengine's, on every\nviewer at once.\n\n- **Occupants stay occupants.** Nobody is ejected, no seat is freed, no life state changes, and the\n server sends no damage to any player. What the blast does to a body -- inside the car or standing\n next to it -- is the engine's own damage model running on each client, and it reaches the server\n the ordinary way, through the player damage report. A resource that wants the occupants dead has\n to say so itself.\n- **It is terminal.** A second call on an already exploded vehicle is refused with\n `already_exploded` rather than re-detonating a wreck. A vehicle that is merely `destroyed` --\n written off by collisions, never blown up -- is still a legitimate target.\n- **An owner report cannot undo it.** Health merges by minimum and the destruction bits by OR, both\n monotone, so a report taken a moment before the explosion and claiming a pristine car does not\n bring it back to life.\n\n**Per-player locks** exist because all-or-nothing is the wrong shape for keys. A keys resource\nlocks the car for the street and excepts the holders:\n\n```lua\nOpen77.vehicles.setLockedForAll(id, true) -- the street\nOpen77.vehicles.setLockedForPlayer(id, holder, false) -- the key holder\n```\n\nThe canonical bit stays `locked`, so a late joiner is told the right thing with no bookkeeping, and\na key holder who disconnects takes their exception with them. The substitution happens at the point\nwhere state reaches a socket, so no fan-out path can disagree with `isLockedForPlayer`.\n\n`setLockedForAll` **revokes every exception** as well as moving the canonical bit -- \"lock this for\neveryone\" has to mean everyone, including the holders excepted a minute ago. `Open77.vehicles.setLocked`\nis unchanged and moves only the canonical bit, leaving exceptions standing.","natives":["server:Open77.vehicles.setLockedForAll","server:Open77.vehicles.setLockedForPlayer"],"permissions":[],"events":[]},{"anchor":"lifetime","heading":"Lifetime","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.setTimeToLive` / `setTtl` | `(id, ttlMs)` | Give the vehicle a deadline; `0` or `nil` cancels one. |\n| `Open77.vehicles.setPersistent` | `(id, persistent)` | Exempt it from every automatic removal. |\n| `Open77.vehicles.setDespawnWhenUnobserved` | `(id, despawn)` | Remove it once nobody has it streamed. |\n\n`Open77.vehicles.create` takes the same three as `ttlMs`, `persistent` and `despawnWhenUnobserved`,\nso a garage does not have to make two calls. A definition without them behaves exactly as it did\nbefore. A lifetime that cannot be applied undoes the creation rather than half-honouring it, the\nsame rule the initial-damage path already follows.\n\n`persistent` has one meaning, deliberately: **nothing automatic removes this vehicle.** No\ntime-to-live takes it, the unobserved sweep does not take it, and it survives the stop of the\nresource that created it. An explicit `Open77.vehicles.remove` still takes it -- persistence is\nprotection from the reapers, not from the owner.\n\n`despawnWhenUnobserved` removes the vehicle once no player has had it streamed for **thirty\nseconds**. The window is not zero because interest is recomputed against the last received player\nsnapshot, and a loading screen or a bucket change briefly empties a viewer set that is about to\nrefill. The clock runs for a vehicle nobody has *ever* seen too, which is what a garage wants: a\ncar spawned for a player who never turns up still goes. A viewer coming back resets the clock\nrather than pausing it.\n\nA deadline is read back as `get(id).ttlMs`, counted from now, and is absent rather than zero once\nit is cancelled. `ttlMs` is capped at seven days, mirroring the prop registry; `invalid_ttl` is the\nrefusal.\n\n**A deadline takes an occupied vehicle too**, deliberately -- a time-to-live silently declined\nbecause someone was sitting in the car is a worse surprise than the removal. The seat ledger is\nclosed out properly either way. Clear the deadline on `onPlayerEnteredVehicle`, or mark the vehicle\npersistent, if that matters. The unobserved sweep cannot hit an occupied vehicle at all: a seated\nplayer always has their vehicle streamed.","natives":[],"permissions":[],"events":[]},{"anchor":"finding-a-vehicle","heading":"Finding a vehicle","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.nearby` | `(anchor, radius?, options?)` | Registered vehicles near a point or a player, nearest first. |\n| `Open77.vehicles.closest` | `(anchor, options?)` | The nearest one, or a bare `nil`. |\n| `Open77.vehicles.seatFree` | `(id, seat)` | Whether one seat is free. |\n| `Open77.vehicles.occupantInSeat` | `(id, seat)` | The player in one seat, or `nil`. |\n| `Open77.vehicles.freeSeats` | `(id)` | Free seats in canonical order. |\n| `Open77.vehicles.seatName` | `(seat)` | Canonical spelling of any accepted seat form. |\n\nThe anchor, bucket and limit conventions are exactly those of `Open77.players.nearby`: a position\ntable (a vector3 is one) or a player id, a player anchor defaulting to that player's own routing\nbucket, `bucket = false` for every bucket, ties broken by id. `options.occupied` keeps only\noccupied (`true`) or only empty (`false`) vehicles, and `limit` caps the list. Nothing in range is a\nbare `nil` with no reason.\n\n**Only server-spawned vehicles are in the registry.** A player standing next to a vanilla traffic\ncar is standing next to something REDengine population spawned on their own client: it has no\ncanonical id and none of these calls can see it. `closest` will answer with a registered car\nfurther away instead, which is expected rather than broken -- check `distance` yourself if it\nmatters. Adopting vanilla traffic is a separate capability that does not exist yet.","natives":[],"permissions":[],"events":[]},{"anchor":"player-seats","heading":"Player seats","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.vehicles.warpPlayerIntoVehicle` | `(playerId, vehicleId, seat, options?)` | Reserve a canonical vehicle seat and durably force the client mount, including late stream-in. |\n| `Open77.vehicles.setPlayerIntoVehicle` | `(playerId, vehicleId, seat, options?)` | Alias of `warpPlayerIntoVehicle`. |\n| `Open77.vehicles.forcePlayerOutOfVehicle` | `(playerId, vehicleId?)` | Force native unmount, overriding exit lock. |\n| `Open77.vehicles.removePlayerFromVehicle` | `(playerId, vehicleId?)` | Alias of `forcePlayerOutOfVehicle`. |\n| `Open77.vehicles.setPlayerExitLocked` | `(playerId, locked, vehicleId?)` | Set or clear the durable no-exit policy. |\n| `Open77.vehicles.getPlayerSeat` | `(playerId)` | Canonical assignment or `nil`. |\n| `Open77.vehicles.isPlayerExitLocked` | `(playerId)` | Canonical exit-lock boolean. |\n\nSeat changes also raise three derived events -- `onPlayerEnteringVehicle`,\n`onPlayerEnteredVehicle` and `onPlayerLeftVehicle`, each `(playerId, vehicleId, seat)` -- so a\nresource no longer has to fetch the vehicle and diff the occupant list itself on every\n`onVehicleOccupancyChanged`. They are derived from that same ledger at the single point it is\npublished, `entered`/`left` are balanced pairs, and the names are reserved: a resource publishing\none gets `false, \"reserved_event\"`. The exact firing rules are in the\n[vehicle guide](vehicles.md#enter-and-exit-derived).\n\n`options.moveBucket` defaults to `true`; when false, a bucket mismatch returns `wrong_bucket`.\n`options.exitLocked` (alias `lockExit`) defaults to false. Seats accept FiveM indexes `-1..2`,\nthe `Open77.vehicles.seats` constants, aliases such as `driver`/`frontPassenger`, and canonical\n`seat_*` names. Seat coordination is cross-resource: `world.vehicles` permits assignment, ejection,\nand exit locking in any canonical vehicle. A forced entry bypasses proximity and the vehicle locked\nflag but rejects destroyed/exploded targets and occupied seats. Durable vehicle state and lifecycle\nmutations follow the same cross-resource contract.\n\nThe forced order remains in reliable occupancy state until native confirmation, so it survives a\ntarget that has not streamed yet, a bucket transition, stream-out/stream-in, and local projection\nreplacement. Exit lock blocks ordinary unmount and manual seat switching; a forced exit overrides\nit and retains the seat until confirmation or the bounded transition timeout.\n\nPlayer-oriented aliases are `Open77.players.getVehicleSeat`, `warpIntoVehicle`,\n`forceOutOfVehicle`, and `setVehicleExitLocked`. See\n[authoritative player seats](vehicles.md#authoritative-player-seats) for assignment fields,\nfailure reasons, client read APIs, and examples.\n\n`create` accepts `record`, `position`, `yaw`, `bucket`, `appearance`, `health`, `flags`,\n`primaryColor`, `secondaryColor`, the nested `paint = { primary, secondary }` form, and the same\ninitial damage/opening fields accepted by `update`. Paint colors accept `\"#RRGGBB\"`, `{ r, g, b }`,\nor positional `{ r, g, b }` tables. Supplying only a primary color mirrors it to secondary.\n`setPaint` and `resetPaint` publish reliable canonical state to current viewers and late joiners;\npure black is valid because `paintApplied` is a separate flag. See the dedicated\n[vehicle paint guide](vehicle-paint.md) for complete examples, events, and native limitations.\n\n`setLocked`, `lock`, and `unlock` change only the `locked` flag and publish durable state to current\nviewers and late joiners. The entry lock blocks ordinary seat claims; it is distinct from the\nper-occupant `setPlayerExitLocked` policy and from client-side grid freeze/control locks. Forced\nserver assignment may deliberately bypass it. The client consumes the replicated lock before\npublishing its mount interaction and checks it again before the mounting request, so a rejected\nentry does not briefly put the player inside. `triggerHorn` accepts 100..2000 milliseconds\n(default 250) and sends a reliable authority-epoch-bound command to the current physics owner.\nParked vehicles deliberately retain owner 0, so their command is sent directly to every current\nviewer without granting a physics lease. Horn state is transient and is mirrored to observers by\nthe regular vehicle motion stream rather than persisted.\n\nThe server vehicle registry is shared across resources. Any resource granted `world.vehicles` may\nread, mutate, repair, paint, move, seat players in, or remove any canonical vehicle, regardless of\nwhich resource created it. The snapshot `resource` field is provenance and automatic cleanup scope,\nnot an authorization boundary.","natives":[],"permissions":[],"events":[]},{"anchor":"body-glass-lights-and-tires","heading":"Body, glass, lights, and tires","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `setBodyDamage` | `(id, values[30])` | Replace all normalized body cells. |\n| `setBodyCell` / `damageBodyCell` / `repairBodyCell` | `(id, cell, value?)` | Set, add to, or clear cell 1–30. |\n| `setBodyZone` / `damageBodyZone` / `repairBodyZone` | `(id, zone, value?)` | Mutate a named/profile zone or explicit cell array. |\n| `setGlassMask` | `(id, mask)` | Replace the 32-bit broken-glass mask. |\n| `setGlassBroken` | `(id, glass, broken)` | Mutate one numeric or profile-named pane. |\n| `breakGlass` / `repairGlass` | `(id, glass)` | Convenience pane mutation. |\n| `breakAllGlass` / `repairAllGlass` | `(id, count?)` / `(id)` | Break the first 0–32 panes or clear the mask. |\n| `setLightMask` | `(id, mask)` | Replace broken-light mask. |\n| `setLightBroken` | `(id, index, broken)` | Mutate one light bit. |\n| `breakLight` / `repairLight` / `repairAllLights` | `(id, index?)` | Light convenience methods. |\n| `setTireMask` | `(id, mask)` | Replace four-wheel broken-tire mask. |\n| `setTireBroken` | `(id, index, broken)` | Mutate tire index 0–3. |\n| `breakTire` / `repairTire` / `repairAllTires` | `(id, index?)` | Tire convenience methods. |\n| `registerDamageProfile` | `(record, profile)` | Register record-specific glass names and body zones in this VM. |\n| `getDamageProfile` | `(record)` | Return this resource's registered profile or `nil`. |\n\nDefault `Open77.vehicles.bodyZones` are `backLeft`, `back`, `backRight`, `left`, `center`, `right`,\n`frontLeft`, `front`, `frontRight`, `lower`, `roof`, and `all`.","natives":[],"permissions":[],"events":[]},{"anchor":"doors-and-windows","heading":"Doors and windows","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `setDoorMask` | `(id, mask)` | Replace the six-bit opening mask. |\n| `setDoorOpen` | `(id, door, opened)` | Set one opening. |\n| `openDoor` / `closeDoor` | `(id, door)` | Convenience mutation. |\n| `isDoorOpen` | `(id, door)` | Boolean or `nil` for unknown vehicle. |\n| `setWindowOpen` | `(id, window, opened)` | Set one window opening. |\n| `openWindow` / `closeWindow` | `(id, window)` | Convenience mutation. |\n| `isWindowOpen` | `(id, window)` | Boolean or `nil` for unknown vehicle. |\n\nDoor names are `frontLeft`, `frontRight`, `backLeft`, `backRight`, `trunk`, and `hood`; windows\nuse the four side names. Constants live in `Open77.vehicles.doors` and `.windows`. State flags live\nin `.flags`: `engineOn`, `locked`, `destroyed`, `exploded`, `invulnerable`, `immortal`, `lightsOn`,\n`highBeams`, `sirenOn`, and `paintApplied`. Prefer `setPaint`/`resetPaint` over changing the paint bit\ndirectly.","natives":[],"permissions":[],"events":[]},{"anchor":"detached-panels","heading":"Detached panels","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `setDetachedPartMask` | `(id, mask)` | Add a monotonic 16-bit detached-part mask. |\n| `setPartDetached` | `(id, part, true)` | Detach one standard named/indexed panel. |\n| `detachPart` | `(id, part)` | Convenience detachment method. |\n| `isPartDetached` | `(id, part)` | Read one canonical detachment bit. |\n\nNames are exposed in `Open77.vehicles.detachedParts`. Live reattachment is deliberately rejected\nbecause REDengine 2.31 has no validated safe inverse of `DetachPart`; respawn the vehicle instead.","natives":[],"permissions":[],"events":[]},{"anchor":"autonomous-driving","heading":"Autonomous driving","level":3,"text":"`Open77.vehicles.ai` attaches a native driver to a canonical vehicle and gives it orders:\n`attachDriver(id, options)`, `removeDriver(id)`, `state(id)`, `driveTo`, `followRoute`, `follow`,\n`chase`, `joinTraffic`, `stop`, `setSpeed`, `setBehavior`, and `on(event, handler)` for the\n`open77:vehicles:ai:*` events. Every one of them is one `VehicleDrivingCommand` call underneath —\nthat global is the low-level alias for the whole table. See [vehicle AI](vehicle-ai.md) for the\noptions, the behaviour vocabulary and the events.\n\nLow-level aliases are `CreateVehicle`, `UpdateVehicleState`, `SetVehiclePaint`,\n`ResetVehiclePaint`, `SetVehicleLocked`, `TriggerVehicleHorn`, `SetVehicleTransform`, `RemoveVehicle`, `GetVehicle`, `GetVehicles`,\n`SetPlayerIntoVehicle`, `ForcePlayerOutOfVehicle`,\n`SetPlayerVehicleExitLocked`, and `GetPlayerVehicleSeat`. The namespaced API supplies validation,\noptions, aliases, and damage helpers and is the recommended surface. See [vehicles](vehicles.md) for\nsnapshots, streaming, authority and seats.","natives":[],"permissions":[],"events":[]},{"anchor":"npcs-and-tasks","heading":"NPCs and tasks","level":2,"text":"All methods require `world.npcs`.","natives":[],"permissions":[],"events":[]},{"anchor":"npc-lifecycle-and-state","heading":"NPC lifecycle and state","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.npcs.create` | `(definition)` | Create an authoritative NPC from `record = \"Character.*\"` (no catalogue required), or a legacy `template` alias. Returns ID or `nil, reason`; engine readiness is asynchronous. See [NPCs](npcs.md). |\n| `Open77.npcs.update` | `(id, patch)` | Patch appearance, loadout, behavior, AI mode, damage policy, health, or ragdoll. |\n| `Open77.npcs.setTransform` | `(id, transform)` | Set canonical position and yaw. |\n| `Open77.npcs.setBucket` | `(id, bucket)` | Move NPC visibility scope. |\n| `Open77.npcs.setAppearance` | `(id, appearance)` | Appearance convenience patch. |\n| `Open77.npcs.setLoadout` | `(id, loadout)` | Loadout convenience patch. |\n| `Open77.npcs.setHealth` | `(id, health, maxHealth?)` | Health convenience patch. |\n| `Open77.npcs.setDamagePolicy` | `(id, policy)` | Set mortal/immortal/invulnerable policy. |\n| `Open77.npcs.setAiMode` | `(id, mode)` | Set tasks/frozen/native mode. |\n| `Open77.npcs.setBehavior` / `getBehavior` | `(id, partialOptions)` / `(id)` | Update/read native AI, combat, perception and voice policy. See [NPC behavior](npc-behavior.md). |\n| `Open77.npcs.setAIEnabled` | `(id, enabled)` | Pause/resume the native AI agent and Open77 scheduler without changing `aiMode`. |\n| `Open77.npcs.setCombatEnabled` | `(id, enabled)` | Suppress/allow combat and hostile acquisition. |\n| `Open77.npcs.setPerceptionEnabled` | `(id, enabled)` | Suppress/allow autonomous sensory acquisition. |\n| `Open77.npcs.setVoiceEnabled` | `(id, enabled)` | Suppress/allow per-NPC voice lines and barks. |\n| `Open77.npcs.setRagdoll` | `(id, enabled)` | Set canonical ragdoll state. |\n| `Open77.npcs.setAttitude` / `getAttitude` | `(id, attitude, options?)` / `(id)` | Make an NPC hostile, neutral or friendly toward one player, one owned NPC, one group, or everyone. Per NPC and per target. See [NPCs](npcs.md#attitude-and-relationship-groups). |\n| `Open77.npcs.setGroup` / `getGroup` | `(id, group)` / `(id)` | Put an NPC in a relationship group (the pre-existing `combat.group` field). |\n| `Open77.npcs.setRelationship` / `getRelationship` | `(groupA, groupB, attitude)` / `(groupA, groupB)` | Declare how two of this resource's groups regard each other. Per resource, symmetric, dies with the resource. |\n| `Open77.npcs.target` | `(id)` | Who the NPC is currently fighting, as its lease holder's engine reports it. |\n| `Open77.npcs.kill` / `revive` / `applyDamage` | `(id, ...)` | Authoritative life mutations. |\n| `Open77.npcs.remove` | `(id)` | Remove the NPC. |\n| `Open77.npcs.get` / `all` | `(id)` / `(bucket?)` | One snapshot or an array. |\n| `Open77.npcs.templates` | `()` | Legacy alias catalogue, not a limit on direct Character records. |\n\nDefinition fields include `record`, `template`, `position`, `yaw`, `bucket`, `appearance`, `loadout`, `behavior`,\n`aiMode`, `damagePolicy`, `health`, `maxHealth`, `streamingRadius`, `streamingHysteresis`,\n`despawnWhenUnobserved`, and `persistent`.\n\nConstants are `Open77.npcs.flags`, `.ai`, `.damage`, and `.channels`.","natives":[],"permissions":[],"events":[]},{"anchor":"task-queue","heading":"Task queue","level":3,"text":"| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.npcs.tasks.enqueue` | `(id, type, parameters?, options?)` | Enqueue any supported task. |\n| `cancel` | `(id, taskId)` | Cancel one task. |\n| `clear` | `(id, channel?)` | Clear queued/current tasks. |\n| `get` | `(id, taskId)` | Read one task. |\n| `all` | `(id)` | List tasks for an NPC. |\n| `moveTo` | `(id, position, options?)` | Move with speed, acceptance radius, priority, and timeout. |\n| `follow` | `(id, target, options?)` | Follow a player, NPC, or position. |\n| `patrol` | `(id, points, options?)` | Patrol with loop/back-and-forth options. |\n| `wander` | `(id, options?)` | Wander using the supplied movement parameters. |\n| `face` | `(id, position, options?)` | Rotate toward a world point. |\n| `lookAt` | `(id, target, options?)` | Drive the look channel toward player/NPC/position. |\n| `wait` | `(id, durationMs, options?)` | Timed action-channel wait. |\n| `hold` | `(id, options?)` | Hold movement indefinitely or until timeout/cancel. |\n| `playAnimation` | `(id, animation, options?)` | Run a full-body animation task. |\n| `attack` | `(id, target, options?)` | Engage a player or an owned NPC. Action channel, so it composes with a standing movement task. |\n| `guard` | `(id, position, radius?, options?)` | Hold an area, returning to the anchor only when not already in a fight. |\n| `flee` | `(id, from, options?)` | Break contact with a player, NPC or fixed point. |\n| `workspot` | `(id, reference, options?)` | Play an authored scenario: a catalogue profile id or a raw clip, with `durationMs` or `loop`. |\n| `workspots` | `(query?)` | The shipped scenario profiles -- the same catalogue `Open77.animations.list()` serves. |\n| `enterVehicle` | `(id, vehicleId, seat?, options?)` | Walk to a vehicle and take a seat; `warp` skips the walk. |\n| `exitVehicle` | `(id, options?)` | Leave the seat. Idempotent; `vehicleId` narrows it to one car. |\n\nFive further verbs address **driving** by ped id. They resolve the vehicle the NPC is the attached\ndriver of and forward to `Open77.vehicles.ai`, which owns the vanilla driving pipeline -- there is no\nsecond driving stack. All of them require both `world.npcs` and `world.vehicles`, and all of them\nanswer `nil, \"npc_not_driving\"` until `Open77.vehicles.ai.attachDriver(vehicleId, { npcId = id })`\nhas run.\n\n| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.npcs.tasks.driving` | `(id)` | The driving job this NPC drives, or `nil, reason`. |\n| `driveTo` | `(id, position, options?)` | Drive to a point with `speed` and `style`. |\n| `driveWander` | `(id, options?)` | Join traffic and follow the road network indefinitely. Not a free-roam wander: Cyberpunk AI drives authored lanes. |\n| `chase` | `(id, target, options?)` | Pursue a player, vehicle or NPC. |\n| `stopDriving` | `(id)` | Stop the current driving job. |\n| `setDriverAbility` | `(id, ability)` | Map a 0..1 ability onto behaviour and speed cap. 2.31 has no skill parameter; the mapping is documented in [NPCs](npcs.md). |\n\nLow-level aliases are `CreateNpc`, `UpdateNpc`, `SetNpcTransform`, `SetNpcBucket`, `RemoveNpc`,\n`GetNpc`, `GetNpcs`, `EnqueueNpcTask`, `CancelNpcTask`, `ClearNpcTasks`, `GetNpcTask`, `GetNpcTasks`,\n`KillNpc`, `ReviveNpc`, `DamageNpc`, `GetNpcTemplates`, `SetNpcAttitude`, `GetNpcAttitude`,\n`SetNpcGroup`, `SetNpcRelationship`, `GetNpcRelationship`, and `GetNpcTarget`. See\n[NPCs](npcs.md) for the task state machine, ownership, streaming, templates, and events.\n\n`onNpcTargetChanged(npcId, kind, targetId, previousKind, previousTargetId)` fires when an NPC\nacquires or loses a target, and is delivered only to the resource that owns it.","natives":[],"permissions":[],"events":[]},{"anchor":"elevators","heading":"Elevators","level":2,"text":"All methods require `world.elevators`.\n\n| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.elevators.adopt` | `(definition)` | Adopt a native lift and return an Open77 elevator ID. |\n| `goTo` / `call` | `(id, floor, options?)` | Start authoritative travel; options include `travelMs` and `force`. |\n| `teleport` | `(id, floor)` | Administrative recovery without travel. |\n| `pause` / `resume` | `(id)` | Pause or continue authoritative movement. |\n| `setFlags` | `(id, flags)` | Update power, lock, interaction, and door policy. |\n| `remove` | `(id)` | Release the managed elevator. |\n| `get` / `all` | `(id)` / `(bucket?)` | Read canonical snapshots. |\n\n`adopt` accepts `engineEntity`, `position`, `bucket`, `initialFloor`, `flags`, and required\n`floorCount`. Constants are in `Open77.elevators.flags`. Low-level aliases are `AdoptElevator`,\n`GoToElevator`, `TeleportElevator`, `PauseElevator`, `ResumeElevator`, `SetElevatorFlags`,\n`RemoveElevator`, `GetElevator`, and `GetElevators`. See [elevators](elevators.md).","natives":[],"permissions":[],"events":[]},{"anchor":"voice-topology-and-policy","heading":"Voice topology and policy","level":2,"text":"All methods require `voice.manage`. The dedicated server owns reachability: a client's requested\nscope never bypasses routing buckets, proximity, membership, mute/deaf, rate, or quality checks.\n\n| Method | Purpose |\n|---|---|\n| `Open77.voice.status` | Global quality/profile, default proximity, and relay/rejection counters. |\n| `channels` / `getChannel` | List/read canonical radio, phone, party, admin, and spatial channels. |\n| `participants` / `getParticipant` | Read proximity, server mute/deaf, and channel memberships. |\n| `createChannel` / `updateChannel` / `removeChannel` | Resource-owned topology and effect control. |\n| `addPlayer` / `removePlayer` | Change channel membership. |\n| `setChannelPlayerMuted` | Mute one player in one channel. |\n| `setChannelPlayerPermissions` | Set independent speak/listen permission. |\n| `setPlayerMuted` / `setPlayerDeaf` | Server-wide send/receive policy. |\n| `setProximity` | Enable/disable and tune one player's server-side reach. |\n| `setDefaultProximityDistance` | Change the default, optionally for existing participants. |\n| `setQuality` / `setEnabled` | Set global codec policy or disable voice. |\n\nChannel effects include `gain`, `lowPassHz`, `highPassHz`, `distortion`, `radioNoise`,\n`spatialBlend`, and the reverb controls `reverbWet`, `reverbRoomSize`, `reverbDecay`,\n`reverbDamping`, and `reverbPreDelayMs`. Non-persistent channels are released automatically with\ntheir owning resource.\nSee [Integrated voice chat](voice.md) for complete signatures, examples, client APIs, settings, and\nthe security/bandwidth model.","natives":[],"permissions":[],"events":[]},{"anchor":"ambient-population","heading":"Ambient population","level":2,"text":"`Open77.world.setPopulation` requires `world.population`; `getPopulation` is ungated.\n\nAn unconfigured bucket has no ambient crowd, traffic or police. Enable population explicitly with the density settings or `bucket.population on`.\n\n| Function | Signature | Purpose |\n|---|---|---|\n| `Open77.world.setPopulation` | `(bucket, { crowd?, traffic?, police? })` | Ambient density for one routing bucket, replicated to every client in it. |\n| `getPopulation` | `(bucket)` | Read it back, including what the engine can actually apply. |\n\n```lua\n-- An empty staging instance, with no cops to wander into it.\nOpen77.world.setPopulation(4, { crowd = 0, traffic = 0, police = false })\n\n-- Half the usual pedestrians, vanilla traffic.\nOpen77.world.setPopulation(0, { crowd = 0.5, traffic = 1, police = true })\n```","natives":["server:Open77.world.setPopulation"],"permissions":[],"events":[]},{"anchor":"what-is-a-fraction-and-what-is-a-switch","heading":"What is a fraction and what is a switch","level":3,"text":"`crowd` is a genuine multiplier on the vanilla pedestrian density and is applied as one, through the\nengine's community density modifier. `traffic` is accepted as `0..1` so the API does not have to\nchange if a vehicle equivalent is ever found, but on 2.31 **there is none**: any value above zero\nmeans vanilla traffic. `getPopulation` states this rather than leaving a caller to discover it --\n`crowdGranularity` is `continuous`, `trafficGranularity` is `binary`.\n\n`police` allows or forbids vanilla prevention spawns outright.\n\n`enabled` is **derived** (`crowd > 0 or traffic > 0 or police`), so it can never claim the\npopulation is on while every density is zero -- and an unconfigured bucket reads `0 / 0 / false`,\nas the section opens with.","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-client-does-with-it","heading":"What the client does with it","level":3,"text":"Two client mechanisms remove vanilla bodies while a session is active, and both follow the policy:\nthe vehicle spawn policy (which vanilla spawns are allowed to complete) and the identity sanitizer\n(which unowned runtime NPCs and vehicles are swept, so that a body nothing owns is never mistaken\nfor a replicated one). A kind the policy allows -- pedestrians when `crowd > 0`, vehicles when\n`traffic > 0` -- is scenery, replicates to nobody, and is left alone by both. Until client 67 the\nsanitizer ignored the policy, which is why a bucket with `traffic = 1` on release 65 showed cars\nthat vanished half a second after they spawned; from client 68 on, `pop` in the developer console\nreports the bodies it kept for the policy as `sanitizerAllowedByPolicy`.\n`Open77.routingBuckets.setPopulationEnabled` is the same state expressed as a switch: off sets all\nthree off, on restores the vanilla figures.","natives":[],"permissions":[],"events":[]},{"anchor":"the-client-half-is-read-only","heading":"The client half is read-only","level":3,"text":"Clients receive the policy for their own bucket and read it with `Open77.world.population()`. There\nis no client setter, because density is a server-load decision that has to be the same for everyone\nin a bucket.\n\nThat one-way shape is also why this needs no intent window, unlike the vehicle electrical flags: the\nsirens problem was a server write landing in a field the owning client republishes twice a second.\nNo client message carries ambient density at all, so there is nothing to overwrite it. What does\nneed ordering is a **replay** -- a bucket change re-sends the policy, and that replay can be older\nthan a write that already landed -- so every message carries a revision monotonic across all\nbuckets and the client drops anything older than what it holds.","natives":[],"permissions":[],"events":[]},{"anchor":"refusals","heading":"Refusals","level":3,"text":"| Reason | Meaning |\n|---|---|\n| `permission_denied:world.population` | the resource does not hold the permission |\n| `invalid_bucket` | not a non-negative integer |\n| `invalid_crowd` / `invalid_traffic` | outside `0..1`, or not a number |\n| `invalid_options` | the second argument was not a table, or `police` was not a boolean |","natives":[],"permissions":[],"events":[]},{"anchor":"clearing-an-area","heading":"Clearing an area","level":2,"text":"`Open77.world.clearArea(position, radius, options?)` removes the server-owned entities inside a\nsphere and can suppress vanilla population there. It is the server half of `Open77.world`, and the\nonly function in it: the client's `world` table (`raycast`, `groundZ`, `nearby`, `nearest`,\n`district`) needs a physics world and a streamed player, and a dedicated server has neither.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.world.clearArea` | `(position, radius, options?)` | `{ npcs, vehicles, props, refused, foreignVehicles, population }`, or `nil, reason`. |\n\nOptions are `npcs`, `vehicles`, `props` (booleans, all default `true`), `population` (default\n`false`), `bucket`, `foreign` (default `false`) and `grace`, metres of outward slack. A `nil`\n`bucket` means every bucket — the same reading `Open77.zones.playersIn` takes, since a place is not\na player and has no bucket of its own to borrow. The containment test is `Open77.zones.contains`\nfrom the shared zone module, so the radius cap (2,000 m) and the geometry rules are the ones every\nother Open77 zone obeys.\n\nFailures are `permission_denied:world.clearArea.foreign`, `invalid_radius`, `invalid_position`,\n`invalid_options`, `invalid_bucket`, `bucket_required_for_population`, and the shared zone-module\ntokens such as `invalid_grace`.","natives":[],"permissions":[],"events":[]},{"anchor":"it-removes-what-you-own-and-counts-the-rest","heading":"It removes what you own, and counts the rest","level":3,"text":"This is the one world call that destroys state another resource created and still holds ids for. A\ngamemode resetting a street would otherwise delete the parked cars a roleplay resource spawned, and\nthat resource would find out only when its own ids began answering `not_found`, with nothing in any\nlog tying the two together.\n\nSo `clearArea` removes only entities whose owner is the calling resource. Anything else inside the\nradius is counted in `refused` and left standing.\n\n```lua\n-- Event setup: take down my own props and cars, leave everyone else's alone.\nlocal swept = Open77.world.clearArea({ x = -1540, y = -2020, z = 24 }, 40, { bucket = 7 })\nprint((\"cleared %d npcs, %d vehicles, %d props; left %d belonging to other resources\")\n :format(swept.npcs, swept.vehicles, swept.props, swept.refused))\n```\n\n`foreign = true` lifts that rule, and it asks twice: the flag at the call site **and**\n`world.clearArea.foreign` in the manifest, which a server operator has to have read and granted.\nWithout the permission the **whole call** is refused rather than quietly downgraded to an own-only\nsweep — a silent downgrade would report success on a street that still has somebody else's cars in\nit.\n\nThe permission reaches **vehicles and only vehicles**, and that is structural rather than a policy\nchoice worth arguing with:\n\n| Kind | Foreign entity, with the permission |\n|---|---|\n| Vehicles | Removed, and counted in `foreignVehicles`. |\n| Props | Refused. `PropAuthorityService` own-checks its own removals. |\n| NPCs | Never even a candidate: the server's NPC read is owner-scoped, so a foreign NPC is invisible to the sweep and is not counted in `refused` either. |\n\nA missing `world.npcs`, `world.vehicles` or `world.props` skips that kind and the call still\nsucceeds. A teardown that failed outright because one of three permissions was absent would leave\nthe other two kinds behind, which is the opposite of what a teardown is for.","natives":["server:Open77.world.clearArea"],"permissions":[],"events":[]},{"anchor":"population-suppression-is-per-bucket-not-per-radius","heading":"Population suppression is per bucket, not per radius","level":3,"text":"`population = true` requires a `bucket` and drives `Open77.routingBuckets.setPopulationEnabled`,\nthe same switch the shipped gamemodes use. **The suppression that follows is bucket-wide and is not\nconfined to the radius.** There is no radius-scoped vanilla-population control on 2.31; saying so\nis better than implying a precision that does not exist, and a bucket-less call is refused with\n`bucket_required_for_population` rather than guessing which of \"suppress everywhere\" or \"do\nnothing\" was meant.","natives":[],"permissions":[],"events":[]},{"anchor":"world-props","heading":"World props","level":2,"text":"Every method requires `world.props`. Props are authoritative, owned by their creating resource,\nand streamed per player rather than broadcast to a bucket. IDs are decimal strings; do not pass\nthem through `tonumber`.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.props.create` | `(definition)` | Open77 prop ID, or `nil, reason`. |\n| `Open77.props.update` | `(id, patch)` | `boolean, reason?` |\n| `Open77.props.setTransform` | `(id, { position?, yaw?, scale? })` | `boolean, reason?` |\n| `Open77.props.setBucket` | `(id, bucket)` | `boolean, reason?` |\n| `Open77.props.attach` | `(id, { kind, id }, { offset?, yaw?, trackYaw?, detachOnDeath?, followBucket? })` | `boolean, reason?` |\n| `Open77.props.detach` | `(id)` | `boolean, reason?` |\n| `Open77.props.remove` | `(id)` | `boolean, reason?` |\n| `Open77.props.get` | `(id)` | Prop snapshot, or `nil`. |\n| `Open77.props.all` | `(bucket?)` | Array of prop snapshots, optionally filtered to one bucket. |\n| `Open77.props.catalog` | `()` | Curated model aliases and the models they resolve to. |\n| `Open77.props.clear` | `()` | Remove every prop this resource owns; returns how many. |\n\nDefinitions accept `model`, `position`, `yaw`, `scale`, `appearance`, `bucket`, `physics`\n(`static`, `kinematic`, `dynamic`, `none`), `collision`, `visible`, `kind` (`prop`, `light`,\n`effect`), `light`, `streamingRadius` (10–2000), `streamingHysteresis` (0 to the radius), and\n`ttlMs` (0 for no expiry, seven days maximum). `update` is sparse and does not accept `model` or\n`kind`; changing either is a remove and a create. The registry holds 8,192 props, of which one\nresource may own 2,048. A resource may read every prop but may only mutate or remove its own.\n\nFailures are `permission_denied:world.props`, `quota_exceeded`, `not_found`,\n`owned_by_another_resource`, `invalid_position`, `invalid_model`, `unknown_alias`,\n`record_provisioning_failed`, `entity_spawn_failed`, and `world_unavailable`.\n\n`attach` makes a prop **follow** a player, an NPC or a vehicle: the server recomputes its\ntransform from the target's ten times a second and replicates it as an ordinary prop upsert.\nIt does not sit in the hand, and `setTransform` on an attached prop is refused with\n`prop_attached`. A target that disconnects, dies or leaves ends the follow and leaves the\nprop where it stood; a routing-bucket change is followed. For something that must be held,\nuse `Open77.heldItems.hold` instead -- see [props](props.md#attachment) for the difference.\n\nLow-level aliases are `CreateProp`, `UpdateProp`, `SetPropTransform`, `SetPropBucket`,\n`AttachProp`, `DetachProp`, `RemoveProp`, `GetProp`, `GetProps`, and `ClearProps`. The namespaced API supplies validation and structured\noption tables and is the recommended surface. See [props](props.md) for the model catalogue,\nstreaming rules, lights, the client projection API, and the current limitations.","natives":[],"permissions":[],"events":[]},{"anchor":"world-effects","heading":"World effects","level":2,"text":"Every method requires `world.effects`. A one-shot is broadcast to the players in range and never\nstored; a looping effect is a registry entry with the same identity, ownership, revisioning and\nstreaming semantics as a prop, in a registry of its own.\n\n| Function | Signature | Result |\n|---|---|---|\n| `Open77.effects.play` | `(name, opts)` | `boolean, reason?` — one-shot, no handle. |\n| `Open77.effects.create` | `(definition)` | Open77 effect ID, or `nil, reason`. |\n| `Open77.effects.update` | `(id, patch)` | `boolean, reason?` |\n| `Open77.effects.remove` | `(id)` | `boolean, reason?` |\n| `Open77.effects.all` | `(bucket?)` | Array of looping-effect snapshots, optionally filtered to one bucket. |\n| `Open77.effects.catalog` | `()` | Curated effect aliases. |\n| `Open77.effects.get` | `(id)` | Looping-effect snapshot, or `nil`. |\n| `Open77.effects.playOn` | `(entityOrPlayerId, name, opts)` | `boolean, reason?` — entity-authored VFX. |\n| `Open77.effects.sound` | `(entityOrPlayerId, event, opts)` | `boolean, reason?` — entity-bound Wwise event. |\n| `Open77.effects.attach` | `(target, effect, opts)` | Open77 effect ID, or `nil, reason` — a looping effect bound to an entity slot. |\n| `Open77.effects.screen` / `PlayerScreenEffect` | `(playerId, name, opts?)` | `boolean, reason?` — a full-screen post-process on ONE player. Not replicated; permission `players.screenfx`. `name` false clears. See [Screen effects](effects.md#screen-effects). |\n| `Open77.effects.clearScreen` | `(playerId)` | `boolean, reason?` — clears every server-driven screen effect on that client. |\n| `Open77.effects.screenCatalog` / `GetScreenEffectCatalog` | `()` | The screen-effect aliases this server accepts, ascending. |\n\n`attach` is the difference between an effect that *plays on* something and one that *belongs to* it.\n`playOn` is fire-and-forget: nothing is stored, and a player who streams in a second later sees\nnothing. `attach` creates a registry entry with an id, an owning resource and per-player streaming,\nso it follows the entity and a late joiner sees it too. `target` is\n`{ kind = \"player\"|\"npc\"|\"vehicle\"|\"prop\", id = ... }` and `opts.slot` is required; `localAnchor`\n(default `body`), `localSlot` (defaults to `slot`), `ttlMs`, `streamingRadius` (default 90),\n`streamingHysteresis` (default 20), `localEvent`, `soundEvent` and `soundOnOwner` are optional. The\nlow-level alias is `AttachEffect`.\n\n`play` options are `position`, `orientation`, `bucket`, `range` (1–500, default 150), and\n`sound`. `create` definitions accept `effect`, `position`, `orientation`, `bucket`, `visible`,\n`streamingRadius` (10–2000, default 90), `streamingHysteresis` (default 20), and `ttlMs`. The\neffect name is not patchable. The registry holds 2,048 looping effects, of which one resource may\nown 512.\n\nLow-level aliases are `PlayEffect`, `CreateEffect`, `UpdateEffect`, `RemoveEffect`,\n`GetEffect`, `GetEffects`, `PlayEntityEffect`, and `PlayEntitySound`. The namespaced API\nsupplies validation and structured option tables and is the recommended surface.\n\nFailures use the same vocabulary as props with `permission_denied:world.effects` in place of the\nprops permission.\n\nThere are no low-level aliases for `Open77.effects`; the namespaced table is the whole surface.\nThe client-local `Open77.vfx` and `Open77.sfx` tables are a **different API in a different\nruntime** and are not replicated — see [effects](effects.md) for the distinction, which is the\neasiest thing to get wrong here.","natives":[],"permissions":[],"events":[]},{"anchor":"database","heading":"Database","level":2,"text":"Database access requires `database.access`. `Open77.database` and `MySQL` refer to the same\noxmysql-compatible table.\n\n| Method | Callback form | Await form |\n|---|---|---|\n| `query` / `prepare` | `(sql, params?, callback?)` | `.await(sql, params?)` returns rows. |\n| `single` | `(sql, params?, callback?)` | `.await` returns one row or `nil`. |\n| `scalar` | `(sql, params?, callback?)` | `.await` returns one scalar. |\n| `insert` | `(sql, params?, callback?)` | `.await` returns inserted ID/result. |\n| `update` / `rawExecute` | `(sql, params?, callback?)` | `.await` returns affected-row result. |\n| `transaction` | `(statements, callback?)` | `.await(statements)` returns `true` or `false, reason`. |\n\nCallbacks and `.await` continuations resume on the owning resource's scheduler, never on the\ndatabase worker. See [Configure a SQL database](/docs/database) for installation, credentials,\nconfiguration, a Lua connection probe and troubleshooting. The\n[platform database guide](https://github.com/Open2077/open77-base/blob/main/docs/database.md)\ndetails parameter forms, limits and transactions.","natives":[],"permissions":[],"events":[]},{"anchor":"waiting-for-the-database","heading":"Waiting for the database","level":3,"text":"| Method | Signature | Result |\n|---|---|---|\n| `ready` | `(handler)` | `true` once the handler is queued or run, or `false, reason`. |\n| `isReady` | `()` | `true`, or `false, reason`. |\n\n`MySQL.ready(fn)` is oxmysql's boot gate: put schema creation or a migration in it instead of\nracing it against the first connection. The host opens one connection at startup and runs\n`SELECT 1` on it; readiness is that answer, not a guess from the configuration file.\n\n**Three states, not a boolean, and that is the whole of the design.** A server started with no\ndatabase bridge at all can never become ready, so `ready` refuses it with\n`database_unavailable` and the handler *never runs* — running it would hand the resource a\nbridge whose every query fails. A database still connecting, or configured but not answering,\n**can** still become ready: the probe retries with a backoff up to 30 s, so the handler is\nqueued and a database that comes up ten minutes late still fires it. `isReady()` answers\n`false, \"database_connecting\"` or `false, \"database_unreachable\"` for those two, and never\n`true` for a database that is down.\n\n**A handler registered after readiness runs anyway**, on the next tick. A resource never has to\nask whether it started before or after the database came up, and the late path is scheduled\nrather than inline so it behaves exactly like the early one.\n\n```lua\nMySQL.ready(function()\n MySQL.query.await([[\n CREATE TABLE IF NOT EXISTS garage (plate VARCHAR(16) PRIMARY KEY, owner BIGINT NOT NULL)\n ]])\n print(\"garage schema ready\")\nend)\n```\n\nReadiness is **one-way**. A later outage is a failing request, and every request already\nreports its own failure; flipping back would make `ready` fire twice for one boot, which no\noxmysql resource expects.\n\nBoth need `database.access`, the same capability the queries need, and answer\n`permission_denied:database.access` without it. Gating readiness separately and more cheaply\nwould let a resource that may not query the database still learn whether the operator runs one.\n`ready` also answers `invalid_argument` for anything that is not a function.","natives":[],"permissions":[],"events":[]},{"anchor":"admin-events","heading":"Admin events","level":2,"text":"Warden used to act in silence: a resource learned a player had been banned only by watching\nthem disconnect, and learned a restart was coming not at all. Eight events now describe the\noperator's own acts, emitted by the host and by Warden into every running resource.\n\n| Event | Signature | Raised when |\n|---|---|---|\n| `open77:admin:scheduledRestart` | `(secondsRemaining, reason)` | A restart is armed, and again on each rung of the warning ladder (30m, 15m, 10m, 5m, 2m, 60s, 30s, 10s). |\n| `open77:admin:restartCancelled` | `()` | A pending restart is disarmed. Not raised when nothing was armed. |\n| `open77:admin:serverShuttingDown` | `(reason)` | The loop is stopping. `reason` is `restart`, `reset` or `shutdown`. |\n| `open77:admin:announcement` | `(text)` | An operator announcement went out. |\n| `open77:admin:playerWarned` | `(playerId, author)` | A warning was delivered to that player. |\n| `open77:admin:playerKicked` | `(playerId, author)` | A player was removed, other than as part of a ban. |\n| `open77:admin:playerBanned` | `(playerId, author, durationSeconds)` | A player was banned. `durationSeconds` is empty for a permanent ban. |\n| `open77:admin:playerHealed` | `(playerId, author)` | An operator healed a player from Warden's Players tab. Not raised by a resource's own `Open77.players.heal`. |\n\n```lua\nAddEventHandler(\"open77:admin:scheduledRestart\", function(secondsRemaining, reason)\n if tonumber(secondsRemaining) <= 60 then FlushEverythingNow() end\nend)\n\nAddEventHandler(\"open77:admin:serverShuttingDown\", function(reason)\n print((\"server going down (%s)\"):format(reason))\nend)\n```","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:admin:scheduledRestart","open77:admin:restartCancelled","open77:admin:serverShuttingDown","open77:admin:announcement","open77:admin:playerWarned","open77:admin:playerKicked","open77:admin:playerBanned","open77:admin:playerHealed"]},{"anchor":"what-they-carry-and-what-they-deliberately-do-not","heading":"What they carry, and what they deliberately do not","level":3,"text":"These events are **host-wide**: every running resource receives one, whatever it is about. So\nthe payload is the minimum that lets a resource react, and no more.\n\n**The moderation reason never travels.** A warning's text and a kick's or ban's reason are\naddressed to one player; a host-wide event naming them would hand every resource on the server\na running moderation log. This is the same rule `onAclChanged` follows when it carries a\nrevision and an operation but never the subject or the permission.\n\n**The operator is never named.** `author` is *provenance*, not identity — which channel acted,\nnot which member of staff. It has two values:\n\n| `author` | Meaning |\n|---|---|\n| `warden` | The operator panel or the server console. |\n| `resource` | A running resource, through `Open77.players.kick` or `Open77.players.ban`. |\n\nThat answers the question a resource actually has — \"did staff remove this player, or did a\nscript?\" — without turning the bus into a staff activity feed.\n\n**A ban is reported once.** Banning removes the player, but it is one moderation act:\n`playerBanned` is raised and the removal underneath it is *not* also reported as\n`playerKicked`. A resource counting removals would otherwise count every ban twice. The\nduration does travel, because it is what decides whether a resource purges a player's saved\nstate or keeps it warm.\n\n**The announcement text and the restart reason do travel.** Both are already broadcast to every\nplayer's chat, so withholding them would protect nothing and would cost a resource the ability\nto mirror an announcement to Discord.","natives":[],"permissions":[],"events":[]},{"anchor":"none-of-them-is-cancellable","heading":"None of them is cancellable","level":3,"text":"They are plain notifications, raised with the ordinary host emitter and never with the bus's\ncancellable path, so `CancelEvent()` inside one of these handlers does nothing. They are named\nas notifications for that reason — `scheduledRestart`, not `restartRequested` — so that no\nhandler is invited to try.\n\nA resource that must **stop** a restart calls `Open77.runtime.cancelRestart()`, which costs\n`runtime.restart`. Cancelling a shutdown is an authority, not a veto every listener gets for\nfree.","natives":[],"permissions":[],"events":[]},{"anchor":"driving-the-restart-from-a-resource","heading":"Driving the restart from a resource","level":3,"text":"| Function | Permission | Result |\n|---|---|---|\n| `Open77.runtime.scheduleRestart(seconds, reason?)` | `runtime.restart` | `{ scheduled, secondsRemaining, reason? }`, or `nil, reason`. |\n| `Open77.runtime.cancelRestart()` | `runtime.restart` | `{ scheduled = false, secondsRemaining = 0 }`, or `nil, reason`. |\n| `Open77.runtime.restartStatus()` | `runtime.restart` | `{ scheduled, secondsRemaining, reason? }`, or `nil, reason`. |\n\n`runtime.restart` is its own capability, not `resources.control`: stopping one resource and\nstopping the whole server are different powers, and an operator who granted the first did not\nthereby grant the second.\n\nA resource-scheduled restart takes exactly the path a Warden-scheduled one takes — the same\nbroadcast now, the same warning ladder, the same events — because a restart players were not\nwarned about is the failure this surface exists to prevent. Calling it again re-arms the\nschedule rather than adding a second one.\n\n**`restartStatus()` is the read the event cannot replace.** An event announces a *change*, and\na resource that started after the restart was scheduled never saw one. Ask once at start, then\nsubscribe:\n\n```lua\nAddEventHandler(\"onResourceStart\", function(name)\n if name ~= GetCurrentResourceName() then return end\n local status = Open77.runtime.restartStatus()\n if status and status.scheduled then ArmSaveTimer(status.secondsRemaining) end\nend)\n```\n\n`seconds` must be a whole number in `[1, 604800]`; the scheduler clamps anything under five\nseconds up to five. `reason` is broadcast to players, so it is capped at 200 bytes and must\ncarry no control characters. Refusals: `permission_denied:runtime.restart`, `invalid_delay`,\n`invalid_reason`, `restart_control_unavailable`.","natives":["AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName","server:Open77.runtime.restartStatus"],"permissions":[],"events":[]},{"anchor":"what-a-shutdown-notice-can-and-cannot-promise","heading":"What a shutdown notice can and cannot promise","level":3,"text":"`open77:admin:serverShuttingDown` is emitted after the loop stops, and the host then pumps a\nsmall fixed number of resource ticks so the handlers actually run — an event queued into a VM\nthat is never ticked again would be no notice at all.\n\nThat budget is small and fixed on purpose: a shutdown that waits on resources is a shutdown one\nbroken resource can hold open, and a supervisor restarting the process does not wait politely.\n**A handler that saves synchronously completes; a handler that yields for long is not\nguaranteed to finish.** The reliable place to persist state is `onResourceStop`, which every\nresource still receives from its own VM as the host tears it down.","natives":[],"permissions":[],"events":["open77:admin:serverShuttingDown"]},{"anchor":"reserved-as-a-whole-namespace","heading":"Reserved, as a whole namespace","level":3,"text":"No resource may publish anything under `open77:admin:`; `TriggerEvent` answers\n`false, \"reserved_event\"`. The namespace means one thing — *the operator's own surface did\nthis* — and that is exactly the claim an attacker wants to make. A forged `playerBanned` would\ntell an audit resource that staff removed a player nobody removed; a forged\n`serverShuttingDown` would make every resource on the server flush and stop saving.\n\nAn admin **resource**'s own acts belong in its own namespace, because they are its acts and not\nthe platform's. That is the line `open77:admin:playerHealed` walks: it is raised when an operator\nheals from Warden's own [Players tab](warden-players.md) — the operator's surface acting, which is\nexactly what the namespace is for — and it is **not** raised when a resource calls\n`Open77.players.heal`, because that is a gameplay native any resource may call, and announcing\nevery call of it as an administrative act would be both a firehose and a lie. A resource that\nheals on its own admin command should publish its own event, under its own namespace, saying so.","natives":[],"permissions":[],"events":["open77:admin:playerHealed"]},{"anchor":"outbound-http","heading":"Outbound HTTP","level":2,"text":"`PerformHttpRequest` (alias `Open77.http.request`) requires the `http.request` permission **and**\na server that enabled the bridge with an allow-list. Same shape as the database bridge: the\nrequest leaves the tick thread at once, the exchange runs on the thread pool, and the callback\nruns on the owning resource's own tick.\n\n```lua\nPerformHttpRequest(\"https://discord.com/api/webhooks/…\", function(status, body, headers, err)\n if status == 0 then print(\"webhook failed: \" .. tostring(err)) return end\n print((\"webhook answered %d\"):format(status))\nend, \"POST\", { content = \"Race heat finished\" }, { [\"Content-Type\"] = \"application/json\" })\n```\n\n| Argument | Meaning |\n|---|---|\n| `url` | Absolute `http://` or `https://` URL, no credentials, host on the server's allow-list. |\n| `callback` | `(status, body, headers, err)`. `status` is the HTTP status code, or `0` when the exchange did not complete; `err` then names the reason. Optional. |\n| `method` | `GET` (default), `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`. |\n| `body` | A string, or a table that is JSON-encoded for you. At most 256 KiB. |\n| `headers` | `{ name = value }`, at most 32; no CR/LF. |\n\nReturns `true` when the request was accepted, else `false, reason`; a refused request still\ninvokes the callback with `status = 0` and the reason, so a script never waits on it.\n\nReasons: `permission_denied:http.request`, `http_unavailable` (bridge off on this server),\n`invalid_url`, `host_not_allowed`, `invalid_method`, `body_too_large`, `too_many_headers`,\n`invalid_header`, `too_many_requests` (16 in flight per resource); completion errors `timeout`,\n`response_too_large`, `request_failed`.\n\nOperator side (`server.jsonc`):\n\n```jsonc\n\"http\": {\n \"enabled\": true,\n \"allowedHosts\": [\"discord.com\", \"*.example.com\"],\n \"timeoutSeconds\": 10,\n \"maxResponseBytes\": 1048576\n}\n```\n\nAn exact entry matches that host only; `*.example.com` matches one level of subdomain and the\nbare host. An empty list reaches nothing. Redirects are never followed, so the list is the whole\nreachable surface.","natives":["server:PerformHttpRequest"],"permissions":[],"events":[]},{"anchor":"serving-http","heading":"Serving HTTP","level":2,"text":"`Open77.http.listen(prefix, handler)` serves inbound HTTP under `//` on the\nserver's own listener. It requires `http.serve` -- a different power from `http.request`: the\nnetwork calling a resource rather than the resource calling out, and an operator reading a\nmanifest should see which of the two a resource wants. The listener is a server-wide opt-in, off\nby default and loopback by default:\n\n```jsonc\n\"httpHandlers\": { \"enabled\": true, \"listenUrl\": \"http://127.0.0.1:11781\", \"timeoutSeconds\": 5 }\n```\n\nWhen disabled, `listen` returns `http_handlers_unavailable`. The listener uses plain HTTP; place a TLS reverse proxy in front of any publicly exposed route.\n\n```lua\n-- open77.lua: permissions { \"http.serve\" }\n-- POST /my_resource/heal { \"playerId\": 3 }\nlocal route = Open77.http.listen(\"/heal\", function(req, res)\n if req.method ~= \"POST\" then return res.json(405, { error = \"method_not_allowed\" }) end\n local payload = json.decode(req.body or \"\") or {}\n local ok, reason = Open77.players.setHealth(payload.playerId, 100)\n if not ok then return res.json(404, { error = reason }) end\n res.json({ healed = payload.playerId })\nend)\nprint(\"serving \" .. tostring(route)) -- /my_resource/heal\n```\n\n| | |\n|---|---|\n| Routing | Routes are namespaced by resource (`//…`); a resource can never claim another's segment. The longest registered prefix wins; `listen(\"/\", …)` -- or FiveM's `SetHttpHandler(handler)` -- takes everything under the resource. `unlisten(prefix)` removes one route; stop and reload drop them all. `routes()` lists this resource's public routes. |\n| The handler | Runs on the resource's own tick, one tick after the request landed -- never cross-thread, so it may call any other API. `req = { method, path, query, body, route, remoteAddress?, headers }`; header lookup is case-insensitive. |\n| The answer | `res.send(status, body, headers?)` -- a table body is JSON-encoded with a JSON content type -- or the sugar `res.json(status?, value)` / `res.text(status?, text)`. Exactly once: a second call answers `false, already_sent`. A handler that throws answers `500 {\"error\":\"handler_error\"}`; one that never answers is timed out by the host after `timeoutSeconds`. |\n| Bounds | Bodies over 16 KiB are refused with `body_too_large` before Lua sees them; 16 requests in flight per resource, the rest get 503; headers capped like the outbound side. |\n\nReasons: `permission_denied:http.serve`, `http_handlers_unavailable`, `invalid_prefix`,\n`invalid_handler`, `route_not_found` (`unlisten` on a route this resource never registered).","natives":["server:Open77.http.listen","server:Open77.players.setHealth"],"permissions":["http.serve"],"events":[]},{"anchor":"logging","heading":"Logging","level":2,"text":"`Open77.log.debug`, `.info`, `.warn` and `.error` write to the resource-prefixed server logger at\ntheir own level -- `DBG`, `INF`, `WRN`, `ERR` in the log line, the same four the client has -- so an\noperator can filter a resource's warnings from its chatter. `print` stays `INF`, `Citizen.Trace` is\n`debug`. Their common signature is `(...)`, values are joined with a tab like `print`, and no value\nis returned. Control sequences (ANSI colour codes, cursor moves) are stripped from every resource\nline before it reaches the log, so a ported script that colours its output cannot corrupt the\nterminal or the log file.","natives":[],"permissions":[],"events":[]},{"anchor":"latent-chunked-client-events","heading":"Latent (chunked) client events","level":2,"text":"A net event carries at most 48 KiB of JSON. `TriggerLatentClientEvent(name, target,\nbytesPerSecond, ...)` -- alias `Open77.net.emitLatent` -- is the same call as `TriggerClientEvent`\nwith FiveM's rate argument in third place, for a payload the envelope cannot carry: up to 4 MiB,\ncut into 40 KiB frames on a reserved name, paced at the requested rate, reassembled by the client\nhost and delivered to `RegisterNetEvent` handlers under the **original** name. The receiving resource\ncannot tell it was latent, so a ported inventory or catalogue push needs no client change. Requires\n`network.events`.\n\n```lua\n-- Push the whole item catalogue to a joining player without stalling their session\nlocal id, reason = TriggerLatentClientEvent(\"shop:catalogue\", playerId, 256 * 1024, catalogue, revision)\nif not id then return print(\"catalogue not sent: \" .. tostring(reason)) end\nCreateThread(function()\n while true do\n Wait(1000)\n local s = Open77.net.latentStatus(id)\n if not s or s.state ~= \"sending\" then print(\"catalogue \" .. (s and s.state or \"gone\")); return end\n end\nend)\n```\n\n| | |\n|---|---|\n| `target` | one player id, or `-1` for every connected player (one stream per recipient, one status). |\n| Rate | clamped to 1 KiB/s .. 2.5 MiB/s; all of a resource's streams together never exceed 64 frames per second, inside the per-player event budget. |\n| `Open77.net.latentStatus(id)` | `{ id, name, target, state, sentBytes, totalBytes, sentFrames, totalFrames, progress, bytesPerSecond, elapsedMs, reason? }`; `state` is `sending`, `done`, `failed` (`player_left`) or `cancelled`. A finished stream answers for a minute, then `nil, not_found`. |\n| `Open77.net.cancelLatent(id)` | stops a stream still sending; `false, already_finished` otherwise. The client drops a partial copy on its own after a minute without a frame. |\n| Bounds | 16 streams in flight per resource (`latent_stream_limit`); a payload over 4 MiB is refused before anything is sent (`latent_payload_too_large`); the argument rules of `TriggerClientEvent` apply (`latent_payload_not_serializable`). |","natives":["server:TriggerLatentClientEvent","CreateThread","server:CreateThread","Wait","server:Wait","server:Open77.net.latentStatus"],"permissions":[],"events":[]},{"anchor":"permission-summary","heading":"Permission summary","level":2,"text":"| Capability | Server namespaces |\n|---|---|\n| `network.events` | `Open77.net`, `RegisterNetEvent`, `TriggerClientEvent` |\n| `world.loot` | `Open77.loot` |\n| `world.vehicles` | `Open77.vehicles` |\n| `world.npcs` | `Open77.npcs` |\n| `world.elevators` | `Open77.elevators` |\n| `world.props` | `Open77.props` |\n| `players.appearance.relay` | `Open77.appearance.capture` / `Open77.appearance.apply` — reads and writes the face and body a player wears. Separate from `player.appearance.edit` (client-side, local player) and from clothing: a snapshot is character-creator output, so a reader can fingerprint every player and a writer can put a face on somebody who did not choose it |\n| `world.clearArea.foreign` | `Open77.world.clearArea` with `foreign = true` — lets a sweep remove **other resources'** vehicles. Grant it only to a resource that is trusted to own the whole world, such as an event-setup script; without it `clearArea` still works on everything the caller owns |\n| `world.effects` | `Open77.effects`, including the visual half of `Open77.effects.explosion` |\n| `world.explosions` | `Open77.effects.explosion` with a positive `damage` — area damage to **every** player in a radius, including players the resource never named. Deliberately not `players.stats.apply`, which only reaches a player already named one at a time |\n| `world.environment` | `Open77.environment`: the session's clock and sky, and the per-bucket overrides — the same capability name the client side of this API uses |\n| `players.life.read` | Player life reads |\n| `players.life.kill` | `Open77.players.kill` |\n| `players.life.revive` | `Open77.players.revive` |\n| `players.life.respawn` | `Open77.players.respawn` |\n| `players.teleport` | `Open77.players.teleport` / `setHeading` — deliberately **not** under `players.life.*`: a move is not a life event and has a different blast radius |\n| `players.life.resync` | `Open77.players.requestLifeResync` |\n| `players.life.freeze` | `Open77.players.setFrozen` |\n| `players.damage.read` | `Open77.players.getHealth` |\n| `players.damage.apply` | Player health/damage mutations |\n| `players.stats.read` | Shared `Open77.stats` reads for health and stamina |\n| `players.stats.apply` | Server-only health/stamina values, maximums and regeneration |\n| `players.gate` | `onPlayerConnecting` handlers: hold, admit or refuse a connecting player |\n| `players.access` | `Open77.access`: the server's built-in whitelist and ban list |\n| `acl.read` | `Open77.acl.isAllowed` / `roles` / `grants`: read the compiled ACL for an admitted session |\n| `acl.grant:` | `Open77.acl.grant` / `revoke` / `addRole` / `removeRole`, bounded to permissions matching ``. **There is no bare `acl.grant`**, and `acl.grant:*` is not a wildcard — it is dropped. The pattern is the delegation an operator sees before installing the resource. See [the ACL guide](server-acl.md#the-delegation-scope-is-the-security-model) |\n| `acl.define:` | `Open77.acl.definePermission`: define or retune a runtime role whose every permission matches ``. Separate from `acl.grant` because retuning a role changes the rights of everyone holding it, at once and retroactively |\n| `players.identity.sensitive` | `Open77.players.endpoint` / `GetPlayerEndpoint`, and the `endpoint` field of `Open77.players.identifiers`. **An endpoint is a player's IP address**: it identifies a person outside the game and in most of the world it is regulated personal data, so it gets its own capability rather than riding on the ungated `Open77.players.identity`. Ping is deliberately not behind it — latency is not identity |\n| `runtime.commands` | `Open77.runtime.executeCommand` with a **plain resource's** authority: another resource's ordinary command, never a restricted one and never a lifecycle verb |\n| `resources.control` | `Open77.resource.start` / `stop` / `restart`, and raising `executeCommand` to the authority the in-game operator escalation uses. The admin-panel capability |\n| `runtime.restart` | `Open77.runtime.scheduleRestart` / `cancelRestart` / `restartStatus`. Its own string rather than a corner of `resources.control`: stopping one resource and stopping the whole server are different powers, and an operator who granted the first did not thereby grant the second |\n| `players.disconnect` | `Open77.players.disconnect` / `kick` |\n| `players.ban` | `Open77.players.ban` |\n| `combat.config` | Global `Open77.combat` policy and damage arbiters |\n| `combat.scope.control` | Resource-owned `Open77.combat.createScope` / `removeScope` |\n| `players.animations.control` | Start, sequence and stop player RP animations owned by this VM |\n| `players.animations.read` | Query a player's authoritative RP playback |\n| `players.wanted` | `Open77.players.setWanted` / `setMaxWanted` - point the NCPD at one player |\n| `players.wanted.read` | `Open77.players.getWanted` - read back what this server decided for a player |\n| `world.prevention` | `Open77.world.setPreventionEnabled` - turn NCPD dispatch off for a whole bucket, which every player in it lives under. Separate from `players.wanted`: making somebody a fugitive and abolishing the police are not the same power |\n| `voice.manage` | `Open77.voice` authoritative topology and policy |\n| `filesystem.read` | `Open77.io.read`, `readJson`, `exists`, `list`, `stat`, and the source side of `copy` |\n| `filesystem.write` | `Open77.io.write`, `writeJson`, `append`, `makeDirectory`, `remove`, `move`, and the destination side of `copy` |\n| `database.access` | `Open77.database` / `MySQL`, readiness (`ready`, `isReady`) included |\n| `http.request` | `PerformHttpRequest` / `Open77.http.request`, within the server's `http.allowedHosts` |\n| `http.serve` | `Open77.http.listen` / `unlisten` / `SetHttpHandler`: inbound HTTP under `//`, on the server's opt-in `httpHandlers` listener. Distinct from `http.request` on purpose: the network calling a resource is a different power from the resource calling out |\n| `players.identity.history` | The history half of `Open77.players.sessionStats` (`firstSeenUtc`, `joinCount`, `totalPlaySeconds`) and all of `Open77.players.lastSeen`: what the server's identity directory remembers about a person across sessions. The live session is ungated |\n| `world.entities.observe` | Receive the generic `onEntityCreated` / `onEntityRemoved` feed. An opt-in on top of the per-kind capability, never a substitute for it: the mirror still costs `world.props`, `world.effects` or `world.loot` for those kinds, so it can only ever show a resource what it could already see |\n\nRequest only the capabilities a resource actually uses. A manifest permission grants access to a\nbinding; it does not replace validation of player identity, distance, ownership, revision, bucket,\nor gameplay state.","natives":[],"permissions":[],"events":[]},{"anchor":"reference-coverage","heading":"Reference coverage","level":2,"text":"Use the [API reference](/docs/api) for individual signatures. This guide groups server APIs by task and describes permissions, lifecycle and multi-call workflows.\n\nServer world props and effects are replicated. Client-local `Open77.vfx` and `Open77.sfx` calls are separate APIs and do not replicate.","natives":[],"permissions":[],"events":[]}]},{"slug":"server-exports","file":"server-exports.md","title":"Cross-resource server exports","summary":"Publish and call cross-resource server functions synchronously with `exports.resource:name(...)` or asynchronously with `Open77.exports.call(...)`. Each resource retains its own Lua VM; calls do not share globals, functions, handles or mutable tables.","sections":[{"anchor":"cross-resource-server-exports","heading":"Cross-resource server exports","level":1,"text":"Publish and call cross-resource server functions synchronously with `exports.resource:name(...)` or asynchronously with `Open77.exports.call(...)`. Each resource retains its own Lua VM; calls do not share globals, functions, handles or mutable tables.\n\nClient and server export registries are separate. A server export is neither a client RPC nor a network event.","natives":[],"permissions":[],"events":[]},{"anchor":"publish-a-service","heading":"Publish a service","level":2,"text":"Create `resources/score_service/open77.lua`:\n\n```lua\nresource 'score_service'\nversion '1.0.0'\nserver_script 'server.lua'\n```\n\nThen `resources/score_service/server.lua`:\n\n```lua\nlocal scores = {}\n\nexports('add', function(playerId, points)\n -- Identity comes from the runtime, never from a supplied owner string.\n if GetInvokingResource() ~= 'race_mode' then\n return nil, 'caller_denied'\n end\n if type(playerId) ~= 'number' or playerId < 1 or playerId % 1 ~= 0 then\n return nil, 'invalid_player'\n end\n if type(points) ~= 'number' or points % 1 ~= 0 or points < 1 or points > 100 then\n return nil, 'invalid_points'\n end\n scores[playerId] = (scores[playerId] or 0) + points\n return scores[playerId]\nend)\n```\n\nThis is an in-memory example, not persistent storage or a race-result validator.\nThe gamemode must verify the player's result before awarding points. The service\nmust validate every public argument even when it recognizes the caller.","natives":["GetInvokingResource","server:GetInvokingResource"],"permissions":[],"events":[]},{"anchor":"call-it-from-another-resource","heading":"Call it from another resource","level":2,"text":"`resources/race_mode/open77.lua`:\n\n```lua\nresource 'race_mode'\nversion '1.0.0'\ndependency 'score_service >=1.0.0'\nserver_script 'server.lua'\n```\n\n`resources/race_mode/server.lua`:\n\n```lua\n-- Restricted demonstration command: do not expose arbitrary score grants.\nRegisterCommand('score_demo', function(playerId)\n if playerId == 0 then print('Run this as an authorized player'); return end\n local pending, dispatchError = Open77.exports.call('score_service', 'add', playerId, 10)\n if not pending then print(dispatchError); return end\n\n local score, callError = pending:await()\n if score == nil then print(callError); return end\n print('New score: ' .. score)\nend, true)\n```","natives":["RegisterCommand","server:RegisterCommand","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"or-call-it-synchronously","heading":"Or call it synchronously","level":2,"text":"The FiveM spelling works too, and returns the export's values directly instead of\na Promise:\n\n```lua\nlocal score = exports.score_service:add(playerId, 10)\nlocal stored = exports['open77-garage']:store(vehicleId)\n```\n\nA resource name with a hyphen only works through the bracket form, exactly as in\nFiveM; `exports.res.name(...)` with a dot works as well, because the proxy\nrecognises and drops the `self` the colon inserts.\n`Open77.exports.callSync(resource, name, ...)` is the same path without the\nsugar, for when the export name is in a variable.\n\nThe call runs **inline on the host scheduler thread**. Every server resource VM\nalready runs on that one thread, and the scripting host holds no lock while a VM\nexecutes, so entering a second VM from inside the first is a plain nested call:\nthere is nothing to contend for, and therefore nothing that can deadlock. The\ncallee's body finishes before the call expression returns — no queue, no tick\nboundary, no Promise.\n\nFour consequences, and each is worth knowing before choosing this form:\n\n- **The callee must not yield.** `Wait`, `:await()` and the MySQL `.await` helper\n fail the call with `export_yielded`: there is no scheduler underneath a\n synchronous callee to yield to. That is the dividing line between the two\n forms — if the callee can be slow, or touches the database, network or HTTP,\n use `Open77.exports.call`.\n- **Failure raises instead of returning `nil, reason`.** This is deliberate and\n it is the one place Open77 breaks its own convention. FiveM raises, ported\n resources are written for that, and a raise is the only way to keep \"the export\n returned nil\" distinguishable from \"the call never happened\". Wrap it in\n `pcall` when you want to decide for yourself. The asynchronous form keeps\n `nil, reason` unchanged.\n- **Recursion is capped at 8 frames.** A → B → A works and is supported; the\n ninth nested frame fails with `export_recursion_limit`. The cap exists because\n each frame costs a real host stack frame across two Lua states, and Lua's own\n C-stack guard only counts frames inside one state.\n- **A resource may call its own export.** It is an ordinary nested call, arguments\n and results still copied, and it counts toward the cap. Unlike the asynchronous\n self-call, which is deferred to the next tick, this one runs now.\n\nBudgets stay where they belong. The callee runs with **its own** instruction\ncounter, reset for the call and restored after it, so a callee that burns its\nbudget fails that one call with `export_budget_exhausted` and leaves the caller\nrunning. `source` is cleared for the duration and restored afterwards, so a\nsynchronous export is no more proof of a player's identity than an asynchronous\none — including on a self-call, where both sides share the same VM global.\n\nEnable `race_mode` in the server's resource selection; its dependency starts the\nservice first. Keep the existing resources enabled. The restricted command uses\nthe normal `command.score_demo` ACL. A declared dependency controls ordering and\nstop propagation, **not authorization**: any running server resource can attempt\nan exported call. The service decides which callers it accepts.\n\nUse `CreateThread`, an event handler or a command handler for `:await()`. Do not\ncall exports at file scope while a candidate VM is preparing: the call returns\n`nil, 'resource_preparing'`. Registering exports at file scope is supported.\nStart initial requests from a scheduled thread or `onResourceStart` instead.","natives":[],"permissions":[],"events":[]},{"anchor":"execution-identity-and-permissions","heading":"Execution, identity and permissions","level":2,"text":"- `exports(name, function)` publishes or replaces a function in this VM.\n- `Open77.exports.call(resource, name, ...)` returns a Promise or `nil, reason`.\n- `exports.resource:method(...)` and `Open77.exports.callSync(resource, name, ...)`\n run the callee inline and return its values, or **raise**. See the synchronous\n section above; `exports` is both the registration call and the proxy, and\n neither it nor a proxy can be assigned to.\n- `promise:await()` returns the function's values, including multiple values and\n nil holes. A rejected call returns `nil, reason`; `promise:status()` reports\n `pending`, `resolved`, `rejected` or `cancelled`. A service may also deliberately\n return `nil, reason` as a normal result (Promise status then remains `resolved`).\n- Calls execute on the target's scheduler, not inside the caller's native stack.\n Exported functions may `Wait`, await another export, or await supported MySQL\n operations. Self-calls and nested calls are also deferred.\n- `GetCurrentResourceName()` remains the provider. `GetInvokingResource()` and\n `GetInvokingResourceGeneration()` identify the immediate caller throughout the\n exported coroutine, including after a yield. Outside that coroutine they are\n nil; a new thread/timer does not inherit the identity. Nested A → B → C calls\n identify B as C's caller. `GetCurrentResourceGeneration()` identifies this VM.\n- Native permissions, filesystem roots, database ownership and gameplay handles\n remain those of the **provider**. A privileged caller does not lend capabilities\n to an unprivileged provider. Conversely, a privileged provider must explicitly\n authorize callers before offering privileged operations.\n- The export callback does not inherit a network player's `source`; it begins\n each resume with `source == nil`. Pass a validated player ID explicitly. An\n export is a resource-to-resource call, not proof of a player's identity.\n\nKeep resource-owned handles in the provider. Return a serializable ID and expose\nvalidated methods for operating on it; never attempt to transfer a native handle\nor function. For long-lived ownership, retain both caller name and generation,\nand remove stale ownership when the resource stops/reloads.\nUse `Open77.resource.generation(ownerName)` to compare with the stored generation:\nit changes on reload and returns `0` if the provider is no longer running. With\nno argument it returns this VM's generation. These identities are local to the\nserver process, not durable IDs to persist across a server restart.\n\nExports and events are different tools. `TriggerEvent` publishes on the host-wide\nbus (every running resource that handles the name receives it on the next tick,\nsee [the host-wide event bus](server-api.md#the-host-wide-event-bus)); an export is\na synchronous call with a return value. Client/server communication still uses\nauthenticated network events.","natives":[],"permissions":[],"events":[]},{"anchor":"values-limits-and-lifecycle","heading":"Values, limits and lifecycle","level":2,"text":"Arguments and results support nil, booleans, finite numbers, 64-bit Lua integers,\nbinary strings and nested plain tables with string/integer keys. They are copied:\nmutating a received table cannot modify the sender's table. Metatables are not\ntransferred. Functions, userdata, threads, cyclic tables, nonfinite numbers and\nunsupported keys are rejected.\n\nServer limits per argument/result tuple are 32 values, depth 16, 4,096 visited\nnodes and a 48 KiB transfer budget (including node overhead). Export names are\n1–64 characters; a resource can register 256 names. There are at most 256 retained\nrequests per caller, 256 pending requests per target and 4,096 requests per host.\nThe existing 1,024-task, instruction and VM-memory limits still apply.\n\nA pending server call times out after **30 seconds** of host monotonic time.\nTimeout, caller shutdown or caller reload cancels the target's scheduled task.\nThis stops future continuation; it cannot undo writes already performed or cancel\nan external database/HTTP operation already submitted. Design retries to be safe.\n\nRequests bind both VM generations. A target stop or successful reload rejects\npending calls to its old VM with `export_resource_stopped`. A failed transactional\nreload leaves the old service available: candidate exports are never published.\nNew calls resolve the new provider after a successful reload. Stopping a declared\ndependency also stops its dependents under the existing lifecycle rules.\n\nKeep the Promise until you have consumed the result. Once it is collected, the\nrequest is released; a still-pending target task is cancelled. Retaining every\ncompleted Promise indefinitely eventually reaches the caller request quota.\n\nCommon refusal reasons:\n\n| Reason | Meaning |\n| --- | --- |\n| `resource_preparing`, `resource_stopping` | Calling from a VM that is not active. |\n| `invalid_export_name`, `export_function_required` | Invalid registration/call shape. |\n| `export_resource_unavailable`, `export_not_found` | No active provider or no matching export. |\n| `export_arguments_not_serializable`, `export_results_not_serializable` | Unsupported value or transfer limit. |\n| `export_request_limit`, `export_target_busy`, `export_registration_limit` | A request, task or registration quota was reached. |\n| `await_requires_scheduler_coroutine` | A pending Promise was awaited outside a managed task. |\n| `export_resource_stopped`, `export_timeout`, `promise_cancelled` | The request's lifetime ended. |\n| `export_target_stopped` | Synchronous: the provider is stopping or not yet active. |\n| `export_recursion_limit` | Synchronous: the chain already has 8 frames. |\n| `export_yielded` | Synchronous: the callee tried to `Wait` or `await`. |\n| `export_budget_exhausted` | Synchronous: the callee burned its own instruction budget. |\n| `export_raised` | Synchronous: the callee raised; its message follows the reason. |\n\nLua handler errors reject the Promise with the bounded Lua error text, and raise\nin the caller with the same text on the synchronous path. An export is not\nautomatically registered as a network event, and an export failure alone does not\nstop otherwise healthy resources.\n\nA synchronous chain pins every VM it passes through for the length of the call.\n`Stop` and `Reload` refuse a resource that is on such a chain, with\n`stop refused: a synchronous export call is still on the stack` in the log,\nrather than disposing a Lua state that is still executing. Nothing in the current\nhost can reach `Stop` from inside a VM callback, so this is a guard rather than a\nbehaviour you should ever see.\n\nSee the [server API reference](server-api.md#cross-resource-exports),\n[official client resource exports](resource-exports.md), and\n[gamemode architecture](gamemode-kernel.md).","natives":[],"permissions":[],"events":[]}]},{"slug":"server-mods","file":"server-mods.md","title":"Mods your server requires","summary":"Declare, host and manage the mods required by your server. This guide covers Warden, trust levels and review. For installation flow and troubleshooting, see [Mods](mods.md).","sections":[{"anchor":"mods-your-server-requires","heading":"Mods your server requires","level":1,"text":"Declare, host and manage the mods required by your server. This guide covers Warden, trust levels and review. For installation flow and troubleshooting, see [Mods](mods.md).\n\nA server declares the mods its world needs, hosts the bytes itself, and the launcher installs them\nwhen the player presses Connect — before the game process exists. The master vouches for hashes,\nnever for files: no third-party mod transits Open77 infrastructure. Because the install happens\nbefore boot, a normal join needs no restart. The game was not running yet.\n\nUse this guide to decide whether your world needs a mod at all, import one from Warden, choose who\nhosts the bytes, read the trust badge your players will see before they join, request a review, and\nremove a mod later without stranding anyone.","natives":[],"permissions":[],"events":[]},{"anchor":"quick-start-add-a-mod-in-five-steps","heading":"Quick start: add a mod in five steps","level":2,"text":"If you already have the archive and just want it required, this is the whole flow. Each step is\nexplained in full under [Add a mod from the Warden panel](#add-a-mod-from-the-warden-panel).\n\n1. **Open Warden → Mods.** Under *Platform* in the sidebar. Needs the `mods.manage` permission.\n2. **Drop the archive in.** Zip, 7z or RAR. Nothing is written yet — you get a plan back.\n3. **Read the plan.** Every file, where it lands, whether it *runs*, and whether it **adds** or\n **replaces**. Refuse anything you did not expect; this is the only moment you see it before your\n players do.\n4. **Commit.** Now it is written: the bytes are stored under their own hash and `requiredMods` is\n updated in `server.jsonc` for you.\n5. **Restart the server.** The set digest changed, and that is what every launcher compares.\n\nThat is it. You never hand-edit `server.jsonc`, and you never upload anything to Open77 — the bytes\nstay on your server.\n\n> **Before you reach for a mod at all:** if the content can be built from assets the game already\n> has — props, markers, zones, a course — it is a [server resource](/docs/server-resources), not a\n> mod. No download, no restart, hot-reloadable. See [Is it a mod at all?](#is-it-a-mod-at-all)","natives":[],"permissions":[],"events":[]},{"anchor":"what-happens-when-a-player-presses-connect","heading":"What happens when a player presses Connect","level":2,"text":"1. The launcher reads your catalogue row, which carries the required list and a set digest announced\n by your server's heartbeat.\n2. It asks the master for a verdict on every SHA-256 in that list. Hashes go up, verdicts come back;\n no file moves.\n3. It applies the capability cap to each package: an unverified package containing an executable\n type is refused here, before anything is downloaded.\n4. It shows the player what the world requires — each package named, sized and badged — and waits\n for consent.\n5. It fetches the missing blobs from *your* server, hashes every byte against the announced hash,\n resolves dependencies, projects the files into the game folder and commits.\n6. It launches the game into your world.\n\nThe player's own game is not touched permanently. World mods live in a per-world profile alongside\nthe OPEN//77 profile; the Solo profile still gives them their own install back.\n\nThere is one restart case, and it is not a normal join: the client is already running in a different\nworld whose profile does not match. The launcher says so, closes the game, projects the new set and\nrelaunches straight back into your world.","natives":[],"permissions":[],"events":[]},{"anchor":"is-it-a-mod-at-all","heading":"Is it a mod at all?","level":2,"text":"This is the second question every owner asks, usually as \"can I have a custom map?\", and the answer\nsplits in two. Which half you are in decides whether you need this pipeline or none of it.\n\n| | Runtime placement | Baked geometry |\n|---|---|---|\n| Examples | Props, barriers, markers, zones, furnished interiors, spawned objects | New buildings, new interiors, terrain |\n| Ships as | A Lua resource | `.archive` meshes and sectors plus an ArchiveXL `.xl` |\n| Player download | None | The full archive |\n| Applies | Hot-reloadable, live | On the next boot |\n| Needs this guide | No | Yes |\n\nUse a Lua resource for content assembled from existing game assets: props, barriers, markers, zones or furnished interiors. See [props](props.md), [world UI](worldui.md) and [interactions](interactions.md). Resources can update while players are connected, without downloading additional model archives.\n\nUse a required mod for assets the game does not include. Delivery of `.archive` and `.xl` files does not guarantee custom `.streamingsector` compatibility with world suppression or routing buckets. Custom map geometry remains experimental.","natives":[],"permissions":[],"events":[]},{"anchor":"what-you-can-require","heading":"What you can require","level":2,"text":"Every package carries a trust verdict, and the verdict decides what the package is allowed to\ncontain.\n\n| Verdict | Meaning | Effect |\n|---|---|---|\n| `verified` | We read those exact bytes. The hash is in the whitelist with a name, author, source page and a redistribution finding. | Anything, including executable content. The mod menu carries the Open77 mark. |\n| `unverified` | Supplied by your world, not reviewed by us. | **Inert data only.** Size-capped, confined to known roots. The player is told before anything is written and can decline. |\n| `blocked` | Refused outright. | The join is refused with the reason named. |\n\nUnverified is warned, not blocked, because blocking would make Open77 a bottleneck on every server's\ncreative choices. That is only safe because of a hard capability cap.\n\n**The cap.** An unverified package may contain inert data and nothing else: `.archive`, `.tweak`,\n`.xl`, and INI files under `engine/config`. Executable content — `.dll`, `.reds`, CET Lua, `.asi` —\nis refused unless that exact SHA-256 is verified, whatever your server claims about it. The launcher\nenforces this against the file's own type and destination, not against your declaration.\n\n**There is no player toggle to bypass it.** Not a global switch, not a per-package grant. A single\n\"allow unverified mods\" checkbox is functionally \"let any world run code on my PC\", and its failure\nmode is normalisation: servers post *just turn on the toggle*, players tick it once and forget, and\nwithin a month the setting protects nobody while still reading as protection. Do not build your\nworld around a bypass arriving.\n\nA package is also blocked when it writes outside the allowed roots, when the archive contains a path\ntraversal entry, or when a downloaded file does not hash to what your server announced. That last\none is indistinguishable from tampering and is treated as such.","natives":[],"permissions":[],"events":[]},{"anchor":"redistribution-is-a-separate-axis","heading":"Redistribution is a separate axis","level":3,"text":"A mod can be perfectly inert and still be one its author never licensed anyone to hand out. The\nwhitelist records **safety** and **redistribution** separately, and they often disagree.\n\nWhen redistribution is refused, you cannot host the bytes. The launcher routes to the player-fetch\nflow instead: the player downloads the archive once from the author's own page, the launcher\nrecognises it by the pinned hash, and every world requiring it afterwards is satisfied from the\nlocal store. Nobody rehosts anything.\n\nCheck the author's terms before you import. \"It is only a small file\" is not a licence.","natives":[],"permissions":[],"events":[]},{"anchor":"add-a-mod-from-the-warden-panel","heading":"Add a mod from the Warden panel","level":2,"text":"You do not hand-edit `server.jsonc` for this. The panel has a **Mods** tab under Platform, gated by\nthe `mods.manage` permission, and every action there is written to the audit log like any other.\n\n1. **Open Mods.** The tab lists what your world currently requires, with each package's id, version,\n size, hash and trust verdict.\n2. **Upload the archive.** Zip, 7z and RAR are read. Nothing is written yet — the upload goes to\n `POST /api/mods/inspect`, which returns a plan.\n3. **Read the plan.** This is the whole point of the step. It names, for every entry in the archive:\n\n | Column | What it tells you |\n |---|---|\n | File | The path inside the archive |\n | Root | The destination it maps to, or a refusal if it maps to none |\n | Class | `inert` or `executable` |\n | Disposition | `adds` a new file, or `replaces` one the game ships |\n | SHA-256 / size | Per file, plus the archive's own hash |\n\n The package as a whole is classified by the most privileged type it contains: one `.dll` among a\n hundred `.archive` files makes the package executable, and therefore unusable until that exact\n hash is verified.\n4. **Read the disposition column especially.** `adds` and `replaces` are very different promises\n about what \"switch it off later\" means. A package that only adds files is clean to remove. A\n package that replaces a shipped file has displaced something, and you should prove the removal\n path on a test client before you require it of everyone.\n5. **Commit.** `POST /api/mods` writes the entry. Inspect and commit are separate calls on purpose:\n you should see exactly what a download contains before it becomes something every one of your\n players is asked to install.\n6. **Restart the server.** Changing the required set changes the set digest, which is announced to\n the master and compared by every launcher. Warden reports the restart as required for that\n reason.\n\nWarden overlays only the `requiredMods` section and leaves master, resources, warden and licence\nsettings verbatim. What it writes looks like this:\n\n```jsonc\n\"requiredMods\": {\n \"enabled\": true,\n \"note\": \"Shared vehicle handling — keeps physics identical across clients.\",\n \"packages\": [\n // Indexed: a package the platform already distributes. \"source\" defaults to\n // \"indexed\", so it can be left out.\n { \"id\": \"archivexl\", \"minVersion\": \"1.27.1\" },\n\n // Hosted: your bytes, served from your own endpoint. sizeBytes is the size of\n // the ARCHIVE, not of the files inside it.\n {\n \"id\": \"vehiclehandling\",\n \"source\": \"hosted\",\n \"displayName\": \"Vehicle Handling v1.3\",\n \"version\": \"1.3\",\n \"sha256\": \"34bb0c3d55a2b4b1c2137f7fa45030ad7f6685bd191952a55644c28b7d37a931\",\n \"sizeBytes\": 598\n }\n ]\n}\n```\n\nThe two shapes do not mix. An indexed entry may carry `minVersion` and must not carry `sha256` or a\npinned `version`; a hosted entry must carry `sha256`, `version` and `sizeBytes`, and must not carry\n`minVersion` — it pins one exact build. A half-filled entry is refused at startup rather than being\nread as one custody model or the other, because guessing would mean fetching bytes from a place you\ndid not mean.\n\nAn absent `requiredMods` block means \"this world requires nothing\", which is what every server means\ntoday. The configuration is validated at startup, not at connect time: bad ids, unparseable\nversions, malformed hashes, a root that is not on the allowlist, or more than 64 packages all refuse\nthe boot with the offending entry named. A server that starts is a server whose required set is\nwell-formed.\n\nYour server then serves the bytes beside the Lua resource endpoint it already runs:\n\n```text\nGET /mods/v1/manifest\nGET /mods/v1/blobs/{sha256}\n```\n\nBoth are immutable and content-addressed, with the same ETag, Range and resume behaviour the\nresource endpoint has.","natives":[],"permissions":[],"events":[]},{"anchor":"where-the-bytes-come-from","heading":"Where the bytes come from","level":2,"text":"Three custody options. They are not interchangeable and the choice is usually made for you.\n\n| Option | When to use it | Who pays the bandwidth |\n|---|---|---|\n| **Indexed** — a package the platform already distributes, pinned by `id` and `minVersion` | Loader layers Open77 is licensed to redistribute: ArchiveXL, TweakXL, Codeware, CET | Open77's CDN |\n| **Hosted** — you upload the archive to Warden and your server serves it | Anything you made, and anything whose author permits redistribution | You |\n| **Player-fetch** — the launcher asks the player to download it from the author's page | Redistribution refused, or a mod the author wants installed from their own page | The author's host |\n\nPrefer indexed when it exists: it is free for you and already verified. Choose hosted when the\ncontent is yours or the licence is clear. Player-fetch is not a fallback you select — it is what the\nwhitelist record forces when redistribution is refused, and the launcher still recognises the\ndownloaded file by its pinned hash, so the player only ever does it once across every world that\nrequires it.","natives":[],"permissions":[],"events":[]},{"anchor":"packages-owned-by-a-lua-resource","heading":"Packages owned by a Lua resource","level":3,"text":"A selected resource can declare `preload_mod \"dist/package.zip\"` for inert assets that must exist\nbefore REDengine and Lua start. The server inspects and stages the package automatically, then\nmerges it into the same required-mod set described above. This avoids duplicating a resource-owned\nworld/texture package in `server.jsonc`, while preserving the same launcher consent, hashing,\nhosting and world-profile isolation.\n\nThe declaration is intentionally stricter than Warden: executable content is refused rather than\nautomatically requesting trust, and disabling `requiredMods` while a selected resource declares a\npreload is a startup error. Changes take effect after a server restart and a fresh launcher boot,\nnot after a Lua resource reload. See [Server resources](server-resources.md#pre-boot-redengine-assets)\nand the [sky hologram walkthrough](sky-advertising.md).\n\nThe Warden **Mods** page lists these entries alongside the ones written in `server.jsonc`, with a\n`resource ` badge and no remove button: the manifest owns them, so the only way to drop one\nis to delete the `preload_mod` line and restart. The digest shown at the top covers both halves and\ntherefore matches the \"Required mods: N declared\" line in the server log and what the master\nannounces.","natives":[],"permissions":[],"events":[]},{"anchor":"unsecured-mode","heading":"Unsecured mode","level":2,"text":"Everything above assumes the platform stands between the operator and the player: executable\ncontent needs a verified attestation, indexed packages come from Open77's signed index, and a\nresource preload refuses anything that would run. `requiredMods.unsecured` switches that review\noff for one server, deliberately and visibly:\n\n```jsonc\n\"requiredMods\": {\n \"enabled\": true,\n \"unsecured\": true,\n \"packages\": []\n}\n```\n\nWith the flag on:\n\n- A resource may `preload_mod` a package that contains executable content (a RED4ext plugin such\n as ArchiveXL, a script bundle). The boot log prints an `UNSECURED` warning for each one instead\n of refusing the manifest.\n- The launcher installs the server's executable packages without a verified verdict once the\n player has accepted the warning. A **blocked** verdict, a hash mismatch, an indexed id that is\n not in the index and content outside the known mod roots are still refused: unsecured lifts the\n \"nobody reviewed this code\" cap, nothing else.\n- The master lists the server with `unsecured: true`. The launcher and the in-game server browser\n show an **UNSECURED** badge on the row, a red note in the detail pane, and a confirmation dialog\n the first time a player joins in a session. A double-click cannot skip it, and the shell refuses\n a join that did not carry the confirmation.\n- Warden's Mods page shows a red `unsecured` chip next to the set digest.\n\nThe loaders themselves are resources, so several worlds can share them. [`resources/assets/loaders/archivexl`](https://github.com/Open2077/open77-resources/tree/main/libraries/archivexl) and\n[`resources/assets/loaders/tweakxl`](https://github.com/Open2077/open77-resources/tree/main/libraries/tweakxl) each wrap the official release zip in a `preload_mod` and carry the upstream\nversion in their manifest; a world declares what it needs and the server starts the loaders first:\n\n```lua\nresource \"kujira_world\"\nversion \"1.0.0\"\ndependency \"archivexl >=1.27.1\"\ndependency \"tweakxl >=1.11.4\"\npreload_mod \"dist/kujira.zip\"\n```\n\nSelecting the world then declares three required mods (`archivexl-assets`, `tweakxl-assets`,\n`kujira_world-assets`); the launcher installs them into the world profile before the game boots, and\nRED4ext initialises the plugins before the depot mounts any archive, so there is no load order to\nmanage. A dependency must itself be in `resources.load` (or matched by its wildcard). Because the\nloader packages are executable, a wildcard selection such as `load: [\"*\"]` only boots on an unsecured\nserver: exclude them with `\"!archivexl\", \"!tweakxl\"` on a secured one.\n\nUse it for a private or trusted community that needs mods the index does not carry yet. A public\nserver that turns it on is asking strangers to run its code on their machines; the badge exists so\nthey know that before they click.","natives":[],"permissions":[],"events":[]},{"anchor":"joining-from-the-in-game-server-browser","heading":"Joining from the in-game server browser","level":2,"text":"A player who is already in the game and picks a world that requires mods cannot receive them\nmid-session: REDengine mounts archives at boot and the loaders register at depot initialisation.\nThe in-game browser handles this without sending anyone to the desktop:\n\n1. The shell compares the world's `requiredModsDigest` (relayed by the master) with the digest the\n launcher installed for this boot (`Open77.session.launcherContext().modsDigest`). Equal means\n this boot was prepared for exactly that world and the connection proceeds as usual.\n2. Otherwise a **Restart required** prompt names the world and its packages and counts down ten\n seconds. On accept (or when the countdown ends) the shell calls\n `Open77.session.relaunchViaLauncher(serverId, endpoint)`, which opens\n `open77://connect?server=…&endpoint=…&relaunch=` through the launcher's registered\n protocol handler, then quits the game.\n3. The launcher, already running or started by Windows for the link, waits for the game process\n to exit, runs the normal consent flow (unsecured warning, packages to fetch), installs the\n world's mods into its profile and starts the game with an auto-connect. The next boot lands in\n the world.\n\nWithout the launcher installed the prompt says so and the player stays in the browser; a world\nthat requires nothing never shows the prompt. An unsecured world shows its warning first.","natives":[],"permissions":[],"events":[]},{"anchor":"what-your-players-see-before-they-join","heading":"What your players see before they join","level":2,"text":"The consent gate names each package individually — what it is, how big it is, where it comes from\nand its verdict. Unverified entries are never rolled up into a count; every one is named.\n\n- **Verified** reads as reviewed by Open77 and carries the platform mark. It is the only badge under\n which executable content can exist at all.\n- **Unverified** reads plainly as *supplied by this world, not reviewed by Open77*, with the\n reassurance that it can only be inert data. Some players will decline. That is the design working,\n not a bug to route around.\n- **Blocked** ends the join with the rule named. No server can talk a launcher past this.\n\nAfterwards, the mod menu's **Worlds** section groups every file by the world that asked for it, with\nits badge, its total size and a \"remove this world's mods\" action. If a player cannot see where\nsomething on their disk came from, this feature has failed, so assume they will look.\n\nTwo consequences worth planning for. First, an unverified badge is a real conversion cost on a\npublic server: expect some players to bounce at the consent gate, and say in your server description\nwhat you require and why. Second, the more you require, the longer the first join takes — see\nbandwidth below.","natives":[],"permissions":[],"events":[]},{"anchor":"asking-for-a-review","heading":"Asking for a review","level":2,"text":"If you need executable content — a loader, a `.reds` script, a CET mod — the only path is\nverification. Warden posts the request for you:\n\n```text\nPOST /api/mods/{id}/request-review\n```\n\nIt forwards the hash and the source information the importer already collected to the master's\nreview queue. A reviewer reads those exact bytes and records two findings, separately: whether the\npackage is what it claims to be, and whether the author permits a server to hand it to players. An\napproval writes an attestation against the SHA-256; a decline records a reason you can read back in\nWarden.\n\nThree things to know before you plan around it:\n\n- **The verdict is on the bytes, not the mod.** A new release of the same mod is a new hash and\n needs its own review. Pin the version you tested.\n- **A verdict can be revoked.** Revocation flips the hash to blocked and takes effect on the next\n attestation call — no launcher release, no client update. Design for the possibility.\n- **Throughput is the real constraint, and it is unproven.** In the seven-mod sample we classified,\n six were executable. If that ratio holds across the wider ecosystem, the review queue is the main\n path rather than an edge case, and we do not yet know what it can absorb. Seven mods is far too\n small a sample to conclude anything; it is enough not to assume the opposite. Do not schedule a\n launch around a review landing by a given date.","natives":[],"permissions":[],"events":[]},{"anchor":"bandwidth-you-are-the-host","heading":"Bandwidth: you are the host","level":2,"text":"Hosted packages leave your machine, once per player who does not already have them. Do the\narithmetic before you require something large: a 1.2 GB archive pack and 500 new players in a month\nis 600 GB of egress you are paying for, and none of it is Open77's.\n\nWhat works in your favour:\n\n- Blobs are content-addressed and immutable, so a returning player downloads nothing.\n- A blob two of your worlds share is stored once on the player's disk and fetched once.\n- The endpoint supports Range and resume, so an interrupted download does not start over.\n\nWhat does not:\n\n- Every *new* player pays the full set on their first Connect, and so do you.\n- The launcher fetches on the Connect click, which is exactly when you are most likely to have a\n crowd arriving at once — a stream raid lands as simultaneous full-set downloads.\n\nBandwidth depends on package size, concurrent joins and cache reuse. Monitor your server's egress and keep required packages as small as practical.","natives":[],"permissions":[],"events":[]},{"anchor":"removing-a-mod-without-stranding-players","heading":"Removing a mod without stranding players","level":2,"text":"1. Delete the entry in Warden (`DELETE /api/mods/{id}`).\n2. Restart the server. The set digest changes, which is what every launcher compares.\n3. Players are not stranded: on their next Connect the world profile is re-derived without the\n package and it is simply no longer projected. Nobody is asked to uninstall anything, and their\n Solo profile was never affected.\n4. The bytes stay in the player's local store until it is reclaimed, which is deliberate — rejoining\n a world you played last week should not re-download everything. The mod menu shows how much disk\n that is and lets the player free it. Reclaiming disk is their choice, not a silent delete.\n\nThe one case that needs care is a package whose plan said `replaces`. Removing it has to put the\noriginal file back, so prove that round trip on a test client before you require such a package of\neveryone. A package that only `adds` has no such problem: the file disappears and the engine falls\nback to its built-in defaults.\n\nNever remove a required mod and leave the server running on the old config in the hope that it\nresolves itself. The digest is the contract; a stale one only produces confusing refusals.","natives":[],"permissions":[],"events":[]},{"anchor":"worked-example-a-shared-handling-file","heading":"Worked example: a shared handling file","level":2,"text":"The mod that motivated this whole feature is the smallest one imaginable, and it is worth walking\nend to end because every step is visible.\n\n**What it is.** `vehiclehandling` v1.3, a `.7z` containing exactly one file:\n`engine/config/platform/pc/vehiclehandling.ini`, 655 bytes, 21 keys under `[Vehicle]` — among them\n`vehicleVsVehicleCollisions`, `trafficVsTrafficCollisions`, `WeightTransferMode`, `UseDifferential`,\n`physicsCCD` and `PhysXTimeCompensationFactor`. Archive SHA-256\n`34bb0c3d55a2b4b1c2137f7fa45030ad7f6685bd191952a55644c28b7d37a931`.\n\n**Why a server would enforce it.** Those keys change *client-side* physics integration. Open77\nvehicles are client-authoritative with server arbitration, so two players running different handling\nvalues simulate the same car differently, and the divergence surfaces as desync rather than as a\nvisual difference. Requiring the file server-wide is how you make every client integrate the same\nphysics.\n\nRequired handling files enforce consistent configuration; they do not guarantee identical simulation under all network conditions.\n\nThis package contains inert configuration data, not executable code, and follows the unverified-content consent flow.\n\n**What the importer reports.**\n\n| | |\n|---|---|\n| Format | 7z, 1 entry |\n| Root | `engine/config` |\n| Class | inert |\n| Disposition | adds — the vanilla install ships only `platformgameplay.ini` and `rendering.ini` there |\n| Removal | clean: delete the file, the engine falls back to built-in defaults |\n\n**End to end.** Import it in Warden, read the plan, commit, restart. Your server announces one\npackage with that hash. A player presses Connect; the launcher asks the master, gets *unknown*, sees\ninert-only content, names the mod at the consent gate with an unverified badge, fetches 655 bytes\nfrom your server, hashes them, projects the INI and boots into your world. If you later get the hash\nverified, the same players see the badge flip on their next join, with no launcher release and no\nchange on your side.\n\n**One thing to note about the file type.** An engine INI is read once at startup. It cannot be hot\nreloaded the way a Lua resource can, which is precisely why it belongs to the launcher path and the\nboot, and not to [server-resources.md](server-resources.md).","natives":[],"permissions":[],"events":[]},{"anchor":"compatibility-limits","heading":"Compatibility limits","level":2,"text":"- Successful package delivery does not guarantee custom `.streamingsector` behavior with world suppression and routing buckets.\n- Required handling files enforce identical configuration, not identical simulation.\n- Review turnaround and mod-hosting bandwidth depend on package size and player traffic.","natives":[],"permissions":[],"events":[]}]},{"slug":"server-resources","file":"server-resources.md","title":"Lua resources loaded by the server","summary":"Package server and client Lua scripts, permissions, dependencies and web assets in an Open77 resource. The server loads the declared resources and distributes their client files to joining players.","sections":[{"anchor":"lua-resources-loaded-by-the-server","heading":"Lua resources loaded by the server","level":1,"text":"Package server and client Lua scripts, permissions, dependencies and web assets in an Open77 resource. The server loads the declared resources and distributes their client files to joining players.\n\nUse this guide to write a resource, understand what reaches the player, and publish a change to a\nrunning session.","natives":[],"permissions":[],"events":[]},{"anchor":"layout-of-the-resources-folder","heading":"Layout of the resources folder","level":2,"text":"The library is grouped in category folders. A folder without an `open77.lua` is a category, not a\nresource; the server's load rules, the wizard and the tooling look through categories, so a\n`resources.load` written as bare names (`\"open77_shell\"`, `\"freeroam\"`) or as `\"*\"` keeps working.\n\n```text\nresources/\n system/ official platform resources: open77_shell, open77_chat, open77_props, open-voice, ...\n gamemodes/ playable modes and their HUDs: freeroam, race, pursuit, open77_deathmatch, open77_cordon, ...\n dev/ examples and harnesses: open77_example, open77_dbtest\n assets/\n loaders/ archivexl, tweakxl (tracked: the platform redistributes them)\n maps/ world packages such as kujira_world (ignored by git)\n vehicles/ vehicle packages (ignored by git)\n mods/ other third-party packages (ignored by git)\n```\n\nRules: the last segment of a load rule (`*`, `open77_*`, a bare name) matches through up to two\nlevels of category folders; earlier segments stay literal, so `gamemodes/race` is exact and\n`assets/**` is the explicit recursive form. Third-party content under `assets/maps`, `assets/vehicles`\nand `assets/mods` is never committed; only its manifests are yours to write. `scripts/new-resource.ps1`\ntakes `-Category` (default `gamemodes`), and `scripts/check-lua.ps1` resolves a bare name in any\ncategory.","natives":[],"permissions":[],"events":[]},{"anchor":"connection-flow","heading":"Connection flow","level":2,"text":"1. The GNS handshake announces a generation, a SHA-256 digest, an Ed25519 key, and an HTTPS URL.\n2. The shell shows a full-screen loading WebUI.\n3. The client verifies the signed set, the CBOR manifests, each chunk, then each file.\n4. The complete generation is committed under\n `red4ext/plugins/Open77/cache/server-resources/sets//resources`.\n5. Only then does Open77 load the pristine save.\n6. The local Lua runtime is replaced by the server's client image.\n\nThe screen reports `manifest`, `downloading`, `verifying`, `ready`, or `failed`, along with the\nresource, the byte count, and the number of files. A signature, hash, or path error blocks entry and\ndisconnects the client.","natives":[],"permissions":[],"events":[]},{"anchor":"writing-a-resource","heading":"Writing a resource","level":2,"text":"The resource lives in a directory selected by the server's `resources.load` rules. Bare names and\nrelative paths use `resources.root` as their base:\n\n```lua\nresource \"garage\"\nversion \"1.0.0\"\nauto_start true\n\nshared_script \"shared/config.lua\"\nserver_script \"server/main.lua\"\nclient_script \"client/main.lua\"\n\nweb_ui_page \"web/index.html\"\nweb_files { \"web/**\" }\nfiles { \"assets/blips/*.png\", \"assets/audio/*.wav\" }\npreload_mod \"dist/world-assets.zip\"\npermissions { \"network.events\" }\n```\n\nThe server runs `server_script` and `shared_script`. The downloaded package contains `open77.lua`,\nthe `client` and `shared` files, and the declared assets — never the `server/` files. The client and\nserver Lua APIs are documented in the wiki's main reference. The authoritative subsystem for ground\nitems is covered in [loot.md](loot.md). The reference time/weather package and its protocol are\ncovered in [weather.md](weather.md).\n\n`files` / `file` declare generic client assets. Globs are expanded by the server, included in the\nsigned resource set, downloaded before Lua starts, and recorded in the client allowlist. Use\n`web_files` only for files served to that resource's WebUI. For example,\n`Open77.assets.texture(\"assets/blips/job-center.png\")` only succeeds when the exact file matched a\n`files` entry. Empty globs, traversal paths, oversized files, and undeclared texture reads fail the\nresource instead of falling back to arbitrary disk access.","natives":[],"permissions":["network.events"],"events":[]},{"anchor":"declarative-exports-and-ported-manifests","heading":"Declarative exports and ported manifests","level":3,"text":"A resource may list its exports in the manifest instead of calling `exports(name, fn)`:\n\n```lua\nexports { \"GetClosestDoor\", \"IsDoorOpen\" } -- client exports\nserver_exports { \"GetBalance\", \"AddMoney\" } -- server exports\n```\n\nEach side pre-registers its own list from the **global function of that name** after the last\nscript has run and before the resource is `Running`, through exactly the registration a scripted\n`exports()` call performs -- so a sibling calling `exports.bank:GetBalance()` from its own start\nhandler finds it, and a function defined in any file counts. The manifest is the later word: a\nname registered by `exports()` and listed here ends up bound to the global. A listed name with no\nglobal function refuses the start by name (`manifest_export_missing:`), never starts a\nresource whose manifest promises what its code does not provide; a name that is not a Lua\nidentifier of at most 64 characters fails the manifest at discovery\n(`invalid_manifest_export::`). `Open77.resource.metadata(name, \"exports\")` and\n`\"serverExports\"` read the lists back.\n\n`ui_page` is accepted as an alias of `web_ui_page`, with the same rules (a safe relative path that\nmust also be declared in `web_files`). When a manifest carries **both**, the one written last in\nthe file wins -- what a Lua-executed manifest would do -- so a ported manifest that kept its\n`ui_page` line above a new `web_ui_page` line gets the Open77 one, and vice versa.\n\nThe FiveM keys that describe the FiveM runtime or metadata nothing here reads are **accepted and\nignored**: `fx_version`, `game`, `games`, `lua54`, `use_experimental_fxv2_oal`, `author`,\n`description`, `provide`, `provides`, `escrow`, `escrow_ignore`. A ported manifest loads without\nediting them out, and so that the acceptance never passes for something done, each side names the\nones it saw once per start, in one `INF` line:\n\n```text\nINF|my_resource|manifest_ignored_keys=fx_version,lua54\n```\n\n`@other_resource/file.lua` in a script list -- FiveM's cross-resource include -- is refused **by\nname** on both sides, `cross_resource_include_refused:@other_resource/file.lua`, at manifest\nparsing. It is a design decision, not a limitation: a resource's scripts run in its own VM, and\nsharing code across resources is `dependency` plus [`require('@resource/module')`](lua-modules.md)\nover declared `files` on the client, and [exports](resource-exports.md) on both sides. Before this\nthe include matched nothing and either failed as \"Resource contains no scripts.\" or, when the\nmanifest listed other scripts too, was dropped without a word.","natives":[],"permissions":[],"events":[]},{"anchor":"pre-boot-redengine-assets","heading":"Pre-boot REDengine assets","level":3,"text":"`preload_mod` / `preload_mods` attach inert game assets to a selected resource when REDengine must\nsee those files before Lua starts. Typical examples are `.archive` packages containing XBM texture\nreplacers or custom world data:\n\n```lua\nresource \"open77_sky_ads\"\nversion \"1.0.0\"\n\nserver_script \"server/main.lua\"\npreload_mod \"dist/Open77SkyAds.zip\"\n```\n\nThe ZIP or 7z is inspected at dedicated-server startup, staged in the server's content-addressed\nmod store, and promoted to the required-mod set announced to the master. The launcher downloads\nand projects it before it starts Cyberpunk. A preload is deliberately excluded from the signed Lua\nresource package, including when a broad `files { \"dist/**\" }` pattern also matches it.\n\nThis is a boot contract, not a hot-reload API:\n\n- the resource must be selected by `resources.load`;\n- `requiredMods.enabled` must be `true`, or server startup fails with the responsible directive;\n- only inert packages are accepted automatically; executable DLL/REDscript/CET content still goes\n through Warden review;\n- a resource may declare at most eight `.zip`/`.7z` packages, each from 1 byte through 64 MiB;\n- every declared path or glob must match an existing package;\n- rebuilding or changing one requires a dedicated-server restart, then a fresh launcher boot of\n the game; restarting only the Lua resource cannot remount the REDengine depot.\n\nSee [Sky hologram advertisements](sky-advertising.md) for the complete PNG/DDS-to-XBM example and\n[Server-required mods](server-mods.md) for storage, consent, trust, and removal semantics.\n\nA server script can register a command reachable from the Open77 developer terminal or from the\ndedicated console:\n\n```lua\nRegisterCommand(\"garage.list\", function(source, args, rawCommand)\n -- source = authenticated playerId from the Open77 terminal, 0 from the dedicated console.\n print(\"garages: \" .. tostring(#args))\nend, false) -- true puts the command behind the `command.garage.list` ACL\n```\n\nIn the in-game terminal, Open77 runs local native commands first. If no local handler matches, the\ntokenised line is sent reliably to the server under the session's identity. The server never trusts\na `source` supplied by the client. Commands are removed automatically along with their VM when a\nresource stops or reloads.\n\nCommands declared with `restricted=true` require the ACL permission\n`command.`. The `*` permission and namespace wildcards such as `command.garage.*`\nare accepted. See [server-acl.md](server-acl.md).\n\nA resource may also run a command, list what commands exist, and control the resource lifecycle,\nbehind two capabilities that are deliberately not the same one:\n\n```lua\npermissions { \"runtime.commands\" } -- invoke a SIBLING's ordinary command, and nothing else\npermissions { \"resources.control\" } -- start/stop/restart, and the operator's authority for a line\n```\n\nEverything on that surface is queued for the next tick boundary and reports acceptance rather than\ncompletion, because Lua runs inside the server's tick and a resource stopping itself synchronously\nwould free the Lua state doing the stopping. See\n[Runtime and resource control](server-api.md#runtime-and-resource-control).","natives":["RegisterCommand","server:RegisterCommand"],"permissions":["runtime.commands","resources.control"],"events":[]},{"anchor":"selecting-which-resources-load","heading":"Selecting which resources load","level":2,"text":"The dedicated server does not have to load every directory below `resources.root`. The\n`resources.load` array is an ordered set of directory rules:\n\n```jsonc\n\"resources\": {\n \"enabled\": true,\n \"root\": \"../resources\",\n \"load\": [\n \"open77_shell\",\n \"open77_chat\",\n \"open77_vehicles\",\n \"freeroam\",\n \"open77_freeroam\"\n ],\n \"autoStart\": true\n}\n```\n\nAn exact list is recommended for production and gamemode profiles. Adding a new development\nresource to the library then cannot silently add it to a live server.\n\nThe list is read at startup, and the first-run wizard writes an exact list (the template's\nresources, by name). To add a resource to such a server: create its directory, add its name to\n`resources.load` in `server.jsonc`, then either restart the server or -- on a running server\n-- run `refresh` (rescans the manifests on disk; only names the load rules admit are\ndiscovered) followed by `ensure `. `refresh` does not re-read `server.jsonc`: a name that\nwas not in the list when the server started needs the restart. `ensure` on an unknown name\nanswers `Resource '' was not found`, which is this case, not a broken manifest.","natives":[],"permissions":[],"events":[]},{"anchor":"rule-syntax","heading":"Rule syntax","level":3,"text":"| Rule | Result |\n|---|---|\n| `\"freeroam\"` | Selects `/freeroam`. |\n| `\"open77_*\"` | Selects matching immediate child directories. |\n| `\"packs/*\"` | Selects matching resources one level below `/packs`. |\n| `\"packs/**\"` | Recursively selects resources anywhere below `/packs`. `**` must be a complete path segment. |\n| `\"garage/open77.lua\"` | Selects the resource containing that exact manifest; the final `open77.lua` is optional. |\n| `\"D:/Open77/shared/**\"` | Selects from an absolute path outside `resources.root`. Forward slashes avoid JSON escaping on Windows. |\n| `\"!open77_debug\"` | Removes matches selected by an earlier rule. |\n\nRelative names and paths are resolved from `resources.root`. The root itself is resolved from the\ndirectory containing `server.jsonc`, not from the shell's current directory. `*` and `?` never cross\na directory separator. A complete `**` segment crosses any number of directories.\n\nRules run from left to right, so later rules can remove or re-add a resource:\n\n```jsonc\n\"load\": [\n \"open77_*\", // add every immediate open77_ resource\n \"!open77_debug\", // remove debug\n \"!open77_example\", // remove the example package\n \"open77_debug\" // re-add debug deliberately\n]\n```\n\nUseful special cases:\n\n- Omitting `load` is equivalent to `\"load\": [\"*\"]` and preserves the old load-everything behavior.\n- `\"load\": []` selects no resources.\n- A rule matching no current directory selects nothing; the watcher will pick it up if a matching\n resource appears later.\n- Two selected directories declaring the same resource name are both rejected as ambiguous.","natives":[],"permissions":[],"events":[]},{"anchor":"selection-startup-dependencies-and-client-delivery","heading":"Selection, startup, dependencies, and client delivery","level":3,"text":"Selection and startup are separate gates. `load` decides which resources the host knows about.\n`resources.autoStart` decides whether the host starts selected manifests whose own `auto_start` is\ntrue. A selected manifest with `auto_start false` remains available to `ensure `; an\nunselected resource is unknown to `ensure`.\n\nManifest dependencies are not silently added. Every dependency must also match `load`, otherwise\nstarting the dependent resource fails and names the missing dependency. This keeps the configured\nset authoritative: an excluded gamemode cannot return indirectly through another manifest.\n\nOnly selected resources that reach `Running` are packaged and signed for clients. Removing a\nresource from `load` therefore stops it server-side and removes its client scripts, WebUI, and\ndeclared files from the next resource generation.\n\nA resource is delivered to clients whenever it has client content: `shared_script`,\n`client_script` or `files`. A `server_script` never reaches a client and does not require a\n`client_script` beside it. A library that declares `files` and no client script is delivered as a\n**passive library**: the client gives it an idle VM, marks it `Running`, and dependants\n`require('@name/...')` its files (see [Lua modules](lua-modules.md)). What a manifest cannot be is\nempty: with no script and no `files` it is refused on both sides, `no_client_scripts` on the\nclient and \"Resource contains no scripts or files.\" on the server. `web_files` alone do not count.\nBuilds up to 2.31.13+op77.82 refused every delivered resource without a client or shared script,\nand one such refusal failed the whole activation (`resource_activation_failed`, client stuck on\n\"Verifying resources\"); on those builds an empty `client_script` is the workaround.\n\nSelected `preload_mod` declarations are resolved earlier, during dedicated-server boot, because\nthe launcher needs their digest before it can start the game. They are not silently pulled in from\nan excluded resource and they are not added by a dependency that `load` omitted.\n\nFilesystem matches are recalculated at every `watchIntervalMilliseconds` scan. Editing a resource,\ncreating a matching directory, or deleting one is noticed automatically. Editing `server.jsonc`\nitself is not hot-reloaded: restart the dedicated server after changing `root` or `load`. Run\n`resources` at the server console or in Warden to verify the final discovered set.","natives":[],"permissions":[],"events":[]},{"anchor":"publishing-and-reloading","heading":"Publishing and reloading","level":2,"text":"With `autoStart=true`, the server notices any valid change. It prepares a new server VM, publishes a\nnew signed set, then sends `ResourceSetChanged` to the players. The client downloads only the\nmissing chunks and switches generation once verification completes. An invalid Lua candidate keeps\nthe previous generation.\n\nUseful server commands:\n\n```text\nresources\nrefresh\nensure \nstart \nstop \nrestart \nreload \n```\n\nClient diagnostic commands:\n\n```text\nresource.root\nresource.distribution\nresource.list\nresource.status \n```\n\n`resource.root` shows `source=bootstrap` before the session and `source=server` once active.\n`resource.distribution` exposes phase, generation, progress, digest, and current resource.\n\nEvery one of the server commands above is visible to the running resources as events.\n`start`, `ensure` and `restart` announce the resource **before** it starts with\n`onResourceStarting(name)` — delivered inline, while `GetResourceState(name)` still reads\n`starting`; it is a notification, not a veto — then `onResourceStart(name)` once it runs; `stop`\nand `restart` announce `onResourceStop(name, reason)`; `reload` is a stop and a start to every\nother resource; and `refresh` ends with `onResourceListRefresh()` once the tree has been re-read\nand the new package set published. The Open77 spellings (`open77:resource:starting`, `started`,\n`stopped`, `refreshed`) carry a lifecycle revision beside each. See\n[Resource lifecycle events](server-api.md#resource-lifecycle-events).","natives":[],"permissions":[],"events":["open77:resource:starting"]},{"anchor":"security-and-operations","heading":"Security and operations","level":2,"text":"- HTTPS is mandatory for a public URL; HTTP is limited to loopback.\n- HTTP redirects and absolute or traversal paths are refused.\n- The server must keep its signing key file across a migration.\n- The private key and the `.open77` cache must never be committed.\n- A server resource is code chosen by the operator: only grant sensitive permissions to resources\n you audit.\n\nMinimum configuration:\n\n```json\n\"resources\": {\n \"enabled\": true,\n \"root\": \"../resources\",\n \"load\": [\"open77_*\", \"garage\", \"!open77_debug\", \"../shared/**\"],\n \"autoStart\": true,\n \"download\": {\n \"enabled\": true,\n \"listenUrl\": \"http://0.0.0.0:11779\",\n \"publicBaseUrl\": \"https://cdn.example.net/\",\n \"cacheDirectory\": \".open77/resource-cache\",\n \"signingKeyFile\": \".open77/resource-signing-key.json\",\n \"chunkSizeBytes\": 1048576\n }\n}\n```\n\nSee [Selecting which resources load](#selecting-which-resources-load) for exact names, wildcard and\nexclusion semantics, absolute path examples, dependencies, and restart behavior.","natives":[],"permissions":[],"events":[]}]},{"slug":"sky-advertising","file":"sky-advertising.md","title":"Sky hologram advertisements","summary":"Replace supported sky-advertising textures with a custom resource pack. The following sections describe the asset layout, build process and deployment requirements.","sections":[{"anchor":"sky-hologram-advertisements","heading":"Sky hologram advertisements","level":1,"text":"Replace supported sky-advertising textures with a custom resource pack. The following sections describe the asset layout, build process and deployment requirements.\n\nThis is a pre-boot asset pipeline. Lua declares the resulting package, but it cannot swap these\ntextures while the game is running: the particle material resolves its XBM through REDengine's\nresource depot when the game boots.","natives":[],"permissions":[],"events":[]},{"anchor":"build-the-open77-example","heading":"Build the Open77 example","level":2,"text":"Requirements:\n\n- Cyberpunk 2077 2.31 available at the game directory;\n- PowerShell 7;\n- WolvenKit CLI 8.19 or a compatible release.\n\nFrom the repository root:\n\n```powershell\npwsh scripts/new-open77-sky-ad-texture.ps1\n\npwsh scripts/build-sky-advertising.ps1 `\n -InputPath assets/sky-ads/open77-towers-of-light.png `\n -ArchiveName Open77SkyAds `\n -OutputDirectory resources/open77_sky_ads/dist `\n -WolvenKitCli E:/Apps/WolvenKit.Console/WolvenKit.CLI.exe\n```\n\nThe first command deterministically composites the tracked `assets/open77.png` logo into a\n128×2048 strip. The second command:\n\n1. extracts the four matching vanilla XBM headers from the local game installation;\n2. imports the PNG into each header while preserving the measured texture contract;\n3. validates dimensions, compression, group, gamma, streaming, downgrade and 12 mips;\n4. packs `Open77SkyAds.archive`;\n5. wraps it as `Open77SkyAds.zip` under `archive/pc/mod/`;\n6. writes a manifest with hashes and sizes.\n\nThe archive index timestamp and its CRC64 are normalized, so rebuilding unchanged input produces\nthe same archive and package hash instead of forcing every player to download identical art again.\n\nThe scripts never install the archive and never start or stop the game. Extracted CDPR resources\nstay under ignored `build/` paths and are not committed.","natives":[],"permissions":[],"events":[]},{"anchor":"put-it-in-a-resource","heading":"Put it in a resource","level":2,"text":"Use this layout:\n\n```text\nresources/open77_sky_ads/\n open77.lua\n server/main.lua\n source/custom-strip.png\n dist/Open77SkyAds.zip\n```\n\n`open77.lua`:\n\n```lua\nresource \"open77_sky_ads\"\nversion \"1.0.0\"\nauto_start true\n\nserver_script \"server/main.lua\"\npreload_mod \"dist/Open77SkyAds.zip\"\n```\n\n`server/main.lua` can be minimal; the declaration does the boot work:\n\n```lua\nAddEventHandler(\"onResourceStart\", function(name)\n if name == GetCurrentResourceName() then\n print(\"Open77 sky-ad theme declared for the next launcher boot\")\n end\nend)\n```\n\nSelect [`open77_sky_ads`](https://github.com/Open2077/open77-resources/tree/main/standalone/open77_sky_ads) in `resources.load` and leave `requiredMods.enabled` set to `true`. Restart\nthe dedicated server. Its effective required-mod digest now includes the resource package; players\nreceive it through the normal launcher Connect flow before Cyberpunk starts.\n\nDo not also add the ZIP to `files`. Open77 excludes it if a broad glob happens to match, but the\nsource PNG does not need client delivery either. It is build input, not a WebUI texture.","natives":["AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName"],"permissions":[],"events":[]},{"anchor":"custom-png-or-dds","heading":"Custom PNG or DDS","level":2,"text":"The input must be exactly **128×2048**. PNG is the simplest authoring format. DDS is accepted for\nartists who already control its export, but the rebuilt XBM is still validated against the vanilla\ncontract.\n\nAll four bands are replaced by default:\n\n```powershell\npwsh scripts/build-sky-advertising.ps1 `\n -InputPath resources/open77_sky_ads/source/custom-strip.dds `\n -OutputDirectory resources/open77_sky_ads/dist `\n -ArchiveName MyWorldSkyAds\n```\n\nFor a diagnostic partial replacer, pass `-Targets a,b` (valid values are `a`, `b`, `b_censored`,\nand `c`). A public world should normally replace all four so quest/censorship variants do not fall\nback to unrelated vanilla art.","natives":[],"permissions":[],"events":[]},{"anchor":"what-is-replaced","heading":"What is replaced","level":2,"text":"The archive overrides four vanilla depot paths below:\n\n```text\nbase/environment/decoration/advertising/holograms/giant_commercial_stripe/\n```\n\nThey are `giant_commercial_stripe_a.xbm`, `_b.xbm`, `_b_censored.xbm`, and `_c.xbm`.\nTen cityscape/quest particle resources consume these textures. The particles keep their own\nvertical scroll, glow, alpha mask and fade, so source art remains static.\n\nOrdinary billboards are a different Ink/TweakDB `worldAdvertisementNode` system. Tools such as\nWorld Advert Configurator apply to those panels, not to the four sky stripes.","natives":[],"permissions":[],"events":[]},{"anchor":"update-and-removal","heading":"Update and removal","level":2,"text":"After changing the source image:\n\n1. rebuild the ZIP into the resource;\n2. restart the dedicated server so it computes and publishes the new hash;\n3. leave the current game session;\n4. reconnect through the launcher so the new world profile is projected before boot.\n\nA resource `restart` is insufficient. Removing the resource from `resources.load`, restarting the\nserver and launching that world again removes its package from the profile and restores vanilla,\nunless another installed replacer targets the same paths.","natives":[],"permissions":[],"events":[]}]},{"slug":"sound","file":"sound.md","title":"Resource audio","summary":"`open77_sound` plays audio files bundled with a resource, with controls for volume and pitch. Use it for custom interface sounds, ambient audio, radios and positional emitters.","sections":[{"anchor":"resource-audio","heading":"Resource audio","level":1,"text":"`open77_sound` plays audio files bundled with a resource, with controls for volume and pitch. Use it for custom interface sounds, ambient audio, radios and positional emitters.\n\nIt is a different thing from [`Open77.sfx.*`](effects.md), and both stay. `sfx`\nplays **the game's own Wwise events** through the game's own mixer; it is the\nright tool for a gunshot, a door, a vehicle, a menu blip\n([`Open77.sfx.play2d`](effects.md#two-dimensional-sfx) for that last one, since a\nUI click has no position). `open77_sound` plays **your file**, which Wwise has\nnever heard of. Neither replaces the other, and nothing about `Open77.sfx.*`\nchanged when this arrived.\n\nOne asymmetry is worth knowing before you choose: `open77_sound` has `volume` and\n`pitch`; `Open77.sfx` does not, and cannot. The engine's audio events carry no\ngain and no pitch field at all — the measurement is in\n[effects.md](effects.md#there-is-no-volume-and-no-pitch-and-that-is-the-engine).\nIf you need to fade something or bend its pitch, it has to be your file.\n\nRead [What this is not](#what-this-is-not) before you build on it. It is a short\nlist and every item on it is load-bearing.\n\nSource: [`open77_sound`](https://github.com/Open2077/open77-resources/tree/main/system/open77_sound).","natives":[],"permissions":[],"events":[]},{"anchor":"the-model","heading":"The model","level":2,"text":"One hidden CEF surface, owned by the bundled `open77_sound` resource, runs the\nWeb Audio graph for the whole session: decode, gain, pitch, looping and an HRTF\npanner per spatial sound. Your resource never sees it. You call an export, the\nservice ships your file's bytes to that surface once, and it plays them.\n\n```text\nyour resource --exports--> open77_sound --page:send--> hidden CEF surface\n ships the file owns the ids, AudioContext\n names the sound the caps, the PannerNode (HRTF)\n lifetime system audio out\n```\n\nThree consequences worth knowing up front:\n\n- The surface is created **invisible, 16x16, at 1 fps** — the lowest frame rate\n the WebUI host allows. `visible = false` parks the compositor entirely\n (`WasHidden(true)`), and the audio thread is clocked independently of it, so\n the page costs a browser process at idle and no composited frame.\n- Only the **listener** is per-frame-ish, and it is not per-frame: the camera\n transform is pushed at 20 Hz and only while something spatial is playing.\n- Everything is **client-local and owned**. A sound belongs to the resource that\n started it, on the client it started on. The server twins below are a wire that\n asks each client to play it, not a replicated audio entity.","natives":[],"permissions":[],"events":[]},{"anchor":"ship-a-file","heading":"Ship a file","level":2,"text":"Declare it in your manifest's `files` entry. That declaration is the permission:\na file merely sitting in your resource directory cannot be played.\n\n```lua\nresource \"cops\"\nversion \"1.0.0\"\nauto_start true\n\nclient_script \"client/main.lua\"\nserver_script \"server/main.lua\"\n\nfiles { \"sfx/*.ogg\", \"sfx/siren.mp3\" }\n```\n\nPlayable extensions: `mp3`, `ogg`, `oga`, `opus`, `wav`, `flac`, `m4a`, `aac`,\n`webm`. Anything else is refused with `unsupported_audio_format` rather than\nhanded to a decoder that will not take it.","natives":[],"permissions":[],"events":[]},{"anchor":"play-it","heading":"Play it","level":2,"text":"```lua\n-- Flat, everywhere, no position: a UI beep or a phone in your own hand.\nexports.open77_sound:play(\"phone\", \"sfx/ring.ogg\", { volume = 0.7, loop = true })\n\n-- In the world. This is the feature.\nexports.open77_sound:play(\"boombox\", \"sfx/street.ogg\", {\n position = { x = -1590.0, y = 390.0, z = 25.0 },\n loop = true,\n maxDistance = 35.0,\n})\n\n-- Following a body or a vehicle.\nexports.open77_sound:play(\"siren\", \"sfx/siren.ogg\", {\n entity = vehicleEntityId,\n loop = true,\n maxDistance = 80.0,\n})\n\nexports.open77_sound:setVolume(\"boombox\", 0.3)\nexports.open77_sound:setPosition(\"boombox\", { x = -1600.0, y = 395.0, z = 25.0 })\nexports.open77_sound:stop(\"phone\")\n```\n\n| Option | Default | Meaning |\n|---|---|---|\n| `position` | — | A world point. Its presence is what makes a sound spatial. |\n| `entity` | — | An entity the sound follows. Mutually exclusive with `position`. |\n| `volume` | `1.0` | 0..1, your resource's own gain. |\n| `loop` | `false` | Repeats until stopped, or until your resource stops. |\n| `pitch` | `1.0` | 0.25..4. Playback rate, so it changes speed and pitch together. |\n| `offset` | `0` | Seconds into the clip to start at. |\n| `maxDistance` | `40` | Silent at and beyond this distance. |\n| `refDistance` | `1` | Full volume within this distance. Must be below `maxDistance`. |\n\nPlaying an `id` that is already playing **restarts** it. A doorbell rung twice is\none doorbell, not two overlapping ones. Use different ids when you want both.\n\nThe full export list is in [Official resource exports](resource-exports.md).","natives":[],"permissions":[],"events":[]},{"anchor":"the-spatial-behaviour","heading":"The spatial behaviour","level":2,"text":"A spatial sound is a `PannerNode` with `panningModel = \"HRTF\"` — a head-related\ntransfer function, not a left/right pan. That distinction is the whole point: an\nequal-power pan can only tell you a sound is to your left, while HRTF is what\nmakes a boombox on the street *behind* you audibly behind you rather than merely\nquiet.\n\n**The listener is your camera**, not your body. `Open77.camera.view()` is read at\n20 Hz — and only while at least one spatial sound is playing, which is what keeps\nthat read off the idle path; it costs six RTTI lookups and up to six\n`ExecuteFunction` calls on the game thread. Both the listener transform and the\npanner positions are ramped over 60 ms, so the image slides rather than stepping\nfive times a second when you whip the camera.\n\nThe game is Z-up (X east, Y north, Z up) and Web Audio is Y-up with the listener\nfacing −Z, so every position and direction crosses through one mapping:\n\n```text\naudio.x = game.x right stays right\naudio.y = game.z up stays up\naudio.z = -game.y north becomes \"away\"\n```\n\nIts determinant is +1, so handedness is preserved — which matters, because a\nmapping that flips it would put sounds behind you in front of you and nothing\nelse would look wrong.\n\n**Distance uses the linear model**, not the inverse one: gain falls from full at\n`refDistance` to exactly zero at `maxDistance`. \"Audible within 35 m\" therefore\nmeans what it says. The inverse model never quite reaches zero, which is more\nphysical and less useful for a script.\n\nTwo things the listener does **not** carry. There is no roll: the client API\ngives position and forward, not the camera's up vector, so up is taken as the\nworld's +Z. And there is no velocity, so there is no Doppler.","natives":[],"permissions":[],"events":[]},{"anchor":"server-twins","heading":"Server twins","level":2,"text":"A server resource names a file **it** ships and an audience. The audience is\nresolved on the server and the event goes to the players who could hear it —\nnever broadcast for every client to receive and discard.\n\n```lua\n-- One player.\nOpen77.sound.play(source, \"sfx/phone.ogg\")\n\n-- Everyone. One event, not one per player.\nOpen77.sound.broadcast(\"sfx/curfew.ogg\", { volume = 0.4 })\n\n-- Everyone within 40 m of a point, in routing bucket 3.\nOpen77.sound.play(\n { near = { x = -1590.0, y = 390.0, z = 25.0 }, radius = 40.0, bucket = 3 },\n \"sfx/street.ogg\",\n { id = \"boombox\", loop = true, position = { x = -1590.0, y = 390.0, z = 25.0 } })\n\n-- Everyone within 25 m of a player, in that player's own bucket.\nOpen77.sound.play({ near = source, radius = 25.0 }, \"sfx/shout.ogg\")\n\nOpen77.sound.stop(source, \"phone\")\nOpen77.sound.setVolume(-1, \"boombox\", 0.2)\nOpen77.sound.stopAll()\n```\n\n`{ near = ..., radius = ... }` is handed straight to\n[`Open77.players.nearby`](server-api.md), so its options are the same ones —\n`bucket`, `limit`, `maxAgeMs`, `includeSelf` — and so is its bucket rule: the\nanchor player's own bucket by default, `bucket = ` to name one, and\n`bucket = false` for every bucket. **Two players in different routing buckets are\nnot in the same world and never hear each other's boombox.**\n\n`radius` is required in that form. A `near` with no radius would silently reach\nthe whole bucket, which is the mistake the shape exists to prevent.\n\n`id` defaults to the file path, so the common case — one sound, one file — needs\nno id at all.","natives":["server:Open77.sound.play","server:Open77.sound.broadcast","server:Open77.sound.stop","server:Open77.sound.setVolume","server:Open77.sound.stopAll"],"permissions":[],"events":[]},{"anchor":"the-client-half-must-exist","heading":"The client half must exist","level":3,"text":"The file is read out of the session's **client** resource image, so the resource\nthat ships it needs at least one `client_script`. A server-only resource is never\ndownloaded, and its audio is unreachable; the client says so on the local\n`open77:soundFailed` event and in the log rather than failing silently.","natives":[],"permissions":[],"events":["open77:soundFailed"]},{"anchor":"ownership-limits-and-lifetime","heading":"Ownership, limits and lifetime","level":2,"text":"| Limit | Value | Why that number |\n|---|---|---|\n| Simultaneous sounds, per resource | 8 | So one resource cannot spend the session budget. |\n| Simultaneous sounds, per session | 24 | 24 concurrent HRTF panners is already a heavy mix. |\n| File size | 1 MiB | The audio package keeps its own clip cap. The updated client's file-read ceiling is 3 MiB, but the 2 MiB WebUI message ceiling is unchanged; a larger Base64 result need not fit. See [runtime quotas](/docs/resource-runtime#sandbox-and-quotas). |\n| Decoded cache | 64 MiB, LRU | Decoded audio is 32-bit float per channel: a one-minute stereo clip is ~21 MiB no matter how small its file was. |\n| Clip transport | once per file | A clip crosses on its first play and is replayed from the cache afterwards. `preload(file)` moves that cost off the moment the sound has to be heard. |\n\nOver a cap, `play` returns `nil, \"owner_sound_limit\"` or `nil, \"sound_limit\"`. It\nrefuses; it does not evict somebody else's sound to make room.\n\n**A sound cannot outlive the resource that started it.** That is the failure mode\nthis whole design is aimed at — a looping siren still wailing after its resource\nis gone, with nothing left that knows how to stop it. Three things catch it:\n\n- a **reload** is caught by a generation check, on the owner's very next call;\n- a **stop** is caught by a 250 ms owner sweep. It has to be the sweep: the client\n host dispatches `onClientResourceStop` to the stopping instance only, so\n `open77_sound` never hears about anyone else's stop. A quarter second of siren\n is the bounded worst case;\n- **`open77_sound` itself stopping** takes the surface with it, and the\n `AudioContext` and every source node hanging off it go with the surface.\n\nA server resource stopping sends `stopOwner` to every client automatically.","natives":[],"permissions":[],"events":[]},{"anchor":"cross-resource-files-no","heading":"Cross-resource files: no","level":2,"text":"**A resource can only play a file it ships itself.** This is not a policy choice;\nit is the shape of the host, in two independent places:\n\n- `Open77.resource.readFile` / `LoadResourceFile` resolve under the **calling**\n resource's root and refuse a different resource by name\n (`cross_resource_read_denied`);\n- a bundled WebUI surface's virtual files come from its owning resource's root\n and declared allowed-file list; requests into another resource's virtual\n origin are refused. The updated [external-content policy](/docs/resource-runtime#remote-pages-external-content-and-hot-reload)\n allows ordinary web hosts, not cross-resource virtual-file reads.\n\nThose two together mean the bundled service could not serve `cops/sfx/siren.mp3`\nto its own page at all. The one door that makes the service possible is a native\n`Open77.resource.readPackedFile(resource, path)` gated by the\n`resources.files.read` permission — which only `open77_sound` holds, and which\nreads **declared** files only. `open77_sound` never takes the owner from an\nargument: on the export path it is `GetInvokingResource()`, and on the server\npath it is stamped by the host from the calling server resource's name, with\n`open77:sound:` reserved as a client-event prefix so no resource can write that\nfield itself.\n\nThe alternative — letting any resource read any other's assets — buys nothing a\nresource cannot get by shipping its own copy, and costs the guarantee that a\nresource's files are its own.","natives":[],"permissions":[],"events":[]},{"anchor":"what-this-is-not","heading":"What this is not","level":2,"text":"Plainly, because each of these will otherwise be discovered the hard way:\n\n- **It is not Wwise, and it is not in the game's mix.** It is a second, parallel\n audio path that ends at the same speakers. Nothing it plays ducks gunfire,\n dialogue or music, and nothing the game plays ducks it.\n- **It does not follow the player's in-game audio sliders.** The game's master,\n music, SFX and dialogue volumes do not reach it. If your server should respect\n them, wire that yourself: read a setting and call `setVolume`.\n- **It does not occlude.** A sound inside a building is heard through the wall at\n full distance-attenuated volume. There is no raycast, no material, no\n reverb — distance and direction, and nothing else.\n- **There is no Doppler and no roll.** No velocity is fed to the listener or the\n sources, and the camera's up vector is not available to the client API, so the\n world's +Z is used instead.\n- **It is not replicated.** Two clients told to play the same sound are two\n independent playbacks that will drift. Anything that must be sample-accurate\n across clients is out of scope.\n- **It is one shared surface.** Every resource's audio runs in one `AudioContext`;\n a resource cannot be given its own device, its own master gain or its own\n output.\n- **Latency is a browser's latency, not an engine's.** A clip's first play pays\n transport and decode; `preload` exists precisely because that is real.\n\nA native Wwise external-source path would fix most of this list. It is a later\nupgrade and it is not what this is.","natives":[],"permissions":[],"events":[]},{"anchor":"reasons","heading":"Reasons","level":2,"text":"Every refusal is a stable snake_case token.\n\n| Token | Meaning |\n|---|---|\n| `invalid_sound_id` | Empty, over 240 characters, or outside `[%w_:-./]`. |\n| `invalid_file` | Empty, over 240 characters, or containing `..`, a leading `/` or a `:`. |\n| `unsupported_audio_format` | The extension is not one the browser will decode. |\n| `file_not_declared` | The file is not in that resource's `files` manifest entry. |\n| `clip_too_large` | Over 1 MiB. |\n| `file_unreadable` | Declared, but the file could not be read from disk. |\n| `resource_not_found` | The named resource is not in this client's resource set. |\n| `owner_sound_limit` / `sound_limit` | Caps above. |\n| `sound_not_found` | No live sound of yours holds that id. |\n| `sound_not_spatial` | `setPosition` on a sound started with neither `position` nor `entity`. |\n| `position_and_entity` | Both were given; pick one. |\n| `entity_not_found` | The entity has no readable position on this client. |\n| `surface_unavailable` | The page is not up yet, or `open77_sound` is not running. |\n| `audio_blocked` | The browser refused to start audio. See below. |\n| `export_call_required` | Called other than through an export, so there is no owner. |\n| `invalid_sound_target` / `invalid_sound_radius` | Server side: a bad audience. |\n| `no_audience` | Server side: the audience resolved to nobody. |\n| `reserved_sound_event` | Server side: `open77:sound:` is not yours to emit. |\n\n`audio_blocked` deserves a note. Chromium gates unattended audio behind a user\ngesture, per document, and this surface is invisible and receives no input — so\nit can never earn one. The WebUI host therefore runs with\n`autoplay-policy=no-user-gesture-required`: every document in it is a file a\nresource declared, served from disk over a per-surface origin under\n`default-src 'self'` with `connect-src 'none'`, so there is no drive-by web\ncontent the gate was protecting anyone from. The page still calls `resume()` and\nstill reports `audio_blocked` if that ever changes, so the failure stays named\ninstead of going silent.","natives":[],"permissions":[],"events":[]}]},{"slug":"state-bags","file":"state-bags.md","title":"Replicated state bags","summary":"Replicate structured state with server-authoritative state bags. Resources can read snapshots and subscribe to changes instead of defining separate synchronization events for each value.","sections":[{"anchor":"replicated-state-bags","heading":"Replicated state bags","level":1,"text":"Replicate structured state with server-authoritative state bags. Resources can read snapshots and subscribe to changes instead of defining separate synchronization events for each value.\n\n```lua\n-- server\nOpen77.state.player(source):set(\"job\", { name = \"police\", grade = 3 })\nOpen77.state.entity(\"vehicle\", vehicleId):set(\"fuel\", 61.5)\nOpen77.state.global:set(\"weather\", \"rain\")\n```\n\n```lua\n-- client, anywhere, on any resource\nprint(Open77.state.player(source).job.name) --> police\nprint(Open77.state.global.weather) --> rain\n```\n\nThat is the whole idea. Vehicle keys, fuel, cuffs, job, duty and engine state are all\nstate-bag fields in a modern roleplay framework, and a bag replaces the ad-hoc broadcast\neach of them used to need. Two of those are worked through on this page: the bundled\n[`open77_fuel`](#the-fuel-sample-open77_fuel) resource is the reference for a server-owned\nper-vehicle value with a consumption tick, and the [plate recipe](#recipe-a-number-plate) is\nthe two-line version.\n\nThe FiveM names work too, because ported code reaches for those and never for an\n`Open77.*` name:\n\n```lua\nPlayer(source).state.cuffed = true\nEntity(vehicleId).state:set(\"keys\", { \"12\", \"13\" })\nGlobalState.hour = 21\nLocalPlayer.state.job -- client only\nAddStateBagChangeHandler(\"cuffed\", nil, function(bagName, key, value) ... end)\n```\n\n---","natives":["Open77.state.player","server:Open77.state.player","Open77.state.entity","server:Open77.state.entity","Player","server:Player","Entity","server:Entity","AddStateBagChangeHandler","server:AddStateBagChangeHandler"],"permissions":[],"events":[]},{"anchor":"open77state-now-holds-two-unrelated-things","heading":"`Open77.state` now holds two unrelated things","level":2,"text":"This is worth stating plainly before anything else, because it will look like a mistake\notherwise.\n\n| Name | What it is |\n|---|---|\n| `Open77.state.save` / `.load` / `.clear` | **Resource-private JSON that survives a reload and never crosses the wire.** One opaque blob per resource, 64 KiB, dropped on stop. See [server resources](server-resources.md). |\n| `Open77.state.global` / `.player(id)` / `.entity(...)` / `.onChange` / `.allowRequest` | **Replicated key/value bags**, the subject of this page. |\n\nThey share a table and share nothing else. The tidier arrangement would have been to move\nthe first group somewhere like `Open77.resource.stash`, and that was considered and\nrejected: five of the seven bundled server resources feature-detect with\n\n```lua\nif type(Open77.state) ~= \"table\" or type(Open77.state.save) ~= \"function\" then return end\n```\n\nand a rename that left those guards standing would make them take the silent no-op branch.\nCordon would stop carrying its match state across a reload and say nothing about it. A\nsilent data loss is worse than a slightly crowded table, so `save`/`load`/`clear` keep\nworking exactly as they always have, and the bag accessors were added beside them.\n\nThere is no parity cost either way: nothing ported from FiveM reaches for an `Open77.*`\nbag name.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"selectors","heading":"Selectors","level":2,"text":"A **selector** names one bag. Every one of these returns a bag handle.\n\n| Selector | Side | Meaning |\n|---|---|---|\n| `Open77.state.global` | both | The one server-wide bag. Not a function: it is the handle. |\n| `Open77.state.player(id)` | both | The bag of one player. `id` may be a number or its decimal string. |\n| `Open77.state.entity(kind, id)` | both | `kind` is `\"vehicle\"`, `\"npc\"` or `\"prop\"`. |\n| `Open77.state.entity{ kind = , id = }` | both | The same thing in table form. |\n| `Open77.state.localPlayer()` | client | The local player's own bag. `nil, \"no_session\"` before a session exists. |\n\nA handle is a plain Lua table behind a protected metatable, so it can be kept in an upvalue\nand reused; asking for the same bag twice gives you an equivalent handle either way.","natives":[],"permissions":[],"events":[]},{"anchor":"reading","heading":"Reading","level":3,"text":"```lua\nlocal bag = Open77.state.player(source)\n\nbag:get(\"job\") --> value, or nil\nbag.job --> the same value (read sugar)\nbag:all() --> { job = ..., duty = ... } -- a copy, never a live view\nbag:revision() --> a monotonic integer, 0 for a bag that does not exist\nbag:selector() --> { kind = \"player\", id = \"12\" }\n```\n\n`bag:get` and `bag.key` are the same store. A bag that does not exist reads as an empty\none rather than erroring, which is what lets a client read a key before the server has ever\nwritten it.\n\n**Five key names are shadowed by the methods**: `get`, `set`, `all`, `clear` and\n`revision`. `bag.get` is the function, not the value under the key `\"get\"`. Reach those\nkeys — if you really must have them — with `bag:get(\"get\")`.","natives":["Open77.state.player","server:Open77.state.player"],"permissions":[],"events":[]},{"anchor":"writing-server-only","heading":"Writing (server only)","level":3,"text":"```lua\nbag:set(\"fuel\", 61.5) --> true | false, reason\nbag:set(\"fuel\", nil) --> clears the key\nbag.fuel = 61.5 --> write sugar; RAISES on refusal\nbag:clear(\"fuel\") --> true | false, reason\nbag:clear() --> destroys the whole bag\n```\n\nWriting needs the `state.write` permission in the manifest. Reading never does.\n\n```lua\n-- open77.lua\npermission \"state.write\"\n```\n\n`bag:set` returns `false, reason` so a caller can handle a refusal. The `bag.key = value`\nsugar has no return channel, so it **raises**: a write that silently disappeared would be\nfar harder to find than one that says so at the line that is wrong.","natives":[],"permissions":[],"events":[]},{"anchor":"values","heading":"Values","level":3,"text":"Anything JSON can carry: strings, numbers, booleans, and nested tables of them. Functions,\ncoroutines and cycles are refused with `state_value_not_serializable`.\n\nA **vector goes in and comes back as the plain `x`/`y`/`z` table** every Open77 position\nAPI already returns — that is the [vector](vectors.md) contract, not a limitation of bags:\n\n```lua\nbag:set(\"spawn\", vector3(-1234.5, 678.25, 12.5))\nlocal spawn = bag.spawn\nspawn.x --> -1234.5\nvector3(spawn) --> a vector again, for the maths\n```\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"change-handlers","heading":"Change handlers","level":2,"text":"```lua\nlocal subscription = Open77.state.onChange(selector, key, function(selector, key, value, previous)\n print(selector.kind, selector.id, key, value, previous)\nend)\n\nOpen77.state.offChange(subscription)\n```\n\n`selector` may be a bag handle, a `{ kind = , id = }` table, a bare kind string\n(`\"player\"`, `\"vehicle\"`, …) to watch every bag of that kind, or `nil`/`\"*\"` for every\nbag. `key` may be a key name or `nil`/`\"*\"` for every key.\n\nThe handler runs at a tick boundary, never inside the write, so it may `Wait`. On a cleared\nkey `value` is `nil`; on a first write `previous` is `nil`.\n\n**Only the resources that subscribed are touched.** The filter lives on the host side, so a\n`duty` flip does not marshal a payload into every running resource so that one of them can\nread it.","natives":["Open77.state.onChange","server:Open77.state.onChange","Open77.state.offChange","server:Open77.state.offChange"],"permissions":[],"events":[]},{"anchor":"the-fivem-shim","heading":"The FiveM shim","level":3,"text":"```lua\nlocal id = AddStateBagChangeHandler(key, bagName, function(bagName, key, value, reserved, replicated)\n ...\nend)\nRemoveStateBagChangeHandler(id)\n```\n\n`bagName` is `nil` for every bag, `\"global\"`, `\"player:\"`, `\":\"`, or\n`\"entity:\"` — the last matches any entity kind with that id, because FiveM has one\nentity namespace and Open77 has three registries. `reserved` is always `nil` and\n`replicated` is always `true`; both exist so a ported signature still lines up.\n\n---","natives":["AddStateBagChangeHandler","server:AddStateBagChangeHandler","RemoveStateBagChangeHandler","server:RemoveStateBagChangeHandler"],"permissions":[],"events":[]},{"anchor":"the-client-never-writes-a-bag","heading":"The client never writes a bag","level":2,"text":"This is the rule that surprises people porting from FiveM, and it is deliberate.\n\n`bag:set` exists on the client, and always answers:\n\n```lua\nlocal ok, reason = Open77.state.global:set(\"weather\", \"sun\")\n--> false, \"requires_server_arbitration\"\n```\n\n`bag.key = value` on the client raises, for the same reason as on the server: a silent\nno-op would be worse.\n\nWhat a client *can* do is **ask**:\n\n```lua\n-- client\nOpen77.state.request(Open77.state.localPlayer(), \"seatbelt\", true) --> true | false, reason\n```\n\n`request` acknowledges the **send**, never the outcome. The server decides, and the value\nlands in the mirror when the delta comes back — there is no optimistic apply, and no path\nby which a client's bytes become the bag's value unread.\n\nThe server opts a key in and supplies the validator:\n\n```lua\n-- server\nOpen77.state.allowRequest(\"player\", \"seatbelt\", function(source, value)\n if type(value) ~= \"boolean\" then return false end\n return true, value -- accepted, and this is the value that gets stored\nend)\n\nOpen77.state.denyRequest(\"player\", \"seatbelt\")\n```\n\nThe validator runs in the declaring resource's VM, and **the value it returns is what the\nbag stores** — return a normalised value to clamp, round or rewrite whatever the client\nsent. Returning anything but `true` refuses. A key nobody opted in is refused as\n`not_requestable` before any Lua runs at all.\n\n`allowRequest` accepts a kind string to cover every bag of that kind, or a specific bag\nhandle / `{ kind = , id = }` to cover one.","natives":["Open77.state.request","Open77.state.localPlayer","server:Open77.state.allowRequest","server:Open77.state.denyRequest"],"permissions":[],"events":[]},{"anchor":"why-and-the-cost-of-getting-it-wrong","heading":"Why, and the cost of getting it wrong","level":3,"text":"Two reasons, and the second is the hard one.\n\n**Trust.** A bag is shared state. A client that could write `Player(src).state.job` could\nwrite it for anybody watching, and an admin flag, a bounty or a stash code is exactly the\nkind of thing a bag carries.\n\n**The inbound budget.** The transport disconnects a session on its **33rd inbound net event\nin one second**. A FiveM resource that sets a client state key every frame will, ported\nnaively, drop the player. Open77 makes that hard to do by accident:\n\n- the client shim **coalesces** — every `request` in a window is collapsed per key and sent\n as one net event, at most four times a second;\n- the server refuses beyond **4 requests per second per session**, with\n `state_request_rate_limit`, long before the transport's limit can fire.\n\nA refusal comes back as a log line on the client rather than vanishing. Do not build a UI\nthat calls `request` on every keystroke; call it when the value settles.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"who-sees-what","heading":"Who sees what","level":2,"text":"| Bag | Audience |\n|---|---|\n| `global` | every connected player, every routing bucket |\n| `player(id)` | every player in that player's routing bucket, including the subject |\n| `entity(kind, id)` | exactly the players streaming that entity — the registry's own viewer set |\n\nAn entity bag's audience **is** the entity's audience, which is what makes stream-in,\nstream-out and bucket changes correct without a second interest pass to keep in sync.\n\n**Routing buckets are isolated.** A player in another bucket does not receive the value and\nis not told to ignore it: a bag carries gameplay secrets, so shipping it to a client that\nis merely asked not to look is an information leak with no bandwidth saving. Moving bucket\ndrops the player's whole mirror and re-sends what the new bucket holds, atomically with the\nmove.\n\nA late joiner, a streamed-in entity and a resynchronising client all receive a **full\nsnapshot**, chunked across several events when it is large. Everything else is a **delta**\ncarrying only the keys that moved.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"lifecycle","heading":"Lifecycle","level":2,"text":"- A bag **dies with its subject**: removing a prop, vehicle or NPC destroys its bag, and a\n disconnecting player takes theirs with them. Every holder is told to drop it, and every\n key raises a change so a handler watching one key sees it go.\n- A resource **stopping** retires its change subscriptions and its `allowRequest`\n validators. A requestable key whose validator went down with its resource stops being\n requestable, rather than accepting whatever the client sends.\n- The client mirror lives in the host, not in a resource, so it **survives a resource\n restart**: a resource coming back up reads the current values immediately.\n- A new server session drops the mirror and asks for a fresh one, so the previous server's\n bags never linger.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"caps","heading":"Caps","level":2,"text":"Nothing truncates. A write that breaches a cap is refused by name and the bag is left\nexactly as it was — no partial apply, no key dropped to make room.\n\n| Cap | Value | Refusal |\n|---|---|---|\n| Keys per bag | 64 | `bag_key_limit` |\n| Key name | 64 bytes, `[A-Za-z][A-Za-z0-9_.-]*` | `invalid_state_key` |\n| Value, serialized | 4 KiB | `state_value_too_large` |\n| Value depth / nodes | 8 / 256 | `state_value_not_serializable` |\n| Bytes per bag | 16 KiB | `bag_byte_limit` |\n| Bags per server | 8 192 | `bag_limit` |\n| Total bag bytes | 8 MiB | `bag_byte_limit` |\n| Client requests | 4 per second per session | `state_request_rate_limit` |\n| Server writes | 20 000 per second per resource | `state_write_rate_limit` |\n\nThe value budget is deliberately tighter than the 48 KiB export envelope: an export payload\ncrosses once, a bag value is re-sent to every viewer on every change and again in every\nsnapshot.\n\nThe server write budget is a safety valve, not a bandwidth control — ten writes to one key\nin one tick already collapse into one delta carrying the final value, so outbound cost\nfollows the number of distinct keys that *moved*. A resource that trips it is named in the\nserver log, once per window, so the failure is attributable.","natives":[],"permissions":[],"events":[]},{"anchor":"every-failure-reason","heading":"Every failure reason","level":3,"text":"| Reason | Means |\n|---|---|\n| `invalid_state_key` | The key name is empty, too long, or outside `[A-Za-z][A-Za-z0-9_.-]*`. |\n| `invalid_state_selector` | The kind is not one of `global`/`player`/`vehicle`/`npc`/`prop`, or the id is not a non-negative integer. |\n| `state_value_too_large` | Over 4 KiB serialized. |\n| `state_value_not_serializable` | A function, a coroutine, a cycle, or deeper than 8 / more than 256 nodes. |\n| `bag_key_limit` | A 65th key. |\n| `bag_byte_limit` | Over 16 KiB in one bag, or 8 MiB across the server. |\n| `bag_limit` | An 8 193rd bag. |\n| `unknown_bag` | Clearing a key or a bag that does not exist. |\n| `permission_denied:state.write` | Writing without the manifest permission. |\n| `requires_server_arbitration` | `set`/`clear` called on the client. |\n| `not_requestable` | A client asked for a key no resource opted in. |\n| `state_request_rate_limit` | More than 4 client requests a second from one session. |\n| `state_write_rate_limit` | More than 20 000 server writes a second from one resource. |\n| `state_unavailable` | The host has no bag registry (a fixture that does not replicate). |\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"the-fuel-sample-open77fuel","heading":"The fuel sample (`open77_fuel`)","level":2,"text":"Cyberpunk has no fuel model, so FiveM's `GetVehicleFuelLevel` / `SetVehicleFuelLevel` have no\nnative to adapt. Fuel is a bag field, and [`resources/system/open77_fuel`](https://github.com/Open2077/open77-resources/tree/main/system/open77_fuel) is the canonical sample\nof the pattern -- small enough to copy, and shipped so the three places that already cited it\nwere telling the truth.\n\n**What it does.** The server owns one number, `fuel`, on every canonical vehicle's bag. Once a\nsecond it walks `Open77.vehicles.all()`, reads the replicated `speed` and the `engineOn` bit the\nphysics owner already reports, and burns the tank through a pure model (`shared/fuel.lua`:\nlitres per hundred kilometres while moving, litres per minute while idling, one `multiplier`\nover both). At zero it cuts the engine with `Open77.vehicles.setEngine(id, false)` -- the\nserver-authored bit that is held against the owner's next report -- and keeps cutting it every\ntick until somebody refuels, and it fires `open77:fuel:empty(vehicleId)` host-wide once per\nempty. A tank the model has never seen starts full; a removed vehicle takes its bag with it.\n\n```lua\n-- Any server resource: FiveM's two natives, as exports.\nlocal litres = exports.open77_fuel:level(vehicleId) -- GetVehicleFuelLevel\nexports.open77_fuel:set(vehicleId, 12.5) -- SetVehicleFuelLevel\nexports.open77_fuel:refuel(vehicleId) -- to the brim; refuel(id, 20) adds 20 L\nexports.open77_fuel:configure({ litresPerHundredKm = 9.0 }) -- or multiplier, idleLitresPerMinute, capacity\n\n-- Or the bag itself, with no export in the way.\nlocal litres = Open77.state.entity(\"vehicle\", vehicleId).fuel\nEntity(vehicleId).state:set(\"fuel\", 30) -- the FiveM spelling; needs state.write\n```\n\n```lua\n-- Any client: a gauge is a read. No permission, no event, no request.\nlocal litres = Open77.state.entity(\"vehicle\", vehicleId).fuel\nlocal mine = exports.open77_fuel:current() -- the car the local player is sitting in\nAddEventHandler(\"open77:fuel:changed\", function(vehicleId, litres, previous) gauge.set(litres) end)\n```\n\n**What it deliberately is not.** The client never writes the bag, because a fuel gauge a\nclient could set is a fuel gauge a client can cheat; consumption is derived from the owner's\nreplicated speed, which the server already validates. And the model lives in a shared file with\nits own Lua suite (`tests/fuel_test.lua`, run inside the server test process) so the numbers a\nserver owner tunes are the numbers that are tested.\n\nCommands: `/fuel ` prints the tank, `/refuel [litres]`, `/fuel.set ` and\n`/fuel.multiplier ` are admin-gated. The manifest holds `world.vehicles` and `state.write`\non the server and `vehicles.read` on the client, the last only so `current()` can map the entity\nthe local body is mounted in back to its canonical id.","natives":["Open77.state.entity","server:Open77.state.entity","Entity","server:Entity","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:fuel:changed"]},{"anchor":"recipe-a-number-plate","heading":"Recipe: a number plate","level":2,"text":"Cyberpunk has no readable or writable plate -- `Open77.vehicles.setProperties(id, { plateText =\n... })` is refused by name and `Open77.vehicles.unsupportedProperties.plateText` says why -- so a\nplate is a bag key, exactly like fuel but without the tick:\n\n```lua\n-- server (needs state.write): the plate is whatever the server says it is\nEntity(vehicleId).state:set(\"plate\", \"NC-1977\")\n-- the same write, natively spelled\nOpen77.state.entity(\"vehicle\", vehicleId):set(\"plate\", \"NC-1977\")\n```\n\n```lua\n-- either side: read it back\nlocal plate = Entity(vehicleId).state.plate\n```\n\nThere is no `Open77.vehicles.setLabel`, and nameplates are player-keyed, so the plate is\n**shown** the way any other per-entity text is: a client-side world anchor (`Open77.anchors.create`\nin the [API reference](index.html), with `render = \"dot\"` drawn natively by the plugin) pinned to\nthe car's entity, whose label follows the bag.\n\n```lua\n-- client: a small dot above every streamed car that carries a plate\nlocal anchors = {}\n\nlocal function show(vehicleId, plate)\n local car = Open77.vehicles.get(vehicleId) -- needs vehicles.read\n if car == nil or not car.streamed then return end\n local presentation = { label = plate, scale = 0.8 }\n if anchors[vehicleId] then\n Open77.anchors.update(anchors[vehicleId], { presentation = presentation })\n return\n end\n anchors[vehicleId] = Open77.anchors.create({\n render = \"dot\",\n entity = car.entity, offset = { x = 0, y = 0, z = 1.6 },\n maxDistance = 25,\n presentation = presentation,\n })\nend\n\nOpen77.state.onChange(\"vehicle\", \"plate\", function(selector, _, plate)\n local vehicleId = tonumber(selector.id)\n if plate == nil then\n if anchors[vehicleId] then Open77.anchors.remove(anchors[vehicleId]) end\n anchors[vehicleId] = nil\n else\n show(vehicleId, plate)\n end\nend)\n\nAddEventHandler(\"open77:vehicleRemoved\", function(vehicleId)\n if anchors[vehicleId] then Open77.anchors.remove(anchors[vehicleId]) end\n anchors[vehicleId] = nil\nend)\n```\n\nThe bag change arrives before the car has necessarily streamed in on this client; the\n`open77:vehicleCreated` event is the moment to call `show` for a plate that was set before the\ncar was in range. Everything else -- late joiners, bucket changes, the car leaving -- the bag\nand the anchor already handle: the bag is re-sent with the entity, and an entity anchor whose\nentity is not streamed is simply not drawn.","natives":["Entity","server:Entity","Open77.state.entity","server:Open77.state.entity","Open77.vehicles.get","server:Open77.vehicles.get","Open77.anchors.update","Open77.anchors.create","Open77.state.onChange","server:Open77.state.onChange","Open77.anchors.remove","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:vehicleRemoved","open77:vehicleCreated"]},{"anchor":"cost","heading":"Cost","level":2,"text":"Five things keep this cheap, in the order they matter: deltas rather than snapshots, a\ndirty set rather than a scan, coalescing to the tick, recipients from the entity's own\nviewer set, and **one event per recipient per tick** carrying everything that recipient is\nowed.\n\nMeasured at 64 players, each with a 20-key bag:\n\n| Tick | Events | Bytes server-wide | Time |\n|---|---|---|---|\n| 5 keys change (a realistic roleplay tick) | 64 | ~16 KB | ~0.5 ms |\n| All 64 bags change at once (pathological) | 64 | ~213 KB, largest event 3.3 KB | ~20 ms |\n\nNever 64 × 64 messages, and every event fits one net-event envelope with room to spare. The\nsecond row is reachable only if every player in one bucket changes a key in the same tick; most\nof its cost is the transport encoding 213 KB, not the bags.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"the-wire-and-why-there-is-no-protocol-change","heading":"The wire, and why there is no protocol change","level":2,"text":"Bags ride the existing `NetEvent` envelope, the same road props, effects and loot already\ntake. That is deliberate and load-bearing: the protocol version check is **exact equality\non every frame, in both directions**, so a new binary opcode would cost a `MinorVersion`\nbump and refuse every client that had not been rebuilt in lockstep, at the first frame.\n\nThe five reserved names — `open77:state:snapshot`, `:delta`, `:drop`, `:ready` and\n`:request` — are platform-owned on both sides. A resource cannot publish one and a client\ncannot raise one into a resource handler.\n\n---","natives":[],"permissions":[],"events":["open77:state:snapshot"]},{"anchor":"see-also","heading":"See also","level":2,"text":"- [Vectors and quaternions](vectors.md) — what a vector is when it crosses the network.\n- [Server resources](server-resources.md) — manifests, permissions, and `Open77.state.save`.\n- [Complete server Lua API](server-api.md) — every server global and method.","natives":[],"permissions":[],"events":[]}]},{"slug":"third-person-camera","file":"third-person-camera.md","title":"Third-person camera styles","summary":"Configure the playable third-person camera through `Open77.camera`: framing, movement response and resource-owned styles. Cinematic camera ownership is documented separately in [Scripted cameras](cameras.md).","sections":[{"anchor":"third-person-camera-styles","heading":"Third-person camera styles","level":1,"text":"Configure the playable third-person camera through `Open77.camera`: framing, movement response and resource-owned styles. Cinematic camera ownership is documented separately in [Scripted cameras](cameras.md).","natives":[],"permissions":[],"events":[]},{"anchor":"availability-and-permissions","heading":"Availability and permissions","level":2,"text":"These functions require the client update introducing camera styles, cinematic\ndisplay and resource-owned perspective locks. Older clients do not gain native\nAPIs from a resource reload or a server update. Check for the functions before\noffering these options if your package supports older clients:\n\n```lua\nlocal supported = Open77.camera and Open77.camera.configureThirdPerson\n and Open77.perspective and Open77.perspective.setThirdPerson\n and Open77.hud and Open77.hud.setCinematic\nif not supported then\n print(\"Update the Open77 client to use camera styles and cinematic display\")\n return\nend\n```\n\n| Control | Client manifest permission | Release |\n|---|---|---|\n| Camera framing, FOV and shake | `camera.style` | `Open77.camera.resetThirdPerson()` |\n| Ordinary FPP/TPP request | None | Player can toggle again |\n| Temporary forced FPP/TPP | `perspective.policy` | `Open77.perspective.clearThirdPersonOverride()` |\n| Cinematic HUD and black bars | `ui.vanilla.hud` | `Open77.hud.setCinematic(false)` |\n\nThese are independent controls. They do not require a new network protocol or\na dedicated-server binary change; they affect the calling client's presentation.\n\nAdd `\"camera.style\"` to your resource's `permissions`. Configuration alone does\nnot enable third person or bypass a server perspective policy:\n\n```lua\nlocal ok, reason = Open77.camera.configureThirdPerson({ style = \"shoulder\" })\nif not ok then print(reason); return end\nlocal enabled, why = Open77.perspective.set(\"tps\")\nif not enabled then Open77.camera.resetThirdPerson(); print(why) end\n```","natives":["Open77.camera.configureThirdPerson","Open77.perspective.set","Open77.camera.resetThirdPerson"],"permissions":[],"events":[]},{"anchor":"functions","heading":"Functions","level":2,"text":"To enable/disable TPP, or force it and block the F7 toggle, see\n[`Open77.perspective.setThirdPerson(enabled, force)`](perspective.md#enable-disable-or-temporarily-force-a-view-client).\nStyle configuration and perspective locks are independent: resetting one does\nnot reset the other.\n\n| Function | Result / purpose |\n|---|---|\n| `Open77.camera.configureThirdPerson(options)` | `true`, or `false, reason`. Acquire the style or patch your current settings. |\n| `Open77.camera.thirdPersonState()` | Snapshot described below, or `nil, reason` when unavailable. Read-only; no permission required. |\n| `Open77.camera.resetThirdPerson()` | Release your style and shake, blend back to the underlying framing/lens. Does not disable F7. |\n| `Open77.camera.shakeThirdPerson(options)` | Start/replace one finite positional shake owned by your style. |\n| `Open77.camera.stopThirdPersonShake()` | Stop only your shake. Safe if you own none. |\n\nAll mutations return `true` or `false, reason`. A second resource cannot replace\nor reset the current owner's style (`camera_style_owned`). Stop/reload,\ndisconnection teardown and a failed Lua coroutine release the owning resource's\nclaim automatically. A stopped unrelated resource does not affect it.","natives":[],"permissions":[],"events":[]},{"anchor":"configuration","heading":"Configuration","level":2,"text":"Unknown fields and invalid/non-finite values are rejected atomically. No partial\nconfiguration is applied. Without `style`, fields patch your current settings;\non the first call they patch `classic`. Supplying `style` resets to that preset\nbefore applying the other fields in the same call.\n\n| Field | Values / units |\n|---|---|\n| `style` | `\"classic\"`, `\"shoulder\"`, `\"centered\"` |\n| `anchor` | `\"eyes\"` (default, tracked native eye parent), or `\"body\"` (player origin at the feet) |\n| `hip`, `aim`, `explore` | Independent framing tables: `{ distance, offset, fov }` |\n| `distance` in a framing | Boom length, 0.8–8 metres. Geometry and near-plane safety can shorten it. |\n| `offset` in a framing | `vector3(x,y,z)` or a complete `{x=,y=,z=}` table: view-right, view-forward, world-up metres. X: −2…2, Y: −1…1, Z: −1.5…3. |\n| `fov` in a framing | Native component FOV degrees, 40–110; `0` restores/preserves the captured gameplay lens. |\n| `shoulder` | `\"right\"` (default) or `\"left\"`; mirrors the lateral offset with a smooth swap. |\n| `transition` | Framing/FOV blend duration, 0–3 seconds; default 0.3. Reversal continues from the current framing. |\n| `runSpeed` | Ground-speed threshold for `runFov`, 0.5–15 m/s; default 2.5. |\n| `runFov`, `sprintFov` | Optional absolute FOV, same range as `fov`; zero disables that override. Sprint wins over running; aiming wins over both. |\n\nThe shoulder preset uses hip/explore distance 1.65 m, offset `(0.65, 0, -0.12)`,\nFOV 65; aim distance 1.25 m, offset `(0.65, 0.05, -0.06)`, FOV 55. Running/sprint\nFOV is 70/76. It deliberately frames the upper body instead of fitting the feet.\nClassic preserves the gameplay FOV; centered removes hip/explore lateral bias\nbut retains an offset when aiming so the character does not cover the reticle.\n\nAnchors are **stable gameplay pivots, not animated skeleton bones**. A literal\nshoulder bone would inherit gait/attack jitter. `eyes` follows native crouch\nheight; `body` is a fixed offset above the entity origin, so specify a suitable\nZ yourself (for example 1.5 m). Existing crouch boom shortening still applies.\nExtreme pitch, walls and fitting-room previews take safety/presentation priority\nover exact requested distances. No collision-disable API is provided.\n\n```lua\nassert(Open77.camera.configureThirdPerson({\n style = \"shoulder\",\n anchor = \"body\",\n hip = { distance = 1.8, offset = vector3(0.65, 0, 1.5), fov = 65 },\n aim = { distance = 1.3, offset = vector3(0.65, 0, 1.55), fov = 55 },\n explore = { distance = 2.2, offset = vector3(0.5, 0, 1.5), fov = 68 },\n transition = 0.35,\n runSpeed = 2.5, runFov = 72, sprintFov = 78,\n}))\n-- Later, one patch; no Lua per-frame camera loop is needed.\nassert(Open77.camera.configureThirdPerson({ shoulder = \"left\" }))\n```\n\n`thirdPersonState()` returns `configured`, `owned`, `active`, `shaking`,\n`appliedFov`, and the complete configuration (`style`, `anchor`, `shoulder`,\n`hip`, `aim`, `explore`, `transition`, `runSpeed`, `runFov`, `sprintFov`).\n`style` names the base preset, not a guarantee its fields were never customized.\n`active=false` while FPP, vehicle/scripted cameras or wardrobe own the view;\nthe configuration stays requested and resumes when the playable TPP rig returns.\n`appliedFov` is the last rig-written FOV, not necessarily another camera's lens;\nuse `Open77.camera.view()` for the current rendered view.","natives":["Open77.camera.configureThirdPerson"],"permissions":[],"events":[]},{"anchor":"shake","heading":"Shake","level":2,"text":"Configure/acquire a style first. The effect moves the pivot **before collision\nresolution**, not the player or the gameplay aim. It is positional only: it\ndoes not inject angular aim/recoil or camera roll.\n\n```lua\nlocal ok, reason = Open77.camera.shakeThirdPerson({\n amplitude = vector3(0.03, 0.015, 0.02), -- maximum metres per camera axis\n frequency = 8, -- Hz, 0.1–20\n duration = 0.65, -- seconds, 0.05–10\n})\n-- Optional early cancellation:\nOpen77.camera.stopThirdPersonShake()\n```\n\nDefaults: amplitude `(0.02,0.01,0.015)`, frequency 8 Hz, duration 0.5 seconds.\nEach amplitude must be 0–0.2 m. The attack/release envelope avoids abrupt starts\nand natural expiry. Explicit stop is immediate. A new shake replaces the old\none rather than accumulating unbounded effects. It expires in wall-clock time\neven while another camera owns the screen. Do not trigger it every frame.\n\nReasons include `permission_denied:camera.style`, `camera_style_unavailable`,\n`invalid_camera_style_fields`, `invalid_camera_style`, `invalid_camera_shake`,\n`camera_style_owned`, `camera_style_not_owned`.","natives":["Open77.camera.shakeThirdPerson","Open77.camera.stopThirdPersonShake"],"permissions":[],"events":[]},{"anchor":"freeroam","heading":"Freeroam","level":2,"text":"- `/camchange`: cycle classic → shoulder → centered → classic, enabling TPP.\n- `/camchange shoulder`, `/camchange centered`, `/camchange classic`: select.\n- `/camchange left` or `/camchange right`: swap the selected style's shoulder.\n- `/camchange shake`: preview a short shake; a style must already be selected.\n- `/camchange reset`: release Freeroam's claim, restoring default framing/FOV.\n\nFreeroam acquires no style on startup. Use `reset` before handing control to\nanother resource. This command changes only the sender's local camera; it is\nnot a server camera broadcast. Existing `camera.thirdPerson`, `camera.setFov`\nand cinematic `camera.shake(id, ...)` retain their original signatures. Prefer\nthis configuration API over fighting a running rig with repeated `setFov` calls.","natives":[],"permissions":[],"events":[]},{"anchor":"cinematic-display","heading":"Cinematic display","level":2,"text":"Freeroam provides `/cinematic` (toggle), `/cinematic on` and `/cinematic off`.\nIt hides the native HUD (including minimap), Open77 pages (including watermark,\nchat history and Freeroam HUD), and native world labels/debug overlays. Black\nletterbox bars slide in at the top and bottom. It does not change the camera,\npause the game, alter audio, or hide the interface on other players' screens.\n\n```lua\n-- Client API; requires permission \"ui.vanilla.hud\".\nlocal ok, reason = Open77.hud.setCinematic(true, 0.12)\n-- Second argument optional: height of EACH black bar, fraction of the viewport.\n-- Finite 0.01..0.4, default 0.12. false releases only this resource's claim.\nOpen77.hud.setCinematic(false)\n```\n\nClaims are cooperative: the greatest requested bar height wins while multiple\nresources hold the mode. Stop/reload, coroutine failure and session teardown\nrelease ownership. Existing HUD visibility settings are preserved, including\nhide claims held by the same resource for other purposes. New surfaces are\nalso masked; rendering is gated without destroying pages or changing their\n`visible` property. The currently focused chat/menu is deliberately allowed\nwhen opened, so you can type `/cinematic` again or use pause/settings. The\ndeveloper console remains accessible. Close these menus for a clean capture.\n\nThis native API is distinct from `open77_uikit:showCinematicBars`, which only\ndraws its own letterbox and optionally hides native HUD components.\n\nInvalid arguments return `false, reason`: `permission_denied:ui.vanilla.hud`,\n`invalid_cinematic_arguments`, `invalid_cinematic_height`, or\n`cinematic_unavailable_on_this_host`. Omit optional arguments to use defaults;\ndo not pass strings, `nil`, or `0` as substitutes for a boolean/height.","natives":["Open77.hud.setCinematic"],"permissions":[],"events":[]}]},{"slug":"transactions","file":"transactions.md","title":"Items, payments and storage","summary":"Use `open77_transactions` to save the item and money changes that belong together. A recipe can consume ingredients and create a meal in one operation. A sale can transfer that meal to the customer and pay the business in the same operation.","sections":[{"anchor":"items-payments-and-storage","heading":"Items, payments and storage","level":1,"text":"Use `open77_transactions` to save the item and money changes that belong together. A recipe can\nconsume ingredients and create a meal in one operation. A sale can transfer that meal to the\ncustomer and pay the business in the same operation.\n\nAn **operation** is one group of transfers. They are saved together in a database transaction:\nall of its lines commit, or none do. Its operation id identifies the result, so retrying the\nsame id returns a saved receipt without repeating the transfer. Creating a new id creates a\nnew operation; your job must keep the original id when retrying an uncertain payment.\n\nThis page is for authors integrating inventory, recipes, sales or storage. Server owners\nenabling an existing job should start with [Jobs and world interaction](world-interaction.md).\nFor a worked Lua example, see [Build your first Lua job](build-a-job.md).","natives":[],"permissions":[],"events":[]},{"anchor":"choose-your-inventory-integration","heading":"Choose your inventory integration","level":2,"text":"| Your server uses… | Integration to use |\n|---|---|\n| The RP pack's inventory and economy | Define items through `rp_inventory`; use its asynchronous transfer operations and `rp_economy` sales/payments. Follow the salvage or street-food example. |\n| Your own inventory and economy | Use the coordinator directly and decide which resource manages each account, item definition and capacity. |\n| No inventory yet; you want to learn | Follow the repair tutorial, which creates test pockets and fuses directly. |\n\nThe RP inventory's `transactions` backend uses this coordinator underneath. Its legacy\nsynchronous exports can acknowledge a queued change before it is saved. New job code should\nwait for asynchronous transfer results before treating an item exchange as complete.\n\nThe coordinator saves the transfers you submit. It does not also save a machine's repaired\nstate, deliver a notification or apply a health effect. Your resource must manage those results\nand their recovery separately. Avoid treating a durable payment as proof that every step of a\njob has finished.\n\nSource: [`open77_transactions`](https://github.com/Open2077/open77-resources/tree/next/system/open77_transactions), [`open77_containers`](https://github.com/Open2077/open77-resources/tree/next/system/open77_containers).","natives":[],"permissions":[],"events":[]},{"anchor":"reserve-commit-and-cancel","heading":"Reserve, commit and cancel","level":2,"text":"For timed work, reserve the planned transfer before the animation starts. Commit it after the\nwork succeeds, or cancel the reservation if it ends before committing. The three numbered\nblocks below belong in separate action hooks; the cancel block is an alternative to committing,\nnot a step to run after a successful commit. Complete hook definitions are in the\n[repair tutorial](build-a-job.md#step-3-the-four-hooks).\n\n```lua\n-- my_repairs/server/main.lua: each step runs inside one of your action's hooks\nlocal function tx(name, ...)\n local promise, why = Open77.exports.call(\"open77_transactions\", name, ...)\n if not promise then return nil, why end\n return promise:await()\nend\n\n-- 1. Before the progress bar: hold one fuse and the pay.\nlocal pockets = exports.open77_transactions:account(ctx.playerId, \"char\") -- \"char:\"\nlocal cash = exports.open77_transactions:account(ctx.playerId, \"cash\") -- \"cash:\"\nlocal opId = \"repairs:\" .. ctx.opId\nlocal held, why = tx(\"reserve\", { opId = opId, kind = \"repair\", actor = pockets, lines = {\n { item = \"fuse\", count = 1, from = pockets, to = \"sys:repairs\" }, -- the fuse is used up\n { money = 40, from = \"sys:repairs\", to = cash }, -- 40 eddies are paid\n} })\nif not held then return nil, why end -- \"insufficient_items\": no fuse, so no bar at all\n\n-- 2. The bar finished: make it final.\nlocal receipt, err = tx(\"commit\", opId)\n\n-- 3. Or the bar was interrupted: release the hold. Nothing moved.\nexports.open77_transactions:cancel(opId, \"moved_away\")\n```\n\nMost exports wait for the database, so call them through `tx` from a thread, an event handler, a\ncommand or an action hook; the plain `exports.open77_transactions:transfer(...)` form answers\n`export_yielded`. These never wait: `account`, `subjectOf`, `newOpId`, `define`, `info`, `health`,\n`allowed`, `authorize`, `cancel`, `submit`, and `read` of an account already in memory. A call\nanswers its result, or `nil, reason, info`; `info.retryable` says whether trying again can help.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"five-ideas","heading":"Five ideas","level":2,"text":"1. **One operation is one database transaction**: all its lines commit together, or none do.\n2. **Reuse the id for a retry**: committing an id again returns its stored receipt with\n `replay = true` and moves nothing. A new id is another operation, even for the same player and job.\n3. **Reserve holds value before the work**, so a player with no fuse is refused before the bar.\n4. **Commit makes it final, cancel releases an unused hold**: nothing moves before the commit.\n If the database stops answering during commit, resolve that result before deciding it failed.\n5. **`in_doubt` means \"not sure yet\"**: the database stopped answering mid-commit. Retry with the\n **same** id, or ask `status(opId)`; a new id could pay twice.","natives":[],"permissions":[],"events":[]},{"anchor":"install","heading":"Install","level":2,"text":"Add `dependency \"open77_transactions >=0.1.0\"` to your manifest, only in a server-only resource. A\nresource that also ships a client script must leave it out (the client refuses it with\n`missing_dependency:open77_transactions`) and wait for the `open77_transactions:ready` event.\n\nThe operator turns it on once: list it ([selecting which resources load](server-resources.md)), then\nstart it before the resources that use it.\n\n```text\nresources.load: [ ..., \"open77_transactions\", \"my_repairs\" ]\nensure open77_transactions\nensure my_repairs\n```\n\nIt needs a MariaDB or MySQL database ([SQL database setup](database.md)). At its first start it\ncreates its `op77_tx_*` tables (InnoDB) and logs `ready`. Just before that, its start-up self-test\nlogs **one expected error**: a transaction built to fail, proving the database really rolls back.\n\n```text\n[database] resource 'open77_transactions' transaction statement 2/2 failed: database_conflict (SQL 1062): …\n[resource:open77_transactions] ready: store=sql schema=v1 tag=srv\n```\n\nIf the database is missing, unreachable at start, not InnoDB, or newer than this build, it refuses\nto start: every call answers `store_failed` or `store_unavailable`; it never falls back to another\nstore.\n\n**Coming from the RP pack's 1.x [`rp_economy`](https://github.com/Open2077/open77-resources/tree/main/rp/rp_economy)?** The newer one refuses every wallet change with\n`migration_pending` (log: `… 1.x wallet row(s) are not imported yet: wallets are refused until\neconomy_migrate runs`) until the operator runs `economy_migrate` on the console once. Nobody gets a\nfresh starting balance and then the imported one, and a second run imports nothing twice.","natives":[],"permissions":[],"events":[]},{"anchor":"accounts","heading":"Accounts","level":2,"text":"Everything that holds value is an account named `:[:]`:\n\n| Namespace | Holds | Example |\n|---|---|---|\n| `char` | the items a character carries (their pockets) | `char:` |\n| `cash`, `bank` | a character's money | `cash:` |\n| `soc` | a society's money | `soc:mechanics` |\n| `stash` | items in a stash: a locker, a stall's pantry | `stash:sf.food_court:pantry` |\n| `veh` | items in one part of a vehicle, keyed by **plate** | `veh:NC-1234:cargo` |\n| `world` | a crate or pile on the ground; the coordinator picks the id | `world:srv.lq2x8k3z.1` |\n| `sys` | an unlimited source or sink, recorded in the journal only | `sys:repairs` |\n\n- Ask for a player's accounts with `account(playerId, \"char\" | \"cash\" | \"bank\")`, never build them. A\n session player id or runtime entity id is refused as a key (`cash:12` answers `invalid_key`).\n- A container (`char`, `stash`, `veh`) must exist before items move in or out (`unknown_account`\n otherwise): `open` creates it. A wallet is created by its first payment.\n- Moving value into `sys:` burns it; moving it out creates it, which needs an operator's mint rule.\n Keep `sys:` names free of a second colon (`sys:streetfood.kitchen`) so a rule can name them.","natives":[],"permissions":[],"events":[]},{"anchor":"declare-your-items","heading":"Declare your items","level":2,"text":"Weights are in grams. Definitions live in memory, so declare them at start and again on\n`open77_transactions:ready` (its argument is `{ store, schema }`), which fires whenever the\ncoordinator is ready. On a server running the RP pack, define items through [`rp_inventory`](https://github.com/Open2077/open77-resources/tree/main/rp/rp_inventory) instead.\n\n```lua\nlocal function defineItems() exports.open77_transactions:define({ fuse = { weightG = 100 } }) end\nCreateThread(defineItems)\nAddEventHandler(\"open77_transactions:ready\", defineItems)\n```\n\n`define` answers `registered, rejected, collisions`; an item another resource defined first stays\ntheirs.","natives":["CreateThread","server:CreateThread","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"pay-a-player-for-a-job","heading":"Pay a player for a job","level":2,"text":"- **The calls**: `reserve(spec)` answers `{ opId, state = \"reserved\", expiresAt }`,\n `commit(opId[, { lines }])` the receipt, `cancel(opId, reason)` `true`. The spec needs `opId` and\n `lines` (1 to 48); `kind` (a short label), `actor`, `reason` (also per line) and `ttlMs` are optional.\n- **The id**: derive it from the job, never from a counter of your own: `\"repairs:\" .. ctx.opId` is\n one id per repair, so every retry of that repair reaches the same operation.\n- **The hold expires** after `ttlMs` (default 30 s, at most 10 minutes) and is cancelled when your\n resource stops. Give it the length of the bar plus a margin.\n- **Commit less than you reserved** by passing final lines to `commit`: less than the reservation,\n never more (`exceeds_reservation`). Send the same lines on every retry, or the replay answers\n `op_id_conflict`.\n- **The receipt** carries `opId`, `kind`, `actor`, `lines`, `reason`, `at` and `replay`. With\n `replay = true` the commit had already happened: show the result again, pay nothing more.\n- **In doubt**, `cancel` is refused: the coordinator keeps the hold and settles the operation once\n the database answers. Ask `tx(\"status\", opId)` until its `state` leaves `in_doubt`.\n\nWhen the yield is unknown before the bar, reserve **room** out of a `sys:` source, then commit the\nreal items; a reservation made only of capacity lines needs final lines at commit. The scrapyard job\n[rp_salvage](https://github.com/Open2077/open77-resources/tree/main/rp/rp_salvage) works this way, and\nits `server/ledger.lua` (the only file that calls the coordinator) is a good shape to copy.\n\n```lua\ntx(\"reserve\", { opId = opId, ttlMs = 15000, lines = { { capacityG = 2000, from = \"sys:salvage\", to = pockets } } })\ntx(\"commit\", opId, { lines = { { item = \"scrap\", count = 3, from = \"sys:salvage\", to = pockets } } })\n```","natives":[],"permissions":[],"events":[]},{"anchor":"give-an-item","heading":"Give an item","level":2,"text":"```lua\nlocal pockets = exports.open77_transactions:account(playerId, \"char\")\ntx(\"open\", pockets, { capacityG = 30000 }) -- creates the pockets, or sets their capacity again\nlocal opId = exports.open77_transactions:newOpId() -- make the id once, reuse it if you retry\nlocal receipt, why = tx(\"transfer\", { opId = opId, kind = \"grant\", lines = {\n { item = \"fuse\", count = 3, from = \"sys:repairs\", to = pockets },\n} })\n```\n\n`transfer` is a reserve and a commit in one call, for anything without a bar. Creating fuses out of\n`sys:repairs` needs a mint rule (`mint_denied` otherwise). With `rp_inventory` the pockets already\nexist: do not `open` them, since `open` sets the capacity again.","natives":[],"permissions":[],"events":[]},{"anchor":"sell-to-another-player","heading":"Sell to another player","level":2,"text":"Collect the buyer's consent your way, then move goods and money in one operation. Make the id when\nthe offer is made, keep it with the offer, and reuse it on every retry. No value is created, so no\nmint rule is needed: the resource only needs to write `char`, `cash` and `soc`.\n\n```lua\nlocal T = exports.open77_transactions\noffer.opId = \"sale:\" .. T:newOpId() -- once, when the seller makes the offer\nlocal sellerCash, buyerCash = T:account(offer.seller, \"cash\"), T:account(offer.buyer, \"cash\")\nlocal receipt, why, info = tx(\"transfer\", { opId = offer.opId, kind = \"sale\", reason = \"two fuses\", lines = {\n { item = \"fuse\", count = 2, from = T:account(offer.seller, \"char\"), to = T:account(offer.buyer, \"char\") },\n { money = 90, from = buyerCash, to = sellerCash },\n { money = 10, from = buyerCash, to = \"soc:mechanics\", reason = \"guild share\" },\n} })\nif receipt then -- done; receipt.replay == true means it had already gone through\nelseif info and info.retryable then -- conflict, busy, in_doubt, store_unavailable: retry, SAME id\nelse -- insufficient_funds, insufficient_items...: tell both players, nothing moved\nend\n```","natives":[],"permissions":[],"events":[]},{"anchor":"stock-a-stall-stashes","heading":"Stock a stall: stashes","level":2,"text":"A stash has a fixed name, so a restart finds the same goods with no lookup. The street-food stall\nkeeps ingredients in a pantry and cooked dishes on a pass:\n\n```lua\nlocal PANTRY, PASS = \"stash:sf.food_court:pantry\", \"stash:sf.food_court:pass\"\ntx(\"open\", PANTRY, { capacityG = 40000 }) -- at start: creates it, or finds it again\ntx(\"open\", PASS, { capacityG = 2000 })\n\n-- grilling a skewer, reserved before the bar: an empty pantry or a full pass is refused right here\nlocal held, why = tx(\"reserve\", { opId = cookOpId, kind = \"streetfood.cook\", ttlMs = 40000, lines = {\n { item = \"sf_synth_meat\", count = 2, from = PANTRY, to = \"sys:streetfood.kitchen\" },\n { item = \"sf_sauce\", count = 1, from = PANTRY, to = \"sys:streetfood.kitchen\" },\n { item = \"sf_skewer\", count = 1, from = \"sys:streetfood.kitchen\", to = PASS },\n} })\n```\n\nThe ingredients burn into the kitchen; the skewer is created out of it, which needs a mint rule. The\ncomplete stall, with a consented sale paid to a society plus a tip, is\n[rp_streetfood](https://github.com/Open2077/open77-resources/tree/main/rp/rp_streetfood).","natives":[],"permissions":[],"events":[]},{"anchor":"read-balances-and-history","heading":"Read balances and history","level":2,"text":"```lua\nlocal acct, why = tx(\"read\", cash) -- loads on demand, never creates: \"unknown_account\" if never paid\nlocal rows = tx(\"history\", cash, { limit = 20 }) -- newest first; page back with { before = }\n```\n\nA snapshot carries `balance` (wallets) or `items`, `instances`, `weightG`, `capacityG` (containers),\nplus `held` (reserved) and `pending` (committing, not confirmed yet). History rows carry `opId`,\n`asset`, `delta`, `reason`, `at`, `kind`, `actor` and `resource`. `list(namespace, after, limit)`\npages through the containers of `char`, `stash`, `veh` or `world`. The event\n`open77_transactions:committed(opId, kind, accounts)` fires after every commit.","natives":[],"permissions":[],"events":[]},{"anchor":"unique-items-and-metadata","heading":"Unique items and metadata","level":2,"text":"A tool with its own wear is an **instance**: one object with an id you pick.\n\n```lua\nexports.open77_transactions:define({ crowbar = { weightG = 1500, unique = true } })\n-- three lines, each usable in an operation:\n{ instance = \"cb-7f3a\", item = \"crowbar\", from = \"sys:dealer\", to = pockets, meta = { durability = 20 } } -- create\n{ instance = \"cb-7f3a\", at = pockets, metaPatch = { durability = 19 } } -- wear\n{ instance = \"cb-7f3a\", from = pockets, to = \"stash:locker_12\" } -- move\n```\n\n`meta` replaces, `metaPatch` merges one level deep. One operation at a time can hold an instance; a\nsecond answers `instance_unavailable`.","natives":[],"permissions":[],"events":[]},{"anchor":"durable-world-containers","heading":"Durable world containers","level":2,"text":"A [world prop](props.md) disappears with its resource and the server process. A crate whose\n**contents** must survive a restart (spare fuses in a workshop, a dropped pile, a wreck searched\nthree times) is an `open77_containers` container: the coordinator holds the contents and placement,\nand `open77_containers` rebuilds the prop, prompt and summary at every start under the same id. List\nit next to `open77_transactions` (with [`open77_interactions`](https://github.com/Open2077/open77-resources/tree/main/system/open77_interactions) and [`open77_props`](https://github.com/Open2077/open77-resources/tree/main/system/open77_props)) and `ensure` it.\n\n```lua\n-- boxes(name, ...) is the tx helper above, calling \"open77_containers\"\nlocal crate\nlocal function placeCrate()\n crate = exports.open77_containers:find(\"workshop-fuses\") or boxes(\"create\", {\n key = \"workshop-fuses\", -- your own name for it: after a restart, the same crate\n kind = \"crate\", model = \"crate.small\", position = { x = -1411.0, y = 957.6, z = 23.4 }, bucket = 0,\n capacityG = 20000, label = \"Spare fuses\", prompt = { label = \"Take a fuse\", key = \"E\" },\n })\nend\nCreateThread(placeCrate)\nAddEventHandler(\"open77_containers:ready\", function() CreateThread(placeCrate) end)\n\nAddEventHandler(\"open77_containers:used\", function(id, playerId) -- a player pressed the prompt\n if not crate or id ~= crate.id then return end\n CreateThread(function()\n local pockets = exports.open77_transactions:account(playerId, \"char\")\n boxes(\"transfer\", { opId = \"take:\" .. exports.open77_transactions:newOpId(), player = playerId,\n lines = { { item = \"fuse\", count = 1, from = crate.id, to = pockets } } })\n end)\nend)\n```\n\n- `crate.id` (`world:srv.…`) is the durable identity, `crate.propId` only today's prop. A keyed\n `create` finds the same container again; a different spec under that key answers `already_exists`.\n- A `crate` stays when empty; a `pile` disappears when empty.\n- Moves go through `open77_containers` (`reserve`, `commit`, `cancel`, `transfer`, `status`: the\n coordinator's shapes plus `player`), which checks rights, reach (`reachM`, 4 m) and bucket first.\n- The owner grants `take`, `put`, `target`, `consume`, `move`, `meta`, `read` or `close` to another\n resource with `grant(id, grantee, rights, { ttlMs })`, and takes them back with `revoke`.\n- `find`, `summary`, `list` and `access` answer at once; the rest wait. Before its start-up rebuild,\n `create` answers `not_ready`; with the coordinator down, value calls answer `coordinator_unavailable`.\n\nA **node** carries charges and, by default, holds nothing: a wreck searched three times, then once\nmore every five minutes. `model = false` puts its prompt on a point instead of a prop:\n\n```lua\nlocal node = exports.open77_containers:find(\"node.wreck_1\") or boxes(\"create\", { key = \"node.wreck_1\",\n kind = \"node\", model = false, position = wreckPos, node = { charges = 3, regrowMs = 300000 } })\nlocal state, why = boxes(\"consume\", node.id, { ref = opId, player = playerId }) -- \"depleted\" when empty\n```\n\nThe same `ref` never spends twice (`state.replay`). `refill(id, { charges })` resets a node in place\n(full by default) and keeps its id, grants and refs; `open77_containers:depleted(id)` and\n`open77_containers:regrown(id, charges)` report the changes. Never `close` a node you want back: a\nclosed key is never reused.\n\nWithout `open77_containers`, a `world:` account is yours to draw. `openWorld` mints it, `close`\ndeletes it once empty (`container_not_empty` otherwise), `setMeta(id, meta)` changes its metadata,\nand `open77_transactions:world(id, change, meta)` reports `opened`, `meta` and `closed`. At start,\npage through `listWorld(after, 100)` and re-create each prop from its `meta`:\n\n```lua\nlocal pile = tx(\"openWorld\", { capacityG = 50000, meta = { x = x, y = y, z = z, bucket = 0, model = \"crate.small\" } })\ntx(\"transfer\", { opId = dropId, lines = { { item = \"fuse\", count = 4, from = pockets, to = pile.id } } })\n```","natives":["CreateThread","server:CreateThread","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"who-may-write-and-who-may-create-value","heading":"Who may write, and who may create value","level":2,"text":"Operators set both in the coordinator's tunables, from the Warden panel or the console\n`tunable.set open77_transactions ` ([Operator tunables](tunables.md)):\n\n| Tunable | Default | Meaning |\n|---|---|---|\n| `writers` | `*` | which resources may change which accounts; `*` lets every resource write (live) |\n| `writers2` … `writers4` | empty | more entries for the `writers` list, when it does not fit in one value; ignored while `writers` is `*` (live) |\n| `mint1` … `mint6` | empty | who may create what out of `sys:` accounts, and how much an hour (live) |\n| `store` | `sql` | the production store; a disposable store exists for unlisted development servers (restart) |\n| `serverTag` | `srv` | 1 to 11 of `a-z 0-9 _ -`, a letter first; unique per server sharing a database (restart) |\n| `preloadWorld` | 2000 | `world:` containers loaded into memory at start (restart) |\n| `retentionDays` | 90 | journal and operation rows older than this are deleted; a replay needs its row |\n| `workPerTick` | 12 | coordinator steps per server tick |\n\n- **Writers**: `*`, or a comma-separated list. `name` may write every namespace, `name:ns+ns` only\n those, `name:ns/part` one part (`rp_cargo:veh/cargo` writes `veh::cargo`, not the trunk).\n A resource left out answers `namespace_denied`. `writers2`, `writers3` and `writers4` continue\n the list with the same syntax: the coordinator joins the four values into one list.\n- **Mint rules**: ` `, several\n separated by `;`; `*` is any item, never money. **Nothing mints by default**: `mint_denied` until a\n rule allows it, then `mint_cap_exceeded` once the hour's cap is used. Burning into `sys:` needs no\n rule. Caps count over a sliding hour and survive a restart. A malformed entry is ignored and\n logged (`writers: ignored entry '…'`, `mint rules: ignored entry '…'`).\n- **256 characters per value.** A text tunable holds at most 256 characters. A longer value is\n refused as a whole (`error: mint4: exceeds 256 characters` on the console) and the previous value\n stays. A server running several jobs outgrows one value: continue the writers in `writers2` to\n `writers4`, and spread the mint rules over `mint1` to `mint6`, each under 256 characters. A rule\n on `*` (any item) with one cap for the whole account is shorter than one rule per item.\n\nThe repair job, then a server running the RP pack's scrapyard (size the caps to your population):\n\n```text\ntunable.set open77_transactions writers my_repairs:char+cash\ntunable.set open77_transactions mint1 my_repairs sys:repairs money 20000\ntunable.set open77_transactions mint2 my_repairs sys:repairs fuse 200\n\ntunable.set open77_transactions writers rp_salvage:world+char, rp_cargo:veh/cargo, rp_economy:cash+bank+soc, rp_inventory, open77_containers:world\ntunable.set open77_transactions mint1 rp_salvage sys:salvage * 2000; rp_inventory sys:* * 2000; rp_economy sys:* money 2000000\n```\n\nAdding the CHOOH2 chain and the techie to that server: the writers continue in `writers2`, and each\njob's mint rules get a slot of their own next to the shared `mint1`:\n\n```text\ntunable.set open77_transactions writers2 rp_chooh2:world+stash+char, rp_techie:stash/tray+char+cash\ntunable.set open77_transactions mint2 rp_chooh2 sys:chooh2.fields * 2000; rp_chooh2 sys:chooh2.refinery chooh2_canister 400\ntunable.set open77_transactions mint3 rp_techie sys:techie.workshop * 960; rp_techie sys:techie.dealer * 540\n```\n\nThe [roleplay jobs](rp-jobs.md#run-every-job-on-one-server) page lists the values for a server\nrunning all of them.\n\n`open77_containers` creates the `world:` account of every container itself, and the coordinator sees\nit as the writer of every move it forwards. With a `writers` list, give it `world` plus each\nnamespace your containers trade with (`open77_containers:world+char` for a crate players take from);\nwithout `world`, every `create` answers `namespace_denied`. A container move that creates value\nneeds a mint rule for `open77_containers` and one for your resource.\n\nTo let another resource touch your accounts for one operation (the stall letting `rp_economy` sell\nfrom its pass), authorize it first, for up to 16 accounts you may write yourself:\n`exports.open77_transactions:authorize(opId, \"rp_economy\", { PASS, buyerPockets }, 30000)`.\n`allowed(resourceName, \"veh:NC-1:cargo\")` answers whether a resource may write an account; for a\n`sys:` account (with an optional item or `\"money\"`), whether it may create value from it.","natives":[],"permissions":[],"events":[]},{"anchor":"refusals","heading":"Refusals","level":2,"text":"| Reason | Retry? | Meaning |\n|---|---|---|\n| `invalid_spec`, `invalid_op_id`, `invalid_account`, `invalid_key`, `unknown_item`, `invalid_count`, `invalid_amount` | no | a bug or a forged input |\n| `namespace_denied`, `mint_denied` | no | the operator's policy says no |\n| `insufficient_items`, `insufficient_funds`, `capacity_exceeded`, `instance_unavailable`, `balance_limit` | no | the business answer; nothing is held |\n| `unknown_account`, `container_not_empty`, `not_loaded` | no | the account does not exist, is not empty, or is not in memory |\n| `already_committed` | no | `reserve` of an id that already committed; `info.receipt` is its receipt |\n| `exceeds_reservation`, `operation_too_large` | no | the commit left its reservation, or needs more than 64 SQL statements |\n| `expired`, `cancelled`, `unknown_operation`, `op_id_conflict`, `aborted`, `not_owner`, `stale_version` | no | lifecycle; after `aborted`, use a new id |\n| `mint_cap_exceeded` | later | the hourly cap is used |\n| `conflict` | yes | the database refused a stale view; plan again |\n| `busy`, `in_doubt` | yes, same opId | still committing, or outcome unknown: ask `status` |\n| `store_unavailable` | yes | starting, database down, or certainly not committed |\n| `store_failed` | no | refused at start; an operator must act |\n\nA refused, expired or cancelled id left no trace: the same id is a fresh attempt. `status(opId)`\nanswers `{ state, reason, receipt }`; `state` is `reserved`, `committing`, `committed`, `in_doubt`,\n`failed`, `cancelled`, `expired`, `aborted` or `unknown`.\n\n**When the database goes away**, the coordinator logs `store unreachable; mutating calls answer\nstore_unavailable until a probe succeeds`. Calls that had not started committing answer\n`store_unavailable`: nothing was applied, so retrying is safe, and a reservation can still be\ncommitted later. It probes every 2 s and logs `store reachable again`. An operation that was already\ncommitting stays `in_doubt` with its holds until the coordinator settles it: poll `status(opId)`\nwith that same id, never a new one.","natives":[],"permissions":[],"events":[]},{"anchor":"console","heading":"Console","level":2,"text":"```text\ntransactions health # phase, store, counters\ntransactions mints # every mint rule with this hour's use\ntransactions read # an account already in memory\ntransactions status \ntransactions resolve # settles an unknown opId for good: committed or aborted, never both\n```\n\nUse `resolve` only on an operation that may still be in flight from before a restart: an id it\nmarks `aborted` can never commit.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":2,"text":"48 lines per operation and at most 64 SQL statements per commit; operation ids of 8 to 96 characters\n(letters, digits, `_ . : @ -`); account ids up to 96 bytes; metadata up to 2 KiB; reasons up to 64\nbytes; `history` up to 200 rows a page, `list` up to 500. A container is read with at most 512\nstacks and 512 instances.","natives":[],"permissions":[],"events":[]}]},{"slug":"travel","file":"travel.md","title":"Travel","summary":"Move players with the server-owned travel APIs. Use teleportation and placement operations that coordinate client streaming instead of writing transforms directly.","sections":[{"anchor":"travel","heading":"Travel","level":1,"text":"Move players with the server-owned travel APIs. Use teleportation and placement operations that coordinate client streaming instead of writing transforms directly.\n\nEverything on `Open77.travel` is client-side and requires the `player.travel` permission.\nThat permission belongs only in operator-audited resources: the native lab commands these\nreplace are refused while a multiplayer session is active, so an ACL-checked server\ncommand asking a trusted client resource to call them is the intended path.\n\nIf the **server** is the one deciding where somebody goes, do not reach for this page —\nuse [`Open77.players.teleport`](server-api.md#moving-a-player), which drives exactly the\nmachinery described below and answers the server with the result.","natives":[],"permissions":[],"events":[]},{"anchor":"the-two-teleports-and-why-there-are-two","heading":"The two teleports, and why there are two","level":2,"text":"```lua\nOpen77.travel.teleport(x, y, z, heading) -- fire and forget\nOpen77.travel.teleportAndSettle({ x = x, y = y, z = z }, heading) -- Promise\n```\n\n`Open77.travel.teleport` queues one engine request and returns. It checks a finite\ntransform and a live player, and that is all it knows. In particular **it knows nothing\nabout streaming**, and that gap is not academic:\n\n- The engine executes the teleport on a later tick, so a position sampled in the frame\n that asked for it is the old one.\n- If the floor at the destination has not streamed in yet, the body falls through it.\n- Cyberpunk's own `Failsafe/PlayerTeleportationIfFallsUnderWorld` then returns the player\n to the last place the engine considers safe — the save's spawn of record, which can be\n kilometres away — and nothing tells the caller.\n\nUse the travel APIs' streaming and settle checks for distant destinations. Repeating a raw transform write does not make an unloaded destination safe.\n\n`Open77.travel.teleportAndSettle` is the same teleport with the answer attached.\n\n```lua\nCreateThread(function()\n local arrival, reason = Open77.travel.teleportAndSettle(\n { x = 1669.75, y = -739.12, z = 49.86 }, 180):await()\n if not arrival then\n print(\"did not arrive: \" .. tostring(reason))\n return\n end\n print((\"%s after %d attempts, %d pins, %d ms\")\n :format(arrival.state, arrival.attempts, arrival.pins, arrival.elapsedMs))\nend)\n```\n\nIt resolves with `{ state, x, y, z, attempts, pins, elapsedMs }`:\n\n| `state` | What it means |\n|---|---|\n| `settled` | The body was on the point, grounded, and in no fall state for **three consecutive frames**. This is the answer. |\n| `near` | The 1.5 s window closed with the body on the point but never reported grounded — a mark on a prop, or a very slow stream-in. An honest success with a weaker claim. |\n\nand rejects with a stable reason:\n\n| Reason | Meaning |\n|---|---|\n| `settle_timeout` | The body never stood at the destination inside the budget (default 12 s, `options.timeoutMs` is 1000..30000). |\n| `settle_superseded` | Another `teleportAndSettle` started before this one finished. There is one body, so there is one session. |\n| `settle_cancelled` | `Open77.travel.cancelSettle` stopped the watch. The body stays wherever it currently is. |\n| `player_unavailable`, `teleport_unavailable`, `invalid_transform` | The teleport itself was refused; the watch never started. |","natives":["Open77.travel.teleport","Open77.travel.teleportAndSettle","CreateThread","server:CreateThread"],"permissions":[],"events":[]},{"anchor":"what-settled-is-actually-testing","heading":"What \"settled\" is actually testing","level":2,"text":"**There is no streaming query anywhere in the engine surface Open77 can reach.**\n`StreamingSector`, `worldStreaming`, `IsStreamed`, `RequestStreaming`, `StreamingWorld` and\n`worldRuntimeScene` were all swept for; none of them is callable. So \"is the destination\nstreamed?\" is not a question that can be asked, and the API deliberately does not pretend\notherwise.\n\nWhat can be observed is the body, and that turns out to be the question a caller really\nhas: *can I let go of this yet?* The watch asserts three things together, every frame:\n\n1. **on the point** — within 4 m horizontally and 6 m vertically of the mark;\n2. **grounded** — the player state machine says so;\n3. **not falling** — the fall state is `None`. Not \"not a lethal fall\": the second\n production trace read `fall=Safe` while the body sank through an unloaded floor.\n\nAll three, for three consecutive frames. A single near sample proves nothing; the trap\nshowed the body at the wrong point twice before reading `grounded=yes`.\n\nWhen the body is on the point but sinking — not grounded, and more than 0.75 m below the\nmark — the teleport is **re-issued every 250 ms** for up to 7 s. That resets the fall\nbefore the failsafe can fire and keeps the engine's streaming prefetch aimed at the mark,\nuntil the floor arrives. `pins` in the result counts how many times that happened; a\nnon-zero `pins` means the destination was not resident when you asked for it.\n\nIf the body ends up somewhere else entirely when the window closes, the whole placement is\nre-issued, up to the session's timeout.\n\nThe same watch drives the respawn placement in `LifeReplication` — one implementation, one\nset of constants, both paid for by the same two incidents.","natives":[],"permissions":[],"events":[]},{"anchor":"raw-settle-tickets","heading":"Raw settle tickets","level":2,"text":"`teleportAndSettle` is a thin wrapper over three natives, which are there for a caller who\nwants to drive the poll itself:\n\n```lua\nlocal ticket, reason = Open77.travel.beginSettle(x, y, z, heading, timeoutMs)\nlocal status = Open77.travel.settleStatus(ticket) -- state, reason, x, y, z, attempts, pins, elapsedMs\nOpen77.travel.cancelSettle(ticket)\n```\n\n`settleStatus` returns `nil, unknown_settle_ticket` for a ticket that is neither the live\nsession nor the last one to finish.","natives":["Open77.travel.beginSettle","Open77.travel.settleStatus","Open77.travel.cancelSettle"],"permissions":[],"events":[]},{"anchor":"noclip-and-the-map-pick","heading":"Noclip and the map pick","level":2,"text":"```lua\nOpen77.travel.setNoclip(enabled)\nOpen77.travel.isNoclip()\nOpen77.travel.setNoclipSpeed(metresPerSecond) -- 0.1 .. 500\nOpen77.travel.getNoclipSpeed() -- current base speed, including wheel changes\nOpen77.travel.setMapPick(enabled)\nOpen77.travel.isMapPick()\n```\n\nNoclip moves through the same teleport facility and never takes ownership of the player's\nmovement component. It is camera-relative, uses the player's configured forward/back/left/right\nkeys, and submits one bounded movement request per running game frame. Releasing a movement\nkey does not leave a target that continues to converge.\n\n- Space / Ctrl: ascend / descend; mouse wheel: adjust base speed (20% per notch).\n- Shift: 4x boost; Alt: 0.25x precision; both can be combined.\n- XInput: left stick moves, right stick looks, triggers ascend/descend, LB boosts and RB slows.\n- Diagonal movement is normalized. Menus, focused WebUIs, the developer console and loss\n of window focus suspend movement input; the held position is retained.\n- Native movement/combat restrictions belong to noclip and are released on exit. It does\n not heal the player, pause world time or clear restrictions belonging to other resources.\n- Activation requires a living, attached player outside vehicles and workspot animations.\n `setNoclip` returns `false, reason` if refused. Death, body replacement or entering a vehicle\n stops flight. Stopping the owning resource releases flight on the next running frame.\n- Only the resource which enabled noclip can disable it. All travel calls still require\n `player.travel`; a gamemode must also enforce server-side ACL before delegating admin use.\n\nThe admin package retains `/admin.self.noclip`, `/admin.self.fly` and `/admin.self.speed`.\nIts compact HUD shows current speed and controls without capturing input. Native wheel\nchanges are reflected in both admin interfaces. The diagnostic `noclip.state` is read-only\nand reports the controller, owning resource, paused input and safety guards.\n\n`setMapPick` only lets the world map publish a double-clicked point;\nit moves nobody, and it does not bypass the ACL-checked server command that consumes the\npoint.","natives":["Open77.travel.setNoclip","Open77.travel.isNoclip","Open77.travel.setNoclipSpeed","Open77.travel.getNoclipSpeed","Open77.travel.setMapPick","Open77.travel.isMapPick"],"permissions":[],"events":[]},{"anchor":"loading-screen-integration","heading":"Loading-screen integration","level":2,"text":"A long-distance teleport may open Cyberpunk's native loading screen. Use the\n[loading-screen lifecycle](loading-screens.md) to display your own cover or hide\na HUD. Loading completion is presentation information; keep the travel API's\nsettling checks for destination readiness.","natives":[],"permissions":[],"events":[]}]},{"slug":"ui-kit","file":"ui-kit.md","title":"The UI kit","summary":"`open77_uikit` provides shared dialogs and HUD widgets: progress bars, hints, confirmations, input forms, context and keyboard menus, radial wheels, world text and letterboxing.","sections":[{"anchor":"the-ui-kit","heading":"The UI kit","level":1,"text":"`open77_uikit` provides shared dialogs and HUD widgets: progress bars, hints, confirmations, input forms, context and keyboard menus, radial wheels, world text and letterboxing.\n\nWidgets share one WebUI surface and a common design system. Resources do not need a separate browser page for each dialog.\n\n`lib.notify` already exists and is not here: toasts are\n[`open77_notifications`](notifications.md). Use that.\n\nSource: [`open77_uikit`](https://github.com/Open2077/open77-resources/tree/main/system/open77_uikit).","natives":[],"permissions":[],"events":[]},{"anchor":"add-the-dependency","heading":"Add the dependency","level":2,"text":"```lua\nresource 'garage'\nversion '1.0.0'\ndependency 'open77_uikit >=1.0.0'\n\nclient_script 'client.lua'\nserver_script 'server.lua'\npermissions { 'network.events' }\n```\n\nYou need **no permission for the kit itself**. `open77_uikit` holds\n`input.actions`, `input.blockAll` and the surface; that is the point of a\npresentation boundary. A caller that wants to block input for its own reasons\nstill needs its own `input.actions` -- see [Blocking player input](input-blocking.md).","natives":[],"permissions":["network.events"],"events":[]},{"anchor":"calling-a-widget","heading":"Calling a widget","level":2,"text":"Every widget is a **client export called asynchronously**:\n\n```lua\nlocal promise, reason = Open77.exports.call('open77_uikit', 'alert', {\n title = 'Sell the Quadra?',\n message = 'It will be gone for good. 12 400 eddies, paid on hand-over.',\n confirm = 'Sell it',\n cancel = 'Keep it',\n tone = 'warning',\n})\nassert(promise, reason)\nlocal answer = promise:await()\n```\n\n**The synchronous spelling does not work for a dialog**, and this is not a\nstyle preference. `exports.open77_uikit:alert(...)` runs the callee inline on\nthe game thread, and a dialog has to wait for a human; a synchronous callee that\nyields fails with `export_yielded`. The calls that never wait -- `textUI`,\n`hideTextUI`, `textUIState`, `context`, `menu`, `radial`, `hideContext`,\n`hideMenu`, `hideRadial`, `progressActive`, `cancelProgress`, `drawText3D`,\n`updateText3D`, `clearText3D`, `listText3D`, `showCinematicBars`,\n`setCinematic`, `cinematicState`, `close` and `state` -- work either way. When\nin doubt use `Open77.exports.call`: it is correct for all of them.\n\n`await` needs a scheduler coroutine, so wrap it in `CreateThread`. See\n[Cross-resource server exports](resource-exports.md#calling-an-export).","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"two-result-shapes-and-they-are-never-confused","heading":"Two result shapes, and they are never confused","level":2,"text":"```lua\nlocal promise, reason = Open77.exports.call('open77_uikit', 'input', definition)\nif not promise then -- (1) the request was refused\n Open77.log.warn('uikit: ' .. reason)\n return\nend\nlocal answer = promise:await() -- (2) the widget ran\nif answer.ok then\n use(answer.value)\nelseif answer.outcome == 'cancelled' then\n -- ordinary. The player changed their mind.\nelseif answer.outcome == 'timeout' then\n -- they wandered off.\nend\n```\n\n1. **`nil, reason`** -- the widget never opened. A bad definition, another\n resource holding the page, the surface not ready. `reason` is a stable\n snake_case token from [the table below](#failure-reasons). Nothing was shown.\n2. **A table** -- the widget ran. Always `{ ok, outcome, value }`:\n\n | Field | Meaning |\n |---|---|\n | `ok` | `true` only when `outcome == \"ok\"`. |\n | `outcome` | `\"ok\"`, `\"cancelled\"` or `\"timeout\"`. Always present. |\n | `value` | the answer. Absent unless `ok`. |\n\n**A cancelled dialog is a normal outcome, not an error.** The player pressing\nEscape resolves the promise with `{ ok = false, outcome = \"cancelled\" }`; it\nnever rejects. You do not need a `pcall` around ordinary user behaviour, and you\ncan tell \"they said no\" from \"it never opened\" without parsing a message.","natives":["Open77.exports.call","server:Open77.exports.call","Open77.log.warn","server:Open77.log.warn"],"permissions":[],"events":[]},{"anchor":"focus-the-rule-that-outranks-the-feature-list","heading":"Focus: the rule that outranks the feature list","level":2,"text":"Taking keyboard and cursor focus **stops game input wholesale**. A player left\nwith a dead dialog cannot move, cannot shoot, and cannot type to say so. The kit\nis built around giving focus back, and the design has three parts:\n\n- **Focus is a ledger, not a flag.** It is held while at least one open widget\n asked for it, and `setFocus` is called from exactly one function that derives\n the real state from that set.\n- **Every exit funnels through one place.** Confirm, cancel, Escape, timeout,\n owner stop, owner reload, owner vanished, our own stop, death and disconnect\n are ten doors into the same room.\n- **A watchdog asserts the invariant every tick.** If the ledger is empty and\n focus is still held, it is released. A future edit that adds an eleventh door\n and forgets to close it costs one frame, not a stranded player.","natives":[],"permissions":[],"events":[]},{"anchor":"the-release-matrix","heading":"The release matrix","level":3,"text":"Every row is covered by `scripting/tests/uikit_focus_test.py`, which loads the\nreal `client/main.lua` into a real Lua 5.4 VM and asserts both halves: the\npromise settled, **and** `setFocus(false, false)` ran.\n\n| # | Exit path | Raised by | Outcome |\n|---|---|---|---|\n| 1 | Confirm / submit / a leaf option picked | the page | `ok` |\n| 2 | Cancel, close, click off a browse dialog | the page | `cancelled` |\n| 3 | **Escape** | `open77:pauseKey` | `cancelled` |\n| 4 | Timeout | the kit's own deadline | `timeout` |\n| 5 | The calling resource stops | `onClientResourceStop` | `cancelled` |\n| 6 | The calling resource reloads | generation change on its next call | `cancelled` |\n| 7 | The calling resource vanishes | the once-a-second owner sweep | `cancelled` |\n| 8 | The player dies | `open77:playerLifeStateChanged` | `cancelled` |\n| 9 | The session ends | `open77:session:ended` | `cancelled` |\n| 10 | `open77_uikit` itself stops | `onClientResourceStop` | `cancelled` |\n| 11 | The kit's VM dies without running a line | the host destroys the surface | -- |\n\nRow 3 deserves a note, because a page's own Escape handler is not what runs. The\nplugin **swallows Escape in the window procedure**, so a focused surface never\nsees the key; `open77:pauseKey` is raised instead and that is the path the kit\nlistens on. The page has its own `Escape` handler as well, which is what makes\nit behave correctly outside a live session; a double cancel is harmless because\nthe second one carries a dead token.\n\nRow 8 is opt-out. `useWhileDead = true` keeps a dialog open through death --\na revive prompt wants exactly that. Everything else closes, because a dialog\nthat survives death leaves a respawn screen the player cannot click through.","natives":[],"permissions":[],"events":["open77:pauseKey","open77:playerLifeStateChanged","open77:session:ended"]},{"anchor":"a-dialog-is-refused-never-queued","heading":"A dialog is refused, never queued","level":3,"text":"**Only one focus-taking dialog can be open across all resources.** Additional requests return `dialog_active` and are not queued. Only the owning resource can close it; other resources receive `not_owner`.\n\nThe other widgets are not exclusive. `textUI` gives each resource its own slot,\nfour at once. `progress` is one at a time and refuses with `progress_active`,\nbecause two bars stacked over each other say nothing useful.","natives":[],"permissions":[],"events":[]},{"anchor":"control-blocking-is-not-focus","heading":"Control blocking is not focus","level":2,"text":"A progress bar that stops the player moving is the canonical use of\n[`Open77.input`](input-blocking.md), and the kit blocks through that surface --\n**never** by holding focus:\n\n```lua\nOpen77.exports.call('open77_uikit', 'progress', {\n label = 'Hotwiring the door',\n duration = 4000,\n disable = { move = true, combat = true },\n})\n```\n\nHolding focus would achieve something that looks similar and is much worse: it\ntakes the keyboard from chat too. `blockAll` deliberately leaves chat and voice\nworking, because a player who cannot act must still be able to say so. A\nprogress bar in this kit **never focuses**, and the test suite asserts it.\n\n| `disable` key | Vocabulary action | Effect |\n|---|---|---|\n| `move` | `Movement` | walking, running, jumping, dodging, climbing |\n| `combat` | `Attack` | firing a ranged weapon |\n| `map` | `Map` | the fullscreen map shortcut |\n| `hub` | `Hub` | inventory, crafting, perks, stats, journal |\n| `fastTravel` | `FastTravel` | the player's own fast travel |\n| `disable = 'all'` | `blockAll` | the whole gameplay stream. Accepts `except = { 'Movement' }`. |\n\nClaims are **refcounted inside the kit** and released when the widget settles,\nwhen the owner goes away, and when the kit stops. Blocking is a loan and the\nplatform releases only the calling resource's claim, so the kit cannot hand a\nclaim back while another widget still needs it.\n\nAn action the engine cannot refuse is refused by name\n(`action_not_blockable`), logged, and the widget still runs -- read\n[Blocking player input](input-blocking.md) before you assume `combat` is\nairtight on 2.31, and note that `Attack` is `inferred` there, not proven.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"asking-whether-a-menu-is-open","heading":"Asking whether a menu is open","level":2,"text":"A gameplay loop usually wants to stop reading input while *anything* modal is on\nscreen -- the kit's own dialogs, the pause overlay, chat, a vanilla menu, photo\nmode. Before this existed the only signal was `open77:pauseKey`, which is an\n**edge**, and only the one the window procedure decided to swallow; eight\nframework files each kept their own boolean and every one of them was wrong the\nmoment a different layer took the screen.\n\n`Open77.session` answers it directly. The permission is `session.menus`.\n\n| Call | Answers |\n|---|---|\n| `Open77.session.isMenuOpen()` | Any modal layer, Open77's or the game's. `boolean`, or `nil, reason`. |\n| `Open77.session.isPauseMenuOpen()` | The **vanilla** pause menu specifically. |\n| `Open77.session.menuState()` | Every layer at once, plus which one has the screen. |\n\n```lua\npermissions { \"session.menus\" }\n\nCreateThread(function()\n while true do\n Wait(0)\n if not Open77.session.isMenuOpen() then\n pollGameplayInput()\n end\n end\nend)\n```","natives":["CreateThread","server:CreateThread","Wait","server:Wait","Open77.session.isMenuOpen"],"permissions":["session.menus"],"events":["open77:pauseKey"]},{"anchor":"what-ismenuopen-promises","heading":"What `isMenuOpen` promises","level":3,"text":"That some modal layer owns the screen and a gameplay loop should not be reading\ninput. Six layers count:\n\n| Layer | `source` | Comes from |\n|---|---|---|\n| An Open77 WebUI surface holds the keyboard | `overlay` | the kit, the pause menu, chat, any page that called `setFocus` |\n| The pre-game character creator, or the menus it is unwinding through | `creator` | the pre-game bridge |\n| Any vanilla menu scenario | `vanilla` | the scenario wrappers; `menuState().scenario` names it |\n| Photo mode | `photoMode` | the native photo-mode system |\n| The Open77 appearance mirror | `appearance` | the appearance bridge |\n| The Open77 developer console | `console` | the console |\n\nThat is the **same disjunction the window procedure already uses to route the\nescape key**, extended by photo mode and the console, and `source` reports them\nin the same priority order. So a script branching on `source` branches the way\nthe game already behaves, rather than on a second opinion about it.\n\nThe pause menu is a menu, but a menu is not the pause menu: a resource that\nstops its loop for the map while keeping its own pause handler needs those to be\ntwo questions, which is exactly what `open77:pauseKey` alone could never express.","natives":[],"permissions":[],"events":["open77:pauseKey"]},{"anchor":"what-it-cannot-see","heading":"What it cannot see","level":3,"text":"The vanilla half is fed by the gameplay idle scenario's leave/enter wrappers, so\n**a screen that does not pass through that scenario never reports itself**.\nRadial and hub wheels are in that category -- Open77 refuses them rather than\nobserving them -- and so is every pre-game screen except the creator, which has\nits own predicate. A resource that needs certainty about its *own* modal should\nstill track its own, and compare `menuState().overlayResource`, which names the\nresource that owns the focused surface rather than hard-coding one in the\nengine.","natives":[],"permissions":[],"events":[]},{"anchor":"the-event","heading":"The event","level":3,"text":"`open77:menuStateChanged` is raised on the edge, from the same snapshot\n`menuState()` reads, so the two can never describe one instant differently.\n\n```lua\nAddEventHandler(\"open77:menuStateChanged\", function(open, pauseMenu, source, scenario)\n if open == \"1\" then pauseLoop(source) else resumeLoop() end\nend)\n```\n\nArguments are strings, as every local event argument is on this transport:\n`open` and `pauseMenu` are `\"1\"` or `\"0\"`, `source` is one of the values above,\nand `scenario` is the vanilla scenario name or `\"\"`.\n\nBoth `open` and `source` are compared when deciding whether to raise it. A\nplayer who goes from the vanilla map straight into photo mode never passes\nthrough \"closed\", and a loop listening only for the boolean would believe\nnothing had changed.\n\n**No event is synthesised at startup.** There is no prior state to have changed\nfrom, and a \"menus are closed\" on every session start is indistinguishable from\na real one. Read `Open77.session.menuState()` once when your resource starts, and\nagain after a hot reload -- which a handler would have to do anyway.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:menuStateChanged"]},{"anchor":"one-page-many-widgets","heading":"One page, many widgets","level":2,"text":"Eight surfaces per resource is the cap, so the kit spends **one**. Every widget\nthat is drawn in a page is a component on that single page, and two channels\ncarry everything (the one exception is `drawText3D`, which is not a page at\nall -- see below):\n\n```text\nLua -> page \"uikit:apply\" { widget, action, token, spec }\npage -> Lua \"uikit:event\" { widget, token, type, ... }\n```\n\n`widget` selects the component (`progress`, `text`, `dialog`, `cinematic`);\n`action` and `type` are that component's verbs. `token` is a monotonic integer minted on\nevery open and echoed back on every event, and Lua drops a token that is no\nlonger live -- so a click that raced a close cannot resolve whatever replaced\nit. The page is hidden whenever no widget is showing, because CSS opacity does\nnot suspend CEF or stop native composition.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"the-widgets","heading":"The widgets","level":1,"text":"","natives":[],"permissions":[],"events":[]},{"anchor":"progress","heading":"`progress`","level":2,"text":"```lua\nlocal answer = Open77.exports.call('open77_uikit', 'progress', {\n label = 'Washing the car', -- optional\n duration = 5000, -- ms, 100..600000. REQUIRED\n position = 'bottom', -- top | center | bottom\n style = 'bar', -- bar | circle\n color = '#22D8E2',\n cancellable = true, -- default true\n cancelKey = 'X', -- a single alphanumeric, or a named key\n disable = { move = true, combat = true },\n useWhileDead = false,\n}):await()\n-- { ok = true, outcome = 'ok' } it ran to the end\n-- { ok = false, outcome = 'cancelled' } the player pressed X, or Escape\n```\n\n`value` is always nil; the outcome is the answer. Also:\n\n| Export | Returns |\n|---|---|\n| `progressActive()` | `boolean, owner` |\n| `cancelProgress()` | `true`, or `nil, \"progress_not_active\"` / `\"not_owner\"` |\n\nThe cancel key is **polled**, not focused, and it is edge-detected: holding it\ndown through the end of one bar does not cancel the next. It stands down while\nanother surface legitimately owns the keyboard (`Open77.input.isCaptured`), so\ntyping an `x` in chat never cancels a bar.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"textui-hidetextui","heading":"`textUI` / `hideTextUI`","level":2,"text":"A persistent hint over gameplay. No focus, no scrim, no pointer events.\n\n```lua\nOpen77.exports.call('open77_uikit', 'textUI', {\n text = 'Hold to hotwire',\n key = 'E', -- rendered as a keycap\n icon = 'V', -- a short mono glyph, <= 16 chars\n eyebrow = 'Door', -- the // label\n position = 'bottom',\n color = '#22D8E2',\n})\nOpen77.exports.call('open77_uikit', 'hideTextUI')\n```\n\n`textUI(string)` is accepted as shorthand for `{ text = string }`. Calling it\nagain **replaces** the caller's hint rather than stacking a second one; each\nresource owns exactly one slot and four resources can show one at once\n(`text_slot_limit` beyond that). `hideTextUI` on a resource that has no hint is\n`true`, not an error, so a resource that tidies up on every path can do so\nunconditionally. `textUIState()` returns `{ open, text, position, total }`.\n\nSlots are released when the owner stops, reloads, disconnects or calls `close`.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"drawtext3d-updatetext3d-cleartext3d-listtext3d","heading":"`drawText3D` / `updateText3D` / `clearText3D` / `listText3D`","level":2,"text":"A string floating over a world point. This is the FiveM `DrawText3D` pattern --\nproject a coordinate every frame, draw text at the result -- with the loop\nremoved: the projection is the host's, so the call that used to run every tick\nruns **once** and returns a handle.\n\n```lua\nlocal handle, reason = exports.open77_uikit:drawText3D({\n position = { x = -1442.2, y = 127.4, z = 18.8 }, -- or entity = id, offset = {...}\n text = 'Delivery point', -- <= 96 bytes, no control chars\n sublabel = 'Press E to drop the package', -- optional second line\n color = '#F2F6F8', -- #RRGGBB or #RRGGBBAA\n background = '#0A1220B8', -- or 'none' for bare text\n accent = 'none', -- the card border; none by default\n scale = 1.0, -- 0.25..4\n maxDistance = 50, -- metres, 1..1000\n showDistance = false, -- appends \"12 m\" under the text\n ttl = 20000, -- ms; omit to keep it until cleared\n})\nexports.open77_uikit:updateText3D(handle, { text = 'Delivered', color = '#22D8E2' })\nexports.open77_uikit:clearText3D(handle) -- true; also true if it already expired\nexports.open77_uikit:clearText3D() -- true, : everything this resource drew\n```\n\n`position` takes `{ x, y, z }`, a `vector3`, or a `{ x, y, z }` array; `entity`\n(with an optional world-axis `offset`) follows a live entity instead, and\nexactly one of the two must be given (`position_or_entity_required`). The text\nis drawn **natively**, in the frame that presents it, as a\n[world anchor](../wiki/data/api.json) of render style `card` with a label and\nno keycap -- there is no page and no CEF hop, which is why it does not trail\nthe world the way a web-page billboard does. Everything the interaction\nprompts already compute applies for free: the distance band (`maxDistance`,\nwith a fade at the outer edge), the camera-plane cull, the viewport cull, and\nthe **occlusion test** -- a string behind a wall is hidden after a few\noccluded frames and comes back the frame the wall is gone.\n\n`updateText3D` takes `text`, `sublabel`, `color`, `accent`, `background`,\n`scale`, `showDistance`, `maxDistance`, `visible`, and `position` (a point\ntext) or `offset` (an entity text). Moving a text between a point and an\nentity is not a patch: clear it and draw another. A patch that is refused\nchanges nothing.\n\n`listText3D()` returns the caller's texts with the host's last projection\njoined in, so a resource -- or a probe -- can ask *is it showing* without\ntouching `Open77.anchors`:\n\n```lua\n{ { handle = 'text3d:7', text = 'Delivery point', maxDistance = 50, expiresIn = 12.4,\n distance = 5.8, onScreen = true, projected = true, inRange = true } }\n```\n\n`distance` is camera-to-point. `inRange` is `distance <= maxDistance` on a\nprojected point: `false` is the host telling you it will not draw it.\n\n**The quota is the kit's, and it is budgeted.** Every text is an anchor the\nhost counts against *this* resource -- the caller's identity is the kit's as\nfar as `Open77.anchors` is concerned -- and the per-resource ceiling is 32. So\nthe kit refuses **8 per owner** (`text3d_owner_limit`) and **24 in all**\n(`text3d_limit`) before the host ever answers `quota_exceeded`, which would\notherwise read as a fault in a caller that did nothing wrong. Twenty-four\nfloating strings is already a screen nobody can read; if a resource needs more\nthan eight it should be showing the nearest eight, and `listText3D` gives it\nthe distances to choose by.\n\nTexts are released when the owner stops, reloads, disconnects, calls `close`,\nor when their `ttl` passes -- on the kit's own tick, whether or not the owner\never calls again. `drawText3D` needs no permission on the caller and none on\nthe kit: `Open77.anchors` is deliberately ungated.","natives":[],"permissions":[],"events":[]},{"anchor":"porting-a-drawtext3d-loop","heading":"Porting a `DrawText3D` loop","level":3,"text":"```lua\n-- Before: FiveM, a thread per string\nCreateThread(function()\n while showing do\n DrawText3D(x, y, z, 'Delivery point')\n Wait(0)\n end\nend)\n\n-- After: one handle, no thread. Clear it when `showing` would have gone false.\nlocal handle = exports.open77_uikit:drawText3D({\n position = { x = x, y = y, z = z }, text = 'Delivery point',\n})\n-- ...\nexports.open77_uikit:clearText3D(handle)\n```\n\nA loop that *changed* the string every frame -- a countdown, a live price --\nbecomes `updateText3D` when the value changes, not on a timer. A loop that\ntested `#(playerCoords - point) < 10` before drawing becomes `maxDistance =\n10`.","natives":["CreateThread","server:CreateThread","Wait","server:Wait"],"permissions":[],"events":[]},{"anchor":"showcinematicbars-setcinematic","heading":"`showCinematicBars` / `setCinematic`","level":2,"text":"The cinematic letterbox: two black bars, the vanilla HUD hidden, both under\n**one claim** the kit gives back on every exit path it already guards.\n\n```lua\nexports.open77_uikit:showCinematicBars(true, {\n heightPct = 12, -- per bar, percent of the viewport, 1..40\n durationMs = 350, -- the slide in and out, 0..5000\n color = '#000000',\n hideHud = true, -- false = bars only, the HUD stays\n})\n-- ... the scene ...\nexports.open77_uikit:showCinematicBars(false)\n```\n\n`setCinematic(enabled, opts)` is the same function under the name the parity\nledger gives it; `cinematicState()` returns `{ active, hudHidden, mine,\nholders, heightPct, color }` for a resource that wants to assert before it\nstarts a scene.\n\nThe HUD half is [`Open77.hud.setVisible(\"all\", false)`](hud-visibility.md), a\nper-owner loan the host releases with the owning resource. The kit holds that\nloan on the caller's behalf -- `open77_uikit` carries `ui.vanilla.hud`, the\ncaller needs nothing -- and composes it with the bars, which are two `div`s on\nthe kit's own page: no new surface, no focus, no pointer events, drawn *under*\na dialog so a \"skip cutscene?\" confirmation is never covered by its own\nletterbox.\n\nBars are a **claim per owner**, the same model as a HUD hide. They show while\nany claim stands, the most recent claim decides the look, and the last release\nrestores the HUD -- so two resources composing one scene cannot pull the bars\nout from under each other, and a release of something never claimed is `true`,\nnot an error. Owner stop, owner reload, `close`, disconnect and the kit's own\nstop all release; the page stays up for the length of `durationMs` after the\nlast release so the slide-out is actually seen.\n\n**The camera is deliberately not part of this call.** `Open77.camera` handles\nbelong to the resource that created them, and a kit that activated a caller's\ncamera would own the one thing it cannot release. The shape of a cutscene is:\n\n```lua\nlocal cam = Open77.camera.create({ position = p, lookAt = target, fov = 40 })\nOpen77.camera.activate(cam, { blendMs = 800 })\nexports.open77_uikit:showCinematicBars(true)\n-- ...\nexports.open77_uikit:showCinematicBars(false)\nOpen77.camera.deactivate({ blendMs = 800 })\nOpen77.camera.destroy(cam)\n```\n\nActivating a scripted camera does **not** hide the HUD by itself; this call\nis what does.","natives":["Open77.camera.create","Open77.camera.activate","Open77.camera.deactivate","Open77.camera.destroy"],"permissions":[],"events":[]},{"anchor":"alert","heading":"`alert`","level":2,"text":"```lua\nlocal answer = Open77.exports.call('open77_uikit', 'alert', {\n title = 'Sell the Quadra?', -- REQUIRED, <= 96\n message = 'It will be gone for good.', -- <= 1024\n confirm = 'Sell it', -- default 'Confirm'\n cancel = 'Keep it', -- omit for a single-button notice\n tone = 'warning', -- info | success | warning | danger\n size = 'normal', -- normal | large\n timeoutMs = 30000, -- 1000..120000, default 60000\n}):await()\n-- { ok = true, outcome = 'ok', value = 'confirm' }\n```\n\nEnter confirms, Escape cancels. A `danger` tone paints the confirm button in\n`--op77-danger` -- use it for the button you would regret.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"input","heading":"`input`","level":2,"text":"```lua\nlocal answer = Open77.exports.call('open77_uikit', 'input', {\n title = 'Register the plate',\n description = 'The DMV will not check any of this.',\n fields = {\n { id = 'plate', type = 'text', label = 'Plate', max = 8, required = true },\n { id = 'colour', type = 'select', label = 'Colour',\n options = { 'Chrome', 'Matte black', { value = 'red', label = 'Samurai red' } } },\n { id = 'tint', type = 'slider', label = 'Window tint', min = 0, max = 100, default = 40 },\n { id = 'notes', type = 'textarea', label = 'Notes', max = 400 },\n { id = 'insure', type = 'checkbox', label = 'Insure it' },\n },\n confirm = 'Register', cancel = 'Cancel', timeoutMs = 60000,\n}):await()\n\nif answer.ok then\n print(answer.value.plate, answer.value.colour, answer.value.tint)\n print(answer.value[1], answer.value[2], answer.value[3]) -- the same values, in order\nend\n```\n\nValues arrive **keyed by field id and indexed by position**, so a caller that\nwrote its fields in order can read them back in order without inventing ids.\n\n| `type` | Rendered as | `value` |\n|---|---|---|\n| `text`, `password` | a single-line field | string |\n| `textarea` | a fixed-height box | string |\n| `number` | a numeric field, `min`/`max`/`step` | number |\n| `slider` | a track with a tabular readout | number |\n| `checkbox` | a chamfered toggle | boolean |\n| `select` | a **custom `role=\"listbox\"`** | the chosen `value` |\n\n`select` is a listbox of buttons and not a native dropdown, deliberately: the\nnative popup does not render reliably in the CEF compositor, and the server\nsuite asserts that none ships anywhere under `resources/*/web`. Do not add one.\n\nValidation runs on the page before submit -- `required`, length and range -- and\nshows the error under the field rather than closing on a bad value. Fields are\nre-validated on the way in: an unknown `type`, a duplicate `id`, an empty\n`options` list and a `max <= min` range are all refused before anything is shown\n(see [failure reasons](#failure-reasons)). Up to 24 fields.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"context-showcontext-hidecontext","heading":"`context` / `showContext` / `hideContext`","level":2,"text":"A browse menu, registered once and shown many times, with real navigation.\n\n```lua\nOpen77.exports.call('open77_uikit', 'context', {\n id = 'garage_root',\n title = 'Northside garage',\n description = 'Three bays, one owner.',\n options = {\n { id = 'vehicles', label = 'Vehicles', icon = 'V',\n description = 'Bring one up to the ramp.', menu = 'garage_bays' },\n { id = 'impound', label = 'Pay the impound fee', tone = 'danger',\n metadata = { { label = 'Fee', value = '2 000' } } },\n { id = 'closed', label = 'Valet (off shift)', disabled = true },\n },\n})\nOpen77.exports.call('open77_uikit', 'context', {\n id = 'garage_bays', title = 'Bays',\n options = { { id = 'bay1', label = 'Bay 1' }, { id = 'bay2', label = 'Bay 2' } },\n})\n\nlocal answer = Open77.exports.call('open77_uikit', 'showContext', 'garage_root'):await()\n-- { ok = true, outcome = 'ok', value = { id = 'bay1', menu = 'garage_bays' } }\n```\n\nAn option with a `menu` field **navigates inside the same dialog**: the trail is\npushed, the panel is redrawn, the token does not change and the promise does not\nresolve. Back pops the trail; back at the root closes the menu and resolves\n`cancelled`. There is deliberately no `parent` field -- a declared parent and a\nreal trail can disagree, and the disagreement shows up as a back button that\ncancels instead of going back.\n\nMenus are **owner-scoped**: two resources may both register `main`. 32 menus per\nresource, 64 options per menu. `showContext` takes an optional second argument\n`{ timeoutMs = 30000, useWhileDead = false }`. `hideContext()` closes the\ncaller's open dialog.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"menu-showmenu-hidemenu","heading":"`menu` / `showMenu` / `hideMenu`","level":2,"text":"The keyboard-driven cousin of `context`: arrow keys move, Enter picks, left and\nright side-scroll a value, and a checkbox row toggles in place.\n\n```lua\nOpen77.exports.call('open77_uikit', 'menu', {\n id = 'squad_settings', title = 'Squad settings',\n options = {\n { id = 'voice', label = 'Squad voice', checked = true },\n { id = 'range', label = 'Radio range',\n values = { 'Short', 'Medium', 'Long' }, defaultIndex = 2 },\n { id = 'leave', label = 'Leave the squad', tone = 'danger' },\n },\n})\nlocal answer = Open77.exports.call('open77_uikit', 'showMenu', 'squad_settings'):await()\n-- answer.value = { id = 'leave', menu = 'squad_settings' } when a row is picked\n-- answer.values = { voice = false, range = 3 } always\n```\n\n**A checkbox row does not resolve the menu.** It toggles, and its state leaves\nwith whatever finally does -- a picked row, or the close. That is why `values`\nis present on a `cancelled` outcome too: a settings menu made only of toggles\nhas no row that picks anything, and without this its state could never reach\nLua at all.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"radial-showradial-hideradial","heading":"`radial` / `showRadial` / `hideRadial`","level":2,"text":"An eight-sector wheel, for quick actions.\n\n```lua\nOpen77.exports.call('open77_uikit', 'radial', {\n id = 'quick', title = 'Quick actions',\n options = {\n { id = 'hands', label = 'Hands up', icon = 'H' },\n { id = 'point', label = 'Point', icon = 'P' },\n { id = 'sit', label = 'Sit', icon = 'S' },\n { id = 'cuff', label = 'Cuff', icon = 'C' },\n },\n})\nlocal answer = Open77.exports.call('open77_uikit', 'showRadial', 'quick'):await()\n```\n\nEight sectors is a hard cap (`too_many_options`) because a ninth is unreadable\nat a glance, which is the only thing a radial is for. **Known limitation: the\nradial is mouse-only.** It has no keyboard navigation, unlike `menu`. Escape\nstill closes it through the ordinary release path, so it cannot strand anybody;\nit is a gap in the interaction, not in the safety.","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"close-and-state","heading":"`close` and `state`","level":2,"text":"```lua\nOpen77.exports.call('open77_uikit', 'close') -- true, \nOpen77.exports.call('open77_uikit', 'state') -- a diagnostic snapshot\n```\n\n`close` cancels everything the calling resource holds -- its dialog, its\nprogress bar, its text slot, its 3D texts, its letterbox claim -- in one call. It is the right thing in a resource's\nown cleanup path even though the kit does it for you when you stop.\n\n`state()` is a diagnostic, not steady-state API. Read it when a widget \"did\nnothing\": it separates *the surface never reported ready* from *another resource\nholds the dialog* from *focus is stuck*.\n\n```lua\n{ ready = true, visible = false, focusHeld = false,\n dialog = nil, progress = nil, textSlots = 0, blockAll = false,\n texts3d = 0, cinematic = false, cinematicHudHidden = false,\n blocks = { { action = 'Movement', holders = 1 } },\n widgets = { { token = 7, owner = 'garage', widget = 'dialog', kind = 'alert', focus = true } } }\n```\n\n---","natives":["Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"server-twins","heading":"Server twins","level":1,"text":"Every widget has a server-side twin, so a server job step can drive a bar or ask\na question without a bespoke net event in every resource.\n\n```lua\n-- resources/jobs/server.lua\nCreateThread(function()\n local answer, reason = exports.open77_uikit:alert(source, { ... })\nend)\n```\n\nThat is the wrong spelling and it will fail -- a twin waits on a callback, so it\nyields, and the synchronous export proxy has no scheduler under it. Use the\nasynchronous form:\n\n```lua\nlocal promise, dispatchError = Open77.exports.call('open77_uikit', 'alert', source, {\n title = 'Sign the contract?', confirm = 'Sign', cancel = 'Not today',\n timeoutMs = 30000,\n})\nif not promise then return end\nlocal answer, reason = promise:await()\n```\n\n| Server export | Arguments |\n|---|---|\n| `progress(playerId, definition)` | the client `progress` definition |\n| `alert(playerId, definition)` | the client `alert` definition |\n| `input(playerId, definition)` | the client `input` definition |\n| `context(playerId, definition, options?)` | registers **and** shows; pass `show = false` in `options` to only register |\n| `menu(playerId, definition, options?)` | as above |\n| `radial(playerId, definition, options?)` | as above |\n| `textUI(playerId, definition)` | shows a hint owned by the server resource |\n| `hideTextUI(playerId)` | takes it away |\n| `drawText3D(playerId, definition)` | the client `drawText3D` definition; the answer's `value` is the handle |\n| `updateText3D(playerId, handle, patch)` | the client patch |\n| `clearText3D(playerId, handle?)` | one text, or every text that server resource drew on that client |\n| `showCinematicBars(playerId, enabled, options?)` / `setCinematic(...)` | the client options; the answer's `value` is `cinematicState()` |\n| `close(playerId)` | cancels everything that server resource holds on that client |\n\nThe world-text and letterbox twins never wait on the player, so they ride a\nfive-second transport deadline rather than a dialog's.","natives":["CreateThread","server:CreateThread","Open77.exports.call","server:Open77.exports.call"],"permissions":[],"events":[]},{"anchor":"a-server-twin-is-a-request-not-a-command","heading":"A server twin is a request, not a command","level":2,"text":"The client may never answer -- loading, dead, already holding a dialog for\nanother resource, or gone. So a twin has exactly two result shapes and they do\nnot overlap:\n\n| Shape | Meaning |\n|---|---|\n| `nil, reason` | the question never reached an answer. `reason` is a stable token from the callback transport: `callback_timeout`, `callback_not_found`, `callback_resource_unavailable`, `callback_target_stopped`, `network_unavailable` -- or one of the twin's own argument refusals (`invalid_player`, `definition_must_be_a_table`, `export_call_required`, `callbacks_unavailable`). |\n| a table | the client answered. The **same** `{ ok, outcome, value }` the client export resolves with, so a job step reads one shape wherever it runs. |\n\nA player pressing Escape is a table with `ok = false`, never a `nil, reason`.\n\n**The transport deadline is deliberately longer than the dialog's** -- the\ndialog's own `timeoutMs` plus five seconds, clamped to the transport's\n100..120 000 ms range. If they were equal the server would give up in the same\ninstant the player was still reading, and the client would be left holding focus\nfor a question nobody is listening to any more.\n\nThe owner of a server-driven widget is the **calling server resource**,\nnamespaced so it can never collide with a client resource of the same name. Two\nserver resources get their own text slots and their own `close`.","natives":[],"permissions":[],"events":[]},{"anchor":"uikitdemo","heading":"`/uikit.demo`","level":2,"text":"A restricted command that drives one widget on the caller's own client through\nthe real server-to-client path:\n\n```text\n/uikit.demo progress | text | hidetext | alert | input | context | menu | radial\n/uikit.demo text3d | cinematic | nocinematic\n```\n\n`text3d` floats `Delivery point` five metres north of the caller for sixty\nseconds with a 50 m band; `cinematic` / `nocinematic` claim and release the\nletterbox.\n\nIt is what the [integrator checklist](#integrator-checklist) below runs.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"styling","heading":"Styling","level":1,"text":"The kit renders in the Open77 design system and a resource **cannot inject CSS**\n-- each WebUI surface runs under its own isolated virtual origin, and more to\nthe point, a kit whose look every caller could override would not be a kit. What\na caller may change is the small set of tokens below.\n\n| Field | Where | Design token it drives |\n|---|---|---|\n| `color` (`#RRGGBB`) | `progress`, `textUI` | the leading rule, the fill, the icon glyph. Default `--op77-accent` `#22D8E2`. |\n| `color`, `background`, `accent` (`#RRGGBB`, `#RRGGBBAA`, `none`) | `drawText3D` | the text, its backdrop and its border, drawn natively. Defaults `#F2F6F8`, `#0A1220B8`, none. |\n| `heightPct`, `color` | `showCinematicBars` | the bars. Deliberately the one element with no chamfer, no rule and no accent: a letterbox is a frame around the picture, not a panel in it. |\n| `tone` | `alert` | `info` -> `--op77-accent`, `success` -> `--op77-ok`, `warning` -> `--op77-warn`, `danger` -> `--op77-danger`. Paints the eyebrow, the rule and the confirm button. |\n| `tone` | an option row | `danger` -> `--op77-danger`, `success` -> `--op77-ok` on the row title. |\n| `icon` | hints and rows | a short mono glyph in a chamfered chip, `--op77-font-mono`. Text, not an image: the page has no network and bundles no icon font. |\n| `eyebrow` | `textUI` | the `//` label, `--op77-fs-micro` tracked at `.18em`. |\n| `position` | all | `top` / `center` / `bottom`, on the shared `--op77-inset-y`. |\n| `style` | `progress` | `bar` or `circle`. |\n| `size` | `alert` | `normal` (520px) or `large` (700px). |\n\nAn invalid colour is **refused** (`invalid_color`), not silently replaced: a\npalette that quietly falls back is how a surface drifts off-brand without\nanybody noticing.\n\nThe surface follows the versioned kit's four resolved rules -- chamfer rather\nthan radius, mono for machine values only, no separation shadow (a `clip-path`\nclips one away), and the 0.94 opacity floor for anything carrying words over\ngameplay. Its values come from the mirrored `web/open77-ui.css` that every\nshared platform service carries. `web/app.css` carries the full reasoning; see\n[`docs/ui-design-system.md`](../docs/ui-design-system.md) sections 2 and 4.\n\n**Accent budget:** a dialog shows cyan on at most three things -- the leading\nrule, the primary action, and the one focused row. If you find yourself wanting\na fourth, you want a different tone, not more cyan.\n\n---","natives":[],"permissions":[],"events":[]},{"anchor":"a-worked-example-a-job-step-with-a-progress-bar","heading":"A worked example: a job step with a progress bar","level":1,"text":"Server-authoritative, client-presented. The server decides what may happen, the\nkit shows it, and the server re-checks after the yield because a progress bar is\na long time in a game world.\n\n`resources/jobs/open77.lua`\n\n```lua\nresource 'jobs'\nversion '1.0.0'\ndependency 'open77_uikit >=1.0.0'\nserver_script 'server.lua'\npermissions { 'network.events' }\n```\n\n`resources/jobs/server.lua`\n\n```lua\nlocal kDumpster = { x = -1580.0, y = -1290.0, z = 8.0 }\n\nlocal function atThe(playerId, spot, radius)\n local at = Open77.players.position(playerId)\n if not at then return false end\n local dx, dy, dz = at.x - spot.x, at.y - spot.y, at.z - spot.z\n return (dx * dx + dy * dy + dz * dz) <= radius * radius\nend\n\nlocal function uikit(name, ...)\n local promise, reason = Open77.exports.call('open77_uikit', name, ...)\n if not promise then return nil, reason end\n return promise:await()\nend\n\nRegisterCommand('collect', function(source)\n CreateThread(function()\n if not atThe(source, kDumpster, 4.0) then return end\n\n -- 1. Ask. A refusal is ordinary; a no-answer is not.\n local answer, reason = uikit('alert', source, {\n title = 'Empty this dumpster?',\n message = 'Takes about six seconds. Pays 45 eddies.',\n confirm = 'Get to it', cancel = 'Later', timeoutMs = 20000,\n })\n if answer == nil then\n Open77.log.warn('jobs: no answer from ' .. source .. ' (' .. tostring(reason) .. ')')\n return\n end\n if not answer.ok then return end -- cancelled or timed out\n\n -- 2. Do it, with the player held by the CONTROL surface, not by focus.\n -- They can still open chat and complain about the pay.\n answer, reason = uikit('progress', source, {\n label = 'Emptying the dumpster',\n duration = 6000,\n disable = { move = true, combat = true },\n })\n if answer == nil or not answer.ok then\n -- `outcome == 'cancelled'` means they pressed X. No pay, no error.\n return\n end\n\n -- 3. Re-check. Six seconds is long enough to be somewhere else.\n if not atThe(source, kDumpster, 6.0) then return end\n payWages(source, 45) -- your own economy, not a platform API\n\n Open77.notifications.send(source, {\n id = 'jobs_paid', type = 'success',\n title = 'Shift', message = '45 eddies.', replace = true,\n })\n end)\nend, false)\n```\n\nThree things in that example are the point of the whole page. The bar **blocks\nthrough `Open77.input`**, so chat and voice keep working. The cancel resolves\nwith `ok = false` rather than raising, so there is no `pcall`. And the server\nre-validates after the yield, because the kit made the round trip convenient and\nchanged nothing about who decides.\n\n---","natives":["server:Open77.players.position","Open77.exports.call","server:Open77.exports.call","RegisterCommand","server:RegisterCommand","CreateThread","server:CreateThread","Open77.log.warn","server:Open77.log.warn","server:Open77.notifications.send"],"permissions":["network.events"],"events":[]},{"anchor":"failure-reasons","heading":"Failure reasons","level":1,"text":"All stable snake_case. Returned as the second value of `nil, reason` before\nanything is shown.\n\n| Reason | Meaning |\n|---|---|\n| `export_call_required` | Called other than through an export, so there is no owner. |\n| `surface_unavailable` | `WebUI.create` failed at start-up. Read the client log. |\n| `surface_not_ready` | The page has not reported ready. A dialog is refused rather than taking the mouse and rendering nothing. |\n| `dialog_active` | Another resource holds the one focus dialog. |\n| `progress_active` | A progress bar is already running. |\n| `progress_not_active` | `cancelProgress` with nothing to cancel. |\n| `not_owner` | Closing or cancelling something another resource opened. |\n| `text_slot_limit` | Four resources already show a hint. |\n| `menu_limit` | 32 registered menus for this resource. |\n| `menu_not_found` | No menu with that id **for this resource**. Menus are owner-scoped. |\n| `menu_kind_mismatch` | `showMenu` on a `context`, or similar. |\n| `definition_must_be_a_table` | |\n| `invalid_title`, `invalid_message`, `invalid_confirm`, `invalid_cancel` | Copy missing, too long, or carrying control characters. |\n| `invalid_label`, `invalid_text`, `invalid_key`, `invalid_icon`, `invalid_eyebrow` | |\n| `invalid_duration` | Outside 100..600 000 ms, or not a whole number. |\n| `invalid_timeout` | Outside 1 000..120 000 ms. |\n| `invalid_position`, `invalid_style`, `invalid_tone`, `invalid_color` | Not in the vocabulary. |\n| `invalid_cancel_key` | |\n| `invalid_disable` | Not a table, `'all'`, or a boolean map; or an `except` entry that is not an action name. |\n| `invalid_fields`, `invalid_field`, `invalid_field_type`, `invalid_field_id`, `invalid_field_label`, `invalid_field_range`, `invalid_field_default`, `invalid_field_options` | |\n| `duplicate_field_id`, `duplicate_option_id` | |\n| `invalid_menu_id`, `invalid_options`, `too_many_options` | |\n| `invalid_option`, `invalid_option_id`, `invalid_option_label`, `invalid_option_menu`, `invalid_option_values`, `invalid_option_checked`, `invalid_option_metadata` | |\n| `invalid_player` | Server twin: a player id must be a positive integer. |\n| `callbacks_unavailable` | Server twin: this host has no callback transport. |\n\nPlus everything [`Open77.exports.call`](resource-exports.md) and\n[the callback transport](callbacks.md) can answer with in their own right.","natives":[],"permissions":[],"events":[]},{"anchor":"limits","heading":"Limits","level":1,"text":"| Limit | Value |\n|---|---|\n| Focus-taking dialogs open at once | 1, across every resource |\n| Progress bars at once | 1 |\n| Text hints at once | 4, one per resource |\n| Registered menus per resource | 32 |\n| Options per menu | 64 (radial: 8) |\n| Fields per input dialog | 24 |\n| Dialog timeout | 1 000..120 000 ms, default 60 000 |\n| Progress duration | 100..600 000 ms |\n| WebUI surfaces used | **1** |","natives":[],"permissions":[],"events":[]},{"anchor":"not-in-this-kit","heading":"Not in this kit","level":1,"text":"- **`notify`**: use [`open77_notifications`](notifications.md) for toasts.\n- **`skillCheck`**: not provided by this kit.","natives":[],"permissions":[],"events":[]},{"anchor":"verification","heading":"Verification","level":1,"text":"```powershell\npwsh -File scripts/check-lua.ps1 open77_uikit\npython scripting/tests/uikit_focus_test.py\npython scripting/tests/uikit_widgets_test.py\n```\n\nThe two suites load the real `client/main.lua` and `server/main.lua` into a real\nLua 5.4 VM with the host bindings stubbed, and cover the release matrix, the\ncontrol-block claims, each widget's promise resolving and cancelling, stale\ntokens, two resources not fighting over the page, the server twin's timeout,\nthe 3D-text handles (quota, ttl, owner scoping, whole-presentation patches)\nand the letterbox claims (last claim decides, last release restores).","natives":[],"permissions":[],"events":[]},{"anchor":"integrator-checklist","heading":"Integrator checklist","level":2,"text":"Three minutes at a keyboard, from an account that may run restricted commands.\n**The important half is the second column of every row**: after a dialog, the\nplayer can move, shoot and type again.\n\n| # | Do this | Must appear | Must work afterwards |\n|---|---|---|---|\n| 1 | `/uikit.demo text` | a hint bottom-centre, `Hold to hotwire` with an `E` keycap | move, shoot and open chat -- the hint takes no input at all |\n| 2 | `/uikit.demo hidetext` | the hint goes | -- |\n| 3 | `/uikit.demo progress` | a bar bottom-centre counting down, with an `X` keycap | **while it runs**: W does not move you and the trigger does not fire, but **chat opens and you can type**. When it ends, move and shoot again |\n| 4 | `/uikit.demo progress`, then press `X` | the bar goes early; chat says `cancelled` | move and shoot again |\n| 5 | `/uikit.demo alert`, click **Sell it** | a centred panel, amber rule, two buttons | chat says `ok -> confirm`; move, shoot, type |\n| 6 | `/uikit.demo alert`, press **Escape** | the panel goes | chat says `cancelled`; **move, shoot and type** |\n| 7 | `/uikit.demo input` | four fields; the Colour field is a **list of rows, not a dropdown** | pick a colour, drag the tint, press Enter. Chat echoes the values; move, shoot, type |\n| 8 | `/uikit.demo input`, clear the Plate field, press Enter | `Required.` under the field, the dialog stays open | Escape closes it; move, shoot, type |\n| 9 | `/uikit.demo context`, click **Retrieve a vehicle** | a three-row menu with icons and a fee row | chat says `ok -> id=retrieve`; move, shoot, type |\n| 10 | `/uikit.demo menu`, arrow down to Radio range, press Right twice, then Escape | the value cycles Medium -> Long -> Short | chat says `cancelled` with the values; move, shoot, type |\n| 11 | `/uikit.demo radial`, click a sector | a four-sector wheel | chat says `ok`; move, shoot, type |\n| 12 | `/uikit.demo alert`, and **while it is open** get another player to run `/uikit.demo alert` | the second player sees their own dialog; nothing interferes | -- |\n| 13 | `/uikit.demo alert`, and while it is open **die** | the dialog closes on its own | you can click through the respawn screen |\n| 14 | `/uikit.demo alert`, and while it is open **disconnect and reconnect** | -- | you spawn with full control |\n| 15 | `/uikit.demo text3d` | `Delivery point` floating 5 m ahead on a dark chip, with `5 m` under it | walk 60 m away: it fades at the band's edge and is gone; walk back: it is there; put a wall between you and it: it goes, and returns when you step out |\n| 16 | `/uikit.demo cinematic` | two black bars, top and bottom, sliding in; **the whole vanilla HUD is gone** (minimap, health, weapon, quest tracker) | move, shoot, type -- the bars take nothing |\n| 17 | `/uikit.demo nocinematic` | the bars slide out; the HUD is back exactly as it was | -- |\n| 18 | `/uikit.demo cinematic`, then **disconnect** | -- | the main menu has no bars and the next session has its HUD |\n\nRows 6, 13 and 14 are the ones that matter. If any of them ever leaves a player\nunable to move or type, the bug is in the release path, not in the widget.","natives":[],"permissions":[],"events":[]},{"anchor":"see-also","heading":"See also","level":1,"text":"- [Notifications](notifications.md) -- toasts, the `notify` half of the kit.\n- [Blocking player input](input-blocking.md) -- the vocabulary `disable` maps to,\n and what this engine genuinely cannot refuse.\n- [Network callbacks](callbacks.md) -- the transport under every server twin.\n- [Official resource exports](resource-exports.md) -- how to call an export, and\n every other package's surface.\n- [Contextual interactions](interactions.md) -- world prompts, which is a\n different problem from a dialog and deliberately a different service.","natives":[],"permissions":[],"events":[]}]},{"slug":"vectors","file":"vectors.md","title":"Vectors and quaternions","summary":"`vector2`, `vector3`, `vector4`, `vec` and `quat` are global constructors in both Lua runtimes. They provide arithmetic, magnitude (`#`), swizzles, distance helpers and quaternion operations:","sections":[{"anchor":"vectors-and-quaternions","heading":"Vectors and quaternions","level":1,"text":"`vector2`, `vector3`, `vector4`, `vec` and `quat` are global constructors in both Lua runtimes. They provide arithmetic, magnitude (`#`), swizzles, distance helpers and quaternion operations:\n\n```lua\nif #(playerPos - jobPos) < 3.0 then\n -- in range\nend\n```\n\nThe expression works in both runtimes.","natives":[],"permissions":[],"events":[]},{"anchor":"plain-table-compatibility","heading":"Plain-table compatibility","level":2,"text":"**A vector *is* a plain Lua table carrying `x`, `y`, `z` (and `w`) as real fields.** The only\nthing a constructor adds is a shared metatable. Nothing about the table changed:\n\n```lua\nlocal v = vector3(1, 2, 3)\ntype(v) --> \"table\"\nrawget(v, \"x\") --> 1\nfor k in pairs(v) do print(k) end --> x, y, z\n```\n\nThree consequences, all of them deliberate:\n\n- Every API that accepts a `{ x =, y =, z = }` table today accepts a vector, with no change to\n the API and no change at the call site. The client's native position readers and the\n server's `type(p.x) == \"number\"` guards never had to learn a new type, because there is no\n new type to learn -- only new behaviour on a familiar one.\n- Every API that returns a position **still returns a plain table**. Nothing about a return\n shape changed in this wave; changing one would break every resource reading `pos.x`. Wrap it\n when you want the maths: `vector3(pos)`, or `vec(pos)`.\n- Serialisation is unchanged too, so `json.encode`, net events and exports carry a vector as\n the object it always was. See [JSON and the network](#json-and-the-network).\n\nMixing the two freely is the expected style:\n\n```lua\nlocal here = Open77.character.position() -- a plain table, as always\nlocal there = vector3(1660.0, -723.6, 50.5)\nlocal metres = #(there - here) -- one vector operand is enough\n```","natives":["Open77.character.position"],"permissions":[],"events":[]},{"anchor":"constructors","heading":"Constructors","level":2,"text":"| Call | Result |\n|---|---|\n| `vector2(x, y)` | a 2D vector |\n| `vector3(x, y, z)` | a 3D vector |\n| `vector4(x, y, z, w)` | a 4D vector |\n| `vec(...)` | dispatches on the argument count: 2, 3 or 4 numbers |\n| `quat(x, y, z, w)` | a quaternion |\n\nEvery constructor also accepts **one table** -- another vector, or any `{ x =, y =, z = }`\ntable -- and copies it:\n\n```lua\nvector3({ x = 1, y = 2, z = 3 }) --> vector3(1.0, 2.0, 3.0)\nvector3(someVector4) --> takes x, y, z\nvec(Open77.character.position()) --> a vector3, because the table has three axes\n```\n\n`vec` with a single table picks the size from the axes that are actually there: `w` present\nmeans `vector4`, else `z` means `vector3`, else `y` means `vector2`.\n\nComponents must be finite numbers. A constructor that cannot build a vector returns\n`nil, reason` -- never a half-built value:\n\n```lua\nlocal v, reason = vector3(1, 2) --> nil, \"invalid_vector\"\nlocal q, reason = quat(1, 2, 3) --> nil, \"invalid_quat\"\n```","natives":["Open77.character.position"],"permissions":[],"events":[]},{"anchor":"fields-and-swizzles","heading":"Fields and swizzles","level":2,"text":"`v.x`, `v.y`, `v.z` and `v.w` are ordinary field reads with no metamethod in the way.\n\nAny combination of two to four axes the vector actually has is a swizzle, returning the\nvector of that size:\n\n```lua\nlocal v = vector4(1, 2, 3, 4)\nv.xy --> vector2(1.0, 2.0)\nv.xz --> vector2(1.0, 3.0)\nv.yz --> vector2(2.0, 3.0)\nv.xyz --> vector3(1.0, 2.0, 3.0)\nv.zyx --> vector3(3.0, 2.0, 1.0)\nvector3(1, 2, 3).xw --> nil, there is no w\n```","natives":[],"permissions":[],"events":[]},{"anchor":"read-only-fields-and-cached-length","heading":"Read-only fields and cached length","level":3,"text":"A vector is a **value**. Assigning any key raises:\n\n```lua\nlocal v = vector3(1, 2, 3)\nv.label = \"spawn\" -- error: Open77 vectors are read-only (tried to set 'label')\n```\n\n**The exception is the component keys themselves.** `v.x = 9` succeeds, silently, and there is\nno way to stop it: Lua 5.4 only calls `__newindex` for a key that is *absent* from the table,\nand `x`, `y`, `z` and `w` must be physically present or every existing reader -- `json.encode`,\nnet-event serialisation, `pairs`, `rawget`, the native C++ and C# position parsers -- would\nstop seeing a vector as the table it has always accepted. That compatibility was worth more\nthan the guard, so the guard is documented rather than faked.\n\nTreat components as immutable anyway. To change one, build a new vector:\n\n```lua\nlocal raised = vector3(v.x, v.y, v.z + 1.0)\n```\n\n`v:table()` gives you a fresh, ordinary, mutable copy when you want one.","natives":[],"permissions":[],"events":[]},{"anchor":"operators","heading":"Operators","level":2,"text":"| Operator | Meaning |\n|---|---|\n| `a + b`, `a - b` | component-wise; a number broadcasts to every component |\n| `a * b`, `a / b` | component-wise by a vector, or scaled by a number |\n| `-a` | negation |\n| `#a` | magnitude (`a:length()`) |\n| `a == b` | exact, and only between two Open77 values of the same kind |\n| `a .. b`, `tostring(a)` | `vector3(1.0, 2.0, 3.0)` |\n\nThe other operand of an arithmetic operator may be a vector, a plain `{ x =, y =, z = }` table,\nor a number:\n\n```lua\nvector3(1, 2, 3) + vector3(4, 5, 6) --> vector3(5.0, 7.0, 9.0)\nvector3(1, 2, 3) + { x = 1, y = 1, z = 1 } --> vector3(2.0, 3.0, 4.0)\nvector3(1, 2, 3) * 2 --> vector3(2.0, 4.0, 6.0)\n{ x = 1, y = 1, z = 1 } + vector3(1, 2, 3) --> vector3(2.0, 3.0, 4.0)\n```\n\n**Operators raise; functions return `nil, reason`.** An operator has no second return slot, so\nyielding a silent `nil` from `a + b` would hide the bug instead of naming it. Mixing\ndimensions, handing an operator something that is not a vector, and producing a non-finite\ncomponent all raise:\n\n```lua\nvector2(1, 2) + vector3(1, 2, 3) -- error: vector addition between a vector2 and a vector3\nvector3(1, 2, 3) + \"text\" -- error: vector addition requires a vector, ... or a number\n```\n\n`==` is exact and kind-aware. `vector3(1, 2, 3) == vector3(1.0, 2.0, 3.0)` is true (Lua numbers\ncompare by value), while `vector3(1, 2, 3) == { x = 1, y = 2, z = 3 }` is **false**: a plain\ntable has no kind, and guessing one would make `==` depend on which keys a table happens to\ncarry. Compare components, or wrap the table first.\n\n`quat` deliberately has **no** arithmetic and no `#`. It has three methods and nothing more.","natives":[],"permissions":[],"events":[]},{"anchor":"methods","heading":"Methods","level":2,"text":"Every method that takes another vector also accepts a plain `{ x =, y =, z = }` table.\n\n| Method | Returns |\n|---|---|\n| `v:length()` | magnitude; same as `#v` |\n| `v:lengthSquared()` | magnitude squared -- the one to compare against a squared radius |\n| `v:distance(other)` | distance to `other` |\n| `v:distanceSquared(other)` | squared distance |\n| `v:normalize()` | unit vector; **a zero vector normalises to zero, never to NaN** |\n| `v:dot(other)` | dot product |\n| `v:cross(other)` | cross product, `vector3` only; otherwise `nil, \"cross_requires_vector3\"` |\n| `v:lerp(other, t)` | linear interpolation, **unclamped** -- `t` outside `0..1` extrapolates |\n| `v:unpack()` | the components as multiple return values |\n| `v:table()` | a fresh, plain, mutable `{ x =, y =, z = }` table |\n\n```lua\nlocal distance = playerPos:distance(target) --> number\nlocal nearby = vector3(playerPos):lengthSquared() < 25.0\nlocal nothing, reason = vector3(1, 2, 3):dot(42) --> nil, \"invalid_vector\"\n```","natives":[],"permissions":[],"events":[]},{"anchor":"quaternions","heading":"Quaternions","level":3,"text":"`quat` exists because the codebase already hands `{ x =, y =, z =, w = }` orientations to\n`Open77.effects` and `Open77.props`. It makes that table computable and stops there.\n\n| Method | Returns |\n|---|---|\n| `q:normalize()` | unit quaternion; a **zero** quaternion normalises to the identity `(0, 0, 0, 1)` |\n| `q:mul(other)` | Hamilton product: this rotation followed by `other` |\n| `q:rotate(v)` | a `vector3` rotated by `q`; assumes `q` is unit, so `normalize()` first when unsure |\n\n```lua\nlocal spin = quat(0, 0, 1, 0) -- half turn about Z\nspin:rotate(vector3(1, 0, 0)) --> vector3(-1.0, 0.0, 0.0)\nOpen77.effects.create({ effect = \"fire.small\", position = vector3(7, 8, 9), orientation = spin })\n```","natives":["server:Open77.effects.create"],"permissions":[],"events":[]},{"anchor":"json-and-the-network","heading":"JSON and the network","level":2,"text":"Because a vector is a table with real `x`, `y`, `z` fields, it serialises as the JSON object it\nlooks like, with no extra members:\n\n```lua\njson.encode(vector3(1, 2, 3)) --> {\"x\":1,\"y\":2,\"z\":3}\n```\n\nMember **order** is whatever the encoder's table walk produces, as for any Lua table; only the\nmembers and their values are guaranteed. Nested vectors serialise the same way, so a table of\nwaypoints encodes correctly with nothing special at the call site.\n\nThe same reader serves `json.encode`, network events and cross-resource exports. So a vector\nsent over the wire arrives on the other side as a **plain table**, not a vector -- the\nmetatable does not cross the network, and no other runtime's values ever do. That is correct\nand intentional; wrap it on arrival when you want the maths back:\n\n```lua\nRegisterNetEvent(\"job:waypoint\", function(point)\n local target = vector3(point) -- point arrived as { x =, y =, z = }\n if #(Open77.character.position() - target) < 5.0 then ... end\nend)\n```","natives":["RegisterNetEvent","server:RegisterNetEvent","Open77.character.position"],"permissions":[],"events":[]},{"anchor":"one-implementation-two-runtimes","heading":"One implementation, two runtimes","level":2,"text":"The type is written once, in Lua, in `scripting/lua/open77_vector.lua`. The client embeds that\nfile as a C++ string literal generated at configure time; the dedicated server embeds the very\nsame file as an assembly resource. There is no second copy to drift.\n\nBoth runtimes prove it the same way: `scripting/lua/open77_vector_parity.lua` runs on each and\nits transcript is compared against one shared golden file,\n`scripting/lua/open77_vector_parity.expected.txt`. A semantic difference between client and\nserver can only appear as a failing test.\n\nEvery metatable is protected with `__metatable`, so `setmetatable` on a vector raises and\n`getmetatable` hands back only the kind tag (`\"Open77.vector3\"`), never the metatable itself.\nThat matters on the server, where those two globals are not sandboxed away as they are on the\nclient.","natives":[],"permissions":[],"events":[]},{"anchor":"failure-vocabulary","heading":"Failure vocabulary","level":2,"text":"| Reason | Meaning |\n|---|---|\n| `invalid_vector` | the argument was not a vector, nor a table with that many finite numeric axes |\n| `invalid_quat` | same, for `quat` |\n| `invalid_argument` | `lerp` was given a non-finite `t` |\n| `cross_requires_vector3` | `cross` was called on a `vector2` or `vector4` |","natives":[],"permissions":[],"events":[]}]},{"slug":"vehicle-ai","file":"vehicle-ai.md","title":"Networked vehicle AI","summary":"`Open77.vehicles.ai` is a server API requiring `world.vehicles`. The server owns destinations, seat reservations and task lifecycle. One nearby ready client executes native driving; observers receive replicated motion under the existing authority lease and routing-bucket rules.","sections":[{"anchor":"networked-vehicle-ai","heading":"Networked vehicle AI","level":1,"text":"`Open77.vehicles.ai` is a server API requiring `world.vehicles`. The server owns destinations, seat reservations and task lifecycle. One nearby ready client executes native driving; observers receive replicated motion under the existing authority lease and routing-bucket rules.\n\nRequires client and server **2.31.13+op77.54** or later, using compatible network protocols. Vehicle and route compatibility varies; fleet-scale traffic is not guaranteed. This API does not activate Delamain quests or the taxi UI. For player-selected destinations, see [Native map and player waypoints](native-map.md).","natives":[],"permissions":[],"events":[]},{"anchor":"autonomous-vehicle","heading":"Autonomous vehicle","level":2,"text":"```lua\npermissions { \"world.vehicles\" }\n\nlocal id = assert(Open77.vehicles.create({\n record = \"Vehicle.v_standard2_archer_hella_player\",\n position = {x=-2140, y=-600, z=8}, bucket=0,\n}))\nassert(Open77.vehicles.ai.attachDriver(id)) -- driverless\nlocal task, reason = Open77.vehicles.ai.driveTo(id, {\n position = {x=-2100, y=-600, z=8},\n speed = 8, arrivalRadius = 4, timeoutMilliseconds = 120000,\n behavior = \"normal\",\n})\nif not task then print(reason) end\n\nOpen77.vehicles.ai.on(\"arrived\", function(state)\n print(\"Arrived: \" .. state.vehicleId)\nend)\nOpen77.vehicles.ai.on(\"failed\", function(state)\n print(state.vehicleId, state.reason)\nend)\n```\n\nOmit `npcId` for a genuinely autonomous car: the driver's seat is empty on every\nclient. No invisible NPC, character model or hidden actor is required. Use the\nvisible-driver form below only when you actually want a character at the wheel.\n\nChoose drivable coordinates **including the correct road elevation**; the example\nis illustrative, not a validated route. A point above a road can fail native routing.\nTasks wait for a streamed, compatible client to acknowledge native readiness.\nThere is no headless server physics: without a nearby ready player, no AI moves.\nThe existing vehicle spawn-settling interval applies before simulator election.","natives":["server:Open77.vehicles.create","server:Open77.vehicles.ai.attachDriver","server:Open77.vehicles.ai.driveTo","server:Open77.vehicles.ai.on"],"permissions":["world.vehicles"],"events":[]},{"anchor":"visible-driver-and-passengers","heading":"Visible driver and passengers","level":2,"text":"Create an NPC using `Open77.npcs.create`, then call\n`attachDriver(vehicleId, {npcId=npcId})`. This also requires `world.npcs`.\nThe NPC must belong to this resource, be alive, in the vehicle's bucket and have\nno active tasks. Its normal AI is suspended and its driver mount is replicated.\nRemoval restores its previous AI mode and does **not** delete it.\n\nThe driver seat is reserved even in driverless mode. Passengers can board;\nplayers cannot claim the driver seat, including by force-warp. Call\n`removeDriver` before handing control to a human. AVs and Basilisk are unsupported.\nThe local entry prompt and mount check also honor this reservation, so approaching\nthe driver door offers an available passenger seat instead of hijacking the AI.\nVanilla's automatic front-passenger-to-driver seat switch is suppressed while\nthe driver seat is reserved, including when no NPC is visible.\n`stop()` keeps the reservation; only removing the controller releases it.\n\n`civilian_female_relaxed_01` resolves to `Character.Panam`. Pass an explicit NPC ID to select a visible driver, or omit it for a driverless vehicle.","natives":[],"permissions":[],"events":[]},{"anchor":"commands","heading":"Commands","level":2,"text":"Mutators require the resource that attached the controller. They return a state\nor `nil, reason`; invalid replacement commands leave the current task intact.\n\n| Method | Arguments after vehicleId |\n|---|---|\n| `attachDriver` | Optional `{npcId}` |\n| `driveTo` | `{position={x,y,z}, ...options}` |\n| `followRoute` | `{points={{x,y,z},...}, loop=false, ...options}` |\n| `follow` / `chase` | `{targetKind=\"player\"|\"vehicle\"|\"npc\", targetId=id, distance=8, ...options}` |\n| `joinTraffic` | Optional options; indefinite native road-following |\n| `setSpeed` | Number, 0.5..55 m/s |\n| `setBehavior` | `\"normal\"`, `\"cautious\"` or `\"aggressive\"` |\n| `stop` | Keeps the seat/controller; idempotent |\n| `removeDriver` | Releases the seat/controller |\n| `state` | Read-only; nil when unattached |\n\nOptions: `speed` (default 12), `behavior`, `arrivalRadius` (1..20 m, default 4),\n`timeoutMilliseconds` (1000..3600000, default 300000). Routes contain 1..64 points.\nFollow distance is 3..80 m. Coordinates must be finite and within ±16000.\n\nNormal uses native avoidance; cautious applies 75% of the speed ceiling;\naggressive enables native `clearTrafficOnPath`. These presets do not implement\npolice logic, weapons or guaranteed traffic-law compliance. Follow/chase use\nnative autonomous pathing to server-updated target coordinates, at most twice\nper second. No teleport catch-up or scripted ramming. `joinTraffic` uses\nindefinite road-following, not ambient population spawning.","natives":[],"permissions":[],"events":[]},{"anchor":"state-and-events","heading":"State and events","level":2,"text":"IDs `vehicleId`, `npcId`, `taskId` and `owner` are strings. Other fields:\n`revision`, `epoch`, `mode`, `status`, `reason`, `speed`, `behavior`,\ndestination `x/y/z`, `routeIndex`, `routeCount`, `targetKind`, `targetId`,\nand `distance` (native stopping/follow distance, **not** road-route distance).\n\nStatuses: idle, waiting_for_simulator, running, arrived, cancelled, failed,\ndetached. `ai.on(event, handler)` delivers a decoded state only to the controller's\nresource. Events: `waypointReached`, `arrived`, `cancelled`, `failed`,\n`blocked`, `resumed`. Query state initially; events are not replayed.\n\nBlocked/no_progress means less than 2 m movement over 8 seconds, not proof of a\ncollision. It may be a traffic wait or invalid path. Failures include timeout,\nunavailable/cross-bucket target, unavailable driver, destroyed vehicle and native\ncommand failure. Losing a simulator does not permit stale-epoch motion; a\nreplacement must acknowledge readiness. Human-driven authority is unchanged.","natives":[],"permissions":[],"events":[]},{"anchor":"route-integration","heading":"Route integration","level":2,"text":"Choose destinations on reachable road surfaces and wait for vehicle creation and mounting before issuing a task. Handle `blocked`, `failed` and `waiting_for_simulator` states; a nearby point may not have a valid road path.","natives":[],"permissions":[],"events":[]}]},{"slug":"vehicle-paint","file":"vehicle-paint.md","title":"Authoritative vehicle paint","summary":"Set vehicle paint through the server's canonical state. The selected color is replicated to current viewers, late joiners and clients that stream the vehicle back in.","sections":[{"anchor":"authoritative-vehicle-paint","heading":"Authoritative vehicle paint","level":1,"text":"Set vehicle paint through the server's canonical state. The selected color is replicated to current viewers, late joiners and clients that stream the vehicle back in.","natives":[],"permissions":[],"events":[]},{"anchor":"permissions-and-runtime-split","heading":"Permissions and runtime split","level":2,"text":"Server mutations require `world.vehicles`:\n\n```lua\npermissions { \"world.vehicles\" }\n```\n\nClient reads require `vehicles.read`:\n\n```lua\npermissions { \"vehicles.read\" }\n```\n\nOnly the server can change canonical paint. The client API is deliberately read-only.\n\n| Runtime | Function | Purpose |\n|---|---|---|\n| Server | `Open77.vehicles.setPaint(id, paint)` | Apply primary and secondary RGB colors. |\n| Server | `Open77.vehicles.getPaint(id)` | Read canonical paint. |\n| Server | `Open77.vehicles.resetPaint(id)` | Restore the vehicle record's authored paint. |\n| Server | `Open77.vehicles.setLightsHue(id, hue?)` | Tint the car's own lights; `nil` restores the record's colour. |\n| Server | `Open77.vehicles.setHeadlightColor(id, color?)` | The same tint from an RGB colour. |\n| Server | `Open77.vehicles.setAppearance(id, name?)` | Swap the appearance variant. Also needs `world.vehicles.appearance`. |\n| Client | `Open77.vehicles.getPaint(id)` | Read paint for one currently streamed vehicle. |\n\nVehicle operations are cross-resource. Any server resource granted `world.vehicles` can paint a\ncanonical vehicle created by another resource; the creator name is not an authorization boundary.","natives":[],"permissions":["world.vehicles","vehicles.read"],"events":[]},{"anchor":"color-formats","heading":"Color formats","level":2,"text":"Every color accepts one of these equivalent forms:\n\n```lua\n\"#00D8FF\"\n{ r = 0, g = 216, b = 255 }\n{ 0, 216, 255 }\n```\n\nChannels are integers from `0` through `255`. Supplying only `primary` mirrors it to `secondary`.\nSupplying only `secondary` preserves the current primary color. Custom paint has a separate\n`applied` flag, so `{ r = 0, g = 0, b = 0 }` is valid black paint and never means reset.","natives":[],"permissions":[],"events":[]},{"anchor":"apply-inspect-and-reset","heading":"Apply, inspect, and reset","level":2,"text":"```lua\nlocal ok = Open77.vehicles.setPaint(vehicleId, {\n primary = \"#00D8FF\",\n secondary = { r = 12, g = 24, b = 36 },\n})\nassert(ok, \"unknown canonical vehicle\")\n\nlocal paint = assert(Open77.vehicles.getPaint(vehicleId))\nprint(paint.applied) -- true\nprint(paint.primary.r) -- 0\nprint(paint.secondary.b) -- 36\n\nassert(Open77.vehicles.resetPaint(vehicleId))\nassert(Open77.vehicles.getPaint(vehicleId).applied == false)\n```\n\nThe compatibility keys `primaryColor` and `secondaryColor` are also accepted by `setPaint`.\nFor ordinary code, prefer the shorter `primary` and `secondary` names.","natives":["server:Open77.vehicles.setPaint","Open77.vehicles.getPaint","server:Open77.vehicles.getPaint","server:Open77.vehicles.resetPaint"],"permissions":[],"events":[]},{"anchor":"initial-paint-during-creation","heading":"Initial paint during creation","level":2,"text":"Paint can be included in the atomic vehicle definition:\n\n```lua\nlocal vehicleId = assert(Open77.vehicles.create({\n record = \"Vehicle.v_standard2_archer_hella_player\",\n position = { x = -1671.2, y = -710.3, z = 49.9 },\n yaw = 90.0,\n paint = {\n primary = \"#101820\",\n secondary = \"#00D8FF\",\n },\n}))\n```\n\nTop-level `primaryColor` and `secondaryColor` remain supported. Supplying either form sets the\ncanonical `Open77.vehicles.flags.paintApplied` bit automatically.","natives":["server:Open77.vehicles.create"],"permissions":[],"events":[]},{"anchor":"events","heading":"Events","level":2,"text":"Server resources receive the canonical vehicle ID and revision as strings:\n\n```lua\nAddEventHandler(\"onVehiclePaintChanged\", function(id, revision)\n local paint = Open77.vehicles.getPaint(tonumber(id))\nend)\n```\n\nClient resources receive the same transition after replicated state changes:\n\n```lua\nAddEventHandler(\"open77:vehiclePaintChanged\", function(id, revision)\n local paint = Open77.vehicles.getPaint(id)\nend)\n```\n\nThe full vehicle snapshot also exposes `paintApplied`, `primaryColor`, `secondaryColor`, and the\nnested `paint = { applied, primary, secondary, lightsHue }` view.","natives":["AddEventHandler","server:AddEventHandler","Open77.vehicles.getPaint","server:Open77.vehicles.getPaint"],"permissions":[],"events":["open77:vehiclePaintChanged"]},{"anchor":"the-third-channel-the-lights-hue","heading":"The third channel: the lights hue","level":2,"text":"The engine struct behind all of this, `GenericTemplatePersistentData`, carries **three** things,\nnot two: primary RGB, secondary RGB and a lights **hue**. The hue tints the vehicle's own lights,\nit is a single float in `0..1` rather than a colour, and it is written to the engine in the SAME\nevent as the two body colours.\n\nTwo consequences follow from that shared event, and both are visible in the API:\n\n- the hue lives inside `paint` in a property table, and `getPaint`/`getProperties` report it there;\n- it is **not** governed by `paintApplied`. A car can wear a tinted headlight over its stock body\n paint, and `resetPaint` clears both because it resets the whole template.\n\n`nil` means the record's own colour rather than zero, because zero is a real hue -- red -- and the\nengine struct carries its own `lightsColorDefined` boolean for exactly that reason.\n\n```lua\nOpen77.vehicles.setLightsHue(vehicleId, 0.55)\nOpen77.vehicles.setHeadlightColor(vehicleId, \"#00D8FF\") -- converted to a hue; only the hue survives\nOpen77.vehicles.clearLightsHue(vehicleId)\n```\n\n**This is the closest build 2.31 has to `SetVehicleNeonLights` or `SetVehicleHeadlightsColour`, and\nit is not the same thing.** There is no underglow and no xenon palette in this game. See\n[Network vehicles](vehicles.md) for the full list of FiveM vehicle properties with no counterpart\nhere, and for the `getProperties` / `setProperties` round trip that carries all of this at once.","natives":["server:Open77.vehicles.setLightsHue","server:Open77.vehicles.setHeadlightColor","server:Open77.vehicles.clearLightsHue"],"permissions":[],"events":[]},{"anchor":"native-presentation-and-limitations","heading":"Native presentation and limitations","level":2,"text":"Open77 applies paint through the vehicle's native visual-customization component and retries after\nattachment when that component is not ready on the first frame. The stock CrystalCoat/TWINTONE\ninteraction and popup are disabled during an authenticated Open77 session so a player cannot keep a\nprivate local override that disagrees with the server.\n\nArbitrary RGB display still depends on the selected REDengine vehicle record exposing a compatible\ngeneric paint component. Unsupported records retain their authored appearance, while the canonical\nserver state stays intact and can still be read by scripts.\n\nSee [Network vehicles](vehicles.md) for lifecycle, streaming, seats, damage, and the complete Lua\nsurface.","natives":[],"permissions":[],"events":[]}]},{"slug":"vehicle-seat-switching","file":"vehicle-seat-switching.md","title":"Changing seats inside a vehicle","summary":"Seat switching is an asynchronous operation on an already-seated player. The server keeps the source occupied and reserves the destination while the native in-cabin animation runs. A confirmed change moves the occupant atomically; cancellation or timeout releases the destination and retains the source. If the animation is already underway, the body finishes that movement and plays the return to it","sections":[{"anchor":"changing-seats-inside-a-vehicle","heading":"Changing seats inside a vehicle","level":1,"text":"Seat switching is an asynchronous operation on an already-seated player. The\nserver keeps the source occupied and reserves the destination while the native\nin-cabin animation runs. A confirmed change moves the occupant atomically;\ncancellation or timeout releases the destination and retains the source.\nIf the animation is already underway, the body finishes that movement and plays\nthe return to its original seat. Cancellation is not an instant visual stop.\n\nThis API uses **protocol 1.44**. Client and server must both support that\nprotocol. It is separate from boarding (`taskPlayerEnterVehicle`) and instant\nplacement (`warpPlayerIntoVehicle`). A successful request means accepted or\nqueued, not that the player has reached the destination.","natives":[],"permissions":[],"events":[]},{"anchor":"seat-names","heading":"Seat names","level":2,"text":"| Seat | Name | Numeric alias |\n|---|---|---|\n| Driver | `driver` / `seat_front_left` | `-1` |\n| Front passenger | `frontPassenger` / `seat_front_right` | `0` |\n| Rear left | `rearLeft` / `seat_back_left` | `1` |\n| Rear right | `rearRight` / `seat_back_right` | `2` |\n\nThe destination must exist in the actual car and be free. Bikes, AVs and custom\nworkspots without a supported seat-switch path are refused locally; a request\nissued by the server is cancelled if the client's native check cannot support it. A request never\nevicts another occupant. The cabin's boarding lock does not prevent an internal\nmove; the player's `exitLocked` policy does.\n\nCross-row moves use the native destination-aware seat transition. They do not\nadd a bespoke climbing animation for every cabin layout.","natives":[],"permissions":[],"events":[]},{"anchor":"choose-the-right-operation","heading":"Choose the right operation","level":2,"text":"| Player's situation | Operation |\n|---|---|\n| On foot, entering a car | Server `taskPlayerEnterVehicle(playerId, vehicleId, seat)`. |\n| Instant placement in a car | Server `warpPlayerIntoVehicle(playerId, vehicleId, seat)`. |\n| Already seated, moving inside the same car | Server `switchPlayerSeat(playerId, seat)` or client `switchSeat(seat)`. |\n| Front passenger should stay put when the driver leaves | Disable automatic seat switching for that player. |\n\nSeat switching keeps the same vehicle and replicated body. Other players see\nthe in-cabin animation. The source remains occupied while the destination is\nreserved; another player cannot claim either seat during the move. Use the\ncompletion event to update gameplay state, rather than a timer or the request's\nreturn value. Unconfirmed transitions time out after eight seconds.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest","heading":"Manifest","level":2,"text":"For a resource using both client and server examples:\n\n```lua\n-- open77.lua\nresource 'my_seat_controls'\nversion '1.0.0'\nauto_start true\nclient_script 'client.lua'\nserver_script 'server.lua'\npermissions { 'world.vehicles', 'vehicles.read', 'vehicles.control' }\n```\n\nA server-only resource needs `world.vehicles`. Client state queries need\n`vehicles.read`; client requests and policy changes need `vehicles.control`. These\nexamples do not need network events or a WebUI. Add the corresponding\npermissions if your own menu sends additional network messages or creates a page.","natives":[],"permissions":["world.vehicles","vehicles.read","vehicles.control"],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"Requires `world.vehicles` in the resource manifest. These functions are all under\n`Open77.vehicles`.\n\n| Function | Result |\n|---|---|\n| `switchPlayerSeat(playerId, seat)` | `true, transitionId` if reserved; `false, reason` if refused. |\n| `getPlayerSeatSwitch(playerId)` | Active transition table, `nil` when idle, or `false, reason` on refusal. |\n| `cancelPlayerSeatSwitch(playerId, transitionId)` | `true` or `false, reason`. A stale ID cannot cancel a later move. |\n| `setPlayerAutoSeatSwitchEnabled(playerId, enabled)` | Adds/removes this resource's veto of automatic driver takeover. |\n| `isPlayerAutoSeatSwitchEnabled(playerId)` | Effective policy across all resources. |\n\nTransition tables contain `id`, `playerId`, `vehicleId`, `fromSeat`, `toSeat`,\nand `automatic`. Keep `id` as a string. Existing occupant snapshots also expose\n`switching`, `switchId`, `switchSeat`, and `autoSwitchEnabled`; seat availability\nincludes the reserved destination.\n\n| Server transition field | Type / meaning |\n|---|---|\n| `id` | Opaque string token; pass it unchanged to cancellation. |\n| `playerId`, `vehicleId` | Numeric IDs for the player and current vehicle. |\n| `fromSeat`, `toSeat` | Canonical `seat_*` strings. |\n| `automatic` | Boolean: native automatic takeover rather than an explicit request. |\n\nDisabling automatic takeover does not disable explicit requests. Vetoes belong\nto the resource: enabling from another resource cannot remove them, and stopping\ntheir owner releases them. An explicit move into the front passenger seat can\nbe followed by vanilla automatic takeover if the driver's seat is empty and\nautomatic takeover remains enabled.\n\n```lua\n-- server.lua — keep passengers in their chosen seat\nAddEventHandler('onPlayerReady', function(playerId)\n Open77.vehicles.setPlayerAutoSeatSwitchEnabled(tonumber(playerId), false)\nend)\n\nRegisterCommand('seat', function(playerId, args)\n if playerId == 0 then return end\n local ok, idOrReason = Open77.vehicles.switchPlayerSeat(\n playerId, args[1] or 'frontPassenger')\n if not ok then print('Seat change refused: ' .. idOrReason) end\nend, false)\n\nAddEventHandler('onPlayerVehicleSeatSwitchCompleted',\n function(playerId, vehicleId, transitionId, fromSeat, toSeat, reason)\n print(('Player %s reached %s'):format(playerId, toSeat))\nend)\n```\n\nApply the policy to already connected players too if your resource starts\nmid-session; `onPlayerReady` is not replayed for them. You can also apply it\nwhen your gameplay assigns a passenger seat.","natives":["AddEventHandler","server:AddEventHandler","server:Open77.vehicles.setPlayerAutoSeatSwitchEnabled","RegisterCommand","server:RegisterCommand","server:Open77.vehicles.switchPlayerSeat"],"permissions":[],"events":[]},{"anchor":"cancel-a-server-request","heading":"Cancel a server request","level":3,"text":"```lua\nRegisterCommand('seatcancel', function(playerId)\n if playerId == 0 then return end\n local transition, reason = Open77.vehicles.getPlayerSeatSwitch(playerId)\n if transition == false then print(reason); return end\n if not transition then return end\n local ok, err = Open77.vehicles.cancelPlayerSeatSwitch(playerId, transition.id)\n if not ok then print(err) end\nend, false)\n```\n\nKeep tokens as strings; converting a 64-bit token to a floating-point number\ncan lose precision. A successful cancellation retains the original seat in the\nserver ledger. A body already moving can finish its outbound animation and\nanimate back before its visual position matches that seat again.","natives":["RegisterCommand","server:RegisterCommand","server:Open77.vehicles.getPlayerSeatSwitch","server:Open77.vehicles.cancelPlayerSeatSwitch"],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"Client mutations target only the local player and still require server admission.\nReads require `vehicles.read`; mutations require `vehicles.control`.\n\n| Function under `Open77.vehicles` | Result |\n|---|---|\n| `switchSeat(seat)` | `true` when submitted; otherwise `false, reason`. |\n| `cancelSeatSwitch(transitionId)` | `true` when cancellation is submitted; otherwise `false, reason`. |\n| `seatSwitchState()` | `{ active, autoSwitchEnabled, vehicleId?, fromSeat?, id?, toSeat? }`; `nil, reason` when unavailable. |\n| `setAutoSeatSwitchEnabled(enabled)` | `true`, or `false, reason`; adds/removes this client resource's automatic takeover veto. |\n| `isAutoSeatSwitchEnabled()` | Effective local and server policy; `nil, reason` when unavailable. |\n\nThe server's veto always wins over a client enable. A client resource's veto\ndoes not prevent an explicit server-authorized move. On resource stop, only that\nresource's policy is released.\n\n| Client state field | Presence / meaning |\n|---|---|\n| `active` | Always present; an admitted seat transition is in progress. |\n| `autoSwitchEnabled` | Effective client and server automatic takeover policy. |\n| `vehicleId`, `fromSeat` | Present while seated; `vehicleId` is a string. |\n| `id`, `toSeat` | Present while switching; `id` is a string. |\n\nBefore admission, `switchSeat` can return `true` while the state is still idle.\nKeep your menu's pending-request state until the decision event arrives; an\naccepted decision still precedes native completion.\n\n```lua\n-- client.lua — a seat selection menu\nAddEventHandler('open77:vehicleSeatSwitchDecision',\n function(vehicleId, transitionId, seat, status, reason)\n -- status is 'accepted' or 'rejected'; accepted is not completion.\n if status == 'rejected' then print(reason) end\n end)\n\n-- Call this from your menu's action, after registering the handlers.\nlocal function chooseRearRight()\n local ok, reason = Open77.vehicles.switchSeat('rearRight')\n if not ok then print(reason) end\nend\n\nAddEventHandler('open77:vehicleSeatSwitchCompleted',\n function(vehicleId, transitionId, fromSeat, toSeat, reason)\n -- Close your seat menu or update its selected seat here.\nend)\n```\n\nRegister event handlers before issuing the request in an actual menu. Query\n`seatSwitchState()` when the resource starts or the menu opens to recover an\nalready active move:\n\n```lua\nlocal state, reason = Open77.vehicles.seatSwitchState()\nif not state then\n print(reason)\nelseif state.active then\n print(('Moving from %s to %s'):format(state.fromSeat, state.toSeat))\nend\n\n-- A cancel button must use the current admitted transition's token.\nlocal function cancelCurrentSeatMove()\n local current = Open77.vehicles.seatSwitchState()\n if not current or not current.active then return end\n local ok, err = Open77.vehicles.cancelSeatSwitch(current.id)\n if not ok then print(err) end\nend\n```","natives":["AddEventHandler","server:AddEventHandler","Open77.vehicles.switchSeat","Open77.vehicles.seatSwitchState","Open77.vehicles.cancelSeatSwitch"],"permissions":[],"events":["open77:vehicleSeatSwitchDecision","open77:vehicleSeatSwitchCompleted"]},{"anchor":"automatic-passenger-to-driver-takeover","heading":"Automatic passenger-to-driver takeover","level":2,"text":"The native behavior moves the front-right passenger into an empty front-left\ndriver seat when its normal conditions are met, including after the driver\nleaves. It does not move rear passengers automatically and never evicts an\nexisting driver. Automatic moves use the same reservation, animation and\nlifecycle as explicit moves.\n\n```lua\n-- server.lua — for this resource and player\nOpen77.vehicles.setPlayerAutoSeatSwitchEnabled(playerId, false)\n-- Release this resource's veto later:\nOpen77.vehicles.setPlayerAutoSeatSwitchEnabled(playerId, true)\n\n-- client.lua — for this resource's local player\nOpen77.vehicles.setAutoSeatSwitchEnabled(false)\n-- Release this resource's local veto later:\nOpen77.vehicles.setAutoSeatSwitchEnabled(true)\n```\n\nEnabling means removing your own veto, not forcing an immediate switch. Read\n`isPlayerAutoSeatSwitchEnabled(playerId)` for the server policy and\n`isAutoSeatSwitchEnabled()` for the effective local policy. Explicit requests\nremain available when automatic takeover is disabled. Server claims are removed\nwhen their resource stops or the player disconnects; client claims are removed\nwhen their resource stops.","natives":["server:Open77.vehicles.setPlayerAutoSeatSwitchEnabled","Open77.vehicles.setAutoSeatSwitchEnabled"],"permissions":[],"events":[]},{"anchor":"events-and-failures","heading":"Events and failures","level":2,"text":"Server events are `onPlayerVehicleSeatSwitchStarted`,\n`onPlayerVehicleSeatSwitchCompleted`, and `onPlayerVehicleSeatSwitchFailed`.\nEach receives `(playerId, vehicleId, transitionId, fromSeat, toSeat, reason)`.\nArguments follow the vehicle lifecycle convention: IDs and seat names arrive as\nstrings. The platform owns these event names.\n\nClient events are `open77:vehicleSeatSwitchStarted`,\n`open77:vehicleSeatSwitchCompleted`, and `open77:vehicleSeatSwitchFailed`.\nThey concern the local player and receive\n`(vehicleId, transitionId, fromSeat, toSeat, reason)`.\nThe extra `open77:vehicleSeatSwitchDecision` event answers client requests with\n`(vehicleId, transitionId, seat, status, reason)`.\n\n| Event | Payload | Meaning |\n|---|---|---|\n| `onPlayerVehicleSeatSwitchStarted` | `(playerId, vehicleId, transitionId, fromSeat, toSeat, reason)` | Server: source retained, destination reserved. |\n| `onPlayerVehicleSeatSwitchCompleted` | `(playerId, vehicleId, transitionId, fromSeat, toSeat, reason)` | Server: destination committed. |\n| `onPlayerVehicleSeatSwitchFailed` | `(playerId, vehicleId, transitionId, fromSeat, toSeat, reason)` | Server: reservation released; see the failure reason. |\n| `open77:vehicleSeatSwitchDecision` | `(vehicleId, transitionId, seat, status, reason)` | Client: accepted or rejected; no completion guarantee. |\n| `open77:vehicleSeatSwitchStarted` | `(vehicleId, transitionId, fromSeat, toSeat, reason)` | Client: an admitted move has started. |\n| `open77:vehicleSeatSwitchCompleted` | `(vehicleId, transitionId, fromSeat, toSeat, reason)` | Client: the destination assignment is confirmed. |\n| `open77:vehicleSeatSwitchFailed` | `(vehicleId, transitionId, fromSeat, toSeat, reason)` | Client: move failed; visual return may still be running. |\n\nCorrelate callbacks by their transition ID rather\nthan assuming the next callback belongs to the latest button press. A rejected\nrequest may produce a decision without a started transition.\n\nThe server failure event distinguishes cancellation, `timeout`,\n`resource_stopped`, `forced_exit`, `occupant_removed`, `vehicle_removed`,\n`wrong_bucket`, `exit_locked`, and `vehicle_unavailable`. Client lifecycle failures currently\nreport `cancelled` when the canonical reservation disappears without reaching\nits destination, or `vehicle_removed` when the vehicle is removed; consult the\nserver event for the detailed cause.\n\nImmediate refusals include `not_occupant`, `invalid_seat`, `seat_occupied`,\n`exit_locked`, `transition_in_progress`, `auto_switch_disabled`,\n`stale_transition`, and `unsupported_seat_switch`. Client requests may also\nreturn `request_pending` while a previous request is waiting for admission.\n\nPermission errors identify the missing manifest permission. Client reads return\n`nil, reason` when the session or seat-switch backend is unavailable; client\nmutations return `false, reason`. Server reads can return `false, reason` for\ninvalid players or missing permissions, which is distinct from idle `nil`.\n\n```lua\nlocal ok, result = Open77.vehicles.switchPlayerSeat(playerId, 'rearLeft')\nif not ok then\n -- Common refusals: seat_occupied, exit_locked, not_occupant.\n print('Seat request refused: ' .. result)\nend\n-- Do not mark the move completed here, even when ok is true.\n```\n\nExisting `vehicleEntered`/`vehicleLeft` and server equivalents continue to\ndescribe the change of seat. Use the dedicated switch events to distinguish an\ninternal move from leaving the car.\n\nThe optional [`resources/examples/open77_seat_switch`](https://github.com/Open2077/open77-resources/tree/main/examples/open77_seat_switch) resource provides\n`/seats move `, `/seats auto on|off`, `/seats cancel`, and `/seats status`,\nplus `/seats local ` and `/seats localauto on|off` to exercise client calls.","natives":["server:Open77.vehicles.switchPlayerSeat"],"permissions":[],"events":["open77:vehicleSeatSwitchStarted","open77:vehicleSeatSwitchCompleted","open77:vehicleSeatSwitchFailed","open77:vehicleSeatSwitchDecision"]},{"anchor":"compatibility-and-presentation","heading":"Compatibility and presentation","level":2,"text":"Use matching client/server builds supporting protocol 1.44 and the client\nanimation assets. Feature-detect the Lua functions when supporting older\ninstallations. Unsupported native layouts are refused rather than treated as\nsuccessful seat changes. A brief startup pose discontinuity can still occur\non a replicated character; it does not change the server's seat reservation.\n\nSee [network vehicles](vehicles.md) for boarding, exit locks, snapshots and seat\nqueries, and [passenger drive-by](drive-by.md) for in-vehicle combat policy.","natives":[],"permissions":[],"events":[]}]},{"slug":"vehicle-weapons","file":"vehicle-weapons.md","title":"Armed vehicles and weapon Lua API","summary":"Read mounted armament from client Lua for vehicle HUDs and diagnostics. The API distinguishes declared model mounts, attached weapon objects and the driver's selected weapons. Find model IDs in the [spawn catalogue](armed-vehicles.md).","sections":[{"anchor":"armed-vehicles-and-weapon-lua-api","heading":"Armed vehicles and weapon Lua API","level":1,"text":"Read mounted armament from client Lua for vehicle HUDs and diagnostics. The API distinguishes declared model mounts, attached weapon objects and the driver's selected weapons. Find model IDs in the [spawn catalogue](armed-vehicles.md).","natives":[],"permissions":[],"events":[]},{"anchor":"release-and-compatibility","heading":"Release and compatibility","level":2,"text":"Requires client and server **2.31.13+op77.53** or later, with compatible network protocols, on Cyberpunk 2077 **2.31 / build 23100**. The feature was introduced with protocol **1.24**; protocol **1.23** cannot join a **1.24** session.\n\nThis is **experimental** functionality, not a claim that every armed model\nor visual effect is fully validated. Keep [`open77_vehicles`](https://github.com/Open2077/open77-resources/tree/main/system/open77_vehicles) loaded for the shared\nvehicle helpers and diagnostic commands. Native replication runs in the client\nand server binaries; a HUD resource does not need to forward shots itself.","natives":[],"permissions":[],"events":[]},{"anchor":"how-multiplayer-weapon-sync-works","heading":"How multiplayer weapon sync works","level":2,"text":"1. A server resource creates a canonical [network vehicle](vehicles.md). Model\n declarations come from the server's extracted catalogue, never a client-supplied\n list of permitted weapons.\n2. The seated driver with the current vehicle authority lease supplies actual\n mounted-shot events, selection and supported turret inputs. Merely simulating\n the chassis, sitting in a passenger seat, or reading a Lua snapshot does not\n grant weapon authority.\n3. The server checks the driver, seat, authority generation, routing bucket,\n model/slot, sequence, firing cadence and bounded geometry before accepting a\n shot. Accepted projectiles carry flight updates and a terminal impact,\n explosion or expiry; expiry alone is not an explosion.\n4. Observers receive weapon state and presentation events. Their effects do not\n spawn a second damaging projectile. Hit reports identify targets, not damage\n amounts; the server admits and prices damage through its normal combat rules.\n\nSelection and in-flight state can be restored when a vehicle enters a client's\nstream. Completed shots are not replayed as new attacks. Seat exit, ownership\nchanges, destruction and disconnect clear weapon selection or reject stale\ncontroller messages. Already-admitted projectiles may finish after the driver\nexits; getting out is not a way to cancel their consequences.\n\nDo not call native NPC shooting or spawn another damaging attack to make an\nobserver look like it fired. That duplicates gameplay instead of adding a visual\neffect. There is currently **no public Lua mounted-weapon fire/select/ammo setter**.\nUse the read-only API for HUDs; use server-side vehicle and combat policy for\nshared world changes.","natives":[],"permissions":[],"events":[]},{"anchor":"model-specific-armament","heading":"Model-specific armament","level":2,"text":"Examples of model-specific armament. Other appearances can attach a different subset of declared mounts.\n\n| Model | Armament |\n|---|---|\n| `Vehicle.v_militech_basilisk` | Four attached mounts, with cannon, missile and countermeasure projectile streams. |\n| `Vehicle.v_sport1_herrera_outlaw_heist_player` | Two mounted machine guns. |\n| `Vehicle.v_sport2_mizutani_shion_nomad_player_missiles` | Nine declared mounts; attached indices 5/6 are MGs and 8 is a missile launcher. |\n\nRemote and passenger ammunition, heat, reload and lock details are not replicated. Observer weapon deployment and audio may differ from the driver's view; NPC damage and overlapping missile-hit association are experimental. `weapon.active` reports selection, not visible mesh deployment. Display unavailable native data as unavailable rather than estimating it.","natives":[],"permissions":[],"events":[]},{"anchor":"current-damage-policy","heading":"Current damage policy","level":2,"text":"These are **Open77 .53 server balance values**, not extracted native damage\nprices and not a guarantee of final applied damage. Normal team, god-mode,\ninvulnerability and Lua damage arbitration still apply.\n\n| Weapon category | Nominal player HP | Nominal vehicle health fraction |\n|---|---:|---:|\n| Cannon | 70 | 0.20 |\n| Missile | 30 | 0.06 |\n| Countermeasure | 15 | 0.025 |\n| Machine gun | 12 | 0.012 |\n\nDamage requires an admitted projectile terminal and a plausible canonical target in the same bucket. Repeated projectile/target receipts are deduplicated; clients cannot set the damage value. NPC damage remains experimental.\n\nA mounted missile, cannon or exploding round that the server admitted also moves nearby cars and\nknocks down NPCs on every screen through the [`open77_weapons`](https://github.com/Open2077/open77-resources/tree/main/system/open77_weapons) blast relay, which listens to the\nreserved server event `onVehicleWeaponExplosion(playerId, vehicleId, weapon, x, y, z, bucket,\nrecordRadius, projectileId)`. See [the blast relay](weapons-api.md#explosions-on-every-screen-the-blast-relay).","natives":[],"permissions":[],"events":[]},{"anchor":"handheld-guns-on-cars-another-player-drives","heading":"Handheld guns on cars another player drives","level":3,"text":"**Server option, off by default, experimental.** A car's health belongs to the client that\nsimulates it, so a pistol round fired at a car someone else drives does not change it. The server\noption `combat.handheldVehicleDamage` (in `server.jsonc`, default `false`) makes the server apply\nthose hits itself:\n\n```jsonc\n\"combat\": {\n \"handheldDischarges\": true, // default; required\n \"handheldVehicleDamage\": true // default false\n}\n```\n\nEach round must be one the server admitted for the shooter's drawn weapon at that weapon class's\nfire rate (`combat.handheldDischarges`, default `true`), each round damages a given car once, and\nthe price is fixed per class, as a fraction of the car's health: sniper 0.090, precision rifle\n0.065, shotgun 0.060, revolver 0.045, handgun 0.028, assault rifle 0.020, SMG 0.015, machine gun\n0.012. Melee weapons, grenade launchers and grenades are not priced this way. Invulnerable,\ndestroyed and exploded cars refuse, a car the shooter sits in or simulates is never a target, and\nthere is no Lua callback: protect a car with its `invulnerable` state\n(`Open77.vehicles.flags.invulnerable`). An immortal car keeps a small health floor.","natives":[],"permissions":[],"events":[]},{"anchor":"start-with-a-known-model","heading":"Start with a known model","level":2,"text":"Use the [catalogue's spawn examples](armed-vehicles.md#spawn-a-network-vehicle)\nto create a network vehicle from the server console or a resource with\n`world.vehicles`. Creating a vehicle does not seat a player or prove its current\nappearance attaches every declared mount. Enter normally or use the existing\nserver-authoritative seat APIs, then inspect it with `getWeaponState`.\n\nSelection and firing follow the player's vehicle bindings. Do not hard-code weapon-selection keys. Use the exact armed model record: ordinary Outlaw or Shion variants may not carry weapons.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest-and-runtime","heading":"Manifest and runtime","level":2,"text":"```lua\nresource \"vehicle_weapon_hud\"\nversion \"1.0.0\"\nclient_script \"client/main.lua\"\npermissions { \"vehicles.read\" }\n```\n\nAll functions below are **client-side, synchronous, read-only** calls under `Open77.vehicles`.\nThey return data directly, not a Promise. They cannot spawn a vehicle, select/fire an arm,\nchange ammunition or apply damage. Network authority remains on the server.\n\nPass the **canonical network vehicle ID** from `Open77.vehicles.getPlayerSeat().vehicleId`\nor `Open77.vehicles.all()[i].id`. Do not pass the `entity`, `engineEntity`, weapon ID\nor slot ID. Model queries instead take the full `Vehicle.*` record string.","natives":[],"permissions":["vehicles.read"],"events":[]},{"anchor":"function-reference","heading":"Function reference","level":2,"text":"The searchable [client vehicle API reference](/docs/api/client/open77-vehicles)\ncontains the individual signatures and examples. There are no server versions\nof these twelve armament getters.\n\n| Function | Arguments | Result |\n|---|---|---|\n| `Open77.vehicles.getWeaponModel` | `record` | Model table, or nil, reason. Reads the weapon mounts declared by a vehicle model. |\n| `Open77.vehicles.modelHasWeaponMounts` | `record` | boolean, or nil, reason. Checks whether a vehicle model declares any weapon mounts. |\n| `Open77.vehicles.getWeaponState` | `vehicleId` | Snapshot table, or nil, reason. Reads one coherent mounted-weapon snapshot. |\n| `Open77.vehicles.isArmed` | `vehicleId` | boolean, or nil, reason. Checks whether a streamed vehicle actually has attached weapons. |\n| `Open77.vehicles.getWeapons` | `vehicleId` | Array of weapon tables, or nil, reason. Lists the weapon objects actually attached to a streamed vehicle. |\n| `Open77.vehicles.getActiveWeapons` | `vehicleId` | Array of weapon tables, or nil, reason. Lists the attached weapons selected by the driver. |\n| `Open77.vehicles.getWeapon` | `vehicleId, mountIndex` | Weapon table, or nil, reason. Reads one weapon mount by its stable one-based index. |\n| `Open77.vehicles.getWeaponAmmo` | `vehicleId, mountIndex` | Ammo table, or nil, reason. Reads available native ammunition counters for a mounted weapon. |\n| `Open77.vehicles.getWeaponType` | `vehicleId, mountIndex` | Category string, or nil, reason. Reads a mounted weapon's stable category. |\n| `Open77.vehicles.isWeaponActive` | `vehicleId, mountIndex` | boolean, or nil, reason. Checks whether the driver selected a particular attached mount. |\n| `Open77.vehicles.getWeaponCount` | `vehicleId` | integer, or nil, reason. Counts weapon objects attached to a streamed vehicle. |\n| `Open77.vehicles.getWeaponAim` | `vehicleId` | Aim table, or nil, reason. Reads the Basilisk's captured or replicated turret inputs. |\n\nFailures return `nil, reason`, including boolean queries. Test `value == nil` when\nyou need to distinguish an error from a valid `false`.","natives":[],"permissions":[],"events":[]},{"anchor":"model-information","heading":"Model information","level":2,"text":"```lua\nlocal model, reason = Open77.vehicles.getWeaponModel(\n \"Vehicle.v_militech_basilisk\")\nif not model then print(reason); return end\n\nprint(model.hasWeaponMounts, model.declaredCount)\nfor _, mount in ipairs(model.mounts) do\n print(mount.index, mount.record, mount.slot, mount.type)\nend\n```\n\nA model result contains `record`, `hasWeaponMounts`, `declaredCount` and `mounts`.\nMount entries include the static identification and timing fields described below;\nthey have no attached object or live ammo in a model-only query.\n\n**A declared mount is not proof of an equipped gun.** Some game records share a list of\nnine possible mounts while their current appearance attaches only three. Use `isArmed(id)`\nor `getWeapons(id)` to inspect a real streamed vehicle. A valid unarmed model returns\n`hasWeaponMounts=false`; an invalid/unknown model returns an error.","natives":["Open77.vehicles.getWeaponModel"],"permissions":[],"events":[]},{"anchor":"vehicle-snapshot","heading":"Vehicle snapshot","level":2,"text":"`getWeaponState(vehicleId)` returns:\n\n| Field | Meaning |\n|---|---|\n| `vehicleId`, `record` | Canonical vehicle ID and model record. |\n| `streamed` | The native vehicle is attached in this client's world. |\n| `localDriver` | This client currently captures the settled, leased driver. |\n| `hasWeaponMounts`, `declaredCount` | Static model declarations. |\n| `armed`, `attachedCount` | Whether actual weapons are attached and how many. Omitted if the vehicle is not streamed. |\n| `selectionKnown` | Whether native-local or received driver selection is available. |\n| `activeMask`, `activeCount` | Selected mount bits and count of attached selected weapons. Omitted when selection is unknown. |\n| `mounts` | All declared mounts, in stable model order, including unattached ones. |\n| `aim` | Optional Basilisk turret input table: `yaw`, `pitch`, `source`. |\n\nReads of the same live vehicle share a short cache (at most **100 ms**). Polling at 10 Hz\nis enough for HUD values; repeatedly calling every getter on every rendered frame does\nnot provide more precise data. Tables are copies, so changing them in Lua changes nothing in the game.\n\nThe `activeMask` uses bit `index - 1`. Prefer `weapon.active` to manual bit operations.\nSelection means **selected by the driver**, not “currently firing”, “has ammunition” or\n“the remote mesh has finished its deployment animation”.","natives":[],"permissions":[],"events":[]},{"anchor":"weapon-fields-and-indices","heading":"Weapon fields and indices","level":2,"text":"`getWeapons` and `getActiveWeapons` return dense arrays, but **their array positions are\nnot mount identifiers**. Always pass the returned `weapon.index` to single-weapon getters:\n\n```lua\nlocal weapons, reason = Open77.vehicles.getWeapons(vehicleId)\nif weapons then\n for _, weapon in ipairs(weapons) do\n local ammo = Open77.vehicles.getWeaponAmmo(vehicleId, weapon.index)\n print(weapon.index, weapon.type, ammo and ammo.source)\n end\nend\n```\n\n| Field | Meaning |\n|---|---|\n| `index` | One-based position in the model's declared mount list. Stable within this model; not across models. |\n| `weaponId`, `slotId` | Numeric 40-bit TweakDB IDs. Two mounts may share one weapon item. |\n| `record`, `slot` | Known `Items.*` and `AttachmentSlots.*` names; empty for an unknown extension, while IDs remain usable. |\n| `type` | `machine_gun`, `cannon`, `missile`, `countermeasure` or `unknown`. |\n| `attached` | The matching weapon object currently exists in this slot. |\n| `active` | This attached mount is selected; omitted if selection is unknown. |\n| `cycleSeconds` | Model's native firing-cycle parameter, not a remaining cooldown timer. |\n| `burstProjectiles` | Declared number of projectiles per native burst; not an ammo count. |\n| `ammo` | Availability/source and optional native counters, described below. |\n| `overheat`, `overheated` | Optional raw native `OverheatPercentage` and forced-overheat-cooldown flag. |\n| `reloadPending` | Optional native pending-reload flag; not a progress timer. |\n| `targetLocked` | Optional native target-lock flag; not a target's network identity. |\n| `triggerMode`, `itemType` | Optional native trigger-mode and item-type record names. Separate from the stable `type` category. |\n\nAmmo/heat/trigger details are read from the **local driver's** actual weapon. They are not\ncurrently transmitted in the weapon-state packet. A passenger or observer must not display\nthe untouched counters of its own visual weapon object as the driver's remaining ammunition.","natives":["Open77.vehicles.getWeapons","Open77.vehicles.getWeaponAmmo"],"permissions":[],"events":[]},{"anchor":"ammunition-availability","heading":"Ammunition availability","level":2,"text":"`getWeaponAmmo(id, index)` returns an object even when counters are unavailable:\n\n| Field | Meaning |\n|---|---|\n| `available` | At least one valid native magazine/total counter was read for the local driver. |\n| `source` | `native_local`, `not_replicated`, `not_attached` or `unavailable`. |\n| `magazine` | Optional rounds currently in the native magazine. Zero is a valid reading. |\n| `capacity` | Optional native magazine capacity. |\n| `total` | Optional native total-ammunition count. |\n| `reserve` | `total - magazine`, only when both exist and total is not smaller than magazine. |\n\nThese are **native counters, not a server inventory ledger**. Some mounted weapons use a\nheat/cooldown cycle instead of conventional finite magazines. A zero or missing counter must\nnot be interpreted as proof that firing is disabled, and this API does not fabricate an\n“infinite ammunition” flag.\n\nIn the tested Basilisk and Shion mounted weapons, `total` reports `2147483647`\n(`Int32` maximum), while magazines still change after firing. Treat this as a native\nspecial-case counter, not as a player carrying two billion spare rounds. Prefer\nthe magazine/capacity and heat fields for those HUDs.\n\n```lua\nlocal function ammoLabel(ammo)\n if not ammo or not ammo.available then return \"--\" end\n if ammo.magazine ~= nil and ammo.capacity ~= nil then\n return (\"%d / %d\"):format(ammo.magazine, ammo.capacity)\n end\n return ammo.total ~= nil and tostring(ammo.total) or \"--\"\nend\n\nCreateThread(function()\n while true do\n local seat = Open77.vehicles.getPlayerSeat()\n if seat then\n local weapons = Open77.vehicles.getActiveWeapons(seat.vehicleId)\n for _, weapon in ipairs(weapons or {}) do\n -- Replace print with your HUD update, preferably only on changes.\n print(weapon.type .. \" · \" .. ammoLabel(weapon.ammo))\n end\n end\n Wait(100)\n end\nend)\n```","natives":["CreateThread","server:CreateThread","Open77.vehicles.getPlayerSeat","server:Open77.vehicles.getPlayerSeat","Open77.vehicles.getActiveWeapons","Wait","server:Wait"],"permissions":[],"events":[]},{"anchor":"turret-aim","heading":"Turret aim","level":2,"text":"`getWeaponAim(id)` exposes the Basilisk's named cannon graph inputs in **degrees relative\nto the chassis**, not world coordinates or a muzzle ray. `source` is `native_local`\nfor the capturing driver or `replicated` for a received state. Unsupported models and\nunknown state return `nil, \"weapon_aim_unavailable\"`.","natives":[],"permissions":[],"events":[]},{"anchor":"errors-and-streaming","heading":"Errors and streaming","level":2,"text":"| Reason | Action |\n|---|---|\n| `permission_denied:vehicles.read` | Declare the read permission in the resource manifest. |\n| `vehicle_weapons_unavailable` | This host/client does not provide the backend; use a compatible client build. |\n| `invalid_vehicle_record`, `unknown_vehicle_record` | Supply a real `Vehicle.*` model, not an item ID. |\n| `invalid_vehicle_id`, `unknown_vehicle` | Use a currently known canonical network vehicle ID. |\n| `vehicle_not_streamed` | Wait for the native projection to attach; do not treat this as an unarmed vehicle. |\n| `invalid_weapon_index` | Use a one-based integer from `weapon.index` for this model. |\n| `weapon_state_unavailable` | Driver selection has not arrived; retry later. |\n| `weapon_aim_unavailable` | No supported/current turret-angle source exists. |\n\nA streamed but unarmed vehicle returns `false` from `isArmed`, zero from\n`getWeaponCount`, and an empty array from `getWeapons`. A vehicle unknown after a\nstream-out returns `unknown_vehicle`; a known pending projection can still provide its\nmodel through `getWeaponState` with `streamed=false`.","natives":[],"permissions":[],"events":[]},{"anchor":"diagnostics-and-compatibility","heading":"Diagnostics and compatibility","level":2,"text":"With `open77_vehicles` loaded, the read-only event\n`open77:vehicles:weapons:probe` logs up to 32 known vehicles using these public Lua APIs.\nIt reports mount/selection counts and the ammo source, allowing a HUD bug to be separated\nfrom missing native data.\nThe server console or an authorized administrator can request it with\n`vehicle.weapons.probe `. This uses the ordinary restricted command/event\npath; it does not require enabling local debug mutations during an active session.\n\nUse `type(Open77.vehicles.getWeaponState) == \"function\"` for feature detection when\nsupporting older clients. These calls belong to the client runtime; they do not add\nserver exports or authorize client-side firing, ammo mutation or damage.","natives":[],"permissions":[],"events":["open77:vehicles:weapons:probe"]},{"anchor":"troubleshooting","heading":"Troubleshooting","level":2,"text":"| Symptom | Check |\n|---|---|\n| Vehicle has declared mounts but no guns | Inspect `streamed`, `attachedCount` and the exact model/appearance. The catalogue is not an equipment guarantee. |\n| A passenger's ammo reads unavailable | Expected: `source=\"not_replicated\"`. Show `--`; do not use an observer's native magazine as the driver's ammo. |\n| Selection is missing immediately after streaming | Wait for `selectionKnown`; distinguish unknown state from a valid unselected weapon. |\n| The wrong weapon is inspected | Pass `weapon.index`, not the position in the dense result array. Shion indices 5/6/8 are not 1/2/3. |\n| A shot has no applied damage | Check current seat/lease, bucket, target protection and accepted terminal/hit evidence. A visible effect is not proof of admitted damage. |\n| Client cannot join after updating | Confirm both peers use protocol 1.24 and a matching release before investigating the model. |","natives":[],"permissions":[],"events":[]},{"anchor":"sources","heading":"Sources","level":2,"text":"","natives":[],"permissions":[],"events":[]}]},{"slug":"vehicles","file":"vehicles.md","title":"Network vehicles","summary":"Create and manage network vehicles on the server. Resources with `world.vehicles` control canonical state and lifecycle; clients render nearby projections.","sections":[{"anchor":"network-vehicles","heading":"Network vehicles","level":1,"text":"Create and manage network vehicles on the server. Resources with `world.vehicles` control canonical state and lifecycle; clients render nearby projections.\n\n[Passenger drive-by](drive-by.md) documents handheld window combat, automatic\nwindow synchronization, and Lua controls for enabling, restricting and reading\nthe action on the client and server (Unstable `.117`, protocol 1.42).\n\nFind weapon-equipped model IDs in the [armed vehicle spawn catalogue](armed-vehicles.md), including mount declarations and special-purpose variants.\n\nFor weapon replication, release compatibility, damage policy, current limitations and the twelve client-side armament, ammo, heat and selection queries, see the dedicated [armed vehicles and weapon Lua API guide](vehicle-weapons.md).\n\n[Changing seats](vehicle-seat-switching.md) covers animated in-cabin moves,\nautomatic driver takeover, cancellation and Lua lifecycle events.","natives":[],"permissions":[],"events":[]},{"anchor":"manifest","heading":"Manifest","level":2,"text":"```lua\npermissions { \"world.vehicles\", \"vehicles.read\", \"vehicles.presentation\", \"vehicles.control\" }\n```\n\n`world.vehicles` is a server permission. `vehicles.read` exposes the read-only client projection.\n`vehicles.presentation` is optional and permits a client resource to select the visual entry path\nfor an occupant whose exact player, vehicle, and seat were already validated by the server.\n`vehicles.control` permits locally owned horn control and server-admitted seat\nrequests for the local player. It does not grant authority over another player's vehicle.","natives":[],"permissions":["world.vehicles","vehicles.read","vehicles.presentation","vehicles.control"],"events":[]},{"anchor":"complete-lua-api-inventory","heading":"Complete Lua API inventory","level":2,"text":"The vehicle surface is intentionally asymmetric:\n\n| Runtime | Surface | Mutation |\n|---|---|---|\n| Server | `Open77.vehicles.*` | Authoritative lifecycle, seats, lock state, horn routing, damage, detached parts, openings, paint, and transform. |\n| Client | `Open77.vehicles.*` | State reads, locally owned horn control, local seat-switch requests and policy, and guarded remote-occupant presentation. |\n| Client package | `open77_vehicles` exports | Read-only compatibility wrappers. |\n| Server low level | FiveM-style globals | Raw implementation surface; prefer `Open77.vehicles.*`. |\n\nThere is deliberately no client API for breaking or repairing glass, tyres, lights, bodywork, or\ndetached panels. Only the current physics owner may publish native destructive state; other clients\nare protected render projections. Scripted mutation uses the authoritative server methods below.","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-lifecycle-and-state-methods","heading":"All server lifecycle and state methods","level":3,"text":"Every method in this table requires `world.vehicles`.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.create` | `(definition)` | Vehicle ID, or `nil, reason`. |\n| `Open77.vehicles.update` | `(id, patch)` | `boolean`; replaces supplied canonical fields. |\n| `Open77.vehicles.get` | `(id)` | Server snapshot or `nil`. |\n| `Open77.vehicles.all` | `(bucket?)` | Array of server snapshots, ordered by ID. |\n| `Open77.vehicles.remove` | `(id)` | `boolean`; removes any canonical vehicle. |\n| `Open77.vehicles.setTransform` | `(id, transform)` | `boolean`; position plus yaw, revoking any active physics lease. |\n| `Open77.vehicles.getDamage` | `(id)` | `{ body, glass, lights, tires, detachedParts }`, or `nil`. |\n| `Open77.vehicles.setDamage` | `(id, damage)` | `boolean`; combined damage update. |\n| `Open77.vehicles.repair` | `(id, scope?)` | `boolean`; scope is `glass`, `body`, `lights`, `tires`, `visual`, `mechanical`, or `full`. |\n| `Open77.vehicles.setPaint` | `(id, paint)` | `boolean`; publishes canonical primary/secondary paint. |\n| `Open77.vehicles.getPaint` | `(id)` | `{ applied, primary, secondary }`, or `nil`. |\n| `Open77.vehicles.resetPaint` | `(id)` | `boolean`; restores the model's original paint. |\n| `Open77.vehicles.getProperties` | `(id)` | The whole property table a garage stores, or `nil, reason`. Alias: `getVehicleProperties`. |\n| `Open77.vehicles.setProperties` | `(id, props, options?)` | Applies one. Refuses a FiveM property with no counterpart **by name**. Aliases: `setVehicleProperties`, `applyProperties`. |\n| `Open77.vehicles.unsupportedProperties` | *(table)* | Every such property name, mapped to why there is nothing to map it to. |\n| `Open77.vehicles.getAppearance` | `(id)` | The entity appearance variant, or `\"\"` for the record's default. |\n| `Open77.vehicles.setAppearance` | `(id, name?)` | Swaps the variant. Also needs `world.vehicles.appearance` to CHANGE one. |\n| `Open77.vehicles.getLightsHue` | `(id)` | CrystalCoat lights hue `0..1`, or `nil`. Alias: `getHeadlightHue`. |\n| `Open77.vehicles.setLightsHue` | `(id, hue?)` | Tints the car's own lights; `nil` restores the record's colour. |\n| `Open77.vehicles.clearLightsHue` | `(id)` | `setLightsHue(id, nil)`. |\n| `Open77.vehicles.setHeadlightColor` | `(id, color?)` | The same tint from an RGB colour. Alias: `setHeadlightColour`. |\n| `Open77.vehicles.setLocked` | `(id, locked)` | `boolean`; atomically changes the durable entry lock. |\n| `Open77.vehicles.lock` | `(id)` | Convenience alias for `setLocked(id, true)`. |\n| `Open77.vehicles.unlock` | `(id)` | Convenience alias for `setLocked(id, false)`. |\n| `Open77.vehicles.isLocked` | `(id)` | Canonical entry-lock boolean, or `nil` for an unknown vehicle. |\n| `Open77.vehicles.triggerHorn` | `(id, durationMs?)` | Routes a reliable synchronized horn command to the owner or parked viewers. |\n| `Open77.vehicles.honk` | `(id, durationMs?)` | Alias of `triggerHorn`; default duration is 250 ms. |\n| `Open77.vehicles.registerDamageProfile` | `(record, profile)` | `true`; registers names inside the calling resource VM. |\n| `Open77.vehicles.getDamageProfile` | `(record)` | This resource's profile or `nil`. |\n| `Open77.vehicles.warpPlayerIntoVehicle` | `(playerId, vehicleId, seat, options?)` | Authoritatively assigns and eventually warps a player into any canonical vehicle. |\n| `Open77.vehicles.setPlayerIntoVehicle` | `(playerId, vehicleId, seat, options?)` | Alias of `warpPlayerIntoVehicle`. |\n| `Open77.vehicles.forcePlayerOutOfVehicle` | `(playerId, vehicleId?)` | Forces native exit and retains the seat until confirmation/timeout. |\n| `Open77.vehicles.removePlayerFromVehicle` | `(playerId, vehicleId?)` | Alias of `forcePlayerOutOfVehicle`. |\n| `Open77.vehicles.taskPlayerEnter` | `(playerId, vehicleId, seat, options?)` | Same assignment as `warpPlayerIntoVehicle`, reached through the vanilla entry animation. |\n| `Open77.vehicles.taskPlayerEnterVehicle` | `(playerId, vehicleId, seat, options?)` | Alias of `taskPlayerEnter`. |\n| `Open77.vehicles.taskPlayerLeave` | `(playerId, vehicleId?)` | Same ejection as `forcePlayerOutOfVehicle`; the leaving player's own exit is instant. |\n| `Open77.vehicles.taskPlayerLeaveVehicle` | `(playerId, vehicleId?)` | Alias of `taskPlayerLeave`. |\n| `Open77.vehicles.adopt` | `(playerId, description)` | Turns a client's description of a vanilla car into a canonical vehicle. |\n| `Open77.vehicles.setAdoptPolicy` | `(policy, options?)` | Opens a routing bucket to adoption: `none`, `driver` or `any`. |\n| `Open77.vehicles.getAdoptPolicy` | `(bucket?)` | Reads one bucket's adoption policy. |\n| `Open77.vehicles.setPlayerExitLocked` | `(playerId, locked, vehicleId?)` | Sets the durable no-exit policy. |\n| `Open77.vehicles.getPlayerSeat` | `(playerId)` | Canonical server seat assignment or `nil`. |\n| `Open77.vehicles.isPlayerExitLocked` | `(playerId)` | Whether the canonical assignment is exit-locked. |","natives":[],"permissions":[],"events":[]},{"anchor":"server-seat-switch-controls","heading":"Server seat-switch controls","level":3,"text":"These methods require `world.vehicles`.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.switchPlayerSeat` | `(playerId, seat)` | `true, transitionId` on reservation, otherwise `false, reason`; animated move within the current car. |\n| `Open77.vehicles.getPlayerSeatSwitch` | `(playerId)` | Active transition, idle `nil`, or `false, reason` on refusal. |\n| `Open77.vehicles.cancelPlayerSeatSwitch` | `(playerId, transitionId)` | Cancels the matching token; retains the original seat. |\n| `Open77.vehicles.setPlayerAutoSeatSwitchEnabled` | `(playerId, enabled)` | Adds or releases this resource's automatic driver-takeover veto. |\n| `Open77.vehicles.isPlayerAutoSeatSwitchEnabled` | `(playerId)` | Effective server policy, or `false, reason` on refusal. |","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-engine-light-and-siren-methods","heading":"All server engine, light and siren methods","level":3,"text":"Every method in this table requires `world.vehicles`. Each writes **one named bit** of the durable\nflags and leaves the rest alone; see [engine, lights and siren](#engine-lights-and-siren) for why\nthat matters and for the ten-second intent window that makes a setter stick.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.setEngine` | `(id, on)` | `true`, or `false, reason`. |\n| `Open77.vehicles.startEngine` | `(id)` | Alias for `setEngine(id, true)`. |\n| `Open77.vehicles.stopEngine` | `(id)` | Alias for `setEngine(id, false)`. |\n| `Open77.vehicles.isEngineOn` | `(id)` | Canonical boolean, or `nil, reason`. |\n| `Open77.vehicles.setDrivable` | `(id, drivable)` | Out of service also cuts the engine, in one revision. |\n| `Open77.vehicles.setUndriveable` | `(id, undriveable)` | Inverted spelling of `setDrivable`. |\n| `Open77.vehicles.isDrivable` | `(id)` | Canonical boolean, or `nil, reason`. |\n| `Open77.vehicles.setFrozen` | `(id, frozen)` | Pins the car where it stands; nothing electrical changes. See [freezing a car](#freezing-a-car). |\n| `Open77.vehicles.isFrozen` | `(id)` | Canonical boolean, or `nil, reason`. |\n| `Open77.vehicles.setLights` | `(id, mode)` | `\"off\"`, `\"on\"`, `\"high\"`, or a boolean. |\n| `Open77.vehicles.setHighBeams` | `(id, on)` | Raises or drops the beams without going dark. |\n| `Open77.vehicles.getLights` | `(id)` | `\"off\"`, `\"on\"` or `\"high\"`, or `nil, reason`. |\n| `Open77.vehicles.areLightsOn` | `(id)` | True for both `\"on\"` and `\"high\"`. |\n| `Open77.vehicles.setSiren` | `(id, on)` | Light bar **and** sound; they cannot be separated. |\n| `Open77.vehicles.isSirenOn` | `(id)` | Canonical boolean, or `nil, reason`. |\n| `Open77.vehicles.setHealth` | `(id, health)` | `0..1`; out of range is refused, not clamped. |\n| `Open77.vehicles.getHealth` | `(id)` | Canonical normalized health, or `nil, reason`. |\n| `Open77.vehicles.setEngineHealth` | `(id, health)` | The same one pool under its FiveM name. |\n| `Open77.vehicles.getEngineHealth` | `(id)` | The same one pool under its FiveM name. |","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-transform-motion-and-policy-methods","heading":"All server transform, motion and policy methods","level":3,"text":"Every method in this table requires `world.vehicles`, and every one of them sees **only\nserver-spawned vehicles** -- see [what `closest` can and cannot\nfind](#what-closest-can-and-cannot-find).\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.getPosition` | `(id)` | `{ x, y, z }`, a vector3, or `nil, reason`. |\n| `Open77.vehicles.getHeading` | `(id)` | Heading in degrees, or `nil, reason`. |\n| `Open77.vehicles.getYaw` | `(id)` | Alias of `getHeading`. |\n| `Open77.vehicles.getSpeed` | `(id)` | Metres per second, or `nil, reason`. |\n| `Open77.vehicles.getSpeedKph` | `(id)` | Kilometres per hour, or `nil, reason`. |\n| `Open77.vehicles.getVelocity` | `(id)` | `{ x, y, z }` in m/s, or `nil, reason`. |\n| `Open77.vehicles.getDriver` | `(id)` | The driving player id, or `nil`. |\n| `Open77.vehicles.owner` | `(id)` | Who simulates the car, in what capacity and since when; see [who owns a car](#who-owns-a-car). |\n| `Open77.vehicles.requestAuthority` | `(id, playerId)` | Asks that one client simulate the car; refused by name, never a steal. |\n| `Open77.vehicles.setPerformance` | `(id, profile)` | `boolean, reason?`; `nil` clears the ceiling. |\n| `Open77.vehicles.clearPerformance` | `(id)` | `boolean, reason?`; rated performance again. |\n| `Open77.vehicles.getPerformance` | `(id)` | `{ topSpeedKph, accelerationScale, taperKph }`, or `nil`. |\n| `Open77.vehicles.explode` | `(id)` | `boolean, reason?`; `already_exploded` the second time. |\n| `Open77.vehicles.setLockedForPlayer` | `(id, playerId, locked)` | `boolean, reason?`; `nil` clears the exception. |\n| `Open77.vehicles.clearLockedForPlayer` | `(id, playerId)` | `boolean, reason?`. |\n| `Open77.vehicles.setLockedForAll` | `(id, locked)` | `boolean, reason?`; revokes every exception. |\n| `Open77.vehicles.lockForAll` | `(id)` | Alias for `setLockedForAll(id, true)`. |\n| `Open77.vehicles.unlockForAll` | `(id)` | Alias for `setLockedForAll(id, false)`. |\n| `Open77.vehicles.isLockedForPlayer` | `(id, playerId)` | `boolean`, or `nil, reason`. |\n| `Open77.vehicles.setTimeToLive` | `(id, ttlMs)` | `boolean, reason?`; `0` or `nil` cancels. |\n| `Open77.vehicles.setTtl` | `(id, ttlMs)` | Alias of `setTimeToLive`. |\n| `Open77.vehicles.setPersistent` | `(id, persistent)` | `boolean, reason?`. |\n| `Open77.vehicles.setDespawnWhenUnobserved` | `(id, despawn)` | `boolean, reason?`. |","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-vehicle-finding-methods","heading":"All server vehicle-finding methods","level":3,"text":"Every method in this table requires `world.vehicles`. **They see only server-spawned vehicles** --\nsee [what `closest` can and cannot find](#what-closest-can-and-cannot-find).\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.nearby` | `(anchor, radius?, options?)` | Array of proximity entries, nearest first. |\n| `Open77.vehicles.closest` | `(anchor, options?)` | One entry, or a bare `nil` when nothing is in range. |\n| `Open77.vehicles.seatFree` | `(id, seat)` | `boolean`, or `nil, reason`. |\n| `Open77.vehicles.occupantInSeat` | `(id, seat)` | `playerId, occupant`, or `nil` when free. |\n| `Open77.vehicles.freeSeats` | `(id)` | Canonical seat names in seat order, or `nil, reason`. |\n| `Open77.vehicles.seatName` | `(seat)` | Canonical spelling of any accepted seat form. |","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-body-damage-methods","heading":"All server body-damage methods","level":3,"text":"Body cells use Lua indexes **1..30** and normalized finite values **0..1**.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.setBodyDamage` | `(id, values)` | Replaces the exact 30-value grid. |\n| `Open77.vehicles.setBodyCell` | `(id, cell, value)` | Sets one normalized cell. |\n| `Open77.vehicles.damageBodyCell` | `(id, cell, amount)` | Adds damage and clamps the result to 0..1. |\n| `Open77.vehicles.repairBodyCell` | `(id, cell)` | Sets one cell to zero. |\n| `Open77.vehicles.setBodyZone` | `(id, zone, value)` | Sets every cell in a named zone or explicit index array. |\n| `Open77.vehicles.damageBodyZone` | `(id, zone, amount)` | Adds and clamps damage across a zone. |\n| `Open77.vehicles.repairBodyZone` | `(id, zone)` | Clears a zone. |\n\n`Open77.vehicles.bodyZones` contains `backLeft`, `back`, `backRight`, `left`, `center`, `right`,\n`frontLeft`, `front`, `frontRight`, `lower`, `roof`, and `all`. A damage profile can override or\nextend the zone map for one vehicle record.","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-glass-light-and-tyre-methods","heading":"All server glass, light, and tyre methods","level":3,"text":"Glass and light indexes are **0..31**. Tyre indexes are **0..3**. Glass ordering comes from the\nspecific vehicle record's `Destruction.Glass` list; it is not equivalent to the four openable\nside-window indexes.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.setGlassMask` | `(id, mask)` | Replaces the 32-bit broken-glass mask. |\n| `Open77.vehicles.setGlassBroken` | `(id, glass, broken)` | Sets one numeric or profile-named glass bit. |\n| `Open77.vehicles.breakGlass` | `(id, glass)` | Sets one glass bit. |\n| `Open77.vehicles.repairGlass` | `(id, glass)` | Clears one glass bit. |\n| `Open77.vehicles.breakAllGlass` | `(id, count?)` | Breaks the first `count` bits; default is all 32. |\n| `Open77.vehicles.repairAllGlass` | `(id)` | Clears the entire glass mask. |\n| `Open77.vehicles.setLightMask` | `(id, mask)` | Replaces the 32-bit broken-light mask. |\n| `Open77.vehicles.setLightBroken` | `(id, index, broken)` | Sets or clears one light bit. |\n| `Open77.vehicles.breakLight` | `(id, index)` | Sets one light bit. |\n| `Open77.vehicles.repairLight` | `(id, index)` | Clears one light bit. |\n| `Open77.vehicles.repairAllLights` | `(id)` | Clears the complete light mask. |\n| `Open77.vehicles.setTireMask` | `(id, mask)` | Replaces the four-bit broken-tyre mask. |\n| `Open77.vehicles.setTireBroken` | `(id, index, broken)` | Sets or clears one tyre bit. |\n| `Open77.vehicles.breakTire` | `(id, index)` | Sets one tyre bit. |\n| `Open77.vehicles.repairTire` | `(id, index)` | Clears one tyre bit. |\n| `Open77.vehicles.repairAllTires` | `(id)` | Clears the complete tyre mask. |","natives":[],"permissions":[],"events":[]},{"anchor":"all-server-door-and-openable-window-methods","heading":"All server door and openable-window methods","level":3,"text":"Doors are a six-bit reversible state. Openable windows are a separate four-bit reversible state;\nneither mask represents broken glass.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.setDoorMask` | `(id, mask)` | Replaces the door/trunk/hood mask; range 0..63. |\n| `Open77.vehicles.setDoorOpen` | `(id, door, opened)` | Sets one opening bit. |\n| `Open77.vehicles.openDoor` | `(id, door)` | Opens one door, trunk, or hood. |\n| `Open77.vehicles.closeDoor` | `(id, door)` | Closes one door, trunk, or hood. |\n| `Open77.vehicles.isDoorOpen` | `(id, door)` | `boolean`, or `nil` for an unknown vehicle. |\n| `Open77.vehicles.setWindowOpen` | `(id, window, opened)` | Sets one side-window opening bit. |\n| `Open77.vehicles.openWindow` | `(id, window)` | Opens one side window. |\n| `Open77.vehicles.closeWindow` | `(id, window)` | Closes one side window. |\n| `Open77.vehicles.isWindowOpen` | `(id, window)` | `boolean`, or `nil` for an unknown vehicle. |","natives":[],"permissions":[],"events":[]},{"anchor":"detached-vehicle-parts","heading":"Detached vehicle parts","level":3,"text":"Detached parts are a separate, monotonic 16-bit damage channel. They are reported by the native\n`VehicleOnPartDetachedEvent`, retained by the server, replayed to streamed observers, and included\nin late join. REDengine 2.31 exposes no proven safe reattachment primitive for a live vehicle, so\nclearing a detached bit is rejected; respawn the vehicle to restore its panels.\n\n| Method | Signature | Return / behavior |\n|---|---|---|\n| `Open77.vehicles.setDetachedPartMask` | `(id, mask)` | Adds a canonical mask in range 0..65535; rejects bit removal. |\n| `Open77.vehicles.setPartDetached` | `(id, part, true)` | Detaches a named/indexed part; `false` is rejected. |\n| `Open77.vehicles.detachPart` | `(id, part)` | Convenience form of `setPartDetached(..., true)`. |\n| `Open77.vehicles.isPartDetached` | `(id, part)` | `boolean`, or `nil` for an unknown vehicle. |\n\n`Open77.vehicles.detachedParts` maps `trunk`, `hood`, `hoodLeft`, `hoodRight`, the front-door\nvariants `doorFrontLeft[A-C]` / `doorFrontRight[A-C]`, `doorBackLeft`, `doorBackRight`,\n`bumperFront`, and `bumperBack` to bit indexes 0..15.\n\nThe remaining `Open77.vehicles.update` fields are `health`, `flags`, `primaryColor`,\n`secondaryColor`, `doors`, `windows`, `tires`, `bodyDamage`, `brokenGlass`, `brokenLights`, and\n`detachedParts`.","natives":[],"permissions":[],"events":[]},{"anchor":"exact-client-methods","heading":"Exact client methods","level":3,"text":"| Method | Permission | Signature | Return / behavior |\n|---|---|---|---|\n| `Open77.vehicles.get` | `vehicles.read` | `(id)` | One streamed client snapshot or `nil`. |\n| `Open77.vehicles.all` | `vehicles.read` | `()` | All currently streamed snapshots; empty when unavailable or denied. |\n| `Open77.vehicles.getPaint` | `vehicles.read` | `(id)` | Read-only `{ applied, primary, secondary }`, or `nil`. |\n| `Open77.vehicles.isLocked` | `vehicles.read` | `(id)` | Reads the replicated durable entry lock, or `nil`. |\n| `Open77.vehicles.triggerHorn` / `honk` | `vehicles.control` | `(id, durationMs?)` | Sounds a streamed vehicle only when this client is its current physics owner. |\n| `Open77.vehicles.getPlayerSeat` | `vehicles.read` | `(playerId?)` | Replicated seat assignment; omitted ID selects the local player. |\n| `Open77.vehicles.isPlayerExitLocked` | `vehicles.read` | `(playerId?)` | Replicated exit-lock state, or `nil` without an assignment. |\n| `Open77.vehicles.isDoorOpen` | `vehicles.read` | `(id, door)` | Reads the canonical six-bit door state. |\n| `Open77.vehicles.isWindowOpen` | `vehicles.read` | `(id, window)` | Reads the canonical four-bit opening state, not broken glass. |\n| `Open77.vehicles.warpPlayerIntoVehicle` | `vehicles.presentation` | `(playerId, vehicleId, seat)` | Instantly presents an already-authorized remote occupant. |\n| `Open77.vehicles.taskPlayerEnterVehicle` | `vehicles.presentation` | `(playerId, vehicleId, seat)` | Guarded staged entry presentation for an already-authorized remote occupant. |\n| `Open77.vehicles.adoptable` | `vehicles.read` | `(options?)` | Every engine-spawned vehicle near the player that the server does not own, nearest first. |\n| `Open77.vehicles.describeAdoptable` | `vehicles.read` | `(engineEntity)` | The same description for one named chassis. |\n| `Open77.vehicles.releaseAdopted` | `vehicles.adopt` | `(engineEntity)` | Hands one engine-spawned chassis back to the population system. |\n| `Open77.vehicles.setPerformance` | `vehicles.performance` | `(id, profile)` | Caps one vehicle's top speed and pickup. |\n| `Open77.vehicles.clearPerformance` | `vehicles.performance` | `(id)` | Removes that vehicle's cap. |\n| `Open77.vehicles.setPerformanceClass` | `vehicles.performance` | `(record, profile)` | Caps every present and future vehicle of one TweakDB record. |\n| `Open77.vehicles.clearPerformanceClass` | `vehicles.performance` | `(record)` | Removes that record's cap. |\n| `Open77.vehicles.clearAllPerformance` | `vehicles.performance` | `()` | Drops every cap, instance and class. |\n| `Open77.vehicles.setControlLock` | `vehicles.performance` | `(id, locked)` | Suppresses or restores acceleration and brake/reverse input for a stationary vehicle. |\n| `Open77.vehicles.setFrozen` | `vehicles.performance` | `(id, frozen)` | Disables or restores the native chassis physics mask without changing ownership or the mounted workspot. |\n| `Open77.vehicles.ratedTopSpeed` | `vehicles.read` | `(id)` | Gearing rating of the vehicle's record in km/h, or `nil`. |\n| `Open77.vehicles.nearby` | `vehicles.read` | `(radius, options?)` | Replicated vehicles within a radius, nearest first. |\n| `Open77.vehicles.closest` | `vehicles.read` | `(radius?)` or `(options?)` | The nearest one, or `nil, \"no_vehicle_in_range\"`. |\n| `Open77.vehicles.aimed` | `vehicles.read` | `()` | The one the local player is looking at, or `nil, reason`. |\n| `Open77.vehicles.fromEntity` | `vehicles.read` | `(entityId)` | Snapshot for an Open77 or REDengine entity id. |\n| `Open77.vehicles.seatFree` | `vehicles.read` | `(id, seat)` | Reads the replicated occupancy ledger. |\n| `Open77.vehicles.occupantInSeat` | `vehicles.read` | `(id, seat)` | Player id in that seat, or `nil` when free. |\n| `Open77.vehicles.switchSeat` | `vehicles.control` | `(seat)` | Submits a local in-cabin move for server admission; `true` means queued. |\n| `Open77.vehicles.cancelSeatSwitch` | `vehicles.control` | `(transitionId)` | Submits cancellation of the current transition token. |\n| `Open77.vehicles.seatSwitchState` | `vehicles.read` | `()` | `{ active, autoSwitchEnabled, vehicleId?, fromSeat?, id?, toSeat? }`, or `nil, reason`. |\n| `Open77.vehicles.setAutoSeatSwitchEnabled` | `vehicles.control` | `(enabled)` | Adds or releases this resource's local automatic takeover veto. |\n| `Open77.vehicles.isAutoSeatSwitchEnabled` | `vehicles.read` | `()` | Effective client and server policy, or `nil, reason`. |\n\nClient constants are `Open77.vehicles.doors`, `Open77.vehicles.windows`, and\n`Open77.vehicles.seats`. They are tables, not callable methods.\n\nVehicle snapshots include the durable fields above, including the derived `locked` boolean, plus live read-only drivetrain and network\ntelemetry. `speed`, `rpm`, `rpmMax`, `throttle`, `brake`, `steering`, `wheelRotation`,\n`suspensionLongitudinal`, `suspensionTransversal`, `burnout`, `gear`, `onGround`, and `reversing`\ndescribe the latest authoritative motion. The derived normalized fields `longitudinalSlip`,\n`lateralSlip`, and `totalSlip` drive remote tire audio and skid effects. Network diagnostics are\n`bufferedSamples`, `interpolationDelayMs`, `packetAgeMs`, and `extrapolating`; they are intended for\ntelemetry and debugging, not gameplay authority.","natives":[],"permissions":[],"events":[]},{"anchor":"official-package-exports","heading":"Official package exports","level":3,"text":"The [`open77_vehicles`](https://github.com/Open2077/open77-resources/tree/main/system/open77_vehicles) client package exposes these read-only wrappers:\n\n| Export | Signature | Result |\n|---|---|---|\n| `get` | `get(id)` | Compatibility wrapper over `Open77.vehicles.get`. |\n| `all` | `all()` | Compatibility wrapper over `Open77.vehicles.all`. |\n| `getPlayerSeat` | `getPlayerSeat(playerId?)` | Compatibility wrapper over `Open77.vehicles.getPlayerSeat`. |\n| `isPlayerExitLocked` | `isPlayerExitLocked(playerId?)` | Compatibility wrapper over `Open77.vehicles.isPlayerExitLocked`. |\n\nCall them with `Open77.exports.call(\"open77_vehicles\", \"get\", id)`; no\n`exports.:` proxy exists. The package intentionally exposes no mutation export.","natives":[],"permissions":[],"events":[]},{"anchor":"low-level-server-globals","heading":"Low-level server globals","level":3,"text":"These globals are public for framework compatibility, but the namespaced API above supplies\nstructured tables, defaults, and helper validation.\n\n| Global | Signature |\n|---|---|\n| `CreateVehicle` | `(record, x, y, z, yaw, bucket, appearance, health, flags, primaryR, primaryG, primaryB, secondaryR, secondaryG, secondaryB)` |\n| `UpdateVehicleState` | `(id, health, flags, primaryR, primaryG, primaryB, secondaryR, secondaryG, secondaryB, doors, windows, tires, bodyDamage30, brokenGlass, brokenLights, detachedParts)` |\n| `SetVehicleTransform` | `(id, x, y, z, yaw)` |\n| `RemoveVehicle` | `(id)` |\n| `GetVehicle` | `(id)` |\n| `GetVehicles` | `(bucket?)` |\n| `SetVehicleLocked` | `(id, locked)` |\n| `TriggerVehicleHorn` | `(id, durationMs?)` |\n| `SetPlayerIntoVehicle` | `(playerId, vehicleId, seat, moveBucket, exitLocked)` |\n| `ForcePlayerOutOfVehicle` | `(playerId, vehicleId?)` |\n| `SetPlayerVehicleExitLocked` | `(playerId, locked, vehicleId?)` |\n| `GetPlayerVehicleSeat` | `(playerId)` |","natives":[],"permissions":[],"events":[]},{"anchor":"server-api","heading":"Server API","level":2,"text":"```lua\nlocal id, reason = Open77.vehicles.create({\n record = \"Vehicle.v_standard2_archer_hella_player\",\n appearance = \"default\",\n position = { x = -1607.4, y = 1268.2, z = 18.1 },\n yaw = 90.0,\n bucket = 0,\n health = 1.0,\n flags = Open77.vehicles.flags.locked,\n primaryColor = { r = 22, g = 105, b = 180 },\n secondaryColor = { r = 8, g = 15, b = 24 },\n})\n```","natives":["server:Open77.vehicles.create"],"permissions":[],"events":[]},{"anchor":"open77vehiclescreatedefinition","heading":"`Open77.vehicles.create(definition)`","level":3,"text":"Creates a generation-checked 64-bit vehicle id. Required fields are `record` and `position`.\nOptional fields are `appearance`, `yaw`, `bucket`, `health`, `flags`, `primaryColor`,\n`secondaryColor`, and `paint = { primary, secondary }`. Returns `id`, or `nil, reason`.","natives":[],"permissions":[],"events":[]},{"anchor":"open77vehiclesupdateid-patch","heading":"`Open77.vehicles.update(id, patch)`","level":3,"text":"Updates durable state. Supported fields are `health`, `flags`, `primaryColor`, `secondaryColor`,\n`doors`, `windows`, `tires`, `bodyDamage`, `brokenGlass`, `brokenLights`, `detachedParts`, and the nested\n`damage = { body, glass, lights, tires, detachedParts }` form. `windows` means opened windows; it is deliberately\nseparate from `brokenGlass`. Door bits are front-left, front-right, back-left, back-right, trunk,\nand hood. Window/tire bits use the first four positions.\n\n```lua\nlocal flags = Open77.vehicles.flags.engineOn | Open77.vehicles.flags.lightsOn\nOpen77.vehicles.update(id, { flags = flags, health = 0.85 })\n```","natives":["server:Open77.vehicles.update"],"permissions":[],"events":[]},{"anchor":"authoritative-paint","heading":"Authoritative paint","level":3,"text":"See the dedicated [vehicle paint guide](vehicle-paint.md) for the runtime split, supported color\nformats, initial paint, events, synchronization semantics, and native model limitations.\n\n```lua\nassert(Open77.vehicles.setPaint(id, {\n primary = \"#000000\",\n secondary = { r = 20, g = 40, b = 60 },\n}))\n\nlocal paint = Open77.vehicles.getPaint(id)\nprint(paint.applied, paint.primary.r, paint.secondary.b)\n\nassert(Open77.vehicles.resetPaint(id))\n```\n\nColors accept `\"#RRGGBB\"`, keyed `{ r, g, b }`, or positional `{ r, g, b }` tables. Supplying only\n`primary` uses the same color for both channels. Paint has a dedicated `paintApplied` state, so pure\nblack is not confused with reset. Current viewers, stream-in projections, and late joiners converge\non the same server state. Paint is a cross-resource control API: a resource holding `world.vehicles`\nmay style a canonical vehicle created by another resource. The same cross-resource rule applies to\nall server vehicle mutations; the creator field is never consulted as an authorization check.\nThe stock CrystalCoat/TWINTONE interaction and popup are suppressed during\nan authenticated Open77 session, so a client cannot keep a private local paint override.\n\nArbitrary RGB display still requires the REDengine vehicle model to expose its generic paint\ncomponent. Unsupported records keep their authored appearance; the client retries after attachment\nwithout turning a model limitation into divergent canonical state.","natives":["server:Open77.vehicles.setPaint","Open77.vehicles.getPaint","server:Open77.vehicles.getPaint","server:Open77.vehicles.resetPaint"],"permissions":[],"events":[]},{"anchor":"vehicle-properties-and-what-cyberpunk-does-not-have","heading":"Vehicle properties, and what Cyberpunk does not have","level":3,"text":"`Open77.vehicles.getProperties(id)` and `setProperties(id, props)` are the Cyberpunk form of the\n`GetVehicleProperties` / `SetVehicleProperties` pair every FiveM framework ships. **The round trip\nis the contract**: `setProperties(id, getProperties(id))` leaves the vehicle observably unchanged,\nand a table stored in a database and applied to a fresh vehicle of the same record reproduces it.\nThe individual setters below are conveniences on top of that.\n\n```lua\n-- Park the car.\nlocal props = Open77.vehicles.getProperties(vehicleId)\nstore(plate, json.encode(props))\n\n-- Fetch it back, tomorrow, on another instance.\nlocal stored = json.decode(load(plate))\nlocal id = Open77.vehicles.create({ record = stored.record, position = spot, yaw = 90.0 })\nlocal ok, reason = Open77.vehicles.setProperties(id, stored)\n```\n\nThe table is plain data — strings, numbers, booleans and tables — so it survives `json.encode`\nand a database column unchanged:\n\n```lua\n{\n version = 1,\n record = \"Vehicle.v_standard2_archer_hella_player\",\n appearance = \"archer_hella__rayfield\", -- \"\" is the record's default\n paint = {\n applied = true,\n primary = { r = 22, g = 105, b = 180 },\n secondary = { r = 8, g = 15, b = 24 },\n lightsHue = 0.55, -- absent when the record's own colour is used\n },\n health = 0.83,\n engineOn = false, lights = \"off\", siren = false,\n locked = true, drivable = true, invulnerable = false, immortal = false,\n doors = 0, windows = 0,\n damage = {\n body = { --[[ 30 cells, 0..1 ]] },\n glass = 0, lights = 0, tires = 0, detachedParts = 0,\n destroyed = false, exploded = false,\n },\n performance = { topSpeedKph = 60.0, accelerationScale = 0.5, taperKph = 10.0 },\n}\n```\n\n**It deliberately does not carry where the car was standing.** Position, heading, velocity,\noccupants, routing bucket and revision are not properties of the car, and a table that carried\nthem would tempt a caller into teleporting by accident. `setTransform` and `setBucket` still own\nthose.","natives":["server:Open77.vehicles.getProperties","server:Open77.vehicles.create","server:Open77.vehicles.setProperties"],"permissions":[],"events":[]},{"anchor":"version-decides-how-absence-is-read","heading":"`version` decides how absence is read","level":4,"text":"This is the only rule you have to remember, and it is the one that makes both halves of the\ncontract work at once.\n\n| Table | Meaning | An absent optional field |\n|---|---|---|\n| carries `version = 1` — every table `getProperties` returns | a **complete** description of a car | means *this car has none*: the lights hue and the performance ceiling are cleared |\n| no `version` | a **patch** | is left exactly as it was |\n\nSo `setProperties(id, { paint = { primary = \"#FF0000\" } })` is a repaint and nothing else, while\n`setProperties(id, storedTable)` restores a car down to the absence of a tint.","natives":[],"permissions":[],"events":[]},{"anchor":"what-cyberpunk-actually-has","heading":"What Cyberpunk actually has","level":4,"text":"Vehicle visual customisation on build 2.31 is one engine component,\n`vehicleVisualCustomizationComponent`, and one struct, `GenericTemplatePersistentData`, whose\nentire content is **primary RGB, secondary RGB and a lights hue**. Beside it sits the entity\n**appearance variant** — a name from the vehicle's own template, which is how the game ships one\nQuadra in several trims. That is the whole vocabulary:\n\n```text\nappearance variant + primary RGB + secondary RGB + lights hue\n```\n\n| Method | Signature | What it does |\n|---|---|---|\n| `Open77.vehicles.getProperties` | `(id)` | The whole table above, or `nil, reason`. Alias: `getVehicleProperties`. |\n| `Open77.vehicles.setProperties` | `(id, props, options?)` | Applies it. Aliases: `setVehicleProperties`, `applyProperties`. |\n| `Open77.vehicles.getAppearance` | `(id)` | The variant name, or `\"\"`. |\n| `Open77.vehicles.setAppearance` | `(id, name?)` | Swaps the variant. `nil` restores the record's default. |\n| `Open77.vehicles.getLightsHue` | `(id)` | `0..1`, or `nil` for the record's own colour. Alias: `getHeadlightHue`. |\n| `Open77.vehicles.setLightsHue` | `(id, hue?)` | Tints the car's own lights. |\n| `Open77.vehicles.clearLightsHue` | `(id)` | Back to the record's colour. |\n| `Open77.vehicles.setHeadlightColor` | `(id, color?)` | The same tint from `\"#RRGGBB\"` or `{ r, g, b }`. Alias: `setHeadlightColour`. |\n| `Open77.vehicles.unsupportedProperties` | *(table)* | Every FiveM property name with no counterpart, mapped to why. |\n\n```lua\nOpen77.vehicles.setAppearance(id, \"archer_hella__rayfield\")\nOpen77.vehicles.setHeadlightColor(id, \"#00D8FF\")\n```\n\n`setAppearance` — and the `appearance` key of `setProperties` — needs `world.vehicles.appearance`\n**in addition to** `world.vehicles`. It is the one vehicle write that changes what the world looks\nlike rather than what a car does: it reaches a native entity-appearance reschedule on every viewer\nat once, for every car a resource can name. The same argument gave adoption its own string.\n**The permission gates a change, never a restatement**: writing the variant a car already wears is\naccepted without it, which is what keeps `setProperties(id, getProperties(id))` working for a\nresource that never touches a look.\n\nThe variant name is not validated by the server, because only the client holds the entity\ntemplate. A name the model does not define is a no-op on each projection and the canonical value\nstays as written. `Open77.data.vehicle(record)` looks a record up but does **not** enumerate\nvariants — the shipped 2.31 catalogue does not carry them.","natives":["server:Open77.vehicles.setAppearance","server:Open77.vehicles.setHeadlightColor"],"permissions":[],"events":[]},{"anchor":"what-cyberpunk-does-not-have","heading":"What Cyberpunk does not have","level":4,"text":"These FiveM property names have **no counterpart in this engine**. Not \"unimplemented\" — absent:\nthe 2.31 RTTI carries no type for any of them. `setProperties` refuses such a key by name rather\nthan dropping it, because a garage that silently loses two thirds of its table is worse than one\nthat fails loudly on day one:\n\n```lua\nlocal ok, reason = Open77.vehicles.setProperties(id, portedTable)\n-- false, \"unsupported_property:modEngine\"\n```\n\n| FiveM property | Why there is nothing to map it to |\n|---|---|\n| `modKit`, `mods`, `modEngine`, `modBrakes`, `modTransmission`, `modSuspension`, `modArmour`, `modTurbo` | There is no mod-slot system at all. A model's look is its appearance variant plus the two CrystalCoat colours; its performance is `setPerformance`. |\n| `wheels`, `wheelType` | No wheel-type system: wheels are meshes inside the appearance variant. |\n| `wheelColor` | No per-wheel colour channel in `GenericTemplatePersistentData`. |\n| `windowTint` | No window tint on 2.31 — the RTTI carries no type for one. |\n| `neonEnabled`, `neonColor` | No underglow. `paint.lightsHue` tints the car's **own** lights instead. |\n| `xenonColor` | No xenon index. `paint.lightsHue` is the continuous equivalent. |\n| `livery`, `roofLivery` | No livery index. The appearance variant is the nearest thing, and it is a **name**, not a number. |\n| `extras` | No numbered extras. Removable bodywork is `damage.detachedParts`, and it is one-way. |\n| `color1`, `color2`, `pearlescentColor`, `interiorColor`, `dashboardColor` | Cyberpunk paint is RGB, not palette indices, and there is one body pair and nothing else. Use `paint.primary` / `paint.secondary`. |\n| `dirtLevel` | No dirt channel. Body wear is the 30-cell `damage.body` grid. |\n| `tyreSmokeColor` | No tyre smoke colour channel. |\n| `plateText`, `plateIndex` | Cyberpunk has no readable or writable plate. Carry it in a [state bag](state-bags.md#recipe-a-number-plate) -- the recipe is on that page. |\n| `fuelLevel` | No fuel model. Fuel is a state-bag field owned by the bundled [`open77_fuel`](state-bags.md#the-fuel-sample-open77_fuel) sample: `exports.open77_fuel:level(id)` reads it, `:set(id, litres)` and `:refuel(id)` write it. |\n\n`Open77.vehicles.unsupportedProperties` is the same list as a table, keyed by property name, so a\nporting script can ask before it writes:\n\n```lua\nfor name in pairs(portedTable) do\n local why = Open77.vehicles.unsupportedProperties[name]\n if why then print((\"dropping %s: %s\"):format(name, why)) end\nend\nlocal ok, _, skipped = Open77.vehicles.setProperties(id, portedTable, { ignoreUnsupported = true })\n```","natives":["server:Open77.vehicles.setProperties"],"permissions":[],"events":[]},{"anchor":"refusals-and-the-order-of-application","heading":"Refusals, and the order of application","level":4,"text":"`record` is checked, never applied: REDengine cannot change a spawned entity's record, so a Quadra\ntable handed to a Thorton answers `record_mismatch` rather than half-restoring. Detached parts are\none-way on a live projection — there is no validated reattach on 2.31 — so a table asking for fewer\nthan the car has already lost answers `detached_parts_not_reattachable`; respawn and apply the\ntable to the new car.\n\nThe order inside `setProperties` is deliberate and worth knowing if you write your own:\n\n1. health, paint, doors, windows and the durable damage go in **one** `update`, so no viewer ever\n sees a half-restored car;\n2. the electrical bits then go through `setEngine`, `setLights`, `setSiren` and `setDrivable` —\n because those are what record the server intent that stops the physics owner's next durable\n report from undoing them. Writing them as raw flags looks like it worked and then quietly\n reverts about half a second later;\n3. the visual template and the performance ceiling follow on their own durable channels.\n\nThe appearance and the lights hue need **no** such intent window, and that is structural rather\nthan lucky: the physics owner's durable report has no appearance field and no colour field of any\nkind, so the merge that undid the sirens cannot reach either value. On the client the variant goes\nthrough `entEntity::ScheduleAppearanceChange` and is held on the replica until the projection\nattaches — measured at up to about 3.7 s after the create — then re-applied if the entity is torn\ndown and streamed back.","natives":[],"permissions":[],"events":[]},{"anchor":"entry-lock-and-synchronized-horn","heading":"Entry lock and synchronized horn","level":3,"text":"Use the dedicated lock methods instead of reading, changing, and replacing the complete flags mask:\n\n```lua\n-- Server: durable, authoritative entry policy.\nassert(Open77.vehicles.lock(vehicleId))\nassert(Open77.vehicles.isLocked(vehicleId) == true)\nassert(Open77.vehicles.unlock(vehicleId))\n\n-- Server: delivered reliably to the client currently simulating the vehicle.\nlocal ok, reason = Open77.vehicles.triggerHorn(vehicleId, 400)\nassert(ok, reason)\n```\n\n`setLocked(id, locked)`, `lock(id)`, and `unlock(id)` update only the `locked` bit. The server sends\nthe resulting canonical state to current viewers and includes it in stream-in and late-join state.\nOn each client, the replicated bit synchronously removes every mount choice from the vehicle and is\nchecked again at the mounting boundary, so stale interaction UI cannot start an animation that the\nserver would later reject. Ordinary entry and seat claims are rejected while locked. An explicit server\n`warpPlayerIntoVehicle` remains an administrative/gamemode override and may bypass the entry lock.\nThis vehicle entry lock is separate from `setPlayerExitLocked`, which keeps one existing occupant\ninside, and from the client-only `setControlLock`/`setFrozen` grid controls.\n\n`triggerHorn(id, durationMs?)` and its `honk` alias accept 100..2000 ms and default to 250 ms. A\nserver call is a reliable one-shot command bound to the vehicle's current authority epoch. With a\nphysics owner, only that owner injects the native horn event and the normal motion stream mirrors\nthe edge to observers. An unoccupied parked vehicle intentionally has no physics lease; in that\ncase the server addresses every current streamed projection directly instead of rejecting the\nrequest or manufacturing a bystander owner. The event is transient and is not replayed to a late\njoiner.\n\nA client resource with `vehicles.control` may call the same `triggerHorn`/`honk` API directly, but\nonly for a streamed vehicle whose snapshot has `locallyOwned == true`. Remote attempts fail with\n`not_physics_owner`; use the server API when gameplay code needs to sound an arbitrary canonical\nvehicle. `Open77.vehicles.isLocked(id)` is available client-side with `vehicles.read`.\n\nAvailable flags:\n\n| Constant | Value | Constant | Value |\n|---|---:|---|---:|\n| `Open77.vehicles.flags.engineOn` | `1` | `Open77.vehicles.flags.locked` | `2` |\n| `Open77.vehicles.flags.destroyed` | `4` | `Open77.vehicles.flags.exploded` | `8` |\n| `Open77.vehicles.flags.invulnerable` | `16` | `Open77.vehicles.flags.immortal` | `32` |\n| `Open77.vehicles.flags.lightsOn` | `64` | `Open77.vehicles.flags.highBeams` | `128` |\n| `Open77.vehicles.flags.sirenOn` | `256` | `Open77.vehicles.flags.paintApplied` | `512` |\n\nCombine flags with Lua 5.4 bitwise operators (`|`, `&`, `~`). Never replace the complete mask when\nyou only intend to toggle one bit without first reading the current canonical value.","natives":["server:Open77.vehicles.lock","Open77.vehicles.isLocked","server:Open77.vehicles.isLocked","server:Open77.vehicles.unlock","Open77.vehicles.triggerHorn","server:Open77.vehicles.triggerHorn"],"permissions":[],"events":[]},{"anchor":"doors-trunk-hood-and-windows","heading":"Doors, trunk, hood, and windows","level":3,"text":"Openings have named, persistent server APIs. They are replicated to current viewers and included\nin stream-in/late-join state. Live changes use the vehicle's native animation; the initial streamed\nstate is applied immediately so an already-open trunk does not visibly replay from closed.\n\n```lua\nOpen77.vehicles.openDoor(id, \"trunk\")\nOpen77.vehicles.closeDoor(id, \"hood\")\nOpen77.vehicles.setDoorOpen(id, Open77.vehicles.doors.frontRight, true)\n\nif Open77.vehicles.isDoorOpen(id, \"trunk\") then\n -- Server-side inventory logic can now expose the trunk contents.\nend\n\nOpen77.vehicles.openWindow(id, \"frontLeft\")\nOpen77.vehicles.closeWindow(id, Open77.vehicles.windows.frontLeft)\n```\n\nDoor names are `frontLeft`, `frontRight`, `backLeft`, `backRight`, `trunk`, and `hood`.\nSnake-case cabin aliases are also accepted. Window names are the first four door names. Low-level\n`setDoorMask(id, mask)` and `update(id, { doors = mask, windows = mask })` remain available for\nframeworks that already store bitfields.\n\n| Index / bit | Door | Openable window |\n|---:|---|---|\n| `0` | `frontLeft` / `front_left` | `frontLeft` |\n| `1` | `frontRight` / `front_right` | `frontRight` |\n| `2` | `backLeft` / `back_left` | `backLeft` |\n| `3` | `backRight` / `back_right` | `backRight` |\n| `4` | `trunk` | — |\n| `5` | `hood` | — |\n\nNatural player interactions are observed too: opening or closing a trunk/hood in the world updates\nthe canonical server state. A reversible opening report is accepted only from the current driver or\na streamed player within 15 metres; a distant client cannot toggle another vehicle.","natives":["server:Open77.vehicles.openDoor","server:Open77.vehicles.closeDoor","server:Open77.vehicles.setDoorOpen","Open77.vehicles.isDoorOpen","server:Open77.vehicles.isDoorOpen","server:Open77.vehicles.openWindow","server:Open77.vehicles.closeWindow"],"permissions":[],"events":[]},{"anchor":"damage-and-repair-api","heading":"Damage and repair API","level":3,"text":"Damage is canonical server state and is replayed to current viewers, stream-in clients, and late\njoiners. The client observation channel can only add damage. Only the server resource owning the\nvehicle can repair it.\n\n```lua\n-- Glass indices are zero-based indices into this model's Destruction.Glass list.\nOpen77.vehicles.breakGlass(id, 0)\nOpen77.vehicles.repairGlass(id, 0)\nOpen77.vehicles.breakAllGlass(id) -- all 32 mask bits\nOpen77.vehicles.breakAllGlass(id, 6) -- first six glass records\nOpen77.vehicles.repairAllGlass(id)\n\nOpen77.vehicles.setTireBroken(id, 0, true)\nOpen77.vehicles.repairTire(id, 0)\nOpen77.vehicles.setLightBroken(id, 2, true)\nOpen77.vehicles.repairLight(id, 2)\n\nOpen77.vehicles.damageBodyCell(id, 13, 0.25) -- cells are Lua indices 1..30\nOpen77.vehicles.damageBodyZone(id, \"front\", 0.40)\nOpen77.vehicles.repairBodyZone(id, \"front\")\nOpen77.vehicles.repair(id, \"visual\")\nOpen77.vehicles.repair(id, \"full\")\n```\n\nAvailable body zones are `backLeft`, `back`, `backRight`, `left`, `center`, `right`,\n`frontLeft`, `front`, `frontRight`, `lower`, `roof`, and `all`. The low-level API remains exposed\nfor custom damage systems:\n\n```lua\nlocal damage = Open77.vehicles.getDamage(id)\ndamage.body[14] = 0.9\ndamage.glass = damage.glass | (1 << 3)\nOpen77.vehicles.setDamage(id, damage)\n\nOpen77.vehicles.setBodyDamage(id, thirtyNormalizedValues)\nOpen77.vehicles.setGlassMask(id, 0x15)\nOpen77.vehicles.setLightMask(id, 0x02)\nOpen77.vehicles.setTireMask(id, 0x05)\n```\n\nGlass ordering is record-specific. A resource can register readable names instead of spreading\nnumeric indices throughout gameplay code:\n\n```lua\nOpen77.vehicles.registerDamageProfile(\"Vehicle.v_standard2_archer_hella_player\", {\n glass = { windshield = 0, rearWindow = 1, frontLeft = 2, frontRight = 3 },\n bodyZones = { engineBay = { 13, 14, 15 } },\n})\n\nOpen77.vehicles.breakGlass(id, \"windshield\")\nOpen77.vehicles.damageBodyZone(id, \"engineBay\", 0.5)\n```\n\nOpen77 intentionally does not ship guessed glass names: the `Destruction.Glass` order differs by\nvehicle record. Numeric glass/light indices are `0..31`, tyre indices are `0..3`, and all body\nvalues are finite normalized values in `0..1`.","natives":["server:Open77.vehicles.breakGlass","server:Open77.vehicles.repairGlass","server:Open77.vehicles.breakAllGlass","server:Open77.vehicles.repairAllGlass","server:Open77.vehicles.setTireBroken","server:Open77.vehicles.repairTire","server:Open77.vehicles.setLightBroken","server:Open77.vehicles.repairLight","server:Open77.vehicles.damageBodyCell","server:Open77.vehicles.damageBodyZone","server:Open77.vehicles.repairBodyZone","server:Open77.vehicles.repair","server:Open77.vehicles.getDamage","server:Open77.vehicles.setDamage","server:Open77.vehicles.setBodyDamage","server:Open77.vehicles.setGlassMask","server:Open77.vehicles.setLightMask","server:Open77.vehicles.setTireMask","server:Open77.vehicles.registerDamageProfile"],"permissions":[],"events":[]},{"anchor":"other-server-calls","heading":"Other server calls","level":3,"text":"```lua\nOpen77.vehicles.setTransform(id, { x = 10, y = 20, z = 30, yaw = 180 })\nOpen77.vehicles.get(id)\nOpen77.vehicles.all()\nOpen77.vehicles.all(bucket)\nOpen77.vehicles.remove(id)\n```\n\n`setTransform` is server-authoritative: it revokes an active physics lease, advances the\nauthority epoch, and publishes the complete canonical transform to every current viewer.\n\n`get` and `all` also return the canonical seat ledger:\n\n```lua\nlocal vehicle = Open77.vehicles.get(id)\nfor _, occupant in ipairs(vehicle.occupants) do\n print(occupant.playerId, occupant.seat)\nend\n```\n\nSeat names are `seat_front_left`, `seat_front_right`, `seat_back_left`, and\n`seat_back_right`. `seat_front_left` is the only driver seat. Ordinary player entry still comes\nfrom native mount detection and server validation; an owning server resource can also write the\nledger through the forced-seat API below.","natives":["server:Open77.vehicles.setTransform","Open77.vehicles.get","server:Open77.vehicles.get","Open77.vehicles.all","server:Open77.vehicles.all","server:Open77.vehicles.remove"],"permissions":[],"events":[]},{"anchor":"authoritative-player-seats","heading":"Authoritative player seats","level":3,"text":"Use the server version of `warpPlayerIntoVehicle` when a gamemode must put a player directly into\na vehicle rather than waiting for a local enter interaction:\n\n```lua\nlocal ok, reason = Open77.vehicles.warpPlayerIntoVehicle(\n playerId,\n vehicleId,\n Open77.vehicles.seats.driver,\n {\n moveBucket = true, -- default: move the player into the vehicle bucket\n exitLocked = true, -- optional: block manual exit until the server releases it\n })\nassert(ok, reason)\n```\n\nAccepted seats are the FiveM-compatible numbers `-1` (driver), `0` (front passenger), `1`\n(rear-left), and `2` (rear-right), the values in `Open77.vehicles.seats`, or these names:\n`driver`, `frontPassenger`, `rearLeft`, `rearRight`, `frontLeft`, `frontRight`, `backLeft`,\n`backRight`, and the canonical `seat_*` strings.\n\nThe assignment is durable network state, not a one-shot client event. The server reserves the\nseat first and carries `forcedEntry` in every occupancy snapshot until the affected client confirms\nthe native mount. If the vehicle is not streamed yet, streams after a routing-bucket move, streams\nout and back in, or its local projection is replaced, the client retries against the current\ngeneration-checked vehicle entity. Occupancy pins the target vehicle into that player's interest\nset during convergence, so a stale or temporarily missing player-position snapshot cannot strand\nthe instruction.\n\nForced assignment intentionally bypasses distance and the vehicle's `locked` flag. It still\nrejects unknown players, destroyed/exploded vehicles, and occupied seats. Seat operations are\ndeliberately cross-resource: any server resource granted `world.vehicles` may assign, eject, or\nlock a player in any canonical vehicle, including one created by another resource. By default the\nplayer moves into the vehicle routing bucket; pass\n`{ moveBucket = false }` to fail with `wrong_bucket` instead. Dead and pending revive/respawn\nplayers are rejected as `player_unavailable`.\n\n```lua\n-- Unlock later without changing the seat.\nassert(Open77.vehicles.setPlayerExitLocked(playerId, false, vehicleId))\n\n-- Or force an exit even while the exit lock is enabled.\nassert(Open77.vehicles.forcePlayerOutOfVehicle(playerId, vehicleId))\n```","natives":["Open77.vehicles.warpPlayerIntoVehicle","server:Open77.vehicles.warpPlayerIntoVehicle","server:Open77.vehicles.setPlayerExitLocked","server:Open77.vehicles.forcePlayerOutOfVehicle"],"permissions":[],"events":[]},{"anchor":"entering-with-the-vanilla-animation","heading":"Entering with the vanilla animation","level":4,"text":"`Open77.vehicles.taskPlayerEnter` is `warpPlayerIntoVehicle` with one bit changed. Everything\nauthoritative is identical -- the seat is reserved in the same write, the bucket moves the same\nway, and every refusal reason is the warp's, verbatim. What changes is only how the affected\nclient reaches the seat: instead of being placed in it in one frame, it mounts through the\nmounting facility with its own pose preserved and plays the authored `OccupantSlots` entry, so the\nbody slides in.\n\n```lua\nlocal ok, reason = Open77.vehicles.taskPlayerEnter(\n playerId, vehicleId, Open77.vehicles.seats.driver,\n { moveBucket = true, exitLocked = false })\nassert(ok, reason)\n\n-- The player-oriented alias, next to warpIntoVehicle / forceOutOfVehicle:\nOpen77.players.taskEnterVehicle(playerId, vehicleId, \"driver\")\n```\n\nThree things this is **not**, due to engine limits:\n\n* **It does not walk the player to the door.** The approach is an NPC behaviour\n (`ApproachVehicleDecorator`), and the single attempt to drive an Open77 network puppet through\n it crashed the *observing* client about three seconds after the vehicle streamed. The player is\n mounted where they stand. The upside is that the obvious failure of a walk-to-door task -- a\n player stuck walking toward a car that has driven off -- cannot happen here.\n* **It cannot strand anybody.** The animation is an attempt in front of the warp, never instead of\n it. If the staged mount cannot be started, or if the native mounting relation is not readable\n within three seconds, the client warps and the seat is reached anyway. `warpPlayerIntoVehicle`\n remains the call that never depends on an animation at all.\n* **There is no animated exit.** `Open77.vehicles.taskPlayerLeave` exists, and it is the same\n durable ejection as `forcePlayerOutOfVehicle`: **the leaving player's own exit is instant.**\n Driving that player's authored exit workspot inside the mount release window faulted on the\n engine's release-dispatch thread and the workspot never started once in any session, so the\n client does not attempt it. Observers are unaffected -- a remote occupant's exit has always been\n animated on their proxy, so only the ejected player's own screen skips it.\n\nCancelling a task in flight is `forcePlayerOutOfVehicle` (or `taskPlayerLeave`): a forced exit\noverrides a pending forced entry, and the client's animated attempt ends with it. Nothing else is\nneeded, because the attempt is bounded rather than open-ended.\n\n`Open77.vehicles.get(id).occupants[i].animatedEntry`, and the same field on the client's vehicle\nread, says whether a pending forced entry carries the modifier. It is a read for every client but\nthe one being seated.\n\nWhile `exitLocked` is active, the local vanilla unmount callback is rejected and reconciliation\nremounts divergent state. A manual seat switch is rejected too because REDengine begins it by\nunmounting the current workspot. `forcePlayerOutOfVehicle` overrides that policy and keeps the\ncanonical seat reserved until native unmount confirmation; an eight-second transition timeout\ncleans up a disconnected or non-cooperating client. Passing the optional `vehicleId` to exit/lock\noperations is recommended: it prevents stale gamemode code from modifying a newer assignment.\n\nRead the same assignment through either namespace:\n\n```lua\nlocal seat = Open77.vehicles.getPlayerSeat(playerId)\n-- Equivalent player-oriented alias:\nseat = Open77.players.getVehicleSeat(playerId)\n\nif seat then\n print(seat.vehicleId, seat.seat, seat.exitLocked)\nend\n```\n\nThe seat table contains `playerId`, `vehicleId`, `seat`, `flags`, `entering`, `exiting`,\n`forcedEntry`, `exitLocked`, and `forcedExit`. `Open77.vehicles.isPlayerExitLocked(playerId)` is a\nboolean convenience query. Server aliases are `Open77.players.warpIntoVehicle`,\n`forceOutOfVehicle`, and `setVehicleExitLocked`.\n\nNormal failures return `false, reason`. Stable reasons are `permission_denied:world.vehicles`,\n`vehicles_unavailable`, `invalid_player_id`, `invalid_vehicle_id`, `invalid_seat`,\n`invalid_argument`, `player_not_found`, `player_unavailable`, `vehicle_not_found`, `wrong_bucket`,\n`seat_occupied`, `not_occupant`, `vehicle_unavailable`, `exit_locked`, and\n`seat_operation_failed`.","natives":["server:Open77.vehicles.taskPlayerEnter","server:Open77.players.taskEnterVehicle","Open77.vehicles.getPlayerSeat","server:Open77.vehicles.getPlayerSeat","Open77.players.getVehicleSeat","server:Open77.players.getVehicleSeat"],"permissions":[],"events":[]},{"anchor":"animated-changes-inside-a-car","heading":"Animated changes inside a car","level":3,"text":"Use `switchPlayerSeat` on the server or `switchSeat` on the client when the\nplayer is already seated. The destination is reserved while the source stays\noccupied, and the in-cabin animation is replicated to other players. These APIs\nrequire matching protocol 1.44 client/server builds and their animation assets.\n\n```lua\n-- server.lua; permission: world.vehicles\nlocal ok, tokenOrReason = Open77.vehicles.switchPlayerSeat(playerId, 'rearRight')\nif not ok then print(tokenOrReason) end\n\n-- Keep the front passenger in their seat when the driver leaves.\nOpen77.vehicles.setPlayerAutoSeatSwitchEnabled(playerId, false)\n```\n\n`true` accepts or queues a request; it does not mean the move is finished.\nServer events are `onPlayerVehicleSeatSwitchStarted`,\n`onPlayerVehicleSeatSwitchCompleted` and `onPlayerVehicleSeatSwitchFailed`, with\n`(playerId, vehicleId, transitionId, fromSeat, toSeat, reason)`.\nClient equivalents use `open77:vehicleSeatSwitchStarted`,\n`open77:vehicleSeatSwitchCompleted` and `open77:vehicleSeatSwitchFailed`, with\n`(vehicleId, transitionId, fromSeat, toSeat, reason)`. The extra client\n`open77:vehicleSeatSwitchDecision` receives\n`(vehicleId, transitionId, seat, status, reason)` after a request.\n\nUse the [seat-switching guide](vehicle-seat-switching.md) for all ten functions,\nseat aliases, state fields, cancellation, automatic takeover, event payloads,\nresource-owned policy and error handling. It includes server commands and\nclient menu examples. Automatic takeover can be disabled without disabling\nexplicit seat requests.","natives":["server:Open77.vehicles.switchPlayerSeat","server:Open77.vehicles.setPlayerAutoSeatSwitchEnabled"],"permissions":[],"events":["open77:vehicleSeatSwitchStarted","open77:vehicleSeatSwitchCompleted","open77:vehicleSeatSwitchFailed","open77:vehicleSeatSwitchDecision"]},{"anchor":"server-snapshot-fields","heading":"Server snapshot fields","level":3,"text":"`Open77.vehicles.get` and each entry returned by `all` contain:\n\n| Group | Fields |\n|---|---|\n| Identity | `id`, `resource`, `record`, `appearance`, `revision` |\n| World | `bucket`, `x`, `y`, `z`, `position` |\n| Transform | `heading`, `yaw`, `orientation = { x, y, z, w }` |\n| Motion | `velocity`, `angularVelocity`, `speed`, `speedKph`, `onGround`, `reversing`, `moving` |\n| Authority | `physicsOwner`, `authorityEpoch` |\n| Durable state | `health`, `flags`, `locked`, `exploded`, `destroyed`, `paintApplied`, `primaryColor`, `secondaryColor`, `paint`, `doors`, `windows`, `tires`, `brokenGlass`, `brokenLights`, `detachedParts` |\n| Paint | `primaryR`, `primaryG`, `primaryB`, `secondaryR`, `secondaryG`, `secondaryB` |\n| Body | `bodyDamage[1..30]` |\n| Damage view | `damage = { body, glass, lights, tires, detachedParts }` |\n| Lifetime | `persistent`, `despawnWhenUnobserved`, `ttlMs` (absent when there is no deadline) |\n| Performance | `performance = { topSpeedKph, accelerationScale, taperKph }` (absent when uncapped) |\n| Seats | `occupants[] = { playerId, seat, flags, entering, exiting, forcedEntry, exitLocked, forcedExit, switching, switchId, switchSeat, autoSwitchEnabled }`, plus `driverPlayerId` |\n\n`x`, `y` and `z` are unchanged; `position` is the same point as a plain `{ x, y, z }` table, which\nis what an Open77 vector3 is, so `#(a.position - b.position)` works on it without a conversion.\n`heading` and `yaw` are one value under both names -- `heading` is what the player read and\n`respawn` use, `yaw` is what `create` and `setTransform` take -- and it is extracted from the full\nquaternion rather than from `qz`/`qw` alone, so a car on a slope reports the heading it is actually\nfacing.\n\n**The motion group is the last thing the physics owner reported.** A vehicle with no owner is not\nbeing simulated by anyone, so losing the lease -- a driver stepping out, disconnecting, or the\nlease expiring -- zeroes `velocity`, `angularVelocity`, `speed` and the dynamics bits rather than\nleaving a parked car claiming the speed it had when its driver got out. A server `setTransform`\nclears them for the same reason. `moving` is exactly `physicsOwner ~= 0 and speed > 0.1`, which is\nthe check a speed camera wants to make before it reads `speedKph`.\n\n`driverPlayerId` is absent, not zero, when nobody is driving, and absent while an entry animation\nis still running: a reserved seat is not yet a driver.\n\nValidation constraints are `health = 0..1`, RGB channels `0..255`, finite world coordinates with\nan absolute maximum of 1,000,000, a record length up to 256 characters, an appearance length up to\n128, flags limited to the documented ten bits, exactly 30 normalized body values, doors `0..63`,\nand windows/tyres `0..15`.","natives":[],"permissions":[],"events":[]},{"anchor":"server-events","heading":"Server events","level":3,"text":"```lua\nAddEventHandler(\"onVehicleCreated\", function(id, resource, record) end)\nAddEventHandler(\"onVehicleUpdated\", function(id, revision) end)\nAddEventHandler(\"onVehicleRemoved\", function(id, reason) end)\nAddEventHandler(\"onVehicleAuthorityChanged\", function(id, owner, epoch, reason) end)\nAddEventHandler(\"onVehicleOccupancyChanged\", function(id, revision)\n local canonical = Open77.vehicles.get(tonumber(id))\nend)\n\n-- The three derived seat transitions. Same ledger, already diffed.\nAddEventHandler(\"onPlayerEnteringVehicle\", function(playerId, vehicleId, seat) end)\nAddEventHandler(\"onPlayerEnteredVehicle\", function(playerId, vehicleId, seat) end)\nAddEventHandler(\"onPlayerLeftVehicle\", function(playerId, vehicleId, seat) end)\n\nAddEventHandler(\"onVehicleDamageChanged\", function(id, revision)\n local damage = Open77.vehicles.getDamage(tonumber(id))\nend)\n\nAddEventHandler(\"onVehiclePaintChanged\", function(id, revision)\n local paint = Open77.vehicles.getPaint(tonumber(id))\nend)\n```\n\nThe same creation and removal also reach `onEntityCreated(kind, id, resource)` and\n`onEntityRemoved(kind, id, reason)` with `kind` = `\"vehicle\"`, for a resource that declares\n`world.entities.observe` and wants every registry under one name. The mirror is raised by the\nsame statement as the event above it, so the two can never disagree — see\n[entity lifecycle events](server-api.md#entity-lifecycle-events).\n\nServer runtime event arguments arrive as strings. Preserve the ID as an opaque value unless the\ncalled binding explicitly requires an integer. `onVehicleUpdated` fires for every canonical state\nupdate; `onVehicleDamageChanged` and `onVehiclePaintChanged` are narrower channel-specific signals.","natives":["AddEventHandler","server:AddEventHandler","Open77.vehicles.get","server:Open77.vehicles.get","server:Open77.vehicles.getDamage","Open77.vehicles.getPaint","server:Open77.vehicles.getPaint"],"permissions":[],"events":[]},{"anchor":"enter-and-exit-derived","heading":"Enter and exit, derived","level":4,"text":"`onVehicleOccupancyChanged` is a *ledger* signal: it says the occupant list changed and leaves the\nresource to fetch the vehicle and diff the list itself to learn what actually happened. Every\ngamemode wrote that diff, and most of them got the same two cases wrong.\n\nThe three events above are that diff, done once, in the one place the occupancy ledger is\npublished. They are **derived, never authored** -- there is no second source of truth to drift\nfrom `onVehicleOccupancyChanged`, and any future seat mutation path gets correct transitions\nwithout new plumbing. The rules, each one paid for by a shape the raw ledger makes ambiguous:\n\n- **`onPlayerEnteringVehicle`** fires when an occupant appears carrying the `entering` flag: the\n seat is *reserved* and the entry animation is running. Nobody is in the car yet, and a second\n warp into that seat is already refused.\n- **`onPlayerEnteredVehicle`** fires when the player is actually seated -- either straight away\n (a direct entry, or a server `warpPlayerIntoVehicle`, neither of which has an animation) or when\n a pending `entering` is confirmed. It fires **exactly once per entry**: a client re-announcing\n `BeginEnter` from a seat it already holds does not make it fire again.\n- **`onPlayerLeftVehicle`** fires when a seated player leaves that seat -- by exit, seat switch,\n disconnect, incapacitation, or the vehicle being deleted out from under them.\n- **An entry that is never confirmed raises no `left`.** The reservation times out, the seat frees,\n and because nobody was ever in the car there is nothing to report leaving it. `entered` and\n `left` are therefore balanced pairs, which is what makes a seat map built on them safe.\n- **A seat switch is `left(old)` then `entered(new)`**, in that order, because the service frees\n the old seat before taking the new one. Moving between two vehicles names the right vehicle in\n each half.\n\nArguments are `playerId, vehicleId, seat`, with the seat as the canonical `seat_front_left`\nspelling every other vehicle read uses. They deliberately carry no revision: a revision is what you\nneed in order to diff a ledger, and the diff has already been done. `Open77.vehicles.get(id)` still\nanswers for anything else the handler wants.\n\nThe three names are reserved platform events. `TriggerEvent(\"onPlayerEnteredVehicle\", ...)` from a\nresource answers `false, \"reserved_event\"` -- a forged one would tell every other resource that a\nplayer it does not own just took the wheel of a car it does not own.\n\n```lua\nlocal seatOf = {}\n\nAddEventHandler(\"onPlayerEnteredVehicle\", function(playerId, vehicleId, seat)\n playerId, vehicleId = tonumber(playerId), tonumber(vehicleId)\n seatOf[playerId] = { vehicle = vehicleId, seat = seat }\n -- A keys resource acts on the driver seat only, and acts once: this is the\n -- moment the player is actually in, not the moment they reached for the door.\n if seat == \"seat_front_left\" and not hasKeys(playerId, vehicleId) then\n Open77.vehicles.setEngine(vehicleId, false)\n end\nend)\n\nAddEventHandler(\"onPlayerLeftVehicle\", function(playerId)\n seatOf[tonumber(playerId)] = nil\nend)\n```\n\nEvery resource granted `world.vehicles` can mutate, move, seat players in, or remove every canonical\nvehicle. The `resource` snapshot field is provenance, not an access-control boundary. Stopping or\nreloading the creating resource still removes the vehicles it created and clears their assignments,\nso temporary entities retain deterministic lifecycle cleanup.","natives":["AddEventHandler","server:AddEventHandler","server:Open77.vehicles.setEngine"],"permissions":[],"events":[]},{"anchor":"adopting-a-vanilla-car","heading":"Adopting a vanilla car","level":2,"text":"Every read on this page so far answers from the canonical registry, which holds\nonly vehicles the server created. The traffic car a player is standing next to\nwas spawned by REDengine population **on that player's own machine**, has no\ncanonical id, and is invisible to every other client — which is why\n`Open77.vehicles.aimed()` answers `unknown_entity` when the player is plainly\nlooking at a car.\n\nAdoption is how such a car becomes real for everybody. It is what a car-theft or\nworld-car RP server needs: walk up to a Hella, make it canonical, drive it, and\nhave the rest of the session see you do it.","natives":[],"permissions":[],"events":[]},{"anchor":"what-adoption-actually-does","heading":"What adoption actually does","level":3,"text":"**It takes the car's identity, not the engine's entity.** That is the entire\ndesign and it is a deliberate answer to how this could go wrong.\n\nThe engine owns those cars. It spawned them, it moves them, and it deletes them\nwhenever it likes, with no notification this plugin can rely on. A canonical\nvehicle bound to such an entity would quietly become a ghost the moment the\nengine took it back — and a vehicle that vanishes without a removal is worse\nthan one that was never adopted.\n\nSo nothing is bound. The sequence is:\n\n1. the client **describes** the car — record, pose, paint, damage;\n2. the server **creates an ordinary canonical vehicle** from that description,\n with the same lifetime, the same reaper and the same projection path as a car\n a garage spawned;\n3. the client **hands its vanilla chassis back** to the population system.\n\nAfter step 3 the engine owns nothing the server believes it owns, and the\nadopted car is a vehicle like any other: `get`, `update`, `setLocked`,\n`setPerformance`, `setTimeToLive` and `remove` all behave exactly as they do for\na `create`. Only `get(id).adopted`, `adoptedFrom` and `adoptedEngineEntity`\nremember where it came from, and they are provenance — they grant nothing.\n\nAdoption replaces the vanilla chassis with a network projection. A visible gap can occur while the replacement streams in; its duration depends on asset loading.","natives":[],"permissions":[],"events":[]},{"anchor":"the-three-calls","heading":"The three calls","level":3,"text":"On the **client**, with `vehicles.read`:\n\n```lua\n-- Nearest first. `radius` defaults to 25 m, `limit` to 8.\nlocal offered = Open77.vehicles.adoptable({ radius = 8, limit = 4 })\nfor _, car in ipairs(offered or {}) do\n print(car.record, car.distance, car.adoptable)\nend\n```\n\nAn entry carries everything the server's `adopt` reads, spelled the same, so it\ncan be handed over untouched. `engineEntity` and `recordId` are fixed `0x`\nstrings, because a REDengine hash does not survive a Lua number.\n\n`adoptable` is false — and `record` empty — when the car's TweakDBID is not in\nthe shipped reverse table. The engine hands out ids, not names: a TweakDBID is\nCRC32 of the record name plus its length, a shipping build keeps no reverse\ntable, and `gamedataVehicle_Record` has no `Name` accessor. So the 1376 known\n2.31 vehicle records are carried in the client, every one of them verified by\nrecomputing its own id, and a car outside that set reports its raw id and says\nit cannot be adopted rather than inventing a name.\n\nOn the **server**, with `world.vehicles` *and* `world.vehicles.adopt`:\n\n```lua\nOpen77.vehicles.setAdoptPolicy(\"driver\") -- bucket 0\nOpen77.vehicles.setAdoptPolicy(\"any\", { bucket = 7 })\nprint(Open77.vehicles.getAdoptPolicy(0)) -- \"driver\"\n```\n\nEvery bucket starts at `\"none\"`: nothing is adoptable until a resource says so.\n`\"driver\"` accepts only a car the proposing player is standing at, within six\nmetres — the car-theft shape. `\"any\"` widens that to sixty metres; it does not\nremove the check.\n\nBack on the **client**, with `vehicles.adopt`, once the server has agreed:\n\n```lua\nOpen77.vehicles.releaseAdopted(car.engineEntity)\n```\n\nThat deletes the vanilla chassis through the population system — the same call\nthe world sanitizer already makes every second — and it refuses a chassis this\nclient was not offered in the last minute, a car the server owns, and the car\nthe player is sitting in.","natives":["Open77.vehicles.adoptable","server:Open77.vehicles.setAdoptPolicy","server:Open77.vehicles.getAdoptPolicy","Open77.vehicles.releaseAdopted"],"permissions":[],"events":[]},{"anchor":"end-to-end","heading":"End to end","level":3,"text":"```lua\n-- client\nlocal car = (Open77.vehicles.adoptable({ radius = 6, limit = 1 }) or {})[1]\nif car and car.adoptable then TriggerServerEvent(\"garage:adopt\", car) end\n\nRegisterNetEvent(\"garage:adopted\", function(engineEntity, vehicleId)\n Open77.vehicles.releaseAdopted(engineEntity)\n print(\"that Hella is now vehicle \" .. vehicleId)\nend)\n```\n\n```lua\n-- server\nRegisterNetEvent(\"garage:adopt\", function(description)\n -- `source`, never a field out of the payload: the placement checks are the\n -- entire defence, and they have to run against the real sender.\n local id, reason = Open77.vehicles.adopt(source, description)\n if not id then return print(reason) end\n TriggerClientEvent(\"garage:adopted\", source, description.engineEntity, id)\nend)\n```","natives":["Open77.vehicles.adoptable","TriggerServerEvent","RegisterNetEvent","server:RegisterNetEvent","Open77.vehicles.releaseAdopted","server:Open77.vehicles.adopt","server:TriggerClientEvent"],"permissions":[],"events":[]},{"anchor":"what-the-server-checks-and-what-it-cannot","heading":"What the server checks, and what it cannot","level":3,"text":"Everything in a description was read on the proposing client and **none of it is\ntrusted**. The server decides whether a car appears at all, in which bucket and\nwhere; the description only decides what it looks like, which is exactly the\nauthority a resource already has through `create`. So the checks are about\nplacement:\n\n* the player's bucket must have a policy other than `none`\n (`adopt_policy_closed`);\n* the player must actually be there, within the policy's radius (`too_far`), and\n must have a known position at all (`player_unavailable`);\n* no adopted car of the same record may already stand within three metres in\n that bucket (`duplicate_adoption`) — traffic is spawned per client, so two\n players on one corner are each describing their own car, and without this rule\n the corner fills with copies;\n* the same chassis proposed twice by the same player answers with the id of the\n first vehicle and the reason `already_adopted`, because a retry after a dropped\n acknowledgement is the ordinary way that happens.\n\nTwo things it deliberately does not do. It does not verify that the record\nexists — a made-up but well-formed record string produces a canonical vehicle no\nclient can project, exactly as `create` would. And it does not know whether an\nNPC was driving the car: the only way to ask is a scripted function, and calling\none without a script frame ends the process. `adoptable()` reports `speed` so a\nresource can prefer parked cars, and `\"driver\"` policy plus a player at the door\nis the shape that avoids the question.","natives":[],"permissions":[],"events":[]},{"anchor":"client-api","heading":"Client API","level":2,"text":"The client state surface is intentionally read-only:\n\n```lua\nlocal vehicle = Open77.vehicles.get(id)\nlocal streamed = Open77.vehicles.all()\nlocal trunkOpen = Open77.vehicles.isDoorOpen(id, \"trunk\")\nlocal hoodOpen = Open77.vehicles.isDoorOpen(id, Open77.vehicles.doors.hood)\nlocal entryLocked = Open77.vehicles.isLocked(id)\nlocal mySeat = Open77.vehicles.getPlayerSeat()\nlocal remoteSeat = Open77.players.getVehicleSeat(remotePlayerId)\nlocal cannotExit = Open77.vehicles.isPlayerExitLocked()\n\n-- Requires vehicles.control and local physics ownership.\nlocal ok, reason = Open77.vehicles.triggerHorn(id, 300)\n```","natives":["Open77.vehicles.get","server:Open77.vehicles.get","Open77.vehicles.all","server:Open77.vehicles.all","Open77.vehicles.isDoorOpen","server:Open77.vehicles.isDoorOpen","Open77.vehicles.isLocked","server:Open77.vehicles.isLocked","Open77.vehicles.getPlayerSeat","server:Open77.vehicles.getPlayerSeat","Open77.players.getVehicleSeat","server:Open77.players.getVehicleSeat","Open77.vehicles.isPlayerExitLocked","server:Open77.vehicles.isPlayerExitLocked","Open77.vehicles.triggerHorn","server:Open77.vehicles.triggerHorn"],"permissions":[],"events":[]},{"anchor":"performance-ceilings","heading":"Performance ceilings","level":3,"text":"**This is the client-local form.** It is unchanged and still works; the server-authoritative form,\nwhich takes the same three numbers and replicates them to every viewer, is\n[Performance, decided by the server](#performance-decided-by-the-server). Use the server form for\nanything a player must not be able to opt out of -- a tuning shop, a speed limit inside an instance\n-- and this one for a client's own presentation.\n\nA roster of cars is only a real choice when no single car simply wins. Cyberpunk 2077 2.31 exposes\nno speed setter, no writable drive model and no writable input system, so Open77 applies a ceiling\nthe way an engine control unit does: it withdraws driver throttle as the ceiling approaches, at the\nexact point in the drive update where REDengine reads it. The car still accelerates normally, then\nthe top of its acceleration curve flattens. Nothing fights the physics solver, so there is no\njudder and no rubber-banding.\n\n```lua\n-- Every Archer Hella in this gamemode tops out at 140 km/h.\nOpen77.vehicles.setPerformanceClass(\"Vehicle.v_standard2_archer_hella_player\", {\n topSpeedKph = 140,\n})\n\n-- Same ceiling, and half the pickup, for one specific spawned car.\nOpen77.vehicles.setPerformance(vehicleId, {\n topSpeedKph = 140,\n taperKph = 15,\n accelerationScale = 0.5,\n})\n\nOpen77.vehicles.clearPerformanceClass(\"Vehicle.v_standard2_archer_hella_player\")\nOpen77.vehicles.clearAllPerformance()\n\n-- Hold a stopped car on a starting grid, then release it on authoritative GO.\nassert(Open77.vehicles.setControlLock(vehicleId, true))\nassert(Open77.vehicles.setFrozen(vehicleId, true))\nassert(Open77.vehicles.setFrozen(vehicleId, false)) -- wake chassis first\nassert(Open77.vehicles.setControlLock(vehicleId, false))\n```\n\n| Field | Default | Meaning |\n|---|---|---|\n| `topSpeedKph` | `0` | The ceiling. Zero leaves the top end alone, so a profile can cap pickup only. |\n| `taperKph` | `12` | How wide the roll-off below the ceiling is. Wider is softer. Values under `3` are refused, because a band that narrow makes the limiter hunt and the hunting reads as lag. |\n| `accelerationScale` | `1` | Throttle authority at every speed, in `(0, 1]`. This is how a car is made to *pick up* more slowly rather than only to top out lower. |\n\nPrecedence is instance, then record class, then a default set by `setDefaultPerformance` on the\nnative API. A car matched by nothing is untouched. Caps are dropped automatically when the vehicle\ndespawns, and they do not survive a client restart.\n\n`Open77.vehicles.ratedTopSpeed(id)` returns the gearing rating of the vehicle's TweakDB record: the\nspeed at which top gear reaches its rated maximum engine RPM. It is the right number for *ranking* a\nroster and for picking a ceiling that no car in the roster has to be slowed much to meet. It is not\nthe measured terminal velocity — torque and aerodynamic drag carry real top speed above it.\n\n> **This is balance, not anti-cheat.** The ceiling is applied by the client that simulates the\n> vehicle, and a modified client can decline to apply it. It equalises a roster among players who\n> are running the stock client; it enforces nothing against a determined one. The server currently\n> cannot corroborate it either — the server-side vehicle snapshot carries no velocity, so there is\n> nothing to check a reported speed against. Treat a speed ceiling as a game-design tool, on the\n> same footing as which cars the roster offers at all.\n\n`setControlLock` is the hard stationary counterpart to the soft performance\ngovernor. While locked, the native drive update consumes zero acceleration and\nzero brake/reverse input and clears burnout. Releasing the lock restores the\ninput snapshot on the next frame; no velocity or transform is overwritten, so\nthere is no rubber-banding. Engage it only after placing a stopped vehicle. The\nsame client-side trust boundary applies: it is a gamemode start lock, not an\nanti-cheat primitive.\n\n`setFrozen` is the physical half of a real starting-grid hold. It calls the\nverified REDengine `vehicleBaseObject::EnablePhysics` path for the complete\nchassis group, while preserving the local physics-owner lease, driver workspot\nand player-controlled state. This stops inertia, gravity and external impulses;\nthe API does not repeatedly teleport the vehicle. On release it restores the\nmask and wakes the rigid body. Pair it with `setControlLock`: freeze after inputs\nare locked, then thaw before restoring inputs on GO. Calls are idempotent and an\nunknown or not-yet-streamed vehicle fails closed so a resource can retry.\n\nWhere the number should live: a gamemode should not hard-code it. Declare it as a resource tunable\nso a server owner can edit it in Warden, and have the resource apply the value it reads. A ceiling\nis a match parameter, so `nextMatch` is the right apply timing — moving it under two people already\nracing is not a live update.\n\nTrusted presentation resources can request an entry presentation or use an explicit instant warp:\n\n```lua\n-- FiveM-compatible seat numbers: driver=-1, front passenger=0,\n-- rear-left=1, rear-right=2.\nlocal ok, reason = Open77.vehicles.taskPlayerEnterVehicle(\n playerId, vehicleId, Open77.vehicles.seats.frontPassenger)\n\n-- Recovery, stream reconstruction, teleport-oriented game modes, or tests.\nok, reason = Open77.vehicles.warpPlayerIntoVehicle(\n playerId, vehicleId, Open77.vehicles.seats.driver)\n```\n\nNamed seats (`driver`, `frontPassenger`, `rearLeft`, `rearRight`, `frontLeft`,\n`frontRight`, `backLeft`, `backRight`, and canonical `seat_*` names) are accepted too.\nThese functions operate only on remote player proxies. They return\n`false, \"occupancy_mismatch\"` unless the replicated server ledger already contains that exact\ntuple. They cannot grant a seat, move the local player, steal a vehicle, or change physics\nauthority. This is the **client** meaning of `warpPlayerIntoVehicle`; the identically named server\nmethod creates the authoritative assignment. Client and server resources run in separate Lua\nstates, so the same name cannot accidentally cross that boundary.\n\n`taskPlayerEnterVehicle` uses Open77's guarded staged door/workspot path for a live remote entry,\nwith a bounded instant-warp fallback. It remains presentation-only.\n\n`getPlayerSeat(playerId?)` and its `Open77.players.getVehicleSeat` alias return the replicated\nassignment. Omit the ID to query the local player. In addition to the server fields, a client result\ncontains `streamed` and `entity`; `entity` is the current ephemeral local projection handle and must\nnot be cached across stream-out. `isPlayerExitLocked(playerId?)` returns `nil` when no assignment is\nreplicated, otherwise the canonical boolean.","natives":["Open77.vehicles.setPerformanceClass","Open77.vehicles.setPerformance","server:Open77.vehicles.setPerformance","Open77.vehicles.clearPerformanceClass","Open77.vehicles.clearAllPerformance","Open77.vehicles.setControlLock","Open77.vehicles.setFrozen","server:Open77.vehicles.setFrozen","Open77.vehicles.taskPlayerEnterVehicle","server:Open77.vehicles.taskPlayerEnterVehicle","Open77.vehicles.warpPlayerIntoVehicle","server:Open77.vehicles.warpPlayerIntoVehicle"],"permissions":[],"events":[]},{"anchor":"client-snapshot-fields","heading":"Client snapshot fields","level":3,"text":"The client snapshot deliberately differs from the server snapshot:\n\n| Group | Fields |\n|---|---|\n| Identity | `id`, `record`, `revision` |\n| Local projection | `entity`, `engineEntity`, `streamed`, `locallyOwned` |\n| Authority | `physicsOwner`, `authorityEpoch` |\n| World | `position` (a plain `{ x, y, z }`, which is what an Open77 vector3 is) |\n| Transform | `orientation = { x, y, z, w }`, `heading`, `yaw` |\n| Durable state | `health`, `flags`, `locked`, `doors`, `windows`, `tires`, `brokenGlass`, `brokenLights`, `detachedParts` |\n| Control state | `engineOn`, `lights` (`\"off\"`/`\"on\"`/`\"high\"`), `sirenOn`, `undriveable`, `drivable`, `frozen` |\n| Body and damage | `bodyDamage[1..30]`, `damage = { body, glass, lights, tires, detachedParts }` |\n| Drivetrain | `speed` (m/s), `speedKph`, `rpm`, `rpmMax`, `throttle`, `brake`, `gear`, `burnout` |\n| Wheels/suspension | `steering`, `wheelRotation`, `suspensionLongitudinal`, `suspensionTransversal`, `onGround`, `reversing` |\n| Seats | `occupants[] = { playerId, seat, flags, entering, exiting, forcedEntry, exitLocked, forcedExit, switching, switchId, switchSeat, autoSwitchEnabled }` |\n\n`position` and `orientation` come from the same source: the latest replicated motion when one\nexists, the canonical create otherwise, so a proximity query answers for a car whose REDengine\nprojection has not attached yet. `heading` and `yaw` are one value derived from `orientation` with\nthe same full-quaternion extraction the server uses, so the two runtimes never disagree about which\nway a car points. `speed` stays metres per second, the unit every Open77 read uses; `speedKph` is\nthe same number converted once, because performance ceilings and speed limits are authored in km/h.\n\n`entity` is an ephemeral, generation-checked Open77 handle for the local projection.\n`engineEntity` is diagnostic engine identity. Neither is the durable server vehicle ID, and neither\nshould be cached after stream-out. Client snapshots do not include server ownership metadata such\nas `resource`, `bucket`, or world coordinates. Paint is available both in the snapshot and through\n`Open77.vehicles.getPaint(id)`.\n\n**There is no streamer to ask, and no `RequestModel` / `HasModelLoaded` loop to write.** Streaming\nis implicit: the server registry streams a vehicle to every client inside its radius, and the\nclient attaches its projection when the engine has spawned it -- measured up to 3.7 s after the\ncreate arrives. `create` returns before that; `get(id).streamed` says whether *this* client has the\nbody, `open77:vehicleCreated` says the create arrived. `Open77.vehicles.whenStreamed` folds both into\none promise, which is the whole of what a ported loading loop was waiting for:\n\n```lua\n-- Requires vehicles.read. Resolves with the get(id) snapshot once streamed == true --\n-- before the call returns when it already is -- and rejects with `timeout`\n-- (default 15000 ms, clamped to 1..120000).\nCreateThread(function()\n local pending, reason = Open77.vehicles.whenStreamed(vehicleId, 10000)\n if not pending then return print(\"cannot wait: \" .. tostring(reason)) end\n local car, why = pending:await()\n if not car then return print(\"never streamed in: \" .. tostring(why)) end\n Open77.blips.create({ entity = car.entity, sprite = \"taxi\", label = \"Your taxi\" })\nend)\n```\n\nIt listens for `open77:vehicleCreated` and polls the snapshot every 100 ms until it is attached;\nthe one-shot handler is removed on resolve, on timeout and when the resource stops. `nil, reason`\nonly for a bad id, a bad timeout or a missing permission -- an id this client has never seen is\nwaited for and times out, because \"not here yet\" and \"not anywhere\" look the same from a client.\nThere is deliberately no server counterpart: a vehicle's steward is elected by a driver's claim,\nnever by a streamed report, so the server holds no signal that means \"somebody has this car\";\n`Open77.npcs.whenReady` exists on the server because NPCs do report readiness ([npcs.md](npcs.md)).\n\nClient events:\n\n```lua\nAddEventHandler(\"open77:vehicleCreated\", function(id) end)\nAddEventHandler(\"open77:vehicleRemoved\", function(id, reason) end)\nAddEventHandler(\"open77:vehicleAuthorityChanged\", function(id, ownerPlayerId) end)\nAddEventHandler(\"open77:vehicleOccupancyChanged\", function(id, revision) end)\n\n-- The local player's own seat transitions, derived from the same replicated\n-- ledger. Seat is a canonical `seat_*` name.\nAddEventHandler(\"open77:vehicleEntering\", function(id, seat) end)\nAddEventHandler(\"open77:vehicleEntered\", function(id, seat) end)\nAddEventHandler(\"open77:vehicleLeft\", function(id, seat) end)\n\n-- Electrical edges, so a HUD does not have to keep its own copy of `flags`.\nAddEventHandler(\"open77:vehicleEngineChanged\", function(id, state) end) -- \"on\" / \"off\"\nAddEventHandler(\"open77:vehicleLightsChanged\", function(id, mode) end) -- \"off\" / \"on\" / \"high\"\nAddEventHandler(\"open77:vehicleSirenChanged\", function(id, state) end) -- \"on\" / \"off\"\nAddEventHandler(\"open77:vehicleDrivableChanged\", function(id, state) end) -- \"yes\" / \"no\"\nAddEventHandler(\"open77:vehicleDamageChanged\", function(id, revision) end)\nAddEventHandler(\"open77:vehiclePaintChanged\", function(id, revision) end)\n```\n\nThe reference resource also emits `open77:vehicleOwnerChanged(id, ownerPlayerId)` and\n`open77:vehicleSeatsChanged(vehicleSnapshot, revision)`. The latter resolves the fresh snapshot\nbefore dispatch, unlike the lower-level occupancy event.","natives":["CreateThread","server:CreateThread","Open77.vehicles.whenStreamed","Open77.blips.create","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:vehicleCreated","open77:vehicleRemoved","open77:vehicleAuthorityChanged","open77:vehicleOccupancyChanged","open77:vehicleEntering","open77:vehicleEntered","open77:vehicleLeft","open77:vehicleEngineChanged","open77:vehicleLightsChanged","open77:vehicleSirenChanged","open77:vehicleDrivableChanged","open77:vehicleDamageChanged","open77:vehiclePaintChanged"]},{"anchor":"seat-and-proxy-replication","heading":"Seat and proxy replication","level":2,"text":"1. REDengine reports the local player's real mount and slot.\n2. The client requests that seat; it never assigns itself locally in the network ledger. A server\n resource may instead create a forced assignment for teleport-oriented gameplay.\n3. The server checks vehicle id, routing bucket, 15-metre proximity, lock/destruction state,\n one-seat-per-player, and one-player-per-seat.\n4. A reliable ordered occupancy snapshot is sent to every vehicle viewer. Durable forced-entry,\n exit-lock, and forced-exit flags remain present until native confirmation, so late stream-in and\n projection replacement replay the policy rather than losing a one-shot command.\n5. Each observing client mounts the corresponding remote player proxy into the streamed\n vehicle and exact seat. A live addition runs the native NPC approach/door/workspot behavior;\n an initial stream snapshot uses the instant warp so it does not replay an old entrance. When\n the snapshot removes the player, Open77 discards that disposable\n native proxy and recreates it from the next authoritative player snapshot. This avoids reusing\n a REDengine puppet whose vehicle workspot left its locomotion representation inactive.\n6. Root player movement is suspended only after the native mount is confirmed, avoiding a\n transform fight between pedestrian interpolation and the vehicle mounting system. A fresh\n pedestrian controller is installed on the replacement proxy after exit.\n\nIf an ordinary local engine mount disagrees with the server for two seconds (for example a locked\nseat was rejected), Open77 unmounts the player. A forced entry or exit-lock mismatch is reconciled\nimmediately whenever the target projection exists. Disconnect, vehicle removal, seat change, and\nconfirmed exit clear the server ledger. Leaving the driver seat also revokes physics authority.","natives":[],"permissions":[],"events":[]},{"anchor":"damage-electrical-state-and-horn","heading":"Damage, electrical state, and horn","level":2,"text":"Protocol 1.13 extends the reliable observation channel for state changed by REDengine. The current\nphysics owner captures normalized health, the native 30-cell body-destruction grid, broken\nglass/light bitfields, flat tyres, engine state, headlight mode, six door states, and four window\nstates while driving. Streamed\nclients also watch monotonic destructive changes on nearby vehicles, so gunfire, collisions,\nfire, and explosions are reported even when the target vehicle is parked and has no physics\nowner. Nearby clients also report reversible opening changes. The server accepts damage witness\nreports only from the vehicle's current interest set and\nmerges damage monotonically; a client can add damage but cannot repair a vehicle or change its\nelectrical state. Opening changes additionally require a position within 15 metres. Repairs and\narbitrary opening mutations remain explicit server-resource operations.\n\nThe canonical state is sent to current viewers and embedded in `VehicleCreate` for stream-in\nand late join. A player connecting after a collision or explosion receives the same health,\ndents, broken glass/lights, tyres, destroyed state, and native explosion event instead of a\npristine local projection.\n\nWhen the driver exits, disconnects, dies, loses the lease, or is revoked, the server clears\n`engineOn`, `lightsOn`, `highBeams`, and `sirenOn` before publishing the new authority epoch.\nThis prevents parked vehicles from retaining engine audio or headlights on one client.","natives":[],"permissions":[],"events":[]},{"anchor":"engine-lights-and-siren","heading":"Engine, lights and siren","level":3,"text":"These four bits have always existed in `flags` and have always been applied by every client. What\ndid not exist was a way to write one of them. A keys resource that wanted to cut an engine had to\nread `get(id).flags`, clear bit 0, and post the whole bitfield back through `update` -- a\nread-modify-write on state shared by every resource holding `world.vehicles`. Two resources doing\nit in the same tick each overwrite the other's bit, because each learned the flags before the\nother wrote them.\n\n`setEngine`, `setDrivable`, `setLights`, `setHighBeams` and `setSiren` write one named bit and\nleave every other flag exactly as it was. **`flags` and `update` are unchanged** and keep working\nas they always did; these are another way into the same canonical field, not a replacement.","natives":[],"permissions":[],"events":[]},{"anchor":"the-intent-window-and-why-a-setter-without-one-is-unreliable","heading":"The intent window, and why a setter without one is unreliable","level":4,"text":"The physics owner publishes what its own projection reads back every 500 ms, and the server merges\nthe electrical bits by *replacement*. So a `setEngine(id, true)` races the report that was already\nin flight when it was called -- and loses. The bit lands, the stale report lands a moment later,\nand the API looks broken at random. This is the same mechanism that made Pursuit's cop sirens go\ndark about five seconds after every spawn for a month.\n\nEach electrical write therefore records a **ten-second intent** that outranks the owner's report\nfor the bits it names. It is a window, not a lock:\n\n- the intent is **released the moment the owner reports the same value** -- proof that its\n projection applied it -- so a driver who then flips their own headlights is obeyed immediately;\n- a bit the owner can never report (the siren) is held until the window expires, then dropped, so\n the ledger can never get permanently stuck;\n- the ten-second window allows for stream-in and the first owner report.","natives":[],"permissions":[],"events":[]},{"anchor":"what-the-siren-can-and-cannot-do","heading":"What the siren can and cannot do","level":4,"text":"**Siren lights and sound share one switch.** `vehicleBaseObject.ToggleSiren(Bool)` has no separate channels, so the API cannot control them independently.\n\nOwner snapshots do not include a native siren readback. Durable reports therefore echo the canonical siren value instead of reading it from the vehicle.","natives":[],"permissions":[],"events":[]},{"anchor":"indicators-and-interior-lighting","heading":"Indicators and interior lighting","level":4,"text":"Indicator and interior-light controls are not supported by this API.","natives":[],"permissions":[],"events":[]},{"anchor":"what-setdrivable-actually-does","heading":"What `setDrivable` actually does","level":4,"text":"Out of service cuts the engine **in the same canonical revision** and locks the simulated driver's\nthrottle and brake on every projection. Both halves matter: a car whose engine is off but whose\ninputs are live can still be rolled along, and a car whose throttle is dead while its engine idles\nis a bug report waiting to happen. Putting it back in service does *not* restart the engine --\nthat is `setEngine`.\n\nLike the entry lock, `undriveable` is server-authored and absent from every owner report, so no\nclient can clear it.","natives":[],"permissions":[],"events":[]},{"anchor":"freezing-a-car","heading":"Freezing a car","level":4,"text":"`setFrozen(id, true)` is the server half of FiveM's `FreezeEntityPosition` for a vehicle, and it\nis deliberately **only** that half -- the control lock above is `setUndriveable`, and the two\ncompose. Three things happen in one canonical revision:\n\n- the `frozen` bit goes up (`Open77.vehicles.flags.frozen`, bit 11, readable through `isFrozen`\n and on the raw `flags`);\n- the motion facts are pinned: `speed`, `velocity`, `angularVelocity`, `onGround` and `reversing`\n read as a car at rest, and stay that way;\n- the physics owner's transform reports are **discarded** while the bit is up -- not merged, and\n not punished either. The lease is renewed and the tick consumed exactly as an accepted report\n would be, because the owner is doing nothing wrong by reporting a car it still simulates, and\n revoking it would put the steward election and the freeze into a two-second flap. Nothing is\n logged for a discard: twenty a second is not a diagnostic.\n\nOn every client the bit maps to the same native physics mask the observer projection already uses,\nso inertia, gravity and impulses stop too -- the car is not merely refused by the server, it does\nnot move on the driver's screen. A driver pressing the throttle revs an engine that goes nowhere,\nand `undriveable` is not implied: an impound wants both bits, a starting grid wants the freeze alone\nwith the engine running.\n\n```lua\n-- Impound: nobody drives it, nobody pushes it, and it survives the lot emptying.\nOpen77.vehicles.setUndriveable(id, true)\nOpen77.vehicles.setFrozen(id, true)\nOpen77.vehicles.setPersistent(id, true)\n\n-- Release, in the order a driver expects: inputs first, then the body.\nOpen77.vehicles.setFrozen(id, false)\nOpen77.vehicles.setDrivable(id, true)\n```\n\nFreezing pins the last accepted canonical transform. A moving owner can briefly render past that position because of network delay. Prefer `setFrozen` to a read-modify-write update of the `frozen` flag; both use the same authoritative pin.\n\nLike `undriveable`, `frozen` is server-authored, absent from every owner report and preserved\nacross them, so no client can clear it. **It is a wire change**: the accepted vehicle flag mask\nwidened from bits 0..10 to 0..11, and a client that does not know the bit refuses the whole state\nframe as invalid, so the server and the client that carry it must be built together.","natives":["server:Open77.vehicles.setUndriveable","Open77.vehicles.setFrozen","server:Open77.vehicles.setFrozen","server:Open77.vehicles.setPersistent","server:Open77.vehicles.setDrivable"],"permissions":[],"events":[]},{"anchor":"health-is-one-pool","heading":"Health is one pool","level":4,"text":"`setEngineHealth` and `getEngineHealth` are the FiveM-shaped names for `setHealth`/`getHealth`,\nwhich are the same normalized `0..1` pool `get(id).health` reads. **Cyberpunk models one vehicle\nhealth pool**; there is no separate engine pool to damage, so at 0 the car is a wreck rather than a\nrunning car with a dead engine. A car that must stay whole but refuse to move is\n`setDrivable(id, false)`.\n\nPer-vehicle gameplay data a server invents -- fuel, keys, a plate, an owner, an insurance record --\ndoes **not** belong in these flags. It belongs in a [state bag](state-bags.md) on the vehicle, which\nreplicates to the clients that care and costs the canonical ledger nothing. The bundled\n[`open77_fuel`](state-bags.md#the-fuel-sample-open77_fuel) resource is the reference for exactly\nthat pattern, and the [plate recipe](state-bags.md#recipe-a-number-plate) is the two-line version.\n\nHorn state is intentionally transient rather than durable. The native `VehicleComponent`\nhorn latch is sampled in the realtime motion stream and observers call `ToggleHorn` only on\nedges. Receivers force it off after 350 ms without a fresh owner packet and on every authority\nchange, so packet loss can never leave a remote horn stuck on.\n\nThere is no `setHorn`, `setRpm`, `setSteering`, or `setWheelRotation` Lua method. Those values\ndescribe native driver input or physics and cannot be authored as durable script state. Seat\nassignment is server-only and uses the explicit forced-seat API; client scripts can only read the\nledger or present a tuple the server already authorized. Engine, lights, high beams, siren, locks,\ndestruction, immortality, and invulnerability are the scriptable vehicle `flags` bits.\n\nTop speed is the one performance value that *is* scriptable, and it is scriptable in a different\nway: not as durable server state but as a client-side ceiling. See\n[Performance ceilings](#performance-ceilings).","natives":[],"permissions":[],"events":[]},{"anchor":"server-authoritative-vehicle-policy","heading":"Server-authoritative vehicle policy","level":2,"text":"Four capabilities that only the server can honestly decide: a performance ceiling, an explosion,\nwho a car is locked for, and how long it lives.","natives":[],"permissions":[],"events":[]},{"anchor":"performance-decided-by-the-server","heading":"Performance, decided by the server","level":3,"text":"`Open77.vehicles.setPerformance(id, { topSpeedKph, accelerationScale, taperKph })` is the\nserver-authoritative form of the [client-local governor](#performance-ceilings). The three numbers\nare the same, and they end up in the same native throttle policy; the difference is only who\ndecides them. A tuning shop, or a speed limit inside an instance, cannot be a decision the client\nmakes.\n\n```lua\n-- Server. Everything inside this instance is limited to 60 km/h.\nfor _, entry in ipairs(Open77.vehicles.nearby(centre, 200.0, { bucket = instanceBucket })) do\n Open77.vehicles.setPerformance(entry.id, { topSpeedKph = 60.0, taperKph = 10.0 })\nend\n```\n\nThe ceiling is replicated to **every viewer**, not only to whichever client currently owns the\nphysics. Ownership changes every time a driver steps in or out, and a ceiling chased across those\ntransitions would be missing for exactly the first second of every drive -- the second a speed\nlimit has to hold. Each client applies it to its own projection; the native governor only has\nthrottle to clamp on the machine actually simulating the car, so sending it wide costs a no-op on\nobservers and removes a whole class of hand-off bug.\n\n**Why it does not need an intent window.** The [electrical\nsetters](#engine-lights-and-siren) need one because the physics owner republishes those bits every\n500 ms and the server merges them by replacement, so a write racing an in-flight report loses. The\nperformance ceiling is not a field that report carries at all, so there is nothing for the merge to\noverwrite. The race that *does* exist is on the client, where the ceiling routinely arrives before\nthe projection has attached -- a server-created vehicle has been measured taking 3.7 s to stream in\n-- and it is answered the same way the intent window answers its race: the client holds the server\nintent on the replica and applies it when the entity appears, retrying every frame until the\ngovernor accepts it, and again if the projection re-attaches.\n\n**The envelope is the client governor's envelope**, enforced on the Lua boundary, in the registry\nand on both sides of the wire: `topSpeedKph` `0..1000`, `accelerationScale` in `(0, 1]`, `taperKph`\n`3..200` with a default of `12`. The taper floor is not arbitrary -- below about 3 km/h the roll-off\nis short enough that the limiter hunts around the ceiling and it reads as stutter, which is why the\n[client-local form](#performance-ceilings) has refused that band since it shipped. A server cap must\nnot be a way around a rule somebody measured, so an unnamed taper takes the native default rather\nthan zero.\n\nRead it back with `Open77.vehicles.getPerformance(id)`, or `get(id).performance`. Clearing it is\n`clearPerformance(id)`, `setPerformance(id, nil)`, or any profile that asks for nothing -- no\nceiling and full throttle authority. The taper is not part of that test: a roll-off width around a\nceiling that does not exist changes nothing, which is how the native profile decides it too.","natives":["Open77.vehicles.nearby","server:Open77.vehicles.nearby","Open77.vehicles.setPerformance","server:Open77.vehicles.setPerformance"],"permissions":[],"events":[]},{"anchor":"explode","heading":"Explode","level":3,"text":"`Open77.vehicles.explode(id)` writes the canonical ledger: health to zero, `destroyed` and\n`exploded` raised, one revision. Every client that has the vehicle streamed then reconciles its own\nprojection through the damage path that already replays a wreck to a late joiner, which ends in\nREDengine's own explosion on that machine. The blast, the sound and the physics are the engine's,\non every viewer at once; the server never touches an entity.\n\nThree things it is worth being exact about, because a keys or a police resource will hit all three:\n\n- **Occupants stay occupants.** The seat ledger is not touched. Nobody is ejected, no seat is\n freed, no life state changes, and the server sends no damage to any player -- inside the car or\n standing next to it. What the blast does to a body is the engine's own damage model running on\n each client, and it reaches the server the ordinary way, through the player damage report. A\n resource that wants the occupants dead has to say so itself.\n- **It is terminal.** A second call is refused with `already_exploded` rather than re-detonating a\n wreck. A vehicle that is merely `destroyed` -- written off by collisions, never blown up -- is\n still a legitimate target.\n- **An owner report cannot undo it.** Health merges by minimum and the destruction bits by OR, both\n monotone, so a report taken a moment before the explosion and claiming a pristine car does not\n bring the vehicle back to life.","natives":[],"permissions":[],"events":[]},{"anchor":"locking-a-car-for-one-player","heading":"Locking a car for one player","level":3,"text":"The durable entry lock has always been all-or-nothing, which is the wrong shape for keys: a car is\nlocked for the street and open for the two people holding keys. Per-player exceptions express that\ndirectly.\n\n```lua\nOpen77.vehicles.setLockedForAll(id, true) -- the street\nfor _, holder in ipairs(keyHolders) do\n Open77.vehicles.setLockedForPlayer(id, holder, false) -- the exceptions\nend\n```\n\nThe canonical bit stays `locked`, so a late joiner is told the right thing with no bookkeeping, and\na key holder who disconnects takes their exception with them. `isLockedForPlayer(id, playerId)` is\nthe single function that decides what a client is told, and the substitution happens at the point\nwhere state reaches a socket -- both the periodic state packet and the create a viewer gets on\nstream-in -- so no fan-out path can disagree with it. Nothing per-player is ever written into the\ncanonical flags, which is also why no owner report can disturb it: `Locked` is outside the set an\nowner report is allowed to carry, and a report containing it is refused outright.\n\n`setLockedForAll` **revokes every exception** as well as moving the canonical bit. That is the\npoint, not a side effect: \"lock this for everyone\" has to mean everyone, including the holders\nexcepted a minute ago. `Open77.vehicles.setLocked` is unchanged and moves only the canonical bit,\nleaving exceptions standing.","natives":["server:Open77.vehicles.setLockedForAll","server:Open77.vehicles.setLockedForPlayer"],"permissions":[],"events":[]},{"anchor":"lifetime","heading":"Lifetime","level":3,"text":"Props have had `ttlMs` since they shipped; vehicles had nothing, so a garage had to sweep its own.\n\n| Policy | Where | Meaning |\n|---|---|---|\n| `ttlMs` | `create` definition, `setTimeToLive(id, ms)` | Remove the vehicle this many milliseconds from now. `0` or `nil` cancels. Capped at seven days. |\n| `persistent` | `create` definition, `setPersistent(id, bool)` | **Nothing automatic removes this vehicle.** |\n| `despawnWhenUnobserved` | `create` definition, `setDespawnWhenUnobserved(id, bool)` | Remove it once nobody has had it streamed for thirty seconds. |\n\n```lua\nlocal id = Open77.vehicles.create({\n record = \"Vehicle.v_standard2_thorton_galena_player\",\n position = spot, yaw = 90.0,\n ttlMs = 10 * 60 * 1000, despawnWhenUnobserved = true,\n})\n```\n\n`persistent` has one meaning, deliberately: no time-to-live takes it, the unobserved sweep does not\ntake it, and it survives the stop of the resource that created it. An explicit\n`Open77.vehicles.remove` still takes it -- persistence is protection from the reapers, not from the\nowner. Taking persistence away hands the vehicle back to any deadline it still carries.\n\nThe unobserved window is not zero because interest is recomputed against the last received player\nsnapshot, and a loading screen or a bucket change briefly empties a viewer set that is about to\nrefill; thirty seconds rides that out. The clock runs for a vehicle nobody has *ever* seen too,\nwhich is what a garage wants -- a car spawned for a player who never turns up still goes. A viewer\ncoming back resets the clock rather than pausing it, and turning the flag off forgets it, so\nturning it back on later does not inherit a deadline from a window nobody was counting.\n\nThe reaper runs once per tick, before interest is reconciled, for the same reason the prop reaper\ndoes: a vehicle whose lifetime ends must leave as a real removal the client tears down, not by\nquietly dropping out of the next interest pass. Its removal reason is `3`.\n\n**A deadline takes an occupied vehicle too.** A time-to-live a resource authored and then silently\ndeclined to honour because someone happened to be sitting in the car is a worse surprise than the\nremoval, and the seat ledger is closed out properly either way -- every occupant gets their\n`onPlayerLeftVehicle`. A garage that must not yank a car from under its driver clears the deadline\non `onPlayerEnteredVehicle`, or marks the vehicle persistent. The unobserved sweep cannot hit an\noccupied vehicle at all: a seated player always has their vehicle streamed, so it always has a\nviewer.","natives":["server:Open77.vehicles.create"],"permissions":[],"events":[]},{"anchor":"finding-a-vehicle","heading":"Finding a vehicle","level":2,"text":"Every vehicle interaction in an RP server starts with \"the closest vehicle\", and until these\nexisted there was no way to ask.\n\n| Runtime | Call | Answers |\n|---|---|---|\n| Client | `Open77.vehicles.closest(radius?)` | The nearest replicated vehicle to the local player. |\n| Client | `Open77.vehicles.nearby(radius, options?)` | All of them within a radius, nearest first. |\n| Client | `Open77.vehicles.aimed()` | The one the player is looking at, right now. |\n| Client | `Open77.vehicles.fromEntity(entityId)` | The vehicle wearing an entity id. |\n| Client | `Open77.vehicles.seatFree(id, seat)` / `occupantInSeat(id, seat)` | Seat occupancy, from the replicated ledger. |\n| Server | `Open77.vehicles.nearby(anchor, radius?, options?)` | All registered vehicles near a point or a player. |\n| Server | `Open77.vehicles.closest(anchor, options?)` | The nearest one. |\n| Server | `Open77.vehicles.seatFree(id, seat)` / `occupantInSeat(id, seat)` / `freeSeats(id)` | Canonical seat occupancy. |\n\nThe server calls reuse the anchor, bucket and limit conventions of\n[`Open77.players.nearby`](server-api.md#nearby-closest-distance) exactly: the anchor is a position table (a\nvector3 is one) or a player id, a player anchor defaults to that player's own routing bucket,\n`bucket = false` opts back out to every bucket, and ties are broken by vehicle id so the order does\nnot reshuffle between two calls with the same input.\n\nThe two sides say \"nothing in range\" differently, and the difference is deliberate. The **server**\nanswers a bare `nil` with no reason, matching `Open77.players.closest`: that is how a caller tells\n\"the search worked and the street is empty\" from \"the search failed\". The **client** names the case\n`no_vehicle_in_range`, because it has near neighbours a resource genuinely has to tell apart --\n`no_local_position` when the player has no body yet, `no_session` before there is a roster at all.\n\n```lua\n-- Client: the car the player is standing at, or the one they are looking at.\nlocal car = Open77.vehicles.closest(6.0) or Open77.vehicles.aimed()\nif car then Open77.net.emitServer(\"keys:tryUnlock\", car.id) end\n\n-- Server: an empty seat in the nearest car in this player's own bucket.\nlocal nearest = Open77.vehicles.closest(playerId, { radius = 10.0, occupied = false })\nif nearest then\n local free = Open77.vehicles.freeSeats(nearest.id)\n if free[1] then\n Open77.vehicles.warpPlayerIntoVehicle(playerId, nearest.id, free[1])\n end\nend\n```","natives":["Open77.vehicles.closest","server:Open77.vehicles.closest","Open77.vehicles.aimed","Open77.net.emitServer","server:Open77.vehicles.freeSeats","Open77.vehicles.warpPlayerIntoVehicle","server:Open77.vehicles.warpPlayerIntoVehicle"],"permissions":[],"events":[]},{"anchor":"what-closest-can-and-cannot-find","heading":"What `closest` can and cannot find","level":3,"text":"**Only server-spawned vehicles are in the registry.** A player who walks up to a vanilla traffic\ncar is standing next to something REDengine population spawned on their own client: it has no\ncanonical id, no replica, and no entry in any of these calls. `closest()` will happily answer with\na registered car forty metres away while the player leans on a taxi, and that is the expected\nbehaviour rather than a bug.\n\nTwo consequences a server creator will otherwise trip over:\n\n- a resource that must not act on the wrong car has to check `distance` itself, because \"nearest\n registered\" is not \"nearest\";\n- `Open77.vehicles.aimed()` answers `unknown_entity` far more often than `no_target`. The player\n really is looking at a car; that car is simply traffic nobody created.\n\nAdopting vanilla traffic into the registry is a separate capability that does not exist yet.","natives":[],"permissions":[],"events":[]},{"anchor":"seat-occupancy","heading":"Seat occupancy","level":3,"text":"`seatFree` and `occupantInSeat` read the **occupancy ledger**, not the local projection, so the\nclient and the server agree about who is where. A seat held by an occupant whose entry animation is\nstill running counts as **taken**: the seat is reserved and a second warp into it is refused, so\nreporting it free would be a lie the caller then trips over one line later.\n\nDuring an animated seat switch, both source and destination count as taken.\nThe occupant's `seat` stays at the source; `switching`, `switchId` and `switchSeat`\nidentify the reserved destination. `switchId` is an opaque string.\n\n`freeSeats(id)` returns canonical seat names in `driver, frontPassenger, rearLeft, rearRight`\norder, so `freeSeats(id)[1]` is a deterministic \"put them anywhere\" seat. It considers all four\nseats of the standard layout; the registry does not know a two-seat record's seat count, so a\ntwo-seater still reports its rear seats free.","natives":[],"permissions":[],"events":[]},{"anchor":"authority-and-streaming","heading":"Authority and streaming","level":2,"text":"- Stream-in radius: 350 metres.\n- Stream-out radius: 425 metres, providing hysteresis.\n- Only the canonical front-left occupant requests a two-second physics lease automatically.\n- Valid owner motion renews the lease.\n- Exit, timeout, disconnect, stale epoch, or an implausible jump revokes it.\n- Create/remove/state/authority use reliable ordered delivery.\n- Motion uses unreliable sequenced delivery and is coalesced client-side.\n- Observers render through an adaptive jitter buffer. It targets 75-180 ms from the measured\n packet interval and jitter, uses cubic Hermite position interpolation plus shortest-path\n quaternion slerp, and predicts linear/angular motion for at most 180 ms after the newest sample.\n- Duplicate/stale ticks are rejected and every authority-epoch change resets history. Throttle,\n brake, burnout, and tire-slip transients decay after 350 ms without fresh owner motion.\n- The owner also retains every outgoing motion sample locally. Because the server does not echo\n unreliable motion to its sender, this retained sample is the handoff pose when authority is\n released; exiting a vehicle therefore cannot fall back to its original spawn transform.\n- An observer projection uses REDengine's whole-vehicle movement path: simple movement,\n physics masking, and `ForceMoveTo` with the interpolated pose. Open77 deliberately does not\n make streamed vehicles kinematic: runtime tests showed REDengine did not reconstruct a\n driveable backend when local ownership was later acquired. On local ownership, Open77 clears\n the observer physics mask explicitly, disables simple movement, restores player control, enables\n transform updates, and wakes native physics.\n- Direct manipulation of a mesh `PhysicalBodyInterface` remains deliberately disabled: that\n prototype crashed the second client during stream-in. Private vehicle entry points are accepted\n only when their 2.31 relocation resolves to the exact audited executable RVA.","natives":[],"permissions":[],"events":[]},{"anchor":"who-owns-a-car","heading":"Who owns a car","level":3,"text":"`Open77.vehicles.owner(id)` answers FiveM's `NetworkGetEntityOwner` with more than a number,\nbecause the lease has more than one shape. It requires `world.vehicles` and answers `nil, reason`\nfor an unknown id.\n\n| Field | Meaning |\n|---|---|\n| `physicsOwner`, `authorityPlayerId` | The client simulating the car; `0` when parked. Both names carry the same value -- the vehicle read has always said `physicsOwner`, the NPC read says `authorityPlayerId`, and a script reading both should not learn two words. |\n| `driver` | The seated controller -- the front-left occupant, or any occupant of an AV -- **whether or not** they own the lease. Absent when nobody is. |\n| `steward` | The owner holds the lease without being the driver: the parked-car simulation the platform hands to the nearest occupant, or a script's own grant. |\n| `aiDriven` | An NPC drive task holds the lease. |\n| `epoch` | The authority epoch that owner holds. |\n| `since`, `ageMs` | When the owner last changed, on the `GetGameTimer()` clock, and how long ago that was. |\n| `reason` | Why it last changed: `driverclaim`, `driverrelease`, `leaseexpired`, `playerdisconnected`, `validationfailed`, `serverrevoke`, or `none` for a platform steward grant and for a car nobody has owned yet. |\n| `leaseMs` | How long the current lease still has, when there is one. |\n\n```lua\n-- A speed camera only trusts a car somebody is actually driving.\nlocal owner = Open77.vehicles.owner(id)\nif owner and owner.driver and not owner.aiDriven then\n fine(owner.driver, Open77.vehicles.getSpeedKph(id))\nend\n```\n\n`Open77.vehicles.requestAuthority(id, playerId)` is `NetworkRequestControlOfEntity` as a\n**request**. It returns the owner table after the grant, or `nil, reason`, and the reasons are the\nelection's own eligibility made explicit:\n\n| Reason | Means |\n|---|---|\n| `driven` | Somebody else is the seated controller. A driven car is never taken from its driver -- this is the rule that makes the call a request rather than an order. |\n| `ai_driven` | An NPC drive task holds the lease; re-granting it would strand the task. |\n| `not_streamed` | The player has no body for this car yet. A client cannot simulate what it has not spawned. |\n| `too_far` | Beyond the 350 m stream-in radius. |\n| `wrong_bucket` | The player is in another routing bucket. |\n| `player_unavailable` | No fresh position for the player -- the same two-second rule every claim applies. |\n| `already_owner` | Not a refusal: the call returns the read, and nothing is re-granted. |\n| `invalid_player_id`, `vehicle_not_found` | The usual. |\n\nThe requested player being the driver already is the one case that is not a steal, and it is\ngranted. A grant is a lease like any other: the client keeps it by reporting, which its parked-pose\nloop does twice a second once the car has been attached for five seconds, so a grant to a client\nthat has only just streamed the car in can lapse two seconds later -- and `owner` then says so.\nThat is what a lease is, not a defect of the request.\n\n```lua\n-- Hand a parked car to the player who just bought it, so their client simulates it\n-- while they walk around it. Refused by name if somebody is sitting in it.\nlocal read, reason = Open77.vehicles.requestAuthority(id, buyerId)\nif read == nil then print(\"not yet: \" .. reason) end\n```\n\nThere is no `Open77.vehicles.entity(id)`: the client's `Open77.vehicles.get(id).entity` **field**\nis the REDengine entity, and `Open77.vehicles.fromEntity(entity)` is the way back, which is all\n`NetworkGetNetworkIdFromEntity` ever needed. NPCs have the same read, `Open77.npcs.owner(id)`, in\nthe same shape (`authorityPlayerId`, `physicsOwner`, `epoch`, `since`, `ageMs`, `reason`,\n`leaseMs`, plus `readyClients`, the election's candidates) and no request: an NPC's authority is\nelected from the clients whose projection reported ready, and a script has no better information\nthan the election about which of them should simulate a body.","natives":["server:Open77.vehicles.owner","server:Open77.vehicles.getSpeedKph","server:Open77.vehicles.requestAuthority"],"permissions":[],"events":[]},{"anchor":"drivetrain-wheels-and-engine-audio","heading":"Drivetrain, wheels, and engine audio","level":3,"text":"The live vehicle blackboard and input state are appended to every owner motion sample.\nRPM, maximum RPM, gear, speed, and longitudinal/transversal suspension forces are read from the\n2.31 `VehicleDef` blackboard. Throttle, brake, burnout, reverse, and on-ground state come from the\nnative `vehicleBaseObject`. Observers smooth these values, write them into their streamed\nvehicle's blackboard, and update entity-scoped mechanical audio parameters at 30 Hz. Engine pitch,\nload, braking, gear, and lateral load therefore follow the network owner instead of being inferred\nindependently by each client.\n\nRemote engine audio starts when the canonical engine state, a remote driver, or fresh drivetrain\nactivity proves the engine active. It stops when the engine and driver are inactive, the vehicle\nstreams out, or authority becomes local. This avoids initial silence while the durable engine bit\nlags the realtime motion. Two switchable strategies exist (debug bridge\n`vehicle.audiomode_<0-3>`: `0` off, `1` traffic, `2` mechanical, `3` both; **default mechanical**):\n*mechanical* engages the vanilla driver-mix state\nmachine (`vehicleAudioEvent OnPlayerDriving`) and feeds the measured 2.31 RTPC names\n(`paramEngineRPM`, `paramVehicleSpeed`, `paramWheelAngularSpeed`, `veh_speed`, `veh_accel`,\n`veh_engine_throttle_input`, `paramLongSLip`, `paramLatSlip`, and `paramTotalSlip`) from the\nreplicated motion at 30 Hz, so pitch, load, and tire stress follow the\nnetwork owner. Each engine value is published to the vehicle entity scope consumed by its\nmodel-specific `gameaudioVehicleAudioComponent`, plus the authored engine and general emitters for\nspatial voices. *Traffic* is a diagnostic-only mode: it plays the discrete\n`_traffic_engine_loop` Wwise pair resolved from the live TweakDB record, but\nthat simplified traffic voice cannot reproduce the local vehicle's complete mechanical timbre.\nFour global tyre loops target the template's named wheel emitters and receive\ntheir RTPC slip values independently, which preserves spatial tyre roll and squeal. Replicated\ngear transitions additionally play the model's `_gear_up`/`_gear_down` one-shots.\n\nObserver tire smoke uses\n`base\\fx\\vehicles\\_skid_marks\\skid_fx.effect`; the asphalt trail is a separate native\n`base\\fx\\vehicles\\_wheels\\skid_marks\\sport\\v_skid_mark_s_m_01.effect` backed by\n`trail_decal.mt`. A deterministic slip signal is derived from\nchassis-relative velocity, brake, throttle, and burnout. Hysteresis prevents VFX flicker; one\npair of emitters per rear wheel (one centered pair on bikes) follows safe chassis-relative contact\npoints. When slip stops, the native effect loop is broken instead of killed: smoke fades for about\n2.5 seconds and trail decals remain for their roughly 31-second authored lifetime. Stream-out,\ndespawn, or authority handoff still performs an immediate cleanup. Exact\nsurface-material particles are outside the v1 contract.\n\nBody damage is replayed only after the streamed vehicle's authored components have attached.\nBroken-glass bits are resolved against that exact vehicle record's destruction-glass list and\nthen applied through native glass events, so custom/model-specific window component names are\npreserved for current viewers and late joiners.\n\n`steering` is normalized to `-1..1` and `wheelRotation` is a wrapped radian phase. REDengine 2.31\ndoes not expose its live steering input through RTTI, but Open77's audited 2.31 adapter reads the\nnative lateral vehicle input at `vehicleBaseObject+0x278`. The owner therefore transmits real\nkeyboard/controller steering even while stationary. The authoritative wrapped wheel phase initializes\nthe observer and replicated velocity advances it every rendered frame without packet-phase feedback.\nThese values remain available in read-only client snapshots. Open77 does not currently write the\nobserver's hard-transform wheel bindings: their parent animation graph can be incomplete immediately\nafter streamed attachment, and querying that graph caused a reproducible null dereference inside\nREDengine on both clients. The locally driven vehicle keeps its native wheel animation and real\nphysics; a lifecycle-safe observer chassis adapter remains required for guaranteed visual wheel pose.\n\nThe server validates bucket, claim proximity, owner-to-vehicle proximity, generation/epoch,\nmonotonic tick, finite values, velocity/RPM/input/suspension bounds, and travelled distance. Client scripts cannot\nclaim authority directly.","natives":[],"permissions":[],"events":[]},{"anchor":"multiplayer-spawn-policy","heading":"Multiplayer spawn policy","level":2,"text":"There is no client-side vehicle creation API. The entire native `vehicle.*` developer-command\nmodule is absent from the client build; the documented commands below are Lua server commands and\ntherefore use command ACLs. During a multiplayer connection, every vehicle spawn notification is checked\nagainst the live network registry. A REDengine vehicle without a server-issued 64-bit Open77\nvehicle id is removed immediately; merely having a local dynamic-entity handle does not count.\nEntering an unknown vehicle is independently detected and forcibly unmounted.\n\nThis policy complements the multiplayer REDscript suppression of traffic, summons, and ambient\nvehicle producers. It is an identity boundary, not just a traffic-density setting.","natives":[],"permissions":[],"events":[]},{"anchor":"reference-resource-and-commands","heading":"Reference resource and commands","level":2,"text":"`resources/system/open77_vehicles` is the runnable example. It registers:\n\n```text\nvehicle.list [bucket]\nvehicle.create [yaw] [model] [bucket]\nvehicle.create.player [model]\nvehicle.remove \nvehicle.engine \nvehicle.lock \nvehicle.health <0..1>\nvehicle.damage.dump \nvehicle.glass.break \nvehicle.glass.repair \nvehicle.repair [glass|body|lights|tires|visual|mechanical|full]\nvehicle.paint [r2 g2 b2]\nvehicle.paint.reset \n```\n\n`vehicle.list` and `vehicle.damage.dump` are read-only. Mutation commands are restricted. Grant\nthe corresponding exact `command.vehicle.*` ACL permissions before using them from chat.\n\nVehicle record names are listed in the project vehicle catalog. Prefer player variants such as `Vehicle.v_standard2_archer_hella_player`; quest and traffic variants are valid records but are not guaranteed to be pilotable.","natives":[],"permissions":[],"events":[]}]},{"slug":"voice-lipsync","file":"voice-lipsync.md","title":"Voice lipsync","summary":"Open77's native voice renderer can drive a small mouth-only animation on player heads. Lua controls the policy; no Lua loop, custom animation bank or Redscript resource is needed. The existing body locomotion, combat and RP animation systems remain separate.","sections":[{"anchor":"voice-lipsync","heading":"Voice lipsync","level":1,"text":"Open77's native voice renderer can drive a small mouth-only animation on player\nheads. Lua controls the policy; no Lua loop, custom animation bank or Redscript\nresource is needed. The existing body locomotion, combat and RP animation systems\nremain separate.\n\nThis is **audio-envelope lipsync**: the mouth follows speech energy and returns to\nrest during silence. It does not recognize phonemes, languages or words. The\nImmersive Third Person archive's prerecorded dialogue clips are not installed.","natives":[],"permissions":[],"events":[]},{"anchor":"client-build-requirements","heading":"Client build requirements","level":2,"text":"Use a client build containing these four Lua functions **and its matching**\n`Open77.archive` with the facial graph inputs. Updating only the server's Lua\npackage cannot add native functions or facial assets to an older client.\nThis feature itself needs no server protocol change.\n\nResources supporting older clients can check availability before calling:\n\n```lua\nif type(Open77.voice.setLipSyncEnabled) ~= \"function\" then\n print(\"Voice lipsync needs a newer Open77 client build\")\n return\nend\n```\n\nFor microphone setup and server routing, see [Integrated voice chat](voice.md).","natives":[],"permissions":[],"events":[]},{"anchor":"quick-start","heading":"Quick start","level":2,"text":"Declare the client permission in `open77.lua`:\n\n```lua\npermissions { \"voice.client\" }\n```\n\n```lua\n-- Native presentation is enabled by default.\nassert(Open77.voice.setLipSyncEnabled(true))\n\n-- Temporarily suppress all voice-driven mouths on this client.\nassert(Open77.voice.setLipSyncEnabled(false))\n\n-- Release this resource's global suppression.\nassert(Open77.voice.setLipSyncEnabled(true))\n\n-- Or suppress only one network player (server ID, not a ped/entity handle).\nassert(Open77.voice.setPlayerLipSyncEnabled(42, false))\nassert(Open77.voice.setPlayerLipSyncEnabled(42, true))\n\nlocal status = Open77.voice.getLipSyncStatus()\nlocal player, reason = Open77.voice.getPlayerLipSyncState(42)\n```\n\nThe reference [`open77_voice/shared/config.lua`](https://github.com/Open2077/open77-resources/blob/main/system/open77_voice/shared/config.lua) exposes `lipSyncEnabled = true`.\nSet it to `false` to disable the effect for clients using that package. Do not\ncall the setters every frame.","natives":["Open77.voice.setLipSyncEnabled","Open77.voice.setPlayerLipSyncEnabled","Open77.voice.getLipSyncStatus","Open77.voice.getPlayerLipSyncState"],"permissions":["voice.client"],"events":[]},{"anchor":"api-contract","heading":"API contract","level":2,"text":"| Client function | Result |\n| --- | --- |\n| `setLipSyncEnabled(enabled)` | `true`, or `false, reason`; requires a real boolean |\n| `setPlayerLipSyncEnabled(playerId, enabled)` | `true`, or `false, reason`; positive integral network ID |\n| `getLipSyncStatus()` | Table, or `nil, reason` |\n| `getPlayerLipSyncState(playerId)` | Table, or `nil, reason` |\n\nGlobal status contains `enabled`, `sessionActive`, `mode = \"audio_envelope\"`,\n`trackedPlayers`, `availablePlayers` and `activePlayers`.\n\nPlayer state contains `playerId`, `entity`, `isLocal`, `enabled`, `available`,\n`active`, `level` (smoothed 0–1) and `reason`. Entity `0` is the native local player\nsentinel when `isLocal` is true; it is not a remote player ID. `available` means\nthat the current head has the required facial graph inputs; `active` means a\nnonzero mouth envelope was submitted. These are not microphone permission flags.\n\nReasons include `no_session`, `not_streamed`, `disabled`,\n`facial_graph_unavailable`, `controller_unavailable`, `silent` and `speaking`.\nAn unstreamed player returns a state with `available = false`, not an exception.\nInvalid arguments fail without altering the existing policy. Missing permission\nreturns `permission_denied:voice.client`; older/unavailable native backends return\n`voice_backend_unavailable`. Policies are bounded (`lipsync_policy_limit`).","natives":[],"permissions":[],"events":[]},{"anchor":"ownership-and-multiplayer","heading":"Ownership and multiplayer","level":2,"text":"Each Lua resource owns its own suppressions. `true` releases **only that\nresource's** suppression: it cannot undo another resource's `false`. A global\nsuppression wins over a per-player enable. Stop, failed startup, script failure\nand reload release the owning instance's policy; identically named resources in\ndifferent client hosts are isolated. Per-player IDs reset on session change.\n\nThese are **local presentation controls**, not server authority or network\nevents. To impose a gamemode policy on all viewers, run it in the gamemode's\nclient script. Disabling lipsync never mutes anyone or changes voice reachability.\nNo extra network packet or protocol version is introduced.\n\nRemote mouths follow dry decoded audio at playback time, after the jitter\nbuffer. Local mouths follow microphone frames actually accepted for transmission\nunder server policy, rather than the microphone meter. Mic monitoring alone\ndoes not animate the local player. Locally blocked players, zero-volume routes,\nstalled output and silence suppress remote movement. Radio/static/reverb effects\ndo not drive the mouth, and several voice routes do not multiply the effect.","natives":[],"permissions":[],"events":[]},{"anchor":"heads-and-animations","heading":"Heads and animations","level":2,"text":"The asset layer targets the stock male and female player-head facial graphs.\nIt adds bounded jaw/upper-lip/lower-lip values before the native facial solver;\nzero input leaves the original tracks unchanged. No body rig, locomotion graph,\nweapon pose, animation event or root motion is replaced. Native eye and facial\nanimation layers remain in the graph.\n\nThe local target follows the visible F7 proxy, falling back to the native player\nwhen F7 is absent or suspended. Remote targets are streamed replicated players.\nHead replacement and entity changes rebind the inputs; stream-out and disconnect\nclear them. Custom heads with unrelated facial graphs are unsupported and report\n`facial_graph_unavailable` instead of attempting an unsafe skeleton conversion.","natives":[],"permissions":[],"events":[]},{"anchor":"developer-diagnostics","heading":"Developer diagnostics","level":2,"text":"```text\nvoice.lipsyncstate 42\nvoice.lipsync 42 0.5\nvoice.lipsync 42 off\n```\n\nThe existing diagnostic bridge's forced-envelope command is local-only and\nrequires a tracked player. Always restore `off` after a visual test. Normal Lua\nresources cannot set arbitrary facial coefficients or access PCM/Opus buffers.\n\nImplementation and validation details: `docs/research/voice-lipsync-integration.md`.","natives":[],"permissions":[],"events":[]}]},{"slug":"voice","file":"voice.md","title":"Integrated voice chat","summary":"Open77 voice captures 48 kHz mono audio and encodes it as 20 ms Opus frames. A frame can be routed through proximity and up to four selected channels without duplicating microphone bandwidth. PCM and Opus payloads are not exposed to Lua.","sections":[{"anchor":"integrated-voice-chat","heading":"Integrated voice chat","level":1,"text":"Open77 voice captures 48 kHz mono audio and encodes it as 20 ms Opus frames. A frame can be routed through proximity and up to four selected channels without duplicating microphone bandwidth. PCM and Opus payloads are not exposed to Lua.\n\nThe server is the only component that decides who may receive a frame. It checks the authenticated\nspeaker, routing bucket, proximity, channel membership, per-channel permissions, mute/deaf state,\nsequence, quality, packet rate, and payload size before forwarding anything. A client transmit\nscope is only a request.","natives":[],"permissions":[],"events":[]},{"anchor":"enable-the-reference-resource","heading":"Enable the reference resource","level":2,"text":"[`open77_voice`](https://github.com/Open2077/open77-resources/tree/main/system/open77_voice) is an auto-start reference package. Its manifest requests `voice.manage` on the\nserver and `voice.client`, `input.actions`, and `ui.nameplates` on the client. The default PTT key is\n`N`. The pause menu has a dedicated **VOICE** tab for:\n\n- microphone and output endpoint selection, including the Windows communications defaults;\n- independent 0–200% microphone and voice gains;\n- capture enable/disable;\n- the current proximity reach as read-only server policy;\n- a persistent, per-server push-to-talk key binding;\n- PTT or voice activation and its threshold;\n- local microphone loopback plus a live input meter.\n\nPreferences use endpoint- and resource-isolated client KVP storage. Device IDs may disappear after\na Windows device change; an empty ID always selects the current communications default. Microphone\ntest is forcibly stopped when the settings pane or pause menu closes. Because that KVP namespace is\nkeyed by the authenticated server endpoint, the pause package restores it only after the session is\nactive and the native audio endpoint catalogue is ready. A missing saved endpoint is retained and\nreported rather than silently overwritten with the Windows default.\n\nThe activation threshold and input meter intentionally share the exact same normalized RMS scale\n(`-60..-10 dBFS` mapped to `0..100%`). A threshold of 40% therefore opens transmission only when\nthe visible input meter reaches 40%; a short 250 ms hangover prevents clipped word endings. The\nwhite marker on the meter shows the configured threshold.\n\nServer configuration owns the codec quality for the whole session:\n\n```jsonc\n{\n \"voice\": {\n \"enabled\": true,\n \"quality\": \"standard\",\n \"proximityDistance\": 20.0,\n \"minimumClientProximityDistance\": 1.0,\n \"maximumClientProximityDistance\": 100.0,\n \"maximumChannelAudience\": 128,\n \"maximumSimultaneousTalkers\": 32,\n \"maximumTalkersPerRecipient\": 8,\n \"globalEgressKbps\": 0,\n \"recipientEgressKbps\": 0,\n \"ingressRejectLimit\": 200,\n \"ingressRejectWindowSeconds\": 10,\n \"ingressBackoffMilliseconds\": 250\n }\n}\n```\n\nQuality values are `low`, `standard`, `high`, and `ultra`. They select bounded Opus bitrate,\ncomplexity, packet-loss recovery, DTX, and maximum payload/rate limits. Clients cannot override the\nnegotiated quality.\n\nThe two bandwidth values use `0` for conservative automatic budgets derived from quality and\nserver capacity. The remaining limits bound fan-out amplification and sustained invalid media;\nthe server applies backoff before disconnecting an abusive voice sender.\n\n`Open77.voice.status()` exposes those effective audience/talker/egress limits\nand the metadata-only rejection counters (`egressPacketsDroppedGlobal`,\n`egressPacketsDroppedRecipient`, `egressBytesDropped`,\n`channelAudienceRejected`, `talkerLimitRejected`,\n`recipientTalkerLimitRejected`, `ingressBackoffDrops`,\n`ingressDisconnects`, and `activeTalkers`) under `statistics`. It never exposes\nPCM or Opus payloads.","natives":[],"permissions":[],"events":[]},{"anchor":"open-voice-gameplay-package","heading":"`open-voice` gameplay package","level":2,"text":"[`open-voice`](https://github.com/Open2077/open77-resources/tree/main/system/open-voice) is the pma-voice-style gameplay layer and auto-starts after\n`open77_voice`. The native resource remains the only capture/PTT/VAD/codec\ndriver. `open-voice` adds a retained bottom-right HUD and an authoritative reach-mode policy. The\nCyberM/Open77 panel renders the real normalized native input level, encoder transmission state,\ncanonical distance, server-defined presets, cycle key, PTT/VAD policy and active remote-talker\ncount. Its five states are `LISTENING`, `SIGNAL DETECTED`, `TRANSMITTING`, `MIC DISABLED`, and\n`VOICE OFFLINE`; it never fabricates audio activity in JavaScript:\n\n- `WHISPER` — 3 m;\n- `NORMAL` — 20 m;\n- `SHOUT` — 40 m;\n- `F11` cycles to the next mode by sending a request to the server.\n\nAll values are configured in `resources/system/open-voice/server/config.lua`. The\nclient request contains no distance or target mode: the server rate-limits the\nrequest, advances its own stored mode, calls `Open77.voice.setProximity`, then\nreturns the canonical state. A player therefore cannot select an arbitrary\nreach from the pause menu or by forging the cycle event.\n\nTrusted server resources may emit `open-voice:setPlayerMode(playerId, mode)` or\n`open-voice:cyclePlayerMode(playerId)` and observe\n`open-voice:modeApplied(playerId, mode, distance, label, color)`. These remain\nlocal server events and are deliberately not registered as client network\nevents.\n\nThe HUD keeps microphone activity and transmission distinct. The segmented\nmeter follows normalized native capture activity; the `TRANSMITTING` state is\nshown only while the native encoder is inside a real authorized talkspurt.\nThis distinction provides useful feedback in both PTT and voice-activation\nmodes.","natives":[],"permissions":[],"events":[]},{"anchor":"server-lua-api","heading":"Server Lua API","level":2,"text":"Every method below requires `voice.manage`. Channel mutations are resource-owned: a resource cannot\nalter another resource's channel. Non-persistent channels are removed automatically on stop or\nreload.\n\n| Method | Result | Purpose |\n|---|---|---|\n| `Open77.voice.status()` | `table` | Enabled state, quality/profile, default distance, counters. |\n| `channels()` / `getChannel(id)` | array / channel | Read canonical channel state. |\n| `participants()` / `getParticipant(playerId)` | array / participant | Read proximity, mute/deaf and memberships. |\n| `createChannel(options)` | channel | Create a `radio`, `phone`, `party`, `admin`, or `spatial` channel. |\n| `updateChannel(id, patch)` | channel | Change name, mode, reach, effect, or persistence. |\n| `removeChannel(id)` | `boolean, reason?` | Remove a channel owned by this resource. |\n| `addPlayer(id, playerId, permissions?)` | `boolean, reason?` | Add membership with speak/listen flags. |\n| `removePlayer(id, playerId)` | `boolean, reason?` | Remove membership. |\n| `setChannelPlayerMuted(id, playerId, muted)` | `boolean, reason?` | Mute inside one channel only. |\n| `setChannelPlayerPermissions(id, playerId, options)` | `boolean, reason?` | Change speak/listen independently. |\n| `setPlayerMuted(playerId, muted)` | `boolean, reason?` | Server-wide transmit mute. |\n| `setPlayerDeaf(playerId, deaf)` | `boolean, reason?` | Server-wide receive mute. |\n| `setProximity(playerId, options)` | `boolean, reason?` | Enable/disable proximity and set its reach. |\n| `setDefaultProximityDistance(distance, applyExisting?)` | `boolean, reason?` | Change the default reach. |\n| `setQuality(quality)` / `setEnabled(enabled)` | `boolean, reason?` | Change global voice policy. |\n\nChannel options and effect tuning:\n\n```lua\nlocal dispatch, reason = Open77.voice.createChannel({\n name = \"NCPD dispatch\",\n mode = \"radio\",\n persistent = false,\n effect = {\n gain = 1.0,\n highPassHz = 220.0,\n lowPassHz = 4800.0,\n distortion = 0.08,\n radioNoise = 0.04,\n spatialBlend = 0.0,\n reverbWet = 0.22,\n reverbRoomSize = 0.7,\n reverbDecay = 1.8,\n reverbDamping = 0.55,\n reverbPreDelayMs = 24.0,\n }\n})\nassert(dispatch, reason)\n\nassert(Open77.voice.addPlayer(dispatch.id, officerId, {\n canSpeak = true,\n canListen = true,\n}))\n```\n\n| Effect field | Range | Meaning |\n|---|---:|---|\n| `gain` | `0.0..4.0` | Channel gain before the local player/channel volume. |\n| `highPassHz` | `0.0..20000.0` | Removes frequencies below the cutoff. Must be below `lowPassHz`. |\n| `lowPassHz` | `20.0..24000.0` | Removes frequencies above the cutoff. |\n| `distortion` | `0.0..1.0` | Soft saturation drive. |\n| `radioNoise` | `0.0..1.0` | Deterministic narrow-band radio noise. |\n| `spatialBlend` | `0.0..1.0` | `0` is non-spatial; `1` is fully positional. |\n| `reverbWet` | `0.0..1.0` | Wet/dry mix. `0` disables the reverb and its DSP allocation. |\n| `reverbRoomSize` | `0.0..1.0` | Scales the virtual reflection delays. |\n| `reverbDecay` | `0.1..10.0` | Approximate RT60 tail duration in seconds. |\n| `reverbDamping` | `0.0..1.0` | High-frequency absorption inside the tail. |\n| `reverbPreDelayMs` | `0.0..250.0` | Delay before the first reflected sound. |\n\n`maxDistance` additionally constrains `spatial` channels. Reverb is a lightweight stereo delay\nnetwork retained per talker/channel route, so its tail continues after the source stops speaking.\nEffects are canonical channel metadata; a client only renders effects received from the server.\n\nServer packages call these methods directly, preserving resource ownership of\nchannel handles. A package can also wrap them in [server exports](server-exports.md):\nthe provider still owns the channel and must authorize its callers.","natives":["server:Open77.voice.createChannel","server:Open77.voice.addPlayer"],"permissions":[],"events":[]},{"anchor":"client-lua-api","heading":"Client Lua API","level":2,"text":"Every method requires `voice.client`. Device/gain/block settings are local presentation policy;\nthey never grant reachability or channel membership.\n\n| Method | Purpose |\n|---|---|\n| `Open77.voice.devices(flow?)` | Enumerate `input`, `output`, or `all` endpoints. |\n| `status()` | Capture/render/device/level/packet snapshot. |\n| `talkers()` | Remote talking state, level, local gain/block, and active routes. |\n| `selectDevice(flow, endpointId)` | Select an endpoint; `\"\"` means Windows default. |\n| `setCaptureEnabled(enabled)` | Enable or disable microphone capture. |\n| `setTransmitting(enabled, intent?)` | Change PTT state and requested route intent. |\n| `setMicrophoneTest(enabled)` | Local loopback; no network transmission is implied. |\n| `setInputVolume(gain)` / `setOutputVolume(gain)` | Set a finite gain from `0.0` to `2.0`. |\n| `setVoiceActivation(enabled, threshold?)` | Enable VAD with normalized threshold `0.0..1.0`. |\n| `setProximityDistance(distance)` | Request a personal reach; the server clamps and republishes it. |\n| `setPlayerVolume(playerId, gain)` | Local per-talker gain. |\n| `setPlayerBlocked(playerId, blocked)` | Local per-talker silence. |\n| `setChannelVolume(channelId, gain)` | Local gain retained for current and future route streams. |\n\nTransmit intents are `proximity`, `channels`, `channel:`, or `all`. `channels` selects up to\nfour currently authorized speakable memberships; `all` selects the same channels plus proximity.\nBoth are resolved by the native client in deterministic channel-ID order and are never sent as a\nwildcard on the wire.\n\nProximity is the permanent default voice route. A client can change only its\ndistance, never its enabled flag. `status()` exposes `proximityEnabled`, the\ncanonical `proximityDistance`, an in-flight `requestedProximityDistance` /\n`proximityRequestPending`, and the server-advertised\n`defaultProximityDistance`, `minimumProximityDistance`, and\n`maximumProximityDistance`. A successful setter result means the reliable\nrequest was queued (or was already canonical), not that the requested value was\naccepted verbatim.\n\nThe reference package maps `whisper=3`, `normal=server default`, and\n`shout=40`. All presets are configurable in `shared/config.lua`; setting a\ncycle key is intentionally opt-in to avoid stealing a gameplay binding:\n\n```lua\n-- In another input-capable package, using a rising edge rather than every tick.\nCreateThread(function()\n local held = false\n while true do\n local down = not Open77.input.isCaptured() and Open77.input.isDown(\"Z\")\n if down and not held then\n Open77.exports.call(\"open77_voice\", \"cycleProximityMode\", true)\n end\n held = down\n Wait(20)\n end\nend)\n```\n\n```lua\n-- One Opus frame, heard by nearby players and two authorized radio/call groups.\nlocal ok, reason = Open77.voice.setTransmitting(\n true, \"all\")\nassert(ok, reason)\n```\n\nThe host publishes `open77:voice:talkingChanged(playerId, talking, level, detail)` on the Lua tick\nthread. `open77_voice` mirrors it into `Open77.nameplates.setTalking`, which drives the native\nspeaker icon. A bounded talker snapshot loop is the recovery path for late join, stream-in, and a\ndropped event. Puppet lip motion consumes the same native talking/level state; resources should not\nattempt to infer speech by polling network packets.\n\nFor reach changes, the native host emits\n`open77:voice:proximityChanged(localPlayerId, enabled, 0, distance)` after the\ncanonical participant revision arrives. The package additionally emits\n`open77:voice:proximityRequested(distance, mode)` and\n`open77:voice:proximityModeChanged(mode, canonicalDistance, enabled)`.\n\nThe package exports `status`, `devices`, `getProximityDistance`,\n`setProximityDistance`, `getProximityMode`, `setProximityMode`,\n`cycleProximityMode`, `setPlayerVolume`, `setPlayerBlocked`, and\n`setChannelVolume` for convenience.","natives":["CreateThread","server:CreateThread","Open77.input.isCaptured","Open77.input.isDown","Open77.exports.call","server:Open77.exports.call","Wait","server:Wait","Open77.voice.setTransmitting"],"permissions":[],"events":[]},{"anchor":"reliability-and-bandwidth","heading":"Reliability and bandwidth","level":2,"text":"- Opus uses one 20 ms stream per speaker, not one stream per route.\n- Native VAD does not emit silent talkspurts. Held PTT silence uses Opus DTX, while congestion\n drops old media instead of playing it late.\n- Media uses a bounded unreliable queue while config, membership, and channel state are reliable.\n- A jitter buffer, packet-loss concealment, and optional FEC live in the native audio engine.\n- Late join first receives canonical voice config, participant memberships, and channels; media is\n buffered but silent until an authorized route exists.\n- Disconnect, resource reload, bucket changes, mute/deaf changes, and device restarts clear stale\n routes and talking indicators.\n- Channel metadata is visible only to members, and another participant's private membership list\n is projected only through channels shared with the viewer.\n- The client follows Windows communication-default changes and automatically retries an invalidated\n input or output endpoint without blocking the game thread.","natives":[],"permissions":[],"events":[]},{"anchor":"voice-driven-lipsync","heading":"Voice-driven lipsync","level":2,"text":"Client resources can enable or suppress mouth animation globally or per player.\nSee [Voice lipsync](voice-lipsync.md) for the Lua API, supported facial graphs,\nresource ownership and limitations. This is an audio-energy envelope, not\nphoneme recognition or prerecorded quest dialogue.","natives":[],"permissions":[],"events":[]},{"anchor":"runtime-diagnostics","heading":"Runtime diagnostics","level":2,"text":"Authenticated development sessions expose metadata-only commands; no PCM or Opus payload is logged:\n\n```text\nvoice.status\nvoice.devices [input|output]\nvoice.mictest on|off\nvoice.transmit on|off [proximity|channels|all|channel:]\nvoice.lipsync <0..1|off>\nvoice.lipsyncstate \n```\n\n`voice.lipsync` is a bounded visual laboratory override for validating a streamed proxy's facial\ngraph. It is not a network or Lua route and is cleared on stream-out and session teardown.\n\nLua intentionally has no raw audio callback, recipient list, or codec-packet API. This keeps the\naudio thread isolated, prevents scripts from bypassing server reachability, and makes bandwidth\nlimits enforceable.","natives":[],"permissions":[],"events":[]}]},{"slug":"warden-players","file":"warden-players.md","title":"The Players tab in Warden","summary":"Manage connected players in Warden's **Players** tab. The live roster refreshes automatically and provides warnings, kicks, bans, in-game permissions, healing, freezing and movement controls.","sections":[{"anchor":"the-players-tab-in-warden","heading":"The Players tab in Warden","level":1,"text":"Manage connected players in Warden's **Players** tab. The live roster refreshes automatically and provides warnings, kicks, bans, in-game permissions, healing, freezing and movement controls.\n\nThis page covers the roster columns and the per-row actions. The whitelist and ban\ndoor list has its own tab and its own page — see\n[Connection control](connection-control.md). Managing operator accounts and roles is\nin [Identity and ACL](server-acl.md).","natives":[],"permissions":[],"events":[]},{"anchor":"permissions","heading":"Permissions","level":2,"text":"Every action is gated separately, so a role can hold one without the others. A role\nholding `players.view` sees the tab; each button appears only when the session's role\nholds its permission, and the server enforces it again on every call.\n\n| Permission | Allows |\n| --- | --- |\n| `players.view` | See the tab and the roster. |\n| `players.warn` | Send an admin message to one player. |\n| `players.kick` | Disconnect a player (they can rejoin). |\n| `players.ban` | Ban a player's identity and disconnect them. |\n| `players.heal` | Restore a player to full health, or revive them where they fell. |\n| `players.freeze` | Hold a player's body still, and release it again. |\n| `players.teleport` | Move a player to coordinates or to another player, and bring a player to another. |\n| `acl.edit` | Grant or revoke in-game rights from a row (the *Rights* button). |\n| `access.edit` | Add a player to the whitelist from a row (the *Allow* button). |\n\n`players.heal`, `players.freeze` and `players.teleport` sit beside the moderation\nfour rather than inside them on purpose. Removing tonight's troublemaker and pinning\na player in place during a match are different authorities: a server can hand a\ngreeter role the whitelist *Allow* button and a game designer the freeze and move\nbuttons without either becoming a full moderator. The owner role (`*`) holds them all.","natives":[],"permissions":[],"events":[]},{"anchor":"the-roster-columns","heading":"The roster columns","level":2,"text":"| Column | What it shows |\n| --- | --- |\n| **ID** | The player's numeric session id. Every action is keyed on it. |\n| **Name** | The display name, with how long ago they connected. |\n| **Identity** | The account GUID, shortened; click to copy the full value. |\n| **Ping** | Round-trip time in milliseconds, coloured by health. A dash means the transport has no sample yet — never a misleading `0 ms`. |\n| **Health** | Current health as a percentage of their maximum, with armour beside it. A dash means the server holds no health record for this player. |\n| **State** | `alive` is the quiet default and shows nothing; `dead`, `loading`, `frozen`, `god`, `ghost`, `hidden`, `in vehicle`, `watching #N` and `not ready` each show a chip. |\n| **Match** | The routing bucket: `lobby` (bucket 0, the shared world) or `#N` (an isolated match — a Pursuit round, say). Two players in different buckets cannot see each other. |\n| **Rights** | Their in-game role or grant count, from the ACL, and a *listed* chip when they are on an enabled whitelist. |\n| **Position** | Their last known X, Y, Z. Past two seconds it is shown greyed with its age, because \"one late tick\" and \"never reported\" are different facts an operator must be able to tell apart. |\n\nThe roster deliberately carries **no IP address**. The server knows one, and the\n`Open77.players.endpoint` read puts it behind the `players.identity.sensitive` manifest\ncapability precisely because an endpoint is the one field that can follow a player home.\nA browser panel on a box an operator may share is not a stronger place to keep it than a\nresource manifest, so it is not published here at all.","natives":[],"permissions":[],"events":[]},{"anchor":"heal","heading":"Heal","level":2,"text":"The **Heal** button restores a living player to full health and tells them an\nadministrator did it. On a **dead** player the button reads **Revive** instead and\nbrings them back where they fell at full health with a short grace window — a dead body\ncannot be topped up, so the one action does the right one of the two and the toast says\nwhich. Health writes go through the same stats authority `Open77.players.setHealth`\nuses, so god mode and the life-phase interlocks still apply.\n\nEvery panel heal — and only a panel heal — raises the host-wide event\n`open77:admin:playerHealed(playerId, author)`. A resource calling `Open77.players.heal`\ndoes **not** raise it: `open77:admin:` means \"the operator's own surface did this\", and\nannouncing every gameplay heal under that name would be a firehose and a lie. See\n[Admin events](server-api.md#admin-events).","natives":[],"permissions":[],"events":[]},{"anchor":"freeze","heading":"Freeze","level":2,"text":"**Freeze** holds a player's body still; the button then reads **Thaw**. A frozen player\nkeeps their camera, chat, voice, menus and interaction prompts, and still takes damage —\nit is a primitive, not a policy. See [Player freeze](player-freeze.md) for exactly which\ninputs it blocks.\n\nThe hold is **cooperative and keyed to the panel**. A gamemode script may hold the same\nplayer at the same time, and *Thaw* drops only the panel's claim: if a script still holds\nthem, they stay frozen and the toast says so, rather than letting an operator think they\nreleased somebody they did not. The panel's claim is released automatically on the\nplayer's death, respawn, bucket change and disconnect, the same as any other holder — a\nplayer can never be stranded frozen.","natives":[],"permissions":[],"events":[]},{"anchor":"move","heading":"Move","level":2,"text":"**Move** opens a dialog with three modes:\n\n- **To coordinates** — prefilled with the player's own position, so a small nudge is a\n small edit. Their routing bucket is left alone.\n- **To another player** — the player is placed beside that player (offset to the side so\n two bodies never resolve inside each other) and into that player's routing bucket.\n- **Bring another player here** — the inverse: pick somebody else, and they are moved\n beside this player, into this player's bucket.\n\nThe dialog warns before you click when the player to be moved is dead, still behind the\nreadiness gate, or in a vehicle (a move is refused unless you tick *Take them out of a\nvehicle first*).\n\nA move is the one admin action that is **not** instant. The server does not move the\nbody; it asks the owning client to, and the client fades the screen, teleports, then\nwatches the body settle on solid ground for three frames before answering — because a\ndirect write over any real distance can drop a player through world that has not streamed\nin, whereupon the engine's fall-under-world failsafe silently returns them to the save's\nspawn point kilometres away. So the panel waits for the client's answer and reports what\nthe **body** did: `arrived`, or a named refusal (`player_in_vehicle`,\n`player_not_alive`, `settle_timeout`). A move whose client never answers in time is\nreported as *pending*, not as a success — it may still land, and the panel does not\nguess.\n\nBucket changes happen before the body moves, so an observer left behind never glimpses\nthe destination; if the move is refused, the bucket is rolled back so the player is never\nleft alone in a world nobody meant them to see.","natives":[],"permissions":[],"events":[]},{"anchor":"spectate-is-not-available","heading":"Spectate is not available","level":2,"text":"txAdmin's player panel has a spectate button. Warden's does not, and it is not an\noversight. The platform *has* a spectate primitive (`Open77.players.spectate`), but it\nneeds a spectator who is a connected player: it ghosts that player, hides their body and\nputs *their client's camera* on the target. A Warden session is a browser with no body to\nghost and no renderer to receive frames, and Cyberpunk 2.31 exposes no server-side view of\nthe world to stream to one. The Players tab therefore *reports* who a player is watching\n(the `watching #N` chip) but offers no way to start one. An operator who wants to watch a\nplayer joins the server and uses `Open77.players.spectate` from an in-game admin resource,\nwith a body.","natives":[],"permissions":[],"events":[]},{"anchor":"everything-is-audited","heading":"Everything is audited","level":2,"text":"Every action writes one line to the audit trail (the **Audit** tab, permission\n`audit.view`): the operator's panel username, the action (`players.heal`,\n`players.freeze`, `players.unfreeze`, `players.teleport`, `players.bring`), the target and\nthe outcome. A refused action is recorded as failed with its reason, so the trail is a\ntrue record of what an operator did and what actually happened — not only what they tried.","natives":[],"permissions":[],"events":[]}]},{"slug":"weapons-api","file":"weapons-api.md","title":"Weapon Lua API","summary":"Manage the local player's three `EquipmentArea.Weapon` slots with the asynchronous client API. Use [`open77_weapons`](https://github.com/Open2077/open77-resources/tree/main/system/open77_weapons) for authenticated server-to-owner requests and exact TweakDB IDs such as `Items.Preset_Lexington_Default`.","sections":[{"anchor":"weapon-lua-api","heading":"Weapon Lua API","level":1,"text":"Manage the local player's three `EquipmentArea.Weapon` slots with the asynchronous client API. Use [`open77_weapons`](https://github.com/Open2077/open77-resources/tree/main/system/open77_weapons) for authenticated server-to-owner requests and exact TweakDB IDs such as `Items.Preset_Lexington_Default`.\n\nFor reload speed, recoil, advanced native statistics and impact blasts,\nsee [Weapon customization](weapon-customization.md) and the English\n[rp_weapons_effect workshop](https://github.com/Open2077/open77-resources/tree/main/rp/rp_weapons_effect).\nTuning requires a compatible development client and uses separate, resource-owned\n`setTuning`, `clearTuning` and `tuning` calls.\n\nThe three-slot surface intentionally rejects heavy weapons (`WeaponHeavy`) and\narm cyberware (`ArmsCW`): those equipment areas have different REDengine\nlifecycles. Grenades are not weapon slots either — they are `QuickSlot`\ngadgets thrown from a hotkey — and have their own calls, [below](#gadgets-grenades-in-the-quick-slots).\nParts that go *onto* a weapon (a scope, a silencer, a mod) are\n[components](#weapon-components-scopes-muzzles-and-mods).","natives":[],"permissions":[],"events":[]},{"anchor":"grenades-and-healing-charges","heading":"Grenades and healing charges","level":2,"text":"Use [Open77.consumables](consumables-api.md) to configure usable grenade and\nhealing charges, select MaxDoc or Bounce Back, equip or unequip the hotkey,\nand control regeneration. `giveGadget` changes inventory membership; its stack\ncount is not the number of throws. The consumables guide covers asynchronous\ncompletion, actual-use events and server-authoritative healing.","natives":[],"permissions":[],"events":[]},{"anchor":"client-permissions-and-methods","heading":"Client permissions and methods","level":2,"text":"Declare `player.weapons.read` for `slots`, `snapshot`, and `all`. Declare\n`player.weapons.edit` for mutation.\n\n| Method | Purpose |\n|---|---|\n| `Open77.weapons.slots()` | Return `{ 1, 2, 3 }`. |\n| `Open77.weapons.assign(record, slot, options?)` | Put a template in one exact slot. |\n| `Open77.weapons.setActive(slot)` | Select and draw an assigned slot. |\n| `Open77.weapons.setActive(record, options?)` | Reuse or equip a template, then draw it. |\n| `Open77.weapons.activate(...)` | Alias of `setActive`. |\n| `Open77.weapons.remove(slot)` / `unequip(slot)` | Clear a slot without deleting inventory. |\n| `Open77.weapons.holster()` | Holster without changing the loadout. |\n| `Open77.weapons.setAmmo(slot, amounts)` | Set exact spare rounds and/or magazine rounds. |\n| `Open77.weapons.snapshot()` / `all()` | Request every slot's verified state. |\n| `Open77.weapons.setComponent(slot, part, options?)` | Install a scope, muzzle or mod on the weapon in a slot. |\n| `Open77.weapons.removeComponent(slot, attachmentSlot)` | Empty one attachment slot of that weapon. |\n| `Open77.weapons.components(slot)` | List that weapon's attachment slots, taken or empty. |\n| `Open77.weapons.giveGadget(record, count?, options?)` | Grant grenades and put them on the throw hotkey. |\n| `Open77.weapons.takeGadget(record?, count?)` | Take grenades back. |\n| `Open77.weapons.gadgets()` | List the quick slots. |\n\nThe bundled `open77_weapons` resource adds one more as an export:\n`exports.open77_weapons:clear()` empties all three slots at once. It is three\nnative removals aggregated into one answer rather than three the caller has to\ncorrelate, and a slot that was already empty is not a failure.\n\n`assign` options are `active` (default `false`) and `addToInventory` (default\n`true`). Template-form `setActive` accepts `slot` as a placement fallback and\n`addToInventory`; if the template is already assigned, its existing slot wins.\n\n`setAmmo` accepts `{ reserve = 120, magazine = 18, activate = true }`. At least\none count is required; an omitted count is preserved. `reserve` is the spare\nHUD count, while `magazine` is the loaded count and may not exceed the live\nweapon capacity. `activate = true` selects and draws the requested slot when\nneeded; otherwise a non-instantiated weapon returns `weapon_not_drawn`.\n\n```lua\nlocal requestId, reason = Open77.weapons.assign(\n \"Items.Preset_Lexington_Default\", 1,\n { active = true, addToInventory = true })\nassert(requestId, reason)\n\nOpen77.weapons.assign(\"Items.Preset_Katana_Default\", 2)\nOpen77.weapons.setActive(2)\nOpen77.weapons.setAmmo(1, { reserve = 120, magazine = 18, activate = true })\n```\n\nThe returned request ID only proves that the request was validated and queued.\n`EquipmentSystemPlayerData` owns the actual loadout and runs in REDscript, so\ncompletion is asynchronous:\n\n```lua\nAddEventHandler(\"open77:weapons:completed\", function(\n requestId, operation, accepted, reason, slot, record, tweakDbId, active, drawn,\n ammoRecord, ammoTweakDbId, ammoTotal, ammoReserve, magazine, capacity)\n if accepted ~= \"true\" then\n print(\"weapon request failed\", operation, reason)\n end\nend)\n```\n\nClient event arguments use the generic string event transport. Compare boolean\nfields with `\"true\"`, IDs with `tostring(requestId)`, and retain `tweakDbId` as\nan opaque hexadecimal string.\n\nA snapshot emits one state row per slot before its completion:\n\n```lua\nAddEventHandler(\"open77:weapons:state\", function(\n requestId, slot, record, tweakDbId, active, drawn, locked,\n ammoRecord, ammoTweakDbId, ammoTotal, ammoReserve, magazine, capacity)\n print(slot, record, active, drawn, locked)\nend)\n```\n\nAn empty `tweakDbId` means an empty slot. `record` is restored for templates\npreviously validated through this API; it may remain empty for an item equipped\nby another pipeline because shipping REDengine builds do not always retain the\nreverse TweakDB name table. Use `equipped`/`tweakDbId` as the presence test.\nAmmo fields report the shared total, spare reserve, loaded magazine and live\ncapacity. An inactive slot can still report its ammo type and total, but its\nreserve/magazine/capacity are unavailable (`-1`) until the weapon object exists.","natives":["Open77.weapons.assign","server:Open77.weapons.assign","Open77.weapons.setActive","server:Open77.weapons.setActive","Open77.weapons.setAmmo","server:Open77.weapons.setAmmo","AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:weapons:completed","open77:weapons:state"]},{"anchor":"server-targeted-api","heading":"Server-targeted API","level":2,"text":"A server resource declares:\n\n```lua\ndependency \"open77_weapons >=0.1.0\"\npermission \"network.events\"\n```\n\nIt can then target the authenticated owner client:\n\n```lua\nlocal requestId, reason = Open77.weapons.assign(\n playerId, \"Items.Preset_Lexington_Default\", 1, { active = true })\nassert(requestId, reason)\n\nOpen77.weapons.setActive(playerId, 1)\nOpen77.weapons.setActive(\n playerId, \"Items.Preset_Katana_Default\", { slot = 2 })\nOpen77.weapons.remove(playerId, 3)\nOpen77.weapons.holster(playerId)\nOpen77.weapons.setAmmo(\n playerId, 1, { reserve = 120, magazine = 18, activate = true })\nOpen77.weapons.requestSnapshot(playerId)\n\n-- Every slot at once: the RemoveAllPedWeapons equivalent.\nOpen77.weapons.clear(playerId)\n```\n\n`clear` is **one** request, not three `remove` calls. The owner runs the three\nnative removals itself and answers once, so there is one request id to correlate\ninstead of three and no way to end up half-cleared because one leg was refused. A\nslot that was already empty answers `weapon_slot_empty` natively and is **not**\ncounted as a failure — \"remove every weapon\" succeeded on a slot that had none.\nThe completion's `result` carries `cleared` and a `slots` array of\n`{ slot, accepted, reason }`.","natives":["Open77.weapons.assign","server:Open77.weapons.assign","Open77.weapons.setActive","server:Open77.weapons.setActive","Open77.weapons.remove","server:Open77.weapons.remove","Open77.weapons.holster","server:Open77.weapons.holster","Open77.weapons.setAmmo","server:Open77.weapons.setAmmo","server:Open77.weapons.requestSnapshot","server:Open77.weapons.clear"],"permissions":[],"events":[]},{"anchor":"weapon-components-scopes-muzzles-and-mods","heading":"Weapon components: scopes, muzzles and mods","level":2,"text":"A scope, a silencer or a generic mod is a **part** sitting in an **attachment\nslot** of the weapon *item*. 2.31 installs one through the vanilla\n`ItemModificationSystem` — the scripted system the inventory screen queues\n`InstallItemPart` / `RemoveItemPart` into — over two natives,\n`TransactionSystem.ForcePartInSlot` and `RemovePart`. Open77 drives exactly that\npath from the bridge script, so a part installed here is a real part: it shows\non the owner's weapon, it survives holstering, and the vanilla inventory screen\nsees it as its own.\n\nThe vocabulary is TweakDB's, not Open77's:\n\n| What | Records | Examples |\n|---|---|---|\n| Slots | `AttachmentSlots.*` | `Scope`, `PowerModule` (muzzles), `Power_Handgun_WeaponMod1` / `..2` (generic mods, one pair per evolution and class) |\n| Parts | `Items.*` tagged `itemPart` (928 on 2.31) | `Items.w_att_scope_short_01`, `Items.w_att_scope_long_02`, `Items.w_silencer_01`, `Items.w_muzzle_brake_01` |\n\nWhich slots a given weapon has comes from its blueprint, and `components` reads\nit off the live item rather than guessing: a Lexington declares `Scope`,\n`PowerModule` and the two `Power_Handgun_WeaponMod` slots beside its receiver,\nbarrel and magazine; a katana declares `Blade_WeaponMod1/2` and `Grip`. The\nfull tables — 59 slots, 928 parts, every `Items.*` weapon's declared slots — are\nin `docs/generated/attachment-slots-2.31.csv`, `weapon-parts-2.31.csv` and\n`weapon-part-slots-2.31.csv`.\n\n```lua\n-- Client: a short scope on the rifle in slot 1, then read the slots back.\nlocal requestId, reason = Open77.weapons.setComponent(1, \"Items.w_att_scope_short_01\")\nassert(requestId, reason)\nOpen77.weapons.components(1)\n\nAddEventHandler(\"open77:weapons:part\", function(\n requestId, slot, attachmentSlot, attachmentSlotId, taken, base, record, tweakDbId)\n -- one row per attachment slot; `base == \"true\"` is the weapon's own receiver,\n -- barrel or magazine, which is not a mod and cannot be removed\n print(slot, attachmentSlot, taken, base, record)\nend)\n```\n\n`setComponent` without `options.attachmentSlot` picks the first of the part's\nown placement slots that this weapon declares, empty ones first — so a generic\nmod finds the pistol slot on a pistol, where vanilla's `GetPlacementSlot` would\nname the rifle one. Naming the slot is refused by name when it is not on the\nweapon (`slot_not_on_weapon`), when the part cannot go there\n(`part_does_not_fit_slot`), or when it holds a base part (`base_part_slot`). A\npart the player does not own is created for the install, as the inventory\nscreen's is consumed by it; a part already in the slot is swapped out and\ndeleted, exactly as the vanilla screen does. The completion carries the changed\nslot as `result.part` and a `components` request carries `result.parts`.\n\n```lua\n-- Server: the armoury fits a silencer to whatever is in slot 1, then lists it.\nOpen77.weapons.setComponent(playerId, 1, \"Items.w_silencer_01\",\n { attachmentSlot = \"AttachmentSlots.PowerModule\" })\nOpen77.weapons.requestComponents(playerId, 1)\nOpen77.weapons.removeComponent(playerId, 1, \"AttachmentSlots.PowerModule\")\n\n-- The synchronous read, from the cache the owner keeps fed.\nlocal fitted = Open77.weapons.components(playerId, 1)\nfor _, part in ipairs(fitted and fitted.parts or {}) do\n print(part.attachmentSlot, part.record)\nend\n```\n\nThe owner's loadout report carries the installed, non-base parts of each slot,\nso `Open77.weapons.get(playerId).slots[n].parts` and\n`Open77.weapons.components(playerId, n)` answer without a round trip, dated by\nthe same `loadoutAgeMs` as the slot list.\n\n**What other players see.** A part changes the owner's own weapon and the\nserver's cache. It does **not** change what other players see on the owner's\nproxy: the proxy weapon is rebuilt from the `ItemID` the 20 Hz snapshot carries\n(TweakDBID, seed, counter, structure), and parts are per-inventory-item data the\n`ItemID` does not encode. Shipping that visual is a wire change and is not in\nthis release; a server that needs remote players to *see* the scope has no path\nyet, and one that needs to *know* about it has the cache.","natives":["Open77.weapons.setComponent","server:Open77.weapons.setComponent","Open77.weapons.components","server:Open77.weapons.components","AddEventHandler","server:AddEventHandler","server:Open77.weapons.requestComponents","Open77.weapons.removeComponent","server:Open77.weapons.removeComponent"],"permissions":[],"events":["open77:weapons:part"]},{"anchor":"gadgets-grenades-in-the-quick-slots","heading":"Gadgets: grenades in the quick slots","level":2,"text":"Grenades are `EquipmentArea.QuickSlot` gadgets, not weapon slots. `giveGadget` grants a counted stack and equips quick slot 1 unless `equip = false`. Use a grenade record such as `Items.GrenadeFragRegular`, `Items.GrenadeEMPRegular` or `Items.Preset_Grenade_Smoke_Default`; catalogue entries use `category = grenade`.\n\n```lua\n-- Client\nOpen77.weapons.giveGadget(\"Items.GrenadeFragRegular\", 3) -- three, equipped, on RB\nOpen77.weapons.takeGadget(\"Items.GrenadeFragRegular\", 1) -- one back\nOpen77.weapons.takeGadget() -- every one of the active gadget\nOpen77.weapons.gadgets()\n\nAddEventHandler(\"open77:weapons:gadget\", function(\n requestId, quickSlot, record, tweakDbId, quantity, active, difference)\n -- requestId \"0\": the engine consumed one (the player threw it); `difference`\n -- is the change and `quantity` the count left\nend)\n```\n\n```lua\n-- Server: a medic's kit, counted.\nOpen77.weapons.giveGadget(playerId, \"Items.GrenadeFragRegular\", 2, { equip = true })\nOpen77.weapons.requestGadgets(playerId)\n\n-- And the cache: the count after the last throw, with its own age.\nlocal kit = Open77.weapons.gadgets(playerId)\nif kit then print(kit.active, #kit.slots, kit.reportedAgeMs) end\n\nAddEventHandler(\"onGadgetConsumed\", function(playerId, record, remaining)\n print(playerId .. \" threw \" .. record .. \", \" .. remaining .. \" left\")\nend)\n```\n\n**The consume half, and what a server sees of the explosion.** A throw is the\nengine removing one unit from the stack. The bridge script registers an\ninventory listener on the local player (the same `InventoryScriptCallback` the\nvanilla currency and shard toasts use), reports the shrink with request id `0`,\nand the `open77_weapons` client resource — which knows whether one of its own\n`takeGadget` requests is in flight — pushes it to the server on the reserved\n`open77:weapons:gadgets` transport as a consumption. The host republishes it as\n`onGadgetConsumed(playerId, record, remaining[, tweakDbId])`, a reserved name a\nresource cannot forge, and refreshes the cache behind `gadgets()`.\n\nNative grenade explosions already replicate through the thrower's snapshot and report hits to the damage arbiter. Do not also call `Open77.effects.explosion` for the same grenade: that would apply damage twice. Each detonation a player's client observed is republished on the server as the reserved event `onPlayerExplosion(playerId, sequence, x, y, z, count, bucket)`. It is the owner's **report**, not a verdict: a resource that must trust it pairs it with a consumed grenade, which is exactly what the [blast relay](#explosions-on-every-screen-the-blast-relay) does before it moves anything. Healing inhalers and other `Consumable` items are outside this API; apply healing through server-owned stats.","natives":["Open77.weapons.giveGadget","server:Open77.weapons.giveGadget","Open77.weapons.takeGadget","server:Open77.weapons.takeGadget","Open77.weapons.gadgets","server:Open77.weapons.gadgets","AddEventHandler","server:AddEventHandler","server:Open77.weapons.requestGadgets"],"permissions":[],"events":["open77:weapons:gadget","open77:weapons:gadgets"]},{"anchor":"explosions-on-every-screen-the-blast-relay","heading":"Explosions on every screen: the blast relay","level":2,"text":"Only the client that simulates a car can move it, and a vanilla explosion runs its physics on one\nclient: the grenade's thrower, the missile's shooter, the simulator of the car that blew up.\nWithout help, only that screen sees cars jump and NPCs fall. The server side of `open77_weapons`\nrelays every explosion the server can vouch for:\n\n| Source | Accepted when |\n|---|---|\n| Grenade: `onPlayerExplosion` | paired with a frag, incendiary or cutting grenade the same player spent, reported by `onConsumableUsed` (a grenade charge) or `onGadgetConsumed` (a quick-slot stack that shrank): one detonation per grenade, within 80 m of where it was thrown (160 m for a sticky one) and 160 m of the thrower, in the thrower's bucket, three at once then one every two seconds |\n| Car: its canonical `exploded` flag rising | once per explosion; a wreck created as a wreck is not one |\n| Mounted weapon: `onVehicleWeaponExplosion` | the vehicle weapon ticket admitted the projectile's explosion (missiles, cannons and exploding rounds) |\n| `exports.open77_weapons:relayBlast(request)` | another server resource admitted it |\n\nFor each accepted blast the relay sends `open77_weapons:blast` to the physics owner of every other\ncar in reach, listing that owner's cars (its client pushes them with\n[`Open77.weapons.applyBlast`](/docs/api/client/open77-weapons#applyblast), from its own poses),\ngrants an ownerless car to the source, and tells the nearest players within 150 m of the blast\nradius to knock down their own copies of living network NPCs in range. The source's own cars and\nNPC copies are not repeated: its engine already moved them. Radius and impulse come from server\ntables per explosion family (frag: radius 5.5 m); no client supplies them, and the relay deals no\ndamage. A client without `Open77.weapons.applyBlast` ignores the relay.\n\nKnocking **players** down is a gamemode rule and is off until a gamemode asks for it:\n\n```lua\n-- Server; publish again whenever this resource or open77_weapons (re)starts.\nlocal function publishBlastPolicy()\n local promise, reason = Open77.exports.call(\"open77_weapons\", \"setBlastPolicy\", {\n players = true, -- launch players in reach\n safeZones = { { x = -1448, y = 96, z = 17, radius = 30 } },\n buckets = { 0 }, -- optional\n })\n if not promise then print(\"blast policy not sent: \" .. tostring(reason)); return end\n local accepted, failure = promise:await()\n if accepted ~= true then print(\"blast policy refused: \" .. tostring(failure)) end\nend\nAddEventHandler(\"onResourceStart\", function(name)\n if name == GetCurrentResourceName() or name == \"open77_weapons\" then CreateThread(publishBlastPolicy) end\nend)\n```\n\nThe launch is decided twice: when the blast is admitted and again just before each launch, so a\nplayer who reached a safe zone meanwhile, or a policy turned off meanwhile, is not launched.\nFreeroam does this with its spawn safe zones and the street bucket (its `combat.blastKnockdown`\nsetting, on by default). A policy lapses when its resource stops; `players = false` also forbids\nexplicit launches requested through `relayBlast`.\n\n| Control | Effect |\n|---|---|\n| convar `open77_blasts` | `off` (or `0`, `false`) turns every source off; default on |\n| `/weapon.blasts` (ACL `command.weapon.blasts`) | prints counters, blasts per kind and refusals by `kind:reason` |\n| `exports.open77_weapons:blastStats()` | the same counters as a table |\n\n`relayBlast(request)` lets another server resource relay a blast it admitted itself. The request is\n`{ kind = \"tuned\" | \"scripted\", source = playerId | 0, bucket (when source is 0), x, y, z, radius,\npush, lift, falloff?, serverSide?, players?, diagnostics?, sequence? }`. It refuses anything outside\nthe client relay's own limits (radius above 0 and at most 60 m, push and lift 0–40 m/s with a\nnon-zero sum, falloff 0–4, default 1), a centre more than 250 m from the source, or a dead source\nfor `tuned`. It answers `{ vehicles, owners, characterHolders, accepted, rejected, reasons,\nclosest, ... }` or `nil, reason` (`disabled`, `invalid_blast`, `invalid_source`, `invalid_bucket`,\n`position_unavailable`, `body_unavailable`, `out_of_range`, `global_rate`, `source_rate`). It is\nrate-limited per source player, or per invoking resource for a world blast.\n\n```lua\n-- Server: a scripted blast at a fixed point in bucket 0, pushing cars and knocking down NPCs.\nRegisterCommand(\"testblast\", function(source)\n local result, reason = exports.open77_weapons:relayBlast({\n kind = \"scripted\", source = 0, bucket = 0,\n x = -1442.2, y = 127.4, z = 18.0, radius = 8, push = 10, lift = 8,\n })\n if not result then print(\"blast refused: \" .. tostring(reason)); return end\n print((\"blast reached %d cars across %d owners\"):format(result.vehicles, result.owners))\nend, true)\n```","natives":["Open77.exports.call","server:Open77.exports.call","AddEventHandler","server:AddEventHandler","GetCurrentResourceName","server:GetCurrentResourceName","CreateThread","server:CreateThread","RegisterCommand","server:RegisterCommand"],"permissions":[],"events":[]},{"anchor":"reading-a-players-weapons-from-the-server-synchronously","heading":"Reading a player's weapons from the server, synchronously","level":2,"text":"Everything above is a *request*: the answer comes back later, on an event. That is\nunusable inside a decision. An anticheat check, a shop validating a sale and an\ninventory sync reconciling a slot all have to conclude **now**, and an answer that\narrives ten milliseconds later is an answer to a question nobody is still asking.\n\n`Open77.weapons.get(playerId)` answers immediately:\n\n```lua\n-- manifest: permission \"player.weapons.read\"\nlocal weapons, reason = Open77.weapons.get(playerId)\nif weapons == nil then return end -- \"weapons_unreported\" when nothing is known\n\nprint(weapons.active, weapons.activeRecord, weapons.drawn, weapons.magazine)\nfor _, slot in ipairs(weapons.slots) do\n print(slot.slot, slot.record, slot.active, slot.drawn, slot.ammo and slot.ammo.magazine)\nend\n```\n\n| Field | Meaning |\n|---|---|\n| `slots` | one row per slot `1..3`, in the shape the relay's snapshot already answers with |\n| `active` | the **selected** slot, or absent when none is known |\n| `activeRecord`, `activeTweakDbId` | the selected weapon; the id uses the same `0x%016X` spelling as the relay |\n| `drawn` | the weapon is in hand. Holstering clears this without clearing `active` |\n| `magazine` | rounds in the drawn weapon; absent when the client did not report it |\n| `reportedAgeMs` | how old `active`, `drawn` and `magazine` are |\n| `loadoutAgeMs` | how old `slots` is |\n| `fresh` | `reportedAgeMs` passes the same two-second rule `Open77.players.get` applies |\n| `source` | `report`, `snapshot`, or the call fails |","natives":["server:Open77.weapons.get"],"permissions":[],"events":[]},{"anchor":"it-is-a-cache-and-the-staleness-is-yours-to-judge","heading":"It is a cache, and the staleness is yours to judge","level":3,"text":"**Weapons are client-owned.** They live in the client's REDengine equipment\nsystem; the server keeps no copy of that state and has no way to verify one. Every\nfield above is **as true as the owner's last report**, and a client can lie about\nits own weapons exactly as it always could. Read this to *decide* — refuse a sale,\nflag an anomaly, reconcile an inventory — never to *assert*.\n\nWhich is why there are **two** ages rather than one, and why mixing them up is the\nfirst mistake a caller makes:\n\n* `reportedAgeMs` dates the drawn weapon, whether it is in hand, and the magazine.\n Those ride the ordinary **20 Hz player snapshot**, which the client already\n sends — no extra traffic, no request. While a player is simulating this is tens\n of milliseconds; it stops advancing the moment they are not.\n* `loadoutAgeMs` dates the **slot list**, which the owner pushes only when the\n loadout *changes*. An untouched loadout is legitimately minutes old and that is\n not a fault. Rejecting it for age would reject every player who has not swapped\n a gun since they connected.\n\n`source` tells you which halves you have. `report` means the owner has described\nits slots at least once. `snapshot` means only the packet's weapon block has\narrived, so `activeTweakDbId` and `drawn` are real but `slots` is empty and\n`active` is unknown — the packet carries an id, not a slot number. A player nobody\nhas heard anything about at all fails with `weapons_unreported`, which is\ndeliberately different from a player carrying nothing.\n\n```lua\n-- The shape an anticheat actually wants.\nlocal weapons = Open77.weapons.get(playerId)\nif weapons and weapons.fresh and weapons.source == \"report\"\n and (weapons.loadoutAgeMs or math.huge) < 60000 then\n -- recent enough for this rule; otherwise ask, do not assume\nend\n```","natives":["server:Open77.weapons.get"],"permissions":[],"events":[]},{"anchor":"onplayerweaponchanged","heading":"`onPlayerWeaponChanged`","level":3,"text":"A host-wide server event, fired **once per change** and never for a report that\nchanges nothing — a player standing still resends the same weapon block twenty\ntimes a second, and an event that repeated it would be unusable.\n\n```lua\nAddEventHandler(\"onPlayerWeaponChanged\", function(playerId, slot, record, drawn)\n playerId, slot = tonumber(playerId), tonumber(slot)\n if drawn == \"true\" then print(playerId .. \" drew \" .. record) end\nend)\n```\n\nArguments are strings, like every other host event. It fires when a slot's\ncontents change, and when the selection or `drawn` changes — reported against the\nslot concerned. The first report after a connection fires too, so a handler that\nonly ever hears about changes still learns the initial loadout.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":[]},{"anchor":"how-the-cache-is-fed","heading":"How the cache is fed","level":3,"text":"Two sources, kept apart on purpose.\n\nThe **drawn weapon, whether it is in hand, and the magazine** come out of the\nordinary player snapshot: `weaponTdbId`, the `WeaponValid` / `WeaponEquipped` state\nbits and `ammoRemaining` are already in that packet, so this half costs no extra\nbandwidth and no request at all. `WeaponValid` clear means \"the sender did not fill\nthis block\", never \"unarmed\" — treating the two as the same is how an anticheat\nstarts accusing players who are in a loading screen.\n\n**Slot membership** has no wire field, so it is pushed by the bundled\n`open77_weapons` client resource, which reports after every accepted mutation and\nonce shortly after start. It does **not** poll the engine: a snapshot is a real\nREDengine round trip, and the loadout changes a few times an hour. Instead it\ncompares the locally readable drawn weapon (`Open77.character.weapon()`, a field\nread) against what it last reported, twice in a row, and only re-snapshots when\nthey genuinely disagree — which is what happens when a player loots a gun or swaps\none through the vanilla UI. An untouched loadout costs one table read a second and\nzero traffic.\n\nA disconnect forgets everything about that player, so a recycled player id never\ninherits the previous holder's loadout.\n\n`activate` and `unequip` are server aliases. Results return only to the server\nresource VM that created the namespaced request:\n\n```lua\nAddEventHandler(\"open77:weapons:completed\", function(\n playerId, requestId, operation, accepted, reason, result)\n if not accepted then print(playerId, operation, reason) end\nend)\n```\n\nMutation `result` contains `slot`, `record`, `tweakDbId`, `active`, `drawn`, and\nan `ammo` table with `record`, `tweakDbId`, `total`, `reserve`, `magazine`, and\n`capacity` when applicable.\nSnapshot `result` is an array of slot rows that additionally contain\n`equipped` and `locked`. The relay matches the authenticated source, request\nID, and operation; unanswered calls complete with `request_timeout` after ten\nseconds.","natives":["AddEventHandler","server:AddEventHandler"],"permissions":[],"events":["open77:weapons:completed"]},{"anchor":"acl-gated-admin-commands","heading":"ACL-gated admin commands","level":2,"text":"The official resource registers:\n\n```text\n/weapon.give