Assets tab: browse, preview and place the whole shipped catalog - #66
Open
mefisme wants to merge 30 commits into
Open
Assets tab: browse, preview and place the whole shipped catalog#66mefisme wants to merge 30 commits into
mefisme wants to merge 30 commits into
Conversation
…ansport
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 <noreply@anthropic.com>
…eview
The Assets tab becomes a real browser: every asset type the shipped containers
carry, a preview pane, Apply-to-selection, New Entity, keyboard navigation, and
sound auditioning. The megatexture decoder from the previous commit finally has
a UI in front of it.
CATALOG. imgpreview.c indexes 9 SH_ASSET_* types out of .index/.resources.
Sounds are the one category fed by TWO sources, because a `sound` decl and a
Wwise event are different sets and NEITHER contains the other:
Wwise events (soundbanksinfo.xml) 7,649
sound decls (snap box) 5,658 overlap 5,058
events with no decl 2,591 decls not in the manifest 600
union, deduped 8,249
594 of the event-only names are play_vo_snapmaps_* -- the generic male/female
SnapMap VO, much of it DLC3, and exactly what a mapper notices missing. Two
parsing traps: the manifest lists each event once PER SOUNDBANK (39,971 elements
for 7,649 names, so it must be deduped against itself or the list gains ~9,400
duplicate rows), and it spells events Play_Vo_... while decls are lowercase, so
every comparison is case-insensitive. Campaign-box sounds are offered too --
1,186 exist only there, and they play and place.
PRODUCERS. megapreview.c decodes megatexture pages by calling DOOM's own decoder
(no renderer, no GPU, no map residency); imgpreview.c covers plain materials from
the containers via bcn.c (BC1/BC3/BC7). Both publish through preview.c's existing
transport.
SOUND PREVIEW. soundpreview.c drives the editor's OWN audition path -- sound
world vtbl +0x30 -- and keeps the emitter handle it returns. `testSound` is not
usable for this: it discards the handle, so a preview can be neither stopped nor
kept from stacking (confirmed live). Stop is vtbl +0x98, bound from the vtable
rather than by signature because it has a byte-identical clone at +0xA0 that no
pattern can separate; the binding validates itself by checking that +0x30 is the
same function the SoundPreview signature found independently.
Preview mode (s_playSoundInBackground + the engine's solo/forced-listener pair)
is held for a SESSION, not toggled per click -- toggling it suspended and resumed
DOOM's whole audio engine on every play, so sounds faded in and short ones could
finish before the resume did. The session is scoped to window focus: those cvars
are only wanted while DOOM lacks focus, and solo mutes the editor while held, so
blurring drops the whole mode and hands the editor its audio back.
PLACEMENT. New Entity authors a one-entity idSnapEntityPrefab and stages it
through the engine's own paste + auto-grab. PasteInstantiate computes
origin = camera + forward * grabDistance
entity = origin + Rz(yawDelta) * spawnPosition
so the two fields ADD. Shipping both put entities ~400 units out; grabDistance is
now the only lever, and it follows the full view ray rather than staying level.
This overturns experiment/entity-creation's "grabDistance is ignored" -- that test
wrote JSON integers into a float field, so neither value parsed.
FIX. sig_result arrays were sized [64] with the DB count clamped to match, so
growing the signature database past 64 silently dropped the tail -- the entry was
never scanned, never failed, and the report still read "64/64 resolved". Now
SIG_RESULTS_MAX (128), and an overflow logs loudly before any clamp.
iface, append-only as always; every prior offset unchanged:
+0x2E0 ext 15 list_materials
+0x2E8 ext 16 list_assets (any indexed type)
+0x2F0 ext 17 material_rect (atlas rect for the virtualmapping carrier)
+0x2F8 ext 18 sound_preview (audition; NULL/empty name = stop)
+0x300 ext 19 sound_session (hold preview mode while the browser is up)
sizeof(sh_iface_vtbl) = 0x308. All 14 native tests pass.
Derived in the doom-re campaign revenant-asset-index-and-viewport; the placement
math, the sound-preview API and four traps that each cost a live cycle are
written up in its evidence 10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three new browser categories out of the two decl types the catalog was
throwing away:
Modules 232 the palettes/mega_blessed .bmodel set
Brush models 9,729 every other .bmodel -- walls, floors, internals
Clip models 10,050 the `cm` type (.bcm/.lwo/.md6)
A module is placeable as an entity that is both VISIBLE and SOLID: its
geometry goes to renderModelInfo.model and its collision to
clipModelInfo.clipModelName, paired across directories by
maps/modules/palettes/mega_blessed/<pal>/<name>.bmodel
-> maps/modules/<pal>/<name>/_combo/world.bcm
which resolves for 232 of 232 with no orphans on either side. Confirmed
in game: a whole module places and is walkable.
No `type` is written alongside clipModelName. The confirmed-solid test
kept the def's inherited CLIPMODEL_AUTO and the named clip model still
took effect, so AUTO means "derive collision from the render model
UNLESS a name is given" -- forcing CLIPMODEL_CUSTOM would change the
inherited mode for nothing.
Fixes a matching bug that would have made both new .bmodel categories
read empty: one decl type can now map to several kinds discriminated
only by extension (`model` -> .lwo Models and .bmodel Brush models), and
a suffix miss did `break` instead of `continue` -- so every .bmodel hit
the .lwo entry first and aborted the search before reaching its own.
Campaign-box records are excluded by the pre-existing load-time rule, so
all three lists are snap-box only. That is load-bearing here rather than
incidental: SnapMap never mounts gameresources.resources, and a
campaign-box model resolves in the editor as a black cube.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tests Three engine functions were still reached as a raw module_base + RVA, which is build-locked in exactly the way the signature resolver exists to avoid: Mega2PageDecode 0x196E140 the megatexture page decoder PrefabDtor 0x51D870 idSnapEntityPrefab::~idSnapEntityPrefab EntityDeshare 0x52C920 COW make-unique before an in-place entity edit Each prologue is unique across the whole executable image once the rel32 call displacements are wildcarded, so all three are now ordinary signature entries. The prefab pair joins the ctor/populate/MemLocal group on ae_pick_engine_fn -- signature wins, known_rva cross-checks and logs a mismatch loudly. The decoder previously had a local memcmp of its own prologue, which could detect a moved function but never FIND one, so a shifted build lost previews outright; it now resolves wherever the loader put it, and an unrecognised build fails to resolve rather than calling into the wrong code. idList-grow could NOT be signatured and is not pretending to be. It is one of 1,560 byte-identical instantiations of the same idList template, separable only by displacements -- the same wall StopSound's clone hit, and no prologue length gets past it. It is resolved relationally instead: AddCommand (which IS signatured) calls it on cmdSys+0x08 via `LEA RCX,[RSI+8]; CALL rel32`, so the callee is decoded out of that call site. The pinned RVA stays as a logged cross-check only. Both signature tests still declared `sig_result results[64]` -- the same array that silently truncated the DB at entry 65 last week and prompted SIG_RESULTS_MAX in the first place. The constant was applied to the shipping callers and missed here. At 67 entries it stopped being silent: sig_resolve_all wrote past the end of a stack array, producing garbage statuses and an access violation inside the test itself. Both now size with SIG_RESULTS_MAX and fail loudly if the DB ever outgrows it. Verified: sig_test 67/67 resolve to their known RVAs, hooktol_test 3/3, all 16 native tests pass, go test ./... ok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Assets tab shipped several sessions ago and appears in none of the docs.
Adds it where each doc already covers its own layer:
capabilities.md the user-facing inventory -- the tab row, plus a section
on the categories, where previews come from, and the
per-target-class rules for what may be applied
webview-ui.md a dated changelog entry for the frontend work
backend-changes.md today's signature migration and the sig_result[64] bug
architecture.md the extension-slot range, which said +0x2B8 and is now
+0x300 (sizeof 0x308) -- checked against the header's
own SH_STATIC_ASSERTs rather than from memory
The capabilities entry states the campaign-box rule plainly, because it is
the one thing about this feature a user can otherwise only discover by
placing something and getting a black cube: SnapMap never mounts
gameresources.resources, so the browser lists snap-box assets only --
with sounds as the deliberate, tested exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aced
Previewing a sound could take DOOM's whole sound engine down, and did
(2026-08-05, owner-reported: AV then a freeze). Root cause is in the
engine, in StartSound_wwise (RVA 0x1854600), which publishes a brand-new
emitter into the sound world's LIVE list before initialising it:
emitter = FUN_1418455e0(operator_new(0x2200)); // allocate + base ctor
world[0x3EE][i] = emitter; // PUBLISH (world+0x1F70)
world[0x3EF] = i + 1; // bump count (world+0x1F78)
...
FUN_141846900(emitter, world, ...); // ONLY NOW is +0x20A0 set
idSoundWorld::Update (0x1857270, from the "Sound World Update" job at
0x18520B0) walks that list on an AUDIO WORKER THREAD, and idSound::Update
(0x1847670) opens with:
mov rax,[emitter+0x20A0] ; the sound shader
mov edi,[rax+0xA4] ; rax == 0 in the window above
which is the reported fault exactly: AV at rip 0x18476C1, address 0xA4.
We were calling the preview from the frontend's 30 Hz think-loop thread,
so that window was live on every single click. The fault-shield made it
worse rather than better: it downgraded the AV to a recoverable Error(6),
which is validated for MAIN-THREAD faults, and unwinding that out of the
audio job left sound-system state held -- hence a freeze instead of
either a recovery or an honest crash.
Fix: nothing in soundpreview.c touches the sound world from the calling
thread any more. Play, stop and session on/off are queued and drained on
the DOOM main thread through the engine command buffer, via a registered
`sh_sndprev` command -- the same routing apply_engine.c already uses for
clone_bss_apply, and the same route the engine's OWN audition takes
(testSound is a console command, which is why it never hits this). We
inherit the shipped worked example's safety rather than inventing one.
Ops go through a small ordered ring rather than a last-wins slot: a stop
queued behind a play must not be collapsed away. The checks a user can
act on stay synchronous (unknown name, no sound world), so those are
still an immediate red toast; only the engine's own "declined to start"
moves one frame later, into the log.
AddCommand's 4th/5th arguments are declared two different ways in this
repo (apply_engine.c: cb,p3,help -- commands.c: handler,help,argComp) and
AddCommand itself does not name them; one of those slots is an
arg-completion FUNCTION POINTER. Rather than guess and risk registering a
help string where a callable goes, this passes NULL for both. The
pre-existing ambiguity in clone_bss_apply's registration is left alone
and flagged for a separate look.
Verified: sig_test 67/67, hooktol_test 3/3, 16/16 native tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sting 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 into the field once per `v`. Owner-reported, confirmed fixed live. The check guarding the clipboard actions read a static flag, `g_ctrl_down`, set from the onKey stream on the Ctrl key-down and cleared on the key-up. Modifier key-ups are not reliably delivered to a focused SWF script object -- the field can lose focus, the window can lose focus, or the engine may simply not dispatch them -- and a single missed key-up left the flag stuck on for the rest of the session. The design was justified in a comment as tracking Ctrl "exactly the way the stock handler tracks Shift", and that is where the reasoning failed: the stock handler reads Shift only while processing the keystroke it was just 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. Fix: keep no state. Read the real modifier at the instant C or V arrives, via GetAsyncKeyState(VK_CONTROL) -- physical key state, not this thread's message queue, which matters because the hook runs on the engine's input path. Nothing can go stale, alt-tabbing mid-chord cannot poison it, and it covers both Ctrl keys without depending on which scancode the engine reports (SC_LCTRL / SC_RCTRL are now unused and removed). Note this fixes the bug by deleting the assumption rather than by locating where the key-up goes. That is deliberate: the latch was unnecessary state and the stateless form is correct either way. Verified live by the owner: plain typing is clean, Ctrl+C/Ctrl+V still work with and without a selection and across the game/Windows boundary, and the hold-Ctrl-then-alt-tab case that previously guaranteed a stuck flag is clean. sig_test 67/67, hooktol_test 3/3, 16/16 native tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d color Users overriding snapeditorentitydef/unknown/unknown.decl only had displayName and spawnOrientation to work with. Add clipModelInfo.size and renderModelInfo.color, mirroring the identical path/tag/inspector triples movement/bounce_pad.decl already ships, and document why placementFlags = SNAP_ENTITY_PLACE_AIR_UNALIGNED must survive any user override of this file (it is whole-stream replaced, not merged, and dropping it makes the tile unplaceable).
A material has two independent addressing paths -- by name through a material decl (customMaterial), or by rectangle through the .vmtr megatexture atlas (virtualmapping) -- and neither set contains the other. Listing only decls hid thousands of atlas rows that are legitimately paintable via Virtual Mapping but have no decl authored for them: users could see the art in-game but could not find or apply it by name in the browser. This was a real regression from the decl-only catalog design. Union the two sources (imgpreview_load_vmtr, dedup case-insensitively against megapreview's already-parsed .vmtr table) and add SH_ASSET_VTONLY so the UI can tell an atlas-only name from a decl-backed one and gate the Custom Material carrier honestly instead of guessing.
- Wire the new decl-or-.vmtr materials union into the browser (SH_ASSET_VTONLY, abHasDecl) so an atlas-only name gates Custom Material off honestly instead of guessing, and drop the "Check the full decl set" button/materialResult path -- it called the engine's CACHED-ONLY material find, which answers "is this loaded right now" rather than "does this exist", so it reported "not found" for names that plainly exist. - Add the Cross Platform Textures filter (AB_XPLAT_RAW, 195 megatexture rects hand-tested to render identically on PC/Xbox/PlayStation) with a ramp tiling picker (AB_RAMP_RATES) and a bloom-mask control, both scoped to Virtual Mapping only -- confirmed in-editor that ramp1/bloommaskscale are read by the triplanar ambientprogram, which a by-name customMaterial apply does not carry. - Remove the dead, permanently-disabled "Apply to entity" button in the asset modal footer (redundant with the real Apply-to-selection controls). - Fix "???" rendering: src/ui/build.ps1 embeds this file via PowerShell 5.1, which reads BOM-less UTF-8 as Windows-1252 and then writes the generated header as pure ASCII, mangling any literal non-ASCII byte. Document the constraint at the top of the file and switch the affected text to HTML entities / String.fromCharCode.
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.
ArrowUp/ArrowDown only moved a highlight; seeing what a row actually was required a follow-up Enter or Space. Auto-select the row the cursor lands on when it is a file, debounced 120ms so holding the key down doesn't fire a backend preview request per row scrolled past -- only the row the mapper actually stops on gets selected. Folders are left alone on purpose: opening one replaces the whole list, a bigger step than previewing a file, so it keeps requiring Enter/Space like before.
…ture update One more comment (the customMaterial-availability gate's rationale) still said "162 textures" after the cross-platform list grew to 224/48/176.
Upstream 7ede1e7 gated the SAVE shadow behind the same switch as the LOAD swap, so `rawmap.json` is no longer written on every save. That is the intended change and its data-loss rationale is sound, but it couples two operations with very different risk profiles: arming the shadow (mirrors one file) now also arms the swap (silently replaces every map you load). A user whose workflow treats rawmap.json as their working map cannot take one without the other. Documents what was proven, what was ruled out as a cause (both detours, the on/off handlers, and the new map-load guards -- with citations), the still-unresolved "save as a new map" prompt and the single question that would settle it, and a proposed split-switch fix that restores the old save behaviour while answering upstream's concern rather than reverting it. No code change -- deferred pending a decision, and any fix belongs upstream as its own PR rather than folded into a feature branch.
The Wwise-event merge deduped by exact name, which catches the 5,160 decls
literally named `play_*` but not the 449 PATH-FORM ones, where the sources
spell the same sound differently:
decl scripted_events/cyberdemon/head_splat_01
event Play_head_splat_01
Those showed as two rows for one sound (owner-reported: `head_splat`
returning four results for two sounds).
The rule is `twin == "Play_" + the decl's LEAF`, and the twin can be EITHER
source -- checking only the events fixes barely half of them:
181 twin is a Wwise event with no exact-name decl
129 twin is a flat `play_<leaf>` decl (so the event was exact-matched
out of the event list and searching it alone misses the pair)
139 no twin -- genuinely unique, correctly kept
Applying the prefix mechanically is safe: none of the 449 path-form decls
already has a `play_` leaf, so it cannot double up. Only path-form rows are
ever hidden and the looked-up name never contains a '/', so a flat row can
never be hidden and two records can never hide each other. The decl array is
built from rows still visible after the box dedup, so a campaign-box twin
that was already hidden cannot suppress the row that survived it.
Dropping the decl side is lossless: 5,657 of 5,658 sound decls are empty
wrappers (`inherit = "default"` + an empty `edit`), the sole exception being
`default.decl` they all inherit. No volume, falloff or randomisation is lost
-- only the folder path, which the owner did not want kept for sounds. Same
reasoning covers the 8 events claimed by two decls (baron/hellknight
groundpound and friends): with nothing to tell them apart, merging is correct
rather than lossy.
Event-only sounds are untouched -- the 1,513 with no decl at all, including
the DLC1-3 `play_vo_snapmaps_*` VO the merge was added for, have nothing to
dedupe against.
The sound catalog's folder tree barely exists: 5,160 of the 5,658 decls are flat `play_*` names with no path at all, so ~95% of the list sits in one root. Browsing it is scrolling, not navigating. The <SoundBank> grouping in soundbanksinfo.xml is the only real structure the catalog has, and it is a good one -- 26 banks, sensibly sized (doom_vo 1666, doom_initial 1551, doom_snapmaps 485, doom_monsters 424), with just 3 under 20 events. The obvious alternative, splitting on the name prefix, is much worse: 835 buckets, 585 of them holding a single event, and `vo` alone swallowing 46% of the catalog. One event can be in several banks -- 1,619 of 7,649 -- which sounds fatal for a single-valued filter and is not, because the overlap is almost entirely `doom_initial` (the always-loaded base bank) paired with the bank that actually means something: doom_initial+doom_monsters 345, +doom_scripted_events 180, +doom_weapon_sp 162. So the tie-break is "prefer the specific bank over doom_initial", which resolves nearly all of it. doom_snapmaps is cleaner still: 485 events, only 27 in any other bank. Backend maps event -> bank while parsing the manifest and serves it on a new SH_ASSET_SNDBANK pseudo-kind as `event|bank` lines -- the same qualifier pattern SH_ASSET_VTONLY already uses, so no new vtable slot and no change to the sound list itself (those names are applied verbatim; appending a bank would corrupt every one). The UI keeps it as a side map and derives a `sound@<bank>` list, reusing the existing tree/search/paging unchanged, the same way the cross-platform material set already works. Also motivates a follow-up: the owner reports many sounds audition silently, and bank membership is a plausible predictor of which ones a SnapMap session can actually play. This filter makes that testable a bank at a time rather than one sound at a time. Not asserted as fact yet. 15/15 native tests pass.
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@<bank>` list.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ield
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Adds the Assets tab — a browser over DOOM's entire shipped asset catalog, with real previews and
one-click placement — plus the engine-call fixes found while building it.
This is a large PR for one feature area. It is 29 commits and the history is meant to be read: each
commit is one decision with its evidence in the message, and several of them record a wrong turn and
the measurement that corrected it. If you review by commit rather than by squashed diff, the
reasoning is there.
Everything is enumerated live from the game's own containers at startup. No pre-extraction step, no
bundled asset list, no DOOM bytes in the repo.
The asset browser
The catalog. Every category is read out of
snap_gameresources.indexat load. Only snap-boxassets are listed, with sounds as the deliberate, tested exception: SnapMap never mounts the
campaign's
gameresources.resources, so a campaign-only model resolves in the editor as a black cube.Sounds are exempt because a sound decl is a thin wrapper and the audio lives in Wwise banks loaded
independently of the
.resourcesboxes — they have a side door, materials and models do not.Categories: Materials, Images, Models, Modules, Brush models, Clip models, Sounds, Lights, FX,
Particles, Decal atlases, Entity defs, Editor defs, Perks, SWF / Flash, plus a user-owned Pinned
shortlist.
Material previews are real pixels. A material's pages are located in the shipped megatexture set
and decoded with DOOM's own page decoder, called in-process. That decoder is a pure function — no
renderer, no GPU, no virtual-texture state, no map residency — which is the property that makes
whole-catalog browsing possible at all: a preview does not require the loaded map to have the
material on screen. Materials with no atlas rect (roughly half the catalog) fall back to reading the
image out of the
.index/.resourcescontainers and decoding BC1/BC3/BC7 directly.This replaced an earlier render-capture route that only ever produced static noise. The transport
that survived it is route-independent, so the producer could be swapped without touching the
frontend.
Sound previews audition through the editor's own preview path with working play/stop, rather than
the
testSoundconsole command — which throws its emitter handle away, so it can neither be stoppednor prevented from stacking a new voice on every click. The preview session is scoped to window
focus: background audio is only wanted while DOOM lacks focus, so blurring the Snapmap+ window ends
the session and hands the editor its audio back.
Apply to selection patches the selected entity's decl and commits immediately — one entity at a
time, since it edits 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.
Each carrier is gated to the classes that own the field it writes, in one function:
customMaterial/virtualmappingidAI2*, cap entities, dynamic SnapMap entitiesrenderModelInfo.model(+clipModelName)soundlightMaterialparticleSystemfxDeclTwo notes on how those lists were arrived at, since both look wrong at first glance. FX appears to
need a denylist: 371 shipped defs across 39 classes carry an
fxDecl. None are placeable in SnapMap— the set a mapper can select is the editor's own
fx/category, which resolves to three runtimeclasses, 45 of its 49 entries being
idVolume_ToggleableDamageOverTime. And the gate keys onclass, never on palette-placeability:
idSnapMapParticleEmitterhas no placeable palette entry,yet this tool creates one by overriding the classname on
snapmaps/unknown, so gating onplaceability would have refused the emitter it just made.
Materials and models share one list on purpose — anything that can wear a model can wear a surface.
Things the catalog turned out to be, that we assumed wrong first
geometry to
renderModelInfo.model, its paired collision toclipModelInfo.clipModelName. The twolive at unrelated paths and pair 232-for-232 by a total path rule; the browser derives the
collision name. The inherited
CLIPMODEL_AUTOis left alone — naming a clip model overrides theautomatic derivation on its own.
materialdecls hid thousands of.vmtratlas rows that are paintable via Virtual Mapping buthave no decl authored. The list is now the union, and
SH_ASSET_VTONLYlets the UI gate the CustomMaterial carrier honestly instead of guessing.
sounddecl and a Wwise event are different sets; a decl-onlylist was missing ~2,600 names including the generic SnapMap VO. Union, deduplicated
case-insensitively — the manifest spells events
Play_Vo_...and decls are lowercase.breakabledecl describes howsomething shatters and names a model; those 108 models are indexed as
discreteAnimation, notmodel, and are invisible to amodel-only catalog. They are in the snap box, so they load.through, written as
lightMaterial. Point vs spot is which entity carries it, so it is the Create-aschoice. The list is material decls under
lights/andlights_blended/; the underscore makesthe second a sibling rather than a subfolder, and matching only the first silently dropped every
flare.
be baked into
.resourcesmore than once at different offsets, and the index lists each copy —decalatlashad 1,673 records for 1,024 distinct names. That is why decals appeared twice, and whyclicking one row selected both (the UI keys off the name). Now collapsed.
Smaller browser behavior worth knowing
one root folder of ~8,000 rows. 24 sensibly-sized banks; an event listed in several is filed under
one home so nothing appears twice; search cuts across banks rather than within the open one.
pinned.json, deliberately not inconfig.json—the settings file is all-or-nothing on a parse error, and a malformed pin list has no business
resetting somebody's theme. The host moves the bytes and parses none of them.
PC, Xbox and PlayStation, with a ramp-tiling picker and a bloom-mask control scoped to Virtual
Mapping only (confirmed in-editor that only the triplanar ambientprogram reads them).
ramp1the option previously did nothing, which is exactly the entity someone picks it for.the game runs, so it re-fetched identical bytes and could not recover a failed index either.
Bugs fixed
corvtyped into any editor text field copied or pasted — typing "variable" pasted the clipboard onceper
v. Owner-reported, confirmed fixed live. The flag was set on Ctrl key-down and cleared onkey-up, and modifier key-ups are not reliably delivered to a focused SWF script object. It had been
justified as tracking Ctrl "exactly the way the stock handler tracks Shift" — but 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. The mechanism was copied without its lifetime
assumption.
a freeze. DOOM's
StartSound_wwisepublishes a new emitter into the sound world's live list beforeinitialising it, and
idSoundWorld::Updatewalks that list on an audio worker thread — dereferencingthe pointer that is still null inside the window. Calling from a non-main thread widened a race the
engine gets away with internally; every audition now runs on the main thread.
listed, and the first fix caught only the ones whose twin was a bare event, missing the 129 whose
twin was another flat decl.
follow-up Enter. File rows now auto-select (debounced, so holding the key previews only where you
stop); folder rows still need Enter, since opening one replaces the whole list.
clipModelInfo.sizeandrenderModelInfo.color, so a user overridingunknown.declhas more than displayName andspawnOrientation to work with.
[64]in thetest arrays that hid a truncated signature DB was fixed — it had bitten once before.
Docs
Updated per
docs/contributing.mdsection 9, each doc getting the layer it already covers:capabilities.mdpinned.jsonwebview-ui.mdbackend-changes.mdarchitecture.mdrawmap-shadow-gate.mdTesting
build.ps1→package.ps1→ deployed into a real DOOM install and exercised in the SnapMap editor.gofmtclean,go vetclean,go test— the install round-trip tests refuse torun while DOOM is open (the installer's own guard); they pass with the game closed.
modules, sounds, FX, particles and lights, prefab place, and the breakable models specifically
(they render rather than showing the campaign-box black cube).
Known limitations
Perks and SWFs are reference-only. A perk is granted by an
idTarget_Commandentity and a.swfbelongs to an entity that owns a screen; neither structure is worked out yet, so both arenames to copy and wire by hand. They are listed under Reference rather than Placeable on purpose —
a category under Placeable whose Apply does nothing reads as a broken tool.
The per-carrier allowlists are built from the classes we have seen own each field in a shipped def,
not derived exhaustively from the class universe. A legitimate target nobody has encountered is
refused rather than permitted — the safe direction to be wrong in, but it does mean a refusal here
reads as not known to work, not proven not to work.
An unexplained off-by-one in the sound catalog: 8,028 rows file against 7,649 distinct events
with 378 unbanked, leaving one more banked row than there are events. Sound names are unique in both
boxes, so the obvious explanation is ruled out. One row in 8,028, no known consequence; recorded in
backend-changes.mdrather than left out.The point and spot light inspectors ship different valid-material whitelists (21 shared, one
unique each). We do not enforce either — the list is the broader known-good set.
Checklist
original-SnapHak content is pasted into the repo.
.dll/.exe/.obj/.pdb/.zip...) -- the source is the only deliverable..c/.h/.cpp/.ps1).build.ps1->package.ps1-> deployed into a real DOOM install, and I ran the Go(
gofmt/go vet/go test) and C (tests\run-tests.ps1) suites, and confirmed it works in DOOM.docs/contributing.mdsection 9).Notes on the boxes, so a reviewer does not have to take them on trust:
upstream/mainfor binary extensions; none.6f40391.apply_engine.ckeeps six that predate this branch; changing them is not this PR's business.snapmap-plus.exe install --local dist, whichcontributing.mdsection 6 allows (dist/mirrors the overlaytree). Both DLLs were hash-verified against
dist/after every copy.sig_test/hooktol_test) were run for the signature work earlier in thisbranch, which is the change that required them.