Files
nimmersky/skyrimnet/action-system.md

8.5 KiB

Action System

This is the most complex subsystem and the one we've already broken once. Read carefully before editing anything that touches actions.

The three registration paths

[verified] from Source/Scripts/SkyrimNetApi.psc:27-31 (Papyrus path), SkyrimNet.log:14123-14266 (CustomActionManager loader), CppAPI/PublicAPI.h:93-97 (C++ path), and on-disk YAML inspection.

1. Papyrus registration

A mod's Papyrus script calls:

SkyrimNetApi.RegisterAction(
    string actionName,             ; UPPERCASED in the registry — "OpenTrade" → "OPENTRADE"
    string actionDescription,
    string eligibilityScriptName,
    string eligibilityFunctionName,
    string executionScriptName,
    string executionFunctionName,
    string triggeringEventTypesCsv,
    string actionType,             ; "PAPYRUS" or "PAPYRUS_CUSTOM"
    int    priority,
    string parameterSchemaJson,
    string customCategory,
    string tagsCsv
)

The C++ DLL keeps a registry of these. When the LLM emits ACTION: OPENTRADE PARAMS: {...}, the DLL:

  1. Looks up OPENTRADE in the registry.
  2. Calls the eligibility function via Papyrus (Action_IsEligible style), waits up to dialogue.eligibilityCheckTimeoutMs (= 2500ms).
  3. If eligible, calls the execution function with the parameters.

Shipped Papyrus actions (from Source/Scripts/skynet_Library.psc:130-315): OpenTrade, AccompanyTarget, StopAccompanying, WaitHere, Gesture (with anim enum), RentRoom, CompanionFollow, CompanionWait, CompanionInventory, CompanionGiveTask.

[verified] SkyrimNet.log:16393 shows: ActionLibrary::RegisterPapyrusAction for action: OPENTRADE.

2. YAML registration

A mod can drop YAML files into SKSE/Plugins/SkyrimNet/config/actions/*.yaml in its own mod folder. SkyrimNet's CustomActionManager::Initialize scans across all installed mods at load.

YAML actions preserve case in the registry (unlike Papyrus actions). So OpenTrade stays OpenTrade.

Schema — sample from SeverActions - SkyrimNet Action Pack/.../config/actions/attacktarget.yaml:1-37:

customCategory: Combat
name: AttackTarget
description: ...
questEditorId: ...        # the Skyrim Quest holding the function
scriptName: ...           # Papyrus script attached to the quest
executionFunctionName: ...
parameterMapping:
  - parameterName: target
    decorator: get_nearby_actor   # or other decorator → Skyrim object
enabled: true
defaultPriority: 50
eligibilityRules:
  - decorator: is_in_combat
    operator: equals
    value: true
  # AND/OR combinations supported

Contributing mods at last load [verified] from SkyrimNet.log:

  • SeverActions - SkyrimNet Action Pack — ~80 generic actions (Combat/Travel/Economy/etc.). The big one.
  • IntelEngine — 15 actions (likely intelligence/observation-themed).
  • OStimNet_v0.9.2 — ~10 actions, prefixed tton_* (sex/affection — adult mod).

3. C++ registration

External SKSE plugins can call PublicRegisterCPPAction() from CppAPI/PublicAPI.h:93-97 to register C++-implemented actions directly. Avoids the Papyrus VM round-trip; useful for performance-critical or low-level actions.

[unknown] whether any currently-loaded mod uses this path in this install.


Action categories (the wrapper layer)

Actions can be grouped under categories defined in cat_*.yaml files. Categories let the LLM "intent-pick" without seeing all leaf actions.

Known categories [verified] from logs: Combat, Communication, Travel, Economy, Items, Magic, Outfit, Crafting, Scheduling, Command (companion), Arrest.

Two-stage flow:

  1. GM (or native action selector) emits ACTION: Communication PARAMS: {"intent": "express gratitude..."} — picks the category and states the intent in natural language.
  2. C++ orchestrator fires a second LLM call using prompts/native_action_selector_drilldown.prompt, which sees only the leaf actions under that category plus the intent. Picks the specific leaf.

This pattern reduces cognitive load: instead of choosing among ~105 actions, the model picks one of ~10 categories then one of ~5-15 leaves.


The ACTION: parser grammar

[verified] from log behavior at ActionManager.cpp:1783 and :1814.

Required form (start of a line):

ACTION: ActionName
ACTION: ActionName PARAMS: {"param": "value", ...}
ACTION: None

Strict requirements:

  • The literal ACTION: prefix (with the colon and space) at the start of a line.
  • Action name must match a registered action exactly (case-sensitive, except Papyrus actions are uppercase in the registry).
  • PARAMS: if present must be valid JSON.
  • The action line must be on its own line, after any dialogue text (with embed_actions_in_dialogue: true).

No leniency:

  • Bare action names (e.g. OPENTRADE alone) are NOT recognized. They fall through to dialogue text.
  • No fuzzy matching. No alternate prefixes (Action:, ACT:, [ACTION]).
  • Logged when not found: [ActionManager.cpp:1783] ParseEmbeddedAction: No ACTION: line found in response.
  • Logged when found: [ActionManager.cpp:1814] ParseEmbeddedAction: Successfully parsed action 'X' with params: {...}.

Strip behavior: FilterActionLines (ActionManager.cpp:1840) removes the recognized ACTION: line from dialogue text before TTS, so it isn't spoken aloud. It only strips lines it parsed successfully — malformed lines (the bug #2 case) stay in the text and get spoken.


The two action selectors — how they relate

Aspect gamemaster_action_selector native_action_selector
Scope scene-level orchestration per-NPC, post-dialogue attribution
When GM polling tick, player input after Dialogue agent produces text
Picks from StartConversation / ContinueConversation / Narrate / None / native actions the eligible-actions registry, two-stage
Variant gamemaster_evaluation action_evaluation
Model claude-sonnet (this install) eva (this install)
max_tokens 256 500
Output one ACTION line one ACTION line (after drilldown: two LLM calls total)

They are complementary, not competitive:

  • The GM directs who speaks and what about.
  • The native action selector classifies what physically/mechanically happens once an NPC has spoken.

With embed_actions_in_dialogue: true: the Dialogue agent itself can emit an ACTION: line inline, which bypasses the native action selector entirely. Faster, but more prone to format errors → bug #2 territory.


Where custom actions actually live (resolved)

We initially looked for them in mods/SkyrimNet/ itself and didn't find them. They live in other mods' folders:

/home/dafit/Games/Skyrim/nimmersky/mods/SeverActions - SkyrimNet Action Pack/SKSE/Plugins/SkyrimNet/config/actions/*.yaml
/home/dafit/Games/Skyrim/nimmersky/mods/IntelEngine/SKSE/Plugins/SkyrimNet/config/actions/*.yaml
/home/dafit/Games/Skyrim/nimmersky/mods/OStimNet_v0.9.2/SKSE/Plugins/SkyrimNet/config/actions/*.yaml

[hypothesis] — paths inferred from the contributing-mod names in the log; the agent didn't enumerate the actual files. Confirm by find /home/dafit/Games/Skyrim/nimmersky/mods/ -name "*.yaml" -path "*/config/actions/*".

This is also why "export from web UI" doesn't work cleanly — the UI sees actions in its registry but doesn't know they came from neighbor mods' folders, and can't reach back across mod boundaries to dump them.

Path forward for git-tracking: symlink each contributing mod's config/actions/ into a nimmersky/skyrimnet/contributed-actions/{modname}/ archive, or write a one-shot collator script that copies them out.


The malformed-marker problem (bug #2)

The Dialogue model occasionally emits OPENTRADE (bare uppercase token) instead of ACTION: OpenTrade. The parser ignores the bare token, doesn't strip it, and it gets TTS'd as plain text — Arcadia speaks "OPENTRADE" out loud.

Why it happens: submodules/user_final_instructions/0750_embedded_actions.prompt:6-9 lists actions to the LLM as bullets:

**Available Actions:**
- `OPENTRADE` — Use ONLY if ...
- `OFFERQUEST` — ...

The model sometimes emits the bare bullet name instead of the full ACTION: OPENTRADE line. The bullet list format encourages the mistake.

Why it became visible only after fixing bug #1: the over-firing GM loop gave the Dialogue model 3-5 retries per scene; bug #2 was statistically masked. Fixing the loop removed the retries, exposing bug #2.

See bugs-and-fixes.md for fix candidates.