Framework core classes
Status: experimental.
What it is
This page covers the small building blocks that nearly every mod uses, plus a few bigger ones. They are not tied to one part of the game. You get a log file for your mod, a safe way to change game methods, and events for the clock and for saving and loading. You also get the game’s date and time, the player’s OC balance, and a place to keep your mod’s own save data. The rest covers the player’s needs (hunger, thirst, health and so on), the camera, the built-in gameplay tweaks, the Mods menu, apps on the in-game computers, and the player’s look. The list of game areas and the known API inconsistencies are in API.md.
The samples on this page are written as BepInEx 6 plugins. On BepInEx 5, delete the using BepInEx.Unity.Mono; line from each sample. The plugin class still derives from BaseUnityPlugin. The first sample uses the simpler framework mod form, which needs no BepInEx line at all.
When to use it
- Write a line to your own log file, and see it in
BepInEx\logs. - Do something when a new hour or a new day starts, or when a save has loaded.
- Keep a number or a list of your own in the player’s save, so it is still there after a reload.
- Make the player hungrier, slow down thirst, or react when the player faints.
- Add a page to the Mods menu, or an app to the in-game computers.
- Change what a game method does, in a way that stays safe when another mod changes the same method.
Quick example
This framework mod writes one log line at the start of every game day. Once a save is loaded, it also logs the hour and the player’s hunger value. Events.DayPassed is an event: the framework calls your code each time it happens. Events.WhenReady waits until a save is loaded and then runs your code once. See Using events for more on both.
[Safety] RefuseUnknownBuild = false. When true, every patch is refused on a game build other than 0.5.08 (buildid 25969064). By default an unknown build is patched, and a warning is logged. Takes effect on the next start.[Debug] DeveloperMenu = false. Turns on the Developer menu, opened with F8 (rebindable under Mod Keybinds) or theomf_devconsole command. Off by default, and when off nothing is built. See Developer menu.[Debug] Master = false. The framework’s debug trace, written toBepInEx\logs\ObenseuerCore.debug.log. Also in the Mods menu and thecore_debugconsole command.[Debug] TraceFilterlimits it to type name prefixes. See Debug.[Debug] Verbose = false. When true,CoreLog.Debugwrites.[Debug] AccessProbe = false. Runs the L0 access probe at start and logs the result.[Debug] PlayerLookSelfCheck = false. Runs the PlayerLook clamping and gender-rule self-check at start and logs the result.[Debug] ModIdsSelfCheck = false. Runs the mod id and save file name self-check at start and logsmodids self-check ok, or the first failing case.[Debug] NetBlobSelfCheck = falseruns the blob chunking, CRC32 and gzip self-check at start (netblob self-check okor the first failing case).[Debug] SaveDataSelfCheck = falseregisters thecore_savedata_checkconsole command, which logsSaveData.SelfCheck(); it changes the live inventory, so use a scratch save (seedocs/api/savedata-game.md).[Debug] SaveJsonSelfCheck = falseruns the save JSON round trip self-check at start and logssave json self-check ok, or the first failing case.[Debug] JsonProbeCommand = falseregisters thecore_jsonprobeconsole command, which logs one probe value asJsonUtilityand asSaveDatawrites it.[Debug] SceneLooksCommand = falseregisters thescenelooksconsole command, which prints the scene look index for a scene (list,listall,find,findchar,here); it reads the installed game files and changes nothing in the game (seedocs/api/scenelooks.md). The framework’s own Settings page in the Mods menu also has aScene looksaction,Run scene looks checks, which needs no flag and no console: it prints the same index for the loaded scene to the menu status line and the log (seedocs/api/scenelooks.md).[Debug] ModMenuSelfCheck = falseruns the classic Mods menu self-check at start and logsmodmenu self-check ok, or the first failing case. It covers the row kind, value and default helpers, the config list filter and sort, the action registry and its confirm flag and expiry, the navigation order and focus-keep helpers, and the action row confirm rule. It calls no Unity engine code and drives no live control, so it runs at start before the menu is built.[UI] ThemeConfigManager = truegives the ConfigurationManager window a dark theme (see ModMenu).
While the ConfigurationManager window (F1) is open, the framework freezes mouse-look and player movement with the game’s own GameController.ControlsDisabled, and keeps the cursor visible. It does nothing when ConfigurationManager is not installed. Gameplay is not frozen while a game menu is open, and the freeze is released when the window closes.
CorePatcher
public static class CorePatcher
{
public static bool Patch(MethodBase target, MethodInfo prefix, MethodInfo postfix, MethodInfo finalizer, bool core, string owner);
public static bool Patch(Type type, string name, Type[] args, MethodInfo prefix, MethodInfo postfix, MethodInfo finalizer, bool core, string owner);
public static bool Patch(MethodBase target, MethodInfo prefix, MethodInfo postfix, MethodInfo finalizer, bool core, string owner, int priority);
public static bool Patch(Type type, string name, Type[] args, MethodInfo prefix, MethodInfo postfix, MethodInfo finalizer, bool core, string owner, int priority);
public static bool IsDisabled(string owner);
}
ownermust be a non-empty mod id that does not start withobenseuer.moddingframeworkornomad.obenseuer.core(any letter case). Otherwise all four overloads throwArgumentExceptionbefore anything else runs. Both prefixes are reserved for the framework’s own patches.- The short overloads keep the
[HarmonyPriority],[HarmonyBefore]and[HarmonyAfter]attributes of each method (checked with HarmonyX 2.9). priorityis the HarmonyX priority (HarmonyLib.Priority) given to the prefix, postfix and finalizer. It overrides a[HarmonyPriority]attribute.-1leaves the attributes alone.Priority.Normalis 400 andPriority.Firstis 800. For prefixes, a higher value runs first.- HarmonyX runs every prefix, even when an earlier prefix returns
false. Afalseonly skips the original method. A higher priority prefix runs before the others, but it cannot stop them. - One Harmony instance per
owner. - Returns
falseand logs toBepInEx\logs\<owner>.logon failure. core: truemarks the patch as critical. If it fails, every patch the framework made under that owner is removed andIsDisabled(owner)becomes true. Your ownnew Harmony(owner)patches are not touched.- A disabled owner can’t patch again this session.
Patchreturnsfalsefor it without logging, until the game restarts. - Refuses to patch (returns
false, and logspatch skipped, unsupported game build) whenCore.PatchingAllowedis false. That is only when the build is not 0.5.08 and[Safety] RefuseUnknownBuildis set. By default an unknown build is patched.
using System;
using System.Reflection;
using BepInEx;
using BepInEx.Unity.Mono;
using ObenseuerCore;
public class MyMod : BaseUnityPlugin
{
private void Awake()
{
MethodInfo postfix = typeof(MyMod).GetMethod("AfterDayChanged", BindingFlags.Static | BindingFlags.NonPublic);
bool ok = CorePatcher.Patch(typeof(TimeOfDayAzure), "IncreaseDayCounter", Type.EmptyTypes, null, postfix, null, false, "example.mymod");
if (!ok || CorePatcher.IsDisabled("example.mymod")) Logger.LogWarning("MyMod patch unavailable");
}
private static void AfterDayChanged() { }
}
CoreLog
public class CoreLog
{
public static CoreLog For(string modId); // BepInEx\logs\<modId>.log; the previous file moves to <modId>.log.1 on start; AutoFlush; at 4 MB the file moves to .1, replacing the older .1
public void Info(string message);
public void Warn(string message);
public void Error(string message);
public void Debug(string key, string message); // only when Debug.Verbose; writes when the text for key changes
public int LineLimit { get; set; } // lines per second, default 100, clamped 1 to 100000
}
Lines look like [frame 1234] [56.789] INFO: message.
An id that is not a usable file name (a path separator, .., :, a Windows device name such as CON, NUL or COM1, or a trailing dot or space) logs to BepInEx\logs\ObenseuerCore.log, with the id as a [id] prefix. For(null) does the same, and never throws.
using BepInEx;
using BepInEx.Unity.Mono;
using ObenseuerCore;
public class MyMod : BaseUnityPlugin
{
private static readonly CoreLog Log = CoreLog.For("example.mymod");
private void Awake()
{
Log.Info("started");
Log.Debug("state", "idle");
}
}
Events
public static class Events
{
public static event Action<int> HourPassed; // hour 0..23, when the game day and hour change
public static event Action<int> DayPassed; // day counter after the change
public static event Action<string> SceneLoaded; // scene name, for every load, additive ones included
public static event Action Saved; // after the save files are written
public static event Action Loaded; // after a save has been loaded
}
- Handlers run in subscription order. An exception in one handler is logged, and the other handlers still run. The first failure of each handler for each event is logged; later failures of it are not. A handler may subscribe or unsubscribe during a dispatch; the change takes effect at the next event.
HourPassedis checked on each game minute, so a time change is seen on the next minute. A change that lands on the same hour number on another day still fires.- A level change does not repeat the hour it lands on. After it,
HourPassedfires only when the hour or the day differs from the last one reported. A load or new game started from the main menu firesHourPassedon its first game minute, even for the same hour, because the main menu resets the last hour.SavedandLoadedalso fire on every level change, because a level change is a save followed by a load. - Subscribers that live in scenes must unsubscribe (for example in
OnDestroy). Otherwise they are called after their objects are destroyed, and only the first failure of each handler is logged.
using BepInEx;
using BepInEx.Unity.Mono;
using ObenseuerCore;
public class MyMod : BaseUnityPlugin
{
private void Awake()
{
Events.HourPassed += hour => Logger.LogInfo("hour " + hour);
Events.DayPassed += day => Logger.LogInfo("day " + day);
Events.Loaded += () => Logger.LogInfo("loaded");
}
}
GameTime
public static class GameTime
{
public static int Day { get; } // dayCounter, starts at 0
public static System.DayOfWeek DayOfWeek { get; }
public static int Hour { get; } // 0..23
public static int Minute { get; } // 0..59
public static bool IsPaused { get; } // Time.timeScale == 0
}
using ObenseuerCore;
[ModInfo("example.coretour", "Framework Tour", "0.1.0")]
[ModDependency("obenseuer.moddingframework", "0.11.0")]
public sealed class CoreTour : FrameworkMod
{
protected override void OnLoad()
{
Events.DayPassed += day => Log.Info("A new day started: day " + day);
Events.WhenReady(() => Log.Info("It is " + GameTime.Hour + ":00 and the hunger value is " + Survival.Get(Stat.Hunger)));
}
}
Survival
Stats live in PlayerStats.instance. Mods reach them through this facade and never touch the game objects.
Game-facing paths never throw into the game. A call that needs a hook group which is missing returns its default (0, false, or no-op). Survival.IsAvailable is true only when every group is installed. A throwing mod handler is logged and the next handler still runs. Argument validation throws to the caller, even when IsAvailable is false: an empty modId throws ArgumentException, and a null callback or handler throws ArgumentNullException.
public enum Stat
{
Hunger = 0, Thirst = 1, Bowel = 2, Bladder = 3, Tiredness = 4, Hygiene = 5, Mood = 6, Health = 7,
Bleeding = 8, Alcohol = 9, High = 10, Smv = 11, Radiation = 12, SaunaHeat = 13, DuckNeed = 14,
AlcoholAddiction = 15, MushroomAddiction = 16, SmokingAddiction = 17, GamblingAddiction = 18,
AlcoholNeed = 19, MushroomNeed = 20, SmokingNeed = 21, GamblingNeed = 22
}
public static partial class Survival
{
public static bool IsAvailable { get; } // false until the Survival patches are installed; false when they were refused (`Core.PatchingAllowed` is false) or failed, or when the owner is disabled
public static float Get(Stat stat); // 0 when PlayerStats.instance is null
public static bool Set(Stat stat, float value); // clamped to the stat range; false when PlayerStats.instance is null or a save loads or saves
public static bool OnCrossed(string modId, Stat stat, float threshold, Action<Stat, float, bool> callback); // false when IsAvailable is false
public static bool RemoveCrossed(string modId, Action<Stat, float, bool> callback); // true when that mod's watch was removed
public static void RemoveAll(string modId); // removes this mod's decay modifiers, intercepts, damage handlers and OnCrossed watches
public static bool InterceptDamage(string modId, Action<DamageArgs> handler); // false when the damage hook group is not installed
public static bool RemoveDamage(string modId, Action<DamageArgs> handler); // true when that mod's handler was removed
public static event Action<DamageTypeNames> PlayerDied;
public static event Action PassedOut;
public static event Action<float, DamageTypeNames> Damaged;
public static event Action<BreakdownArgs> Breakdown;
public static class Decay
{
public static bool Supports(Stat stat);
public static bool Set(Stat stat, string modId, float multiplier, float offsetPerHour);
public static bool Remove(Stat stat, string modId);
}
public static class Consumables
{
public static void Intercept(string modId, ConsumeHandler handler);
public static bool Remove(string modId); // removes this mod's intercepts; false when it had none
public static bool Remove(string modId, ConsumeHandler handler); // removes one handler of that mod; false when it was not registered
public static event Action<ConsumedArgs> Consumed; // after the game applied a use; not for a cancelled or refused use
}
}
public delegate void ConsumeHandler(ItemData itemData, ItemConsumable itemConsumable, ConsumeArgs args);
public class ConsumeArgs { public bool Cancel { get; set; } }
public sealed class ConsumedArgs { public int ItemId { get; } }
public sealed class DamageArgs { public DamageTypeNames Reason { get; } public bool Silent { get; } public float Original { get; } public float Amount { get; set; } public bool Cancel { get; set; } }
public enum BreakdownKind { Suicide, SelfHarm, Mushrooms, Alcohol }
public sealed class BreakdownArgs { public BreakdownKind Kind { get; } public bool Cancel { get; set; } }
The enum values are fixed. New stats are appended, and existing values do not change.
Stats, the game field each one reads and writes, and the range Set clamps to:
| Stat | Game field | Range |
|---|---|---|
| Hunger | PlayerStatsHungerAndThirst.Hunger | -100..100 |
| Thirst | PlayerStatsHungerAndThirst.Thirst | 0..100 |
| Bowel | PlayerStatsHungerAndThirst.Bowel | 0..100 |
| Bladder | PlayerStatsHungerAndThirst.Bladder | 0..100 |
| Tiredness | PlayerStatsTiredness.currentStatus | 0..100 |
| Hygiene | PlayerStatsHygiene.Hygiene | 0..100 |
| Mood | PlayerStatsMentalHealth.currentStatus | 0..100 |
| Health | PlayerStatsHealth.currentStatus | 0..100 |
| Bleeding | PlayerStatsHealth.Bleeding | 0..100 |
| Alcohol | PlayerStatsAlcohol.AlcoholInBlood | 0..100 (the game does not clamp above 100) |
| High | PlayerStatsMushrooms.MushroomsHigh | 0..100 |
| Smv | PlayerStatsMushrooms.SMVProgression | 0..100 |
| Radiation | PlayerStatsRadiation.Dose | 0 and up |
| SaunaHeat | PlayerStatsSauna.Overheat | 0 and up |
| DuckNeed | PlayerStatsDuck.currentStatus | 0..100 |
| AlcoholAddiction, MushroomAddiction, SmokingAddiction, GamblingAddiction | PlayerStatsAddictions.<name> | 0..100 |
| AlcoholNeed, MushroomNeed, SmokingNeed, GamblingNeed | PlayerStatsAddictions.<name> | 0..100 |
Getreturns the raw game value.Setreturnsfalsewhen the value is NaN,PlayerStats.instanceis null, or a save loads or saves.OnCrossedfirescallback(stat, value, rising)when the value moves from below to at-or-abovethreshold(rising), or from at-or-above to below it (falling). The value is sampled once per game second. Stats change only while the player is active, so no crossing fires while frozen. The first sample after a load sets the baseline without firing.RemoveCrossed(modId, callback)removes the watch thatOnCrossedadded, and returnstruewhen one was removed.Decay.Setuses one entry per(stat, modId). The effective rate for a stat isbase × product(multipliers) + sum(offsetPerHour), wherebaseis the game’s rate for that stat. A secondSetfrom the same mod replaces the first. Multipliers must be>= 0. OtherwiseSetreturnsfalse. ForAlcoholNeed,MushroomNeed,SmokingNeedandGamblingNeedthe effective rate is never below 0: a negative result is clamped to 0, so an offset cannot make a need rise. The game lowers these needs with no upper clamp when the player is not addicted.- For the other stats a negative total rate is allowed and makes the stat move the other way. Hunger and Thirst rise at a positive base rate, so a negative total lowers them. Hygiene falls at a negative base rate, so a positive total makes it rise. Offsets may be negative, so the total can be negative for those stats.
- The game scales the patched rate afterwards, so an offset is not an absolute amount per hour. Awake hunger is multiplied by
(1 - High / 100)and(1 - AlcoholAddiction / 200). Sleeping hunger and thirst are multiplied by 0.33. Sleeping hygiene is multiplied by 0.5. - The four addiction need getters drive both the rise and the fall. The rise is
Addiction × 0.01 × rate / 60(the same form applies to the other needs). The fall israte / 60. A modifier on an addiction need changes both. Decay.Supportsreturnstrueonly for:Hunger,Thirst,Hygiene,AlcoholNeed,MushroomNeed,SmokingNeed,GamblingNeed. These are the stats with a per-hour rate getter that the game uses to change the stat.Consumables.Interceptruns once per consumable use, before the game does anything for that use. The framework dispatches at the use entry point:ItemInfo.UseItem(int, bool)for inventory use, including a drink from a liquid container, andCollectibleItem.InvokeActions()for world use. If any handler setsargs.Cancel = true, the whole use is skipped. Nothing else happens for that use: no cooldown, no sound, no consume events, no meta change, no stat change, no liquid change, no outputs, no item removal or ammo change, noRequiredItemschange and noItemsToSpawn. A use the game would refuse (no use button, cooldown, active trade,CanUsefalse, or an owner permission that denies use) never reaches the handlers.ItemConsumable.Invoke(ItemData, bool)is the fallback for other callers. Handlers run once per use. Handlers share oneConsumeArgsper use. A handler that throws is logged, and the other handlers still run. TheItemConsumableis shared by the game and is never changed by the framework.Consumables.Remove(modId)removes that mod’s intercepts, andRemove(modId, handler)removes one handler of that mod.RemoveAllcalls the first.Consumables.Consumed(ConsumedArgs)is for observing only. It fires once for eachItemConsumable.Invokethe game runs, after the game has applied the effects, removed or used up the item and spawned itsItemsToSpawn.ItemIdis the item’sItem.ID. It does not fire when an intercept cancels the use, when the game refuses the use beforeInvoke(the list underIntercept), or whenInvokethrows. It does not fire whenSurvival.IsAvailableis false. A use that runs more than one consumableInvokefires once perInvoke:ItemInfo.UseItemruns the child item’s actions and then the item’s own action, andItem.InvokeActionsruns each action of an item. Depression-forced uses fire it too. Handlers are isolated from each other. Unsubscribe with-=;RemoveAlldoes not remove them. The payload has no stat deltas.InterceptDamage(modId, handler)runs before the game applies health damage, in the order the handlers were registered. The handler gets aDamageArgsholding the amount the game asked for (Original), the working amount (Amount), theReasonandSilent, and it can changeAmountor setCancel = true. ACancelfrom any handler wins, and the later handlers still run and see it. Handlers do not run while a save loads or saves, for an amount of 0 or less, or from inside another damage handler.RemoveDamage(modId, handler)removes one handler of that mod, andRemoveAllremoves them all.Damaged(lost, reason)fires whenPlayerStatsHealth.DamagelowerscurrentStatus.lostis the amount actually removed, after the clamp at 0. Bleed damage raisesBleedingand not health, so it does not fire. Mushroom and gas protection return before the health change, so they do not fire.PassedOutfires whenWaitingController.PassOut(..., Type.Passout)changeswaitingTypetoPassout. It does not fire for a refused call, for a call that starts normal sleep (Sleep, which setsSleeping), forType.Arrest, or for a call made while the player is already passed out.Breakdownfires when the game starts a breakdown step, with aBreakdownArgswhoseKindisSuicide,SelfHarm,MushroomsorAlcohol. SetCancel = trueto stop that step, and aCancelfrom any handler wins. The later handlers still run and see it. Handlers do not run while a save loads or saves, and the step goes ahead.
Open questions:
- The cancel path at the entry points (
ItemInfo.UseItem,CollectibleItem.InvokeActions).
Not supported:
DuckNeeddecay. Its base-rate getter only sets the level when the player has enough ducks, so it is not a rate.- Decay for the addiction levels (
AlcoholAddictionetc.),Bowel,Bladder,Health,Mood,Tiredness,RadiationandSaunaHeat. Hunger decay also scalesBowelbuild-up, and thirst decay scalesBladder. - Scaling of item effects.
Interceptcan cancel an item use. It cannot change an effect’s size. - A HUD for custom stats.
PlayerDied,PassedOutandDamagedare plain C# events, soRemoveAllcannot remove their handlers. Unsubscribe with-=.- A depression-forced use calls
ItemConsumable.Invokedirectly, so only theInvokefallback applies. A cancel skips the item’s effects and its removal, and the item stays in the inventory.ItemInfo.Activatestill runs.Depressionstill returnstrueafter a cancel, so the game’s mental health code skips its fallback. The game treats the use as handled.
using System;
using BepInEx;
using BepInEx.Unity.Mono;
using ObenseuerCore;
public class SlowHunger : BaseUnityPlugin
{
private static readonly CoreLog Log = CoreLog.For("SlowHunger");
private void Awake()
{
Survival.Decay.Set(Stat.Hunger, "example.slowhunger", 0.5f, 0f);
Survival.OnCrossed("example.slowhunger", Stat.Hunger, 80f, (stat, value, rising) =>
Log.Info(stat + " crossed 80, rising " + rising + ", value " + value));
Survival.PlayerDied += reason => Log.Info("player died, reason " + reason);
}
}
The example is Examples\SlowHunger\.
CameraView
First and third person, driven through the game’s own ThirdPersonCameraCollision state. Nothing moves a camera by hand.
public static class CameraView
public static bool IsAvailable { get; } // a ThirdPersonCameraCollision exists (false on the main menu)
public static bool IsFirstPerson { get; } // the game's firstperson flag
public static bool ThirdPersonAllowed { get; } // false with no camera, while a PlayerCameraTrigger forces first person, or while disabledZoomList is not empty
public static bool SetThirdPerson(float distance = 2.5f); // false when not allowed. Sets currentDistance, clamped to minDistance..maxDistance
public static void SetFirstPerson(); // currentDistance = 0 and FirstPerson() now
public static bool Toggle(); // third person when first, first person otherwise. false when not available or refused
public static event Action<bool> ModeChanged; // true = first person. Forwarded from ThirdPersonCameraCollision.CameraModeChange
- Vanilla 0.5.08 is first person only. Third person is a dormant game mode, so the player body, animations and clipping may look rough.
SetThirdPersonclearsdisableThirdPersonandfirstpersonOnlyand keeps the vanilla values, soTweaks.Cameracan put them back. The game’sFixedUpdatethen callsThirdPerson()on the next physics step.ModeChangedfires then, not inside the call.- A wall closer than about 0.6 m keeps first person, because the game’s collision sets
distancebelow 0.5. SetFirstPersondoes not change the camera rotation. The controller’srotX/rotYstay as they are, so the view direction does not jump.- A forced zone is detected by a postfix on
PlayerCameraTrigger.FirstPersonOnly(bool). A trigger counts as forcing from itsFirstPersonOnly(true)call until itsFirstPersonOnly(false)call, or until it is destroyed. A trigger whose GameObject is only disabled, with nofalsecall, stays counted. No game code callsPlayerCameraTrigger.FirstPersonOnly, so how a zone is entered and left is set in the scene and not verified. - If the postfix cannot be patched,
ThirdPersonAllowedstays false. Third person is then refused, never forced. firstpersonOnlyalone is not a forced zone.ThirdPersonCameraCollision.Startsets it fromdisableThirdPersonfor the whole game.- Save: the game saves
firstperson,currentDistance,disableThirdPersonandfirstpersonOnly(the public, non-fsIgnorefields in gameSaveController). A save made in third person stores the cleared flags, so loading it restores them. - A new
ThirdPersonCameraCollisionrunsStartagain, which sets the first-person lock again. WhileThirdPersonToggleorAllowScrollZoomis on, the lock is cleared again on the next frame. Not verified thatStartruns on every scene load. - Null-safe: with no camera (main menu) the properties return false and the methods return false or do nothing.
- Hand items:
PlayerHandItems.CameraModeChangeis subscribed to the same static event, andFirstPerson()/ThirdPerson()fire it. Going through them keeps the held items right.
Tweaks
Built-in gameplay tweaks. They have no C# API. Settings live in BepInEx\config\obenseuer.moddingframework.cfg, one section per tweak, and in the in-game config window (BepInEx ConfigurationManager, F1).
- Every tweak is off by default: multipliers are
1, bools arefalse. - Changes apply live from the in-game config window. A tweak’s patches are installed the first time its value becomes non-default, and stay installed. At the default, a patch does nothing and returns the game value unchanged.
- Multipliers are clamped to
0.01to100. NaN becomes1. Each clamp is logged toBepInEx\logs\obenseuer.moddingframework.tweaks.log. - Each change is logged once as
tweak <Section>.<Key>: <old> -> <new>. A change is logged after its value has been still for 0.5 s, so a slider drag logs one line. A patch install logstweak <Section> patchedortweak <Section> patch not applied. - Patches are owned by
obenseuer.moddingframework.tweaks. A failed tweak logs and does nothing else.
| Section | Key | Default | What it does | Save caveat |
|---|---|---|---|---|
Tweaks.Needs | HungerDrainMultiplier, ThirstDrainMultiplier, HygieneDrainMultiplier, AlcoholNeedDrainMultiplier, MushroomNeedDrainMultiplier, SmokingNeedDrainMultiplier, GamblingNeedDrainMultiplier | 1 | Scales the stat’s base decay rate through Survival.Decay, set or removed live (no patch). For the four addiction needs it scales both the rise and the fall. | Not saved. |
Tweaks.Stacks | StackMultiplier | 1 | Scales the stack size of items that stack (above 1). Liquid capacity per slot scales too. | Stacks over the limit stay over it when the value is lowered or set back to 1, until that slot is filled again. |
Tweaks.Spoilage | SpoilageMultiplier | 1 | Scales the rate items spoil at. | Decay already applied is saved permanently. |
Tweaks.CraftingSpeed | CraftingSpeed | 1 | 2 is twice as fast. Scales manufacturing craft time and blueprint craft time. Below 1 the blueprint wait speed-up is slower than its target: the craft ends at the scaled length, but the speed-up uses the unscaled time. | A craft started with a value keeps its scaled craft length after the value changes. Manufacturing crafts are checked against the current length, so raising the value mid-craft can finish it at once, lowering it can make its progress jump, and changing it between sessions does the same. |
Tweaks.DishwasherSpeed | DishwasherSpeed | 1 | 2 is twice as fast. Scales dishwasher recipe process time, for recipes generated from now on. Stacks with CraftingSpeed. | Not saved. |
Tweaks.NoMinimumSkill | NoMinimumSkill | false | Manufacturing only. Lifts the minimum skill gate, including HardMinimumSkill, and removes the craft time penalty for low skill. Recipes whose craft roll always fails stay disabled. Skill still affects fail chance and quality. Blueprints are not covered. Turning it off while a station is open takes effect only when the recipe list refreshes; the craft path has no skill check of its own. | Not saved. |
Tweaks.Renovations | CostMultiplier | 1 | Scales the OC first payment and the RM final payment. Rounded to whole units. | Not saved. Orders already placed keep their saved progress. |
Tweaks.Renovations | MoneyOnly | false | Renovations need only money. The item requirement is skipped for the upgrade check and removal. A mod that takes renovation items itself does not read this flag: its prefix on TenementController.RemoveRequiredItems still runs, because HarmonyX runs every prefix, and it may still take items. See TweakState. | Not saved. |
Tweaks.Renovations | BuildTimeMultiplier | 1 | Scales the build time when an order is placed, and the displayed total. Rounded up to whole hours for the total. | Not saved. Changing it while an order is placed can leave that order’s progress wrong. |
Tweaks.Renovations | InstantBuild | false | Places renovation orders with zero build time. Scripted TenementUpgradeStart orders keep their authored time. | Not saved. |
Tweaks.Renovations | TakeRemainingOnly | false | A renovation takes from each tenement storage only the items it still needs, and only the rest from the player, instead of asking every storage for the whole amount. Fixes a game bug that can take too many items. Independent of MoneyOnly; with MoneyOnly on it does nothing, because the whole item step is skipped. | Not saved. |
Tweaks.Camera | ThirdPersonToggle | false | Turns on the ToggleKey switch between first and third person. Off clears the toggle’s lock and goes back to first person when the game’s first-person lock was on. | Not saved by the framework. The game saves firstperson and the cleared flags, see CameraView. |
Tweaks.Camera | ToggleKey | F6 | Switches the view while ThirdPersonToggle is on. Ignored while a game menu, shop, pause, dialogue, the console or game controls are active. V is bound to “Rotation Mode”, so it is not the default. | Not saved. |
Tweaks.Camera | AllowScrollZoom | false | Clears the first-person lock so the scroll wheel moves between first and third person. Restored to the game’s values when it and ThirdPersonToggle are both off. Not applied while a trigger forces first person. | Not saved by the framework. A save made while it is on stores the cleared flags. |
Tweaks.Time | TimeScale | 1 | 2 makes the game clock run twice as fast. Need drain, spoilage, craft timers and renovation timers all run on game time, so they speed up too. If Mono inlines AzureTimeController.SetNewDayLength, this does nothing. Check that the clock speeds up after setting it. | Not saved. The saved time of day is unchanged. |
The NPC route timing and the blueprint wait speed-up still use the unscaled day length.
TweakState
public static class TweakState
public static bool RenovationsMoneyOnly { get; } // Tweaks.Renovations MoneyOnly, the value the game sees
A mod that takes renovation items itself must check this in its own TenementController.RemoveRequiredItems prefix: when it is true, return false early, before taking anything. HarmonyX still runs every prefix, so the check belongs in that mod’s code. The framework’s own TakeRemainingOnly fix also runs on that method, installed as a prefix last, and TweakState does not report it: with that tweak on, the framework takes the remaining items and skips the original, and a mod’s own prefix at the default priority still runs first.
Read the flag from a helper, and call the helper from the prefix inside try/catch. An older framework version without the member fails when the code that reads it is compiled. That is a TypeLoadException or a MissingMethodException, and it is thrown at the call into the helper. A try/catch inside the helper does not catch it (checked with a two-version probe on .NET Framework).
private static bool MoneyOnlyOn()
{
return ObenseuerCore.TweakState.RenovationsMoneyOnly;
}
private static bool MyPrefix()
{
try
{
if (MoneyOnlyOn()) return false;
}
catch (Exception)
{
}
return true;
}
ModMenu
public static class ModMenu
{
public static void AddPage(string modId, string title, Action<RectTransform> build);
public static ModMenuWidgets WidgetsFor(RectTransform content); // null when content is not a page's build target
}
ModMenuWidgets (what WidgetsFor returns) is obsolete ([Obsolete("Use ObenseuerCore.Ui.ModUi")]). It still works, but new pages should build with ModUi. A mod that uses it gets compiler warning CS0618.
The Mods menu opens as a web page by default ([UI] ModMenuStyle = web). Each plugin with visible config entries gets one “Settings” page. Classic AddPage pages are added under their mod id, or as a new entry named after the page’s title when no plugin has that mod id. The classic window opens when [UI] ModMenuStyle is classic, or when the web page fails to start. If the page does not say hello within 8 s twice in a row, the menu stays classic until the game restarts.
The framework itself gets a Settings page like any plugin, and it is the one page that also carries actions. The framework’s page has a Scene looks section with a Run scene looks checks button: clicking it runs the check for the loaded scene and shows the result on the menu status line. The classic window shows the same button under the framework’s group, with the result next to it. The action needs no [Debug] flag.
The classic Mods menu is one ModWindow in the Full dock. Full is used because the Center dock (640 px) cannot hold the sidebar and the settings rows. The window’s Side column is a sidebar with one button per page, and Body shows the selected page. The sidebar lists “Mod Settings” first (the framework’s config editor), then every added page sorted by title. AddPage adds a sidebar entry only. It never adds a game tab. The sidebar is read when the window opens, so a page added while the window is open appears the next time it opens.
ModMenu.AddPage pages are classic only. The page’s build callback runs in the classic uGUI window, whether the player opens the page from the classic sidebar or from the web menu. The web menu opens the classic pane for a page it does not render itself, with a Back to Mods button. A page never renders as HTML. A mod that needs a rich control, such as a searchable dropdown, a colour picker or multi-line text, builds that in the WebUi menu instead (see the WebUi menu). It keeps the classic page to labels, buttons and the ModUi kit.
Entry points:
- In game: the Mods tab is one entry in
UIPageButtons.MenuPages. Its button opens the window, and arriving at the tab opens it too. Closing the window while the tab is selected returns the pause menu to the page it came from. The tab’s id is the first unusedMenuPageNamesvalue aboveMenuPageNames.Items, checked againstMenuPages, so two mods never get the same id. - Main menu: a “MODS” button sits under the main menu grid, in the row below “???”. It opens the same window.
- The tab is built once
UIPageButtonsandPauseMenuexist, and again when the game recreates them. A menu whose tab failed to build is not retried for that menu instance. There is no runtime removal. - The game’s bumper cycling uses a fixed list of six names (
_tabs), and the framework does not change that list. Mouse and click reach the tab.
Keys: Esc closes the window (the game’s Pause Menu key), and so does Joystick1Button1, unless a text field or key row is capturing. Esc pressed while a text field has focus, or while a key row is capturing, is used by that field or row and does not close the window. When a back action is registered, which the web menu does for its classic pane, Esc and Joystick1Button1 run that action first and the window stays open.
Controller and keyboard navigation: the sidebar tabs, the search box, every control of every row and the footer buttons form one ordered focus list in visual order, which is wired into Selectable.navigation in Explicit mode. The game’s own input module keeps the left stick and the arrow keys. Up and down move along the current column, and both wrap at the ends. Left and right step a focused slider or stepper, and otherwise move between columns. The framework adds the d-pad (the DPadHorizontal and DPadVertical axes that the game’s own menus read), the bumpers (Switch Tab Left and Switch Tab Right) for the sidebar pages, and Enter or A on a focused slider, which steps it by 5% of its range. A focused button, toggle or text field is activated by the game’s Submit key. The footer shows these keys, with the A and B sprites from InputManager.inputImages when the player uses a controller.
Pages:
- A page’s
buildreceives the page’s content area. It runs when the page is selected, so the rows are built once per page open. The content is cleared before each build. - If
buildthrows, the page shows the notice “This page failed to load. Details are in BepInEx\logs\obenseuer.moddingframework.ui.log”, the error is logged there, and the window stays open. The other pages still work. - Controls from
ModUirun their callbacks underModWindow.Run. A callback that throws closes the window with reasonFault. This is the kit’s rule for every window, not a page rule.
Mod Settings:
- Lists every ConfigEntry of the loaded plugins, grouped by plugin (the first group starts expanded, the others collapsed) and then by section. Rows are sorted by section and key.
- A search box at the top filters the rows by plugin, section or key. While a search is active, every group is open. Row controls: bool is a toggle (
ModUi.Toggle). An enum, and a list of acceptable values (AcceptableValueList<T>), is a stepper (ModUi.Stepper). A[Flags]enum is a text field. A KeyboardShortcut is a key binding (ModUi.KeyBinding): click it, then press a key. Ctrl, Shift and Alt held at the press are kept. Esc cancels, and mouse buttons are ignored. A ranged int or float is a slider (ModUi.Slider). A string, and any other type that converts from text, is a text field (ModUi.TextField). Other types are read-only. A row shows its description under the label, and the same text is still the hover tooltip. A row whose value differs from its default shows an accent dot and a Reset button; a row at its default shows neither. Entries that ConfigurationManager marksBrowsable = falseare not listed. - Saving: toggles, steppers, key bindings and text fields write the ConfigEntry and save at once. A text field writes when its edit ends (Enter, or a click away), not on each key. Sliders write at once and save 0.25 s after the last change. Quitting to the desktop from the pause menu or the main menu saves a change that is still waiting.
Lock and inventory:
- While a text field has focus, or a key row is capturing, the game’s controls and keys are locked and its controller update is skipped. The lock is released when the field loses focus or the capture ends.
- A custom page does not reach the inventory: while one is selected, the inventory’s tab switch and its per-frame input (Drop, Move Item, Use) do nothing.
Open questions:
- The in-game Mods tab: the window opens on arrival, closing it returns to the previous page, and the pause menu does not react to keys while the window is open.
- The main-menu MODS button: placement under the grid, the click, left and right navigation to Multiplayer, and the navigation restore on close.
- Esc and Joystick1Button1 closing, Esc inside a text field and inside a key capture, and that the Pause Menu does not open on the same Esc press.
- Search, grouping, every row control, the 0.25 s save, and Reset.
- The page failure notice, and a throwing callback closing the window with reason Fault.
Config [UI] ThemeConfigManager = true gives the ConfigurationManager (F1) window a dark theme. It takes effect on the next start. The framework patches the private OnGUI of ConfigurationManager through CorePatcher: a prefix swaps GUI.skin for a dark skin built from the menu palette, and a finalizer restores the previous skin. Without ConfigurationManager, nothing is patched.
MainMenuButtons
MainMenuButtons.Add(string id, string label, Action onClick),MainMenuButtons.SetControlsEnabled(bool enabled)andMainMenuButtons.IsOpenadd a button to the main menu row under the grid, next to MODS, and turn the menu’s own controls off and on. Buttons from other mods in that row are kept clear. Details: docs/api/mainmenu.md.
ComputerApps
Apps on the in-game computer desks, a full-screen app window, a typing guard for app inputs, and use prompts for world objects. The desk patches install on the first Register call, not at start-up. IsAvailable is true when Core.PatchingAllowed is true and the owner has not been disabled.
public static class ComputerApps
{
public static bool IsAvailable { get; }
public static event Action<ComputerAppClosedArgs> AppClosed;
public static event Action<RectTransform> ScreenChanged;
public static RectTransform ActiveScreen { get; }
public static bool Register(string modId, string appId, string title, Sprite icon, Action<RectTransform> open);
public static void Close(string appId);
public static void RemoveAll(string modId);
public static bool OpenStandalone(string modId, string appId);
public static bool AddInteraction(GameObject target, string modId, string title, string message, Action onUse);
}
public sealed class ComputerAppClosedArgs
{
public string ModId { get; }
public string AppId { get; }
}
Desk apps:
Registeradds an icon to every desk, both the desks that exist now and the ones created later.titlemust not be empty.iconmay be null, and then the title is drawn on the tile. It returnsfalsewhen the desk patches cannot be installed (CorePatcherrefuses patches whenCore.PatchingAllowedis false), or whenappIdis already registered. The first registration wins, and a warning is logged for the second. It throwsArgumentExceptionfor a null or emptymodId, andArgumentNullExceptionfor a nullopen. An emptyappId, anappIdthat contains/, or an emptytitlereturnsfalseand logs once.- Icons form a grid at the top right of each desk: 84 x 72 tiles, 5 rows, columns 96 px apart, rows 84 px apart, 12 px margin. An icon is created once per desk and app, and it is reused after that.
- A click calls
open(host).hostis aRectTransformthat fills the desk’s wallpaper, one per desk and app. The framework clears the host before everyopencall: each child is renamed, deactivated and destroyed. Soopenalways builds your UI into an empty host, the same asOpenStandalone. Anything kept in the host’s children is gone on the next click, so keep your state in your mod. - An exception in
openis logged to the app’s mod log, and the host is hidden. - The host must sit under a
GraphicRaycaster, or its widgets receive no clicks. The desk logs one warning when none is found above the host. Each time an app is shown, the framework makes sure an EventSystem exists. - Apps hide when the player leaves the computer.
Close(appId)hides the app on every desk, and it closes the full-screen window if that window shows the app.Closealways runs, also while a save loads or saves. Each close raisesAppClosed(below). AppClosed(ComputerAppClosedArgs:ModIdis the mod that registered the app,AppIdis its id) is raised once for each view that closes. A desk host raises it when it was showing andClose(appId), the leave-the-computer hide (HideAll), a single scene load (not an additive one),RemoveAllfor its mod, or anopenthat throws hides it. A host that was already hidden does not raise it. The full-screen window raises it when it closes: its Close button, Esc, a single scene load,RemoveAllfor its mod, orOpenStandaloneof another app.AppClosedfires for both the desk host and the full-screen window, and the args carry onlyModIdandAppId, so a handler cannot tell which view closed. A handler that must tell them apart has to track its own view. Handlers are isolated from each other, and unsubscribe with-=.- While an input field inside an app host has focus, the desk’s own key handling is skipped, so typing does not leave the computer.
ActiveScreenis the deskRectTransform(the monitor’s wallpaper) of the computer the player is using now, or null. It is set when the player starts using a computer (InteractableComputer.Use) and cleared when the use stops (StopUse) or a single scene load clears it.ScreenChangedis raised with the new value on each change, not on every frame. AWebViewcan follow it withPinTo(see docs/api/webui.md).
Full-screen window:
OpenStandaloneopens the app over the game. It uses a ScreenSpaceOverlay canvas at sort order 500, a CanvasScaler at 1920x1080 (match 0.5), a GraphicRaycaster, and a Close button at the top right. The game’s controls are locked withControlsDisabled, and the cursor is shown. It changes no saved state, so it does not check the save gate.- The window creates an EventSystem with a StandaloneInputModule when the scene has none. The last window to close destroys it, if the framework created it.
- It returns
falseand logs a warning when the app is unknown, or when the game is not ready or is busy (a menu, shop, pause, dialogue or the console is open). It returnstruewhen that app is already open. - The window closes on Esc, on its Close button, on a single scene load (not an additive one), and when the window throws. Esc does not close it while an input field in it has focus or a key row is capturing: that Esc belongs to the field or row, and the desk computer and the Pause Menu ignore it too. Otherwise Esc closes the window and does not open the Pause Menu on the same press. The close runs after the game’s
Update, while the controls lock still holds.
World objects:
AddInteractiongivestargetthe game’s use prompt. It returnsfalsewhile a save loads or saves.targetneeds aCollider, and the game’sInteractObjectsmust exist. It returnsfalsewhen patching is not allowed (Core.PatchingAllowed), when there is no Collider or interact system, when no usable layer is found, when another mod owns the object, or when the object already has a gameIInteractableMonoBehaviour (any MonoBehaviour that implementsIInteractable, except the framework’s own prompt). The game would show its own prompt in that case, so the mod’s prompt would never appear. A warning is logged once.- Calling it again from the same mod updates
title,messageandonUse. The prompt showstitleand(message), the way the game shows its own prompts.onUseruns on use. An exception inonUseis logged to the mod’s log. - The object keeps its layer when that layer is usable and in
InteractObjects.MaskList. Otherwise the first usable layer of 17, 23, 29, 20 and 15 is used.RemoveAllputs the original layer back. RemoveAll(modId)removes that mod’s apps and icons, closes its window, and removes its interactions.
using BepInEx;
using BepInEx.Unity.Mono;
using UnityEngine;
using ObenseuerCore;
public class NotesApp : BaseUnityPlugin
{
private void Awake()
{
ComputerApps.Register("example.notesapp", "notes", "Notes", null, host => BuildNotes(host));
}
private void Start()
{
GameObject board = GameObject.Find("Whiteboard");
if (board != null) ComputerApps.AddInteraction(board, "example.notesapp", "Whiteboard", "Read", () => ComputerApps.OpenStandalone("example.notesapp", "notes"));
}
private void BuildNotes(RectTransform host)
{
}
}
Open questions:
- Icon placement and draw order on a real desk, and that clicks reach the icons. The desk raycaster warning should not appear there.
- Clicks in the full-screen window, both with a scene EventSystem and without one (the framework creates it).
- Whether the game’s
MaskListincludes the chosen layers at runtime. The check is the same bit test that the raycast uses. - Esc is also the Pause Menu key. The window’s close runs in
LateUpdate, so the game’sUpdatestill sees the lock and returns before its Pause Menu check. - Movement and cursor state after the window closes while the game UI is open.
AddInteractionon an object that already has anotherIInteractablereturnsfalse.
PlayerLook
The player’s look as plain data, and the game’s own path for applying it.
public static class PlayerLook
{
public static event Action<Look> Changed;
public static bool IsAvailable { get; }
public static Look Capture();
public static Look Capture(Appearance appearance);
public static bool Apply(Look look);
public static bool Apply(Look look, Appearance appearance);
public static bool Live(Look look);
public static bool Live(Look look, Appearance appearance);
public static Look Randomise();
public static Look Randomise(Appearance appearance);
public static Look Vanilla();
public static IList<string> Options(string slot, bool male);
public static IList<string> Options(string slot, bool male, Appearance appearance);
[Serializable]
public sealed class Look
{
public bool set;
public bool male;
public bool advanced;
public float height = 0.8f;
// Public fields, see the list below. Dials are floats, and the wearables are strings.
}
}
Look fields, all public: set, male, advanced, height (0.8), shape, gender, headSize (1), faceSize (1), neckLength, shoulderWidth, breastSize, hipWidth, handSize (1), footSize (1), lipThickness, jawPosition, chinPosition, eyeSize (1), eyeDistance, eyePosition, eyeDepth, earSize (1), earRotation, noseRootRotation, noseTipRotation, noseWidth. The floats default to 0 unless shown. The strings are head, hat, hair, glasses, beard, shirtT, shirtB, pantsT, pantsB, socks and shoes. They hold wearable asset names, and "" means none.
Capture()returns the player’s look, ornullwhenIsAvailableis false.Capture(appearance)reads any Appearance. A look fromCapturehassettrue.Apply(look)sets the player’s look. Each dial is clamped to its range: Height 0 to 1, Shape -1 to 1, and every other dial 0 to 1. A new costume is built at runtime, so the game’s shared Random NPC asset is never written. The game’sLoadCharacterthen rebuilds the body and clothes, andChangedfires. It returnsfalsewhen not available, or when the game throws (the error is logged).- Wearables are matched by name within the list for the look’s gender. A name that is not found clears the slot, except Head, which falls back to the first head of the gender. A female beard is matched by name in the same full list as a male one, because the game’s editor offers every beard to either gender. Leggings are matched by name.
Apply(look, appearance)does the same for any Appearance, withoutChanged.Live(look, appearance),Randomise(appearance)andOptions(slot, male, appearance)are the player versions for any Appearance, and they do not fireChanged.LiveandRandomisereturnfalseornullwhen the Appearance is not ready, andOptionsreturns an empty list then.Live(look)sets the dial values (body and face) on the player and callsUpdateShapes. Clothes do not change. The clamps apply, andChangedfires.Randomise()captures the current look, rolls every dial except Height, rolls each wearable (Leggings and a female beard take their first entry, as the game’s Random NPC rule does), applies the result, firesChanged, and returns the new look.Vanilla()returns a new look made with the game’s own random NPC rules (Appearance.LoadCharacterValues): male and advanced by a coin toss, Breast size 0 to 0.3 for male and 0.3 to 1 for female, Gender rounded to 0 or 1, and Height included. It does not apply the look.Options(slot, male)lists the wearable names of a slot for that gender, in the game’s order. The slots areHead,Hat,Hair,Glasses,Beard,Jacket,Shirt,Pants,Leggings,SocksandShoes. A female beard gives the same full list as a male one. An unknown slot throwsArgumentException. When not available, it returns an empty list.Changedfires afterApply(look),Live(look)andRandomise(), and it receives the captured look. The Appearance overloads do not fire it. That look is null when the capture fails. A handler that throws is logged, and the other handlers still run.- Gender rules: the Head list follows
male. A female beard is not pinned inOptionsorApply, and onlyRandomiseandVanillaroll it as its first entry. Breast size is not limited by gender inApply,LiveorRandomise:Randomiserolls it over 0 to 1 for either gender, and keeps the capturedmaleandadvanced. OnlyVanillauses the game’s gender ranges (male 0 to 0.3, female 0.3 to 1). - Nothing is saved. Store a
LookwithSaveData.Registerif you need it. - A null
lookorappearancethrowsArgumentNullException, except thatOptionsreturns an empty list for a nullappearance. Game errors are logged toBepInEx\logs\obenseuer.moddingframework.playerlook.log, and the call returnsfalseornull.
Debug check:
[Debug] PlayerLookSelfCheck = falseinBepInEx\config\obenseuer.moddingframework.cfg. When it is true, the framework runs the check at Awake and logsplayerlook self-check ok, orplayerlook self-check failed: <check>, toBepInEx\logs\ObenseuerCore.log. The check covers the dial clamps and the wearable rules, on temporaryWearableassets. It does not touch an Appearance.
using BepInEx;
using BepInEx.Unity.Mono;
using ObenseuerCore;
public class LookTweak : BaseUnityPlugin
{
private void Awake()
{
PlayerLook.Changed += look => Logger.LogInfo("look changed");
}
private void Start()
{
PlayerLook.Look look = PlayerLook.Capture();
if (look == null) return;
look.hat = "";
PlayerLook.Apply(look);
}
}
Open questions:
Apply,LiveandRandomiseon a running player: the body and clothes afterLoadCharacter.IsAvailableon the main menu and during scene changes.
UI kit (ModUi, UiTheme, UiSprites): see api/ui.md.