Outward
Install

Details

Last Updated
First Uploaded
Downloads
10
Likes
0
Size
146KB
Dependency string
CeruleanCutlass-SkillKit-0.1.0
Dependants

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

  1. Reference the project (Private=false — the DLL ships once, from dist/SkillKit/, never inside your mod's folder: two copies in plugins/ = duplicate-GUID load + split types):

    <ProjectReference Include="..\SkillKit\SkillKit.csproj" Private="false" />
    
  2. Declare the load-order dependency on your plugin class:

    [BepInPlugin(GUID, NAME, VERSION)]
    [BepInDependency(SkillKit.Plugin.GUID, BepInDependency.DependencyFlags.HardDependency)]
    
  3. 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 your Awake:

    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),
    });
    
  4. 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 (GenerateItemNetworkTryUnlockSkill), 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 Skill gates only itself; Cooldown=1 is a pure double-press debounce, 0 means 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.

Thunderstore development is made possible with ads. Please consider making an exception to your adblock.