Created
November 10, 2025 03:57
-
-
Save kanzwataru/224e547e6da04c9214b9033857636e15 to your computer and use it in GitHub Desktop.
A generic C undo system, with a chunked ring-buffer that just memcpy's undoable chunks. Based on rxi's simple undo blog post.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Push a maybe-change backup to the undo system | |
| // Commit-or-discard a set of changes | |
| // | |
| // * Push to end (with wraparound) | |
| // * Reclaim free blocks by pushing the head | |
| // * Discard from middle | |
| // * Move cursor around | |
| // * Keep a separate set of pending items to then include into the structure as above | |
| // * Always keeping the chronological order | |
| // 0 -> 1 -> 2 -> 3 | |
| // | | | | |
| #include <stdint.h> | |
| #include <stdbool.h> | |
| #include <string.h> | |
| #include <assert.h> | |
| #include <stdio.h> | |
| #define UNDO_BLOCK_SIZE 64 | |
| #define UNBO_BLOCK_COUNT 512 | |
| struct UndoBlock { | |
| void *ptr; | |
| uint32_t size; | |
| uint64_t id; | |
| uint32_t _padding; | |
| char undo_data[UNDO_BLOCK_SIZE]; | |
| char redo_data[UNDO_BLOCK_SIZE]; | |
| }; | |
| struct Undo { | |
| struct UndoBlock blocks[UNBO_BLOCK_COUNT]; | |
| uint32_t head; | |
| uint32_t tail; | |
| uint32_t pending_tail; | |
| uint32_t cursor; | |
| uint32_t id_generator; | |
| }; | |
| typedef struct Undo Undo; | |
| typedef struct UndoBlock UndoBlock; | |
| #define offset_resolve(o_) ((o_) % (UNBO_BLOCK_COUNT)) | |
| static void undo_print_diagram(Undo *u) | |
| { | |
| for(uint32_t i = u->head; offset_resolve(i) != offset_resolve(u->cursor); ++i) { | |
| fputc('U', stdout); | |
| } | |
| fputc('|', stdout); | |
| for(uint32_t i = u->cursor; offset_resolve(i) != offset_resolve(u->tail); ++i) { | |
| if(u->blocks[offset_resolve(i)].ptr) { | |
| fputc('R', stdout); | |
| } | |
| else { | |
| fputc('_', stdout); | |
| } | |
| } | |
| for(uint32_t i = u->tail; offset_resolve(i) != offset_resolve(u->pending_tail); ++i) { | |
| if(u->blocks[offset_resolve(i)].ptr && u->blocks[offset_resolve(i)].id >> 32) { | |
| fputc('!', stdout); | |
| } | |
| else if(u->blocks[offset_resolve(i)].ptr && !(u->blocks[offset_resolve(i)].id >> 32)) { | |
| fputc('?', stdout); | |
| } | |
| else { | |
| fputc('_', stdout); | |
| } | |
| } | |
| } | |
| static void undo_push(Undo *u, void *ptr, uint32_t size, uint32_t id) | |
| { | |
| printf("undo_push\n"); | |
| undo_print_diagram(u); printf("\n"); | |
| // Push a new pending block, but only if we don't already have one. | |
| // The initial data is stored, for the undo. | |
| assert(size <= UNDO_BLOCK_SIZE); // TODO: Block splitting | |
| for(uint32_t i = u->tail; offset_resolve(i) < offset_resolve(u->pending_tail); ++i) { | |
| if(u->blocks[offset_resolve(i)].id == id && u->blocks[offset_resolve(i)].ptr == ptr && u->blocks[offset_resolve(i)].size == size) { | |
| // We already have this block, early-out. | |
| // NOTE: An edge case if this is an already-comitted block, maybe should AND the top ID bits away | |
| printf("-> Early-out, we already have this block\n"); | |
| return; | |
| } | |
| } | |
| UndoBlock *block = &u->blocks[offset_resolve(u->pending_tail++)]; | |
| if(offset_resolve(u->pending_tail) == offset_resolve(u->head) && offset_resolve(u->head) != offset_resolve(u->tail)) { | |
| ++u->head; | |
| if(u->head >= u->cursor) { | |
| ++u->cursor; // This can happen if we have a buffer that is full of redos. | |
| // NOTE: Still has edge cases if we exhaust the buffer with just pendings... | |
| } | |
| } | |
| block->ptr = ptr; | |
| block->size = size; | |
| block->id = id; | |
| memcpy(block->undo_data, ptr, size); | |
| undo_print_diagram(u); printf("\n"); | |
| } | |
| static void undo_commit(Undo *u, uint32_t id) | |
| { | |
| printf("undo_commit\n"); | |
| undo_print_diagram(u); printf("\n"); | |
| // Mark the blocks of this id as either to-commit or to-discard. | |
| // Any might-modify actions that were pre-emptively pushed but weren't actually modified, should be discarded. | |
| // We also store the redo data here. | |
| bool any_modified = false; | |
| for(uint32_t i = u->tail; offset_resolve(i) < offset_resolve(u->pending_tail); ++i) { | |
| UndoBlock *block = &u->blocks[offset_resolve(i)]; | |
| if(block->id == id) { | |
| const bool modified = 0 != memcmp(block->ptr, block->undo_data, block->size); | |
| if(modified) { | |
| block->id |= ((uint64_t)u->id_generator + 1) << 32; | |
| memcpy(block->redo_data, block->ptr, block->size); | |
| } | |
| else { | |
| block->ptr = NULL; | |
| } | |
| } | |
| } | |
| if(any_modified) { | |
| ++u->id_generator; | |
| } | |
| undo_print_diagram(u); printf("\n"); | |
| } | |
| static void undo_cancel(Undo *u, uint32_t id) | |
| { | |
| printf("undo_cancel\n"); | |
| undo_print_diagram(u); printf("\n"); | |
| // Mark all blocks of this id as to-discard. | |
| // This function can be useful if you have a whole bunch of sliders in ImGui and you push them every frame, but usually don't have them edited. | |
| for(uint32_t i = u->tail; offset_resolve(i) < offset_resolve(u->pending_tail); ++i) { | |
| UndoBlock *block = &u->blocks[offset_resolve(i)]; | |
| if(block->id == id) { | |
| block->ptr = NULL; | |
| } | |
| } | |
| undo_print_diagram(u); printf("\n"); | |
| } | |
| static void undo_flush(Undo *u) | |
| { | |
| printf("undo_flush\n"); | |
| undo_print_diagram(u); printf("\n"); | |
| // Defragment pending blocks and overwrite redo blocks. | |
| // Basically we move all the confirmed blocks back towards the cursor, discarding all redo blocks and to-discard blocks. | |
| if(offset_resolve(u->tail) == offset_resolve(u->pending_tail)) { | |
| return; | |
| } | |
| bool any_pending = false; | |
| for(uint32_t i = u->tail; i <= u->pending_tail; ++i) { | |
| UndoBlock *block = &u->blocks[offset_resolve(i)]; | |
| if(block->ptr && !(block->id >> 32)) { | |
| any_pending = true; | |
| break; | |
| } | |
| } | |
| uint32_t new_pending_tail = any_pending ? u->tail : u->cursor; | |
| for(uint32_t i = u->tail; i <= u->pending_tail; ++i) { | |
| UndoBlock *block = &u->blocks[offset_resolve(i)]; | |
| if(block->ptr) { | |
| u->blocks[offset_resolve(new_pending_tail++)] = *block; | |
| } | |
| } | |
| u->pending_tail = new_pending_tail; | |
| if(!any_pending) { | |
| u->cursor = new_pending_tail; | |
| u->tail = new_pending_tail; | |
| } | |
| undo_print_diagram(u); printf("\n"); | |
| } | |
| static bool undo_back(Undo *u) | |
| { | |
| printf("undo\n"); | |
| undo_print_diagram(u); printf("\n"); | |
| // Move the cursor back and apply the undo data | |
| bool modified = false; | |
| uint64_t id = u->blocks[offset_resolve(u->cursor - 1)].id; | |
| const UndoBlock *b = &u->blocks[offset_resolve(u->cursor - 1)]; | |
| while(b->id == id && offset_resolve(u->cursor) != offset_resolve(u->head)) { | |
| memcpy(b->ptr, b->undo_data, b->size); | |
| modified = true; | |
| --u->cursor; | |
| b = &u->blocks[offset_resolve(u->cursor - 1)]; | |
| } | |
| undo_print_diagram(u); printf("\n"); | |
| return modified; | |
| } | |
| static bool undo_forward(Undo *u) | |
| { | |
| printf("redo\n"); | |
| undo_print_diagram(u); printf("\n"); | |
| // Move the cursor forward and apply the redo data | |
| bool modified = false; | |
| const UndoBlock *b = &u->blocks[offset_resolve(u->cursor)]; | |
| uint64_t id = b->id; | |
| while(b->id == id && offset_resolve(u->cursor) != offset_resolve(u->tail)) { | |
| memcpy(b->ptr, b->redo_data, b->size); | |
| modified = true; | |
| ++u->cursor; | |
| b = &u->blocks[offset_resolve(u->cursor)]; | |
| } | |
| undo_print_diagram(u); printf("\n"); | |
| return modified; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include "undo.h" | |
| #include <assert.h> | |
| #include <stdio.h> | |
| struct Undo u; | |
| void print_floats(float *f, int count) | |
| { | |
| for(int i = 0; i < count; ++i) { | |
| printf("%f ", f[i]); | |
| } | |
| printf("\n"); | |
| } | |
| int main(int argc, char **argv) | |
| { | |
| float f[] = { 0.0f, 1.0f, 2.0f, 3.0f }; | |
| #define PF() print_floats(f, sizeof(f) / sizeof(float)) | |
| printf("// 1. Simple case: One commit, two pushes, only one push makes it\n"); | |
| PF(); | |
| undo_push(&u, &f[0], sizeof(float), 0); | |
| undo_push(&u, &f[1], sizeof(f[1]), 0); | |
| f[0] = 0.5f; | |
| f[0] = 0.25f; | |
| f[0] = 0.75f; | |
| undo_commit(&u, 0); | |
| undo_flush(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| printf("\n"); | |
| printf("// 2. Two simultanous transactions, only one has a modification\n"); | |
| PF(); | |
| undo_push(&u, &f[0], sizeof(float), 0); | |
| f[0] = 200.0f; | |
| undo_commit(&u, 0); | |
| undo_push(&u, &f[1], sizeof(float), 1); | |
| undo_commit(&u, 1); | |
| undo_flush(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_forward(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| printf("\n"); | |
| printf("// 3. Two frames, two transactions, one commits on the first frame, second commits on second frame.\n"); | |
| PF(); | |
| undo_push(&u, &f[0], sizeof(float), 1); | |
| f[0] = 1337.0f; | |
| undo_commit(&u, 1); | |
| undo_push(&u, &f[1], sizeof(float), 42); | |
| f[1] = 500.0f; | |
| undo_flush(&u); | |
| PF(); | |
| undo_push(&u, &f[1], sizeof(float), 42); | |
| f[1] = 750.0f; | |
| undo_commit(&u, 42); | |
| undo_flush(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_forward(&u); | |
| PF(); | |
| undo_forward(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| printf("\n"); | |
| printf("// 4. Two frames, an interaction transaction that happens across frames, and one that pushes a bunch of stuff but always cancels\n"); | |
| PF(); | |
| undo_push(&u, &f[0], sizeof(float), 1); | |
| f[0] = -1.0f; | |
| PF(); | |
| undo_push(&u, &f[1], sizeof(float), 2); | |
| undo_push(&u, &f[2], sizeof(float), 2); | |
| undo_push(&u, &f[3], sizeof(float), 2); | |
| undo_cancel(&u, 2); | |
| undo_flush(&u); | |
| undo_push(&u, &f[0], sizeof(float), 1); | |
| f[0] = -2.0f; | |
| PF(); | |
| undo_commit(&u, 1); | |
| undo_push(&u, &f[1], sizeof(float), 2); | |
| undo_push(&u, &f[2], sizeof(float), 2); | |
| undo_push(&u, &f[3], sizeof(float), 2); | |
| undo_cancel(&u, 2); | |
| undo_flush(&u); | |
| undo_back(&u); | |
| PF(); | |
| undo_forward(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| printf("// 5. Two frames, two interaction transaction that happens across frames, and one that pushes a bunch of stuff but always cancels, but it's in the middle!\n"); | |
| PF(); | |
| undo_push(&u, &f[0], sizeof(float), 1); | |
| f[0] = -1.0f; | |
| PF(); | |
| undo_push(&u, &f[1], sizeof(float), 2); | |
| undo_push(&u, &f[2], sizeof(float), 2); | |
| undo_push(&u, &f[3], sizeof(float), 2); | |
| undo_cancel(&u, 2); | |
| undo_push(&u, &f[3], sizeof(float), 99); | |
| f[3] = 99999.0f; | |
| PF(); | |
| undo_commit(&u, 99); | |
| undo_flush(&u); | |
| undo_push(&u, &f[0], sizeof(float), 1); | |
| f[0] = -2.0f; | |
| PF(); | |
| undo_commit(&u, 1); | |
| undo_push(&u, &f[1], sizeof(float), 2); | |
| undo_push(&u, &f[2], sizeof(float), 2); | |
| undo_push(&u, &f[3], sizeof(float), 2); | |
| undo_cancel(&u, 2); | |
| undo_flush(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_forward(&u); | |
| PF(); | |
| undo_forward(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| undo_back(&u); | |
| PF(); | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment