SkillKit
Ship a learnable custom skill for Outward without rediscovering the traps: SideLoader pack-load wiring, effect-bucket routing, the stuck-cast guard, dynamic quickslot icons, native cast sync. Requires SideLoader. Auto-installed as a dependency.SkillKit — ship a learnable custom skill without rediscovering the traps
📖 Full documentation: SkillKit wiki page
A BepInEx library plugin for Outward (Definitive Edition, Mono branch) that owns the mechanism of shipping a custom skill: SideLoader pack-load wiring, effect-bucket routing, the stuck-cast guard, dynamic multi-state quickslot icons, weapon-dependent native cast animation, learn helper, and convergent re-stamping. Consumers own the content: the SL_Skill XML, the ItemID block, the icon PNGs, and what the skill actually does.
Extracted from Beastwhispering 2026-07-06 (plan + evidence trail: docs/skilltoolkit-plan.md).
Hard dependency: sinai-dev-SideLoader (an Outward-modding norm; referenced locally from
reference/gamelibs/SideLoader.dll at build time).
Consuming the kit
-
Reference the project (
Private=false— the DLL ships once, fromdist/SkillKit/, never inside your mod's folder: two copies inplugins/= duplicate-GUID load + split types):<ProjectReference Include="..\SkillKit\SkillKit.csproj" Private="false" /> -
Declare the load-order dependency on your plugin class:
[BepInPlugin(GUID, NAME, VERSION)] [BepInDependency(SkillKit.Plugin.GUID, BepInDependency.DependencyFlags.HardDependency)] -
Ship an SL_Skill XML in your own SideLoader pack (
<YourMod>/SideLoader/Items/...next to your DLL — see the template below), then register a spec from yourAwake:SkillRegistry.Register(new SkillSpec { ItemId = 12345, // an id from YOUR mod's community-allocated range Label = "PetCommand", // log tag OnCast = player => TogglePetCommand(), // what the skill DOES (runs from the native pipeline) // OPTIONAL: multi-state quickslot icon (vanilla PistolSkill Fire/Reload mechanism). // For a plain STATIC icon use `Icon = DynamicIcon.Fixed("YourSkill.png")` — same // convergent restamping, one state. Icon = new DynamicIcon { Select = () => passive ? "engage" : "disengage", // state key, polled on every sync Sprites = // embedded PNGs in YOUR assembly { ["engage"] = "PetCommandEngage.png", ["disengage"] = "PetCommandDisengage.png", }, }, // OPTIONAL: native cast animation, re-resolved per equipped weapon every frame CastAnim = player => IsBow(player) ? new CastPick("PowerShot", Character.SpellCastModifier.Immobilized) : new CastPick("Probe", Character.SpellCastModifier.Attack), }); -
SkillRegistry.Learn(player, itemId)grants it (TryUnlockSkill + immediate re-stamp).SkillRegistry.Verify(player)asserts every spec's wired state — run it from a dev verb after pack load; bug-16/19 regressions are silent by nature, so assert, don't infer.
The kit's own Plugin drives the convergence loop (per-frame cast-anim sync, ~2s icon tick, a
re-stamp at player-ready on every scene load). Call SkillRegistry.SyncIcons(player) yourself
whenever an icon's state input flips, for same-frame feedback.
Diagnostics to bind into your command channel: DumpLearned(player, filter),
DumpPrefabs(filter), CastDump(player), CastClear(player).
The trap table (why each piece exists)
| Piece | The trap it encodes |
|---|---|
Effect host named "ActivationEffects" (SetupOne) |
Bug 19: EffectSynchronizer.RegisterEffect buckets an Effect by its host GameObject's NAME — only an Activation name lands where Skill.SkillStarted() reads. Wrong name = silent no-op, zero errors. Never wire an effect GameObject by hand. |
CastGuard (spec default ClearCastAfter = true) |
Bug 16: donor 8200040's Spark cast NEVER fires its CastDone animation event for a cloned skill — IsCasting sticks true forever and every later CastSpell (any mod's!) silently no-ops. Self-heal 2 frames after our effects run. |
SkillInstanceSync |
The quickslot/cast pipeline can hold either the prefab or a learned instance; stamping only one is the session-10 stale-icon class of bug. Everything the kit stamps goes through this loop. |
DynamicIcon engine |
QuickSlotDisplay blanks the TEXT label for custom-icon items (bake words into the sprite); icon reads are cached unless HasDynamicQuickSlotIcon = true; un-pinned sprites die to Resources.UnloadUnusedAssets (HideAndDontSave); stamping must be CONVERGENT (pack-load + player-ready + tick), never event-timed. |
CastAnim engine |
Bug 19's fix: the skill's own native activation reads ActivateEffectAnimType/CastModifier fresh at press time — set the fields, never fight CastSpell from outside. Bug 13's invariant (kit-enforced): a bow must NEVER get CastModifier.Attack (AttackInput charge-path crash). AlternateAnimHasSkillID is always forced to -1 or a donor's baked alternate-anim variant silently bypasses the sync. |
Learn |
The exact call SideLoader's own LearnSkillEffect uses (GenerateItemNetwork → TryUnlockSkill), plus an immediate re-stamp so the fresh instance is right before the next frame's poll. |
Knowledge that is content, not code:
- Outward has NO global cooldown. Every
Skillgates only itself;Cooldown=1is a pure double-press debounce,0means none. - Donor 8200040 is the confirmed-real clone target for a from-scratch active skill (the shared Spark-cast donor Beastwhispering's four skills all clone).
- Use ids from a range the community allocated to YOUR mod and allocate within it; bump on a reported collision.
SL_Skill XML template
<YourMod>/SideLoader/Items/<SkillName>/<SkillName>.xml (SideLoader treats a SideLoader/
subfolder of each mod's plugin folder as that mod's pack root):
<?xml version="1.0" encoding="utf-8"?>
<SL_Skill xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<EffectBehaviour>NONE</EffectBehaviour>
<Target_ItemID>8200040</Target_ItemID> <!-- the confirmed donor -->
<New_ItemID>12345</New_ItemID> <!-- your id block -->
<Name>Command Pet</Name>
<Description>What the tooltip says.</Description>
<IsPickable>false</IsPickable>
<IsUsable>false</IsUsable>
<CastType>Spark</CastType> <!-- overridden live if the spec sets CastAnim -->
<CastModifier>Immobilized</CastModifier>
<Cooldown>1</Cooldown> <!-- self-only; 1 = debounce, 0 = none -->
<StaminaCost>0</StaminaCost>
<ManaCost>0</ManaCost>
</SL_Skill>
(The stripped-down set above is what matters; see Beastwhispering's
SideLoaderPacks/Items/PetCommand/PetCommand.xml for a full-fields example.)
Passive skills
Set Passive = true and omit OnCast/CastAnim (the kit warns and nulls them if set — a
passive never activates, and an effect on one could only fire ONCE at learn time, which is never
what an OnCast means). The learned skill is a pure knowledge token: the kit strips the
donor's child effects so PassiveSkill.m_passiveEffects stays empty and nothing fires at learn —
your feature code reads player.Inventory.SkillKnowledge.IsItemLearned(itemId) wherever the
passive should change behavior. No ActivationEffects host is attached.
The donor trap: SideLoader has no SL_PassiveSkill — an SL_Skill clone keeps its donor's
C# type, so Target_ItemID must be a plain vanilla PassiveSkill. Primary candidate: Fitness
8205040 (fallbacks: Marathoner 8205000, Survivor's Resilience 8205100). AVOID the
NeedPassiveSkill family (Steadfast Ascetic 8205060, Slow Metabolism 8205070, Efficiency
8205400): NeedPassiveSkill : Skill is NOT a PassiveSkill and its StartInit NREs once its
children are stripped. Donor types are unverifiable offline, so SetupOne hard-asserts
skill is PassiveSkill at runtime and REFUSES a wrong donor without stripping it (inert beats
half-armed); the boot-resolve line logs type= so one boot log proves the family.
Icon = DynamicIcon.Fixed("<Name>.png") is effectively REQUIRED on a passive — without it the
clone keeps the DONOR's icon in the trainer tree, the skill menu, and the learn toast. Passives
are never quickslotable (PassiveSkill.IsQuickSlotable => false), so those three surfaces are
the only places the icon shows.
In the XML: <EffectBehaviour>DestroyEffects</EffectBehaviour> (belt-and-braces with the kit
strip — NB the enum member is DestroyEffects, there is no Destroy), Cooldown 0, no costs,
and no cast fields (inert on a passive).
Icon authoring
160×160 PNG, words IN the sprite (the quickbar blanks the text label for custom-icon items — the vanilla Fire/Reload sprites do the same). Embed via csproj:
<ItemGroup>
<EmbeddedResource Include="Icons\*.png" />
</ItemGroup>
Names are suffix-matched against your assembly's resource manifest, so folder prefixes don't
matter. Beastwhispering's Icons/gen-*.sh scripts show an ImageMagick/rsvg generation pipeline.
Cost model
Per-frame work is SyncCastAnims: specs without CastAnim are skipped outright; a spec with one
costs two enum compares when already correct (the common case — writes are edge-triggered on
weapon swaps). Icons sync on the ~2s tick + explicit calls only. If you register dozens of
cast-anim specs, reconsider — that's dozens of selector calls per frame.
Configuration
SkillKit has no configuration file of its own and no command channel of its own. Its
diagnostics ship as a verb pack (SkillVerbs.RegisterAll(...): castdump / castclear /
skillverify / skilldump / skillitemdump) that a consumer registers into its command
registry — so they answer on the consuming mod's channel (BepInEx/config/<Mod>_cmd.txt, e.g.
Beastwhispering's bw_cmd.txt), not a SkillKit_cmd.txt. Any settings a skill needs live in the
consuming mod's own cobalt.<name>.cfg.
Sibling module (future, v1.1)
The consumable-item patterns (the "Effects" Normal-bucket host, programmatic ApplyTemplate()
registration, the Item.Use prefix veto, success-only consumption via QtyRemovedOnUse=0) are
the same EffectSynchronizer mechanism from the other direction. Beastwhispering's
TamingFoodSetup/BlanketSetup implement it twice; a third copy is the DRY trigger for a
UsableItemSpec module here.
