# aqua!v2 - LuaJIT Scripting API Reference

This document describes the LuaJIT integration, directory layout, lifecycle callbacks, and standard API bindings for **script authors**.


---

## 1. Directory Structure & Entry Points

Scripts live in isolated subdirectories under the config folder:

* **Base directory**: `~/.config/nvidia/scripts/`
* **Layout**:
  ```text
  ~/.config/nvidia/scripts/
  ├── my_esp_script/
  │   ├── main.lua
  │   ├── script.json           # optional title, description, author, version, icon
  │   ├── icon.png              # optional 128 x 128 icon
  │   └── img/                  # optional assets for aqua.draw.image
  │       ├── icons/*.png
  │       └── weapons/*.png
  └── ...
  ```
* **Entry point**: must be named exactly `main.lua`.
* **Multi-file**: other `.lua` files under the script folder are editable as **file tabs** in the workspace (create with `+`, rename/delete via right-click; `main.lua` is protected).
* **PNG assets**: any subfolder under the script root; see `aqua.draw.image`.
* **UI** (Scripts tab):
  * **Script list**: grouped Running / Idle, filter, status, hover actions, inline folder rename and hold-to-delete.
  * **OPTIONS**: the script's controls use the same cards and controls as the built-in menu. Run on load and Enable FFI sit in a separate strip beneath the header.
  * **CODE**: file tabs, JetBrains Mono editor, Save / Ctrl+S, Run / Stop, scrollbars with current-line and error markers, and a maximise button that hides the list.
  * **CONSOLE / PROBLEMS**: collapsible output, this-script / all filter, clear, and clickable error locations.
  * The `...` menu offers folder rename, duplication, Open folder, Reload from disk and hold-to-delete. Reload asks before replacing a dirty open buffer. Renaming saves all edited buffers first.
* **Auto-run**: the independent **Run on load** switch stores `Scripts.<name>.AutoRun`. Run / Stop controls the current session without changing that preference. Profile switches apply that profile's AutoRun settings. Renaming migrates the AutoRun and FFI preferences. A duplicate starts stopped with FFI disabled.

Optional `script.json` sits beside `main.lua`:

```json
{
  "title": "My script",
  "description": "What this script does.",
  "author": "Your name",
  "version": "1.0",
  "icon": "icon.png"
}
```

The list and header show `title`, falling back to the folder name. The description
falls back to the first nonblank comment in `main.lua`. The header keeps the folder,
author and version visible. A relative PNG icon replaces the name's two-letter tile;
128 x 128 is recommended. Missing or invalid metadata and icons use the fallbacks.
The empty Scripts page stays empty until you create or install a script.

External edits to the open file refresh the editor buffer (throttled) when the buffer is not dirty.

---

## 2. Sandboxing & FFI Opt-In

By default scripts run in a sandboxed environment table:

* **FFI off (default)**: no `ffi`, `require("ffi")` errors, `package.loadlib` cleared.
* **FFI on** (Enable FFI switch, config key `Scripts.<name>.FfiEnabled`): full LuaJIT FFI.

The standard Lua `io` library is available with FFI off. Use `aqua.script_dir()` to build
paths for files owned by your script. Persistent settings already use `aqua.config.set`
and `aqua.config.save`; a separate storage API is unnecessary.

**LuaJIT is on by default** (`luaJIT_setmode(... MODE_ON)` when the script state is created). Hot loops and numeric code run compiled; leave it on unless you have a specific reason to call `jit.off()` yourself.

Check status from a script:

```lua
local on, status = jit.status()
print("jit.status() =", on, status, jit.version, jit.arch)
```

See **§15 What you can do with FFI** for realistic use cases and risks.

---

## 3. Lifecycle Callbacks

### `on_frame_stage_notify(stage)`

* **Thread**: game thread, every frame stage.
* Safe for entity/schema/config/panorama queue/input **held** state.
* `aqua.input.key_pressed` / `mouse_clicked` sticky edges are visible for the whole multi-stage burst and cleared after stage `12`.
* **Call `aqua.trace_ray` here** (or any other non-`on_draw` game-thread stage). Cache results in script locals / tables for use in `on_draw`.

### `on_draw()`

* **Thread**: game thread, once per frame (stage `12`, `FRAME_RENDER_END`), after player data updates, so `get_players` and bombs match the built-in ESP for that frame.
* Queue overlay draws via `aqua.draw.*`. They are drawn together later, so a frame never shows only some of them.
* Geometry + text are pixel-snapped together on replay so labels stay locked to rects while moving (bitmap fonts must land on whole pixels).
* **`aqua.trace_ray` errors if called here** - do wall/player/smoke casts in `on_frame_stage_notify` and draw from the cached result.

### `on_event(name, data)`

* **Thread**: game thread, stage `12`, once per frame for everything the engine dispatched since the last frame.
* `name` is the event name (`"player_hurt"`, `"player_death"`, ...). `data` is a table of the fields **you asked for** - see `aqua.events.listen` below. Fields you did not register are simply absent.
* You receive only events **your script** registered, with only its requested fields.
  Stopping, errors and unloading remove your subscriptions and queued deliveries.
* Events are snapshotted on the engine's dispatch thread and replayed here, so this never runs mid-dispatch and cannot stall the game.

```lua
aqua.events.listen("player_hurt", { "userid", "attacker", "dmg_health", "hitgroup" })

function on_event(name, data)
    if name == "player_hurt" then
        print(("hurt: %d dmg, hitgroup %d"):format(data.dmg_health, data.hitgroup))
    end
end
```

### `on_shot(data)` / `on_shot_hit(data)` / `on_shot_miss(data)`

Define these functions to receive copied local-shot notifications on the game thread at
stage 12. No explicit event registration is needed; callbacks work with HUD hit/miss
logs disabled. Stopping, errors or unloading remove callback interest and queued delivery.

* `on_shot` reports an observed local `weapon_fire`: `time`, `weapon`, `command=-1`.
* `on_shot_hit` reports confirmed local damage: `time`, `weapon`, `target_handle`
  (full controller handle), `target_name`, `damage`, `health`, `hitgroup` and
  `reason="confirmed damage"`. Damage is not assigned an invented command number.
* `on_shot_miss` reports a ragebot shot with no confirmed damage, as soon as the server's
  events for it have arrived (or its RTT window ran out). It includes `time`, `weapon`,
  `command`, `target_handle`, `target_name`, `hitchance`, `reason` and `ambiguous`.
  `reason` is one of `"spread"`, `"lag compensation"`, `"animation mismatch"`, `"autowall"`,
  `"shoot position mismatch"`, `"prediction error"`, `"aim punch"`, `"extrapolation"`, `"you died"`, `"target died"`,
  `"unregistered shot"` or `"unknown"`. Unjoined shots carry command/HC -1 and `"unknown"`.

Reasons use the server's own bullet impacts where the client subscribes to them (non-Valve
servers with Rage on); otherwise our client simulation. Overlapping shots, pellets and
lost events can remain ambiguous. Manual/script shots have fire/hit callbacks and can
produce an unknown no-damage outcome; they do not acquire a ragebot target or hitchance.
Callbacks share the bounded event queue and do not write commands.

```lua
function on_shot_hit(shot)
    print(shot.target_name, shot.damage, shot.health)
end
```

### `on_create_move(cmd)` / `on_create_move_post(cmd)`

* **Thread**: game thread, inside the game's CreateMove. This can run **several times per tick**
  with the same `cmd.sequence`; `cmd.new_tick` is true on the first call of a tick.
* Not called on official Valve servers, or when aqua had to disable command writes.
  Aiming and action requests require the same gate's permission; priority cannot
  bypass it. Blocked calls discard pending aim without changing the camera or command.
* `cmd` is a fresh table each call. Its methods only work during the callback that received it.

**Order inside one CreateMove call:** bhop -> air strafer -> **`on_create_move`** -> anti-aim
-> ragebot -> doubletap -> script actions/aim arbitration -> **`on_create_move_post`** -> checksum.
`on_create_move` submits movement, aim and action requests. Anti-aim rotates movement like your own
keys, and ragebot auto-stop can override it. `on_create_move_post` is read-only and sees the
final command. A script with aim priority above ragebot suspends ragebot and doubletap for
that call before they can generate attacks.

**Fields (both callbacks):**

| Field | Meaning |
|---|---|
| `sequence`, `new_tick` | Command number, and whether this is the tick's first call |
| `alive`, `on_ground`, `velocity` {x,y,z} | Local player state |
| `view_yaw` | Your real camera yaw (not the anti-aim yaw) |
| `angles` {pitch,yaw,roll} | Base command angles, which may already contain anti-aim's pose. In `_post`, the final base angles sent |
| `eye_position` {x,y,z} | Local geometric eye position for `look_at`; nil when unavailable |
| `ragebot_aim_priority`, `antiaim_aim_priority` | Built-in aim priorities: 100 and 50 |
| `weapon` | Copied active weapon details, or nil (see section 6) |
| `forward_move`, `left_move` | Movement wish, -1..1. `left_move` +1 = strafe **left** |
| `buttons` | Button bitmask |
| `aa_active` | Anti-aim wrote the pose this tick |
| `strafer_active` | The air strafer owns movement this tick (your request is ignored) |
| `subtick_moves`, `subtick_remaining`, `subtick_capacity` | Sub-tick moves in the command, and the 32-move budget left this tick (read-only) |
| `jump_priority` | Built-in Bunnyhop owns jump input because the user is holding jump; script jump requests are refused |
| `jump_pending` | A script jump request is queued and has not expired (snapshot before this callback) |
| `jump_staged` | The shared jump injector staged a press/release pair on this CreateMove call; not proof the engine accepted the jump |

`_post` only: `aa_pose` {pitch,yaw,roll} (nil when anti-aim is off), `move_applied`,
`history_size`, `attack`, `aim_applied` (a script aim was written), `aim_owner`
(`"script"`, `"ragebot"`, `"antiaim"`, `"none"` or `"unavailable"`), `aim_priority`
(selected script request's priority, nil without a request), and `aim_silent`
(selected script request's mode, even if it lost arbitration). These describe the shared
command, not just the script receiving the callback. `actions_applied` is a table with
boolean `attack1`, `attack2`, `reload`, `walk` and `switch_weapon` fields. Applied means
the request reached the command; it does not prove the game accepted the action.

**Methods (`on_create_move` only; `_post` raises "read-only"):**

| Method | Effect |
|---|---|
| `cmd:move_world(x, y)` | Walk toward world-space direction (x, y). Length is clamped to 1; shorter = slower wish. z is not used |
| `cmd:move(forward, left)` | Same, relative to your **camera** (not the anti-aim yaw). Clamped to length 1 |
| `cmd:stop()` | Request zero movement (overrides the player's keys) |
| `cmd:clear_move()` | Drop this script's request; the player's keys apply again |
| `cmd:set_button(name, down)` | `"duck"`, `"walk"` or `"use"`. Not play-tested |
| `cmd:jump(down=true)` | Queue a jump intent; returns true if queued, false if dead or built-in jump priority applies. `false` cancels this script's pending request |
| `cmd:aim(pitch, yaw, opts)` | Submit aim angles. Pitch clamps to -89..89, yaw wraps, roll is zero. Options: `silent=false`, `priority=75`. Returns whether the request was accepted, not whether it won final arbitration |
| `cmd:look_at(x, y, z, opts)` | Submit aim toward world coordinates from `eye_position`, with the same options. Returns false if dead, the eye is unavailable, or the target equals the eye |
| `cmd:clear_aim()` | Cancel this script's pending aim request |
| `cmd:attack1(down=true, opts)` | Request primary attack press/hold or release |
| `cmd:attack2(down=true, opts)` | Request secondary attack press/hold or release |
| `cmd:reload(down=true, opts)` | Request reload input |
| `cmd:walk(down=true, opts)` | Request Shift/walking input |
| `cmd:switch_weapon(handle, opts)` | Request a held weapon by its full handle from `aqua.get_weapon_inventory()` |
| `cmd:clear_actions()` | Cancel this script's pending action requests |

* A planar movement request lasts for **the current tick only**: call `move_world` every tick you want to move.
  No call = no override. It is also dropped when you die, and when the script stops, errors
  or unloads. If two scripts ask in the same tick, the last one wins.
* Movement methods do not change view angles. Movement is planar: pitch never changes the direction.
  Aqua's anti-aim always sends roll 0; a non-zero roll from elsewhere would skew the server's
  movement basis, and this API does not compensate for it.
* While the air strafer drives (`strafer_active`), planar movement requests are ignored. Turn the strafer off
  for a walkbot that jumps.
* **Direction accuracy:** servers usually run `sv_quantize_movement_input 1`, which rounds the
  wish to 8 directions **relative to the sent yaw**.
  - Anti-aim **on**: aqua compensates through the sent yaw, so `move_world` is exact
    (measured in game: within 0.012 deg). With anti-aim's movement dither on, the heading
    ripples about +/-2 deg around the target instead. A direction change shows up one tick late.
  - Anti-aim **off**: the sent yaw is your camera, so the direction snaps to the nearest of 8
    around it (up to about 30 deg off; measured 29 deg for a request 61 deg off the camera).
    Steer the camera toward the target if you need exact paths without anti-aim.
* `cmd:jump()` requests a jump on the **next CreateMove call**, which may be a later call in
  this tick or the next tick. It works with Bunnyhop disabled. Refresh it each tick while
  you want to jump; it expires after more than one command advance. Last requesting script
  wins; cancellation affects only its owner's request. Death, command-write blocking,
  stopping, errors and unload clear requests. Already staged edges cannot be cancelled.
* Jump requests use the same timing and shared subtick budget as Bunnyhop, before the air
  strafer claims slots. Built-in held-space Bunnyhop has priority (`jump_priority`). The
  module reserves a press/release pair when a jump is plausible; raw budget writes are not
  exposed. Queued does not mean executed: grounding, movement type, water, engine timing,
  capacity and ordering can prevent staging. Keep requesting until airborne when attempting
  a single grounded jump. `jump_staged` includes built-in jumps as well as script jumps.
* Raw sub-tick move emission is not exposed.

**Action requests:**

* Each lasts one CreateMove call. Repeat a hold every callback; `false` explicitly releases
  input. No request leaves normal input in charge. Requests cancel on stop/error/unload,
  death or blocked writes. Methods return whether queued, not final success.
* `opts.priority` is an integer 0..200, default 75. Each action is arbitrated separately
  between scripts; highest wins, last equal request wins. Combat actions at 100 or below
  yield while ragebot is enabled. Above 100 they suspend ragebot and doubletap for this call.
  Walking uses script priority only and is applied before anti-aim movement correction.
* Attack requests use the current camera direction in existing shot history when no
  script aim takes over, preventing a button-only request from firing along anti-aim.
  Pair attack with `aim`/`look_at` to choose another direction. Keep the chosen policy
  across a firing sequence. These inputs do not add recoil/spread compensation.
* Weapon handles are checked against the live held inventory before writing a switch.
  A dropped/recycled handle is rejected. Reload, scope, grenades and other secondary
  actions remain subject to native ammo, cooldown and weapon-state rules.

**Aim priority and camera mode:**

`opts.priority` is an integer **0..200**, default **75**. Larger values have more
control. Aim has one winner across scripts; actions choose a winner separately for
attack1, attack2, reload, weapon switching and walking. Priorities apply to requests
from the current CreateMove call, including repeated calls with the same sequence.

| Script priority | Against an applied anti-aim pose (50) | Against ragebot-authored aim (100) | Combat actions while ragebot is enabled |
|---|---|---|---|
| 0..50 | Anti-aim wins | Ragebot wins | Script request yields |
| 51..100 | Script wins | Ragebot wins | Script request yields |
| 101..200 | Script wins | Script suspends ragebot and doubletap | Script takes combat control |

At equal priority, **built-ins win against scripts**. Between scripts, the last
equal-priority request wins; script execution order breaks the tie, so distinct
priorities are clearer when one script must dominate. Walking competes only between
scripts and never suspends ragebot/doubletap by itself.

Aim yields to ragebot only when ragebot **actually authors aim**, including held-shot
history. Combat actions at 100 or below yield whenever ragebot **is enabled**, even
without a target. If neither built-in authors aim, a lower-priority script can aim.
Priority above 100 suspends ragebot/doubletap for the call, even if no target exists.
**No priority bypasses the Valve-server or command-write gate.**

An accepted request may later lose arbitration. Check `aim_owner`/`aim_applied` and
`actions_applied` in `on_create_move_post`; an applied request still does not prove
the native weapon accepted the action. Cancelling a winner does not resurrect
requests that already lost or were rejected. Choose one ownership policy through
the firing sequence rather than switching priorities around a shot.

* Aim requests last for this **CreateMove call**, including when the same command sequence
  runs again. Call `aim` or `look_at` every callback while you want control. No request means
  normal aiming. Stop, error, unload, death and blocked command writes discard requests.
* A lower request is rejected while a higher one is pending. Clear your own request before
  lowering its priority within the same callback. Cancelling the winner does not restore
  previously rejected requests.
* Taking control above **100** suppresses ragebot's firing, auto-stop, scope/cock actions
  and doubletap for the call. Player attack input is retained; use action requests to
  control script input. Ragebot aim ownership includes the command following a shot.
* `silent=true` writes the base command and existing history while leaving the camera alone.
  `silent=false` also turns the camera through the native input API. This API's silent mode
  changes the network/body pose; it is not a history-only mode. If visible camera access is
  unavailable, or the camera-copy guard needed for silent mode is unavailable, aiming
  is refused and `_post.aim_owner` is `"unavailable"`.
* Both modes rotate base movement and directional buttons to keep the existing world-space
  wish. Server movement quantization still applies; existing airborne subtick steering is
  not retargeted. `look_at` is geometric aiming, without recoil/spread compensation,
  visibility checks, lag compensation or firing-time eye prediction.
* Angles/coordinates must be finite floats. Invalid options raise an error. These methods
  work only during the owning script's `on_create_move`, never in `_post` or `on_draw`.
  Pitch clamps to -89..89, yaw wraps to -180..180 and roll is zero. For example,
  `cmd:aim(9999, 9999)` writes pitch 89, yaw -81, roll 0. NaN/infinity raise errors.
  Visible/silent camera modes, bounds and priorities were tested on a local server.

```lua
function on_create_move(cmd)
    if cmd.alive then
        -- Override anti-aim, let ragebot own its shots, turn the camera visibly.
        cmd:look_at(1200, -500, 64, {priority = 75, silent = false})
        -- For full control with the camera unchanged, use priority = 101, silent = true.
        -- Use the same 101 priority on cmd:attack1(want_fire, {priority = 101}).
    end
end

function on_create_move_post(cmd)
    if cmd.new_tick then
        print("aim owner:", cmd.aim_owner, "script applied:", cmd.aim_applied)
    end
end
```

```lua
-- Example only: replace this with your walkbot's decision to cross an obstacle.
local want_jump = false

function on_create_move(cmd)
    if want_jump and cmd.alive and cmd.on_ground and not cmd.jump_priority then
        cmd:jump()
    else
        cmd:jump(false)
    end
end
```

```lua
-- Walk toward a world point, stopping inside 32 units.
local target = { x = 1200, y = -500 }

function on_create_move(cmd)
    local me = aqua.get_local_player()
    if not (cmd.alive and me and me.origin) then return end
    local dx, dy = target.x - me.origin.x, target.y - me.origin.y
    if dx * dx + dy * dy < 32 * 32 then
        cmd:stop()
    else
        cmd:move_world(dx, dy)  -- clamped to length 1
    end
end

function on_create_move_post(cmd)
    -- cmd.angles.yaw is the yaw actually sent; cmd.view_yaw is your camera.
    if cmd.new_tick and cmd.move_applied then
        print(("sent yaw %.1f, camera %.1f"):format(cmd.angles.yaw, cmd.view_yaw))
    end
end
```

### `on_unload()`

* **Thread**: game thread, once, when the script stops (STOP button, profile change, reload, or shutdown).
* Called **before** the Lua state is closed, so the script can still touch `aqua.*` to release anything it holds.
* Optional. A pcall failure here is logged and teardown continues regardless - it cannot wedge a stop.
* Engine-owned resources are released for you afterwards anyway (input lock, music stream); use this for your own bookkeeping, or to stop things early and deliberately.

```lua
function on_unload()
    aqua.sound.stream.stop()
    print("radio off")
end
```

A pcall failure stops the script, sets status to **Error** (explorer row + console), and prints to the game console + workspace console drawer.

---

## 4. Output

### `print(...)` / `aqua.log(msg)`

Both write to:

1. **In-game console**, in cyan: `[Lua: <name>] ...`
2. **Scripts workspace console**, with timestamps

### `aqua.print_chat(text) -> bool[, reason]`

Print a client-only line in the game's native chat, including its fading notices
and chat history. Other players do not receive the line. No FFI is required.
Percent signs and HTML characters are literal; format values with Lua before
calling. The function does not add a prefix or play a chat sound.

`true` means queued. Delivery runs on the game thread after script callbacks;
it works from `on_draw` and the Scripts console too. An unavailable HUD can drop
a queued line. Outside a match, invalid input or a limit returns `false, reason`.
Stopping/reloading a script cancels its pending lines; disconnects/map changes
clear the queue, and lines older than one second are discarded. Already
dispatched lines remain in chat history.

Text accepts 1-512 UTF-8 bytes without NUL and must contain visible characters.
Escaping and color spans must also fit the game's 2048-byte display buffer;
heavily styled text can hit that limit before 512 input bytes. Limits are
8 pending lines per script, 32 overall, and 4 accepted lines per second per
script with a burst of 4. Print on an event or behind `aqua.once`/`rate_limit`.

```lua
local ok, reason = aqua.print_chat('[aqua] 100% ready: <hello> & "welcome"')
if not ok then print(reason) end
```

**Styling:** insert native color bytes with `string.char(code)`. Colors apply
until the next color byte; code 1 restores the default. HTML tags are escaped.
Bytes 1-16 are color controls, so tabs/newlines are not line-break formatting.
Other control bytes are rejected.

| Code | Color | Code | Color |
| :--- | :--- | :--- | :--- |
| 1 | Default / white | 9 | Olive |
| 2 | Red | 10 | Pale blue |
| 3 | Native contextual color | 11 | Light blue |
| 4 | Green | 12 | Blue |
| 5 | Lime | 13 | Purple |
| 6 | Light green | 14 | Pink |
| 7 | Light red | 15 | Red-pink |
| 8 | Gray | 16 | Gold |

```lua
local color = {
    default = string.char(1), green = string.char(4),
    red = string.char(7), gray = string.char(8),
    blue = string.char(11), purple = string.char(13), gold = string.char(16)
}

-- Colored prefix, followed by normal text.
aqua.print_chat(color.blue .. '[aqua] ' .. color.default .. 'Script loaded')

-- Several styles in the same line.
aqua.print_chat(color.green .. '[+] ' .. color.default .. 'Hit '
    .. color.gold .. 'Player' .. color.default .. ' for '
    .. color.red .. '43' .. color.default .. ' damage')

-- Values use Lua formatting; percent signs in the result remain literal.
local accuracy = 87
aqua.print_chat(color.purple .. '[stats] ' .. color.default
    .. ('Accuracy: %d%%'):format(accuracy) .. color.gray .. ' (this round)')
```


---

## 5. Time, connection & rate helpers

| API | Returns | Notes |
| :--- | :--- | :--- |
| `aqua.get_curtime()` | number | Client curtime (seconds) |
| `aqua.realtime()` | number | Monotonic seconds. Keeps running in menus and across matches - use it for UI timing (fades, delays) that must not care about game state |
| `aqua.script_dir()` | string | Absolute path of this script's folder |
| `aqua.is_in_game()` | bool | Signon full (in level / match) via engine client |
| `aqua.is_connected()` | bool | Connection handshake started (may not be fully in level yet) |
| `aqua.server_address()` | string | The address the `status` command prints as "udp/ip" (`"a.b.c.d"`), or `""` when the network service did not resolve |
| `aqua.is_official_server()` | bool | True on a Valve dedicated server (`C_CSGameRules::m_bIsValveDS`); false on community/local servers and in the menu |
| `aqua.rate_limit(id, intervalSec)` | bool | `true` when `intervalSec` elapsed since last true for `id` (per script run). Resets if curtime goes backwards (new match). |
| `aqua.once(id)` | bool | `true` only the first time `id` is seen this script run |

```lua
if aqua.rate_limit("heartbeat", 1.0) then
    print("once per second")
end

if aqua.once("hello") then
    print("startup message")
end
```

---

## 6. Players, weapons, bombs, sync

### `aqua.get_players() -> table`

Array of player tables:

| Field | Type | Description |
| :--- | :--- | :--- |
| `index` | integer | Entity index |
| `handle` / `pawnHandle` | integer | Full controller/pawn entity handles, including serial; compare these to detect recycled indices. `4294967295` means an invalid pawn handle |
| `isLocalPlayer` | boolean | Local client |
| `isSynced` | boolean | Local or network-synced peer (skin sync) |
| `hasVisualData` | boolean | Bones / visual data ready |
| `duckAmount` | number | `0..1` crouch |
| `aimPunchPitch` / `aimPunchYaw` | number | Accumulated aim punch (degrees) |
| `money` | integer | Cash (`-1` if unknown) |
| `isBombCarrier` / `isPlanting` / `isReloading` | boolean | Flags |
| `hasDefuser` / `isImmune` / `isVisible` / `behindSmoke` | boolean | Flags |
| `weapons` | `{def,...}` | Held weapon **item definition indices** |
| `activeWeapon` | integer | Active weapon def index (`0` if none) |
| `name` | string | Sanitized name |
| `isBot` | boolean | `steamId == "0"` |
| **`steamId`** | **string** | **Exact decimal SteamID64** (never use number) |
| `armor` / `ping` | integer | |
| `competitiveRank` | integer | Skill group `0` or `1..18` |
| `alive` | boolean | Pawn alive |
| `health` / `team` | integer | From pawn when valid (`2` T / `3` CT) |
| `origin` | `{x,y,z}` | World position |
| `hitboxes` / **`bones`** | `{{x,y,z},...}` | Same table: world joint positions (CS2 set, up to 19); **Lua index 1 ≈ head**. Prefer `bones` for skeleton scripts |
| `controller` / `pawn` | table | Schema `__index` proxies |

#### Dynamic schema access

```lua
local account = p.controller and p.controller.m_iAccount
local flags = p.pawn and p.pawn.m_fFlags
-- uint64 schema fields (e.g. m_steamID) return DECIMAL strings
local sid = p.controller and p.controller.m_steamID  -- same precision as p.steamId
```

Supported types: `bool`, floats, ints, `Vector`, `QAngle`, string pointers, **int64/uint64 as decimal strings**.

### `aqua.get_local_player() -> table|nil`

Local player table, or `nil`.

### `aqua.get_synced_players() -> table`

Array of entity indices currently in the skin-sync set (not including a separate local entry - use `p.isSynced` / `isLocalPlayer` on player tables).

### `aqua.get_spectators() -> table`

Who is spectating the local player (snapshot). Each entry:

| Field | Type | Notes |
| :--- | :--- | :--- |
| `name` | string | Spectator name |
| `mode` | integer | `2` in-eye, `3` chase, `4` roaming |

### `aqua.get_weapons() -> table`

Dropped world weapons:

| Field | Type |
| :--- | :--- |
| `name` | string (display) |
| `clip` / `reserve` | integer (`-1` if N/A) |
| `origin` | `{x,y,z}` when available |

### `aqua.get_active_weapon() -> table|nil`

Copied details of the local active weapon, or nil without a live held weapon. Safe from
`on_draw`; captures refresh each draw frame and before `on_create_move`. `cmd.weapon`
contains the same details captured for that command callback.

| Field | Meaning |
|---|---|
| `handle`, `index`, `definition`, `name` | Full handle, weapon entity index, item definition, copied designer name |
| `clip`, `reserve_magazines` | Loaded ammo and spare magazines (not reserve rounds) |
| `active`, `reloading`, `scoped`, `mode` | Current weapon state |
| `recoil_index` | Native recoil counter |
| `inaccuracy`, `spread`, `max_speed` | Native active-weapon values; absent when the corresponding binding is unavailable |
| `next_primary_tick`, `next_primary_fraction`, `next_secondary_tick` | Native attack cooldown fields |
| `postpone_fire_tick`, `next_attack_time` | Revolver/deploy timing; times use the game's clock |
| `can_primary_attack`, `can_secondary_attack` | Snapshot checks for active weapon, ammo, reload, deploy/cooldown and revolver postpone |

Readiness is a snapshot check, not a guarantee of a shot. Native state and secondary
behavior still depend on the weapon; scripts request input through command methods.

### `aqua.get_weapon_inventory() -> table`

Copied local held inventory, using the fields above. Native spread/inaccuracy/max-speed
values are populated for the active weapon. Pass an entry's **full `handle`** to
`cmd:switch_weapon`; an item definition or entity index is not a full handle.

### `aqua.get_bombs() -> table`

Planted C4 (all GameTime fields use the same axis as `aqua.get_curtime()`):

| Field | Type | Notes |
| :--- | :--- | :--- |
| `origin` | `{x,y,z}` | When scene node present |
| `blowTime` | number | Detonation GameTime |
| `timerLength` | number | Full fuse length (seconds) |
| `defused` / `ticking` | boolean | |
| `site` | integer | `0` = A, `1` = B |
| `siteName` | string | `"A"` / `"B"` / `"?"` |
| `beingDefused` | boolean | |
| `defuseLength` | number | Full defuse duration (seconds) |
| `defuseCountDown` | number | GameTime when defuse completes |
| `hasExploded` | boolean | |
| `timeRemaining` | number | `blowTime - curtime` (can be negative) |
| `defuseRemaining` | number? | Only when `beingDefused`; `defuseCountDown - curtime` |

```lua
for _, b in ipairs(aqua.get_bombs()) do
    if b.ticking and not b.defused then
        print(b.siteName, string.format("%.1fs", b.timeRemaining or 0))
        if b.beingDefused then
            print("  defuse", b.defuseRemaining)
        end
    end
end
```

### `aqua.get_throwables() -> table`

Live grenades in flight (snapshot):

| Field | Type | Notes |
| :--- | :--- | :--- |
| `type` | string | `"smokegrenade"`, `"flashbang"`, `"hegrenade"`, `"molotov"`, `"incgrenade"` or `"decoy"` (match the panorama icon names) |
| `handle` | integer | Entity handle, for tracking one grenade across frames |
| `origin` | `{x,y,z}` | When scene node present |
| `timerRemaining` / `timerTotal` | number | Seconds; smoke counts volume left, flash counts fuse. `-1` = nothing to draw for this type |

```lua
for _, t in ipairs(aqua.get_throwables()) do
    if t.origin then
        print(t.type, string.format("%.1fs", t.timerRemaining or -1))
    end
end
```

---

## 6a. Shared script state and Aqua peers

`aqua.sync` shares small state values with other Aqua users on the current game
server. Enable **Sync** in the menu first. FFI is not required. All calls use copied
state without waiting for network I/O on the callback thread.

| API | Result |
|---|---|
| `aqua.sync.status()` | `{enabled, connected, inMatch, available, steamId}` |
| `aqua.sync.peers()` | Array of known Aqua peers in the current player roster, including the local user |
| `aqua.sync.publish(channel, value)` | `true` when accepted locally, or `false, reason` |
| `aqua.sync.publish(channel, nil)` | Withdraw this script's channel |
| `aqua.sync.read(steamId, channel)` | A fresh copied value plus age in seconds, or `nil` |

`available` means Sync is enabled, a match is active, and the server endpoint and
local identity are known. It does not require `connected`, so publishing can queue
the latest state during a temporary relay outage. `connected` describes the last
relay cycle, not an acknowledgement of an individual value. `steamId` is always
an exact decimal **string** (`"0"` when unknown); `read` also requires this string,
never a rounded Lua number.

Each peer has:

| Field | Meaning |
|---|---|
| `steamId` | Exact decimal SteamID64 string |
| `index`, `name`, `team` | Current copied player metadata; team `2` T / `3` CT / `0` unavailable |
| `isLocalPlayer` | This client |
| `hasData` | Fresh script channels are available; does not imply your particular channel exists |

Remote discovery uses the same cached loadouts as cosmetic sync. Older Aqua clients
can appear with `hasData=false`. A peer entry is not proof that Aqua is currently
running or that the identity is authenticated; absent remote peers may also be
waiting for their first relay poll.

```lua
local channel = "my_script.ready.v1"  -- same name on every cooperating client

function on_draw()
    local status = aqua.sync.status()
    if not status.available then return end

    -- Identical values are free; changed values are rate-limited.
    local ok, reason = aqua.sync.publish(channel, {ready = true})
    if not ok then return end

    for _, peer in ipairs(aqua.sync.peers()) do
        if not peer.isLocalPlayer then
            local data, age = aqua.sync.read(peer.steamId, channel)
            if type(data) == "table" and data.ready == true then
                -- Draw your own indicator using peer.index/name and age.
            end
        end
    end
end
```

Use namespaced channel names to avoid collisions between scripts. Channels accept
1-48 ASCII letters, digits, `.`, `_`, `-`, `:`. A local channel belongs to the script
that first publishes it; another local script cannot overwrite or withdraw it.
Other clients can publish the same channel under their own SteamID.

Values may be booleans, finite numbers, UTF-8 strings or tables. Tables must be
objects with string keys or dense arrays with integer keys `1..N`; empty tables
encode as objects. Nested nil/null, mixed/sparse keys, cycles, functions, threads
and userdata are rejected. Numbers have Lua's usual floating-point precision;
send identifiers as strings. Reading creates a copy; editing it never changes
published state.

Limits: **1024 encoded JSON bytes per value**, nesting depth **8**, **128 value
nodes**, **8 channels per script**, **16 channels per client**. Each script may
make **4 changed publishes per second**, with a burst of **4**. Repeating the same
value consumes no rate allowance. Withdrawal remains possible when rate-limited
or unavailable; invalid input returns `false, reason`. An unknown channel, invalid
SteamID string, absent peer or stale value reads as `nil`.

This is **latest state**, with the existing roughly **3-second** relay cycle and
additional delay from polling/network failures. Rapid updates can be coalesced;
it is not a reliable message/event stream. Active state refreshes each cycle and
expires after **30 seconds** without a fresh publication. The returned age uses
the publisher's whole-second timestamp (local reads return `0`); clocks must be
within **10 seconds** for future timestamps to be accepted.

Disabling Sync, leaving the match, changing server/local identity, or stopping,
reloading or erroring the publishing script clears its local state. Republish when
available again. Withdrawals reach peers on a later poll; during an outage they
can retain the old value until expiry. Departed players are removed from the local
roster. State is scoped to the full server endpoint, including port, rather than a
globally unique match ID; a quick rematch at the same endpoint may briefly expose
the prior state until its deadline.

The current relay does **not authenticate SteamIDs or encrypt these payloads**.
Treat identities and received values as untrusted. Do not send secrets, make
authorization decisions from this data, or execute received code/commands.

---

## 6b. Current match and observer state

### `aqua.get_game_rules() -> table|nil`

Copied current state, refreshed once per rendered game frame. It works when a
script starts mid-match without reconstructing earlier events. Returns `nil` in
the menu, after disconnect, or when the required state is unavailable. Editing
the returned table does not change the game or the next result.

| Field | Meaning |
|---|---|
| `mapName` | Short map identifier, also available through `aqua.engine.get_map_name()` |
| `scoreT`, `scoreCT` | Current T/CT scores; absent if the respective team is unavailable |
| `roundsPlayed`, `roundNumber` | Completed rounds and `roundsPlayed + 1`, matching the scoreboard convention |
| `phase` | `"warmup"`, `"freeze"`, `"live"`, `"round_over"`, `"halftime"` or `"over"` |
| `gamePhase` | Raw native phase number; distinct from the derived `phase` string |
| `warmup`, `freeze`, `matchStarted` | Current native flags |
| `halftime`, `matchOver` | Native halftime/match-ended phase flags |
| `overtime` | Native overtime counter; `0` during regulation |
| `roundTime`, `roundStartTime` | Configured round duration in seconds and start on the game's clock |
| `time` | Pause-adjusted game time in seconds; absent if its native function is unavailable |
| `roundTimeRemaining` | Native round timer, absent during warmup or if unavailable; game modes can use sentinel values |
| `phaseTimeRemaining` | Nonnegative current-phase countdown, or absent if no countdown is known |
| `freezeTimeRemaining` | Freeze countdown only while `phase == "freeze"`; otherwise absent |
| `roundWinStatus` | Raw native round-win status; `0` while undecided |

Phase precedence follows the game: match ended, warmup, freeze, round over,
halftime, then live. Flags remain native values, so consult `phase` for that
precedence. `matchStarted` is independent and does not override the phase.

Freeze timing uses the upcoming round start and the engine's pause-aware clock.
Warmup without a scheduled end has no countdown. Halftime uses the native phase
timer; round over uses the scheduled restart. These are client-reported values,
not a promise that every server/game mode exposes a finite authoritative timer.

```lua
local rules = aqua.get_game_rules()
if rules and rules.freezeTimeRemaining then
    print(string.format("Freeze: %.1fs", rules.freezeTimeRemaining))
end
```

### `aqua.get_observer_target() -> table|nil`

The player the local client is following in-eye (`mode=2`) or chase (`mode=3`).
Returns a copied identity table with `index`, `handle`, `pawnHandle`, `steamId`,
`name`, `team`, `isLocalPlayer` and `mode`. SteamIDs are exact decimal strings,
including `"0"` for bots. Match `handle` to `get_players()` if you need that
player's health, origin or other full player fields.

Returns `nil` while alive, roaming, disconnected, following a non-player camera,
or when the target's full handle is no longer valid. It follows the active camera
pawn and does not reuse a dead player's stale observer target after respawn.

## 6c. Engine methods (`aqua.engine`)

### `aqua.engine.get_map_name() -> string|nil`

Current short map identifier, for example `"de_nuke"`, copied from the engine.
Returns `nil` outside a match or if unavailable. It is not a localized display
name or a `maps/...vpk` path.

### `aqua.engine.exec(command) -> bool[, reason]`

Queue a client console command through the engine's unrestricted command path.
`true` means accepted into the local queue; it does not confirm execution or
server permission. The queue is pumped on the game thread after script callbacks,
so calling this from `on_draw` does not execute game functions inside drawing.

Commands accept 1-1024 bytes without NUL. Quotes, semicolons and newlines retain
their normal console meaning; no JavaScript escaping or Panorama panel is needed.
Limits are 8 queued commands per script, 32 overall, and 8 accepted commands per
second per script with burst 8. Stopping/erroring/unloading a script removes its
pending commands. An already submitted engine command cannot be recalled.

Unrestricted concerns client command flags; server-side cheats/admin rules still
apply. Unavailable bindings, invalid input or limits return `false, reason`.

```lua
local ok, reason = aqua.engine.exec("give weapon_ssg08")
if not ok then print(reason) end
```

## 6d. Outbound UDP (`aqua.udp`)

No FFI is required. Requests use a background worker and never wait for a network
reply on the callback thread. Only outbound UDP is provided.

| API | Result |
|---|---|
| `aqua.udp.send(ip, port, data)` | Request ID when queued, or `nil, reason` |
| `aqua.udp.poll(id)` | Consumes `{ok, bytes, error}` when ready; otherwise `nil, "pending"` or `nil, "unknown request"` |
| `aqua.udp.cancel(id)` | Removes this script's request/result; `true` if it existed |

`ip` must be a numeric IPv4 or IPv6 address, such as `"127.0.0.1"` or `"::1"`;
hostnames and address-with-port strings are not accepted. `port` is an integer
from 1 to 65535. `data` is a binary string, including embedded NUL, with at most
1200 bytes. An empty string sends a zero-length datagram.

There are 8 outstanding requests/results per script, 32 overall, and a rate limit
of 32 sends per second per script with burst 8. Poll completed requests to free
their slots. Unconsumed work/results expire after 30 seconds. Request IDs belong
to the publishing script; another script cannot poll or cancel them.

`ok=true` means the local socket accepted the datagram, not that the recipient
received it. UDP supplies no acknowledgements, ordering or retransmission. Socket
errors appear in the completed result. Cancel/stop/error/unload removes queued
work and results; a datagram already being sent may still reach its recipient.

```lua
local request, reason = aqua.udp.send("127.0.0.1", 40123, "hello")

function on_draw()
    if not request then return end
    local result, state = aqua.udp.poll(request)
    if result then
        if not result.ok then print(result.error) end
        request = nil
    elseif state ~= "pending" then
        request = nil
    end
end
```

## 7. Items

| API | Returns |
| :--- | :--- |
| `aqua.glove_kit_id(paintIdx)` | Glove paint-kit id for `Skins.Glove.*.PaintIdx` (0 = none; N = `SortedGloveKitIds()[N-1]`) |
| `aqua.agent_def_index(team, modelIndex)` | Agent item def for `Misc.Agent.*.ModelIndex` (`"ct"`/`"t"`; 0 = none) |
| `aqua.item_icon(def)` | short equipment icon name (`"ak47"`, `"m4a1_silencer"`, ...) or `nil` |
| `aqua.item_name(def)` | localized display name or `nil` |
| `aqua.item_path(def)` | Panorama path `file://{images}/icons/equipment/<icon>.svg` or `nil` |

Icons match the built-in scoreboard, including the MP5-SD, M4A1-S, USP-S and R8.

```lua
for _, def in ipairs(p.weapons or {}) do
    local icon = aqua.item_icon(def)
    local path = aqua.item_path(def)
    print(def, icon, aqua.item_name(def))
end
```

---

## 8. Input

Key codes are **SDL scancodes** (keyboard) or `aqua.keys.MOUSE1`..`MOUSE5` (same encoding as C++ `KeyState`).

### Constants: `aqua.keys`

Common entries: `A`–`Z`, `N0`–`N9`, `F1`–`F12`, `TAB`, `ESC`, `SPACE`, `ENTER`, `INSERT`, arrows, modifiers, `MOUSE1`..`MOUSE5`, `MOUSE_BASE`, `UNBOUND`.

### API

| API | Description |
| :--- | :--- |
| `aqua.input.is_down(code)` | Held, **raw** - true even while the key is being typed into a chat/console box (`false` for `UNBOUND`) |
| `aqua.input.is_bind_down(code)` | Held **and** binds are live right now - `is_down` minus the typing case (`false` for `UNBOUND`) |
| `aqua.input.is_key_down(scancode)` | Keyboard held |
| `aqua.input.is_mouse_down(button)` | `1` left, `2` middle, `3` right |
| `aqua.input.key_pressed(scancode)` | Rising edge this frame (sticky until end of stage 12) |
| `aqua.input.mouse_clicked(button)` | Rising edge this frame |
| `aqua.input.mouse()` | `x, y, buttons` - SDL coords + button bitmask |
| `aqua.input.wheel()` | Vertical wheel notches this frame (positive = up, `0` idle) |
| `aqua.input.key_name(code)` | Display label (`"E"`, `"MOUSE1"`, ...) |
| `aqua.input.scoreboard_open()` | TAB held (same check as the built-in scoreboard) |
| `aqua.input.menu_open()` | aqua menu visible |
| `aqua.is_menu_open()` | alias of menu visible |
| `aqua.input.console_open()` | **CS2's own console is up.** Read from a value sampled once per frame on the game thread, so it is safe from `on_draw` too |
| `aqua.input.binds_suppressed()` | True while a keypress is being **typed** rather than meant as a bind (console open, menu open, or any script lock) |
| `aqua.input.lock()` | Free OS cursor, disable relative mouse, **block game** mouse/keyboard (same capture path as the menu). Per-script; refcounted across scripts. |
| `aqua.input.unlock()` | Release **this** script's lock. Menu open / other scripts may still capture. |
| `aqua.input.set_locked(bool)` | `lock()` if true, `unlock()` if false |
| `aqua.input.is_locked()` | Whether **this** script currently holds `lock()` |
| `aqua.input.is_capture_active()` | Game input is blocked right now (menu open **or** any script lock) |

```lua
local K = aqua.keys

function on_draw()
    if aqua.input.menu_open() then return end

    if aqua.input.key_pressed(K.F6) then
        print("F6 pressed")
    end

    if aqua.input.is_down(K.MOUSE1) then
        -- held
    end

    if aqua.input.mouse_clicked(1) then
        local mx, my, buttons = aqua.input.mouse()
        print("click", mx, my)
    end

    if aqua.input.scoreboard_open() and aqua.rate_limit("sb", 0.2) then
        -- inject panorama payload here
    end
end
```

#### Raw keys vs binds

`is_down` / `is_key_down` / `key_pressed` are **raw**: they report the physical key, and they
cannot tell a bind from a key being typed into CS2's console. That is deliberate - a script
asking for raw key state gets raw key state - but it means a script bind on `T` will also fire
while you type `T` into the console.

If your key is acting as a **bind**, use `is_bind_down` (or gate on `binds_suppressed()`), which
is dead while the console or the aqua menu owns the keyboard:

```lua
-- bind: must not fire while typing into the console
if aqua.input.is_bind_down(cfg.key) then
    toggle_thing()
end

-- equivalent, when you need the raw edge helpers
if aqua.input.key_pressed(K.T) and not aqua.input.binds_suppressed() then
    toggle_thing()
end
```

The console half comes from the engine itself (`GameUIService_001::IsConsoleVisible`), not from a
guess about focus, so it is exact rather than heuristic. Both are read from a value sampled once
per frame on the game thread, so calling them from `on_draw` is safe.

**Wheel:** latched for the whole FSN burst like the key/mouse edges, so every script sees
the same value from either callback and one script reading it does not starve another. It
is fed from **every** wheel event, blocked or not - a script cycling something with the
wheel needs it while the game owns input. The event still reaches the game, so a bare-wheel
binding shares the input with weapon switch; gate on a modifier (or `aqua.input.lock()`) if
that matters.

```lua
local steps = aqua.input.wheel()
if steps ~= 0 and aqua.input.is_key_down(aqua.keys.LALT) then
    station = station + (steps > 0 and 1 or -1)
end
```

### Input capture (`lock` / `unlock`)

Same mechanism the aqua menu uses when open:

* Show the free OS cursor and turn off window relative-mouse mode
* Drop mouse motion/buttons/wheel and keyboard/text events so the game does not see them
* Script-held locks are **refcounted**; STOP / unload auto-`unlock`s that script
* Opening the menu while locked keeps capture; closing the menu while still locked keeps capture
* INSERT still toggles the menu; it does not force-unlock scripts
* Does **not** open the aqua UI or claim menu click regions - only free cursor + block game input
* Scripts can still read `aqua.input.*` / draw overlays while locked

```lua
local locked = false

function on_frame_stage_notify(stage)
    if stage ~= 12 then return end
    if aqua.input.key_pressed(aqua.keys.F7) then
        locked = not locked
        aqua.input.set_locked(locked)
        print(locked and "input locked" or "input unlocked")
    end
end

-- Always safe: unlock on stop is automatic, but you can unlock early:
-- aqua.input.unlock()
```

---

## 9. Config

| API | Description |
| :--- | :--- |
| `aqua.config.get(key)` | bool / int / float / `{r,g,b,a}` / string (auto-creates) |
| `aqua.config.set(key, value)` | `true` if accepted; `false` for incompatible types or failure to create an editable profile |
| `aqua.config.list([prefix])` | `{ {key=, value=, displayName?}, ... }` |
| `aqua.config.save()` | `SaveIfDirty` |
| `aqua.config.override(key, value [, priority=75])` | Set this script's temporary layer for a registered key; returns false for unknown keys or incompatible values |
| `aqua.config.clear_override([key])` | Release this script's layer for one key, or all its layers when omitted |

Prefer keys under `Scripts.<yourName>.*`.

`default` is a clean-slate profile. The first actual setting change automatically creates and selects an editable `new config` profile (numbered when that name is taken). New keys written through `aqua.config.set` use their type's zero/empty value as the default; register a UI control first when a different default is needed. Reading or registering a key without changing its value does not create a profile. Registering a control can replace the implicit zero default created by an earlier read; saved values and actual edits are preserved.

Moving the menu (`Window.X/Y`) while default is selected only changes its position for the current session; it does not create a profile or save changes into default. Menu position continues to save normally in editable profiles.

Temporary overrides last until released, or until the script stops, errors or unloads.
Higher priority wins (integer 0..200); the latest update wins ties. Releasing a winning
layer reveals the next script layer or the latest user value. `get`/`list` read effective
values, while `set`, user edits, save and profile duplication operate on saved values
underneath. Overrides survive profile switches while the owning script keeps running;
they never enter profile JSON or create a profile. Register a UI control first for a
script setting that needs an explicit default/type.

Config-override priorities are **separate from aim/action priorities**. The default
is 75, but anti-aim's 50 and ragebot's 100 do not participate in config arbitration.
Each key retains one layer per script. A lower-priority layer stays stored beneath
the winner; updating a layer replaces that script's value/priority and its tie order.
Clearing the winner reveals the next layer, unlike command requests that already lost.

For example, script A overrides a key at 75 and script B at 100: B's value is read.
Clearing B reveals A's value; clearing A reveals the current user/profile value.

### Menu UI from scripts (`aqua.ui`)

Register controls once while the script runs (typically at top-level). They are
cleared on Stop/Run and appear in the selected script's **OPTIONS** view. Registration
copies descriptions to the menu; it does not draw immediately or invoke Lua from the
render thread. Repeated registration appends repeated rows, so use `aqua.ui.clear()`
when rebuilding the options.

`aqua.ui.card(title, key?)` starts a new titled card. Following controls belong to it
until the next `card` call; no closing call is needed. An optional bool config key
adds a feature switch to the header, defaults to `true` if newly declared, and dims
the card body when off. The script must check that key to disable its own behavior.
Saved preferences survive registration. Controls before the first explicit card go
in a card titled with the script's display title. Cards flow through two columns
when there is room and one column in a narrow workspace.

`label` remains wrapped display text, and `separator()` remains a hairline inside
the current card. Neither starts a card. Existing scripts can adopt titled cards
without changing the config keys used by their controls.

Every control except `label`, `value` and `separator` stores its value in the config
key you pass, so it is saved per profile and readable with `aqua.config.get(key)`.
`value` rows are the exception: their second argument is a row id, the text lives only
in the menu, and `aqua.ui.set_value` updates it. Use them for live readouts (hit counts,
the current target) - writing those through `aqua.config.set` would mark the profile
dirty and rewrite it every save.

Tooltips, subtitles and conditions are **modifiers**: call them right after the
control or card they belong to (see the second table). Controls with config keys
appear in menu search under `<script title> > <card>`, including their tooltips.

| API | Description |
| :--- | :--- |
| `aqua.ui.card(title [, key])` | Start a titled card; optional bool key adds a header feature switch |
| `aqua.ui.checkbox(label, key [, default=false])` | Toggle; registers config key |
| `aqua.ui.switch(label, key [, default=false])` | Same bool key as `checkbox`, drawn as a label with a switch on the right |
| `aqua.ui.slider(label, key, default, min, max [, opts])` | Slider. `opts = { int = true, step = 5, format = "%d ms" }`: `int` stores an **int** key, `step` snaps drag and typed values, `format` is a printf format with exactly one `%d`/`%i` (int) or `%f`/`%e`/`%g` (float) conversion; `%%` is a literal percent. A bad format raises an error |
| `aqua.ui.color(label, key [, {r,g,b,a} [, presets]])` | Color swatch (config `float[4]`). `presets = { {r,g,b,a}, ... }` (max 8) adds one-click swatches; pass `nil` as the default to keep white |
| `aqua.ui.dropdown(label, key, items [, defaultIndex=0])` | Combo; `items = {"A","B",...}`; config stores **0-based int** |
| `aqua.ui.segmented(label, key, items [, defaultIndex=0])` | Side-by-side choice buttons for 2-5 short items; same storage as `dropdown` |
| `aqua.ui.multiselect(label, key, items [, defaults])` | Wrapping chips, any number selected (max 31 items). Config stores an **int bitmask**: item `i` (0-based) is on when `bit.band(v, bit.lshift(1, i)) ~= 0`. `defaults` is a bitmask or a table of 0-based indices |
| `aqua.ui.text(label, key [, default="" [, maxLength=64]])` | Single-line text field (config string, max 256). Saved while typing |
| `aqua.ui.keybind(label, key [, defaultCode=UNBOUND])` | Key capture button; config stores KeyState int (`aqua.keys.*`) |
| `aqua.ui.button(label, key [, icon="play"])` | Momentary press. Sets the **bool** config `key` true; the script polls it and sets it back to false (no cross-thread Lua callback). `icon` is a name from `aqua.ui.icons`, or `"none"`; an unknown name raises an error |
| `aqua.ui.label(text)` | Display-only text row |
| `aqua.ui.value(label, id [, text])` | Read-only label + value row for live stats. `id` is NOT a config key: nothing is saved |
| `aqua.ui.set_value(id, text)` -> `bool` | Update a `value` row (string or number). Cheap enough for every frame; `false` when no row has that id |
| `aqua.ui.separator()` | Thin horizontal rule |
| `aqua.ui.clear()` | Drop fields for this script (re-register after) |
| `aqua.ui.icons` | Array of the icon names `button` accepts |

**Modifiers** apply to the control or card declared **just before** them. They raise an
error when nothing was declared yet or the last call was `separator()`.

| API | Description |
| :--- | :--- |
| `aqua.ui.tooltip(text [, meta])` | Hover tooltip; `meta` is an optional small mono line (`"default on"`). On a card it sits on the title. Script tooltips also show in menu search |
| `aqua.ui.description(text)` | Grey subtitle: under the label for checkbox / switch, a caption under every other row, the first line of a card body |
| `aqua.ui.visible_if(key [, value])` | Hide the row (or the whole card) unless config `key` equals `value`; without `value`, unless it is truthy (true, non-zero, non-empty). Hidden rows also leave menu search |
| `aqua.ui.enabled_if(key [, value])` | Same test, but the row (or card) stays visible, greyed out and locked |

Conditions compare exactly: a float key equals a number only when it is that exact
value, so condition on bools, dropdown indices or strings.

```lua
aqua.ui.card("Demo options", "Scripts.demo.Enabled")
aqua.ui.slider("Scale", "Scripts.demo.Scale", 1.0, 0.25, 3.0)
aqua.ui.color("Tint", "Scripts.demo.Tint", {0.3, 0.7, 1, 1})
aqua.ui.switch("Outline", "Scripts.demo.Outline", true)
aqua.ui.tooltip("Draws a dark edge so the text stays readable on bright maps.", "default on")
aqua.ui.card("Controls")
aqua.ui.dropdown("Mode", "Scripts.demo.Mode", { "Off", "Enemies", "All" }, 1)
aqua.ui.tooltip("Which players the overlay follows.")
aqua.ui.keybind("Toggle", "Scripts.demo.Key", aqua.keys.F6)
aqua.ui.slider("Delay", "Scripts.demo.Delay", 100, 0, 500, { int = true, step = 10, format = "%d ms" })
aqua.ui.enabled_if("Scripts.demo.Mode", 2)
aqua.ui.button("Do the thing", "Scripts.demo.Go", "bolt")
aqua.ui.value("Hits", "hits", "0")

aqua.ui.card("Filters")
aqua.ui.multiselect("Show on", "Scripts.demo.Parts", { "Head", "Chest", "Legs" }, { 0 })
aqua.ui.segmented("Size", "Scripts.demo.Size", { "S", "M", "L" }, 1)
aqua.ui.text("Prefix", "Scripts.demo.Prefix", "[demo]", 16)
aqua.ui.visible_if("Scripts.demo.Mode", 2)        -- only while Mode = "All"
aqua.ui.color("Accent", "Scripts.demo.Accent", nil, { {1, 0.3, 0.3, 1}, {0.3, 0.7, 1, 1} })

local hits = 0

local function selected(mask, index)              -- 0-based, like dropdown indices
    return bit.band(mask, bit.lshift(1, index)) ~= 0
end

function on_draw()
    if not aqua.config.get("Scripts.demo.Enabled") then return end
    local mode = aqua.config.get("Scripts.demo.Mode") or 0
    if aqua.input.key_pressed(aqua.config.get("Scripts.demo.Key") or 0) then
        print("toggle key")
    end
    if aqua.config.get("Scripts.demo.Go") == true then
        aqua.config.set("Scripts.demo.Go", false)   -- consume the press
        hits = hits + 1
        aqua.ui.set_value("hits", hits)             -- numbers are accepted too
    end
    local parts = aqua.config.get("Scripts.demo.Parts") or 0
    if selected(parts, 0) then
        -- "Head" is ticked
    end
    -- ...
end
```

## 9a. Game events (`aqua.events`)

You **listen**, you never **subscribe**. aqua already shadows the engine's event
dispatcher (`CGameEventManager::FireEventClientSide`), so every networked event
passes through it with `AddListener` called **zero** times. `aqua.events` only
tells *aqua* which fields to copy out - the engine never learns a listener
exists, and there is no script-reachable way to register one. That is deliberate:
a registration sits in a game-owned, enumerable list, and one that outlives an
unload is a call into unmapped memory.

### `aqua.events.listen(name, fields)` -> `bool`

Declare an event and the field keys you want off it. You must name the keys:
the engine's getters are by-name and there is no key enumeration we can walk, so
aqua cannot guess what you came for.

Two forms, and you can mix them:

```lua
aqua.events.listen("player_hurt", { "userid", "dmg_health" })          -- array: read as int
aqua.events.listen("player_death", { weapon = "string", userid = "int" }) -- map: explicit type
```

* Types are `"int"` (default), `"float"`, `"bool"`, `"string"` and `"player"`.
  `player` returns the identity table described below; other types retain their
  existing number/string/boolean results.
* A conflicting type for a key already requested by another script rejects your
  entire update and returns false. Its previous subscription is retained. `int`
  and `player` can coexist: each script receives its requested representation.
* Unknown event names and unknown keys are not errors: you get `0` / `""`.
  Nothing validates against the game's event schema.
* Calling it again for the same event replaces your script's keys and discards
  its pending deliveries from the old subscription. `{}` listens without fields.
* `aqua.events.unlisten(name)` removes this script's subscription to that event.
* `aqua.events.clear()` removes this script's event subscriptions. Other scripts
  and automatic shot callbacks retain their subscriptions.
* Subscriptions and pending delivery are removed on stop, error or unload.

### Examples

Resolve player fields directly, including the local player on the first event:

```lua
aqua.events.listen("player_hurt", {
    userid = "player", attacker = "player", dmg_health = "int",
})

function on_event(name, data)
    if name == "player_hurt" and data.userid and data.userid.isLocalPlayer then
        print("Local damage: " .. data.dmg_health)
    end
end
```

A `player` field always includes `userid` (the original integer, default `0`) and
`resolved`. When resolved it also includes `index`, `handle`, `pawnHandle`,
`steamId`, `name`, `team` and `isLocalPlayer`. Match `handle` with the local or
other `get_players()` entries when needed. Full handles include serial numbers;
SteamIDs stay strings. Bots can resolve with `steamId="0"`, so SteamID alone
cannot distinguish bots.

Resolution uses the engine's event-player getter, not an assumed userid/index
formula. Identity is copied at event capture and remains the captured identity
even if the player leaves before delivery. A world attacker, unknown player,
new player awaiting its first snapshot or recycled identity produces
`resolved=false` with no guessed player fields. Fields unavailable because the
native getter is unresolved can be absent entirely. Existing array/`int` fields
continue returning the raw number.

`player_hurt` - the fields the built-in hitmarker uses:

```lua
aqua.events.listen("player_hurt", {
    "userid", "attacker", "health", "armor", "dmg_health", "dmg_armor", "hitgroup"
})
```

`player_death`:

```lua
aqua.events.listen("player_death", {
    userid = "int", attacker = "int", assister = "int",
    weapon = "string", headshot = "bool", noscope = "bool",
})
```

These two are examples, not a supported list - **any** event the server sends
works the same way. Name it, name its keys, read them in `on_event`.

### Limits worth knowing

* **No `uint64` getter bound.** `int`, `float`, `bool` and `string` are; a 64-bit
  field (e.g. an xuid) will not read correctly through any of them.
* **`userid="int"` is the raw event number**. Request `userid="player"` (and
  likewise attacker/assister) for resolved identity; do not infer an entity index.
* The queue holds `256` events between frames. Past that, events are dropped and
  a `WARNING` naming the count goes to the log rather than the queue growing.
* Delivery is **once per frame**, batched - not the instant the engine fires.

## 9b. ConVars (read-only, cached)

Each name is looked up once, then cached.

| API | Description |
| :--- | :--- |
| `aqua.cvar.get(name)` | float, or `nil` if missing |
| `aqua.cvar.get_int(name)` | int, or `nil` |
| `aqua.cvar.get_bool(name)` | bool, or `nil` |
| `aqua.cvar.type(name)` | `"bool"`/`"int32"`/`"float32"`/... , `"invalid"` if the tag is unavailable, `nil` if missing |
| `aqua.cvar.exists(name)` | bool |

```lua
local vol = aqua.cvar.get("volume") or 1.0
local sens = aqua.cvar.get("sensitivity")

-- Getters CONVERT, so this is fine even though sv_showimpacts is an int cvar:
local impacts = aqua.cvar.get("sv_showimpacts")        -- 1.0, not 1.4e-45
```

**The getters convert.** A convar's value is a type-tagged union, and each getter
now reads it as its real type and converts - `aqua.cvar.get` on an **int** convar
returns `1.0`, not the float reinterpretation of the bit pattern.

Before 2026-08-06 they reinterpreted raw bytes, so calling the wrong getter
returned garbage with nothing to indicate it: `sv_showimpacts` read back as
`1.4012984643248e-45` (the float view of `0x00000001`) and looked like a broken
or unset convar rather than "enabled".

**One case still behaves the old way:** if the type tag fails its runtime
validation, conversion is disabled and the getters reinterpret raw bytes again.
`aqua.cvar.type(name)` returns `"invalid"` in that state, so a script that cares
can check for it. It is a fail-closed fallback, not the expected path.

---

## 9c. Trace ray (`aqua.trace_ray`)

Line cast via engine `TraceShape` (geometry) and optional smoke density LOS (`SmokeVolume_GetSmokeDensityLOS`).

**Thread rule (hard):** only from game-thread callbacks such as `on_frame_stage_notify`. **Errors if called from `on_draw`.** Cache the returned table and read it when drawing.

```lua
-- cache
local g_lastTrace = nil

function on_frame_stage_notify(stage)
    -- any stage is fine; many scripts use a non-draw stage or stage 12 before on_draw
    local me = aqua.get_local_player()
    if not me or not me.origin then return end
    local eye = { x = me.origin.x, y = me.origin.y, z = me.origin.z + 64 }
    local forward = { x = eye.x + 1000, y = eye.y, z = eye.z } -- example end
    g_lastTrace = aqua.trace_ray(eye, forward, {
        walls = true,
        players = true,
        smoke = true,      -- default false
        skip_local = true, -- default true (filter skips local pawn)
    })
end

function on_draw()
    local t = g_lastTrace
    if not t then return end
    if t.hit then
        local sx, sy, vis = aqua.draw.world_to_screen(t.endpos)
        if vis then
            aqua.draw.circle(sx, sy, 4, 1, 0.2, 0.2, 1)
        end
    end
end
```

**Signatures**

| Call | Notes |
| :--- | :--- |
| `aqua.trace_ray(from, to [, opts])` | `from`/`to` as `{x,y,z}` or `{1,2,3}` |
| `aqua.trace_ray(fx,fy,fz, tx,ty,tz [, opts])` | Six numbers |

**`opts` (all optional)**

| Field | Default | Meaning |
| :--- | :--- | :--- |
| `walls` | `true` | Hit world / props (solid, window, debris, …) |
| `players` | `true` | Hit player pawns (hitbox / player contents) |
| `smoke` | `false` | Smoke density LOS along free segment |
| `skip_local` | `true` | Engine filter skips local pawn |

At least one of `walls` / `players` / `smoke` must be true. Geometry traces need a live local pawn (in-match). Smoke-only does not.

**Return table**

| Field | Type | Notes |
| :--- | :--- | :--- |
| `hit` | bool | `hit_world or hit_player or hit_smoke` |
| `fraction` | number | 0..1 along the ray (geometry; 1 if only smoke / miss) |
| `hit_world` | bool | Non-player geometry hit (`walls`) |
| `hit_player` | bool | Matched a published player pawn (`players`) |
| `hit_smoke` | bool | Density ≥ 0.2 (same threshold as the built-in ESP and aim) |
| `startpos` / `endpos` / `normal` | `{x,y,z}` | Engine start, stop, surface normal |
| `entity_index` | int? | Controller list index when `hit_player` |
| `hitgroup` | int? | When hitbox data present |
| `smoke_density` | number? | Present when `smoke=true` |

Smoke is evaluated along `from → endpos` when geometry ran (stops at first wall/player), or `from → to` for smoke-only.

---

## 9d. Hull traces (`aqua.trace_hull`)

`aqua.trace_hull(from, to, mins, maxs)` sweeps an axis-aligned hull with the local
player's native movement collision mask/filter, skipping the local pawn. Each argument
is `{x,y,z}` or `{x=,y=,z=}`; mins/maxs are relative to the swept origin. All components
must be finite and mins must be less than maxs on every axis.

Call from a game-thread callback outside `on_draw`, then cache results for drawing.
Requires a live local pawn and movement/trace bindings. Returns `nil, reason` when
unavailable or the native result is invalid; malformed inputs and wrong callbacks raise.

The result contains `hit`, `solid` (engine movement rejection flag), `fraction` (0..1),
`startpos`, `endpos`, `normal`, and optional `entity_index`/`entity_handle` for the hit
entity. This entity index is the actual hit entity, which can be a pawn or prop.

```lua
local sweep, reason = aqua.trace_hull(
    {0, 0, 0}, {100, 0, 0}, {-16, -16, 0}, {16, 16, 72})
if sweep then print(sweep.hit, sweep.fraction) end
```

## 10. Drawing (`aqua.draw`)

Call `aqua.draw` functions inside `on_draw()`. A drawing lasts one frame, so
draw it again every frame.

Positions are screen pixels from the top-left corner. Colors are
`{ r, g, b, a }` from 0 to 1; alpha is optional. Options go in a table at the
end.

```lua
local WHITE = { 1, 1, 1 }
local PANEL = { 0.05, 0.05, 0.08, 0.9 }

function on_draw()
    aqua.draw.rect(20, 20, 180, 36, PANEL, { rounding = 6 })
    aqua.draw.text(32, 28, "Hello from Lua", WHITE, { size = 16 })
end
```

You can also pass a color as separate numbers (`1, 0, 0, 1`). If a number
follows it, include the alpha, or that number is read as the alpha.

### Shapes

| Function | Draws | Options |
| :--- | :--- | :--- |
| `rect(x, y, w, h, color, [opts])` | Filled rectangle | `rounding`, `clip` |
| `rect_outline(x, y, w, h, color, [opts])` | Rectangle border | `rounding`, `thickness`, `clip` |
| `rect_gradient(x, y, w, h, tl, tr, br, bl, [opts])` | Rectangle blending four corner colors | `rounding`, `clip` |
| `line(x1, y1, x2, y2, color, [opts])` | Line | `thickness`, `clip` |
| `circle(x, y, radius, color, [opts])` | Circle outline | `thickness`, `segments`, `clip` |
| `arc(x, y, radius, startDeg, endDeg, color, [opts])` | Part of a circle outline | `thickness`, `segments`, `clip` |
| `polyline(points, color, [opts])` | Line through a list of points | `thickness`, `closed`, `clip` |
| `bar(x, y, w, h, fraction, background, fill, [opts])` | Bar filled from 0 to 1 | `rounding`, `vertical`, `clip` |

| Option | Meaning |
| :--- | :--- |
| `rounding` | Corner radius in pixels. Default `0` |
| `thickness` | Line width in pixels. Default `1` |
| `segments` | Straight pieces per curve. Default `48` |
| `closed` | Join the last point back to the first |
| `vertical` | Fill from the bottom up |
| `clip` | `{ x, y, w, h }`: draw only inside this rectangle |

A number instead of the table sets `thickness` (`rounding` for `rect`).
Arc angles are degrees: `0` points right and `90` points down.

```lua
local WHITE = { 1, 1, 1 }
local RED   = { 1, 0.2, 0.2 }
local GREEN = { 0.2, 0.9, 0.3 }
local DARK  = { 0.1, 0.1, 0.1, 0.9 }
local BLUE  = { 0.3, 0.55, 1, 1 }
local CLEAR = { 0.3, 0.55, 1, 0 }

function on_draw()
    aqua.draw.rect(20, 20, 120, 60, DARK, { rounding = 4 })
    aqua.draw.rect_outline(20, 100, 120, 60, WHITE, { thickness = 2 })
    aqua.draw.line(20, 180, 140, 220, RED, 2)

    -- Centre (80, 280), radius 40
    aqua.draw.circle(80, 280, 40, WHITE, 2)

    -- Top half: from pointing left (180) to pointing right (360)
    aqua.draw.arc(80, 380, 40, 180, 360, WHITE, 2)

    -- Points as { x, y } pairs, or a flat list: { 10, 400, 50, 470, 90, 410 }
    aqua.draw.polyline({ { 10, 400 }, { 50, 470 }, { 90, 410 } }, RED, { closed = true })

    -- 75% full
    aqua.draw.bar(20, 500, 120, 6, 0.75, DARK, GREEN)

    -- Corners: top-left, top-right, bottom-right, bottom-left
    aqua.draw.rect_gradient(20, 520, 200, 40, BLUE, CLEAR, CLEAR, BLUE)
end
```

### Text

| Function | Does |
| :--- | :--- |
| `text(x, y, str, color, [opts])` | Draws text. `x, y` is the top-left of the line |
| `text_width(str, [size], [font])` | Width in pixels |
| `text_metrics([size], [font])` | `lineHeight, ascent, sizePx` |

| Option | Meaning |
| :--- | :--- |
| `size` | Pixels. Snaps to the nearest available size from 10 to 32 |
| `align` | `"left"`, `"center"` or `"right"`; `x` becomes that point of the text |
| `shadow` | `true` for a drop shadow |
| `outline` | `true` for a black outline, or a color |
| `font` | Handle from `load_font` |
| `clip` | `{ x, y, w, h }`: draw only inside this rectangle |

```lua
local WHITE = { 1, 1, 1 }

function on_draw()
    local screenW = aqua.draw.screen_size()

    aqua.draw.text(20, 20, "hello", WHITE, { size = 14 })
    aqua.draw.text(screenW / 2, 40, "centered", WHITE, { size = 18, align = "center", outline = true })
end
```

To fit a box around text, measure the height with `text_metrics`, not `size`:
a line is taller than its font size.

```lua
local SIZE, PAD = 14, 6

function on_draw()
    local w = aqua.draw.text_width("aqua!v2", SIZE)
    local h = aqua.draw.text_metrics(SIZE)

    aqua.draw.rect(20, 20, w + PAD * 2, h + PAD * 2, { 0, 0, 0, 0.8 }, 4)
    aqua.draw.text(20 + PAD, 20 + PAD, "aqua!v2", { 1, 1, 1 }, { size = SIZE })
end
```

### Fonts

`load_font(path)` loads a `.ttf` or `.otf` from your script folder and returns
a handle, or `nil` if the file is missing or not a font.

```lua
local font = aqua.draw.load_font("Roboto-Medium.ttf")  -- once, outside on_draw

function on_draw()
    aqua.draw.text(20, 20, "aqua!v2", { 1, 1, 1 }, { size = 13, font = font })
end
```

* Pass the same handle to `text_width` to measure with that font.
* `load_font(path, { hinting = "none" })` gives softer letters that keep their
  original shape.
* Characters the font lacks are drawn with the default font.

### World to screen

| Function | Returns |
| :--- | :--- |
| `world_to_screen(point)` or `(x, y, z)` | `sx, sy, visible` |
| `world_to_screen_many(points)` | `{ x, y, visible }` per point |
| `screen_size()` | `width, height` |

`visible` is `false` for points behind the camera.

```lua
local WHITE = { 1, 1, 1 }

function on_draw()
    for _, player in ipairs(aqua.get_players()) do
        if player.alive and player.origin then
            local x, y, visible = aqua.draw.world_to_screen(player.origin)
            if visible then
                aqua.draw.text(x, y, player.name, WHITE, { size = 12, align = "center" })
            end
        end
    end
end
```

Skeleton:

```lua
local ORANGE = { 1, 0.5, 0.2, 0.9 }
local edges = aqua.skeleton.edges()  -- pairs of bone numbers to connect

function on_draw()
    for _, player in ipairs(aqua.get_players()) do
        if player.bones then
            local screen = aqua.draw.world_to_screen_many(player.bones)
            for _, edge in ipairs(edges) do
                local a, b = screen[edge[1]], screen[edge[2]]
                if a and b and a.visible and b.visible then
                    aqua.draw.line(a.x, a.y, b.x, b.y, ORANGE, 1.5)
                end
            end
        end
    end
end
```

### Images

| Function | Draws |
| :--- | :--- |
| `icon(x, y, w, h, name, [color], [opts])` | Flat weapon icon, as in ESP: `"ak47"` or a def index |
| `weapon(x, y, w, h, defIndex, [paintId \| opts])` | Inventory picture of a weapon |
| `econ(x, y, w, h, key, [color], [opts])` | Inventory picture by key |
| `image(x, y, w, h, path, [color], [opts])` | PNG from your script folder (`png` is the same function) |

Inventory pictures load in the background and may be missing for the first
frames. Pictures keep their shape inside the box; `letterbox = false`
stretches them.

```lua
aqua.draw.icon(20, 20, 48, 24, "ak47")
aqua.draw.weapon(20, 60, 120, 90, 7)                              -- AK-47
aqua.draw.weapon(150, 60, 120, 90, 7, { paint = 44, wear = 0.5 }) -- with a skin
aqua.draw.image(20, 160, 64, 64, "img/logo.png")
```

`weapon` options: `paint`, `wear` (0 to 1), `letterbox`, `color`, `clip`.

`econ` keys:

| Builder | Picture |
| :--- | :--- |
| `aqua.econ_key_base(weapon)` | Weapon without a skin |
| `aqua.econ_key_skin(weapon, paint, [wear])` | Weapon with a skin; `paint` is an id or a name like `"asiimov"` |
| `aqua.econ_key_mat(path)` | Sticker, agent or music kit by image path |

```lua
aqua.draw.econ(20, 20, 120, 90, aqua.econ_key_skin("weapon_ak47", "asiimov"))
aqua.draw.econ(150, 20, 80, 80, aqua.econ_key_mat("econ/stickers/default/stickers1"))
```

`aqua.econ_ready(key)` is `true` once loaded; `aqua.econ_miss(key)` is `true`
if it never will be.

PNG paths are relative to your script folder and cannot leave it. JPEG is not
supported. `image` options:

| Option | Meaning |
| :--- | :--- |
| `letterbox` | `false` stretches the image |
| `src` | `{ x, y, w, h }`: draw only this part of the image (sprite sheets) |
| `menu_attach` | `x, y` are relative to the aqua menu and move with it |
| `clip` | `{ x, y, w, h }`: draw only inside this rectangle |

| Function | Does |
| :--- | :--- |
| `aqua.image_ready(path)` | Loads the image; `true` once loaded |
| `aqua.image_size(path)` | `width, height`, or `nil` |
| `aqua.image_miss(path)` | `true` if it failed to load |
| `aqua.image_unload(path)` | Reloads the file on the next draw |
| `aqua.image_delete(path)` | Unloads and deletes the PNG file |

Images over 512 pixels on a side are shrunk; `src` uses the shrunk size from
`image_size`.

---

## 10b. Math, skeleton, view matrix, menu rect

### `aqua.math`

| API | Returns |
| :--- | :--- |
| `distance(a, b)` | number (vectors as `{x,y,z}` or six floats) |
| `length(v)` | number |
| `normalize(v)` | `x, y, z` |
| `dot(a, b)` | number |
| `lerp(a, b, t)` | number |
| `lerp_vec(a, b, t)` | `x, y, z` |
| `clamp(v, min, max)` | number |
| `angles_to_forward(pitch, yaw)` or `({pitch,yaw})` | `x, y, z` unit forward |
| `calc_angle(from, to)` | `pitch, yaw` degrees (Source-style) |
| `fov(eye, target, viewAngles)` | degrees between view forward and eye→target |

Note: this is **`aqua.math`**, not the global Lua `math` library (both remain available).

### Bones / skeleton

- Per player: **`p.bones`** (alias of **`p.hitboxes`**) - world joint positions, Lua **1-based**, index **1 ≈ head**.
- **`aqua.skeleton.parents()`** → `{ [i] = parentOr0 }` (1-based; `0` = root).
- **`aqua.skeleton.edges()`** → `{ {child, parent}, ... }` for drawing.
- **`aqua.skeleton.names()`** → hitbox name strings (`"head_0"`, …).

Connectivity matches the built-in ESP skeleton.

### `aqua.get_view_matrix()`

Returns a table of **16 row-major floats** at indices `1..16`, plus nested rows `m[1]..m[4]` each with `1..4` columns. Same VP matrix used by `world_to_screen`.

### `aqua.get_menu()`

Menu geometry for attaching overlay chrome next to the aqua window:

| Field | Meaning |
| :--- | :--- |
| `open` | Menu visible |
| `x, y, w, h` | **Screen pixels** (same space as `aqua.draw.*`) |
| `logical_x/y/w/h` | Menu canvas units (pre-`UI_SCALE`) |
| `scale` | Current `UI_SCALE` |

```lua
function on_draw()
    local m = aqua.get_menu()
    if not m.open then return end
    -- Panel docked to the right edge of the menu
    aqua.draw.rect(m.x + m.w + 8, m.y, 160, m.h, 0.05, 0.05, 0.08, 0.9, 4)
    aqua.draw.text(m.x + m.w + 16, m.y + 12, "side panel", 1, 1, 1, 1, { size = 12 })
end
```

---

## 11. Sound (`aqua.sound`)

WAV playback via the same SDL3 `SoundPlayer` as hitmarker.

**Where files live.** A name is resolved against **your script's folder first** (exactly like
`aqua.draw.image`), then falls back to the shared `~/.config/nvidia/snd/`. Prefer your own
folder: the shared one is what the **hitmarker and killsound pickers enumerate**, so anything
you drop there becomes a selectable hitmarker sound for the user. Sequential groups from the
menu (e.g. `hit(01-03).wav`) work as with hitmarker, and only resolve in the shared folder.

| API | Description |
| :--- | :--- |
| `aqua.sound.play(file [, volume=1 [, pitch=1 [, channel="script" [, startSeconds=0]]]])` | Play once (retrigger clears queue). **Final gain = `volume` × game `volume` cvar** (same as hitmarker). `pitch` is frequency ratio (`1` = normal, `>1` higher/faster). `startSeconds` skips into the sample. |
| `aqua.sound.loop(file [, volume=1 [, pitch=1 [, channel="script" [, startSeconds=0]]]])` | Same, but repeats until `stop`. Kept topped up by the engine, so the wrap is seamless. |
| `aqua.sound.gain(volume [, channel="script"])` | Change a playing channel's level **without restarting it**. Also scaled by the game `volume` cvar. |
| `aqua.sound.stop([channel="script"])` | Stop / clear queue on channel (also ends a loop) |
| `aqua.sound.list([refresh=false])` | Array of available bare names / group labels in the SHARED folder |
| `aqua.sound.dir()` | Absolute path of the shared snd folder |

**Use `loop` + `gain` rather than re-triggering.** Replaying a one-shot at its own loop point
clears the SDL stream, which is audible as a click however seamless the sample is - and it
pins the phase to the sample start instead of letting it run. Crossfading two textures is
then just two looping channels and a `gain` swap:

```lua
aqua.sound.loop("noise_a.wav", 0.8, 1.0, "script2")
aqua.sound.loop("noise_b.wav", 0,   1.0, "script3")   -- running, silent
-- later, to hand over without either restarting:
aqua.sound.gain(0,   "script2")
aqua.sound.gain(0.8, "script3")
```

`startSeconds` was added 2026-08-19 alongside `loop`/`gain`. **An older injected module
ignores extra arguments silently** (`luaL_opt*` has no arity check), so a script that needs
them cannot detect the shortfall from the argument itself - guard on `if aqua.sound.loop then`
and treat absence as "module older than the script".

**Channels:** `"script"` (default), `"script2"`, `"script3"`, `"hit"`, `"kill"`. Script channels are isolated from the hitmarker ones both ways. **`play` clears its channel to retrigger**, so two script sounds that must overlap (a click over a sustained tone, one-shot under a loop) have to be on different channels - that is what `script2`/`script3` are for.

```lua
-- Full master volume, normal pitch, script channel
aqua.sound.play("click.wav")

-- Half of master volume, slightly higher pitch
aqua.sound.play("click.wav", 0.5, 1.1)

-- Explicit channel
aqua.sound.play("kill.wav", 1.0, 1.0, "kill")
aqua.sound.stop("script")

-- Start 4.2 s in (5th argument)
aqua.sound.play("long.wav", 1.0, 1.0, "script", 4.2)

for _, name in ipairs(aqua.sound.list(true)) do
    print(name)
end
```

WAV only (no MP3). Game-thread only (call from `on_draw` / `on_frame_stage_notify`).

### Music stream (`aqua.sound.stream`) - Ogg Vorbis

A different shape from `play`: **one** track at a time, decoded incrementally (a
multi-minute song costs a small buffer, not its whole PCM), with a queryable position so a
script can sequence a playlist against it. The engine pumps it every frame - there is no
`pump()` to forget. Decoding is vendored `stb_vorbis`, so no system codec is required.

| API | Description |
| :--- | :--- |
| `aqua.sound.stream.play(path [, offset=0 [, length=0 [, xorKey=nil [, startSeconds=0]]]])` | Start a track. `length` 0 = to end of file. `startSeconds` starts partway in - **prefer it over play-then-`seek`** (see below). Returns `true` if playback started. |
| `aqua.sound.stream.enqueue(path [, offset=0 [, length=0 [, xorKey=nil]]])` | Queue the track to start the **instant** the current one runs out of samples |
| `aqua.sound.stream.clear_next()` | Drop the queued track |
| `aqua.sound.stream.serial()` | Increments on each rollover to an enqueued track |
| `aqua.sound.stream.stop()` | Stop and free the track (also drops the queued one) |
| `aqua.sound.stream.seek(seconds)` | Jump within the track; `false` if out of range |
| `aqua.sound.stream.position()` | Seconds the device has actually **played** (not decoded) |
| `aqua.sound.stream.duration()` | Seconds, `0` if the decoder cannot report a length |
| `aqua.sound.stream.is_playing()` | `false` once the track finishes draining |
| `aqua.sound.stream.gain(v)` | `v` x game `volume` cvar, same convention as `play` |

`offset`/`length` address a byte range **inside** a larger file, so a track can be played
straight out of a game archive with no extraction. `xorKey` is a raw byte string that
unmasks the payload as it is read, phased on the **absolute file offset**; pass `nil` for a plain `.ogg`.

**Starting partway in:** pass `startSeconds` to `play` rather than calling `seek` after it.
`play` primes the device queue before it returns, and a `seek` throws that away and primes it
again - both decodes land in the same frame, and it is already the frame paying for the read
and the decoder init. `seek` is for jumping around a track that is *already* playing.

**Gapless playlists:** `enqueue` is the only way to chain tracks without a hole. Waiting for
`is_playing()` to go false cannot work - by the time it reports false the device has already
gone silent, and opening the next track then costs a synchronous read plus decoder init. With
`enqueue` the decoder swaps inside the refill and keeps feeding the same device stream. Poll
`serial()` to notice the hand-off (it increments) and queue the following track.

```lua
if aqua.sound.stream.serial() ~= lastSerial then
    lastSerial = aqua.sound.stream.serial()
    aqua.sound.stream.enqueue(nextTrack())   -- keep one always queued
end
```

A rate change between tracks cannot be seamless (SDL must flush the resampler to accept a new
input format); same-rate tracks hand over with no gap at all.

The script that calls `play` owns the channel: stopping or unloading it closes the stream,
so a stopped script never leaves a track playing.

```lua
local KEY = "\234\058\196\161\154\168\020\243\072\176\215\035\157\232\255\241"

aqua.sound.stream.play("/path/to/streams/CO", trackOffset + 8068, trackLength, KEY)
aqua.sound.stream.gain(0.6)

function on_draw()
    if not aqua.sound.stream.is_playing() then
        next_track()   -- sequence the playlist off the real end of the track
    end
end
```

Game-thread only, like the rest of `aqua.sound`.

---

## 12. Panorama

| API | Description |
| :--- | :--- |
| `aqua.panorama_runscript(windowId, source, [context])` | Queue JS (`"CSGOHud"`, `"CSGOMainMenu"`, ...) |
| `aqua.panorama_invalidate_window(windowId)` | Drop cached panel pointer |

Scoreboard-style injection: build a payload with `steamId` strings and `item_icon` names, gate with `scoreboard_open()` + `rate_limit`, emit JS that finds `player-<steamId>` rows.

---

## 13. Sample: inventory + scoreboard-ready data

```lua
function on_draw()
    if not aqua.input.scoreboard_open() then return end
    if not aqua.rate_limit("sb_dump", 1.0) then return end

    for _, p in ipairs(aqua.get_players()) do
        if p.steamId and p.steamId ~= "0" then
            local icons = {}
            for _, def in ipairs(p.weapons or {}) do
                icons[#icons + 1] = aqua.item_icon(def) or "?"
            end
            print(p.name, p.steamId, p.money, table.concat(icons, ","))
        end
    end
end
```

---

## 14. Threading & safety

1. Callbacks run on the **game thread** - OK for snapshots, schema, config, panorama queue.
2. Only **queue** draws with `aqua.draw` in `on_draw`.
3. **SteamIDs are strings** - never coerce with `tonumber` for row keys.
4. Prefer `Scripts.<name>.*` config keys.
5. Do not busy-loop in callbacks.
6. FFI is opt-in and unsafe - leave off unless required.

---

## 15. What you can do with FFI

With **Enable FFI** checked, LuaJIT `ffi` is loaded into the script env. Realistically:

### Reasonable / power-user

* **Declare and call exported C APIs** from libraries already mapped in the CS2 process (`libc`, etc.) when you know the symbol and ABI.
* **Pack/unpack binary layouts** you reverse yourself (structs, bitfields) for one-off experiments.
* **Pointer arithmetic** on addresses you already obtained from a trusted path (e.g. a value you printed from schema / a module base you resolved carefully).
* **Performance-sensitive math** or bulk buffer walks without crossing into C++ bindings for every field.

### Hard / fragile without more C++ help

* Calling **C++ member functions / virtual methods** (thiscall, vtables, name mangling, Itanium ABI on Linux).
* Finding **reliable module bases** of `libclient.so` without a binding (`aqua` does not currently expose `dl_iterate_phdr` helpers).
* Anything that must stay **VAC/thread-safe** - wrong-thread calls and bad pointers crash the game, not just the script.

### Strongly discouraged

* Writing random process memory “because it might be a cvar”.
* Loading extra `.so` files from disk (`package.loadlib` / `ffi.load` of untrusted paths).
* Long-lived `ffi` callbacks from engine threads you do not own.

### Prefer first-class bindings instead of FFI when possible

If many scripts need the same thing (input, items, steamId, weapons), it should be a normal `aqua.*` API - safer, versioned, and no ABI guesswork. FFI is the escape hatch for advanced authors, not the default toolbox.

Example shape (illustrative only - still requires correct addresses/types):

```lua
-- Requires Enable FFI
local ffi = require("ffi")
ffi.cdef[[
  int puts(const char *s);
]]
-- ffi.C.puts("hello from ffi")  -- libc, usually present
```

---


## 16. Known gaps

* No mouse move / key injection from scripts. Movement can be steered per tick from
  `on_create_move` (see section 3), including queued jumps and prioritized visible/silent aim.
* Use `on_event` plus `aqua.events.listen` for game events (see 9a). Player fields
  include `isLocalPlayer` for local filtering; separate local-event callbacks are
  intentionally omitted.
* Sound files resolve against the script folder first, then the shared snd folder; there is
  no way to enumerate your OWN folder (`aqua.sound.list` only sees the shared one)
* No **sandboxed** script-folder file I/O API yet. Note this does *not* mean scripts cannot
  read files: `luaL_openlibs` runs and the sandbox only strips `ffi`/`require`, so `io` and
  `os` are available with **no FFI needed**. What is missing is a path-checked helper.
* No auto UI for `Scripts.<name>.*` config keys
* Drawing is 2D only: shapes, text and images (no 3D models or custom shaders)
* Inventory pictures load in the background (may miss a few frames) and share space with the LOADOUT tab; the least recently used are dropped first
* Schema writes are not exposed (read-only dynamic fields)
