diff --git a/README.md b/README.md index 41f71c4..60f7056 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ A collection of public, reusable agent skills, cheatsheets, and operational work | **Go** | [`go.md`](skills-public/code-languages/go.md) | strict `golangci-lint` set, concrete types over `interface{}`, context everywhere, CGO-free static builds | | **C# / .NET** | [`csharp-dotnet.md`](skills-public/code-languages/csharp-dotnet.md) | `TreatWarningsAsErrors`, nullable enabled, no `.Result`, trimming/single-file publish caveats, SDK shims | | **C / C++** | [`c-cpp.md`](skills-public/code-languages/c-cpp.md) | full warning set + `-Werror`, sanitizers, RAII/Rule of Zero, target-based CMake, FFI ownership | +| **Lua** | [`lua.md`](skills-public/code-languages/lua.md) | pin the dialect first, `luac -p` + luacheck as the only gate, locals over globals, `pcall` at the boundary, `#t`/pattern traps, embedded & HavokScript hosts | +| **GSC** | [`gsc.md`](skills-public/code-languages/gsc.md) | no compiler or test harness, `isdefined` on everything external, `endon` before the first wait, per-frame budget, precache at load, level-state side effects | Each language file extends `coding-dev.md` rather than repeating it. A project's own `AGENTS.md` / `CLAUDE.md` / `.rules` always overrides both. diff --git a/code-languages/gsc.md b/code-languages/gsc.md new file mode 100644 index 0000000..4f571ed --- /dev/null +++ b/code-languages/gsc.md @@ -0,0 +1,237 @@ +--- +name: gsc +description: >- + Use this skill when writing, reviewing, or debugging GSC (Game Script / Call of Duty script) + or CSC — including entity-vs-level scope, threads and notify/wait semantics, endon/waittill + discipline, undefined-value guards, the no-OOP/no-exceptions reality, asset precaching, + per-frame cost, and the fact that GSC has no compiler, linter, or test harness worth the name. +--- + +# GSC — Rules & Preferences + +Extends [`coding-dev.md`](skills-public/coding-dev.md). Everything there applies; this adds GSC specifics. + +GSC is the Call of Duty engine's own scripting language (`.gsc` server-side, `.csc` +client-side). It is **not Lua, not JavaScript, and not C** despite resembling all three. It has +no type system, no exceptions, no classes, no closures in most dialects, and — in a modded +install — **no diagnostics beyond the in-game console**. That last fact drives most of what +follows: the language will not tell you that you are wrong, so the code has to be written so +that it cannot be. + +> [!IMPORTANT] +> **Establish the dialect before writing anything.** IW3/IW4 (MW/MW2), IW5 (MW3), T5/T6 (Black +> Ops 1/2), IW6, s1/s2, H1/H2 (MWR / MW2CR), and the various mod loaders (h2-mod, iw4x, plutonium, +> t6 GSC Studio) each differ in available builtins, in how scripts get loaded, and in whether +> `foreach`, `switch` fallthrough, or function pointers exist. A builtin from the wrong title is +> a silent `undefined` or a hard crash. + +--- + +## 1. There Is No Gate — Build One + +GSC has no `gsc -Wall`. What exists: + +| Check | How | +| :--- | :--- | +| **Syntax** | The engine's own compile at load. Read the console; a parse error names the file and line and then stops the whole script loading. | +| **Runtime errors** | In-game console / `developer 1`. Many mod loaders print a script stack trace. | +| **Static lint** | Third-party only, and dialect-specific. Use one if it exists for the target title; otherwise there is none. | +| **Tests** | None off-host. Behaviour is verified by playing the map. | + +Therefore: + +- **`developer 1` (or the loader's equivalent) is mandatory during development.** Without it the + engine suppresses script warnings and an undefined-value error looks like "the feature did + nothing". +- **Load the script and read the console on every change**, before judging behaviour. "Nothing + happened" is far more often a parse error at load than a logic bug. +- **Instrument with `iprintln` / `iprintlnbold` / `println`** on the success *and* the failure + path. A function that silently returns on a guard is indistinguishable from one that never ran. +- **Say plainly in your report that GSC behaviour is unverified** unless you actually loaded the + map and watched it. There is no test suite to hide behind. + +--- + +## 2. Scope — `self`, `level`, `game`, and Locals + +| Scope | Lifetime | Use for | +| :--- | :--- | :--- | +| local variable | the function call | everything it can hold | +| `self` | the entity | per-entity state (a player's, an actor's) | +| `level` | the current map/level | per-round state, map config, shared handles | +| `game` | across map restarts | persistent state only | + +- **Prefix your own fields.** `level.mymod_spawn_queue`, not `level.queue`. `level` and `self` + are one flat namespace shared with the stock game scripts and every other mod; a collision + overwrites engine state and the symptom appears somewhere unrelated. +- **Never write to a stock field you did not create** unless that is the documented integration + point. Read it, copy it, leave it. +- **A function called *on* an entity gets it as `self`**: `player giveweapon("m4")`. Calling the + same function without a prefix leaves `self` undefined and the first `self.x` access errors. +- `self` is not inherited into a `thread`ed call's children automatically in the way callers + expect — `ent thread f()` gives `f` `self == ent`, but a plain `f()` inside it does not. + +--- + +## 3. Undefined Is The Default — Guard Everything External + +GSC has no types and no nullability. **Reading a field that was never set yields `undefined`, +and using it in arithmetic or a method call is a runtime error that aborts the thread** — often +silently from the player's point of view. + +```gsc +// WRONG: any of these can be undefined +ent = level.target; +ent setorigin(level.player.origin + (0, 0, 64)); + +// RIGHT +if (!isdefined(level.target) || !isdefined(level.player)) + return; +``` + +- **`isdefined()` on every entity, every `level.*`/`self.*` read you did not write in the same + function, and every return value from a lookup.** +- **Entities die.** A handle held across a `wait` may be a dead entity by the time the thread + resumes. Re-check `isdefined(ent)` *after* every `wait`, not only before. +- `isalive(ent)` is the check for an actor you intend to act on; `isdefined` only proves the + handle exists. +- This is the one place where the general "don't over-guard" rule inverts: in GSC a value from + outside the current function genuinely can be undefined, so the check is not dead code. +- Arrays: `array.size` is safe on an empty array but errors on an undefined one. Guard the array, + then the index. + +--- + +## 4. Threads, `notify`, `waittill`, `endon` + +```gsc +monitor_thing() +{ + level endon("game_ended"); + self endon("death"); + + for (;;) + { + self waittill("trigger_activated"); + do_work(); + } +} +``` + +- **`thread` starts a coroutine and returns immediately.** It does not return the function's + value — a `thread`ed function's `return` value is unreachable. Pass a handle or notify. +- **Every long-lived thread needs an `endon`.** Without one, a loop keeps running after the + entity dies or the level ends, and then errors on a dead `self` — or leaks, one copy per spawn. + `level endon("game_ended")` plus the relevant entity `endon` is the baseline. +- **`endon` must be registered before the first `wait`**, at the top of the function. Registering + it later leaves a window. +- **`waittill` is the event primitive — prefer it to polling.** `self waittill("death")` is + correct; `while (isalive(self)) wait 0.05;` is the same thing, worse, and costs every frame + forever. This is the general events-over-polling rule, and GSC gives you the event. +- **A `notify` with no waiter is lost.** There is no queue. If a thread might not be waiting yet, + set a flag as well as notifying, and have the waiter check the flag first. +- `waittill("x", a, b)` receives the notify's extra parameters. The names are positional. +- **`waittillframeend`** is the tool for "after everything else this frame" — use it instead of + `wait 0.05` when the intent is ordering, not delay. +- `wait 0` is not zero; it is one frame. `wait 0.05` is one server frame at 20Hz — write the + intent, not a magic number, and name the constant. + +--- + +## 5. Per-Frame Cost Is Your Budget + +The script VM shares the server frame with everything else, and there is a **hard instruction +budget per frame**. Exceeding it produces `script runtime error: exceeded maximum number of +script variables` or a visible server hitch. + +- **One polling loop per concern, never one per entity.** A `for(;;) { wait 0.05 }` loop on each + of 60 actors is 60 wakeups a frame. Keep one manager loop over an array. +- **Where an event exists, the polling loop should not.** See §4. +- **Never loop without a `wait` inside.** A `for(;;)` with no `wait` hangs the server frame and + takes the match down — this is the single most common fatal GSC mistake. +- Keep the body of a per-frame loop trivial. Move anything expensive behind a condition or a + longer interval, and say in a comment why that interval. +- Spawning is expensive and entity counts are capped. Reuse and pool; delete what you spawned. + +--- + +## 6. Assets Must Be Precached + +```gsc +init() +{ + precachemodel("com_laptop_open"); + precacheshader("hud_icon_frag"); + precachestring(&"MYMOD_HINT"); +} +``` + +- **Precaching only works during the level's load phase.** A `precachemodel` after the map is + live either errors or silently fails, and the model then renders as a missing-asset + placeholder or nothing at all. +- Put every precache in the init path, unconditionally — not behind the feature flag that decides + whether the asset gets used. +- **A model or weapon name not present in the loaded level's zone will not spawn**, regardless of + precaching. Asset availability is per-map; a name verified on one map proves nothing about + another. State which map you verified on. +- Localized strings use the `&"REFERENCE"` form and must exist in the string table; a missing one + displays the raw reference. + +--- + +## 7. Language Facts That Surprise People + +- **No exceptions, no `try`.** An error aborts the current thread and nothing else. There is no + recovery and no handler — which is why §3's guards are the error handling. +- **No classes, no inheritance, no `new`.** "Objects" are entities with fields, or plain arrays + used as structs. `spawnstruct()` is the idiom for a bag of fields with no world presence. +- **Function pointers**: `::funcname` (and `[[ptr]]()` to call) exist in most dialects but not + all. Verify before relying on them. +- **No closures** in most dialects — an inner function cannot capture a local. Pass state + explicitly or hang it on a struct. +- `foreach (x in arr)` exists in IW4+ and T6 but not in the earliest dialects; `for (i = 0; + i < arr.size; i++)` always works. +- **Arrays are associative.** `array[]` with integer keys behaves like a list, but deleting an + element leaves a hole and `.size` counts keys. Rebuild rather than delete-in-place. +- String comparison is `==`; there is no `switch` on anything but constants, and fallthrough + rules differ by dialect. +- Vectors are `(x, y, z)` literals with `+`, `-`, and scalar `*`. Component access is `v[0]`, + `v[1]`, `v[2]`. `anglestoforward` / `vectornormalize` / `distance` are builtins — do not + hand-roll vector maths. + +--- + +## 8. Style & Structure + +- **Allman braces, 4 spaces** — the stock scripts' convention; match it, because GSC is almost + always read next to engine scripts. +- `main()` is the level entry point; `init()` is the convention for a module's setup called from + it. Which one the loader calls is dialect- and loader-specific — verify, do not assume. +- `#include maps\_utility;` (dialect-dependent path syntax) at the top; prefer the stock utility + functions over reimplementing them. `maps\_utility` and `common_scripts\utility` already have + flag, wait, and array helpers. +- **`/* */` comments do not nest.** Commenting out a block that contains one breaks the file. +- File and function size limits from the general rules apply, and matter more here than usual: + there is no debugger worth the name, so a 300-line function is unanalysable. +- **Delete dead code.** With no linter to flag it, commented-out GSC accumulates forever. + +--- + +## 9. Side Effects on Level State + +GSC runs inside someone else's running game. A change to shared state is not scoped to your +feature and is not undone when your thread ends. + +- **Mutating a stock spawner, trigger, or flag changes the mission.** Setting + `spawner.count = 999` to get an extra AI makes that spawner infinite for the rest of the level. + Save the old value and restore it, or clone rather than mutate. +- **Anything you spawn, you delete.** Track handles in your own `level.mymod_*` array and provide + a cleanup path; an orphaned entity survives until map change and counts against the cap. +- **`setdvar` is global and persistent.** A dvar used as a command channel must be cleared + immediately on read, and the clear must happen even on the failure path — otherwise the next + poll re-fires the same command. +- A dvar as an IPC channel is a **poll**, which §5 says to avoid; it is acceptable only because + GSC cannot receive a host event, and the interval and the clear-on-read should both be + explicit and commented. +- Never leave the player in a modified state (god mode, `notarget`, a changed movement speed) + when the feature that set it is turned off. Toggles need an off path that actually restores. diff --git a/code-languages/lua.md b/code-languages/lua.md new file mode 100644 index 0000000..9d769d5 --- /dev/null +++ b/code-languages/lua.md @@ -0,0 +1,232 @@ +--- +name: lua +description: >- + Use this skill when writing, reviewing, or debugging Lua — including Lua 5.1/5.2/5.3+ and + LuaJIT dialect differences, HavokScript (HKS) as used by Call of Duty's LUI, luacheck + configuration, locals over globals, pcall error handling, the integer-vs-float and + table-length pitfalls, module layout, and embedded/sandboxed-host constraints. +--- + +# Lua — Rules & Preferences + +Extends [`coding-dev.md`](skills-public/coding-dev.md). Everything there applies; this adds Lua specifics. + +Lua is almost always **embedded**: the host decides the dialect, the available standard +library, the module loader, and whether an uncaught error takes the process down. **Establish +which host and which dialect you are writing for before writing a line** — the same file is +correct in one and a crash in another. + +--- + +## 1. Pin the Dialect First + +| Dialect | Notable traits | +| :--- | :--- | +| **5.1** | `loadstring`, `unpack`, `setfenv`/`getfenv`, `module()`, no integer type, no goto | +| **LuaJIT** | 5.1 plus `bit`, `ffi`, `jit`; a few 5.2 extensions | +| **5.2** | `load` replaces `loadstring`, `_ENV` replaces `setfenv`, `goto`, `table.unpack` | +| **5.3** | distinct integer subtype, `//`, bitwise operators, `math.type` | +| **5.4** | `` / `` attributes, generational GC | +| **HavokScript (HKS)** | a 5.1 fork; see §8 | + +- **Determine it from the host, not from habit.** `print(_VERSION)` in the target runtime, or + read the host's embedding code. A `goto` in a 5.1 host is a parse error, and a `loadstring` + in 5.4 is a nil call. +- **Write for the host's dialect; do not write portable-across-all-five code** unless the + module genuinely ships to several. Compatibility shims + (`local unpack = table.unpack or unpack`) are worth it only where that is true. +- Lua 5.1 has **no integer type** — every number is a double. `1/2` is `0.5`, and + `tostring(3.0)` is `"3"` in 5.1 but `"3.0"` in 5.3. Never round-trip a number through a + string and expect the same text across dialects. + +--- + +## 2. The Gate + +```bash +luacheck . # lint; must be clean +stylua --check . # or lua-format; formatting +luac -p $(git ls-files '*.lua') # parse check against the TARGET dialect's luac +busted # tests, where the code is testable off-host +``` + +- **`luac -p` is the cheapest real check available** and the one most often skipped. It catches + syntax errors, and when run with the host's own Lua version it catches dialect mismatches + before the host does. For an embedded host whose scripts cannot be unit tested, this plus + `luacheck` *is* the gate — wire it into the build script and never commit a file that fails it. +- `luacheck` needs its globals declared or it reports the host's API as undefined. Declare them + rather than disabling the check: + ```lua + -- .luacheckrc + std = "lua51" + read_globals = { "Engine", "LUI", "GenericButtonSettings" } + globals = { "MyModuleState" } -- only what this code deliberately writes + max_line_length = 120 + ``` +- `read_globals` for the host's API, `globals` only for names you intentionally define. If the + list of writable globals is growing, that is the finding — see §3. + +--- + +## 3. Locals, Not Globals + +**Every variable is `local` unless it must be visible to the host.** An unqualified assignment +creates a global, silently, with no warning from the language. + +```lua +-- WRONG: typo creates a new global; the real one keeps its old value +local function update() cofnig.volume = 1 end + +-- RIGHT +local config = require("config") +local function update() config.volume = 1 end +``` + +- Locals are also **faster** — they are register slots, while a global is a hash lookup in + `_ENV` on every access. Hoist hot library functions into locals at the top of a module: + `local floor, insert = math.floor, table.insert`. +- **Namespace what must be global.** One table (`MyMod.state`), never a scatter of + `myModFoo` / `myModBar`. In a shared host every global is in one namespace with every other + script's. +- `luacheck` flags accidental globals. Keep it clean and the typo class disappears. +- The 200-local-per-function limit is real; hitting it means the function needed splitting long ago. + +--- + +## 4. Errors — `pcall` at the Boundary, Not Everywhere + +- **`error()` for real failures; return `nil, message` for expected ones.** A file that may not + exist returns `nil, err`; a caller passing the wrong type gets an `error`. +- **`pcall` / `xpcall` at the boundary only** — a callback the host invokes, a plugin entry + point, a request handler. Wrapping every internal call turns a stack trace into a string and + hides where the fault was. +- **`xpcall(f, debug.traceback)` when you need the trace.** Plain `pcall` discards it, and the + message alone rarely identifies the line in a long callback chain. +- **Never swallow it.** `pcall(f)` with the result ignored is an empty catch block: + ```lua + -- WRONG + pcall(risky) + + -- RIGHT + local ok, err = pcall(risky) + if not ok then log.warn("risky failed: " .. tostring(err)) end + ``` +- `error("msg", 2)` blames the caller instead of the raising line — the right level for an + argument-validation error. +- `error()` can be given a **table**, which is how you carry structured error data; the + `tostring` of it is then meaningless, so handlers must check `type(err)`. + +--- + +## 5. Tables — The Traps + +- **`#t` is only defined for a sequence** (keys `1..n` with no holes). With a hole it may return + either boundary, and the choice can change between runs. Track the count yourself when a + table can be sparse, and never use `#t` on a map. +- **`table.insert` / `table.remove` shift every later element** — O(n). Appending with + `t[#t+1] = v` is cheaper than `table.insert(t, v)`; building a new table beats removing from + the middle in a loop. +- **Never mutate a table while iterating it with `pairs`.** Assigning to an existing key is + defined; adding a new one is not. Collect keys first, then act. +- **`pairs` order is unspecified** and varies run to run. Anything user-visible or compared + between runs must sort its keys first. +- `ipairs` stops at the first `nil`. Use it for sequences and `pairs` for maps — the choice + documents which the table is. +- `nil` cannot be stored in a table; `t[k] = nil` is a delete, and a sequence containing `nil` + is not a sequence. +- Beware `0` and `""` being **truthy**. Only `nil` and `false` are false, so + `if count then` is true at zero — test `if count ~= 0 then`. +- `1` and `1.0` are the **same key** in 5.3+ (the float is normalized), but a non-integral + float is its own key. + +--- + +## 6. Strings + +- **Never build a string in a loop with `..`.** Each concatenation allocates; collect into a + table and `table.concat` once. +- **Lua patterns are not regex.** `%` is the escape (not `\`), there is no alternation and no + `+`/`?` on groups, character classes are `%a %d %s %w %p %c %x` and their uppercase + complements. Reach for `lpeg` when the grammar is real. +- `string.format("%q", s)` produces a Lua-readable quoted literal — correct for serialization, + wrong for display. +- `%s` in `string.format` calls `tostring`, so a nil argument renders `"nil"` rather than + erroring — the opposite of `..`, which errors on nil. That asymmetry is a frequent crash + source in host callbacks: **prefer `string.format` over `..` for any message built from + values that could be nil**, or guard with `tostring()`. +- Indices are 1-based and inclusive on both ends: `s:sub(1, 3)` is the first three bytes. +- Strings are **byte** strings. `#s` and `s:sub` count bytes, not characters — use `utf8` (5.3+) + or a library for anything non-ASCII. + +--- + +## 7. Modules & Structure + +```lua +local M = {} + +local function helper(x) -- private: a local, not M.helper + return x * 2 +end + +function M.public(x) + return helper(x) +end + +return M +``` + +- **Return a local table. Never set globals, never use `module()`** (deprecated in 5.1, + removed in 5.2). +- `require` caches by module name — a module body runs once. Do not rely on re-`require` to + reset state; expose an explicit `M.reset()` if a reset is needed. +- `:` for methods that take `self`, `.` for everything else. Be consistent — a `.` call on a + `:` method passes the first argument as `self` and fails confusingly. +- File and function size limits from the general rules apply. + +--- + +## 8. Embedded & Sandboxed Hosts + +When the host owns the interpreter (a game engine, nginx, Redis, Neovim, a plugin API), its +constraints outrank every preference above. + +- **Verify an API exists before calling it.** The host's surface differs between versions and + builds, and a missing function is a `nil` call that takes down whatever invoked you: + ```lua + if type(Engine.SetDvarString) == "function" then + Engine.SetDvarString(name, value) + end + ``` + Prefer `type(x) == "function"` over a bare `if x then` — it also rejects a truthy non-callable. +- **An uncaught error in a host callback may crash the process, not just the script.** In a C + host the error unwinds through C frames that were not written to expect it. Treat every + callback body as a trust boundary and `pcall` it. +- **Do not monkey-patch host globals** (`print`, `require`, a host API function) outside a + tightly scoped save/restore — and even then, remember the restore does not happen if the body + errors, so do it in the `pcall` result path. Reentrancy makes the saved original a wrapper. +- **Load order is the host's, not yours.** Do not assume a sibling script has run. Read shared + state inside callbacks rather than at load time, and register your own state defensively. +- **Check what the standard library actually is.** Many hosts remove `io`, `os`, and `debug`, or + replace them with their own (`io.readfile` rather than `io.open`). Feature-test; do not assume. +- Logging is whatever the host provides. Use it rather than `print`, and never leave a + high-frequency print in a per-frame callback. + +### HavokScript (HKS) — Call of Duty LUI + +Call of Duty's UI (LUI) runs **HavokScript**, a proprietary Lua 5.1 fork: + +- It is **5.1-based**: `loadstring`, `unpack`, `setfenv` — no `goto`, no integer division, no + bitwise operators, no `table.unpack`. +- There is **no `require` for siblings inside a UI script folder** in h2-mod-style loaders; a + module directory's `__init__.lua` is loaded on its own. Keep each module self-contained in one + file rather than splitting it and discovering the split does not load. +- There is **no off-host test harness**. `luac -p` with a 5.1 `luac`, `luacheck --std lua51`, + and reading the game's console output are the entire verification story — so lint and parse + checks are not optional here, they are the only gate that exists. +- **A dvar read is not type-safe.** `Engine.GetDvarString` on an unregistered dvar returns `nil`, + and `nil .. "text"` is an error that propagates out of the LUI event dispatcher as an access + violation. Register the dvar first, feature-test the accessor, and build messages with + `string.format`. +- **Hot-reload with `dofile` leaves the previous registration in place.** Guard re-entry with a + module flag, and expect duplicate menu buttons when you do not. diff --git a/coding-dev.md b/coding-dev.md index 6a69850..c5bdfc5 100644 --- a/coding-dev.md +++ b/coding-dev.md @@ -38,6 +38,8 @@ Per-language specifics live in [`code-languages/`](skills-public/code-languages/ | Go | [`code-languages/go.md`](skills-public/code-languages/go.md) | | C# / .NET | [`code-languages/csharp-dotnet.md`](skills-public/code-languages/csharp-dotnet.md) | | C / C++ | [`code-languages/c-cpp.md`](skills-public/code-languages/c-cpp.md) | +| Lua | [`code-languages/lua.md`](skills-public/code-languages/lua.md) | +| GSC (Call of Duty script) | [`code-languages/gsc.md`](skills-public/code-languages/gsc.md) | ---