diff --git a/.gitignore b/.gitignore index 17ffbf6..07692d9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,8 @@ dist/ .claude/ .codex/ -# Personal local-dev convenience script (not for upstream) -/sync-and-build-webview.ps1 +# Personal local-dev convenience scripts (not for upstream) +/testScript.ps1 # wrangler's local cache (account metadata -- never commit) feedback/.wrangler/ 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/docs/capabilities.md b/docs/capabilities.md index 0428b60..0c5dca6 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -107,7 +107,11 @@ editor (run `sh` in the console if it doesn't auto-open). Full detail: [`webview | Surface | What it does | |---|---| | Window shell | The Win32 host window + the manual 30 Hz think-loop; a menu bar with a persistent light/dark theme toggle (seeded before the page is first shown, so a saved dark theme never flashes light); the always-visible Camera-Origin bar (X/Y/Z track the live editor camera; "Lock Position" pins it). | -| 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. | +| 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. | | 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). | @@ -154,8 +158,9 @@ Engine detours and resource-loader shadows the backend installs. |---|---| | Rawmap load | When rawmaps are on, load the map from `%LOCALAPPDATA%\snapmap-plus\rawmap.json` instead of the engine's own save. | | Rawmap save | On every editor save, mirror the serialized map JSON to `%LOCALAPPDATA%\snapmap-plus\rawmap.json`. | -| Overrides file-shadow | Three-layer resource resolution: your file under `%LOCALAPPDATA%\snapmap-plus\overrides\` wins when the player-file layer is enabled, then the built-in default decls (served from memory — the "*Custom" tab set; never written to your folder, so they update with each release and deleting your file restores the default), then the game's packaged resource. At startup, `overrides.user_enabled` is captured as an immutable choice for the player-file first layer; a successful `sh_user_overrides 0` or `sh_user_overrides 1` save changes the next launch and requires a DOOM restart. On a save failure, the console reports it and this launch remains unchanged. Built-ins and packaged resources remain available. Install logs an audit of your active overrides and reclaims untouched default copies written by earlier releases. | +| Overrides file-shadow | Three-layer resource resolution: your file under `%LOCALAPPDATA%\snapmap-plus\overrides\` wins when the player-file layer is enabled, then the built-in default decls (served from memory — the "*Custom" tab set: **Timeline**, **Unknown** and **Lift**; never written to your folder, so they update with each release and deleting your file restores the default), then the game's packaged resource. At startup, `overrides.user_enabled` is captured as an immutable choice for the player-file first layer; a successful `sh_user_overrides 0` or `sh_user_overrides 1` save changes the next launch and requires a DOOM restart. On a save failure, the console reports it and this launch remains unchanged. Built-ins and packaged resources remain available. Install logs an audit of your active overrides and reclaims untouched default copies written by earlier releases. | | Strids injector | Inject custom `#str_` strings from `%LOCALAPPDATA%\snapmap-plus\strings\strids.json` into the engine string table (your key wins over a built-in default for the same id). | +| Text-field clipboard | **Ctrl+C / Ctrl+V in the editor's text fields** — every free-text property (datapad and transmission message bodies) plus the text-backed int / float / vec3 / size inspectors. Copies the selection, or the whole field when nothing is selected; pastes over the selection, or at the caret. Multi-line text pasted into a single-line field collapses each break to a space; datapad bodies keep their newlines. Respects the field's own character cap. Vanilla has no text clipboard at all here (only the dev console accepts a paste) — and it is genuinely absent rather than disabled: the engine's SWF text-edit key handler has no Ctrl branch whatsoever. Entity-level Ctrl+C/Ctrl+V and Ctrl+X are untouched. Exceeds the original. | | Fault handling | The clone replaces the original's two kill-detours (which terminate DOOM) with the recover-in-place fault-shield (see [`fidelity.md`](fidelity.md)). | | SuperScript table | Merge the parked `cs_*` SuperScript objects into the engine's eventDef enumerate/lookup/dispatch paths. | | Math acceleration | An optional SIMD/threading accelerator for engine math; not required for parity (a perf feature, not a user-facing one). | diff --git a/docs/webview-ui.md b/docs/webview-ui.md index 3b9b355..12a74b5 100644 --- a/docs/webview-ui.md +++ b/docs/webview-ui.md @@ -62,6 +62,8 @@ The frontend holds no engine addresses; it calls the backend only through the vt | Installed version readout | reads `%LOCALAPPDATA%\snapmap-plus\install.json` (written by the installer) | | Persistent settings (Light / Dark and Entities controls) | `config_get_json` +0x2B0, `config_set_json` +0x2B8 — registered UTF-8 JSON fragments owned by the backend | | Deselect (explicit button, "Select in 3D editor" mode) | `clear_selection` +0x148 | +| List-driven selections behave natively (empty-space click deselects; Delete / Move / bottom-bar controls all apply) | `add_to_selection` +0x138, `clear_selection` +0x148 and `remove_from_selection` +0x130 additionally sync the editor's EntityMode selection-state field (`editor+0x22330`, state `+0x1ac`, dirty `+0xBB8`) -- the field the engine's own empty-space-click handler consults. Direct SEH-guarded field writes, gated on the editor already being in EntityMode. Re-derive recipe at the constant block in `src/backend/iface_engine.c`. | +| Entities list clears its highlight on a native deselect ("Select in 3D" mode) | the existing `selCount` broadcast (`get_selection` +0x150, ~330 ms poll); the UI now acts on its >0 -> 0 transition | | Live "Create from selection (N)" button count | `get_selection` +0x150, polled every ~330 ms independent of the sync checkboxes | | Prefabs list, detail pane, delete/rename, folders (create/rename/delete/move) | `resolve_prefab_path` +0xc0 only -- pure Win32 file/directory ops (`FindFirstFileA`, `DeleteFileA`, `MoveFileA`, `CreateDirectoryA`, `RemoveDirectoryA`) on the resolved path. No other engine slot involved, unaffected by the +0xb0 issues below. | | Create from selection | `serialize_selection` +0xb0 | @@ -112,6 +114,115 @@ 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 + deselects it, Delete deletes all of it, Move works, and every bottom-bar control applies -- for single + and multi-entity selections, and when switching between entities. Previously only the explicit + **Deselect** button worked, and Delete/Move misbehaved (Move could soft-lock the game). +- **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, and its state field says 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 one inconsistency explains every symptom, including Delete and Move, which were never + separate bugs. It also explains the old workaround (clicking one of the already-selected entities in + the 3D view runs the native hit path, which sets the state regardless of how the selection was made). +- **Fix:** `src/backend/iface_engine.c` now syncs that mode 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 can't leave the inverse inconsistency). All writes + are SEH-guarded and gated on the editor already being in EntityMode, matching the file's existing + conventions. The offsets and the per-build re-derive recipe are documented at the constant block. +- **List sync:** deselecting natively in the 3D view now clears the Entities-list highlight too, in + "Select in 3D" mode. The backend already broadcast the live editor selection count on every change; + the UI simply wasn't acting on it reaching zero. Gated to that mode only ("Follow selection" already + mirrors the whole editor selection, and with both off the list selection is local), to a real + 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. +- **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 + "Select in 3D" mode. + +### 2026-07-27 -- Keyboard paging for every list and dropdown; built-in filter entities hidden + +- **ArrowUp/ArrowDown now page the Entities, Timelines and Prefabs lists.** Moving the highlight + selects the row, exactly as clicking it does (Timelines also opens it; Prefabs also loads its + detail card), and the row is scrolled into view. With no current selection, ArrowDown lands on the + first row and ArrowUp on the last. + The Entities and Prefabs handlers are scoped to the **document**, not to their filter box: "Follow + selection" mode 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, and a listener scoped to one input would never fire. + Excluded from both: the Entity State editor (`#editor` -- its decl textarea needs real caret + movement), the Prefab Details card (`#prefabCard` -- same, for the description textarea), an + in-progress folder rename, and any `.combo` input (those have their own arrow handling, below). +- **The Inherit / Classname combos and the Timelines "Runs on" entity picker page their dropdowns.** + Arrow keys move a highlighted option, Enter accepts it, and -- unlike the lists -- this only applies + **while the dropdown is open**, so arrows are free for normal use otherwise. Typing still filters; + paging a narrowed list re-renders against the current text rather than snapping back to the full + set. The highlight reuses `.combo-opt`'s existing hover styling via a new `.active` class. +- **SnapMap's own built-in filter/droppable helper entities are excluded from every entity list and + picker** (`renderList`, the "Runs on" picker, and the per-event entity-arg dropdown). These are the + engine-seeded prefilters and droppable slots (`any player` / `any ai`, per-team/player/race, + keycards, flags, power cores) -- dev-layer-only, never placed or edited by a mapper, and previously + they buried the real entities whenever Show Hidden was on. + Matched by the id **starting with** `snapmaps/filter/`, not containing it: a mapper's own filter + always carries its map/module path first (e.g. `0_c_ind_cross/snapmaps/filter/ai_151`), so + user-placed filters are unaffected. The `" (no module)"` suffix is deliberately NOT used as the + signal -- it also appears on any real entity not yet assigned to a module. + ### 2026-07-23 -- Persistent Entities controls - **Show Hidden and the Entity selection direction now persist through the backend-owned configuration @@ -600,10 +711,10 @@ that doc for the write-up. - Default window size bumped to 1440x900 (from 1040x720) so the Entities and Prefabs tabs fit without a manual resize on first launch. - Explicit **Deselect** button next to "Select in 3D editor" (only visible while that mode is on): calls - `clear_selection` directly. A native click on empty space in the 3D view doesn't clear a selection that - was set via `add_to_selection` (confirmed: a purely native selection deselects fine on its own -- only - our externally-driven selection gets stuck), and the root cause is unRE'd in this codebase, so this is a - reliable escape hatch rather than a fix for the underlying click behavior. + `clear_selection` directly. Added here as a workaround, because at the time a native empty-space click + would not clear a list-driven selection. That root cause was reverse-engineered and fixed on + 2026-07-27 (see the entry at the top of this changelog); the button remains, now purely as a + convenience and as the escape hatch if the editor mode state is ever out of sync. - **Prefabs tab, wired to the real filesystem** (`%LOCALAPPDATA%\snapmap-plus\prefabs\`) -- no fake/mockup data: - Live list of real `.json` prefab files, refreshed from disk on every Prefabs-tab click; an empty-state message when there are none yet. diff --git a/src/backend/apply_engine.c b/src/backend/apply_engine.c index 16d5e6c..a3bf8fa 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; } @@ -339,8 +439,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 +460,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]; @@ -804,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; } @@ -835,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. */ @@ -860,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); @@ -1178,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); @@ -1207,6 +1506,138 @@ 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. */ +} + +/* 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 */ @@ -1233,22 +1664,41 @@ 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"); + + /* 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. */ 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); } 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/build.ps1 b/src/backend/build.ps1 index 8b49bb8..2f98d32 100644 --- a/src/backend/build.ps1 +++ b/src/backend/build.ps1 @@ -49,7 +49,7 @@ param( "rawmap.c", "palette_guard.c", "strids.c", "overrides.c", "user_overrides.c", "cvars.c", "commands.c", "clipboard.c", "config.c", "config_json.c", - "entity.c", "typeinfo.c", "patch.c", "algo.c", "target_any.c", "wiring_cleandirect.c", "ui_bridge.c", + "entity.c", "typeinfo.c", "patch.c", "algo.c", "target_any.c", "wiring_cleandirect.c", "swf_textedit.c", "ui_bridge.c", "iface_engine.c", "apply_engine.c", "../common/snapmap_plus_iface.c", # backend-hosted SnapStack (snapstack.c + json_patch.c): the `sh psel`/`sh acctargets`/ # etc. console commands + the stores -- the SOLE SnapStack implementation (the frontend diff --git a/src/backend/dllmain.c b/src/backend/dllmain.c index 1e3f8e1..532eeeb 100644 --- a/src/backend/dllmain.c +++ b/src/backend/dllmain.c @@ -34,6 +34,7 @@ #include "algo.h" #include "target_any.h" /* sh_target_any editor-decl visibility toggle (OG FUN_180021EE0 port) */ #include "wiring_cleandirect.h" /* sh_target_any wire-any: force the stock clean-direct connect branch (bind to any target ENTITY, no input radial) */ +#include "swf_textedit.h" /* SWF text-field clipboard: Ctrl+C out of the editor's free-text property fields */ #include "ui_bridge.h" #include "config.h" #include "user_overrides.h" @@ -350,6 +351,13 @@ static DWORD WINAPI bootstrap_thread(LPVOID p) * the target ENTITY, no picker, no node mediation. Transient-flag technique; forces no slots, frees no * node (so none of the placeholder / stray-wire / "(no module)" artifacts). Off until reveal. */ sh_wiring_cleandirect_install(g_doom_base); + + /* SWF text-field clipboard (clone improvement -- vanilla has NO text copy/paste in the editor at + * all, and it is genuinely absent rather than disabled: the stock SWF text-edit key handler has no + * Ctrl branch, only a shift flag). Detours that handler so Ctrl+C copies the focused field's + * selection (whole field when nothing is selected). Read-only against the engine for now; paste + * lands once this half is confirmed live. */ + sh_swf_textedit_install(g_doom_base); } /* FAULT-SHIELD (merged 2026-06-22): install the recover-in-place shield -- a first-in-chain VEH + diff --git a/src/backend/iface_engine.c b/src/backend/iface_engine.c index 6b2691e..4a7ed6a 100644 --- a/src/backend/iface_engine.c +++ b/src/backend/iface_engine.c @@ -61,11 +61,51 @@ * Create-New-Timeline gate (the feature spawns/morphs a timeline host, * valid ONLY while tabbed inside a module). RE-DERIVE per build via the * state-machine recipe. */ +/* the EntityMode object + its selection state -- the native-click-deselect fix. The engine keeps a per-editor-state + * mode object INLINE in the editor; GetMode(editor, stateId) (engine RVA 0x1183c40 on this build) is a plain switch + * returning editor+OFF, and `case 2:` (EntityMode, i.e. ED_ENTITY_MODE_OFF == 2) returns editor+0x22330. Its +0x1ac + * is a small state enum: 1 = base/idle (nothing selected), 2 = an entity IS selected; +0xBB8 is the redraw/dirty + * flag. The engine's own setters are one-liners -- SetSelected (RVA 0x1255120) is `{mode+0x1ac = 2; mode+0xBB8 = 1;}` + * and SetIdle (RVA 0x1255100) is the same with 1 -- so we replicate the writes DIRECT rather than resolve two + * trivial functions. NB: these RVAs are documentation for the re-derive only, never resolved at runtime -- and note + * this file's older AddToSelection/ClearSelection RVAs (0x59f210/0x59fa00) were PROVEN STALE by the same campaign + * (the real offsets on this build are 0x11fad50/0x11fb540), which is exactly why they resolve by signature. + * WHY: the native base-mode click handler (RVA 0x1264ba0) calls AddToSelection *and* SetSelected on a hit, and does + * NOTHING at all on an empty-space miss. So an empty click only deselects because the editor is in state 2; a + * selection pushed via add_to_selection alone leaves +0x1ac == 1, the editor believes nothing is selected, and the + * miss path correctly does nothing -- the long-standing "list selection won't native-deselect" bug. Same root cause + * as Delete-not-deleting-all and Move soft-locking on a list-driven selection. DIRECT (doom-re campaign + * native-click-deselect, 2026-07-27); empirically corroborated: a native click on top of a list-driven selection + * sets +0x1ac = 2 and every one of those symptoms clears. + * RE-DERIVE per build: decompile the editor's OnDeactivate (it self-identifies via + * "idSnapEditorLocal::OnDeactivate - User is quitting SnapEditor.") -- it types the editor as longlong* and calls + * SetIdle(editor + 0x4466) => 0x4466*8 = 0x22330. Cross-check the index math in that same function against the + * offsets we already trust: editor[0x409a] == ED_SEL_OBJ_OFF and editor[0x4211] == ED_SCREEN_OFF. */ +#define ED_MODE_OBJ_OFF 0x22330 /* editor+0x22330 -> inline EntityMode object (= GetMode(editor, 2)) */ +#define MODE_SEL_STATE_OFF 0x1ac /* mode+0x1ac -> 1 = idle/nothing selected, 2 = an entity is selected */ +#define MODE_DIRTY_OFF 0xBB8 /* mode+0xBB8 -> redraw/dirty flag (set alongside every state write) */ +#define MODE_STATE_IDLE 1 +#define MODE_STATE_SELECTED 2 + #define SEL_IDS_OFF 0x80 /* selObj+0x80 -> int* selected ids */ #define SEL_COUNT_OFF 0x88 /* selObj+0x88 -> int selected count */ #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_*). */ @@ -360,22 +400,114 @@ static int slot_get_selection(sh_iface *self, int *out_ids, int max) return written; } -/* +0x148 CLEAR selection (psel). */ +/* Sync the EntityMode selection state to match what we just did to the selection ARRAY -- the native-click-deselect + * fix (see ED_MODE_OBJ_OFF). The engine's empty-space-click handler does nothing unless the mode is in the + * "something is selected" state, so a selection we push has to announce itself the same way a native click does. + * 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(); + if (!ed) return; + 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; + } __except (EXCEPTION_EXECUTE_HANDLER) {} +} + +/* +0x148 CLEAR selection (psel). Also drops the mode back to idle so the editor doesn't keep believing something is + * selected after a programmatic clear (empty array + idle is the consistent pairing -- it is what makes the + * Deselect button leave the editor immediately usable natively). */ 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) {} + mode_set_selection_state(MODE_STATE_IDLE); } -/* +0x138 ADD to selection (popsel). */ +/* +0x138 ADD to selection (popsel). Also puts the mode into the "selected" state, exactly as the native click + * handler does after a successful hit -- without this the pushed selection is invisible to the editor's own + * empty-space-click / Delete / Move logic. */ 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) {} + mode_set_selection_state(MODE_STATE_SELECTED); } /* +0x198 hovered id (phov): selObj+0x2c. <0 / fault -> -1. */ @@ -770,16 +902,22 @@ static void slot_rebuild_declsource(sh_iface *self, int id, const char *cstr) } /* +0x130 REMOVE id from selection (Entities ctx-menu Delete): gated on editor+0x204d0 != 0 && id != -1. - * OG FUN_1800073c0 -> engine 0x59fda0 (RemoveFromSelection). */ + * OG FUN_1800073c0 -> engine 0x59fda0 (RemoveFromSelection). + * Drops the mode back to idle once the removal empties the selection -- the mirror of the add/clear sync, so the + * editor is never left believing something is selected over an empty array (see ED_MODE_OBJ_OFF). */ 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; if (!ie_read_ptr(ed + ED_SEL_OBJ_OFF_C3, &sel) || sel == NULL) return; __try { g_remove_sel(sel, id); } __except (EXCEPTION_EXECUTE_HANDLER) {} + int remaining = 0; + if (ie_read_s32((const uint8_t *)sel + SEL_COUNT_OFF, &remaining) && remaining <= 0) + mode_set_selection_state(MODE_STATE_IDLE); } /* +0x110 ENUMERATE the decls of a resource class (the Timeline-Editor constrained decl-comboboxes). The @@ -1103,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/backend/overrides_baked.h b/src/backend/overrides_baked.h index 73e54d6..f4e1a9e 100644 --- a/src/backend/overrides_baked.h +++ b/src/backend/overrides_baked.h @@ -1,6 +1,9 @@ /* overrides_baked.h -- GENERATED byte arrays (regen from the decl sources; do not hand-edit). * The clone's built-in "*Custom" palette-tab default set: a Timeline (an editor-entity + entityDef - * override promoting a hidden placeholder into a placeable idTarget_Timeline) + the Unknown entity. + * override promoting a hidden placeholder into a placeable idTarget_Timeline), the Unknown entity, and + * Lift (a single-decl palette-tile promotion -- Lift already has a real, working class/entityDef, so + * unlike Timeline this needs no entitydef-layer override, just the snapeditorentitydef tile itself moved + * into the toybox category with its flags flipped placeable/editable/etc; no propertySheets yet). * overrides.c serves these FROM MEMORY as the built-in default layer (user disk file -> baked default * -> engine); nothing is written to the user's overrides folder. The text is LF-only ('\n') -- the * install-time reclaim compares user files against it with CR bytes stripped, so keep it LF when @@ -15,11 +18,16 @@ typedef struct { const char *name; const char *text; unsigned int len; } ov_bake static const unsigned char g_ov_baked_d0[] = {123,10,9,101,100,105,116,32,61,32,123,10,9,9,112,114,101,118,105,101,119,73,109,97,103,101,32,61,32,34,116,101,120,116,117,114,101,115,47,103,117,105,115,47,115,110,97,112,109,97,112,115,47,101,110,116,105,116,121,95,105,99,111,110,115,47,102,105,108,116,101,114,115,47,105,100,34,59,10,9,9,100,105,115,112,108,97,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,116,105,109,101,108,105,110,101,34,59,10,9,9,100,105,115,112,108,97,121,68,101,115,99,114,105,112,116,105,111,110,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,116,105,109,101,108,105,110,101,95,100,101,115,99,34,59,10,9,9,109,101,116,97,67,97,116,101,103,111,114,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,101,110,116,105,116,121,95,109,101,116,97,99,97,116,101,103,111,114,121,95,116,111,121,98,111,120,34,59,10,9,9,99,97,116,101,103,111,114,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,99,97,116,101,103,111,114,121,95,49,34,59,10,9,9,99,104,101,99,107,82,101,110,100,101,114,77,111,100,101,108,70,111,114,66,111,117,110,100,115,32,61,32,116,114,117,101,59,10,9,9,115,111,114,116,80,114,105,111,114,105,116,121,32,61,32,55,59,10,9,9,101,110,116,105,116,121,68,101,102,32,61,32,34,115,110,97,112,109,97,112,115,47,101,100,105,116,111,114,95,111,110,108,121,47,112,108,97,99,101,104,111,108,100,101,114,95,116,97,114,103,101,116,34,59,10,9,9,112,108,97,99,101,109,101,110,116,70,108,97,103,115,32,61,32,34,83,78,65,80,95,69,78,84,73,84,89,95,80,76,65,67,69,95,65,73,82,34,59,10,9,9,114,101,110,100,101,114,73,110,102,111,32,61,32,123,10,9,9,9,97,108,119,97,121,115,68,114,97,119,66,111,117,110,100,115,32,61,32,116,114,117,101,59,10,9,9,9,97,108,119,97,121,115,68,114,97,119,76,97,98,101,108,32,61,32,116,114,117,101,59,10,9,9,9,97,114,114,111,119,83,105,122,101,32,61,32,123,10,9,9,9,9,120,32,61,32,50,59,10,9,9,9,9,121,32,61,32,50,59,10,9,9,9,9,122,32,61,32,49,59,10,9,9,9,125,10,9,9,125,10,9,9,109,111,100,117,108,101,65,117,116,111,109,97,116,105,111,110,32,61,32,34,83,78,65,80,95,77,79,68,85,76,69,95,65,85,84,79,95,69,68,73,84,34,59,10,9,9,102,108,97,103,115,32,61,32,123,10,9,9,9,101,100,105,116,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,77,111,118,101,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,68,101,108,101,116,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,115,104,111,119,80,108,97,99,101,109,101,110,116,65,114,114,111,119,32,61,32,116,114,117,101,59,10,9,9,125,10,9,125,10,125,10}; static const unsigned char g_ov_baked_d1[] = {123,10,9,105,110,104,101,114,105,116,32,61,32,34,34,59,10,9,99,108,97,115,115,32,61,32,34,105,100,84,97,114,103,101,116,95,84,105,109,101,108,105,110,101,34,59,10,9,101,100,105,116,111,114,86,97,114,115,32,123,10,9,9,112,108,97,99,101,97,98,108,101,32,61,32,102,97,108,115,101,59,10,9,125,10,9,101,100,105,116,32,61,32,123,10,9,125,10,125,10}; static const unsigned char g_ov_baked_d2[] = {123,10,9,101,100,105,116,32,61,32,123,10,9,9,112,114,101,118,105,101,119,73,109,97,103,101,32,61,32,34,116,101,120,116,117,114,101,115,47,103,117,105,115,47,115,110,97,112,109,97,112,115,47,101,110,116,105,116,121,95,105,99,111,110,115,47,102,105,108,116,101,114,115,47,105,100,34,59,10,9,9,100,105,115,112,108,97,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,117,110,107,110,111,119,110,34,59,10,9,9,100,105,115,112,108,97,121,68,101,115,99,114,105,112,116,105,111,110,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,117,110,107,110,111,119,110,95,100,101,115,99,34,59,10,9,9,109,101,116,97,67,97,116,101,103,111,114,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,101,110,116,105,116,121,95,109,101,116,97,99,97,116,101,103,111,114,121,95,116,111,121,98,111,120,34,59,10,9,9,99,97,116,101,103,111,114,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,99,97,116,101,103,111,114,121,95,49,34,59,10,9,9,99,104,101,99,107,82,101,110,100,101,114,77,111,100,101,108,70,111,114,66,111,117,110,100,115,32,61,32,116,114,117,101,59,10,9,9,115,111,114,116,80,114,105,111,114,105,116,121,32,61,32,49,59,10,9,9,101,110,116,105,116,121,68,101,102,32,61,32,34,115,110,97,112,109,97,112,115,47,117,110,107,110,111,119,110,34,59,10,9,9,112,108,97,99,101,109,101,110,116,70,108,97,103,115,32,61,32,34,83,78,65,80,95,69,78,84,73,84,89,95,80,76,65,67,69,95,65,73,82,95,85,78,65,76,73,71,78,69,68,34,59,10,9,9,114,101,110,100,101,114,73,110,102,111,32,61,32,123,10,9,9,9,97,108,119,97,121,115,68,114,97,119,66,111,117,110,100,115,32,61,32,116,114,117,101,59,10,9,9,9,97,108,119,97,121,115,68,114,97,119,76,97,98,101,108,32,61,32,102,97,108,115,101,59,10,9,9,9,97,114,114,111,119,83,105,122,101,32,61,32,123,10,9,9,9,9,120,32,61,32,50,59,10,9,9,9,9,121,32,61,32,50,59,10,9,9,9,9,122,32,61,32,49,59,10,9,9,9,125,10,9,9,125,10,9,9,102,108,97,103,115,32,61,32,123,10,9,9,9,101,100,105,116,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,77,111,118,101,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,68,101,108,101,116,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,115,104,111,119,80,108,97,99,101,109,101,110,116,65,114,114,111,119,32,61,32,116,114,117,101,59,10,9,9,9,105,115,65,105,73,110,104,105,98,105,116,86,111,108,117,109,101,32,61,32,116,114,117,101,59,10,9,9,125,10,9,9,112,114,111,112,101,114,116,121,83,104,101,101,116,115,32,61,32,123,10,9,9,9,110,117,109,32,61,32,49,59,10,9,9,9,105,116,101,109,91,48,93,32,61,32,123,10,9,9,9,9,112,114,111,112,101,114,116,105,101,115,32,61,32,123,10,9,9,9,9,9,110,117,109,32,61,32,50,59,10,9,9,9,9,9,105,116,101,109,91,48,93,32,61,32,123,10,9,9,9,9,9,9,112,97,116,104,32,61,32,34,100,105,115,112,108,97,121,78,97,109,101,34,59,10,9,9,9,9,9,9,100,105,115,112,108,97,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,112,114,111,112,101,114,116,121,95,118,97,114,105,97,98,108,101,95,110,97,109,101,34,59,10,9,9,9,9,9,9,100,101,115,99,114,105,112,116,105,111,110,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,112,114,111,112,101,114,116,121,95,118,97,114,105,97,98,108,101,95,110,97,109,101,95,100,101,115,99,34,59,10,9,9,9,9,9,9,105,110,115,112,101,99,116,111,114,32,61,32,34,100,105,115,112,108,97,121,95,110,97,109,101,95,105,110,115,112,101,99,116,111,114,34,59,10,9,9,9,9,9,125,10,9,9,9,9,9,105,116,101,109,91,49,93,32,61,32,123,10,9,9,9,9,9,9,112,97,116,104,32,61,32,34,115,112,97,119,110,79,114,105,101,110,116,97,116,105,111,110,34,59,10,9,9,9,9,9,9,100,105,115,112,108,97,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,112,114,111,112,101,114,116,121,95,115,112,97,119,110,79,114,105,101,110,116,97,116,105,111,110,34,59,10,9,9,9,9,9,9,100,101,115,99,114,105,112,116,105,111,110,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,112,114,111,112,101,114,116,121,95,115,112,97,119,110,79,114,105,101,110,116,97,116,105,111,110,95,100,101,115,99,34,59,10,9,9,9,9,9,9,105,110,115,112,101,99,116,111,114,32,61,32,34,114,111,116,97,116,105,111,110,105,110,115,112,101,99,116,111,114,34,59,10,9,9,9,9,9,125,10,9,9,9,9,125,10,9,9,9,125,10,9,9,125,10,9,9,105,110,112,117,116,115,32,61,32,123,10,9,9,9,110,117,109,32,61,32,55,59,10,9,9,9,105,116,101,109,91,48,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,104,105,100,101,34,59,10,9,9,9,105,116,101,109,91,49,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,115,104,111,119,34,59,10,9,9,9,105,116,101,109,91,50,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,114,101,109,111,118,101,34,59,10,9,9,9,105,116,101,109,91,51,93,32,61,32,34,97,99,116,105,111,110,47,97,105,47,104,101,97,108,34,59,10,9,9,9,105,116,101,109,91,52,93,32,61,32,34,97,99,116,105,111,110,47,101,99,104,111,47,115,101,116,95,97,110,105,109,34,59,10,9,9,9,105,116,101,109,91,53,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,115,101,116,95,112,111,105,34,59,10,9,9,9,105,116,101,109,91,54,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,114,101,109,111,118,101,95,112,111,105,34,59,10,9,9,125,10,9,125,10,125}; +/* generated/decls/snapeditorentitydef/func/lift.decl -- Recipe A (existing usable class, entityDef + * unchanged): promotes the vanilla-hidden lift palette tile into the "*Custom" tab, same toybox + * category/sortPriority/flags pattern as d0/d2 above. No propertySheets section (yet). */ +static const unsigned char g_ov_baked_d3[] = {123,10,9,101,100,105,116,32,61,32,123,10,9,9,112,114,101,118,105,101,119,73,109,97,103,101,32,61,32,34,116,101,120,116,117,114,101,115,47,103,117,105,115,47,115,110,97,112,109,97,112,115,47,101,110,116,105,116,121,95,105,99,111,110,115,47,102,105,108,116,101,114,115,47,105,100,34,59,10,9,9,100,105,115,112,108,97,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,101,110,116,105,116,121,95,108,105,102,116,34,59,10,9,9,100,105,115,112,108,97,121,68,101,115,99,114,105,112,116,105,111,110,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,101,110,116,105,116,121,95,108,105,102,116,95,100,105,115,112,95,100,101,115,99,34,59,10,9,9,109,101,116,97,67,97,116,101,103,111,114,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,110,97,112,101,110,116,105,116,121,95,109,101,116,97,99,97,116,101,103,111,114,121,95,116,111,121,98,111,120,34,59,10,9,9,99,97,116,101,103,111,114,121,78,97,109,101,84,97,103,32,61,32,34,35,115,116,114,95,115,104,95,99,97,116,101,103,111,114,121,95,49,34,59,10,9,9,99,104,101,99,107,82,101,110,100,101,114,77,111,100,101,108,70,111,114,66,111,117,110,100,115,32,61,32,116,114,117,101,59,10,9,9,115,111,114,116,80,114,105,111,114,105,116,121,32,61,32,50,59,10,9,9,101,110,116,105,116,121,68,101,102,32,61,32,34,115,110,97,112,109,97,112,115,47,102,117,110,99,47,115,110,97,112,95,108,105,102,116,34,59,10,9,9,112,108,97,99,101,109,101,110,116,70,108,97,103,115,32,61,32,34,83,78,65,80,95,69,78,84,73,84,89,95,80,76,65,67,69,95,65,73,82,95,85,78,65,76,73,71,78,69,68,34,59,10,9,9,114,101,110,100,101,114,73,110,102,111,32,61,32,123,10,9,9,9,97,108,119,97,121,115,68,114,97,119,66,111,117,110,100,115,32,61,32,116,114,117,101,59,10,9,9,9,97,108,119,97,121,115,68,114,97,119,76,97,98,101,108,32,61,32,116,114,117,101,59,10,9,9,9,97,114,114,111,119,83,105,122,101,32,61,32,123,10,9,9,9,9,120,32,61,32,50,59,10,9,9,9,9,121,32,61,32,50,59,10,9,9,9,9,122,32,61,32,49,59,10,9,9,9,125,10,9,9,125,10,9,9,101,118,101,110,116,73,110,102,111,32,61,32,123,10,9,9,9,115,101,108,101,99,116,101,100,69,118,101,110,116,32,61,32,123,10,9,9,9,9,115,111,117,110,100,32,61,32,34,112,108,97,121,95,112,105,99,107,117,112,95,116,114,105,103,103,101,114,34,59,10,9,9,9,125,10,9,9,9,112,108,97,99,101,100,69,118,101,110,116,32,61,32,123,10,9,9,9,9,115,111,117,110,100,32,61,32,34,112,108,97,121,95,112,117,116,100,111,119,110,95,116,114,105,103,103,101,114,34,59,10,9,9,9,125,10,9,9,125,10,9,9,109,111,100,117,108,101,65,117,116,111,109,97,116,105,111,110,32,61,32,34,83,78,65,80,95,77,79,68,85,76,69,95,65,85,84,79,95,69,68,73,84,34,59,10,9,9,102,108,97,103,115,32,61,32,123,10,9,9,9,101,100,105,116,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,112,108,97,99,101,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,77,111,118,101,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,68,101,108,101,116,97,98,108,101,32,61,32,116,114,117,101,59,10,9,9,9,115,104,111,119,80,108,97,99,101,109,101,110,116,65,114,114,111,119,32,61,32,116,114,117,101,59,10,9,9,9,104,105,100,101,73,110,66,108,117,101,112,114,105,110,116,32,61,32,116,114,117,101,59,10,9,9,9,102,108,105,112,87,105,116,104,77,111,100,117,108,101,32,61,32,116,114,117,101,59,10,9,9,9,105,115,77,111,118,101,114,32,61,32,116,114,117,101,59,10,9,9,9,105,115,65,105,73,110,104,105,98,105,116,86,111,108,117,109,101,32,61,32,116,114,117,101,59,10,9,9,125,10,9,9,108,105,109,105,116,115,32,61,32,123,10,9,9,9,101,100,105,116,111,114,77,111,100,101,108,115,32,61,32,49,59,10,9,9,9,114,101,110,100,101,114,77,111,100,101,108,115,32,61,32,49,59,10,9,9,125,10,9,9,111,117,116,112,117,116,115,32,61,32,123,10,9,9,9,110,117,109,32,61,32,49,59,10,9,9,9,105,116,101,109,91,48,93,32,61,32,34,108,105,115,116,101,110,101,114,47,109,111,118,101,114,47,109,111,118,101,95,102,105,110,105,115,104,101,100,34,59,10,9,9,125,10,9,9,105,110,112,117,116,115,32,61,32,123,10,9,9,9,110,117,109,32,61,32,52,59,10,9,9,9,105,116,101,109,91,48,93,32,61,32,34,97,99,116,105,111,110,47,109,111,118,101,114,47,115,116,97,114,116,95,109,111,118,101,34,59,10,9,9,9,105,116,101,109,91,49,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,104,105,100,101,34,59,10,9,9,9,105,116,101,109,91,50,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,115,104,111,119,34,59,10,9,9,9,105,116,101,109,91,51,93,32,61,32,34,97,99,116,105,111,110,47,117,116,105,108,115,47,114,101,109,111,118,101,34,59,10,9,9,125,10,9,125,10,125,10}; static const ov_baked_decl_t g_ov_baked_decls[] = { { "generated/decls/snapeditorentitydef/editor_only/placeholder_target.decl", (const char *)g_ov_baked_d0, 724 }, { "generated/decls/entitydef/snapmaps/editor_only/placeholder_target.decl", (const char *)g_ov_baked_d1, 100 }, { "generated/decls/snapeditorentitydef/unknown/unknown.decl", (const char *)g_ov_baked_d2, 1506 }, + { "generated/decls/snapeditorentitydef/func/lift.decl", (const char *)g_ov_baked_d3, 1301 }, }; #endif diff --git a/src/backend/signatures.c b/src/backend/signatures.c index 63216e7..393025c 100644 --- a/src/backend/signatures.c +++ b/src/backend/signatures.c @@ -678,5 +678,129 @@ const sig_entry BACKEND_ENGINE_SIGNATURES[] = { "89 B3 A8 02 00 00 48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 8B 0D ?? ?? ?? ?? " "48 85 C9 74 12 66 C7", 0x5E4C28u }, + { "SwfTextOnKeyCall", /* idSWFScriptObject_TextInstancePrototype::idSWFScriptFunction_onKey::Call + * (0x17505e0) -- THE keyboard entry point for a FOCUSED Scaleform text field, + * i.e. every SnapMap free-text property (datapad/transmission bodies, anything + * using the `textinspector`). swf_textedit.c detours it to add the clipboard + * copy/paste vanilla never had: the stock body has no Ctrl branch at all, only + * a shift flag, so there is nothing to unlock. + * ABI: void*(self, sretRetVal, thisObject, parms) -- `thisObject` is the + * "TextField" script object (its +0xC0 = the idSWFTextInstance), `parms` points + * at an idSWFScriptValue[2] = (scancode, isDown). + * Located by RTTI-walking the onKey/onChar script-function classes and diffing + * their 11-slot vtables (slot 10 is the differing `Call` override; onChar's is + * 0x1750500). 63-byte prologue, 4 wildcards = the security-cookie rip-disp. + * Re-derive per DOOM build: decompile it -- the idSWFTextInstance offsets + * swf_textedit.c uses are exactly the constants this function dereferences. */ + "40 55 56 57 41 56 41 57 48 81 EC D0 00 00 00 48 C7 44 24 28 FE FF FF FF " + "48 89 9C 24 00 01 00 00 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 C0 00 00 00 " + "49 8B F9 49 8B E8 4C 8B F2 45 33 FF", + 0x17505E0u }, + { "IdStrAssignFromStr", /* idStr::operator=(const idStr&) (0x33a380) -- the REAL idStr assignment: + * grows the destination when needed (engine allocator, frees the old heap + * buffer), memcpys, NUL-terminates and writes the new length. This is the + * one the engine's own SWF text-edit backspace/delete path uses to write + * a spliced string back into a live text field, and swf_textedit.c's paste + * uses it the same way. + * NOTE: distinct from this DB's "IdStrAssign" (0x32b3d0), which despite the + * name is an INTERN-AND-STORE-POINTER for decl name fields, not an idStr + * assign -- do not substitute one for the other. + * ABI: void(idStr *dst, const idStr *src). Reads only src's +0x08 len and + * +0x10 data (its +0x18 flags only select a steal/swap fast path, which a + * zeroed flags word declines), so a caller may hand it a stack-built src. + * 27 bytes, 1 wildcard = the build-volatile jz disp8. */ + "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 + * 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/backend/swf_textedit.c b/src/backend/swf_textedit.c new file mode 100644 index 0000000..66a8051 --- /dev/null +++ b/src/backend/swf_textedit.c @@ -0,0 +1,356 @@ +/* swf_textedit.c -- see swf_textedit.h. Ctrl+C / Ctrl+V for the editor's SWF text fields. + * + * COPY is pure reads: the focused idSWFTextInstance's text + selection range -> the Windows clipboard. + * Confirmed live across text, int, float, vec3 and size inspectors (they are all SWF-text-backed). + * + * PASTE splices the clipboard into the live text idStr, mirroring EXACTLY what the stock + * BACKSPACE/DELETE case does -- rebuild as left + inserted + right, assign back, collapse the + * selection -- so it inherits the engine's own semantics rather than inventing new ones. It is the + * only thing here that WRITES, and it is gated on its own separately-resolved idStr assignment: if + * that does not resolve, copy still works and paste simply stays dark rather than guessing. + * + * We deliberately do NOT filter what may be pasted into numeric fields. The editor already accepts + * arbitrary typed text there and resolves it at commit (non-numeric input commits as 0, confirmed + * live), so matching that is more faithful than inventing a restriction the editor does not have. + * + * Every engine touch is SEH-guarded and range-checked, and we ALWAYS chain to the stock handler -- + * we never swallow a key. + */ +#include +#include +#include +#include +#include "swf_textedit.h" +#include "signatures.h" +#include "patch.h" /* sh_install_detour_sig / sh_uninstall_detour */ +#include "clipboard.h" /* sh_clipboard_set */ +#include "backend_log.h" + +#define SIG_SWF_ONKEY "SwfTextOnKeyCall" +#define SWF_ONKEY_STOLEN 15u /* push rbp/rsi/rdi/r14/r15 (8) + sub rsp,imm32 (7) = 15 whole, PIC bytes */ + +/* ---- build-specific offsets (RE-DERIVE per DOOM build: decompile the onKey::Call this detours) --- + * All recovered DIRECT from that one function: it dereferences exactly these. */ +#define TF_TEXTINST_OFF 0xC0 /* "TextField" script object -> its idSWFTextInstance (param_3[0x18]) */ +#define TI_TEXT_OFF 0x38 /* idSWFTextInstance -> the live text idStr */ +#define TI_LEN_OFF 0x40 /* idSWFTextInstance -> character count (caret/END bound) */ +#define TI_SELSTART_OFF 0x124 /* selection anchor (== caret unless shift-selecting) */ +#define TI_SELEND_OFF 0x128 /* caret / selection end */ + +/* idStr ABI. CONFIRMED DIRECT from the engine's own idStr::Left (FUN_14033e640), which reads + * `*(char **)(str + 0x10) + offset` with NO short-string branch, and whose freshly-constructed result + * sets `data = self + 0x1c`. So +0x10 is ALWAYS a real char* -- for a short string it simply points at + * that same object's inline base buffer at +0x1c, never at the pointer field itself. + * (Do NOT reintroduce a `len < 0x10 ? inline : heap` branch here: that copies the pointer's own bytes + * as text for every short string -- observed live as garbled clipboard output.) */ +#define IDSTR_LEN_OFF 0x08 /* int len (chars, excl NUL) */ +#define IDSTR_DATA_OFF 0x10 /* char* data -- always a pointer (inline base buffer @ +0x1c, or heap) */ + +/* idSWFScriptValue: { int32 type @ +0; value @ +8 }, stride 0x10. Type ids read off the engine's own + * ToInteger/ToBool converters (FUN_1417438c0 / FUN_1417437e0). */ +#define SWFV_STRIDE 0x10 +#define SWFV_TYPE_OFF 0x00 +#define SWFV_VAL_OFF 0x08 +#define SWFV_T_DOUBLE 4 +#define SWFV_T_INT 5 +#define SWFV_T_BOOL 7 + +/* DirectInput scancodes -- consistent with every case in the stock handler (0x0e BACKSPACE, + * 0x2a/0x36 SHIFT, 0xcb LEFT, 0xcd RIGHT, 0xc7 HOME, 0xcf END, 0xd3 DELETE). */ +#define SC_LCTRL 0x1d +#define SC_RCTRL 0x9d +#define SC_C 0x2e +#define SC_V 0x2f + +/* Extra idSWFTextInstance fields the paste path needs (same provenance as the block above). */ +#define TI_MULTILINE_OFF 0x140 /* nonzero = ENTER inserts a newline (gates the stock 0x1c/0x9c case) */ +#define TI_MAXCHARS_OFF 0x280 /* character cap; the stock focus path truncates when text exceeds it */ + +/* idStr size -- +0x1c inline base buffer + STR_ALLOC_BASE(20), matching the ctor's 0x80000014 flags. */ +#define IDSTR_SIZE 0x30 +#define IDSTR_FLAGS_OFF 0x18 + +/* Sanity ceiling for a text field's length. A datapad body is far below this; anything above means we + * are reading garbage (wrong object / stale offset) and must bail rather than trust it. */ +#define TEXT_SANE_MAX (256 * 1024) +/* Clipboard read cap. Heap-allocated (see paste_at_selection) -- never put this on the hook's stack. */ +#define CLIP_MAX (64 * 1024) + +/* ---- the "is this actually a TextField?" gate ----------------------------------------------------- + * onKey::Call fires for EVERY focused SWF script object, not only text fields, and only reads its + * +0xC0 after asking the object `vtable[1](obj, "TextField")`. Skipping that check reads +0xC0 off an + * unrelated object and yields garbage (observed live on non-text inspectors). + * + * That check is a POINTER-IDENTITY compare against the engine's own interned type-name string -- + * `return param_2 == PTR_s_Object_...;` -- NOT a strcmp, so passing our own "TextField" literal would + * always answer false. We must hand it the engine's exact pointer. + * + * We recover that pointer from the detoured function's OWN BODY rather than a hardcoded RVA (same + * LEA/MOV-decode technique the backend already uses for its *Lea signatures): at +0x4B the prologue + * carries `48 8B 15 ` = `mov rdx,[rip+rel32]`, whose target holds the interned "TextField" + * pointer the engine passes. Verified before use; a byte mismatch disarms the feature rather than + * guessing. BUILD-SPECIFIC -- re-derive this offset if the prologue changes. */ +#define TF_STRPTR_INSN_OFF 0x4Bu /* offset of the `mov rdx,[rip+rel32]` in onKey::Call */ +static const uint8_t TF_STRPTR_INSN[3] = { 0x48, 0x8B, 0x15 }; +#define TF_STRPTR_INSN_LEN 7u /* 3 opcode + 4 rel32 */ +#define SWFOBJ_ISTYPE_SLOT 1 /* vtable[1](obj, internedTypeName) -> bool */ + +typedef void *(*onkey_fn)(void *self, void *retbuf, void *thisObject, void *parms); +typedef char (*is_type_fn)(void *obj, const void *interned_name); +typedef void (*idstr_assign_fn)(void *dst_idstr, const void *src_idstr); + +static onkey_fn g_orig_onkey = NULL; +static const void *g_textfield_id = NULL; /* the engine's interned "TextField" string pointer */ +static idstr_assign_fn g_idstr_assign = NULL; /* idStr::operator=(const idStr&) -- NULL => paste off */ +static volatile LONG g_ctrl_down = 0; + +/* Ask the script object whether it is a TextField, exactly the way the stock handler does. Returns 0 + * on anything unexpected -- we only ever proceed on a positive answer. */ +static int is_textfield(void *obj) +{ + if (obj == NULL || g_textfield_id == NULL) return 0; + void **vtbl = *(void ***)obj; + if (vtbl == NULL) return 0; + is_type_fn is_type = (is_type_fn)vtbl[SWFOBJ_ISTYPE_SLOT]; + if (is_type == NULL) return 0; + return is_type(obj, g_textfield_id) != 0; +} + +/* Read one idSWFScriptValue as an integer, mirroring the engine's own ToInteger for the numeric + * types. Returns 0 for a type we do not expect here (string/object) rather than guessing. */ +static int swfv_int(const uint8_t *v, int64_t *out) +{ + int32_t t = *(const int32_t *)(v + SWFV_TYPE_OFF); + const void *p = v + SWFV_VAL_OFF; + if (t == SWFV_T_INT) { *out = *(const int64_t *)p; return 1; } + if (t == SWFV_T_DOUBLE) { *out = (int64_t)*(const double *)p; return 1; } + if (t == SWFV_T_BOOL) { *out = (*(const char *)p != 0); return 1; } + return 0; +} + +/* Resolve an idStr to its bytes (SSO-aware), with a sanity bound. 0 = do not trust it. */ +static int idstr_view(const uint8_t *s, const char **out_data, int *out_len) +{ + int len = *(const int *)(s + IDSTR_LEN_OFF); + if (len < 0 || len > TEXT_SANE_MAX) return 0; + *out_data = *(const char *const *)(s + IDSTR_DATA_OFF); + *out_len = len; + return (*out_data != NULL); +} + +/* Copy the focused field's selection (or the whole field when nothing is selected) to the clipboard. + * Pure reads. Returns 1 if something was placed on the clipboard. */ +static int copy_selection(const uint8_t *ti) +{ + const char *data = NULL; + int len = 0; + if (!idstr_view(ti + TI_TEXT_OFF, &data, &len)) return 0; + if (len == 0) return 0; + + int a = *(const int *)(ti + TI_SELSTART_OFF); + int b = *(const int *)(ti + TI_SELEND_OFF); + int lo = (a < b) ? a : b; + int hi = (a < b) ? b : a; + /* Clamp against the ACTUAL string length, not the instance's own count field -- if the two ever + * disagree the string is the one we are about to read out of. */ + if (lo < 0) lo = 0; + if (hi > len) hi = len; + if (lo > len) lo = len; + if (lo >= hi) { lo = 0; hi = len; } /* nothing selected -> copy the whole field */ + + int n = hi - lo; + if (n <= 0 || n > TEXT_SANE_MAX) return 0; + + char *buf = (char *)HeapAlloc(GetProcessHeap(), 0, (SIZE_T)n + 1); + if (buf == NULL) return 0; + memcpy(buf, data + lo, (size_t)n); + buf[n] = '\0'; + int ok = sh_clipboard_set(buf); + HeapFree(GetProcessHeap(), 0, buf); + + if (ok) { + char msg[96]; + _snprintf_s(msg, sizeof msg, _TRUNCATE, + "swf-textedit: copied %d char%s to the clipboard", n, n == 1 ? "" : "s"); + backend_log(msg); + } + return ok; +} + +/* Splice the clipboard into the focused field at the selection, mirroring EXACTLY what the stock + * BACKSPACE/DELETE case does: rebuild the string as left + inserted + right, assign it back into the + * live idStr, then collapse the selection to the end of what was inserted. The engine never touches + * the instance's own length field there either -- it does not need to, because that field IS the + * idStr's length word (text @ +0x38, len @ +0x38+0x08 = +0x40), so the assign updates it for free. + * + * We do NOT filter what gets pasted into numeric fields: the engine already accepts arbitrary typed + * text there and resolves it at commit (a non-numeric value simply commits as 0, confirmed live), so + * matching that behaviour is strictly more faithful than inventing a restriction the editor lacks. */ +static int paste_at_selection(const uint8_t *ti) +{ + if (g_idstr_assign == NULL) return 0; + + /* Heap, not stack: this runs on the game's main thread inside a detour, where a buffer this size + * would be a stack-overflow hazard. */ + char *clip = (char *)HeapAlloc(GetProcessHeap(), 0, CLIP_MAX); + if (clip == NULL) return 0; + if (!sh_clipboard_get(clip, (int)CLIP_MAX)) { HeapFree(GetProcessHeap(), 0, clip); return 0; } + + /* Normalise line endings. CR is never wanted. A multi-line field keeps LF verbatim -- blank lines + * are real formatting in a datapad body. A single-line field cannot represent line structure at + * all, so any RUN of newlines collapses to ONE space, and a run at the very start or very end is + * dropped outright. (Emitting one space per newline instead just leaks the original's blank lines + * as runs of spaces -- pasted prose is full of them.) */ + int multiline = (*(const int *)(ti + TI_MULTILINE_OFF) != 0); + int ci = 0, co = 0, pending_break = 0; + for (; clip[ci] != '\0'; ci++) { + char c = clip[ci]; + if (c == '\r') continue; + if (c == '\n' && !multiline) { pending_break = 1; continue; } + if (pending_break) { if (co > 0) clip[co++] = ' '; pending_break = 0; } + clip[co++] = c; + } + clip[co] = '\0'; /* a trailing run leaves pending_break set and is intentionally dropped */ + + const char *data = NULL; + int len = 0; + if (co == 0 || !idstr_view(ti + TI_TEXT_OFF, &data, &len)) { + HeapFree(GetProcessHeap(), 0, clip); + return 0; + } + + int a = *(const int *)(ti + TI_SELSTART_OFF); + int b = *(const int *)(ti + TI_SELEND_OFF); + int lo = (a < b) ? a : b; + int hi = (a < b) ? b : a; + if (lo < 0) lo = 0; + if (lo > len) lo = len; + if (hi < lo) hi = lo; + if (hi > len) hi = len; + + /* Honour the field's character cap the way the engine's own focus path does. <= 0 means no cap. */ + int maxchars = *(const int *)(ti + TI_MAXCHARS_OFF); + int keep = lo + (len - hi); + int ins = co; + if (maxchars > 0 && keep + ins > maxchars) ins = maxchars - keep; + int total = keep + ins; + char *buf = NULL; + if (ins > 0 && total >= 0 && total < TEXT_SANE_MAX) + buf = (char *)HeapAlloc(GetProcessHeap(), 0, (SIZE_T)total + 1); + if (buf == NULL) { HeapFree(GetProcessHeap(), 0, clip); return 0; } + memcpy(buf, data, (size_t)lo); + memcpy(buf + lo, clip, (size_t)ins); + memcpy(buf + lo + ins, data + hi, (size_t)(len - hi)); + buf[total] = '\0'; + + /* A stack-built source idStr: the assign only reads its length and data, and a zeroed flags word + * declines the steal/swap fast path, so nothing engine-owned is aliased or freed. */ + uint8_t src[IDSTR_SIZE]; + memset(src, 0, sizeof src); + *(int *)(src + IDSTR_LEN_OFF) = total; + *(char **)(src + IDSTR_DATA_OFF) = buf; + *(uint32_t *)(src + IDSTR_FLAGS_OFF) = 0; + + g_idstr_assign((void *)(ti + TI_TEXT_OFF), src); + HeapFree(GetProcessHeap(), 0, buf); + HeapFree(GetProcessHeap(), 0, clip); + + int caret = lo + ins; + *(int *)(ti + TI_SELSTART_OFF) = caret; + *(int *)(ti + TI_SELEND_OFF) = caret; + + char msg[96]; + _snprintf_s(msg, sizeof msg, _TRUNCATE, + "swf-textedit: pasted %d char%s%s", ins, ins == 1 ? "" : "s", + (ins < co) ? " (truncated to the field's limit)" : ""); + backend_log(msg); + return 1; +} + +/* The detour. Tracks Ctrl exactly the way the stock handler tracks Shift (from the isDown it is + * handed), acts on Ctrl+C / Ctrl+V, and ALWAYS chains -- we never swallow a key. */ +static void *swf_onkey_detour(void *self, void *retbuf, void *thisObject, void *parms) +{ + __try { + const uint8_t *pv = (parms != NULL) ? *(const uint8_t *const *)parms : NULL; + int64_t key = 0, down = 0; + if (pv != NULL && swfv_int(pv, &key) && swfv_int(pv + SWFV_STRIDE, &down)) { + if (key == SC_LCTRL || key == SC_RCTRL) { + InterlockedExchange(&g_ctrl_down, down ? 1 : 0); + } else if (down && (key == SC_C || key == SC_V) && g_ctrl_down && is_textfield(thisObject)) { + const uint8_t *ti = *(const uint8_t *const *)((const uint8_t *)thisObject + TF_TEXTINST_OFF); + if (ti != NULL) { + if (key == SC_C) copy_selection(ti); + else paste_at_selection(ti); + } + } + } + } __except (EXCEPTION_EXECUTE_HANDLER) { + /* A bad offset degrades to "clipboard did nothing" -- never to a broken text field. */ + } + return g_orig_onkey(self, retbuf, thisObject, parms); +} + +void sh_swf_textedit_install(const uint8_t *module_base) +{ + if (g_orig_onkey != NULL) return; /* already armed */ + if (module_base == NULL) { backend_log("swf-textedit: NOT armed (no module base)"); return; } + + const sig_entry *sig = NULL; + for (size_t i = 0; BACKEND_ENGINE_SIGNATURES[i].name != NULL; i++) + if (strcmp(BACKEND_ENGINE_SIGNATURES[i].name, SIG_SWF_ONKEY) == 0) { + sig = &BACKEND_ENGINE_SIGNATURES[i]; + break; + } + if (sig == NULL) { backend_log("swf-textedit: NOT armed (signature missing from the DB)"); return; } + + sig_result r; + sig_resolve_one(module_base, sig, &r); + + /* Recover the interned "TextField" pointer from the resolved function's own body BEFORE detouring + * (the detour overwrites only the first 15 bytes, but decode first anyway so a failure costs no + * engine write at all). Without it the type gate can never answer true, so refuse to arm. */ + if (r.status == SIG_OK || r.status == SIG_OK_HOOKED) { + __try { + const uint8_t *insn = (const uint8_t *)r.addr + TF_STRPTR_INSN_OFF; + if (memcmp(insn, TF_STRPTR_INSN, sizeof TF_STRPTR_INSN) == 0) { + int32_t rel = *(const int32_t *)(insn + sizeof TF_STRPTR_INSN); + const void *const *slot = (const void *const *)(insn + TF_STRPTR_INSN_LEN + rel); + g_textfield_id = *slot; + } + } __except (EXCEPTION_EXECUTE_HANDLER) { g_textfield_id = NULL; } + } + if (g_textfield_id == NULL) { + backend_log("swf-textedit: NOT armed (could not decode the interned \"TextField\" id from the " + "handler prologue -- re-derive TF_STRPTR_INSN_OFF for this DOOM build)"); + return; + } + + void *tramp = sh_install_detour_sig(&r, (void *)swf_onkey_detour, SWF_ONKEY_STOLEN); + if (tramp == NULL) { + backend_log("swf-textedit: NOT armed (onKey detour install refused/failed)"); + return; + } + g_orig_onkey = (onkey_fn)tramp; + + /* Paste additionally needs a real idStr assignment. Resolve it independently: if it is missing, + * copy still works and only paste stays dark -- never fall back to a guess, since this one WRITES. */ + for (size_t i = 0; BACKEND_ENGINE_SIGNATURES[i].name != NULL; i++) { + if (strcmp(BACKEND_ENGINE_SIGNATURES[i].name, "IdStrAssignFromStr") != 0) continue; + sig_result ra; + sig_status st = sig_resolve_one(module_base, &BACKEND_ENGINE_SIGNATURES[i], &ra); + if (st == SIG_OK || st == SIG_OK_HOOKED) g_idstr_assign = (idstr_assign_fn)ra.addr; + break; + } + + backend_log(g_idstr_assign != NULL + ? "swf-textedit: ready (Ctrl+C copies / Ctrl+V pastes the focused SWF text field)" + : "swf-textedit: ready, COPY ONLY (idStr assign unresolved -- Ctrl+V disabled)"); +} + +void sh_swf_textedit_uninstall(void) +{ + if (g_orig_onkey == NULL) return; + sh_uninstall_detour((void *)g_orig_onkey); + g_orig_onkey = NULL; +} diff --git a/src/backend/swf_textedit.h b/src/backend/swf_textedit.h new file mode 100644 index 0000000..c5f3e0b --- /dev/null +++ b/src/backend/swf_textedit.h @@ -0,0 +1,44 @@ +/* swf_textedit.h -- clipboard copy/paste for the editor's SWF text fields (datapad / transmission + * message bodies, and every other free-text property that uses the `textinspector`). + * + * WHY THIS EXISTS. Vanilla SnapMap has no text clipboard support at all: you can paste into the dev + * console, but not into any in-editor text field. This is NOT a disabled feature -- the engine's SWF + * text-edit key handler has no Ctrl-modifier branch whatsoever (only shift, for selection), so there + * is nothing to unlock. This adds it. + * + * MECHANISM (DIRECT, our own RE -- doom-re campaign `text-inspector-input-path`, 2026-07-27). + * A SnapMap text property is an `idSnapTextInspector`, whose live widget is an + * `idMenuPropertyStringInput`, which is backed by a Scaleform `idSWFTextInstance`. Keyboard input for + * a FOCUSED text instance arrives at exactly one place: + * + * idSWFScriptObject_TextInstancePrototype::idSWFScriptFunction_onKey::Call(self, ret, thisObj, parms) + * + * We detour it. That single site hands us everything: `thisObj` is the "TextField" script object whose + * +0xC0 is the `idSWFTextInstance`, and `parms` carries (scancode, isDown). Focus needs no lookup -- + * the handler only ever runs for the field that has it, so if we are executing, that IS the target + * field. (The engine does keep a focused-instance pointer at `idSWF+0x1a8`, but we never need it.) + * + * Modifier state needs no engine global either: the stock handler tracks SHIFT itself from the + * (scancode, isDown) pair it is handed, so we track CTRL the identical way. + * + * PORTABILITY: the handler resolves by SIGNATURE (never a hardcoded RVA -- the doom-re project proved + * cited RVAs go stale even against a byte-identical build). The idSWFTextInstance field offsets ARE + * build-specific -- RE-DERIVE per DOOM build by decompiling that onKey::Call (the constants it + * dereferences are exactly these). + * + * Clean-room: ported from our own RE. Zero OG SnapHak bytes (the original never had this feature). + */ +#ifndef BACKEND_SWF_TEXTEDIT_H +#define BACKEND_SWF_TEXTEDIT_H + +#include + +/* Detour the SWF text-instance onKey handler so Ctrl+C copies the focused text field's selection + * (whole field when nothing is selected) to the Windows clipboard. No-op + logged reason if the + * signature does not resolve cleanly. Safe to call once at backend install. */ +void sh_swf_textedit_install(const uint8_t *module_base); + +/* Reverse the detour (leaves the engine byte-clean). Idempotent. */ +void sh_swf_textedit_uninstall(void); + +#endif /* BACKEND_SWF_TEXTEDIT_H */ diff --git a/src/common/snapmap_plus_iface.c b/src/common/snapmap_plus_iface.c index 37c8106..e388957 100644 --- a/src/common/snapmap_plus_iface.c +++ b/src/common/snapmap_plus_iface.c @@ -105,8 +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. */ +/* 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) { + 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; @@ -295,6 +311,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..696c095 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 @@ -491,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 + @@ -572,6 +590,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 9b0049a..58a9b20 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; } @@ -387,7 +387,7 @@ - +
Waiting for the map...
@@ -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