Skip to content
Back to skills

Papyrus Optimization

ASecurity

Use when reviewing or optimizing Skyrim Papyrus scripts for latency, polling, stack pressure, save bloat, hot loops, or avoidable native calls.

  • 5 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 3, 2026
ai-agentsgobashperformance

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 3, 2026

npx -y skills add ShugokiFable/Ultimate-AI-Starter-Bundle --skill papyrus-optimization --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Papyrus Optimization?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Papyrus Optimization
[![Security: A β€” Skills Directory](https://www.skillsdirectory.com/api/skills/shugokifable-papyrus-optimization/badge)](https://www.skillsdirectory.com/skills/shugokifable-papyrus-optimization)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: papyrus-optimization
description: Use when reviewing or optimizing Skyrim Papyrus scripts for latency, polling, stack pressure, save bloat, hot loops, or avoidable native calls.
---

# Papyrus Optimization

## Overview

This skill is a read-and-assess investigation flow for Papyrus (`.psc`) source. It lets you read a
script and classify each part as **πŸ”΄ broken**, **🟑 suboptimal**, or **🟒 clean**, name *what* makes
a part heavy, and give the specific fix β€” so you catch performance problems a signature lookup can't
see. It is the cost-and-habits complement to the `papyrus-reference` skill (which gives function
signatures): that one tells you *what a call is*; this one tells you *what it costs and whether to
make it*. The deep cost model, exact numbers, full technique catalogue, and citations live in
[`references/papyrus-performance-reference.md`](references/papyrus-performance-reference.md) β€” load it
when you need the *why* behind a verdict; the workflow below is enough for most reviews.

It reads script *text* rather than houseCARL's record/conflict surfaces, so it composes with normal
file reading (or `housecarl_bsa_extract` + the decompiler when the script is packed) β€” no L2 query
surface is required.

## First step: establish the trigger before judging anything

The whole review hinges on one formula:

> **cost = how often the code runs Γ— how much latent/native work each run does.**

So before classifying any line, find **what wakes this code** β€” a one-shot event (`OnInit`,
`OnEffectStart`), a real engine event (`OnHit`, `OnActivate`), or a periodic update
(`RegisterForUpdate`/`OnUpdate` chain). The same line is 🟒 in a one-shot and πŸ”΄ in a 0.1 s loop.
Why this comes first: it sets the cost multiplier for everything else, and it stops you from
red-flagging a long-but-rare init script or acquitting a short-but-constant poller. Read the triggers,
then read the body.

## The three tiers

- **πŸ”΄ Broken** β€” incorrect, leaks, will saturate/freeze the VM, or violates an engine contract. Fix
  before shipping. (Silent no-ops, persistence/registration leaks, polling that can freeze the game,
  missing cleanup that corrupts state, event signatures the engine never calls.)
- **🟑 Suboptimal** β€” correct, but a strictly-better idiom exists. Fix if it's on a hot path or the
  fix is cheap; otherwise note and move on. (Uncached lookups, bare `RegisterForUpdate`, polling where
  an event exists, wrong data structure, hot-path logging, off-the-shelf micro-misses.)
- **🟒 Clean** β€” idiomatic and as optimized as it usefully gets. Leave it. (Event-driven, early-out
  guards, cached refs, single-update chains with an exit, states for gating, native offload, symmetric
  cleanup.)

Judge against frequency Γ— latency, not line count β€” a finding's tier rises with how often its trigger
fires.

## Review checklist

Walk these top-to-bottom. The early questions catch πŸ”΄; the later ones catch 🟑. For the mechanism
behind any item, see the reference file's matching section.

1. **Trigger?** One-shot, event, or periodic update? (Sets the multiplier for everything below.)
2. **Event contracts valid?** Does every `Event` block match a real engine event's name and signature?
   A misspelled/invented event silently never fires β†’ πŸ”΄. Is it expecting `OnUpdate` during menus
   (won't fire)?
3. **Polling safe?** Any `RegisterForUpdate`? Small interval (freeze risk) β†’ πŸ”΄. No
   `UnregisterForUpdate` / no `OnPlayerLoadGame` re-arm / handler possibly longer than its interval
   (save-bloat) β†’ πŸ”΄/🟑. Could it be a `RegisterForSingleUpdate` chain, or fully event-driven?
4. **Latent loops?** A `Utility.Wait` chain inside an event holding the thread β†’ 🟑/πŸ”΄ by duration and
   frequency. Prefer a single-update chain.
5. **Cached?** Repeated `Game.GetPlayer()` / `GetFormFromFile` / `GetWornForm`-style native chains
   resolving unchanging values each run β†’ 🟑 (πŸ”΄ in a tight loop).
6. **Persistence clean?** Property pointed at a placed reference (permanent persistence)? Ref variables
   never cleared to `None`? Registrations never undone? β†’ πŸ”΄ (leak) / 🟑.
7. **Cleanup symmetric?** Every `Add*`/`Register*` matched by a `Remove*`/`Unregister*`? Does a chain
   have an exit and a state machine a `GoToState("")` back? Missing β†’ πŸ”΄.
8. **Hot-path weight?** In a frequently-firing handler (`OnHit`, `OnAnimationEvent`, cloak): is there a
   cheap early-out before the expensive work? Many native calls on the path? No guard / many calls β†’
   🟑.
9. **Right tool?** Container-as-counter, 128-cap array bookkeeping, or a cloak scan where a native
   (`StorageUtil`, `MiscUtil.ScanCellNPCs`, PO3 filtered events) does it in one call β†’ 🟑.
10. **States vs flags?** Re-entrancy/do-once handled by a boolean guard where an empty-state handler
    would gate at the engine level β†’ 🟑.
11. **Logging gated?** `Debug.Trace`/`Notification` on a hot path in a release build β†’ 🟑.
12. **Micro (hot path only):** abbreviated AV wrappers, `== true`, int-literal-to-float, `Math.Floor`
    vs `as int`, non-`Global` stateless helpers β€” inside a loop/frequent event β†’ 🟑; elsewhere β†’ 🟒
    (don't-care).

## Symptom β†’ tier β†’ fix

| Symptom in the script | Tier | Why | Fix |
|---|---|---|---|
| `Event` name/signature matches no engine event | πŸ”΄ | Never called β€” silent no-op | Correct to the real event signature |
| `RegisterForUpdate(0.1)`-style small interval | πŸ”΄ | Can freeze the VM; bloats saves | `RegisterForSingleUpdate` chain, longest tolerable interval |
| Update handler longer than its interval | πŸ”΄ | Calls queue faster than they drain β†’ 1 GB saves | Single-update chain (next tick waits for previous) |
| `RegisterForUpdate` with no unregister / survives uninstall | πŸ”΄ | Registration baked into save, fires forever, errors each tick | Single-update chain + `OnPlayerLoadGame` re-arm; alias script |
| Property pointed at a placed reference | πŸ”΄ | Reference permanently persistent β€” never unloads | Point at base form; resolve ref at runtime, clear when done |
| `Add*` with no matching `Remove*` on effect end | πŸ”΄ | Spell/perk leak when effect expires/dispels | Symmetric `OnEffectFinish` cleanup |
| `Utility.Wait` chain in an event for a timed sequence | 🟑/πŸ”΄ | Blocks/pins the thread for the duration | Convert to a `RegisterForSingleUpdate` tick chain |
| Polling for a condition that has an event | 🟑 | Idle ticks doing nothing | Register for the event (`OnHit`, `OnAnimationEvent`, PO3 `OnHitEx`…) |
| `Game.GetPlayer()` / `GetFormFromFile` re-called every run | 🟑 | External call each time (~1000Γ— a cached read) | Cache in an auto property / `OnInit` |
| Cloak spell scanning nearby actors | 🟑 | Re-applies to everyone in range each cycle | SPID for distribution, or `MiscUtil.ScanCellNPCs` |
| Container `GetItemCount` used as a counter | 🟑 | Native inventory serialisation per access | `StorageUtil.AdjustIntValue`/`GetIntValue` |
| Cross-script property read in a hot loop | 🟑 | External call (~2Γ— local) per read | Cache to a local variable once |
| Boolean guard for do-once / re-entrancy | 🟑 | Event still fires, enters function, checks flag | Empty-state handler gates at engine level |
| `Debug.Trace` on a hot path (release) | 🟑 | External call + disk I/O every fire | Gate behind a debug flag / remove |
| `GetAV`, `== true`, `Wait(1)`, `Math.Floor` *in a loop* | 🟑 | Extra bytecode/cast/call layer per iteration | Native names, `If(b)`, `1.0`, `as int` |
| Event-driven, early-out guard, cached refs, single-update chain w/ exit, symmetric cleanup | 🟒 | Minimal frequency Γ— latency | Leave it |

**Red flags at a glance:** `RegisterForUpdate(` with a small literal Β· an `OnUpdate` with no exit
condition Β· `Utility.Wait` inside a loop Β· a property whose value is a placed reference Β·
`Game.GetPlayer()` inside a loop or frequent event Β· a cloak/scan distribution Β· `GetItemCount` as a
number Β· `Add*`/`Register*` with no matching `Remove*`/`Unregister*` Β· `Debug.Trace` in
`OnHit`/`OnUpdate`.

## Common review mistakes

- **Judging a line before its trigger.** A 7-second `Utility.Wait` chain is alarming in a 0.1 s loop
  and fine in a once-per-game flourish. Always resolve step 1 first β€” otherwise you flag rare code and
  miss constant code.
- **Chasing micro-opts off the hot path.** `b == true` or `Wait(1)` in a one-shot is 🟒, not 🟑. The
  micro-optimizations only pay inside loops that run hundreds/thousands of times; spending the review
  there on rare code is wasted, and rewriting it adds risk for no gain.
- **Only hunting for bad patterns.** Recognise and *credit* the good ones β€” a cached `PlayerRef`, an
  early-out guard, an `OnPlayerLoadGame` re-arm. A review that calls everything 🟑 is as useless as one
  that calls everything 🟒.
- **Repeating INI folklore.** "Raise the Papyrus budget / memory page size to fix lag" is debunked
  (reference Β§5.3). Don't recommend INI edits as a script fix; fix the script, or point at an engine
  mod (Papyrus Tweaks NG) for genuine engine limits.
- **Inventing a signature to justify a verdict.** If a verdict depends on a function's exact behavior,
  confirm it via `papyrus-reference` or the read source β€” don't assert a signature from memory.

## Make a defensible verdict (no silent wrong answers)

A script review must land on one of two honest outcomes, never a confident guess:

1. **A classification with a reason and a fix** β€” "🟑: `Game.GetPlayer()` on lines 81/94/204 re-resolves
   the player each call; cache it in an auto `PlayerREF` property. Low severity here because the
   trigger is a one-shot init." Tie the tier to the trigger and name the concrete fix.
2. **An explicit "I can't tell yet β€” here's what I checked and what to look at next"** β€” e.g. when the
   trigger frequency depends on how an effect is applied (a magic effect's delivery, a quest alias's
   fill), or when behavior hinges on a function whose semantics you haven't confirmed. Say what you'd
   need (the effect setup, the calling script, a `papyrus-reference` lookup).

A confidently wrong "this is fine" or "this is broken" is worse than a clear non-answer β€” it sends the
user to fix the wrong thing or ship a real problem. Prefer the honest gap.

## Worked example

Two real decompiled snippets, classified.

🟒 **Event-driven with an early-out** β€” fires only on a blocked hit; guards before the expensive cast:
```papyrus
Event OnHit(ObjectReference akAggressor, Form akSource, Projectile akProjectile, \
            bool abPowerAttack, bool abSneakAttack, bool abBashAttack, bool abHitBlocked)
    If abHitBlocked
        GrimyAbCounterStrikeSpell.Cast(spellTarget, spellTarget)
    EndIf
EndEvent
```
Why 🟒: the engine wakes it only on a hit (no idle cost), and the `abHitBlocked` guard means the cast
runs only on the rare blocked hit. Trigger is event-driven; hot-path work is gated. Nothing to fix.

πŸ”΄ **`Utility.Wait` bleed chain** β€” blocks the thread ~3 s, pinning the object:
```papyrus
Event OnEffectStart(Actor akTarget, Actor akCaster)
    akTarget.DamageActorValue("Health", dmg)
    Utility.Wait(0.6)
    akTarget.DamageActorValue("Health", dmg)
    Utility.Wait(0.6)
    ; …×5
EndEvent
```
Why πŸ”΄ (in context): the `Wait` chain holds this script's thread for the full bleed, pinning the actor
and blocking other events on the script. Fix: drive the ticks with a `RegisterForSingleUpdate` chain
that re-arms until the bleed count is spent, so the thread is free between ticks.

## Notes

- **Severity is contextual.** Every tier in the tables assumes a hot-path trigger; down-weight on rare
  triggers per step 1. State the trigger in the verdict so the severity is auditable.
- **Official vs community, contested claims.** The cost mechanics are official (Creation Kit wiki);
  benchmark magnitudes ("~1000Γ— faster cached") are single community measurements β€” directionally
  right, not exact. "States gate at zero engine cost" and "scripted cloaks are always bad" are
  community consensus with contested edges. The reference file flags each; carry the flag into the
  verdict rather than overstating.
- **Signatures:** confirm any function's parameters/flags via `papyrus-reference` before relying on
  them in a fix.
- **Packed scripts:** when only a `.pex` is shipped, extract with `housecarl_bsa_extract` and decompile
  before reviewing β€” you can only classify source you can read.

Files in this skill

  • SKILL.md12.2 KB
  • references/papyrus-performance-reference.md23.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.