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:
- Looks up
OPENTRADEin the registry. - Calls the eligibility function via Papyrus (
Action_IsEligiblestyle), waits up todialogue.eligibilityCheckTimeoutMs(= 2500ms). - 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, prefixedtton_*(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:
- GM (or native action selector) emits
ACTION: Communication PARAMS: {"intent": "express gratitude..."}— picks the category and states the intent in natural language. - 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.
OPENTRADEalone) 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.