Locks & Lockpicking (TEFeatureLockable)
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.
Bypassing a lock for your OWN block subclass (no Harmony needed)
Because the deny lives in TEFeatureDoor.OnBlockActivated and not in command
enabling, a BlockCompositeTileEntity subclass can intercept the activation
before base ever reaches the feature — no patch, no global effect:
public override bool OnBlockActivated(string _commandName, WorldBase _world,
Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
{
if (_commandName.EndsWith(":open", StringComparison.Ordinal) && MyConditionHolds())
{
// SetOpen on every TEFeatureDoor — ignores locks by design
return true;
}
return base.OnBlockActivated(_commandName, _world, _blockPos, _blockValue, _player);
}
Key detail: command names reaching the Block for a composite are
namespaced — TileEntityComposite.InitBlockActivationCommands builds them
as featureData.Name + ":" + command, so you get "TEFeatureDoor:open", not
"open" (SplitFullCommandName splits on the first :). featureData.Name
is the CompositeFeatures class name from blocks.xml, and two entries of the
same class (the vanilla elevator doors declare TEFeatureDoor twice) produce
the same prefix — so match on the suffix, not the whole string.
Also override Block.GetActivationText for the same condition: the "Locked"
line comes from the feature (TileEntityComposite.GetActivationText returns
the first non-null feature text, doors before lockable), so without it the
tooltip still says Locked while E works. Reuse the vanilla strings —
string.Format(Localization.Get("tooltipUnlocked"), markup, Localization.Get("door")).
For markup, pass the literal "[action:local:Activate][action:permanent:Activate]"
rather than playerInput.Activate.GetBindingXuiMarkupString(): the tooltip
renderer expands it, and the binding call drags in a reference to the
InControl assembly (CS0012 if your csproj doesn't reference it).
Working example: BlockElevatorDoor + ElevatorDoors.LockBypassed /
ToggleByHand in the Elevator mod — a locked landing door opens by hand only
while the car is parked at that floor.
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.
// History
- v2 — upgrayedd85 · 1mo ago
- v1 — unknown · 2mo ago