Skip to content

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 — a LinkedList<DevkitTransactionGroup> of past actions (capacity-limited)
  • redoable — a Stack<DevkitTransactionGroup> of undone actions
  • pendingGroup — the currently open group being recorded
  • transactionDepth — nesting counter for beginTransaction/endTransaction pairs

Transaction Lifecycle

  1. beginTransaction(string name) — Opens a new group. Increments transactionDepth. On the outermost call, clears the redo stack and creates a fresh pendingGroup.

  2. recordTransaction(IDevkitTransaction txn) — Records an action into the pending group. No-ops if no group is open.

  3. endTransaction() — Decrements transactionDepth. On the outermost close:

    • Calls pendingGroup.end()
    • Checks pendingGroup.delta — if any real changes occurred, pushes to undo; otherwise discards via forget()
    • Fires transactionsChanged event
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

EventSignatureFires When
transactionPerformedDevkitTransactionPerformedHandlerAfter any undo or redo
transactionsChangedDevkitTransactionsChangedHandlerAfter endTransaction finalizes a group

Built-in Transaction Types

The devkit provides several concrete transaction implementations:

TypePurpose
DevkitObjectDeltaTransactionRecords field/property changes on a devkit object
DevkitGameObjectInstantiationTransactionTracks creation of new game objects
DevkitGameObjectDestructionTransactionTracks destruction of game objects
DevkitTransformChangeParentTransactionTracks 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.