Closes the loop on the asset_extract --emit-terrain pipeline. The
runtime terrain loader now probes for a .whm/.wot/.woc trio in the
same directory as the resolved ADT (e.g. <data>/world/maps/foo/
foo_30_30.{whm,wot,woc}) before falling back to ADTLoader.
Hits the open-format path when:
custom_zones/<map>/<map>_X_Y.{whm,wot} exists (zone author override)
output/<map>/<map>_X_Y.{whm,wot} exists (editor export)
<data>/world/maps/<map>/<map>_X_Y.{whm,wot} (asset extractor)
-- otherwise falls through to ADTLoader::load(adtData)
Promotes AssetManager::resolveFile to public so callers (terrain
sidecar probe here, anything else later) can locate an extracted
file's directory without reading the bytes.
Servers/private servers continue to read .adt via manifest paths
unchanged. Runtime sidecar coverage now matches the extractor's
emit set across all five binary open formats.
terrain_manager already attempts WOM/WOB via tryLoadByGamePath but
its prefix list only included custom_zones/ and output/. The asset
extractor's --emit-wom/--emit-wob writes sidecars next to the M2/WMO
in the asset tree itself (e.g. <data>/world/maps/foo/foo.wom).
Pass the AssetManager's data path as an extra prefix so the runtime
picks the open-format sidecar up there before falling back to the
proprietary M2/WMO load path. Completes the runtime side of the
dual-format extraction:
AssetManager::loadTexture → tries .png sidecar first, then BLP.
AssetManager::loadDBC → tries .json sidecar first, then DBC.
TerrainManager M2/WMO → tries .wom/.wob sidecar first, then m2/wmo.
Servers/private servers see no change — they read from the proprietary
files via manifest paths and don't touch the sidecars.
AssetManager::loadDBC now probes for a .json sidecar in the same
directory as the binary DBC the manifest resolves to. asset_extract
--emit-json-dbc writes that sidecar on extraction, so the runtime
client transparently falls back to it when the binary DBC is absent
(e.g. open-format-only extraction for end-to-end format testing).
Order: binary DBC > JSON sidecar > custom_zones JSON > CSV > error.
Servers (AzerothCore/TrinityCore) only read the binary DBC, so this
change is invisible to them — the wowee runtime is the only consumer
that tries the JSON path.
The PNG sidecar pickup (tryLoadPngOverride) was already in place from
prior work — this completes the symmetric runtime-side wiring for
both BLP→PNG and DBC→JSON open-format outputs.
Extends asset_extract with two more open-format emitters:
--emit-wom foo.m2 (+ foo00.skin) → foo.wom
--emit-wob foo.wmo (+ foo_NNN.wmo groups) → foo.wob
--emit-open now also turns these on
Originals are preserved so private servers still load .m2/.wmo
through the manifest path; the wowee runtime/editor pick up the
.wom/.wob next to them via the existing open-format search rules.
Implementation:
- New WoweeModelLoader::fromM2Bytes(m2Data, skinData) shares the
conversion body with fromM2(path, am) via a static helper
(convertM2ToWom). Lets the extractor convert without standing
up an AssetManager.
- fromM2(path, am) moved to a separate translation unit
(wowee_model_fromm2.cpp) so asset_extract doesn't have to
link the AssetManager dependency.
- WoweeBuildingLoader::fromWMO already takes a WMOModel directly,
so emitWobFromWmo just needs to read root + group files and
call save().
- Group sub-files (<base>_NNN.wmo) are skipped during the walk
since they're merged into the root WMO.
A JSON DBC with a malformed record (object instead of array, or
a string entry) would call row[col] which throws on non-arrays —
the outer try-catch treated this as a hard failure for the whole
DBC. Skip the row (stays zero-initialized) so a single malformed
record doesn't lose all the rest.
Both name lists used n.get<std::string> which throws on non-string
entries (would abort the entire WOT load). Real zones use ~5k names
max; cap at 65536 (uint16 nameId range upper bound) so the cap is
generous but bounded. Guard with is_string so a single bad entry
just gets skipped instead of failing the file.
Three issues:
- textures vector was unbounded (cap at 1024).
- Per-chunk layers vector was unbounded (cap at 8 — WoW ADT
format supports 4, doubling for headroom).
- texId.get<uint32_t> and holes.get<uint16_t> would throw
json::type_error on negative or oversize values, aborting the
entire WOT load. Read as int64, clamp to the target range.
Same defense pattern as the editor JSON loaders. Real ADTs cap at
~64k MDDF entries and ~5k in practice; 100k matches the editor
ObjectPlacer cap so an extreme WOT can't bloat the in-memory
terrain past what the editor itself would accept.
val.get<uint32_t>() throws on negative or > UINT32_MAX. The
outer try-catch would then abort the entire JSON DBC load on a
single bad cell. Read as int64_t, clamp to [0, UINT32_MAX], and
zero out anything out of range — matches the per-field NaN scrub
applied to floats one branch up.
Same load-desync pattern as elsewhere — alphaSize > 65536 silently
skipped the read but the actual alpha bytes were still on disk, so
the next chunk's baseHeight float read would parse alpha bytes.
Now rejects the load with LOG_ERROR.
DBCFile::load multiplied recordCount * recordSize as uint32 (line
108), so a header with recordCount=1B and recordSize=1024 would
wrap to a tiny size — resize allocates ~tiny, memcpy reads ~TB
of memory and crashes.
Reject impossible header values up front (10M records / 1024
fields / 16KB record / 256MB string block) and use uint64_t for
the file-size sanity check + size_t for the resize/memcpy product
so the bounds-check is the only path that allows large counts.
Same silent-corruption pattern as WoB: model.name had no length
check at all (would happily allocate 64KB), and texture paths
silently zeroed pathLen on overflow leaving the actual bytes on
disk to shift the rest of the file. Now reject with LOG_ERROR.
Building name, group name, group texture path, material texture
path, and doodad model path all had the same defect: when the
length field exceeded 1024 the loader silently set the local
counter to 0 and skipped the read — but the actual string bytes
were still on disk, so the next read interpreted them as the next
length+data pair and the whole rest of the file desynced.
Now reject the whole load on each oversize length with an explicit
LOG_ERROR. Save caps at 1024 so this only triggers on hand-crafted
or future-version files, but the failure mode was severe enough
(silent zone corruption, not a clean error) to warrant the fix.
Previously load silently skipped the materials block when mc > 256,
leaving the file pointer right after the count — the next group's
name would then read material bytes as garbage and the rest of the
file would shift. Save now caps at 256 (so the asymmetry shouldn't
trigger from our own writer), but a hand-crafted or future-version
WoB could still hit it.
Top-level WOM save was writing raw model.vertices/.indices/.texturePaths
sizes; load enforces 1M / 4M / 1024 limits. A pathological model would
emit a header rejected on load, leaking the rest of the file body.
Cap each count at the load limit and iterate the WOM1 vertex block +
texture-path block by index so the body matches the header.
Same per-section cap pattern. The loader caps batchCount at 4096;
save iterated all validBatches without checking. A model with
>4096 batches would write a header rejected on round-trip.
Same per-section cap pattern. Real portals carry 4-12 verts; the
load enforces 4096 max. Save previously wrote raw size() so a
huge portal would write a header the loader rejects.
Per-group counts were uncapped on save while load enforced 1M
vertices, 4M indices, 1024 texture paths. A single huge group
exceeded any cap would write a header the loader rejects, leaking
the rest of the file body into a misread chain.
Cap counts at the load limits and iterate the texture-path block
by index so the body matches the header on round-trip.
WoB load enforces 4096 groups / 8192 portals / 65536 doodads. Save
previously wrote raw size() and iterated all entries — a build
exceeding any cap would be rejected wholesale on round-trip.
Cap each count at the load limit and use indexed loops so the
written body matches the header count even if the in-memory data
goes over.
WOC load caps tris at 2M and clamps tile coords to 0..63. Save
previously wrote raw size() and tileX/Y — a >2M-tri collision
would be silently rejected on round-trip, and OOR tile coords
would log a warning every reload. Cap at save and reuse the
load-side clamp so the on-disk file is round-trip clean.
WOM load caps bones at 512 and animations at 1024. Save previously
wrote raw size() and iterated all entries — a model with >512 bones
would write fine but truncate on round-trip, and the post-truncation
keyframe data would be misread as the next animation.
Cap both counts at save and iterate using the capped value so the
per-bone keyframe block stays aligned with what load expects.
Save previously wrote raw materials.size() as the count, then iterated
all materials. Load caps at 256, so a build with >256 materials would
write fine but truncate on round-trip and the post-truncation block
would be misread as the next group's data. Cap at save and only write
the first 256.
If forward is parallel to (0,0,1) — camera staring straight up or
down — the cross product is zero and glm::normalize returned NaN.
That NaN flowed into glm::lookAt and produced a NaN view matrix.
The editor camera clamps pitch to +/-89 so it doesn't trigger,
but other call sites or scripted test paths could construct a
Camera at +/-90 and immediately blow up. Length-check the cross
and fall back to world +X / +Z.
glm::min/max on NaN is implementation-defined, so a single bad
vertex would propagate NaN into the camera-occlusion and culling
AABB used by the runtime. WOM/M2 loaders already scrub but defense
in depth catches anything they miss. Falls back to a unit box if
every vertex is bad.
The matrix-transformed normal could be near-zero if the M2 instance
has a degenerate scale; glm::normalize then returns NaN that
contaminates the slope check (NaN < 0.35 is false → no early-out)
and bestNormalZ goes NaN, breaking the walkable-floor heuristic.
Length-check the transformed normal and fall back to the (0,0,1)
flat default — same pattern as the WMO renderer.
A WMO vertex with zero-length or NaN normal would produce a NaN
normalized normal, contaminating the Gram-Schmidt tangent for the
whole vertex and producing visibly broken normal mapping for the
affected face. Length-check before normalize and fall back to
(0,0,1) when degenerate.
A portal whose first three vertices are coincident or collinear
produces a zero cross product and glm::normalize returns NaN. The
NaN propagates into the portal-frustum cull (every interior group
either always-visible or never-visible depending on plane orientation).
Use the same length-check pattern as the editor's spline/path code:
zero cross → fall back to (0,0,1) up-axis.
Mirrors the createInstance guard. position drives the dedup hash
key (std::round of NaN is implementation-defined) and the matrix
flows into the GPU UBO.
Same boundary-rejection pattern as createInstance. NaN in either
function would corrupt the spatial grid (stale cells pointing at
NaN-bounded instances) and the GPU model-matrix UBO.
Even with all the upstream guards I've been adding, internal callers
or addon-style scripted spawns could pass NaN. Reject at the API
boundary so we never hash-key with NaN coords (std::round of NaN
is implementation-defined) or push a NaN instance into the model-
matrix uniform buffer (GPU crash / origin render).
Three issues addressed:
- NaN portal vertices break the WMO portal-frustum cull, defeating
the indoor optimization and forcing the whole interior to draw.
- Out-of-range portal.groupA/groupB indices walk past wmo.groups
during cull; clamp to -1 (invalid) on load.
- Symmetric save-side scrub so we don't persist bad in-memory data.
WHM load already scrubs, but mid-edit terrain can briefly carry
NaN before stitchEdges runs. A single NaN vertex propagates into
normal computations and the chunk's frustum cull, crashing both.
WoB allows uint32 indices but WMO format is uint16. The previous
static_cast would silently wrap a >65k index into a wrong-but-
valid value — producing visible mis-stitched triangles in the
renderer. Now log a warning once per group and clamp to 0
(degenerate triangle) so the bug is visible.
Symmetric with the load-side index clamp. A WoM whose indices
reference past the vertex buffer would crash the GPU vertex shader;
the save side now clamps to 0 (degenerate triangle) so the file
matches what the load guard would produce.
Symmetric with the load-side validation. A WOM3 batch whose
indexStart+indexCount exceeds the index buffer, or whose texture
index points past the texture array, would otherwise emit an
invalid file that the load-time guard then has to drop.
Filter at save instead so the on-disk file stays compact and
self-consistent.
Symmetric with the existing load-side guards. A bone with a NaN
pivot poisons its child bones' world matrices; an out-of-range
parent index would walk past the bones array during evaluation.
Symmetric scrub on the WoB save path matching the existing load
guard. A manually-constructed WoweeBuilding with NaN vertices
would otherwise persist them and force the load-time scrub to
re-clean the same data on every reload.
The load side already scrubs keyframe translation/rotation/scale
floats, but fromM2 → save → load is the typical path: a corrupt
M2 source would write NaN keyframes that the load-time guard would
have to clean up on every subsequent load. Symmetric scrub here
ensures the file is clean from the start.
movingSpeed also defaults to 0 if non-finite (matches load).
A WMO with a NaN doodad quaternion would produce NaN euler angles
through glm::eulerAngles() and persist them into the WoB. Identity
quaternion fallback for a non-finite source, plus NaN scrub on
position/rotation/scale separately so the converted WOB is always
load-safe.
WoW liquid types are 0=water/1=ocean/2=magma/3=slime. A user-edited
WOT could carry an out-of-range value that the editor renderer
silently maps to plain water but the server treats as undefined.
A WOT water entry with non-finite height would push NaN through
the water mesh builder and produce a degenerate Vulkan draw
(invisible water at best, GPU hang at worst).
Same scrub now applied symmetrically on the save side so a
corrupted in-memory doodad transform can't be persisted into a
WOB and then have to be cleaned up on every subsequent load.
Already had a guard for scale; extending to position/rotation too.
A WoB with non-finite doodad transforms produces NaN model matrices
that propagate into the M2 instance SSBO and crash the GPU.
Mirrors the editor-side ZoneManifest sanitize on the discovery
scanner used by the launcher and asset manager. A custom_zones/
zone with bad mapId or out-of-range tile coords would otherwise
appear in the picker and silently fail when the user selects it.
A WOT JSON could carry tile coords outside 0..63 (would compute
chunk world positions tens of thousands of units off-grid) or NaN
position/rotation values on doodad/WMO placements (would propagate
into rendering matrices and produce invisible geometry).
Same float-NaN scrub for WoB save matching the WHM/WOC/WOM saves.
Building boundRadius defaults to 1.0 if non-finite; per-group bounds
zero out non-finite components (would otherwise corrupt the cull
frustum).
Mirrors the WHM and WOC save sanitize. boundRadius defaults to 1.0 if
non-finite (matches load-time default); boundMin/boundMax components
zero out non-finite values. Prevents an in-memory model with a NaN
spike (e.g. mid-edit) from being persisted into the WOM and requiring
load-time cleanup forever after.
In-memory collision can be polluted by addMesh on bad input (e.g. a
WMO with NaN vertex positions). Without this, the save would persist
that NaN into the WOC and the load-time guards would have to clean
it up forever. Now scrubs vertices and bounds at write time, matching
the WHM save sanitize.
Same fix as WoB save just got. Without truncation a model name or
texture path over 65535 chars would silently get a wrap-around length
and corrupt the file.