Skip to content

Instantly share code, notes, and snippets.

@kanzwataru
Created November 10, 2025 03:57
Show Gist options
  • Select an option

  • Save kanzwataru/224e547e6da04c9214b9033857636e15 to your computer and use it in GitHub Desktop.

Select an option

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.
// 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;
}
#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