The composition
OpenMW managers kept accumulating three neighboring namespaces: storage-backed settings, transient runtime fields, and methods. ProtectedTable makes them one thing.
Each source keeps its own ownership and lifetime:
| Manager member | Actual source | Role |
|---|---|---|
manager.Enabled | OpenMW storage section | Settings that can be read, cached, synchronized, and sometimes written. |
manager.state.lastUpdate | Script-owned Lua table | Transient runtime state. |
manager.reset | Methods table attached to the manager | Behavior that operates on the composed view. |
The important part is that the state namespace is transparent when reading. The setup writes through .state, while consumers can read the value from the manager itself:
local I = require 'openmw.interfaces'
local manager = I.S3ProtectedTable.new {
inputGroupName = 'SettingsGlobalMyMod',
managerName = 'MyMod',
}
manager.state.lastUpdate = 0
function manager.reset()
manager.state.lastUpdate = 0
end
local enabled = manager.Enabled
local lastUpdate = manager.lastUpdate
if enabled and lastUpdate == 0 then
manager.reset()
end
manager.Enabled comes from the OpenMW storage section. manager.lastUpdate falls through to the script-owned state table. manager.reset comes from the manager's method table. The three things look like one object without pretending they have the same persistence or mutation rules.
The ProtectedTable API documents the current contract. The history explains why this composition was worth keeping.
The origin story
ProtectedTable did not begin as a general manager framework. It first had to answer small, concrete questions about what callers were allowed to put in the table and how failures should be explained:
7a2580caadded checks for inserting new values;94f60f76improved diagnostics and made failed inputs produce an expected value intostringoutput;e6b7f1a2made settings transparently indexable through ProtectedTable.
That third change is the real origin story. actor.lua, fatigueManager.lua, and hitChanceManager.lua no longer had to keep duplicate settings, or synchronize their copies with the storage section. A consumer could ask the manager for the setting and let the boundary own the lookup and cached value.
Then 4d78a2d5 hardened the metatable so its protection could not be replaced accidentally. Only after that sequence did the shape become a broadly reusable boundary, promoted as an installed interface in 5cbc5ed6.
The path was not “specific settings guardrail, then framework.” It was composition first: remove synchronization plumbing, make the composed view safe, then give the proven boundary a stable name.
Protection and synchronization came afterward
Composition is useful only if the sources cannot silently corrupt one another. ProtectedTable therefore accumulated rules around the composed view:
36ef9e14made.statedirectly readable and writable;e0122c97allowed an existing storage section to be supplied;5d2ece29allowed writes to storage groups owned by the manager when the group was writable;27c5eb07restored an explicitsubscribeHandler = falseoption for consumers that own synchronization themselves.
The constructor also probes whether the supplied storage section is writable. That is a legitimate protected call: the attempted write is expected to fail for read-only sections, and that failure communicates the capability being queried. See Error Handling for the narrower rule.
The default subscription is dependency management, not decoration. 9d5c7c2d fixed cached-setting invalidation and stopped the default handler from walking an entire storage group for every individual change. The manager maintains a local view of external state, so invalidation is part of the correctness contract.
The abstraction was right. The implementation was expensive.
Once ProtectedTable became convenient enough to sit in many paths, its contract was useful but its machinery became measurable overhead. Pr0f1l3r showed the hot lookup path hammering LuaJIT VM operations. The answer was not to throw away the composed interface; it was to reduce the cost underneath it.
The optimization sequence attacked different parts of that cost:
5f051eeeunrolled the__indexloop and removed table allocations;2499548eoptimized ProtectedTable's lookup paths more aggressively;e6a18a18completed another optimization and annotation pass.
That is the useful lesson. Do not discard an abstraction because its implementation is expensive. First establish whether the contract is wrong or merely the machinery underneath it. Keep the settings/state/method composition, then make __index, allocation, and lookup behavior earn their place on the hot path.
Recognizing the earned abstraction
ProtectedTable is a good example of recognizing a reusable abstraction after the fact:
- settings and runtime state accumulate beside one another;
- duplicate synchronization makes the composition painful;
- transparent lookup removes that plumbing;
- protection and invalidation make the composition safe;
- the stable interface names the boundary;
- measured optimization reduces its cost without changing the boundary.
That is different from starting with Manager:new() and hoping a universal settings framework will emerge around it.
What to steal
When settings, transient state, and behavior repeatedly travel together, make their sources explicit before adding a manager. Then ask whether one boundary can own the lookup, mutation rules, and invalidation without lying about the different lifetimes underneath.
See Storage and Lifecycle, the ProtectedTable API, and the shadow-cache invalidation scar.
When not to use this
Use a plain storage section when there is no neighboring transient state, method surface, protection rule, or synchronization boundary to compose. Do not wrap a small table in a manager merely to make the code look architectural. The composition earns its existence when it removes real duplication and enforces real ownership.