Files
BluscreamandClaude Opus 5 e385e01993 fix(gsc): correct the claim that no off-engine compiler exists
gsc-tool compiles GSC outside the engine and reproduces the engine's own
diagnostics, so the skill's "no compiler, linter, or test harness worth the
name" framing was wrong and sent the reader to load a map instead of running a
50ms check. §1 is now the gate: `--mode comp --game <title> --system pc --dry`,
with the warning that `--mode parse` is syntax-only and accepts code the
compiler rejects, and that `--game` takes the engine codename (h2 for MW2CR)
rather than the marketing name.

A clean compile proves the script loads, not that it works, so the
"behaviour is unverified until the map runs" half is kept and sharpened.

Also adds two §8 entries found the hard way: a local declared inside a block is
not visible after it, and there are no file-scope constants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 03:04:02 +02:00

263 lines
13 KiB
Markdown

---
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, block scoping, and compiling off-engine with gsc-tool.
---
# 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 no test harness.
It can be compiled off-engine (see §1), which catches syntax and scope errors — but nothing
checks a type, a field that was never set, or a builtin that does not exist in the target title.
So the code still has to be written so that it cannot be wrong.
> [!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. The Gate — `gsc-tool`, Then The Game
**There is an off-engine GSC compiler: `gsc-tool`.** Reach for it first; it is
easy to assume none exists and verify by loading a map instead.
```bash
gsc-tool --mode comp --game <title> --system pc --dry path/to/script.gsc
```
It exits non-zero and prints the engine's own diagnostic, e.g.
`[ERROR]:compiler:script:97:25: local variable 'previous_count' not found`.
- **Use `--mode comp`, not `--mode parse`.** `parse` is syntax-only and happily
accepts code the engine's compiler rejects, including the block-scope error
above. `comp` with `--dry` writes nothing and does the real thing.
- **`--game` must match the title**, and it is not the marketing name: MW2
Campaign Remastered is `h2`, MWR is `h1`. The choices are `iw5 iw6 iw7 iw8
iw9 s1 s2 s4 h1 h2 t6 t7 t8 t9 jup`. Wrong title, wrong builtin table.
- Prebuilt binaries ship on its GitHub releases; there is no distro package.
- Many mod trees already vendor it (Overlord has it under `deps/gsc-tool`).
What that still leaves:
| Check | How |
| :--- | :--- |
| **Syntax + semantics** | `gsc-tool --mode comp`. Put it in the build gate. |
| **Builtin exists for this title** | `gsc-tool`'s own engine tables, e.g. `src/gsc/engine/h2_meth.cpp`. Grep before using a builtin you remember from another title. |
| **Runtime errors** | In-game console / `developer 1`. Many mod loaders print a script stack trace. |
| **Tests** | None off-host. Behaviour is verified by playing the map. |
So a compile is cheap and mandatory, and **behaviour is still unverified until
the map runs.** Both halves matter:
- **`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. A clean `gsc-tool` compile proves the script loads, not that it works.
---
## 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.
- **A local declared inside a block is not visible after it.** Writing it in one `if` and reading
it in a second does not compile — declare it unconditionally at function scope. Assigning
`undefined` to an entity field unsets it, which is usually the right "restore nothing".
- **No file-scope constants.** `MY_CONST = 0.2;` outside a function is not valid in most
dialects; use a local at the point of use or a `level.` field set during init.
- 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.