Lua & resources
FiveM compatibility aliases
The `Citizen` table, `SetTick`, generic promises and the shared-script helpers that let a FiveM script run unchanged, the three places OPEN//77 deliberately answers differently, and what is still missing.
On this page7 sections
- Availability at a glance
- Threads and ticks
- SetTick(fn) -> id and ClearTick(id) -> boolean
- GetGameTimer()
- Promises
- Shared scripts
- IsDuplicityVersion()
- LoadResourceFile(resourceName, path) and SaveResourceFile(...)
- GetHashKey(str) / joaat(str) β a TweakDBID, not a GTA hash
- DoesEntityExist(kind, id)
- SetEntityInvincible(kind, id, enabled)
- ACE checks (server)
- Player identifiers (server)
- Commands on the client
- Convars, and why tunables are better
- ExecuteCommand does not carry rcon
- Player identifiers, ping and endpoint (server)
- txAdmin events, under Open77 names
- Damage feedback events (client)
- Keeping an NPC on task
- Porting FiveM draw calls
- Manifest keys
- What is missing, and why
- Verifying a port
- See also
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.
Everything here is an alias or an addition. No function that existed before changed meaning,
and nothing widens the sandbox: io, os, debug, package and load are still absent, and
LoadResourceFile cannot leave the calling resource.
The second half of the page is the porting notes that are not aliases β where a FiveM idiom maps onto something shaped differently here: the damage feedback events, keeping an NPC on task, draw calls, manifest keys, and the streaming loops that have nothing to wait on.
Three places where Open77 deliberately answers differently from FiveM are called out below in their own sections. Read those before porting: they are silent traps otherwise.
Availability at a glance
| Name | Client | Server | Maps to |
|---|---|---|---|
Citizen.CreateThread / Citizen.Wait |
yes | yes | the existing CreateThread / Wait β the same function value |
Citizen.SetTimeout / Citizen.ClearTimeout |
yes | yes | the existing SetTimeout / ClearTimeout |
Citizen.SetTick / Citizen.ClearTick |
yes | yes | SetTick / ClearTick below |
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) |
Citizen.Await |
yes | yes | promise:await() |
Citizen.CreateThreadNow |
no | no | see What is missing |
SetTick(fn) / ClearTick(id) |
yes | yes | a scheduler task looping with an implicit Wait(0) |
GetGameTimer() |
yes | yes | monotonic milliseconds |
promise.new() |
yes | yes | the existing Open77.Promise type |
IsDuplicityVersion() |
false |
true |
β |
LoadResourceFile(res, path) |
yes | yes | Open77.resource.readFile / Open77.io.read |
SaveResourceFile(res, path, data) |
no | yes | Open77.io.write |
GetHashKey(str) / joaat(str) |
yes | yes | TweakDBID, not a Jenkins hash |
DoesEntityExist(kind, id) |
yes | yes | the registry that owns that kind |
SetEntityInvincible(kind, id, enabled) |
no | yes | Open77.players.setGodMode for "player"; every other kind is refused by name β see below |
IsPlayerAceAllowed(playerId, perm) |
no | yes | Open77.acl.isAllowed |
IsPrincipalAceAllowed(userId, perm) |
no | yes | the same check, addressed by account id |
add_ace <principal> <object> 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 |
remove_ace <principal> <object> allow |
no | yes | Open77.acl.revoke |
add_principal <child> <parent> |
no | yes | Open77.acl.addRole(userId, role); remove_principal is removeRole |
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 |
explosionEvent |
no | yes | onExplosion, host-wide, carrying counts rather than names |
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) |
RemoveScriptFire(handle) |
no | yes | Open77.effects.removeFire(fireId); Open77.effects.fires() lists what this resource still has burning |
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 |
fireEvent |
no | yes | onFire, host-wide, with started/stopped and a count rather than names |
ptFxEvent |
no | yes | onParticleEffect, host-wide, for every world-positioned server effect. Entity-bound playOn/attach raise nothing: the target owns the transform |
GetPlayerIdentifierByType(playerId, type) |
no | yes | Bare open77, userId, name, fingerprint, license, steam or gog value |
GetPlayerIdentifiers(playerId) |
no | yes | Available identifiers as "type:value" strings; table form: Open77.players.identifiers |
RegisterCommand(name, fn, restricted?) |
yes | yes | server: ACL command.<name>; client: Open77.runtime.registerCommand |
ExecuteCommand(line) |
yes | yes | client: the local registry only; server: the console, see below |
GetRegisteredCommands() |
yes | yes | Open77.runtime.commands() |
GetPlayerPing(playerId) |
no | yes | Open77.players.ping |
GetPlayerEndpoint(playerId) |
no | yes | Open77.players.endpoint, behind players.identity.sensitive |
GetConvar / GetConvarInt / GetConvarBool / SetConvar |
no | yes | the convars block, then this resource's tunables |
GetResourceMetadata(name, key) |
no | yes | Open77.resource.metadata; no third index argument |
StartResource / StopResource |
no | yes | Open77.resource.start / stop, behind resources.control |
GetNumResources() / GetResourceByFindIndex(i) |
no | yes | Open77.resource.list(); the find index is zero-based |
CancelEvent() / WasEventCanceled() |
no | yes | the cancellable bus (TriggerCancellableEvent), and inside a playerConnecting handler the FiveM refusal exactly: setKickReason(msg) then CancelEvent() β see Connection control |
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 |
SetHttpHandler(fn) |
no | yes | Open77.http.listen("/", fn) under the FiveM name; the listener is an operator opt-in and the route lives under /<resource>/ β Serving HTTP |
GetPlayerTimeOnline(playerId) / GetPlayerLastMsg(playerId) |
no | yes | Open77.players.sessionStats(id).sessionSeconds * 1000 and Open77.players.get(id).ageMs β milliseconds, the units FiveM documents |
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 |
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 |
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 |
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. Players: Open77.players.setFrozen / Open77.character.setFrozen |
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 |
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 |
fall-damage toggle (SetPedCanRagdoll family) |
no | yes | Open77.players.setFallDamage(id, false) β fall damage only, not god mode β Player stats |
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 |
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 |
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 |
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 |
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 |
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 |
RequestModel / HasModelLoaded / RequestAnimDict / SetModelAsNoLongerNeeded |
β | β | not needed, and not provided β see What is missing |
SetBlockingOfNonTemporaryEvents / SetPedKeepTask / SetEntityAsMissionEntity |
no | yes | three NPC settings that already exist under other names β see Keeping an NPC on task |
DrawText, DrawText3D, DrawMarker, DrawLine, DrawSprite |
yes | β | the UI kit and the world-drawing namespaces β see Porting FiveM draw calls |
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 |
Failures follow the repository convention: nil, reason for a function that returns a value,
false, reason for a predicate, with stable snake_case reason tokens.
Threads and ticks
Citizen.CreateThread, Citizen.Wait, Citizen.SetTimeout and Citizen.ClearTimeout are the
same function values as the bare globals β Citizen.CreateThread == CreateThread is true β so
there is no second scheduler and no second set of budgets.
Citizen.CreateThread(function()
while true do
Citizen.Wait(500)
Citizen.Trace("still here")
end
end)
SetTick(fn) -> id and ClearTick(id) -> boolean
A tick is an ordinary scheduler task looping with an implicit Wait(0): it runs once per host
tick (per frame on the client, per scheduler pass on the server), is bounded by the same per-frame
budget as any other task, and is counted against the same task cap. Stopping the resource stops it.
local id = SetTick(function()
-- runs every frame
end)
ClearTick(id) -- true the first time, false afterwards
SetTick returns nil, "invalid_tick_function" when the argument is not a function, and
nil, "task_limit_or_stopping" when the resource is at its task cap or shutting down.
Errors inside a tick do not kill the VM, and are not ignored either. Each failing pass is
logged at error level with the Lua error; after five consecutive failures the tick logs
tick cancelled after 5 consecutive failures and cancels itself. One successful pass resets the
count. The reason for cancelling rather than logging forever: a tick that throws every frame
otherwise fills the log and keeps spending its slice of the frame budget for the whole session.
Cancellation takes effect on the next pass, on both sides: fn never runs again after ClearTick
returns.
GetGameTimer()
Monotonic milliseconds, on both sides. The client reads the same clock as
Open77.time.monotonic(), which reports seconds; the server returns the value the host hands its
scheduler each tick.
Like FiveM's, the epoch is arbitrary and is not wall clock: only the difference between two
readings means anything, and the client and the server share no origin. For a wall clock the
server has GetUnixTime() and GetUtcTimestamp().
Promises
promise.new() builds the same object Open77.exports.call returns, so there is one promise
type in the runtime rather than two.
local p = promise.new()
CreateThread(function()
Wait(250)
p:resolve("done") -- or p:reject("no_such_thing")
end)
CreateThread(function()
local value, reason = Citizen.Await(p) -- identical to p:await()
print(value, reason)
end)
| Method | Result |
|---|---|
p:resolve(...) |
true, or false, "promise_already_settled" / "promise_cancelled" |
p:reject(reason) |
same shape; a non-string reason becomes "promise_rejected" |
p:await() |
the resolved values, or nil, reason when rejected |
p:status() |
"pending", "resolved", "rejected", or "cancelled" |
p:next(onResolved, onRejected) |
a scheduler task id, or nil, reason |
Four things to know before porting promise-heavy code:
awaitneeds a managed coroutine. Called from script load, or from anything that is not a scheduler task, it returnsnil, "await_requires_scheduler_coroutine"rather than parking the VM.Citizen.Await(p)is exactlyp:await()and behaves identically.:nextis not chainable. It returns the id of the task it schedules, not a new promise, sop:next(a):next(b)does not work. Sequences belong in aCreateThreadwith:await(). The id is useful to cancel a continuation that is still waiting (ClearTimeout).:nextrecognises a rejection by shape: thenil, <string reason>pair:await()returns for one. That is this codebase's failure convention everywhere, so a promise deliberately resolved with a leadingnilfollowed by a string is read as rejected. Resolve with a table, or use:await()directly, if that distinction matters.- On the client a pending promise has one consumer. The first
:await()/:next()woken by a settle takes the value, and the promise then reportscancelled; awaiting an already settled promise can be repeated. This is the existing client export-promise behaviour, not something the alias layer introduced. On the server apromise.new()promise keeps its value and can be awaited repeatedly. Code that awaits once is portable across both. - Resolved values cross a serialization boundary on the client (the same one exports use), so
a value that cannot be serialized is refused with
false, "promise_value_not_serializable"rather than silently dropped. The server passes Lua values through untouched.
Shared scripts
IsDuplicityVersion()
true in the server VM, false in the client VM. This is the branch a shared_script uses:
if IsDuplicityVersion() then
RegisterCommand("ping", function(source) print(source) end, false)
else
RegisterNetEvent("app:pong", function() print("pong") end)
end
LoadResourceFile(resourceName, path) and SaveResourceFile(...)
LoadResourceFile reads a file belonging to the calling resource and nothing else. Naming
another resource returns nil, "cross_resource_read_denied" β it does not quietly read your own
file, because that would make ported code look like it works.
| Client | Server | |
|---|---|---|
| Root | the resource directory (Open77.resource.readFile) |
the resource's data/ directory (Open77.io.read) |
| Permission | none | filesystem.read |
| Size cap | 1 MiB | the Open77.io cap |
| Escapes | nil, "invalid_resource_path" |
nil, "path_outside_resource" |
SaveResourceFile(resourceName, path, data) exists on the server only, maps to
Open77.io.write, needs filesystem.write, and returns true or false, reason. There is no
client equivalent: a client resource writes through Open77.kvp, which is the persistence the
client actually has.
GetHashKey(str) / joaat(str) β a TweakDBID, not a GTA hash
Hash compatibility. In FiveM GetHashKey is the
Jenkins one-at-a-time hash (joaat) of a GTA model or entry name. Open77's records are TweakDB
strings β "Vehicle.v_sport2_quadra_turbo_r", "Items.Preset_Lexington_Chrome" β so the only
hash that resolves to anything in this game is the one REDengine computes itself:
TweakDBID = CRC-32/ISO-HDLC(name) in the low 32 bits
| (length(name) & 0xFF) in byte 4
Both runtimes compute the identical value (the client in C++, the server in Lua), so a hash crossing the wire means the same thing on both sides:
GetHashKey("Vehicle.v_sport2_quadra_turbo_r") --> 133740073284 (0x1E23879144)
GetHashKey("a") --> 8199323203
GetHashKey("") --> 0
joaat is the same function under the FiveM name, kept so ported code reads naturally β but it is
not the Jenkins hash the name suggests. A hash constant copied out of a FiveM resource names a
GTA model and matches nothing here; port the string, not the number.
DoesEntityExist(kind, id)
FiveM has one opaque entity handle space. Open77 has one registry per kind, and they do not share an id space, so the kind has to be named:
DoesEntityExist("vehicle", vehicleId) --> boolean
DoesEntityExist(vehicleId) --> false, "kind_required"
The one-argument FiveM form cannot be answered and is refused rather than guessed at.
kind |
Client | Server |
|---|---|---|
"vehicle" |
Open77.vehicles.get |
Open77.vehicles.get |
"npc" |
Open77.npcs.get |
Open77.npcs.get |
"elevator" |
Open77.elevators.get |
Open77.elevators.get |
"prop" |
the resource's own prop list | Open77.props.get |
"effect" |
the resource's own VFX/SFX lists | Open77.effects.get |
"player" |
the replicated health roster | GetPlayers() |
"loot" |
false, "kind_unavailable_on_client" |
Open77.loot.get |
Reasons: "kind_required", "id_required", "invalid_entity_id", "unknown_entity_kind",
"kind_unavailable_on_client".
Each kind answers through the registry that already owns it, with that registry's permissions:
a resource that may not read vehicles gets false, the same answer an absent id gets. If you need
to tell "denied" from "gone", call the namespace function directly β it returns the reason.
The client has no loot registry it can be asked about (the server owns that list), so the client
says so instead of answering false, which would read as "the drop is gone".
SetEntityInvincible(kind, id, enabled)
The same divergence, on the one write FiveM scripts make through an entity handle. Server only, because damage immunity is a server ledger here:
SetEntityInvincible("player", playerId, true) --> true (Open77.players.setGodMode)
SetEntityInvincible("player", playerId, false) --> true
SetEntityInvincible(playerId, true) --> false, "kind_required"
SetEntityInvincible("vehicle", vehicleId, true) --> false, "unsupported_entity_kind"
For "player" it is Open77.players.setGodMode β the same players.stats.apply permission
(refused as permission_denied:players.stats.apply before any id is looked at), the same combat
ledger bit, read back as Open77.players.get(id).godMode. Every other kind is refused by name
rather than accepted and ignored: a vehicle's damage is its own scopes (setEngineHealth, the body
zones), an NPC's is its damagePolicy, and a ported script that expects an indestructible car
should find that out here rather than on the first hit. A name that is no kind at all is
unknown_entity_kind; a non-boolean enabled, or a player the ledger does not hold yet, is
invalid_argument.
Two neighbours it is not: Open77.players.setFallDamage(id, false) switches fall damage off and
nothing else, and the grav-chute's shield is a blanket Invulnerable that lives only while the
chute is armed β Player stats draws the line.
ACE checks (server)
IsPlayerAceAllowed(playerId, permission) is Open77.acl.isAllowed under the FiveM name: same
acl.read permission, same session player id, same answer.
IsPrincipalAceAllowed(userId, permission) asks the same question addressed by account id.
FiveM principals are durable; Open77 resolves permissions through the connected session, so a user
who is not connected cannot be answered and the call returns
false, "principal_not_connected" rather than a bare false that would read as "denied".
Player identifiers (server)
GetPlayerIdentifierByType(src, "open77") --> "11111111-2222-3333-4444-555555555555"
GetPlayerIdentifierByType(src, "userId") --> the same installation identity GUID
GetPlayerIdentifierByType(src, "name") --> "Valerie"
GetPlayerIdentifierByType(src, "fingerprint") --> "sha256:..."
GetPlayerIdentifierByType(src, "license") --> "aaaaaaaa111122223333bbbbbbbbbbbb"
GetPlayerIdentifierByType(src, "steam") --> "11000010000002a" (hexadecimal, if linked)
GetPlayerIdentifierByType(src, "gog") --> "12345678901234567" (decimal, if linked)
GetPlayerIdentifiers(src)
-- Example with both stores linked (either store may be absent):
--> { "open77:1111...", "name:Valerie", "fingerprint:sha256:...",
-- "license:aaaaaaaa111122223333bbbbbbbbbbbb",
-- "steam:11000010000002a", "gog:12345678901234567" }
GetPlayerIdentifierByType returns the bare value; GetPlayerIdentifiers returns the FiveM
shape, an array of "type:value" strings. Iterate safely with
for _, id in ipairs(GetPlayerIdentifiers(src) or {}) do ... end.
The array read returns nil, "player_not_found" for an unknown session. The typed read returns
nil, "identifier_not_linked" when the requested license, steam or gog value is unavailable.
Type names are case-insensitive; unsupported types return nil, "unknown_identifier_type".
license, steam and gog require server runtime 2.31.13+op77.101 or later. Use license
as the permanent Open77 account key across linked devices; keep all identifiers as strings.
One Steam or GOG account owning both Cyberpunk 2077 and Phantom Liberty is verified once and
linked permanently. Since runtime 2.31.13+op77.102, an account manually approved by an Open77
administrator can also connect with license present and both store identifiers absent.
Resources must support this case. No Discord or Xbox identifier is exposed.
Store linking is currently optional; players who choose Skip for now also have license
without either store ID. Existing verified store links remain available.
See verified account and store identifiers
for the server event example, formats and ownership semantics.
Commands on the client
RegisterCommand exists on both runtimes now, with the same handler shape
(source, args, rawCommand). The client passes source = 0, because on the client there is no
other party to attribute a command to; a resource that wants the local session id calls
Open77.session.playerId().
-- client script
RegisterCommand("hud", function(_, args)
Open77.hud.setVisible(args[1] ~= "off")
end, false, { help = "Show or hide the HUD.", parameters = { { name = "state", optional = true } } })
The fourth argument is an Open77 addition, not a FiveM one: it is the completion entry the chat composer shows. A port that passes three arguments is accepted unchanged and simply gets a generic completion line.
restricted = true on the client refuses rather than pretends. The command is registered and
listed, and then declines to run with command_restricted. The client has no ACL β it cannot
answer an authority question β so enforcing nothing while claiming to enforce something would be
the worse failure. That is also what FiveM effectively does for a player who does not hold the ace.
An admin-only command belongs on the server, where command.<name> is a real check.
ExecuteCommand(line) differs from FiveM's deliberately: it resolves against the client registry
only and reports whether it matched, rather than silently forwarding an unrecognised line to the
server. false, "unknown_command" is the signal a caller uses to decide to forward, and that is
exactly what the chat composer does β it tries the client first, forwards on unknown_command, and
shows any other refusal. A composer that could not tell "no client command" from "the client
command failed" would either swallow errors or spend a net event on every keystroke.
GetRegisteredCommands() returns { name, resource, restricted, help?, parameters? } on the
client β two fields more than FiveM's { name, resource }, because the composer needs them to
build a completion. The server's returns the same shape plus source, which says whether a command
came from a Lua resource or from the operator console.
Convars, and why tunables are better
GetConvar, GetConvarInt, GetConvarBool and SetConvar read a convars block of the server's
configuration, then fall through to the calling resource's own declared tunables by exact key,
then to the caller's default. A getter never raises. SetConvar is a server-wide, in-memory
override that does not survive a restart.
They exist so a port runs. They are not the mechanism to reach for when writing something new:
Open77.tunables is typed, range-checked, validated on every write, retunable by the operator from
the Warden panel while the server runs, persisted, scoped to one resource, and it notifies its owner
when a value changes. The fall-through to tunables is deliberate β it lets a ported resource be
migrated one key at a time instead of all at once. See
the server API reference.
ExecuteCommand does not carry rcon
This is the one place Open77 refuses to copy FiveM, and it is worth reading before porting an admin
resource. In FiveM, ExecuteCommand runs as the console: full authority, with nothing anywhere
declaring that the resource holds it.
Here it is split in two. runtime.commands in the manifest lets a resource run another resource's
ordinary command and nothing else β a restricted command and every resource-lifecycle verb are
refused. resources.control raises that to the operator's authority and is separately required by
StartResource / StopResource. An operator installing a resource therefore sees the escalation in
the manifest before running it.
Commands are also queued, not run inline, and report acceptance rather than completion: Lua runs inside the server's tick, and a resource stopping itself synchronously would free the Lua state doing the stopping. The reply goes to the server log.
Player identifiers, ping and endpoint (server)
GetPlayerPing(playerId) takes no capability β latency is not identity, and every scoreboard in the
genre shows it. It answers nil, "ping_unavailable" on a transport that cannot measure, rather than
a plausible zero.
GetPlayerEndpoint(playerId) does take one: players.identity.sensitive. An endpoint is an IP
address. FiveM gates nothing here; a port that quietly logged addresses will start reporting
permission_denied:players.identity.sensitive until its manifest says what it is doing.
Open77.players.identifiers(playerId) is the table companion to the array GetPlayerIdentifiers
returns: { open77, userId, name, fingerprint, joinedAt, license?, steam?, gog? }, plus
endpoint only when players.identity.sensitive is held. Account and store identifiers need
no permission. Unavailable fields are omitted and read as nil in Lua.
txAdmin events, under Open77 names
A ported script that listens for txAdmin:events:scheduledRestart or
txAdmin:events:serverShuttingDown finds the same information under the
open77:admin: prefix. Rename the handler; the shapes are close but not identical.
| txAdmin | Open77 | Difference |
|---|---|---|
txAdmin:events:scheduledRestart |
open77:admin:scheduledRestart(secondsRemaining, reason) |
Arguments, not a table. Raised on each rung of the warning ladder. |
txAdmin:events:serverShuttingDown |
open77:admin:serverShuttingDown(reason) |
restart, reset or shutdown. |
txAdmin:events:announcement |
open77:admin:announcement(text) |
Same. |
txAdmin:events:playerWarned |
open77:admin:playerWarned(playerId, author) |
No reason and no operator name β see below. |
txAdmin:events:playerKicked |
open77:admin:playerKicked(playerId, author) |
Same. |
txAdmin:events:playerBanned |
open77:admin:playerBanned(playerId, author, durationSeconds) |
Same, plus the duration. |
txAdmin:events:healedPlayer |
open77:admin:playerHealed(playerId, author) |
Raised by Warden's heal action only β see below. |
Two differences will bite a straight port. The moderation reason is not carried, and
author is a channel (warden or resource) rather than a staff name: these events reach
every resource on the server, and a host-wide event carrying either would be a moderation log
for anything that cared to listen. A script that displayed the reason must get it from its own
admin resource instead.
And healedPlayer fires only for Warden's own heal. txAdmin has it because healing is
something its panel does β and since the Players tab grew a heal action,
Warden's panel does too, so open77:admin:playerHealed(playerId, author) is raised when an
operator heals from it. It is not raised when a resource calls Open77.players.heal: that
is a gameplay native any resource may call, and announcing every call of it as an administrative
act would be both a firehose and a lie. An admin resource that heals on command should publish
its own event, under its own namespace β open77:admin: is reserved to the platform precisely so
that "the operator's surface did this" stays a claim only the platform can make.
The full contract, including the Open77.runtime.scheduleRestart lever, is in
Admin events.
Damage feedback events (client)
FiveM's gameEventTriggered with CEventNetworkEntityDamage is one firehose every combat HUD
filters. Open77 splits it into three client events, all raised from the server's authoritative
health verdict β the PlayerHealthState broadcast, never the shooter's local raycast β so a
hitmarker drawn from hitConfirmed is a hit the ledger credited:
| Event | Raised on | Arguments |
|---|---|---|
open77:localDamaged |
the victim's client, when the local player lost health | attackerId, amount, dirX, dirY, dirZ, bodyPart, health, maxHealth |
open77:hitConfirmed |
the attacker's client, when the server credited a hit by the local player on a player | victimId, amount, bodyPart, lethal |
open77:playerDamaged |
every client that has the victim's proxy β the observer feed for nameplates and kill feeds | victimId, attackerId, amount, bodyPart, health, maxHealth |
AddEventHandler("open77:hitConfirmed", function(victimId, amount, bodyPart, lethal)
showHitmarker(lethal == "1") -- a string, not a boolean
end)
AddEventHandler("open77:localDamaged", function(attackerId, amount, dirX, dirY, dirZ)
showDamageDirection(tonumber(dirX), tonumber(dirY), tonumber(dirZ))
end)
Three traps, each found while building a combat HUD rather than imagined:
hitConfirmedis attacker-side and player-victim only. It is raised from the player-health drain, so a hit on a server-owned NPC produces no hitmarker at all. For NPCs the shooter's client getsopen77:npcHit(npcId, damage, hitZ, weaponTdbId, attackKind)instead, and a gamemode that credits the hit server-side emits its own marker event from there.- Every argument is a string, like every engine-raised event:
lethalarrives as"1"/"0", andamount,health,maxHealthand the direction needtonumber. - The server event of the same name has a different list. Server-side
open77:playerDamagedcarries(victimId, attackerId, amount, attackKind, weaponTdbId, bodyPart, remainingHealth, maxHealth, lethal, downedHit); a handler copied from one side to the other reads the wrong column. Both lists live in Player stats.
Keeping an NPC on task
Three FiveM natives keep a ped from wandering off, and each is a setting that already exists here under another name. Nothing was built for them:
| FiveM | Open77 | What it actually does |
|---|---|---|
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. |
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. |
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. |
The three switches are documented with their side effects in NPC behaviour and the task options in NPCs.
Porting FiveM draw calls
FiveM draws with per-frame natives β a thread that calls DrawText every tick keeps the text on
screen. Nothing here is per-frame: every drawing surface takes a description once and returns a
handle, and the loop goes away with the port.
| FiveM | Open77 | The difference that matters |
|---|---|---|
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. |
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. |
DrawMarker |
Open77.markers.* |
A shape/style vocabulary, ground circles through open77_groundcircle. |
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. |
DrawSprite, DrawRect |
an <img> or a <div> on a WebUI page |
Screen-space art is a page; a rectangle pair every cutscene draws for bars is exports.open77_uikit:showCinematicBars(true). |
Open77.anchors underneath can render a card, ring, dot or page at a world point when the
kit's text is not enough. The kit's own porting notes are in
UI kit β porting a DrawText3D loop.
Manifest keys
A FiveM fxmanifest.lua ports to open77.lua mostly by renaming the file; the keys that carry
meaning here are handled, the rest are named once and ignored:
| FiveM key | Open77 |
|---|---|
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:<name>). |
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. |
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. |
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. |
The full manifest reference is Server resources.
What is missing, and why
Citizen.CreateThreadNow is unavailable. Both schedulers start tasks on the next pass. Use Citizen.CreateThread, or call non-yielding code directly.
Citizen.InvokeNative, Citizen.CreateUThread, Citizen.Wait inside a non-managed coroutine, and
the msgpack surface are not provided: Open77 has no GTA native table to invoke, and the event
payload encoding is not msgpack.
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:
| A ported loop waited on | Ask instead |
|---|---|
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 |
| 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 |
RequestAnimDict before TaskPlayAnim |
nothing: Open77.animations.play resolves a clip from the catalogue itself |
RequestNamedPtfxAsset before a particle |
nothing: Open77.vfx.* names a curated effect and the engine owns the asset |
Delete the loop rather than emulating it: a HasModelLoaded that always answered true would
have ported code spawning a blip on an entity that is not there yet.
Verifying a port
A shared script that prints this block should produce the same output on both runtimes, except for
duplicity. It is what the client and server test suites assert:
print("parity duplicity=" .. tostring(IsDuplicityVersion()))
print("parity hash=" .. tostring(GetHashKey("Vehicle.v_sport2_quadra_turbo_r")))
print("parity joaat=" .. tostring(joaat("x") == GetHashKey("x")))
print("parity citizen=" .. tostring(Citizen.CreateThread == CreateThread) ..
"," .. tostring(Citizen.CreateThreadNow == nil))
print("parity timer=" .. type(GetGameTimer()))
local _, kindError = DoesEntityExist(1)
print("parity kind=" .. tostring(kindError))
local _, crossError = LoadResourceFile("somewhere_else", "note.txt")
print("parity cross=" .. tostring(crossError))
local settled = promise.new()
settled:resolve("parity")
print("parity promise=" .. tostring(settled:status()))
local _, awaitError = Citizen.Await(promise.new())
print("parity await=" .. tostring(awaitError))
print("parity tick=" .. type(SetTick) .. "," .. type(ClearTick))
parity duplicity=false (client) | parity duplicity=true (server)
parity hash=133740073284 | parity hash=133740073284
parity joaat=true | parity joaat=true
parity citizen=true,true | parity citizen=true,true
parity timer=number | parity timer=number
parity kind=kind_required | parity kind=kind_required
parity cross=cross_resource_read_denied| parity cross=cross_resource_read_denied
parity promise=resolved | parity promise=resolved
parity await=await_requires_scheduler_coroutine (both)
parity tick=function,function | parity tick=function,function
See also
- Complete server Lua API β every server global and
Open77.*namespace. - Cross-resource server exports β where the promise type comes from.
- Server resources β manifests, permissions, and the resource lifecycle.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
