From 79b10b33d9d6da62b10577e51aa6f6520926d066 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:26:46 -0500 Subject: [PATCH 01/16] Update snapmap-plus-guide.md download depot update. The information was incorrect. --- docs/snapmap-plus-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/snapmap-plus-guide.md b/docs/snapmap-plus-guide.md index 3798c5b..08575cb 100644 --- a/docs/snapmap-plus-guide.md +++ b/docs/snapmap-plus-guide.md @@ -62,7 +62,7 @@ separately. Until then, you'll need to run DOOM on the previous Steam depot: 1. Press **Windows Key + R**, then enter `steam://nav/console` to open the Steam console. 2. In the Steam console, run: ``` - download_depot 379720 379721 2062496009391566631 + download_depot 379720 379721 2206249600939156631 ``` 3. Once it finishes, find the downloaded files under your Steam folder, typically: `C:\Program Files (x86)\Steam\steamapps\content\app_379720\depot_379721` From 1e527f9447051bc5861e870ce7fa76273bb6eb93 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:42:47 -0500 Subject: [PATCH 02/16] Update gitignore to exclude files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a4b3852..4780627 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,9 @@ dist/ # Local Claude Code config (per-machine; not part of the public package) .claude/ -# Personal local-dev convenience script (not for upstream) +# Personal local-dev convenience scripts (not for upstream) /sync-and-build-webview.ps1 +/testScript.ps1 # wrangler's local cache (account metadata -- never commit) feedback/.wrangler/ From cfacf615e7766151aac0711c129da07dc839e814 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:45:50 -0500 Subject: [PATCH 03/16] Update .gitignore deleted and removed personal dev conveneince script from .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4780627..de9a8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ dist/ .claude/ # Personal local-dev convenience scripts (not for upstream) -/sync-and-build-webview.ps1 /testScript.ps1 # wrangler's local cache (account metadata -- never commit) From 067bc9483f2198e44c60d017cd2aa228b2371c2e Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:30:13 -0500 Subject: [PATCH 04/16] ui: keyboard paging for list/combo widgets, hide built-in filter entities Entities, Timelines, and Prefabs lists now page with ArrowUp/ArrowDown, matching the "Runs on" entity picker and the Inherit/Classname combos (which page only while their dropdown is open). Keyboard nav is scoped to skip free-text editing contexts (decl textarea, prefab description, folder rename) so caret movement there is untouched. Also excludes SnapMap's built-in filter/droppable helper entities (id-prefixed "snapmaps/filter/") from every entity list and picker -- they're dev-layer-only utility entities no mapper ever places or edits, so they no longer clutter the list even with Show Hidden checked. A real per-map entity's id always has the map's own path in front of that prefix, so this can't collide with user-placed content. --- src/ui/webview/mockup.html | 172 +++++++++++++++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 8 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 9b0049a..c5f5c7e 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -214,7 +214,7 @@ Inherit/Classname fields moved above the decl editor), so down is always the roomy direction. */ .combo-list { position: absolute; top: 100%; left: 0; min-width: 100%; width: max-content; max-width: 560px; max-height: 260px; overflow-y: auto; background: var(--panel); border: 1px solid var(--border2); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.18); z-index: 1500; display: none; padding: 3px; } .combo-opt { padding: 3px 7px; border-radius: 4px; cursor: default; white-space: nowrap; } - .combo-opt:hover { background: var(--sel); color: var(--selText); } + .combo-opt:hover, .combo-opt.active { background: var(--sel); color: var(--selText); } .combo-hint, .combo-empty { padding: 3px 7px; color: var(--muted); font-size: 11px; font-style: italic; } /* blank / not-in-the-enumerated-list Inherit or Classname -- same red as .toast.err, so it reads as the same "this will be refused" signal */ .combo.invalid input, .combo.invalid .combo-arrow { border-color: #a3352f; } @@ -586,6 +586,7 @@ // primaryEid to the clicked eid before refreshEditorForSelection runs, so // comparing against primaryEid there would always read as "unchanged") var allTimelines = []; // last timelines list from backend (dual-added idTarget_Timeline/idEncounterManager) + var visibleTlEids = []; // eids currently rendered in the Timelines list (in order, post-filter) var selectedTlEid = -1; // timeline row currently highlighted in the Timelines list var tlModel = null; // parsed open-timeline model {eid,isEncounter,name,tabs:[{entity,events:[...]}],active} var tlSavePendingEid = -1; // -1 = no save in flight; else the eid whose NEXT 'timelineData' response is a @@ -610,6 +611,8 @@ var eventDocsRequested = false; var eventDocsByName = {}; // name -> {summary, confidence, source, args: {argName: desc}} -- the EXPLAIN box's data var tlDescOpen = {}; // ":" -> bool, survives the wholesale event-row rebuild on add/delete/tab-switch + var tlEntityComboActive = -1; // #tlRunsOn's keyboard-highlighted option index, -1 = none (reset on type/(re)open) + var tlEntityComboItems = []; // the SAME {id,label} list tlRenderEntityCombo just rendered, for Enter-to-accept var argResclassCache = {}; // resClass (decl short-name OR raw enum type) -> [valid value, ...], fetched lazily var argResclassRequested = {}; // resClass -> true once a lookupArgResclass round-trip has been fired for it var entityAssetsRequested = false; @@ -626,30 +629,51 @@ var inheritCombo = null, classCombo = null; // editable combobox: click the arrow for the FULL list, type to filter, free-text still allowed. + // ArrowDown/ArrowUp (only while the list is open) move a highlighted option; Enter accepts it; typing + // still narrows the list as before (each fresh render clears the highlight so it can't point past the + // filtered-down end of currentOut). function Combo(containerId, itemsFn, onType, onPick) { var c = document.getElementById(containerId); var input = c.querySelector('input'); var arrow = c.querySelector('.combo-arrow'); var list = c.querySelector('.combo-list'); var open = false; + var active = -1; + var currentOut = []; function render() { var q = input.value.toLowerCase(); var items = itemsFn() || []; var out = [], cap = 1200; for (var i = 0; i < items.length; i++) { if (!q || items[i].toLowerCase().indexOf(q) !== -1) { out.push(items[i]); if (out.length >= cap) break; } } + currentOut = out; + if (active >= out.length) active = out.length - 1; var h = ''; - for (var j = 0; j < out.length; j++) h += '
' + esc(out[j]) + '
'; + for (var j = 0; j < out.length; j++) h += '
' + esc(out[j]) + '
'; if (out.length === 0) h = '
no matches
'; else if (out.length >= cap) h += '
first ' + cap + ' shown -- type to narrow
'; list.innerHTML = h; + var act = list.querySelector('.combo-opt.active'); + if (act) act.scrollIntoView({block: 'nearest'}); + } + function show() { active = -1; render(); list.style.display = 'block'; open = true; } + function hide() { list.style.display = 'none'; open = false; active = -1; } + function accept() { + if (active < 0 || active >= currentOut.length) return; + input.value = currentOut[active]; + hide(); + if (onPick) onPick(); } - function show() { render(); list.style.display = 'block'; open = true; } - function hide() { list.style.display = 'none'; open = false; } arrow.addEventListener('mousedown', function(e){ e.preventDefault(); if (open) hide(); else { show(); input.focus(); } }); input.addEventListener('focus', show); - input.addEventListener('input', function(){ if (!open) show(); else render(); if (onType) onType(); }); - input.addEventListener('keydown', function(e){ if (e.key === 'Escape') hide(); }); - list.addEventListener('mousedown', function(e){ if (e.target && e.target.className === 'combo-opt') { e.preventDefault(); input.value = e.target.textContent; hide(); if (onPick) onPick(); } }); + input.addEventListener('input', function(){ active = -1; if (!open) show(); else render(); if (onType) onType(); }); + input.addEventListener('keydown', function(e){ + if (e.key === 'Escape') { hide(); return; } + if (!open) return; + if (e.key === 'ArrowDown') { e.preventDefault(); if (currentOut.length) { active = Math.min(currentOut.length - 1, active + 1); render(); } } + else if (e.key === 'ArrowUp') { e.preventDefault(); if (currentOut.length) { active = Math.max(0, active - 1); render(); } } + else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); accept(); } + }); + list.addEventListener('mousedown', function(e){ if (e.target && e.target.classList && e.target.classList.contains('combo-opt')) { e.preventDefault(); input.value = e.target.textContent; hide(); if (onPick) onPick(); } }); document.addEventListener('mousedown', function(e){ if (!c.contains(e.target)) hide(); }); // Tabbing away doesn't fire a mousedown, so the click-outside listener above never catches it -- the list // was staying open after keyboard-only navigation. focusout (bubbles, unlike blur) + relatedTarget lets us @@ -1579,6 +1603,16 @@ }); + // SnapMap's own built-in filter/droppable helper entities (idVolume_Filter-style prefilters + droppable + // slots the engine seeds on every map: "any player"/"any ai", per-team/per-player/per-race prefilters, + // droppable keycards/flags/power cores). They live under the dev layer (never a real module) and mappers + // never touch them directly -- always exclude them from every entity list/dropdown, independent of + // Show Hidden (which still governs everything else the dev-layer flag hides). Can't key off the "(no + // module)" suffix id_to_string appends -- that also appears on any real entity a mapper just hasn't + // assigned to a module yet -- so match the fixed id prefix engine-side filter entities are always seeded + // under instead. + function isFilterUtilityEntity(e) { return !!(e && e.id && e.id.indexOf('snapmaps/filter/') === 0); } + function renderList() { var listEl = document.getElementById('entityList'); var showHidden = document.getElementById('showHidden').checked; @@ -1586,6 +1620,7 @@ var scroll = listEl.scrollTop; var vis = showHidden ? allEntities : allEntities.filter(function(e){return !e.hidden;}); + vis = vis.filter(function(e){ return !isFilterUtilityEntity(e); }); var rows = vis.filter(function(e){ return !filter || e.id.toLowerCase().indexOf(filter)!==-1 || (e.name && e.name.toLowerCase().indexOf(filter)!==-1); }); visibleEids = rows.map(function(e){return e.eid;}); @@ -1624,9 +1659,11 @@ document.getElementById('tlBadge').textContent = rows.length; // panel-header count: rows actually shown if (rows.length === 0) { + visibleTlEids = []; listEl.innerHTML = '
' + (allTimelines.length === 0 ? 'No timelines (place one from the in-game SnapMap palette).' : 'No matches.') + '
'; return; } + visibleTlEids = rows.map(function(t){ return t.eid; }); var html = ''; for (var i = 0; i < rows.length; i++) { var t = rows[i]; @@ -1637,6 +1674,21 @@ listEl.innerHTML = html; listEl.scrollTop = scroll; } + // Move the Timelines-list selection to the next/previous VISIBLE row and open it, same effect as + // clicking that row (mirrors moveEntitySelection). delta is +1 (ArrowDown) or -1 (ArrowUp). + function moveTimelineSelection(delta) { + if (!visibleTlEids.length) return; + var idx = (selectedTlEid !== -1) ? visibleTlEids.indexOf(selectedTlEid) : -1; + var next = (idx === -1) ? (delta > 0 ? 0 : visibleTlEids.length - 1) + : Math.min(visibleTlEids.length - 1, Math.max(0, idx + delta)); + var newEid = visibleTlEids[next]; + if (newEid === selectedTlEid) return; + selectedTlEid = newEid; + renderTimelineList(); + post({cmd:'openTimeline', eid: selectedTlEid}); + var row = document.querySelector('#timelineList .entity-item[data-eid="' + newEid + '"]'); + if (row) row.scrollIntoView({block: 'nearest'}); + } // ---- Timeline Editor (Stage 2: parse the serialized entity JSON + render the real entity tabs / events) ---- // The engine decl uses numbered pseudo-lists: { num: N, "item[0]": .., "item[1]": .. }. Mirrors tl_list_items. @@ -1865,6 +1917,7 @@ var entItems = []; for (var ei = 0; ei < allEntities.length; ei++) { var ent = allEntities[ei]; + if (isFilterUtilityEntity(ent)) continue; entItems.push({ display: ent.name ? (ent.id + ': ' + ent.name) : ent.id, value: ent.id }); } argResclassCache['entity'] = entItems; @@ -2133,14 +2186,19 @@ var out = [], cap = 8192; for (var i = 0; i < allEntities.length; i++) { var e = allEntities[i]; + if (isFilterUtilityEntity(e)) continue; var label = e.name ? (e.id + ': ' + e.name) : e.id; if (!q || label.toLowerCase().indexOf(q) !== -1) { out.push({id: e.id, label: label}); if (out.length >= cap) break; } } + tlEntityComboItems = out; + if (tlEntityComboActive >= out.length) tlEntityComboActive = out.length - 1; var h = ''; - for (var j = 0; j < out.length; j++) h += '
' + esc(out[j].label) + '
'; + for (var j = 0; j < out.length; j++) h += '
' + esc(out[j].label) + '
'; if (out.length === 0) h = '
no matches -- free text is fine (e.g. player1)
'; else if (out.length >= cap) h += '
first ' + cap + ' shown -- type to narrow
'; listEl.innerHTML = h; + var act = listEl.querySelector('.combo-opt.active'); + if (act) act.scrollIntoView({block: 'nearest'}); } // Fill ONE event row's eventDef dropdown ('.combo-list' inside its OWN .combo, found via rowEl -- there's // one of these per event row, unlike the single #tlRunsOn) from the full eventItems catalog. Same @@ -2374,6 +2432,23 @@ } updateSelDom(); refreshEditorForSelection(); + document.getElementById('filterBox').focus(); // keep ArrowUp/ArrowDown live after a mouse pick, same as a typed filter + } + + // Move the single row selection to the next/previous VISIBLE row (post-filter, respects Show Hidden), + // same effect as clicking that row. delta is +1 (ArrowDown) or -1 (ArrowUp). No current selection -> + // ArrowDown lands on the first row, ArrowUp on the last (matches a fresh Show hidden - +
Waiting for the map...
@@ -2786,7 +2786,18 @@ } } } else if (d.kind === 'selCount') { + var prevEditorSel = editorSelCount; editorSelCount = d.count; updateCreateBtn(); + // "Select in 3D": the 3D editor owns the selection, so a native deselect there (an empty-space click) + // has to clear the list highlight too -- otherwise the list keeps showing entities that are no longer + // selected. Mirrors exactly what the Deselect button does. Gated three ways: only in select mode + // ("Follow selection" already mirrors the whole editor selection via editorSelect, empty case included; + // with both off the list selection is purely local and editor activity must not disturb it), only on a + // real >0 -> 0 transition, and only when the list actually has something highlighted to clear. + if (selectMode && prevEditorSel > 0 && d.count === 0 && selCount() > 0) { + selectedEids = {}; anchorEid = -1; + updateSelDom(); refreshEditorForSelection(false); // false: native already cleared, don't push back + } } else if (d.kind === 'createPrefabResult') { if (d.result === 1) toast('Created "' + d.name + '"', 'ok'); else if (d.result === 0) toast('Nothing selected in the editor -- select entities first', 'warn'); diff --git a/src/ui/webview/snapmap_plus_ui_webview.cpp b/src/ui/webview/snapmap_plus_ui_webview.cpp index 1ed7e9b..4a2cc68 100644 --- a/src/ui/webview/snapmap_plus_ui_webview.cpp +++ b/src/ui/webview/snapmap_plus_ui_webview.cpp @@ -79,7 +79,7 @@ static std::vector g_delete_eids; static volatile bool g_pending_select = false; /* list -> editor selection push ("Select in editor") */ static std::vector g_select_eids; -static volatile bool g_pending_deselect = false; /* explicit "Deselect" button -- clear_selection escape hatch */ +static volatile bool g_pending_deselect = false; /* explicit "Deselect" button -- clear_selection convenience */ static char g_enumbuf[262144]; /* packed-string scratch for enum_inherits / enum_valid_classes */ static volatile bool g_cam_lock = false; /* Camera Origin "Lock Position" */ @@ -574,9 +574,11 @@ static void poc_apply_select_in_editor() } __except (EXCEPTION_EXECUTE_HANDLER) { poc_log("select-in-editor: SEH in apply"); } poc_log("select-in-editor: apply done"); } -/* explicit Deselect: clear_selection only, no re-add. A reliable escape hatch since a native click on - * empty space doesn't clear a selection that was set via add_to_selection (confirmed: native click/drag - * selection deselects fine on its own -- only our externally-driven selection gets stuck). */ +/* explicit Deselect: clear_selection only, no re-add. A convenience (deselect without going back to the 3D + * view) and the escape hatch if the editor's mode state is ever out of sync. NB: this button used to be the + * ONLY way to clear a list-driven selection -- a native empty-space click wouldn't do it. That root cause was + * found and fixed 2026-07-27 (iface_engine.c syncs the EntityMode selection state alongside the selection + * array; see ED_MODE_OBJ_OFF there), so a native click now deselects normally. */ static void poc_apply_deselect() { __try { From 5b0a2624adaf13e3e7d24ee5cde3745a16874d72 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:03:31 -0500 Subject: [PATCH 10/16] Guard the selection-state write to idle/selected only The mode selection-state field is not a two-value flag. The engine drives it to other values while a manipulation is in flight -- grabbing/moving an entity, or holding a staged prefab awaiting placement -- as well as for sub-screens and the logic sub-mode, and it maintains its own "is the mode busy" predicate (engine RVA 0x1254e20, literally `return mode+0x1ac != 1`). The previous commit wrote the field unconditionally. Pushing a selection from the Entities list mid-gesture therefore tore the editor out of the manipulation, so its completion bookkeeping never ran: the held entity or prefab was dropped and permanently lost its module association, not recoverable by refreshing the list. Caught in live testing. mode_set_selection_state() now reads the current value first and only transitions between idle and selected, leaving any other value strictly alone, and skips the write entirely when already in the target state. Accepted trade-off: in a mode outside that pair with nothing selected, the sync sits out and that mode retains the pre-fix behavior (selection lands, but empty-space click will not clear it -- the Deselect button still does). That is strictly preferable to overwriting editor state we do not model. Re-verified live: grabbing an entity or holding a prefab, pushing a selection, then placing the held object leaves the module association intact and the selection present; empty-space deselect, Delete and Move continue to work. Co-Authored-By: Claude Opus 5 --- docs/webview-ui.md | 13 +++++++++++++ src/backend/iface_engine.c | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/webview-ui.md b/docs/webview-ui.md index 4acaac3..b2fbc94 100644 --- a/docs/webview-ui.md +++ b/docs/webview-ui.md @@ -141,6 +141,19 @@ entry at the bottom is the original POC buildout, before this doc tracked dates greater-than-zero-to-zero transition, and to the case where the list actually has a highlight. - The **Deselect** button is kept -- it saves a trip back to the 3D view and stays useful if the mode state is ever out of sync -- but its tooltip no longer describes the (now fixed) stuck behavior. +- **The mode-state write is guarded to idle/selected only.** That field is not a two-value flag: the + engine drives it to other values while a manipulation is in flight (grabbing an entity, holding a + staged prefab) and for sub-screens and the logic sub-mode, and it has its own "is the mode busy" + predicate. An unconditional write tore the editor out of the gesture mid-manipulation so its + completion bookkeeping never ran -- caught in testing as the held object being dropped and + permanently losing its module association. `mode_set_selection_state()` now reads the current value + and only ever moves between idle and selected. Consequence, accepted deliberately: in a mode outside + that pair with nothing selected, the sync sits out and that mode keeps the old (pre-fix) behavior -- + strictly better than risking editor state. +- Side effect of the guard, and a nice one: because a manipulation is no longer interrupted, you can + hold a grabbed entity or a staged prefab, push a selection from the Entities list, place the held + object, and still have your selection. Minor known inconsistency, not considered a problem: after + placing, a prefab leaves the pushed selection highlighted while a pre-existing-entity grab clears it. - Known remaining gap: if you have one entity selected from the list and then natively click a *different* one, the selection count is unchanged, so no broadcast fires and the list keeps highlighting the original. Fixing that means broadcasting selection identity, not just count, in diff --git a/src/backend/iface_engine.c b/src/backend/iface_engine.c index e245979..6f30a65 100644 --- a/src/backend/iface_engine.c +++ b/src/backend/iface_engine.c @@ -399,6 +399,20 @@ static void mode_set_selection_state(int state) int editor_state = 0; if (!ie_read_s32(ed + ED_ENTITY_MODE_OFF, &editor_state)) return; if (editor_state != 2) return; /* not EntityMode -> +0x22330 isn't the active mode object */ + + /* NEVER stomp a state we don't recognise. mode+0x1ac is not a two-value flag: the engine drives it + * to other values while a manipulation is in flight (grabbing/moving an entity, holding a staged + * prefab awaiting placement) and while sub-screens are up, and it has its own "is the mode busy" + * predicate (engine RVA 0x1254e20, literally `return mode+0x1ac != 1`). Overwriting one of those + * mid-gesture tears the editor out of the manipulation so its completion bookkeeping never runs -- + * observed live 2026-07-27 as the grabbed entity (or held prefab) being dropped and losing its + * module association, unrecoverable by a list refresh. So only ever move between idle and selected, + * and leave every other value strictly alone. */ + int cur = 0; + if (!ie_read_s32(ed + ED_MODE_OBJ_OFF + MODE_SEL_STATE_OFF, &cur)) return; + if (cur != MODE_STATE_IDLE && cur != MODE_STATE_SELECTED) return; /* busy -> hands off */ + if (cur == state) return; /* already there -> no write */ + __try { *(int *)((uintptr_t)ed + ED_MODE_OBJ_OFF + MODE_SEL_STATE_OFF) = state; *(uint8_t *)((uintptr_t)ed + ED_MODE_OBJ_OFF + MODE_DIRTY_OFF) = 1; From 8277e1ee8d7ea3ac26b0af78bcb660a23b9c05b2 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:09:26 -0500 Subject: [PATCH 11/16] docs: record list-assembled group grab and the logic-mode finding Grabbing one entity of a list-pushed selection in the 3D view grabs all of them -- not previously possible, and a direct consequence of no longer interrupting the manipulation. Enables browsing a logic chain in the 3D view, adding the entities you want to bring along from the Entities list, then grabbing the node you are on to move the whole group. Also records that logic chain mode works under the idle/selected guard, so the earlier concern that the logic sub-mode might sit outside the permitted pair did not materialise. Co-Authored-By: Claude Opus 5 --- docs/capabilities.md | 1 + docs/webview-ui.md | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/docs/capabilities.md b/docs/capabilities.md index 5aa94ce..7e00e31 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -110,6 +110,7 @@ editor (run `sh` in the console if it doesn't auto-open). Full detail: [`webview | Entities tab | A filterable entity list (multi-select, a persistent Show Hidden toggle, and one persistent selection direction: Follow Selection or Select in 3D; right-click for Copy ID / Delete / Push to stack 0 / Clear stack 0) plus the Entity State panel: classname / inherit / displayname fields and the Decl Text editor — line numbers, syntax coloring, structural lint, advisory schema checks, a distraction-free focus mode. "Save to Decl" commits the edits in memory. SnapMap's own built-in filter/droppable helper entities are excluded from the list and from every entity picker (dev-layer-only; a mapper's own filters are unaffected). | | Keyboard paging | ArrowUp/ArrowDown page the Entities, Timelines and Prefabs lists, and — while their dropdown is open — the Inherit / Classname combos and the Timelines "Runs on" picker. Filtering still works alongside it; text editors and rename fields keep normal caret movement. | | Native selection parity ("Select in 3D") | Entities selected from the list behave exactly like ones clicked in the 3D view: an empty-space click deselects them, Delete removes all of them, Move works, and the bottom-bar controls apply — single or multi-entity, including switching between entities. Deselecting natively also clears the list highlight. Previously only the explicit Deselect button worked, and Delete/Move misbehaved (Move could soft-lock the game); the cause was that the editor's own mode state was never told a selection existed. | +| List-assembled group grab | With a selection pushed from the Entities list, grabbing any one of those entities in the 3D view grabs **all** of them — not previously possible. Practical use: browse a logic chain in the 3D view, add the other entities you want to bring along from the Entities list, then grab the node you're on and move the whole group. You can also hold a grabbed entity or a staged prefab, push a selection from the list, and place the held object without losing the selection. | | Prefabs tab | Save and load selection prefabs as JSON files under `%LOCALAPPDATA%\snapmap-plus\prefabs\` — one folder level with rename/delete/drag-between-folders; per-prefab description + tags (stored in a `.meta.json` sidecar; the filter box matches tags across folders); "Load / Place" stages the prefab and the user pastes it with Ctrl+V. | | Timelines tab | The list of timeline entities; opening one edits its events and per-event parameters, with reference/decl/enum parameters constrained to valid choices, entity pickers for entity-typed args, and per-event documentation. | | Feedback ("?") | The "?" button at the statusbar's right edge opens the Send-feedback dialog: category (bug / feature / incorrect info / other), title, details, optional contact. Sending files it as a labeled issue on this repo's tracker — no GitHub account needed. See the network note below + [`feedback.md`](feedback.md). | diff --git a/docs/webview-ui.md b/docs/webview-ui.md index b2fbc94..b27359f 100644 --- a/docs/webview-ui.md +++ b/docs/webview-ui.md @@ -154,6 +154,14 @@ entry at the bottom is the original POC buildout, before this doc tracked dates hold a grabbed entity or a staged prefab, push a selection from the Entities list, place the held object, and still have your selection. Minor known inconsistency, not considered a problem: after placing, a prefab leaves the pushed selection highlighted while a pre-existing-entity grab clears it. +- **New capability that falls out of this: list-assembled group grab.** With a selection pushed from + the Entities list, grabbing any one of the selected entities in the 3D view grabs *all* of them. + There was no way to do this before. It makes a practical workflow possible: browse a logic chain in + the 3D view, find the other entities you want to bring along in the Entities list, include them in + the selection, then grab the logic-chain node you are on and move the whole group together. + Confirmed working in logic chain mode, which also means that mode is inside the idle/selected pair + the guard permits -- the earlier worry that the logic sub-mode might sit outside it did not + materialise. - Known remaining gap: if you have one entity selected from the list and then natively click a *different* one, the selection count is unchanged, so no broadcast fires and the list keeps highlighting the original. Fixing that means broadcasting selection identity, not just count, in From 000a10d0b3d94ef725120cf43274e877becd99ee Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:46:44 -0500 Subject: [PATCH 12/16] Refuse selection changes while the editor is holding something The engine captures a snapshot of the selection when a manipulation starts (grabbing an entity, or holding a staged prefab), and its cancel path (Escape) restores that snapshot indexed POSITIONALLY against the live selection array, with no re-validation. Changing the selection in between makes Escape write each saved record onto the wrong entity, swapping entity pointers inside the live map. Observed live: duplicated entities, entities vanishing from the map entirely, "(no module)", and hard freezes. The capture also stashes each entity's real layer/module index, which is why module association is what visibly breaks. This is a PRE-EXISTING engine bug, not a regression: it reproduces on the v0.2.1-beta.2 release, which has none of the selection-state work. Only cancellation triggers it -- mouse click, controller accept and space are all unaffected. The backend now refuses add_to_selection / clear_selection / remove_from_selection while a snapshot is outstanding, and fails closed on any read error. Detection uses the capture's own side effect: it moves the manipulated entities onto the editor's scratch layer, so "any selected entity is on the scratch layer" is a precise test. Exposed to frontends as vtable ext 11 (manipulation_in_progress, +0x2C0) so the UI can explain the refusal instead of appearing to do nothing; the WebView rolls the list highlight back and toasts. Deliberately NOT keyed off the "currently held" indicator: placing a NEW entity from the palette sets that indicator but captures no snapshot, and is verified safe (Escape behaves correctly there). Keying off it would have blocked a state that never needed blocking. The vtable slot is appended at the tail, so every existing offset is unchanged; the iface ABI test was updated for the new size and passes. Native click select/deselect, Create-from-selection's hover requirement, and select-then-grab group grab are all unaffected. Live-verified; native C test suite passes 14/14. Co-Authored-By: Claude Opus 5 --- docs/capabilities.md | 1 + docs/webview-ui.md | 29 +++++++++ src/backend/iface_engine.c | 71 ++++++++++++++++++++++ src/common/snapmap_plus_iface.c | 1 + src/common/snapmap_plus_iface.h | 16 ++++- src/ui/webview/mockup.html | 9 +++ src/ui/webview/snapmap_plus_ui_webview.cpp | 16 ++++- tests/iface_config_test.c | 6 +- 8 files changed, 146 insertions(+), 3 deletions(-) diff --git a/docs/capabilities.md b/docs/capabilities.md index 7e00e31..0c5dca6 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -110,6 +110,7 @@ editor (run `sh` in the console if it doesn't auto-open). Full detail: [`webview | Entities tab | A filterable entity list (multi-select, a persistent Show Hidden toggle, and one persistent selection direction: Follow Selection or Select in 3D; right-click for Copy ID / Delete / Push to stack 0 / Clear stack 0) plus the Entity State panel: classname / inherit / displayname fields and the Decl Text editor — line numbers, syntax coloring, structural lint, advisory schema checks, a distraction-free focus mode. "Save to Decl" commits the edits in memory. SnapMap's own built-in filter/droppable helper entities are excluded from the list and from every entity picker (dev-layer-only; a mapper's own filters are unaffected). | | Keyboard paging | ArrowUp/ArrowDown page the Entities, Timelines and Prefabs lists, and — while their dropdown is open — the Inherit / Classname combos and the Timelines "Runs on" picker. Filtering still works alongside it; text editors and rename fields keep normal caret movement. | | Native selection parity ("Select in 3D") | Entities selected from the list behave exactly like ones clicked in the 3D view: an empty-space click deselects them, Delete removes all of them, Move works, and the bottom-bar controls apply — single or multi-entity, including switching between entities. Deselecting natively also clears the list highlight. Previously only the explicit Deselect button worked, and Delete/Move misbehaved (Move could soft-lock the game); the cause was that the editor's own mode state was never told a selection existed. | +| Selection guard while holding | Selecting from the Entities list is refused (with an explanatory toast) while you're grabbing an entity or holding a staged prefab. The engine's Escape/cancel path restores a snapshot keyed positionally to the selection that was live when the grab began, so changing the selection first makes Escape swap entity pointers inside the live map — duplicating entities, deleting others outright, and freezing the game. A pre-existing engine bug (reproduces on released builds); only cancellation triggers it, never accept. Placing a *new* palette entity captures no snapshot and is left unrestricted. | | List-assembled group grab | With a selection pushed from the Entities list, grabbing any one of those entities in the 3D view grabs **all** of them — not previously possible. Practical use: browse a logic chain in the 3D view, add the other entities you want to bring along from the Entities list, then grab the node you're on and move the whole group. You can also hold a grabbed entity or a staged prefab, push a selection from the list, and place the held object without losing the selection. | | Prefabs tab | Save and load selection prefabs as JSON files under `%LOCALAPPDATA%\snapmap-plus\prefabs\` — one folder level with rename/delete/drag-between-folders; per-prefab description + tags (stored in a `.meta.json` sidecar; the filter box matches tags across folders); "Load / Place" stages the prefab and the user pastes it with Ctrl+V. | | Timelines tab | The list of timeline entities; opening one edits its events and per-event parameters, with reference/decl/enum parameters constrained to valid choices, entity pickers for entity-typed args, and per-event documentation. | diff --git a/docs/webview-ui.md b/docs/webview-ui.md index b27359f..12a74b5 100644 --- a/docs/webview-ui.md +++ b/docs/webview-ui.md @@ -114,6 +114,35 @@ through it). Newest first. Each dated entry covers one working session's worth of change; the undated **Baseline** entry at the bottom is the original POC buildout, before this doc tracked dates per entry. +### 2026-07-27 -- Selection changes refused while the editor is holding something + +- **Selecting from the Entities list is now refused while you are grabbing an entity or holding a + staged prefab**, with a toast: *"Place or cancel what you are holding in the 3D view first, then + select"*. The list highlight rolls back so the UI never claims a selection the editor didn't take. +- **Why.** The engine captures a snapshot of the selection when a manipulation starts, and its + cancel path (Escape) restores that snapshot **indexed positionally against the live selection + array**, with no re-validation. Change the selection in between and Escape writes each saved record + onto the wrong entity -- swapping entity pointers inside the live map. Observed live: duplicated + entities, entities vanishing from the map entirely, "(no module)", and hard freezes. The capture + also stashes each entity's real layer/module index, which is why the module association is what + visibly breaks. +- **This is a pre-existing engine bug, not a regression.** It reproduces on the v0.2.1-beta.2 + release, which has none of the selection-state work. Only *cancellation* triggers it -- accept + paths (mouse click, controller accept, space) are unaffected. +- **Detection** is the capture's own side effect: it moves the manipulated entities onto the editor's + scratch layer, so "any selected entity is on the scratch layer" is a precise test for "a snapshot is + outstanding". Enforced in the backend across `add_to_selection`, `clear_selection` and + `remove_from_selection`, and fails closed -- an unreadable editor is treated as in-progress. + Exposed to frontends as vtable ext 11 (`manipulation_in_progress`, +0x2C0) so the UI can explain the + refusal rather than silently doing nothing. +- Deliberately NOT keyed off the "currently held" indicator: placing a **new** entity from the palette + sets that indicator but captures no snapshot, and is provably safe (Escape behaves correctly there). + Keying off it would have blocked a state that never needed blocking. +- Unaffected: native click select/deselect, Create-from-selection's own "hover one of the selected + entities" requirement, and selecting *before* grabbing (the list-assembled group grab). +- Known cosmetic issue, not addressed: while an entity is grabbed, the other selected entities render + in the move/grab colour rather than the normal selection colour. + ### 2026-07-27 -- Native 3D-viewport deselect fixed at the root - **A selection pushed from the Entities list now behaves exactly like a native one.** Empty-space click diff --git a/src/backend/iface_engine.c b/src/backend/iface_engine.c index 6f30a65..4a7ed6a 100644 --- a/src/backend/iface_engine.c +++ b/src/backend/iface_engine.c @@ -92,6 +92,20 @@ #define SEL_HOVERED_OFF 0x2c /* selObj+0x2c -> looked-at/hovered entity id */ #define ARR_ENT_ARRAY_OFF 0x6a0 /* arrObj+0x6a0 -> entity-ptr array (8-byte entries) */ #define ARR_ENT_COUNT_OFF 0x6a8 /* arrObj+0x6a8 -> entity count (u32) */ +/* per-entity layer index + the editor's scratch/working layer -- the "is a manipulation in progress?" + * test. When the user grabs (or holds a staged prefab), the engine's snapshot-capture moves every + * selected entity onto the scratch layer and stashes its REAL layer in a snapshot record, restoring it + * on cancel. So "a selected entity currently sits on the scratch layer" == "a manipulation snapshot is + * outstanding". DIRECT: capture is engine RVA 0x11b08d0, which reads `*(int*)(mapObj+0x6f0 + id*4)` per + * entity and compares it against `*(int*)(mapObj+0x758)`, calling the layer setter when they differ. + * WHY WE CARE: that snapshot is indexed POSITIONALLY against the live selection array and is never + * re-validated, so mutating the selection while it is outstanding makes the cancel path (Escape) write + * each saved record onto the wrong entity -- swapping entity pointers in the live map. Observed live: + * duplicated entities, entities vanishing from the map entirely, "(no module)", and hard freezes. + * PRE-EXISTING engine behaviour, reproduced on the v0.2.1-beta.2 release which has none of this file's + * selection-state work. Tracked in the doom-re campaign `cancel-selection-escape-path`. */ +#define MAP_ENT_LAYER_ARR_OFF 0x6f0 /* mapObj+0x6f0 -> int* per-entity layer/module index */ +#define MAP_SCRATCH_LAYER_OFF 0x758 /* mapObj+0x758 -> int, the scratch/working layer id */ /* the loaded-map MODULE table -- for the OG Entities-list id-string "_/_" (port of * FUN_180003ba0 + FUN_180003c80). All build-specific (same loaded-map object the entity array lives in; re-derive * per build alongside ARR_ENT_*). */ @@ -392,6 +406,53 @@ static int slot_get_selection(sh_iface *self, int *out_ids, int max) * Gated on ED_ENTITY_MODE_OFF == 2 (tabbed inside a module) because that is precisely the state for which * editor+0x22330 is the live mode object; anywhere else the write would land on an inactive sub-object. * SEH-guarded like every other editor deref here: a shifted offset degrades to a clean no-op. */ +/* Is a manipulation snapshot outstanding? (see MAP_ENT_LAYER_ARR_OFF for the full rationale) + * + * True while the user is grabbing/moving entities or holding a staged prefab. Callers must NOT mutate + * the editor selection while this is true: the engine's cancel path restores a positionally-indexed + * snapshot against the live selection array, so changing that array corrupts the map on Escape. + * + * Implemented as "does any currently-selected entity sit on the scratch layer", which is exactly what + * the engine's snapshot-capture arranges. Fails CLOSED on any read error -- an unreadable editor is + * treated as "in progress" so we refuse rather than risk the corrupting path. */ +static int manipulation_in_progress(void) +{ + const uint8_t *ed = editor_session(); + if (!ed) return 1; /* can't tell -> refuse */ + void *mapObj = NULL, *sel = NULL; + if (!ie_read_ptr(ed + ED_MAP_OBJ_OFF, &mapObj) || !mapObj) return 1; + if (!ie_read_ptr(ed + ED_SEL_OBJ_OFF, &sel) || !sel) return 0; /* no selection -> nothing at risk */ + + int count = 0; + if (!ie_read_s32((const uint8_t *)sel + SEL_COUNT_OFF, &count)) return 1; + if (count <= 0) return 0; /* empty selection -> no snapshot can be keyed to it */ + if (count > SEL_MAX_IDS) return 1; /* implausible -> refuse */ + + void *ids = NULL, *layers = NULL; + if (!ie_read_ptr((const uint8_t *)sel + SEL_IDS_OFF, &ids) || !ids) return 1; + if (!ie_read_ptr((const uint8_t *)mapObj + MAP_ENT_LAYER_ARR_OFF, &layers) || !layers) return 1; + int scratch = 0; + if (!ie_read_s32((const uint8_t *)mapObj + MAP_SCRATCH_LAYER_OFF, &scratch)) return 1; + + for (int i = 0; i < count; i++) { + uint32_t id = 0; + if (!ie_read_u32((const uint8_t *)ids + (size_t)i * 4, &id)) return 1; + int layer = 0; + if (!ie_read_s32((const uint8_t *)layers + (size_t)id * 4, &layer)) return 1; + if (layer == scratch) return 1; /* mid-manipulation */ + } + return 0; +} + +/* +0x2C0 (ext 11) query: 1 while the editor is grabbing/holding, so the UI can refuse and explain + * rather than silently no-op. Exposed because the refusal is a user-visible restriction, not an + * internal detail. */ +static int slot_manipulation_in_progress(sh_iface *self) +{ + (void)self; + return manipulation_in_progress() ? 1 : 0; +} + static void mode_set_selection_state(int state) { const uint8_t *ed = editor_session(); @@ -425,6 +486,11 @@ static void mode_set_selection_state(int state) static void slot_clear_selection(sh_iface *self) { (void)self; + /* HARD SAFETY GATE -- see MAP_ENT_LAYER_ARR_OFF. Mutating the selection while the editor holds a + * manipulation snapshot corrupts the live map on the next Escape (entity pointers swapped into the + * wrong slots: duplicated entities, entities deleted outright, freezes). Pre-existing engine + * behaviour; refusing is the only safe option until the cancel path itself is fixed. */ + if (manipulation_in_progress()) return; void *sel = selection_object(); if (!sel || !g_clear_sel) return; __try { g_clear_sel(sel); } __except (EXCEPTION_EXECUTE_HANDLER) {} @@ -437,6 +503,7 @@ static void slot_clear_selection(sh_iface *self) static void slot_add_to_selection(sh_iface *self, int id) { (void)self; + if (manipulation_in_progress()) return; /* see slot_clear_selection */ void *sel = selection_object(); if (!sel || !g_add_sel) return; __try { g_add_sel(sel, id); } __except (EXCEPTION_EXECUTE_HANDLER) {} @@ -842,6 +909,7 @@ static void slot_remove_from_selection(sh_iface *self, int id) { (void)self; if (!g_remove_sel || id == -1) return; + if (manipulation_in_progress()) return; /* see slot_clear_selection */ const uint8_t *ed = editor_session(); if (!ed) return; void *sel = NULL; @@ -1173,6 +1241,9 @@ int sh_iface_engine_install(const sig_result *results, size_t n, const uint8_t * slots.push_to_stack = slot_push_to_stack; /* +0x2A0 ext 7 */ /* clone-extension: empty the backend-owned SnapStack stack (out-of-process frontends only). */ slots.clear_stack = slot_clear_stack; /* +0x2A8 ext 8 */ + /* clone-extension: "the editor is mid-manipulation" -- every selection mutation is refused while + * true, because the engine's Escape/cancel path would then corrupt the live map. */ + slots.manipulation_in_progress = slot_manipulation_in_progress; /* +0x2C0 ext 11 */ sh_iface_bind_engine_slots(&slots); char line[200]; diff --git a/src/common/snapmap_plus_iface.c b/src/common/snapmap_plus_iface.c index 37c8106..c6676ac 100644 --- a/src/common/snapmap_plus_iface.c +++ b/src/common/snapmap_plus_iface.c @@ -295,6 +295,7 @@ void sh_iface_bind_engine_slots(const sh_iface_engine_slots *s) g_iface_vtbl_live.push_to_stack = s->push_to_stack; /* +0x2A0 */ /* clone-extension (empty the backend-owned SnapStack stack; out-of-process frontends only). */ g_iface_vtbl_live.clear_stack = s->clear_stack; /* +0x2A8 */ + g_iface_vtbl_live.manipulation_in_progress = s->manipulation_in_progress; /* +0x2C0 */ } /* --------------------------------------------------------------------- the factory ----------------- diff --git a/src/common/snapmap_plus_iface.h b/src/common/snapmap_plus_iface.h index dc6fc20..5c7bd3a 100644 --- a/src/common/snapmap_plus_iface.h +++ b/src/common/snapmap_plus_iface.h @@ -222,6 +222,15 @@ typedef void (*sh_push_to_stack_fn)(struct sh_iface *self, int index, c * backend (the webview host) drive "Clear stack 0" from a context-menu click instead of needing the DOOM * console. */ typedef int (*sh_clear_stack_fn)(struct sh_iface *self, int index); /* +0x2A8 (ext 8) */ +/* +0x2C0 (ext 11) 1 while the editor is mid-manipulation -- the user is grabbing/moving entities, or + * holding a staged prefab awaiting placement. While this is 1 the backend REFUSES every selection + * mutation (add / clear / remove), because the engine's cancel path (Escape) restores a snapshot that + * is indexed positionally against the live selection array and is never re-validated: changing that + * array mid-manipulation makes Escape swap entity pointers into the wrong slots, which duplicates + * entities, deletes others outright, and can freeze the game. Pre-existing engine behaviour -- + * reproduced on v0.2.1-beta.2, which has none of the selection-state work. Frontends should call this + * before pushing a selection so they can explain the refusal instead of appearing to do nothing. */ +typedef int (*sh_manipulation_in_progress_fn)(struct sh_iface *self); /* +0x2C0 (ext 11) */ /* +0x2B0/+0x2B8 (ext 9/10) backend-owned persistent configuration. Values cross the matched-pair * boundary as complete UTF-8 JSON fragments so future booleans/numbers/objects do not need new ABI @@ -423,11 +432,14 @@ typedef struct sh_iface_vtbl { * stack `index` -- see the typedef comment */ sh_config_get_json_fn config_get_json; /* +0x2B0 (ext 9) registered setting -> JSON */ sh_config_set_json_fn config_set_json; /* +0x2B8 (ext 10) validate + persist JSON */ + sh_manipulation_in_progress_fn manipulation_in_progress; /* +0x2C0 (ext 11) editor is grabbing/holding + * -> every selection mutation is refused; see typedef */ } sh_iface_vtbl; SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, config_get_json) == 0x2B0); SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, config_set_json) == 0x2B8); -SH_STATIC_ASSERT(sizeof(sh_iface_vtbl) == 0x2C0); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, manipulation_in_progress) == 0x2C0); +SH_STATIC_ASSERT(sizeof(sh_iface_vtbl) == 0x2C8); /* ------------------------------------------------------------------ the interface object ----------- * Object layout PINNED to FUN_1800229b1: +0x00 vtable, +0x08 mutex, +0x58 sub-object. The mutex is an @@ -572,6 +584,8 @@ typedef struct sh_iface_engine_slots { sh_push_to_stack_fn push_to_stack; /* +0x2A0 (ext 7) */ /* clone-extension: empty the backend-owned SnapStack stack (out-of-process frontends only). */ sh_clear_stack_fn clear_stack; /* +0x2A8 (ext 8) */ + /* clone-extension: "the editor is mid-manipulation" -- selection mutations are refused while true. */ + sh_manipulation_in_progress_fn manipulation_in_progress; /* +0x2C0 (ext 11) */ } sh_iface_engine_slots; void sh_iface_bind_engine_slots(const sh_iface_engine_slots *slots); diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index fe67399..0444c07 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -2785,6 +2785,15 @@ renderTlBody(); } } + } else if (d.kind === 'selectRefused') { + // The 3D editor is mid-grab (an entity picked up, or a staged prefab being held). Changing the + // editor selection now would corrupt the map on the next Escape -- the engine's cancel path + // restores a snapshot keyed positionally to the selection that was live when the grab started. + // Refused by the backend; roll the list highlight back so the UI doesn't claim a selection the + // editor never took. + selectedEids = {}; anchorEid = -1; + updateSelDom(); refreshEditorForSelection(false); + toast('Place or cancel what you are holding in the 3D view first, then select', 'warn'); } else if (d.kind === 'selCount') { var prevEditorSel = editorSelCount; editorSelCount = d.count; updateCreateBtn(); diff --git a/src/ui/webview/snapmap_plus_ui_webview.cpp b/src/ui/webview/snapmap_plus_ui_webview.cpp index 4a2cc68..87186ca 100644 --- a/src/ui/webview/snapmap_plus_ui_webview.cpp +++ b/src/ui/webview/snapmap_plus_ui_webview.cpp @@ -80,6 +80,7 @@ static std::vector g_delete_eids; static volatile bool g_pending_select = false; /* list -> editor selection push ("Select in editor") */ static std::vector g_select_eids; static volatile bool g_pending_deselect = false; /* explicit "Deselect" button -- clear_selection convenience */ +static volatile bool g_select_refused = false; /* last selection push was refused (editor mid-grab/hold) */ static char g_enumbuf[262144]; /* packed-string scratch for enum_inherits / enum_valid_classes */ static volatile bool g_cam_lock = false; /* Camera Origin "Lock Position" */ @@ -559,6 +560,17 @@ static void poc_emit_entity_inherit(int eid, int json_len) static void poc_apply_select_in_editor() { poc_logf("select-in-editor: apply start ids=%lu", (unsigned long)g_select_eids.size()); + /* Refused while the editor is grabbing/holding: the engine's Escape/cancel path restores a snapshot + * indexed positionally against the live selection array, so changing that array mid-manipulation + * makes Escape swap entity pointers into the wrong slots -- duplicated entities, entities deleted + * outright, freezes. Pre-existing engine behaviour (reproduced on v0.2.1-beta.2). The backend + * enforces this too; checking here as well lets us tell the user why nothing happened. */ + if (g_iface && g_iface->vtbl && g_iface->vtbl->manipulation_in_progress + && g_iface->vtbl->manipulation_in_progress(g_iface)) { + poc_log("select-in-editor: REFUSED -- editor is mid-manipulation (grab/hold)"); + g_select_refused = true; + return; + } __try { if (g_iface && g_iface->vtbl) { if (g_iface->vtbl->clear_selection) { poc_log("select-in-editor: clear"); g_iface->vtbl->clear_selection(g_iface); } @@ -1970,7 +1982,7 @@ static void poc_think_loop() unsigned frame = 0; for (;;) { frame++; - bool did_save = false, did_delete = false, did_create_prefab = false; + bool did_save = false, did_delete = false, did_create_prefab = false, did_select_refused = false; bool did_delete_prefab = false, did_rename_prefab = false, did_load_prefab = false; bool did_create_folder = false, did_rename_folder = false, did_delete_folder = false, did_move_prefab = false; bool did_open_timeline = false, did_resolve_entity = false, did_save_timeline = false; @@ -1990,6 +2002,7 @@ static void poc_think_loop() g_last_editor_sel = -1; g_last_sel_sig = 0; g_pending_deselect = false; } + if (g_select_refused) { g_select_refused = false; did_select_refused = true; } if (g_pending_create_prefab) { poc_apply_create_prefab(); g_pending_create_prefab = false; @@ -2016,6 +2029,7 @@ static void poc_think_loop() if (g_save_eid >= 0) poc_send_state(g_save_eid, false); } if (did_delete) poc_send_list(); + if (did_select_refused) poc_post_json(L"{\"kind\":\"selectRefused\"}"); if (did_create_prefab) { std::wstring m = L"{\"kind\":\"createPrefabResult\",\"result\":"; m += std::to_wstring(g_create_result); m += L",\"name\":\""; m += poc_json_w(g_create_prefab_name.c_str()); m += L"\"}"; diff --git a/tests/iface_config_test.c b/tests/iface_config_test.c index 7ec190d..0f39844 100644 --- a/tests/iface_config_test.c +++ b/tests/iface_config_test.c @@ -6,7 +6,11 @@ SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, config_get_json) == 0x2B0); SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, config_set_json) == 0x2B8); -SH_STATIC_ASSERT(sizeof(sh_iface_vtbl) == 0x2C0); +/* ext 11, appended 2026-07-27: the "editor is mid-manipulation" query that gates every selection + * mutation (see the typedef in snapmap_plus_iface.h). Appended at the tail, so every pre-existing + * offset above is unchanged -- only the total size grows. */ +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, manipulation_in_progress) == 0x2C0); +SH_STATIC_ASSERT(sizeof(sh_iface_vtbl) == 0x2C8); SH_STATIC_ASSERT(offsetof(sh_iface, sub) == 0x58); SH_STATIC_ASSERT(sizeof(sh_iface) == 0x60); From 967648f87ad82ca7c83b33886525bfe4a56049a8 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:51:06 -0500 Subject: [PATCH 13/16] apply_engine: drop the bogus idStr small-string branch idStr's data field at +0x10 is ALWAYS a real pointer -- for a short string it points at the object's own inline buffer at +0x1c. There is no small-string-optimization branch to take. ae_read_idstr carried one (len < 0x10 ? inline-at-+0x10 : heap-pointer), which reads the pointer field's own bytes as text and garbles every string under 16 characters. DIRECT, settled from the engine's own idStr::Left during the doom-re campaign text-inspector-input-path, where the identical bug shipped briefly in swf_textedit.c and was caught in live testing. rawmap.c always read it correctly; this was the last copy of the bad pattern. Latent rather than live here: all three call sites read a rendered-JSON idStr, and a serialized entity or prefab is far longer than 16 bytes, so the short-string path was effectively unreachable. Fixed anyway -- the branch is simply wrong, and the next caller might not be so lucky. No behaviour change is expected on any current path, which also means there is nothing user-visible to test. Native C test suite passes 14/14. --- src/backend/apply_engine.c | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/backend/apply_engine.c b/src/backend/apply_engine.c index 16d5e6c..7af7f1c 100644 --- a/src/backend/apply_engine.c +++ b/src/backend/apply_engine.c @@ -339,8 +339,19 @@ static void *ae_get_reflect(void) } } -/* read an idStr (48-byte layout: int len@+8; char* data@+0x10 [heap] or inline [SSO]). Copies up to cap-1 - * bytes into out + NUL. Returns the byte length written (0 on fault / empty). SEH-guarded. */ +/* read an idStr (48-byte layout: int len@+8; char* data@+0x10). Copies up to cap-1 bytes into out + NUL. + * Returns the byte length written (0 on fault / empty). SEH-guarded. + * + * NB: `data` at +0x10 is ALWAYS a real pointer -- for a short string it points at the object's own + * inline buffer at +0x1c. There is NO small-string-optimization branch to take. This function used to + * carry one (`len < 0x10 ? inline-at-+0x10 : heap-pointer`), which reads the pointer field's own bytes + * as text and garbles every string under 16 characters. DIRECT, settled from the engine's own + * idStr::Left (RVA 0x33e640) during the doom-re campaign `text-inspector-input-path`, where the same + * bug shipped briefly in swf_textedit.c and was caught live. `rawmap.c` always read it correctly. + * + * In THIS file the bug was latent rather than live: all three call sites read a rendered-JSON idStr, + * and a serialized entity/prefab is far longer than 16 bytes, so the short-string path was effectively + * unreachable. Fixed anyway -- the branch is simply wrong, and the next caller might not be so lucky. */ static int ae_read_idstr(const void *p, char *out, int cap) { if (cap > 0) out[0] = '\0'; @@ -349,9 +360,7 @@ static int ae_read_idstr(const void *p, char *out, int cap) __try { int len = *(const int *)((const uint8_t *)p + IDSTR_LEN_OFF); if (len <= 0 || len > APPLY_TEXT_CAP) return 0; - const char *base; - if (len >= 0x10) base = *(const char * const *)((const uint8_t *)p + IDSTR_DATA_OFF); - else base = (const char *)((const uint8_t *)p + IDSTR_DATA_OFF); + const char *base = *(const char * const *)((const uint8_t *)p + IDSTR_DATA_OFF); if (!base) return 0; int n = len < (cap - 1) ? len : (cap - 1); for (int i = 0; i < n; i++) out[i] = base[i]; From 905d8be2f7124daffb44fa99c71485d2854a21b6 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:53:01 -0500 Subject: [PATCH 14/16] Fix staged prefab poisoning the paste slot across a Play round-trip Staging a prefab with Load/Place, playing the map, and returning to the editor left the game's own Ctrl+C and Ctrl+V dead until Load/Place was pressed again. Ctrl+C was not being gated off -- it was running and faulting. The engine's copy handler calls CreatePrefab on the shared staging slot at editor+0x209a8, and CreatePrefab tears down whatever is already there first. Our staged prefab does not survive a Play round-trip, so that teardown walked pointers the map teardown had already freed (repeated first-chance AVs at rip+0x1AB32EE reading -1, classified "in-editor draw fault -> aborted draw"). Load/Place appeared to fix it only because ae_mkcmd_one re-ctors the slot before staging. Pre-existing and unrelated to any Load/Place automation: it reproduces with plain stage-only behaviour. sh_apply_prefab_poll_play() now watches the engine load_state global and re-initialises the slot on the way INTO Play, while its memory is still intact. The prefab ctor rewrites every field with no reads and no frees, so it turns dangling pointers into a clean empty prefab rather than double-freeing them. Two guards matter as much as the fix: - Ownership: only touch the slot when we staged it and the count is unchanged. An engine-made Ctrl+C clipboard legitimately survives Play and across maps and is left strictly alone. An earlier revision cleared it unconditionally and destroyed the user's clipboard. - Direction: cleaning on the way back is too late; the engine can touch the dangling slot first. Detecting Play needs load_state (base+0x6DDE198, 3 = RUNNING). The editor session going null and the loaded-map object pointer changing both survive the round-trip and never fire -- do not retry them. Also lands, dormant, the Load/Place auto-grab work behind kind=2 (currently set to kind=1, stage-only): PasteInstantiate + EnterAddPrefabGrab signatures, and an action-injection path that writes paste action 0x5C into the editor's own queued action slot so the engine dispatches it from its own frame. The mechanism is proven live, but a heap corruption correlated with it is unexplained, so it stays disabled. Calling PasteInstantiate directly from the command-buffer drain is a dead end -- it corrupts on place. Known limitation: our staged prefab still does not survive a Play; we now discard it deliberately. Press Load/Place again to re-stage from disk. Co-Authored-By: Claude Opus 5 --- docs/backend-changes.md | 43 +++ src/backend/apply_engine.c | 399 ++++++++++++++++++++- src/backend/apply_engine.h | 17 + src/backend/signatures.c | 59 +++ src/common/snapmap_plus_iface.c | 8 + src/ui/webview/mockup.html | 3 + src/ui/webview/snapmap_plus_ui_webview.cpp | 26 +- 7 files changed, 549 insertions(+), 6 deletions(-) diff --git a/docs/backend-changes.md b/docs/backend-changes.md index f20b304..32b0653 100644 --- a/docs/backend-changes.md +++ b/docs/backend-changes.md @@ -6,6 +6,49 @@ where our own reimplementation was wrong, not the original SnapHak's behavior; a (or faithful reproduction of) the *original's* behavior belongs in [`fidelity.md`](fidelity.md) instead. Entries are chronological, newest first. +## 2026-07-28 — a staged prefab poisoned the paste slot and killed vanilla Ctrl+C / Ctrl+V after Play + +**Symptom.** Stage a prefab with Load/Place, press Play, come back to the editor — and the game's own +copy and paste were dead. They stayed dead until you pressed Load/Place again, which appeared to "fix" +them. Vanilla copy/paste with no Load/Place involved was completely unaffected, which is what made this +look like an editor-state problem for a long time. + +**What was actually happening.** Ctrl+C was not being refused — it was *running and faulting*. The +shield log showed repeated first-chance access violations at `rip+0x1AB32EE` reading `-1`, classified +`in-editor draw fault -> aborted draw, resumed editor frame`. The engine's copy handler calls +`CreatePrefab` on the shared staging slot at `editor+0x209a8`, and `CreatePrefab`'s first act is to tear +down whatever is already in that slot. Our staged prefab does not survive a Play round-trip, so that +teardown walked pointers the map teardown had already freed. Pressing Load/Place "fixed" it only because +`ae_mkcmd_one` re-ctors the slot before staging, which happens to heal it. + +This is **pre-existing** and independent of any Load/Place automation work — it reproduces with plain +stage-only behaviour, and would hit anyone who staged a prefab and then played their map. + +**Fix.** `sh_apply_prefab_poll_play()` (polled from the per-tick drain) watches the engine `load_state` +global and re-initialises the staging slot on the way **into** Play, while its memory is still intact. +Re-ctor'ing is the right tool: the prefab ctor rewrites every field unconditionally with no reads and no +frees, so it turns dangling pointers into a clean empty prefab — it leaks the already-dead allocations +rather than double-freeing them. + +Two guards matter as much as the fix: + +- **Ownership.** We only touch the slot when *we* staged it and the entity count is unchanged. An + engine-made Ctrl+C clipboard is left strictly alone — it legitimately survives a Play round-trip and + across maps, and an earlier revision of this fix that cleared the slot unconditionally destroyed the + user's clipboard and disabled paste. Don't do that. +- **Direction.** Cleaning on the way *back* is too late; the engine can touch the dangling slot first. + +**Detecting Play is harder than it looks.** Two obvious signals do **not** work and should not be +retried: the editor session going null (`editor+0x204c8`), and the loaded-map object pointer changing. +Both survive a Play round-trip, so neither ever fired. The engine's `load_state` global +(`base+0x6DDE198`; `3` = RUNNING) does move, and per the fault-shield's own notes an in-editor in-place +load never writes it. + +**Known limitation.** Our staged prefab still does not survive a Play — we now discard it deliberately +rather than leave a landmine. Press Load/Place again after returning; it re-reads from disk. The durable +fix is to make `ae_deserialize_to_obj` build an object structurally equivalent to what `CreatePrefab` +produces, which is not yet understood. + ## 2026-07-13/14 — SnapStack lives in the backend (`snapstack.c` + `json_patch.c`); the `json_patch` empty-`edit` fix; store slots + management commands **What & why.** The SnapStack subsystem — the stack-of-stacks + named-group stores and all 20 `sh` diff --git a/src/backend/apply_engine.c b/src/backend/apply_engine.c index 7af7f1c..974222f 100644 --- a/src/backend/apply_engine.c +++ b/src/backend/apply_engine.c @@ -71,6 +71,72 @@ * FUN_14054f950(editor+0x209a8, editor) after staging (0x54F950, the engine Ctrl+V; not yet wired). */ #define PASTE_STAGING_OFF 0x209a8 +/* --- kind=2 (stage THEN place) support: the engine's own Ctrl+V follow-through ---------------------- + * editor+0x209e0 is the staged prefab's ENTITY COUNT -- it is literally PASTE_STAGING_OFF + 0x38, the + * same field PasteInstantiate loops over (prefab[0xe]). The engine's paste-available gate tests exactly + * this (`>= 1`) alongside the snapEdit_enableCopyPaste cvar, so it doubles as our "did the stage actually + * produce anything" check -- a stage that deserialized to zero entities must NOT be instantiated. + * The mode object + editor-state id mirror iface_engine.c's ED_MODE_OBJ_OFF / ED_ENTITY_MODE_OFF (same + * live build, same re-derive recipe -- see that file's comment block for the OnDeactivate derivation). + * The selection count is read to VERIFY the clear actually took effect before instantiating, because a + * non-empty selection silently mis-wires the paste (see the PasteInstantiate signature comment). + * DIRECT (doom-re campaign synthetic-action-injection, 2026-07-27). */ +#define PASTE_PREFAB_ENTCOUNT_OFF 0x209e0 /* = editor+0x209a8+0x38 -> staged prefab entity count (s32) */ +#define ED_MODE_OBJ_OFF 0x22330 /* editor+0x22330 -> inline EntityMode object */ +#define ED_ENTITY_MODE_OFF 0x23618 /* editor+0x23618 -> active editor state id (2 == EntityMode) */ +#define ED_SEL_OBJ_OFF 0x204d0 /* editor+0x204d0 -> selection object ptr */ +#define SEL_COUNT_OFF 0x88 /* selObj+0x88 -> selected count (s32) */ +#define SEL_HOVERED_OFF 0x2c /* selObj+0x2c -> hovered entity id (-1 == hovering nothing) */ +#define PREFAB_MAX_ENTITIES 100000 /* stale-slot sanity guard on the staged entity count */ + +/* --- ACTION INJECTION (how we ask the engine to paste, instead of pasting ourselves) ---------------- + * WHY NOT JUST CALL IT: we did, and it corrupts the map. Calling PasteInstantiate + the grab setter + * directly from the command-buffer drain creates the entities fine (cancelling out of it is clean), but + * COMMITTING the placement afterwards damages the heap -- an idStr destructor later frees a block whose + * header is already bad, surfacing as an AV + "Memory corruption before block!" on the next Play. Proven + * live 2026-07-27: same crash with 93 entities and with 1, while the SAME staged prefab pasted with a + * real Ctrl+V and placed is completely clean. So the prefab data and our staging are fine; the fault is + * the call SITE. The engine runs the paste inside the idle sub-state's per-frame handler during the mode + * Think, and the place-commit depends on per-frame bookkeeping that only holds on that path. + * + * So: don't call it. ASK. The mode's context-menu descriptor doubles as a queued-action slot -- the base + * dispatcher reads it and handles the result IDENTICALLY to a live key press: + * actionId = (*(int *)(mode+0x420) == -1) ? -1 : *(int *)(mode+0x424); + * and the paste branch matches on `IsActionPressed(0x5C) || actionId == 0x5C`. We write the id, arm the + * slot, and the engine dispatches it from exactly the right place on its own next frame -- so the paste + * lands on the engine's own code path, on the engine's own thread, with all surrounding state correct. + * We only ever write two ints. The mode's Think RESETS the descriptor after the sub-object's per-frame + * handler has consumed it, so an injected action fires EXACTLY ONCE with no repeat-firing. + * + * Store order matters: the action id must be visible before the arming word, or the dispatcher can + * observe an armed slot with a stale id. + * + * The dispatcher additionally gates paste on `substate+0x41 & 0x40`, which the engine recomputes every + * frame as (snapEdit_enableCopyPaste != 0) AND (staged entity count >= 1) AND (hovered id == -1). We + * check what we can see so a request that the engine would silently ignore degrades to an honest + * stage-only toast instead of a lie. `substate` here is the IDLE sub-state, inline at mode+0x1B0. + * DIRECT (doom-re campaign synthetic-action-injection, 2026-07-27). */ +#define MODE_IDLE_SUBSTATE_OFF 0x1B0 /* mode+0x1B0 -> the idle sub-state object (active when +0x1ac==1) */ +#define SUBSTATE_FLAGS1_OFF 0x41 /* substate+0x41 -> capability byte; bit 0x40 = paste available */ +#define SUBSTATE_PASTE_AVAIL_BIT 0x40 +/* substate+0x42 bit 0 = "recompute the capability bytes". The action gates in +0x40/+0x41 are a CACHE, + * not live state: the dispatcher rebuilds them (engine 0x1264dd0 on-disk, which opens by zeroing + * +0x40/+0x41/+0x42) only on the frame this bit is set, and that rebuild path RETURNS without reading + * the queued action. Consequences we hit live 2026-07-27: + * - staging a prefab changes the entity count but dirties nothing, so the cached "paste available" bit + * stays clear and the FIRST Load/Place after staging was refused; any user action that dirtied the + * cache (a manual Ctrl+V) made every later press work. + * - after a Play round-trip the whole byte comes back stale, so Ctrl+C AND Ctrl+V are both dead until + * something dirties it -- which a Load/Place did incidentally, via the engine ClearSelection. + * So: dirty it on the post-Play edge (where there is no queued action to lose), but for our own paste we + * set the one bit directly instead -- dirtying in the same tick would consume our armed action. */ +#define SUBSTATE_FLAGS2_OFF 0x42 +#define SUBSTATE_RECOMPUTE_BIT 0x01 +#define MODE_ACTION_ARM_OFF 0x420 /* mode+0x420 -> descriptor arming word (-1 == empty) */ +#define MODE_ACTION_ID_OFF 0x424 /* mode+0x424 -> the queued action id the dispatcher reads */ +#define EDITOR_ACTION_PASTE 0x5C /* the abstract action id the paste branch matches on */ +#define MODE_ACTION_ARMED 0 /* any value != -1 arms it; 0 is the menu's own first slot */ + /* the prefab-from-selection serialize (+0xb0) engine fns. RE-DERIVE off the OG XINPUT1_3 * FUN_180004210 (the serialize-SELECTION body): a temp prefab is ctor'd via `(DAT_18003e120 + 0x54d0a0)` @@ -223,6 +289,8 @@ typedef void (*add_command_fn)(void *cmdSys, const char *name, void *cb, void * typedef void (*prefab_ctor_fn)(void *self); /* PrefabCtor 0x54d0a0 */ typedef char (*prefab_populate_fn)(void *self, void *editor, int *outStatus); /* PrefabPopulate 0x54e410 */ typedef void (*prefab_dtor_fn)(void *self); /* PrefabDtor 0x51d870 */ +typedef void (*paste_instantiate_fn)(void *prefab, void *editor); /* PasteInstantiate (SIG-resolved) */ +typedef void (*enter_prefab_grab_fn)(void *mode); /* EnterAddPrefabGrab (SIG-resolved) */ /* ============================================================ module state (resolved once) ========== */ static const uint8_t *g_doom_base = NULL; @@ -248,6 +316,32 @@ static add_command_fn g_add_command = NULL; static prefab_ctor_fn g_prefab_ctor = NULL; /* +0xb0 serialize-selection */ static prefab_populate_fn g_prefab_populate = NULL; static prefab_dtor_fn g_prefab_dtor = NULL; +/* kind=2 place-after-stage. BOTH are SIGNATURE-resolved (never a raw RVA): the engine's paste worker and + * the tool-state transition the engine always runs immediately after it. If either fails to resolve, + * kind=2 degrades to kind=1 (stage-only) and tells the user to press Ctrl+V -- never a partial place. */ +static paste_instantiate_fn g_paste_instantiate = NULL; +static enter_prefab_grab_fn g_enter_prefab_grab = NULL; +/* Engine load_state (module_base + LOAD_STATE_RVA): 0 boot / 2 LOADING / 3 RUNNING. THE Play detector. + * Two earlier attempts failed and are recorded so they are not retried: + * - "editor session went null and came back" -- the session stays live across a Play round-trip; + * - "the loaded-map object pointer changed" -- the map object survives the round-trip too. + * Neither ever fired (proven live 2026-07-27/28: no C2 stage line on any Play return), which is why the + * slot cleanup never ran. load_state DOES move for a play/boot load, and per the fault-shield's own note + * an in-editor in-place load never writes it -- so leaving 3 is specifically "we are going to Play". */ +#define LOAD_STATE_RVA 0x6dde198u +#define LOAD_STATE_RUNNING 3 +static volatile LONG g_last_load_state = -1; +/* "WE were the last thing to stage the prefab slot, and this is the entity count we left in it." + * Set by ae_mkcmd_one. Used to tell OUR staged prefab apart from an engine-made Ctrl+C clipboard, which + * must never be disturbed -- see sh_apply_prefab_poll_play. The count is a cheap overwrite heuristic: if + * it no longer matches, something else (a Ctrl+C) rewrote the slot and it is not ours to touch. */ +static volatile LONG g_we_staged = 0; +static volatile LONG g_we_staged_count = 0; +/* Outcome of the most recent kind=2 item (AE_PASTE_*), so the Load/Place toast can say "held, ready to + * position" vs "staged -- press Ctrl+V" instead of guessing. Written on the DOOM main thread during the drain, read + * by the UI afterwards; a torn read is impossible (aligned int) and a stale read is harmless (worst case + * the toast wording lags one action). */ +static volatile LONG g_last_place_result = 0; static volatile LONG g_installed = 0; static volatile LONG g_cmd_registered = 0; /* clone_bss_apply registered once (lazy) */ @@ -280,6 +374,12 @@ static int ae_read_u32(const void *src, uint32_t *out) __except (EXCEPTION_EXECUTE_HANDLER) { return 0; } } /* int-typed sibling for the diagnostic's idStr-len read (idStr len@+8 is a signed int). */ +static int ae_read_u8_safe(const void *src, unsigned *out) +{ + __try { *out = *(const unsigned char *)src; return 1; } + __except (EXCEPTION_EXECUTE_HANDLER) { *out = 0; return 0; } +} + static int ae_read_u32_safe(const void *src, int *out) { __try { *out = *(const int *)src; return 1; } @@ -813,7 +913,170 @@ static int ae_mkcmd_one(const char *prefab_text) * branches, so it cannot double-free; it only leaks the slot's prior list allocations (small, one-shot per * create -- acceptable vs a crash). SEH-guarded: a failed reset falls through to the pre-existing behavior. */ if (g_prefab_ctor) { __try { g_prefab_ctor(staging); } __except (EXCEPTION_EXECUTE_HANDLER) {} } - return ae_deserialize_to_obj(prefab_text, staging, "idSnapEntityPrefab"); + int ok = ae_deserialize_to_obj(prefab_text, staging, "idSnapEntityPrefab"); + /* Remember that the slot now holds OUR prefab, and how many entities we left in it. Unlike an + * engine-made Ctrl+C clipboard, ours does not survive a Play round-trip -- see + * sh_apply_prefab_poll_play, which uses this to clean up only what we put there. */ + if (ok) { + int n = 0; + const uint8_t *ed2 = ae_editor_session(); + if (ed2 && ae_read_u32_safe(ed2 + PASTE_PREFAB_ENTCOUNT_OFF, &n)) { + InterlockedExchange(&g_we_staged_count, n); + InterlockedExchange(&g_we_staged, 1); + } + } + return ok; +} + +/* ============================================================ kind=2: stage THEN place ============== + * Load/Place without the manual Ctrl+V. Runs the engine's OWN paste sequence -- the exact pair its idle + * sub-state dispatcher runs on action 0x5C -- rather than synthesizing input (the Snapmap+ window holds + * focus when the button is clicked and DOOM reads through DirectInput, so a synthetic Ctrl+V goes to the + * wrong window; a previous attempt also produced a spurious ESC-menu popup from the OS focus switch). + * A memory-level call bypasses the input stack entirely, so focus is irrelevant. + * + * Order is NOT negotiable: + * 1. stage -- deserialize the prefab text into editor+0x209a8 (kind=1) + * 2. CLEAR the selection -- PasteInstantiate uses the selection array as its old->new id map and + * AddToSelection appends, so a live selection silently mis-wires every + * pasted connection. Routed through the iface slot so it inherits the + * manipulation-snapshot guard (mutating the selection mid-grab corrupts + * the map on the next Escape -- cancel-selection-escape-path). + * 3. VERIFY the clear took -- the guard can legitimately REFUSE the clear; instantiating anyway is + * the corrupting case, so a non-empty selection aborts to stage-only. + * 4. PasteInstantiate -- build the entities into the live map + * 5. EnterAddPrefabGrab -- the tool-state transition the engine always runs next; skipping it is + * what left the 2026-07-06 attempt placed-but-undraggable and crashing + * on the following Play transition. + * + * NOTHING IS PLACED INTO THE MAP HERE. This reproduces Ctrl+V exactly: the prefab's entities are + * instantiated and handed to the editor HELD (the Add-Prefab grab state), so the user still moves them + * and clicks to drop. That click is the engine's own place-commit handler and is untouched by us. The + * point of this function is purely to remove the manual keystroke, not to decide where anything lands. + * + * MUST run on the DOOM main thread -- callers reach it via the clone_bss_apply drain or apply_sync. + * TRI-STATE return, because "staged but not handed over" is a success for the apply batch but a + * different outcome for the user: + * 0 = the stage itself failed (nothing usable happened) + * 1 = STAGED ONLY -- every abort path lands here, leaving exactly the old stage-only behaviour, so the + * caller falls back to the "press Ctrl+V" toast. There is no partially-instantiated outcome. + * 2 = HELD -- instantiated and now grabbed, awaiting the user's positioning click. + * DIRECT (doom-re campaign synthetic-action-injection, 2026-07-27). */ +#define AE_PASTE_FAILED 0 +#define AE_PASTE_STAGED 1 +#define AE_PASTE_HELD 2 + +static int ae_mkcmd_instantiate(const char *prefab_text) +{ + if (!ae_mkcmd_one(prefab_text)) return AE_PASTE_FAILED; /* stage failed -> nothing to place */ + + /* NB: g_paste_instantiate / g_enter_prefab_grab are resolved but DELIBERATELY NOT CALLED -- see the + * ACTION INJECTION block above for why calling them directly corrupts the map. They are kept + * resolved purely as a diagnostic (the install log prints them, which is how we confirmed the + * signatures match this build) and as the documented direct path should it ever become viable. */ + const uint8_t *ed = ae_editor_session(); + if (!ed) { backend_log("C2 place: no editor session -> staged only"); return AE_PASTE_STAGED; } + + /* editor+0x22330 is the live mode object ONLY while the editor is in EntityMode (state 2); anywhere + * else the grab-state write would land on an inactive sub-object. */ + int editor_state = 0; + if (!ae_read_u32_safe(ed + ED_ENTITY_MODE_OFF, &editor_state) || editor_state != 2) { + backend_log("C2 place: not EntityMode -> staged only"); + return AE_PASTE_STAGED; + } + + /* the stage must have produced at least one entity -- this is the same field the engine's own + * paste-available gate tests, and PasteInstantiate loops over it. */ + int ent_count = 0; + if (!ae_read_u32_safe(ed + PASTE_PREFAB_ENTCOUNT_OFF, &ent_count) || + ent_count < 1 || ent_count > PREFAB_MAX_ENTITIES) { + char l[128]; + _snprintf_s(l, sizeof l, _TRUNCATE, "C2 place: staged entity count %d out of range -> staged only", ent_count); + backend_log(l); + return AE_PASTE_STAGED; + } + + /* THE decisive gate: the engine dispatches paste ONLY from the mode's IDLE sub-state. mode+0x1ac + * selects which sub-object drives (1 = idle at mode+0x1B0, 2 = an entity is selected, 4 = the + * EditEntity manipulation sub-state at mode+0x290), and PasteInstantiate's sole call site lives in + * the idle one -- so "not idle-or-selected" means the editor is mid-gesture and a pick-up here would + * be a state the engine never produces for itself. + * + * This is what makes a SECOND Load/Place while the first prefab is still held safe: holding puts + * +0x1ac at 4, so we abort to stage-only instead of deselecting the held entities out from under the + * user (which is the map-corrupting act -- cancel-selection-escape-path). + * + * Checked BEFORE the clear, because the clear itself drives the state to idle and would mask this. + * Deliberately NOT relying on manipulation_in_progress() for this: that test is layer-based, and the + * layer move happens in the EditEntity sub-state's own enter (0x125f010) a frame AFTER our grab + * transition -- so it is racy for a rapid second press. mode+0x1ac is set synchronously by + * EnterAddPrefabGrab itself, so it is true the instant we hand over. */ + int mode_state = 0; + if (!ae_read_u32_safe(ed + ED_MODE_OBJ_OFF + 0x1ac, &mode_state) || + (mode_state != 1 && mode_state != 2)) { + char l[128]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "C2 place: mode busy (mode+0x1ac=%d, likely already holding) -> staged only", mode_state); + backend_log(l); + return AE_PASTE_STAGED; + } + + /* clear via the iface slot so the manipulation-snapshot guard applies too (it may refuse -- that is + * why the next step verifies rather than assumes). */ + sh_iface *iface = sh_ui_get_iface(); + if (iface && iface->vtbl && iface->vtbl->clear_selection) + iface->vtbl->clear_selection(iface); + + void *sel = NULL; + int sel_count = -1; + if (!ae_read_ptr(ed + ED_SEL_OBJ_OFF, &sel) || !sel || + !ae_read_u32_safe((const uint8_t *)sel + SEL_COUNT_OFF, &sel_count) || sel_count != 0) { + char l[128]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "C2 place: selection not empty (count=%d) -> staged only (placing would mis-wire)", sel_count); + backend_log(l); + return AE_PASTE_STAGED; + } + + /* The engine only offers paste while nothing is hovered -- it is part of the same gate bit. Reading + * it ourselves lets us say WHY instead of silently doing nothing. */ + int hovered = 0; + if (!ae_read_u32_safe((const uint8_t *)sel + SEL_HOVERED_OFF, &hovered) || hovered != -1) { + backend_log("C2 place: hovering an entity (engine refuses paste there) -> staged only"); + return AE_PASTE_STAGED; + } + + /* SET the engine's paste-available bit rather than requiring it. It is a stale cache immediately + * after staging (see SUBSTATE_FLAGS2_OFF), so requiring it made the first press after every stage + * fail. We have already verified the two conditions that bit actually encodes and that we can see -- + * staged entity count >= 1 and hovered id == -1 -- so setting it states the truth a frame earlier + * than the engine's own recompute would. Deliberately a single-bit OR, not a byte write: every other + * gate in that byte (delete, duplicate, wire, ...) must keep whatever the engine last computed. + * + * The remaining condition we cannot cheaply see is the snapEdit_enableCopyPaste cvar. It defaults on + * and the engine's own recompute will clear this bit again on its next dirty pass, so forcing it + * cannot make paste permanently available against the user's setting -- at worst one paste. */ + unsigned char *flags1 = (unsigned char *)((uintptr_t)ed + ED_MODE_OBJ_OFF + + MODE_IDLE_SUBSTATE_OFF + SUBSTATE_FLAGS1_OFF); + /* Ask, don't call. Action id BEFORE the arming word. */ + int armed = 0; + __try { + *(volatile unsigned char *)flags1 = (unsigned char)(*(volatile unsigned char *)flags1 | + SUBSTATE_PASTE_AVAIL_BIT); + *(volatile int *)((uintptr_t)ed + ED_MODE_OBJ_OFF + MODE_ACTION_ID_OFF) = EDITOR_ACTION_PASTE; + _ReadWriteBarrier(); + *(volatile int *)((uintptr_t)ed + ED_MODE_OBJ_OFF + MODE_ACTION_ARM_OFF) = MODE_ACTION_ARMED; + armed = 1; + } __except (EXCEPTION_EXECUTE_HANDLER) { + backend_log("C2 place: arming the action slot faulted -> staged only"); + armed = 0; + } + if (!armed) return AE_PASTE_STAGED; + + char l[128]; + _snprintf_s(l, sizeof l, _TRUNCATE, "C2 place: placed %d entities, editor now holding them", ent_count); + backend_log(l); + return AE_PASTE_HELD; } @@ -844,6 +1107,13 @@ static void ae_toast_result(const char *op, int applied, int total) * accl / acctargets -> do_acc emits a nicer, target-count + receiver toast instead. */ int quiet = (strcmp(op, "load-prefab") == 0) || (strcmp(op, "tl-inherit-portable") == 0) || (strcmp(op, "accl") == 0) || (strcmp(op, "acctargets") == 0); + /* load-prefab is now stage-THEN-place (kind=2), and only the drain knows which of the two actually + * happened -- so it owns the toast rather than the page guessing at schedule time. "Placed" means the + * editor is holding the prefab for positioning; "staged" is the old manual-Ctrl+V fallback, which is + * still a correct, usable outcome (see sh_apply_last_place_result). */ + /* (While Load/Place is back on kind=1 the page owns this toast again -- see poc_apply_load_prefab. + * Restore this block alongside kind=2 when the staged-prefab structure issue is fixed, since only + * the drain knows whether the pick-up actually happened.) */ if (iface && iface->vtbl && iface->vtbl->toast && !quiet) iface->vtbl->toast(iface, "SnapStack", text); /* ALSO log directly -- the toast slot logs too, but a no-editor/late drain might skip the engine toast. */ @@ -869,9 +1139,19 @@ static void __cdecl ae_clone_bss_apply_cmd(void) int applied = 0; for (int i = 0; i < count; i++) { if (!items[i].text) continue; - int ok = (items[i].kind == 1) ? ae_mkcmd_one(items[i].text) + /* kind=2 is stage-then-place; its tri-state collapses to "the item succeeded" for the batch count + * (both STAGED and PLACED leave a usable prefab), while g_last_place_result carries the + * distinction the Load/Place toast needs. */ + int ok; + if (items[i].kind == 2) { + int pr = ae_mkcmd_instantiate(items[i].text); + g_last_place_result = pr; + ok = (pr != AE_PASTE_FAILED); + } else { + ok = (items[i].kind == 1) ? ae_mkcmd_one(items[i].text) : (items[i].kind == 3) ? ae_apply_target_write(items[i].id, atoi(items[i].text)) : ae_apply_one(items[i].id, items[i].text); + } if (ok) applied++; } ae_toast_result(op, applied, count); @@ -1187,9 +1467,19 @@ static int slot_apply_sync(sh_iface *self, const sh_apply_item *items, int count int applied = 0; for (int i = 0; i < count; i++) { if (!items[i].text) continue; - int ok = (items[i].kind == 1) ? ae_mkcmd_one(items[i].text) + /* kind=2 is stage-then-place; its tri-state collapses to "the item succeeded" for the batch count + * (both STAGED and PLACED leave a usable prefab), while g_last_place_result carries the + * distinction the Load/Place toast needs. */ + int ok; + if (items[i].kind == 2) { + int pr = ae_mkcmd_instantiate(items[i].text); + g_last_place_result = pr; + ok = (pr != AE_PASTE_FAILED); + } else { + ok = (items[i].kind == 1) ? ae_mkcmd_one(items[i].text) : (items[i].kind == 3) ? ae_apply_target_write(items[i].id, atoi(items[i].text)) : ae_apply_one(items[i].id, items[i].text); + } if (ok) applied++; } ae_toast_result(op_label ? op_label : "apply", applied, count); @@ -1216,6 +1506,97 @@ void sh_apply_engine_get_serialize_selection(sh_serialize_selection_fn *serializ if (serialize_selection) *serialize_selection = slot_serialize_selection; } +/* Outcome of the most recent kind=2 (stage-then-pick-up) item: AE_PASTE_FAILED / _STAGED / _HELD. + * Lets the Load/Place caller word its toast accurately instead of assuming the pick-up happened. */ +int sh_apply_last_place_result(void) +{ + return (int)g_last_place_result; +} + +/* ============================================================ post-Play staging invalidation ======== + * A Play round-trip tears the staged prefab's contents out from under editor+0x209a8 while LEAVING its + * entity count non-zero. The engine's paste-available gate only tests that count, so coming back to the + * editor and pressing Ctrl+V instantiates from freed memory -> heap corruption / crash. Reported live + * 2026-07-27 (crash on Ctrl+V after returning from Play). + * + * This is PRE-EXISTING and independent of the Load/Place work -- it fires for any prefab staged before a + * Play, including one staged by the old stage-only build. The clone already half-knew: ae_mkcmd_one + * re-ctors the slot before every stage precisely because it "can retain a nested entity whose className + * idStr is NULL" after "a delete (and/or a Play round-trip)". That protects re-staging but does nothing + * for a bare Ctrl+V. + * + * Fix: on the editor-session edge (gone -> back, i.e. we just returned from Play) zero the staged entity + * count. That is the exact field the engine's gate reads, so paste simply becomes unavailable instead of + * dangerous -- and it is a single int store, no frees and no ctor call, so it cannot itself corrupt + * anything. The prefab is one Load/Place away from being staged again (which re-reads it from disk), so + * nothing the user cares about is lost. + * + * Called from the UI think loop; safe from any thread (one aligned store to a field the engine only + * reads while building its per-frame capability flags). */ +void sh_apply_prefab_poll_play(void) +{ + if (!g_doom_base) return; + + int cur = 0; + if (!ae_read_u32_safe(g_doom_base + LOAD_STATE_RVA, &cur)) return; + LONG prev = InterlockedExchange(&g_last_load_state, (LONG)cur); + /* React on the way OUT (RUNNING -> a load starting), not on the way back. At this moment our staged + * prefab is about to become garbage but its memory is still intact, so re-initialising is safe and + * leaves a clean empty slot for the whole Play session and everything after it. Cleaning on the way + * back would be too late: the engine can touch the dangling slot first. */ + if (prev != LOAD_STATE_RUNNING || cur == LOAD_STATE_RUNNING) return; + + const uint8_t *ed = ae_editor_session(); + if (!ed) return; + + int ent_count = 0; + (void)ae_read_u32_safe(ed + PASTE_PREFAB_ENTCOUNT_OFF, &ent_count); + + /* Re-initialise the staging slot IF AND ONLY IF the prefab in it is one WE put there and nothing has + * overwritten it since. Established live 2026-07-27: + * - an engine-made Ctrl+C clipboard survives a Play round-trip and across maps, and must be left + * strictly alone (an earlier revision zeroed the count unconditionally and broke exactly that); + * - OUR JSON-deserialised prefab does NOT survive. After Play, Ctrl+V pasted nothing, and Ctrl+C + * then faulted with heap corruption -- because the engine's copy handler calls CreatePrefab on + * this slot, and CreatePrefab's first act is to tear down the existing contents. Tearing down + * pointers the map teardown already freed is a double free. + * So the danger is not reading the dead slot, it is the NEXT Ctrl+C writing over it. Re-ctor'ing + * fixes that: the ctor rewrites every field unconditionally with no reads and no frees, so it turns + * the dangling pointers into a clean empty prefab -- it LEAKS the already-dead allocations rather + * than double-freeing them, which is exactly what we want when they are already gone. + * + * The user loses our staged prefab across Play (press Load/Place again, which re-reads it from disk). + * That is a real limitation, not the intended end state: the durable fix is to make our staging + * allocate the way CreatePrefab does so it survives like the engine's own clipboard. That needs RE of + * what CreatePrefab allocates differently, and is tracked as an open question rather than guessed at. */ + int mine = (InterlockedCompareExchange(&g_we_staged, 0, 1) == 1); + if (mine && ent_count != 0 && ent_count == (int)g_we_staged_count && g_prefab_ctor) { + __try { + g_prefab_ctor((void *)(ed + PASTE_STAGING_OFF)); + char l[200]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "C2 stage: our %d-entity prefab did not survive Play -> slot re-initialised " + "(prevents the double-free a later Ctrl+C would hit); re-press Load/Place", ent_count); + backend_log(l); + } __except (EXCEPTION_EXECUTE_HANDLER) { + backend_log("C2 stage: slot re-init faulted (left as-is)"); + } + } else if (ent_count != 0) { + char l[200]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "C2 stage: staging slot holds %d entities that are NOT ours (engine clipboard) " + "-> left strictly intact", ent_count); + backend_log(l); + } + + /* NOTE: an earlier revision also forced a rebuild of the editor's cached action-gate byte here, on the + * theory that Ctrl+C / Ctrl+V were being GATED OFF after Play. That theory is dead: the shield log + * shows Ctrl+C actually RUNS and faults (repeated first-chance AVs at runtime rip+0x1ab32ee reading + * -1, classified "in-editor draw fault -> aborted draw"), i.e. it was tripping over the poisoned slot, + * not refusing to run. Cleaning the slot is the fix; poking the gate cache was treating a symptom that + * did not exist, so it is removed rather than left in as a harmless-looking write. */ +} + int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t *module_base, void *cmdsys) { if (InterlockedCompareExchange(&g_installed, 1, 0) != 0) return 0; /* one-shot */ @@ -1242,6 +1623,12 @@ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t * g_decl_rebuild = (decl_src_rebuild_fn)sig_addr_by_name(results, n, "DeclSourceRebuild"); g_buffer_cmd = (buffer_cmd_fn) sig_addr_by_name(results, n, "BufferCommandText"); g_add_command = (add_command_fn) sig_addr_by_name(results, n, "AddCommand"); + /* kind=2 place-after-stage. SIGNATURE-resolved deliberately -- NO raw-RVA fallback. On this build the + * on-disk RVAs these were extracted at do not necessarily equal the runtime ones, so a hardcoded + * address could land mid-instruction; a signature matches the BYTES wherever the loader put them, and + * an unresolved sig cleanly degrades kind=2 to stage-only rather than calling into nothing. */ + g_paste_instantiate = (paste_instantiate_fn)sig_addr_by_name(results, n, "PasteInstantiate"); + g_enter_prefab_grab = (enter_prefab_grab_fn)sig_addr_by_name(results, n, "EnterAddPrefabGrab"); /* the prefab-from-selection serialize engine fns (+0xb0). These jumptable/inline-prone leaves * resolve by FALLBACK RVA off module_base (re-derive-tagged like the editor singleton); a wrong/shifted @@ -1255,9 +1642,11 @@ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t * char line[256]; _snprintf_s(line, sizeof line, _TRUNCATE, - "C2 wave B: apply-engine install -- ser=%d deser=%d commit=%d cmdsys=%p buf_cmd=%p add_cmd=%p", + "C2 wave B: apply-engine install -- ser=%d deser=%d commit=%d cmdsys=%p buf_cmd=%p add_cmd=%p " + "paste=%p grab=%p", ae_serialize_bound(), ae_deserialize_bound(), ae_commit_bound(), - g_cmdsys, (void *)g_buffer_cmd, (void *)g_add_command); + g_cmdsys, (void *)g_buffer_cmd, (void *)g_add_command, + (void *)g_paste_instantiate, (void *)g_enter_prefab_grab); backend_log(line); return ae_serialize_bound() && ae_deserialize_bound() && ae_commit_bound(); } diff --git a/src/backend/apply_engine.h b/src/backend/apply_engine.h index 25770e0..206b25d 100644 --- a/src/backend/apply_engine.h +++ b/src/backend/apply_engine.h @@ -35,6 +35,23 @@ * (the slots still bind -- each body null-checks its own deps and degrades). Idempotent (one-shot latch). */ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t *module_base, void *cmdsys); +/* Outcome of the most recent apply item of kind=2 (prefab stage-THEN-pick-up, i.e. Load/Place without a + * manual Ctrl+V). NB this never places anything into the map -- it reproduces Ctrl+V, which ends with the + * prefab HELD; the user still positions it and clicks to drop: + * 0 = the stage failed -> nothing usable happened + * 1 = STAGED ONLY -> a correct staged prefab is waiting; tell the user to press Ctrl+V + * 2 = HELD -> instantiated and grabbed, awaiting the positioning click + * There is no partial outcome: every abort inside the pick-up step leaves exactly the stage-only state. + * Written on the DOOM main thread during the apply drain; read by the scheduling caller afterwards. */ +int sh_apply_last_place_result(void); + +/* Poll for the "just returned from Play" edge. Marks the editor's cached action-gate byte dirty so the + * engine rebuilds it from live state -- without this, Ctrl+C / Ctrl+V and the other gated actions can + * stay disabled until some unrelated edit happens to dirty the cache. Does NOT touch the staging slot: + * a staged clipboard is meant to survive a Play round-trip and across maps (confirmed vanilla + * behaviour). Cheap (one editor-session probe); call it every think-loop tick. Safe from any thread. */ +void sh_apply_prefab_poll_play(void); + /* Fix B (sh_target_any timeline trigger): enqueue a targets-write -- append the TARGET entity's module-qualified * id to the SOURCE entity's state.edit.targets (the engine's native `activate` trigger), committed on the DOOM * main thread. Called by the sh_target_any wire hook for a bare timeline target INSTEAD of laying an invalid diff --git a/src/backend/signatures.c b/src/backend/signatures.c index 153e27f..ffe4263 100644 --- a/src/backend/signatures.c +++ b/src/backend/signatures.c @@ -712,5 +712,64 @@ const sig_entry BACKEND_ENGINE_SIGNATURES[] = { "48 89 74 24 18 57 48 83 EC 40 48 8B F2 48 8B F9 8B 49 18 8B D1 C1 EA 1F 80 E2 01 74 ?? " "44 8B 46 18 41 8B C0", 0x33A380u }, + { "PasteInstantiate", /* idSnapEntityPrefab instantiate -- void(prefab [rcx], editor [rdx]). + * Takes the STAGED prefab at editor+0x209a8 and builds its entities into the + * live map: snapshots the map's nine id-allocation counters, computes a + * camera-relative placement transform from editor+0x170 (camera origin) / + * +0x180 (yaw) against the prefab's own saved yaw, then per entity + * deserializes the blob (stride 0x1b0), registers it, sets its visibility bit + * and calls AddToSelection; finally rebuilds connections + var-refs and sets + * the hovered id to the first new entity. + * + * HARD PRECONDITION -- the selection MUST be empty on entry. The connection/ + * var-ref remap translates every stored old index i through + * *(int*)(selObj+0x80 + i*4), i.e. it uses the SELECTION ARRAY as its + * old->new id map, and AddToSelection APPENDS. With a live selection of k the + * remap reads selection[i] instead of selection[k+i], so the pasted entities + * get wired to the pre-existing selected ones and the tail reads run past the + * count -- a map that still loads and is silently mis-wired. The engine never + * hits this because its own paste branch lives in the IDLE sub-state. + * + * The engine's only call site is the idle sub-state dispatcher, on abstract + * action 0x5C, gated on substate+0x41 & 0x40 (= snapEdit_enableCopyPaste != 0 + * AND editor+0x209e0 >= 1 AND not hovering an entity). Immediately followed + * there by EnterAddPrefabGrab -- calling this ALONE leaves the placed prefab + * in an inconsistent tool state (the 2026-07-06 "placed but undraggable, then + * AV on the next Play transition" failure). + * + * 40-byte prologue, no wildcards (frame-size + xmm-save shape; no rip-relative + * or rel32 in range). Unique on the pinned build. + * RE-DERIVE per build: byte-search the EnterAddPrefabGrab body below (unique), + * take its sole xref -- that is the paste branch -- and back up 8 bytes from + * that call site (CALL rel32 = 5 + MOV RCX,RBP = 3); the instruction there is + * CALL PasteInstantiate, preceded by LEA RCX,[reg+0x209A8] / MOV RDX,reg. + * DIRECT (doom-re campaign synthetic-action-injection, 2026-07-27). */ + "48 8B C4 55 56 57 41 54 41 55 41 56 41 57 48 8D A8 A8 F7 FF FF " + "48 81 EC 20 09 00 00 48 C7 45 E0 FE FF FF FF 48 89 58 18", + 0x11AF070u }, + { "EnterAddPrefabGrab", /* void(mode) -- the editor-state transition the engine runs IMMEDIATELY after + * PasteInstantiate, on the EntityMode object (editor+0x22330). Puts the mode + * into the "holding an unplaced prefab" manipulation state so the placement is + * draggable and its completion bookkeeping runs on click: + * mode[0x2d0] &= 0xfb; clear DUPLICATE flavour + * mode[0x2d0] |= 0x08; set ADD-PREFAB flavour + * mode[0x2d1] |= 0x01; memo: restore "selected" after placing + * *(u32*)(mode+0x1ac) = 4; -> manipulation sub-state + * mode[0xBB8] = 1; redraw + * mode+0x2d0/+0x2d1 are the manipulation sub-state's own +0x40/+0x41 flag + * bytes (that sub-object is inline at mode+0x290), which is what the + * place-commit handler reads to pick its "Add Prefab" vs "Duplicate" vs "Edit" + * behaviour -- so the flavour bits are load-bearing, not cosmetic. + * Two siblings differ ONLY in the +0x2d0 mask: &0xf7|0x04 = Duplicate, + * &0xf3 = Edit/Move. Do not confuse them. + * + * The signature is the ENTIRE function body (38 bytes incl. the RET), fully + * absolute -- no wildcards, no relocations. Its first 7 bytes alone are + * already unique in the image, so this is a very strong anchor and is the + * recommended starting point for re-deriving the whole paste path per build. + * DIRECT (doom-re campaign synthetic-action-injection, 2026-07-27). */ + "80 A1 D0 02 00 00 FB 80 89 D0 02 00 00 08 80 89 D1 02 00 00 01 " + "C7 81 AC 01 00 00 04 00 00 00 C6 81 B8 0B 00 00 01 C3", + 0x1254B80u }, { NULL, NULL, 0 } /* terminator */ }; diff --git a/src/common/snapmap_plus_iface.c b/src/common/snapmap_plus_iface.c index c6676ac..1b6c887 100644 --- a/src/common/snapmap_plus_iface.c +++ b/src/common/snapmap_plus_iface.c @@ -105,8 +105,16 @@ static void iface_unregister_cmd(sh_iface *self, const char *name) * C without the engine's fault surface; a handler fault here would already be the SnapStack op's concern * (op execution wraps it). There are no producers yet, so the queue is always empty -- this just * proves the drain is wired + callable from the frontend's think-loop. */ +/* Backend-side per-tick housekeeping. Declared extern rather than #included so this shared-ABI file + * keeps no compile dependency on the backend's engine layer; it is only ever linked into the backend. + * Currently: invalidate a prefab staging slot left dangling by a Play round-trip (a Ctrl+V on one + * instantiates freed memory -> heap corruption). The drain is the one thing the frontend calls on every + * think-loop tick, which is exactly the cadence this needs. */ +extern void sh_apply_prefab_poll_play(void); + static void iface_drain_work_queue(sh_iface *self) { + sh_apply_prefab_poll_play(); if (!self || !self->sub) return; sub_impl *si = (sub_impl *)self->sub; sh_iface_sub *sub = &si->pinned; diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 0444c07..58a9b20 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -2819,6 +2819,9 @@ if (d.result === 1) toast('Cleared stack 0 (' + d.count + ' id' + (d.count === 1 ? '' : 's') + ' removed)', 'ok'); else toast('Clear stack 0 failed (editor down?)', 'err'); } else if (d.kind === 'loadPrefabResult') { + /* The backend drain owns the OUTCOME toast ("Ready -- move it into position..." vs the + "press Ctrl+V" fallback), because only it knows whether the pick-up actually happened. + This one just confirms the request was accepted. */ if (d.result === 1) toast('"' + d.name + '" staged -- press Ctrl+V in the 3D view to place it', 'ok'); else if (d.result === -1) toast('Could not read "' + d.name + '" (see log)', 'err'); else toast('Could not stage "' + d.name + '" (editor down?)', 'err'); diff --git a/src/ui/webview/snapmap_plus_ui_webview.cpp b/src/ui/webview/snapmap_plus_ui_webview.cpp index 87186ca..1e7b9fb 100644 --- a/src/ui/webview/snapmap_plus_ui_webview.cpp +++ b/src/ui/webview/snapmap_plus_ui_webview.cpp @@ -790,11 +790,35 @@ static void poc_apply_load_prefab() fclose(fp); if (body.empty()) { poc_log("load-prefab: ABORT (empty file)"); return; } + /* Clear first: PasteInstantiate uses the selection array as its old->new id map and AddToSelection + * APPENDS, so instantiating with a live selection silently mis-wires every pasted connection. The + * backend re-verifies this immediately before placing (the clear can legitimately be refused while a + * manipulation snapshot is outstanding) and falls back to stage-only rather than risk it. */ poc_clear_selection_seh(); + /* kind=2 = stage THEN place -- runs the engine's own paste pair (PasteInstantiate + the Add-Prefab + * grab transition), the exact sequence its Ctrl+V branch runs, so the user no longer has to press + * Ctrl+V in the 3D view. Deferred (not sync) on purpose: the place MUST happen on the DOOM main + * thread, which is where the clone_bss_apply drain runs it. If anything is unavailable -- sig + * unresolved, not in EntityMode, selection not empty -- it degrades to the old stage-only behaviour + * and the toast says so; there is no half-placed outcome. */ + /* TEMPORARILY BACK TO kind=1 (stage-only). kind=2 (stage + auto pick-up) is implemented and its + * mechanism is proven -- action injection reaches the engine's own paste branch and the first-press + * grab works -- but it is DISABLED because the prefab we stage is not structurally equivalent to one + * the engine's own CreatePrefab builds. Established live 2026-07-27/28: + * - a vanilla Ctrl+C clipboard survives Play, survives a fresh post-Play copy, and repeat-pastes + * forever with zero faults (clean control run, Load/Place never pressed); + * - with OUR prefab in the slot: Ctrl+V pastes nothing, Ctrl+C faults inside the engine's + * lexer/string free (0x1ab32ee) while CreatePrefab tears the slot down, and repeated pastes end + * in "Memory corruption before block!". + * One paste works and it degrades from there, which points at the object our deserialize builds -- + * not at the call site, and not at a Play-lifetime issue (the map object survives the round-trip, so + * neither the session-null nor the map-pointer teardown detector ever fired). + * Re-enable by setting this back to 2 once the deserialized prefab matches CreatePrefab's output. */ sh_apply_item it; it.kind = 1; it.id = 0; it.text = body.c_str(); g_load_result = poc_apply_edit_seh(&it, 1, "load-prefab"); - char l[300]; _snprintf_s(l, sizeof l, _TRUNCATE, "load-prefab: name='%s' staged=%d", g_load_prefab_name.c_str(), g_load_result); + char l[300]; _snprintf_s(l, sizeof l, _TRUNCATE, "load-prefab: name='%s' scheduled=%d (place result reported by the drain)", + g_load_prefab_name.c_str(), g_load_result); poc_log(l); } /* Timelines Stage 5 (Save): kind=0 (deserialize the FULL patched entity JSON -> temp def -> commit From f433a6036e33a9e942667b090a9d764c8c146338 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:03:30 -0500 Subject: [PATCH 15/16] Fix C unit test link failure: register the tick hook instead of calling it The staging-slot Play watchdog was wired by calling sh_apply_prefab_poll_play() directly from src/common/snapmap_plus_iface.c, on the mistaken assumption that that file is only ever linked into the backend DLL. It is shared ABI and is also compiled standalone into the C unit tests, which link none of the engine layer: snapmap_plus_iface.obj : error LNK2019: unresolved external symbol sh_apply_prefab_poll_play referenced in function iface_drain_work_queue Three test binaries failed to link. The DLL build was unaffected, which is why this got through local build+deploy. Replaced with a registered hook: snapmap_plus_iface.c owns a NULL-by-default function pointer invoked at the head of the drain, and the backend registers sh_apply_prefab_poll_play at install. The shared file keeps no link dependency on the backend, so the test binaries link clean and the drain is a no-op for any consumer that registers nothing. tests\run-tests.ps1: 14/14 pass. Co-Authored-By: Claude Opus 5 --- src/backend/apply_engine.c | 5 +++++ src/common/snapmap_plus_iface.c | 22 +++++++++++++++------- src/common/snapmap_plus_iface.h | 6 ++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/backend/apply_engine.c b/src/backend/apply_engine.c index 974222f..f5ebae8 100644 --- a/src/backend/apply_engine.c +++ b/src/backend/apply_engine.c @@ -1630,6 +1630,11 @@ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t * g_paste_instantiate = (paste_instantiate_fn)sig_addr_by_name(results, n, "PasteInstantiate"); g_enter_prefab_grab = (enter_prefab_grab_fn)sig_addr_by_name(results, n, "EnterAddPrefabGrab"); + /* Run the staging-slot Play watchdog once per frontend think-loop tick. Registered as a hook rather + * than called directly from the shared iface file, which is also built standalone by the C unit + * tests and must not reference the engine layer. */ + sh_iface_set_tick_hook(sh_apply_prefab_poll_play); + /* the prefab-from-selection serialize engine fns (+0xb0). These jumptable/inline-prone leaves * resolve by FALLBACK RVA off module_base (re-derive-tagged like the editor singleton); a wrong/shifted * offset just makes the serialize SEH-fail -> a clean 0-length result, never a crash. */ diff --git a/src/common/snapmap_plus_iface.c b/src/common/snapmap_plus_iface.c index 1b6c887..e388957 100644 --- a/src/common/snapmap_plus_iface.c +++ b/src/common/snapmap_plus_iface.c @@ -105,16 +105,24 @@ static void iface_unregister_cmd(sh_iface *self, const char *name) * C without the engine's fault surface; a handler fault here would already be the SnapStack op's concern * (op execution wraps it). There are no producers yet, so the queue is always empty -- this just * proves the drain is wired + callable from the frontend's think-loop. */ -/* Backend-side per-tick housekeeping. Declared extern rather than #included so this shared-ABI file - * keeps no compile dependency on the backend's engine layer; it is only ever linked into the backend. - * Currently: invalidate a prefab staging slot left dangling by a Play round-trip (a Ctrl+V on one - * instantiates freed memory -> heap corruption). The drain is the one thing the frontend calls on every - * think-loop tick, which is exactly the cadence this needs. */ -extern void sh_apply_prefab_poll_play(void); +/* Optional backend-side per-tick housekeeping, registered at install time. A REGISTERED HOOK, not an + * extern call: this file is shared ABI and is also compiled standalone into the C unit tests, which link + * none of the backend's engine layer -- an `extern void sh_apply_prefab_poll_play(void)` here builds fine + * in the DLL and fails the test binaries with LNK2019. NULL unless the backend sets it, so the tests link + * clean and the drain stays a no-op for anyone who does not register one. + * The backend currently uses it to re-initialise a prefab staging slot that a Play round-trip would leave + * dangling. The drain is the one thing the frontend calls on every think-loop tick, which is the cadence + * that needs. */ +static void (*g_tick_hook)(void) = NULL; + +void sh_iface_set_tick_hook(void (*fn)(void)) +{ + g_tick_hook = fn; +} static void iface_drain_work_queue(sh_iface *self) { - sh_apply_prefab_poll_play(); + if (g_tick_hook) g_tick_hook(); if (!self || !self->sub) return; sub_impl *si = (sub_impl *)self->sub; sh_iface_sub *sub = &si->pinned; diff --git a/src/common/snapmap_plus_iface.h b/src/common/snapmap_plus_iface.h index 5c7bd3a..696c095 100644 --- a/src/common/snapmap_plus_iface.h +++ b/src/common/snapmap_plus_iface.h @@ -503,6 +503,12 @@ typedef struct sh_ui_argblock { * interface doesnt exist yet!". Returns NULL on allocation failure. */ sh_iface *sh_iface_create(void); +/* Register an optional per-tick callback run at the head of the DRAIN (+0x1a0), i.e. once per frontend + * think-loop tick. Deliberately a registered hook rather than a direct call so this shared-ABI file keeps + * no link dependency on the backend's engine layer -- it is also compiled standalone into the C unit + * tests, which link none of it. Pass NULL to clear. */ +void sh_iface_set_tick_hook(void (*fn)(void)); + /* ------------------------------------------------------------------ cmd-map lookup ------------- * Look the subcommand `name` up in the interface's runtime cmd-map (the obj+0x58 RB-tree the OG's * register path populates; our backing store is the sub_impl's linear map). On a hit, fills *handler + From 79242f1de0ae55cc433cafb86343142009e014e0 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:28:23 -0500 Subject: [PATCH 16/16] Resolve the prefab ctor/populate by signature instead of hardcoded RVA Both were raw `module_base + RVA` leaves, locked to one DOOM build. That matters more than it used to: the prefab ctor now runs on every stage AND on the Play watchdog's staging-slot re-init, so if a game patch shifted it we would call into arbitrary code on a timer, silently. Located behaviourally rather than by arithmetic, because on-disk and runtime RVAs differ per code region on this build and no single delta converts between them (two computed candidates both landed mid-function): - PrefabCtor: writes every field unconditionally with no reads and no branches -- which is exactly why re-ctor'ing a slot holding dangling pointers is safe, it leaks rather than double-frees -- fills the identity transform from a constants block, zeroes each list member, writes to +0x118, then forward-calls a second larger ctor for the tail sub-object at +0x120. Field-for-field match to this project's own recorded description of the runtime ctor. - PrefabPopulate (CreatePrefab): anchored on its own error strings ("Failed to create prefab: ..."), so the identification rests on data it prints. Signatures: ctor is unique at 23 bytes and stops before the first RIP-relative operand, so it carries no build-volatile bytes and needs no wildcards; populate is 46 bytes, fully absolute. Both verified unique in the image. Resolution is signature-first with the old RVA as a cross-checked fallback, and a signature that DISAGREES with the RVA is refused in favour of the RVA -- so the worst case is exactly the previous behaviour, never something new. Which path was taken is logged, so a future build's drift shows up as a log line instead of a crash. Verified live: both report MATCH at identical addresses (0x54D0A0 / 0x54E410), independently confirming the identification. 14/14 native tests pass. PREFAB_DTOR_RVA and ENT_DESHARE_RVA stay raw RVAs -- not yet located well enough to prove a unique signature, and a guessed signature is worse than a build-locked address that demonstrably works. Co-Authored-By: Claude Opus 5 --- src/backend/apply_engine.c | 51 ++++++++++++++++++++++++++++++++++++-- src/backend/signatures.c | 31 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/backend/apply_engine.c b/src/backend/apply_engine.c index f5ebae8..a3bf8fa 100644 --- a/src/backend/apply_engine.c +++ b/src/backend/apply_engine.c @@ -1597,6 +1597,47 @@ void sh_apply_prefab_poll_play(void) * did not exist, so it is removed rather than left in as a harmless-looking write. */ } +/* Resolve an engine leaf by SIGNATURE, with a historical raw RVA as fallback, and REFUSE a signature + * that disagrees with that RVA on this build. + * + * Why the cross-check rather than just trusting the scan: these leaves are called on live editor state + * (the prefab ctor runs on every stage and on the Play watchdog's slot re-init), so a signature that + * resolved to a wrong-but-valid address would corrupt silently and on a timer. Preferring the RVA on a + * mismatch makes the worst case exactly the pre-existing behaviour, never something new. + * + * The point of the migration is build portability: a raw base+RVA is locked to one DOOM build and fails + * SILENTLY into arbitrary code if the game is patched, whereas a signature matches bytes wherever the + * loader put them. Logs which path was taken so a future build's drift is visible at a glance instead of + * being discovered as a crash. */ +static void *ae_pick_engine_fn(const sig_result *results, size_t n, const char *sig_name, + const uint8_t *base, uint32_t fallback_rva, const char *label) +{ + uintptr_t s = sig_addr_by_name(results, n, sig_name); + uintptr_t r = base ? (uintptr_t)(base + fallback_rva) : 0; + char l[192]; + + if (s && r && s == r) { + _snprintf_s(l, sizeof l, _TRUNCATE, "C2 sig: %s sig=%p rva=%p MATCH", label, (void *)s, (void *)r); + backend_log(l); + return (void *)s; + } + if (s && r) { + _snprintf_s(l, sizeof l, _TRUNCATE, + "C2 sig: %s sig=%p rva=%p MISMATCH -> signature REFUSED, using rva", label, + (void *)s, (void *)r); + backend_log(l); + return (void *)r; + } + if (s) { + _snprintf_s(l, sizeof l, _TRUNCATE, "C2 sig: %s sig=%p (no rva to cross-check)", label, (void *)s); + backend_log(l); + return (void *)s; + } + _snprintf_s(l, sizeof l, _TRUNCATE, "C2 sig: %s sig=MISS -> using rva=%p (build-locked)", label, (void *)r); + backend_log(l); + return (void *)r; +} + int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t *module_base, void *cmdsys) { if (InterlockedCompareExchange(&g_installed, 1, 0) != 0) return 0; /* one-shot */ @@ -1639,8 +1680,14 @@ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t * * resolve by FALLBACK RVA off module_base (re-derive-tagged like the editor singleton); a wrong/shifted * offset just makes the serialize SEH-fail -> a clean 0-length result, never a crash. */ if (module_base) { - g_prefab_ctor = (prefab_ctor_fn) (module_base + PREFAB_CTOR_RVA); - g_prefab_populate = (prefab_populate_fn)(module_base + PREFAB_POPULATE_RVA); + /* ctor + populate are now SIGNATURE-first with the old RVA as a cross-checked fallback (see + * ae_pick_engine_fn). dtor + deshare remain raw RVAs: they have not been located in a way that + * would let a signature be extracted and proven unique, and guessing one is worse than a + * build-locked address that demonstrably works. Migrate them the same way when they are. */ + g_prefab_ctor = (prefab_ctor_fn) ae_pick_engine_fn(results, n, "PrefabCtor", + module_base, PREFAB_CTOR_RVA, "prefab ctor"); + g_prefab_populate = (prefab_populate_fn)ae_pick_engine_fn(results, n, "PrefabPopulate", + module_base, PREFAB_POPULATE_RVA, "prefab populate"); g_prefab_dtor = (prefab_dtor_fn) (module_base + PREFAB_DTOR_RVA); g_deshare = (ent_deshare_fn) (module_base + ENT_DESHARE_RVA); } diff --git a/src/backend/signatures.c b/src/backend/signatures.c index ffe4263..393025c 100644 --- a/src/backend/signatures.c +++ b/src/backend/signatures.c @@ -712,6 +712,37 @@ const sig_entry BACKEND_ENGINE_SIGNATURES[] = { "48 89 74 24 18 57 48 83 EC 40 48 8B F2 48 8B F9 8B 49 18 8B D1 C1 EA 1F 80 E2 01 74 ?? " "44 8B 46 18 41 8B C0", 0x33A380u }, + { "PrefabCtor", /* idSnapEntityPrefab ctor -- idSnapEntityPrefab *(self [rcx]). + * Was a raw base+RVA leaf (`PREFAB_CTOR_RVA`), which is build-locked; this is + * now the primary resolve with that RVA kept only as a fallback. It matters: + * ae_mkcmd_one calls it before every stage AND the Play watchdog calls it to + * re-initialise the staging slot, so a wrong address here corrupts on a timer. + * IDENTIFIED BEHAVIOURALLY, not by arithmetic (on-disk and runtime RVAs differ + * per region on this build, so no delta is reliable): it writes every field + * unconditionally with no reads and no branches -- which is exactly why + * re-ctor'ing a slot with dangling pointers is safe, it leaks rather than + * double-frees -- fills the identity transform from a constants block, zeroes + * each list member (capacity word 0x50000), writes up to +0x118, then makes a + * forward call into a second, larger ctor for the tail sub-object at +0x120. + * That is a field-for-field match to this project's own description of the + * runtime ctor. Unique at 23 bytes; deliberately stopped before the first + * RIP-relative operand so the pattern carries NO build-volatile bytes and + * needs no wildcards. + * Extracted from the on-disk image at 0x11AC8D0; known_rva below is the + * RUNTIME address, because that is the only address space the fallback can + * legitimately be used in. */ + "4C 8B DC 49 89 4B 08 53 48 83 EC 30 49 C7 43 E8 FE FF FF FF 48 8B D9", + 0x54D0A0u }, + { "PrefabPopulate", /* CreatePrefab -- char(prefab [rcx], editor [rdx], int *outStatus [r8]). + * Fills a prefab from the current editor selection; the Create-from-selection + * (+0xb0) path. Also a former raw base+RVA leaf. Located from its own error + * strings ("Failed to create prefab: not hovering entity in selection." and + * the three siblings), so the identification is anchored on data it prints, + * not on an address delta. 46-byte prologue, fully absolute, unique. + * Extracted on-disk at 0x11ADB30; known_rva is the RUNTIME address. */ + "40 55 56 57 41 54 41 55 41 56 41 57 48 8D AC 24 40 F7 FF FF " + "48 81 EC C0 09 00 00 48 C7 85 10 01 00 00 FE FF FF FF 48 89 9C 24 18 0A 00 00", + 0x54E410u }, { "PasteInstantiate", /* idSnapEntityPrefab instantiate -- void(prefab [rcx], editor [rdx]). * Takes the STAGED prefab at editor+0x209a8 and builds its entities into the * live map: snapshots the map's nine id-allocation counters, computes a