Skip to content

Schedule I

Platform ID com.tvgs.scheduleone

DigitalHeaven.Mods.ScheduleOne Unity Mono is a BepInEx 5 mod that brings DigitalHeaven avatar replacement to Schedule I.

Mod loaderBepInEx 5
Game versionMono (alternate branch on Steam)
Rendering pipelineURP
Humanoid rigFull Unity Avatar rig (17 required bones)

This mod depends on the DigitalHeaven.Unity library for model loading, rig setup, and material creation.

FeatureStatus
OverlayYes
DigitalHeaven map visitsYes
Voxel volumes in mapsUntested
DigitalHeaven noclipUntested
Avatar replacementYes
Spring bonesYes
Spring collidersUntested
Eye look and blinkNative targets

The avatar follows the game’s character through Unity’s humanoid muscles, with the shared HumanoidPoseMirror. The game’s Mixamo rig maps no jaw, so the avatar’s Jaw is kept out of the transfer and stays closed. The [PoseMirror] log line on the first mirrored frame says so, with how far the transfer had turned the jaw.

FeatureStatus
Avatar syncYes
Pallet transfersYes
NPC replacementYes
Lobby expansionYes

Multiplayer avatar sync includes automatic pallet transfers. When you see another player’s avatar, any missing pallets (including dependencies) come from that player over the game’s network, through the shared transfer stack described in sharing pallets with other players.

The Schedule I host includes the map visit lifecycle and a Maps overlay window. The shared Unity content bridge reads the selected compiled map and builds it into an isolated additive Unity scene.

The scene keeps the map’s source hierarchy, the way Unity itself holds a scene: one GameObject per node of the map’s geometry, with its own local transform, and every mesh node with its own mesh, renderer and MeshCollider. A large map therefore becomes thousands of ordinary colliders rather than one multi-million-triangle mesh. Map objects are marked static, and each material is created once and shared by every node that uses it, as are the textures it samples. A material’s uvScale tiles every texture it binds, so an ambientCG surface authored at [0.33, 0.33] repeats as authored instead of stretching one tile over the whole mesh. The map’s lightmap, GI volume and reflection cubemaps are not read from the pallet at all, since the Unity bridge applies none of them.

Collision follows what the map says:

Map contentIn Schedule I
autoCollision: "mesh" (the default)Each mesh node collides with its own triangles. Skybox material slots never collide.
A part removed or disabled, or its dh.collider state set to off or trigger, by a part overrideNo collider. When the map ships a compiled collision cook, a node the cook dropped gets none either.
autoCollision: "boxes" or "hulls"The compiler’s baked boxes and hulls, with no mesh colliders.
A dh.collider on a part, a brush or a plain objectIts box, sphere, capsule and hull shapes, as authored, in the owner’s frame.
boxBrush entitiesOne GameObject per brush, meshed exactly as the engine meshes it and drawn with the map’s materials, with a BoxCollider. A brush whose dh.collider state is off or trigger gets no collider, and a disabled brush is not built.
Object instance records (props)Drawn from the object each one names, each object uploaded once and shared by all its instances. Only an object with a dh.physicsProp body collides, with a static box; imported Source props carry no body and have no collider. See Unity → Maps.

Authored part overrides are applied through the same resolvers the engine uses (DigitalHeaven.Content.Patches), and on the engine’s terms: each override is about one part, never its subtree.

  • A part an override removes ("removed": true) or switches off ("enabled": false) is not built. No mesh is uploaded and no material, texture or collider is created for it. If something under it is still kept, it stays as an empty GameObject that its children hang from, and those children still draw.
  • An override’s position, rotation and scale move that part alone, about its compiled pivot. Its children keep their own world poses.
  • An override’s name becomes the GameObject’s name.
  • If the map’s part table no longer matches its model (a stale build), no override is applied, every part draws where it was built, and the log says so. An override that no longer resolves is skipped with a warning, and the log reports a count of each kind of override applied.
  • A removed part is never decoded: the map’s model is read with that part left out, so none of its vertex data is read from the pallet. A part switched off is still decoded and then dropped, since a switch is a state rather than a removal.

What is not recreated yet, and is reported in LogOutput.log when a map uses it:

  • A trigger is not a trigger: it simply has no collider. This holds for brushes too, so a trigger brush’s outputs never fire.
  • Water brushes are ordinary solid brushes; there is no swimming.
  • Water is drawn on a clone of Schedule I’s own ocean material (Stylized Water 2), tuned toward the DH water block’s color, clarity and foam. Its waves are flattened, and the DH block’s waves, flow and optics are not carried over. See Unity → Water.
  • Lighting, lightmaps and entities other than spawn points, box brushes, object instances and voxel volumes. An object instance parented to another entity is not built.
  • A node transform with a shear, which Unity cannot hold, takes the closest pose.

StaticBatching hands the finished map to Unity’s runtime static batching. It is off by default: it trades memory for fewer draw calls and has not been measured on a large map.

The window shows, from top to bottom:

  • Current map: the map you are standing on and its placement, or Not visiting. It is separate from the status note underneath, which reports the last thing that happened.
  • The map dropdown. Maps are named by their file name alone, without pallet, folders or extension (debug-map, not core:maps/debug-map.dh-map). Two maps with the same name keep their pallet in parentheses, or their folders when they share a pallet too.
  • Placement and Load Map, while you are in Schedule I. The placement is chosen before loading and is remembered in the config file.
  • Switch to map, while visiting, whenever the dropdown names a different map. It loads the new map, moves you to its spawn and only then unloads the old one, so you never stand on nothing. It keeps your original pre-visit position for the final return, and saving stays blocked across the switch. If the new map fails to load, you stay on the old one.
  • Return to Schedule I, while visiting.

The tab does not blink for a visit or for noclip; those are ongoing states, and the Current map line and the noclip button already show them.

The dropdown lists every map in the compiled pallet directories: your workspace Pallets, any additionalPalletDirectories, and, when enginePath is set in dh-config.jsonc, the engine’s built-in maps (debug-map, bake-eval, …) from the content folder next to it. No copies in the workspace are needed. A map’s core: references resolve against the core pallet found there, and its declared pallet dependencies resolve against the same directories.

A map’s voxelVolume nodes (a Minecraft world, a structure, a .vox model) stream in around you while you visit, through the same streamer the engine runs (DigitalHeaven.Content.Voxels). Nothing is meshed whole: regions near the camera are meshed on worker threads and land a few per frame, and regions you leave behind are dropped.

WhatIn Schedule I
DrawingOne GameObject per region of chunks (2 × 2 × 2 by default) under the map root, with one mesh and four materials: opaque, cutout, translucent, and a magenta checker for blocks nothing resolved. Every material of a volume samples one atlas texture with its own mip chain, point filtered, so texels stay crisp up close, and a block that gives off light (lava, fire, a torch) glows by the atlas’s glow levels, a second texture at the same UVs. Animated block textures (water, lava, fire) play in both, and only on the GPU: every way a tile looks over its cycle is composed once into a frame sheet, and a tile that changes is copied from there over its cells at every mip level, at most once per game tick. A tile with too many looks for the sheet (a slow blend) is composed into a one-cell staging texture and copied from that. Nothing uploads a whole atlas unless the device cannot copy between textures.
CollisionOne MeshCollider per chunk within 32 m of your feet, on the map’s layer and ColliderShape (Convex makes them convex too). A chunk within 4 m is built on the frame that needs it, so you never stand on one still in the queue.
Block textures and shapesFrom the Minecraft platform pallet (com.mojang.minecraft) in your compiled pallet directories. Without it, LogOutput.log says so once, blocks draw as flat colors and collide as whole cubes.
The physics worldA volume’s colliders only stand near you, so its whole extent is checked once, when it opens. A volume reaching past the physics world grows the physics box to cover it; the box is rebuilt only when that makes it bigger.

Streaming follows the engine’s defaults, and [Voxels] in the config tunes the three that matter most on a game’s budget: Radius, MeshBudgetMb and Workers. They apply on the next frame. The drawn look (translucent sorting, the surface shader the game’s pipeline picks) has not been checked in a game yet.

PlacementSchedule I’s worldWhere the map loads
OverlayStays loaded and fully activeWith its origin at OverlayOffsetX/Z and its spawn standing at OverlayOffsetY, by default 46 m up, just above the town
Replace (default)Hidden and not solid; NPCs and traffic paused; the game’s systems keep runningAt the map’s own coordinates

Overlay touches nothing in Schedule I. Walking off the edge of the map drops you back onto the town. The height goes by the spawn rather than the map’s origin because a map’s floor can sit far from its origin: gm_bigcity_improved spawns 282 m below its own, and an origin at 46 m put the whole city under the ocean, whose trigger warped the player back to the town.

Schedule I’s physics runs in a fixed box: its broadphase is multibox pruning over a world of ±250 m in X and Z and ±50 m in Y around the origin. A collider or rigidbody wholly outside that box gets no contacts. Queries still see it, so you can walk on it, but a skateboard, a vehicle or a dropped item passes straight through. An earlier default put Overlay maps 1000 m up, where every rigidbody fell through every wall.

For a map visit DigitalHeaven resizes that broadphase (Physics.RebuildBroadphaseRegions, 16 subdivisions) to the union of the stock box, every map collider and every voxel volume’s whole extent, padded by 16 m, and logs the new box. Return, a map switch, a scene change and quitting put the stock box back. Only if the game refuses the resize does the log and a toast name what lies outside, and a skateboard passes through those parts again.

The town fills almost the whole box horizontally, with colliders reaching about 40 m. So the default Overlay offset is the free level left above it: the map’s spawn at 46 m. Only the map’s lowest few meters are then inside the box. Anything above 50 m, or below the town’s rooftops, is not reliably solid to rigidbodies. That is why Replace is the default: it loads the map at its own coordinates in the middle of the box, so everything of the map that lies within ±250 m and ±50 m is solid.

After every load, the mod checks the map’s colliders against the box. If any lie outside or cross its edge, a warning appears in LogOutput.log, as a toast, and in the Maps window’s status line. It names the counts and the map’s extent. The map is not moved. Replace placement is checked the same way, so a map larger than the box is reported too.

Replace hides all of Schedule I’s world without unloading or destroying anything, so Return can put back exactly what was there. Only the player and what it needs stay: its body and avatar, the camera and what it holds, the skateboard or vehicle in use, the UI and the sun. Each category is handled the way the game tolerates:

CategoryWhat Replace doesReturn
The town (ReplaceRoots, by default Map/Hyland Point)A part that holds nothing live is deactivated whole. Where something live sits (a networked object, a navmesh, a game manager, a saveable or GUID-registered object) it stays active and only loses its renderers, colliders and lightsReactivated
NPCsHidden through the game’s own SetVisible, the call it uses when an NPC goes indoors: the body goes, the navmesh agent stops, so the NPC stays where it stood. Its sight and hearing are switched offShown again where the game wants them
Vehicles, parked and drivingHidden through the game’s own SetVisible, which also turns their physics off, so traffic stops and nothing fallsShown and simulated again
TerrainThe heightmap, trees and grass stop drawing, and the terrain collider is disabledDrawn and solid again
The ocean and pondsRenderers hidden, and the ocean trigger that warps a player back to land is disabled, so a map’s underground below sea level can be reachedBack, with the trigger
Everything else in the sceneIslands, backdrops, distant LODs, props, particles: renderers hidden and colliders disabled. Point, spot and area lights and reflection probes are switched off, so the town’s lamps do not light the map. Loose rigidbodies are made kinematicBack as they were
AmbienceThe town’s audio zones and positional one-shots are muted, along with the sound of hidden NPCs and vehicles. Music and the game’s shared sound pools stay, since the player’s own sounds play through themUnmuted

Every change is recorded, and Return undoes exactly those, ground first: something that was already off before the visit stays off. An NPC or vehicle the game shows during the visit (an NPC leaving a building, a goon or a delivery arriving, a car leaving a parking lot) stays hidden, and Return shows it if the game still wants it shown. NPCs and vehicles that spawn during the visit are caught by a rescan every 2 seconds. LogOutput.log gets one line per category with what was deactivated, hidden, disabled, frozen, paused and muted, the time the hide took, and on Return a line for anything the rescan caught. If none of the roots is found, Replace refuses to load and says so.

What Replace does not do, so expect it:

  • The game’s clock and schedules keep running. A hidden NPC stands still, but its schedule moves on, so after Return some NPCs walk off to where they should be by now.
  • Things other than NPCs and vehicles that appear during the visit, such as an item the game drops, are not hidden.
  • Schedule I’s own fall recovery still applies: a player below a height of -20 m is put back on the stock navmesh, so a map whose floor sits that low cannot be stood on in Replace.
  • The audio zones and one-shots keep their sources in private fields that are read by name; if a game update renames them, that ambience stays audible.
  • Visits are available only after the Main or Tutorial world has finished loading, and not from a vehicle or skateboard.
  • They are disabled when another player is present. A visit that becomes multiplayer ends and returns you.
  • The player’s original position and rotation are restored on return.
  • Every SaveManager.Save overload is blocked from the beginning of a visit until you are back at your original position, so neither a foreign map pose nor a hidden world can enter the save.
  • If Schedule I starts a normal host scene transition, the map is torn down without restoring a pose into the new scene.

The Maps window carries a Noclip toggle that flies the player’s body through walls with the DigitalHeaven engine’s own noclip feel: the stick and view pitch steer, Jump climbs, Crouch descends, Sprint boosts. The flight math is the engine’s, shared through Core, so the two feel the same.

  • It is single-player only, like map visits. With another player in the lobby the toggle is replaced by the reason, and a player joining mid-flight ends it.
  • It is unavailable in a vehicle, on a skateboard, or while the game itself is moving the player.
  • Turning it off hands the body back to Schedule I where it stands, as controllable as before; the player then falls from rest.
  • A scene change or a lost local player ends flight without touching the old player object.
  • There is no keybinding yet.

Config file: BepInEx/config/io.mltn.digitalheaven.scheduleone.cfg

Set your avatar using a barcode — the full pallet.id:path/to/asset identifier from your compiled pallet.

SettingSectionDefaultDescription
PlayerAvatar[DigitalHeaven]""Barcode for the player avatar
NPCAvatar[DigitalHeaven]""Barcode for NPC avatars (empty = disabled)
MaxPlayers[Lobby]4Maximum lobby size when hosting (4–12). Clients adapt automatically.
EyeLook[DigitalHeaven]trueLet avatars look around and blink on their own. The game’s own look targets are followed either way.
Speed[Noclip]12Top noclip fly speed in m/s
Accelerate[Noclip]5Noclip acceleration multiplier
Friction[Noclip]4How quickly flight bleeds to rest; keep it no higher than Accelerate to reach full speed
StopSpeed[Noclip]2.54Friction control floor in m/s
SprintMultiplier[Noclip]2.5Speed multiplier while sprint is held
ToggleKey[Noclip]CKey that toggles noclip outside the overlay (Input System key name); None disables it. Ignored while typing
Placement[Maps]ReplaceOverlay or Replace; the Maps window’s Placement dropdown writes it
OverlayOffsetX[Maps]0Overlay placement: map origin X in meters
OverlayOffsetY[Maps]46Overlay placement: the height in meters the map’s spawn stands at. Physics stops at 50 m, see the physics world
OverlayOffsetZ[Maps]0Overlay placement: map origin Z in meters
ReplaceRoots[Maps]Map/Hyland PointReplace placement: semicolon-separated scene paths whose geometry is hidden
StaticBatching[Maps]falseCombine a loaded map’s meshes with Unity’s runtime static batching; applies to the next visit
ColliderShape[Maps]SharedDiagnostic; applies to the next visit. Shared: each solid node gets a concave collider on its render mesh. UnitScale: the concave collider sits on a child with the node’s world scale baked into its own mesh, so it is cooked at real size. Convex: a convex hull per node, which fills doorways and courtyards
DiagnosticBoxInMapKey[Maps]F7Diagnostic: places a plain static 3 × 3 × 0.5 m cube wall 4 m ahead of you, inside the loaded map’s scene. Ignored while typing; None disables it
DiagnosticBoxInGameKey[Maps]F8Diagnostic: the same wall, left in Schedule I’s own scene. Both walls are removed when the map unloads or the scene changes
Radius[Voxels]128Meters from the camera a map’s voxel volumes stream in (8–4096)
MeshBudgetMb[Voxels]512Megabytes of voxel meshes every volume together may hold; past it the farthest regions are dropped (16–65536)
Workers[Voxels]2Threads that mesh voxel regions (1–16), read when a map’s first volume streams in
UnattendedCollisionRun[Diagnostics]falseDiagnostic: on the next launch, continue the latest save, visit UnattendedCollisionMap in the configured Placement, fire board-like rigidbodies (and a stripped copy of the game’s skateboard) at a map-scene wall, a game-scene wall and the nearest concave map mesh, log one [CollisionRun] line per pairing, and quit. It switches itself back off when it starts
UnattendedCollisionMap[Diagnostics]io.mltn.maps.mltn-city:mltn-city.dh-mapDiagnostic: the barcode of the map the unattended run visits
UnattendedTour[Diagnostics]falseDiagnostic: on the next launch, continue the latest save, visit each of UnattendedTourMaps, photograph it from behind the player and the skateboard dropped beside it, then from a ring of free-camera viewpoints around them (kept inside the physics box the visit resized), into BepInEx/DigitalHeaven/tour/<yyyyMMdd-HHmmss>/<map>-<n>.png, log [Tour] frame times and per-frame cost at each stop and over a ten-second fly-through, and quit. It switches itself back off when it starts; launch the game exe directly, not through steam://
UnattendedTourMaps[Diagnostics]voxel-playground,world-olderDiagnostic: comma-separated maps the tour visits, each a barcode, a display name or a barcode’s asset name (any case). A name that matches nothing logs the maps that are installed and is skipped
UnattendedTourPlacement[Diagnostics]emptyDiagnostic: Overlay or Replace for the tour’s visits; empty uses Placement
[DigitalHeaven]
## Full barcode for the player avatar (e.g. io.example.avatar:avatar.dh-avatar)
PlayerAvatar = io.example.my-avatar:avatar.dh-avatar
## Full barcode for NPC avatars. Leave empty to disable NPC replacement.
NPCAvatar =
[Lobby]
## Maximum lobby size when hosting. Set to 4 for vanilla behavior.
## Clients joining your lobby adapt automatically.
# MaxPlayers = 4

The mod project lives at Platforms/DigitalHeaven.Mods.ScheduleOne/ and is included in the main DigitalHeaven.sln solution under the Platforms folder.

Copy DigitalHeaven.Mods.ScheduleOne.csproj.user.example and remove the .example extension. This file configures your local game install path and is git-ignored:

DigitalHeaven.Mods.ScheduleOne.csproj.user
<Project>
<PropertyGroup>
<!-- Set this to your Schedule I game install path -->
<ScheduleOneDir>C:\Program Files (x86)\Steam\steamapps\common\Schedule I</ScheduleOneDir>
</PropertyGroup>
</Project>

By default, ScheduleOneDir is used to locate both BepInEx assemblies and the game’s managed assemblies. If your setup has these in different locations (e.g. when using an r2modman profile), you can override them individually:

PropertyDefaultDescription
ScheduleOneDir(see .csproj)Base game path. Controls everything if the other properties are not set.
ScheduleOneBepInExDir$(ScheduleOneDir)\BepInExRoot BepInEx directory containing core\ and plugins\ folders.
ScheduleOneManagedDir$(ScheduleOneDir)\Schedule I_Data\ManagedDirectory containing the game’s managed assemblies (Unity engine, Assembly-CSharp, etc.).

For example, with an r2modman profile where BepInEx and plugins live in the profile but managed DLLs are in the Steam install:

DigitalHeaven.Mods.ScheduleOne.csproj.user
<Project>
<PropertyGroup>
<ScheduleOneDir>D:\SteamLibrary\steamapps\common\Schedule I</ScheduleOneDir>
<ScheduleOneBepInExDir>C:\Users\you\AppData\Roaming\r2modmanPlus-local\ScheduleI\profiles\Default\BepInEx</ScheduleOneBepInExDir>
</PropertyGroup>
</Project>

The project references BepInEx DLLs from $(ScheduleOneBepInExDir)\core\ and game assemblies from $(ScheduleOneManagedDir). After building, a post-build target automatically deploys the mod and its dependencies to $(ScheduleOneBepInExDir)\plugins\DigitalHeaven\.

The project targets netstandard2.1 and references both DigitalHeaven.Core and DigitalHeaven.Unity as project dependencies.

Schedule I runs Unity 2022.3 Mono, whose mscorlib already defines Span<T>, ArrayPool<T> and ValueTask. The NuGet polyfills System.Memory, System.Buffers and System.Threading.Tasks.Extensions would define them again and break type identity in game (MissingMethodException in System.IO.Hashing, a Utf8JsonWriter error, a silent config parse failure that falls back to Documents\DigitalHeaven). The project sets DigitalHeavenMonoFacades, so the build and the deploy to plugins\DigitalHeaven\ ship small forwarding facades from Unity/DigitalHeaven.Unity.MonoFacades/ in place of those DLLs, and the netstandard2.1 Microsoft.Bcl.AsyncInterfaces, which is already a facade. After a deploy those four DLLs are a few kilobytes each (System.Memory.dll is about 4.5 KB, not 140 KB). Never copy the NuGet versions into the plugin folder by hand.