← Knowledge base
Locks & Lockpicking (TEFeatureLockable)
locks-lockpicking-tefeaturelockablev1updated 5h ago
Locks & Lockpicking (TEFeatureLockable / TEFeatureLockPickable)
How locking works in 3.0.x and where to patch to bypass it. Verified against
the 3.0.x Assembly-CSharp (2026-07, decomp at
Elevator/.claude/tmp/decomp/); working example: zPhone's
src/apps/God/GodMasterKey.cs ("Master Key" God-app toggle).
Anatomy
The legacy TileEntitySecure* classes are gone in 3.0.x. All lockable
world blocks (doors, gates, garage doors, hatches, safes, storage chests,
signs) are composite tile entities carrying:
TEFeatureLockable : TEFeatureAbs, ILockable— player locks + keypad passwords. State:lockedbool,allowedUserIds,passwordHash(persisted in Read/Write).TEFeatureLockPickable : TEFeatureAbs— POI pick-locks. State:unlockCompletion(0..1) — not persisted, resets on chunk reload. Successful pick callsDowngradeToUnlockedVariant(swaps toLockPickDowngradeBlock, permanent).
Other ILockable implementers (separate code paths, not covered by the
feature patches): TileEntityVendingMachine, EntityVehicle, EntityDrone.
Where activation is actually gated
TEFeatureDoor.OnBlockActivated("open"/"close"): denied whenlockFeature.IsLocked() && !lockFeature.IsUserAllowed(PlatformManager.InternalLocalUserIdentifier). Note: the door'sAllowBlockActivationCommanddoes NOT check locks — only open/close state; the deny is inside OnBlockActivated.TEFeatureStorage.OnBlockActivated("Search"): sameIsLocked() && !IsUserAllowed()conjunction, plusisJammed(quest containers — separate mechanic, not a lock).TEFeatureLockPickable.AllowBlockActivationCommand: whileunlockCompletion != 1f, disables every other feature's commands (storage "Search", door "open") for non-owners — leaving "pick" (orderFirst) as the only tap-E action. This, not NeedsLockpicking(), is the real player-facing lockpick gate.TEFeatureDoor.CanOpen(out canPickToOpen)checksIsLocked()/NeedsLockpicking()but is only called by AI pathing (TraversalProviderNoBreak,EntityMoveHelper), not player activation.- Command enabling is aggregated in
TileEntityComposite: every feature'sAllowBlockActivationCommand(module, commandName, ...)is asked about every command; firstfalsewins._moduleis the feature that OWNS the command being considered, so_module == thisidentifies a feature's own commands. Base impl (TEFeatureAbs) returnstrueunconditionally — skipping the original in a prefix on a subclass loses nothing.
Bypassing locks globally (zPhone Master Key pattern)
Four Harmony patches, all read-through (no lock state mutated, instantly reversible):
TEFeatureLockable.IsLockedprefix →__result = false. Covers door / storage denies, keypad flow, tooltips.TEFeatureLockable.IsUserAllowedprefix →__result = true. Backstop:IsLocked()is a one-line getter the Mono JIT may inline into callers (bypassing the detour), but every player-facing deny isIsLocked() && !IsUserAllowed()and IsUserAllowed is too big to inline.TEFeatureLockPickable.NeedsLockpickingprefix →__result = false(tooltips + AI CanOpen).TEFeatureLockPickable.AllowBlockActivationCommandprefix →__result = !ReferenceEquals(_module, __instance); return false;— hides "pick" and re-enables Search/open, so tap-E opens POI safes directly.
Gotchas:
AllowBlockActivationCommandhas aReadOnlySpan<char> _commandNameparam. A Harmony prefix works fine as long as the patch method does NOT declare the span param (Harmony only injects requested args; ref structs can't be boxed into__args).- Don't set
unlockCompletion = 1finstead of patching — it looks unlocked even after the toggle is switched off, until the chunk reloads. - Patching
IsLockedfalse also affectsCopyFromInternal/UpgradeDowngradeFrom: a locked block upgraded while the bypass is on loses its locked state. isJammed(TEFeatureStorage,PropIsJammed) is a broken-lock quest state, not a lock — the patches above intentionally don't touch it.
Related
- Doors themselves: see "Composite Doors (TEFeatureDoor)" —
SetOpenbypasses locks entirely when driving doors from code. - World time: 24000 ticks/day, 1000/hour, so minutes =
minute * 1000 / 60when callingworld.SetTime.