You are viewing a potentially older version of this package. View all versions.
CeruleanCutlass-SkillKit-0.1.0 icon

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.

Date uploaded a day ago
Version 0.1.0
Download link CeruleanCutlass-SkillKit-0.1.0.zip
Downloads 33
Dependency string CeruleanCutlass-SkillKit-0.1.0

This mod requires the following mods to function

BepInEx-BepInExPack_Outward-5.4.19 icon
BepInEx-BepInExPack_Outward

BepInEx pack for Outward.

Preferred version: 5.4.19
sinai-dev-SideLoader-3.8.4 icon
sinai-dev-SideLoader

API and Mod Development Toolkit for Outward.

Preferred version: 3.8.4
CeruleanCutlass-ForgeKit-0.4.0 icon
CeruleanCutlass-ForgeKit

Dependency-free dev-tooling for Outward BepInEx mods: file-driven dev command loop, self-test harness, on-screen toasts, player-ready lifecycle wait, embedded/override table loaders, and a shared dev-verb pack (movement/combat/skill/status probes).

Preferred version: 0.4.0

README

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 (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 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.

CHANGELOG

SkillKit changelog

0.1.0 โ€” 2026-07-30

  • Thunderstore release prep: changelogs + real category tags for BW
  • Pre-release review fixes: the public wiki, the unguarded scroll seam
  • Review fixes: the shared kits must not enforce OUR id allocation
  • Thunderstore release prep: BW/SkillKit/StoryKit manifests + descriptions
  • Draw every custom id from the community-allocated pool (87000-87999)
  • Icon pipeline: condition generation on real Outward art, restyle every icon
  • Extract the stamp-and-heal Converge helper into ForgeKit (static-analysis A2)
  • Lane 6A: lift SlStatus into SkillKit, BW keeps a thin shim
  • SkillKit: OnCastResult + CooldownSeconds, Lifecycle player-wait, Init warn, NoInlining
  • docs(wiki): always document config file paths + example configs
  • docs: wiki tree + status/readme refresh
  • Integrate verb re-homing wave + 2026-07-20 SP session results + fix batch
  • Gift & item-spawn network-policy bundle (health review 2026-07-18 ยง2.1/ยง2.2)
  • R7b/F14 (review 2026-07-14): SkillKit.SkillClock owns the four-field cooldown write (BW's raw Skill-field pokes die), SkillCooldowns lifted to SkillKit on its own host/logger; SlConsumables stays in BW by dependency truth (deeply SideLoader-typed โ€” ForgeKit can't take it, SkillKit shouldn't)
  • ForgeKit/SkillKit bundle (review 2026-07-14): CommandChannel stamps its dedup key only after a successful read; shared bidirectional Suggest lifted to ForgeKit (superstring typos now suggest everywhere); SkillRegistry.Init guards before assigning; void-floor mirror gets a source-level parity gate; scaffold example key ships unbound
  • Skill-tree overhaul: Maren sells the real 10-skill tree โ€” Scatology, Beast of Burden, Wild Unknown, Communion
  • StoryKit: NPCs/trainers/dialogue as a kit โ€” Maren goes live in Cierzo
  • Project health review wave: 12-agent fanout, 18 confirmed fixes, CI validity gate
  • Hunt-as-One Synergy + For the Kill: honest strikes, stacking buff, execute skill
  • SkillKit: instrument + harden custom-skill boot/save-restore diagnosis
  • โ€ฆ and 4 more (see git history)

0.1.0 โ€” initial release

  • SideLoader pack-load wiring for a learnable custom skill: SL_Skill registration, effect-bucket routing, and the stuck-cast guard (a cancelled/interrupted cast no longer strands the caster's animation or cooldown state).
  • Dynamic multi-state quickslot icons โ€” a skill's icon can change based on runtime state (stacks, cooldown, availability) without hand-rolled UI code per consumer.
  • Weapon-dependent native cast animation sync, and a learn helper for registering a skill into the player's learnable set.
  • Convergent re-stamping โ€” a skill's runtime state re-applies correctly after a save/reload or a scene change, rather than needing a fresh cast to re-sync.
  • Extracted from Beastwhispering; consumers own the skill's actual content (SL_Skill XML, ItemID block, icon art, and what the skill does), SkillKit owns the mechanism.