Skip to content

CustomLiquidBuilder

QinShenYu edited this page Jun 20, 2026 · 3 revisions

Namespace:​ ScavLib.liquid

Registers custom liquid types into the game's Liquids.Registry dictionary. Custom liquids participate fully in the existing systems: they can be poured into WaterContainerItems, used as RecipeBuilder ingredients or results, drunk, and (optionally) injected.

Registration is safe at Awake() time — entries are queued and flushed during ItemSetupItemsPatch.Prefix, which runs after Liquids' static constructor and before LiquidItemInfo builds its default contents. As of 0.7.1, late registration mid-game is also supported.


Quick Start

using ScavLib.liquid;
using UnityEngine;

private void Awake()
{
    CustomLiquidBuilder.Create("mymod_tonic", "MyMod")
        .LocaleName("Bitter Tonic")
        .Tint(new Color(0.35f, 0.55f, 0.20f))
        .ValuePerLiter(8f)
        .Quality("medicine", 0.4f)
        .OnDrink((ml, body) =>
        {
            body.painShock      -= 10f * (ml / 100f);
            body.sicknessAmount  = Mathf.Max(0f, body.sicknessAmount - ml * 0.05f);
        })
        .HealthUsable()
        .OnHealthUse((ml, limb) =>
        {
            limb.skinHealAmount += 5f * (ml / 50f);
        })
        .Register();
}

CustomLiquidBuilder

A chainable builder. Each configuration method returns this; commit with .Register().

Creation

CustomLiquidBuilder.Create(string id, string owner)
Parameter Type Description
id string Unique liquid ID. Prefix with your mod name (e.g. "mymod_tonic"). This is the key used by Liquids.Registry, WaterContainerItem.AddLiquid(id, ml), and RecipeBuilder.IngredientLiquid(id, ml). ID matching is case-insensitive.
owner string The owning mod's identifier. Used by the registry to detect cross-mod ID collisions: re-registering the same id from a different owner will fail; re-registering from the same owner is idempotent.

Display

.LocaleName(string name)                     // LiquidType.localeName
.LocaleFromItem(bool value = true)           // LiquidType.localeFromItem

LocaleName sets the LiquidType.localeName field directly. For multi-language display strings, register them through LocaleRegistry (e.g. LocaleRegistry.AddItems(...)) — the builder itself does not handle localization.

When LocaleFromItem(true) is set, the game pulls the display string from whichever item is acting as the container instead of the liquid's own locale entry, matching the vanilla localeFromItem flag.

Appearance

.Tint(UnityEngine.Color color)               // LiquidType.color

The method is named Tint rather than Color to avoid CS0119 ambiguity with the UnityEngine.Color type when called as .Color(new Color(...)).

Economy

.ValuePerLiter(float value)                  // LiquidType.valuePerLiter

Qualities

.Quality(string qualityId, float amount = 1f)

Adds an entry to LiquidType.qualities. Qualities are how the game identifies a liquid for crafting-by-quality matches — e.g. tagging a custom liquid with "disinfectant" (amount 0.4f) makes it eligible for any recipe declared via IngredientLiquidByQuality("disinfectant", ...).

Quality amounts are interpreted per liter. At craft time the game calls LiquidType.GetScaledQualities(ml) to scale them down to the actually- consumed volume.

Call .Quality(...) multiple times to attach more than one quality tag.

Consumption Hooks

.OnDrink(LiquidType.OnDrink callback)
//   void (float ml, Body body)

.HealthUsable(bool value = true)
.OnHealthUse(LiquidType.OnHealthUse callback)
//   void (float ml, Limb limb)

Delegate signatures are exactly the game's:

public delegate void OnDrink(float ml, Body body);
public delegate void OnHealthUse(float ml, Limb limb);

The ml parameter is the volume actually consumed in this invocation, not the liquid's total amount in the container — multiplying status effects by ml / 100f is the typical pattern.

HealthUsable(true) must be called for OnHealthUse to be reachable; the game checks LiquidType.healthUsable before exposing the limb-treatment UI for the liquid.

Injection

.Injectable(bool value = true)               // LiquidType.injectable
.InjectionSickness(float multiplier)         // LiquidType.injectionSickness

Injectable liquids can be administered through syringes / IVs. The injectionSickness multiplier scales the sickness penalty applied when the liquid is injected, mirroring the vanilla field of the same name.

Danger Flag

.MarkDangerous(bool value = true)

When set, the liquid's ID is appended to Liquids.DangerList upon successful registration. The vanilla code uses this list for AI/warning behavior around hazardous fluids (acids, fuels, contaminated water, etc.).

Registration

.Register()
.Register(out string error)

Both overloads return bool. The out-string overload populates error with a human-readable reason on failure. Failure conditions:

  • id is null or empty.
  • The internal LiquidType is null.
  • The id was already registered by a different owner.

There is currently no overwrite overload. Re-registering the same id from the same owner silently succeeds (idempotent); a conflict with a different owner is rejected and logged via ScavLibPlugin.Log.


Worked Example — Antiseptic

A custom antiseptic that doubles as a "disinfectant" quality for crafting and inflicts mild sickness when injected:

using ScavLib.liquid;
using UnityEngine;

private void Awake()
{
    CustomLiquidBuilder.Create("mymod_antiseptic", "MyMod")
        .LocaleName("Antiseptic Solution")
        .Tint(new Color(0.85f, 0.95f, 0.95f))
        .ValuePerLiter(12f)
        .Quality("disinfectant", 0.6f)
        .HealthUsable()
        .OnHealthUse((ml, limb) =>
        {
            limb.skinDirtyness   = Mathf.Max(0f, limb.skinDirtyness - ml * 0.02f);
            limb.infectionAmount = Mathf.Max(0f, limb.infectionAmount - ml * 0.1f);
        })
        .OnDrink((ml, body) =>
        {
            // Drinking antiseptic is a bad idea.
            body.sicknessAmount += ml * 0.2f;
            body.venomCurrent   += ml * 0.05f;
        })
        .Injectable()
        .InjectionSickness(2f)
        .MarkDangerous()
        .Register(out string err);

    if (!string.IsNullOrEmpty(err))
        Debug.LogError($"[MyMod] antiseptic register failed: {err}");
}

For multi-language display names, register them through LocaleRegistry:

LocaleRegistry.AddItem("mymod_antiseptic", "en", "Antiseptic Solution");
LocaleRegistry.AddItem("mymod_antiseptic", "zh-CN", "消毒液");

Pair this with a RecipeBuilder.IngredientLiquidByQuality("disinfectant", 30f) and the new liquid is immediately accepted everywhere vanilla disinfectants are.


Inspecting Liquids

CustomLiquidRegistry.Contains("mymod_tonic");   // bool

Contains returns true for any ID that has been routed through CustomLiquidBuilder / CustomLiquidRegistry, regardless of whether the flush into Liquids.Registry has already happened.


Late Registration & Multiplayer

Late registration (0.7.1+).​ If Register() is called after the vanilla ItemSetupItems flush has already run (e.g. another mod loads late, or you register from gameplay code rather than Awake()), the builder writes directly into Liquids.Registry instead of waiting for the flush. This prevents KeyNotFoundException when hovering items that reference the new liquid.

KrokoshaCasualtiesMP (KrokMP) bridge.​ When the KrokoshaCasualtiesMP plugin is detected via BepInEx.Bootstrap.Chainloader.PluginInfos, late-registered liquids are also synchronized into KrokMP's LiquidIdRegistry and LiquidNetIdToId tables via reflection, assigning a fresh byte network ID. This keeps custom liquids serializable in multiplayer sessions without requiring a hard dependency on KrokMP at compile time.

The byte net ID space is capped at 255 entries. If your mod stack approaches that limit, expect later registrations to silently collide.


Notes & Limitations

  • ScavLib does not ship a custom liquid container Prefab. To pre-fill a bottle with your liquid at world-load time, spawn a vanilla craftingbottle (or any WaterContainerItem) and call waterContainer.AddLiquid("mymod_tonic", ml).
  • The vanilla LiquidType struct exposes no "color blend" hook; the Tint color is used wholesale for the in-container fluid render.
  • Liquid IDs are matched case-insensitively (StringComparer.OrdinalIgnoreCase).
  • Custom liquid IDs survive language switches. Locale.ChangeLanguage() reloads the active scene, but Liquids.Registry is a static dictionary; ScavLib re-applies its queue on the next Recipes.SetUpRecipes post-load.
  • Registration is not thread-safe. Call the builder from the main Unity / BepInEx thread.

Clone this wiki locally