diff --git a/docs/architecture.md b/docs/architecture.md index 99a47b6..074a5b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,12 +92,13 @@ include that header** — it is a matched pair. The backend writes the vtable an frontend reads them at the same offsets. - The backend builds it (`operator_new(0x60)`), installs the vtable — the **77 original-faithful - slots** (`+0x00..+0x260`) plus the **clone-extension slots** appended after them (`+0x268..+0x2B8` - today: the atomic class+inherit apply, the class/inherit enumerators, the dev-layer query, the - wire-edit generation counter, the synchronous `apply_sync`, the timeline inherit-normalize, - push/clear-stack, and the generic configuration getter/setter) — initializes the mutex at `+0x08`, - and hangs a sub-object off `+0x58` that holds the SnapStack subcommand map and the main-thread - work-queue. + slots** (`+0x00..+0x260`) plus the **clone-extension slots** appended after them (`+0x268..+0x300` + today, `sizeof(sh_iface_vtbl) == 0x308`: the atomic class+inherit apply, the class/inherit + enumerators, the dev-layer query, the wire-edit generation counter, the synchronous `apply_sync`, + the timeline inherit-normalize, push/clear-stack, the generic configuration getter/setter, and the + asset-browser group — preview request/publish, request-by-name, the material atlas rect, the + catalog pager, and sound preview/session) — initializes the mutex at `+0x08`, and hangs a + sub-object off `+0x58` that holds the SnapStack subcommand map and the main-thread work-queue. - **Extension slots are append-only**: a new capability gets the next slot after the current end; original-block offsets never move. This is also a real failure mode, not a formality — a frontend calling an extension slot that an older backend never installed would call through garbage. That is @@ -120,6 +121,14 @@ the backend, where they are re-derived per build. ## Persistent configuration +Two files, owned by different sides on purpose. The backend owns `config.json` — the registered +settings, validated and versioned. The **frontend host** owns `pinned.json`, the asset browser's +shortlist, and deliberately keeps it out of the settings registry: `config.json` is all-or-nothing, +so any parse failure resets the whole document to defaults, which is an acceptable trade for a +handful of validated scalars and not for unbounded data a user grows themselves. The host moves those +bytes and parses none of them; shape and validation live in the UI, the only side that knows what a +pin means. See [`capabilities.md`](capabilities.md#persistent-settings). + The backend is the sole owner of `%LOCALAPPDATA%\snapmap-plus\config.json`; the installer does not generate, parse, or replace it. `sh_config_init` runs after the common per-user directories are available and creates this version-1 document when the file is absent: diff --git a/docs/backend-changes.md b/docs/backend-changes.md index f997fc1..dcb8bb2 100644 --- a/docs/backend-changes.md +++ b/docs/backend-changes.md @@ -6,6 +6,112 @@ 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-08-10 — the asset catalog listed the same asset twice, because the game index is not a catalog + +**What changed.** `imgpreview.c` now collapses records that repeat a name within one resource box, +before anything else reads the record list. + +**The bug.** Decal atlases appeared twice per asset in the browser. Clicking one row selected both and +starring one starred both — which looked like a selection bug and was not: the UI keys off the asset +name, and the catalog genuinely held two rows carrying the same one. + +**Why.** We had been treating `snap_gameresources.index` as a catalog of distinct assets. It is a +record-per-blob table: the same decl can be baked into the `.resources` file more than once, at +different offsets, and the index lists each copy. Measured across the box — `decalatlas` 1,673 records +for 1,024 distinct names, `image` 3,423 for 3,422, and exactly zero repeats for `material`, `model`, +`md6Def`, `sound`, `fx`, `particle`, `entityDef`, `snapEditorEntityDef` and `cm`. So the collapse is +written generally but only ever fires where the data actually repeats. + +The first record wins, which is what `find_rec` would have resolved to anyway, so nothing that already +previewed changes which blob it reads. + +**Adjacent, unresolved.** The sound catalog files 8,028 rows against 7,649 distinct Wwise events with +378 unbanked, which leaves one more row carrying a bank than there are events to carry. Sound names are +unique in both boxes, so the obvious explanation — two names differing only by case — is ruled out. One +row in 8,028, no known consequence, recorded here rather than chased. + +## 2026-08-05 — a sound preview could take the whole sound engine down: the emitter list is published before it is initialised + +**What changed.** Every audition now runs on the main thread instead of the caller's. + +**The bug.** Previewing a sound could fault and then freeze the game (owner-reported: an access +violation followed by a hang). + +**Why.** The race is in the engine, not in our code — but we were the ones exercising it. DOOM's +`StartSound_wwise` publishes a brand-new emitter into the sound world's **live list** before +initialising it: the emitter is allocated and base-constructed, written into the world's array and the +count bumped, and only *then* is its sound-shader pointer set. `idSoundWorld::Update` walks that same +list on an audio worker thread, and `idSound::Update` opens by dereferencing exactly the pointer that +is still null inside that window. + +Calling the preview from any thread but the main one widened a window the engine gets away with +internally. Serialising our calls onto the main thread closes it from our side without patching engine +code. + +## 2026-08-05 — the SWF text editor latched Ctrl: a bare `c` copied and a bare `v` pasted + +**What changed.** `swf_textedit.c` no longer tracks Ctrl in a static flag consulted on later +keystrokes. + +**The bug.** After one Ctrl+C or Ctrl+V, every subsequent bare `c` or `v` typed into any editor text +field copied or pasted — typing "variable" pasted the clipboard once per `v`. Owner-reported, confirmed +fixed live. + +**Why, and the lesson.** The flag was set on Ctrl key-down and cleared on key-up. Modifier key-ups are +not reliably delivered to a focused SWF script object — the field or the window can lose focus, or the +engine may simply not dispatch them — and one missed key-up left the flag stuck on for the session. + +The design had been justified in a comment as tracking Ctrl "exactly the way the stock handler tracks +Shift", and that is precisely where the reasoning failed: the stock handler reads Shift only while +processing the keystroke it was handed, so a release it never sees costs nothing. Ours was consulted on +*later* keystrokes, which turns the same pattern into a permanent latch. The mechanism was copied +without its lifetime assumption. + +## 2026-08-05 — the last hardcoded engine RVAs are gone, and the `[64]` that hid a truncated signature DB bit again + +**What changed.** Three engine functions were still reached as a raw `module_base + RVA`, which is +build-locked in exactly the way the signature resolver exists to avoid. All three are now ordinary +signature entries, verified unique across the whole executable image: + +| Signature | Pinned RVA | What it is | +|---|---|---| +| `Mega2PageDecode` | `0x196E140` | the megatexture page decoder the asset browser calls | +| `PrefabDtor` | `0x51D870` | `idSnapEntityPrefab::~idSnapEntityPrefab` | +| `EntityDeshare` | `0x52C920` | COW make-unique, before an in-place entity edit | + +The two prefab functions join the ctor/populate/`MemLocal` group on `ae_pick_engine_fn` — signature +wins, `known_rva` cross-checks and logs a mismatch loudly rather than silently preferring either. + +The decoder previously carried a **local `memcmp` of its own prologue** at the hardcoded address. +That is strictly weaker than a signature and the difference is not academic: a byte check can detect +that a function moved but can never *find* it, so any build with a shifted `.text` lost previews +outright. It now resolves wherever the loader put it, and a build whose bytes genuinely differ fails +to resolve — which still disables previews, but by refusing rather than by calling into whatever now +occupies that address. + +**One function could not be signatured, and is not pretending to be.** idList-grow (pinned +`0x699A60`) is **one of 1,560 byte-identical instantiations** of the same idList template in the +image, separable only by their displacements. No lengthening of a prologue pattern gets past that — +the same wall `StopSound`'s clone hit. It is resolved *relationally* instead: `AddCommand` (which is +signatured) calls it on `cmdSys+0x08` via `LEA RCX,[RSI+8]` / `CALL rel32`, so the callee is decoded +out of that call site, range-checked against the module, and logged with the pinned RVA as a +cross-check. The decode runs before our own `AddCommand` detour is installed, and the call site sits +well past the 15 stolen prologue bytes either way. + +**The bug this uncovered.** `tests/sig_test.c` and `tests/hooktol_test.c` both still declared +`sig_result results[64]` — the same fixed array that silently truncated the database at entry 65 and +prompted `SIG_RESULTS_MAX` in the first place. The constant was applied to the shipping callers and +missed in the tests. At 67 entries it stopped being silent and became a stack overwrite: +`sig_resolve_all` wrote past the end of the array, producing garbage status values and an access +violation **inside the test process**. Both now size with `SIG_RESULTS_MAX` and print a loud +`SIGNATURE DB OVERFLOW` and fail if the database ever outgrows it. + +The general lesson is worth keeping: a constant introduced to fix a class of bug only fixes the call +sites it is actually applied to. Grep for the literal, not just for the symptom. + +**Verified.** `sig_test` 67/67 signatures resolve to their known RVAs; `hooktol_test` 3/3 +hook-tolerant fallbacks; 16/16 native tests; `go test ./...` clean. + ## 2026-07-28 — Load/Place now picks the prefab up (auto-grab enabled): the corruption was the heap bug **What changed.** `kind=2` (stage → pick up) is enabled. Load/Place no longer asks the user to press Ctrl+V. diff --git a/docs/capabilities.md b/docs/capabilities.md index d5eeb5d..01dbf89 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -108,15 +108,97 @@ editor (run `sh` in the console if it doesn't auto-open). Full detail: [`webview |---|---| | 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. 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. | +| 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. In the asset browser, paging onto a **file** row also selects it (debounced, so holding the key down previews only the row you stop on); **folder** rows still need Enter/Space, since opening one replaces the whole list. | | 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 picks it up for you** — no Ctrl+V needed; you position it and click to drop, exactly as after a vanilla Ctrl+V. It does this by injecting the editor's own paste action, so the engine runs its real paste path rather than anything reimplemented. It degrades to stage-only (with a toast saying why) whenever the engine itself would refuse — not in EntityMode, already holding something, hovering an entity, or a selection that could not be cleared. A staged prefab also **survives a Play round-trip and a map change** — come back and Ctrl+V still works, matching the engine's own Ctrl+C clipboard. (Both were previously broken: the prefab had to be discarded on the way into Play to avoid a crash, because its entity-blob array was allocated in the engine's map heap, which is `HeapDestroy`d at map load. See [`backend-changes.md`](backend-changes.md).) | +| Assets tab | The asset browser — the shipped game catalog, searchable, with a live preview pane, and one click to put an asset on the map. Full detail in the section below. Also reachable as a modal from the Entities tab ("Browse assets"), scoped to the entity you came from. | | 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). | | Crash reports | When the game hits a serious fault, a crash record is saved locally and the crash-report dialog auto-opens (in-session for a survived fault, next launch otherwise): the error + call stack, an optional description, and an opt-out checkbox to attach anonymized log tails. Sending files a `crash`-labeled issue; repeat crashes at the same location group onto one issue. See [`feedback.md`](feedback.md). | +## The asset browser (Assets tab) + +Every category is enumerated **live from the game's own shipped containers** at startup — no +pre-extraction step, no bundled asset list, and nothing shipped in this repo. Only assets from +`snap_gameresources` are listed, with sounds as the deliberate exception (see below): SnapMap never +mounts the campaign's `gameresources.resources`, so a campaign-only model resolves in the editor as a +black cube. + +| Category | What it holds | +|---|---| +| **Pinned** | The mapper's own shortlist, at the top of the rail. Any asset, of any type, starred from its row; one shared list rather than one per type, because "the things I am working with right now" is rarely all of one kind. Kept in `%LOCALAPPDATA%\snapmap-plus\pinned.json` — deliberately **not** in `config.json`, so a malformed pin list can only ever cost the pins (see [Persistent settings](#persistent-settings)). | +| Materials | Surfaces. Previewed as real pixels — see below. The **union** of `material` decls and `.vmtr` megatexture atlas rows: a material is addressable by name *or* by rectangle and neither set contains the other, so a decl-only list hid thousands of rows that are paintable via Virtual Mapping. A **Cross Platform Textures** filter narrows the list to the 224 megatexture rects hand-tested to render identically on PC, Xbox and PlayStation. | +| Images | The lower-level image records the materials sample. | +| Models | Props: `.lwo`, the `md6Def` set, and the `discreteAnimation` set — the last of these being the breakable/gib models that a `breakable` decl names, which are indexed under their own decl type and were invisible to a `model`-only catalog. | +| **Modules** | The 232 `mega_blessed` palette modules — whole SnapMap rooms, placeable as a single entity that is both visible **and solid**. | +| **Brush models** | Every other baked `.bmodel`: the individual wall, floor and detail pieces those modules are assembled from. Render-only. | +| **Clip models** | The `cm` type (`.bcm` / `.lwo` / `.md6`) — collision shapes, appliable on their own. | +| Sounds | The **union** of `sound` decls and Wwise events, deduplicated case-insensitively. Neither set contains the other, and the event-only half is ~2,600 names including the generic SnapMap VO, so a decl-only list is missing thousands of sounds a mapper can hear in the editor. Campaign-box sounds are offered too, and they work. Filed by **soundbank** rather than by name — see below. | +| **Lights** | The light **materials**: the projection a light shines through, written as `lightMaterial`. Point vs spot is not the asset — it is which entity carries it, so it is the Create-as choice. | +| FX, Particles, Decal atlases, Entity defs | The remaining placeable decl types. | +| **Perks**, **SWF / Flash** | Reference-only. A perk is granted by an `idTarget_Command` entity and a `.swf` belongs to an entity that owns a screen; neither structure is worked out, so both are names to copy and wire by hand. They are listed under Reference rather than Placeable on purpose — a category under Placeable whose Apply button does nothing reads as a broken tool. | + +**Previews are real, and cover the catalog.** A material's pixels are produced by locating its pages +in the shipped megatexture set and decoding them with **DOOM's own page decoder, called in-process**. +That decoder is a pure function — no renderer, no GPU, no virtual-texture state and no map residency +— so a preview does not depend on the loaded map having the material on screen, which is what makes +whole-catalog browsing possible at all. Materials with no atlas rect (roughly half) fall back to +reading the image out of the `.index`/`.resources` containers and decoding BC1/BC3/BC7 directly. +Sounds are auditioned through the editor's own preview path with working play/stop. + +**Apply to selection** writes the asset into the selected entity's decl and commits immediately — +one entity at a time, since it patches the decl the editor has open. **New entity** authors a +one-entity prefab and stages it through the engine's own paste path, so it arrives held and ready to +place. + +**What may be applied is decided per target class**, in one place, and each carrier is gated to the +classes that own the field it writes: + +| Asset | Writes | Allowed on | +|---|---|---| +| Material (by name or Virtual Mapping) | `customMaterial` / `virtualmapping` | the render-capable classes: blocking volumes, triggers, props, movers, `idAI2*`, cap entities, dynamic SnapMap entities | +| Model · Brush model · Module · Clip model | `renderModelInfo.model` (+ `clipModelInfo.clipModelName`) | the same set, **plus** interactables — minus three whose model *is* the mechanic (`idInteractable_Obstacle_SnapDoor`, `idInteractable_WorldCache`, `idInteractable_EliteGuard_Coop`) | +| Sound | `sound` | speakers (`idSnapMapGameEntity_Speaker`, `idSpeaker*`) | +| Light material | `lightMaterial` | lights (`idSnapMapGameEntity_Light`, `idLight`) | +| Particle | `particleSystem` | emitters (`idSnapMapParticleEmitter`, `idParticleEmitter`) | +| FX | `fxDecl` | FX entities (`idVolume_ToggleableDamageOverTime`, `idLaserHazard`, `idDynamicStampEntity*`) | +| anything | — | never the player start (`idSnapMapGameEntity_ComboStart*`), and never a variable or a SnapMap action | + +Materials and models deliberately share one list: anything that can wear a model can wear a surface, +and re-texturing a mover or making a trigger volume visible are ordinary techniques. + +The gate keys on the entity's **class**, never on whether that class is placeable from the editor's +palette — those are different questions. `idSnapMapParticleEmitter` has no placeable palette entry at +all, yet Snapmap+ creates one by overriding the classname on `snapmaps/unknown`, so gating on +placeability would refuse the emitter this tool just made. + +A refusal names the class and says what it lacks, rather than greying the button out silently. + +A module is placed by writing **both** halves — the baked geometry into `renderModelInfo.model` and +its paired collision into `clipModelInfo.clipModelName`. The two live at different paths and pair +232-for-232; the browser derives the collision name for you. The def's inherited `CLIPMODEL_AUTO` is +left alone, because naming a clip model overrides the automatic derivation on its own. + +A **light** is placed by picking the light material and then choosing Point light or Spotlight under +Create as. Only `lightMaterial` is written; the cone, the colour and the intensity come from the +inherited def. Applying a light material to a light already on the map replaces its existing value. +Three of the shipped light materials contain a literal **space** in the name (`lights/gaus +_slowpulse` and two more) and the unspaced forms do not exist, so they are listed verbatim — anything +that trims or splits on whitespace corrupts them. + +**Sounds are filed by soundbank, not by name.** After the duplicate collapse nearly every sound name +is a flat `Play_something`, so a name-derived folder tree was one root folder of ~8,000 rows. The +Wwise `` grouping is the only real structure the catalog has — 24 non-empty banks, none of +them enormous, with `doom_snapmaps` being the set SnapMap itself loads. An event listed in several +banks is filed under one home (a specific bank in preference to the always-loaded `doom_initial`), so +no sound appears twice. Search deliberately cuts **across** banks rather than within the open one. + +**No Refresh control, by design.** The catalog is indexed once per process out of `.resources` files +that cannot change while the game is running, so re-fetching returned identical bytes; a type whose +names never arrived re-asks when it is selected, which is the only retry that was ever needed. + ## Persistent settings `%LOCALAPPDATA%\snapmap-plus\config.json` holds player preferences shared through the backend-owned @@ -130,6 +212,16 @@ uses the existing settings setter and recreates a deleted file. If the command c failure, leaves this launch unchanged, and does not establish a next-launch change. The generic bridge already permits a future frontend control for this setting. +**Pinned assets live in their own file**, `%LOCALAPPDATA%\snapmap-plus\pinned.json`, not in +`config.json`. The settings file is all-or-nothing: a parse failure or a schema mismatch sends the +whole document to "damaged → restored defaults". Settings are a handful of validated scalars and can +afford that; pins are unbounded data the user grows themselves, and a malformed pin list has no +business being able to reset somebody's theme along with it. The frontend host moves the bytes and +does no parsing — shape and validation live in the UI, the only side that knows what a pin means — so +the worst a broken file can do is cost the pins. Writes go to a temporary file and are moved into +place, so an interrupted write cannot truncate the real one. A missing file simply means "no pins +yet"; deleting it clears the shortlist and nothing else. + The schema and registry are intentionally extensible: registered values are type-checked and repaired individually, while unrecognized root and `settings` members survive normal rewrites. A damaged file is backed up and replaced with defaults; a file from a newer schema is left untouched; and an I/O failure keeps diff --git a/docs/rawmap-shadow-gate.md b/docs/rawmap-shadow-gate.md new file mode 100644 index 0000000..8437e0c --- /dev/null +++ b/docs/rawmap-shadow-gate.md @@ -0,0 +1,114 @@ +# Open decision — the rawmap save shadow shares one switch with the load swap + +**Status: DEFERRED, 2026-08-10. No code change made.** This documents a reported behaviour change, what +was proven about its cause, what was ruled out, and the proposed fix — so the decision can be made later +without re-deriving any of it. + +--- + +## The report + +> "Doom Snapmap asking me to save my map like its a new map with a new name everytime when +> `sh_rawmaps_on` is not set. The behavior before was rawmap.json was always written on a save +> regardless of the setting of the command." + +Two distinct observations, and they have different answers. One is ours and fully explained; the other +is not ours and is still open. + +--- + +## Observation 1 — rawmap.json no longer written unless armed. EXPLAINED, ours. + +Caused by [`7ede1e7`](https://github.com/doom-snapmap/snapmap-plus/commit/7ede1e7) *("the SAVE shadow now +obeys the same switch the LOAD swap does")*. Before it, `sh_ser_detour` mirrored every saved map to +`rawmap.json` unconditionally from the moment it installed. After it, both detours share one +`rawmap_armed()` predicate. + +Confirmed in a user's own `sh_backend.log` across the build cutover: + +``` +20:27–21:00 B1: rawmap SAVE shadow wrote ... -> rawmap.json [#1..#4] <- pre-7ede1e7, ungated +23:19 B1: rawmap LOAD-swap installed ...; gate=DISARMED <- post-7ede1e7 build +23:35 gate=DISARMED + (two subsequent in-game saves produced NO "wrote" line) +``` + +Upstream's rationale is sound and should not be dismissed: `rawmap.json` is the file you hand-stage a map +into before loading it, so an ungated shadow silently destroys deliberately-placed content on the next +save, with nothing in the log to say so. + +## Observation 2 — SnapMap prompts to save as a new map. NOT OURS, still open. + +Four paths could plausibly touch this. All four were checked and none can: + +| Path | Finding | +|---|---| +| Save shadow (`sh_ser_detour`) | `g_ser_orig(...)` runs **unconditionally and first**; `rawmap_armed()` is checked only afterwards (`rawmap.c` ~404-415). The gate can only skip our disk copy — it cannot alter what the engine writes or which slot it targets. | +| Load swap (`sh_deser_detour`) | Disarmed path is `return g_deser_orig(json, out_map)` — a clean passthrough. | +| `sh_rawmaps_on` / `sh_rawmaps_off` | Handlers call `sh_rawmap_swap_arm()` and print. No save format, no dialog, no map identity. | +| New fault-shield map-load guards | `shield_faults.log` shows only `armed` lines (installed), never a fired/repair line. Inert. | + +**Working hypothesis — NOT established, do not treat as fact.** The prompt itself probably did not +change; its *consequence* did. If the map has no DOOM map-slot identity — which it would not, if +`rawmap.json` *is* the working map — SnapMap has always prompted. Previously that prompt was ignorable +noise because the shadow had already mirrored `rawmap.json` regardless. With the shadow gated, completing +the prompt became the only way to get the map saved anywhere, so it went from background annoyance to +blocker. + +**The one question that settles it:** before the gating change, did that prompt appear and simply get +cancelled/ignored — or did Save genuinely complete silently with no dialog at all? If the former, there +is no second bug and fixing Observation 1 resolves the whole report. + +--- + +## The actual design problem + +`7ede1e7` welded two operations with very different risk profiles onto one switch: + +| Half | What arming it does | Risk | +|---|---|---| +| **LOAD swap** | Every map load is *replaced* by `rawmap.json` | **High** — silently substitutes your map. Must be explicit. | +| **SAVE shadow** | A copy of the saved map is mirrored to `rawmap.json` | **Low** — writes one file. Only harmful while hand-staging that file. | + +The consequence is that you cannot have *"always mirror my saves"* without also accepting *"always hijack +my loads."* For a user whose workflow treats `rawmap.json` as their working map, the shadow is the +feature and the swap is the hazard — and the single switch forces them to take both or neither. + +That coupling — not the gating itself — is the defect worth fixing. + +## Proposed fix — split the switch + +Give the shadow its own gate, defaulting **ON**, and leave the load swap gated exactly as upstream made +it. This restores the pre-`7ede1e7` save behaviour while keeping the dangerous half explicit, and it +*answers* upstream's data-loss concern rather than ignoring it: someone hand-staging `rawmap.json` is +precisely the person who knows to turn the shadow off first. + +Implementation sketch (`src/backend/rawmap.c`, `src/backend/commands.c`): + +1. Split `rawmap_armed()` into `rawmap_load_armed()` and `rawmap_shadow_armed()`. +2. Add `g_shadow_gate`, initialised to `1` (on). `sh_deser_detour` keeps using the load predicate; + `sh_ser_detour` uses the shadow predicate. +3. Add `sh_rawmap_shadow_on` / `sh_rawmap_shadow_off` to the command table, mirroring the existing + `sh_rawmaps_on`/`off` handler shape. +4. Keep `arm.flag` arming **both** — it is the no-console test trigger and should stay all-or-nothing. +5. Report the shadow gate state in the existing install log line alongside `gate=` for the swap. + +### Alternatives considered + +- **Revert `7ede1e7` outright** — gets the old behaviour back in one line, but reintroduces the silent + overwrite upstream deliberately fixed, and puts a bare revert of another maintainer's commit in the + tree. Rejected. +- **No code change; use `arm.flag`** — works today, but arming the flag also arms the LOAD swap, so it + cannot be left on permanently without every map load being substituted. Adequate as a temporary + workaround only, and only with `rawmap.json` backed up first. + +### Coordination + +`7ede1e7` is upstream's (`doom-snapmap/snapmap-plus`, adiazpar, 2026-08-07). Any change here should go +upstream as its own commit or PR with the coupled-switch argument above — **not** folded into an +unrelated feature branch, where it would read as a silent behaviour revert. + +### Safety note for anyone testing this + +Arming (`sh_rawmaps_on` or creating `%LOCALAPPDATA%\snapmap-plus\arm.flag`) arms the **load swap too**. +Back up `rawmap.json` before an armed save — it will be overwritten — and disarm afterwards. diff --git a/docs/webview-ui.md b/docs/webview-ui.md index 0f674a7..70e4298 100644 --- a/docs/webview-ui.md +++ b/docs/webview-ui.md @@ -114,6 +114,85 @@ 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-08-10 -- Asset browser: Pinned, soundbanks, four more categories, and the duplicate rows + +- **Pinned**, a per-user shortlist at the top of the type rail. A star in the left gutter of every + asset row; one shared list across all types, since the handful of things a mapper is holding at + once is rarely all of one kind. Stored in its own `%LOCALAPPDATA%\snapmap-plus\pinned.json` rather + than in the settings config -- a malformed pin list must not be able to reset somebody's theme. + The list renders **flat**, with full names: everywhere else the folder tree earns its keep against + thousands of names, but making the mapper drill to reach their own shortlist is asking them to + navigate their answer to navigating. +- **This forced a list-vs-selection split** that is worth knowing about before touching this code. + Every other list is one type, so `m.type` answered both "which names do I show" and "what kind of + thing is selected". Pinned breaks that -- a pinned row can be any type regardless of which rail + entry is lit. `abListType` keeps the list question; `abSelType` answers the selection question from + the row itself, and the call sites that decide carriers, previewability and Apply now ask it. +- **Sounds are filed by soundbank.** Their own names carry almost no structure, so the catalog was + one root folder of ~8,000 rows; the Wwise `` grouping gives 24 sensibly-sized folders. + The tree builder now separates *where a name sits* from *what it is called*: `abBuildTree` takes a + `place` function for the folder path while the leaf keeps the REAL asset name, so a synthetic + `doom_snapmaps/` prefix can never leak into Copy, selection, preview or Apply. This replaced an + earlier bank **filter** dropdown -- two controls doing one job is how a browser gets confusing. +- **Duplicate sound rows removed, twice over.** 449 path-form `sound` decls are wrappers around a + Wwise event already in the catalog (`scripted_events/cyberdemon/head_splat_01` and + `Play_head_splat_01` are one sound); the first pass caught only the ones whose twin was a bare + event, missing the 129 whose twin was another flat decl. +- **Duplicate rows removed catalog-wide.** Decal atlases listed everything twice -- 1,673 records + for 1,024 distinct names -- because the game index is a record-per-blob table, not a catalog of + distinct assets. Clicking one row selected both and starring one starred both, since the UI keys + off the name. Decal atlases now reads 1,024, Images 3,422. +- **Four more categories.** **Lights** (89) applies the light *material* as `lightMaterial`, with + Point light / Spotlight as the Create-as choice. **Models** gained 108 breakable/gib models that a + `model`-only catalog could not see. **Perks** (190) and **SWF / Flash** (193) are reference-only, + listed under Reference rather than Placeable so no Apply button pretends to work; SWFs are listed + in the `swf/x.swf` form decls actually reference, not the baked `generated/swf/x.bswf` on disk. +- **"No ramp" now removes the ramp** instead of declining to write one, which on an entity that + already carried a `ramp1` left the previous mapper's tiling in place -- the option did nothing on + exactly the entities somebody picks it for. `renderParms` is an indexed array, so removing means + cutting the entry, renumbering the survivors and fixing `num`. +- **The catalog Refresh button is gone.** It promised the list could be stale and made fresh, and + neither half was true. +- Browser-preview stand-in gained a real sound + soundbank sample taken from the shipped manifest, so + the bank tree and search can be exercised without launching the game. + +### 2026-08-05 -- The Assets tab: the whole shipped catalog, previewed and placeable + +- **New Assets tab**, mounted twice from one implementation: as an open-browsing tab, and as a modal + opened from the Entities tab ("Browse assets") scoped to the current selection. Both share the + fetched catalog and folder tree; each keeps its own type / folder / filter / selection state. +- **Materials preview as real pixels.** The backend locates the material's pages in the shipped + megatexture set and decodes them with DOOM's own decoder in-process. Materials with no atlas rect + (roughly half the catalog) fall back to decoding BC1/BC3/BC7 out of the `.index` containers. +- **Sounds audition** with real play/stop, through the editor's own preview path rather than the + `testSound` console command -- which throws its emitter handle away, so it can neither be stopped + nor prevented from stacking a new voice on every click. The preview session is scoped to **window + focus**: background audio is only wanted while DOOM lacks focus, so blurring the Snapmap+ window + ends the session and hands the editor its audio back. +- **The sound list is the union of two sources.** A `sound` decl and a Wwise event are different + sets and neither contains the other; listing only decls was missing ~2,600 names, including the + generic SnapMap VO. Deduplicated case-insensitively, since the manifest spells events `Play_Vo_...` + and decls are lowercase. +- **Three new categories** -- Modules, Brush models, Clip models -- out of the two decl types the + catalog previously discarded. A **module** places as one entity that is both visible and solid: + the baked geometry goes to `renderModelInfo.model` and its paired collision to + `clipModelInfo.clipModelName`, which the browser derives for you (232/232 pair, no orphans). + The inherited `CLIPMODEL_AUTO` is left in place -- naming a clip model overrides the automatic + derivation on its own. +- **Apply to selection** commits into the selected entity's decl immediately rather than staging an + edit. One entity at a time, since it patches the decl the editor has open; with 2+ selected the + button is disabled and the "Browse assets" entry point is too. What may be applied is decided per + target class by a single gate: nothing applies to the player start, FX/particles/sounds are refused + on doors and interactables, models may still be swapped on most interactables. +- **New entity** authors a one-entity prefab and stages it through the engine's own paste path, so it + arrives held and ready to place. `grabDistance` (not `spawnPosition`) carries the placement + distance -- the two ADD, and only `grabDistance` follows the full view ray. +- **Keyboard navigation** throughout: Up/Down move the row selection, Enter/Space open folders, + Backspace goes up a level. Placement is deliberately *not* bound to Enter/Space -- you have to tab + to the button -- so browsing can never place something by accident. The modal takes precedence over + the tab when both are open. +- Tab order is alphabetical, with Assets first; the tab that opens on load is still Entities. + ### 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 diff --git a/src/backend/apply_engine.c b/src/backend/apply_engine.c index ed4bb70..f1ba722 100644 --- a/src/backend/apply_engine.c +++ b/src/backend/apply_engine.c @@ -2625,10 +2625,10 @@ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t * * resolve by FALLBACK RVA off module_base (re-derive-tagged like the editor singleton); a wrong/shifted * offset just makes the serialize SEH-fail -> a clean 0-length result, never a crash. */ if (module_base) { - /* 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. */ + /* All four are SIGNATURE-first with the old RVA as a cross-checked fallback (see + * ae_pick_engine_fn). dtor + deshare were raw RVAs until 2026-08-05, when both turned out to + * have prologues that are unique in the whole executable image once the rel32 call + * displacements are wildcarded -- `PrefabDtor` and `EntityDeshare` in the signature DB. */ 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", @@ -2643,8 +2643,10 @@ int sh_apply_engine_install(const sig_result *results, size_t n, const uint8_t * module_base, MEMLOCAL_PUSHHEAP_RVA, "idMemLocal PushHeap"); g_memlocal_pop = (memlocal_popheap_fn) ae_pick_engine_fn(results, n, "MemLocalPopHeap", module_base, MEMLOCAL_POPHEAP_RVA, "idMemLocal PopHeap"); - g_prefab_dtor = (prefab_dtor_fn) (module_base + PREFAB_DTOR_RVA); - g_deshare = (ent_deshare_fn) (module_base + ENT_DESHARE_RVA); + g_prefab_dtor = (prefab_dtor_fn) ae_pick_engine_fn(results, n, "PrefabDtor", + module_base, PREFAB_DTOR_RVA, "prefab dtor"); + g_deshare = (ent_deshare_fn) ae_pick_engine_fn(results, n, "EntityDeshare", + module_base, ENT_DESHARE_RVA, "entity deshare"); } char line[256]; diff --git a/src/backend/bcn.c b/src/backend/bcn.c new file mode 100644 index 0000000..5c25b69 --- /dev/null +++ b/src/backend/bcn.c @@ -0,0 +1,345 @@ +/* bcn.c -- see bcn.h. BC1 / BC3 / BC7 -> RGBA8. + * + * Straight implementations of the public block-compression formats; no engine dependency. + * Two invariants everything here obeys, because a texture browser must never fault on a + * malformed or truncated asset: + * - reads never go past `src_len` (a short block reads as zero); + * - every channel is clamped on the way out. */ + +#include +#include "bcn.h" + +typedef unsigned char u8; +typedef unsigned int u32; +typedef unsigned long long u64; + +static u8 clamp8(int v) { return (u8)(v < 0 ? 0 : (v > 255 ? 255 : v)); } + +/* Safe fetch: past the end of the source reads as 0 rather than faulting. */ +static u8 at(const u8 *s, size_t len, size_t i) { return i < len ? s[i] : 0u; } + +/* ------------------------------------------------------------------ BC1 colour block ----------*/ + +static void c565(unsigned v, u8 *out) +{ + unsigned r = (v >> 11) & 31u, g = (v >> 5) & 63u, b = v & 31u; + out[0] = (u8)((r * 255u + 15u) / 31u); + out[1] = (u8)((g * 255u + 31u) / 63u); + out[2] = (u8)((b * 255u + 15u) / 31u); +} + +/* Fills pal[4][3]. `opaque_only` forces the 4-colour (no punch-through) interpretation, which + * is what BC3's colour block always uses. */ +static void bc1_palette(const u8 *s, size_t len, size_t off, u8 pal[4][3], int *punch, int opaque_only) +{ + unsigned c0 = (unsigned)at(s, len, off) | ((unsigned)at(s, len, off + 1) << 8); + unsigned c1 = (unsigned)at(s, len, off + 2) | ((unsigned)at(s, len, off + 3) << 8); + c565(c0, pal[0]); + c565(c1, pal[1]); + if (c0 > c1 || opaque_only) { + for (int i = 0; i < 3; ++i) { + pal[2][i] = (u8)((2 * pal[0][i] + pal[1][i]) / 3); + pal[3][i] = (u8)((pal[0][i] + 2 * pal[1][i]) / 3); + } + *punch = 0; + } else { + for (int i = 0; i < 3; ++i) pal[2][i] = (u8)((pal[0][i] + pal[1][i]) / 2); + pal[3][0] = pal[3][1] = pal[3][2] = 0; + *punch = 1; /* index 3 is transparent black */ + } +} + +/* ------------------------------------------------------------------ BC4 alpha block -----------*/ + +static void bc4_palette(const u8 *s, size_t len, size_t off, u8 pal[8]) +{ + unsigned a0 = at(s, len, off), a1 = at(s, len, off + 1); + pal[0] = (u8)a0; pal[1] = (u8)a1; + if (a0 > a1) { + for (int i = 0; i < 6; ++i) pal[2 + i] = (u8)(((6 - i) * a0 + (1 + i) * a1) / 7); + } else { + for (int i = 0; i < 4; ++i) pal[2 + i] = (u8)(((4 - i) * a0 + (1 + i) * a1) / 5); + pal[6] = 0; pal[7] = 255; + } +} + +size_t bcn_rgba_size(unsigned w, unsigned h) { return (size_t)BCN_PAD(w) * BCN_PAD(h) * 4u; } + +int bcn_decode_bc1(const u8 *src, size_t src_len, unsigned w, unsigned h, u8 *dst) +{ + if (!src || !dst || !w || !h) return 0; + unsigned pw = BCN_PAD(w), ph = BCN_PAD(h); + size_t bi = 0; + for (unsigned by = 0; by < ph; by += 4) { + for (unsigned bx = 0; bx < pw; bx += 4, bi += 8) { + u8 pal[4][3]; int punch = 0; + bc1_palette(src, src_len, bi, pal, &punch, 0); + u32 idx = (u32)at(src, src_len, bi + 4) | ((u32)at(src, src_len, bi + 5) << 8) + | ((u32)at(src, src_len, bi + 6) << 16) | ((u32)at(src, src_len, bi + 7) << 24); + for (unsigned py = 0; py < 4; ++py) + for (unsigned px = 0; px < 4; ++px) { + unsigned k = py * 4 + px, sel = (idx >> (2 * k)) & 3u; + u8 *o = dst + ((size_t)(by + py) * pw + (bx + px)) * 4; + o[0] = pal[sel][0]; o[1] = pal[sel][1]; o[2] = pal[sel][2]; + o[3] = (punch && sel == 3) ? 0u : 255u; + } + } + } + return 1; +} + +int bcn_decode_bc3(const u8 *src, size_t src_len, unsigned w, unsigned h, u8 *dst) +{ + if (!src || !dst || !w || !h) return 0; + unsigned pw = BCN_PAD(w), ph = BCN_PAD(h); + size_t bi = 0; + for (unsigned by = 0; by < ph; by += 4) { + for (unsigned bx = 0; bx < pw; bx += 4, bi += 16) { + u8 apal[8]; bc4_palette(src, src_len, bi, apal); + u64 abits = 0; + for (int i = 0; i < 6; ++i) abits |= (u64)at(src, src_len, bi + 2 + i) << (8 * i); + u8 pal[4][3]; int punch = 0; + bc1_palette(src, src_len, bi + 8, pal, &punch, 1); /* BC3 colour is always 4-colour */ + u32 idx = (u32)at(src, src_len, bi + 12) | ((u32)at(src, src_len, bi + 13) << 8) + | ((u32)at(src, src_len, bi + 14) << 16) | ((u32)at(src, src_len, bi + 15) << 24); + for (unsigned py = 0; py < 4; ++py) + for (unsigned px = 0; px < 4; ++px) { + unsigned k = py * 4 + px, sel = (idx >> (2 * k)) & 3u; + u8 *o = dst + ((size_t)(by + py) * pw + (bx + px)) * 4; + o[0] = pal[sel][0]; o[1] = pal[sel][1]; o[2] = pal[sel][2]; + o[3] = apal[(abits >> (3 * k)) & 7u]; + } + } + } + return 1; +} + +/* ---------------------------------------------------------------------------- BC7 -------------- + * Eight modes over a 128-bit block. The tables below are the format's own partition and anchor + * tables (shared with BC6H) and are reproduced verbatim from the specification. */ + +static const u8 P2[64][16] = { +{0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1},{0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,1}, +{0,1,1,1,0,1,1,1,0,1,1,1,0,1,1,1},{0,0,0,1,0,0,1,1,0,0,1,1,0,1,1,1}, +{0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,1},{0,0,1,1,0,1,1,1,0,1,1,1,1,1,1,1}, +{0,0,0,1,0,0,1,1,0,1,1,1,1,1,1,1},{0,0,0,0,0,0,0,1,0,0,1,1,0,1,1,1}, +{0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,1},{0,0,1,1,0,1,1,1,1,1,1,1,1,1,1,1}, +{0,0,0,0,0,0,0,1,0,1,1,1,1,1,1,1},{0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1}, +{0,0,0,1,0,1,1,1,1,1,1,1,1,1,1,1},{0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1}, +{0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1},{0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1}, +{0,0,0,0,1,0,0,0,1,1,1,0,1,1,1,1},{0,1,1,1,0,0,0,1,0,0,0,0,0,0,0,0}, +{0,0,0,0,0,0,0,0,1,0,0,0,1,1,1,0},{0,1,1,1,0,0,1,1,0,0,0,1,0,0,0,0}, +{0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0},{0,0,0,0,1,0,0,0,1,1,0,0,1,1,1,0}, +{0,0,0,0,0,0,0,0,1,0,0,0,1,1,0,0},{0,1,1,1,0,0,1,1,0,0,1,1,0,0,0,1}, +{0,0,1,1,0,0,0,1,0,0,0,1,0,0,0,0},{0,0,0,0,1,0,0,0,1,0,0,0,1,1,0,0}, +{0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0},{0,0,1,1,0,1,1,0,0,1,1,0,1,1,0,0}, +{0,0,0,1,0,1,1,1,1,1,1,0,1,0,0,0},{0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0}, +{0,1,1,1,0,0,0,1,1,0,0,0,1,1,1,0},{0,0,1,1,1,0,0,1,1,0,0,1,1,1,0,0}, +{0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1},{0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1}, +{0,1,0,1,1,0,1,0,0,1,0,1,1,0,1,0},{0,0,1,1,0,0,1,1,1,1,0,0,1,1,0,0}, +{0,0,1,1,1,1,0,0,0,0,1,1,1,1,0,0},{0,1,0,1,0,1,0,1,1,0,1,0,1,0,1,0}, +{0,1,1,0,1,0,0,1,0,1,1,0,1,0,0,1},{0,1,0,1,1,0,1,0,1,0,1,0,0,1,0,1}, +{0,1,1,1,0,0,1,1,1,1,0,0,1,1,1,0},{0,0,0,1,0,0,1,1,1,1,0,0,1,0,0,0}, +{0,0,1,1,0,0,1,0,0,1,0,0,1,1,0,0},{0,0,1,1,1,0,1,1,1,1,0,1,1,1,0,0}, +{0,1,1,0,1,0,0,1,1,0,0,1,0,1,1,0},{0,0,1,1,1,1,0,0,1,1,0,0,0,0,1,1}, +{0,1,1,0,0,1,1,0,1,0,0,1,1,0,0,1},{0,0,0,0,0,1,1,0,0,1,1,0,0,0,0,0}, +{0,1,0,0,1,1,1,0,0,1,0,0,0,0,0,0},{0,0,1,0,0,1,1,1,0,0,1,0,0,0,0,0}, +{0,0,0,0,0,0,1,0,0,1,1,1,0,0,1,0},{0,0,0,0,0,1,0,0,1,1,1,0,0,1,0,0}, +{0,1,1,0,1,1,0,0,1,0,0,1,0,0,1,1},{0,0,1,1,0,1,1,0,1,1,0,0,1,0,0,1}, +{0,1,1,0,0,0,1,1,1,0,0,1,1,1,0,0},{0,0,1,1,1,0,0,1,1,1,0,0,0,1,1,0}, +{0,1,1,0,1,1,0,0,1,1,0,0,1,0,0,1},{0,1,1,0,0,0,1,1,0,0,1,1,1,0,0,1}, +{0,1,1,1,1,1,1,0,1,0,0,0,0,0,0,1},{0,0,0,1,1,0,0,0,1,1,1,0,0,1,1,1}, +{0,0,0,0,1,1,1,1,0,0,1,1,0,0,1,1},{0,0,1,1,0,0,1,1,1,1,1,1,0,0,0,0}, +{0,0,1,0,0,0,1,0,1,1,1,0,1,1,1,0},{0,1,0,0,0,1,0,0,0,1,1,1,0,1,1,1} +}; + +static const u8 P3[64][16] = { +{0,0,1,1,0,0,1,1,0,2,2,1,2,2,2,2},{0,0,0,1,0,0,1,1,2,2,1,1,2,2,2,1}, +{0,0,0,0,2,0,0,1,2,2,1,1,2,2,1,1},{0,2,2,2,0,0,2,2,0,0,1,1,0,1,1,1}, +{0,0,0,0,0,0,0,0,1,1,2,2,1,1,2,2},{0,0,1,1,0,0,1,1,0,0,2,2,0,0,2,2}, +{0,0,2,2,0,0,2,2,1,1,1,1,1,1,1,1},{0,0,1,1,0,0,1,1,2,2,1,1,2,2,1,1}, +{0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2},{0,0,0,0,1,1,1,1,1,1,1,1,2,2,2,2}, +{0,0,0,0,1,1,1,1,2,2,2,2,2,2,2,2},{0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,2}, +{0,1,1,2,0,1,1,2,0,1,1,2,0,1,1,2},{0,1,2,2,0,1,2,2,0,1,2,2,0,1,2,2}, +{0,0,1,1,0,1,1,2,1,1,2,2,1,2,2,2},{0,0,1,1,2,0,0,1,2,2,0,0,2,2,2,0}, +{0,0,0,1,0,0,1,1,0,1,1,2,1,1,2,2},{0,1,1,1,0,0,1,1,2,0,0,1,2,2,0,0}, +{0,0,0,0,1,1,2,2,1,1,2,2,1,1,2,2},{0,0,2,2,0,0,2,2,0,0,2,2,1,1,1,1}, +{0,1,1,1,0,1,1,1,0,2,2,2,0,2,2,2},{0,0,0,1,0,0,0,1,2,2,2,1,2,2,2,1}, +{0,0,0,0,0,0,1,1,0,1,2,2,0,1,2,2},{0,0,0,0,1,1,0,0,2,2,1,0,2,2,1,0}, +{0,1,2,2,0,1,2,2,0,0,1,1,0,0,0,0},{0,0,1,2,0,0,1,2,1,1,2,2,2,2,2,2}, +{0,1,1,0,1,2,2,1,1,2,2,1,0,1,1,0},{0,0,0,0,0,1,1,0,1,2,2,1,1,2,2,1}, +{0,0,2,2,1,1,0,2,1,1,0,2,0,0,2,2},{0,1,1,0,0,1,1,0,2,0,0,2,2,2,2,2}, +{0,0,1,1,0,1,2,2,0,1,2,2,0,0,1,1},{0,0,0,0,2,0,0,0,2,2,1,1,2,2,2,1}, +{0,0,0,0,0,0,0,2,1,1,2,2,1,2,2,2},{0,2,2,2,0,0,2,2,0,0,1,2,0,0,1,1}, +{0,0,1,1,0,0,1,2,0,0,2,2,0,2,2,2},{0,1,2,0,0,1,2,0,0,1,2,0,0,1,2,0}, +{0,0,0,0,1,1,1,1,2,2,2,2,0,0,0,0},{0,1,2,0,1,2,0,1,2,0,1,2,0,1,2,0}, +{0,1,2,0,2,0,1,2,1,2,0,1,0,1,2,0},{0,0,1,1,2,2,0,0,1,1,2,2,0,0,1,1}, +{0,0,1,1,1,1,2,2,2,2,0,0,0,0,1,1},{0,1,0,1,0,1,0,1,2,2,2,2,2,2,2,2}, +{0,0,0,0,0,0,0,0,2,1,2,1,2,1,2,1},{0,0,2,2,1,1,2,2,0,0,2,2,1,1,2,2}, +{0,0,2,2,0,0,1,1,0,0,2,2,0,0,1,1},{0,2,2,0,1,2,2,1,0,2,2,0,1,2,2,1}, +{0,1,0,1,2,2,2,2,2,2,2,2,0,1,0,1},{0,0,0,0,2,1,2,1,2,1,2,1,2,1,2,1}, +{0,1,0,1,0,1,0,1,0,1,0,1,2,2,2,2},{0,2,2,2,0,1,1,1,0,2,2,2,0,1,1,1}, +{0,0,0,2,1,1,1,2,0,0,0,2,1,1,1,2},{0,0,0,0,2,1,1,2,2,1,1,2,2,1,1,2}, +{0,2,2,2,0,1,1,1,0,1,1,1,0,2,2,2},{0,0,0,2,1,1,1,2,1,1,1,2,0,0,0,2}, +{0,1,1,0,0,1,1,0,0,1,1,0,2,2,2,2},{0,0,0,0,0,0,0,0,2,1,1,2,2,1,1,2}, +{0,1,1,0,0,1,1,0,2,2,2,2,2,2,2,2},{0,0,2,2,0,0,1,1,0,0,1,1,0,0,2,2}, +{0,0,2,2,1,1,2,2,1,1,2,2,0,0,2,2},{0,0,0,0,0,0,0,0,0,0,0,0,2,1,1,2}, +{0,0,0,2,0,0,0,1,0,0,0,2,0,0,0,1},{0,2,2,2,1,2,2,2,0,2,2,2,1,2,2,2}, +{0,1,0,1,2,2,2,2,2,2,2,2,2,2,2,2},{0,1,1,1,2,0,1,1,2,2,0,1,2,2,2,0} +}; + +static const u8 A2[64] = { +15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15, +15, 2, 8, 2, 2, 8, 8,15, 2, 8, 2, 2, 8, 8, 2, 2, +15,15, 6, 8, 2, 8,15,15, 2, 8, 2, 2, 2,15,15, 6, + 6, 2, 6, 8,15,15, 2, 2,15,15,15,15,15, 2, 2,15 }; +static const u8 A3a[64] = { + 3, 3,15,15, 8, 3,15,15, 8, 8, 6, 6, 6, 5, 3, 3, + 3, 3, 8,15, 3, 3, 6,10, 5, 8, 8, 6, 8, 5,15,15, + 8,15, 3, 5, 6,10, 8,15,15, 3,15, 5,15,15,15,15, + 3,15, 5, 5, 5, 8, 5,10, 5,10, 8,13,15,12, 3, 3 }; +static const u8 A3b[64] = { +15, 8, 8, 3,15,15, 3, 8,15,15,15,15,15,15,15, 8, +15, 8,15, 3,15, 8,15, 8, 3,15, 6,10,15,15,10, 8, +15, 3,15,10,10, 8, 9,10, 6,15, 8,15, 3, 6, 6, 8, +15, 3,15,15,15,15,15,15,15,15,15,15, 3,15,15, 8 }; + +static const int W2[4] = {0,21,43,64}; +static const int W3[8] = {0,9,18,27,37,46,55,64}; +static const int W4[16] = {0,4,9,13,17,21,26,30,34,38,43,47,51,55,60,64}; + +/* mode: subsets, partition bits, rotation bits, index-select bits, colour bits, alpha bits, + * p-bit kind (0 none / 1 shared per subset / 2 per endpoint), index bits, index2 bits */ +static const struct { u8 ns, pb, rb, isb, cb, ab, pk, ib, ib2; } MODE[8] = { + {3,4,0,0,4,0,2,3,0}, {2,6,0,0,6,0,1,3,0}, {3,6,0,0,5,0,0,2,0}, {2,6,0,0,7,0,2,2,0}, + {1,0,2,1,5,6,0,2,3}, {1,0,2,0,7,8,0,2,2}, {1,0,0,0,7,7,2,4,0}, {2,6,0,0,5,5,2,2,0} +}; + +typedef struct { const u8 *b; size_t len; unsigned pos; } bits_t; + +static u32 bget(bits_t *s, unsigned n) +{ + u32 v = 0; + for (unsigned i = 0; i < n; ++i) { + unsigned bit = s->pos + i; + u8 byte = at(s->b, s->len, bit >> 3); + v |= (u32)((byte >> (bit & 7)) & 1u) << i; + } + s->pos += n; + return v; +} + +/* Expand a `bits`-wide value to 8 bits by replicating the high bits, per the spec. */ +static u8 unq(u32 v, unsigned bits) +{ + if (bits >= 8) return (u8)v; + v <<= (8 - bits); + return (u8)(v | (v >> bits)); +} + +static int interp(int a, int b, int wt) { return (a * (64 - wt) + b * wt + 32) >> 6; } + +int bcn_decode_bc7(const u8 *src, size_t src_len, unsigned w, unsigned h, u8 *dst) +{ + if (!src || !dst || !w || !h) return 0; + unsigned pw = BCN_PAD(w), ph = BCN_PAD(h); + + for (unsigned by = 0; by < ph; by += 4) { + for (unsigned bx = 0; bx < pw; bx += 4) { + size_t bi = ((size_t)(by >> 2) * (pw >> 2) + (bx >> 2)) * 16u; + bits_t s = { src + (bi <= src_len ? bi : src_len), + bi <= src_len ? src_len - bi : 0u, 0u }; + + unsigned mode = 8u; + for (unsigned m = 0; m < 8u; ++m) if (bget(&s, 1)) { mode = m; break; } + if (mode == 8u) { /* invalid block -> transparent black */ + for (unsigned py = 0; py < 4; ++py) + for (unsigned px = 0; px < 4; ++px) + memset(dst + ((size_t)(by + py) * pw + (bx + px)) * 4, 0, 4); + continue; + } + + const u8 ns = MODE[mode].ns, cb = MODE[mode].cb, ab = MODE[mode].ab; + const u8 ib = MODE[mode].ib, ib2 = MODE[mode].ib2, pk = MODE[mode].pk; + + u32 part = MODE[mode].pb ? bget(&s, MODE[mode].pb) : 0u; + u32 rot = MODE[mode].rb ? bget(&s, MODE[mode].rb) : 0u; + u32 isel = MODE[mode].isb ? bget(&s, MODE[mode].isb) : 0u; + + u32 ep[6][4]; /* [endpoint][RGBA], still quantised */ + unsigned nep = (unsigned)ns * 2u; + for (unsigned c = 0; c < 3; ++c) for (unsigned e = 0; e < nep; ++e) ep[e][c] = bget(&s, cb); + if (ab) for (unsigned e = 0; e < nep; ++e) ep[e][3] = bget(&s, ab); + else for (unsigned e = 0; e < nep; ++e) ep[e][3] = 255u; + + u32 pbit[6] = {0,0,0,0,0,0}; + if (pk == 1) { for (unsigned i = 0; i < ns; ++i) { u32 p = bget(&s, 1); pbit[i*2] = pbit[i*2+1] = p; } } + else if (pk == 2) { for (unsigned e = 0; e < nep; ++e) pbit[e] = bget(&s, 1); } + + /* Unquantise endpoints (p-bit becomes the new LSB). */ + u8 col[6][4]; + for (unsigned e = 0; e < nep; ++e) { + unsigned cbits = cb + (pk ? 1u : 0u), abits = ab ? ab + (pk ? 1u : 0u) : 0u; + for (unsigned c = 0; c < 3; ++c) { + u32 v = pk ? ((ep[e][c] << 1) | pbit[e]) : ep[e][c]; + col[e][c] = unq(v, cbits); + } + if (ab) { + u32 v = pk ? ((ep[e][3] << 1) | pbit[e]) : ep[e][3]; + col[e][3] = unq(v, abits); + } else col[e][3] = 255u; + } + + /* Index bits. The anchor pixel of each subset stores one bit fewer. */ + const u8 *ptab = (ns == 2) ? P2[part] : (ns == 3 ? P3[part] : NULL); + unsigned anchor[3] = {0,0,0}; + if (ns == 2) anchor[1] = A2[part]; + else if (ns == 3) { anchor[1] = A3a[part]; anchor[2] = A3b[part]; } + + u32 idx1[16], idx2[16]; + for (unsigned k = 0; k < 16; ++k) { + unsigned sub = ptab ? ptab[k] : 0u; + unsigned n = ib - ((k == anchor[sub]) ? 1u : 0u); + idx1[k] = bget(&s, n); + } + if (ib2) for (unsigned k = 0; k < 16; ++k) idx2[k] = bget(&s, ib2 - (k == 0 ? 1u : 0u)); + + const int *wc = (ib == 2) ? W2 : (ib == 3 ? W3 : W4); + const int *wa = ib2 ? ((ib2 == 2) ? W2 : W3) : wc; + + for (unsigned k = 0; k < 16; ++k) { + unsigned sub = ptab ? ptab[k] : 0u; + const u8 *e0 = col[sub * 2], *e1 = col[sub * 2 + 1]; + /* Mode 4's index-select bit swaps which index set drives colour vs alpha. */ + u32 ci = idx1[k], ai = ib2 ? idx2[k] : idx1[k]; + if (ib2 && isel) { u32 t = ci; ci = ai; ai = t; } + int cw = (ib2 && isel) ? wa[ci] : wc[ci]; + int aw = ib2 ? ((ib2 && isel) ? wc[ai] : wa[ai]) : wc[ai]; + + u8 out[4]; + for (unsigned c = 0; c < 3; ++c) out[c] = clamp8(interp(e0[c], e1[c], cw)); + out[3] = clamp8(interp(e0[3], e1[3], aw)); + + /* Rotation moves the alpha channel into one of R/G/B (modes 4 and 5). */ + if (rot == 1) { u8 t = out[0]; out[0] = out[3]; out[3] = t; } + else if (rot == 2) { u8 t = out[1]; out[1] = out[3]; out[3] = t; } + else if (rot == 3) { u8 t = out[2]; out[2] = out[3]; out[3] = t; } + + unsigned px = k & 3u, py = k >> 2; + memcpy(dst + ((size_t)(by + py) * pw + (bx + px)) * 4, out, 4); + } + } + } + return 1; +} + +int bcn_decode(unsigned format_code, const u8 *src, size_t src_len, + unsigned w, unsigned h, u8 *dst) +{ + switch (format_code) { + case 10u: return bcn_decode_bc1(src, src_len, w, h, dst); + case 11u: return bcn_decode_bc3(src, src_len, w, h, dst); + case 23u: return bcn_decode_bc7(src, src_len, w, h, dst); + default: return 0; + } +} diff --git a/src/backend/bcn.h b/src/backend/bcn.h new file mode 100644 index 0000000..5855de9 --- /dev/null +++ b/src/backend/bcn.h @@ -0,0 +1,40 @@ +/* bcn.h -- block-compressed texture decoders (BC1, BC3, BC7) -> RGBA8. + * + * DOOM's `.bimage` container stores its mips in these formats; the format code in the bimage + * header maps to them as (doom-re campaign `revenant-asset-index-and-viewport`, evidence 09 sec 3d): + * + * code 10 -> BC1 (8 bytes / 4x4 block, RGB + 1-bit alpha) + * code 11 -> BC3 (16 bytes / 4x4, BC4 alpha block + BC1 colour block) + * code 23 -> BC7 (16 bytes / 4x4, 8 modes) + * + * Unlike the megatexture page codec -- which is id's own and had to be CALLED rather than + * reimplemented -- BCn is a public, fully specified format, so these are ordinary decoders + * with no engine dependency at all. + * + * All three write RGBA8 into a caller-supplied buffer sized for the PADDED dimensions + * (width and height each rounded up to a multiple of 4). Cropping to the real size is the + * caller's job. They never read past `src_len`: short input is treated as zero-filled, which + * matters because a truncated asset must degrade to a dim thumbnail, not a fault. + */ +#ifndef BACKEND_BCN_H +#define BACKEND_BCN_H + +#include + +/* Padded dimension helper: BCn always encodes whole 4x4 blocks. */ +#define BCN_PAD(x) (((x) + 3u) & ~3u) + +/* Bytes of RGBA output needed for a w x h image (i.e. padded w * padded h * 4). */ +size_t bcn_rgba_size(unsigned w, unsigned h); + +/* Decode into `dst` (must hold bcn_rgba_size(w,h) bytes). Returns 1 on success, 0 if the + * arguments are unusable. Out-of-range input is clamped, never trusted. */ +int bcn_decode_bc1(const unsigned char *src, size_t src_len, unsigned w, unsigned h, unsigned char *dst); +int bcn_decode_bc3(const unsigned char *src, size_t src_len, unsigned w, unsigned h, unsigned char *dst); +int bcn_decode_bc7(const unsigned char *src, size_t src_len, unsigned w, unsigned h, unsigned char *dst); + +/* Dispatch on the bimage format code (10/11/23). Returns 0 for codes we do not decode. */ +int bcn_decode(unsigned format_code, const unsigned char *src, size_t src_len, + unsigned w, unsigned h, unsigned char *dst); + +#endif /* BACKEND_BCN_H */ diff --git a/src/backend/build.ps1 b/src/backend/build.ps1 index 01f8e2b..08fbaeb 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", "swf_textedit.c", "ui_bridge.c", + "entity.c", "typeinfo.c", "preview.c", "megapreview.c", "imgpreview.c", "soundpreview.c", "bcn.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/commands.c b/src/backend/commands.c index 02b4f73..60c7dc8 100644 --- a/src/backend/commands.c +++ b/src/backend/commands.c @@ -1375,12 +1375,61 @@ static void h_sh_help(idCmdArgs *a) #define CMD_COUNT_SANITY 100000u /* idList grow (engine FUN_140699a60): ensures room for one more element on the idList at `list` - * (granularity-or-double then idList::Resize, the engine allocator). BUILD-LOCKED RVA + recipe: - * re-derive by decompiling AddCommand (0x1aa3630) -- it calls THIS on cmdSys+0x08 (FULL) and - * cmdSys+0x20 (DEV) before each append. A wrong RVA degrades to a skipped insert (SEH), never a crash. */ -#define IDLIST_GROW_RVA 0x699a60u + * (granularity-or-double then idList::Resize, the engine allocator). + * + * NOT signature-resolvable, and not for want of trying: it is ONE of 1,560 byte-identical + * instantiations of the same idList template in the image, differing only in rip-relative and rel32 + * displacements. No lengthening of a prologue pattern separates them -- the same lesson the sound + * module learned from StopSound's clone (doom-re evidence 10 SS4.3): a byte signature answers "where + * is this function", and is the wrong tool when the answer is "in 1,560 places". + * + * So it is resolved RELATIONALLY instead, off AddCommand, which IS signature-resolved: AddCommand + * calls this on cmdSys+0x08 (the FULL list) before appending, and that call site is the instruction + * pair `LEA RCX,[RSI+8]` / `CALL rel32`. Scanning AddCommand's own body for those five bytes and + * decoding the displacement yields the callee wherever this build put it. The old build-locked + * `module_base + 0x699a60` is retained below only as the documented cross-check. + * + * A miss degrades to a skipped insert (the caller SEH-guards), never a crash. */ +#define IDLIST_GROW_RVA 0x699a60u /* pinned-build value -- cross-check only, never used to locate */ typedef void (*idlist_grow_fn)(void *idlist); +/* Decode the idList-grow callee out of AddCommand's body. Returns NULL if the call site is not found + * within the scanned window or the decoded target lands outside the DOOM module. */ +static idlist_grow_fn sh_decode_idlist_grow(void *add_command, const uint8_t *module_base) +{ + if (!add_command || !module_base) return NULL; + + /* `LEA RCX,[RSI+8]` = 48 8D 4E 08, then E8 rel32. The first such pair in AddCommand is the FULL + * list; the DEV one (LEA RCX,[RSI+0x20]) calls the same function. 256 bytes covers both on the + * pinned build, where the first pair starts at +0xBF (its CALL opcode is at +0xC3 -- the logged + * offset below is the start of the 9-byte window, not the call). */ + const uint8_t *p = (const uint8_t *)add_command; + for (unsigned i = 0; i + 9 <= 256; ++i) { + uint8_t win[9]; + if (!sh_safe_read(p + i, win, sizeof win)) return NULL; + if (win[0] != 0x48 || win[1] != 0x8D || win[2] != 0x4E || win[3] != 0x08 || win[4] != 0xE8) + continue; + int32_t rel; + memcpy(&rel, win + 5, sizeof rel); + const uint8_t *tgt = p + i + 9 + rel; + + /* Range-check against the module before handing back something that will be CALLED. */ + uint8_t probe; + if (tgt < module_base || !sh_safe_read(tgt, &probe, 1)) return NULL; + + char l[160]; + uintptr_t rva = (uintptr_t)(tgt - module_base); + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: idList-grow decoded from AddCommand+0x%X -> rva=0x%llX (pinned 0x%X)%s", + i, (unsigned long long)rva, IDLIST_GROW_RVA, + rva == IDLIST_GROW_RVA ? "" : " MISMATCH -- trusting the decode"); + backend_log(l); + return (idlist_grow_fn)tgt; + } + backend_log("B2: idList-grow call site NOT found in AddCommand; command-unlock insert skipped"); + return NULL; +} + /* The AddCommand detour: OR flags|6 then call through the trampoline (= the original mod's * `or [rsp+0x30],6`). 6-arg passthrough; flags is the 6th (stack) arg. */ typedef void (*add_command6_fn)(void *cmdsys, const char *name, void *handler, const char *help, @@ -1465,7 +1514,7 @@ static void sh_command_unlock_install(void *cmdsys, void *add_command, const uin backend_log("B2: command-unlock SKIPPED -- cmdsys/AddCommand unresolved"); return; } - idlist_grow_fn grow = module_base ? (idlist_grow_fn)(module_base + IDLIST_GROW_RVA) : NULL; + idlist_grow_fn grow = sh_decode_idlist_grow(add_command, module_base); /* (1) detour AddCommand: every FUTURE registration (incl. gameplay commands on level load) gets * flags|6, so the engine's own AddCommand inserts it into BOTH tables, growing properly. */ diff --git a/src/backend/dllmain.c b/src/backend/dllmain.c index e65a353..f0e9171 100644 --- a/src/backend/dllmain.c +++ b/src/backend/dllmain.c @@ -31,6 +31,9 @@ #include "cvars.h" #include "entity.h" #include "typeinfo.h" +#include "megapreview.h" /* Assets-tab material preview: megatexture pages -> RGBA, CPU-only */ +#include "imgpreview.h" /* ...and its fallback for materials with no megatexture rect */ +#include "soundpreview.h" /* Assets-tab sound auditioning: the editor's own preview path */ #include "patch.h" #include "algo.h" #include "target_any.h" /* sh_target_any editor-decl visibility toggle (OG FUN_180021EE0 port) */ @@ -167,10 +170,10 @@ static DWORD WINAPI bootstrap_thread(LPVOID p) * already proved the whole DB resolves): the rawmap save/load swap, the strids injector, the * OVERRIDES file-shadow, and the cvar + console-command registration. */ { - sig_result results[64]; - sig_resolve_all(g_doom_base, results, 64); /* fills results[0..sig_db_count) by DB index */ + sig_result results[SIG_RESULTS_MAX]; + sig_resolve_all(g_doom_base, results, SIG_RESULTS_MAX); /* fills results[0..sig_db_count) by DB index */ size_t db = sig_db_count(); - if (db > 64) db = 64; + if (db > SIG_RESULTS_MAX) db = SIG_RESULTS_MAX; /* smoke.c logs the overflow */ /* the rawmap LOAD swap (the keystone feature). Install the DeserializeFromJson detour as * soon as the engine fn is resolved -- it does NOT depend on the editor being up (the detour @@ -367,6 +370,27 @@ static DWORD WINAPI bootstrap_thread(LPVOID p) * sh_entity_install. See typeinfo.c. */ sh_typeinfo_install(results, db, g_doom_base); + /* megapreview: the Assets-tab preview PRODUCER. Reads \virtualtextures off disk and + * decodes a named material's megatexture pages by calling DOOM's own page decoder + * (FUN_14196E140) in-process -- a pure function, so no renderer, no GPU and no map + * residency, which is what lets this cover the whole catalog instead of only what the + * loaded map happens to render. The decoder comes from the signature DB (Mega2PageDecode), + * so an unrecognised build degrades to "no previews" rather than a call into the wrong + * code. Hooks nothing. See megapreview.c and the doom-re campaign + * revenant-asset-index-and-viewport (evidence 06/07/08). */ + sh_megapreview_install(results, db, g_doom_base); + /* imgpreview: the fallback half of the same feature -- materials with no megatexture rect, + * decoded from the shipped .index/.resources containers (BC1/BC3/BC7). Reads files only; + * no engine call, no hook. megapreview's worker calls it when the atlas route declines. */ + sh_imgpreview_install(); + /* soundpreview: the same browser's AUDIO half. Calls the editor's own audition path + * (sound-world vtbl +0x30) and keeps the emitter handle it returns, so a preview can be + * stopped and a second click replaces the first instead of stacking on it -- the two things + * the `testSound` console command cannot do. Needs cmdsys for the s_soloSound / + * s_forceListener / s_playSoundInBackground cvars. Refuses to arm unless BOTH the play and + * the stop resolve. Hooks nothing. See soundpreview.c. */ + sh_soundpreview_install(results, db, g_doom_base, cmdsys); + /* snaphak_algo (cs_dontuse [18] + sh_alginfo): cache the DOOM module base so the cs_dontuse * TOGGLE can resolve the 4 AlgoMatMul/AlgoInverse/AlgoPackRGBA/AlgoCurveEval sigs at FIRE and * FULL-replace the engine math fns with our f64 reimpl (color-pack bit-exact). OFF BY DEFAULT -- diff --git a/src/backend/iface_engine.c b/src/backend/iface_engine.c index 1478912..6eaaf5c 100644 --- a/src/backend/iface_engine.c +++ b/src/backend/iface_engine.c @@ -30,7 +30,11 @@ #include "apply_engine.h" /* the heavy slots (serialize/schedule-apply/read-prefab) */ #include "signatures.h" #include "backend_log.h" -#include "typeinfo.h" /* sh_typeinfo_class_derives + the LIVE registry walks (collect_records/inherits) */ +#include "typeinfo.h" /* sh_typeinfo_class_derives + the LIVE registry walks (collect_records/inherits) */ +#include "preview.h" /* sh_preview_get / sh_preview_request -- the asset-preview transport */ +#include "imgpreview.h" /* sh_imgpreview_list -- the Assets browser's material catalog */ +#include "megapreview.h" /* sh_megapreview_rect -- the virtualmapping carrier's atlas rect */ +#include "soundpreview.h" /* sh_soundpreview_play/stop -- auditioning a sound decl */ #include "valid_class_map.h" /* SH_VCM_* -- the class-dropdown static snapshot (used only if the live walk fails) */ #include "wiring_cleandirect.h" /* sh_wiring_cleandirect_generation -- the wire-any connect-edit counter (+0x288) */ #include "snapstack.h" /* sh_snapstack_push_ids_backend -- the SnapStack stack push (+0x2A0) */ @@ -462,6 +466,84 @@ static int slot_manipulation_in_progress(sh_iface *self) return manipulation_in_progress() ? 1 : 0; } +/* +0x2C8 (ext 12) FIND MATERIAL by name -- pure passthrough to sh_typeinfo_find_material (see typeinfo.c + * for the cached-only-lookup rationale and the FatalError/INT3-trap warning about the primitive this + * deliberately does NOT call). No editor/entity state involved, so no session gate here. */ +static int slot_find_material(sh_iface *self, const char *name, char *out_info, int cap) +{ + (void)self; + return sh_typeinfo_find_material(name, out_info, (size_t)cap); +} + +/* +0x2D0 (ext 13) Latest asset-preview image (preview.c). Pure passthrough; no engine state touched + * here -- the pixels were produced and encoded elsewhere. Returns length, 0 if nothing published yet, + * or -(required) if the UI's buffer is too small. */ +static int slot_get_preview(sh_iface *self, char *out, int cap) +{ + (void)self; + return sh_preview_get(out, (size_t)(cap > 0 ? cap : 0)); +} + +/* +0x2D8 (ext 14) Ask for a NAMED asset to be previewed. Staging only -- production happens on another + * thread, so this returns as soon as the name is recorded and the caller polls get_preview (+0x2D0) for + * the result. NOTE: no producer is installed yet (the megatexture page decoder is unwritten), so this + * currently always times out on the UI side. The ABI slot is deliberately kept: it is route-independent + * and appending it later would move no offsets but would need another matched-pair rollout. */ +static int slot_request_preview(sh_iface *self, const char *name) +{ + (void)self; + if (!name || !*name) return 0; + sh_preview_request(name); + return 1; +} + +/* +0x2E0 (ext 15) Page the material catalog for the Assets browser's list. Pure file/index read + * (imgpreview.c owns the containers); no engine state touched, so no session gate. */ +static int slot_list_materials(sh_iface *self, int start, char *out, int cap) +{ + (void)self; + if (!out || cap <= 1) return 0; + return sh_imgpreview_list(SH_ASSET_MATERIAL, (unsigned)(start > 0 ? start : 0), out, (size_t)cap); +} + +/* +0x2E8 (ext 16) The same, for any indexed asset type. `kind` is an SH_ASSET_* value; out-of-range + * returns 0 rather than falling back to materials, so a UI/backend version mismatch shows up as an + * empty list instead of silently serving the wrong catalog. */ +static int slot_list_assets(sh_iface *self, int kind, int start, char *out, int cap) +{ + (void)self; + if (!out || cap <= 1) return 0; + return sh_imgpreview_list(kind, (unsigned)(start > 0 ? start : 0), out, (size_t)cap); +} + +/* +0x2F0 (ext 17) A material's atlas rect, so the browser can build a `virtualmapping` renderParm + * value and can tell when that carrier does not apply at all. Pure .vmtr read; no engine state. */ +static int slot_material_rect(sh_iface *self, const char *name, int *out_xywh) +{ + (void)self; + return sh_megapreview_rect(name, out_xywh); +} + +/* +0x2F8 (ext 18) Audition a sound decl, or stop the current one when `name` is NULL/empty. The + * play half validates the name against our own container index before it reaches the engine, so a + * bad name here is a refusal and not a fatal error. MAIN THREAD: the UI reaches this through its + * apply drain, never straight off the WebView thread -- this touches live audio state. */ +static int slot_sound_preview(sh_iface *self, const char *name) +{ + (void)self; + if (!name || !name[0]) { sh_soundpreview_stop(); return 0; } + return sh_soundpreview_play(name); +} + +/* +0x300 (ext 19) Hold preview mode open while the asset browser is on screen. The audition cvars + * cost an audio-engine suspend/resume to change, so they are set once here rather than around every + * click -- doing it per click made short sounds fade in or miss their start entirely. MAIN THREAD. */ +static void slot_sound_session(sh_iface *self, int on) +{ + (void)self; + sh_soundpreview_set_session(on); +} + static void mode_set_selection_state(int state) { const uint8_t *ed = editor_session(); @@ -1253,6 +1335,16 @@ int sh_iface_engine_install(const sig_result *results, size_t n, const uint8_t * /* 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 */ + /* clone-extension: FIND a material decl by name (cached-only lookup; the Revenant asset-viewport + * tab's first probe -- see typeinfo.c / sh_typeinfo_find_material). */ + slots.find_material = slot_find_material; /* +0x2C8 ext 12 */ + slots.get_preview = slot_get_preview; /* +0x2D0 ext 13 */ + slots.request_preview = slot_request_preview; /* +0x2D8 ext 14 */ + slots.list_materials = slot_list_materials; /* +0x2E0 ext 15 */ + slots.list_assets = slot_list_assets; /* +0x2E8 ext 16 */ + slots.material_rect = slot_material_rect; /* +0x2F0 ext 17 */ + slots.sound_preview = slot_sound_preview; /* +0x2F8 ext 18 */ + slots.sound_session = slot_sound_session; /* +0x300 ext 19 */ sh_iface_bind_engine_slots(&slots); char line[200]; diff --git a/src/backend/imgpreview.c b/src/backend/imgpreview.c new file mode 100644 index 0000000..4eb483d --- /dev/null +++ b/src/backend/imgpreview.c @@ -0,0 +1,1065 @@ +/* imgpreview.c -- see imgpreview.h. The SECOND preview producer: plain (non-megatexture) + * materials, read out of the game's own `.index`/`.resources` containers and decoded on the CPU. + * + * megapreview.c covers the 5,033 materials that have a `.vmtr` atlas rect. The other ~4,772 + * render fine in game but are backed by ordinary image assets, so the atlas route cannot see + * them at all. Chain (doom-re campaign `revenant-asset-index-and-viewport`, evidence 09): + * + * name -> material record -> inflate decl -> `*map` field -> image record + * -> inflate .bimage -> first mip record -> BC1/BC3/BC7 -> RGBA + * + * Everything is read-only against files the game ships. No engine call at all -- unlike the + * megatexture codec, BCn and DEFLATE are public formats. */ + +#include +#include +#include +#include + +#include "imgpreview.h" +#include "preview.h" +#include "bcn.h" +#include "megapreview.h" /* the .vmtr atlas: the other half of the material catalog */ +#include "backend_log.h" + +#define MAX_PREVIEW 240u /* matches megapreview's 2x2-page budget, so both routes agree */ + +/* ------------------------------------------------------------------ raw DEFLATE --------------- + * The backend links no zlib, and the payloads are raw DEFLATE terminated by a Z_SYNC_FLUSH + * marker rather than a BFINAL block (evidence 03 sec 5). We always know the uncompressed size from + * the index record, so this stops on output-full and never needs to see the terminator. */ + +typedef struct { const unsigned char *src; size_t len, pos; unsigned bitbuf, bitcnt; } inf_t; + +static unsigned inf_bits(inf_t *s, unsigned n) +{ + while (s->bitcnt < n) { + unsigned b = (s->pos < s->len) ? s->src[s->pos++] : 0u; + s->bitbuf |= b << s->bitcnt; + s->bitcnt += 8; + } + unsigned v = s->bitbuf & ((1u << n) - 1u); + s->bitbuf >>= n; s->bitcnt -= n; + return v; +} + +typedef struct { unsigned short count[16], symbol[288]; } huff_t; + +static void huff_build(huff_t *h, const unsigned char *lens, unsigned n) +{ + unsigned offs[16], i; + for (i = 0; i < 16; ++i) h->count[i] = 0; + for (i = 0; i < n; ++i) h->count[lens[i]]++; + h->count[0] = 0; + offs[0] = 0; + for (i = 1; i < 16; ++i) offs[i] = offs[i-1] + h->count[i-1]; + for (i = 0; i < n; ++i) if (lens[i]) h->symbol[offs[lens[i]]++] = (unsigned short)i; +} + +static int huff_decode(inf_t *s, const huff_t *h) +{ + int code = 0, first = 0, index = 0; + for (int len = 1; len < 16; ++len) { + code |= (int)inf_bits(s, 1); + int count = h->count[len]; + if (code - count < first) return h->symbol[index + (code - first)]; + index += count; first += count; first <<= 1; code <<= 1; + } + return -1; +} + +static const unsigned short LBASE[29] = {3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258}; +static const unsigned short LEXT [29] = {0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0}; +static const unsigned short DBASE[30] = {1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577}; +static const unsigned short DEXT [30] = {0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13}; + +/* Returns bytes produced (== dst_len on success). */ +static size_t inflate_raw(const unsigned char *src, size_t src_len, unsigned char *dst, size_t dst_len) +{ + inf_t s = { src, src_len, 0, 0, 0 }; + size_t out = 0; + huff_t lit, dist; + for (;;) { + if (out >= dst_len) break; + unsigned final = inf_bits(&s, 1), type = inf_bits(&s, 2); + if (type == 0) { /* stored */ + s.bitbuf = 0; s.bitcnt = 0; + if (s.pos + 4 > s.len) break; + unsigned len = s.src[s.pos] | (s.src[s.pos+1] << 8); + s.pos += 4; + if (s.pos + len > s.len) len = (unsigned)(s.len - s.pos); + if (out + len > dst_len) len = (unsigned)(dst_len - out); + memcpy(dst + out, s.src + s.pos, len); + s.pos += len; out += len; + } else if (type == 1 || type == 2) { + if (type == 1) { /* fixed tables */ + unsigned char l[288], d[30]; + int i = 0; + for (; i < 144; ++i) l[i] = 8; + for (; i < 256; ++i) l[i] = 9; + for (; i < 280; ++i) l[i] = 7; + for (; i < 288; ++i) l[i] = 8; + for (i = 0; i < 30; ++i) d[i] = 5; + huff_build(&lit, l, 288); huff_build(&dist, d, 30); + } else { /* dynamic tables */ + static const unsigned char ord[19] = {16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15}; + unsigned nlen = inf_bits(&s,5)+257, ndist = inf_bits(&s,5)+1, ncode = inf_bits(&s,4)+4; + unsigned char cl[19]; memset(cl, 0, sizeof cl); + for (unsigned i = 0; i < ncode; ++i) cl[ord[i]] = (unsigned char)inf_bits(&s,3); + huff_t clh; huff_build(&clh, cl, 19); + unsigned char lens[320]; memset(lens, 0, sizeof lens); + unsigned i = 0; + while (i < nlen + ndist) { + int sym = huff_decode(&s, &clh); + if (sym < 0) return out; + if (sym < 16) lens[i++] = (unsigned char)sym; + else if (sym == 16) { unsigned char prev = i ? lens[i-1] : 0; unsigned r = 3 + inf_bits(&s,2); while (r-- && i < 320) lens[i++] = prev; } + else if (sym == 17) { unsigned r = 3 + inf_bits(&s,3); while (r-- && i < 320) lens[i++] = 0; } + else { unsigned r = 11 + inf_bits(&s,7); while (r-- && i < 320) lens[i++] = 0; } + } + huff_build(&lit, lens, nlen); huff_build(&dist, lens + nlen, ndist); + } + for (;;) { + int sym = huff_decode(&s, &lit); + if (sym < 0) return out; + if (sym < 256) { if (out < dst_len) dst[out++] = (unsigned char)sym; else return out; } + else if (sym == 256) break; + else { + sym -= 257; if (sym >= 29) return out; + unsigned len = LBASE[sym] + inf_bits(&s, LEXT[sym]); + int ds = huff_decode(&s, &dist); + if (ds < 0 || ds >= 30) return out; + unsigned d = DBASE[ds] + inf_bits(&s, DEXT[ds]); + if (d > out) return out; + while (len-- && out < dst_len) { dst[out] = dst[out - d]; out++; } + } + } + } else return out; + if (final) break; + if (s.pos >= s.len && s.bitcnt == 0) break; + } + return out; +} + +/* ------------------------------------------------------------------- containers ---------------*/ + +typedef struct { const char *name; unsigned long long roff; unsigned usz, csz; + unsigned char kind, box, hidden; } rec_t; +/* kind: one of SH_ASSET_*, below. box: which .resources file. + * hidden: excluded from the browser listing (a campaign duplicate of a SnapMap record, or a + * campaign record of a type we do not offer). Still findable by name -- see find_rec. */ + +/* The decl types we index. `suffix`, when set, additionally requires the record NAME to end with + * it -- that is how one browser category draws from more than one decl type, and how a decl type + * contributes only part of itself. + * + * Counted directly out of snap_gameresources.index: model 12,630 / material 9,805 / sound 5,658 / + * image 3,423 / entityDef 2,520 / decalatlas 1,673 / particle 1,523 / snapEditorEntityDef 1,362 / + * md6Def 506 / fx 476. Types outside this table (renderProg, anim, cm, aas, ...) are engine + * internals with nothing a mapper can place, so they are skipped entirely. + * + * MODELS is the one category that is not a straight type mapping. `renderModelInfo.model` takes + * .lwo and .md6 values, and .md6 models live in their own decl type, so the category is the union + * of the two. The `model` type's other 9,961 snap-box records are .bmodel -- baked brush geometry, + * sharing no name with any .lwo. Those are not padding for Models; they get their own two categories + * (MODULE / BMODEL) so the props list stays a props list. */ +static const struct { const char *type; unsigned len; unsigned char kind; const char *suffix; unsigned slen; } g_kinds[] = { + { "material", 8, SH_ASSET_MATERIAL, NULL, 0 }, + { "image", 5, SH_ASSET_IMAGE, NULL, 0 }, + { "model", 5, SH_ASSET_MODEL, ".lwo", 4 }, + { "md6Def", 6, SH_ASSET_MODEL, NULL, 0 }, + { "sound", 5, SH_ASSET_SOUND, NULL, 0 }, + { "fx", 2, SH_ASSET_FX, NULL, 0 }, + { "particle", 8, SH_ASSET_PARTICLE, NULL, 0 }, + { "decalatlas", 10, SH_ASSET_DECALATLAS, NULL, 0 }, + { "snapEditorEntityDef",19, SH_ASSET_SNAPDEF, NULL, 0 }, + { "entityDef", 9, SH_ASSET_ENTITYDEF, NULL, 0 }, + /* The `model` type's OTHER half: baked BRUSH geometry under maps/. Split from Models on purpose -- + * .bmodel and .lwo share ZERO stems, so these are disjoint content, not duplicates of the props. + * The 232 palette modules are promoted out of this kind in imgpreview_load. */ + { "model", 5, SH_ASSET_BMODEL, ".bmodel", 7 }, + { "cm", 2, SH_ASSET_CLIPMODEL, NULL, 0 }, + /* The THIRD source of Models, and the reason breakable props looked missing. A `breakable` decl + * describes how something shatters and NAMES a model -- `breakable/barrel2` points at + * `models/mapobjects/prop/destroyables/barrel2gib.lwo` -- and every one of those models is + * indexed under `discreteAnimation`, not `model`. All 108 are .lwo, none of them duplicates a + * name already in Models, and they are in the SNAP box, so unlike a campaign-box model they + * actually load rather than rendering as a black cube. They take renderModelInfo.model like any + * other model, so they belong in the same category rather than a separate one. */ + { "discreteAnimation", 17, SH_ASSET_MODEL, NULL, 0 }, + { "perks", 5, SH_ASSET_PERK, NULL, 0 }, + /* `file` is a mixed bag -- .bimage, .tome, .sbsp, .ambientsh -- and only the .bswf half is worth + * offering, so the suffix does the filtering the type cannot. See imgpreview_swf_name for why + * the listed name is not the name stored here. */ + { "file", 4, SH_ASSET_SWF, ".bswf", 5 } +}; +#define KIND_COUNT ((int)(sizeof g_kinds / sizeof g_kinds[0])) + +typedef struct { unsigned char *idx; size_t idxLen; HANDLE res; } box_t; + +/* ---- Wwise events: the OTHER half of the sound catalog ------------------------------------------- + * A `sound` decl is a thin wrapper naming a Wwise event, but the two sets are not the same and + * NEITHER contains the other. Measured against the shipped files: + * + * Wwise events (soundbanksinfo.xml) 7,649 | sound decls (snap box) 5,658 + * overlap 5,058 | events with no decl 2,591 | decls not in the manifest 600 + * union, deduped 8,249 + * + * The 2,591 event-only names are what a mapper notices missing -- 594 of them are + * `play_vo_snapmaps_*`, the generic male/female SnapMap VO, much of it DLC1-3. The 600 decl-only + * names are mostly path-form (`ambient_events/...`, `effects/...`) plus specials like `_silence`. + * So the browser lists the UNION and dedupes case-insensitively -- the manifest spells events + * `Play_Vo_...` while decls are lowercase, so a naive merge would double 5,058 entries. + * + * That exact-name dedup is necessary but NOT sufficient, because the two sources name the same + * sound two different ways. 5,160 decls are flat and literally named `play_*`, so they equal their + * event outright and collapse here. The other 449 are PATH-FORM, where the rule is instead + * ` == "Play_" + the decl's LEAF` (`scripted_events/cyberdemon/head_splat_01` <-> + * `Play_head_splat_01`). Those slipped through as a second row for an already-listed sound. + * imgpreview_hide_wrapped_sounds below is the pass that collapses them; note that the twin may be + * either a Wwise event OR a flat `play_*` decl, and checking only the events fixes barely half of + * them. Its comment carries the split and the evidence that dropping the decl side is lossless. + * + * `soundbanksinfo.xml` is the Wwise-generated manifest id shipped with the game (26 MB, next to the + * .bnk files). Parsed for `` only; everything else is ignored. */ +static unsigned char *g_wwise; /* the manifest text, kept alive: names point into it */ +static const char **g_ev; /* event names NOT already present as a decl */ +static int g_evCount; + +/* ---- .vmtr-only materials: the OTHER half of the MATERIAL catalog -------------------------------- + * Exactly the same shape of problem as the Wwise/sound split above, for the same reason: a material + * has TWO independent ways to be addressed, and neither set contains the other. + * + * - by NAME, through a `material` decl -> the `customMaterial` field. + * - by RECTANGLE, through the `.vmtr` megatexture atlas -> the `virtualmapping` renderParm. + * + * The atlas does not need a decl. A row in `_vmtr.vmtr` is paintable as a virtualmapping value + * whether or not anyone ever authored a `material` decl of that name, and thousands of shipped + * rows have no decl at all. Listing only decls therefore hides them completely -- they cannot be + * searched for, so they cannot be applied, even though the art is right there in the atlas. + * + * So MATERIALS list the UNION, deduped case-insensitively, exactly like sounds. The decl record + * wins where both exist (it can do both carriers); an atlas-only name is offered as a material + * that supports Virtual Mapping but not Custom Material, which is what `has_decl` reports to the + * UI so it can gate the carrier honestly instead of guessing. */ +static const char **g_vt; /* .vmtr names with NO material decl; point into megapreview */ +static int g_vtCount; + +/* ---- which soundbank a sound came from ----------------------------------------------------------- + * `soundbanksinfo.xml` groups its events under doom_snapmaps..., + * and that grouping is the only meaningful structure the sound catalog has. The names themselves are + * almost entirely FLAT (5,160 of the decls are `play_*` with no path at all), so a folder tree built + * from names is ~95% one giant root -- which is what it looks like in the browser today. + * + * 26 distinct banks, sensibly sized (doom_vo 1666, doom_initial 1551, doom_snapmaps 485, + * doom_monsters 424, ... only 3 under 20 events), so they make a usable filter where a name-prefix + * split does not: prefixes give 835 buckets, 585 of them holding a single event, and `vo` alone + * swallowing 46% of the catalog. + * + * ONE EVENT CAN BE IN SEVERAL BANKS -- 1,619 of 7,649 are. That sounds fatal for a single-valued + * filter and is not, because the overlap is almost entirely `doom_initial` (the always-loaded base + * bank) paired with the bank that actually means something: + * + * 345 doom_initial + doom_monsters 121 doom_initial + doom_effects + * 180 doom_initial + doom_scripted_events 114 doom_initial + doom_ui + * 162 doom_initial + doom_weapon_sp 109 doom_initial + doom_ambience + * + * So the tie-break is "prefer the specific bank over doom_initial", which resolves nearly all of it. + * doom_snapmaps is cleaner still: 485 events, only 27 of which appear in any other bank. */ +typedef struct { const char *name; const char *bank; } sndbank_t; +static sndbank_t *g_sb; /* one row per DISTINCT event; both ptrs into g_wwise */ +static int g_sbCount; +#define SB_BASE_BANK "doom_initial" /* the always-loaded bank a specific one should win over */ + +static box_t g_box[2]; /* 0 = snap_gameresources, 1 = gameresources */ +static rec_t *g_rec; +static int g_recCount; +static int g_loaded; +static char g_baseDir[MAX_PATH]; +static CRITICAL_SECTION g_lock; + +static unsigned be32(const unsigned char *p) { return ((unsigned)p[0]<<24)|((unsigned)p[1]<<16)|((unsigned)p[2]<<8)|p[3]; } +static unsigned long long be64(const unsigned char *p) +{ unsigned long long v=0; for (int i=0;i<8;++i) v=(v<<8)|p[i]; return v; } + +/* Turn the stored SWF record name into the one decls actually use. + * + * generated/swf/interactables/elite_guard.bswf <- what the index stores + * swf/interactables/elite_guard.swf <- what an entityDef references + * + * The baked `.bswf` under `generated/` is the compiled artifact, the same relationship `.bimage` + * has to an image decl. It appears in no decl anywhere, so listing it would give the mapper a name + * that cannot be pasted into anything. + * + * Rewritten IN PLACE, which is safe only because the wanted form is strictly SHORTER: dropping + * `generated/` frees ten bytes and `.bswf` -> `.swf` one more. The prefix is skipped by moving the + * POINTER (no copying), and the extension is overwritten across its own five bytes. Anything not + * shaped as expected is returned untouched rather than half-converted. */ +static const char *imgpreview_swf_name(char *name, unsigned nl) +{ + if (nl >= 5 && _stricmp(name + nl - 5, ".bswf") == 0) + memcpy(name + nl - 5, ".swf", 5); /* copies the terminator too */ + return (_strnicmp(name, "generated/", 10) == 0) ? name + 10 : name; +} + +static int imgpreview_load_box(int b, const char *stem) +{ + char p[MAX_PATH]; + _snprintf_s(p, sizeof p, _TRUNCATE, "%s\\%s.index", g_baseDir, stem); + HANDLE f = CreateFileA(p, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); + if (f == INVALID_HANDLE_VALUE) return 0; + LARGE_INTEGER sz; GetFileSizeEx(f, &sz); + unsigned char *buf = (unsigned char *)malloc((size_t)sz.QuadPart); + DWORD got = 0; + if (!buf || !ReadFile(f, buf, (DWORD)sz.QuadPart, &got, NULL) || got != sz.QuadPart) { + CloseHandle(f); free(buf); return 0; + } + CloseHandle(f); + g_box[b].idx = buf; g_box[b].idxLen = (size_t)sz.QuadPart; + + _snprintf_s(p, sizeof p, _TRUNCATE, "%s\\%s.resources", g_baseDir, stem); + g_box[b].res = CreateFileA(p, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); + if (g_box[b].res == INVALID_HANDLE_VALUE) { g_box[b].res = NULL; return 0; } + + /* Header: magic "\x05SER", BE count at +0x20, records at +0x28. Each record is three + * length-prefixed ASCII strings then a 25-byte fixed block (evidence 03 sec 2). */ + if (buf[0] != 0x05 || memcmp(buf+1, "SER", 3) != 0) return 0; + unsigned n = be32(buf + 0x20); + size_t o = 0x28; + for (unsigned i = 0; i < n && o + 12 < g_box[b].idxLen; ++i) { + unsigned tl = *(unsigned *)(buf + o); const char *type = (const char *)(buf + o + 4); o += 4 + tl; + unsigned nl = *(unsigned *)(buf + o); const char *name = (const char *)(buf + o + 4); o += 4 + nl; + unsigned pl = *(unsigned *)(buf + o); o += 4 + pl; + if (o + 25 > g_box[b].idxLen) break; + const unsigned char *blk = buf + o; o += 25; + + int kind = -1; + for (int k = 0; k < KIND_COUNT; ++k) { + if (tl != g_kinds[k].len || memcmp(type, g_kinds[k].type, tl) != 0) continue; + /* Names are not NUL-terminated here (that happens below), so match the suffix against + * the known length rather than with strcmp. */ + /* `continue`, NOT `break`: one decl type can map to several kinds discriminated only by + * extension (`model` -> .lwo Models and .bmodel Brush models). Breaking here on the + * first entry whose suffix misses would drop every record the later entry owns. */ + if (g_kinds[k].suffix && + (nl < g_kinds[k].slen || + _strnicmp(name + nl - g_kinds[k].slen, g_kinds[k].suffix, g_kinds[k].slen) != 0)) + continue; /* right type, wrong extension -> try next kind */ + kind = g_kinds[k].kind; + break; + } + if (kind < 0) continue; + + if ((g_recCount & 1023) == 0) { + rec_t *bigger = (rec_t *)realloc(g_rec, (size_t)(g_recCount + 1024) * sizeof *bigger); + if (!bigger) break; + g_rec = bigger; + } + rec_t *r = &g_rec[g_recCount++]; + r->name = name; r->roff = be64(blk); r->usz = be32(blk+8); r->csz = be32(blk+12); + /* `hidden` MUST be set here: the record array grows by realloc, which does not zero, so + * leaving it uninitialised would hide records at random. */ + r->kind = (unsigned char)kind; r->box = (unsigned char)b; r->hidden = 0; + /* Names are NOT NUL-terminated in the file; terminate in place. The byte we overwrite + * is the first of the next length prefix, which we have already consumed. */ + ((char *)name)[nl] = '\0'; + if (kind == SH_ASSET_SWF) r->name = imgpreview_swf_name((char *)name, nl); + } + return 1; +} + +/* Sort/search helper: the two sources disagree on case, so every comparison here is case-folded. */ +static int __cdecl cmp_ci(const void *a, const void *b) +{ + return _stricmp(*(const char * const *)a, *(const char * const *)b); +} + +/* Sorts RECORD INDICES by (kind, box, name, original index) so repeats of one name inside one box + * land next to each other. The index is the last key on purpose: it makes the order total, so the + * pass that walks the result can rely on the earlier-indexed record always coming first and keep + * that one. Reads through g_sortRec because qsort gives the comparator no context of its own. */ +static const rec_t *g_sortRec; +static int __cdecl cmp_rec_kind_name(const void *a, const void *b) +{ + int ia = *(const int *)a, ib = *(const int *)b; + const rec_t *x = &g_sortRec[ia], *y = &g_sortRec[ib]; + if (x->kind != y->kind) return (int)x->kind - (int)y->kind; + if (x->box != y->box) return (int)x->box - (int)y->box; + int c = _stricmp(x->name, y->name); + if (c) return c; + return ia - ib; +} + +/* Same, over the (event, bank) pair table -- groups an event's per-bank repeats together. */ +static int __cdecl cmp_sb_name(const void *a, const void *b) +{ + return _stricmp(((const sndbank_t *)a)->name, ((const sndbank_t *)b)->name); +} + +/* Parse the Wwise manifest into g_ev, keeping only events with no `sound` decl of the same name. + * Failure is non-fatal: a missing or unreadable manifest just means the catalog stays as it was. */ +static void imgpreview_load_wwise(void) +{ + char p[MAX_PATH]; + _snprintf_s(p, sizeof p, _TRUNCATE, "%s\\sound\\soundbanks\\pc\\soundbanksinfo.xml", g_baseDir); + HANDLE f = CreateFileA(p, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); + if (f == INVALID_HANDLE_VALUE) { backend_log("B2: imgpreview -- no soundbanksinfo.xml; Wwise events unavailable"); return; } + LARGE_INTEGER sz; GetFileSizeEx(f, &sz); + /* +1 so a name ending at EOF still has a byte to take the NUL. */ + unsigned char *buf = (unsigned char *)malloc((size_t)sz.QuadPart + 1); + DWORD got = 0; + if (!buf || !ReadFile(f, buf, (DWORD)sz.QuadPart, &got, NULL) || got != sz.QuadPart) { + CloseHandle(f); free(buf); return; + } + CloseHandle(f); + buf[got] = '\0'; + g_wwise = buf; + + /* The decl names, sorted case-insensitively, so the dedup is a binary search rather than a + * 7,649 x 5,658 scan. */ + const char **decl = (const char **)malloc((size_t)g_recCount * sizeof *decl); + int dn = 0; + if (decl) { + for (int i = 0; i < g_recCount; ++i) + if (g_rec[i].kind == SH_ASSET_SOUND) decl[dn++] = g_rec[i].name; + qsort(decl, (size_t)dn, sizeof *decl, cmp_ci); + } + + int cap = 1024, dup = 0; + g_ev = (const char **)malloc((size_t)cap * sizeof *g_ev); + if (!g_ev) { free(decl); return; } + + /* Every (event, bank) pair, before dedup. The manifest repeats an event once per bank that + * includes it -- 39,971 elements for 7,649 names -- so this is the raw pair list that the + * prefer-specific-bank collapse below reduces to one row per event. */ + int sbCap = 4096; + sndbank_t *sbRaw = (sndbank_t *)malloc((size_t)sbCap * sizeof *sbRaw); + int sbRawCount = 0; + + /* The bank whose we are currently inside. The manifest is ordered, so tracking + * the most recent 's is enough. Deliberately NOT any : the + * block earlier in the file uses that tag too, for .wav paths. Those precede + * the first and contain no , so anchoring to skips them. */ + const char *bank = ""; + + for (char *s = (char *)buf; ; ) { + char *nb = strstr(s, ""); + char *se = sn ? strstr(sn + 11, "") : NULL; + if (sn && se && sn < ne) { *se = '\0'; bank = sn + 11; s = se + 1; } + else s = nb + 11; + continue; + } + s = ne; + char *nm = strstr(s, "Name=\""); + char *end = strchr(s, '>'); + if (!nm || (end && nm > end)) { s += 7; continue; } /* no Name on this element */ + nm += 6; + char *q = strchr(nm, '"'); + if (!q) break; + *q = '\0'; /* terminate in place */ + s = q + 1; + + if (sbRaw) { /* record the pair before any dedup */ + if (sbRawCount == sbCap) { + sndbank_t *bigger = (sndbank_t *)realloc(sbRaw, (size_t)(sbCap * 2) * sizeof *bigger); + if (bigger) { sbRaw = bigger; sbCap *= 2; } + } + if (sbRawCount < sbCap) { sbRaw[sbRawCount].name = nm; sbRaw[sbRawCount].bank = bank; sbRawCount++; } + } + + if (decl && bsearch(&nm, decl, (size_t)dn, sizeof *decl, cmp_ci)) { dup++; continue; } + if (g_evCount == cap) { + const char **bigger = (const char **)realloc(g_ev, (size_t)(cap * 2) * sizeof *bigger); + if (!bigger) break; + g_ev = bigger; cap *= 2; + } + g_ev[g_evCount++] = nm; + } + free(decl); + + /* The manifest lists each event once PER SOUNDBANK that includes it -- 39,971 elements + * for 7,649 distinct names. Without this the browser would show roughly 9,400 duplicate rows. + * Sort case-insensitively, then collapse adjacent equals. */ + int raw = g_evCount; + if (g_evCount > 1) { + qsort(g_ev, (size_t)g_evCount, sizeof *g_ev, cmp_ci); + int w = 1; + for (int i = 1; i < g_evCount; ++i) + if (_stricmp(g_ev[i], g_ev[w - 1]) != 0) g_ev[w++] = g_ev[i]; + g_evCount = w; + } + + /* Collapse the raw (event, bank) pairs to one row per event, preferring a specific bank over + * the always-loaded base one. Sorting by name groups the repeats; within a group the first + * non-base bank wins, and a group that is ONLY the base bank keeps it. */ + if (sbRaw && sbRawCount > 0) { + qsort(sbRaw, (size_t)sbRawCount, sizeof *sbRaw, cmp_sb_name); + g_sb = sbRaw; + int w = 0; + for (int i = 0; i < sbRawCount; ) { + int j = i; + const char *best = sbRaw[i].bank; + while (j < sbRawCount && _stricmp(sbRaw[j].name, sbRaw[i].name) == 0) { + if (_stricmp(best, SB_BASE_BANK) == 0 && _stricmp(sbRaw[j].bank, SB_BASE_BANK) != 0) + best = sbRaw[j].bank; + j++; + } + g_sb[w].name = sbRaw[i].name; + g_sb[w].bank = best; + w++; + i = j; + } + g_sbCount = w; + } else { + free(sbRaw); + } + + char line[300]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: imgpreview -- Wwise manifest: %d distinct event(s) with no decl added to the sound " + "catalog (%d bank-repeats collapsed, %d already had a decl); %d event(s) mapped to a bank", + g_evCount, raw - g_evCount, dup, g_sbCount); + backend_log(line); +} + +/* Hide a sound decl that is nothing but a wrapper around a Wwise event already in the catalog. + * + * The exact-name dedup in imgpreview_load_wwise catches the 5,160 decls literally NAMED `play_*`, + * because those equal their event's name outright. It cannot catch the other 449, which are + * PATH-FORM -- and those were showing up as a second row for a sound already listed: + * + * decl scripted_events/cyberdemon/head_splat_01 <- this row, redundant + * event Play_head_splat_01 <- same sound + * + * The naming rule is `event == "Play_" + the decl's LEAF`, and it is safe to apply mechanically: + * NONE of the 449 path-form decls already has a `play_` leaf, so the prefix can never double up. + * + * Dropping the decl side loses NOTHING. Measured across the shipped set, 5,657 of 5,658 sound decls + * are empty wrappers -- `inherit = "default"` and an empty `edit` block. The single exception is + * `default.decl` itself, the base they all inherit. A sound decl carries no volume, no falloff, no + * randomisation the event does not already have, so the two rows are the same sound and the event is + * the one that actually plays. What is lost is only the folder path, which the owner explicitly did + * not want to keep for sounds ("I don't think we need the folder structure"). + * + * Where two decls map to one event -- 8 pairs, e.g. monster/baron/attacks/groundpound and + * monster/hellknight/attacks/groundpound both -> Play_groundpound -- collapsing them is correct + * rather than lossy, for the same reason: neither decl adds anything to distinguish them. + * + * The twin is looked for in BOTH sources, because it can be either one and checking only the events + * fixes only half the rows. Of the 449 path-form decls: + * + * 181 twin is a Wwise EVENT in g_ev (no flat decl of that name exists) + * 129 twin is a flat `play_` DECL (so the event was exact-matched OUT of g_ev) + * 139 no twin at all -- genuinely unique, and correctly kept + * + * That second bucket is the trap: `g_ev` deliberately holds only events with NO exact-name decl, so + * for `effects/explosions/rocket_explosion_default` the event `Play_rocket_explosion_default` is + * absent from g_ev -- it was claimed by the flat decl `play_rocket_explosion_default`, which is + * itself a listed row. Searching g_ev alone leaves that pair on screen. + * + * Only PATH-FORM records are ever hidden, and the name looked up (`play_` + leaf) never contains a + * '/', so a flat row can never be hidden by this pass and two records can never hide each other. + * The decl array is built from the records still VISIBLE at this point, so a campaign-box twin that + * the box dedup already hid cannot suppress the row that survived it. + * + * Deliberately does NOT touch flat-named records (no '/'), which the exact-name pass already + * settled. Returns how many rows it hid. */ +static int imgpreview_hide_wrapped_sounds(void) +{ + /* The sound rows the browser would list right now, sorted for a binary search. */ + const char **snd = (const char **)malloc((size_t)g_recCount * sizeof *snd); + int sn = 0; + if (snd) { + for (int i = 0; i < g_recCount; ++i) + if (g_rec[i].kind == SH_ASSET_SOUND && !g_rec[i].hidden) snd[sn++] = g_rec[i].name; + qsort(snd, (size_t)sn, sizeof *snd, cmp_ci); + } + + int hid = 0; + for (int i = 0; i < g_recCount; ++i) { + if (g_rec[i].kind != SH_ASSET_SOUND || g_rec[i].hidden) continue; + const char *leaf = strrchr(g_rec[i].name, '/'); + if (!leaf) continue; /* flat name -- already exact-deduped */ + char want[320]; + _snprintf_s(want, sizeof want, _TRUNCATE, "Play_%s", leaf + 1); + const char *key = want; /* both arrays are sorted case-insensitively */ + int twin = 0; + if (g_ev && g_evCount > 0 && bsearch(&key, g_ev, (size_t)g_evCount, sizeof *g_ev, cmp_ci)) + twin = 1; /* twin is a Wwise event */ + else if (snd && sn > 0 && bsearch(&key, snd, (size_t)sn, sizeof *snd, cmp_ci)) + twin = 1; /* twin is a flat `play_*` decl */ + if (twin) { g_rec[i].hidden = 1; hid++; } + } + free(snd); + return hid; +} + +/* Fold the `.vmtr` atlas rows that have NO material decl into the material catalog. Same shape as + * imgpreview_load_wwise: sort the decl names once, then binary-search each atlas row against them. + * Names point into megapreview's parsed table, which lives for the process, so nothing is copied. + * Failure is non-fatal -- a missing atlas just leaves the catalog decl-only, as it was before. */ +static void imgpreview_load_vmtr(void) +{ + const char **decl = (const char **)malloc((size_t)g_recCount * sizeof *decl); + int dn = 0; + if (decl) { + for (int i = 0; i < g_recCount; ++i) + if (g_rec[i].kind == SH_ASSET_MATERIAL && !g_rec[i].hidden) decl[dn++] = g_rec[i].name; + qsort(decl, (size_t)dn, sizeof *decl, cmp_ci); + } + + int cap = 1024, dup = 0; + g_vt = (const char **)malloc((size_t)cap * sizeof *g_vt); + if (!g_vt) { free(decl); return; } + + for (int i = 0; ; ++i) { + const char *nm = sh_megapreview_name_at(i); + if (!nm) break; + if (decl && bsearch(&nm, decl, (size_t)dn, sizeof *decl, cmp_ci)) { dup++; continue; } + if (g_vtCount == cap) { + const char **bigger = (const char **)realloc(g_vt, (size_t)(cap * 2) * sizeof *bigger); + if (!bigger) break; + g_vt = bigger; cap *= 2; + } + g_vt[g_vtCount++] = nm; + } + free(decl); + + /* The atlas lists a name once per shard it appears in, so collapse adjacent equals after + * sorting -- same reason the Wwise manifest needs it. */ + int raw = g_vtCount; + if (g_vtCount > 1) { + qsort(g_vt, (size_t)g_vtCount, sizeof *g_vt, cmp_ci); + int w = 1; + for (int i = 1; i < g_vtCount; ++i) + if (_stricmp(g_vt[i], g_vt[w - 1]) != 0) g_vt[w++] = g_vt[i]; + g_vtCount = w; + } + + char line[240]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: imgpreview -- .vmtr atlas: %d distinct decl-less material(s) added to the material " + "catalog (%d shard-repeats collapsed, %d already had a decl)", + g_vtCount, raw - g_vtCount, dup); + backend_log(line); +} + +static int imgpreview_load(void) +{ + if (g_loaded) return g_loaded > 0; + g_loaded = -1; + char exe[MAX_PATH] = {0}; + if (!GetModuleFileNameA(NULL, exe, MAX_PATH)) return 0; + char *slash = strrchr(exe, '\\'); if (!slash) return 0; + *slash = '\0'; + _snprintf_s(g_baseDir, sizeof g_baseDir, _TRUNCATE, "%s\\base", exe); + + int a = imgpreview_load_box(0, "snap_gameresources"); + int b = imgpreview_load_box(1, "gameresources"); + + /* Promote the 232 SnapMap MODULES out of the brush-model pile. They are the only .bmodel + * records that pair 1:1 with a collision model, so they are the only ones that can be placed + * as something both visible AND solid -- a different proposition from the wall/floor pieces + * they are assembled from, and worth its own category rather than being lost among 9,729 + * fragments and invisible internals. */ + int modules = 0; + for (int i = 0; i < g_recCount; ++i) + if (g_rec[i].kind == SH_ASSET_BMODEL && strstr(g_rec[i].name, "/palettes/mega_blessed/")) { + g_rec[i].kind = SH_ASSET_MODULE; + modules++; + } + + /* Lights is material DECLS only, and that is not an arbitrary choice -- it is what makes the + * list agree with a known-good one. Including `lightatlas` rows too gave 117 names; filtering to + * decls gives 89, which is exactly the 88 the whitelist is known to accept plus + * `lights/defaultprojectedlight`, sibling of defaultpointlight and defaultparallellight, both + * already known good. + * + * The 28 dropped rows are atlas entries with NO material decl -- the light IMAGES, several with + * a `.tga` on the end and five not even under lights/ (textures/common/white.tga and friends). + * `lightMaterial` names a material, so an image with no decl has nothing to resolve, the same + * reason a decl-less atlas row cannot take customMaterial. + * + * COPY, do not move. Promoting these out of Materials the way palette modules are promoted was + * wrong: a move is only right when the source list should not contain the rows at all, and a + * `lights/` material is still a material. */ + int lights = 0; + for (int i = 0, n0 = g_recCount; i < n0; ++i) { + if (g_rec[i].kind != SH_ASSET_MATERIAL) continue; + /* BOTH prefixes. `lights_blended/` is a real second family -- 11 of them, and 11 of the 88 + * names on the known-good list live there -- and matching only `lights/` silently dropped + * every one. It is not a subfolder of `lights/`; the underscore makes it a sibling. */ + if (_strnicmp(g_rec[i].name, "lights/", 7) != 0 && + _strnicmp(g_rec[i].name, "lights_blended/", 15) != 0) + continue; + if ((g_recCount & 1023) == 0) { + rec_t *bigger = (rec_t *)realloc(g_rec, (size_t)(g_recCount + 1024) * sizeof *bigger); + if (!bigger) break; + g_rec = bigger; + } + g_rec[g_recCount] = g_rec[i]; + g_rec[g_recCount].kind = SH_ASSET_LIGHT; + g_recCount++; + lights++; + } + + /* Collapse records that repeat a name WITHIN one box, before anything else looks at the list. + * + * The index is a record-per-blob table, not a catalog of distinct assets: the same decl can be + * baked into the .resources file more than once, at different offsets. `decalatlas` is where it + * shows -- 1,673 records for 1,024 distinct names -- and the browser was faithfully listing all + * of them, so a mapper saw every decal twice. Clicking one selected both rows and starring one + * starred both, because the two rows ARE the same name and the UI keys off the name. + * + * Measured across snap_gameresources: decalatlas 649 repeats, image 1, and exactly zero for + * material, model, md6Def, sound, fx, particle, entityDef, snapEditorEntityDef and cm. So this + * is general on purpose but only ever fires where the data actually repeats. + * + * The FIRST record wins, which is also what find_rec would have resolved to, so nothing that + * already previewed changes which blob it reads. */ + int boxdup = 0; + { + int *ord = (int *)malloc((size_t)g_recCount * sizeof *ord); + if (ord) { + for (int i = 0; i < g_recCount; ++i) ord[i] = i; + g_sortRec = g_rec; + qsort(ord, (size_t)g_recCount, sizeof *ord, cmp_rec_kind_name); + for (int i = 1; i < g_recCount; ++i) { + const rec_t *a = &g_rec[ord[i - 1]], *b = &g_rec[ord[i]]; + if (a->kind != b->kind || a->box != b->box) continue; + if (_stricmp(a->name, b->name) != 0) continue; + /* Sorted by (kind, box, name, ORIGINAL INDEX), so ord[i] is always the later + * record of the pair and the first one indexed is the one left standing. */ + if (!g_rec[ord[i]].hidden) { g_rec[ord[i]].hidden = 1; boxdup++; } + } + free(ord); + } + } + + imgpreview_load_wwise(); + + /* Decide, once, which records the browser will LIST. + * + * Box 0 (`snap_gameresources`) is everything SnapMap ships with. Box 1 (`gameresources`) is + * the campaign's set; most of it is unreferencable from SnapMap, which is why it is not + * offered wholesale. SOUNDS are the exception worth making: 1,186 sound decls exist only in + * the campaign box and 231 of those are `vo_*`, which is a category a mapper visibly misses. + * Whether the engine will actually SOUND one from a SnapMap session is campaign question Q11 + * and is not answerable from the files -- but the browser now has a working play/stop, so + * offering them turns an unanswerable question into a one-click test. + * + * Duplicates are dropped rather than shown twice: 2,401 sound names appear in both boxes. The + * box-0 record wins, so nothing that already worked changes route. */ + int dup = 0, extra = 0; + for (int i = 0; i < g_recCount; ++i) { + if (g_rec[i].box == 0) continue; + if (g_rec[i].kind != SH_ASSET_SOUND) { g_rec[i].hidden = 1; continue; } + for (int j = 0; j < g_recCount; ++j) { + if (g_rec[j].box != 0 || g_rec[j].kind != SH_ASSET_SOUND) continue; + if (_stricmp(g_rec[j].name, g_rec[i].name) == 0) { g_rec[i].hidden = 1; dup++; break; } + } + if (!g_rec[i].hidden) extra++; + } + + /* AFTER the box dedup, so a campaign sound already hidden as a box-0 duplicate is not counted + * twice, and the tally reports only rows this pass is actually responsible for removing. */ + int wrapped = imgpreview_hide_wrapped_sounds(); + + /* AFTER the hide pass: it dedupes against the decls the browser will actually list, so a + * campaign-box material that got hidden above must not suppress its atlas twin. */ + imgpreview_load_vmtr(); + + char line[480]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: imgpreview -- indexed %d records (snap=%s game=%s); %d SnapMap modules; " + "%d light material(s) also listed under Lights; " + "%d record(s) collapsed as a repeat of a name in the same box; " + "campaign sounds offered: %d (%d duplicates of SnapMap sounds dropped); " + "%d wrapper sound decl(s) hidden behind their Play_ event", + g_recCount, a ? "ok" : "MISSING", b ? "ok" : "missing", modules, lights, boxdup, extra, dup, wrapped); + backend_log(line); + g_loaded = (g_recCount > 0) ? 1 : -1; + return g_loaded > 0; +} + +static const rec_t *find_rec(const char *name, int kind) +{ + for (int i = 0; i < g_recCount; ++i) + if (g_rec[i].kind == kind && _stricmp(g_rec[i].name, name) == 0) return &g_rec[i]; + return NULL; +} + +/* Is `name` a real decl of this type in the shipped containers? Exposed for callers that are about + * to hand a name to an ENGINE lookup and need to know it exists first -- the engine's find-or-create + * primitive fatals on a miss, so "does this name exist" has to be answered from our own data, never + * by trying it. Takes the same lock as the list path; the catalog is parsed lazily on first use. */ +int sh_imgpreview_has(int kind, const char *name) +{ + if (!name || !name[0]) return 0; + EnterCriticalSection(&g_lock); + int ok = 0; + if (imgpreview_load()) { + ok = find_rec(name, kind) != NULL; + /* A Wwise event with no decl is still a real, playable name -- 2,591 of them, including + * the generic SnapMap VO. The engine resolves it through find-or-CREATE, which builds the + * decl on demand; that path is safe by default (its only fatal is gated on + * `resource_errorInGame == 2`, and the cvar ships at 0 = "Nothing"). */ + if (!ok && kind == SH_ASSET_SOUND) { + for (int e = 0; e < g_evCount && !ok; ++e) + if (_stricmp(g_ev[e], name) == 0) ok = 1; + } + } + LeaveCriticalSection(&g_lock); + return ok; +} + +/* Images carry sampler-variant suffixes (`$nearest`, `$bc7`, `$borderclamp$alpha`), so an exact + * miss falls back to the first record whose base name matches. */ +static const rec_t *find_image(const char *name) +{ + const rec_t *r = find_rec(name, 1); + if (r) return r; + size_t n = strlen(name); + for (int i = 0; i < g_recCount; ++i) { + if (g_rec[i].kind != 1) continue; + const char *nm = g_rec[i].name; + if (_strnicmp(nm, name, n) == 0 && nm[n] == '$') return &g_rec[i]; + } + return NULL; +} + +/* Read a record's payload, inflating if needed. Caller frees. */ +static unsigned char *read_payload(const rec_t *r, size_t *out_len) +{ + HANDLE h = g_box[r->box].res; + if (!h || !r->usz) return NULL; + LARGE_INTEGER li; li.QuadPart = (LONGLONG)r->roff; + if (!SetFilePointerEx(h, li, NULL, FILE_BEGIN)) return NULL; + unsigned char *raw = (unsigned char *)malloc(r->csz); + DWORD got = 0; + if (!raw || !ReadFile(h, raw, r->csz, &got, NULL) || got != r->csz) { free(raw); return NULL; } + if (r->csz == r->usz) { *out_len = r->usz; return raw; } + unsigned char *out = (unsigned char *)malloc(r->usz); + if (!out) { free(raw); return NULL; } + size_t n = inflate_raw(raw, r->csz, out, r->usz); + free(raw); + *out_len = n; + return out; +} + +/* --------------------------------------------------------------------- decl parse ------------- + * Decls are plain text. The field naming its image varies by `stageprogram` -- `transmap` is by + * far the most common, then `texturemap` -- so the rule is "any field whose name ends in `map`", + * skipping engine built-ins (`_black`, `_vmtrpagetable`, ...) which all start with '_'. + * Albedo-ish names are preferred so a normal or specular map never wins over the base colour. */ +static const char *PREF[] = { "texturemap","transmap","transsortmap","transatlasmap", + "virtualtransmap","basecolormap","albedomap","diffusemap", + "colormap","sparediffusemap", NULL }; + +static int decl_find_image(const char *txt, size_t len, char *out, size_t cap) +{ + char best[256] = {0}; int bestRank = 9999; + size_t i = 0; + while (i < len) { + while (i < len && (txt[i]=='\n' || txt[i]=='\r' || txt[i]==' ' || txt[i]=='\t')) ++i; + size_t ks = i; + while (i < len && (txt[i]>='a'&&txt[i]<='z')) ++i; + size_t ke = i; + if (ke > ks && ke - ks >= 4 && memcmp(txt + ke - 3, "map", 3) == 0) { + while (i < len && (txt[i]==' ' || txt[i]=='\t')) ++i; + size_t vs = i; + if (i < len && txt[i]=='"') { ++vs; ++i; while (i < len && txt[i] != '"') ++i; } + else while (i < len && txt[i] != '\n' && txt[i] != '\r' && txt[i] != ' ' && txt[i] != '\t') ++i; + size_t vlen = i - vs; + if (vlen && vlen < 250 && txt[vs] != '_' && txt[vs] != '{') { + char key[64]; size_t klen = ke - ks; if (klen > 63) klen = 63; + memcpy(key, txt + ks, klen); key[klen] = 0; + int rank = 500; + for (int p = 0; PREF[p]; ++p) if (strcmp(key, PREF[p]) == 0) { rank = p; break; } + if (rank < bestRank) { + bestRank = rank; + size_t c = vlen < sizeof best - 1 ? vlen : sizeof best - 1; + memcpy(best, txt + vs, c); best[c] = 0; + } + } + } + while (i < len && txt[i] != '\n') ++i; + } + if (!best[0]) return 0; + strncpy_s(out, cap, best, _TRUNCATE); + return 1; +} + +/* --------------------------------------------------------------------- box downscale ----------*/ +static void downscale(const unsigned char *src, unsigned sw, unsigned sh, unsigned pitch, + unsigned char *dst, unsigned dw, unsigned dh) +{ + for (unsigned y = 0; y < dh; ++y) { + unsigned y0 = y * sh / dh, y1 = (y + 1) * sh / dh; if (y1 <= y0) y1 = y0 + 1; + for (unsigned x = 0; x < dw; ++x) { + unsigned x0 = x * sw / dw, x1 = (x + 1) * sw / dw; if (x1 <= x0) x1 = x0 + 1; + unsigned acc[4] = {0,0,0,0}, n = 0; + for (unsigned yy = y0; yy < y1; ++yy) + for (unsigned xx = x0; xx < x1; ++xx) { + const unsigned char *s = src + ((size_t)yy * pitch + xx) * 4; + acc[0]+=s[0]; acc[1]+=s[1]; acc[2]+=s[2]; acc[3]+=s[3]; ++n; + } + unsigned char *d = dst + ((size_t)y * dw + x) * 4; + for (int c = 0; c < 4; ++c) d[c] = (unsigned char)(acc[c] / (n ? n : 1)); + } + } +} + +int sh_imgpreview_produce(const char *name) +{ + EnterCriticalSection(&g_lock); + int ok = 0; + unsigned char *decl = NULL, *bim = NULL, *rgba = NULL, *thumb = NULL; + __try { + if (!imgpreview_load()) __leave; + + const rec_t *mr = find_rec(name, 0); + if (!mr) { backend_log("B2: imgpreview -- no material record"); __leave; } + + size_t dlen = 0; decl = read_payload(mr, &dlen); + if (!decl || !dlen) { backend_log("B2: imgpreview -- decl read failed"); __leave; } + + char img[256]; + if (!decl_find_image((const char *)decl, dlen, img, sizeof img)) { + char l[320]; _snprintf_s(l,sizeof l,_TRUNCATE, + "B2: imgpreview -- '%s' decl names no usable image (atlased decal/particle?)", name); + backend_log(l); __leave; + } + const rec_t *ir = find_image(img); + if (!ir) { + char l[400]; _snprintf_s(l,sizeof l,_TRUNCATE, + "B2: imgpreview -- '%s' -> image '%s' not in any container", name, img); + backend_log(l); __leave; + } + + size_t blen = 0; bim = read_payload(ir, &blen); + if (!bim || blen < 0x40 || memcmp(bim + 4, "\x07MIB", 4) != 0) { + backend_log("B2: imgpreview -- not a .bimage"); __leave; + } + unsigned fmt = *(unsigned *)(bim + 0x20); + /* First mip record at +0x30: BE u32 level, BE u16 w @+4, BE u16 h @+8, BE u32 size @+0x0A. */ + unsigned w = ((unsigned)bim[0x34] << 8) | bim[0x35]; + unsigned h = ((unsigned)bim[0x38] << 8) | bim[0x39]; + unsigned sz = be32(bim + 0x3A); + if (!w || !h || w > 8192 || h > 8192 || (size_t)0x3E + sz > blen) { + backend_log("B2: imgpreview -- bad mip record"); __leave; + } + + size_t need = bcn_rgba_size(w, h); + rgba = (unsigned char *)malloc(need); + if (!rgba) __leave; + if (!bcn_decode(fmt, bim + 0x3E, sz, w, h, rgba)) { + char l[220]; _snprintf_s(l,sizeof l,_TRUNCATE, + "B2: imgpreview -- '%s' format code %u not decodable (only 10/11/23)", name, fmt); + backend_log(l); __leave; + } + + unsigned pw = BCN_PAD(w); + unsigned dw = w, dh = h; + if (dw > MAX_PREVIEW || dh > MAX_PREVIEW) { + if (dw >= dh) { dh = dh * MAX_PREVIEW / dw; dw = MAX_PREVIEW; } + else { dw = dw * MAX_PREVIEW / dh; dh = MAX_PREVIEW; } + if (!dw) dw = 1; if (!dh) dh = 1; + } + thumb = (unsigned char *)malloc((size_t)dw * dh * 4); + if (!thumb) __leave; + downscale(rgba, w, h, pw, thumb, dw, dh); + + sh_preview_publish(thumb, dw, dh); + char l[360]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: imgpreview -- '%s' -> image '%s' %ux%u fmt=%u -> %ux%u preview", + name, img, w, h, fmt, dw, dh); + backend_log(l); + ok = 1; + } __finally { + free(decl); free(bim); free(rgba); free(thumb); + LeaveCriticalSection(&g_lock); + } + return ok; +} + +int sh_imgpreview_list(int kind, unsigned start, char *out, size_t cap) +{ + if (!out || cap < 2) return 0; + out[0] = '\0'; + /* Bound by the ASSET id space, not the table length -- the table has more rows than there are + * categories now that MODELS is fed by two decl types. */ + if (kind < 0 || kind >= SH_ASSET_COUNT) return 0; + EnterCriticalSection(&g_lock); + int written = 0; + if (imgpreview_load()) { + size_t used = 0; + unsigned seen = 0; + for (int i = 0; i < g_recCount; ++i) { + /* `hidden` is decided once at load: everything in the campaign box except sounds, and + * campaign sounds that duplicate a SnapMap one. See imgpreview_load. */ + if (g_rec[i].kind != kind || g_rec[i].hidden) continue; + if (seen++ < start) continue; + size_t n = strlen(g_rec[i].name); + if (used + n + 2 > cap) break; /* leave room for \n and NUL */ + memcpy(out + used, g_rec[i].name, n); used += n; + out[used++] = '\n'; + written++; + } + /* Wwise events continue the SAME `seen` sequence, so the caller's paging (add the returned + * count to `start` until it returns 0) crosses the two sources without a special case. */ + if (kind == SH_ASSET_SOUND) { + for (int e = 0; e < g_evCount; ++e) { + if (seen++ < start) continue; + size_t n = strlen(g_ev[e]); + if (used + n + 2 > cap) break; + memcpy(out + used, g_ev[e], n); used += n; + out[used++] = '\n'; + written++; + } + } + /* The sound -> soundbank map, served on its own pseudo-kind as `event|bank` lines. Not + * folded into SH_ASSET_SOUND: that list is names the UI applies verbatim, and appending a + * bank to them would corrupt every one. The UI keeps this as a side map instead. */ + if (kind == SH_ASSET_SNDBANK) { + for (int b = 0; b < g_sbCount; ++b) { + if (seen++ < start) continue; + size_t n = strlen(g_sb[b].name), m = strlen(g_sb[b].bank); + if (used + n + m + 3 > cap) break; /* name + '|' + bank + '\n' + NUL */ + memcpy(out + used, g_sb[b].name, n); used += n; + out[used++] = '|'; + memcpy(out + used, g_sb[b].bank, m); used += m; + out[used++] = '\n'; + written++; + } + } + /* Same continuation for the decl-less `.vmtr` materials -- they are as applyable as any + * decl-backed one (by rectangle rather than by name), so they belong in the same list. + * SH_ASSET_VTONLY serves the SAME array on its own, so the UI can tell the two apart. */ + if (kind == SH_ASSET_MATERIAL || kind == SH_ASSET_VTONLY) { + for (int v = 0; v < g_vtCount; ++v) { + if (seen++ < start) continue; + size_t n = strlen(g_vt[v]); + if (used + n + 2 > cap) break; + memcpy(out + used, g_vt[v], n); used += n; + out[used++] = '\n'; + written++; + } + } + out[used] = '\0'; + } + LeaveCriticalSection(&g_lock); + return written; +} + +int sh_imgpreview_install(void) +{ + InitializeCriticalSection(&g_lock); + backend_log("B2: imgpreview -- installed (containers load lazily on first request)"); + return 1; +} diff --git a/src/backend/imgpreview.h b/src/backend/imgpreview.h new file mode 100644 index 0000000..4db8901 --- /dev/null +++ b/src/backend/imgpreview.h @@ -0,0 +1,41 @@ +/* imgpreview.h -- the SECOND preview producer: plain (non-megatexture) materials. + * + * megapreview.c serves the 5,033 materials that have a `.vmtr` atlas rect. This serves the rest, + * which render fine in game but are backed by ordinary image assets in the `.index`/`.resources` + * containers. Together they cover ~84% of the ~9,805-material catalog; the remainder are decals + * and particles baked into shared atlases, which need a third route. + * + * Read-only against the shipped containers, and no engine call at all: DEFLATE and BCn are public + * formats, unlike the megatexture page codec which had to be called rather than reimplemented. + * See the doom-re campaign `revenant-asset-index-and-viewport`, evidence 09. + */ +#ifndef BACKEND_IMGPREVIEW_H +#define BACKEND_IMGPREVIEW_H + +/* One-time setup. Cheap: the 15 MB indices are parsed lazily on the first request, so a user who + * never opens the Assets tab pays nothing. Always returns 1. */ +int sh_imgpreview_install(void); + +/* Resolve `name` -> material decl -> image asset -> RGBA, and publish through sh_preview_publish. + * Returns 1 if something was published, 0 otherwise (no material record, decl names no usable + * image, image missing, or an image format we do not decode). Call it only AFTER the megatexture + * route has declined, since a VT-backed material has no image asset to find. */ +int sh_imgpreview_produce(const char *name); + +/* The SH_ASSET_* type ids live in the shared ABI header -- the UI sends one across the iface. */ +#include "../common/snapmap_plus_iface.h" + +/* Enumerate SnapMap asset names of one type for the Assets browser, newline-separated, starting at + * index `start`. Returns how many names were written; 0 means "no more" (or the containers are + * unreadable, or `kind` is out of range). The caller pages by adding the returned count to `start` + * until it gets 0 -- materials alone are ~9,805 names and roughly 400 KB, too much for one + * message. Only box 0 (`snap_gameresources`) is listed; see the note at the definition. */ +int sh_imgpreview_list(int kind, unsigned start, char *out, size_t cap); + +/* Does a decl of this SH_ASSET_* type with this exact name exist in the shipped containers? The + * answer comes from our own index, never from the engine: the engine's by-name decl find is a + * find-OR-CREATE that fatals on a bad name, so it can only be called with a name already known to + * be good. sh_soundpreview_play is the caller that needs this. Returns 1 if present. */ +int sh_imgpreview_has(int kind, const char *name); + +#endif /* BACKEND_IMGPREVIEW_H */ diff --git a/src/backend/megapreview.c b/src/backend/megapreview.c new file mode 100644 index 0000000..b73b0c9 --- /dev/null +++ b/src/backend/megapreview.c @@ -0,0 +1,502 @@ +/* megapreview.c -- see megapreview.h. Reads DOOM's megatexture atlas off disk and decodes a named + * material's pages with the engine's own decoder, then hands the pixels to the preview transport. + * + * Nothing here hooks or mutates the engine. It reads three kinds of file the game ships + * (`*.vmtr` tables and `_vmtr_sq*.mega2` shards, both under \virtualtextures) and makes one + * call into a pure decode function. Every constant is cited to the doom-re campaign evidence that + * established it. */ + +#include +#include +#include +#include + +#include "megapreview.h" +#include "imgpreview.h" /* the fallback producer for materials with no atlas rect */ +#include "preview.h" +#include "backend_log.h" + +/* ------------------------------------------------------------------------ the decoder ----------- + * FUN_14196E140, RVA 0x196E140 in the pinned build. Signature: + * + * void decode(const u8 *header16, const u8 *payload, void *unused, u8 *out); out is 0x50000 + * + * 5 planes of 128x128 RGBA at +0, +0x10000, +0x20000, +0x30000, +0x40000. Plane 0 is albedo, which + * is the only one a browser needs (campaign evidence 04 SS9, confirmed by observation in 08 SS2). + * + * Resolved through the shared signature database as `Mega2PageDecode`, NOT as a hardcoded + * module_base + RVA. This used to be the latter, guarded by a local memcmp of the prologue -- which + * caught a moved function but could not FIND one, so any build that shifted the address lost previews + * entirely. A signature matches the bytes wherever the loader put them, and a resolve that is not + * unique is rejected rather than guessed, so an unrecognised build still degrades to "no previews" + * rather than a call into the wrong code. */ +typedef void (*decode_fn)(const unsigned char *hdr, const unsigned char *payload, + void *unused, unsigned char *out); + +#define OUT_SIZE 0x50000u /* 5 planes x 128 x 128 x 4 */ +#define PLANE_STRIDE 0x10000u +#define PAGE_FULL 128u /* decoded page edge */ +#define PAGE_CORE 120u /* usable pixels; the rest is a 4px border per side (evidence 07) */ +#define PAGE_BORDER 4u + +/* THE TRAP (evidence 08 SS3). The decoder reads a LONG way past the end of the page data -- measured + * mean 73,172 B and max 167,220 B over 120 sampled pages. In the engine the page sits inside a much + * larger staging allocation so this is invisible; a tight buffer faults inside the plane codec. + * 256 KB is ~1.6x the measured worst case. The tail is zeroed so the read-ahead is deterministic + * rather than whatever the heap happened to hold. */ +#define PAGE_SLACK 0x40000u +#define PAGE_MAX 0x40000u /* largest observed payload is ~50 KB; this is a sanity ceiling */ + +/* ------------------------------------------------------------------------ the atlas -------------- + * All from campaign evidence 04 SS5 and 07. The atlas is 245760 x 245760 px = 2048 x 2048 pages of + * 120 px, split into a 4x4 grid of shards each covering 512 x 512 pages. */ +#define ATLAS_PAGE_PX 120u +#define SHARD_PAGES 512u +#define MAX_LEVELS 10 /* 512x512, 256x256, ... 1x1 */ + +/* Index cell layout: the mip chain concatenated, each level row-major, level 0 first. */ +static unsigned g_levelAxis[MAX_LEVELS]; +static unsigned g_levelBase[MAX_LEVELS]; + +typedef struct { + char name[192]; + unsigned x, y, w, h; +} vmtr_rect; + +typedef struct { + FILE *f; + unsigned *index; /* idxCount x u32: cell -> page id, 0xFFFFFFFF = absent */ + unsigned idxCount; + unsigned long long *table; /* pageCount x { u64 offset, u64 size } */ + unsigned pageCount; + int tried; /* so a missing/corrupt shard is only reported once */ +} shard_t; + +static const uint8_t *g_base; +static decode_fn g_decode; +static vmtr_rect *g_rects; +static int g_rectCount; +static shard_t g_shard[17]; /* 1-based, shards 1..16 */ +static unsigned char *g_out; /* OUT_SIZE decode target */ +static unsigned char *g_page; /* PAGE_MAX + PAGE_SLACK, zero-tailed */ +static unsigned char *g_levelTmp[MAX_LEVELS]; /* per-level 120x120 RGBA scratch for mip fallback */ +static CRITICAL_SECTION g_lock; /* serializes the shared scratch above */ +static LONG g_installed; + +static char g_vtDir[MAX_PATH]; + +/* ---------------------------------------------------------------------- file plumbing -----------*/ + +static int megapreview_vt_dir(void) +{ + char exe[MAX_PATH] = { 0 }; + if (!GetModuleFileNameA(NULL, exe, MAX_PATH)) return 0; + char *slash = strrchr(exe, '\\'); + if (!slash) return 0; + *slash = '\0'; + _snprintf_s(g_vtDir, sizeof g_vtDir, _TRUNCATE, "%s\\virtualtextures", exe); + DWORD a = GetFileAttributesA(g_vtDir); + return (a != INVALID_FILE_ATTRIBUTES) && (a & FILE_ATTRIBUTE_DIRECTORY); +} + +/* `.vmtr` rows are CRLF text: line 1 version, line 2 count, line 3 a column comment, then + * x y width height flags timeStamp mtrCheck "name" + * with mtrCheck frequently negative. Anything that does not match that shape is skipped, which + * covers the header lines without needing to count them. */ +static void megapreview_load_one_vmtr(const char *path, int *cap) +{ + FILE *f = fopen(path, "r"); + if (!f) return; + char line[1024]; + while (fgets(line, sizeof line, f)) { + int x, y, w, h, flags; long long ts, chk; + char name[192]; + if (sscanf(line, " %d %d %d %d %d %lld %lld \"%191[^\"]\"", + &x, &y, &w, &h, &flags, &ts, &chk, name) != 8) continue; + if (w <= 0 || h <= 0 || x < 0 || y < 0) continue; + if (g_rectCount == *cap) { + int grown = *cap ? *cap * 2 : 1024; + vmtr_rect *bigger = (vmtr_rect *)realloc(g_rects, (size_t)grown * sizeof *bigger); + if (!bigger) break; + g_rects = bigger; *cap = grown; + } + vmtr_rect *r = &g_rects[g_rectCount++]; + strncpy_s(r->name, sizeof r->name, name, _TRUNCATE); + r->x = (unsigned)x; r->y = (unsigned)y; r->w = (unsigned)w; r->h = (unsigned)h; + } + fclose(f); +} + +static int megapreview_load_rects(void) +{ + if (g_rectCount) return 1; + char pattern[MAX_PATH]; + _snprintf_s(pattern, sizeof pattern, _TRUNCATE, "%s\\*.vmtr", g_vtDir); + + WIN32_FIND_DATAA fd; + HANDLE h = FindFirstFileA(pattern, &fd); + if (h == INVALID_HANDLE_VALUE) { + backend_log("B2: megapreview -- no .vmtr tables found; previews unavailable"); + return 0; + } + int cap = 0; + do { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + char path[MAX_PATH]; + _snprintf_s(path, sizeof path, _TRUNCATE, "%s\\%s", g_vtDir, fd.cFileName); + megapreview_load_one_vmtr(path, &cap); + } while (FindNextFileA(h, &fd)); + FindClose(h); + + char line[160]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: megapreview -- loaded %d atlas rects from %s", g_rectCount, g_vtDir); + backend_log(line); + return g_rectCount > 0; +} + +static const vmtr_rect *megapreview_find(const char *name) +{ + for (int i = 0; i < g_rectCount; ++i) + if (_stricmp(g_rects[i].name, name) == 0) return &g_rects[i]; + return NULL; +} + +/* A material's atlas rect in atlas pixels, or 0 if it has none. Two callers want this: the Assets + * browser, to compute a `virtualmapping` renderParm value ((w,h,x,y)/245760), and the same browser + * to know whether that carrier applies at all -- only VT-backed materials have a rect, so a null + * answer is the honest reason to refuse the Virtual Mapping option rather than write a broken one. + * Read-only against the already-parsed .vmtr table; takes the same lock the producer does. */ +int sh_megapreview_rect(const char *name, int *out_xywh) +{ + if (!name || !out_xywh) return 0; + int got = 0; + EnterCriticalSection(&g_lock); + if (megapreview_load_rects()) { + const vmtr_rect *r = megapreview_find(name); + if (r) { + out_xywh[0] = (int)r->x; out_xywh[1] = (int)r->y; + out_xywh[2] = (int)r->w; out_xywh[3] = (int)r->h; + got = 1; + } + } + LeaveCriticalSection(&g_lock); + return got; +} + +/* Row `i` of the parsed atlas, or NULL past the end. Loads the tables on first use like the rect + * lookup does, so the caller need not care who touched it first. Takes the same lock. */ +const char *sh_megapreview_name_at(int i) +{ + if (i < 0) return NULL; + const char *out = NULL; + EnterCriticalSection(&g_lock); + if (megapreview_load_rects() && i < g_rectCount) out = g_rects[i].name; + LeaveCriticalSection(&g_lock); + return out; +} + +/* Open a shard and read its two tables. Layout, all DIRECT from idMegaTexture2::Load + * (FUN_140e10bf0) and verified against every shipped shard -- campaign evidence 07 SS1: + * + * 0x000 0x170-byte header (magic 0xA63FBB21, version 2) + * 0x170 page payloads, contiguous + * [hdr +0x40] PAGE INDEX idxCount(+0x4C) x u32 + * [hdr +0x38] PAGE TABLE pageCount(+0x48) x { u64 offset, u64 size } */ +static shard_t *megapreview_shard(int n) +{ + if (n < 1 || n > 16) return NULL; + shard_t *s = &g_shard[n]; + if (s->index) return s; + if (s->tried) return NULL; + s->tried = 1; + + char path[MAX_PATH]; + _snprintf_s(path, sizeof path, _TRUNCATE, "%s\\_vmtr_sq%d.mega2", g_vtDir, n); + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + + unsigned char hdr[0x170]; + if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) { fclose(f); return NULL; } + unsigned magic = *(unsigned *)(hdr + 0x00), ver = *(unsigned *)(hdr + 0x04); + if (magic != 0xA63FBB21u || ver != 2u) { + char line[200]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: megapreview -- _vmtr_sq%d.mega2 bad magic/version (%08X/%u)", n, magic, ver); + backend_log(line); + fclose(f); return NULL; + } + unsigned long long tableOff = *(unsigned long long *)(hdr + 0x38); + unsigned long long idxOff = *(unsigned long long *)(hdr + 0x40); + unsigned pageCount = *(unsigned *)(hdr + 0x48); + unsigned idxCount = *(unsigned *)(hdr + 0x4C); + if (!pageCount || !idxCount || idxCount > (1u << 24) || pageCount > (1u << 24)) { + fclose(f); return NULL; + } + + unsigned *index = (unsigned *)malloc((size_t)idxCount * 4); + unsigned long long *table = (unsigned long long *)malloc((size_t)pageCount * 16); + if (!index || !table) { free(index); free(table); fclose(f); return NULL; } + + int ok = (_fseeki64(f, (long long)idxOff, SEEK_SET) == 0) && + (fread(index, 4, idxCount, f) == idxCount) && + (_fseeki64(f, (long long)tableOff, SEEK_SET) == 0) && + (fread(table, 16, pageCount, f) == pageCount); + if (!ok) { free(index); free(table); fclose(f); return NULL; } + + s->f = f; s->index = index; s->idxCount = idxCount; + s->table = table; s->pageCount = pageCount; + return s; +} + +/* -------------------------------------------------------------------------- decoding ------------*/ + +/* Decode one page into g_out. Returns 1 on success, 0 if the cell has no page (absent) or the read + * or decode failed. `level`/`px`/`py` are in that level's page grid. */ +static int megapreview_decode_page(int level, unsigned px, unsigned py) +{ + unsigned axis = g_levelAxis[level]; + if (px >= axis * 4u || py >= axis * 4u) return 0; + + int n = 1 + (int)(px / axis) + 4 * (int)(py / axis); + shard_t *s = megapreview_shard(n); + if (!s) return 0; + + unsigned cell = g_levelBase[level] + (py % axis) * axis + (px % axis); + if (cell >= s->idxCount) return 0; + unsigned pid = s->index[cell]; + if (pid == 0xFFFFFFFFu || pid >= s->pageCount) return 0; + + unsigned long long off = s->table[(size_t)pid * 2]; + unsigned long long size = s->table[(size_t)pid * 2 + 1]; + if (size < 17 || size > PAGE_MAX) return 0; + + if (_fseeki64(s->f, (long long)off, SEEK_SET) != 0) return 0; + if (fread(g_page, 1, (size_t)size, s->f) != (size_t)size) return 0; + memset(g_page + size, 0, PAGE_SLACK); /* the read-ahead tail; see PAGE_SLACK */ + + /* Pre-clear: skipped planes are NOT written by the decoder (evidence 08 SS2), so a stale + * buffer would show the previous material's pixels in any plane this page omits. */ + memset(g_out, 0, OUT_SIZE); + + int ok = 1; + __try { + g_decode(g_page, g_page + 16, NULL, g_out); + } __except (EXCEPTION_EXECUTE_HANDLER) { + ok = 0; + backend_log("B2: megapreview -- FAULTED inside the page decoder"); + } + return ok; +} + +/* One page's 120x120 albedo core, walking UP the mip chain when a page is absent. + * + * An absent page at a fine level is NORMAL virtual texturing: it means there is no extra detail + * there, and the renderer samples the parent. So we upscale the parent's corresponding quadrant + * rather than leaving a hole. Coarse levels are dense, so this always terminates. */ +static int megapreview_page_core(int level, unsigned px, unsigned py, unsigned char *dst) +{ + if (level >= MAX_LEVELS) return 0; + + if (megapreview_decode_page(level, px, py)) { + for (unsigned row = 0; row < PAGE_CORE; ++row) { + const unsigned char *src = + g_out + ((size_t)(row + PAGE_BORDER) * PAGE_FULL + PAGE_BORDER) * 4; + memcpy(dst + (size_t)row * PAGE_CORE * 4, src, PAGE_CORE * 4); + } + return 1; + } + + unsigned char *parent = g_levelTmp[level]; + if (!parent) return 0; + if (!megapreview_page_core(level + 1, px / 2, py / 2, parent)) return 0; + + /* Point-sample the parent's quadrant. 60x60 -> 120x120, so every source pixel becomes a 2x2 + * block; this is a fallback for detail that does not exist, not a resampling problem. */ + unsigned qx = (px & 1u) * (PAGE_CORE / 2), qy = (py & 1u) * (PAGE_CORE / 2); + for (unsigned row = 0; row < PAGE_CORE; ++row) { + const unsigned char *src = parent + ((size_t)(qy + row / 2) * PAGE_CORE + qx) * 4; + unsigned char *out = dst + (size_t)row * PAGE_CORE * 4; + for (unsigned col = 0; col < PAGE_CORE; ++col) + memcpy(out + (size_t)col * 4, src + (size_t)(col / 2) * 4, 4); + } + return 1; +} + +/* --------------------------------------------------------------------------- produce ------------*/ + +/* Preview budget. A preview of P pixels costs (P/120)^2 pages regardless of the material's native + * size, so this is a straight quality/cost dial (campaign evidence 08 SS5b). 2x2 = 240x240 for + * ~4 pages and ~85 KB is the measured sweet spot: a large visible gain over a single page, with + * diminishing returns past it. */ +#define PREVIEW_MAX_PAGES_PER_AXIS 2u + +static int megapreview_produce(const char *name) +{ + const vmtr_rect *r = megapreview_find(name); + if (!r) { + char line[300]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: megapreview -- '%s' has no atlas rect (not a virtual-textured material)", name); + backend_log(line); + return 0; + } + + /* Finest level whose page span fits the budget. Coarser levels always span fewer pages, so the + * first match walking up from 0 is the most detailed one that fits. */ + int level = -1; unsigned px0 = 0, py0 = 0, nx = 0, ny = 0, ps = 0; + for (int L = 0; L < MAX_LEVELS; ++L) { + unsigned size = ATLAS_PAGE_PX << L; + unsigned a = r->x / size, b = r->y / size; + unsigned c = (r->x + r->w - 1) / size, d = (r->y + r->h - 1) / size; + if (c - a + 1 <= PREVIEW_MAX_PAGES_PER_AXIS && d - b + 1 <= PREVIEW_MAX_PAGES_PER_AXIS) { + level = L; px0 = a; py0 = b; nx = c - a + 1; ny = d - b + 1; ps = size; + break; + } + } + if (level < 0) return 0; + + unsigned cw = nx * PAGE_CORE, ch = ny * PAGE_CORE; + unsigned char *canvas = (unsigned char *)malloc((size_t)cw * ch * 4); + if (!canvas) return 0; + memset(canvas, 0, (size_t)cw * ch * 4); + + unsigned char *tile = (unsigned char *)malloc(PAGE_CORE * PAGE_CORE * 4); + if (!tile) { free(canvas); return 0; } + + int got = 0; + for (unsigned j = 0; j < ny; ++j) { + for (unsigned i = 0; i < nx; ++i) { + if (!megapreview_page_core(level, px0 + i, py0 + j, tile)) continue; + /* Plane 0's 4th byte is NOT a coverage alpha. The albedo plane carries no per-pixel + * transparency, so whatever the codec leaves there is not one to honour. The old BMP + * transport discarded the byte and so never showed the difference; PNG honours it, + * which drew every opaque wall translucent over the checkerboard. Stamp opaque for + * the pixels we actually decoded -- and only those, so pages that failed keep the + * pre-cleared alpha=0 and still read as a hole rather than as black content. */ + for (unsigned p = 3; p < PAGE_CORE * PAGE_CORE * 4; p += 4) tile[p] = 0xFF; + for (unsigned row = 0; row < PAGE_CORE; ++row) + memcpy(canvas + (((size_t)(j * PAGE_CORE + row) * cw) + i * PAGE_CORE) * 4, + tile + (size_t)row * PAGE_CORE * 4, PAGE_CORE * 4); + got++; + } + } + free(tile); + if (!got) { free(canvas); backend_log("B2: megapreview -- no pages decoded"); return 0; } + + /* The pages cover at least the material's rect and often more, because the rect need not be + * page-aligned at the chosen level. Crop to the material itself so the preview never shows a + * neighbour's pixels. Offsets are exact: the atlas is a plain grid. */ + unsigned sx = (r->x - px0 * ps) * PAGE_CORE / ps; + unsigned sy = (r->y - py0 * ps) * PAGE_CORE / ps; + unsigned sw = r->w * PAGE_CORE / ps, sh = r->h * PAGE_CORE / ps; + if (sw == 0) sw = 1; + if (sh == 0) sh = 1; + if (sx + sw > cw) sw = cw - sx; + if (sy + sh > ch) sh = ch - sy; + + unsigned char *crop = canvas; + if (sx || sy || sw != cw || sh != ch) { + crop = (unsigned char *)malloc((size_t)sw * sh * 4); + if (!crop) { free(canvas); return 0; } + for (unsigned row = 0; row < sh; ++row) + memcpy(crop + (size_t)row * sw * 4, + canvas + (((size_t)(sy + row) * cw) + sx) * 4, (size_t)sw * 4); + } + + sh_preview_publish(crop, sw, sh); + + char line[320]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: megapreview -- '%s' %ux%u px: mip L%d, %u page(s), %d decoded -> %ux%u preview", + name, r->w, r->h, level, nx * ny, got, sw, sh); + backend_log(line); + + if (crop != canvas) free(crop); + free(canvas); + return 1; +} + +/* ---------------------------------------------------------------------------- worker ------------*/ + +/* One serving thread, so decoding never runs on the UI or render thread and two requests can never + * share the scratch buffers. Polling rather than an event because the transport is deliberately + * ignorant of who produces for it; 100 ms is imperceptible next to the click that caused it. */ +static DWORD WINAPI megapreview_worker(LPVOID unused) +{ + (void)unused; + char want[512], failed[512] = { 0 }; + + for (;;) { + Sleep(100); + if (!sh_preview_take_request(want, sizeof want)) continue; + + /* take_request keeps reporting the same request until something publishes, so a name that + * CANNOT be produced would otherwise be retried ten times a second forever. Suppress only + * that case -- keyed on the name that actually failed. + * + * It must not be keyed on "the last name we attempted": that also swallows a repeat request + * for a name that SUCCEEDED, which is a thing users do constantly (click the same asset + * again, or re-preview after changing something). Doing so leaves the UI polling a request + * nobody is serving until it times out, while the pane still shows the previous image. */ + if (failed[0] && strcmp(want, failed) == 0) continue; + + EnterCriticalSection(&g_lock); + int ok = megapreview_load_rects() && megapreview_produce(want); + LeaveCriticalSection(&g_lock); + + /* Atlas route declined -> the material is not virtual-textured. Roughly half the catalog + * is like that; those are backed by ordinary image assets, which imgpreview reads out of + * the .index/.resources containers. Outside the lock: different scratch, different files. */ + if (!ok) ok = sh_imgpreview_produce(want); + + if (ok) failed[0] = '\0'; + else strncpy_s(failed, sizeof failed, want, _TRUNCATE); + } +} + +int sh_megapreview_install(const sig_result *results, size_t n, const uint8_t *module_base) +{ + if (!module_base) { backend_log("B2: megapreview -- no module base; not installed"); return 0; } + if (InterlockedCompareExchange(&g_installed, 1, 0) != 0) return 0; + + uintptr_t fn = sig_addr_by_name(results, n, "Mega2PageDecode"); + if (!fn) { + backend_log("B2: megapreview -- Mega2PageDecode did not resolve; this DOOM build is not the " + "pinned one. Previews disabled (nothing called)."); + return 0; + } + g_decode = (decode_fn)fn; + g_base = module_base; + + if (!megapreview_vt_dir()) { + backend_log("B2: megapreview -- \\virtualtextures not found; previews disabled"); + return 0; + } + + for (int L = 0, axis = SHARD_PAGES, base = 0; L < MAX_LEVELS; ++L) { + g_levelAxis[L] = (unsigned)axis; + g_levelBase[L] = (unsigned)base; + base += axis * axis; + axis /= 2; + } + + g_out = (unsigned char *)malloc(OUT_SIZE); + g_page = (unsigned char *)malloc(PAGE_MAX + PAGE_SLACK); + if (!g_out || !g_page) { backend_log("B2: megapreview -- scratch alloc failed"); return 0; } + for (int L = 0; L < MAX_LEVELS; ++L) { + g_levelTmp[L] = (unsigned char *)malloc(PAGE_CORE * PAGE_CORE * 4); + if (!g_levelTmp[L]) { backend_log("B2: megapreview -- scratch alloc failed"); return 0; } + } + InitializeCriticalSection(&g_lock); + + /* 8 MB reserved stack: the decoder's own frame is small but it recurses into the plane codec, + * and reserve costs nothing until touched. */ + HANDLE t = CreateThread(NULL, 8u << 20, megapreview_worker, NULL, 0, NULL); + if (!t) { backend_log("B2: megapreview -- worker thread failed to start"); return 0; } + SetThreadPriority(t, THREAD_PRIORITY_BELOW_NORMAL); + CloseHandle(t); + + backend_log("B2: megapreview -- installed; decoder verified at RVA 0x196E140, worker running"); + return 1; +} diff --git a/src/backend/megapreview.h b/src/backend/megapreview.h new file mode 100644 index 0000000..7d55cf2 --- /dev/null +++ b/src/backend/megapreview.h @@ -0,0 +1,54 @@ +/* megapreview.h -- the asset-preview PRODUCER: megatexture pages -> RGBA, on the CPU. + * + * This is the replacement for the retired in-engine render-capture route (see preview.h for why + * that died). Instead of asking the renderer to draw a material -- which only works for materials + * the loaded map already renders -- this reads the material's megatexture pages straight off disk + * and decodes them by calling DOOM's OWN page decoder in-process. + * + * That decoder is a pure function: bytes in, RGBA out. No renderer, no render thread, no GPU, no + * virtual-texture state, and critically NO MAP RESIDENCY. So this works for the full ~6,800-material + * catalog regardless of which map is open, which is exactly what an asset browser needs. + * + * Everything here was derived and proven in the doom-re campaign + * `revenant-asset-index-and-viewport`: + * - evidence 06: the codec is id's own DCT + YCoCg-R, not libjpeg -- CALL it, do not reimplement. + * - evidence 07: page addressing. `.vmtr` rect -> shard -> mip cell -> page id -> file offset. + * - evidence 08: the decoder run offline on 68/68 pages, plus the three rules the port must obey + * (buffer slack, pre-clear the output, plane 0 is albedo). + * + * It produces into the transport in preview.h and touches nothing else. + */ +#ifndef BACKEND_MEGAPREVIEW_H +#define BACKEND_MEGAPREVIEW_H + +#include + +#include "signatures.h" + +/* Start the producer. The decoder is resolved from the shared signature database as + * `Mega2PageDecode` -- no hardcoded RVA -- so it is found wherever the loader put it, and a build + * whose bytes do not match simply fails to resolve rather than calling into the wrong code. + * `module_base` is still needed for the on-disk `virtualtextures` directory next to the exe. + * Spawns one low-priority worker that serves preview requests staged through sh_preview_request. + * + * Returns 1 if the worker started, 0 otherwise (NULL base, unresolved decoder, already installed). + * Failure is non-fatal and only costs previews. */ +int sh_megapreview_install(const sig_result *results, size_t n, const uint8_t *module_base); + +/* A material's `.vmtr` atlas rect, written to out_xywh as {x, y, w, h} in atlas pixels. Returns 1 + * if the material is virtual-textured, 0 if it has no rect (which is the answer to "can this take + * a virtualmapping renderParm?" -- roughly half the catalog cannot). Divide each component by + * 245760 to get the renderParm's value form. */ +int sh_megapreview_rect(const char *name, int *out_xywh); + +/* Enumerate the `.vmtr` atlas by index: the name of row `i`, or NULL once `i` is past the end. + * Returns names in file order and does not filter -- the caller decides what to do with them. + * + * This exists because a material does NOT need a `material` decl to be usable. The atlas addresses + * art by RECTANGLE, so a `virtualmapping` renderParm can paint any row here whether or not anyone + * authored a decl for that name. Enumerating decls alone therefore under-reports the real catalog, + * and the missing names are unreachable in a browser that only lists decls -- they cannot be + * searched for, so they cannot be applied. imgpreview_load_vmtr_names folds these in. */ +const char *sh_megapreview_name_at(int i); + +#endif /* BACKEND_MEGAPREVIEW_H */ diff --git a/src/backend/overrides_baked.h b/src/backend/overrides_baked.h index f4e1a9e..be1746c 100644 --- a/src/backend/overrides_baked.h +++ b/src/backend/overrides_baked.h @@ -17,7 +17,19 @@ 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/unknown/unknown.decl -- the Unknown palette tile. + * propertySheets item[0] carries FOUR properties: displayName, spawnOrientation, clipModelInfo.size + * (vec3inspector_size) and renderModelInfo.color (colorinspector). The latter two are verbatim + * vanilla -- snapeditorentitydef/movement/bounce_pad.decl uses the identical path/tag/inspector + * triples, so both inspectors and both #str tags are known-present in snap_gameresources. + * + * `placementFlags = "SNAP_ENTITY_PLACE_AIR_UNALIGNED"` IS LOAD-BEARING. A user override in + * /overrides/ REPLACES this file wholesale -- overrides.c resolves USER -> BUILT-IN -> + * ENGINE by whole stream, it does not merge fields -- so an override that drops the line does not + * inherit ours: the entity falls back to the decl-type default placement mode and the editor + * refuses every position (red bounds, cannot place, cannot edit). Anyone hand-editing this tile + * must carry the line forward. */ +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,52,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,9,105,116,101,109,91,50,93,32,61,32,123,10,9,9,9,9,9,9,112,97,116,104,32,61,32,34,99,108,105,112,77,111,100,101,108,73,110,102,111,46,115,105,122,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,115,105,122,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,115,105,122,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,118,101,99,51,105,110,115,112,101,99,116,111,114,95,115,105,122,101,34,59,10,9,9,9,9,9,125,10,9,9,9,9,9,105,116,101,109,91,51,93,32,61,32,123,10,9,9,9,9,9,9,112,97,116,104,32,61,32,34,114,101,110,100,101,114,77,111,100,101,108,73,110,102,111,46,99,111,108,111,114,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,99,111,108,111,114,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,99,111,108,111,114,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,99,111,108,111,114,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). */ @@ -26,7 +38,7 @@ static const unsigned char g_ov_baked_d3[] = {123,10,9,101,100,105,116,32,61,32, 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/unknown/unknown.decl", (const char *)g_ov_baked_d2, 1911 }, { "generated/decls/snapeditorentitydef/func/lift.decl", (const char *)g_ov_baked_d3, 1301 }, }; diff --git a/src/backend/preview.c b/src/backend/preview.c new file mode 100644 index 0000000..bdafbe0 --- /dev/null +++ b/src/backend/preview.c @@ -0,0 +1,228 @@ +/* preview.c -- see preview.h. The asset-preview transport and nothing else: request staging, RGBA -> + * 24bpp BMP -> base64 data URI, and the cross-thread handoff to the UI. No engine calls, no detours, no + * renderer state; this file is pure CPU and links against nothing but the CRT and Win32. */ + +#include +#include +#include + +#include "preview.h" +#include "backend_log.h" + +/* ---- published image ------------------------------------------------------------------------------- + * BMP rather than PNG on purpose: no compression, no zlib dependency, and every browser renders + * `data:image/bmp;base64,...` in an directly. Written top-down via a NEGATIVE biHeight so the + * source rows need no flipping. + * + * The producer thread swaps-and-FREES this buffer while the UI thread may be reading it through iface + * ext 13, so the two are serialized. SRWLOCK because it needs no runtime init and the read side is + * shared: concurrent UI fetches do not contend with each other, only with the (rare) producer swap. */ +static char *g_preview_b64 = NULL; +static volatile LONG g_preview_ready = 0; +static SRWLOCK g_preview_lock = SRWLOCK_INIT; + +/* ---- staged request -------------------------------------------------------------------------------- + * Written by the UI thread in sh_preview_request, read by the producer in sh_preview_take_request. Held + * under the same lock as the image: contention is negligible (one write per user click) and one lock is + * one fewer ordering rule to get wrong. */ +static char g_requested[512] = { 0 }; +static volatile LONG g_request_gen = 0; /* bumped per request; log correlation only */ + +int sh_preview_get(char *out, size_t cap) +{ + if (!out || cap == 0) return 0; + out[0] = '\0'; + + AcquireSRWLockShared(&g_preview_lock); + int rc = 0; + if (g_preview_ready && g_preview_b64) { + size_t len = strlen(g_preview_b64); + if (len + 1 > cap) { + rc = -(int)(len + 1); /* negative = required size, so the UI can re-ask */ + } else { + memcpy(out, g_preview_b64, len + 1); + rc = (int)len; + } + } + ReleaseSRWLockShared(&g_preview_lock); + return rc; +} + +void sh_preview_request(const char *name) +{ + if (!name || !*name) return; + + AcquireSRWLockExclusive(&g_preview_lock); + strncpy_s(g_requested, sizeof g_requested, name, _TRUNCATE); + /* Invalidate the current image BEFORE the generation bump, or a poll can see the new request with + * the previous request's picture still marked ready and stop polling one image too early. */ + InterlockedExchange(&g_preview_ready, 0); + LONG gen = InterlockedIncrement(&g_request_gen); + ReleaseSRWLockExclusive(&g_preview_lock); + + char line[600]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: preview REQUEST #%ld -- '%s' staged", gen, name); + backend_log(line); +} + +int sh_preview_take_request(char *out, size_t cap) +{ + if (!out || cap == 0) return 0; + out[0] = '\0'; + + AcquireSRWLockShared(&g_preview_lock); + int pending = (!g_preview_ready && g_requested[0] != '\0'); + if (pending) strncpy_s(out, cap, g_requested, _TRUNCATE); + ReleaseSRWLockShared(&g_preview_lock); + return pending; +} + +void sh_preview_publish(const unsigned char *rgba, unsigned w, unsigned h) +{ + static const char b64[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const char *prefix = "data:image/png;base64,"; + + if (!rgba || w == 0 || h == 0) return; + + __try { + /* PNG, not BMP, and the reason is load-bearing: a 24bpp BMP has nowhere to put ALPHA. + * Many DOOM assets are a flat white RGB plane with the entire image carried in the alpha + * channel -- GUI icons, decals, POI markers. Encoded as BMP those arrive as a solid white + * square, which reads as "the decoder failed" when in fact the decode was perfect and the + * ENCODER threw the answer away. (Observed live 2026-08-03: snap_talk_poi blank while + * snap_poi_dope_fish, which has real colour, was fine.) + * + * No compressor is needed. PNG's IDAT is a zlib stream, and DEFLATE permits STORED + * (uncompressed) blocks, so this emits a valid zlib stream with zero compression: a 2-byte + * header, stored blocks of at most 65535 bytes, and an Adler-32. Every chunk gets a CRC-32. + * Slightly larger than a BMP; correct, which the BMP was not. */ + const unsigned raw_stride = w * 4u + 1u; /* +1 = per-scanline filter byte */ + const size_t raw_len = (size_t)raw_stride * h; + + unsigned char *raw = (unsigned char *)HeapAlloc(GetProcessHeap(), 0, raw_len); + if (!raw) { backend_log("B2: preview -- PNG raw alloc failed"); return; } + for (unsigned y = 0; y < h; ++y) { + unsigned char *dst = raw + (size_t)y * raw_stride; + dst[0] = 0; /* filter type 0 (None) */ + memcpy(dst + 1, rgba + (size_t)y * w * 4u, w * 4u); + } + + /* CRC-32 table, built once. */ + static unsigned crcTab[256]; + static LONG crcReady = 0; + if (InterlockedCompareExchange(&crcReady, 1, 0) == 0) { + for (unsigned i = 0; i < 256; ++i) { + unsigned c = i; + for (int k = 0; k < 8; ++k) c = (c & 1u) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + crcTab[i] = c; + } + InterlockedExchange(&crcReady, 2); + } + while (InterlockedCompareExchange(&crcReady, 2, 2) != 2) Sleep(0); + + const size_t nblocks = (raw_len + 65534u) / 65535u; + const size_t idat_len = 2u + nblocks * 5u + raw_len + 4u; /* hdr + blocks + adler */ + const size_t pngCap = 8u + (12u + 13u) + (12u + idat_len) + 12u; + + unsigned char *png = (unsigned char *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, pngCap); + if (!png) { HeapFree(GetProcessHeap(), 0, raw); backend_log("B2: preview -- PNG alloc failed"); return; } + + size_t o = 0; + static const unsigned char SIG[8] = {0x89,'P','N','G',0x0D,0x0A,0x1A,0x0A}; + memcpy(png + o, SIG, 8); o += 8; + + #define PUT32(p, v) do { unsigned v_ = (unsigned)(v); \ + (p)[0]=(unsigned char)(v_>>24); (p)[1]=(unsigned char)(v_>>16); \ + (p)[2]=(unsigned char)(v_>>8); (p)[3]=(unsigned char)(v_); } while (0) + + size_t ihdr = o; + PUT32(png + o, 13); o += 4; + memcpy(png + o, "IHDR", 4); o += 4; + PUT32(png + o, w); o += 4; + PUT32(png + o, h); o += 4; + png[o++] = 8; /* bit depth */ + png[o++] = 6; /* colour type 6 = RGBA */ + png[o++] = 0; png[o++] = 0; png[o++] = 0; /* deflate / filter 0 / no interlace */ + { unsigned c = 0xFFFFFFFFu; + for (size_t i = ihdr + 4; i < o; ++i) c = crcTab[(c ^ png[i]) & 0xFFu] ^ (c >> 8); + PUT32(png + o, c ^ 0xFFFFFFFFu); o += 4; } + + size_t idat = o; + PUT32(png + o, (unsigned)idat_len); o += 4; + memcpy(png + o, "IDAT", 4); o += 4; + png[o++] = 0x78; png[o++] = 0x01; /* zlib: deflate, 32K, no dict */ + for (size_t b = 0, done = 0; b < nblocks; ++b) { + size_t n = raw_len - done; if (n > 65535u) n = 65535u; + png[o++] = (b + 1u == nblocks) ? 1u : 0u; /* BFINAL on the last, BTYPE = stored */ + png[o++] = (unsigned char)(n & 0xFFu); + png[o++] = (unsigned char)(n >> 8); + png[o++] = (unsigned char)(~n & 0xFFu); + png[o++] = (unsigned char)((~n >> 8) & 0xFFu); + memcpy(png + o, raw + done, n); o += n; done += n; + } + { unsigned a = 1, b2 = 0; /* Adler-32 over the RAW bytes */ + for (size_t i = 0; i < raw_len; ++i) { a = (a + raw[i]) % 65521u; b2 = (b2 + a) % 65521u; } + PUT32(png + o, (b2 << 16) | a); o += 4; } + { unsigned c = 0xFFFFFFFFu; + for (size_t i = idat + 4; i < o; ++i) c = crcTab[(c ^ png[i]) & 0xFFu] ^ (c >> 8); + PUT32(png + o, c ^ 0xFFFFFFFFu); o += 4; } + + PUT32(png + o, 0); o += 4; + memcpy(png + o, "IEND", 4); o += 4; + { unsigned c = 0xFFFFFFFFu; + for (size_t i = o - 4; i < o; ++i) c = crcTab[(c ^ png[i]) & 0xFFu] ^ (c >> 8); + PUT32(png + o, c ^ 0xFFFFFFFFu); o += 4; } + #undef PUT32 + + HeapFree(GetProcessHeap(), 0, raw); + + const unsigned char *bmp = png; /* the base64 stage below is format-agnostic */ + const unsigned fileSize = (unsigned)o; + size_t prefixLen = strlen(prefix); + size_t outCap = prefixLen + ((size_t)fileSize + 2) / 3 * 4 + 1; + char *out = (char *)HeapAlloc(GetProcessHeap(), 0, outCap); + if (!out) { + HeapFree(GetProcessHeap(), 0, bmp); + backend_log("B2: preview -- base64 alloc failed"); + return; + } + memcpy(out, prefix, prefixLen); + + o = prefixLen; + unsigned i = 0; + while (i + 2 < fileSize) { + unsigned v = ((unsigned)bmp[i] << 16) | ((unsigned)bmp[i+1] << 8) | bmp[i+2]; + out[o++] = b64[(v >> 18) & 63]; out[o++] = b64[(v >> 12) & 63]; + out[o++] = b64[(v >> 6) & 63]; out[o++] = b64[v & 63]; + i += 3; + } + if (i < fileSize) { /* 1 or 2 trailing bytes */ + unsigned rem = fileSize - i; + unsigned v = (unsigned)bmp[i] << 16; + if (rem == 2) v |= (unsigned)bmp[i+1] << 8; + out[o++] = b64[(v >> 18) & 63]; + out[o++] = b64[(v >> 12) & 63]; + out[o++] = (rem == 2) ? b64[(v >> 6) & 63] : '='; + out[o++] = '='; + } + out[o] = '\0'; + + HeapFree(GetProcessHeap(), 0, bmp); + + AcquireSRWLockExclusive(&g_preview_lock); + char *old = g_preview_b64; + g_preview_b64 = out; + InterlockedExchange(&g_preview_ready, 1); + ReleaseSRWLockExclusive(&g_preview_lock); + if (old) HeapFree(GetProcessHeap(), 0, old); /* safe: no reader can still hold it */ + + char line[200]; + _snprintf_s(line, sizeof line, _TRUNCATE, + "B2: preview PUBLISHED -- %ux%u RGBA PNG, %zu base64 chars; fetch via iface ext 13", w, h, o); + backend_log(line); + } __except (EXCEPTION_EXECUTE_HANDLER) { + backend_log("B2: preview -- FAULTED encoding the preview"); + } +} diff --git a/src/backend/preview.h b/src/backend/preview.h new file mode 100644 index 0000000..885279d --- /dev/null +++ b/src/backend/preview.h @@ -0,0 +1,59 @@ +/* preview.h -- the asset-preview TRANSPORT: RGBA pixels in, `data:image/bmp;base64,...` out. + * + * This module is deliberately ROUTE-INDEPENDENT. It knows nothing about where the pixels came from -- + * it owns the request/publish handshake, the encode, and the cross-thread buffer, and nothing else. + * That separation is the lesson of the retired route: the *acquisition* half changed completely (see + * below) while every line of this half kept working unmodified. + * + * PRODUCER (whatever can make pixels) CONSUMER (the WebView UI) + * ------------------------------------ ------------------------- + * sh_preview_take_request(name, cap) <----- sh_preview_request(name) iface ext 14 (+0x2D8) + * sh_preview_publish(rgba, w, h) -----> sh_preview_get(out, cap) iface ext 13 (+0x2D0) + * + * HISTORY -- read this before adding a producer. The first producer was `rendercap.c`: it detoured the + * engine's render-target setter, built its own render target, let the engine draw a material into it and + * copied the result back over Vulkan. That worked end to end and is preserved on the branch + * `experimental/asset-preview-render` (commits f84b66c..8b4a6b5). It was RETIRED 2026-08-03 for one + * disqualifying reason: **the engine can only render a material the loaded map already renders**, so + * coverage depended on which map was open -- useless for a browser that must show all ~9,805 materials. + * + * The replacement producer decodes the megatexture pages directly on the CPU, by calling DOOM's own + * page decoder (`FUN_14196E140`, a pure function: bytes in, 5 x 128x128 RGBA out -- no renderer, no GPU, + * no map residency). See the doom-re campaign `revenant-asset-index-and-viewport`, evidence + * `06-mega2-codec-via-doom-transcoder.md`. That producer is NOT WRITTEN YET -- page addressing (which + * file offset holds a given material's page) is the open question. Until it lands, sh_preview_request + * stages a name and no pixels are ever published, so the UI's poll simply times out. + */ +#ifndef BACKEND_PREVIEW_H +#define BACKEND_PREVIEW_H + +#include + +/* ------------------------------------------------------------------ consumer side (the UI thread) --*/ + +/* Latest published preview as a `data:image/bmp;base64,...` URI. Returns the length on success, 0 if + * nothing has been published yet (including immediately after a request, until that request's pixels + * land), or -(required size) if `cap` is too small so the caller can re-ask with a bigger buffer. + * Safe from any thread. Backs iface ext 13 (+0x2D0). */ +int sh_preview_get(char *out, size_t cap); + +/* Stage `name` as the asset the user wants to see, and invalidate whatever is currently published so a + * poll cannot mistake the previous image for this request's answer. ASYNCHRONOUS by nature: pixels are + * produced on another thread, so the caller polls sh_preview_get until it returns > 0. Backs iface + * ext 14 (+0x2D8). */ +void sh_preview_request(const char *name); + +/* ------------------------------------------------------------- producer side (whoever makes pixels) */ + +/* Read the staged request. Copies the name into `out` and returns 1 if a request is pending and has not + * been published for yet; returns 0 otherwise. Does NOT clear the request -- a producer that fails may + * legitimately want to retry, and publishing is what marks it served. */ +int sh_preview_take_request(char *out, size_t cap); + +/* Encode `w` x `h` RGBA8 pixels (row-major, top row first, 4 bytes/pixel) and publish them as the + * current preview. Thread-safe: the previous buffer is swapped out under the lock and only freed once + * no reader can still hold it. Any size is accepted -- 128x128 for one megatexture page, larger for a + * stitched rect. */ +void sh_preview_publish(const unsigned char *rgba, unsigned w, unsigned h); + +#endif /* BACKEND_PREVIEW_H */ diff --git a/src/backend/signatures.c b/src/backend/signatures.c index 79d25c1..7ad8917 100644 --- a/src/backend/signatures.c +++ b/src/backend/signatures.c @@ -882,5 +882,92 @@ const sig_entry BACKEND_ENGINE_SIGNATURES[] = { "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", 0xCF35E0u }, + { "SoundWorldLea", /* idSoundSystemLocal::Update -- void(void). Only wanted for its FIRST + * instruction: `MOV RBX,[rip+disp32]` reading the CURRENT SOUND WORLD + * global (RVA 0x6223A18 on the pinned build). That global is a plain + * .data pointer with no unique code fingerprint of its own -- exactly the + * cmdSystem/gameMgr situation -- so it is decoded from this accessor + * instead of hardcoded. NOTE the modrm is 0x1D (-> RBX), which the SHARED + * sh_decode_rip_slot does not accept (it only takes ->RAX/->RCX); the + * sound module carries its own any-register variant. See soundpreview.c. + * + * Unique on the pinned build: the 0xFF00FF00 profile colour immediately + * after the null test is what makes it so. Wildcards: the two rip-relative + * displacements (the global and the profile-label string). + * DIRECT (our own reverse-engineering, 2026-08-04). */ + "40 53 48 83 EC 20 48 8B 1D ?? ?? ?? ?? 48 85 DB 74 1E 48 8D 15 ?? ?? ?? ?? B9 00 FF 00 FF", + 0x18514F0u }, + { "SoundPreview", /* idSoundWorld PREVIEW -- void*(this [rcx], uint64 *outHandle [rdx], + * const char *name [r8]). Sound-world vtable slot +0x30. + * + * This is the editor's own audition path, not a general play call, and it + * is what makes previewing usable: + * - sets cvar s_soloSound = "preview" so the audition is the only thing + * audible, and forces the listener, so the sound arrives at the ear + * rather than positioned somewhere in the world; + * - plays through vtbl+0x40 with the label "preview" at a fixed origin + * and axis, and writes the emitter HANDLE to *outHandle; + * - when the name resolves to nothing it instead CLEARS s_soloSound and + * s_forceListener and writes handle 0 -- that branch is the whole of + * "leave preview mode". + * + * It DOES stack: each call allocates a fresh emitter (StartSound_wwise + * @0x1854600 does `operator new(0x2200)` and appends to the emitter array + * at world+0x1F70). So the caller MUST stop the previous handle first -- + * see the vtable +0x98 stop in soundpreview.c. That is the difference + * between this and the + * `testSound` console command, which throws its handle away and therefore + * cannot be stopped or prevented from piling up. + * + * DANGER: it resolves the name through the find-OR-CREATE decl primitive + * (0x17B36F0) with allowCreate=1, which fatals on a name that is not a + * real decl. Only ever call it with a name sh_imgpreview_has() confirmed. + * + * Wildcards: the rel32 to the name-hash helper and the rip-relative read + * of the solo-list count. DIRECT (our own reverse-engineering, 2026-08-04). */ + "48 89 5C 24 10 48 89 6C 24 18 48 89 74 24 20 41 54 41 56 41 57 48 83 EC 50 " + "4C 8B E1 4D 8B F0 49 8B C8 4C 8B FA E8 ?? ?? ?? ?? 33 F6 8B E8 39 35 ?? ?? ?? ?? 7E 5E", + 0x1855660u }, + { "Mega2PageDecode", /* the megatexture PAGE DECODER -- + * void(const u8 *header16, const u8 *payload, void *unused, u8 *out) + * where `unused` is NULL in every observed call and `out` is 0x50000 bytes. + * + * id's own DCT codec (YCoCg-R colour transform, so NOT libjpeg), anchored in + * the binary by engine\renderer\jobs\transcode\Transcode.cpp. It is a PURE + * function -- no engine globals, no renderer, no GPU, no virtual-texture + * state and no map residency -- which is why the asset browser can call it + * directly to turn one `.mega2` page into pixels. Proven by decoding 68/68 + * pages in a bare console process with DOOM not running at all. + * + * Output is 5 planes of 128x128 RGBA at +0/+0x10000/.../+0x40000; plane 0 is + * albedo and the only one a preview needs. Callers MUST pre-clear the output + * (skipped streams are left untouched, not zeroed) and MUST give the page + * buffer >= 0x40000 of zero-filled slack -- it reads a measured mean of + * 73,172 and a max of 167,220 bytes past the end of the page data. + * + * The prologue is whole, position-independent instructions with a single + * rip-relative read at the tail, so the only wildcards are that displacement. + * Previously called as a raw module_base + 0x196E140 guarded by a local + * memcmp of these same bytes; that is what this entry replaces. + * DIRECT (our own reverse-engineering, 2026-08-03). */ + "40 55 56 57 41 54 41 55 41 56 41 57 48 81 EC 30 01 00 00 48 8D 6C 24 40 " + "48 C7 45 10 FE FF FF FF 48 89 9D 40 01 00 00 48 8B 05 ?? ?? ?? ??", + 0x196E140u }, + { "PrefabDtor", /* idSnapEntityPrefab::~idSnapEntityPrefab -- void(this [rcx]). + * Pairs with PrefabCtor/PrefabPopulate; tears down the temp prefab the + * serialize-from-selection path builds. Wildcards are the two rel32 call + * displacements (the idStr member dtor and the base dtor). + * DIRECT (our own reverse-engineering, 2026-08-05). */ + "40 57 48 83 EC 30 48 C7 44 24 20 FE FF FF FF 48 89 5C 24 40 48 8B F9 " + "48 81 C1 20 01 00 00 E8 ?? ?? ?? ?? 90 48 8D 8F F0 00 00 00", + 0x51D870u }, + { "EntityDeshare", /* COW make-unique -- void *(entitySlot [rcx]). De-shares an entity's 0x6f8 + * block before an in-place edit; the refcount test `cmp dword [rcx],1` and + * the 0x6F8 allocation size are both visible in the pattern, which is what + * makes it unique. Wildcard is the rel32 to the allocator. + * DIRECT (our own reverse-engineering, 2026-08-05). */ + "40 57 48 83 EC 30 48 C7 44 24 20 FE FF FF FF 48 89 5C 24 48 48 8B F9 " + "48 8B 01 83 38 01 74 3C B9 F8 06 00 00 E8 ?? ?? ?? ?? 48 8B D8", + 0x52C920u }, { NULL, NULL, 0 } /* terminator */ }; diff --git a/src/backend/signatures.h b/src/backend/signatures.h index d2cde9e..8aa56b8 100644 --- a/src/backend/signatures.h +++ b/src/backend/signatures.h @@ -49,6 +49,13 @@ typedef struct sig_result { uint32_t rva; /* recovered RVA, or 0 */ } sig_result; +/* How many sig_result slots every caller of sig_resolve_all must provide. Callers used to size that + * array with a bare 64 and CLAMP the DB count down to it, so growing the database past 64 silently + * dropped the last entries -- they never resolved, never failed, and never appeared in the resolve + * report. That cost a debugging session when SoundStopSound became entry 65 (2026-08-04). Size the + * array with this and never clamp; sh_smoke_run logs loudly if the DB ever outgrows it. */ +#define SIG_RESULTS_MAX 128 + /* The shipped engine signature database. NULL-terminated * (the final entry has name==NULL). */ extern const sig_entry BACKEND_ENGINE_SIGNATURES[]; diff --git a/src/backend/smoke.c b/src/backend/smoke.c index 241180b..31251d1 100644 --- a/src/backend/smoke.c +++ b/src/backend/smoke.c @@ -131,8 +131,8 @@ static void log_sig_failure_diag(const uint8_t *doom_base, const sig_entry *e, s /* ---- lightweight resolve pass (the bootstrap poll uses this) ----------------------------------- */ size_t sh_resolve_count(const uint8_t *doom_base) { - sig_result results[64]; - return sig_resolve_all(doom_base, results, 64); + sig_result results[SIG_RESULTS_MAX]; + return sig_resolve_all(doom_base, results, SIG_RESULTS_MAX); } /* ---- the proof --------------------------------------------------------------------------------- */ @@ -142,9 +142,19 @@ int sh_smoke_run(const uint8_t *doom_base, unsigned long deferred_ms) /* (A) resolver -------------------------------------------------------------------------------- */ size_t total = sig_db_count(); - sig_result results[64]; - if (total > 64) total = 64; - size_t ok = sig_resolve_all(doom_base, results, 64); + sig_result results[SIG_RESULTS_MAX]; + /* NOT a silent clamp. Truncating here is what hid an unresolved signature once already: the + * report said "64/64 resolved" while entry 65 had never been looked at. Say so instead. */ + if (total > SIG_RESULTS_MAX) { + char over[160]; + _snprintf_s(over, sizeof over, _TRUNCATE, + "PB0: SIGNATURE DB OVERFLOW -- %zu entries but only %d result slots; raise " + "SIG_RESULTS_MAX. The last %zu signature(s) were NOT resolved.", + total, SIG_RESULTS_MAX, total - (size_t)SIG_RESULTS_MAX); + backend_log(over); + total = SIG_RESULTS_MAX; + } + size_t ok = sig_resolve_all(doom_base, results, SIG_RESULTS_MAX); int rva_match = 0, rva_diff = 0, hooked = 0; char hooked_names[160] = {0}; /* comma-list of the hook-tolerant sigs for the success line */ diff --git a/src/backend/soundpreview.c b/src/backend/soundpreview.c new file mode 100644 index 0000000..70dc278 --- /dev/null +++ b/src/backend/soundpreview.c @@ -0,0 +1,478 @@ +/* soundpreview.c -- see soundpreview.h for what this is and why testSound is not it. */ + +#include +#include +#include +#include "soundpreview.h" +#include "imgpreview.h" /* sh_imgpreview_has -- the name gate in front of the engine's find */ +#include "backend_log.h" + +/* idSoundWorld PREVIEW: void*(this, uint64 *outHandle, const char *name). Slot +0x30. */ +typedef void *(*snd_preview_fn)(void *world, unsigned long long *out, const char *name); +/* idSoundWorld::StopSound: void(this, uint64 handle). Slot +0x98. */ +typedef void (*snd_stop_fn)(void *world, unsigned long long handle); +/* cmdSystem vtbl +0x48 ExecuteCommandText -- the same slot sh_spawninfo uses for `getviewpos`. */ +typedef void (*cmd_exec_fn)(void *cmdSys, const char *text); +/* The main-thread bridge, same two engine functions apply_engine.c uses for clone_bss_apply. */ +typedef void (*snd_buffer_cmd_fn)(void *cmdSys, const char *text); +/* AddCommand's 4th and 5th arguments are help and arg-completion in SOME order, and this repo's two + * existing declarations of it disagree about which (apply_engine.c says cb,p3,help; commands.c says + * handler,help,argComp). AddCommand itself (0x1AA3630) only shows that they land in adjacent slots + * of its 0x28-byte record -- param_5 at +0x10, param_4 at +0x18 -- without naming either. Since one + * of those slots is a FUNCTION POINTER the engine may later call for tab-completion, passing a help + * string into the wrong one is a jump into string bytes. This command is internal plumbing whose + * help text nobody reads, so BOTH are passed NULL and the question does not have to be answered. */ +typedef void (*snd_add_command_fn)(void *cmdSys, const char *name, void *handler, void *arg4, + void *arg5, unsigned int flags); + +static const uint8_t *g_slot; /* address OF the sound-world pointer, not the world itself */ +static snd_preview_fn g_preview; +static snd_stop_fn g_stop; /* from the world's VTABLE, not a signature -- see sp_bind_stop */ +static void *g_cmdsys; +static int g_installed; +static int g_stop_bound; /* 0 = not tried, 1 = bound, -1 = tried and refused */ + +/* ================================================== THE MAIN-THREAD RULE ========================= + * NOTHING in this file may touch the sound world from the calling thread. Everything that starts, + * stops or re-modes a sound is queued and executed on the DOOM MAIN THREAD, through the engine's own + * command buffer -- the same routing apply_engine.c uses for clone_bss_apply. + * + * This is not defensive style, it is a crash we shipped and hit (2026-08-05). `StartSound_wwise` + * (RVA 0x1854600) publishes a brand-new emitter into the sound world's LIVE list before it is + * initialised: + * + * emitter = FUN_1418455e0(operator_new(0x2200)); // allocate + base ctor + * world[0x3EE][i] = emitter; // PUBLISH (world+0x1F70) + * world[0x3EF] = i + 1; // and bump the count (world+0x1F78) + * ... + * FUN_141846900(emitter, world, ...); // ONLY NOW is emitter+0x20A0 (the shader) set + * + * Meanwhile idSoundWorld::Update (RVA 0x1857270, called from the "Sound World Update" job at + * 0x18520B0) walks that list on an AUDIO WORKER THREAD and does, at the top of idSound::Update + * (0x1847670): + * + * mov rax,[emitter+0x20A0] ; the sound shader + * mov edi,[rax+0xA4] ; <-- rax == 0 inside the window above -> AV at address 0xA4 + * + * So any caller that starts a sound off the main thread races that window on every single call. We + * were calling from the frontend's 30 Hz think-loop thread, and it faulted exactly there. + * + * The engine's own audition, `testSound`, does not hit this because it is a CONSOLE COMMAND: the + * command buffer is drained on the main thread, which does not overlap the sound job. We take the + * same route rather than inventing a safety story of our own -- if the engine's worked example is + * safe, an identical routing is safe for the same reason. + * + * (The fault-shield does not save you here either: it downgrades the AV to a recoverable Error(6), + * which is validated for MAIN-THREAD faults. Unwinding it out of the audio job instead leaves the + * sound system's state held and the game freezes rather than recovering.) */ +#define SP_CMD_NAME "sh_sndprev" + +typedef enum { SP_OP_PLAY = 0, SP_OP_STOP, SP_OP_SESSION_ON, SP_OP_SESSION_OFF } sp_op; + +/* A small ring rather than a single last-wins slot: ordering between ops is meaningful (a + * session-off that arrives after a play must still stop it), so they cannot be collapsed. 8 is + * generous -- the UI produces at most one op per click. On overflow the OLDEST is dropped and said + * so, because the newest op is the one that reflects what the user last did. */ +#define SP_QUEUE_MAX 8 +typedef struct { sp_op op; char name[256]; } sp_item; +static sp_item g_queue[SP_QUEUE_MAX]; +static int g_qhead, g_qcount; +static CRITICAL_SECTION g_qlock; +static int g_qlock_init; +static volatile LONG g_cmd_registered; +static snd_buffer_cmd_fn g_buffer_cmd; +static snd_add_command_fn g_add_command; + +/* Sound-world vtable slots, both decompile-verified on the pinned build. + * +0x30 the PREVIEW entry point -- also resolved by signature, which is what lets us prove the + * vtable layout is what we think it is before trusting any other slot in it. + * +0x98 StopSound(handle). */ +#define SP_VSLOT_PREVIEW 0x30 +#define SP_VSLOT_STOP 0x98 + +/* StopSound's prologue: the frame setup, then the handle decode that is its fingerprint -- + * SHR RAX,0x20 to take the emitter index out of the packed handle, TEST/JS, then CMP against the + * emitter count at world+0x1F78. Used to VERIFY the vtable slot, never to search for it: the + * function has a byte-identical twin at +0xA0 (same code, different globals), so a search returns + * two hits and cannot pick. Verifying a pointer we already took from the right slot has no such + * problem -- the twin only means "these bytes are a StopSound", which is exactly the claim. */ +static const uint8_t SP_STOP_PROLOGUE[] = { + 0x48,0x8B,0xC4, 0x48,0x89,0x50,0x10, 0x57, 0x48,0x83,0xEC,0x60, + 0x48,0xC7,0x40,0xC8,0xFE,0xFF,0xFF,0xFF, 0x48,0x89,0x58,0x08, 0x48,0x89,0x70,0x18, + 0x48,0x8B,0xF9, 0x48,0x8B,0xC2, 0x48,0xC1,0xE8,0x20, 0x85,0xC0, 0x78,0x27, + 0x3B,0x81,0x78,0x1F,0x00,0x00 +}; + +static unsigned long long g_handle; /* the one live preview, 0 = nothing playing */ + +/* PREVIEW MODE is the cvar state an audition needs: background audio on (Snapmap+ has focus, DOOM + * does not) plus the engine's solo/forced-listener pair. It is entered ONCE and held, never toggled + * per click. + * + * The first version toggled it per preview, and that was audibly wrong: each play ran + * s_playSoundInBackground 0 (tearing down the previous preview) immediately followed by 1, so every + * click suspended and resumed DOOM's whole audio engine and re-entered solo. Sounds faded in, and + * short ones could be over before the resume finished -- which is exactly the "sometimes it does + * not play" this was reported as. + * + * g_session is the UI holding the mode open for as long as the asset browser is up; g_mode_on is + * what we have actually written. A preview can still be started without a session (the mode is + * entered on demand and dropped on stop) -- the session only means "do not drop it between clicks". */ +static int g_session; +static int g_mode_on; + +/* RIP-relative decode, any destination register. + * + * The shared sh_decode_rip_slot in commands.c only accepts modrm 0x05 (->RAX) and 0x0D (->RCX) + * because that is what its two callers' accessors happen to use. Our accessor loads into RBX + * (modrm 0x1D). Rather than widen a scanner two other subsystems depend on, this takes the general + * form: for a [rip+disp32] operand the modrm byte is mod=00 rm=101, i.e. (modrm & 0xC7) == 0x05, + * with the register in bits 3..5. Same arithmetic otherwise. */ +#define SP_SCAN_WINDOW 64 +static const uint8_t *sp_decode_rip_any(const uint8_t *fn) +{ + uint8_t b[SP_SCAN_WINDOW]; + __try { for (int i = 0; i < SP_SCAN_WINDOW; i++) b[i] = fn[i]; } + __except (EXCEPTION_EXECUTE_HANDLER) { return NULL; } + for (int i = 0; i + 7 <= SP_SCAN_WINDOW; i++) { + if (b[i] == 0x48 && (b[i + 1] == 0x8B || b[i + 1] == 0x8D) && (b[i + 2] & 0xC7) == 0x05) { + int32_t disp; + memcpy(&disp, &b[i + 3], 4); + return fn + i + 7 + disp; + } + } + return NULL; +} + +/* The live sound world, or NULL. Read through the slot EVERY time: the world is torn down and + * rebuilt across map loads and s_restart, so a cached pointer goes stale and would be a use-after- + * free the first time someone reloads a map with a preview handle outstanding. */ +static void *sp_world(void) +{ + if (!g_slot) return NULL; + __try { + void *w = *(void * const *)g_slot; + return w; + } __except (EXCEPTION_EXECUTE_HANDLER) { return NULL; } +} + +/* cmdSystem vtbl +0x48, the same slot and the same byte offset sh_spawninfo uses for `getviewpos`. */ +#define SP_VSLOT_EXEC_CMD_TEXT 0x48 +/* Bind StopSound from the live sound world's vtable, once, and only if the vtable proves itself + * first: slot +0x30 must be the very function the SoundPreview signature already found. If that + * matches, this is the vtable we reverse-engineered and slot +0x98 means what we recorded; if it + * does not, the layout moved and we refuse rather than call an arbitrary pointer. + * + * Deferred to first use because the sound world does not exist at install time. Returns 1 if bound. */ +static int sp_bind_stop(void *world) +{ + if (g_stop_bound) return g_stop_bound > 0; + g_stop_bound = -1; + if (!world || !g_preview) return 0; + __try { + const uint8_t *vtbl = *(const uint8_t * const *)world; + if (!vtbl) return 0; + void *slot_preview = *(void * const *)(vtbl + SP_VSLOT_PREVIEW); + if (slot_preview != (void *)g_preview) { + char l[200]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: soundpreview -- vtable+0x30 is %p but the signature found %p; layout moved, " + "refusing to trust +0x98", slot_preview, (void *)g_preview); + backend_log(l); + return 0; + } + const uint8_t *stop = *(const uint8_t * const *)(vtbl + SP_VSLOT_STOP); + if (!stop) return 0; + for (size_t i = 0; i < sizeof SP_STOP_PROLOGUE; i++) + if (stop[i] != SP_STOP_PROLOGUE[i]) { + char l[200]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: soundpreview -- vtable+0x98 -> %p does not look like StopSound (byte %zu); refusing", + (const void *)stop, i); + backend_log(l); + return 0; + } + g_stop = (snd_stop_fn)stop; + g_stop_bound = 1; + char l[160]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: soundpreview -- stop bound from vtable+0x98 -> %p (vtable verified via +0x30)", + (const void *)stop); + backend_log(l); + return 1; + } __except (EXCEPTION_EXECUTE_HANDLER) { return 0; } +} + +static void sp_console(const char *text) +{ + if (!g_cmdsys || !text) return; + __try { + const uint8_t *vtbl = *(const uint8_t * const *)g_cmdsys; + if (!vtbl) return; + cmd_exec_fn exec = *(cmd_exec_fn const *)(vtbl + SP_VSLOT_EXEC_CMD_TEXT); + if (exec) exec(g_cmdsys, text); + } __except (EXCEPTION_EXECUTE_HANDLER) {} +} + +/* ---- the main-thread queue --------------------------------------------------------------------- + * Producers (any thread) push an op and kick the command buffer; the consumer is the registered + * console command, which the engine runs on the main thread. See THE MAIN-THREAD RULE above. */ + +static void sp_do_play(const char *name); /* the real bodies, main-thread only */ +static void sp_do_stop(void); +static void sp_do_session(int on); + +static void __cdecl sp_drain_cmd(void) +{ + /* Runs on the DOOM main thread. Drain everything queued since the last frame, in order. */ + for (;;) { + sp_item it; + if (g_qlock_init) EnterCriticalSection(&g_qlock); + int have = g_qcount > 0; + if (have) { it = g_queue[g_qhead]; g_qhead = (g_qhead + 1) % SP_QUEUE_MAX; g_qcount--; } + if (g_qlock_init) LeaveCriticalSection(&g_qlock); + if (!have) return; + + switch (it.op) { + case SP_OP_PLAY: sp_do_play(it.name); break; + case SP_OP_STOP: sp_do_stop(); break; + case SP_OP_SESSION_ON: sp_do_session(1); break; + case SP_OP_SESSION_OFF: sp_do_session(0); break; + } + } +} + +/* Register SP_CMD_NAME once, lazily -- the command system is not ready at install time. */ +static int sp_ensure_command(void) +{ + if (InterlockedCompareExchange(&g_cmd_registered, 1, 0) != 0) return 1; + if (!g_add_command || !g_cmdsys) { + InterlockedExchange(&g_cmd_registered, 0); /* retry once the deps bind */ + return 0; + } + __try { + g_add_command(g_cmdsys, SP_CMD_NAME, (void *)sp_drain_cmd, NULL, NULL, 0); + } __except (EXCEPTION_EXECUTE_HANDLER) { + InterlockedExchange(&g_cmd_registered, 0); + return 0; + } + backend_log("B2: soundpreview -- " SP_CMD_NAME " registered (auditions now run on the main thread)"); + return 1; +} + +/* Queue one op and kick the buffer. Returns 1 if it was accepted for main-thread execution. */ +static int sp_post(sp_op op, const char *name) +{ + if (!g_buffer_cmd || !g_cmdsys) { + backend_log("soundpreview: REFUSED -- no command buffer, so nothing can run on the main thread"); + return 0; + } + if (!sp_ensure_command()) { + backend_log("soundpreview: REFUSED -- " SP_CMD_NAME " could not be registered"); + return 0; + } + + if (g_qlock_init) EnterCriticalSection(&g_qlock); + if (g_qcount == SP_QUEUE_MAX) { /* full: drop the oldest, keep the newest intent */ + g_qhead = (g_qhead + 1) % SP_QUEUE_MAX; + g_qcount--; + } + sp_item *slot = &g_queue[(g_qhead + g_qcount) % SP_QUEUE_MAX]; + slot->op = op; + if (name) strncpy_s(slot->name, sizeof slot->name, name, _TRUNCATE); + else slot->name[0] = '\0'; + g_qcount++; + if (g_qlock_init) LeaveCriticalSection(&g_qlock); + + int enq = 0; + __try { g_buffer_cmd(g_cmdsys, SP_CMD_NAME "\n"); enq = 1; } + __except (EXCEPTION_EXECUTE_HANDLER) { enq = 0; } + return enq; +} + +int sh_soundpreview_install(const sig_result *results, size_t n, + const uint8_t *module_base, void *cmdsys) +{ + if (g_installed) return g_preview != NULL; + g_installed = 1; + (void)module_base; + g_cmdsys = cmdsys; + + if (!g_qlock_init) { InitializeCriticalSection(&g_qlock); g_qlock_init = 1; } + + const uint8_t *acc = (const uint8_t *)sig_addr_by_name(results, n, "SoundWorldLea"); + if (acc) g_slot = sp_decode_rip_any(acc); + g_preview = (snd_preview_fn)sig_addr_by_name(results, n, "SoundPreview"); + /* The main-thread bridge. Without BOTH of these there is no safe way to start a sound at all, + * so the module refuses to arm rather than falling back to calling from the caller's thread. */ + g_buffer_cmd = (snd_buffer_cmd_fn) sig_addr_by_name(results, n, "BufferCommandText"); + g_add_command = (snd_add_command_fn)sig_addr_by_name(results, n, "AddCommand"); + /* g_stop is NOT resolved here. It comes from the sound world's own vtable at first use -- + * see sp_bind_stop for why a signature cannot find it. */ + + if (!g_slot || !g_preview || !g_buffer_cmd || !g_add_command) { + char l[260]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: soundpreview DISABLED (worldSlot=%p preview=%p bufferCmd=%p addCmd=%p) -- sound " + "auditioning unavailable", (void *)g_slot, (void *)g_preview, + (void *)g_buffer_cmd, (void *)g_add_command); + backend_log(l); + g_preview = NULL; + return 0; + } + char l[260]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "B2: soundpreview installed (worldSlot=%p preview=%p; stop binds from the vtable on first " + "use; every audition runs on the main thread via " SP_CMD_NAME ")", + (void *)g_slot, (void *)g_preview); + backend_log(l); + return 1; +} + +int sh_soundpreview_active(void) { return g_handle != 0; } + +/* Raise background audio. The engine's preview call writes s_soloSound/s_forceListener itself, so + * entering the mode only has to do the half the engine does not. Idempotent. */ +static void sp_enter_mode(void) +{ + if (g_mode_on) return; + sp_console("s_playSoundInBackground 1"); + g_mode_on = 1; + backend_log("soundpreview: preview mode ON (s_playSoundInBackground 1)"); +} + +/* Drop the whole mode: the solo/forced-listener pair the engine set, and our background raise. + * Clearing solo matters even if nothing was playing -- leave it set and every other sound in the + * editor stays muted. */ +static void sp_leave_mode(void) +{ + sp_console("s_soloSound \"\""); + sp_console("s_forceListener 0"); + if (g_mode_on) { + sp_console("s_playSoundInBackground 0"); + g_mode_on = 0; + backend_log("soundpreview: preview mode OFF"); + } +} + +/* Silence the current emitter and nothing else. This is what runs BETWEEN previews -- the cvars are + * deliberately untouched, because churning them is what made playback inconsistent. */ +static void sp_stop_emitter(void) +{ + void *w = sp_world(); + if (g_handle && w && g_stop) { + unsigned long long h = g_handle; + __try { g_stop(w, h); } __except (EXCEPTION_EXECUTE_HANDLER) {} + } + /* Cleared even if the call faulted or the world went away: the handle names an emitter in a + * world that no longer exists, so keeping it can only cause a second bad call later. */ + g_handle = 0; +} + +/* MAIN THREAD ONLY (reached via sp_drain_cmd). */ +static void sp_do_session(int on) +{ + if (on) { g_session = 1; sp_enter_mode(); return; } + g_session = 0; + sp_stop_emitter(); + sp_leave_mode(); +} + +/* MAIN THREAD ONLY (reached via sp_drain_cmd). */ +static void sp_do_stop(void) +{ + sp_stop_emitter(); + /* With the browser still open, stay in preview mode: the next Play should be instant, and a + * background-audio suspend/resume between clicks is the very thing being avoided. The session + * ending is what tears the mode down. */ + if (!g_session) sp_leave_mode(); +} + +/* The UI holds this open for as long as the asset browser is on screen, so the mode is established + * before the first click rather than during it, and survives between clicks. */ +void sh_soundpreview_set_session(int on) +{ + sp_post(on ? SP_OP_SESSION_ON : SP_OP_SESSION_OFF, NULL); +} + +void sh_soundpreview_stop(void) { sp_post(SP_OP_STOP, NULL); } + +/* MAIN THREAD ONLY (reached via sp_drain_cmd). Everything here touches the live sound world. */ +static void sp_do_play(const char *name) +{ + if (!g_preview || !name || !name[0]) return; + + /* The gate. The engine resolves this name with the find-OR-CREATE decl primitive, which raises + * a fatal error on a name that is not a real decl -- so an unvalidated name is not a failed + * preview, it is a killed game. Our own container index answers the question with no engine + * call at all, and the browser only ever offers names that came from that same index, so this + * rejects nothing legitimate. */ + if (!sh_imgpreview_has(SH_ASSET_SOUND, name)) { + char l[320]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "soundpreview: REFUSED '%s' -- not a soundshader in the shipped containers", name); + backend_log(l); + return; + } + + void *w = sp_world(); + if (!w) { backend_log("soundpreview: no sound world (not in a map yet?)"); return; } + + /* A preview we cannot stop is worse than no preview -- it is exactly testSound's failure -- so + * the stop has to be in hand BEFORE anything is allowed to start. */ + if (!sp_bind_stop(w)) { + backend_log("soundpreview: REFUSED -- StopSound could not be bound, so nothing is played"); + return; + } + + /* Mode FIRST, and only if it is not already up. With a session open this is a no-op, so the + * audio engine is never suspended mid-click. */ + sp_enter_mode(); + + /* One at a time. The engine's preview allocates a fresh emitter per call and does NOT recycle, + * so without this the second click is audible on top of the first -- the pile-up testSound + * suffers from. Only the emitter is stopped; the mode stays up. */ + sp_stop_emitter(); + + unsigned long long h = 0; + __try { g_preview(w, &h, name); } + __except (EXCEPTION_EXECUTE_HANDLER) { h = 0; } + + g_handle = h; + char l[320]; + _snprintf_s(l, sizeof l, _TRUNCATE, "soundpreview: '%s' -> handle 0x%llx%s", + name, h, h ? "" : " (engine declined)"); + backend_log(l); + if (!h) { + /* The engine set solo on the way in and nothing is playing, so hand the editor its audio + * back -- unless a session is open, in which case the next click needs the mode anyway. */ + if (!g_session) sp_leave_mode(); + } +} + +/* PUBLIC, any thread. Validates what can be validated WITHOUT touching the sound world -- the name + * gate and the world's mere existence -- then hands the actual audition to the main thread. + * + * The return value therefore means "accepted", not "audible": a name we reject, a missing sound + * world or a dead command buffer are still reported synchronously (which is every failure the user + * can act on), while the engine's own "declined to start" outcome is one frame later and lands in + * the log. That trade is deliberate -- see THE MAIN-THREAD RULE. */ +int sh_soundpreview_play(const char *name) +{ + if (!g_preview || !name || !name[0]) return 0; + + /* Cheap, engine-free, and the same gate sp_do_play re-applies on the main thread. Doing it here + * as well is what keeps a bad name a red toast instead of a silent nothing a frame later. */ + if (!sh_imgpreview_has(SH_ASSET_SOUND, name)) { + char l[320]; + _snprintf_s(l, sizeof l, _TRUNCATE, + "soundpreview: REFUSED '%s' -- not a soundshader in the shipped containers", name); + backend_log(l); + return 0; + } + /* Reading the world POINTER is a plain load of a global; it does not walk the emitter list and + * is safe from any thread. Only mutating the world is not. */ + if (!sp_world()) { backend_log("soundpreview: no sound world (not in a map yet?)"); return 0; } + + return sp_post(SP_OP_PLAY, name); +} diff --git a/src/backend/soundpreview.h b/src/backend/soundpreview.h new file mode 100644 index 0000000..9a99066 --- /dev/null +++ b/src/backend/soundpreview.h @@ -0,0 +1,62 @@ +/* soundpreview.h -- audition a sound decl from the asset browser. + * + * The obvious route, the `testSound` console command, does not work for a browser: it discards the + * emitter handle the play returns, so a preview cannot be stopped and every click piles another + * voice on top of the last. Confirmed in game as well as in the disassembly. + * + * This uses the editor's own audition path instead -- sound-world vtable +0x30 -- which solos the + * preview, forces the listener so it plays at the ear, and hands back the emitter handle. Holding + * that handle is what makes the difference: one preview at a time, stoppable on demand. + * + * The two engine entry points and the sound-world global are resolved by byte signature; see the + * SoundPreview / SoundStopSound / SoundWorldLea entries in signatures.c for the full derivation. + * Every engine call is SEH-guarded and every one degrades to "no preview", never to a crash. + * + * THREADING: play and stop drive live engine audio state and must run on the DOOM main thread. Call + * them from the apply drain, the same way the entity-staging path does -- not from the UI thread. + */ +#ifndef BACKEND_SOUNDPREVIEW_H +#define BACKEND_SOUNDPREVIEW_H + +#include +#include +#include "signatures.h" + +/* Resolve the sound-world global and the play/stop pair. Idempotent. `cmdsys` is the already-decoded + * idCmdSystemLocal used to set the two cvars this feature needs (see sh_soundpreview_play); NULL is + * tolerated and only costs the background-audio convenience. Returns 1 if a preview can be played. */ +int sh_soundpreview_install(const sig_result *results, size_t n, + const uint8_t *module_base, void *cmdsys); + +/* Stop whatever is previewing, then audition `name` (a soundshader decl path as it appears in the + * browser catalog). Only one preview exists at a time by construction. + * + * Enters preview mode if it is not already up (s_playSoundInBackground 1, because the whole point + * is to hear it while the Snapmap+ window -- not DOOM -- has focus). With a session open that is + * already done, so the play itself changes no cvars at all. + * + * The name is validated against our own asset index FIRST. That is not politeness: the engine + * resolves the name with a find-or-create that fatals on a miss, so an unchecked name is a crash. + * + * Returns 1 if a preview started, 0 if it was refused (not installed, unknown name, engine + * declined). MAIN THREAD ONLY. */ +int sh_soundpreview_play(const char *name); + +/* Stop the current preview. While a session is open (below) this silences the emitter and nothing + * else, leaving preview mode up so the next Play is instant; with no session it also drops the mode + * and DOOM goes quiet again. Safe to call with nothing playing. MAIN THREAD ONLY. */ +void sh_soundpreview_stop(void); + +/* Hold preview mode open for as long as the asset browser is on screen (on=1), and tear it down on + * the way out (on=0, which also stops anything playing). + * + * This exists because toggling the mode per click was audibly wrong: each preview ran + * s_playSoundInBackground 0 then 1, suspending and resuming DOOM's whole audio engine and + * re-entering solo every time. Sounds faded in, and short ones could finish before the resume did. + * Establishing the mode once, before the first click, removes that. MAIN THREAD ONLY. */ +void sh_soundpreview_set_session(int on); + +/* Is something previewing right now? For the UI's play/stop button state. Thread-safe (plain read). */ +int sh_soundpreview_active(void); + +#endif /* BACKEND_SOUNDPREVIEW_H */ diff --git a/src/backend/swf_textedit.c b/src/backend/swf_textedit.c index 66a8051..d70e8c4 100644 --- a/src/backend/swf_textedit.c +++ b/src/backend/swf_textedit.c @@ -57,8 +57,8 @@ /* 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 +/* No SC_LCTRL/SC_RCTRL here on purpose: Ctrl is read from the OS at the moment C or V arrives + * (ctrl_is_held), never tracked from this key stream. See the note above swf_onkey_detour. */ #define SC_C 0x2e #define SC_V 0x2f @@ -102,7 +102,6 @@ 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. */ @@ -267,17 +266,38 @@ static int paste_at_selection(const uint8_t *ti) 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. */ +/* Is Ctrl PHYSICALLY held, right now? + * + * This deliberately does NOT track Ctrl from the onKey stream. The first version did, latching a + * static flag on the Ctrl key-down and clearing it on the key-up, "the way the stock handler tracks + * Shift" -- and it shipped a bug that made the editor's text fields unusable: after one Ctrl+C, a + * bare `c` copied and a bare `v` pasted, forever. Typing the word "variable" pasted the clipboard + * into the field once per `v`. + * + * The cause is that the latch outlives the keystroke. The stock handler reads Shift only while + * processing the keystroke it was handed, so a Ctrl/Shift release it never sees costs nothing; + * ours was consulted on LATER keystrokes, so a single missed key-up left it stuck on. Modifier + * key-ups are not reliably delivered to a focused SWF script object -- the field can lose focus, the + * window can lose focus, or the engine may simply not dispatch them -- and any one of those arms the + * bug permanently. + * + * So: no state. Ask the OS for the real key state at the instant C or V arrives. There is nothing to + * go stale, alt-tabbing mid-chord cannot poison it, and it covers both Ctrl keys without caring + * which scancode the engine reports. GetAsyncKeyState reads physical key state directly rather than + * this thread's message queue, which matters because the hook runs on the engine's input path. */ +static int ctrl_is_held(void) +{ + return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; +} + +/* The detour. 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)) { + if (down && (key == SC_C || key == SC_V) && ctrl_is_held() && 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); diff --git a/src/backend/typeinfo.c b/src/backend/typeinfo.c index 8ba571c..bd0fe60 100644 --- a/src/backend/typeinfo.c +++ b/src/backend/typeinfo.c @@ -297,6 +297,196 @@ const char *sh_typeinfo_inherit_base(const char *inheritName, char *buf, size_t } __except (EXCEPTION_EXECUTE_HANDLER) { buf[0] = '\0'; return NULL; } } +/* -------------------------------------------------- MATERIAL decl-find (Revenant asset-viewport tab) ----- + * Resolves a MATERIAL decl by name using the SAME pure decl-find primitive as sh_typeinfo_inherit_base + * above (DECL_PURE_FIND_RVA -- read-lock -> hash -> probe -> cached-decl-or-NULL -> unlock; no load/parse/ + * FatalError trap), pointed at the MATERIAL type-manager's own ctx instead of the entityDef resource-mgr's. + * + * MATERIAL_MGR_CTX_RVA is DIRECT: read off the live `idSWFSpriteInstance::material` setter's own decl-find + * call in the doom-re `revenant-asset-index-and-viewport` campaign (evidence 05 SS8.2 -- that project's + * Ghidra numbering, image base 0x140000000, address 0x1459bd9d0 => RVA 0x59BD9D0). Reusing + * DECL_PURE_FIND_RVA against this different ctx is an ASSUMPTION that the pure-find primitive generalizes + * across resource-manager instances of the same shape -- corroborated (not proven) by the material ctx + * sitting only 0xE0 bytes from RESOURCE_MGR_CTX_RVA (0x59BD8F0), suggestive of a common per-decl-type + * context table. SEH-guarded either way: a wrong assumption degrades to "not found", never a crash. + * + * CORRECTED 2026-07-30 by live in-game test (user): this is NOT a "cached-only, session-so-far" lookup as + * first assumed here -- it resolves ANY of the shipped material decls (the full ~9,805-entry catalog), + * with no placement/rendering/prior use required, while a genuinely made-up name still correctly reports + * "not found" (confirmed with a negative-control test). The likely reason: material DECLS (unlike the GPU + * image data they may reference) are cheap text metadata the engine registers into the type manager's hash + * table for the whole catalog at boot, independent of whether any given material has actually been drawn + * yet -- so "pure find" here means "is this decl NAME known to the registry", not "has this material's + * resource been loaded". This is materially better than first thought: the resolve step already covers the + * full asset index, not just an in-use subset. Still NOT the load-or-create primitive (FUN_1417b36f0 in + * that campaign's numbering, which the same evidence flags as FatalError/INT3-trapping on a miss) -- no + * reason to touch that now that the pure find already covers the whole catalog. + * + * On a hit, ALSO best-effort calls the engine's own materialWidth/materialHeight getters (the same two + * functions the SWF native vars `materialWidth`/`materialHeight` call in that campaign -- RVAs 0xD75D40 / + * 0xD75B40, DIRECT from its decompile) on the resolved idMaterial*, rather than reimplementing their + * branchy fallback logic ourselves. A dimension-read fault degrades to "found" with no dimensions, never a + * crash -- the decl-find result is the useful part either way. CONFIRMED live (2026-07-30, user): the + * reported dimensions vary sensibly across materials (4096x4096 down to very small) -- real per-material + * image metadata, not a fixed fallback value, even for materials never placed/rendered this session. Safe + * to use for real dimension data, not just as a found/not-found signal. */ +#define MATERIAL_MGR_CTX_RVA 0x59BD9D0u /* material type-mgr ctx (DIRECT, revenant-asset-index-and-viewport evidence 05 SS8.2) */ +#define MATERIAL_WIDTH_FN_RVA 0xD75D40u /* idMaterial width getter (DIRECT, same campaign) */ +#define MATERIAL_HEIGHT_FN_RVA 0xD75B40u /* idMaterial height getter (DIRECT, same campaign) */ + +/* Structural probe only (2026-07-30) -- reads two more pointer hops WITHOUT touching Vulkan/GPU state, to + * confirm the offsets before any capture code is written. Traced from `idVirtualTexture::SetSource` + * (FUN_140E11C50 in the doom-re campaign's numbering): a VMTR-backed material's `+0x170` field (the SAME + * one the width/height getters above already read) is an `idVirtualTexture*`, and that object keeps an + * ALWAYS-RESIDENT low-res fallback texture -- a genuine `idImage` (built via the same ScratchImage / + * idImage_Vulkan_PC path evidence 05 SS13.2 already proved reusable, name-suffixed "_minlod", with its own + * FatalError check on allocation failure) -- at a fixed offset on the idVirtualTexture object. + * MATERIAL_VTEX_OFF is the SAME offset the width/height getters dereference (0x170); VTEX_MINLOD_IMAGE_OFF + * is DIRECT from SetSource's own `ScratchImage(..., "..._minlod", ...)` call site writing to `this+0x3F0`. + * This is INFERRED to generalize (SetSource's `this` and the getter's `*(material+0x170)` write/read the + * SAME header-derived field at the SAME +0x20 offset, which is the corroborating link -- not yet confirmed + * any further). Purely additive to the existing find/dimension result; never touches the material-find + * result or return value, so a wrong offset here degrades to blank probe fields, nothing else. + * + * THIRD ATTEMPT at the image's real pixel size (2026-07-31), after two live-tested failures. Both earlier + * guesses read a field that some OTHER code path happened to write; this one reads the field the Vulkan + * image-creation path ITSELF consumes, so it cannot be path-specific: + * + * idImage_Vulkan_PC::Create (FUN_140DADB70 in the campaign's numbering -- identified by its own assertion + * naming Image_Vulkan_PC.cpp) memsets a 0x58-byte stack struct, fills it, and passes it to vkCreateImage + * as pCreateInfo. 0x58 is sizeof(VkImageCreateInfo) exactly, and the fill is a byte-exact match to that + * struct's layout, confirmed by FIVE independent constants that could not all line up by chance: + * +0x00 sType = 0xE == VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO + * +0x10 flags = 0x10 == VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT (set only on the cube branch) + * +0x14 type = 1 or 2 == VK_IMAGE_TYPE_2D / _3D (selected by the same cube/3D discriminator) + * +0x30 samples = 1|2|4|8 exactly (VkSampleCountFlagBits' only legal values, from a 4-way switch) + * +0x3C/+0x40/+0x48 = sharingMode / queueFamilyIndexCount=2 / pQueueFamilyIndices -- all three set + * together off one global, the textbook CONCURRENT-sharing triple + * With the layout pinned, the extent fields are read verbatim from the image object: + * extent.width (+0x1C) <- *(int *)(image + 0x60) + * extent.height (+0x20) <- *(int *)(image + 0x64) + * mipLevels (+0x28) <- *(int *)(image + 0x70) + * These are populated by the opts->image field copy at the TOP of Create (image field = opts field + + * 0x54), and idImageManager::ScratchImage (FUN_140DA3600, named by its own "called with empty name" + * error) performs the IDENTICAL copy on its deferred branch too -- so the fields are valid whether or not + * the VkImage itself exists yet. This is why it beats both dead ends: it is the universal path every + * engine image passes through, not one creation variant. + * + * The min-LOD call site supplies KNOWN LITERALS for three of these, which this probe reads back as built-in + * negative controls -- the thing both failed attempts lacked. SetSource's "_minlod" ScratchImage opts have + * type=0 (2D), engine format=0x13, mipLevels=1, so IMAGE_TYPE_OFF/IMAGE_FMT_OFF/IMAGE_MIPS_OFF must read + * back exactly 0 / 0x13 / 1. If they do, the offset family is confirmed by three literal matches and the + * width/height read beside them is trustworthy; if they do not, the mapping is wrong and the dimensions + * must be discarded regardless of how plausible they look. IMAGE_CREATEFAIL_OFF is the same +0xBD byte + * SetSource itself tests to decide whether to FatalError on min-LOD allocation failure (expect 0), and + * IMAGE_VKIMAGE_OFF is the live VkImage handle vkCreateImage writes (expect non-NULL) -- together they say + * whether the image is really GPU-resident and thus copyable. + * + * PRIOR DEAD ENDS, both live-tested wrong, retained so neither gets retried: + * 0x38/0x3C -- came from a FILE-LOADING creation variant (FUN_140D9D240); those are decoded-source-art + * dimensions, a different field family from the created VkImage's extent. Not populated on a + * ScratchImage-created image. + * VTEX +0x1C -- SetSource really does pass this as both width and height, so the static read was right, + * but the value is in PAGES, not pixels (the same function computes it as `1 << (numLodLevels-1)` and + * derives the pixel size as `that * 120`, the known page quantum -- 2048 pages * 120 = 245760, the + * atlas width from promoted truth). It is also only overwritten on ONE of SetSource's two branches; on + * the other it retains raw .vmtr header bytes that happen to sit at that offset, which is exactly the + * large non-power-of-two garbage the live test saw. Kept below purely as a cross-check to print + * alongside the extent, NOT as a size. */ +#define MATERIAL_VTEX_OFF 0x170u /* material -> idVirtualTexture* (0 if not virtual-textured) */ +#define VTEX_MINLOD_IMAGE_OFF 0x3F0u /* idVirtualTexture -> idImage* (always-resident low-res fallback) */ +/* LIVE-CORRECTED 2026-07-31: +0x1C is the material's atlas Y COORDINATE, not a page count. The probe read + * 149760 for skull_key_gray, exactly that material's atlas y (its .vmtr row is `9600 149760 1920 1920` = + * x y w h). SetSource's two 8-byte header stores lay the row out as x@+0x18, y@+0x1C, w@+0x20, h@+0x24; + * the `1 << (numLodLevels-1)` write that suggested "pages" is on a branch these materials do not take. + * CONFIRMED as a by-product: +0x20 really is the atlas width -- the engine's own material width getter + * computes `*(vtex+0x20) * 128 / 120`, and 1920*128/120 = 2048 matched the live reported dimensions. */ +#define VTEX_ATLAS_Y_OFF 0x1Cu /* idVirtualTexture -> atlas Y coordinate (px) -- cross-check only */ +#define VTEX_ATLAS_W_OFF 0x20u /* idVirtualTexture -> atlas width (px); getter does *128/120 */ +#define IMAGE_TYPE_OFF 0x54u /* idImage -> image type; min-LOD control value: 0 (2D) */ +#define IMAGE_FMT_OFF 0x58u /* idImage -> engine format enum; min-LOD control value: 0x13 */ +#define IMAGE_EXTENT_W_OFF 0x60u /* idImage -> VkImageCreateInfo.extent.width (authoritative) */ +#define IMAGE_EXTENT_H_OFF 0x64u /* idImage -> VkImageCreateInfo.extent.height (authoritative) */ +#define IMAGE_MIPS_OFF 0x70u /* idImage -> VkImageCreateInfo.mipLevels; min-LOD control value: 1 */ +#define IMAGE_CREATEFAIL_OFF 0xBDu /* idImage -> creation-failure byte SetSource tests (expect 0) */ +#define IMAGE_VKIMAGE_OFF 0xE0u /* idImage -> live VkImage handle (expect non-NULL if resident) */ + +int sh_typeinfo_find_material(const char *name, char *buf, size_t cap) +{ + if (buf && cap) buf[0] = '\0'; + if (!g_doom_base || !name || !name[0] || !buf || cap < 2) return 0; + __try { + typedef void *(*decl_find_fn)(void *ctx, const char *name); + void *ctx = (void *)(g_doom_base + MATERIAL_MGR_CTX_RVA); + decl_find_fn find = (decl_find_fn)(g_doom_base + DECL_PURE_FIND_RVA); + void *material = find(ctx, name); + if (!material) return 0; + + int w = -1, h = -1; + __try { + typedef int (*dim_fn)(void *material); + dim_fn get_w = (dim_fn)(g_doom_base + MATERIAL_WIDTH_FN_RVA); + dim_fn get_h = (dim_fn)(g_doom_base + MATERIAL_HEIGHT_FN_RVA); + w = get_w(material); + h = get_h(material); + } __except (EXCEPTION_EXECUTE_HANDLER) { w = -1; h = -1; } + + /* structural probe -- see the comment above; never affects found/dims, best-effort only */ + int has_vtex = 0, has_minlod = 0; + int img_w = -1, img_h = -1, img_type = -1, img_fmt = -1, img_mips = -1; + int pages = -1, fail = -1, resident = 0; + __try { + void *vtex = *(void * const *)((const uint8_t *)material + MATERIAL_VTEX_OFF); + if (vtex) { + has_vtex = 1; + pages = *(const int *)((const uint8_t *)vtex + VTEX_ATLAS_Y_OFF); + void *minlod = *(void * const *)((const uint8_t *)vtex + VTEX_MINLOD_IMAGE_OFF); + if (minlod) { + const uint8_t *im = (const uint8_t *)minlod; + has_minlod = 1; + img_type = *(const int *)(im + IMAGE_TYPE_OFF); + img_fmt = *(const int *)(im + IMAGE_FMT_OFF); + img_w = *(const int *)(im + IMAGE_EXTENT_W_OFF); + img_h = *(const int *)(im + IMAGE_EXTENT_H_OFF); + img_mips = *(const int *)(im + IMAGE_MIPS_OFF); + fail = *(const uint8_t *)(im + IMAGE_CREATEFAIL_OFF); + resident = *(void * const *)(im + IMAGE_VKIMAGE_OFF) != NULL; + } + } + } __except (EXCEPTION_EXECUTE_HANDLER) { + has_vtex = 0; has_minlod = 0; + img_w = -1; img_h = -1; img_type = -1; img_fmt = -1; img_mips = -1; + pages = -1; fail = -1; resident = 0; + } + + char tag[192] = ""; + if (has_vtex) { + char pagetag[32] = ""; + if (pages > 0) _snprintf_s(pagetag, sizeof pagetag, _TRUNCATE, " atlasY=%d", pages); + if (has_minlod) { + /* the three literals SetSource itself wrote -- if these do not read back exactly, the + * offset mapping is wrong and img_w/img_h must NOT be believed, however plausible */ + int ctl = (img_type == 0 && img_fmt == 0x13 && img_mips == 1); + _snprintf_s(tag, sizeof tag, _TRUNCATE, + " [vt+minlod %dx%d ctl=%s(t%d/f%#x/m%d) fail=%d vk=%s%s]", + img_w, img_h, ctl ? "OK" : "BAD", img_type, (unsigned)img_fmt, img_mips, + fail, resident ? "yes" : "no", pagetag); + } else { + _snprintf_s(tag, sizeof tag, _TRUNCATE, " [vt, no minlod%s]", pagetag); + } + } + + if (w > 0 && h > 0) + _snprintf_s(buf, cap, _TRUNCATE, "found (%dx%d)%s", w, h, tag); + else + _snprintf_s(buf, cap, _TRUNCATE, "found%s", tag); + return 1; + } __except (EXCEPTION_EXECUTE_HANDLER) { + if (cap) buf[0] = '\0'; + return 0; + } +} + /* -------------------------------------------------- LIVE reflection type-registry walk (enumerate all) ---- * The registry is a NULL-name-sentinel flat array reachable from the SAME reflect sh_type uses: P = * *(reflect+0) (the container global), type-record array B = *(P+0x20), records stride 0x38, className @ diff --git a/src/backend/typeinfo.h b/src/backend/typeinfo.h index cbad019..5b935a0 100644 --- a/src/backend/typeinfo.h +++ b/src/backend/typeinfo.h @@ -113,4 +113,25 @@ int sh_typeinfo_collect_records(sh_ti_record *out, int cap); * or -1 if the manager is unreachable (caller falls back to a static list). */ int sh_typeinfo_collect_inherits(const char **out_names, int cap); +/* Resolve a MATERIAL decl by name via the SAME pure decl-find primitive sh_typeinfo_inherit_base uses + * (DECL_PURE_FIND_RVA -- read-lock -> hash -> probe -> cached-decl-or-NULL -> unlock; no load/parse/ + * FatalError trap), pointed at the material type-manager's own ctx instead of the entityDef one. + * + * Live-tested in-game (2026-07-30): resolves ANY shipped material decl (the full ~9,805-entry catalog), + * with no placement/rendering/prior use required -- an earlier "cached-only, session-so-far" assumption in + * the .c file's comment was WRONG and is corrected there; a genuinely made-up name still correctly reports + * "not found" (confirmed with a negative-control test). Material decls are cheap text metadata registered + * for the whole catalog at boot, independent of whether the material has actually been drawn. + * + * Deliberately still NOT the load-or-create primitive (FUN_1417b36f0 in the doom-re + * revenant-asset-index-and-viewport campaign's numbering), which has FatalError/INT3 traps on an + * unresolvable name and must never be called here -- no reason to touch it now that the pure find already + * covers the whole catalog. On a hit, best-effort (SEH-guarded, degrades gracefully) also reads the + * material's bound-image width/height via the engine's own getters rather than reimplementing their + * fallback logic -- CONFIRMED live (2026-07-30) to return real per-material dimensions (varying sensibly, + * 4096x4096 down to very small), not a fixed fallback, even for a never-rendered material. Writes a short + * human-readable result into buf ("found (WxH)" / "found" / empty on a miss) and returns 1 on a hit, 0 + * otherwise (miss, bad args, or engine unavailable -- never a crash). */ +int sh_typeinfo_find_material(const char *name, char *buf, size_t cap); + #endif /* BACKEND_B2_TYPEINFO_H */ diff --git a/src/common/snapmap_plus_iface.c b/src/common/snapmap_plus_iface.c index e388957..1bab4a0 100644 --- a/src/common/snapmap_plus_iface.c +++ b/src/common/snapmap_plus_iface.c @@ -312,6 +312,15 @@ void sh_iface_bind_engine_slots(const sh_iface_engine_slots *s) /* 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 */ + /* clone-extension (FIND a material decl by name; cached-only lookup, asset-viewport tab probe). */ + g_iface_vtbl_live.find_material = s->find_material; /* +0x2C8 */ + g_iface_vtbl_live.get_preview = s->get_preview; /* +0x2D0 */ + g_iface_vtbl_live.request_preview = s->request_preview; /* +0x2D8 */ + g_iface_vtbl_live.list_materials = s->list_materials; /* +0x2E0 */ + g_iface_vtbl_live.list_assets = s->list_assets; /* +0x2E8 */ + g_iface_vtbl_live.material_rect = s->material_rect; /* +0x2F0 */ + g_iface_vtbl_live.sound_preview = s->sound_preview; /* +0x2F8 */ + g_iface_vtbl_live.sound_session = s->sound_session; /* +0x300 */ } /* --------------------------------------------------------------------- the factory ----------------- diff --git a/src/common/snapmap_plus_iface.h b/src/common/snapmap_plus_iface.h index 696c095..4e1612e 100644 --- a/src/common/snapmap_plus_iface.h +++ b/src/common/snapmap_plus_iface.h @@ -232,6 +232,124 @@ typedef int (*sh_clear_stack_fn)(struct sh_iface *self, int index); * 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) */ +/* +0x2C8 (ext 12) FIND MATERIAL by name (the Revenant asset-viewport tab's first probe). Resolves a + * MATERIAL decl through the engine's PURE decl-find (never the load-or-create primitive, which has + * FatalError/INT3 traps on a miss -- see typeinfo.c). Live-tested in-game (2026-07-30): resolves ANY + * shipped material (the full ~9,805-entry catalog) with no placement/prior use required -- material decls + * are registered for the whole catalog at boot, independent of whether the material has been drawn; a + * genuinely made-up name still correctly reports "not found". Writes a short human-readable result into + * out_info ("found (WxH)" / "found" / empty), returns 1 on a hit, 0 otherwise (miss / bad name / engine + * down). */ +/* +0x2D0 (ext 13) Fetch the latest rendered asset preview as a `data:image/bmp;base64,...` URI. + * Returns length, 0 if nothing captured yet, or -(required size) when `cap` is too small. */ +typedef int (*sh_get_preview_fn)(struct sh_iface *self, char *out, int cap); + +/* +0x2D8 (ext 14) Request that `name` be previewed. ASYNCHRONOUS: it stages the name and invalidates the + * current image; pixels are produced on another thread and take some time to arrive. Poll get_preview + * (+0x2D0) until it returns > 0. Returns 1 if the request was staged, 0 if it was rejected (null/empty + * name, or the engine side is not installed). Staging always succeeds even when no image producer is + * installed -- in that case the poll simply times out. */ +typedef int (*sh_request_preview_fn)(struct sh_iface *self, const char *name); + +typedef int (*sh_find_material_fn)(struct sh_iface *self, const char *name, + char *out_info, int cap); /* +0x2C8 (ext 12) */ + +/* +0x2E0 (ext 15) Enumerate material names for the Assets browser, newline-separated, starting at + * `start`. Returns how many were written; 0 means no more. The catalog is ~9,805 names (~400 KB), + * so the caller pages: add the returned count to `start` and ask again until it returns 0. */ +typedef int (*sh_list_materials_fn)(struct sh_iface *self, int start, char *out, int cap); + +/* Asset types the browser can enumerate, in the order it lists them. These values ARE ABI -- the UI + * sends one to list_assets -- so append only, never renumber. MATERIAL and IMAGE are 0 and 1 + * because the preview producers address container records by those same ids. + * + * The set is exactly the decl types in the shipped containers that a mapper can act on. Engine + * internals present in the same index (renderProg, anim, cm, aas, table, ...) are deliberately + * absent: there is nothing to place or apply, so listing them would only be noise. */ +#define SH_ASSET_MATERIAL 0 +#define SH_ASSET_IMAGE 1 +#define SH_ASSET_MODEL 2 +#define SH_ASSET_SOUND 3 +#define SH_ASSET_FX 4 +#define SH_ASSET_PARTICLE 5 +#define SH_ASSET_DECALATLAS 6 +#define SH_ASSET_SNAPDEF 7 /* snapEditorEntityDef -- the SnapMap editor's placeable list */ +#define SH_ASSET_ENTITYDEF 8 +/* Appended 2026-08-04. Baked BRUSH geometry -- the `maps/...` half of the `model` decl type, which + * the Models category deliberately excludes (it keeps `models/...` props: .lwo + md6Def). + * MODULE the 232 `palettes/mega_blessed` SnapMap modules. Each pairs 1:1 with a `_combo/world.bcm`, + * so one of these can be placed as a prop that is BOTH visible and solid -- see + * abModuleClip() in the UI and the doom-re campaign's evidence 10 sec 3.2. + * BMODEL every other .bmodel: the individual wall/floor pieces those modules are assembled from, + * plus the invisible internals (navmesh, occlusion, umbra, clip). Render-only; the + * component pieces have no collision of their own because it is baked at the combo level. + * CLIPMODEL the `cm` decl type (.bcm/.lwo/.md6), appliable on its own as clipModelInfo.clipModelName. */ +#define SH_ASSET_MODULE 9 +#define SH_ASSET_BMODEL 10 +#define SH_ASSET_CLIPMODEL 11 +/* Appended 2026-08-06. NOT a browser category -- a QUALIFIER on SH_ASSET_MATERIAL. + * + * A material can be addressed two independent ways: by NAME through a `material` decl + * (`customMaterial`), or by RECTANGLE through the `.vmtr` megatexture atlas (`virtualmapping`). + * Neither set contains the other, and thousands of shipped atlas rows have no decl at all. The + * MATERIAL list is therefore the union of both, so every applyable texture is searchable. + * + * This kind lists the atlas-only subset -- the names in MATERIAL that have NO decl. The UI fetches + * it once and keeps it as a set, so "can this take customMaterial?" is answered locally and + * instantly for any name, instead of a per-selection round-trip. */ +#define SH_ASSET_VTONLY 12 +/* Appended 2026-08-10. Like SH_ASSET_VTONLY this is NOT a browser category -- a QUALIFIER on + * SH_ASSET_SOUND. Each line is `event|bank`, the soundbank a sound came from. + * + * Sound names are almost entirely FLAT, so a folder tree built from them is one giant root. The + * only real structure the catalog has is the grouping in soundbanksinfo.xml -- 26 + * banks, sensibly sized, and `doom_snapmaps` in particular is the 485 events that are SnapMap's + * own. The UI fetches this once and keeps it as a map, so filtering by bank costs no round-trip. */ +#define SH_ASSET_SNDBANK 13 +/* Appended 2026-08-10. Two REAL categories, both reference-only. + * + * PERK: the `perks` decl type, 190 of them. A perk is activated by idTarget_Command rather than + * placed, and the command structure is not worked out yet, so this is a name you copy and wire by + * hand. Listed because the alternative is not knowing the names exist. + * + * SWF: the Flash movies, 193 of them. NOT a decl type at all -- they are `file` records, which is + * exactly why they are reference-only: a .swf belongs to some other entity that owns a screen, and + * that entity's shape is unknown the same way the perk command is. Names are rewritten to the form + * decls actually reference (`swf/interactables/elite_guard.swf`), NOT the baked artifact on disk + * (`generated/swf/interactables/elite_guard.bswf`) -- the baked name appears in no decl anywhere and + * would be uncopyable. Verified both directions: all 193 live under generated/swf/, and all 30 swf + * names referenced by entity defs resolve to one. */ +#define SH_ASSET_PERK 14 +#define SH_ASSET_SWF 15 +/* LIGHT: the light MATERIALS -- the projection/falloff textures a light shines through, which is + * what `lightMaterial` names. NOT the light entity: point vs spot is which entity carries the + * material, and that is the Create-as choice, not the asset. The editor def exposes the same field + * as `#str_snapproperty_light_type`, so this list is that dropdown. + * + * The union of two sources, the same shape Materials has: `material` decls under `lights/` (78) plus + * `lightatlas` rows that have no decl (28) = 106. The decls are PROMOTED out of Materials rather + * than copied -- a light projection is not a surface anyone would put on a wall, so listing it in + * both places would only ever be the wrong answer in one of them. */ +#define SH_ASSET_LIGHT 16 +#define SH_ASSET_COUNT 17 + +/* Page ONE asset type's catalog. `kind` is an SH_ASSET_* value (backend/imgpreview.h); `start` is + * how many names of that type to skip. Supersedes list_materials, which is kind 0 and stays put + * because the vtable is append-only. */ +typedef int (*sh_list_assets_fn)(struct sh_iface *self, int kind, int start, char *out, int cap); +/* A material's .vmtr atlas rect -> out_xywh = {x,y,w,h} in atlas pixels; 1 if virtual-textured, + * 0 if it has no rect. Divide by 245760 for the `virtualmapping` renderParm value form. */ +typedef int (*sh_material_rect_fn)(struct sh_iface *self, const char *name, int *out_xywh); +/* Audition a soundshader by name through the editor's own preview path, or -- with a NULL/empty + * name -- stop whatever is auditioning. One preview exists at a time; playing a second stops the + * first, so the caller never has to pair the calls. Returns 1 if something is now playing. + * Backend-side this drives live audio state, so the UI must reach it from the main-thread drain. */ +typedef int (*sh_sound_preview_fn)(struct sh_iface *self, const char *name); +/* Hold sound-preview mode open while the asset browser is on screen (on=1) and drop it on the way + * out (on=0, which also stops playback). The cvars an audition needs cost an audio-engine suspend + * and resume to change, so they are established once per session rather than per click. */ +typedef void (*sh_sound_session_fn)(struct sh_iface *self, int on); + /* +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 * slots. `get` returns the required byte count excluding NUL; a NULL/zero buffer is a size query and an @@ -434,12 +552,36 @@ typedef struct sh_iface_vtbl { 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_find_material_fn find_material; /* +0x2C8 (ext 12) FIND a material decl by name + * (cached-only; the Revenant asset-viewport tab probe) */ + sh_get_preview_fn get_preview; /* +0x2D0 (ext 13) latest engine-rendered asset + * preview as a data:image/bmp;base64 URI */ + sh_request_preview_fn request_preview; /* +0x2D8 (ext 14) ask for a NAMED asset to be + * produced into that preview */ + sh_list_materials_fn list_materials; /* +0x2E0 (ext 15) page the material catalog + * for the Assets browser list */ + sh_list_assets_fn list_assets; /* +0x2E8 (ext 16) page ANY indexed asset type + * (models, sounds, fx, particles, defs, ...) */ + sh_material_rect_fn material_rect; /* +0x2F0 (ext 17) a material's atlas rect, + * for the virtualmapping carrier */ + sh_sound_preview_fn sound_preview; /* +0x2F8 (ext 18) audition a sound decl; + * NULL/empty name = stop */ + sh_sound_session_fn sound_session; /* +0x300 (ext 19) hold preview mode open + * while the browser is on screen */ } 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(offsetof(sh_iface_vtbl, manipulation_in_progress) == 0x2C0); -SH_STATIC_ASSERT(sizeof(sh_iface_vtbl) == 0x2C8); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, find_material) == 0x2C8); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, get_preview) == 0x2D0); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, request_preview) == 0x2D8); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, list_materials) == 0x2E0); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, list_assets) == 0x2E8); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, material_rect) == 0x2F0); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, sound_preview) == 0x2F8); +SH_STATIC_ASSERT(offsetof(sh_iface_vtbl, sound_session) == 0x300); +SH_STATIC_ASSERT(sizeof(sh_iface_vtbl) == 0x308); /* ------------------------------------------------------------------ the interface object ----------- * Object layout PINNED to FUN_1800229b1: +0x00 vtable, +0x08 mutex, +0x58 sub-object. The mutex is an @@ -592,6 +734,15 @@ typedef struct sh_iface_engine_slots { 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) */ + /* clone-extension: FIND a material decl by name (cached-only lookup; asset-viewport tab probe). */ + sh_find_material_fn find_material; /* +0x2C8 (ext 12) */ + sh_get_preview_fn get_preview; /* +0x2D0 (ext 13) */ + sh_request_preview_fn request_preview; /* +0x2D8 (ext 14) */ + sh_list_materials_fn list_materials; /* +0x2E0 (ext 15) */ + sh_list_assets_fn list_assets; /* +0x2E8 (ext 16) */ + sh_material_rect_fn material_rect; /* +0x2F0 (ext 17) */ + sh_sound_preview_fn sound_preview; /* +0x2F8 (ext 18) */ + sh_sound_session_fn sound_session; /* +0x300 (ext 19) */ } 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 b73dd52..c25cfb1 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -1,4 +1,11 @@ + @@ -46,6 +53,129 @@ .tab { padding: 7px 14px; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; } .tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } .tab:hover:not(.active) { color: var(--text); } + .mat-result { font-weight: 600; } + .mat-result.ok { color: #2e7d46; } + .mat-result.err { color: var(--danger); } + + /* ---- asset browser ------------------------------------------------------------------------- + * Three columns: type rail | catalog | asset inspector. Mounted twice -- once as the Assets tab + * (open browsing) and once inside a modal opened from the Entities tab (scoped to the selection). + * Everything below is built from the existing tokens and .panel/.entity-item/.btn skeleton so the + * browser reads as part of the app rather than a bolted-on pane. */ + .ab-grid { display: flex; gap: 8px; align-items: stretch; flex: 1; min-height: 0; } + .ab-rail { width: 138px; flex: 0 0 138px; } + .ab-mid { flex: 1 1 auto; min-width: 200px; } + .ab-insp { width: 268px; flex: 0 0 268px; } + /* Narrow window: drop the type rail first (only one type is indexed, and the filter reaches + everything anyway). The inspector is never dropped -- it holds the preview and the copy + button, which are the whole point of the pane. */ + @media (max-width: 1000px) { .ab-rail { display: none; } } + @media (max-width: 760px) { .ab-insp { width: 208px; flex: 0 0 208px; } } + + .ab-rail .rail-head { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--muted); padding: 8px 7px 3px; } + .ab-rail .rail-head:first-child { padding-top: 3px; } + .ab-rail .entity-item { display: flex; align-items: baseline; gap: 6px; color: var(--text); cursor: pointer; } + .ab-rail .entity-item.off { color: var(--muted); opacity: .6; cursor: default; } + .ab-rail .entity-item.off:hover { background: transparent; } + .ab-rail .entity-item .n { margin-left: auto; color: var(--muted); font-family: Consolas, "Courier New", monospace; + font-size: 10px; font-variant-numeric: tabular-nums; } + .ab-rail .entity-item.selected .n { color: var(--selText); } + + /* Keyboard cursor. Distinct from .selected on purpose: arrowing through a 3,000-entry folder moves + this, and only Enter/Space commits it to the selection -- so the two have to be tellable apart + while both are on screen. An outline rather than a fill, drawn inside the row so it does not + shift anything. */ + .ab-grid .entity-item.kb { outline: 1px solid var(--accent); outline-offset: -1px; border-radius: 4px; } + + /* Breadcrumbs, because the catalog is drilled one folder at a time (see renderAbTree). */ + .ab-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; color: var(--muted); + font-family: Consolas, "Courier New", monospace; font-size: 10.5px; line-height: 1.5; } + .ab-crumbs .cb { color: var(--link); cursor: pointer; padding: 0 2px; border-radius: 3px; } + .ab-crumbs .cb:hover { background: var(--sel); color: var(--selText); } + .ab-crumbs .cb-flat { font-style: italic; } + + .ab-row { display: flex; align-items: center; gap: 6px; } + .ab-row .tw { width: 10px; flex: 0 0 10px; color: var(--muted); font-size: 9px; text-align: center; } + .ab-row .nm { overflow: hidden; text-overflow: ellipsis; } + .ab-row .leaf { font-family: Consolas, "Courier New", monospace; font-size: 11px; } + .ab-row .cnt { margin-left: auto; color: var(--muted); font-family: Consolas, "Courier New", monospace; + font-size: 10px; font-variant-numeric: tabular-nums; } + /* Copy rides ON the row: pasting a path into the Decl Text editor is the most common thing done + with a name here, so it must not be buried behind selecting the asset first. */ + .ab-row .cp { margin-left: auto; opacity: 0; border: 1px solid var(--border2); background: var(--panel); + color: var(--muted); border-radius: 4px; padding: 0 5px; font-size: 10px; cursor: pointer; + font-family: inherit; flex-shrink: 0; } + .entity-item:hover .cp, .entity-item.selected .cp { opacity: 1; } + .ab-row .cp:hover { border-color: var(--accent); color: var(--text); } + /* The pin flag is a GUTTER on the left, not one of the right-hand actions: it reports state, and + mixing a state indicator in with Copy made the row read lopsided. Fixed width so every name + starts on the same column whether or not the row is pinned or is a folder -- a ragged left edge + is worse than a blank cell. Borderless and transparent: it should look like a margin mark, not + a second button competing with Copy. */ + /* Sized UP from the row text, not matched to it: the name is an 11px monospace string that you + read, the star is a single mark you scan for at a glance down the left edge. At 11px it was not + legible as a star at all, and 15px still read as a speck -- so it gets its own much larger size. + The BOX is pinned to the row's ~16px line box while the glyph is allowed to overflow it, so the + star can grow past the text without every row in the list getting taller. */ + .ab-row .ab-pin, .ab-row .ab-pin-gap { width: 18px; flex: 0 0 18px; } + .ab-row .ab-pin { opacity: 0; border: 0; background: none; color: var(--muted); + padding: 0; font-size: 18px; line-height: 1; height: 16px; cursor: pointer; + font-family: inherit; display: inline-flex; align-items: center; + justify-content: center; } + .entity-item:hover .ab-pin { opacity: .55; } + .ab-row .ab-pin:hover { opacity: 1; color: var(--accent); } + /* A PINNED row keeps its flag visible without hover -- otherwise the only way to see what you + pinned is to hover every row one at a time, which defeats the point of a shortlist. */ + .ab-row .ab-pin.on { opacity: 1; color: var(--accent); } + + .ab-insp .panel-body { padding: 10px; display: flex; flex-direction: column; gap: 9px; } + /* Checkerboard: many assets are white-on-transparent (GUI icons, decals, POI markers). On a flat + dark background those read as an empty pane; on a checker the shape is legible. */ + .ab-thumb { width: 100%; aspect-ratio: 1; border: 1px solid var(--border2); border-radius: 5px; display: block; + background-color: #2a2a30; image-rendering: pixelated; object-fit: contain; + background-image: linear-gradient(45deg,#3a3a42 25%,transparent 25%,transparent 75%,#3a3a42 75%), + linear-gradient(45deg,#3a3a42 25%,transparent 25%,transparent 75%,#3a3a42 75%); + background-size: 16px 16px; background-position: 0 0, 8px 8px; } + .ab-nothumb { width: 100%; aspect-ratio: 1; border: 1px dashed var(--border2); border-radius: 5px; + display: flex; align-items: center; justify-content: center; text-align: center; + color: var(--muted); font-style: italic; padding: 14px; line-height: 1.45; } + /* The audition card takes the preview square for sounds, which have no pixels to show. Same + footprint as .ab-thumb / .ab-nothumb so the panel below it does not move between asset types. */ + .ab-audition { width: 100%; aspect-ratio: 1; border: 1px solid var(--border2); border-radius: 5px; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; background: var(--panel2); } + .ab-audition.playing { border-color: var(--accent); } + .ab-audition .ab-play { width: 54px; height: 54px; border-radius: 50%; font-size: 17px; line-height: 1; + padding: 0; display: flex; align-items: center; justify-content: center; } + .ab-audition.playing .ab-play { border-color: var(--accent); color: var(--accent); } + .ab-audition-note { color: var(--muted); font-size: 11px; } + .ab-audition.playing .ab-audition-note { color: var(--text); } + .ab-pathrow { display: flex; gap: 5px; } + .ab-pathrow input { flex: 1; min-width: 0; font-family: Consolas, "Courier New", monospace; color: var(--muted); } + .ab-sect { border-top: 1px solid var(--border); padding-top: 8px; display: flex; flex-direction: column; gap: 5px; } + .ab-sect > .panel-title { padding: 0; } + .ab-opt { display: flex; gap: 6px; align-items: flex-start; line-height: 1.4; } + .ab-opt input { margin: 2px 0 0; } + .ab-opt .why { color: var(--muted); display: block; } + .ab-opt.off { opacity: .55; } + .ab-meta { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; } + .ab-meta dt { color: var(--muted); } + .ab-meta dd { margin: 0; text-align: right; font-family: Consolas, "Courier New", monospace; + font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; } + + /* the asset browser opened from the Entities tab: a wide modal, not the narrow prompt-sized one */ + .modal.assets { width: min(1040px, 94vw); height: min(660px, 88vh); padding: 0; gap: 0; + overflow: hidden; } + .modal.assets .modal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; + border-bottom: 1px solid var(--border); background: var(--panel2); flex-shrink: 0; } + .modal.assets .ab-grid { padding: 8px; } + .modal.assets .modal-foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; + padding: 8px 12px; border-top: 1px solid var(--border); + background: var(--panel2); flex-shrink: 0; } + .modal.assets .modal-foot .grow { flex: 1; min-width: 140px; color: var(--muted); + font-family: Consolas, "Courier New", monospace; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* the global camera group lives in the tabstrip's spare right-side space */ .camera { display: flex; align-items: center; gap: 8px; padding: 2px 6px; color: var(--muted); } .camera label { display: flex; align-items: center; gap: 4px; color: var(--text); } @@ -79,9 +209,12 @@ .entity-item.selected .name { color: var(--selText); } .list-empty { padding: 10px; color: var(--muted); font-style: italic; } - input[type=text], textarea, select { background: var(--field); color: var(--text); border: 1px solid var(--border2); border-radius: 5px; padding: 4px 7px; font-size: 11px; font-family: inherit; user-select: text; -webkit-user-select: text; } - input[type=text]:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); } + input[type=text], input[type=number], textarea, select { background: var(--field); color: var(--text); border: 1px solid var(--border2); border-radius: 5px; padding: 4px 7px; font-size: 11px; font-family: inherit; user-select: text; -webkit-user-select: text; } + input[type=text]:focus, input[type=number]:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); } input[type=checkbox] { accent-color: var(--accent); } + /* Chromium's native number-input spinner is its own separate control with its own sizing -- + * it never picks up the field styling above and looks visibly foreign next to it. Suppress it. */ + input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .btn { padding: 4px 12px; border: 1px solid var(--border2); background: var(--panel2); color: var(--text); cursor: pointer; border-radius: 5px; font-size: 11px; } .btn:hover:not(:disabled) { border-color: var(--accent); } @@ -360,6 +493,10 @@
+ +
Assets
Entities
Prefabs
Timelines
@@ -388,6 +525,9 @@ + +
Waiting for the map...
@@ -474,6 +614,12 @@ + + +