How Pyrite64's Undo/Redo System Manages Scene State and History Internally

Pyrite64 implements undo/redo by serializing the entire scene state into JSON snapshots stored in a dual-stack history system, enabling full scene restoration while tracking object selection changes.

Pyrite64 is an open-source Nintendo 64 homebrew engine and editor that provides robust tooling for scene manipulation. Understanding how its undo/redo system internally manages state is crucial for developers extending the editor or debugging history-related issues. The system operates at the full-scene level, capturing complete serialized states rather than individual command objects.

Core Architecture and Data Structures

The undo/redo implementation resides in src/editor/undoRedo.h and src/editor/undoRedo.cpp, centered around two primary structures: the Entry snapshot container and the History manager class.

The Entry Structure

Each Editor::UndoRedo::Entry represents a single point in history containing:

  • state: A serialized JSON string of the entire scene (captured via scene->serialize(true))
  • description: A human-readable label for UI display (e.g., "Moved selected object")
  • selection: A vector of selected object UUIDs to restore the user's context

The Entry class also provides getMemoryUsage() to report its individual memory footprint for diagnostics.

The History Class

The History class maintains the state machine through several critical members:

  • undoStack and redoStack: Vectors of unique_ptr<Entry> storing past and future states
  • maxHistorySize: Configurable limit to prevent unbounded memory growth
  • snapshotScene and snapshotSelUUID: Pointers tracking the "in-flight" modification state between begin() and end() calls
  • nextChangedReason: String buffer holding the description set by markChanged()

The Undo/Redo Lifecycle

The system follows a transactional pattern where modifications are bracketed by begin() and end() calls, with explicit change notification in between.

Starting a Modification with begin()

When History::begin() is invoked:

  1. The system retrieves the currently loaded scene via ctx.project->getScenes().getLoadedScene()
  2. If the undo stack is empty, it automatically stores an "Initial State" snapshot to ensure the user can return to the original configuration
  3. It saves a pointer to the scene (snapshotScene) and the currently selected object UUID (snapshotSelUUID)

This captures the baseline state before any modifications occur.

Capturing Changes with markChanged()

During the edit operation, the editor calls History::markChanged("description") to set nextChangedReason. This labels the upcoming snapshot with a human-readable description for the undo menu. If end() is called without markChanged(), no entry is created, filtering out no-op operations.

Finalizing with end()

The History::end() method completes the transaction:

  1. Validates that nextChangedReason is set; otherwise returns early
  2. Clears the redoStack (new actions invalidate previous redo history)
  3. Serializes the current scene state using scene->serialize(true)
  4. Creates a new Entry with the serialized state, description, and current selection UUIDs
  5. Compares the new state against the previous stack entry to avoid duplicate consecutive snapshots
  6. Pushes the entry onto undoStack and enforces maxHistorySize by trimming old entries

Executing Undo and Redo Operations

Undo (History::undo()):

  • Pops the top entry from undoStack (the state to revert from) and pushes it to redoStack
  • Takes the new top of undoStack (the previous state) and deserializes it into snapshotScene using snapshotScene->deserialize(prevCmd->state)
  • Restores the selection by finding the first UUID from prevCmd->selection that still exists in the scene

Redo (History::redo()):

  • Pops the top entry from redoStack, deserializes its state into the scene, and pushes it back onto undoStack
  • Restores the selection similarly to the undo operation

Memory Management and Limits

The system implements explicit memory accounting to prevent unbounded growth during long editing sessions. Each Entry implements getMemoryUsage() to report its string size and overhead. The History class aggregates this via getMemoryUsage() to provide total consumption of both stacks.

The maxHistorySize configuration acts as a hard limit. When the undo stack exceeds this count, the oldest entries are removed from the bottom of the stack. This trades infinite history for predictable memory usage, essential for an editor handling large Nintendo 64 scenes.

Implementation Example

The following pattern demonstrates the standard integration used throughout Pyrite64's editor tools:

// Begin a user-driven edit operation
Editor::UndoRedo::getHistory().begin();

// ... user manipulates objects, calls various editor actions ...

// Signal that a change occurred, with a description for the UI
Editor::UndoRedo::getHistory().markChanged("Moved selected object");

// End the operation – snapshot is stored automatically
Editor::UndoRedo::getHistory().end();

// Later, UI button triggers undo
if (Editor::UndoRedo::getHistory().canUndo())
    Editor::UndoRedo::getHistory().undo();

// Redo button
if (Editor::UndoRedo::getHistory().canRedo())
    Editor::UndoRedo::getHistory().redo();

Summary

  • Pyrite64's undo/redo system operates on complete scene serialization, storing JSON snapshots rather than command objects.
  • The History class in src/editor/undoRedo.h manages two stacks (undoStack and redoStack) with configurable limits via maxHistorySize.
  • Transactions bracket modifications using begin(), markChanged(), and end() to capture state changes with human-readable descriptions.
  • Undo and redo operations deserialize stored snapshots back into the live scene via Project::Scene::deserialize() and restore previous object selections.
  • Explicit memory accounting through getMemoryUsage() enables monitoring and automatic pruning of old history entries.

Frequently Asked Questions

How does Pyrite64's undo/redo system handle memory usage for large scenes?

The system tracks memory consumption explicitly through the Entry::getMemoryUsage() method, which reports the size of the serialized JSON state string. The History class aggregates this across both stacks via getMemoryUsage() and enforces a hard limit using maxHistorySize. When the undo stack exceeds this limit, the oldest entries are automatically removed from the bottom, ensuring memory usage remains predictable even when editing complex Nintendo 64 scenes with numerous objects.

What happens if an editor action does not call markChanged() before end()?

If History::end() is called without a preceding markChanged() call, the system returns early and does not create a history entry. This behavior acts as a filter to prevent no-op operations or cancelled actions from polluting the undo stack with meaningless snapshots. The nextChangedReason string must be populated with a description for the entry to be committed, ensuring every recorded state has a human-readable label for the UI.

How does the system restore object selection during undo operations?

When performing an undo, the system retrieves the selection vector from the target Entry, which contains the UUIDs of objects that were selected at that point in history. It then iterates through these UUIDs and restores the first one that still exists in the current scene. This approach gracefully handles cases where objects have been deleted since the snapshot was taken, falling back to the next valid UUID to maintain user context during the undo/redo workflow.

Why does Pyrite64 serialize the entire scene rather than using command objects?

Pyrite64 uses full scene serialization (storing complete JSON snapshots via scene->serialize(true)) rather than command objects because it provides a simpler, more robust mechanism for an editor handling complex hierarchical scenes. This approach eliminates the need to implement inverse operations for every possible edit action and ensures that any type of modification—whether to object transforms, materials, or scene hierarchy—can be captured and restored consistently. The trade-off is higher memory usage per entry, which is mitigated by the configurable maxHistorySize limit and explicit memory tracking.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →