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
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.0README
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.
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.