# 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: ```papyrus 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`: ```yaml 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`](bugs-and-fixes.md) for fix candidates.