Skip to content

Multiplatform editor GUI systems

Status: proposed target architecture, 2026-10-06. This is a design decision, not a GUI rewrite or a claim of measured superiority. The current Linux Qt workspace and bounded Qt/Wasm document preview remain the implemented baseline. The native macOS/Windows editor, Qt-free document core, scene viewport and contracts below require implementation and acceptance. The initial S2 slice implements a Qt-free bounded history primitive and adapts the current Qt project settings form; descriptor validation and persistence have not yet moved into the portable core. This does not complete milestone M1.

Decision and priorities

Build a portable document/command core, a Qt 6 Widgets native shell, and Ludus rendered authoring viewports. Keep the current Qt/Wasm preview. Qualify its presentation for full browser authoring against explicit gates; select a DOM frontend if it cannot pass them. Share document semantics across frontends and platforms, while giving each host honest file, process and rendering capabilities. ADR 0023 records the selection and when to revisit it.

The priority order is preservation of work and predictable operations; usable text, keyboard and accessibility; readable ownership and debugging; responsive large documents; then visual polish, footprint and advanced customization. There is no release deadline. This permits real integration experiments and user evaluation; it does not make the lifetime cost of an extra toolkit vanish. The recommendation is engineering judgment for Ludus, not a universal toolkit ranking. There are no comparative performance results yet.

The modern part of this design is the combination of headless authoring, transactional edits, revision-safe jobs, isolated gameplay and measured, semantic presentation. A renderer that can draw buttons does not supply all of those systems. Keep toolkit choice replaceable by putting authoring policy below presentation, rather than designing a universal widget API. A future DOM frontend consumes the same core compiled to Wasm through bounded typed commands, queries and incremental deltas; it must not reimplement document validation/history in JavaScript or serialize an entire project on each keystroke. Choose the smallest binding needed by the accepted workflow, not a general RPC/object proxy system.

Source editing direction

The code editor architecture owns the target product built on this foundation; this section documents what is implemented.

The accepted direction (2026-10-21) is a reusable code editor over Qt's maintained plain-text engine, with asynchronous language services. The Renderer Source pane is the first consumer; scripting reuses the editing commands and language-service contracts. Keep Qt text layout, clipboard, selection, native navigation, input methods and accessibility. Do not build another text engine. Thanks to The Qt Company, QPlainTextEdit, QSyntaxHighlighter and Code Editor Example, Qt Widgets/GUI documentation, for the block-highlighting and visible line-gutter mechanisms: plain text, highlighter, code editor.

Responsibilities and dependency direction

Component Owns Boundary
Code editor view Text presentation, visible line gutter, current line/bracket highlighting, popup presentation and native input Sends editing intentions; no file writes, shader compilation or process ownership
Editing controller Editing commands, request scheduling/cancellation and result application Uses version-checked document edits and copied service results
Document adapter Connects text edits to the existing draft/history/save owner One authority for authored state; formatting/completion are atomic undo transactions
Language services Hover, completion, signature help, diagnostics and formatting Explicit per-language capabilities; no dependency on a widget or GPU
Shader context provider Selected program, captured sources, generated interfaces, imports, defines and compiler settings Shared with shader compilation; generated locations map back to authored sources

Keep the implementation private to the editor. Portable request/result/edit values must not expose Qt objects or enter the installed engine SDK. Add a scripting provider when its workflow needs it; language selection supplies comment syntax, highlighting, indentation and service capabilities, rather than forking the editor. The implemented document identity/edit/selection seam is Qt-free; the native formatter's owned process buffers and XML decoder remain private Qt adapters and convert at that seam. This is not a general plugin or widget framework.

Editing and document transactions

The foundation supplies line numbers, syntax coloring, current-line and matching bracket highlighting, automatic indentation, selected-line indent/outdent and comment toggling, duplicate/move/delete line, find/replace and go to line. Preserve platform Home/End, modifiers, clipboard, selection, IME and accessibility.

The target document operation accepts an expected revision and bounded edits; rejecting an edit preserves the draft. Typing coalesces into sensible undo groups; completion and formatting each form one group. Route undo/redo through the document owner, without competing widget and workspace histories. IME preedit is temporary until committed. Dirty drafts survive conflicting external saves.

The implemented adapter (CodeDocumentHost) uses Qt-free UTF-8 byte edits, selection ranges and an identity containing project epoch, source binding and the owner's monotonic revision. CodeDocumentController maps Qt UTF-16 input deltas into that contract. The render-graph adapter commits them immediately to the existing package history; Qt's competing undo stack is disabled while bound. Keyboard, context-menu and shell undo all route to that owner. A different single-file/script document can implement the same host without graph or shader dependencies.

Typing coalesces while adjacent, within one second and within a word boundary; navigation, focus/source changes, Save and commands break groups. Line commands, Replace All and formatting each commit once. Undo/redo restores the relevant source and directed selection; those view hints are excluded from authored content equality and serialization. IME preedit creates no history until commit. UTF-8 scalar boundaries, UTF-16 surrogate pairs and CRLF units are mapped explicitly. Existing BOMs and untouched source bytes survive ordinary edits. New lines follow CRLF when the source uses it; mixed endings are otherwise left as authored.

Admission checks precede publication. Invalid text, stale revisions and source capacity failures restore the last accepted view, preserving the draft/history. Save alone publishes files; it retains the existing package bounds and external conflict checks. Expensive tree/interface/watch refreshes are coalesced at the existing debounce; draft revisions and Undo availability update immediately.

Compiler diagnostics in the view

The workspace keeps the build report. The editor receives copied CodeDiagnostic values for the shown file: a one-based line, an optional one-based Unicode scalar column and length, a severity and the message. It turns each into a document cursor range, so Qt moves the range with later edits. It draws a gutter marker and an underline, and shows the message as escaped text in the hover. The first character edit marks the set out of date. The editor ignores the same-length document notices that highlighter reformatting produces, and a whole-text replacement drops the markers. The editor shows out-of-date markers hollow and dotted rather than hiding them. The workspace also passes a set as out of date when the report's captured build identity no longer matches the sources. This includes a saved-source build of a file whose draft differs. It re-places the markers only for a new result, project or file, so re-rendering the pane keeps their anchored positions. Columns come from the render-graph tool, which checks Slang's caret against the captured source; the view never parses compiler text.

Language intelligence and formatting

Use Slang's language server for symbol hover, completion, signature help and analysis diagnostics. Thanks to the Slang contributors, The Slang Shading Language VSCode Extension, sections "Features", "Additional Search Path" and "clang-format location and style", for the existing semantic and formatting integration model. Its official source provides context-aware suggestions and signature/documentation hover; formatting requires clang-format. Use these tools rather than maintaining a shader parser for semantic intelligence. Lexical coloring is independently available.

Assistance sees unsaved source buffers, manifest-generated types/bindings, imports, defines and the selected program's compiler settings from the same source-generation owner as compilation. One file may participate in several program contexts: key results by program/context identity, not only filename. The Shader API pane is a reference view, not a second semantic authority.

Hover appears after a short dwell and shows type/signature and documentation. Completion supports explicit invocation, member triggers, keyboard selection, replacement ranges, optional additional edits and bounded documentation. Native completion and signature help are implemented through a lazy workspace slangd process, a reusable editing controller and copied popup presentation. render-graph shader-context uses the compiler owner's prepare_sources and shader_source operations to capture and lower the current draft package. It stages dependencies and a virtual translation unit with one verbatim editable source span. Selected linked-program contexts preserve generated bindings, scene interfaces, payloads and authored setting defines. They do not turn the reference pane's declaration search into a shader parser. Compiler target capability checking remains part of validation/build.

The controller maps negotiated UTF-16 LSP ranges through the editable span to the UTF-8 document seam. Text-only changes reuse the virtual URI and synchronize an incremental delta; changed dependencies or generated declarations select another URI. Package, source, project, context, caret and request changes invalidate replies. Plain replacements and additional edits are validated together and published as one document history operation. Generated spans, overlapping edits, unsolicited snippets and out-of-capacity results are rejected without publication. The provider explicitly rejects legacy rewrites, repeated editable occurrences and cycles that cannot preserve this single-span mapping.

Source symbol, definition and reference requests share that adapter and its exact draft/context identity. The compiler context exports a bounded URI/file/hash inventory; unchanged captured package includes map to editable drafts, while generated or rewritten locations are read-only inspections. Unknown URIs never trigger file access. Symbol lookup excludes generated surrounding declarations. Navigation retains at most 256 results from 8192 examined entries, validates UTF-16 ranges and indexes each captured source's lines once per result set. The chooser filters copied labels without previewing selection; accepting revalidates the request identity. Source Back/Forward history is separate presentation state, with 64 locations per direction, active-buffer cursor anchors and clamped line/column locations after rebinding. Quick Open uses the document owner's existing file inventory. Graph/project replacement clears it; Save and authored Undo stay with their existing owner.

The native adapter keeps one assistance request and one JSON decode in flight. It parses protocol bodies off the input/paint thread, with a 4 MiB wire/buffer limit, 8 KiB header and diagnostic limits and a ten-second request deadline. Context preparation has a separate fifteen-second deadline and uses the existing bounded render-graph job adapter. Captured buffers travel through stdin under a separate 8 MiB encoded-input budget. The background context owner validates the existing decoded package limits, writes/removes private staging files and prunes only its marked obsolete contexts; typing performs no snapshot file writes. Presentation keeps at most 256 completions from 8192 inspected candidates, 32 signatures and 4096 UTF-16 documentation units; additional edits are bounded to 32. Obsolete requests are cancelled; decoder results also carry a connection generation. Tool failure preserves editing and appears locally with retry/repair guidance. Native interaction and latency targets below still need separate host measurements; these bounds are not performance claims. Symbol hover, completion-item resolution and snippet placeholders remain future work. The independent compiler report still owns displayed build errors.

Explicit Format Document is implemented for native shader sources. The process adapter checks the exact clang-format pin from config/tool_versions.json, reads the captured source through stdin and requests replacement XML. --assume-filename locates project .clang-format configuration; include sorting and qualifier rewriting are disabled to keep this formatting profile conservative. It does not pass -i, invoke a shell or save files. A missing/wrong tool, incomplete parse or process failure leaves the draft intact. Use Choose Formatter... to configure its executable; normal editing needs no tool. No process is launched until formatting is explicitly requested.

One request runs at a time, with a ten-second deadline, an independent 8 MiB encoded-output limit and 8 KiB diagnostic limit; decoded text must fit the document owner's existing source limit. XML replacement decoding yields between two-millisecond slices. Ordered nonoverlapping UTF-8 scalar ranges map the current directed selection, and publication preserves scroll. An edit, Undo, project replacement or source rebinding invalidates the captured identity; rebinding cancels work. Cancellation/failure never adds history. A successful change is one document undo group; an unchanged result adds none. Tool profiles, captured values and result application are shared seams for later script formatters, although this first decoder speaks clang-format replacement XML. There is no format-on-save or script formatter yet.

Slang formatting remains a C++-syntax clang-format profile. Regression fixtures exercise Unicode, CRLF, project style, attributes and common shader constructs; they cannot establish correctness for every Slang extension. Compile/validate a shader after changing it, including after formatting.

Every asynchronous request records project epoch, document identity/revision, shader-context identity and request identity. Validate them before publication, including after close, project switch and tool restart. Map positions explicitly between UTF-8 document bytes, Qt UTF-16 positions and the negotiated language server position encoding. Do not infer freshness from response arrival order.

Responsiveness and host capabilities

Capture edit deltas on the input path; defer full UTF-8 snapshots until a consumer needs them. Cache lexical state by block and update affected blocks plus necessary successors. Bound bracket searches, queues, result sets, diagnostics and caches. Start one language server lazily per workspace/language, shared by its files; use incremental synchronization when the pinned server supports it. Parsing, formatting and shader compilation do not block the UI thread. Prioritize hover and completion over background diagnostics; cancel obsolete requests. Preview compilation has its own debounce. Tool failure/restart must leave editing usable.

Native Open in Code Editor remains available. The launcher opens a saved project file, asks before saving pending changes or copying the built-in graph, and preserves conflicts. Browser text editing can share Qt/Wasm views; native language-server/formatter processes are unavailable in the static browser host. Qualify a Wasm worker or an authorized host transport separately, with explicit capabilities and the same stale-result rules.

Proposed acceptance targets, not measurements: under 4 ms p95 UI-thread edit handling and warm completion within 150 ms on a named reference machine. Measure current admitted file/package sizes, long lines, large pastes, multiple open files and typing during diagnostics. Report end-to-end input/paint latency, memory, startup and idle work. Exercise Unicode mapping, IME, native navigation, undo grouping, source switches, stale replies, formatter failure and server crashes; offscreen tests alone do not qualify native interaction or Qt/Wasm.

The foundation includes opt-in [.benchmark] editor tests with ordinary lines at the current shader file limit, an extreme single line, and an unmodified QPlainTextEdit comparison with the same font/NoWrap settings. The single-line probe exposes a Qt text-layout bottleneck also present in the plain control; this workload is not qualified against the edit-latency target. Keep raw host measurements in ignored output, separate from the portable architecture. Run these probes without concurrent build/analysis jobs before comparing changes.

Incremental delivery

  1. Implemented: shared editing foundation and shader integration; lexical coloring, commands and find/replace.
  2. Implemented: version-checked document adapter/unified text history and native asynchronous formatting, atomic undo, project style and stale-result rejection.
  3. Implemented: compiler diagnostics in the editor. Build/validation messages are marked in the gutter and underlined, with hover text, column-accurate Error List navigation, markers anchored through edits, and out-of-date marking. These are the batch compiler's results, not live analysis.
  4. Slang context integration, unsaved overlays, hover, completion and live language-server diagnostics.
  5. Scripting workspace integration and its language provider.

The code editor architecture carries this plan forward: a document tab for every project text file, multiple cursors, tree-sitter syntax, a generic LSP client for Slang, Luau and C/C++, debugging through DAP, and the engine-specific features, in milestones M0-M9. This section remains the owner of the implemented foundation's contracts.

Toolkit evaluation

Option Where it helps Cost or constraint Decision
Qt Widgets Native forms, trees, tables, text, menus, accessibility, docking; existing C++ tools Custom visual design needs discipline; browser integration has separate limits Selected native presentation
Qt Quick/QML Highly custom, fluid interfaces and declarative visual composition A second presentation language and item/model layer; specialized desktop authoring still needs work Evaluate for a specific surface only when Widgets fails its task
DOM application, optionally desktop hosted Browser semantics, inspectable layout, broad web UI ecosystem; strongest alternative for browser authoring Additional bindings/toolchain and desktop packaging; native GPU composition/process services still need explicit integration Browser fallback; whole-editor replacement requires evidence
Dear ImGui Developer diagnostics and rapidly built renderer-connected tools Upstream documents incomplete accessibility and international text shaping/bidirectional support Optional developer overlay
Ludus-owned widget toolkit Complete control over drawing and dependencies Ludus must own editing, IME, shaping, focus, accessibility, docking, clipboard and platform behavior indefinitely Do not build for the editor
Different native UI stacks per OS Deep platform customization Duplicate layouts, bug fixes and authoring integration Shared Qt views with narrow host adapters

Qt's model/view APIs allow a projection over a separate data repository; the widget model need not own the document. Use that seam instead of item widgets as the database. Qt model/view documentation. Mixing in QQuickWidget has a documented extra render pass and disables the threaded Quick render loop; this specific embedding cost does not imply that all Qt Quick applications are slow. Qt QQuickWidget. Dear ImGui's own stated limitations inform its role here. Dear ImGui.

Start with Qt's built-in docking and a small number of named panels. Prototype advanced docking middleware only if cross-window tab groups or other required workflows cannot be served well; evaluate recovery, accessibility, supported platforms and dependency terms before adoption. A single new feature is not a reason to replace the entire shell. The native shell now uses Qt's docking for a Visual Studio-style layout (below); the evaluation records what middleware would add.

Visual Studio-style shell on Qt docking

The native editor follows Visual Studio's arrangement, which users of game and engine tools already know: a central document well and tool windows docked around it (user guide). Thanks to Microsoft for Customize window layouts and personalize document tabs in Visual Studio (Visual Studio documentation, "Tab groups" and "Arrange windows"); its tab-group, tool-window and reset-layout model informs this shell. Microsoft documentation.

Part Qt Widgets mechanism Owner and contract
Document well QMainWindow central widget: a QSplitter of QTabWidget tab groups (one direction, at most four groups) DocumentWell owns presentation only: open views, order, active group and split. Closing a tab hides a view; its document owner keeps the draft
Tool windows One QDockWidget per tool window with a stable objectName (Project Explorer, Properties, Error List, Output, Content, Render Preview, Live Inspector, Timing) Each reads its owner's copied state or hosts a document owner's panel; none owns a draft
Commands One QAction per command from a small catalogue (stable ID, label, short toolbar label, shortcut, help) shared by menus, toolbars and shortcuts Document and controller operations validate at dispatch; documents have no duplicate command rows
Active document The current tab of the active group Save/Undo/Redo route to it; focus in a tool window keeps the document active
Layout saveState/restoreState (version 5) per user, plus per-project open documents and groups Older, other-Qt or corrupt layouts fall back to the default
Preferences Tools > Options: a modal page list beside the selected page, editing a copy The document owner validates and applies the accepted values

Properties hosts one page per document kind and shows the active document's: the World's entity inspector, or the render graph's node inspector, which follows the canvas selection. Each panel is taken from its document owner (WorldWorkspace::TakePropertiesPanel, RendererWorkspace::TakeInspectorPanel) and keeps editing that document's draft, so Save and Undo still route to the document. The Render Preview tool window hosts the render graph's preview panel (RendererWorkspace::TakePreviewPanel); the preview itself, its borrowed World viewport device and its activation stay with the document owner.

The Render Graph document is the source tree beside Canvas, Source and Plan views. The shell's renderGraph.* commands (File > New/Open Render Graph, the Build menu and the Render Graph toolbar) call its operations and read its gates (CanBuild, CanCreateGraph, IsJobActive, CommandsChanged). Live Preview is document state (SetLivePreview, LivePreviewChanged), so Cancel or a project change unchecks the toggle. Tools > Options edits the document's RenderGraphBuildSettings and shows what the last automatic build resolved. The Error List copies render-graph results and parses build output; navigation calls the producer's existing location mapping (RendererWorkspace::ActivateDiagnostic), whose rows are the Error List's rows in the same order.

The built-in templates live in one catalog, apps/editor/resources/render_graph_templates.json: complete graphs for New Render Graph, snippets for Apply Configuration Template, node types for Add Node and Create Node, and snippets and nodes for the canvas Quick Add palette (RenderGraphQuickAdd). Quick Add also discovers existing package graphs from the current document draft through BuildRenderGraphQuickAddGraphs, shows their default interfaces, and blocks recursive references before insertion. These are Subgraph references; complete built-in templates still open New Render Graph. RenderGraphTemplateRegistry validates the built-in catalog, and each picker offers its kind in catalog order. A new graph or snippet entry also needs its sources under modules/graphics/renderer/render_graphs/ and its identity in the tooling's render_graph_templates.TEMPLATES and render-graph new --template choices. test_render_graph_templates.py checks that the catalog and the tooling agree.

Today one render graph is open at a time. The next subsection designs one canvas document per open .lurender.json.

One document per render graph

Only slice 1 below, owner-closed DocumentWell documents, is implemented. Each open root .lurender.json becomes its own Render Graph document, in the way Visual Studio opens one editor per file. The document owner, RendererWorkspace, keeps its contract: one typed RenderGraphDocument draft, one history, one RenderGraphJob, and one RenderGraphPreview. A new private component, RenderGraphDocuments, owns the set of these documents for the bound project. MainWindow still only routes commands and tool windows to it. This follows the refactoring rules: the set is a narrow owner with its own state, not new branches spread through MainWindow.

Identity and admission. A document's identity is the canonical path of its root graph. Opening a path that is already open activates that document. RenderGraphDocument::Load reads every authored source below its package root. Two documents whose package roots are equal, or where one root contains the other, would therefore keep two drafts of the same files. The set refuses such an open, names the open document that owns the package, and leaves that document unchanged. The set holds at most MAX_RENDER_GRAPH_DOCUMENTS documents (8) plus the main graph described below. A refused open reports its reason and loads nothing. Project Explorer, File > Open Render Graph and shader items (FindPackageGraph) all go through this one admission path.

Main graph and engine default. graphs/Main/main.lurender.json is a permanent slot. While a project is bound there is always one main document. It keeps today's rendererWorkspace objectName, so existing layouts restore, and closing its tab only hides it, as now. When the file does not exist, the slot shows the read-only engine default. Its identity is already the destination path, so Save or Customize creates the package without re-keying the document. New Graph refuses a name whose root graph would collide with an open document, including an unsaved engine default. That stops creation from writing beneath a draft. Every other graph document is dynamic: the set creates it when it opens and destroys it when it closes.

Jobs and creation. Each document keeps its own job, and the existing checks on request, epoch and loaded document reject stale results. Documents in different packages may run jobs at the same time. Their build trees (out/render-graph/<package>/<graph>/) and draft snapshots are disjoint. The set adds no global queue until measurements show contention for the shared worker socket. RenderGraphCreation moves from RendererWorkspace into the set, with one creation per project at a time. New Graph and Open Graph add a document instead of replacing a draft, so they no longer ask to discard one. CanCreateGraph means "a project is bound and no creation is running". It no longer depends on another document's job.

Current graph document. The commands that act on a graph target the current graph document: Validate, Build, Build & Preview, Cook Runtime, Cancel, Live Preview, Tools > Options' resolved tools, and Render Preview. This is the most recently activated graph document. It stays current while a World or another non-graph document is active, because Build & Preview is normally used with the World tab in front. When the current document closes, the next most recently active graph document takes over, and otherwise the main document. Save, Undo and Redo still route to the active document only.

Error List identity. ErrorListEntry gains a nonzero Owner, the document's serial. The set assigns serials from a per-session counter and never reuses them. ReplaceEntries(producer, owner, entries) replaces only that document's rows; build output uses owner 0. Render-graph rows show the File as a project-relative path (package plus package-relative file), so rows from two graphs can be told apart. To activate a row, the shell looks up the document by Owner, opens it, and calls its own ActivateDiagnostic(SourceIndex), so row order still matches MakeRenderGraphErrorEntries. Closing a document removes its rows. A project change removes every render-graph row. A row whose owner is gone cannot navigate to another document.

Properties. Properties keeps one page per document instead of re-binding a shared page. Each inspector panel is built over its own workspace's selection, draft and edit callbacks. Re-binding one page would first require moving inspector state out of RendererWorkspace, which is a separate extraction. The set adds the document's TakeInspectorPanel page to the Properties stack when it creates the document, and removes it when the document closes. Properties shows the page of the active document, or the World page or the hint, as today.

Render Preview and the shared device. The Render Preview tool window holds a stack of each document's TakePreviewPanel page and shows the current graph document's page. Only the current document has a preview source: SetPreviewSource(&World->GetRenderPreview()). Every other document has SetPreviewSource(nullptr), which detaches its RenderGraphActivation and releases its generations on the World viewport device. Its last image stays visible, marked stale, and its requested generation re-stages through ServicePreview when it becomes current again. So at most one activation uses the shared device, and RenderPreview::DeviceStopping still has a single consumer to release. WorldWorkspace::RenderPreviewChanged re-targets only the current document. A document without a source stops its preview timer.

Live Preview stays a per-document state, but at most one document may have it on. Changing the current document turns it off on the previous one, and LivePreviewChanged updates the toggle, because a background document's results could not be shown. The set owns the project's RenderGraphBuildSettings and applies them to every document, including new ones. Options shows the resolved tools and SDK of the current document.

Document well lifecycle. DocumentWell gains documents that the owner closes. They are registered with that flag, and a tab close, Window > Close Document or Close All emits CloseRequested(document) instead of hiding the document. The set asks to discard a dirty draft and cancels the document's job and creation callback. It sets the preview source to null, removes the Error List rows and the Properties and Render Preview pages, and calls RemoveDocument, which unregisters the view without deleting it. It then deletes the workspace before the panels it gave away; their connections use the workspace as context, so they are already disconnected. A dynamic document's objectName is renderGraph. followed by the first 16 hexadecimal digits of the SHA-256 of its project-relative root graph path. So DocumentWell::SaveState still contains no paths. Tab titles are the root graph's file name, with its package folder added when two open documents share a file name, plus * while dirty. Closing the project or the editor asks about every dirty graph document in well order.

Per-project layout. The project workspace file adds renderGraphs: the project-relative root graph paths of the dynamic documents, in opening order, at most MAX_RENDER_GRAPH_DOCUMENTS. The layout version stays 5, because files without the key restore only the main document and the Qt dock state does not change. On restore, the set binds the project first. For each entry it rejects absolute or escaping paths, requires an existing .lurender.json inside the canonical project root, and applies the admission rules, skipping refused entries and naming them in the status bar. Restore reads sources and writes nothing. Only then does DocumentWell::RestoreState arrange the restored objectNames. This reverses today's order, where the well restores before RendererWorkspace::SetProject.

Slice Scope Regression evidence
1 DocumentWell owner-closed documents: CloseRequested, RemoveDocument; Close Document and Close All route through them Well tests: hide versus request-close, removal while active or split, SaveState after removal
2 Error List Owner and project-relative render-graph File Per-owner replacement keeps other owners' rows; activation reaches the owning document's ActivateDiagnostic
3 Extract RenderGraphDocuments holding only the main document, with no behaviour change: creation, build settings, preview-source assignment, current-graph tracking. RendererWorkspace gains a project binding that does not open the main graph Existing editor_shell_tests and renderer_workspace_tests pass unchanged
4 Dynamic documents: admission, cap, per-document Properties and preview pages, current-graph routing, Live Preview handoff, close and destroy editor_shell_tests: two graphs open in two tabs, Properties follows the active one, B's rows navigate to B, closing B cancels its job and removes its rows and pages, an overlapping package is refused, only the current document is attached (native preview builds)
5 Per-project restore of renderGraphs; user guides (workspace layout, render-graph authoring) Restore reopens graphs by relative path, skips missing or escaping entries, and keeps the main document's objectName

Docking middleware evaluation

Qt Widgets docking lacks three Visual Studio behaviours: auto-hide (pinned) tool windows, a drag-target guide diamond, and floating or cross-window document tab groups. Two maintained Qt libraries provide some of them. Neither is added by this change; adoption needs a prototype against the gates below and its own reviewed change.

Qt Advanced Docking System KDDockWidgets
Licence LGPL-2.1 GPL-2.0 or GPL-3.0, or a commercial KDAB licence
Ludus licence fit Usable from MIT-licensed Ludus when dynamically linked or relinkable, with notices; the static Qt/Wasm build already carries comparable LGPL obligations GPL terms would apply to distributed editor binaries; avoiding them needs a commercial licence. A blocker for an MIT project without that purchase
Frontends Qt Widgets (Qt 5 and 6) Qt Widgets and Qt Quick (Qt 5 and 6)
Auto-hide Yes, main window and floating containers Not stated in its README; verify in a prototype
Drop guides Drop overlays while dragging Arrow drop indicators, customizable
Floating windows / tab groups Docking into floating windows; named perspectives Nested floating windows; partial layout save/restore
Stated platform support Developed and tested on Windows; macOS reported working but untested by the author; Linux depends on the compositor, with Wayland limits (native decorations, compositor stacking, no restored floating positions, Qt 6.6.3+) Lists macOS, Linux (X11, Wayland), Windows, WebAssembly and EGLFS
Integration cost Replaces QMainWindow docking with its own dock manager; every tool window and the document well move to its API Replaces QMainWindow docking with its own main window and layout serializer

Read on 2026-10-19 from each project's README. Gates before adopting either: macOS and Linux (X11 and Wayland) acceptance of drag, float, re-dock and restore; keyboard access and accessible names for every dock and tab; recovery from corrupt or old layouts at least as robust as the current versioned fallback; the Qt/Wasm build; no exception-dependent API on normal paths; and a licence review. On today's evidence the Qt Advanced Docking System is the candidate to prototype if auto-hide becomes a required workflow; KDDockWidgets is not viable without a commercial licence.

Dependency and ownership map

flowchart TD
    Qt[Qt native shell / Qt Wasm preview] --> App[Editor application coordination]
    Future[Conditional DOM browser frontend] -. bounded binding .-> App
    Headless[Headless tests / batch authoring] --> App
    App --> Core[Editor document core / commands / schemas]
    App --> Jobs[Job coordination / host capabilities]
    Qt --> Preview[Trusted authoring preview adapter]
    Preview --> Snapshots[Immutable document snapshots]
    Core --> Snapshots
    Preview --> RHI[Ludus rendering / RHI]
    Core --> Policy[Existing parsing / config / content policy]
    Jobs --> Tools[Shared project tools / import workers]
    Jobs --> GameHost[Separate gameplay host]
    GameHost --> Game[Game code / engine runtime]

Arrows describe dependencies or data consumption, not synchronous scheduling. The DOM frontend is conditional, not a second required implementation. The core does not depend on Qt, the renderer, a window, a GPU, or game code. Rendering failure must leave documents editable and saveable.

Owner Authority and lifetime What it exposes
Document core One authored draft, validated edits, history and saved-content identity per document Typed queries, operations, immutable snapshots and revision deltas
Application coordination Open documents, active task, selection, operation routing and capability results Explicit command context and workflow state
Presentation Widgets, focused text buffers, view projections, layout, local navigation User intent; no direct draft or runtime mutation
Host adapters File/persistence operations, process ownership, dialogs and host lifecycle Bounded requests/results and supported/unavailable reasons
Job coordination Operation identity, progress, cancellation and result publication Copied results; never Qt model calls from workers
Authoring preview Trusted scene rendering, cameras, picking and per-surface state Revision-tagged input/results; no arbitrary game-module loading
Gameplay host Game code, simulation and runtime resource lifetime Existing bounded session protocol

These are logical boundaries, not seven mandatory libraries. Start with one private Qt-free document/application library and one private Qt shell target under apps/editor, plus existing tooling adapters. The current target called ludus_editor_core contains Qt types; its name is not proof of this separation. Extract one real workflow before deciding further target splits. Do not build an editor framework in Foundation or export Qt through the installed SDK.

Use existing Foundation parsing, configuration, task and content facilities at their documented boundaries. Project setup continues to use its shared CLI policy. Promote a domain service to its existing engine owner only when a real non-editor consumer needs it. No new allocator, general reflection system, global service locator or event bus is required. Follow Ludus C++23, explicit-error, no-exception, type, include and public documentation standards throughout the migration.

Documents and edit transactions

A document has a stable identity, schema version, authored content, monotonic revision, saved-content identity, and bounded history. Paths are locations, not object identities. Entity/asset/property IDs survive view sorting; runtime handles additionally carry session/generation identity. Qt row indices and raw pointers cannot identify persisted operations.

Revision and dirty state are different. Every committed change, including Undo, advances the revision used to reject stale results. Dirty compares current content with saved content or a valid history savepoint. Undo can restore saved content without restoring its old revision. History eviction must not falsely mark a modified document clean. A save captures content and identity; if edits occur while it runs, completion acknowledges that snapshot and leaves newer content dirty.

Each edit follows prepare, validate, commit and notify. Prepare captures enough before/after data for a reversible operation and reserves required storage. Validation checks the current target, schema, operation constraints and expected revision. Failure preserves the draft and history. Commit publishes one coherent change and a copied delta. Multi-selection edits prepare every target before committing; partial application is not success. Prefer small typed operations and direct functions over a generic reflective setter language.

An interactive gesture owns transient preview state. Begin records its targets; updates change only that transient state; accept publishes one history item; Escape, capture loss, invalidated targets or failure discard the preview. A preview is visually marked and cannot silently enter a save snapshot. Scene transforms, numeric scrubbing and curve edits use this same contract. Small edits store changed values; use bounded snapshots when structural changes justify them. There is one authoritative document history, rather than a second independent Qt undo stack. Undo/Redo checks the current history head, not the original edit's now-obsolete dispatch revision.

Focused text editing has a separate, temporary buffer: incomplete numbers and IME composition must be representable without becoming document values. Background deltas must preserve that buffer; conflicting changes get an explicit resolution path. Text-widget Undo operates on this buffer while it owns focus; document Undo operates on committed changes. Save first validates pending edits in scope and reports invalid ones without discarding them. Close considers pending buffers as unsaved work as well as committed dirty documents.

Source files and authored drafts are separate from simulation state. Live tuning changes a session; copying a supported live value to a draft is an explicit validated command. Saving that draft is another operation. Preserve the existing configuration and live-reload owners and protocols.

Thanks to Robert Nystrom, Game Programming Patterns, “Command,” especially Undo and Redo: reversible operations inform this design. Ludus adds stable IDs, validation and failure preservation rather than adopting raw-pointer examples. Author's chapter. The exact reviewed excerpts are in the reference review.

Commands and property presentation

A small command catalogue describes a stable ID, user label, context, shortcut scope, capability/reason, typed parameters and execution category. Menus, buttons, shortcuts, command search and automation dispatch through the same application operation. Catalogue metadata is not a second source of domain validation. Host and document validation run again at dispatch.

Distinguish reversible document edits, session controls, and external operations such as save/build/import. An external effect cannot be reversed by pretending it is a history entry. Undo after saving changes the draft; it does not rewrite the disk until another explicit save. Automation invokes document commands and job requests, rather than synthesizing widget clicks. Begin with headless tests and batch calls; macro recording or scripting requires a demonstrated workflow.

Property descriptors declare a stable key, value type, display name, group/order, units, defaults, bounds, help, authoring/session scope and editability. The source schema owns validation; Qt adapters select reusable editors. Multi-selection can show a mixed value without overwriting all objects on focus. Display conversions must round-trip deliberately; formatting must not reduce stored precision. Expensive previews and custom editors are registered per property type as needed.

Do not expose every C++ member or use arbitrary raw setters. Metadata describes semantics; it does not bypass commands, ownership or serialization policy. Thanks to Graham Wihlidal, Game Engine Toolset Development (2006), ch. 20, “Using the Property Grid Control with Late Binding,” pp. 199–203, and ch. 43, “MVC Object Model Automation with CodeDom,” pp. 531–537, for metadata and presentation-independent automation. Ludus uses explicit C++ schemas and typed operations, without .NET reflection, CodeDom or a global Application singleton. Author's book.

Threads, jobs and debugging

The UI thread owns Qt objects, model/view notifications and the serialized application/document commit point. Workers receive immutable inputs and return copied results. A worker never calls QAbstractItemModel APIs; the UI adapter applies deltas using Qt's row/role notification contracts. Qt model threading and updates. Use direct calls within an owner; queues only where lifetime/thread/process boundaries require them. An event system for every field change adds indirection without fixing ownership. Browser adapters use cooperative chunks or explicit worker messages as supported; the current single-threaded Pages preview is not proof that native threads or blocking modal waits work there. Threaded Wasm needs its own toolchain/deployment qualification. Asyncify is a compatibility mechanism, not an excuse to block authoring work indefinitely.

Every job captures an operation ID, project epoch, relevant document/content identity and target profile. Source changes may invalidate a result even when the project remains open. Before publication, check those identities and the operation's terminal state. Old jobs cannot publish into a reopened document or launch an old artifact after a failed build. Reuse the project's existing supervision and cleanup contracts.

Cancellation is a request followed by acknowledgement or another terminal outcome. “Cancellation requested” is not “Cancelled”; a completed commit cannot be undone by a late cancellation. Coalesce progress, bound queues/output/history, and reserve terminal delivery so a progress flood cannot hide completion. For imports, stage and validate an immutable artifact before switching the active version; retain the last valid artifact on failure. Do not move blocking waits or unbounded parsing onto the GUI thread.

Correlate command, operation, document revision and session IDs in existing logging/profiling. Inspectable phase transitions and explicit error context should reveal who owns a failure. A bounded diagnostic command record can replay authoring failures against a captured source snapshot; external effects require mocked adapters and are never automatically replayed. It is not a promise of bit-identical gameplay replay or an unbounded capture of project data.

Thanks to Graham Wihlidal, ch. 6, “Measurement Metrics for Tool Quality,” pp. 43–48, ch. 8, “Distributed Componential Architecture Design,” pp. 57–61, and ch. 37, “Responsive UI During Intensive Processing,” pp. 423–428: the adopted ideas are measurable maintainability, multiple entry points and responsive cancellable work. We use private in-process functions and existing process adapters rather than the book's .NET/COM/remoting stack. Author's book.

Rendering and platform hosts

Use Qt for the shell, forms, tables, hierarchy, search and text. Use custom Qt 2D painting for graph/timeline canvases when standard controls cannot serve them; provide keyboard operations and semantic accessibility alongside drawing. Use Ludus rendering for scene/material/game previews and 3D gizmos. Optional ImGui panels serve developer diagnostics. Runtime UI remains its own engine concern; modules/ui is not an editor widget toolkit.

Authoring preview runs trusted engine rendering from immutable draft snapshots; it does not load arbitrary project game code. Gameplay stays in a supervised child process. The proposed Play in the editor viewport design presents that process's window in the World viewport, with a separate window as the user's choice. It defines the format, ownership, sequence, resize and backpressure contracts for macOS Metal and Linux Vulkan only. Do not promise zero-copy sharing between Qt, Ludus and a child process elsewhere. CPU readback is a diagnostic fallback, not the assumed interactive production path.

A render-surface adapter must specify who creates/destroys the window/view/canvas, which thread may access it, pixel dimensions and scale, surface generation, device/session identity, and teardown fencing. Resize, hiding, reparenting, detaching, display change and device loss are normal transitions. Stale picking results carry document, camera and surface identities and cannot select a new scene or use an obsolete image after resize. Reuse the proposed camera ownership and render-view contract for each logical output; editor navigation is its own view state, not authored game camera data.

This integration is not implemented by passing winId() to current RHI. Current Platform native-window handles are borrowed from Platform-owned windows; current RHI window connection is not a general multiple-surface editor API. Prototype a borrowed-surface contract and backend changes separately, preserving Platform ownership. Native child-window embedding, focus, clipping and popups need Linux Wayland/X11, macOS view and Windows tests. Cross-process reparenting is not a portable default. A separate preview window remains the recovery path.

Multiple authoring views should share compatible preview resources/device where the backend permits, while owning independent surfaces, cameras and render state. Do not create a device per panel or assume the Qt compositor shares Ludus's device. A device failure invalidates every dependent surface; each view establishes its state explicitly. Thanks to Graham Wihlidal, ch. 25, “Using Direct3D Swap Chains with MDI Applications,” pp. 243–247, for the shared-resource/per-view-lifetime review. Its Direct3D 9 reset and swap-chain APIs are historical; modern RHI/Metal/ WebGPU behavior must be proven independently. Author's book.

Host Shared presentation and policy Host-specific acceptance
Linux native Qt views and portable authoring core Existing process policy; Wayland/X11 focus, surfaces and dialogs
macOS native target Same Qt views and core Application menu, Command shortcuts, IME/VoiceOver, display scale, NSView/Metal lifetime, app bundle and process cleanup
Windows native target Same Qt views and core DPI changes, dialogs/paths, process-tree cleanup, surface and packaging integration
Browser Current Qt/Wasm preview; qualified full-authoring frontend Asynchronous files, download/recovery, browser focus/IME, semantic accessibility, canvas composition and unavailable native services

Use Qt's public platform behavior for ordinary text, clipboard and dialogs; add narrow host services for operations with different capability/ownership. Do not abstract every Qt API into Ludus. Process availability, filesystem access, audio and render support are independent capability results with useful reasons. An OS rendering backend alone does not establish native editor support.

Browser composition requires a real prototype: Qt/Wasm presentation uses WebGL, while the Ludus viewport uses WebGPU. Separate canvases must agree on logical and physical bounds, clipping, stacking, popup overlap, focus, input and DPI. A DOM frontend has similar viewport obligations. Host a canvas in a supported layout seam; do not rely on Qt private globals as the enduring rendering API. A failed viewport leaves the document UI usable. Browser strategy defines the frontend selection gates and persistence limits.

Play in the editor viewport

Status: approved 2026-10-19. P1 (the World-demo player on the engine Renderer), P2 (World-demo Worlds in the shared World viewport, ScenePreview deleted) and P3 (the viewport frame source) are implemented. P4, the Platform hosted window channel, and P5, the RHI hosted surface, are implemented. P6a (source-owned frame reads) and P6b (PlayViewportSource, which presents a hosted game's frames in a PreviewViewport on macOS Metal) and P6c (Play saved World runs the game in the World viewport, with input forwarding and Stop through the game's window) are implemented on macOS. P7 (the remembered In viewport / In separate window choice, and stated refusals that offer a separate window instead of switching silently) is implemented. P8 brings Play in the viewport to Linux Vulkan: the RHI consumer submits the queue family transfers and fences the release (P8a), and Qt's Vulkan device imports the game's memory (P8b).

While a game plays, the World viewport is that game's window. The game owns rendering and input there and behaves as it would in its own OS window. A separate OS window is a mode the user chooses. Edit and Play both draw through the one engine Renderer. No workspace, adapter or game keeps a private renderer, window path or input path for the same job.

Hosting stays out of process

The game runs in its own process: the Build and Run target, or GameHost for Live Play. The editor never loads game code. This keeps ADR 0012 and ADR 0023 intact:

  • A crashing or hung game cannot take documents with it.
  • Module reload and checkpoint migration keep their supervisor.
  • The editor and the game each own exactly one RHI device, as the current one-device-per-process rhi::CreateDevice contract requires.

In-process hosting is rejected. The game would have to run on the editor's Qt-shared device and inside the editor's frame loop. Its window and input would then differ from those of a shipped game, and a game crash would end the editing session. The cost of keeping it out of process is a cross-process GPU image transport, which RHI does not have yet.

The hosted window

Platform gains one backend, WindowSystem::Hosted. A hosted window is a real platform::Window whose events and surface come from a host channel instead of an OS window server.

To start a session, the editor passes a channel address in the run request: a per-session Unix socket path in a 0700 directory, plus a random token. The tooling adapters (editor_tool.py, play_tool.py) add that address to the child's environment. When the variable is present, WindowManager::CreateWindow returns a hosted window. Game code does not change. The same mechanism covers apps/world_demo, GameHost's FramePresenter and any other Ludus application. Without the variable, the native backend runs as it does today.

The channel carries a bounded, versioned binary protocol owned by Platform. It is separate from the GameHost control protocol and from the tooling stdio events. A connection is accepted only with the token and a peer of the same uid. The editor endpoint owns the socket. Losing the channel delivers a close to the game, just as closing its window would.

The hosted backend implements the same WindowBase contract and event vocabulary as Cocoa and Wayland:

  • resize, in backing pixels plus a scale;
  • visibility: Visible, Occluded or Minimized;
  • focus, as FocusEntered/FocusLost resets with a held-key baseline;
  • keyboard records through the attached sink;
  • close.

Platform has no native pointer, cursor capture, fullscreen or text input yet. Each of these lands in every backend in the same change, the hosted backend included, so the viewport is never more or less capable than a native window.

Key translation has one owner. Platform publishes its existing native key-code maps: Cocoa's MapKeyCode and the evdev keymap. The editor's Qt adapter converts QKeyEvent::nativeVirtualKey/nativeScanCode through those maps, not through a second table.

Hosted window channel

Platform implements the hosted backend on macOS and Linux, with or without Wayland. Browser and Windows builds have none: there the endpoint reports Unavailable.

  • Endpoint. HostedWindowEndpoint (<ludus/platform/hosted_window.h>) is Qt-free and nonblocking. Start creates a mkdtemp directory (mode 0700) in a caller-chosen parent, binds the socket window inside it (mode 0600) and draws a 32-byte token from system entropy. The caller pumps it each frame and sends resize, visibility, focus, key and close events through it. It admits one window. After admission it removes the socket file, so no other process can connect. Stop closes the channel and removes the directory.
  • Address. The value of LUDUS_HOSTED_WINDOW is 1:<64 lowercase hex token digits>:<absolute socket path>. The path is at most 103 bytes, so it fits sockaddr_un on both systems; keep the parent directory short.
  • Game side. When the variable is present, WindowManager::Initialize only validates the address. It does not start AppKit, so no Dock icon or menu appears. CreateWindow connects, checks that the endpoint has the same effective uid, sends Hello and waits up to 5 seconds for Welcome. An invalid address, a refusal or a timeout fails creation; it never falls back to a native window. CreateInfo dimensions are ignored, because the host decides the extent. NativeWindowInfo reports WindowSystem::Hosted with backing-pixel Width/Height and Scale. RHI renders into the window's hosted surface, reached through NativeWindowInfo::HostedSurfaceOrNull.
  • Admission. The endpoint first checks the peer's effective uid (getpeereid on macOS, SO_PEERCRED on Linux). It then requires a complete Hello within 5 seconds, carrying the protocol version, input::KEY_COUNT and the token, which it compares in constant time. A refused peer gets a Refused message with the reason (WrongUser, BadToken, IncompatibleVersion, MalformedHello or HandshakeTimeout) and is disconnected. The endpoint keeps listening for the real window and counts each refusal.
  • Protocol. The protocol is version 3. Version 1 had no hosted surface and version 2 no consumer adapter in the Welcome; either side refuses another version as IncompatibleVersion. Each message is a little-endian 4-byte header (uint16 kind, uint16 payload size) followed by a fixed-size payload. A frame payload is at most 255 bytes, so a peer can read and refuse another version's Hello. The address keeps its own format version, 1:.
Message Direction Payload
Hello window → endpoint magic LHWN, version, key count, 32-byte token
Welcome endpoint → window version, visibility, flags, width, height, scale, 16-byte Mach rendezvous nonce (zero on Linux), and the consumer adapter's deviceUUID and driverUUID (zero unless the consumer-adapter flag is set)
Refused endpoint → window refusal reason
Resize endpoint → window width and height in backing pixels (each ≤ 32768), scale (finite, in (0, 16])
Visibility endpoint → window Visible, Occluded or Minimized
Reset endpoint → window FocusEntered, FocusLost or Overflow, plus a held-key bit mask (FocusEntered only)
Key endpoint → window physical input::Key, Down/Up, repeat flag
Close endpoint → window none
Images window → endpoint generation, width and height (1 to 32768), format (RGBA8), handle kind, and for Vulkan three allocation sizes, the memory type, deviceUUID and driverUUID; the handles travel alongside
Present window → endpoint generation, image index, frame sequence
Release endpoint → window generation, image index

Either side treats an unknown kind, a wrong size, a nonzero reserved byte or an out-of-range value as a violation and closes the channel. After the Hello, a window sends only Images and Present; the endpoint treats anything else as a violation and disconnects it.

The window reproduces the Cocoa contract:

  • FocusEntered delivers the baseline without press edges.
  • FocusLost, Overflow and close deliver an empty, unfocused reset. A repeated FocusLost is ignored.
  • Keys that arrive without focus are dropped.
  • A newly attached sink receives the current focus state on the next HandleEvent.
  • Minimized publishes a zero extent.
  • Close, and loss of the channel for any reason, deliver WindowClosed. Events received before the loss are still delivered first. HandleEvent then returns false.

The endpoint mirrors the extent, visibility, focus, held keys and close request. State set before admission goes into the Welcome, followed by FocusEntered and Close when they apply. Outgoing bytes queue in a fixed 16 KiB buffer. When the game does not drain it, a send returns Overflow and drops that message. Once the queue drains, the endpoint resends the current extent and visibility, an Overflow reset and the current FocusEntered baseline, so no key stays stuck. The editor converts native keys with MapCocoaKeyCode (Qt's nativeVirtualKey on macOS) or MapEvdevKeyCode (Qt's nativeScanCode minus 8 on Linux), from <ludus/platform/native_keymap.h>.

ludus_platform_hosted_tests covers this contract over socketpairs: every event, focus baselines, sink synchronization, overflow recovery, close, channel loss, protocol violations and each refusal. A uid refusal is produced by expecting another uid. The same target also runs WindowManager against a listening endpoint, checking the directory mode, socket removal after admission, a forged token and an invalid address. ludus_platform_native_keymap_tests checks that the public maps match the backend tables entry for entry.

The hosted surface and frame ownership

RHI connects a device to a hosted window exactly as it connects to any window. CreateDevice(app, {.System = Hosted, …}) yields a hosted surface. BeginFrame, EndFrame and the Renderer's surface draws are unchanged. The hosted surface is a swapchain whose presentation engine is the editor:

Step Producer: game RHI Consumer: editor viewport
Configure Allocates three exportable RGBA8 images at the configured size, tagged with a surface generation Imports each image onto its own Qt-shared device and records the generation
Acquire BeginFrame takes an image the consumer has released. If none is free it returns FrameStatus::Skipped without blocking —
Present After EndFrame's submission token is Ready, sends Present with image, generation and sequence Wraps the image as the pending Qt texture through the existing lease path
Release The image becomes acquirable again Returns the image after the Qt frame that sampled it ends (afterFrameEnd)
Resize Allocates a new generation on the next frame. Old images retire once all are released Keeps the old generation's last image until the first new-generation frame arrives

Completion is fenced on the CPU. The game announces a frame only after its GPU work has completed. The editor returns an image only after its own sampling has completed. Neither side needs the other's fence objects. Shared GPU events (MTLSharedEvent, external semaphores) are a later optimization, made only if measurements justify it. A skipped frame never undoes simulation, as specified in frame and tick updates.

Backends:

  • macOS Metal. Images are IOSurface-backed MTLTextures. Each IOSurface is sent as a Mach send right (IOSurfaceCreateMachPort, then IOSurfaceLookupFromMachPort) through a per-session Mach rendezvous port that the editor registers. A Unix socket cannot carry Mach rights. Global IOSurface IDs are rejected because any process can look them up. The editor creates its texture on Qt's MTLDevice from the IOSurface. The P5 probe, ludus_platform_hosted_mach_tests, confirmed this exchange against Apple's IOSurface documentation before the backend landed: IOSurfaceCreateMachPort passes a surface without making it global, and kIOSurfaceIsGlobal is deprecated as insecure.
  • Linux Vulkan. Images use VK_KHR_external_memory_fd: export with vkGetMemoryFdKHR, import with VkImportMemoryFdInfoKHR. The fds travel over the same Unix socket with SCM_RIGHTS. Opaque fd handles require the same VkPhysicalDeviceIDProperties::deviceUUID and driverUUID on both sides. On a mismatch, the editor refuses Play in the viewport and states the reason. Queue ownership transfers use VK_QUEUE_FAMILY_EXTERNAL. Thanks to the Khronos Vulkan Working Group for the VK_KHR_external_memory_fd and external memory handle type compatibility reference pages, which define the export, import and UUID-matching contract adopted here.
  • Browser and Windows. Not covered. The browser editor keeps its separate web-sample player, and Platform has no Win32 backend. Each needs its own decision.

There is no CPU-readback Play transport. When hosted images are unavailable (offscreen Qt, mismatched adapters, a missing extension), the editor states why and offers the separate window. It never switches modes silently.

On the editor side, imported images reach the screen through the existing presentation path: QQuickWindow, ImageItem, two frame slots, and release at afterFrameEnd. PreviewViewport presents frames from a narrow ViewportFrameSource with two implementations:

  • RenderPreview, the in-process edit renderer;
  • PlayViewportSource, which holds the imported hosted images.

PlayViewportSource owns the session's HostedWindowEndpoint and HostedSurfaceConsumer and pumps both on a 4 ms timer, including before a viewport shows it, because the game waits in window creation for the endpoint. Each read it opens holds one game frame. Ending a read calls HostedSurfaceConsumer::ReleaseFrameAfterQueue, which commits a marker on Qt's queue behind the sampling: an empty Metal command buffer, or a Vulkan fence submitted with no batches. The next pump returns the image to the game once that marker completes. This uses the same queue-order guarantee as RHI's native texture reads, with no CPU wait. PreviewViewport::SetFrameSource switches a viewport between RenderPreview and the play source: it ends every read of the old source, then presents the new source's pending frame, so the caller switches once the game has a frame.

On Vulkan the viewport records nothing of its own. The play source's TakeFrameRead acquires the frame, and the consumer submits the queue family acquire to Qt's queue before Qt records the frame that samples it. Ending the read queues the consumer's release and fence behind that frame's submission. The editor creates Qt's Vulkan device and enables VK_KHR_external_memory_fd, with VK_KHR_external_memory where the adapter still lists it, whenever the adapter supports it. Without it the viewport still edits, and Play in the viewport states why it is unavailable. The readback canvas cannot host a game either, because it has no Qt device to import onto. Vulkan imports must be destroyed before Qt's device, so the World viewport ends Play when the edit preview announces DeviceStopping.

Each source owns its leases and generations, and the native read Qt samples. TakeFrameRead opens a read of the pending image on the viewport's device and returns its NativeTextureInfo. After the Qt frame that sampled it has been submitted, the viewport calls EndFrameRead. The source answers Pending until the consumer queue has finished that sampling, and the viewport keeps the slot and asks again. DrainFrameRead ends a read during teardown. The default implementation opens an RHI native read of a RasterViewport image, which is what RenderPreview uses; the play source overrides all three with its imported frames. The play source keeps a retired generation alive until its last lease is released, as PreviewGpuGeneration does. Play frames are shown 1:1 at the full viewport pixel size. The 1024-pixel cap and the stretch mapping apply only to edit previews.

Edit-preview scheduling stays on the GUI/device owner thread. Submitted producer work is polled every 1 ms only until completion, and pending consumer reads use the same retry interval after Qt submits its frame. Returning an image queues a coalesced producer wake; it never submits recursively from Qt's frame-end callback. A 16 ms fallback services previews without a returning consumer. Navigation preserves the existing service deadline, so continuous input cannot starve rendering. Stop cancels completion polling and queued wakes. Nested event loops defer producer servicing, and cannot retire a read while Qt is recording.

Hosted surface channel

P5 implements the hosted surface on macOS Metal and Linux Vulkan. The editor does not use it until P6.

  • Transport. Platform moves the handles; RHI never sees the socket.
  • File descriptors ride on the first byte of their Images message with SCM_RIGHTS. They are received close-on-exec, and a surplus is a violation.
  • Mach send rights go through the session's rendezvous. Start draws a 16-byte nonce and registers the bootstrap name org.ludus.hosted-window.<nonce hex> with bootstrap_check_in. The Welcome carries the nonce, and the window finds the port with bootstrap_look_up.
  • The name is not a secret. The endpoint accepts a Mach message only when all three hold: the kernel's audit-token pid equals the admitted socket peer's (LOCAL_PEERPID), the euid is the editor's, and the session token matches. Any other message is destroyed with its rights.
  • The window sends the Mach message before the socket message that announces it, so the endpoint finds the rights already queued.
  • Images, Present and Release travel on the same socket in order.
  • Window side. The hosted window implements platform::HostedSurfaceChannel (<ludus/platform/hosted_surface.h>): PublishImages, Present and TakeRelease, all nonblocking.
  • TakeRelease takes Release messages out of the receive buffer. Window events stay queued, in order, for HandleEvent.
  • A full socket returns Busy, and nothing is consumed.
  • Endpoint side. HostedWindowEndpoint::TryTakeSurfaceEvent yields Images (with owned handles) and Present events in arrival order.
  • At most eight events wait. While the queue is full the endpoint stops reading, so the game sees Busy instead of losing frames.
  • SendRelease is never dropped: up to twelve releases wait in order for a full send queue.
  • Producer. The RHI backends share HostedRing:
  • It keeps at most three live generations, the current one and two retiring.
  • BeginFrame returns Skipped without waiting in four cases: the consumer holds every image, the new generation is not yet published, a resize finds every generation slot held, or the channel is gone.
  • A frame is announced only after its command buffer (Metal) or frame fence (Vulkan) has completed. Frames of a retired generation are freed unannounced.
  • Metal draws surface pipelines in the session's surface format: RGBA8 when hosted, BGRA8 otherwise.
  • Vulkan enables VK_KHR_external_memory_fd and skips adapters without it. It tries the consumer's adapter first; see the adapter hint below. After the last pass it releases the image from TRANSFER_SRC_OPTIMAL to VK_QUEUE_FAMILY_EXTERNAL in SHADER_READ_ONLY_OPTIMAL. A depth epoch replaces the generation whenever the shared depth attachment is recreated.
  • Consumer. rhi::HostedSurfaceConsumer (<ludus/graphics/rhi/hosted_surface.h>) is Qt-free. It takes the editor's NativeDeviceIdentity and imports each generation onto that device.
  • Pump runs after the endpoint's Pump.
  • AcquireFrame returns the newest completed frame. A frame superseded before acquisition, or a late frame of a replaced generation, goes back unseen.
  • ReleaseFrame goes after the editor's sampling has completed. A replaced generation's imports are destroyed once none of its frames is held.
  • The consumer submits what its backend needs on the identity's queue, so callers record nothing backend-specific and the public API names no Vulkan object beyond the sampled VkImage and its layout. Each backend keeps its native objects behind an opaque state that the portable bookkeeping addresses by generation slot and image index. Vulkan entry points come from volk instance and device tables loaded at Start, so they survive an RHI session on the same device finalizing volk.
  • On Vulkan, an image changes queue family ownership in both directions, through two command buffers recorded once per imported image. AcquireFrame submits the second half of the game's release: from VK_QUEUE_FAMILY_EXTERNAL to the identity's queue family, with the same layouts (TRANSFER_SRC_OPTIMAL to SHADER_READ_ONLY_OPTIMAL). Sampling submitted to that queue afterwards needs no barrier.
  • ReleaseFrameAfterQueue returns a frame once the consumer's queue has finished the work already submitted to it, with no CPU wait. Metal commits an empty command buffer behind the sampling. Vulkan submits the image's release back to VK_QUEUE_FAMILY_EXTERNAL with a per-image fence, which signals once all earlier work on the queue has completed; teardown waits for it before destroying the imports. ReleaseFrame submits nothing, because the game discards an image's contents before it renders into it again.
  • A refusal is terminal for the session and has a reason: AdapterMismatch (Vulkan UUIDs differ), UnsupportedHandle, ImportFailed or ProtocolViolation.
  • IOSurfaces work across GPUs, so Metal has no adapter check.
  • Qt's Vulkan device must enable VK_KHR_external_memory_fd; the editor enables it whenever the adapter supports it (P8b).
  • Adapter hint. The editor ranks its Vulkan adapters by type (discrete first), while the game would take the first adapter with VK_KHR_external_memory_fd. On a multi-GPU Linux host the two can differ, so the editor announces its adapter. rhi::TryGetHostedConsumerAdapter reads the consumer device's deviceUUID and driverUUID, the editor passes them as HostedWindowEndpoint::StartInfo::ConsumerAdapterOrNull, and the Welcome carries them. The hosted window reports them from HostedSurfaceChannel::TryGetConsumerAdapter. When Vulkan RHI connects a device to the window, it tries the adapters whose two UUIDs both equal the consumer's first, in enumeration order, then the rest. The same GPU on another driver is not preferred, because its memory cannot be imported.
  • The hint is a preference. When no adapter matches, or the matching one lacks a required feature, the game selects as before and logs a warning; the consumer then refuses its images with AdapterMismatch and the editor states why. Metal announces no adapter, because IOSurfaces work across GPUs.
  • Evidence.
  • ludus_platform_hosted_tests covers the version-3 codec, descriptor and Mach transport over socketpairs, release extraction, backpressure, deferred releases and violations. It checks the Welcome's consumer adapter from StartInfo to TryGetConsumerAdapter, and refuses version-1 and version-2 windows.
  • ludus_platform_hosted_mach_tests is the Mach probe.
  • ludus_rhi_lifecycle_tests pins HostedRing transitions and the hosted adapter order: the consumer's adapter first, the same GPU on another driver not preferred, and enumeration order without a match.
  • ludus_rhi_hosted_surface_tests runs ludus_rhi_hosted_producer, an ordinary RHI application, in a hosted window. It covers: the known colour, no reuse of a held image, Skipped while every image is held (each call returns in under 100 ms), recovery on release, five resizes that need retirement, and refusal of forged images. On Linux it opens with the device-UUID probe, checks the UUIDs TryGetHostedConsumerAdapter announces, and refuses forged adapter UUIDs. Every session announces the consumer's adapter, as the editor does. Its Linux cases sample acquired frames with no barrier of their own, check that frames released after the queue come back only once the fence behind their unwaited sampling has signalled, and that a Vulkan identity without its queue is refused.

Input, focus and capture

In Play, PreviewViewport stops navigating and forwards input instead. It does not interpret game keys.

  • Focus. Keyboard focus entering the viewport (by click or Tab) sends FocusEntered with the current held-key baseline. Focus loss, editor-window deactivation, hiding and modal dialogs send FocusLost. These are the transitions that Cocoa key-window changes and Wayland keyboard enter/leave already produce.
  • Shortcuts. While the viewport has Play focus, it accepts ShortcutOverride, so menus and editor shortcuts cannot take game keys. Two chords stay reserved for the editor:
  • Shift+F6 stops Play (the existing Stop shortcut).
  • Shift+F1 releases capture and focus without stopping the game.
  • Esc belongs to the game.
  • Capture. Once Platform supports cursor capture, a capture request from the game makes the viewport grab the mouse and hide the cursor, as navigation fly mode already does. The release chord, focus loss and Stop each end the capture.
  • Resize, scale and visibility. A viewport resize becomes a hosted resize event in backing pixels and scale. Switching tabs or hiding the dock becomes Occluded, so GameHost's existing 10 Hz hidden pacing applies unchanged.
  • Close. Stop sends a hosted close, the same as clicking a native window's close button, so the game shuts down along its normal path. The existing supervisor timeout and process-group cleanup remain the backstop.

Separate window mode

Play offers In viewport (the default) and In separate window. The choice is a local editor preference, not project data. In separate-window mode the launcher omits the hosted-window variable, so the same binary with the same arguments opens its native window. The World viewport keeps the edit view and labels it as playing elsewhere. A mode change applies to the next Play; a running session does not move between modes.

Edit and Play transitions

One World viewport serves every World, World-demo included, in both modes.

  1. Edit. RenderPreview renders the World extraction from the document snapshot and editor camera through the engine Renderer. Input drives navigation. The Render Graph document's preview follows WorldWorkspace::GetRenderPreview.
  2. Play requested. The existing preconditions still apply: a saved project, clean drafts, and Build and Run or Live Play eligibility. The edit preview stops producing frames but keeps its device and its last image. The play source starts Pending, and the viewport keeps showing the edit image until a game frame is Ready.
  3. Playing. The first presented game frame makes the play source Ready, and the viewport switches to it. Input switches to forwarding. The render-graph preview keeps its edit source; reloading a graph inside the game belongs to the live-reload asset path.
  4. Stop, exit or crash. Leases drain, and imported images are destroyed after release. The edit source resumes from the document; simulation never writes authored state. A crash or failed start is reported in the status area and Output, and the edit view returns.

World-demo on the engine Renderer

World-demo levels contain only 2D oriented boxes, and every one is opaque: collision, scatter and role visuals all use alpha one (apps/world_demo/world.cpp). They map onto existing Renderer features without a new shader:

  • One unit-quad MeshDescription.
  • One SceneItem per RenderFrame draw. Its Affine3 comes from position, rotation and half extent, its FlatMaterial from the draw colour, and it uses the Opaque layer. The draw index becomes an explicit depth key, so collision boxes sit under scatter and scatter under actors, with no blending.
  • The camera is the gameplay CameraSample that GameWorld::Extract already evaluates. The existing camera_view builder turns it into an orthographic ViewDescription.

A single Qt-free function owned by apps/world_demo turns a RenderFrame into these items and the view. The runtime calls it every frame and draws with Renderer on an explicit CreateDevice device instead of rhi::Start. The editor builds a GameWorld from the projected level (ProjectWorldDemo) without ticking it and extracts one frame. It then passes the same items to a RenderScene, so World-demo Worlds render in the shared viewport like every other World. GetRenderPreview returns that preview, so the render-graph preview also works for World-demo Worlds. Edit and Play draw identical geometry, scatter included, because both use the game's own extraction.

A World-demo frame holds up to 193 draws (MAX_RENDER_DRAWS, including the room floor). That is more than RenderScene's current 128-object bound but within the Renderer's default of 256. P2 raises the RenderScene bound to a named constant owned by the Renderer limit, so the limit is not written as a second literal.

The following are then deleted:

  • ScenePreview and its unavailable stub;
  • SceneDocument and the ludus_editor_scene library;
  • world_scene.slang and the editor's editor_scene shader;
  • the World-demo Open rendered preview button;
  • the [.scene-native] journey.

ScenePreview's keyboard gestures (WASD cursor, Enter pick, arrow transforms) are not ported. Transforms are edited in the World inspector, as they are for every other World. Viewport picking and gizmos are later work for all Worlds, not a World-demo feature.

Delivery stages

Each stage is one PR to develop, with regression tests, documentation and the AGENTS.md gates.

Stage Boundary Evidence
P1 World-demo runtime on the engine Renderer: shared item/view builder and an explicit device. The runtime stops using world_scene.slang Builder unit tests for transform, order, colour and camera; headless and --frames runs; offscreen readback against the expected box layout
P2 World-demo edit view in the shared viewport. Deletes ScenePreview, SceneDocument and world_scene.slang World-demo Worlds publish a RenderScene and a RenderPreview; the render-graph preview attaches; inspector edits and undo update the frame; gesture tests replaced
P3 ViewportFrameSource extracted in PreviewViewport, with no behaviour change Existing viewport lease, resize, retire and offscreen tests pass unchanged
P4 Platform hosted-window backend, editor endpoint and public native key-code maps Socket-pair tests for each event, the focus baseline, close on channel loss, and token/uid refusal
P5 RHI hosted surface: Metal IOSurface and Vulkan external-memory export and import Two-process GPU tests: known colour, release-gated acquire, Skipped on exhaustion, retirement of resized generations, adapter-mismatch refusal
P6 Play in the viewport: launcher variable, play source, input forwarding, reserved chords and transitions Controller and viewport state tests; Cocoa journey playing World-demo in the viewport with native keys and resize; return to edit after crash and Stop
P6a Source-owned native frame reads: ViewportFrameSource opens and ends the read Qt samples, with no behaviour change Existing frame-source, viewport and native World tests; a source answering Pending keeps its slot until it ends the read
P6b Play source over the hosted surface, with a queue-ordered release on Qt's queue (macOS Metal) Two-process test presenting a producer's frames through PreviewViewport; releases follow Qt's completed sampling
P6c Launcher variable, edit and play transitions, input forwarding and reserved chords Controller and viewport state tests; Cocoa journey playing World-demo in the viewport; return to edit after crash and Stop
P7 Separate-window preference and unavailable reasons Preference persistence; launch without the variable; refusal-message tests
P8 Play in the viewport on Linux Vulkan, in two PRs As below
P8a RHI consumer on Vulkan: the consumer submits the queue family acquire and release itself, with a fence as the queue-ordered release marker Linux two-process tests sampling acquired frames; queued releases return only after the fence behind the sampling
P8b Qt's Vulkan device enables external memory; PlayViewportSource accepts Vulkan; Play ends before the viewport's device stops Play source tests on both APIs; the [.play-native] journey on Xvfb with lavapipe in Linux CI (evdev keys, resize, Stop, crash)

P3's private ViewportFrameSource carries only frame production and leases: HasPendingFrame, TakeFrame, ReleaseFrame, Resize, FrameReady and ResettingFrames. Device admission, navigation and the statistics overlay stay on RenderPreview, and the browser canvas still leases from RenderPreview directly. A source's images must live on the device that RenderPreview admitted. P6 splits into three PRs. P6a moves the native read into the source, so the play source issues its own reads instead of the Renderer's ViewportFrame leases, which only RasterViewport can create. Play in the viewport shipped on macOS Metal first; P8 adds Linux Vulkan once Qt's Vulkan device enables VK_KHR_external_memory_fd and the viewport records the external queue family transfers.

P1, P3 and P4 are independent of each other. P2 needs P1, P5 needs P4, and P6 needs P2, P3 and P5. P5 opens with its macOS Mach rendezvous and Linux device-UUID probes in the same PR.

Implementation boundaries and refactoring

Large source files in scene cooking, rendering and the editor combine workflows with different failure and lifetime contracts. Reduce coupling through private components that own their state. Line counts prompt review; moving definitions into headers or several files around an unchanged shared state does not establish a boundary. Preserve public APIs and module dependency direction during the initial extractions. The contributor policy is in AGENTS.md.

The first extraction introduces PreviewGpuGeneration in the editor's private implementation. Each generation freezes its validated scene and shading mode, owns its renderer, assets, snapshot and lighting resources, and advances named preparation stages through Pending, Ready or terminal Failed outcomes. The preview coordinator owns camera/view updates, producer completion, frame publication and routing image releases to the live or retired generation. It retains the previous consumer-leased generation while a replacement prepares. Preparation does not borrow the coordinator's mutable camera or asset handles. Static-lighting publication and frame routing still need their own focused extractions; this change does not complete the decomposition or qualify a new platform. Preserve existing native/browser behavior and explicit unavailable paths.

Sequence Boundary Required preservation evidence
1 Preview GPU preparation and resource generations; then frame publication/retirement and lighting/bake coordination Pending and failed replacements retain the prior leased image; successful replacements publish their own image; release reaches the correct generation; camera/shading/resize and shutdown still work
2 World inspector presentation, import workflow, save/recovery and preview coordination One document history; focused drafts survive failed replacement; stale document/project completions are rejected
3 Scene asset preparation, catalog/dependency assembly and transactional publication Conflict detection, cancellation, paired catalog/level rollback and output preservation
4 Renderer resource/material/view/presentation and lighting/reference services Stable handle ownership, bounded capacities and completion-based retirement; separately compiled private services
5 Reference cooker parsing, geometry/image conversion and serialization Byte-identical reference outputs; reuse the existing general PBRT importer where contracts agree

Use a separate reviewable change per boundary. Establish focused tests for the existing contracts before extraction, identify intentional fixes separately from moves, and run the applicable compiler, sanitizer, format/tidy and source-policy checks. Offscreen GPU tests establish resource/publication behavior; interactive native and browser viewport acceptance remains a separate requirement. Do not mark later stages complete because a file became shorter.

Project graph owns native World output

The native World viewport delegates frame production to the current project's render graph. RenderPreview retains the scene extraction, camera controller and device lifetime; it does not prepare a second built-in scene renderer while delegated. RendererWorkspace owns the immutable build requests and graph activation; WorldGraphView owns output/configuration selection and display. The prebuilt engine default provides the initial producer while a project graph builds, and remains available if that first build fails. The authored graph stays selected, its diagnostics remain visible, and the viewport labels the default fallback. Source/build diagnostics do not fail pending scene preparation while this producer is usable; extraction and unavailable-producer failures still do. The Render Preview dock mirrors that producer instead of defining a second rendering choice.

The existing graph producer finishes its asynchronous attachment read before polling an activation that can retire that generation. Pending and failed builds retain the last complete displayed image. Native import publication receives frame completion only for the current extraction revision. External saves are debounced and re-read before accepting a matching build result; dirty-source conflicts preserve the draft. Attachments and authored root configurations supply diagnostic views. See the canonical workflow and profile limits.

Workspace and visual system

Redesign around the active document, using the interaction contract. Native hosts keep normal OS window decoration and platform menu conventions. Browser hosts use an app container without a simulated OS title bar. One compact application/context row exposes project, document, save state and useful actions. The central task gets space before optional inspector and diagnostics panels.

Keep common actions visible in a stable menu or contextual strip as well as shortcuts/context menus. Place frequent context actions near their work; advanced options and infrequent panels can be disclosed. Do not shuffle action positions based on live usage metrics. Narrow containers switch secondary panels to tabs or drawers, rather than clipping form fields behind empty docks. Tool windows scroll their own overflow so a tabbed neighbour's minimum size never widens a dock column at the documents' expense. Output stays a background tab behind the Error List; a failed job raises the Error List without stealing focus. Large desktop workspaces remain customizable with a recoverable default.

A small semantic design system owns surfaces, text, focus, selection, error, spacing, typography, density and icon roles. Bind it to Qt palettes/style and limited shared styling; avoid a separate stylesheet per panel. Preserve system text behavior, scale and contrast choices. Light/dark and compact/comfortable variants share components. HTML around a Qt canvas cannot theme the widgets inside it. Accessibility includes names, roles, focus order, keyboard equivalents, text entry and usable errors, rather than a color contrast pass alone.

Thanks to David Lightbown, Designing the User Experience of Game Development Tools (CRC Press, 2015), ch. 5, “Excise” and “Progressive Disclosure,” pp. 104–114, and ch. 6, pp. 117–129: remove repetitive navigation and clutter while preserving discoverability; evaluate tasks with real users. These are workflow principles, not a mandate to imitate another editor's skin. Author's companion site.

Performance, evidence and delivery

Ordinary GUI work paints on demand. Active gestures/visible previews schedule frames; inactive or hidden viewports stop or throttle. Preview frame rate does not justify continuously repainting every panel. Virtualize large lists with paged/lazy data, update changed roles, bound thumbnail caches and diagnostics, and prioritize visible data. Preserve selected IDs and focused buffers across updates. Optimize only after tracing input, domain work, notification and paint costs separately.

Initial engineering targets below require a named machine, OS/browser, release profile and reproducible dataset. They are targets, not current results or industry benchmark claims. Baseline before setting enforced regression budgets. Correctness, recovery and accessibility are hard gates even when a frontend is faster. An unsupported browser/host must receive a useful capability explanation.

Exercise Measurement / gate
Ordinary command or property edit Visible acknowledgement p95 under 100 ms; domain mutation and paint measured separately
Active viewport/drag Target 16.7 ms total frame at 60 Hz; report p50/p95 and stalls, with Qt and engine costs separated
Idle open editor / hidden views Record CPU, wakeups and GPU submissions against host baseline; no application-wide fixed refresh loop
10,000 scene objects / 100,000 asset rows Search, scroll, selection and edits remain usable; bounded models/caches; no widget per row
100 Hz session updates / flooding diagnostics Coalesced updates; edit buffers survive; completion and cancellation remain deliverable
Cold start / browser download Record bytes, p50/p95 ready-to-edit, peak memory and failure recovery on declared devices
Add one property and one command Domain registration plus adapter work, no duplicated validation in platform views; record files touched and debugging effort

Deliver in reviewable vertical slices with acceptance evidence:

  1. M1: Portable authoring core. Extract project-settings documents, validation and save into the Qt-free core. Prove the same fixtures and failure semantics headlessly, natively and in Wasm. Test save-during-edit, conflicts, history/savepoint behavior, invalid buffers and explicit allocation/error outcomes. Preserve existing serializers first.
  2. M2: Workspace shell. Redesign the shell and semantic controls using those operations. Verify real keyboard, text, light/dark, narrow-container and panel-recovery tasks, including macOS integration; an offscreen screenshot alone does not qualify a platform.
  3. M3: Platform and viewport qualification. Resolve browser frontend, large-model and native rendering risks with small runnable prototypes. Test Unicode/IME, screen readers, virtualized selection, compositor overlap and device/surface failure. Record decisions against the same tasks. If Qt/Wasm fails hard browser tasks, adopt DOM for browser and retire the preview frontend once it replaces its workflows.
  4. M4: End-to-end scene workflow. Ship one asset/scene slice: import, find, place, select, transform, Undo, save, reopen and Play. Include cancelled/stale jobs, picking after resize, crash recovery and a real rendered frame. Establish multiple-surface RHI support before promising multiwindow scene previews.
  5. M5: Specialist authoring tools. Add material/animation/audio tools through the same schemas, commands and preview ownership. Register trusted extensions statically first; do not auto-load native project plugins or hot-unload Qt object code. Add scripting or worker/plugin protocols only for a concrete task.

After M1–M4 pass their acceptance gates, record the implementation baseline and perform the deferred research review. Return to GDC/UIST and the other selected conferences, journals and talks with observed workflow, performance and maintenance problems; compare bounded experiments before changing the architecture. M3 qualification work remains part of the first implementation. Specialist readings follow the relevant M5 work.

Per-document saves preserve conflict detection and atomic replacement semantics. Multiple files do not acquire a fictitious global filesystem transaction. Design recovery snapshots/journals with explicit bounds and crash guarantees before shipping scene authoring; test truncated writes, stale recovery and failed flushes. Browser persistence/export has a separate host contract: handing bytes to a browser download acknowledges export, not durable saving to a chosen native file. Label that outcome honestly; keep recovery/export reminders until the product's declared persistence guarantee is met. Browser tab-close/unload cannot be assumed to complete an asynchronous save. Reuse existing save policies during extraction rather than silently expanding their guarantees.

The reference review preserves the initial proposal and the actual changes caused by reading. Existing E0/S1 specifications remain historical implementation contracts; this target guides subsequent work.