rollnw | client Transactions
rollnw client's current transaction boundary is the composition of CommandBus, a
validated mutation batch, CommandUndoAction, and WorkspaceState. There is no
general ToolsetTransaction type and no serializable server transaction
protocol.
The implemented path is intentionally narrow: one active workspace tab, scalar or managed-data patches against a live object, and Creature/Placeable membership batches against a live area.
Data Contract
ObjectEditBatch owns a contiguous vector of ObjectEditPatch rows:
ObjectEditBatch {
kind: propset_int | propset_int_element | creature_feat
patches: [
object: ObjectHandle
propset_type: TypeID
key: uint32
before: int32
after: int32
element_index: int32 // -1 for a scalar; otherwise an existing array element
]
}
All rows in a batch target the same live object and edit kind. Keys are strictly
ordered and unique. Scalar propset batches order by (propset_type, field key);
array-element propset batches order by (propset_type, field key, element_index);
Creature feat batches order by feat key. Array-element edits support fixed and
unmanaged integer arrays but never resize them. The batch owns its values
through forward application, undo, and redo.
The active object is singular because one workspace surface is active. Patch application is plural even for one field; the single edit is a batch containing one row.
Command To Commit
widget / RmlUi / palette / terminal / Smalls
-> CommandInvocation + CommandContext
-> CommandBus
-> object command reads validated active_object
-> construct ObjectEditBatch
-> commit_object_edits
-> validate complete batch
-> apply forward
-> publish mutation epoch
-> mark active tab dirty
-> return CommandUndoAction
-> CommandBus pushes undo action to active workspace tab
-> workbench observes mutation epoch and rebuilds affected snapshots
CommandContext carries the active tab ID, live area handle, source, workspace
pointer, focused region, selection strings, and whether undo recording is
enabled. The bus fills an empty active tab ID from WorkspaceState.
Only a successful workspace-scoped command may push an undo action. A noop,
rejection, failure, global command, renderer command, or replay with
record_undo == false does not add another undo entry.
Validation And Application
Validation completes before the first write:
- batch is non-empty and has a supported kind;
- target handle resolves to a live object;
- every patch targets the same object;
- keys are strictly ordered and unique;
- propset type, field index, field type, element index, and propset instance are valid;
- feat IDs and Creature target type are valid;
beforeandafterdiffer and are in the supported value range; and- the current live value equals the expected value for the requested direction.
Out-of-range or malformed data is rejected. A current-value mismatch returns
stale_value; rollnw client does not overwrite an intervening edit.
Application writes rows in order. If a write fails after a prefix was applied, that prefix is replayed in the opposite direction before failure is returned. One successful batch increments the global object-mutation epoch once and publishes the edited handle.
Area duplicate/delete uses a separate contiguous membership-row batch. Each row contains the live area handle, child handle, stable vector index, and expected attached state. Validation rejects stale/non-member handles and duplicate inputs before mutation. Attach writes run in ascending index order; detach writes run in descending order so earlier indices remain stable.
The area owns attached children. An undo action owns detached handles and destroys them when its history is discarded; undo/redo therefore retains one live object handle instead of reconstructing gameplay state. Duplicate uses the existing component/propset instance protocol once to instantiate a live copy. The UI does not read or edit that JSON representation.
A duplicate batch keeps its relative layout by applying one common diagonal
offset. rollnw client tests the four (+/-1.5 m, +/-1.5 m) directions in fixed order
and uses the first direction that keeps every clone inside the area bounds. The
command is rejected without creating clones when no direction fits.
A successful structural batch rebuilds renderer rows from the same live Area
and preserves the scene's owned/borrowed root policy during replacement. This
is a cold O(area render-record count) operation per observed structural batch. The
monotonic structural epoch coalesces multiple commands received before a frame
into one rebuild of the final live area state. The session logs record count and
wall time for every rebuild; no benchmark distribution has been established and
no per-frame work is added.
The dominant access pattern is one cold linear validation pass followed by one linear write pass over the patch vector. The current edit batches are initiated by user commands, not by a per-frame loop. No performance result is inferred from that shape.
Undo And Redo
commit_object_edits captures the owned batch in one CommandUndoAction:
- undo applies the batch in the inverse direction and moves the action to the active tab's redo stack;
- redo applies it forward and moves the action back to undo;
- failed replay leaves the action on its source stack; and
- a new successful edit clears the redo stack.
Replay validates expected live values again. Undo therefore fails instead of silently overwriting state changed outside the action sequence.
Object-edit undo and redo mark the tab dirty and publish a new mutation epoch. Dirty state is a boolean, not a comparison against a saved revision; undoing back to a previously saved value still leaves the tab dirty until it is saved again.
Save as New Blueprint records a file action in the existing active-tab history. Its action owns the new resource's path and exact saved bytes, including UUID; it retains no live object. Undo verifies those bytes and deletes the file. Redo uses exclusive creation to restore them. Both refresh module resources, and the resource generation change refreshes the project tree. Source dirty state and selection are unchanged. Changed files, occupied resource keys, and open destination tabs reject replay and leave the action on its original stack. An I/O or resource-refresh failure attempts to restore the prior file state and reports any rollback failure explicitly. This history is process-local and does not provide crash recovery for file creation/deletion.
Update Blueprint References prepares detached replacement instances for the live area on the kernel thread, then replaces native owner slots and destroys the old instances. Inventory grids are validated as a batch; equipment replacement refreshes effects and visuals without routing outgoing equipment through an inventory slot. The area root and unrelated instance handles/edits survive; the area becomes dirty. Its existing handle-based undo/redo history is cleared before destroying instances. This operation currently has no live-area undo. Cancel before Apply releases only detached replacements. Whole Module excludes the live CAF from its worker's file batch; durable restoration therefore applies only to unopened documents actually written by that worker. A live-area update is persisted by the ordinary Save path.
Diagnostics
Mutation results map to the command protocol:
| Mutation result | Command status | Channel |
|---|---|---|
| success | success |
normally none |
| empty batch | noop |
none |
| invalid batch or stale value | rejected |
warn |
| read/write/internal failure | failed |
error |
The terminal, output panel, palette, and RmlUi caller consume the same
CommandResult. RmlUi may correct presentation after a rejection, but it does
not create a second resource mutation or diagnostic policy.
Dirty State And Save
A successful edit, undo, or redo sets the active document tab dirty. Each open
area or blueprint tab owns its live ObjectManager root through ObjectDocument.
Attached children belong to that root; history owns detached children. Closing a
tab releases history before destroying its root, including vector erasure and
reordering. Workspace documents are cleared before object services are replaced.
The viewport borrows the active tab's root. First display loads and transfers
ownership to the tab; later displays rebuild visuals from that same live graph.
Only one scene's render resources are retained. A scene switch, visual rebuild,
or clear does not destroy a tab's data. One pinned area tab owns the current
area; returning from another tab preserves its document and history. Selecting
a different area replaces that document, after Save / Discard / Cancel if dirty.
Failed saves and Cancel preserve it. Confirmations carry the expected current
resource; stale confirmations reject instead of discarding a different area.
Clean replacement releases the old history/root; there is no inactive-area cache.
The save protocol is an ordered, borrowed span of unique, non-empty tab IDs and one project directory. The caller owns the strings and workspace for the entire synchronous call; tabs must not move during the call. Empty input is a no-op. An empty or duplicate ID rejects the whole batch before writes. Each other row resolves to a tab-owned root and an existing project-relative JSON file:
workspace.save_tab [id] / toolset.save_all
-> one requested tab / all dirty tabs in workspace order
-> save_workspace_documents
-> validate tab kind, live root, JSON format, project-contained path
-> serialize blueprint or CAF through existing component/propset JSON
-> atomic file replacement
-> clear that tab's dirty flag only after success
-> regenerate derived area map (failure is a warning)
-> return each successfully replaced map path as native result metadata
-> release those exact RmlUi texture sources on the UI thread
-> aggregate saved/failed counts and per-document diagnostics
Missing, unsupported, stale, binary, outside-project, or unwritable documents
fail individually without changing their dirty state. Later rows still run;
there is no all-files rollback. Saving does not activate tabs, move cameras,
replace live roots, or clear undo history. The output is one CommandResult
with success only when every requested document saved. Atomic replacement
writes the complete JSON document, not a log of mutation patches. Missing files
are not recreated automatically. Failed map writes do not produce texture
invalidation paths; successful rows in a partially failed save batch still do.
This is UI-thread event work: linear scanning to collect dirty tabs, existing linear ID lookup per row, and sequential serialization/file replacement. ID validation and lookup are quadratic in open-tab count; no tab-volume measurement justifies a second index here. Memory retained between switches is the sum of open live graphs and histories, not one GPU scene per tab. Handles are cold ObjectManager identities needed by existing edit/undo protocols; this adds no pointer-heavy frame loop. Save latency and retained-memory limits have not been benchmarked. Texture invalidation is one linear pass over successfully written area maps and performs no per-frame work. No autosave, background queue, disk reload cache, or new file format is introduced.
Tests cover ownership through tab movement/close, multi-document save/reload, failure isolation, inactive saves, and borrowed viewer scene switches/rebuilds. Existing edit tests retain scalar/feat, transform, and membership round trips.
Save Before Close
workspace.close_tab asks WorkspaceState for a close decision. A dirty tab
returns a prompt with three explicit actions:
- Save executes
workspace.save_and_close_tab, which saves first and only executes close after save succeeds. - Discard executes forced close without saving.
- Cancel executes no command.
workspace.save_and_close_tab propagates save rejection or failure directly.
There is no path that reports save failure and then closes the document.
Quitting similarly offers Save All / Discard / Cancel and stays open on save
failure. Opening another project or module rejects unsaved tabs; the user must
save or explicitly discard them before the runtime is replaced.
Script Commands
Smalls command handlers may return an undo token and register named undo/redo
handlers. ScriptCommandHost wraps that token in a CommandUndoAction, so
script-defined workspace commands enter the same per-tab undo admission path as
native commands.
The token is process-local script data. Its meaning and lifetime are owned by the registering script module; it is not a durable or remote transaction ID.
Current Limits
- Supported patch payloads are integer propset fields and Creature feat assignment. Strings, floats, localized strings, arrays, and compound managed operations need an observed editor workflow before extending the protocol.
- Saving supports blueprint JSON and native CAF JSON, not binary
ARE/GIT/GICor binary blueprints. Unsupported dirty tabs are reported, not silently skipped. ObjectHandle, SmallsTypeID, in-process callbacks, and C++ undo closures are not stable cross-process identities or serialization formats.
Future Remote Boundary
A future server may reuse command names and the validate-then-apply semantics,
but it needs a different explicit protocol: stable document/object identity,
schema identity independent of process-local TypeID, typed payload encoding,
base revision, accepted patch batch, and rejection diagnostics. None of that is
implemented or implied by the current C++ structures.