From c8854a94f8cd10b8d06e5528aaa78aff088b8900 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:10:35 -0500 Subject: [PATCH 01/30] preview: replace the render-capture route with a route-independent transport The in-engine render-capture route is retired. It worked -- engine draw into our own render target, GPU->CPU copy, BMP, WebView -- but only for materials the loaded map already renders, so coverage depended on which map was open. For an asset browser, whose whole purpose is previewing textures you have NOT used yet, that is disqualifying. It survives intact on experimental/asset-preview-render (f84b66c..8b4a6b5) and is not deleted, just no longer built. What survives here is the half that was never route-specific: the transport. src/backend/preview.{c,h} owns the request/publish handshake, the RGBA -> 24bpp BMP -> base64 data URI encode, and the cross-thread buffer. It makes no engine calls at all. The producer seam is sh_preview_take_request -> sh_preview_publish. No producer is installed yet, so a request stages a name and the UI poll times out. That is deliberate: the replacement producer decodes the megatexture pages on the CPU by calling DOOM's own page decoder, which needs no renderer and no map residency -- proven offline, 68/68 pages, in the doom-re campaign revenant-asset-index-and-viewport (evidence 06/07/08). The iface slots are kept and are route-independent: +0x2C8 ext 12 find_material (cached-only decl lookup) +0x2D0 ext 13 get_preview (data:image/bmp;base64 URI) +0x2D8 ext 14 request_preview (stage a name; async) sizeof(sh_iface_vtbl) stays 0x2E0 and every prior offset is unchanged, so this is append-only as usual and the test pins still hold. Co-Authored-By: Claude Opus 5 --- src/backend/build.ps1 | 2 +- src/backend/iface_engine.c | 39 ++++- src/backend/preview.c | 167 ++++++++++++++++++ src/backend/preview.h | 59 +++++++ src/backend/typeinfo.c | 190 +++++++++++++++++++++ src/backend/typeinfo.h | 21 +++ src/common/snapmap_plus_iface.c | 4 + src/common/snapmap_plus_iface.h | 37 +++- src/ui/webview/mockup.html | 119 ++++++++++++- src/ui/webview/snapmap_plus_ui_webview.cpp | 78 +++++++++ tests/iface_config_test.c | 12 +- 11 files changed, 723 insertions(+), 5 deletions(-) create mode 100644 src/backend/preview.c create mode 100644 src/backend/preview.h diff --git a/src/backend/build.ps1 b/src/backend/build.ps1 index 01f8e2b..652d2f3 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", "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/iface_engine.c b/src/backend/iface_engine.c index 1478912..723b51e 100644 --- a/src/backend/iface_engine.c +++ b/src/backend/iface_engine.c @@ -30,7 +30,8 @@ #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 "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 +463,37 @@ 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; +} + static void mode_set_selection_state(int state) { const uint8_t *ed = editor_session(); @@ -1253,6 +1285,11 @@ 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 */ sh_iface_bind_engine_slots(&slots); char line[200]; diff --git a/src/backend/preview.c b/src/backend/preview.c new file mode 100644 index 0000000..8b9f3ac --- /dev/null +++ b/src/backend/preview.c @@ -0,0 +1,167 @@ +/* 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 (no producer installed yet; see preview.h)", + 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/bmp;base64,"; + + if (!rgba || w == 0 || h == 0) return; + + __try { + /* BMP rows are padded to a 4-byte boundary. 256*3 and 128*3 both happen to be aligned already, + * but the producer picks the size, so do it properly rather than relying on that. */ + const unsigned rowBytes = ((w * 3u) + 3u) & ~3u; + const unsigned pixBytes = rowBytes * h; + const unsigned fileSize = 54u + pixBytes; + + unsigned char *bmp = (unsigned char *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, fileSize); + if (!bmp) { backend_log("B2: preview -- BMP alloc failed"); return; } + + bmp[0] = 'B'; bmp[1] = 'M'; + *(unsigned *)(bmp + 2) = fileSize; + *(unsigned *)(bmp + 10) = 54u; /* pixel data offset */ + *(unsigned *)(bmp + 14) = 40u; /* BITMAPINFOHEADER */ + *(int *) (bmp + 18) = (int)w; + *(int *) (bmp + 22) = -(int)h; /* negative = top-down */ + *(unsigned short *)(bmp + 26) = 1; /* planes */ + *(unsigned short *)(bmp + 28) = 24; /* bpp */ + *(unsigned *)(bmp + 34) = pixBytes; + + /* Source is RGBA8 (that is what the engine's page decoder emits, and it was also the capture + * format of the retired route); BMP wants BGR -- hence the swap. Alpha is dropped. */ + for (unsigned y = 0; y < h; ++y) { + const unsigned char *src = rgba + (size_t)y * w * 4u; + unsigned char *dst = bmp + 54u + (size_t)y * rowBytes; + for (unsigned x = 0; x < w; ++x) { + dst[x * 3u + 0] = src[x * 4u + 2]; /* B */ + dst[x * 3u + 1] = src[x * 4u + 1]; /* G */ + dst[x * 3u + 2] = src[x * 4u + 0]; /* R */ + } + } + + 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); + + size_t 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 BMP, %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/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..b995328 100644 --- a/src/common/snapmap_plus_iface.c +++ b/src/common/snapmap_plus_iface.c @@ -312,6 +312,10 @@ 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 */ } /* --------------------------------------------------------------------- the factory ----------------- diff --git a/src/common/snapmap_plus_iface.h b/src/common/snapmap_plus_iface.h index 696c095..7ca561a 100644 --- a/src/common/snapmap_plus_iface.h +++ b/src/common/snapmap_plus_iface.h @@ -232,6 +232,28 @@ 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) */ + /* +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 +456,21 @@ 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_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(sizeof(sh_iface_vtbl) == 0x2E0); /* ------------------------------------------------------------------ the interface object ----------- * Object layout PINNED to FUN_1800229b1: +0x00 vtable, +0x08 mutex, +0x58 sub-object. The mutex is an @@ -592,6 +623,10 @@ 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_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 1439b41..1a03273 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -46,6 +46,9 @@ .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); } /* 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); } @@ -363,6 +366,7 @@
Entities
Prefabs
Timelines
+
Assets
Camera @@ -474,6 +478,34 @@
+ + + @@ -3004,8 +3013,6 @@ onPreviewImage(d); } else if (d.kind === 'previewRequested') { onPreviewRequested(d); - } else if (d.kind === 'materialResult') { - renderMaterialResult(d); } else if (d.kind === 'events') { eventItems = d.items || []; eventCatalogByName = {}; @@ -3674,13 +3681,19 @@ * material/snapmap/entitybase, which does not. dynamic_block_solid is the flat-colour sibling. */ var VM_BLOCK_MATERIAL = 'material/snapmap/dynamic_block_textured'; - function dpApply(decl, type, carrier, name, rect) { + function dpApply(decl, type, carrier, name, rect, extra) { var e = dpEditBlock(decl || ''); var s = e.text, open = e.open, close = e.close; /* customMaterial and model both live in renderModelInfo, which is created when missing -- * absence does NOT mean the entity cannot render (a trigger legitimately can), and the engine * refuses the save if it really is wrong. */ + /* No ramp1/bloommaskscale here. They are read by the TRIPLANAR ambientprogram, and a by-name + * apply lands on whatever program the named material already declares -- `outsidesnapbox`, + * `alphatintoutside` and friends, none of which reference `$ramp1`. Tested in-editor: the parms + * take effect only on the SnapMap palette's own triplanar materials, not on textures applied by + * name, so writing them on this path would be inert for nearly every material in the catalog. + * Virtual Mapping is the carrier that always brings the triplanar program with it. */ if (carrier === 'cm' || carrier === 'm') { var rm = dpEnsureBlock(s, open, close, 'renderModelInfo'); return dpSetScalar(rm.text, rm.open, rm.close, @@ -3696,7 +3709,23 @@ /* (w, h, x, y) / 245760 -- verified against a known atlas rect. */ var v = '{ ' + [rect.w, rect.h, rect.x, rect.y] .map(function (n) { return String(n / 245760); }).join(', ') + ' }'; - return dpSetRenderParm(rm2.text, rm2.open, rm2.close, 'virtualmapping', v); + var out = dpSetRenderParm(rm2.text, rm2.open, rm2.close, 'virtualmapping', v); + /* The two companions. `extra` carries the caller's choices; each is written only when it has a + * value, so "keep existing" genuinely leaves the entity's own alone. An absent ramp is NOT + * neutral (see the ramp1 note), but resolving that policy belongs to the caller, not here. + * + * Each write re-derives its block from the CURRENT text, the same way dpSetInBlock does -- + * every write shifts the offsets after it, so reusing rm2's bounds would splice at a stale + * position once virtualmapping had changed the string length. */ + var pairs = []; + if (extra && extra.ramp) pairs.push(['ramp1', extra.ramp]); + if (extra && extra.bloom) pairs.push(['bloommaskscale', extra.bloom]); + for (var pi = 0; pi < pairs.length; pi++) { + var e2 = dpEditBlock(out); + var rmN = dpEnsureBlock(e2.text, e2.open, e2.close, 'renderModelInfo'); + out = dpSetRenderParm(rmN.text, rmN.open, rmN.close, pairs[pi][0], pairs[pi][1]); + } + return out; } /* clipModelName is a member of idClipModelInfo, so it nests inside clipModelInfo -- NOT at the * edit level. `type` is deliberately left alone: the confirmed-solid test kept the inherited @@ -3732,6 +3761,10 @@ * Counts observed in 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 / fx 476. */ + /* Deliberately NOT an AB_TYPES row: it is a qualifier on Materials, not a category to browse. + * Must match SH_ASSET_VTONLY in src/common/snapmap_plus_iface.h. */ + var AB_KIND_VTONLY = 12; + var AB_TYPES = [ {id:'material', kind:0, label:'Materials'}, {id:'model', kind:2, label:'Models'}, @@ -3766,7 +3799,383 @@ } var AB_MAX_HITS = 400; /* cap the filter result DOM; the filter is what narrows */ + /* ---- Cross-platform textures ----------------------------------------------------------------- + * The 195 megatexture rects known to render as the same art on PC, Xbox and PlayStation. + * + * THIS SET IS EMPIRICAL AND CANNOT BE DERIVED. Most virtual-mapped textures are NOT the same + * across platforms -- the atlas is not laid out identically everywhere, so an arbitrary rect can be + * completely different art on a console. These 195 are the survivors of somebody's vigorous + * platform-by-platform testing, supplied to this project as a working rawmap. There is no rule in + * the .vmtr tables that separates them from the rest, so DO NOT try to compute a larger list: the + * only way to add an entry is to test it on the actual hardware. Treat any name not in this table + * as unknown rather than as excluded. + * + * What was verified here is only the transcription: every displayName in that rawmap matched, byte + * for byte, the name in the authoritative `.vmtr` atlas row at the same rect -- 195/195, no misses + * either direction. That makes the names and rects below trustworthy; it says nothing about why + * these particular ones are portable. + * + * BAKED rather than read from the owner's rawmap.json at runtime, because a collaborator who + * installs the build has no such file and this has to work for them too. + * + * Two things about this set are easy to get wrong: + * 1. The texture identity is the RECT, not a material name. All 344 source entities used just + * two carrier materials and varied only the virtualmapping value, so `d` below (does a + * `material` decl of this name exist?) is TRUE for only 33 of the 195. + * 2. Because of that, Custom Material cannot be offered for the other 162 -- there is no decl + * to name. Virtual Mapping is the only carrier that can carry them. abCarriers enforces it. + * + * Format: name|w|h|x|y|hasDecl, atlas pixels. virtualmapping = (w, h, x, y) / 245760. + * Previews need nothing extra: megapreview resolves names straight out of the .vmtr tables and + * never consults the material decl, so all 195 preview even without one. */ + var AB_XPLAT_RAW = [ + 'material/snapmap/dynamic_block_textured_hell_cave_facade|3840|3840|88320|138240|1', + 'models/mapobjects/ca/ca_mp_sacrilegious/ca_hell_hellsack_flat|3840|3840|92160|203520|0', + 'models/mapobjects/hell_terrain/cave_facade_b|3840|3840|15360|203520|0', + 'models/mapobjects/hell_terrain/cave_facade|3840|3840|19200|138240|1', + 'models/mapobjects/prop/jump_pads/jump_pad_templar_tile|3840|3840|7680|199680|0', + 'models/mapobjects/templar/vortex_arch|960|3840|66240|195840|0', + 'models/maps/game/dlc_3/mp_boneyard/egg_sack_pile|1920|1920|99840|228480|0', + 'models/monsters/cacodemon/cacodemon_e_eye_mp|120|120|7200|189120|1', + 'models/monsters/cyberdemon/cyberdemon_eyes|960|960|52800|13440|1', + 'mp/wires/mp_ca_wires_01|1920|1920|67200|224640|1', + 'textures/biomechanic/metal_tile_floor_01|1920|1920|15360|190080|0', + 'textures/biomechanic/pipe_red_01|240|1920|5040|192000|1', + 'textures/biomechanic/red_tile_01|1920|1920|17280|190080|1', + 'textures/biomechanic/tan_tile_01|1920|1920|19200|190080|1', + 'textures/bluecollar/walls/bc_panels_steel|3840|3840|153600|161280|1', + 'textures/bone/boneyard/by_bone_ground_01|3840|3840|69120|215040|0', + 'textures/bone/boneyard/by_bone_wall_01|3840|3840|111360|211200|0', + 'textures/bone/decay|1920|1920|96000|224640|0', + 'textures/bone/id_bone_ivory|1920|1920|97920|224640|0', + 'textures/ca/blockout/walls/beige|240|240|7200|189360|0', + 'textures/ca/blockout/walls/grey_3_white|240|240|7440|189120|0', + 'textures/common/red|120|120|66600|214680|0', + 'textures/exterior/pipes/ext_pipe_huge_copper|960|7680|77760|15360|0', + 'textures/exterior/pipes/pc_ext_pipe_huge_steel|960|7680|76800|15360|0', + 'textures/exterior/trims_steel_ext|3840|3840|119040|7680|0', + 'textures/exterior/walls/wall_tiles_04_drk_02|3840|3840|107520|0|0', + 'textures/gore/boneyard/by_gore_ground_01|3840|3840|107520|211200|0', + 'textures/gore/boneyard/by_gore_ground_02|3840|3840|111360|207360|0', + 'textures/gore/boneyard/by_gore_wall_01|3840|1920|80640|238080|0', + 'textures/gore/boneyard/by_gore_wall_02|3840|3840|107520|207360|0', + 'textures/gore/boneyard/by_gore_wall_03|3840|3840|111360|203520|0', + 'textures/gore/boneyard/by_meat_01|1920|1920|96000|226560|0', + 'textures/gore/egg_skin|3840|3840|107520|203520|0', + 'textures/gore/sinue|960|3840|66240|192000|0', + 'textures/gore/tendril_floor|3840|3840|111360|199680|0', + 'textures/gore/tendril_lrg|1920|3840|67200|195840|0', + 'textures/gore/tendril_tile|3840|3840|107520|199680|0', + 'textures/hell_ext/walls/stone_wall_02_tile_tall|1920|1920|49920|128640|1', + 'textures/hell_ext/walls/stone_wall_02|1920|1920|51840|128640|1', + 'textures/industrial/catwalks/catwalk01_top_256_dirty|7680|7680|153600|184320|0', + 'textures/industrial/catwalks/catwalk01_top_256|7680|7680|168960|176640|1', + 'textures/industrial/floors/gratefloor_t_junction_centers_iwk|3840|3840|149760|203520|0', + 'textures/industrial/greeble_panels_darker_grey_ind_drk|3840|3840|11520|207360|1', + 'textures/industrial/walls/metal_tiles_lrg_drk|3840|3840|157440|157440|1', + 'textures/industrial/walls/panel_maint_1_cyan|3840|3840|111360|7680|1', + 'textures/industrial/walls/panel_maint_1_darkgrey_darker|3840|3840|7680|211200|1', + 'textures/industrial/walls/panel_maint_1_yellow|3840|3840|19200|211200|0', + 'textures/mp/mp_cataclysm/cable_bundle_01_blue|1920|3840|67200|199680|1', + 'textures/mp/mp_cataclysm/cable_bundle_01_red|1920|3840|53760|130560|1', + 'textures/mp/mp_graphite/ca_mp_graphite_elevator_trim|3840|3840|119040|103680|1', + 'textures/mp/mp_graphite/ca_mp_graphite_hangar_panels_01|3840|3840|142080|0|0', + 'textures/pipes/ind_pipe_huge_grey|960|7680|222720|122880|1', + 'textures/skies/dlc1/hell/hell_buildings/brick_top|960|480|6720|128160|0', + 'textures/skies/dlc2/cliff_kit/gears|120|120|7320|189240|0', + 'textures/skies/dlc2/cliff_kit/vista_cliff_wall|3840|3840|11520|211200|0', + 'textures/skies/dlc2/cliff_kit/wood|120|120|7200|189240|0', + 'textures/skies/dlc3/outbreak/ob_ship_lights_engine|120|120|66840|214560|0', + 'textures/skies/dlc3/outbreak/ob_ship_lights_small|480|120|66240|214560|0', + 'textures/skies/dlc3/outbreak/ob_vista_ship_haul|1920|1920|113280|226560|0', + 'textures/skies/dlc3/outbreak/ob_vista_ship_paint|1920|1920|111360|228480|0', + 'textures/skies/dlc3/outbreak/ob_vistaship_haul_black|120|120|66840|214320|0', + 'textures/skies/dlc3/outbreak/ob_vistaship_haul_smooth|1920|1920|92160|230400|0', + 'textures/skies/dlc3/outbreak/ob_vistaship_haul_white|1920|1920|103680|232320|0', + 'textures/skies/dlc3/vortex/vortex_brick_detail|3840|1920|111360|190080|0', + 'textures/skies/dlc3/vortex/vortex_brick|3840|3840|76800|226560|0', + 'textures/skies/dlc3/vortex/vortex_marble_trim|1920|1920|111360|232320|0', + 'textures/skies/dlc3/vortex/vortex_metal|3840|1920|72960|190080|0', + 'textures/skies/mars_terrain/mars_dunes|3840|3840|88320|49920|0', + 'textures/snapmaps/snapedit_logic_model_icon_bg|3840|3840|215040|11520|1', + 'textures/swatches/mp_molten/ind_molten_dark_gray|480|480|6720|189600|0', + 'textures/swatches/mp_molten/ind_molten_green|480|480|7200|189600|0', + 'textures/swatches/mp_outbreak/outbreak_blk|1920|1920|107520|236160|0', + 'textures/swatches/mp_outbreak/outbreak_blu|1920|1920|109440|236160|0', + 'textures/swatches/mp_outbreak/outbreak_drkgry|1920|1920|97920|238080|0', + 'textures/swatches/mp_outbreak/outbreak_green|1920|1920|96000|238080|0', + 'textures/swatches/mp_outbreak/outbreak_gry|1920|1920|94080|238080|0', + 'textures/swatches/mp_outbreak/outbreak_org|1920|1920|92160|238080|0', + 'textures/swatches/mp_outbreak/outbreak_red|1920|1920|107520|215040|0', + 'textures/swatches/mp_outbreak/outbreak_tan|1920|1920|99840|234240|0', + 'textures/swatches/mp_outbreak/outbreak_wht|1920|1920|99840|236160|0', + 'textures/templar/banners_02|1920|1920|103680|234240|0', + 'textures/templar/banners|1920|1920|97920|128640|0', + 'textures/templar/brick01|3840|3840|99840|142080|0', + 'textures/templar/brick02|3840|3840|103680|138240|0', + 'textures/templar/cave_facade|3840|3840|69120|234240|0', + 'textures/templar/column_02_dark_marble|3840|3840|72960|230400|0', + 'textures/templar/floor|1920|1920|113280|149760|0', + 'textures/templar/glass_stain|1920|1920|115200|144000|0', + 'textures/templar/gold_bright_01_hotspot|1920|1920|103680|236160|0', + 'textures/templar/gold_dull_01_hotspot|1920|1920|113280|232320|0', + 'textures/templar/gold_smooth_01_hotspot|1920|1920|107520|234240|0', + 'textures/templar/grate|960|960|88320|148800|0', + 'textures/templar/metal01|1920|1920|105600|128640|0', + 'textures/templar/metal_dull|1920|1920|92160|128640|0', + 'textures/templar/metal_trim_clock|3840|1920|99840|140160|0', + 'textures/templar/metal_trim_dull|3840|1920|111360|128640|0', + 'textures/templar/metal_trim|3840|1920|84480|151680|0', + 'textures/templar/metal_vortex_brass|1920|1920|109440|234240|0', + 'textures/templar/releif_marble_dark|3840|3840|84480|222720|0', + 'textures/templar/releif_marble_white|3840|3840|76800|234240|0', + 'textures/templar/releif_marble|3840|3840|88320|222720|0', + 'textures/templar/releif_sandstone|3840|3840|107520|145920|0', + 'textures/templar/releif|3840|3840|92160|145920|0', + 'textures/templar/rocks_vortex|1920|1920|105600|236160|0', + 'textures/templar/rocks|1920|1920|115200|134400|0', + 'textures/templar/rubble_01_vortex|1920|1920|109440|232320|0', + 'textures/templar/rubble_01|1920|1920|107520|128640|0', + 'textures/templar/stairs|3840|3840|76800|153600|0', + 'textures/templar/stone01_brown|1920|1920|103680|136320|0', + 'textures/templar/stone01_vortex_dark|1920|1920|109440|230400|0', + 'textures/templar/stone01_vortex|1920|1920|107520|232320|0', + 'textures/templar/stone01|1920|1920|94080|128640|0', + 'textures/templar/stone_floor|3840|3840|103680|142080|0', + 'textures/templar/stone_wall_01_hotspot_gold|1920|1920|107520|230400|0', + 'textures/templar/stone_wall_01_hotspot_marble|1920|1920|105600|238080|0', + 'textures/templar/stone_wall_01_hotspot_wide_gold|1920|1920|103680|238080|0', + 'textures/templar/stone_wall_01_hotspot_wide|1920|1920|115200|132480|0', + 'textures/templar/stone_wall_01_hotspot|1920|1920|111360|140160|0', + 'textures/templar/stone_wall_01_marble|1920|1920|101760|238080|0', + 'textures/templar/stone_wall_01_sandstone_hotspot_wide|1920|1920|107520|140160|0', + 'textures/templar/stone_wall_01_sandstone_hotspot|1920|1920|109440|142080|0', + 'textures/templar/stone_wall_02_hotspot_marble_dark|1920|1920|111360|215040|0', + 'textures/templar/stone_wall_02_hotspot_marble_white|1920|1920|109440|216960|0', + 'textures/templar/stone_wall_02_hotspot_marble|1920|1920|99840|238080|0', + 'textures/templar/stone_wall_02_hotspot_wide_gold|1920|1920|67200|220800|0', + 'textures/templar/stone_wall_02_hotspot_wide_marble_dark|1920|1920|103680|228480|0', + 'textures/templar/stone_wall_02_hotspot_wide_marble_white|1920|1920|105600|226560|0', + 'textures/templar/stone_wall_02_hotspot_wide_marble|1920|1920|105600|228480|0', + 'textures/templar/stone_wall_02_hotspot_wide|1920|1920|115200|128640|0', + 'textures/templar/stone_wall_02_hotspot|1920|1920|107520|142080|0', + 'textures/templar/stone_wall_02_marble_dark_dirt|1920|1920|99840|226560|0', + 'textures/templar/stone_wall_02_marble_dark|1920|1920|101760|228480|0', + 'textures/templar/stone_wall_02_marble_dirt|1920|1920|103680|224640|0', + 'textures/templar/stone_wall_02_marble_white_dirt|1920|1920|103680|222720|0', + 'textures/templar/stone_wall_02_marble_white|1920|1920|105600|222720|0', + 'textures/templar/stone_wall_02_marble|1920|1920|103680|226560|0', + 'textures/templar/stone_wall_02_sandstone_hotspot_wide|1920|1920|113280|140160|0', + 'textures/templar/stone_wall_02_sandstone_hotspot|1920|1920|109440|140160|0', + 'textures/templar/stone_wall_02_sandstone_vortex_hotspot_wide|1920|1920|96000|228480|0', + 'textures/templar/stone_wall_02_sandstone_vortex_hotspot|1920|1920|101760|222720|0', + 'textures/templar/stone_wall_02_tiling_sandstone_dark|1920|1920|115200|213120|0', + 'textures/templar/stone_wall_02_tiling_sandstone|1920|1920|67200|218880|0', + 'textures/templar/stone_wall_03_hotspot_marble_dark|1920|1920|115200|209280|0', + 'textures/templar/stone_wall_03_hotspot_marble_white|1920|1920|115200|207360|0', + 'textures/templar/stone_wall_03_hotspot_marble|1920|1920|115200|211200|0', + 'textures/templar/stone_wall_03_hotspot_wide_marble_dark|1920|1920|115200|203520|0', + 'textures/templar/stone_wall_03_hotspot_wide_marble_white|1920|1920|115200|201600|0', + 'textures/templar/stone_wall_03_hotspot_wide_marble|1920|1920|115200|205440|0', + 'textures/templar/templar_wood|3840|3840|96000|138240|0', + 'textures/templar/trim_01_sandstone|3840|1920|111360|138240|0', + 'textures/templar/trim_01|3840|1920|99840|138240|0', + 'textures/templar/trim_02_sandstone|3840|1920|103680|134400|0', + 'textures/templar/trim_02|3840|1920|84480|149760|0', + 'textures/templar/vortex/bricks_marble_large_01_bright|3840|3840|88320|226560|0', + 'textures/templar/vortex/bricks_marble_large_01_dark|3840|3840|69120|230400|0', + 'textures/templar/vortex/bricks_marble_large_01|3840|3840|84480|226560|0', + 'textures/templar/vortex/keystone_large_01|3840|3840|72960|234240|0', + 'textures/templar/vortex/rocks_sandstone_small_01|1920|1920|67200|230400|0', + 'textures/templar/vortex/shrine_column_large_01|3840|3840|76800|230400|0', + 'textures/templar/vortex/shrine_crate_small_01|1920|1920|103680|220800|0', + 'textures/templar/vortex/shrine_inset_large_01|3840|3840|80640|230400|0', + 'textures/templar/vortex/shrine_inset_large_02|3840|3840|80640|226560|0', + 'textures/templar/vortex/shrine_inset_small_01|3840|3840|80640|234240|0', + 'textures/templar/vortex/shrine_inset_small_02|3840|3840|84480|230400|0', + 'textures/templar/vortex/stairs_marble_large_01_bright|3840|3840|84480|234240|0', + 'textures/templar/vortex/stairs_marble_large_01_dark|3840|3840|88320|234240|0', + 'textures/templar/vortex/stairs_marble_large_01|3840|3840|88320|230400|0', + 'textures/templar/vortex/vortex_column_01|3840|3840|92160|215040|0', + 'textures/templar/vortex_grate|1920|120|92160|189120|0', + 'textures/templar/wood_tile|3840|3840|92160|138240|0', + 'textures/terrain/ground/marsrock_ground_02_tiled_c_dark|1920|1920|97920|222720|1', + 'textures/terrain/lava/lava_molten_metal|960|960|4800|191040|0', + 'textures/users/dorian/frame_large_tile_01_lightgrey_0s|3840|3840|99840|84480|1', + 'textures/whitecollar/lab/black_tile_01|1920|1920|115200|192000|0', + 'textures/whitecollar/lab/dark_grey_01|1920|1920|101760|218880|0', + 'textures/whitecollar/lab/grey_tile_01|1920|1920|101760|215040|0', + 'textures/whitecollar/lab/orange_tile_01|1920|1920|97920|226560|0', + 'textures/whitecollar/lab/red_tile_01|1920|1920|94080|228480|0', + 'textures/whitecollar/lab/steel_tile_01|1920|1920|92160|228480|0', + 'textures/whitecollar/lab/tan_tile_01|1920|1920|92160|226560|0', + 'textures/whitecollar/lab/wc_trim_1_lab|1920|1920|92160|224640|0', + 'textures/whitecollar/lab/wc_trim_2_lab|1920|1920|94080|222720|0', + 'textures/whitecollar/lab/wc_trim_3_lab|1920|1920|92160|222720|0', + 'textures/whitecollar/lab/white_tile_01|1920|1920|67200|207360|0', + 'textures/whitecollar/wc_floor_marble_pink|3840|3840|3840|69120|1', + 'textures/whitecollar/wc_floor_org|3840|3840|107520|49920|1', + 'textures/whitecollar/wc_floor_red|3840|3840|38400|138240|1', + 'textures/whitecollar/wc_greeble_d3_2c|3840|3840|92160|218880|1', + 'textures/whitecollar/wc_greeble_d3_4b_darker|3840|3840|26880|149760|1', + 'textures/whitecollar/wc_panel_1a|3840|3840|145920|157440|1', + 'textures/whitecollar/wc_panel_1b|3840|3840|149760|153600|1', + 'textures/whitecollar/wc_panel_maint_1d|3840|3840|138240|157440|1', + 'textures/whitecollar/wc_panel_maint_tall_1a|3840|3840|142080|153600|1', + 'textures/whitecollar/wc_panel_tall_1_darkblue|3840|3840|103680|207360|1', + 'textures/whitecollar/wc_space_caps|1920|1920|32640|128640|0' + ]; + + /* Why 162 of the 195 have no `material` decl, since it reads like an error and is not: + * + * The `.vmtr` table and the material decls are DIFFERENT registries. A `.vmtr` row says "this named + * texture occupies this rectangle of the megatexture" -- pure coordinates. A `material` record is a + * definition the engine resolves BY NAME. All 195 are in the .vmtr (that is what gives each one a + * virtualmapping at all); 33 are `material` decls in the snap box, and the other **162 are material + * decls in the CAMPAIGN box**, which SnapMap never mounts. + * + * That is what makes the technique reach this art at all. `customMaterial` asks SnapMap to resolve + * a NAME, and for those 162 it cannot -- the decl is in a box it does not load, so you would get + * nothing. `virtualmapping` asks for a RECTANGLE, so no name lookup happens and the box rule never + * applies: the pixels are in `virtualtextures/`, which SnapMap does read. + * + * It does NOT follow that a rect means the same art on every platform -- it usually does not. See + * the note on AB_XPLAT_RAW above: that is exactly why this table is a tested list rather than a + * query. */ + var AB_XPLAT = (function () { + var m = {}; + for (var i = 0; i < AB_XPLAT_RAW.length; i++) { + var p = AB_XPLAT_RAW[i].split('|'); + m[p[0].toLowerCase()] = { n: p[0], w: +p[1], h: +p[2], x: +p[3], y: +p[4], d: p[5] === '1' }; + } + return m; + })(); + var AB_XPLAT_NAMES = AB_XPLAT_RAW.map(function (s) { return s.split('|')[0]; }).sort(); + function abXplatOf(name) { return AB_XPLAT[(name || '').toLowerCase()] || null; } + + /* The atlas-only materials: names in the Materials list that have NO `material` decl (backend + * SH_ASSET_VTONLY). They are fully applyable -- by RECTANGLE, via the virtualmapping renderParm -- + * but NOT by name, because customMaterial resolves through the decl manager and there is no decl + * to resolve. Fetched once per session and kept as a lookup, so the carrier gate is answered + * locally for any name instead of costing a round-trip per selection. + * + * Until the fetch lands this is null, which abHasDecl reports as "unknown". Unknown is treated as + * "has a decl" so the common case (most materials) is not needlessly disabled during startup. */ + var abVtOnly = null; + function abHasDecl(name) { + if (!abVtOnly) return true; /* not fetched yet -- do not disable on a guess */ + return !abVtOnly[(name || '').toLowerCase()]; + } + + /* Atlas rects learned at runtime, for materials outside the baked table. Filled by onMaterialRect + * from the same backend lookup the preview uses (.vmtr by name), so it costs one round-trip per + * asset per session and nothing thereafter. */ + var abRectCache = {}; + /* The dimensions to derive a ramp aspect from: the baked table first (it is authoritative and + * needs no round-trip), then anything the backend has told us. Null means "not atlas-backed, or + * not looked up yet" -- callers fall back to a square rate, which is correct for the 173 of 195 + * square textures and simply un-corrected for the rest until the rect lands. */ + function abRectOf(name) { + return abXplatOf(name) || abRectCache[(name || '').toLowerCase()] || null; + } + + /* ---- ramp1: the tiling rate ------------------------------------------------------------------- + * The carrier material is TRIPLANAR (`ambientprogram triplanartintoutside`), so it projects the + * texture in world space and needs to be told how big one copy is. `ramp1` is that scale, and it + * follows one rule taken from a working library: + * + * ramp1 component = 426 / N N = the world-unit period of one texture repeat + * + * Every value in that library falls out of it exactly: 5.325 = 426/80, 6.65625 = 426/64, + * 13.3125 = 426/32, 26.625 = 426/16, 106.5 = 426/4. 426/2^n stays exact in binary floating point, + * so there is no rounding drift and no "keep a decimal point" trap. + * + * NOT WRITING IT IS NOT NEUTRAL. The material ships `ramp1 1.000000` = one tile every 426 units, + * so an unset ramp shows about a fifth of the texture on an 80-unit box. That is why every virtual + * texture in both libraries we have seen carries one. + * + * NON-SQUARE TEXTURES NEED NON-SQUARE RATES. In the contributor's words: "some textures do not have + * equal length and width, so some volumes have different scaling to fit the single texture." The + * longer axis takes the base rate and the shorter is scaled by the aspect, which reproduces their + * hand-picked pairs exactly -- a 1:8 texture at base 256 gives 32x256, a 1:2 gives 128x256. Since + * the baked table already carries each texture's atlas w/h, this is computed rather than chosen. */ + var AB_RAMP_CONST = 426; + /* Observed in the two source libraries: 4, 16, 32, 64, 80 (the cross-platform map) and 64, 256, 80 + * (the 2,820-surface library). 128, 192, 384, 512, 768 and 1024 are not observed but were asked + * for and are the obvious neighbours. Every one of these divides 426 into a value whose shortest + * decimal representation round-trips exactly and multiplies back to 426.0 -- checked, because a + * rate that printed as 3.3281250000000004 would be a "keep a decimal point" bug waiting to + * happen. */ + var AB_RAMP_RATES = [4, 16, 32, 64, 80, 128, 192, 256, 384, 512, 768, 1024]; + var AB_RAMP_DEFAULT = 80; /* the most-used rate in the source library, and the one the + * owner reports looks best (179 boxes + all 60 hotspots) */ + var AB_BLOOM_DEFAULT = 100; /* what working libraries default to; the renderparm's own is 8 */ + + /* Bloom is OFFERED, not decided. 100 is the value working libraries default to, but it is a 12.5x + * boost over the renderparm's own default of 8 and it blows out anything already emissive -- monster + * eyes, engine lights, jump pads all get tuned down by hand in practice. + * + * An earlier version baked a per-texture table of those tuned values. That was dropped + * deliberately: it covered four textures out of 195, it would need extending every time somebody + * tuned another, and it presented one author's taste as if it were a property of the texture. The + * dropdown gives the mapper the same range shipped content uses and lets them look at the surface. + * + * A free NUMBER rather than a fixed list, because a fixed list is provably too narrow: across a + * 2,820-surface library the tuned values include 0.125, 1.5, 32, 80, 128 and 300 alongside the + * round ones. The list below is only the quick-pick suggestions. */ + var AB_BLOOM_VALUES = [0, 1, 5, 8, 10, 20, 32, 50, 80, 100, 128, 200, 300]; + function abBloomValue(v) { return '{ ' + v + ', 0, 0, 0 }'; } + + /* Tile periods (Nx, Ny) for a texture at a base rate, aspect-corrected. */ + function abRampPeriods(xp, base) { + if (!xp || !xp.w || !xp.h || xp.w === xp.h) return {x: base, y: base}; + return (xp.w > xp.h) ? {x: base, y: base * (xp.h / xp.w)} + : {x: base * (xp.w / xp.h), y: base}; + } + /* The renderparm value. First component is negated, reproducing the source library exactly -- the + * mirror is consistent across every entry there, and nothing observed says it is incidental. */ + function abRampValue(xp, base) { + var p = abRampPeriods(xp, base); + var fmt = function (n) { return String(AB_RAMP_CONST / n); }; + return '{ -' + fmt(p.x) + ', ' + fmt(p.y) + ', 0, 0 }'; + } + /* The option label. ALWAYS both axes when the texture size is known -- "128 x 128" for a square + * one, "8 x 64" for a 1:8 one. An earlier version collapsed the square case to a bare "128", which + * read as an unexplained single number: 173 of the 195 cross-platform textures are square, so in + * practice the two-axis form was almost never on screen and there was nothing to compare against. + * When the rect has not arrived yet we CANNOT know the aspect, so say the size is unknown rather + * than print "128 x 128" and be wrong for a non-square texture. */ + /* Single number when the texture is square (by far the common case -- x period == y period, so + * a second number adds nothing a mapper can act on in the half-second they look at this list). + * Two numbers only when it would actually be wrong to collapse them. Unknown size (rect not + * fetched yet) falls back to the bare base rather than guess an aspect. */ + function abRampLabel(xp, base) { + var r = function (n) { return Math.round(n * 1000) / 1000; }; + if (!xp || !xp.w || !xp.h) return base + ' units'; + var p = abRampPeriods(xp, base); + return (p.x === p.y) ? (base + ' units') : (r(p.x) + ' × ' + r(p.y) + ' units'); + } + + /* The list a mount is currently showing. The cross-platform set is a SEPARATE source keyed off a + * pseudo-type, not a filter over the fetched material catalog -- 162 of its 195 names have no + * `material` record at all, so filtering the catalog would silently hide the majority of them. */ + function abListType(m) { + return (m.type === 'material' && m.xplat) ? 'material@xplat' : m.type; + } + + /* Show the checkbox only on Materials, and keep the DOM control in step with the mount's own state + * (each mount keeps its own view state, and the Assets tab and the modal are two mounts). */ + function abSyncXplat(m) { + var wrap = m.el('xplatwrap'), box = m.el('xplat'); + if (!wrap || !box) return; + wrap.style.display = (m.type === 'material') ? 'flex' : 'none'; + box.checked = !!m.xplat; + } + var abNames = {}; /* type -> sorted name array (undefined = never fetched) */ + /* The cross-platform set is baked, so seed it here: it is never fetched, and leaving it undefined + * would make abRenderTree show "Loading the catalog..." forever. */ + abNames['material@xplat'] = AB_XPLAT_NAMES; var abTrees = {}; /* type -> folder tree built from abNames[type] */ var abMounts = []; /* every mounted browser */ var abActive = null; /* the mount that owns the in-flight preview request */ @@ -3810,13 +4219,48 @@ * This is the part that has to be right: it decides what Apply will eventually write. Sourced * from the campaign's placement-carrier map, not guessed from the UI's convenience. */ function abCarriers(type, path) { - if (type === 'material') return [ - {id:'cm', label:'Custom Material', ok:true, - why:'Applies the material by name. Works for any material in the catalog.'}, - {id:'vm', label:'Virtual Mapping', ok:true, - why:'Applies the material by renderParms virtualmapping. Only works for materials that are ' - + 'in the megatexture, and may not be the same texture on other platforms.'} - ]; + if (type === 'material') { + /* A cross-platform texture whose decl lives in the campaign box is refused BY NAME. + * + * INFERRED (2026-08-05), not live-tested for materials specifically -- the reasoning, so it can + * be overturned cheaply if wrong: SnapMap does not mount `gameresources.resources` (live-proven + * for MODELS -- a campaign-box model renders as a black cube), and a customMaterial name is + * resolved through the decl manager out of that same box, so the same lookup fails the same way. + * + * Campaign-box SOUNDS do work, which looks like a counterexample and is not: a sound decl is a + * thin wrapper and the audio itself lives in .bnk/.pck soundbanks loaded independently of the + * .resources boxes. Sounds have a side door; materials do not. + * + * The gate is the conservative direction. If it is wrong we lose one option on 162 textures + * that already have a working carrier; if it were missing, the control would silently produce + * untextured surfaces. To settle it: set customMaterial to one of the d===false names by hand + * in Decl Text and look at the surface. If it renders, delete this gate. */ + var xp = abXplatOf(path); + /* TWO separate reasons a name can be unappliable, and they are not the same check: + * - NO DECL AT ALL. The catalog lists the union of `material` decls and `.vmtr` atlas rows, + * and thousands of atlas rows never had a decl authored. Nothing to resolve by name. + * - DECL IS IN THE CAMPAIGN BOX (the INFERRED case above). A decl exists, but not in a box + * SnapMap mounts. */ + var noDecl = !abHasDecl(path); + var byName = !noDecl && (!xp || xp.d); + return [ + {id:'cm', label:'Custom Material', ok:byName, + why: byName ? 'Applies the material by name. Works for materials that have a decl SnapMap ' + + 'can load — most, but not all, of this list.' + : noDecl ? 'This texture has no material decl — it exists only as a rectangle in the ' + + 'megatexture atlas, so there is no name for the engine to resolve. Use ' + + 'Virtual Mapping.' + : 'This texture’s material decl is in the campaign box, which SnapMap does not ' + + 'load, so it cannot be applied by name. Use Virtual Mapping — that addresses ' + + 'the atlas by rectangle and needs no decl.'}, + {id:'vm', label:'Virtual Mapping', ok:true, + why: xp ? 'Applies the texture by renderParms virtualmapping — a rectangle of the ' + + 'megatexture, not a name. This one is on the tested cross-platform list, so it ' + + 'shows the same art on PC, Xbox and PlayStation.' + : 'Applies the material by renderParms virtualmapping. Only works for materials that ' + + 'are in the megatexture, and may not be the same texture on other platforms.'} + ]; + } if (type === 'model') return [{id:'m', label:'Model', ok:true, why:'Applies the model by name under renderModelInfo.'}]; /* A module is the one asset that writes TWO fields. Geometry alone renders but you walk @@ -3874,6 +4318,15 @@ + '0' + '
' + '
' + /* Materials only, and hidden for every other type rather than shown disabled -- it is + * meaningless outside the megatexture atlas. */ + + '' + '
' + '
' + '
Asset' @@ -3883,18 +4336,24 @@ m.el('q').addEventListener('input', function(){ m.q = this.value; abRenderTree(m); }); m.el('reload').addEventListener('click', function(){ abFetch(m, true); }); + /* Toggling the set changes which list you are in, so the folder path and the selection from the + * other set are both meaningless afterwards -- drop them rather than leaving a stale crumb trail + * pointing at a folder the new list does not have. */ + m.el('xplat').addEventListener('change', function(){ + m.xplat = this.checked; m.dir = ''; m.sel = null; + abRenderTree(m); abRenderInsp(m); + }); m.el('rail').addEventListener('click', function(e){ var it = e.target.closest ? e.target.closest('[data-type]') : null; if (!it) return; m.type = it.getAttribute('data-type'); m.dir = ''; m.sel = null; + abSyncXplat(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); abFetch(m, false); }); m.el('tree').addEventListener('click', function(e){ var cp = e.target.closest ? e.target.closest('[data-copy]') : null; if (cp) { e.stopPropagation(); abCopy(cp.getAttribute('data-copy')); return; } - var ck = e.target.closest ? e.target.closest('[data-check]') : null; - if (ck) { post({cmd:'findMaterial', name: m.q.trim()}); ck.textContent = 'Checking...'; return; } var row = e.target.closest ? e.target.closest('.entity-item') : null; if (!row) return; if (row.getAttribute('data-up')) { m.dir = m.dir.split('/').slice(0,-1).join('/'); abRenderTree(m); return; } @@ -3917,9 +4376,22 @@ if (au) abAudition(m.sel); }); m.el('insp').addEventListener('change', function(e){ - if (e.target && e.target.name === 'abCarrier') m.carrier = e.target.value; + if (e.target && e.target.name === 'abCarrier') { m.carrier = e.target.value; abRenderInsp(m); return; } + if (e.target && e.target.getAttribute) { + var w = e.target.getAttribute('data-ab'); + /* The ramp select carries numbers plus the two string modes, so only digits are coerced. */ + if (w === 'ramp') { var rv = e.target.value; m.ramp = /^\d+$/.test(rv) ? +rv : rv; return; } + /* Blank means "do not write it". Anything unparseable is treated the same rather than + * writing NaN into a renderparm. */ + if (w === 'bloom') { + var bvv = (e.target.value || '').trim(); + m.bloom = (bvv === '' || isNaN(parseFloat(bvv))) ? 'none' : parseFloat(bvv); + return; + } + } }); abMounts.push(m); + abSyncXplat(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); return m; } @@ -4014,12 +4486,41 @@ var cls = document.getElementById('fClassname').value || ''; var denied = abApplyDenied(m.carrier, cls); if (denied) { toast(denied, 'err'); return; } - if (m.carrier === 'vm') { abPendingVM = {m: m, name: m.sel}; post({cmd:'materialRect', name: m.sel}); return; } + if (m.carrier === 'vm') { + /* A cross-platform entry already carries its rect, verified against the .vmtr row it came from, + * so skip the backend round-trip. It would return the same numbers -- megapreview reads the same + * tables -- but there is no reason to wait a frame for an answer we shipped with. */ + var xp = abRectOf(m.sel); + if (xp) { abApplyNow(m, m.sel, 'vm', {w: xp.w, h: xp.h, x: xp.x, y: xp.y}); return; } + abPendingVM = {m: m, name: m.sel}; post({cmd:'materialRect', name: m.sel}); return; + } abApplyNow(m, m.sel, m.carrier, null); } + /* The ramp/bloom choices only make sense for Virtual Mapping. `ramp1`/`bloommaskscale` are read + * by the TRIPLANAR ambientprogram, and a by-name customMaterial apply lands on whatever program + * that material already declares -- confirmed in-editor that only the SnapMap palette's own + * triplanar materials respond; arbitrary by-name textures do not. Virtual Mapping always carries + * the triplanar program with it, so it is the only carrier these controls can affect. + * + * "Keep existing" is honoured only when there IS an existing ramp1. The material ships + * `ramp1 1.000000` = one tile every 426 units, so leaving it unset on an entity that has none + * would show about a fifth of the texture -- a silent wrong-looking result rather than a neutral + * one. So: preserve a value the mapper tuned, but never leave a texture at the stretched default. */ + function abVmExtra(m, name) { + if (m.carrier !== 'vm') return null; + var xp = abRectOf(name); + var out = {}; + var box = document.getElementById('declText'); + var hasRamp = box && /\bramp1\b/.test(box.value || ''); + if (m.ramp === 'keep') { if (!hasRamp) out.ramp = abRampValue(xp, AB_RAMP_DEFAULT); } + else if (m.ramp !== 'none') out.ramp = abRampValue(xp, m.ramp || AB_RAMP_DEFAULT); + if (m.bloom !== 'none') + out.bloom = abBloomValue(m.bloom === undefined ? AB_BLOOM_DEFAULT : m.bloom); + return out; + } function abApplyNow(m, name, carrier, rect) { var box = document.getElementById('declText'); - var next = dpApply(box.value, m.type, carrier, name, rect); + var next = dpApply(box.value, m.type, carrier, name, rect, abVmExtra(m, name)); if (next === null) { toast('That carrier cannot be written', 'err'); return; } if (next === box.value) { toast('Already set to that -- nothing changed'); return; } box.value = next; @@ -4040,6 +4541,16 @@ if (m.mode === 'modal') abCloseModal(); } function onMaterialRect(d) { + /* Cache every rect that comes back, whoever asked for it. The ramp dropdown needs the texture's + * ASPECT to offer an aspect-corrected rate, and only the baked cross-platform table carries w/h + * -- for the other ~4,800 atlas materials this reply is where those dimensions come from. Cached + * before the pending-request check below, because the selection fetch has no pending entry. */ + if (d.ok) { + abRectCache[(d.name || '').toLowerCase()] = {w: d.w, h: d.h, x: d.x, y: d.y}; + /* Re-render whichever mount is showing this asset so the rate labels pick the aspect up. */ + for (var mi = 0; mi < abMounts.length; mi++) + if (abMounts[mi].sel === d.name) abRenderInsp(abMounts[mi]); + } /* Both Apply and New entity can be waiting on a rect; only one is ever outstanding. */ var pa = abPendingVM, pn = (typeof abPendingNewVM !== 'undefined' ? abPendingNewVM : null); abPendingVM = null; if (typeof abPendingNewVM !== 'undefined') abPendingNewVM = null; @@ -4094,7 +4605,7 @@ * constant however deep the path is, and the breadcrumb gets you back in one click. */ function abRenderTree(m) { var el = m.el('tree'), crumbs = m.el('crumbs'), cnt = m.el('cnt'); - var names = abNames[m.type]; + var names = abNames[abListType(m)]; if (!names) { el.innerHTML = '
Loading the catalog...
'; crumbs.innerHTML = ''; cnt.textContent = '...'; @@ -4107,14 +4618,12 @@ if (names[k].toLowerCase().indexOf(q) >= 0) hits.push(names[k]); var h = ''; for (var j = 0; j < hits.length; j++) h += abRow(m, hits[j], hits[j], false, null); - /* The catalog only holds SnapMap materials. A name that isn't here may still be a real decl - * elsewhere in the game, so offer the resolve-by-name check exactly where the question comes - * up rather than as permanent chrome. */ - el.innerHTML = h || ('
Nothing matches “' + abEsc(m.q) + '”.' - + (m.type === 'material' - ? '
' - + '
' : '') - + '
'); + /* No "check the full decl set" escape hatch here any more. It called the engine's CACHED-ONLY + * material find, which answers "is this loaded in memory right now" -- browsing loads nothing, + * so it reported "not found" for names that plainly exist and taught the reader the opposite + * of the truth. It also predated the catalog being complete: Materials is now the UNION of + * `material` decls and `.vmtr` atlas rows, so a miss here really is a miss. */ + el.innerHTML = h || ('
Nothing matches “' + abEsc(m.q) + '”.
'); crumbs.innerHTML = 'Searching every folder'; cnt.textContent = hits.length + (hits.length >= AB_MAX_HITS ? '+' : ''); return; @@ -4124,7 +4633,7 @@ cb += ' / ' + abEsc(parts[c]) + ''; crumbs.innerHTML = cb; - var node = abNodeAt(m.type, m.dir); + var node = abNodeAt(abListType(m), m.dir); if (!node) { el.innerHTML = '
That folder is gone.
'; return; } var out = ''; if (m.dir) out += '
' @@ -4337,6 +4846,42 @@ + '' + '' + opts[k].label + '' + opts[k].why + ''; + /* The two companion renderParms, shown for Virtual Mapping only -- confirmed in-editor that a + * by-name customMaterial does NOT respond to them; only the SnapMap palette's own triplanar + * materials do. Both are visible controls rather than silent writes: they change how the + * surface LOOKS, and a mapper should be able to see and change that before committing. */ + if (m.type === 'material' && m.carrier === 'vm') { + var xpSel = abRectOf(path); + var rampSel = (m.ramp === undefined) ? AB_RAMP_DEFAULT : m.ramp; + h += '' + + ''; + } /* Apply needs a single entity whose decl is loaded (it edits that text), and -- for the * renderModelInfo carriers -- an entity whose class can actually wear a material or model. */ var selCls = document.getElementById('fClassname'); @@ -4429,6 +4974,12 @@ abRenderTree(m); abRenderInsp(m); abStatus(m, 'Requesting...'); post({cmd:'requestPreview', name: path}); + /* Learn this material's atlas rect too, so the tiling dropdown can offer an aspect-corrected + * rate for it. Only for materials we do not already have dimensions for -- the baked + * cross-platform table and the session cache both answer without a round-trip. The reply is + * handled in onMaterialRect, which caches it and re-renders this mount. A material with no + * atlas rect simply never answers ok, and the square fallback is then correct anyway. */ + if (m.type === 'material' && !abRectOf(path)) post({cmd:'materialRect', name: path}); } function onPreviewRequested(d) { @@ -4485,8 +5036,21 @@ function abFetchAll() { for (var i = 0; i < AB_TYPES.length; i++) if (!abNames[AB_TYPES[i].id]) post({cmd:'listAssets', assetKind: AB_TYPES[i].kind}); + /* Not an AB_TYPES row -- see abVtOnly. Fetched alongside the catalogs so the carrier gate is + * accurate by the time anything is selected. */ + if (!abVtOnly) post({cmd:'listAssets', assetKind: AB_KIND_VTONLY}); } function onAssetList(d) { + if (d.assetKind === AB_KIND_VTONLY) { + var vt = {}; + var vn = (d.names || '').split('\n'); + for (var v = 0; v < vn.length; v++) if (vn[v]) vt[vn[v].toLowerCase()] = 1; + abVtOnly = vt; + /* Re-render: any inspector already on screen was drawn under the "assume it has a decl" + * default and may be offering a carrier this name cannot actually use. */ + for (var k = 0; k < abMounts.length; k++) abRenderInsp(abMounts[k]); + return; + } var t = null; for (var i = 0; i < AB_TYPES.length; i++) if (AB_TYPES[i].kind === d.assetKind) t = AB_TYPES[i]; if (!t) return; @@ -4501,20 +5065,6 @@ if (!names.length) toast('The ' + t.label.toLowerCase() + ' catalog came back empty - is base\\snap_gameresources.index readable?'); } - function renderMaterialResult(d) { - /* String.fromCharCode (not a literal glyph / \u escape) -- the mockup.html -> C++ header embedding - * pipeline does not appear to preserve non-ASCII source bytes as valid UTF-8 (a literal check/cross - * character here rendered as three mangled "?" glyphs in-game). Plain ASCII source, built up at - * runtime, sidesteps the embedding step entirely regardless of what it does with encoding. */ - var CHECK = String.fromCharCode(0x2713), CROSS = String.fromCharCode(0x2717); - var outs = document.querySelectorAll('[data-ab-res]'); - for (var i = 0; i < outs.length; i++) { - outs[i].textContent = d.found ? (CHECK + ' ' + d.name + ' -- ' + (d.info || 'found')) - : (CROSS + ' ' + d.name + ' -- not a material name the game knows'); - outs[i].className = 'mat-result ' + (d.found ? 'ok' : 'err'); - } - } - /* ==== New entity ============================================================================== * Authors a ONE-entity prefab and stages it through the same path Load/Place uses, so the engine's * own PasteInstantiate does the creating and the new entity arrives on the cursor to be placed. @@ -4602,7 +5152,7 @@ /* The `edit` object for a new entity of this type. Mirrors the decl, but in the prefab's JSON * spelling (nested objects, "item[N]" keys). */ - function neEditBody(type, carrier, name, rect, cam) { + function neEditBody(type, carrier, name, rect, cam, extra) { var I = ' '; var pos = I + '"spawnPosition" : { "x" : ' + neNum(cam.x) + ', "y" : ' + neNum(cam.y) + ', "z" : ' + neNum(cam.z) + ' }'; @@ -4614,9 +5164,23 @@ /* Override the def's customMaterial too. snapmaps/volume/blocking inherits * material/snapmap/entitybase, which will not show a virtual-textured surface -- the parm * would land and the box would look untouched. */ + /* A NEW entity has no prior ramp to preserve, and the carrier ships ramp1 = 1 (one tile per + * 426 units), so it must be written or the texture arrives badly stretched. Bloom follows + * the same per-texture table Apply uses. */ + var nePar = NE_BLOCKING_PARMS.concat([{parm:'virtualmapping', value:v}]); + var neXp = abRectOf(name); + var neRamp = (extra && extra.ramp) ? extra.ramp : abRampValue(neXp, AB_RAMP_DEFAULT); + nePar = nePar.concat([{parm:'ramp1', value: neRamp}]); + if (!extra || extra.bloom) + nePar = nePar.concat([{parm:'bloommaskscale', + value: (extra && extra.bloom) ? extra.bloom + : abBloomValue(AB_BLOOM_DEFAULT)}]); inner = I + ' "customMaterial" : ' + neStr(VM_BLOCK_MATERIAL) + ',\n' - + neParms(NE_BLOCKING_PARMS.concat([{parm:'virtualmapping', value:v}]), I + ' '); + + neParms(nePar, I + ' '); } else { + /* By-name carrier. ramp1/bloommaskscale are inert here -- confirmed in-editor that only + * Virtual Mapping carries the triplanar program that reads them -- so this stays a bare + * customMaterial write with no parms attached. */ inner = I + ' "customMaterial" : ' + neStr(name); } return I + '"renderModelInfo" : {\n' + inner + '\n' + I + '},\n' + pos; @@ -4757,6 +5321,9 @@ var opt = opts[0]; if (sel) for (var i = 0; i < opts.length; i++) if (opts[i].id === sel.value) opt = opts[i]; if (m.type === 'material' && m.carrier === 'vm') { + /* Same shortcut as abApply: a baked cross-platform entry already knows its rect. */ + var xpn = abRectOf(m.sel); + if (xpn) { abNewEntityNow(m, opt, m.sel, {w: xpn.w, h: xpn.h, x: xpn.x, y: xpn.y}); return; } abPendingNewVM = {m: m, opt: opt, name: m.sel}; post({cmd:'materialRect', name: m.sel}); return; @@ -4764,7 +5331,7 @@ abNewEntityNow(m, opt, m.sel, null); } function abNewEntityNow(m, opt, name, rect) { - var body = neEditBody(m.type, m.carrier, name, rect, neCam()); + var body = neEditBody(m.type, m.carrier, name, rect, neCam(), abVmExtra(m, name)); /* Name it after the asset, so the placed entity is identifiable in the entity list rather than * arriving as a bare class name among however many others share that class. */ var leaf = name.slice(name.lastIndexOf('/') + 1); From 581799a5695b7fb6bd899b40fdba9355846f83d6 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:01:06 -0500 Subject: [PATCH 11/30] ui: add 29 new cross-platform textures from an updated rawmap Diffed the owner's newer rawmap.json (374 virtualmapping-carrying entities, up from 344) against AB_XPLAT_RAW and re-verified every new name byte-for-byte against the .vmtr atlas, the same tier of proof the original 195 already had. 15 of the 29 are material/snapmap/dynamic_block_textured_* variants (hell, gore, industrial) with decls; the other 14 are textures/snapmaps/hotspots/* names with none, matching the existing decl/no-decl split. 224 total now. Also drops a stale duplicate comment on abRampLabel left over from an earlier two-axis-label version that was already reverted to a single number for square textures -- it contradicted the comment right below it. --- src/ui/webview/mockup.html | 76 +++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 8c6fe97..959551a 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -3800,18 +3800,19 @@ var AB_MAX_HITS = 400; /* cap the filter result DOM; the filter is what narrows */ /* ---- Cross-platform textures ----------------------------------------------------------------- - * The 195 megatexture rects known to render as the same art on PC, Xbox and PlayStation. + * The 224 megatexture rects known to render as the same art on PC, Xbox and PlayStation. * * THIS SET IS EMPIRICAL AND CANNOT BE DERIVED. Most virtual-mapped textures are NOT the same * across platforms -- the atlas is not laid out identically everywhere, so an arbitrary rect can be - * completely different art on a console. These 195 are the survivors of somebody's vigorous - * platform-by-platform testing, supplied to this project as a working rawmap. There is no rule in - * the .vmtr tables that separates them from the rest, so DO NOT try to compute a larger list: the - * only way to add an entry is to test it on the actual hardware. Treat any name not in this table - * as unknown rather than as excluded. + * completely different art on a console. These 224 are the survivors of somebody's vigorous + * platform-by-platform testing, supplied to this project as a working rawmap (195 original, plus + * 29 added 2026-08-09 from an updated rawmap.json covering more hell/industrial/hotspot textures). + * There is no rule in the .vmtr tables that separates them from the rest, so DO NOT try to compute + * a larger list: the only way to add an entry is to test it on the actual hardware. Treat any name + * not in this table as unknown rather than as excluded. * * What was verified here is only the transcription: every displayName in that rawmap matched, byte - * for byte, the name in the authoritative `.vmtr` atlas row at the same rect -- 195/195, no misses + * for byte, the name in the authoritative `.vmtr` atlas row at the same rect -- 224/224, no misses * either direction. That makes the names and rects below trustworthy; it says nothing about why * these particular ones are portable. * @@ -3819,15 +3820,15 @@ * installs the build has no such file and this has to work for them too. * * Two things about this set are easy to get wrong: - * 1. The texture identity is the RECT, not a material name. All 344 source entities used just + * 1. The texture identity is the RECT, not a material name. All 374 source entities used just * two carrier materials and varied only the virtualmapping value, so `d` below (does a - * `material` decl of this name exist?) is TRUE for only 33 of the 195. - * 2. Because of that, Custom Material cannot be offered for the other 162 -- there is no decl + * `material` decl of this name exist?) is TRUE for only 48 of the 224. + * 2. Because of that, Custom Material cannot be offered for the other 176 -- there is no decl * to name. Virtual Mapping is the only carrier that can carry them. abCarriers enforces it. * * Format: name|w|h|x|y|hasDecl, atlas pixels. virtualmapping = (w, h, x, y) / 245760. * Previews need nothing extra: megapreview resolves names straight out of the .vmtr tables and - * never consults the material decl, so all 195 preview even without one. */ + * never consults the material decl, so all 224 preview even without one. */ var AB_XPLAT_RAW = [ 'material/snapmap/dynamic_block_textured_hell_cave_facade|3840|3840|88320|138240|1', 'models/mapobjects/ca/ca_mp_sacrilegious/ca_hell_hellsack_flat|3840|3840|92160|203520|0', @@ -4023,19 +4024,48 @@ 'textures/whitecollar/wc_panel_maint_1d|3840|3840|138240|157440|1', 'textures/whitecollar/wc_panel_maint_tall_1a|3840|3840|142080|153600|1', 'textures/whitecollar/wc_panel_tall_1_darkblue|3840|3840|103680|207360|1', - 'textures/whitecollar/wc_space_caps|1920|1920|32640|128640|0' + 'textures/whitecollar/wc_space_caps|1920|1920|32640|128640|0', + 'material/snapmap/dynamic_block_textured_gore_blobby|1920|1920|80640|149760|1', + 'material/snapmap/dynamic_block_textured_gore_floor|3840|3840|84480|145920|1', + 'material/snapmap/dynamic_block_textured_gore_floor_02|1920|1920|82560|149760|1', + 'material/snapmap/dynamic_block_textured_gore_meat_01|1920|1920|80640|151680|1', + 'material/snapmap/dynamic_block_textured_gore_wormy|1920|1920|82560|151680|1', + 'material/snapmap/dynamic_block_textured_hell_brickpattern01|3840|3840|84480|138240|1', + 'material/snapmap/dynamic_block_textured_hell_cave_rock_01|3840|3840|84480|142080|1', + 'material/snapmap/dynamic_block_textured_hell_floor_tile01|3840|3840|88320|142080|1', + 'material/snapmap/dynamic_block_textured_hell_skull_tile|960|960|81600|142080|1', + 'material/snapmap/dynamic_block_textured_industrial_floor_interior_02|3840|3840|76800|145920|1', + 'material/snapmap/dynamic_block_textured_industrial_floor_interior_04|1920|1920|82560|142080|1', + 'material/snapmap/dynamic_block_textured_industrial_metal_tile|3840|3840|80640|145920|1', + 'material/snapmap/dynamic_block_textured_industrial_snap_non_slip|3840|3840|76800|149760|1', + 'material/snapmap/dynamic_block_textured_industrial_tile_diamondplate|1920|1920|80640|144000|1', + 'material/snapmap/dynamic_block_textured_industrial_tile_hex|1920|1920|82560|144000|1', + 'textures/snapmaps/hotspots/bc_wall_panel_darkred|3840|3840|19200|192000|0', + 'textures/snapmaps/hotspots/brick_01_tile|1920|1920|7680|190080|0', + 'textures/snapmaps/hotspots/ind_panels_02_grey_hotspot|3840|3840|7680|195840|0', + 'textures/snapmaps/hotspots/ind_panels_02_org|3840|3840|15360|195840|0', + 'textures/snapmaps/hotspots/ind_panels_light_grey_hotspot|3840|3840|11520|195840|0', + 'textures/snapmaps/hotspots/ind_panels_steel|3840|3840|19200|195840|0', + 'textures/snapmaps/hotspots/panel_maint_1_gray_hotspot|3840|3840|15360|192000|0', + 'textures/snapmaps/hotspots/panel_maint_1_green|3840|3840|23040|192000|0', + 'textures/snapmaps/hotspots/stone_wall_01_hotspot|1920|1920|9600|190080|0', + 'textures/snapmaps/hotspots/stone_wall_02_sandstone_hotspot|1920|1920|11520|190080|0', + 'textures/snapmaps/hotspots/stone_wall_02_tile|1920|1920|5760|190080|0', + 'textures/snapmaps/hotspots/wc_panel_1b|3840|3840|34560|222720|0', + 'textures/snapmaps/hotspots/wc_panel_1c|3840|3840|30720|226560|0', + 'textures/snapmaps/hotspots/wc_panel_maint_1a_red|3840|3840|23040|195840|0' ]; - /* Why 162 of the 195 have no `material` decl, since it reads like an error and is not: + /* Why 176 of the 224 have no `material` decl, since it reads like an error and is not: * * The `.vmtr` table and the material decls are DIFFERENT registries. A `.vmtr` row says "this named * texture occupies this rectangle of the megatexture" -- pure coordinates. A `material` record is a - * definition the engine resolves BY NAME. All 195 are in the .vmtr (that is what gives each one a - * virtualmapping at all); 33 are `material` decls in the snap box, and the other **162 are material + * definition the engine resolves BY NAME. All 224 are in the .vmtr (that is what gives each one a + * virtualmapping at all); 48 are `material` decls in the snap box, and the other **176 are material * decls in the CAMPAIGN box**, which SnapMap never mounts. * * That is what makes the technique reach this art at all. `customMaterial` asks SnapMap to resolve - * a NAME, and for those 162 it cannot -- the decl is in a box it does not load, so you would get + * a NAME, and for those 176 it cannot -- the decl is in a box it does not load, so you would get * nothing. `virtualmapping` asks for a RECTANGLE, so no name lookup happens and the box rule never * applies: the pixels are in `virtualtextures/`, which SnapMap does read. * @@ -4073,7 +4103,7 @@ var abRectCache = {}; /* The dimensions to derive a ramp aspect from: the baked table first (it is authoritative and * needs no round-trip), then anything the backend has told us. Null means "not atlas-backed, or - * not looked up yet" -- callers fall back to a square rate, which is correct for the 173 of 195 + * not looked up yet" -- callers fall back to a square rate, which is correct for the 202 of 224 * square textures and simply un-corrected for the rest until the rect lands. */ function abRectOf(name) { return abXplatOf(name) || abRectCache[(name || '').toLowerCase()] || null; @@ -4116,7 +4146,7 @@ * eyes, engine lights, jump pads all get tuned down by hand in practice. * * An earlier version baked a per-texture table of those tuned values. That was dropped - * deliberately: it covered four textures out of 195, it would need extending every time somebody + * deliberately: it covered four textures out of 224, it would need extending every time somebody * tuned another, and it presented one author's taste as if it were a property of the texture. The * dropdown gives the mapper the same range shipped content uses and lets them look at the surface. * @@ -4139,12 +4169,6 @@ var fmt = function (n) { return String(AB_RAMP_CONST / n); }; return '{ -' + fmt(p.x) + ', ' + fmt(p.y) + ', 0, 0 }'; } - /* The option label. ALWAYS both axes when the texture size is known -- "128 x 128" for a square - * one, "8 x 64" for a 1:8 one. An earlier version collapsed the square case to a bare "128", which - * read as an unexplained single number: 173 of the 195 cross-platform textures are square, so in - * practice the two-axis form was almost never on screen and there was nothing to compare against. - * When the rect has not arrived yet we CANNOT know the aspect, so say the size is unknown rather - * than print "128 x 128" and be wrong for a non-square texture. */ /* Single number when the texture is square (by far the common case -- x period == y period, so * a second number adds nothing a mapper can act on in the half-second they look at this list). * Two numbers only when it would actually be wrong to collapse them. Unknown size (rect not @@ -4157,7 +4181,7 @@ } /* The list a mount is currently showing. The cross-platform set is a SEPARATE source keyed off a - * pseudo-type, not a filter over the fetched material catalog -- 162 of its 195 names have no + * pseudo-type, not a filter over the fetched material catalog -- 176 of its 224 names have no * `material` record at all, so filtering the catalog would silently hide the majority of them. */ function abListType(m) { return (m.type === 'material' && m.xplat) ? 'material@xplat' : m.type; @@ -4322,7 +4346,7 @@ * meaningless outside the megatexture atlas. */ + '' + /* Sounds only. Sound names are almost all flat, so the folder tree barely helps; the bank a + * sound ships in is the structure that actually separates them -- and `doom_snapmaps` is the + * set SnapMap itself uses. */ + + '' + '
' + '
' + '
Asset' @@ -4367,11 +4427,17 @@ m.xplat = this.checked; m.dir = ''; m.sel = null; abRenderTree(m); abRenderInsp(m); }); + /* Same reset as the cross-platform toggle: a different bank is a different list, so the folder + * path and the selection from the previous one are both meaningless afterwards. */ + m.el('bank').addEventListener('change', function(){ + m.bank = this.value; m.dir = ''; m.sel = null; + abRenderTree(m); abRenderInsp(m); + }); m.el('rail').addEventListener('click', function(e){ var it = e.target.closest ? e.target.closest('[data-type]') : null; if (!it) return; - m.type = it.getAttribute('data-type'); m.dir = ''; m.sel = null; - abSyncXplat(m); + m.type = it.getAttribute('data-type'); m.dir = ''; m.sel = null; m.bank = ''; + abSyncXplat(m); abSyncBank(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); abFetch(m, false); }); @@ -4415,7 +4481,7 @@ } }); abMounts.push(m); - abSyncXplat(m); + abSyncXplat(m); abSyncBank(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); return m; } @@ -4629,7 +4695,9 @@ * constant however deep the path is, and the breadcrumb gets you back in one click. */ function abRenderTree(m) { var el = m.el('tree'), crumbs = m.el('crumbs'), cnt = m.el('cnt'); - var names = abNames[abListType(m)]; + var lt = abListType(m); + abEnsureBankNames(lt); /* derives sound@ the first time it is asked for */ + var names = abNames[lt]; if (!names) { el.innerHTML = '
Loading the catalog...
'; crumbs.innerHTML = ''; cnt.textContent = '...'; @@ -5073,7 +5141,15 @@ var t = abType(m.type); if (!t) return; if (abNames[m.type] && !force) return; - if (force) { abNames[m.type] = undefined; abTrees[m.type] = null; abRenderTree(m); } + if (force) { + abNames[m.type] = undefined; abTrees[m.type] = null; + /* The per-bank lists are DERIVED from abNames['sound'], so a refetch has to drop them too -- + * otherwise the filter keeps serving rows built from the catalog we just threw away. */ + if (m.type === 'sound') + for (var bk in abNames) + if (abNames.hasOwnProperty(bk) && bk.indexOf('sound@') === 0) { abNames[bk] = undefined; abTrees[bk] = null; } + abRenderTree(m); + } m.el('cnt').textContent = '...'; post({cmd:'listAssets', assetKind: t.kind}); } @@ -5087,8 +5163,33 @@ /* Not an AB_TYPES row -- see abVtOnly. Fetched alongside the catalogs so the carrier gate is * accurate by the time anything is selected. */ if (!abVtOnly) post({cmd:'listAssets', assetKind: AB_KIND_VTONLY}); + if (!abSndBank) post({cmd:'listAssets', assetKind: AB_KIND_SNDBANK}); } function onAssetList(d) { + if (d.assetKind === AB_KIND_SNDBANK) { + var map = {}, tally = {}; + var bl = (d.names || '').split('\n'); + for (var b = 0; b < bl.length; b++) { + if (!bl[b]) continue; + var cut = bl[b].lastIndexOf('|'); /* last '|' -- a name could contain one, a bank cannot */ + if (cut < 0) continue; + var nm = bl[b].slice(0, cut), bk = bl[b].slice(cut + 1); + if (!nm || !bk) continue; + map[nm.toLowerCase()] = bk; + tally[bk] = (tally[bk] || 0) + 1; + } + abSndBank = map; + var list = []; + for (var kb in tally) if (tally.hasOwnProperty(kb)) list.push({bank: kb, n: tally[kb]}); + list.sort(function (x, y) { return y.n - x.n; }); + abBanks = list; + /* The dropdown is built from this, and the sound list may already be on screen. */ + for (var mb = 0; mb < abMounts.length; mb++) { + abSyncBank(abMounts[mb]); + if (abMounts[mb].type === 'sound') abRenderTree(abMounts[mb]); + } + return; + } if (d.assetKind === AB_KIND_VTONLY) { var vt = {}; var vn = (d.names || '').split('\n'); From cbd52630a9da02983dbf577d2f5e2da97cd62d8e Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:30:18 -0500 Subject: [PATCH 17/30] ui: give the browser-preview side a real sound + soundbank sample The browser-preview harness only sampled materials, so opening mockup.html outside the game showed an empty sound list -- and with the soundbank filter added, a dropdown that hid itself for want of data. The only way to exercise either was to launch DOOM, which is minutes per iteration. Adds a 182-name slice of the real sound catalog across 14 real banks, each name paired with the bank soundbanksinfo.xml actually puts it in. Sampled from shipped data, not invented -- the same rule PREVIEW_MATERIALS already follows, and the reason the other catalogs still answer empty rather than being faked. Enough to drive the filter and, importantly, to see that search narrows WITHIN the selected bank rather than escaping it: both run off the same derived `sound@` list. --- src/ui/webview/mockup.html | 297 ++++++++++++++++++++++++++++++++++++- 1 file changed, 293 insertions(+), 4 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 8b0e6c8..62afcc5 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -974,6 +974,288 @@ 102: JSON.stringify({ entityDef: { state: { edit: { componentTimeLine: { entityEvents: { num: 0 } } } } } }), 103: JSON.stringify({ entityDef: { state: { edit: {} } } }) }; + /* A 182-name sample of the REAL sound catalog with each name's REAL soundbank, taken straight + * from soundbanksinfo.xml across 14 banks. Sampled, not invented -- same rule PREVIEW_MATERIALS + * follows: the browser-preview side may carry a SLICE of shipped data so the UI can be exercised + * without launching the game, but it must never make game data up. It exists so the soundbank + * filter and its interaction with search can be driven in a plain browser. */ + var PREVIEW_SOUNDS = + 'Play_wpn_HAR_dryFire\nPlay_wpn_sp_HAR_dryfire\n' + + 'Play_wpn_sp_HAR_fire\nPlay_wpn_sp_HAR_Zoom_02\n' + + 'Play_wpn_sp_HAR_Zoom_out_02\nPlay_wpn_sp_chainsaw_rev_high_rpm_decel_mid\n' + + 'Play_wpn_sp_chainsaw_start_up\nPlay_wpn_sp_fireball_summon\n' + + 'Play_wpn_sp_gauss_dryFire\nPlay_wpn_sp_HAR_MASTERY_fire\n' + + 'Play_wpn_sp_HAR_missile_ready\nPlay_wpn_sp_HAR_MissileCantFire\n' + + 'Play_wpn_sp_HAR_missileChamber_Close_S\nPlay_wpn_sp_HAR_missileChamber_mastered\n' + + 'Play_wpn_sp_HAR_missileChamber_S\nPlay_wpn_sp_HAR_StickyBomb\n' + + 'Play_wpn_sp_HAR_stickyshot_explode\nPlay_wpn_sp_plasma_hellified_soldier_charge\n' + + 'Play_wpn_sp_plasma_hellified_soldier_fire\nPlay_air_res_mid\n' + + 'Play_air_res_mid_01\nPlay_amb_3d_air_01\n' + + 'Play_amb_3d_air_02\nPlay_amb_3d_air_03\n' + + 'Play_amb_3d_air_04\nPlay_amb_3d_air_05\n' + + 'Play_amb_3d_alarm_01\nPlay_amb_3d_alarm_02\n' + + 'Play_amb_3d_alarm_03\nPlay_amb_3d_alarm_04\n' + + 'Play_amb_3d_alarm_05\nPlay_amb_3d_computer_01\n' + + 'Play_amb_3d_computer_02\nPlay_amb_3d_computer_03\n' + + 'Play_amb_3d_computer_04\nPlay_amb_3d_electrical_01\n' + + 'Play_amb_3d_electrical_02\nPlay_amb_3d_fire_01\n' + + 'Play_amb_3d_fire_02\nPlay_amb_3d_fire_03\n' + + 'Play__avoid_enemy_grenade\nPlay__behind_enemy\n' + + 'Play__breath\nPlay__breathe_in_out\n' + + 'Play__call_reinforcements\nPlay__chatter\n' + + 'Play__death\nPlay__death_glorykill\n' + + 'Play__enemy_behind\nPlay__enemy_charging\n' + + 'Play__enemy_down_there\nPlay__enemy_flanking\n' + + 'Play__enemy_lost\nPlay__enemy_on_left\n' + + 'Play__enemy_on_right\nPlay__enemy_over_here\n' + + 'Play__enemy_resighted\nPlay__enemy_retreating\n' + + 'Play__enemy_sighted\nPlay__enemy_surprise\n' + + 'Play__enemy_up_there\nPlay__exertion\n' + + 'Play_action_failed\nPlay_adjust_success\n' + + 'Play_alarm\nPlay_automap_on\n' + + 'Play_bottom_block_UI_on\nPlay_button_press\n' + + 'Play_button_rollover\nPlay_Challenge_Complete\n' + + 'Play_challenge_start_clock\nPlay_codex_entry\n' + + 'Play_empty_error\nPlay_endOfLevel_challenges_01\n' + + 'Play_endOfLevel_collectables\nPlay_endOfLevel_combat_score_01\n' + + 'Play_chainsaw_impact_metal_loop\nPlay_Gauss_charge_mechanism\n' + + 'Play_Gauss_charge_short_tonal\nPlay_gauss_spiny_Charge\n' + + 'Play_handgun_fire_charge_01\nplay_melee_impact_default_player_npc_glory\n' + + 'Play_pistol_pickup\nPlay_pistol_vent\n' + + 'Play_plasma_readyToFire\nPlay_plasma_stun_larger_impact\n' + + 'Play_pop_rocket_explode_01\nPlay_sfx_chaingun_intro\n' + + 'Play_sfx_chaingun_pickup\nPlay_sfx_wpn_sp_switchToSpin\n' + + 'Play_amb_buzzer_gritty_01\nPlay_amb_buzzer_gritty_lpf\n' + + 'Play_amb_dirt_debris_rand_looping\nPlay_amb_drySparks\n' + + 'Play_amb_drySparks_distant\nPlay_amb_fan_small\n' + + 'Play_amb_hor_influence_quad_lp_02\nPlay_amb_light_buzz\n' + + 'Play_amb_low_whistle_wind\nPlay_amb_metal_bending_3d\n' + + 'Play_amb_metal_creaking_3d\nPlay_amb_metal_taps_3d\n' + + 'Play_bossfight_cyberdemon_kill\nPlay_D4_MX_combat_UAC_03_140bpm_wip_GLORY_02\n' + + 'Play_D4_MX_combat_UAC_03_140bpm_wip_GLORY_02_softer\nPlay_D4_QuakeCon_imp_scare_v1\n' + + 'Play_D4_QuakeCon_Sting_TheHand\nPlay_e1m1_looped_temp_for_challenge\n' + + 'Play_enter_dos\nplay_EOL_01_intro\n' + + 'play_EOL_02_res\nPlay_leaving_dos\n' + + 'Play_Argent_tower_final_filter_explosion\nPlay_bot_wake_oneshot\n' + + 'Play_breath_bubbles\nPlay_chainsaw_hellknight\n' + + 'Play_chainsaw_mancubus_back\nPlay_chainsaw_mancubus_front\n' + + 'Play_chainsaw_mancubus_head\nPlay_chainsawGlory_long\n' + + 'Play_chainsawGlory_med\nPlay_chainsawGlory_short\n' + + 'Play_amb_gore_nest_falloff\nPlay_ambient_wind\n' + + 'Play_bloody_punch_switch\nPlay_boss_shield_off\n' + + 'Play_boss_shield_on\nPlay_buckshot_impact_concrete_local\n' + + 'Play_buckshot_impact_dirt_local\nPlay_buckshot_impact_flesh_local\n' + + 'Play_buckshot_impact_glass\nPlay_buckshot_impact_glass_local\n' + + 'Play_armor_s01\nPlay_Armored_Offensive\n' + + 'Play_berzerk\nPlay_bezerk_off\n' + + 'Play_bezerk_on\nPlay_bezerk_time_out_beep_01\n' + + 'Play_blood_fueled\nPlay_branch_hitbyplayer_standing02_finish01\n' + + 'Play_airlock_close_01\nPlay_airlock_open_01\n' + + 'Play_argent_top_shutters\nplay_bounce_01\n' + + 'Play_close\nPlay_door_slide_close\n' + + 'Play_door_slide_open\nPlay_dor_floodgate_slam_01\n' + + 'Play_8Bit_stinger_secret_lever\nPlay_Adrenaline_shot\n' + + 'Play_apophis_infusion\nPlay_bobblehead_01\n' + + 'Play_console_uac_beeps\nPlay_damage_weapon_ending\n' + + 'Play_demonic_scream_1\nPlay_electric_hand\n' + + 'Play_ability_on\nPlay_invuln_active_lp\n' + + 'Play_invulnerability_activate\nPlay_mp_pickup_quad_Switch\n' + + 'Play_pop_rocket_explode_large\nPlay_quad_active_lp\n' + + 'Play_quad_damage_activate_switch\nPlay_quad_fire_1Shot\n' + + 'Play_counter_tick_02\nPlay_counter_tick_03\n' + + 'Play_counter_tick_04\nPlay_counter_tick_05\n' + + 'Play_mp_vega_callout_overtime_01\nPlay_mp_vega_callout_overtime_02\n' + + 'Play_mp_vega_callout_overtime_03\nPlay_stop_vo_facilityvoice_002_020_powerofflineall\n' + + 'Play_UAC_Spokesperson_2022_FullyIndoctrinated_02\nPlay_vo_argent_intro\n' + + 'Play_vo_snapmaps_genericfemale1_absolutely_01\nPlay_vo_snapmaps_genericfemale1_accidentreported_01\n' + + 'Play_vo_snapmaps_genericfemale1_activatethatpanel_01\nPlay_vo_snapmaps_genericfemale1_activatethis_01\n' + + 'Play_vo_snapmaps_genericfemale1_ahsomeonenew_01\nPlay_vo_snapmaps_genericfemale1_allnewarrivals_01\n' + + 'Play_vo_snapmaps_genericfemale1_areaisoverrun_01\nPlay_vo_snapmaps_genericfemale1_argh1_01\n'; + + var PREVIEW_SNDBANKS = + 'Play_wpn_HAR_dryFire|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_dryfire|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_fire|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_Zoom_02|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_Zoom_out_02|doom_snapmaps\n' + + 'Play_wpn_sp_chainsaw_rev_high_rpm_decel_mid|doom_snapmaps\n' + + 'Play_wpn_sp_chainsaw_start_up|doom_snapmaps\n' + + 'Play_wpn_sp_fireball_summon|doom_snapmaps\n' + + 'Play_wpn_sp_gauss_dryFire|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_MASTERY_fire|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_missile_ready|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_MissileCantFire|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_missileChamber_Close_S|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_missileChamber_mastered|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_missileChamber_S|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_StickyBomb|doom_snapmaps\n' + + 'Play_wpn_sp_HAR_stickyshot_explode|doom_snapmaps\n' + + 'Play_wpn_sp_plasma_hellified_soldier_charge|doom_snapmaps\n' + + 'Play_wpn_sp_plasma_hellified_soldier_fire|doom_snapmaps\n' + + 'Play_air_res_mid|doom_snapmaps\n' + + 'Play_air_res_mid_01|doom_snapmaps\n' + + 'Play_amb_3d_air_01|doom_snapmaps\n' + + 'Play_amb_3d_air_02|doom_snapmaps\n' + + 'Play_amb_3d_air_03|doom_snapmaps\n' + + 'Play_amb_3d_air_04|doom_snapmaps\n' + + 'Play_amb_3d_air_05|doom_snapmaps\n' + + 'Play_amb_3d_alarm_01|doom_snapmaps\n' + + 'Play_amb_3d_alarm_02|doom_snapmaps\n' + + 'Play_amb_3d_alarm_03|doom_snapmaps\n' + + 'Play_amb_3d_alarm_04|doom_snapmaps\n' + + 'Play_amb_3d_alarm_05|doom_snapmaps\n' + + 'Play_amb_3d_computer_01|doom_snapmaps\n' + + 'Play_amb_3d_computer_02|doom_snapmaps\n' + + 'Play_amb_3d_computer_03|doom_snapmaps\n' + + 'Play_amb_3d_computer_04|doom_snapmaps\n' + + 'Play_amb_3d_electrical_01|doom_snapmaps\n' + + 'Play_amb_3d_electrical_02|doom_snapmaps\n' + + 'Play_amb_3d_fire_01|doom_snapmaps\n' + + 'Play_amb_3d_fire_02|doom_snapmaps\n' + + 'Play_amb_3d_fire_03|doom_snapmaps\n' + + 'Play__avoid_enemy_grenade|doom_monsters\n' + + 'Play__behind_enemy|doom_monsters\n' + + 'Play__breath|doom_monsters\n' + + 'Play__breathe_in_out|doom_monsters\n' + + 'Play__call_reinforcements|doom_monsters\n' + + 'Play__chatter|doom_monsters\n' + + 'Play__death|doom_monsters\n' + + 'Play__death_glorykill|doom_monsters\n' + + 'Play__enemy_behind|doom_monsters\n' + + 'Play__enemy_charging|doom_monsters\n' + + 'Play__enemy_down_there|doom_monsters\n' + + 'Play__enemy_flanking|doom_monsters\n' + + 'Play__enemy_lost|doom_monsters\n' + + 'Play__enemy_on_left|doom_monsters\n' + + 'Play__enemy_on_right|doom_monsters\n' + + 'Play__enemy_over_here|doom_monsters\n' + + 'Play__enemy_resighted|doom_monsters\n' + + 'Play__enemy_retreating|doom_monsters\n' + + 'Play__enemy_sighted|doom_monsters\n' + + 'Play__enemy_surprise|doom_monsters\n' + + 'Play__enemy_up_there|doom_monsters\n' + + 'Play__exertion|doom_monsters\n' + + 'Play_action_failed|doom_ui\n' + + 'Play_adjust_success|doom_ui\n' + + 'Play_alarm|doom_ui\n' + + 'Play_automap_on|doom_ui\n' + + 'Play_bottom_block_UI_on|doom_ui\n' + + 'Play_button_press|doom_ui\n' + + 'Play_button_rollover|doom_ui\n' + + 'Play_Challenge_Complete|doom_ui\n' + + 'Play_challenge_start_clock|doom_ui\n' + + 'Play_codex_entry|doom_ui\n' + + 'Play_empty_error|doom_ui\n' + + 'Play_endOfLevel_challenges_01|doom_ui\n' + + 'Play_endOfLevel_collectables|doom_ui\n' + + 'Play_endOfLevel_combat_score_01|doom_ui\n' + + 'Play_chainsaw_impact_metal_loop|doom_weapon_sp\n' + + 'Play_Gauss_charge_mechanism|doom_weapon_sp\n' + + 'Play_Gauss_charge_short_tonal|doom_weapon_sp\n' + + 'Play_gauss_spiny_Charge|doom_weapon_sp\n' + + 'Play_handgun_fire_charge_01|doom_weapon_sp\n' + + 'play_melee_impact_default_player_npc_glory|doom_weapon_sp\n' + + 'Play_pistol_pickup|doom_weapon_sp\n' + + 'Play_pistol_vent|doom_weapon_sp\n' + + 'Play_plasma_readyToFire|doom_weapon_sp\n' + + 'Play_plasma_stun_larger_impact|doom_weapon_sp\n' + + 'Play_pop_rocket_explode_01|doom_weapon_sp\n' + + 'Play_sfx_chaingun_intro|doom_weapon_sp\n' + + 'Play_sfx_chaingun_pickup|doom_weapon_sp\n' + + 'Play_sfx_wpn_sp_switchToSpin|doom_weapon_sp\n' + + 'Play_amb_buzzer_gritty_01|doom_ambience\n' + + 'Play_amb_buzzer_gritty_lpf|doom_ambience\n' + + 'Play_amb_dirt_debris_rand_looping|doom_ambience\n' + + 'Play_amb_drySparks|doom_ambience\n' + + 'Play_amb_drySparks_distant|doom_ambience\n' + + 'Play_amb_fan_small|doom_ambience\n' + + 'Play_amb_hor_influence_quad_lp_02|doom_ambience\n' + + 'Play_amb_light_buzz|doom_ambience\n' + + 'Play_amb_low_whistle_wind|doom_ambience\n' + + 'Play_amb_metal_bending_3d|doom_ambience\n' + + 'Play_amb_metal_creaking_3d|doom_ambience\n' + + 'Play_amb_metal_taps_3d|doom_ambience\n' + + 'Play_bossfight_cyberdemon_kill|doom_music\n' + + 'Play_D4_MX_combat_UAC_03_140bpm_wip_GLORY_02|doom_music\n' + + 'Play_D4_MX_combat_UAC_03_140bpm_wip_GLORY_02_softer|doom_music\n' + + 'Play_D4_QuakeCon_imp_scare_v1|doom_music\n' + + 'Play_D4_QuakeCon_Sting_TheHand|doom_music\n' + + 'Play_e1m1_looped_temp_for_challenge|doom_music\n' + + 'Play_enter_dos|doom_music\n' + + 'play_EOL_01_intro|doom_music\n' + + 'play_EOL_02_res|doom_music\n' + + 'Play_leaving_dos|doom_music\n' + + 'Play_Argent_tower_final_filter_explosion|doom_scripted_events\n' + + 'Play_bot_wake_oneshot|doom_scripted_events\n' + + 'Play_breath_bubbles|doom_scripted_events\n' + + 'Play_chainsaw_hellknight|doom_scripted_events\n' + + 'Play_chainsaw_mancubus_back|doom_scripted_events\n' + + 'Play_chainsaw_mancubus_front|doom_scripted_events\n' + + 'Play_chainsaw_mancubus_head|doom_scripted_events\n' + + 'Play_chainsawGlory_long|doom_scripted_events\n' + + 'Play_chainsawGlory_med|doom_scripted_events\n' + + 'Play_chainsawGlory_short|doom_scripted_events\n' + + 'Play_amb_gore_nest_falloff|doom_effects\n' + + 'Play_ambient_wind|doom_effects\n' + + 'Play_bloody_punch_switch|doom_effects\n' + + 'Play_boss_shield_off|doom_effects\n' + + 'Play_boss_shield_on|doom_effects\n' + + 'Play_buckshot_impact_concrete_local|doom_effects\n' + + 'Play_buckshot_impact_dirt_local|doom_effects\n' + + 'Play_buckshot_impact_flesh_local|doom_effects\n' + + 'Play_buckshot_impact_glass|doom_effects\n' + + 'Play_buckshot_impact_glass_local|doom_effects\n' + + 'Play_armor_s01|doom_player\n' + + 'Play_Armored_Offensive|doom_player\n' + + 'Play_berzerk|doom_player\n' + + 'Play_bezerk_off|doom_player\n' + + 'Play_bezerk_on|doom_player\n' + + 'Play_bezerk_time_out_beep_01|doom_player\n' + + 'Play_blood_fueled|doom_player\n' + + 'Play_branch_hitbyplayer_standing02_finish01|doom_player\n' + + 'Play_airlock_close_01|doom_movers\n' + + 'Play_airlock_open_01|doom_movers\n' + + 'Play_argent_top_shutters|doom_movers\n' + + 'play_bounce_01|doom_movers\n' + + 'Play_close|doom_movers\n' + + 'Play_door_slide_close|doom_movers\n' + + 'Play_door_slide_open|doom_movers\n' + + 'Play_dor_floodgate_slam_01|doom_movers\n' + + 'Play_8Bit_stinger_secret_lever|doom_items\n' + + 'Play_Adrenaline_shot|doom_items\n' + + 'Play_apophis_infusion|doom_items\n' + + 'Play_bobblehead_01|doom_items\n' + + 'Play_console_uac_beeps|doom_items\n' + + 'Play_damage_weapon_ending|doom_items\n' + + 'Play_demonic_scream_1|doom_items\n' + + 'Play_electric_hand|doom_items\n' + + 'Play_ability_on|doom_mp\n' + + 'Play_invuln_active_lp|doom_mp\n' + + 'Play_invulnerability_activate|doom_mp\n' + + 'Play_mp_pickup_quad_Switch|doom_mp\n' + + 'Play_pop_rocket_explode_large|doom_mp\n' + + 'Play_quad_active_lp|doom_mp\n' + + 'Play_quad_damage_activate_switch|doom_mp\n' + + 'Play_quad_fire_1Shot|doom_mp\n' + + 'Play_counter_tick_02|doom_vo\n' + + 'Play_counter_tick_03|doom_vo\n' + + 'Play_counter_tick_04|doom_vo\n' + + 'Play_counter_tick_05|doom_vo\n' + + 'Play_mp_vega_callout_overtime_01|doom_vo\n' + + 'Play_mp_vega_callout_overtime_02|doom_vo\n' + + 'Play_mp_vega_callout_overtime_03|doom_vo\n' + + 'Play_stop_vo_facilityvoice_002_020_powerofflineall|doom_vo\n' + + 'Play_UAC_Spokesperson_2022_FullyIndoctrinated_02|doom_vo\n' + + 'Play_vo_argent_intro|doom_vo\n' + + 'Play_vo_snapmaps_genericfemale1_absolutely_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_accidentreported_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_activatethatpanel_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_activatethis_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_ahsomeonenew_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_allnewarrivals_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_areaisoverrun_01|doom_dlc3_vo\n' + + 'Play_vo_snapmaps_genericfemale1_argh1_01|doom_dlc3_vo\n'; + function previewList() { return {kind:'list', version:'local (preview)', editorReady:true, count:0, entities:previewEnts, timelines:previewTimelines}; } var PREVIEW_MATERIALS = 'default\nmaterial/snapmap/dynamic_block_textured_gore_floor\n' + @@ -1078,11 +1360,18 @@ // A 130-name sample of the REAL SnapMap material catalog (sampled from the campaign's // vt-backed-snap-materials.txt), so the folder tree in a plain browser has the same shape and // depth the in-game one does. The in-game catalog is ~9,800 names and comes from the backend. - // Only materials have a browser-preview sample; every other type answers empty, which the rail's - // "-" count then reflects honestly. Faking nine catalogs would be inventing game data. + // Materials and SOUNDS have a browser-preview sample (both sliced from real shipped data, see + // PREVIEW_MATERIALS / PREVIEW_SOUNDS); every other type answers empty, which the rail's "-" + // count then reflects honestly. Faking the rest would be inventing game data. + // + // Sounds carry their soundbank map too (kind 13), so the bank filter -- and the fact that search + // narrows WITHIN the selected bank -- can both be exercised here without launching the game. else if (o.cmd === 'listAssets') { - onMessage({kind:'assetList', assetKind: o.assetKind, - names: (o.assetKind === 0 ? PREVIEW_MATERIALS : '')}); + var sample = ''; + if (o.assetKind === 0) sample = PREVIEW_MATERIALS; /* SH_ASSET_MATERIAL */ + else if (o.assetKind === 3) sample = PREVIEW_SOUNDS; /* SH_ASSET_SOUND */ + else if (o.assetKind === 13) sample = PREVIEW_SNDBANKS; /* SH_ASSET_SNDBANK */ + onMessage({kind:'assetList', assetKind: o.assetKind, names: sample}); } // No decoder outside the game, so the preview honestly reports that rather than faking pixels. else if (o.cmd === 'requestPreview') { onMessage({kind:'previewRequested', ok:false, name:o.name, From ee2123c661847db5904f0c16ed72037ebd58e118 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:27:11 -0500 Subject: [PATCH 18/30] assets: let the mapper pin a shortlist of assets Finding a texture in 12,635 names is work, and the browser made you redo that work every time you came back to it. Pinned is the mapper's own shortlist: a star in the left gutter of every asset row, and a "Pinned" row at the top of the type rail holding whatever they put there. One list across all types, not one per type. A mapper working on a room is holding a couple of textures, a model and a sound in their head at once -- that is one working set, and splitting it across four rails would mean the shortlist needs the same navigation the shortlist exists to avoid. Stored in %LOCALAPPDATA%\snapmap-plus\pinned.json, deliberately NOT in the settings config. The settings file is all-or-nothing: any parse failure resets the whole document to 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 host moves the bytes and does no parsing -- shape and validation live in the UI, which is the only side that knows what a pin means -- so the worst a broken file can do is cost the pins. The list-vs-selection split this forced is the part worth remembering. Every other list in the browser 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 pinned row itself, and the 13 call sites that decide carriers, previewability and Apply now ask the second one. Co-Authored-By: Claude Opus 5 --- src/ui/webview/mockup.html | 187 +++++++++++++++++++-- src/ui/webview/snapmap_plus_ui_webview.cpp | 68 ++++++++ 2 files changed, 241 insertions(+), 14 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 62afcc5..677ea50 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -108,6 +108,26 @@ 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 @@ -901,6 +921,7 @@ // ---- browser preview: when opened in a plain browser (no DOOM backend), fake the native side so the // file is self-demoing and fully interactive. Inert in DOOM (PREVIEW is false there). ---- + var previewPinsDoc = ''; // session-only stand-in for %LOCALAPPDATA%\snapmap-plus\pinned.json var previewStack0Count = 0; // fake backend-owned stack 0 count, so Push/Clear feel connected in the demo var previewReportSigs = {}; // fake dedup memory for the feedback dialog demo (category|title -> seen) var previewEnts = [ @@ -1373,6 +1394,10 @@ else if (o.assetKind === 13) sample = PREVIEW_SNDBANKS; /* SH_ASSET_SNDBANK */ onMessage({kind:'assetList', assetKind: o.assetKind, names: sample}); } + /* Pins live in a real file in DOOM; in a plain browser they are session-only, which is enough + * to exercise pin/unpin, the Pinned rail row and the cross-type behaviour. */ + else if (o.cmd === 'pinsLoad') { onMessage({kind:'pins', doc: previewPinsDoc}); } + else if (o.cmd === 'pinsSave') { previewPinsDoc = o.doc || ''; } // No decoder outside the game, so the preview honestly reports that rather than faking pixels. else if (o.cmd === 'requestPreview') { onMessage({kind:'previewRequested', ok:false, name:o.name, note:'Previews only decode in-game - there is no codec on the browser side.'}); } @@ -3298,6 +3323,8 @@ onSoundPreviewResult(d); } else if (d.kind === 'assetList') { onAssetList(d); + } else if (d.kind === 'pins') { + abPinsLoad(d.doc); } else if (d.kind === 'previewImage') { onPreviewImage(d); } else if (d.kind === 'previewRequested') { @@ -4398,6 +4425,87 @@ var abSndBank = null; /* lowercased sound name -> bank */ var abBanks = null; /* [{bank, n}] sorted by n desc */ var AB_NO_BANK = '(no bank)'; + + /* ---- pinned assets -------------------------------------------------------------------------- + * The mapper's own shortlist, ONE list across every asset type -- "the three things I am working + * with right now" is rarely all of one kind. Each pin therefore carries its OWN type: the carriers + * a material can wear are not the ones a sound can, so a pinned row has to be able to say what it + * is independently of whichever rail row is highlighted. See abSelType. + * + * Persisted host-side to %LOCALAPPDATA%\snapmap-plus\pinned.json, NOT to the settings config: the + * settings file is all-or-nothing (a parse failure restores every default), and an unbounded list + * the user grows should never be able to reset their theme. Load is best-effort and a malformed + * file degrades to "no pins", never to an error the mapper has to deal with. */ + var AB_PINS_VERSION = 1; + var abPins = []; /* [{type, name}] in the order they were pinned */ + var abPinIx = {}; /* lowercased name -> type, for O(1) lookup while rendering */ + + function abPinKey(name) { return (name || '').toLowerCase(); } + function abPinTypeOf(name) { return abPinIx[abPinKey(name)] || null; } + function abIsPinned(name) { return !!abPinIx[abPinKey(name)]; } + + /* Rebuild the derived index + the name list the Pinned pseudo-type renders from. Called after any + * change to abPins, including the initial load. */ + function abPinsRebuild() { + abPinIx = {}; + var names = []; + for (var i = 0; i < abPins.length; i++) { + var p = abPins[i]; + if (!p || !p.name || !p.type) continue; + abPinIx[abPinKey(p.name)] = p.type; + names.push(p.name); + } + abNames['pinned'] = names; + abTrees['pinned'] = null; /* the folder tree is derived; force a rebuild */ + } + + function abPinsSave() { + if (PREVIEW) { abPinsRebuild(); return; } /* no host to write to in a plain browser */ + post({cmd:'pinsSave', doc: JSON.stringify({version: AB_PINS_VERSION, pins: abPins})}); + } + + /* Toggle. Pinning stores the type the asset was found under, which is what makes the cross-type + * list work. Unpinning drops it wherever it is. */ + function abPinToggle(type, name) { + if (!name) return; + if (abIsPinned(name)) { + var keep = []; + for (var i = 0; i < abPins.length; i++) + if (abPinKey(abPins[i].name) !== abPinKey(name)) keep.push(abPins[i]); + abPins = keep; + } else { + abPins.push({type: type, name: name}); + } + abPinsRebuild(); + abPinsSave(); + for (var k = 0; k < abMounts.length; k++) { abRenderRail(abMounts[k]); abRenderTree(abMounts[k]); abRenderInsp(abMounts[k]); } + } + + /* The host hands back whatever bytes are in the file. Everything about shape is checked HERE -- + * the host deliberately does not parse it -- so a hand-edited or truncated file costs the pins + * and nothing else. */ + function abPinsLoad(doc) { + var parsed = null; + try { parsed = JSON.parse(doc || ''); } catch (e) { parsed = null; } + var list = (parsed && parsed.pins && parsed.pins.length) ? parsed.pins : []; + var out = []; + for (var i = 0; i < list.length; i++) { + var p = list[i]; + if (p && typeof p.name === 'string' && typeof p.type === 'string' && p.name) out.push({type: p.type, name: p.name}); + } + abPins = out; + abPinsRebuild(); + for (var k = 0; k < abMounts.length; k++) { abRenderRail(abMounts[k]); abRenderTree(abMounts[k]); } + } + + /* The type to treat the CURRENT SELECTION as. Normally the rail's type, but the Pinned list is + * cross-type so its rows carry their own. Everything acting on the SELECTED ASSET -- carriers, + * the preview route, New entity, Apply -- must go through this; everything acting on the LIST + * (fetching, the folder tree, the rail highlight) keeps using m.type. */ + function abSelType(m) { + if (m.type !== 'pinned') return m.type; + return (m.sel && abPinTypeOf(m.sel)) || 'material'; + } function abBankOf(name) { if (!abSndBank) return null; return abSndBank[(name || '').toLowerCase()] || AB_NO_BANK; @@ -4539,6 +4647,9 @@ /* The cross-platform set is baked, so seed it here: it is never fetched, and leaving it undefined * would make abRenderTree show "Loading the catalog..." forever. */ abNames['material@xplat'] = AB_XPLAT_NAMES; + /* Seeded empty for the same reason: Pinned is never fetched from the catalog, it is rebuilt from + * abPins. Left undefined it would sit on "Loading the catalog..." forever for a user with no pins. */ + abNames['pinned'] = []; var abTrees = {}; /* type -> folder tree built from abNames[type] */ var abMounts = []; /* every mounted browser */ var abActive = null; /* the mount that owns the in-flight preview request */ @@ -4731,6 +4842,14 @@ abFetch(m, false); }); m.el('tree').addEventListener('click', function(e){ + /* Checked BEFORE the copy button and before row selection: pinning is a decision about the + * row, not a request to look at it, so it must not drag the preview along with it. */ + var pb = e.target.closest ? e.target.closest('[data-pin]') : null; + if (pb) { + e.stopPropagation(); + abPinToggle(pb.getAttribute('data-pintype'), pb.getAttribute('data-pin')); + return; + } var cp = e.target.closest ? e.target.closest('[data-copy]') : null; if (cp) { e.stopPropagation(); abCopy(cp.getAttribute('data-copy')); return; } var row = e.target.closest ? e.target.closest('.entity-item') : null; @@ -4899,7 +5018,7 @@ } function abApplyNow(m, name, carrier, rect) { var box = document.getElementById('declText'); - var next = dpApply(box.value, m.type, carrier, name, rect, abVmExtra(m, name)); + var next = dpApply(box.value, abSelType(m), carrier, name, rect, abVmExtra(m, name)); if (next === null) { toast('That carrier cannot be written', 'err'); return; } if (next === box.value) { toast('Already set to that -- nothing changed'); return; } box.value = next; @@ -4973,7 +5092,17 @@ + '' + (n ? n.toLocaleString() : '-') + '
'; if (t.ref) ref += row; else place += row; } - m.el('rail').innerHTML = '
Placeable
' + place + /* Pinned sits ABOVE the type groups, and is a pseudo-type rather than a group of its own: it + * holds whatever the mapper put there, of any kind, so it belongs to neither Placeable nor + * Reference. Always shown, including empty -- a row that only appears once you have used a + * feature you have not discovered yet is a feature nobody discovers. */ + var pn = abPins.length; + var pinned = '
' + + '' + String.fromCharCode(0x2605) + ' Pinned' + + '' + (pn ? pn.toLocaleString() : '-') + '
'; + m.el('rail').innerHTML = pinned + + '
Placeable
' + place + '
Reference
' + ref; } @@ -5201,8 +5330,35 @@ function abRow(m, label, path, isDir, count) { var sel = (!isDir && m.sel === path) ? ' selected' : ''; + /* Folders are not pinnable -- a pin is a thing you apply, and a folder is not one. The pin + * carries the type the row was found under (abSelType for the Pinned list itself, so + * unpinning from inside Pinned keeps working). */ + var pinned = !isDir && abIsPinned(path); + /* The type of THIS ROW, which is not abSelType -- that answers for the current selection. Inside + * the Pinned list a row's type is whatever it was pinned as; anywhere else it is the rail's. */ + var ptype = (m.type === 'pinned') ? (abPinTypeOf(path) || 'material') : m.type; + /* LEFT of the name, in its own fixed-width gutter beside the folder twisty -- not floated right + * next to Copy, where it made every row look lopsided and put a state indicator in among the + * actions. A pinned row's flag reads down the left edge like a margin mark, which is what a + * shortlist wants. + * + * Filled U+2605 / hollow U+2606 -- the favourites idiom, and it doubles up with the gutter's own + * colour and opacity states (invisible at rest, faint on hover, solid accent when pinned) so the + * pinned/unpinned distinction survives even where colour is hard to judge. Both are BMP, so + * fromCharCode is safe: an ASTRAL glyph (a real pushpin, U+1F588) would need its surrogate pair, + * because fromCharCode truncates to 16 bits and would silently yield a private-use codepoint + * that renders as tofu -- a failure indistinguishable from the font simply lacking the glyph. + * + * Built with fromCharCode rather than typed literally to keep this file pure ASCII (see the + * note at the top of the file -- the build pipeline flattens any non-ASCII byte to '?'). */ + var pinBtn = isDir ? '' + : ''; return '
' + (isDir ? '▸' : '') + '' + + pinBtn + '' + abEsc(label) + '' + (isDir ? '' + count.toLocaleString() + '' : '') @@ -5214,10 +5370,10 @@ if (!m.sel) { body.innerHTML = '
Select an asset to preview it.
'; return; } var path = m.sel, cut = path.lastIndexOf('/'); var leaf = path.slice(cut + 1), folder = cut > 0 ? path.slice(0, cut) : '/'; - var opts = abCarriers(m.type, path), okAny = false, cur = null; + var opts = abCarriers(abSelType(m), path), okAny = false, cur = null; for (var i = 0; i < opts.length; i++) { if (opts[i].ok) okAny = true; if (opts[i].id === m.carrier && opts[i].ok) cur = opts[i]; } if (!cur) { for (var j = 0; j < opts.length; j++) if (opts[j].ok) { m.carrier = opts[j].id; break; } } - var newOpts = abNewOptions(m.type); + var newOpts = abNewOptions(abSelType(m)); /* A sound has no pixels, so the preview slot -- the square the thumbnail would occupy -- carries * the audition control instead of an empty dashed box. * @@ -5226,7 +5382,7 @@ * track is a soundshader decl the same as a footstep, and it is listed in the same catalog. * Playing one is also safe to be wrong about -- the backend only auditions names it can find in * that catalog, so a name it cannot resolve is a refusal and a log line, never a fault. */ - var audible = (m.type === 'sound'); + var audible = (abSelType(m) === 'sound'); var h; if (audible) { var on = (abSoundPlaying === path); @@ -5255,7 +5411,7 @@ * by-name customMaterial does NOT respond to them; only the SnapMap palette's own triplanar * materials do. Both are visible controls rather than silent writes: they change how the * surface LOOKS, and a mapper should be able to see and change that before committing. */ - if (m.type === 'material' && m.carrier === 'vm') { + if (abSelType(m) === 'material' && m.carrier === 'vm') { var xpSel = abRectOf(path); var rampSel = (m.ramp === undefined) ? AB_RAMP_DEFAULT : m.ramp; h += '
'; /* Sounds are the one type with more than one way to be created, and the three differ only in a * flag their defs already carry -- so this picks the def, it does not write a flag. */ @@ -5361,13 +5517,13 @@ if (pe) pe.textContent = path; if (ce) ce.disabled = false; } - if (!abPreviewable(m.type)) { + if (!abPreviewable(abSelType(m))) { /* Modules are worth saying more about than "no preview": the pane is the only place to * explain that this one asset places as BOTH geometry and collision. */ - m.pvNote = (m.type === 'module') + m.pvNote = (abSelType(m) === 'module') ? 'A whole SnapMap module. Placed as a prop it brings its own geometry AND its paired ' + 'collision, so it is solid. No thumbnail - only materials and images decode to pixels.' - : (m.type === 'bmodel') + : (abSelType(m) === 'bmodel') ? 'Baked brush geometry - a piece of a module, or one of its invisible navmesh/occlusion ' + 'brushes. Render-only: it will not be solid.' : 'No preview for this asset type - only materials and images decode to pixels.'; @@ -5384,7 +5540,7 @@ * cross-platform table and the session cache both answer without a round-trip. The reply is * handled in onMaterialRect, which caches it and re-renders this mount. A material with no * atlas rect simply never answers ok, and the square fallback is then correct anyway. */ - if (m.type === 'material' && !abRectOf(path)) post({cmd:'materialRect', name: path}); + if (abSelType(m) === 'material' && !abRectOf(path)) post({cmd:'materialRect', name: path}); } function onPreviewRequested(d) { @@ -5453,7 +5609,10 @@ * accurate by the time anything is selected. */ if (!abVtOnly) post({cmd:'listAssets', assetKind: AB_KIND_VTONLY}); if (!abSndBank) post({cmd:'listAssets', assetKind: AB_KIND_SNDBANK}); + /* Pins come from their own file, not the catalog -- asked for once per session alongside it. */ + if (!abPinsFetched) { abPinsFetched = 1; post({cmd:'pinsLoad'}); } } + var abPinsFetched = 0; function onAssetList(d) { if (d.assetKind === AB_KIND_SNDBANK) { var map = {}, tally = {}; @@ -5745,7 +5904,7 @@ var abPendingNewVM = null; /* {mount, opt, name} while a rect lookup is in flight */ function abNewEntity(m) { - var opts = abNewOptions(m.type); + var opts = abNewOptions(abSelType(m)); if (!opts || !m.sel) return; /* The engine will not paste over a live selection, and the backend refuses rather than degrading * to stage-only (which used to report success with nothing on the cursor). editorSelCount is the @@ -5758,7 +5917,7 @@ var sel = m.el('newvariant'); var opt = opts[0]; if (sel) for (var i = 0; i < opts.length; i++) if (opts[i].id === sel.value) opt = opts[i]; - if (m.type === 'material' && m.carrier === 'vm') { + if (abSelType(m) === 'material' && m.carrier === 'vm') { /* Same shortcut as abApply: a baked cross-platform entry already knows its rect. */ var xpn = abRectOf(m.sel); if (xpn) { abNewEntityNow(m, opt, m.sel, {w: xpn.w, h: xpn.h, x: xpn.x, y: xpn.y}); return; } @@ -5769,7 +5928,7 @@ abNewEntityNow(m, opt, m.sel, null); } function abNewEntityNow(m, opt, name, rect) { - var body = neEditBody(m.type, m.carrier, name, rect, neCam(), abVmExtra(m, name)); + var body = neEditBody(abSelType(m), m.carrier, name, rect, neCam(), abVmExtra(m, name)); /* Name it after the asset, so the placed entity is identifiable in the entity list rather than * arriving as a bare class name among however many others share that class. */ var leaf = name.slice(name.lastIndexOf('/') + 1); diff --git a/src/ui/webview/snapmap_plus_ui_webview.cpp b/src/ui/webview/snapmap_plus_ui_webview.cpp index 04c7f92..04d4cb3 100644 --- a/src/ui/webview/snapmap_plus_ui_webview.cpp +++ b/src/ui/webview/snapmap_plus_ui_webview.cpp @@ -327,6 +327,69 @@ static bool json_get_double(const std::wstring &j, const wchar_t *key, double *o return true; } +/* ---- pinned assets --------------------------------------------------------------------------- + * The mapper's own shortlist, kept in %LOCALAPPDATA%\snapmap-plus\pinned.json -- deliberately its + * OWN file rather than a key in the settings config. + * + * 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 and Show Hidden along with it. Keeping them apart means the worst a + * broken pins file can do is cost the pins. It also sits next to rawmap.json, which is already that + * folder's convention for user data, and stays hand-editable and easy to back up or share. + * + * The host does no parsing -- it moves the bytes and nothing else. Shape and validation belong to the + * UI, which is the only side that knows what a pin means. A missing file is simply "no pins yet". */ +static std::string poc_pins_path() +{ + char *la = nullptr; size_t n = 0; + if (_dupenv_s(&la, &n, "LOCALAPPDATA") != 0 || !la) return std::string(); + std::string dir = std::string(la) + "\\snapmap-plus"; + free(la); + SHCreateDirectoryExA(nullptr, dir.c_str(), nullptr); /* no-op when it already exists */ + return dir + "\\pinned.json"; +} + +static void poc_send_pins() +{ + std::string data; + std::string path = poc_pins_path(); + if (!path.empty()) { + FILE *f = nullptr; + if (fopen_s(&f, path.c_str(), "rb") == 0 && f) { + char buf[4096]; size_t r; + while ((r = fread(buf, 1, sizeof buf, f)) > 0) data.append(buf, r); + fclose(f); + } + } + /* Sent as an escaped STRING, not spliced in as raw JSON: a hand-edited file that is not valid + * JSON must not be able to corrupt the message envelope itself. The UI parses it in a try/catch + * and falls back to an empty list. */ + std::wstring m = L"{\"kind\":\"pins\",\"doc\":\""; + m += poc_json_w(data.c_str()); + m += L"\"}"; + if (g_webview) g_webview->PostWebMessageAsJson(m.c_str()); +} + +/* Write-through: the UI owns the list and hands over the whole document each time it changes. Small + * enough that rewriting it beats maintaining a diff, and it keeps the host free of pin semantics. + * Written to a temp file and moved into place, so an interrupted write cannot truncate the real one. */ +static void poc_save_pins(const std::string &doc) +{ + std::string path = poc_pins_path(); + if (path.empty()) return; + std::string tmp = path + ".tmp"; + FILE *f = nullptr; + if (fopen_s(&f, tmp.c_str(), "wb") != 0 || !f) { poc_log("pins: could not open the temp file"); return; } + size_t w = doc.empty() ? 0 : fwrite(doc.data(), 1, doc.size(), f); + fclose(f); + if (w != doc.size()) { DeleteFileA(tmp.c_str()); poc_log("pins: short write, kept the previous file"); return; } + if (!MoveFileExA(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { + DeleteFileA(tmp.c_str()); + poc_log("pins: could not replace pinned.json"); + } +} + static void poc_read_version() { char *la = nullptr; size_t n = 0; @@ -2094,6 +2157,11 @@ static HRESULT on_message(ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventA } else if (cmd == L"listAssets") { int akind = SH_ASSET_MATERIAL; json_get_int(json, L"assetKind", &akind); poc_send_asset_list(akind); + } else if (cmd == L"pinsLoad") { + poc_send_pins(); + } else if (cmd == L"pinsSave") { + std::wstring doc; json_get_wstr(json, L"doc", doc); + poc_save_pins(w_to_utf8(doc)); } else if (cmd == L"getPreview") { poc_send_preview(); } else if (cmd == L"requestPreview") { From ed241637d78c6e6c7b2d2bf7037bcba1322cc935 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:25:51 -0500 Subject: [PATCH 19/30] assets: file sounds by soundbank as a folder tree Sound names carry almost no structure. After the duplicate collapse nearly every one is a flat `Play_something`, so the catalog was a single folder of 8,028 rows and the folder tree did nothing for the one type that needed it most. The soundbank is the grouping the content actually has: 24 non-empty banks, none of them enormous, and `doom_snapmaps` is the set SnapMap itself loads. The part that had to be right is that WHERE a name sits and WHAT it is called are now two different things. abBuildTree takes a `place` function that decides the folder path while the leaf keeps the REAL asset name in `path` -- so `doom_snapmaps/` is display only, and Copy, selection, preview and Apply all still see `Play_air_res_mid`. A synthetic prefix leaking into that would have looked like the preview breaking, three layers from the cause. An event can be listed in several banks; the map already picks each one a single home, preferring a specific bank over the always-loaded doom_initial. One home is the point -- filing a sound under every bank that lists it would put the same name on screen repeatedly, which is the duplication the last two commits removed. Replaces the bank dropdown rather than joining it. Two controls doing one job is how a browser gets confusing, and the tree does it better: the filter showed you one bank at a time and hid the rest, where the tree shows what the banks ARE. Search still cuts across all of them, which is what you want from search -- you search when you do not know where a thing lives. Co-Authored-By: Claude Opus 5 --- src/ui/webview/mockup.html | 130 +++++++++++++------------------------ 1 file changed, 46 insertions(+), 84 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 677ea50..6cc6fcf 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -1385,8 +1385,8 @@ // PREVIEW_MATERIALS / PREVIEW_SOUNDS); every other type answers empty, which the rail's "-" // count then reflects honestly. Faking the rest would be inventing game data. // - // Sounds carry their soundbank map too (kind 13), so the bank filter -- and the fact that search - // narrows WITHIN the selected bank -- can both be exercised here without launching the game. + // Sounds carry their soundbank map too (kind 13), so the bank folder tree -- and the fact that + // search cuts ACROSS the banks rather than within one -- are both exercisable without the game. else if (o.cmd === 'listAssets') { var sample = ''; if (o.assetKind === 0) sample = PREVIEW_MATERIALS; /* SH_ASSET_MATERIAL */ @@ -4080,10 +4080,11 @@ /* Deliberately NOT an AB_TYPES row: it is a qualifier on Materials, not a category to browse. * Must match SH_ASSET_VTONLY in src/common/snapmap_plus_iface.h. */ var AB_KIND_VTONLY = 12; - /* Sound -> soundbank, a QUALIFIER on the sound list rather than a category of its own. Sound names - * are almost all flat, so the folder tree is one huge root; the grouping in the Wwise - * manifest is the only real structure the catalog has -- 26 banks, and `doom_snapmaps` in - * particular is the 485 events that are SnapMap's own. Each line arrives as `event|bank`. */ + /* Sound -> soundbank, a QUALIFIER on the sound list rather than a category of its own -- but it is + * what the sound list is FILED BY. Sound names are almost all flat, so their own folder tree is one + * huge root; the grouping in the Wwise manifest is the only real structure the catalog + * has -- 26 banks, and `doom_snapmaps` in particular is the 485 events that are SnapMap's own. + * Each line arrives as `event|bank`. */ var AB_KIND_SNDBANK = 13; var AB_TYPES = [ @@ -4418,12 +4419,11 @@ return !abVtOnly[(name || '').toLowerCase()]; } - /* Sound -> soundbank (backend SH_ASSET_SNDBANK), plus the bank list for the filter, sorted by size - * so the banks worth looking in are at the top. Null until the fetch lands; the filter hides - * itself until then rather than offering an empty dropdown. Names not in the map are sounds the - * manifest does not cover -- the path-form decls -- and they land under "(no bank)". */ + /* Sound -> soundbank (backend SH_ASSET_SNDBANK). This IS the sound catalog's folder structure -- + * see abBankPlace. Null until the fetch lands, and the tree stays flat until then rather than + * filing everything under a bank it has not been told yet. Names the manifest does not cover -- + * the path-form decls -- land under "(no bank)". */ var abSndBank = null; /* lowercased sound name -> bank */ - var abBanks = null; /* [{bank, n}] sorted by n desc */ var AB_NO_BANK = '(no bank)'; /* ---- pinned assets -------------------------------------------------------------------------- @@ -4599,23 +4599,8 @@ * `material` record at all, so filtering the catalog would silently hide the majority of them. */ function abListType(m) { if (m.type === 'material' && m.xplat) return 'material@xplat'; - /* Same trick as the cross-platform set: a bank is a SEPARATE derived list keyed by a - * pseudo-type, so the folder tree, the search and the paging all work on it unchanged. */ - if (m.type === 'sound' && m.bank) return 'sound@' + m.bank; return m.type; } - /* Derive (once) the name array behind a `sound@` pseudo-type. Needs both the sound catalog - * and the bank map; until both have landed this returns nothing, and abRenderTree shows its - * normal "Loading the catalog..." state rather than an empty folder. */ - function abEnsureBankNames(type) { - if (abNames[type] || type.indexOf('sound@') !== 0) return; - var want = type.slice(6); - var all = abNames['sound']; - if (!all || !abSndBank) return; - var out = []; - for (var i = 0; i < all.length; i++) if (abBankOf(all[i]) === want) out.push(all[i]); - abNames[type] = out; - } /* Show the checkbox only on Materials, and keep the DOM control in step with the mount's own state * (each mount keeps its own view state, and the Assets tab and the modal are two mounts). */ @@ -4626,23 +4611,6 @@ box.checked = !!m.xplat; } - /* Sounds only, and hidden until the bank map has arrived -- an empty dropdown is worse than none. - * Options are rebuilt from abBanks each time rather than cached, because the map lands after the - * first render and the control has to pick it up without the mount being touched. */ - function abSyncBank(m) { - var wrap = m.el('bankwrap'), sel = m.el('bank'); - if (!wrap || !sel) return; - var show = (m.type === 'sound') && !!abBanks && abBanks.length > 0; - wrap.style.display = show ? 'flex' : 'none'; - if (!show) return; - var h = ''; - for (var i = 0; i < abBanks.length; i++) - h += ''; - sel.innerHTML = h; - sel.value = m.bank || ''; - } - var abNames = {}; /* type -> sorted name array (undefined = never fetched) */ /* The cross-platform set is baked, so seed it here: it is never fetched, and leaving it undefined * would make abRenderTree show "Loading the catalog..." forever. */ @@ -4656,11 +4624,17 @@ /* --- folder tree --------------------------------------------------------------------------- * Names are paths ("textures/guis/snapmaps/poi/snap_poi_talk"), so the folder structure is - * implied by the '/' segments. Built once per type, then drilled. */ - function abBuildTree(names) { + * implied by the '/' segments. Built once per type, then drilled. + * + * WHERE a name sits and WHAT it is called are two different things, and this is the one place the + * distinction matters. `place` may put a name somewhere its own text does not say -- sounds are + * filed under their soundbank -- so the tree structure comes from the placed path while + * `path` stays the REAL asset name. Everything downstream (Copy, selection, preview, Apply) reads + * `path`, so a synthetic folder prefix must never leak into it. */ + function abBuildTree(names, place) { var root = {dirs:{}, dirKeys:[], files:[], n:0}; for (var i = 0; i < names.length; i++) { - var p = names[i].split('/'), node = root; + var p = (place ? place(names[i]) : names[i]).split('/'), node = root; for (var j = 0; j < p.length - 1; j++) { if (!node.dirs[p[j]]) { node.dirs[p[j]] = {dirs:{}, dirKeys:[], files:[], n:0}; node.dirKeys.push(p[j]); } node = node.dirs[p[j]]; @@ -4676,8 +4650,22 @@ })(root); return root; } + /* Sounds are filed by SOUNDBANK. Their own names carry almost no structure -- after the duplicate + * collapse nearly every one is a flat `Play_something`, so the catalog was a single 7,000-row + * folder. The bank is the grouping the content actually has: 24 non-empty ones, none of them + * enormous, and `doom_snapmaps` is the set SnapMap itself loads. + * + * An event can be listed in more than one bank; abSndBank has already picked it a single home + * (preferring a specific bank over the always-loaded doom_initial). One home is the point -- filing + * a sound under every bank that lists it would put the same name on screen several times, which is + * exactly the duplication the catalog work just removed. */ + function abBankPlace(name) { return abBankOf(name) + '/' + name; } function abTree(type) { - if (!abTrees[type]) abTrees[type] = abBuildTree(abNames[type] || []); + /* Not cached while the bank map is still in flight: the tree built without it would be flat and + * would stick. onAssetList drops it again when the map lands, for the other order. */ + if (type === 'sound' && !abSndBank) return abBuildTree(abNames['sound'] || []); + if (!abTrees[type]) + abTrees[type] = abBuildTree(abNames[type] || [], type === 'sound' ? abBankPlace : null); return abTrees[type]; } function abNodeAt(type, dir) { @@ -4784,7 +4772,7 @@ * `mode` is 'tab' (open browsing) or 'modal' (scoped to the Entities-tab selection). */ function abMount(host, mode) { var m = {host:host, mode:mode, type:'material', dir:'', q:'', sel:null, carrier:'cm', - bank:'', pvTimer:null, pvTries:0, kbSelectTimer:null}; + pvTimer:null, pvTries:0, kbSelectTimer:null}; host.innerHTML = '
Asset type
' + '
' @@ -4801,16 +4789,6 @@ + 'hand-tested list, not everything the megatexture can address. Apply them with Virtual ' + 'Mapping — most have no material decl SnapMap can load, so they cannot go on by name.">' + ' Cross Platform Textures Only' - /* Sounds only. Sound names are almost all flat, so the folder tree barely helps; the bank a - * sound ships in is the structure that actually separates them -- and `doom_snapmaps` is the - * set SnapMap itself uses. */ - + '' + '
' + '
' + '
Asset' @@ -4827,17 +4805,11 @@ m.xplat = this.checked; m.dir = ''; m.sel = null; abRenderTree(m); abRenderInsp(m); }); - /* Same reset as the cross-platform toggle: a different bank is a different list, so the folder - * path and the selection from the previous one are both meaningless afterwards. */ - m.el('bank').addEventListener('change', function(){ - m.bank = this.value; m.dir = ''; m.sel = null; - abRenderTree(m); abRenderInsp(m); - }); m.el('rail').addEventListener('click', function(e){ var it = e.target.closest ? e.target.closest('[data-type]') : null; if (!it) return; - m.type = it.getAttribute('data-type'); m.dir = ''; m.sel = null; m.bank = ''; - abSyncXplat(m); abSyncBank(m); + m.type = it.getAttribute('data-type'); m.dir = ''; m.sel = null; + abSyncXplat(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); abFetch(m, false); }); @@ -4889,7 +4861,7 @@ } }); abMounts.push(m); - abSyncXplat(m); abSyncBank(m); + abSyncXplat(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); return m; } @@ -5114,7 +5086,6 @@ function abRenderTree(m) { var el = m.el('tree'), crumbs = m.el('crumbs'), cnt = m.el('cnt'); var lt = abListType(m); - abEnsureBankNames(lt); /* derives sound@ the first time it is asked for */ var names = abNames[lt]; if (!names) { el.innerHTML = '
Loading the catalog...
'; @@ -5588,11 +5559,6 @@ if (abNames[m.type] && !force) return; if (force) { abNames[m.type] = undefined; abTrees[m.type] = null; - /* The per-bank lists are DERIVED from abNames['sound'], so a refetch has to drop them too -- - * otherwise the filter keeps serving rows built from the catalog we just threw away. */ - if (m.type === 'sound') - for (var bk in abNames) - if (abNames.hasOwnProperty(bk) && bk.indexOf('sound@') === 0) { abNames[bk] = undefined; abTrees[bk] = null; } abRenderTree(m); } m.el('cnt').textContent = '...'; @@ -5615,7 +5581,7 @@ var abPinsFetched = 0; function onAssetList(d) { if (d.assetKind === AB_KIND_SNDBANK) { - var map = {}, tally = {}; + var map = {}; var bl = (d.names || '').split('\n'); for (var b = 0; b < bl.length; b++) { if (!bl[b]) continue; @@ -5624,18 +5590,14 @@ var nm = bl[b].slice(0, cut), bk = bl[b].slice(cut + 1); if (!nm || !bk) continue; map[nm.toLowerCase()] = bk; - tally[bk] = (tally[bk] || 0) + 1; } abSndBank = map; - var list = []; - for (var kb in tally) if (tally.hasOwnProperty(kb)) list.push({bank: kb, n: tally[kb]}); - list.sort(function (x, y) { return y.n - x.n; }); - abBanks = list; - /* The dropdown is built from this, and the sound list may already be on screen. */ - for (var mb = 0; mb < abMounts.length; mb++) { - abSyncBank(abMounts[mb]); - if (abMounts[mb].type === 'sound') abRenderTree(abMounts[mb]); - } + /* This map IS the sound folder structure, and the catalog may have landed first -- so drop the + * flat tree built without it, and redraw anyone already looking at sounds. A mount sitting in + * a folder from the flat tree has to come back to the root: that folder no longer exists. */ + abTrees['sound'] = null; + for (var mb = 0; mb < abMounts.length; mb++) + if (abMounts[mb].type === 'sound') { abMounts[mb].dir = ''; abRenderTree(abMounts[mb]); } return; } if (d.assetKind === AB_KIND_VTONLY) { From f98799c4223e3320e9a94a7dc263f47179214b4b Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:25:53 -0500 Subject: [PATCH 20/30] assets: make the Pinned list flat Pinned was building a folder tree out of the pinned paths, so a pinned material nested four deep while a pinned sound, whose name is flat, did not -- the same list looked organised or not depending on what happened to be in it. Neither behaviour was right. Everywhere else the tree earns its keep against thousands of names, and the folder is how you narrow them. A shortlist is a handful of things the mapper picked by hand; making them drill to reach those is asking them to navigate their own answer to navigating. Rows carry the FULL name rather than the leaf. The list is cross-type with no folder context to disambiguate two similarly-named things, and it matches what Copy hands you. The empty state now names the feature instead of saying "Empty folder", which is right for a folder and useless here -- the person reading it is precisely the one who has not worked out what the star does. Co-Authored-By: Claude Opus 5 --- src/ui/webview/mockup.html | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 6cc6fcf..8fcb27d 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -4660,7 +4660,27 @@ * a sound under every bank that lists it would put the same name on screen several times, which is * exactly the duplication the catalog work just removed. */ function abBankPlace(name) { return abBankOf(name) + '/' + name; } + /* Pinned is a FLAT list, deliberately -- the one list in the browser with no folders at all. + * Everywhere else the tree pays for itself: thousands of names, and the folder is how you narrow + * them. A shortlist is a handful of things the mapper chose by hand, and making them drill to + * reach those is asking them to navigate their own answer to navigating. It was also incoherent + * across types -- pinned materials nested by their path while pinned sounds, whose names are flat, + * did not -- so the same list looked organised or not depending on what happened to be in it. + * + * Rows are labelled with the FULL name rather than the leaf: the list is cross-type and unsorted + * by origin, so the path is the only thing that says which of two similarly-named things this is, + * and it matches what Copy hands you. */ + function abBuildFlat(names) { + var root = {dirs:{}, dirKeys:[], files:[], n: names.length}; + for (var i = 0; i < names.length; i++) root.files.push({name: names[i], path: names[i]}); + root.files.sort(function(a,b){ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; }); + return root; + } function abTree(type) { + if (type === 'pinned') { + if (!abTrees['pinned']) abTrees['pinned'] = abBuildFlat(abNames['pinned'] || []); + return abTrees['pinned']; + } /* Not cached while the bank map is still in flight: the tree built without it would be flat and * would stick. onAssetList drops it again when the map lands, for the other order. */ if (type === 'sound' && !abSndBank) return abBuildTree(abNames['sound'] || []); @@ -5125,7 +5145,11 @@ } for (var f = 0; f < node.files.length; f++) out += abRow(m, node.files[f].name, node.files[f].path, false, null); - el.innerHTML = out || '
Empty folder.
'; + /* "Empty folder" is right for a folder and useless for an empty shortlist -- a user who has not + * pinned anything yet is exactly the one who needs telling what the star does. */ + el.innerHTML = out || ('
' + (m.type === 'pinned' + ? 'Nothing pinned yet. Click the star beside any asset to keep it here.' + : 'Empty folder.') + '
'); cnt.textContent = node.n.toLocaleString(); } /* ---- auditioning a sound ---------------------------------------------------------------------- From 89f4ce9af5b4a0fc0eeae38b1fb2d572a19f3ed8 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:58:19 -0500 Subject: [PATCH 21/30] assets: collapse records that repeat a name in the same box Decal atlases listed every asset twice, and clicking one row selected both while starring one starred both. That was not a selection bug: the UI keys off the name, and the catalog genuinely held two rows with the same name. The index is a record-per-blob table, not a catalog of distinct assets -- the same decl can be baked into .resources more than once, at different offsets. Measured across snap_gameresources: 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. Decal atlases now reads 1,024 and Images 3,422. Co-Authored-By: Claude Opus 5 --- src/backend/imgpreview.c | 54 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/backend/imgpreview.c b/src/backend/imgpreview.c index 5b86e5b..da23457 100644 --- a/src/backend/imgpreview.c +++ b/src/backend/imgpreview.c @@ -340,6 +340,22 @@ 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) { @@ -623,6 +639,39 @@ static int imgpreview_load(void) modules++; } + /* 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. @@ -656,12 +705,13 @@ static int imgpreview_load(void) * campaign-box material that got hidden above must not suppress its atlas twin. */ imgpreview_load_vmtr(); - char line[340]; + char line[420]; _snprintf_s(line, sizeof line, _TRUNCATE, "B2: imgpreview -- indexed %d records (snap=%s game=%s); %d SnapMap modules; " + "%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, extra, dup, wrapped); + g_recCount, a ? "ok" : "MISSING", b ? "ok" : "missing", modules, boxdup, extra, dup, wrapped); backend_log(line); g_loaded = (g_recCount > 0) ? 1 : -1; return g_loaded > 0; From 863c8b313b625a87ced501ac4cd71024e338af49 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:58:20 -0500 Subject: [PATCH 22/30] assets: make "No ramp" actually remove the ramp The dropdown says "No ramp -- leave it unset", and the code simply skipped the write. On an entity that already carried a ramp1 that left the previous mapper's tiling in place, so the option did nothing on exactly the entities someone picks it for. renderParms is an indexed ARRAY -- the engine reads num, then walks item[0..num-1] -- so removing means cutting the entry, renumbering every survivor, and correcting num. All three, or the decl is worse off than before it was touched. The renumbering runs backwards over the survivors because each rewrite changes the string length and going forwards would invalidate every offset after the one just written. The removal happens LAST in the vm carrier, after virtualmapping and any companion writes, since those move the offsets a removal would otherwise depend on. dpRemoveRenderParm returns its input untouched when there is no renderParms block or nothing carries the parm, so calling it speculatively is safe. bloommaskscale has the same asymmetry and is deliberately left alone: its control is a free-text field where blank already means "none", and making blank delete is a different judgement call -- you clear the field to type in it. Co-Authored-By: Claude Opus 5 --- src/ui/webview/mockup.html | 69 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 8fcb27d..a60ea49 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -3968,6 +3968,56 @@ return dpSetScalar(s, re.open, re.close, 'num', String(next + 1)); } + /* Delete a renderParm outright, rather than blanking its value. + * + * The list is an ARRAY, not a bag: the engine reads `num` and then indexes item[0..num-1], so a + * hole would make it read an entry that is not there. Removing therefore means cutting the entry, + * renumbering every survivor to close the gap, and correcting `num` -- all three, or the decl is + * worse off than before it was touched. + * + * Renumbering runs BACKWARDS over the survivors: each rewrite changes the string length, and going + * forwards would invalidate every offset after the one just written. Returns the text unchanged if + * there is no renderParms block or nothing in it carries this parm, so calling it speculatively is + * safe. */ + function dpRemoveRenderParm(s, rmOpen, parm) { + var b = dpFindBlock(s, rmOpen, dpMatchBrace(s, rmOpen), 'renderParms'); + if (!b) return s; + var items = []; + dpEachEntry(s, b.open, b.close, function (k, at, after) { + if (/^item\[\d+\]$/.test(k)) items.push({at: at, after: after}); + }); + var cutFrom = -1, cutTo = -1; + for (var i = 0; i < items.length; i++) { + var o = s.indexOf('{', items[i].after - 1); + if (o < 0) continue; + var c = dpMatchBrace(s, o); + if (c < 0) continue; + var p = dpFindChild(s, o, c, 'parm'); + if (!p) continue; + var pm = /\s*"([^"]*)"/.exec(s.slice(p.after)); + if (!pm || pm[1] !== parm) continue; + /* Cut whole LINES, so the entry leaves no blank line and no orphaned indent behind. */ + cutFrom = s.lastIndexOf('\n', items[i].at) + 1; + cutTo = s.indexOf('\n', c); + cutTo = (cutTo < 0) ? s.length : cutTo + 1; + break; + } + if (cutFrom < 0) return s; + s = s.slice(0, cutFrom) + s.slice(cutTo); + + var b2 = dpFindBlock(s, rmOpen, dpMatchBrace(s, rmOpen), 'renderParms'); + if (!b2) return s; + var keys = []; + dpEachEntry(s, b2.open, b2.close, function (k, at) { + if (/^item\[\d+\]$/.test(k)) keys.push({at: at, len: k.length}); + }); + for (var j = keys.length - 1; j >= 0; j--) + s = s.slice(0, keys[j].at) + 'item[' + j + ']' + s.slice(keys[j].at + keys[j].len); + + var b3 = dpFindBlock(s, rmOpen, dpMatchBrace(s, rmOpen), 'renderParms'); + return dpSetScalar(s, b3.open, b3.close, 'num', String(keys.length)); + } + /* The whole decl is one `edit = { }`; some entities' text is just the braces' contents. Return * the edit block's braces, creating the wrapper if the text is empty. */ function dpEditBlock(s) { @@ -4041,6 +4091,14 @@ var rmN = dpEnsureBlock(e2.text, e2.open, e2.close, 'renderModelInfo'); out = dpSetRenderParm(rmN.text, rmN.open, rmN.close, pairs[pi][0], pairs[pi][1]); } + /* "No ramp" REMOVES one the entity is carrying. Skipping the write instead left the previous + * mapper's tiling in place, so the option did the one thing its label promises not to. Last, + * because the writes above move the offsets a removal would depend on. */ + if (extra && extra.rampRemove) { + var e3 = dpEditBlock(out); + var rmR = dpEnsureBlock(e3.text, e3.open, e3.close, 'renderModelInfo'); + out = dpRemoveRenderParm(rmR.text, rmR.open, 'ramp1'); + } return out; } /* clipModelName is a member of idClipModelInfo, so it nests inside clipModelInfo -- NOT at the @@ -4995,7 +5053,13 @@ * "Keep existing" is honoured only when there IS an existing ramp1. The material ships * `ramp1 1.000000` = one tile every 426 units, so leaving it unset on an entity that has none * would show about a fifth of the texture -- a silent wrong-looking result rather than a neutral - * one. So: preserve a value the mapper tuned, but never leave a texture at the stretched default. */ + * one. So: preserve a value the mapper tuned, but never leave a texture at the stretched default. + * + * "No ramp" DELETES an existing one rather than declining to write. The two are not the same on an + * entity that already carries a ramp: skipping the write left the previous mapper's tiling in + * place, so the option quietly did nothing on exactly the entities someone would pick it for. It + * is the only choice here that has to reach into the decl and take something out -- see + * dpRemoveRenderParm, which renumbers the survivors because renderParms is an indexed array. */ function abVmExtra(m, name) { if (m.carrier !== 'vm') return null; var xp = abRectOf(name); @@ -5003,7 +5067,8 @@ var box = document.getElementById('declText'); var hasRamp = box && /\bramp1\b/.test(box.value || ''); if (m.ramp === 'keep') { if (!hasRamp) out.ramp = abRampValue(xp, AB_RAMP_DEFAULT); } - else if (m.ramp !== 'none') out.ramp = abRampValue(xp, m.ramp || AB_RAMP_DEFAULT); + else if (m.ramp === 'none') { if (hasRamp) out.rampRemove = 1; } + else out.ramp = abRampValue(xp, m.ramp || AB_RAMP_DEFAULT); if (m.bloom !== 'none') out.bloom = abBloomValue(m.bloom === undefined ? AB_BLOOM_DEFAULT : m.bloom); return out; From 454dc66848a947076396f7c139d99f8a1364c58e Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:58:23 -0500 Subject: [PATCH 23/30] assets: drop the catalog Refresh button It promised the list might be stale and could be made fresh, and neither half was true. imgpreview_load is guarded by g_loaded, so the catalog is indexed ONCE per process out of .resources files that cannot change while the game is running -- re-fetching returned identical bytes every time. What it could not do is more telling: it could not pick up changed files, since nothing behind it re-indexes; it could not recover a failed index, because g_loaded = -1 is sticky for the life of the process; and it did nothing at all on Pinned, which has no catalog behind it. The one case it looked useful for -- a type stuck on "Loading the catalog..." -- was already covered, because selecting a type re-posts whenever its names are missing. That IS the retry path, so abFetch's force branch went with the button. Co-Authored-By: Claude Opus 5 --- src/ui/webview/mockup.html | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index a60ea49..1572ed9 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -4856,7 +4856,13 @@ + '
' + '
Catalog' + '0' - + '
' + /* No Refresh control. The catalog is indexed ONCE per process (imgpreview_load's g_loaded + * guard) out of .resources files that cannot change while the game is running, so re-fetching + * returned the same bytes every time. It could not pick up changed files, could not recover a + * failed index -- that failure is sticky for the life of the process -- and did nothing at all + * on Pinned. The one case it looked useful for, a type stuck on "Loading the catalog...", is + * already covered: clicking the rail row re-posts whenever the names are missing. */ + + '
' + '
' /* Materials only, and hidden for every other type rather than shown disabled -- it is * meaningless outside the megatexture atlas. */ @@ -4875,7 +4881,6 @@ m.el = function(n){ return host.querySelector('[data-ab="' + n + '"]'); }; m.el('q').addEventListener('input', function(){ m.q = this.value; abRenderTree(m); }); - m.el('reload').addEventListener('click', function(){ abFetch(m, true); }); /* Toggling the set changes which list you are in, so the folder path and the selection from the * other set are both meaningless afterwards -- drop them rather than leaving a stale crumb trail * pointing at a folder the new list does not have. */ @@ -4889,7 +4894,7 @@ m.type = it.getAttribute('data-type'); m.dir = ''; m.sel = null; abSyncXplat(m); abRenderRail(m); abRenderTree(m); abRenderInsp(m); - abFetch(m, false); + abFetch(m); }); m.el('tree').addEventListener('click', function(e){ /* Checked BEFORE the copy button and before row selection: pinning is a decision about the @@ -5642,14 +5647,12 @@ /* --- catalog fetch --------------------------------------------------------------------------- * The full list (~9,800 names) crosses the bridge ONCE per session and is cached here, so * filtering and drilling stay instant and never round-trip. */ - function abFetch(m, force) { + function abFetch(m) { var t = abType(m.type); - if (!t) return; - if (abNames[m.type] && !force) return; - if (force) { - abNames[m.type] = undefined; abTrees[m.type] = null; - abRenderTree(m); - } + if (!t) return; /* pseudo-types (Pinned) have no catalog behind them */ + if (abNames[m.type]) return; + /* This IS the retry path: a type whose names never arrived asks again the next time it is + * selected, which is why removing the Refresh button cost nothing. */ m.el('cnt').textContent = '...'; post({cmd:'listAssets', assetKind: t.kind}); } From eb595aa624512a027cba9962527483b585e0ba34 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:45:16 -0500 Subject: [PATCH 24/30] assets: index the breakable prop models The breakable props looked missing because they are filed under a decl type the catalog never read. A `breakable` decl describes how something shatters and NAMES a model: breakable/barrel2 -> models/mapobjects/prop/destroyables/barrel2gib.lwo None of those models is a `model` or `md6Def` record. All 108 are indexed as `discreteAnimation`, every one a .lwo, and not one duplicates a name already in Models -- so nothing that was previously listed changes, the category simply gains what it was always missing. They are in the SNAP box, which is the part that makes them worth having: a campaign-box model renders as a black cube because SnapMap does not mount gameresources, and these do not have that problem. They take renderModelInfo.model like any other model, so they belong in Models rather than a category of their own. Models goes 3,174 -> 3,282. Co-Authored-By: Claude Opus 5 --- src/backend/imgpreview.c | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/backend/imgpreview.c b/src/backend/imgpreview.c index da23457..2dcedcf 100644 --- a/src/backend/imgpreview.c +++ b/src/backend/imgpreview.c @@ -178,7 +178,15 @@ static const struct { const char *type; unsigned len; unsigned char kind; const * .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 } + { "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 }, }; #define KIND_COUNT ((int)(sizeof g_kinds / sizeof g_kinds[0])) @@ -705,7 +713,7 @@ static int imgpreview_load(void) * campaign-box material that got hidden above must not suppress its atlas twin. */ imgpreview_load_vmtr(); - char line[420]; + char line[480]; _snprintf_s(line, sizeof line, _TRUNCATE, "B2: imgpreview -- indexed %d records (snap=%s game=%s); %d SnapMap modules; " "%d record(s) collapsed as a repeat of a name in the same box; " From b476e443860e5e99715c5be3e3895e07ab17bfb9 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:45:40 -0500 Subject: [PATCH 25/30] assets: add Lights, Perks and SWFs Three categories for the last asset classes the browser could not see. All three are read out of the game's own index at load like everything else -- no hardcoded lists. LIGHTS (89) applies. The asset is the light MATERIAL, the projection a light shines through, and it is written as `lightMaterial` flat in the edit block -- the same field the editor surfaces as the light's own type dropdown. Point vs spot is not the asset, it is which entity carries it, so it is the Create-as choice: snapmaps/light/user_dynamic_point or _spot, class idSnapMapGameEntity_Light. A new light's body is lightMaterial plus spawnPosition; the def already ships the cone, the colour and the intensity, and snapmaps/light/dynamic_spot is literally `edit = { lightMaterial = "..."; }`. The list is material DECLS under `lights/` and `lights_blended/`, and both prefixes matter -- the underscore makes the second a sibling rather than a subfolder, and matching only the first silently dropped every flare. Deliberately NOT the `lightatlas` rows: including them gave 117 names, while decls alone give 89, which is a known-good list of 88 plus lights/defaultprojectedlight -- sibling of two names already on it. The 28 dropped rows have no material decl at all, so `lightMaterial` would have nothing to resolve. Three of them ship with a literal SPACE in the name (`lights/gaus _slowpulse` and two more). The unspaced forms do not exist, so anything that trims or splits on whitespace corrupts them. They are listed verbatim. The decls are COPIED into Lights, not promoted out of Materials the way palette modules are. A move is only right when the source list should not hold the rows at all; a `lights/` material is still a material, and moving all 78 would have emptied rows out of Materials to fill a category that wants 89 of them. PERKS (190) and SWFS (193) are reference-only. A perk is granted by idTarget_Command 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. Neither went into Placeable -- a category there whose Apply does nothing teaches that the tool is broken, where Reference already means "here is the name, wire it yourself". SWFs are listed in the form decls actually reference -- swf/x.swf, not the baked generated/swf/x.bswf on disk, which appears in no decl anywhere and could not be pasted into anything. Rewritten in place, which is safe only because the wanted form is strictly shorter. Co-Authored-By: Claude Opus 5 --- src/backend/imgpreview.c | 63 ++++++++++++++++++++++++++++++++- src/common/snapmap_plus_iface.h | 27 +++++++++++++- src/ui/webview/mockup.html | 46 ++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/backend/imgpreview.c b/src/backend/imgpreview.c index 2dcedcf..adb9c38 100644 --- a/src/backend/imgpreview.c +++ b/src/backend/imgpreview.c @@ -187,6 +187,11 @@ static const struct { const char *type; unsigned len; unsigned char kind; const * 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])) @@ -277,6 +282,26 @@ static unsigned be32(const unsigned char *p) { return ((unsigned)p[0]<<24)|((uns 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]; @@ -338,6 +363,7 @@ static int imgpreview_load_box(int b, const char *stem) /* 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; } @@ -647,6 +673,40 @@ static int imgpreview_load(void) 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 @@ -716,10 +776,11 @@ static int imgpreview_load(void) 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, boxdup, extra, dup, wrapped); + 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; diff --git a/src/common/snapmap_plus_iface.h b/src/common/snapmap_plus_iface.h index 782e8ae..6c325d1 100644 --- a/src/common/snapmap_plus_iface.h +++ b/src/common/snapmap_plus_iface.h @@ -306,7 +306,32 @@ typedef int (*sh_list_materials_fn)(struct sh_iface *self, int start, * 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 -#define SH_ASSET_COUNT 14 +/* 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 diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index 1572ed9..abb070f 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -4115,7 +4115,7 @@ var withModel = dpSetInBlock(decl || '', 'renderModelInfo', [['model', '"' + name + '"']]); return dpApply(withModel, type, 'cl', clip, null); } - var flat = {f: 'fxDecl', s: 'sound', p: 'particleSystem'}[carrier]; + var flat = {f: 'fxDecl', s: 'sound', p: 'particleSystem', li: 'lightMaterial'}[carrier]; if (flat) return dpSetScalar(s, open, close, flat, '"' + name + '"'); return null; } @@ -4157,9 +4157,13 @@ {id:'bmodel', kind:10, label:'Brush models'}, {id:'clipmodel', kind:11, label:'Clip models'}, {id:'image', kind:1, label:'Images', ref:true}, - {id:'decal', kind:6, label:'Decal atlases', ref:true} + {id:'decal', kind:6, label:'Decal atlases', ref:true}, + {id:'light', kind:16, label:'Lights'}, + {id:'perk', kind:14, label:'Perks', ref:true}, + {id:'swf', kind:15, label:'SWF / Flash', ref:true} ]; + /* A SnapMap module ships its geometry and its collision at DIFFERENT paths -- the render half is * baked into the palette, the collision stays in the module's own folder. Matching by stem finds * nothing and makes it look like the modules have no collision at all; this is the real rule, and @@ -4676,6 +4680,7 @@ /* Seeded empty for the same reason: Pinned is never fetched from the catalog, it is rebuilt from * abPins. Left undefined it would sit on "Loading the catalog..." forever for a user with no pins. */ abNames['pinned'] = []; + var abTrees = {}; /* type -> folder tree built from abNames[type] */ var abMounts = []; /* every mounted browser */ var abActive = null; /* the mount that owns the in-flight preview request */ @@ -4839,6 +4844,21 @@ why:'Entity defs are picked from the Inherit dropdown in Entity State. ' + 'Listed here so you can search and copy one, not as a second ' + 'place to set it.'}]; + /* The light MATERIAL is the asset; point vs spot is only which entity carries it, and that is + * the Create-as dropdown. Writes lightMaterial, which sits flat in the edit block -- the same + * field the editor surfaces as the light's own type dropdown. */ + if (type === 'light') return [{id:'li', label:'Light material', ok:true, + why:'Writes lightMaterial — the projection the light shines ' + + 'through. Apply it to a light you have already placed, or use ' + + 'New entity and pick point or spot below.'}]; + if (type === 'perk') return [{id:'pk', label:'Reference only', ok:false, + why:'A perk is granted by an idTarget_Command entity, not placed. ' + + 'The command structure is not worked out yet, so copy the name ' + + 'and wire it by hand for now.'}]; + if (type === 'swf') return [{id:'sw', label:'Reference only', ok:false, + why:'A Flash movie belongs to an entity that owns a screen. Copy the ' + + 'name onto one you have already placed — building that ' + + 'entity from scratch is not something we can do yet.'}]; if (type === 'decal') return [{id:'d', label:'Reference only', ok:false, why:'A decal atlas is the sheet many decal materials are baked into. ' + 'You apply one of those materials, not the atlas.'}]; @@ -5754,7 +5774,23 @@ * placed one looks: inherit the empty snapmaps/unknown, override the classname, and carry the * whole edit block itself. */ particle: [{id:'emitter', label:'Particle emitter', - inherit:'snapmaps/unknown', cls:'idSnapMapParticleEmitter'}] + inherit:'snapmaps/unknown', cls:'idSnapMapParticleEmitter'}], + /* Read out of the shipped defs rather than guessed. The editor def is what the mapper places and + * it names the entity underneath it: + * + * snapEditorEntityDef light/user_point -> entityDef snapmaps/light/user_dynamic_point + * -> inherit light/point -> class idLight + * + * `placeable = false` on the user_dynamic_* defs is NOT a problem and cost me a detour: every + * base def already used here carries it, snapmaps/audio/2d_speaker and snapmaps/props/new/barrel01 + * included. It means "place the editor def that points at me", which is what we are doing. + * + * The spot def ships its own cone (lightTarget.x 640, lightRight.y -640, lightUp.z 640) and both + * ship white lightColorParam, so a placed light looks like one without any parms from us. */ + light: [{id:'point', label:'Point light', + inherit:'snapmaps/light/user_dynamic_point', cls:'idSnapMapGameEntity_Light'}, + {id:'spot', label:'Spotlight', + inherit:'snapmaps/light/user_dynamic_spot', cls:'idSnapMapGameEntity_Light'}] }; function abNewOptions(type) { return AB_NEW[type] || null; } @@ -5850,6 +5886,10 @@ + I + ' "clipModelName" : ' + neStr(clip) + '\n' + I + '},\n' + pos; } + /* Straight into the edit block, exactly as the shipped defs do it -- snapmaps/light/dynamic_spot + * is literally `edit = { lightMaterial = "lights/biground1"; }`. Everything else about the light + * -- cone, colour, intensity -- comes from the inherited def. */ + if (type === 'light') return I + '"lightMaterial" : ' + neStr(name) + ',\n' + pos; if (type === 'fx') return I + '"fxDecl" : ' + neStr(name) + ',\n' + pos; if (type === 'sound') return I + '"sound" : ' + neStr(name) + ',\n' + pos; if (type === 'particle') From fe61d3cd13b0da2ca9d3654efa96067fbb978b2e Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:53:44 -0500 Subject: [PATCH 26/30] docs: sync the docs to twenty commits of asset-browser work The docs were last synced at f105d72, and twenty behavior commits have landed since. Routed by the map in contributing.md section 9, each doc getting the layer it already covers: capabilities.md the user-facing inventory -- Pinned, Lights, Perks and SWFs as categories, the materials union and its cross-platform filter, breakable models inside Models, sounds filed by soundbank, keyboard paging auto-selecting files, why there is no Refresh control, and pinned.json under Persistent settings webview-ui.md a dated changelog entry for the frontend session backend-changes.md three correctness entries it was missing: the same-box record collapse, the main-thread sound audition, and the Ctrl latch in swf_textedit architecture.md the two-file split of persistent state, since which side owns which file is a boundary question Two things are written down because they are the ones a reader would otherwise have to rediscover the hard way: the game index is a record-per-blob table rather than a catalog of distinct assets, which is why the same asset could appear twice; and three shipped light materials contain a literal space in the name, so anything that trims or splits on whitespace corrupts them. The unresolved sound-bank off-by-one is recorded in backend-changes.md as open rather than left out -- one row in 8,028, no known consequence, but the next person to count them should not have to wonder whether anybody noticed. Co-Authored-By: Claude Opus 5 --- docs/architecture.md | 8 ++++++ docs/backend-changes.md | 61 +++++++++++++++++++++++++++++++++++++++++ docs/capabilities.md | 39 +++++++++++++++++++++++--- docs/webview-ui.md | 42 ++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index efaed0a..074a5b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,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 7be53ef..dcb8bb2 100644 --- a/docs/backend-changes.md +++ b/docs/backend-changes.md @@ -6,6 +6,67 @@ 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 diff --git a/docs/capabilities.md b/docs/capabilities.md index 2421fb2..181e7bb 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -108,7 +108,7 @@ 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. | @@ -128,14 +128,17 @@ black cube. | Category | What it holds | |---|---| -| Materials | Surfaces. Previewed as real pixels — see below. | +| **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` plus the `md6Def` set. | +| 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. | +| 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**. @@ -157,6 +160,24 @@ its paired collision into `clipModelInfo.clipModelName`. The two live at differe 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 @@ -170,6 +191,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/webview-ui.md b/docs/webview-ui.md index 876c778..70e4298 100644 --- a/docs/webview-ui.md +++ b/docs/webview-ui.md @@ -114,6 +114,48 @@ 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 From 89f6e000aab2b0a1c3579502447fd37486a0cb93 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:24:20 -0500 Subject: [PATCH 27/30] assets: gate each single-purpose carrier to the class that owns its field Sounds, particles and FX were governed only by a door/interactable denylist, and the light material -- added in this branch -- had no rule at all. So a sound could land on a prop, a particle on a trigger volume, and a light material on anything the browser could select. Writing a field onto a class that does not read it is inert rather than destructive, but it leaves a key in the decl that nothing will ever act on, and the mapper has no way to tell that from a change that worked. Each is now gated to the classes that own the field it writes, derived from the shipped defs rather than guessed: sound idSnapMapGameEntity_Speaker (the only class carrying `sound` in any shipped def), plus the idSpeaker family light idSnapMapGameEntity_Light + idLight particle idSnapMapParticleEmitter + idParticleEmitter fx idVolume_ToggleableDamageOverTime + idLaserHazard + idDynamicStampEntity FX looked like it had to stay a denylist and does not, which is worth recording because the evidence points the wrong way at first glance: 371 shipped defs across 39 classes carry an `fxDecl` -- props, gore entities, AI, weapons. None of them are placeable in SnapMap. The set a mapper can actually select is the editor's own `fx/` category, and that resolves to just those three runtime classes, 45 of its 49 entries being idVolume_ToggleableDamageOverTime, which is also what this browser's New Entity FX option creates. The gate keys on CLASS, never on whether a class is palette-placeable. Those are different questions: idSnapMapParticleEmitter has no placeable palette entry at all, yet Snapmap+ itself creates one by overriding the classname on snapmaps/unknown, and gating on placeability would have refused the emitter this tool just made. Materials and models keep their shared render-capable allowlist. Anything that can wear a model can wear a surface, and re-texturing a mover or making a trigger volume visible are ordinary techniques; splitting them would refuse a material on an entity that still accepts a model. The door/interactable denylist is deleted rather than kept alongside. A door is not a speaker, an emitter or an FX entity, so all three refuse it on their own now, and a second rule that can only ever agree with the first is a place for them to disagree later. Models keep their own exception list, which is a different judgement: those classes CAN wear a model, we just do not want them to. Co-Authored-By: Claude Opus 5 --- src/ui/webview/mockup.html | 63 +++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/src/ui/webview/mockup.html b/src/ui/webview/mockup.html index abb070f..43fc899 100644 --- a/src/ui/webview/mockup.html +++ b/src/ui/webview/mockup.html @@ -5008,15 +5008,13 @@ * anything, so it is refused for every carrier before any per-carrier rule is consulted. */ function abNoApplyEver(cls) { return /^idSnapMapGameEntity_ComboStart/i.test(cls); } - /* Doors and interactables. An FX, particle or sound bolted onto one of these is refused: they own - * their own audio and effects as part of the interaction, and an override fights it. - * idInteractable_Obstacle_SnapDoor is the SnapMap door; the other two are the door-ish classes - * that are not idInteractable at all. */ - function abIsDoorOrInteractable(cls) { - return /^idInteractable/i.test(cls) - || /^idVolume_SnapLockdownDoor/i.test(cls) - || /^idSoundDoor/i.test(cls); - } + /* The door/interactable denylist that used to gate FX, particles and sounds is gone. It was the + * right shape when those three carriers had no rule of their own -- "these own their effects, keep + * out" -- but each is now gated to the classes that actually own the field it writes, and a door + * is not a speaker, an emitter or an FX entity, so it is already refused by all three. Keeping a + * second rule that can only ever agree with the first is a place for them to disagree later. + * Models keep their own exception list below, which is a different judgement: those classes CAN + * wear a model, we simply do not want them to. */ /* MODELS only (not materials) may also be swapped on most interactables -- they are props with a * use-prompt, and re-skinning them is a normal thing to want. The exceptions are the ones whose @@ -5036,21 +5034,58 @@ return abRenderTargetOk(cls) || /^idInteractable/i.test(cls); } + /* The three single-purpose carriers, each gated to the classes that OWN the field it writes. + * + * These were previously governed only by the door/interactable denylist, which let a sound land on + * a prop, a particle on a trigger volume and -- once Lights shipped -- a light material on + * anything at all. Writing a field onto a class that does not read it is inert rather than + * destructive, but it leaves a key in the decl that nothing will ever act on, and the mapper has + * no way to tell that from a change that worked. + * + * Derived from the shipped defs, not guessed: of every entityDef in the snap box, `sound` is + * carried by exactly one class (idSnapMapGameEntity_Speaker, 8 defs) and `lightMaterial` by the + * light classes. `particleSystem` is carried by NO shipped def -- the emitter is built by + * overriding the class on snapmaps/unknown rather than inheriting a def that has one -- so its two + * classes come from the SnapMap editor's own emitter entities instead. */ + function abIsSpeaker(cls) { return /^idSnapMapGameEntity_Speaker/i.test(cls) || /^idSpeaker/i.test(cls); } + function abIsLight(cls) { return /^idSnapMapGameEntity_Light/i.test(cls) || /^idLight/i.test(cls); } + function abIsEmitter(cls) { return /^idSnapMapParticleEmitter/i.test(cls) || /^idParticleEmitter/i.test(cls); } + /* FX belongs on the same list, and the wide read of `fxDecl` is a red herring worth recording so + * nobody re-opens it: 371 shipped defs across 39 classes carry one, but those are campaign props, + * gore entities, AI and weapons -- none of them placeable in SnapMap. The set a mapper can + * actually select is the editor's own `fx/` category, and that resolves to just three runtime + * classes: idVolume_ToggleableDamageOverTime (45 of its 49 entries, and what this browser's own + * New Entity FX option creates), plus the laser hazard and the coop decal stamp. */ + function abIsFxEntity(cls) { + return /^idVolume_ToggleableDamageOverTime/i.test(cls) + || /^idLaserHazard/i.test(cls) + || /^idDynamicStampEntity/i.test(cls); + } + /* The one place that decides whether `carrier` may be written to `cls`. Returns null when it may, * or the reason it may not. */ function abApplyDenied(carrier, cls) { cls = cls || ''; if (abNoApplyEver(cls)) return 'Nothing can be applied to ' + cls; /* Geometry and collision follow the model rule -- anything that can wear a model can wear brush - * geometry, and the clip model is the same surface seen by physics rather than the renderer. */ + * geometry, and the clip model is the same surface seen by physics rather than the renderer. + * Variables and the player spawn are excluded by abRenderTargetOk matching neither. */ if (carrier === 'm' || carrier === 'mod' || carrier === 'cl') return abModelTargetOk(cls) ? null : cls + ' cannot wear a model'; if (carrier === 'cm' || carrier === 'vm') return abRenderTargetOk(cls) ? null : cls + ' cannot wear a material'; - if (carrier === 'f' || carrier === 'p' || carrier === 's') - return abIsDoorOrInteractable(cls) - ? cls + ' owns its own effects and audio -- an FX, particle or sound cannot be applied to it' - : null; + if (carrier === 's') + return abIsSpeaker(cls) ? null + : 'A sound goes on a speaker -- ' + cls + ' has no sound to replace'; + if (carrier === 'li') + return abIsLight(cls) ? null + : 'A light material goes on a light -- ' + cls + ' would ignore it'; + if (carrier === 'p') + return abIsEmitter(cls) ? null + : 'A particle goes on an emitter -- ' + cls + ' has no particle system to replace'; + if (carrier === 'f') + return abIsFxEntity(cls) ? null + : 'An FX goes on an FX entity -- ' + cls + ' has no fxDecl of its own to replace'; return null; } From aca1ef475e051b938d34238c1e53af1bbd06812e Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:25:16 -0500 Subject: [PATCH 28/30] docs: record the per-carrier Apply allowlists capabilities.md described the old rules -- 'FX, particles and sounds are refused on doors and interactables' -- which is no longer how the gate works. Replaced with the actual per-carrier table, plus the two things about it that read wrong without their reasoning: why FX is an allowlist despite 39 classes carrying an fxDecl, and why the gate keys on class rather than on palette-placeability. Co-Authored-By: Claude Opus 5 --- docs/capabilities.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/capabilities.md b/docs/capabilities.md index 181e7bb..01dbf89 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -149,11 +149,32 @@ reading the image out of the `.index`/`.resources` containers and decoding BC1/B 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. Which carriers are legal is -decided per target class: nothing at all applies to the player start; FX, particles and sounds are -refused on doors and interactables, which own their own effects and audio; 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. +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 From 6f4039189cb85cdd7e65be8265b2bc7ca0e0b428 Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:49:41 -0500 Subject: [PATCH 29/30] fix: keep the new source files pure ASCII Three files this branch adds or touches carried a UTF-8 section sign in a comment -- bcn.h, imgpreview.c and snapmap_plus_iface.h, all citing an evidence section like 'evidence 03 (S)5'. Replaced with 'sec'. This is the rule in contributing.md section 1, and it is not cosmetic: the frontend build reads mockup.html through PowerShell 5.1, which decodes BOM-less UTF-8 as Windows-1252 and then writes the generated header as pure ASCII, so a literal non-ASCII byte becomes '?'. The same class of mangling is why that rule covers .c/.h/.cpp/.ps1 as a whole rather than just the file that gets embedded. apply_engine.c keeps its six -- they predate this branch and are not ours to change in a PR about the asset browser. Co-Authored-By: Claude Opus 5 --- src/backend/bcn.h | 2 +- src/backend/imgpreview.c | 4 ++-- src/common/snapmap_plus_iface.h | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/bcn.h b/src/backend/bcn.h index 7f51302..5855de9 100644 --- a/src/backend/bcn.h +++ b/src/backend/bcn.h @@ -1,7 +1,7 @@ /* 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 §3d): + * 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) diff --git a/src/backend/imgpreview.c b/src/backend/imgpreview.c index adb9c38..4eb483d 100644 --- a/src/backend/imgpreview.c +++ b/src/backend/imgpreview.c @@ -26,7 +26,7 @@ /* ------------------------------------------------------------------ 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 §5). We always know the uncompressed size from + * 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; @@ -322,7 +322,7 @@ static int imgpreview_load_box(int b, const char *stem) 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 §2). */ + * 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; diff --git a/src/common/snapmap_plus_iface.h b/src/common/snapmap_plus_iface.h index 6c325d1..4e1612e 100644 --- a/src/common/snapmap_plus_iface.h +++ b/src/common/snapmap_plus_iface.h @@ -279,7 +279,7 @@ typedef int (*sh_list_materials_fn)(struct sh_iface *self, int start, * 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 §3.2. + * 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. From c09d942544800e1f2ba7763f6fe2fa42169c12fb Mon Sep 17 00:00:00 2001 From: mefisme <4188565+mefisme@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:00:02 -0500 Subject: [PATCH 30/30] fix: let a timeline larger than 1 MB open The timeline-open buffer was a fixed 1 MB, chosen when a "heavily-authored" timeline was the largest thing anyone expected to serialize. A GENERATED one is not that: a snapmap-midi song exported to a rawmap serializes to 1.67 MB across 8470 events, 67% past the cap, and every timeline that size refused to open. The failure was also silent in the worst way. serialize_entity does not report the length it needed -- a buffer that is too small returns 0, exactly like a real failure -- so the UI could only say "could not open this timeline (see log)", and the log said nothing either. There is no way to read a size out of that call, so a bigger fixed number would only move the wall. Grow instead: start at 1 MB, double until the result fits, stop at 32 MB. That is the same trick sh_read_growing_text already uses on engine strings, for the same reason. A serialize that fills the buffer to its cap is treated as too-small as well, so a truncated document cannot reach the page as ok and fail to parse there instead. Growth is kept for the session, so the cost is a few extra calls on the one open that outgrows the buffer and none afterwards. Log the byte count whenever the buffer grows, so the next oversized timeline reports its real size rather than leaving it to be inferred from arithmetic. Both serialize buffers get it -- the timeline-open path and the entity-inherit resolve path behind the Stage 3 asset dropdowns, which had the identical cap. Co-Authored-By: Claude Opus 5 --- src/ui/webview/snapmap_plus_ui_webview.cpp | 59 +++++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/ui/webview/snapmap_plus_ui_webview.cpp b/src/ui/webview/snapmap_plus_ui_webview.cpp index ca8084d..a20393a 100644 --- a/src/ui/webview/snapmap_plus_ui_webview.cpp +++ b/src/ui/webview/snapmap_plus_ui_webview.cpp @@ -541,10 +541,53 @@ static int poc_serialize_entity_into(int id, char *buf, int cap) buf[n] = 0; return n; } -/* Timelines Stage 2: serialize the timeline entity itself. Timeline entities are far smaller than prefabs, - * but a 1 MB buffer is comfortably safe for a heavily-authored one. */ -static char g_tl_json[1024 * 1024]; -static int poc_serialize_entity_raw(int id) { return poc_serialize_entity_into(id, g_tl_json, (int)sizeof g_tl_json); } +/* Serialize `id` into a buffer that GROWS until the result fits, and report how many bytes it took. + * + * A fixed cap was wrong here, not merely tight. `serialize_entity` never reports the length it needed -- + * a buffer that is too small comes back as 0, exactly like a real failure -- so the caller cannot say + * "your timeline is bigger than the buffer", only "could not open this timeline". Doubling and retrying + * is the only way to tell the two apart, and it is the same trick sh_read_growing_text uses for engine + * strings, for the same reason. + * + * A serialize that fills the buffer to its cap is treated as too-small as well: that is what a truncating + * writer looks like from here, and half a JSON document would reach the page as `ok` and fail to parse + * there instead. An entity whose real size happens to land exactly on the cap costs one extra call and + * then resolves, which is the same harmless case that header documents. + * + * Growth is kept for the session, so the cost is a few extra calls on the ONE open that outgrows the + * buffer and nothing afterwards. */ +#define POC_SERIALIZE_INITIAL_CAP (1u * 1024 * 1024) /* every hand-authored timeline fits here */ +#define POC_SERIALIZE_MAX_CAP (32u * 1024 * 1024) /* honest boundary, not a guess at the maximum */ + +static int poc_serialize_entity_grow(int id, std::vector &buf, const char *what) +{ + char l[192]; + if (buf.size() < POC_SERIALIZE_INITIAL_CAP) buf.resize(POC_SERIALIZE_INITIAL_CAP); + for (;;) { + int n = poc_serialize_entity_into(id, buf.data(), (int)buf.size()); + if (n > 0 && (size_t)n < buf.size() - 1) { + if (buf.size() > POC_SERIALIZE_INITIAL_CAP) { + _snprintf_s(l, sizeof l, _TRUNCATE, "%s: entity %d serialized %d bytes (buffer grown to %lu)", + what, id, n, (unsigned long)buf.size()); + poc_log(l); + } + return n; + } + if (buf.size() >= POC_SERIALIZE_MAX_CAP) { + _snprintf_s(l, sizeof l, _TRUNCATE, "%s: entity %d does not fit in %lu bytes (last result %d); refusing", + what, id, (unsigned long)buf.size(), n); + poc_log(l); + return 0; + } + buf.resize(buf.size() * 2); + } +} +/* Timelines Stage 2: serialize the timeline entity itself. A hand-authored timeline is small -- this + * buffer was a fixed 1 MB on that basis -- but a GENERATED one is not: a snapmap-midi song exported to a + * rawmap serialized to 1.67 MB across 8470 events, 67% past that cap, and every such timeline refused to + * open with no way to see why. */ +static std::vector g_tl_json; +static int poc_serialize_entity_raw(int id) { return poc_serialize_entity_grow(id, g_tl_json, "timeline-open"); } /* Post {kind:"timelineData", eid, ok, json:""}. The page JSON.parses * `json` and walks entityDef.state.edit.componentTimeLine / encounterComponent itself (the engine's * serialized entity is valid JSON). */ @@ -554,7 +597,7 @@ static void poc_emit_timeline_data(int eid, int json_len) bool ok = json_len > 0; std::wstring m = L"{\"kind\":\"timelineData\",\"eid\":"; m += std::to_wstring(eid); m += L",\"ok\":"; m += ok ? L"true" : L"false"; - m += L",\"json\":\""; if (ok) m += poc_json_w(g_tl_json); m += L"\"}"; + m += L",\"json\":\""; if (ok) m += poc_json_w(g_tl_json.data()); m += L"\"}"; g_webview->PostWebMessageAsJson(m.c_str()); } /* Timelines Stage 3 (per-entity asset dropdowns): resolve the CLASS (entityDef.inherit) of the "Runs on" @@ -562,15 +605,15 @@ static void poc_emit_timeline_data(int eid, int json_len) * OWN buffer (see poc_serialize_entity_into's comment). The page JSON.parses the result and reads .entityDef * .inherit itself (matches tl_entity_inherit_slug's "serialize + read one field" approach, but ships the * whole doc rather than adding a second raw-string field-scanner in C++ -- one parsing path, not two). */ -static char g_resolve_json[1024 * 1024]; -static int poc_serialize_entity_resolve(int id) { return poc_serialize_entity_into(id, g_resolve_json, (int)sizeof g_resolve_json); } +static std::vector g_resolve_json; +static int poc_serialize_entity_resolve(int id) { return poc_serialize_entity_grow(id, g_resolve_json, "entity-resolve"); } static void poc_emit_entity_inherit(int eid, int json_len) { if (!g_webview) return; bool ok = json_len > 0; std::wstring m = L"{\"kind\":\"entityInherit\",\"eid\":"; m += std::to_wstring(eid); m += L",\"ok\":"; m += ok ? L"true" : L"false"; - m += L",\"json\":\""; if (ok) m += poc_json_w(g_resolve_json); m += L"\"}"; + m += L",\"json\":\""; if (ok) m += poc_json_w(g_resolve_json.data()); m += L"\"}"; g_webview->PostWebMessageAsJson(m.c_str()); } /* "Select in editor": drive the 3D editor selection from the list -- clear, then add each. (+0x148/+0x138)