Editor Transaction and Save System
The editor transaction system provides undo/redo support for all devkit operations. It is built on a three-layer architecture: IDevkitTransaction (the atomic operation), DevkitTransactionGroup (a batch of operations), and DevkitTransactionManager (the undo/redo stack). Level persistence is handled by LevelSavedata, which routes file operations through ServerSavedata to the correct level directory.
Source code location: Framework/Devkit/Transactions/DevkitTransactionManager.cs, DevkitTransactionGroup.cs, IDevkitTransaction.cs, DevkitObjectDeltaTransaction.cs, DevkitGameObjectInstantiationTransaction.cs, DevkitGameObjectDestructionTransaction.cs, DevkitTransformChangeParentTransaction.cs, DevkitTransactionUtility.cs, ITransactionDelta.cs, TransactionFieldDelta.cs, TransactionPropertyDelta.cs, Unturned/Files/LevelSavedata.cs, Unturned/Managers/SaveManager.cs
IDevkitTransaction Interface
Every undoable action implements IDevkitTransaction:
csharp
public interface IDevkitTransaction
{
bool delta { get; } // True if this transaction represents a real change
void undo();
void redo();
void begin();
void end();
void forget(); // Called when evicted from history without being undone
}The delta property is critical: if no actual change occurred (e.g., a property was set to its existing value), the transaction is skipped. This prevents no-op actions from filling the undo history.
DevkitTransactionGroup — Batching
DevkitTransactionGroup collects multiple transactions into a single undoable unit. It stores a name (for UI display) and a List<IDevkitTransaction>:
csharp
public class DevkitTransactionGroup
{
public string name { get; protected set; }
public List<IDevkitTransaction> transactions { get; protected set; }
public void record(IDevkitTransaction transaction)
{
transaction.begin();
transactions.Add(transaction);
}
}The delta property performs cleanup: it iterates in reverse, removing any child transaction whose delta is false. If all transactions are removed, the group itself is clean and will be discarded:
csharp
public bool delta
{
get
{
for (int i = transactions.Count - 1; i >= 0; i--)
if (!transactions[i].delta)
transactions.RemoveAt(i);
return transactions.Count > 0;
}
}undo() and redo() execute the contained transactions in forward order. end() calls end() on each child transaction. forget() releases any resources held by the group's transactions.
DevkitTransactionManager — Undo/Redo Stack
DevkitTransactionManager is the static controller that manages the undo/redo history. It maintains three data structures:
undoable— aLinkedList<DevkitTransactionGroup>of past actions (capacity-limited)redoable— aStack<DevkitTransactionGroup>of undone actionspendingGroup— the currently open group being recordedtransactionDepth— nesting counter forbeginTransaction/endTransactionpairs
Transaction Lifecycle
beginTransaction(string name)— Opens a new group. IncrementstransactionDepth. On the outermost call, clears the redo stack and creates a freshpendingGroup.recordTransaction(IDevkitTransaction txn)— Records an action into the pending group. No-ops if no group is open.endTransaction()— DecrementstransactionDepth. On the outermost close:- Calls
pendingGroup.end() - Checks
pendingGroup.delta— if any real changes occurred, pushes to undo; otherwise discards viaforget() - Fires
transactionsChangedevent
- Calls
csharp
public static void endTransaction()
{
if (transactionDepth == 0) return;
transactionDepth--;
if (transactionDepth == 0)
{
pendingGroup.end();
if (pendingGroup.delta)
pushUndo(pendingGroup);
else
pendingGroup.forget();
pendingGroup = null;
triggerTransactionsChanged();
}
}Undo and Redo
undo() pops the most recent group from the undoable list, calls group.undo(), pushes it onto the redo stack, and fires transactionPerformed:
csharp
public static DevkitTransactionGroup undo()
{
if (!canUndo) return null;
DevkitTransactionGroup group = popUndo();
group.undo();
pushRedo(group);
triggerTransactionPerformed(group);
return group;
}redo() reverses the process: pops from redo, calls group.redo(), pushes onto undo.
History Capacity
The history length defaults to 25 (_historyLength = 25). When pushUndo would exceed this limit, the oldest group is evicted via forget():
csharp
protected static void pushUndo(DevkitTransactionGroup group)
{
if (undoable.Count >= historyLength)
{
undoable.First.Value.forget();
undoable.RemoveFirst();
}
undoable.AddLast(group);
}resetTransactions() clears both stacks and the pending group. clearUndo() and clearRedo() are internal methods that call forget() on each evicted group.
Event Notifications
| Event | Signature | Fires When |
|---|---|---|
transactionPerformed | DevkitTransactionPerformedHandler | After any undo or redo |
transactionsChanged | DevkitTransactionsChangedHandler | After endTransaction finalizes a group |
Built-in Transaction Types
The devkit provides several concrete transaction implementations:
| Type | Purpose |
|---|---|
DevkitObjectDeltaTransaction | Records field/property changes on a devkit object |
DevkitGameObjectInstantiationTransaction | Tracks creation of new game objects |
DevkitGameObjectDestructionTransaction | Tracks destruction of game objects |
DevkitTransformChangeParentTransaction | Tracks reparenting of transforms |
TransactionFieldDelta and TransactionPropertyDelta implement ITransactionDelta to capture before/after values of individual fields and properties. These are used by DevkitObjectDeltaTransaction to serialize the state change.
DevkitTransactionUtility provides helper methods for common transaction operations, such as recording transforms changes before and after a drag operation.
ITransactionDelta Interface
csharp
public interface ITransactionDelta
{
void undo();
void redo();
bool delta { get; }
void begin();
void end();
void forget();
}This mirrors IDevkitTransaction but operates on individual data fields rather than whole objects. TransactionFieldDelta<T> stores a reference to the field via reflection or a setter delegate, the before value, and the after value. TransactionPropertyDelta<T> works similarly for properties.
LevelSave Format
Level persistence is built on ServerSavedata with LevelSavedata as a routing layer:
csharp
public class LevelSavedata
{
public static string transformName(string name) =>
ServerSavedata.transformPath("/Level/" + name);
public static void writeData(string path, Data data) =>
ServerSavedata.writeData("/Level/" + Level.info.name + path, data);
public static River openRiver(string path, bool isReading) =>
ServerSavedata.openRiver("/Level/" + Level.info.name + path, isReading);
public static bool fileExists(string path) =>
ServerSavedata.fileExists("/Level/" + Level.info.name + path);
}All level files are stored under ServerSavedata.transformPath("/Level/<levelName>/<path>"). The Data and Block serializers support key-value and binary block formats respectively.
Region-Based File Organization
Levels are divided into regions for efficient save/load. The region system (RegionCoordinate) creates a grid over the level. Each region stores its own object, barricade, structure, and spawn data in separate files. The SaveManager.save() method iterates all managers which in turn iterate their regions:
csharp
public static void save()
{
// Per-player state
foreach (SteamPlayer client in Provider.clients)
client.player.save();
// Manager state
VehicleManager.save();
BarricadeManager.save();
StructureManager.save();
ObjectManager.save();
LightingManager.save();
GroupManager.save();
// Server lists
if (Dedicator.IsDedicatedServer)
{
SteamWhitelist.save();
SteamBlacklist.save();
SteamAdminlist.save();
}
}Each manager's save() method writes region-specific files using LevelSavedata or ServerSavedata paths. On load, each manager reads its region files and populates the world state. The preSave and postSave events on SaveManager allow plugin integrations.
