Skip to content

Editor UX: keyboard paging for lists/dropdowns, Lift in the built-in palette, and text-field copy/paste and prefab staging copy/paste fix - #49

Merged
adiazpar merged 20 commits into
doom-snapmap:mainfrom
mefisme:main
Jul 28, 2026
Merged

Editor UX: keyboard paging for lists/dropdowns, Lift in the built-in palette, and text-field copy/paste and prefab staging copy/paste fix#49
adiazpar merged 20 commits into
doom-snapmap:mainfrom
mefisme:main

Conversation

@mefisme

@mefisme mefisme commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What this changes

Three editor-UX improvements from one session. Each is an independent commit, so they can be split if
you'd rather review them separately.

1. Keyboard paging for every list and dropdown (067bc94)

ArrowUp/ArrowDown now page the Entities, Timelines and Prefabs lists (moving the highlight selects the
row exactly as clicking it does), and -- while their dropdown is open -- the Inherit / Classname combos
and the Timelines "Runs on" picker. Filtering still works alongside it; the decl editor, prefab
description and folder-rename fields keep normal caret movement.

The Entities/Prefabs handlers are document-scoped rather than bound to their filter box: "Follow
selection" drives the Entities list purely from the live 3D-editor pick, so the user may never click
into the filter box or a row at all.

Same commit also excludes SnapMap's own built-in filter/droppable helper entities from the entity
list and every entity picker. These are engine-seeded (any player / any ai, per-team/player/race
prefilters, keycards, flags, power cores), dev-layer-only, never placed or edited by a mapper -- and
they buried the real entities whenever Show Hidden was on. Matched on the id starting with
snapmaps/filter/, not containing it, so a mapper's own filter (0_c_ind_cross/snapmaps/filter/ai_151)
is unaffected. The " (no module)" suffix is deliberately not used as the signal, since it also appears
on any real entity not yet assigned to a module.

*2. Lift added to the built-in "Custom" palette (65b9c7e)

A third baked override alongside Timeline and Unknown. Recipe A -- entityDef unchanged, just the
snapeditorentitydef palette tile promoted into the toybox category with its flags flipped
placeable/editable/moveable/deletable. No propertySheets yet.

3. Ctrl+C / Ctrl+V in the editor's text fields (6d29ece, eda795a, 7bd3412)

Vanilla has no text clipboard in the editor at all -- only the dev console accepts a paste. It is
genuinely absent rather than disabled: the engine's SWF text-edit key handler has no Ctrl branch
whatsoever, only a shift flag for selection.

Detours idSWFScriptFunction_onKey::Call, the single keyboard entry point for a focused Scaleform text
field. Copy takes the selection (or the whole field when nothing is selected); paste splices at the
selection or inserts at the caret, mirroring exactly what the stock BACKSPACE/DELETE case does, so it
inherits the engine's own semantics. Works in every text-backed inspector -- free text, int, float, vec3
and size. Respects the field's character cap. CR is stripped; in a single-line field a run of newlines
collapses to one space, while multi-line fields keep them.

No filtering on what may be pasted into numeric fields: the editor already accepts arbitrary typed text
there and resolves it at commit (non-numeric commits as 0, confirmed live), so matching that is more
faithful than inventing a restriction the editor lacks.

Two notes for reviewers:

  • The engine's object type check is a pointer-identity compare against an interned type-name string,
    not a strcmp, so a caller must pass the engine's own pointer. It is decoded from the handler's own
    prologue (mov rdx,[rip+rel32] at +0x4B) rather than any hardcoded RVA, and a byte mismatch
    disarms the feature instead of guessing.
  • This adds IdStrAssignFromStr, the real idStr::operator=(const idStr&). The existing IdStrAssign
    entry is misnamed -- it resolves to an intern-and-store-pointer for decl name fields, and using it
    on an idStr would write a pointer over the vtable field. Left alone here (it is presumably correct for
    its actual use), but flagging it.

Known follow-ups (not in this PR)

  • src/backend/apply_engine.c:350-354 carries a len < 0x10 ? inline : heap idStr read. The engine's
    own idStr::Left dereferences +0x10 unconditionally, and rawmap.c reads it that way too, so the
    two files disagree. The same pattern shipped briefly in the clipboard code and garbled every string
    under 16 chars. Not touched here because it is in the decl-apply path and may be handling a different
    struct.
  • Paste has no undo integration -- it is a single edit like any other, but the editor's own undo was not
    exercised against it.
  • The Lift override still has no propertySheets.

Checklist

  • Clean-room: this is my own reverse-engineering / implementation -- no decompiled or copyrighted DOOM or
    original-SnapHak content is pasted into the repo.
  • No binaries added (.dll / .exe / .obj / .pdb / .zip ...) -- the source is the only deliverable.
  • Source stays pure ASCII (.c / .h / .cpp / .ps1).
  • Built + tested locally: build.ps1 -> package.ps1 -> snapmap-plus install --local dist, and I ran the Go
    (gofmt / go vet / go test) and C (tests\run-tests.ps1) suites, and confirmed it works in DOOM.
  • Docs updated in this PR for any behavior change (see docs/contributing.md section 9).

Test detail: 14/14 native tests pass; gofmt clean, go vet clean, go test ./... ok. All three
features were exercised in the running editor. For the clipboard specifically: value persists through
commit and reopen; replace-selection and insert-at-caret; start/end/empty-field edges; round trip
between fields; multi-line into both field kinds; no-focus, empty-clipboard and non-text-clipboard all
no-op; entity-level Ctrl+C/Ctrl+V and Ctrl+X unaffected; no stray characters inserted.

mefisme added 12 commits July 19, 2026 23:26
download depot update.  The information was incorrect.
deleted and removed personal dev conveneince script from .gitignore
…ties

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.
Adds a third built-in override (Recipe A -- entityDef unchanged, just
the snapeditorentitydef palette tile promoted): func/lift.decl moves
into the toybox category alongside Timeline/Unknown, with editable/
placeable/moveable/deletable flags flipped on. No propertySheets yet.
Vanilla SnapMap has no text clipboard support in the editor at all -- and
it is genuinely absent rather than disabled: the engine's SWF text-edit
key handler has no Ctrl branch whatsoever, only a shift flag for
selection. This adds the copy half.

Detours idSWFScriptFunction_onKey::Call, the single keyboard entry point
for a focused Scaleform text field. That site hands us everything needed:
the "TextField" script object (its +0xC0 is the idSWFTextInstance) and
the (scancode, isDown) pair. Focus needs no lookup -- the handler only
runs for the field that has it. Ctrl is tracked from the same down/up
pair the stock handler uses to track shift, so no engine modifier global
is needed either.

Ctrl+C copies the selection, or the whole field when nothing is selected.
Confirmed live across text, int, float, vec3 and size inspectors.

Read-only against the engine; paste follows separately.

Two subtleties worth recording:
- The object type check is a POINTER-IDENTITY compare against an interned
  type-name string, not a strcmp, so our own "TextField" literal would
  always answer false. The interned pointer is decoded at install time
  from the handler's own prologue (mov rdx,[rip+rel32] at +0x4B) rather
  than a hardcoded RVA.
- idStr's +0x10 is ALWAYS a char* (short strings point at the object's
  own inline buffer at +0x1c). An SSO-style "len<0x10 ? inline : heap"
  branch reads the pointer's own bytes as text -- it shipped briefly and
  garbled every short string.
Completes the text clipboard. Paste splices at the selection (replacing
it) or inserts at the caret, mirroring exactly what the stock
BACKSPACE/DELETE case does -- rebuild as left + inserted + right, assign
back into the live idStr, collapse the selection -- so it inherits the
engine's own semantics instead of inventing new ones.

CR is always stripped; LF becomes a space in a single-line field and is
kept in a multi-line one. The field's own character cap is honoured by
truncating rather than overflowing.

No filtering on what may be pasted into numeric fields: the editor
already accepts arbitrary typed text there and resolves it at commit
(non-numeric commits as 0, confirmed live), so matching that is more
faithful than inventing a restriction the editor lacks.

Verified live: value persists through commit/reopen; replace-selection
and insert-at-caret; start/end/empty-field edges; round trip between
fields; multi-line into both field kinds; no-focus, empty-clipboard and
non-text-clipboard all no-op; entity Ctrl+C/V and Ctrl+X unaffected; no
stray characters.

Adds IdStrAssignFromStr -- the REAL idStr::operator=(const idStr&). Note
this DB's existing "IdStrAssign" is misnamed: it resolves to an
intern-and-store-pointer for decl name fields, and using it on an idStr
would write a pointer over the vtable field. Paste resolves its assign
separately and stays disabled if it does not resolve, rather than
guessing -- it is the only thing here that writes.
Paste into a SINGLE-LINE field now collapses any run of newlines to one
space, and drops a run entirely at the very start or end. Emitting one
space per newline leaked the source's blank lines as runs of spaces,
which pasted prose is full of. Multi-line fields are unchanged --
blank lines are real formatting in a datapad body.

Docs:
- capabilities.md: the text-field clipboard hook, keyboard paging, the
  built-in filter-entity exclusion, and Lift joining Timeline/Unknown in
  the built-in "*Custom" palette set.
- webview-ui.md: a changelog entry for the keyboard paging (including
  why the Entities/Prefabs handlers are document-scoped rather than
  bound to their filter box) and the filter-entity exclusion (including
  why it matches the id PREFIX, so a mapper's own filters are untouched).
@mefisme
mefisme requested a review from adiazpar as a code owner July 27, 2026 09:53
A selection pushed from the Entities list ("Select in 3D") did not behave
like a native one: an empty-space click would not deselect it, Delete did
not remove all of it, and Move could soft-lock the game. Only the explicit
Deselect button worked.

Root cause (RE'd in the companion doom-re project, campaign
native-click-deselect): the editor keeps a per-mode object inline in the
editor object, whose state field records whether anything is selected. The
native click handler sets that field on a successful hit AND adds to the
selection array; on an empty-space miss it deliberately does nothing at
all. So an empty click only deselects because the editor is in the
"something is selected" state. add_to_selection wrote the array only,
leaving the editor believing nothing was selected -- so the miss path
correctly did nothing. The same inconsistency explains the Delete and Move
breakage; they were never separate bugs. It also explains the old
workaround of first clicking an already-selected entity in the 3D view,
which runs the native hit path and sets the state regardless of how the
selection was made.

iface_engine.c now syncs that state alongside every selection-array write:
add_to_selection -> selected, clear_selection -> idle, and
remove_from_selection -> idle only once the selection is empty, so a full
Delete cannot leave the inverse inconsistency. Writes are SEH-guarded and
gated on the editor already being in EntityMode, matching this file's
conventions; offsets and a per-build re-derive recipe are documented at the
constant block.

The Entities list also clears its highlight on a native deselect in
"Select in 3D" mode. The backend already broadcast the live selection count
on every change; the UI simply was not acting on it reaching zero. Gated to
that mode, to a real >0 -> 0 transition, and to the case where the list
actually has a highlight.

Verified live: empty-click deselect, Delete, Move and the bottom-bar
controls all work for single and multi-entity selections and when switching
between entities. Deselect button kept as a convenience and as the escape
hatch if the mode state is ever out of sync; its tooltip and the stale
"root cause is unRE'd" notes are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mefisme

mefisme commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Added commit 3320152native 3D-viewport deselect fixed at the root.

This resolves the long-standing behavior the Deselect button existed to work around, and it turned out to also be the cause of two symptoms we hadn't connected to it.

Symptoms

A selection pushed from the Entities list ("Select in 3D") didn't behave like a native one:

  • an empty-space click in the 3D view wouldn't deselect it
  • Delete didn't remove all of the selected entities
  • Move could soft-lock the game (process alive, controls unresponsive)

Only the explicit Deselect button worked.

Root cause

Reverse-engineered in the companion doom-re project (campaign native-click-deselect).

The editor keeps a per-mode object inline in the editor object, with a state field recording whether anything is selected. The native click handler sets that field on a successful hit and adds to the selection array. On an empty-space miss it deliberately does nothing at all.

So an empty click only deselects because the editor is in the "something is selected" state. add_to_selection wrote the array only, leaving the editor believing nothing was selected — so the miss path correctly did nothing.

That single inconsistency explains every symptom, including Delete and Move; they were never separate bugs. It also explains the old workaround: clicking an already-selected entity in the 3D view runs the native hit path, which sets the state regardless of how the selection was made.

Worth noting for anyone who followed the earlier attempt — the previously suspected field was a "currently held/picked-up" render indicator, which is why writing it only ever changed the hover outline color. The function it was copied from turned out to be the place-a-new-filter path, not the click-select handler.

Change

src/backend/iface_engine.c syncs the mode state alongside every selection-array write:

slot mode state
add_to_selection selected
clear_selection idle
remove_from_selection idle, only once the selection is empty

That third case is deliberate: without it a full Delete would leave the inverse inconsistency (empty array, mode still claiming a selection).

All writes are SEH-guarded and gated on the editor already being in EntityMode, matching this file's existing conventions. Offsets and a per-build re-derive recipe are documented at the constant block, including a note that the older AddToSelection/ClearSelection RVAs in that file were proven stale — which is exactly why they resolve by signature.

The Entities list also now clears its highlight on a native deselect in "Select in 3D" mode. No backend work was needed: the live selection count was already broadcast on every change (the Prefabs "Create from selection (N)" button uses it), the UI just wasn't acting on it reaching zero. Gated to that mode only, to a real >0 → 0 transition, and to the case where the list actually has a highlight.

Verification

Live-tested in the editor: empty-click deselect, Delete, Move, and all bottom-bar controls work for single and multi-entity selections and when switching between entities; the list highlight clears on a native deselect. Native C test suite passes (14/14).

Notes

  • The Deselect button is kept — it saves a trip back to the 3D view and remains the escape hatch if the mode state is ever out of sync — but its tooltip and the stale "the root cause is unRE'd in this codebase" notes in the docs and source comments are removed.
  • Known remaining gap, not addressed here: with one entity selected from the list, natively clicking a different one leaves the count unchanged, so no broadcast fires and the list keeps highlighting the original. Fixing that means broadcasting selection identity rather than just count.
  • Docs updated: docs/capabilities.md and docs/webview-ui.md.

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 <noreply@anthropic.com>
@mefisme

mefisme commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: commit 5b0a262guards the selection-state write. Reviewers should read this together with 3320152; the first commit alone had a real defect, found in live testing and fixed here.

What was wrong

3320152 wrote the EntityMode selection-state field unconditionally. That 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. It even maintains its own "is the mode busy" predicate, which is literally return mode+0x1ac != 1.

So pushing a selection from the Entities list mid-gesture tore the editor out of the manipulation, and its completion bookkeeping never ran. The held entity or prefab was dropped and permanently lost its module association — every entity then reporting no module, not a display artifact, and not recoverable by refreshing the list.

Fix

mode_set_selection_state() now reads the current value first and only transitions between idle and selected, leaving any other value strictly alone. It also skips the write when already in the target state.

Worth noting the guard is safe under either diagnosis: in the manipulation scenario the new code performs no write at all, so if the symptom had persisted it would have proven the cause lay elsewhere. It didn't — the symptom is gone.

Accepted trade-off

In a mode outside the idle/selected pair with nothing selected, the sync sits out and that mode keeps the pre-fix behavior: the selection lands, but an empty-space click won't clear it (the Deselect button still does). That is strictly preferable to overwriting editor state we don't model. The whitelist can be widened once specific states are known safe.

Re-verified live

  • Grab an entity, or hold a staged prefab, then push a selection and place the held object — module association intact, selection present.
  • Empty-space deselect, Delete, and Move all still work.

A pleasant side effect: because the manipulation is no longer interrupted, you can hold a grabbed entity or staged prefab, select from the list, place the held object, and still have your selection. One cosmetic inconsistency remains and was judged a non-issue — after placing, a prefab leaves the pushed selection highlighted while a pre-existing-entity grab clears it. Documented in docs/webview-ui.md.

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 <noreply@anthropic.com>
@mefisme

mefisme commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Final commit on this thread: 8277e1e, docs only.

Worth calling out because it's a capability rather than a doc tidy — list-assembled group grab, which fell out of the guard rather than being designed.

With a selection pushed from the Entities list, grabbing any one of those entities in the 3D view now grabs all of them. That wasn't possible before, and it enables a real workflow: 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 together. You can also hold a grabbed entity or staged prefab, push a selection, and place the held object without losing that selection.

It exists purely because the guarded version stops interrupting the manipulation — the engine was always willing to move a multi-entity selection as a group, there just wasn't a way to assemble one from outside the 3D view.

Also confirms logic chain mode works correctly under the idle/selected guard, so the trade-off noted on the previous comment is narrower than feared: the logic sub-mode is not one of the states the guard sits out.

Documented in docs/capabilities.md and docs/webview-ui.md.

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 <noreply@anthropic.com>
@mefisme

mefisme commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Commit 000a10drefuses selection changes while the editor is holding something. This closes out the selection work in this PR; consolidated summary below so it doesn't have to be assembled from the earlier comments.

A pre-existing engine bug, found while testing the deselect fix

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.

Change the selection in between and Escape writes each saved record onto the wrong entity, swapping entity pointers inside the live map. Observed live: entities duplicated, 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 the visible casualty.

Confirmed pre-existing, not a regression — it reproduces on the v0.2.1-beta.2 release, which contains none of this PR's selection-state work. Only cancellation triggers it; mouse click, controller accept and space are all unaffected.

The mitigation

The backend refuses add_to_selection / clear_selection / remove_from_selection while a snapshot is outstanding, failing closed on any read error. The UI rolls back the list highlight and toasts, so the refusal is explained rather than silent.

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 for "a snapshot is outstanding".

Notably it is not keyed off the engine's "currently held" indicator, which would have been the obvious choice and would have been wrong: placing a new palette entity 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.

Exposed to frontends as vtable ext 11 (manipulation_in_progress, +0x2C0), appended at the tail so every existing offset is unchanged. The iface ABI test caught the size change on the first run and was updated.

Reviewing the selection commits

They're best read in order, because the middle one is a regression I introduced and then fixed:

commit what
3320152 the deselect fix — syncs the editor's mode selection-state
5b0a262 guard: that field isn't a two-value flag; the unconditional write stomped in-flight manipulations
8277e1e docs — list-assembled group grab, which fell out of no longer interrupting manipulations
000a10d this one — refuse selection changes while a snapshot is outstanding

The final tree is what matters and it's correct; the intermediate states are only of interest for the reasoning. A squash merge would be entirely reasonable here given the PR's scope.

Verification

Live-tested: grab or held prefab → selection refused with toast, Escape safe; new palette entity → unrestricted and safe; native click select/deselect, empty-space deselect, Delete, Move, and select-then-grab group grab all unaffected. Native C suite 14/14.

Not addressed

  • The underlying engine bug. A real fix means hooking capture/restore to re-key the snapshot by entity id — it would fix the vanilla bug rather than avoid it, but it's invasive and belongs in its own change.
  • Cosmetic: while an entity is grabbed, the other selected entities render in the move/grab colour instead of the normal selection colour.

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.
@mefisme

mefisme commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Just noting:
src/backend/apply_engine.c:350-354 carries a len < 0x10 ? inline : heap idStr read. The engine's
own idStr::Left dereferences +0x10 unconditionally, and rawmap.c reads it that way too, so the
two files disagree. The same pattern shipped briefly in the clipboard code and garbled every string
under 16 chars. Not touched here because it is in the decl-apply path and may be handling a different
struct.

This fix is included. That section of the main PR message can be ignored. Regardless whether its fixed or not, we could never test cause the file size for a json is unrealistically never going to be less than 16bytes unless some write failed.

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 <noreply@anthropic.com>
@mefisme mefisme changed the title Editor UX: keyboard paging for lists/dropdowns, Lift in the built-in palette, and text-field copy/paste Editor UX: keyboard paging for lists/dropdowns, Lift in the built-in palette, and text-field copy/paste and prefab staging copy/paste fix Jul 28, 2026
@mefisme

mefisme commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Previous commit failed. Forgot to run tests. Found the fix next commit will pass.

mefisme and others added 2 commits July 28, 2026 01:03
…ng 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@adiazpar adiazpar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against a real merge into main: no conflicts, build clean, 14/14 native tests, Go suite (gofmt/vet/test) clean, terminology gate FAIL=0. The engine work is careful and well evidenced -- the SEH-guarded reads, the fail-closed manipulation gate, and decoding the interned "TextField" pointer from the handler prologue instead of hardcoding it are all the right calls. Confirmed independently that the ae_read_idstr small-string branch you removed really was wrong (the inline base buffer is at +0x1c, not +0x10), and moving PrefabCtor/PrefabPopulate off raw RVAs onto signatures is a genuine portability improvement.

Merging as-is; I am landing a small follow-up commit for four things found in review. Full detail in the follow-up, but the headline is worth flagging because it affects your future work more than this PR:

The four new signature entries store new-build addresses in known_rva. Verified: each pattern occurs exactly once in the pinned build at a different address, and all four occur in the current-retail build at exactly the values committed here. So your Ghidra project is the post-April-2024 DOOM while your test install is the pinned build. Nothing malfunctions -- byte signatures are build-portable, so every runtime path resolves correctly on both, which is why your live testing passed. Only the known_rva fallback (used when another mod has detoured the function) is affected.

This is our documentation's fault, not yours: signatures.c calls the field "the extraction-build RVA" without naming which build, and the test that would catch it (run-tests.ps1 -Doom) is opt-in and never runs in CI. Both are fixed in the follow-up.

Same cause behind the iface_engine.c note that the AddToSelection/ClearSelection RVAs are stale -- 0x11fad50/0x11fb540 are correct on the new build (exactly +0xC5BB40, matching our own newbuild measurements), while 0x59f210/0x59fa00 remain correct on the pinned one. I am rewording rather than deleting, since the new-build values are useful.

Non-blocking, worth a look when convenient: the Ctrl tracking in swf_textedit.c latches from the SWF handler's own key pairs, so if focus is lost between Ctrl-down and Ctrl-up a later bare 'v' in a text field would paste as well as type -- a GetKeyState(VK_CONTROL) check at use closes it.

@adiazpar
adiazpar merged commit 40e0c2d into doom-snapmap:main Jul 28, 2026
2 checks passed
adiazpar added a commit that referenced this pull request Jul 28, 2026
…- correct four addresses and document which build they must come from

The four signature entries added in #49 stored addresses read off the newer
retail DOOM build rather than the build this table is pinned to. Corrected:

  SwfTextOnKeyCall     0x17505e0 -> 0x2b3500
  IdStrAssignFromStr   0x33a380  -> 0x19fd180
  PasteInstantiate     0x11af070 -> 0x54f950
  EnterAddPrefabGrab   0x1254b80 -> 0xcf35e0

Verified: each masked pattern occurs exactly once in the pinned image at the
value now stored, and all four occur in the newer retail image at exactly the
values previously stored. sig_test now reports 59/59 unique, 0 RVA-mismatches
and hooktol_test 0 failures (both were failing).

Nothing malfunctioned. Byte patterns are build-portable, so the scanner found
every one of these on either build and the features worked as intended -- the
only consumer of known_rva is the hook-tolerant fallback, which silently stops
covering a function when the address is wrong. That invisibility is why this
needs a documented contract rather than just a fix:

- BACKEND_ENGINE_SIGNATURES now names the pinned build by SHA256, states that
  it is not the build Steam currently ships, warns that no global delta
  converts between them (the newer build is a cluster-wise re-link), and
  points at run-tests.ps1 -Doom as the only check that catches a bad value.
- The hook-tolerant fallback comment records that it is known_rva's sole
  runtime consumer.
- contributing.md makes the DOOM-image tests REQUIRED when touching the
  signature table, notes CI cannot run them, and explains how to record an
  address from another build without putting it in a field that means "this
  build".

Also corrected an inverted note in iface_engine.c: the AddToSelection /
ClearSelection RVAs 0x59f210/0x59fa00 are correct on the pinned build and must
not be "fixed". 0x11fad50/0x11fb540 are the same two functions on the newer
build (exactly +0xC5BB40). Both pairs are right; they describe different
builds. The surrounding documentation-only RVAs in that block are likewise
newer-build values and are now labelled as such.

Stale comments swept: the install note claimed the SWF clipboard was
copy-only pending a later paste (paste shipped in the same change); the
swf_textedit.h install doc mentioned only Ctrl+C; the load-prefab log line
promised a place result from the drain that kind=1 never produces; and the
toast block described kind=2 as current with a correction stapled underneath.

Build clean, 16/16 native tests, gofmt/vet/go test clean, terminology gate
FAIL=0 with review hits back to their pre-#49 baseline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants