Update Healer Man server

This commit is contained in:
phenom committed 2026-10-06 22:25:34 -04:00
1 parent 6114a2295f
commit 69809f7228
593 files changed
+903643 -40774

No files matched your search

@@ -0,0 +1,27 @@
# Native Runewaker collision assembly
`assemble-native-collision.mjs` assembles WDB static placements from collision structures serialized in their ROS resources. It reads the exact column-major `staticPlacements[].transform.primaryLocalThreeMatrix` values from a placement manifest, applies the recipe's source-unit conversion, and writes all-source and Trekker-filtered collision GLB chunks. It never substitutes render triangles when serialized collision is missing.
Build the separate native Rune Engine extractor when needed:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/runewaker-pipeline/build-native-collision-audit.ps1
```
Then run the Origin assembly:
```powershell
node scripts/runewaker-pipeline/assemble-native-collision.mjs `
--recipe scripts/runewaker-pipeline/recipes/the-origin.json `
--placement-manifest runewaker-export-work/the-origin/converted/wdb-placement-manifest.json `
--collision-mode respect-options `
--output-root artifacts/playtest/native-collision/the-origin
```
`--collision-mode complete` is the default full-source audit and includes triangles regardless of `ruENTITYOPTION_COLLISION`. Use `--collision-mode respect-options` for collision intended to match the engine’s default `m_ignoreCollisionFlag=false`: a disabled entity prevents querying its own collision object/model and suppresses its descendants. The native report keeps both complete and option-enabled triangle totals, counts entities whose collision option is disabled, counts nodes unreachable below an option gate, and records render-mesh fallback separately. Only serialized native collision structures are assembled into GLBs; render-mesh fallback is diagnostic and is not silently substituted. Inventory identity includes the collision mode and both extractor executable/source hashes, so complete-mode sidecars cannot be reused as option-respecting output.
Use `--source-root` to override the recipe's Runewaker source root. Use `--extractor` to select a separately built native auditor. A generated native inventory is kept in an immutable hash-keyed subdirectory under `--output-root`; it includes exact SHA-256 values for the source WDB, every source ROS, each extractor report, each OBJ triangle soup, each little-endian per-triangle flag sidecar, and the extractor binary/source. Pass `--inventory-root <path>` to reuse an indexed inventory; the command revalidates every source ROS and generated sidecar before assembly. Legacy diagnostic inventories without this index are rejected rather than attributed to current source files.
The output is grouped under `runs/<input-fingerprint>/`. Its JSON report includes source hashes, placement/resource counts, complete/option-enabled/omitted triangle totals, per-resource disabled option counts, per-placement flag counts, omissions, and each collision chunk's hash, byte size, tile, triangle count, and flag histogram. `--report-output <path>` copies the full report to a stable workspace path. Every GLB is capped at 90,000 triangles. Primitive extras store one source triangle flag for each consecutive triangle in position order. Spatial tiles default to 256 meters and can be changed with `--tile-size-meters`.
The optional Trekker path set excludes flag mask `0x00A00000` (water `0x00200000` and foliage `0x00800000`), matching `RuFusion_Trekker.cpp::IsPathClear` and `::FindRestingHeight`. All-source output retains those triangles. Both sets remain review assets; the CLI does not wire collision into runtime or shipping packages.
+303
View File
@@ -80,6 +80,309 @@ and treasure passes.
## All configured instances
### Environment texture and lighting quality
The visual optimizer uses `lib/environment-optimization.mjs`. RoM architecture
keeps its source triangles and atlas UV interpolation: `--simplify false` is
required because generic mesh simplification collapses UVs on large planar
walls and floors. Meshopt compression and instancing remain enabled. Native
water and source-detail variant carriers disable joining, pruning and attribute
pruning so auxiliary maps and their mappings survive optimization.
`--texture-compress false` also prevents the optimizer's default
2048-pixel image resize; the separate KTX2 pass encodes the source dimensions.
`src/game/runewakerDungeonEnvironment.ts` supplies reviewed fallback palettes
for all 30 registered base dungeons. The registry applies this adapter before
creating Bite-Sized derivatives. A separate WDB zone exporter and
`DungeonLighting.tsx` now own recovered ambient/directional lighting and fog;
this is not embedded GLB light-component preservation. Native Standard shadows
follow player footing and High follows the camera. Fallback profile Standard
targets the entrance; WoW retains its existing lighting path.
Native entrance directions pass through the same primary-placement frame as
source points. `transformSourceHeadingToPrimaryThree` applies inverse primary
rotation and component scale, bridges source handedness, and normalizes XZ
before deriving runtime yaw. Preserve the Lua direction in source provenance;
using it directly as local yaw faces the wrong way in rotated WDB assemblies.
The selective texture compressor applies UASTC to explicitly reviewed base-color
basenames. Its initial eight-name policy covered 16 images in nine packages;
the current list also covers Barren's exact native atlas/floor crops and HOTO's
four stone/cliff maps and Origin's exact `elves_vine` cutout. Read
`compress-environment-textures.mjs` for the current
list. Those targets use UASTC level 2 with Zstandard 18 and RDO disabled,
retaining source dimensions and the complete mip chain; other images retain the
existing ETC1S path. Pass explicit GLBs or directories so the compressor never
scans the workspace implicitly:
```powershell
node scripts/runewaker-pipeline/compress-environment-textures.mjs <rom-glb-or-directory> --report <report.json>
```
Run the source-resolution, sampler and floor/wall UV diagnostics with:
```powershell
npm run runewaker:audit:environment-quality
node --test scripts/runewaker-pipeline/environment-optimization.test.mjs scripts/runewaker-pipeline/audit-environment-quality.test.mjs scripts/runewaker-pipeline/rom-visual-uv-adaptations.test.mjs
```
The generated `RUNEWAKER_ENVIRONMENT_QUALITY_REPORT.md` covers every registered
RoM visual. Density and UV-rank measurements identify candidates for review,
rather than certifying every polygon. The initial 30-dungeon hotspot dispositions
are recorded in
`artifacts/playtest/rom-environment-quality/rom-texture-pipeline-triage/uv-hotspot-triage.md`.
The later production review supersedes Barren's whole-atlas repeat with exact
floor islands and native wall detail. HOTO retains cliff gradients while adding
source-rock detail. Durable recipe opt-ins are implemented in
`lib/rom-surface-quality-policies.mjs`; current evidence is under
`artifacts/playtest/rom-deep-review/uv-stretch-review/` and
`docs/ROM_ENVIRONMENT_REVIEW_2026-10-06.md`.
Exact native atlas crops carry `texture.extras.romSourcePixelCrop`. The quality
audit verifies the native PNG hash and DDS dimensions, crop rectangle, canonical
PNG's exact RGBA bytes, shipped island dimensions and full mip chain through
`lib/source-pixel-crop-proof.mjs`. Verified island dimensions are reported
separately from the full native atlas; this does not exempt resized images.
`verify-native-uv-source-layout.mjs` independently compares hierarchy/TRS and
expanded ordered triangle attributes while allowing presentation UV1 and
primitive/material regrouping. Original source images must remain byte-identical.
`src/scene/runewakerSurfaceDetailMaterial.ts` supports source-pinned static rock
detail carried by named `KHR_materials_variants` materials. `useGameGLTF.ts`
registers it and `productionDungeonMaterials.ts` preserves it through cloning.
Triplanar sampling uses real world metres and inverse-transpose normals,
including instance transforms. Mean-normalized source textures modulate diffuse
RGB within bounded strength. Reviewed metadata can reduce wall base contrast
around the source mean and blend rotated tile samples to soften repetitions.
`floorBaseStrength` permits a verified full stone/moss tile to supply floor RGB
directly at the chosen world scale; it must not repeat a mixed cliff atlas.
Original UV0, base-map alpha and lighting semantics remain intact. Texture
uniforms participate in existing filtering/disposal. Carrier
variants require the reviewed optimizer's join/prune protections. These detail
scales are browser presentation adaptations, not reconstructed native UVs.
HOTO v2 migrates only complete source-pinned v1 metadata, verified carriers and
all 72 variant mappings; reapplication is idempotent. Its reviewed continuation
uses `promote-hoto-surface-v2.mjs`: the default mode verifies immutable candidates,
source/layout/codec proofs and screenshot hashes without writing. `--apply`
creates a new exclusive backup before replacing only HOTO's three visual GLBs
and current metadata. All non-JSON chunks are byte-identical to v1; historical
codec reports retain their original GLB hashes.
Sand Vortex uses nine native WDB terrain
chunks selected from authoritative encounter bounds, with the original per-chunk
indices, visibility, vertex normals, two DXT1 splat maps, five source tile DDS
layers, and their native tile scales. Runtime material blending normalizes the
source RGB weights and samples the full-resolution layer textures at their
source scales; this replaces the old flat, untextured arena fallback.
Sand Vortex opts into source-scaled side projection on steep terrain through
`source.nativeTerrain.cliffProjection`. The runtime retains native XZ sampling
on level ground and blends height-aware side sampling only on slopes. It uses
the original smooth normals, layer scales, and splat weights; topology and
flat-floor UVs remain unchanged. The asset-root inverse transform keeps this
projection stable through package placement, quantization, and instancing.
The Origin now recovers all 256 WDB terrain chunks selected by the full source
manifest. Four source DDS layers, their per-layer tile scales, and both DXT1
splat maps feed the same linear runtime blend. The previous `[2,2]` chunk
review was only an early ground probe and is not the current coverage claim.
The independent native-water builder decodes all 126 selected version-3 WDB
water layers with exact GUID descriptor joins. It recreates source visibility
masks, vertex color and alpha, and packages the 30-frame animated normal
sequence as exact top-mip PNG carriers. The runtime applies those frames as
animated source normal maps and samples transformed source-world coordinates;
the glTF `TEXCOORD_0` on water layers is a source-world XZ validator carrier
only. The original DDS files and all mip hashes remain in the source audit
cache. Source-enabled non-DIRECT water now uses a bounded browser adaptation of
AquaFX camera reflection and pre-water refraction, with the native first-vertex
split planes, bump scale and Fresnel coefficients. The serialized
`waterDesc:full:enable` Boolean selects this branch even for the Origin's raw
effect value 4; its enum name is not inferred. Up to four active 512-pixel target
pairs share a ten-capture-per-second budget per tagged asset root. Old invisible
planes release their slots; additional active planes and below-plane views use
the lit animated fallback. Source reflect-class filtering and the original
water SH shader remain unsupported. Water remains visual-only and is not added
to source collision.
`export-native-lighting.mjs` decodes all 30 dungeons' exact WDB environment
settings and zone triggers into `src/game/generated/runewakerNativeLighting.generated.ts`.
`src/game/runewakerNativeLighting.ts` resolves source zones from runtime player
coordinates; `src/scene/DungeonLighting.tsx` applies their linear ambient and
three directional colors, directions, and camera-scaled fog. Time/weather
groups default deterministically to 0/0. Missing zones retain the dungeon's
reviewed presentation profile (including Grafu's entrance). The browser supplies
Rune's unoccluded SH constant-band energy as ambient fill (4/17) and uses the
remaining 13/17 in Three.js directional lighting. Angular diffuse response and
PBR specular remain browser adaptations, not the complete original SH/specular
shader. Standard shadows follow the player's footing and High shadows follow
the camera; WoW retains its existing presentation path.
Build all water-bearing chunks, or restrict water to the exact coordinates in
an existing ground manifest whose WDB hash matches:
```powershell
node scripts/runewaker-pipeline/build-native-water.mjs
node scripts/runewaker-pipeline/build-native-water.mjs --ground-manifest runewaker-export-work/the-origin/native-terrain-source/terrain-native-source.json
node scripts/runewaker-pipeline/build-native-water.mjs --ground-manifest runewaker-export-work/the-origin/native-terrain-source/terrain-native-source.json --audit-cache-root runewaker-export-work/the-origin
```
The builder writes `native-water-visual.glb`, `conversion-report.json`, and a
per-descriptor archive of the source DDS frames and lossless PNG carriers under
`runewaker-export-work/<slug>/native-water-source` by default. Use `--recipe`,
`--source-root`, and `--wdb-report` to select its local recipe and source
inputs; `--output-root` must remain inside the active project workspace.
The optional `--audit-cache-root` writes exact, hash-verified aliases named from
each GLB texture/variant into `raw/textures` and `prepared/textures` under that
root, allowing the quality audit to resolve water DDS and PNG source pixels.
Recipes may opt into pipeline integration with
`source.nativeWater.kind: "runewaker-wdb-water-layer"`. This mode requires
`source.nativeTerrain` and a full WDB assembly. The environment builder always
passes the selected native-terrain manifest to the water builder, so water
chunks follow the same reviewed source-ground selection. It merges the result
as a visual-only WDB record; no water geometry is added to source collision or
navigation.
Three reviewed primary-only recipes use `source.primaryNativeCollision` to
replace imported collision with an options-aware query of the exact primary ROS
plus source-coordinate support geometry. The reusable candidate producer hashes
the current recipe, import metadata, ROS/WDB sources, placement reports, and
native extractor; it invokes the native collision query in
`respect-options` mode or accepts a query cache only when its provenance
sidecar matches those inputs. It regenerates support boxes from the recipe and
resolved primary WDB transform rather than reading a previously transformed
GLB. The proof file names the full collision candidate (which the build splits
to the configured per-GLB triangle limit), the native-core/overlapping-support
Recast input, and the baked navigation candidate and report.
Run the candidate producer directly for one of its configured recipes with:
```powershell
node scripts/runewaker-pipeline/build-native-core-support-candidate.mjs aeternal-circle --respect-options
```
Use `--stage-only` to write only the source-verified collision inputs and proof
before Recast, or `--output-root=runewaker-export-work/<slug>/<new-directory>`
to select a fresh artifact directory under that dungeon's work folder. A second
run consumes staged inputs only after rechecking their source and GLB hashes.
The Aeternal entry-support omission is independently ray-checked against the
current option-enabled native collision query each run. Remote support pads
remain separate navigation components; when present, the report declares
`navigationComponentPolicy: "all-components"` without inventing links.
`recipes/the-origin.json` keeps the Lua-authored spawn `[828,263,963]` and
direction `343` as source provenance. The reviewed runtime entrance instead
uses source path graph 1026 node 854, projected to the recovered ground and
navigation. Current native collision selection honors serialized collision
options and Trekker's water/foliage exclusion mask: 865,100 eligible triangles
across 20 collision chunks. The current Recast/Detour artifact contains
103,582 triangles and 24,612 polygons. In that graph, node 854 reaches Life Leecher;
Life Leecher and node 854 both terminate in partial routes to Holy Tree
Falynum. That early collision-graph review covered only two short entrance walking
legs (2.85 m, with all three samples grounded). A later final v5 material review
walked 19/19 legs along the connected Life route (48.56 m, all 42 samples grounded).
Neither certifies a full clear or an approach route to Falynum; evidence and
scope are in `docs/ROM_ENVIRONMENT_REVIEW_2026-10-06.md`.
Rebuild with:
```powershell
npm run runewaker:build:all -- --only the-origin
npm run runewaker:register:all
```
The base Demon Stronghold recipe opts into
`rom-demon-stronghold-derived-support-entrance-floor-02` through
`lib/rom-derived-support-styling-file.mjs`. Exact ROS, DDS, and converted PNG
hashes gate the native entrance-floor texture applied to its 186 generated
support boxes. Added UV1 preserves every existing geometry accessor and uses
the native floor scale/phase at the entrance; vertical box faces retain full
UV rank. This is explicitly a derived support fallback, not native floor
recovery throughout the dungeon. Demon Stronghold 125 has no such opt-in.
`scripts/playtest-rom-visual-routes.cjs --dist=<build-directory>` can freeze an
isolated production build for ordinary UI entry and walking QA when the shared
`dist` directory is locked. Its runtime telemetry is read-only.
Native ROS material alpha provenance is captured by
`native/runewaker_model_exporter.cpp` and applied by
`blender/native_material_alpha.py` through `blender/convert-runewaker.py`. The
export report records material/lighting types, diffuse source-channel identity,
and native alpha-test/blend parameters; the builder stamps the exact ROS
`sourceResourceSha256` before Blender conversion. The converter only applies
those flags when source hash, model, material index and diffuse texture identity
all match. Normal translucent alpha maps to glTF `BLEND`; alpha-tested images
with cutout pixels map to `MASK` with the native threshold, while fully opaque
images stay `OPAQUE`. Exact native `SRCALPHA:ONE` materials are recorded as
additive and use glTF `BLEND`; the runtime applies additive blending only when
that conversion provenance is complete. Unknown or incomplete native modes stay
unsupported. Exact native prelit materials receive `KHR_materials_unlit` before
the optimizer runs, so deduplication keeps them separate from standard-lit
materials sharing the same map. Resource-cache validation includes exporter,
converter and alpha-policy hashes so a semantics change forces re-conversion.
`lib/native-alpha-cache-validation.mjs` also requires the current ROS hash,
complete native alpha parameters and diffuse-channel joins, and identical raw
and prepared material reports. A matching old cache stamp alone is insufficient.
The prelit annotation removes Blender's unused `KHR_materials_specular` metadata
from those same verified materials, avoiding an incompatible glTF extension pair.
Protect alpha matching with
`python scripts/runewaker-pipeline/blender/test_native_material_alpha.py`, the
pre-optimizer semantics with
`node --test scripts/runewaker-pipeline/lib/native-material-semantics.test.mjs`,
and runtime behavior with
`npm test -- --run src/scene/dungeonMaterialAudit.test.ts`.
The environment builder runs terrain extraction and GLB generation as part of
the Sand Vortex recipe. Rebuild and register that package with:
```powershell
npm run runewaker:build:all -- --only sand-vortex
npm run runewaker:register:all
```
The extraction and terrain GLB stages are also available directly for inspection:
```powershell
node scripts/runewaker-pipeline/sand-vortex/extract-native-terrain.mjs
node scripts/runewaker-pipeline/sand-vortex/build-native-terrain.mjs
```
`src/scene/runewakerTerrainMaterial.ts` owns native splat-layer blending in the
browser. `src/game/runewakerDungeonEnvironment.ts`, applied through
`src/game/dungeonRegistry.ts`, owns the runtime lighting profiles; these lights
remain approximations because static imported GLBs contain no authored light
records.
`scripts/playtest-rom-visual-routes.cjs` checks normal offline entry, ordinary
keyboard movement, footing, and Standard/High graphics options using read-only
production telemetry. Pass explicit RoM slugs with `--dungeon=...`, optional
waypoints with `--routes=<json>`, or `--entry-only` for graphics-only captures.
It freezes the production distribution for each run and requires the geometry
loading state to settle before collecting evidence. These checks do not certify
combat, every route, or a full dungeon clear.
Repackage existing converted visuals without rebuilding collision or navigation:
```powershell
node scripts/runewaker-pipeline/reoptimize-environment-visuals.mjs
node scripts/runewaker-pipeline/reoptimize-environment-visuals.mjs --apply-staged
npm run runewaker:register:all
```
The first command stages every configured environment; the second validates all
staged hashes before replacing shipped visuals and their metadata. Forsaken
Abbey's authored visual descriptor must also match its updated import metadata.
For repeatable rendered review, start Vite on port 4207, run
`node scripts/playtest-rom-environments.cjs`, then
`node scripts/runewaker-pipeline/make-environment-review-sheets.mjs`. The preview
uses the shipped GLBs/KTX2, production material preparation and lighting
palettes at entrance, middle/final boss and reverse entrance viewpoints. Captures
and results live in `artifacts/playtest/rom-environment-quality`. This isolated
asset review does not simulate movement, collisions or combat; supplement it
with ordinary in-dungeon checks for runtime changes.
### Configured package workflow
The generalized batch now packages and registers all 30 configured RuneWaker
instances (Forsaken Abbey plus 29 generated definitions). The normal workflow is
fully local and does not use SQL:
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,766 @@
# RoM environment texture and UV quality audit
Generated: 2026-10-06T18:23:31.513Z
Coverage: **30/30 registered RoM environments**; 30 shipped visual GLBs; 1615 embedded textures; 1615 source DDS matches.
The registry scope is the generated RoM definitions plus the explicit Forsaken Abbey definition. The audit reads world visual GLBs from the asset root recorded in the report scope (the shipped root by default) and preserved ROS exporter source DDS/PNG caches. It excludes actor models and untextured collision/navigation GLBs.
UV density is measured from shipped geometry in dungeon meters after node transforms. Texture UVs are transformed through `KHR_texture_transform` if present. Face classes use geometric normals: floor `normal.y ≥ 0.7`, wall `|normal.y| ≤ 0.3`; ceilings, slopes and other faces are reported separately. Triangles below 0.02 m² are excluded from hotspot calculations to keep thin trim and decorative fragments from dominating. Broad hotspot candidates aggregate at least 10 m² inside 20 m spatial cells.
Per-triangle texel density is `sqrt(texture-space area / world-space area)` in source pixels per meter. Anisotropy is the ratio of the largest to smallest singular value of the local UV-to-world mapping. Zero UV area is counted separately; a smallest singular value at or below 0.01 px/m is counted as rank-collapsed. GPU instance translation, rotation, and scale are expanded for these calculations. The compact JSON retains aggregate metrics and selected floor/wall hotspots rather than every spatial group.
## Per-dungeon coverage
| Dungeon | Audit | Images | DDS linked | Downsized | 2048 cap | Median codec PSNR (dB) | Floor density (px/m) | Wall density (px/m) | Embedded lights |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| aeternal-circle | audited | 18 | 18/18 | 0 | 0 | — | 25.5 | 19.9 | 0 |
| barren-caves | audited | 8 | 8/8 | 0 | 0 | — | 79.5 | 147.5 | 0 |
| cave-water-dragon | audited | 24 | 24/24 | 0 | 0 | — | 16.5 | 15.9 | 0 |
| demon-stronghold | audited | 56 | 56/56 | 0 | 0 | — | 5.1 | 8.3 | 0 |
| demon-stronghold-125 | audited | 56 | 56/56 | 0 | 0 | — | 4.8 | 8.3 | 0 |
| dungeon-of-dalanis | audited | 34 | 34/34 | 0 | 0 | — | 26.4 | 33.1 | 0 |
| forsaken-abbey | audited | 37 | 37/37 | 0 | 0 | — | 55.0 | 52.8 | 0 |
| grafu-castle | audited | 44 | 44/44 | 0 | 0 | — | 20.1 | 40.0 | 0 |
| hall-of-survivors | audited | 33 | 33/33 | 0 | 0 | — | 16.5 | 18.4 | 0 |
| hoto | audited | 17 | 17/17 | 0 | 0 | — | 16.9 | 25.6 | 0 |
| ice-dwarf-kingdom | audited | 23 | 23/23 | 0 | 0 | — | 33.8 | 37.6 | 0 |
| kal-turok-hive | audited | 10 | 10/10 | 0 | 0 | — | 40.7 | 39.0 | 0 |
| kalins-shrine | audited | 17 | 17/17 | 0 | 0 | — | 51.1 | 49.5 | 0 |
| kawaks-tomb | audited | 41 | 41/41 | 0 | 0 | — | 17.9 | 26.5 | 0 |
| kulech-bones-nest | audited | 38 | 38/38 | 0 | 0 | — | 51.1 | 50.8 | 0 |
| lair-demon-dragon | audited | 17 | 17/17 | 0 | 0 | — | 12.8 | 15.1 | 0 |
| mystic-altar | audited | 13 | 13/13 | 0 | 0 | — | 22.2 | 31.1 | 0 |
| paspers-shrine | audited | 12 | 12/12 | 0 | 0 | — | 17.7 | 36.4 | 0 |
| raksha-temple | audited | 114 | 114/114 | 0 | 0 | — | 15.3 | 21.4 | 0 |
| sand-vortex | audited | 33 | 33/33 | 0 | 0 | — | 115.9 | 54.8 | 0 |
| sardo-castle | audited | 35 | 35/35 | 0 | 0 | — | 1.1 | 36.4 | 0 |
| the-origin | audited | 572 | 572/572 | 0 | 0 | — | 22.1 | 28.1 | 0 |
| tomb-seven-heroes | audited | 62 | 62/62 | 0 | 0 | — | 4.2 | 29.3 | 0 |
| treasure-trove | audited | 149 | 149/149 | 0 | 0 | — | 17.3 | 13.3 | 0 |
| warnorken-arena | audited | 10 | 10/10 | 0 | 0 | — | 14.9 | 22.1 | 0 |
| ystra-labyrinth-boss1 | audited | 18 | 18/18 | 0 | 0 | — | 44.1 | 45.8 | 0 |
| ystra-labyrinth-boss2 | audited | 18 | 18/18 | 0 | 0 | — | 44.1 | 45.8 | 0 |
| ystra-labyrinth-boss3 | audited | 18 | 18/18 | 0 | 0 | — | 44.1 | 45.8 | 0 |
| zurhidon-stronghold | audited | 44 | 44/44 | 0 | 0 | — | 19.3 | 22.3 | 0 |
| zurhidon-stronghold-124 | audited | 44 | 44/44 | 0 | 0 | — | 19.3 | 22.3 | 0 |
`Downsized` compares the shipped KTX2 level 0 dimensions with the matching source DDS. `2048 cap` identifies source textures whose longer side exceeds 2048 and shipped longer side is exactly 2048. Floor and wall density are area-weighted geometric means over mapped faces at or above the triangle cutoff.
## UV-collapse comparison against the pre-reoptimization shipment
Compares the corrected baseline snapshot (30 comparable of 30 baseline dungeons) with the current shipped artifacts using the same expanded geometry/UV metric and area thresholds. All registered baseline dungeons are comparable. Positive reduction means fewer mapped face square meters have a collapsed direction in the current shipment; any remaining degenerate area still needs visual/source review and is not counted as automatically acceptable. The comparable rank-collapse change is 433300.7 → 9675.5 m² on floors and 679360.2 → 3694.4 m² on walls.
| Dungeon | Rank-collapsed floor before → after (reduction %) | Rank-collapsed wall before → after (reduction %) | Zero-UV floor before → after m² | Zero-UV wall before → after m² |
| --- | ---: | ---: | ---: | ---: |
| aeternal-circle | 3753.1 → 0.0 (100.0%) | 8018.1 → 0.0 (100.0%) | 3753.1 → 0.0 | 8018.1 → 0.0 |
| barren-caves | 2.7 → 0.0 (100.0%) | 33.4 → 0.0 (100.0%) | 2.5 → 0.0 | 33.4 → 0.0 |
| cave-water-dragon | 6978.2 → 1564.4 (77.6%) | 12722.1 → 16.2 (99.9%) | 6802.9 → 1564.4 | 12710.7 → 13.0 |
| demon-stronghold | 43253.7 → 1086.1 (97.5%) | 56177.7 → 681.9 (98.8%) | 28954.1 → 1086.1 | 56097.9 → 602.5 |
| demon-stronghold-125 | 43253.7 → 1086.1 (97.5%) | 56177.7 → 681.9 (98.8%) | 28954.1 → 1086.1 | 56097.9 → 602.5 |
| dungeon-of-dalanis | 7316.8 → 0.0 (100.0%) | 25192.7 → 0.1 (100.0%) | 7050.7 → 0.0 | 23259.2 → 0.1 |
| forsaken-abbey | 4172.3 → 20.3 (99.5%) | 4830.9 → 27.3 (99.4%) | 4172.3 → 20.3 | 4828.7 → 27.3 |
| grafu-castle | 15620.6 → 1374.9 (91.2%) | 40121.1 → 2.5 (100.0%) | 14317.6 → 1374.9 | 38339.9 → 0.0 |
| hall-of-survivors | 66790.1 → 502.1 (99.2%) | 123257.2 → 173.8 (99.9%) | 65129.8 → 502.0 | 119497.9 → 173.6 |
| hoto | 284.6 → 0.0 (100.0%) | 35.2 → 5.1 (85.5%) | 153.1 → 0.0 | 34.8 → 5.1 |
| ice-dwarf-kingdom | 26606.4 → 14.2 (99.9%) | 35872.6 → 23.0 (99.9%) | 20245.1 → 14.2 | 35508.7 → 19.5 |
| kal-turok-hive | 1.5 → 0.0 (100.0%) | 0.0 → 0.0 (—) | 1.5 → 0.0 | 0.0 → 0.0 |
| kalins-shrine | 658.7 → 0.0 (100.0%) | 2235.4 → 0.0 (100.0%) | 658.7 → 0.0 | 2145.6 → 0.0 |
| kawaks-tomb | 49398.3 → 190.6 (99.6%) | 61502.2 → 1168.0 (98.1%) | 46923.8 → 190.6 | 61404.0 → 1143.3 |
| kulech-bones-nest | 651.3 → 7.6 (98.8%) | 1421.3 → 1.7 (99.9%) | 651.3 → 7.6 | 1421.3 → 1.7 |
| lair-demon-dragon | 819.6 → 0.0 (100.0%) | 325.5 → 184.9 (43.2%) | 819.6 → 0.0 | 325.5 → 184.9 |
| mystic-altar | 459.9 → 0.0 (100.0%) | 5524.4 → 285.9 (94.8%) | 338.3 → 0.0 | 5504.2 → 285.9 |
| paspers-shrine | 5672.5 → 0.0 (100.0%) | 6162.3 → 0.0 (100.0%) | 5661.0 → 0.0 | 6162.3 → 0.0 |
| raksha-temple | 37227.0 → 342.7 (99.1%) | 39447.2 → 7.8 (100.0%) | 37226.4 → 342.6 | 39442.0 → 5.9 |
| sand-vortex | 0.0 → 0.0 (—) | 0.0 → 0.1 (—) | 0.0 → 0.0 | 0.0 → 0.1 |
| sardo-castle | 25011.6 → 0.0 (100.0%) | 45517.1 → 11.2 (100.0%) | 23496.9 → 0.0 | 45357.8 → 11.2 |
| the-origin | 29027.9 → 951.7 (96.7%) | 44031.6 → 359.3 (99.2%) | 28703.2 → 951.7 | 43893.6 → 355.8 |
| tomb-seven-heroes | 18540.4 → 11.0 (99.9%) | 75188.6 → 26.4 (100.0%) | 18460.7 → 11.0 | 74763.6 → 26.4 |
| treasure-trove | 12204.9 → 2408.5 (80.3%) | 3645.8 → 27.0 (99.3%) | 11447.2 → 2408.5 | 3066.5 → 27.0 |
| warnorken-arena | 9243.4 → 0.0 (100.0%) | 8650.5 → 0.0 (100.0%) | 9243.4 → 0.0 | 8650.5 → 0.0 |
| ystra-labyrinth-boss1 | 138.3 → 68.1 (50.8%) | 289.9 → 4.0 (98.6%) | 138.3 → 68.1 | 289.9 → 4.0 |
| ystra-labyrinth-boss2 | 38.1 → 22.7 (40.4%) | 62.4 → 1.3 (97.8%) | 38.1 → 22.7 | 62.4 → 1.3 |
| ystra-labyrinth-boss3 | 38.1 → 22.7 (40.4%) | 62.4 → 1.3 (97.8%) | 38.1 → 22.7 | 62.4 → 1.3 |
| zurhidon-stronghold | 13068.4 → 0.8 (100.0%) | 11427.4 → 1.8 (100.0%) | 13064.9 → 0.8 | 11427.3 → 1.6 |
| zurhidon-stronghold-124 | 13068.4 → 0.8 (100.0%) | 11427.4 → 1.8 (100.0%) | 13064.9 → 0.8 | 11427.3 → 1.6 |
## Remaining residuals compared with converted source
The static converted-source comparison covers 30 dungeons. Low-rank UV area (converted source → shipped; shipped minus source) is **9677.511 → 9675.467 m² (-2.044) on floors** and **3702.395 → 3694.431 m² (-7.964) on walls**. The large remaining low-rank areas therefore closely track source mapping; this is source attribution, not a visual-quality pass.
Exact zero-area UV coverage differs by +1493.376 m² on floors and +17.753 m² on walls. Small source UV differences quantizing to exact zero are one possible explanation; per-triangle attribution and visible impact are not proven by these totals.
Among 624 selected broad hotspot groups, 624 match by texture basename and exact 20 m cell, 0 include source-pixel alias support for compatible material alpha modes, and 0 remain unmatched. Exact-name and alias counts can overlap. Pixel-equivalent matches combine coarse surface/cell area and texture aliases; they do not prove triangle-level correspondence. 0 The Origin hotspots remain unmatched.
Source-image aliases can resolve basename-only surface/cell misses when source pixels and material alpha semantics permit it. The selected The Origin waterfall sample was not present in the hotspot set for this run. Whole-dungeon rank deltas are -0.122 m² on floors and +0.056 m² on walls.
Technical audit coverage passed for every registered artifact, but source-level collapsed mappings and unresolved attribution still require visual/source review. The report does not certify every floor or wall as visually green.
## Broad floor and wall mapping hotspots
Rows are the union of the largest rank-collapse/degenerate-UV areas, lowest density groups, and highest p90 anisotropy groups per dungeon, restricted to spatial groups at least 10 m². `cell` is the 20 m world-grid coordinate `[x,y,z]`; bounds are exact current shipped GLB coordinates in meters. `uvDegenerate` reports geometric surface area with zero-area UV triangles; `rank collapsed` reports area with a minimum singular value ≤0.01 px/m. Converted source comparison matches by surface, texture basename, and spatial cell; a positive `post−source rank m²` is a possible newly introduced collapse, subject to the source GLB coverage limit below.
| Dungeon | Face | Material | Texture | Cell | Shipped bounds min → max | Area m² | Mean px/m | P10 px/m | P90 anisotropy | UV-degenerate m² | Rank-collapsed m² | Source match | Source rank m² | Post−source rank m² | Wrap S/T |
| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | --- |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | -1, -2, -1 | [-19.3, -40.2, -24.0] → [3.2, -36.2, -0.8] | 401.9 | 2.2 | 1.3 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | -2, -3, -1 | [-30.5, -42.3, -24.0] → [-19.3, -39.4, -0.8] | 136.6 | 4.7 | 4.4 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -2, -1 | [3.2, -37.0, -24.0] → [14.4, -29.9, -0.8] | 230.6 | 4.8 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | -6, 9, -3 | [-114.6, 191.7, -54.9] → [-105.5, 200.9, -42.1] | 61.8 | 4.9 | 5.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | -1, -2, 0 | [-19.3, -38.5, -0.8] → [3.2, -35.1, 22.4] | 397.3 | 4.9 | 3.7 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -2, 0 | [3.2, -37.2, -0.8] → [14.4, -32.0, 22.4] | 280.1 | 5.0 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 14, -1 | [32.1, 292.8, -23.4] → [38.7, 305.5, -11.9] | 79.4 | 5.0 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 15, 0 | [19.3, 300.7, -1.3] → [34.0, 311.8, 3.1] | 64.2 | 5.0 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 1, 3 | [19.2, 12.5, 52.5] → [33.7, 53.8, 72.4] | 370.1 | 7.1 | 2.2 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -1, 2 | [1.3, -20.1, 39.4] → [16.4, -7.0, 59.5] | 171.7 | 7.5 | 6.2 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, 4, 1 | [-1.8, 74.9, 29.7] → [8.8, 89.1, 37.8] | 81.2 | 10.5 | 4.4 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, 1, 3 | [9.7, 8.9, 45.5] → [25.4, 41.9, 72.9] | 536.9 | 8.2 | 5.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 1, -1 | [29.9, 21.5, -5.8] → [44.6, 29.9, 1.4] | 43.5 | 11.8 | 5.2 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -1, 1 | [7.3, -20.0, 23.9] → [21.6, -11.4, 40.2] | 148.1 | 9.7 | 8.7 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -2, 1 | [-1.5, -44.3, 18.1] → [9.6, -17.5, 44.2] | 718.7 | 9.6 | 8.7 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 8, -1, -2 | [142.8, -10.2, -43.2] → [193.7, 0.7, 13.2] | 1300.5 | 22.4 | 20.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 7, -1, -4 | [114.1, -5.8, -89.7] → [162.2, -0.0, -44.6] | 1078.0 | 22.9 | 20.7 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 7, -1, -1 | [136.0, -11.2, -43.2] → [186.4, 1.1, 13.2] | 1277.6 | 25.4 | 20.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 8, -1, 1 | [140.9, -4.8, 10.9] → [199.8, 0.9, 54.7] | 1526.7 | 28.9 | 20.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 1, -1, -4 | [4.9, -5.7, -81.3] → [50.4, 0.1, -44.0] | 852.0 | 30.2 | 24.7 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 1, -1, -3 | [11.4, -5.7, -81.3] → [50.4, 0.0, -44.0] | 672.4 | 31.7 | 29.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 3, -1, 3 | [39.4, -5.8, 53.3] → [76.6, 0.3, 91.1] | 743.1 | 31.8 | 29.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 2, -1, 3 | [39.4, -5.0, 48.2] → [73.4, 0.7, 83.2] | 642.2 | 33.5 | 29.3 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | -3, 0, 1 | [-41.7, 1.2, 29.1] → [-39.9, 6.6, 37.8] | 43.7 | 126.0 | 117.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | -3, 0, 2 | [-43.8, -0.4, 37.7] → [-39.4, 9.3, 66.6] | 117.5 | 155.3 | 117.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 3, 0, 0 | [65.8, -0.4, -0.4] → [83.3, 4.5, 22.6] | 143.2 | 154.8 | 98.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 9, 0, -2 | [178.2, 2.2, -22.6] → [185.3, 8.3, -17.5] | 25.3 | 117.4 | 117.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 2, 0, 2 | [57.4, 9.0, 43.8] → [60.4, 16.3, 48.0] | 31.9 | 167.3 | 98.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | 4, 0, -2 | [85.6, -1.0, -42.7] → [100.6, 3.3, -19.2] | 41.4 | 155.0 | 98.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | -2, 0, 2 | [-40.4, -0.4, 46.5] → [-26.6, 19.3, 60.5] | 46.3 | 160.2 | 98.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12_repeat4x4_adapted | a6d9c718_wretched_tunnel (1024×512) | -1, 0, 3 | [-22.1, -0.6, 58.9] → [3.5, 2.7, 80.4] | 50.0 | 156.5 | 98.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | -2, 0, -17 | [-42.5, 13.6, -339.7] → [-20.4, 19.0, -320.2] | 374.3 | 5.9 | 3.7 | 3.67 | 39.8 | 39.8 | matched | 39.8 | -0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 8, 0, -13 | [158.5, 8.2, -262.1] → [180.8, 16.1, -241.1] | 406.0 | 5.8 | 4.4 | 3.08 | 38.5 | 38.5 | matched | 38.4 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 8, 1, -25 | [158.5, 23.8, -501.7] → [183.2, 26.8, -483.1] | 323.0 | 7.2 | 6.2 | 3.67 | 38.2 | 38.2 | matched | 38.2 | -0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | -1, -1, -19 | [-21.4, -8.2, -381.9] → [-3.9, -1.9, -357.4] | 458.1 | 6.6 | 5.2 | 2.59 | 37.8 | 37.8 | matched | 37.8 | -0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 12, 0, -24 | [239.4, 1.4, -482.3] → [259.9, 5.7, -473.3] | 166.2 | 5.4 | 4.4 | 3.08 | 37.7 | 37.7 | matched | 37.7 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 9, 1, -15 | [190.7, 35.3, -300.7] → [201.7, 40.6, -279.9] | 140.5 | 6.7 | 4.4 | 5.19 | 37.6 | 37.6 | matched | 37.6 | -0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 12, 0, -25 | [238.6, 1.0, -503.8] → [260.3, 21.0, -477.9] | 456.3 | 5.4 | 4.4 | 3.67 | 37.5 | 37.5 | matched | 37.6 | -0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 3, -1, -16 | [57.3, -18.0, -321.6] → [85.5, -5.1, -304.8] | 460.2 | 5.3 | 2.6 | 3.08 | 37.4 | 37.4 | matched | 37.3 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | -1, 0, -17 | [-20.8, -11.3, -327.6] → [-18.8, 14.8, -320.8] | 83.3 | 3.0 | 3.1 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 10, -3, -25 | [203.9, -47.9, -484.1] → [206.0, -25.8, -476.8] | 73.2 | 3.2 | 3.1 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 10, -2, -25 | [202.7, -47.3, -484.1] → [206.0, -25.8, -478.3] | 71.5 | 3.3 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 4, -1, -16 | [76.9, -16.1, -314.5] → [98.0, -6.3, -306.1] | 123.9 | 3.6 | 2.6 | 2.18 | 7.1 | 7.1 | matched | 7.1 | -0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 3, -1, -17 | [56.8, -18.0, -327.6] → [82.5, -5.8, -317.8] | 291.5 | 3.7 | 2.6 | 2.18 | 7.0 | 7.0 | matched | 7.0 | -0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 12, -2, -24 | [237.7, -47.6, -471.5] → [260.5, -24.1, -467.4] | 1604.7 | 3.8 | 3.1 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 12, -1, -24 | [238.1, -26.1, -474.8] → [260.5, -1.8, -469.8] | 1920.8 | 3.8 | 3.1 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 8, 1, -12 | [166.2, 13.1, -243.1] → [173.8, 35.3, -237.3] | 249.7 | 3.9 | 3.1 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_85 | 8603cf5d_precipice_06 (512×512) | 0, 0, -5 | [-0.1, -1.3, -101.0] → [10.7, 18.4, -79.5] | 161.7 | 27.4 | 12.3 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_31 | 161c1d8f_precipice_04 (1024×1024) | -4, -3, -17 | [-73.1, -46.0, -339.6] → [-55.1, -39.2, -314.1] | 246.1 | 10.5 | 7.3 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_78 | e3e1eed9_precipice_02 (1024×1024) | 6, 1, -10 | [122.5, 24.4, -195.1] → [128.1, 27.6, -185.4] | 42.3 | 14.3 | 8.7 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_31 | 161c1d8f_precipice_04 (1024×1024) | -1, -3, -16 | [-22.0, -46.0, -320.2] → [-10.7, -39.6, -305.4] | 102.8 | 9.1 | 6.2 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_78 | e3e1eed9_precipice_02 (1024×1024) | 14, 0, -24 | [278.0, -5.0, -480.5] → [286.5, 22.0, -463.9] | 350.3 | 11.5 | 7.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_78 | e3e1eed9_precipice_02 (1024×1024) | 14, -1, -24 | [272.7, -21.2, -480.9] → [290.0, 7.9, -465.8] | 356.0 | 10.6 | 7.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_78 | e3e1eed9_precipice_02 (1024×1024) | 14, -2, -24 | [271.0, -41.1, -482.2] → [288.5, -17.8, -463.3] | 382.9 | 10.8 | 7.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_31 | 161c1d8f_precipice_04 (1024×1024) | 14, -2, -20 | [284.8, -32.2, -402.5] → [298.6, -29.6, -379.3] | 26.5 | 13.4 | 10.4 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -19, -1, -9 | [-377.8, -6.2, -178.5] → [-359.9, -4.0, -160.2] | 175.3 | 57.8 | 49.4 | 1.54 | 162.4 | 162.4 | matched | 164.9 | -2.5 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -18, -1, -9 | [-359.9, -6.2, -176.9] → [-343.0, -3.9, -161.7] | 141.6 | 51.8 | 49.4 | 4.36 | 135.2 | 135.2 | matched | 137.1 | -1.9 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 9, 1, -12 | [189.5, 20.7, -228.0] → [197.4, 33.1, -216.4] | 135.7 | 26.4 | 17.4 | 2.59 | 92.3 | 92.3 | matched | 92.2 | 0.1 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 9, 1, -11 | [189.5, 20.7, -219.4] → [197.4, 33.1, -204.7] | 178.9 | 25.6 | 17.4 | 2.59 | 92.3 | 92.3 | matched | 92.2 | 0.1 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -18, -1, -8 | [-359.9, -10.1, -159.8] → [-339.7, -2.9, -140.3] | 217.3 | 32.0 | 24.7 | 1.54 | 80.4 | 80.4 | matched | 81.6 | -1.2 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 6, 1, -13 | [126.9, 20.4, -261.6] → [133.8, 32.8, -250.0] | 119.7 | 29.0 | 20.7 | 2.18 | 80.1 | 80.1 | matched | 80.3 | -0.2 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 0, 0, -13 | [1.1, 1.5, -261.5] → [8.0, 18.8, -249.9] | 123.3 | 30.4 | 20.7 | 2.18 | 80.1 | 80.1 | matched | 80.3 | -0.2 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 6, 1, -14 | [126.9, 20.4, -273.3] → [133.8, 32.8, -258.6] | 159.8 | 27.6 | 20.7 | 2.18 | 80.1 | 80.1 | matched | 80.3 | -0.2 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -13 | [129.3, 67.9, -258.2] → [129.3, 71.9, -252.3] | 23.6 | 0.1 | 0.1 | 60096.78 | 0.0 | 23.6 | matched | 23.4 | 0.2 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -14 | [129.3, 67.9, -270.1] → [129.3, 71.9, -258.2] | 47.3 | 0.1 | 0.1 | 60096.78 | 0.0 | 47.3 | matched | 47.2 | 0.1 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_36 | 9087564d_lava (512×128) | -1, -3, -11 | [-32.8, -42.6, -241.4] → [9.6, -41.7, -185.9] | 995.7 | 0.6 | 0.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_36 | 9087564d_lava (512×128) | -2, -3, -13 | [-39.8, -43.7, -262.2] → [9.6, -41.7, -220.8] | 952.1 | 0.6 | 0.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_36 | 9087564d_lava (512×128) | 2, -21, -14 | [32.6, -435.6, -281.6] → [62.2, -407.3, -267.4] | 456.2 | 0.6 | 0.6 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_36 | 9087564d_lava (512×128) | 5, -3, -9 | [95.6, -43.8, -193.1] → [143.3, -39.0, -160.4] | 741.9 | 0.6 | 0.6 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_36 | 9087564d_lava (512×128) | 2, -22, -14 | [48.1, -435.6, -279.5] → [62.2, -408.1, -271.3] | 224.3 | 0.6 | 0.6 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_36 | 9087564d_lava (512×128) | 4, -3, -9 | [67.3, -41.2, -193.1] → [108.9, -39.0, -155.1] | 716.5 | 0.6 | 0.6 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_28 | ba9b7783_magic_dragon_nest_01 (1024×1024) | 17, 7, -14 | [338.0, 129.5, -271.0] → [353.6, 158.4, -257.5] | 159.0 | 6.7 | 4.4 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_28 | ba9b7783_magic_dragon_nest_01 (1024×1024) | 17, 7, -13 | [333.9, 129.8, -261.3] → [352.7, 158.4, -250.8] | 194.4 | 6.4 | 4.4 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_163 | 5c66da4d_nor_phy_roundwave (512×128) | 2, 1, -12 | [54.6, 24.6, -240.6] → [65.6, 36.6, -231.5] | 122.6 | 1.8 | 1.1 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_163 | 5c66da4d_nor_phy_roundwave (512×128) | 30, 2, -10 | [600.8, 37.4, -199.9] → [612.2, 48.2, -194.9] | 123.4 | 2.3 | 2.2 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_36 | 9087564d_lava (512×128) | 17, -1, -14 | [338.5, -43.6, -270.6] → [350.8, -7.1, -259.8] | 737.0 | 3.8 | 3.1 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_36 | 9087564d_lava (512×128) | 17, -2, -14 | [336.8, -52.7, -271.7] → [352.1, -7.1, -259.8] | 1546.4 | 2.5 | 1.8 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -19, -1, -9 | [-377.8, -6.2, -178.5] → [-359.9, -4.0, -160.2] | 175.3 | 57.8 | 49.4 | 1.54 | 162.4 | 162.4 | matched | 164.9 | -2.5 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -18, -1, -9 | [-359.9, -6.2, -176.9] → [-343.0, -3.9, -161.7] | 141.6 | 51.8 | 49.4 | 4.36 | 135.2 | 135.2 | matched | 137.1 | -1.9 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 9, 1, -12 | [189.5, 20.7, -228.0] → [197.4, 33.1, -216.4] | 135.7 | 26.4 | 17.4 | 2.59 | 92.3 | 92.3 | matched | 92.2 | 0.1 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 9, 1, -11 | [189.5, 20.7, -219.4] → [197.4, 33.1, -204.7] | 178.9 | 25.6 | 17.4 | 2.59 | 92.3 | 92.3 | matched | 92.2 | 0.1 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -18, -1, -8 | [-359.9, -10.1, -159.8] → [-339.7, -2.9, -140.3] | 217.3 | 32.0 | 24.7 | 1.54 | 80.4 | 80.4 | matched | 81.6 | -1.2 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 6, 1, -13 | [126.9, 20.4, -261.6] → [133.8, 32.8, -250.0] | 119.7 | 29.0 | 20.7 | 2.18 | 80.1 | 80.1 | matched | 80.3 | -0.2 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 0, 0, -13 | [1.1, 1.5, -261.5] → [8.0, 18.8, -249.9] | 123.3 | 30.4 | 20.7 | 2.18 | 80.1 | 80.1 | matched | 80.3 | -0.2 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_14 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 6, 1, -14 | [126.9, 20.4, -273.3] → [133.8, 32.8, -258.6] | 159.8 | 27.6 | 20.7 | 2.18 | 80.1 | 80.1 | matched | 80.3 | -0.2 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -13 | [129.3, 67.9, -258.2] → [129.3, 71.9, -252.3] | 23.6 | 0.1 | 0.1 | 60096.78 | 0.0 | 23.6 | matched | 23.4 | 0.2 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -14 | [129.3, 67.9, -270.1] → [129.3, 71.9, -258.2] | 47.3 | 0.1 | 0.1 | 60096.78 | 0.0 | 47.3 | matched | 47.2 | 0.1 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_36 | 9087564d_lava (512×128) | -1, -3, -11 | [-32.8, -42.6, -241.4] → [9.6, -41.7, -185.9] | 995.7 | 0.6 | 0.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_36 | 9087564d_lava (512×128) | -2, -3, -13 | [-39.8, -43.7, -262.2] → [9.6, -41.7, -220.8] | 952.1 | 0.6 | 0.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_36 | 9087564d_lava (512×128) | 2, -21, -14 | [32.6, -435.6, -281.6] → [62.2, -407.3, -267.4] | 456.2 | 0.6 | 0.6 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_36 | 9087564d_lava (512×128) | 5, -3, -9 | [95.6, -43.8, -193.1] → [143.3, -39.0, -160.4] | 741.9 | 0.6 | 0.6 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_36 | 9087564d_lava (512×128) | 2, -22, -14 | [48.1, -435.6, -279.5] → [62.2, -408.1, -271.3] | 224.3 | 0.6 | 0.6 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_36 | 9087564d_lava (512×128) | 4, -3, -9 | [67.3, -41.2, -193.1] → [108.9, -39.0, -155.1] | 716.5 | 0.6 | 0.6 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_28 | ba9b7783_magic_dragon_nest_01 (1024×1024) | 17, 7, -14 | [338.0, 129.5, -271.0] → [353.6, 158.4, -257.5] | 159.0 | 6.7 | 4.4 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_28 | ba9b7783_magic_dragon_nest_01 (1024×1024) | 17, 7, -13 | [333.9, 129.8, -261.3] → [352.7, 158.4, -250.8] | 194.4 | 6.4 | 4.4 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_163 | 5c66da4d_nor_phy_roundwave (512×128) | 2, 1, -12 | [54.6, 24.6, -240.6] → [65.6, 36.6, -231.5] | 122.6 | 1.8 | 1.1 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_163 | 5c66da4d_nor_phy_roundwave (512×128) | 30, 2, -10 | [600.8, 37.4, -199.9] → [612.2, 48.2, -194.9] | 123.4 | 2.3 | 2.2 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_36 | 9087564d_lava (512×128) | 17, -1, -14 | [338.5, -43.6, -270.6] → [350.8, -7.1, -259.8] | 737.0 | 3.8 | 3.1 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_36 | 9087564d_lava (512×128) | 17, -2, -14 | [336.8, -52.7, -271.7] → [352.1, -7.1, -259.8] | 1546.4 | 2.5 | 1.8 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_110 | c18feec2_hand_of_truth_02 (1024×1024) | 2, -1, 4 | [40.5, -3.1, 77.1] → [61.2, -0.0, 99.7] | 440.0 | 49.0 | 41.5 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 4, -1, 2 | [87.6, -3.9, 25.0] → [109.7, -3.9, 85.9] | 670.5 | 3.4 | 3.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 5, -1, 3 | [87.6, -3.9, 25.0] → [109.7, -3.9, 85.9] | 670.5 | 3.4 | 3.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_94 | 398fc819_hand_of_truth_03 (1024×1024) | 3, -1, 3 | [61.1, -4.6, 7.6] → [95.3, -4.6, 105.5] | 1675.4 | 4.3 | 4.4 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_94 | 398fc819_hand_of_truth_03 (1024×1024) | 4, -1, 2 | [61.1, -4.6, 7.6] → [102.8, -0.0, 105.5] | 1841.7 | 5.4 | 4.4 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 1, -1, 1 | [23.1, -1.1, 8.7] → [45.1, -1.1, 27.4] | 206.3 | 6.2 | 6.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 1, -1, 0 | [20.2, -1.9, 3.2] → [45.1, -1.1, 27.4] | 512.7 | 6.8 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 0, -1, -1 | [-5.4, -1.4, -6.9] → [17.4, -0.7, 7.9] | 294.1 | 7.3 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 0, -1, 0 | [-5.4, -1.4, -6.9] → [17.4, -0.7, 7.9] | 294.1 | 7.3 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 20, 3, 8 | [417.6, 59.9, 176.4] → [418.3, 71.6, 176.8] | 16.9 | 88.1 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 20, -2, 8 | [417.6, -39.4, 176.4] → [418.3, -27.8, 176.8] | 16.9 | 88.1 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 17, 3, 9 | [351.5, 59.9, 180.1] → [357.4, 71.6, 183.4] | 19.0 | 88.2 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 17, 3, 5 | [344.4, 59.9, 109.2] → [350.4, 71.6, 112.6] | 19.0 | 88.2 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 17, -2, 5 | [344.4, -39.4, 109.2] → [350.4, -27.8, 112.6] | 19.0 | 88.2 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 17, -2, 9 | [351.5, -39.4, 180.1] → [357.4, -27.8, 183.4] | 19.0 | 88.1 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 20, 3, 5 | [410.6, 59.9, 102.7] → [416.6, 71.6, 106.0] | 19.0 | 88.0 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_9 | 48adbc5a_blood_03 (256×256) | 20, -2, 5 | [410.6, -39.4, 102.7] → [416.6, -27.8, 106.0] | 19.0 | 88.0 | 83.0 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_138 | ab8628d4_monastary_b3_05 (1024×1024) | 14, -1, 3 | [278.9, -20.0, 59.9] → [295.4, -17.3, 76.0] | 157.8 | 127.7 | 98.7 | 1.30 | 6.3 | 6.3 | matched | 6.3 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 14, -1, -4 | [291.0, -19.9, -78.9] → [293.1, -18.9, -62.7] | 20.8 | 72.6 | 69.8 | 1.09 | 2.3 | 2.3 | matched | 2.3 | -0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 16, -1, -4 | [323.8, -19.9, -78.9] → [334.6, -17.2, -59.7] | 61.7 | 68.7 | 58.7 | 1.09 | 2.3 | 2.3 | matched | 2.3 | -0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -3 | [298.3, -19.9, -54.4] → [320.9, -0.1, -36.6] | 312.6 | 23.7 | 8.7 | 2.59 | 2.1 | 2.1 | matched | 2.1 | -0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 14, -1, -3 | [276.2, -19.9, -54.4] → [301.0, -0.1, -36.6] | 355.4 | 23.5 | 10.4 | 2.59 | 2.1 | 2.1 | matched | 2.1 | -0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -3 | [318.3, -19.9, -54.4] → [343.7, -0.1, -36.6] | 385.7 | 23.6 | 12.3 | 2.59 | 2.1 | 2.1 | matched | 2.1 | -0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -2 | [298.3, -19.9, -45.2] → [320.9, -0.1, -19.3] | 460.8 | 25.1 | 12.3 | 2.59 | 2.1 | 2.1 | matched | 2.1 | -0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -2 | [318.3, -19.9, -45.2] → [343.7, -0.1, -19.0] | 534.6 | 24.8 | 12.3 | 2.59 | 2.1 | 2.1 | matched | 2.1 | -0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, 0, -3 | [312.8, -0.4, -48.1] → [346.4, 7.7, -36.6] | 435.4 | 5.4 | 1.5 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, 0, -2 | [312.8, -0.4, -36.6] → [346.4, 7.7, -25.1] | 435.5 | 5.4 | 1.5 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 18, -1, -2 | [375.9, -10.6, -36.6] → [380.3, 4.7, -25.8] | 183.4 | 6.5 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 18, -1, -3 | [375.9, -10.6, -47.4] → [380.3, 4.7, -36.6] | 183.4 | 6.5 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 20, -1, -2 | [398.7, -10.6, -36.6] → [406.0, 4.7, -25.8] | 316.0 | 7.0 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 20, -1, -3 | [398.7, -10.6, -47.4] → [406.0, 4.7, -36.6] | 316.0 | 7.0 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 19, -1, -2 | [380.1, -10.6, -36.6] → [401.6, 4.7, -21.5] | 760.8 | 7.0 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 19, -1, -3 | [380.1, -10.6, -51.7] → [401.6, 4.7, -36.6] | 760.8 | 7.0 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -2 | [279.3, -20.2, -28.9] → [320.9, 2.5, -20.0] | 843.3 | 13.9 | 1.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -3 | [279.3, -20.2, -54.5] → [320.9, 2.5, -44.3] | 932.7 | 14.7 | 1.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -2 | [312.8, -20.2, -28.9] → [346.4, 2.5, -20.0] | 984.6 | 10.0 | 1.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -3 | [312.8, -20.2, -54.5] → [346.4, 2.5, -44.3] | 1135.3 | 11.0 | 1.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 14, -1, -3 | [278.5, -20.2, -54.5] → [312.8, 2.5, -36.6] | 1217.2 | 15.3 | 1.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 14, -1, -2 | [278.5, -20.2, -42.0] → [312.8, 2.5, -19.9] | 1240.2 | 15.6 | 1.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_230 | e7d31840_monastary_b1_03 (1024×1024) | 11, -1, -2 | [235.2, -19.9, -33.2] → [238.3, -18.9, -23.4] | 11.1 | 100.9 | 58.7 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -1 | [320.7, -18.9, -20.0] → [339.0, -10.9, -18.8] | 12.8 | 21.4 | 14.7 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | 3, -1, 5 | [57.0, -4.1, 86.7] → [88.4, -2.8, 131.3] | 2265.4 | 14.7 | 8.7 | 1.83 | 1374.9 | 1374.9 | matched | 1375.5 | -0.6 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -9, -1, -1 | [-180.4, -19.2, -20.9] → [-156.1, -1.8, -2.8] | 583.3 | 51.4 | 41.5 | 1.30 | 0.0 | 1.2 | matched | 1.2 | 0.0 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -1, 2 | [-134.3, -19.6, 37.5] → [-119.3, -2.2, 60.3] | 515.5 | 54.7 | 41.5 | 1.30 | 0.0 | 0.6 | matched | 0.6 | 0.0 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -1, 1 | [-134.3, -19.6, 15.6] → [-119.3, -2.2, 43.0] | 520.5 | 54.6 | 41.5 | 1.30 | 0.0 | 0.6 | matched | 0.6 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | -3, -3, -5 | [-71.9, -40.8, -121.8] → [-28.6, -40.8, -81.1] | 882.2 | 4.4 | 4.4 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | 1, -1, 5 | [22.6, -4.7, 76.2] → [70.2, -4.7, 112.0] | 852.8 | 4.5 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | -4, -3, -5 | [-74.0, -41.4, -124.7] → [-37.5, -41.4, -78.4] | 842.2 | 4.5 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | 6, -2, -7 | [103.2, -31.5, -166.7] → [169.5, -30.2, -119.1] | 2388.4 | 4.5 | 3.7 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | -1, -1, 5 | [-36.3, -3.3, 93.1] → [-7.2, -0.5, 146.6] | 1410.2 | 4.5 | 3.1 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | -3, -3, -6 | [-74.0, -41.4, -124.7] → [-28.6, -40.2, -78.4] | 1760.1 | 4.6 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | -2, -1, 6 | [-38.2, -2.4, 99.6] → [-7.2, -0.5, 146.6] | 870.9 | 5.1 | 3.1 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | 2, -1, 4 | [22.6, -4.7, 72.6] → [91.8, -2.8, 112.7] | 2017.1 | 5.3 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -1, 2 | [-191.7, -18.4, 37.9] → [-190.0, -5.7, 62.7] | 36.8 | 23.6 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -8, -1, -1 | [-163.6, -18.4, -4.5] → [-141.6, -5.7, -2.8] | 36.8 | 23.6 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -1, 0 | [-134.3, -18.4, -0.7] → [-132.5, -5.7, 22.9] | 37.5 | 24.2 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -1, 0 | [-191.7, -18.4, -2.4] → [-190.0, -5.7, 21.4] | 37.6 | 24.1 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -1, 4 | [-193.4, -18.4, 76.4] → [-177.0, -2.8, 84.4] | 40.9 | 49.5 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -1, 1 | [-191.7, -18.4, 19.3] → [-190.0, -5.7, 43.4] | 42.7 | 22.9 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -1, 3 | [-191.7, -18.4, 55.4] → [-190.0, -5.7, 84.4] | 42.9 | 22.9 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -1, 3 | [-134.3, -18.4, 58.9] → [-132.5, -5.7, 82.0] | 43.5 | 23.5 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -7, 0, -7 | [-138.7, -2.8, -146.0] → [-127.5, 20.8, -115.7] | 1231.3 | 19.5 | 17.4 | 1.30 | 59.4 | 59.4 | matched | 59.5 | -0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -6, -1, -10 | [-116.2, -19.7, -201.6] → [-97.0, -17.3, -179.8] | 54.8 | 16.6 | 17.4 | 1.09 | 51.6 | 51.6 | matched | 51.3 | 0.3 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 0, -1, -11 | [-4.2, -19.2, -224.2] → [15.0, -19.2, -197.8] | 77.2 | 11.5 | 12.3 | 1.09 | 51.4 | 51.4 | matched | 51.5 | -0.1 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -7, 0, -8 | [-142.3, -2.8, -151.3] → [-120.4, 20.9, -135.0] | 1073.4 | 19.3 | 17.4 | 1.09 | 40.2 | 40.2 | matched | 40.2 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -6, 0, -10 | [-111.4, 8.9, -198.0] → [-106.0, 8.9, -187.2] | 29.4 | — | — | — | 29.4 | 29.4 | matched | 29.4 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -1, 0, -9 | [-5.6, 8.9, -172.4] → [5.2, 8.9, -167.0] | 29.4 | — | — | — | 29.4 | 29.4 | matched | 29.4 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 1, -1, -14 | [35.1, -19.2, -270.0] → [42.3, -19.2, -262.8] | 25.8 | — | — | — | 25.8 | 25.8 | matched | 25.7 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 0, -1, -14 | [-1.7, -19.2, -274.4] → [15.0, -19.2, -262.8] | 76.9 | 11.6 | 12.3 | 1.09 | 25.8 | 25.8 | matched | 25.7 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | -1, -2, 11 | [-30.7, -32.8, 225.1] → [3.0, -32.8, 245.9] | 193.8 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | 1, -2, 10 | [3.0, -32.8, 204.2] → [36.7, -32.8, 225.1] | 193.9 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | -2, -2, 11 | [-32.4, -32.8, 214.1] → [3.0, -32.8, 236.0] | 387.6 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | 0, -2, 10 | [3.0, -32.8, 189.6] → [31.7, -32.8, 225.1] | 581.3 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | 1, -2, 11 | [3.0, -32.8, 214.1] → [38.4, -32.8, 245.9] | 581.4 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | 0, -2, 12 | [3.0, -32.8, 225.1] → [31.7, -32.8, 260.5] | 581.5 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | -1, -2, 12 | [-25.6, -32.8, 225.1] → [3.0, -32.8, 260.5] | 581.7 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_173 | 50725fe0_hall_of_the_ancients_access_16 (2048×1024) | -1, -2, 10 | [-30.7, -32.8, 189.6] → [3.0, -32.8, 225.1] | 775.5 | 3.1 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | -6, -4, -5 | [-120.5, -81.5, -86.1] → [-119.3, -47.9, -85.6] | 20.9 | 50.0 | 49.4 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_76 | 59a1e954_precipice_01 (2048×2048) | -11, 1, -8 | [-210.4, 15.2, -154.0] → [-201.3, 24.8, -151.4] | 26.3 | 44.3 | 29.3 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | -11, -2, -7 | [-210.4, -54.1, -137.8] → [-209.7, 7.5, -135.4] | 119.7 | 45.9 | 41.5 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | -9, -4, -4 | [-172.2, -81.7, -79.8] → [-171.5, -20.1, -77.5] | 120.9 | 47.1 | 41.5 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | 2, -5, -4 | [50.5, -112.2, -76.8] → [52.8, -50.5, -76.1] | 121.8 | 50.0 | 49.4 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | -6, -3, -5 | [-120.5, -81.5, -86.3] → [-119.1, -39.6, -85.6] | 41.9 | 43.1 | 34.9 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | -8, -5, -4 | [-143.7, -89.7, -71.6] → [-141.4, -81.4, -70.9] | 42.6 | 41.4 | 34.9 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_20 | 967f82d5_magma_01 (512×256) | -7, -4, -5 | [-121.7, -81.5, -86.2] → [-119.7, -47.9, -85.6] | 47.9 | 44.8 | 41.5 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | 4, -1, -3 | [80.4, -20.4, -58.9] → [100.7, 0.5, -44.0] | 486.0 | 22.7 | 20.7 | 1.30 | 3.9 | 3.9 | matched | 3.9 | -0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | 4, 0, -3 | [80.5, -0.6, -61.3] → [101.1, 17.0, -44.0] | 383.9 | 23.9 | 20.7 | 1.54 | 0.8 | 0.8 | matched | 0.8 | -0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | 2, -1, -4 | [40.9, -20.4, -82.6] → [52.2, -0.2, -59.4] | 299.7 | 24.2 | 20.7 | 1.30 | 0.4 | 0.4 | matched | 0.4 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 1, -2, -3 | [34.3, -22.4, -61.3] → [41.1, -21.7, -50.7] | 22.1 | 4.1 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 2, -2, -3 | [39.0, -22.6, -63.4] → [63.8, -21.7, -49.0] | 125.4 | 4.1 | 3.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 2, -2, -7 | [56.8, -22.4, -121.4] → [66.1, -21.8, -118.8] | 12.5 | 4.1 | 4.4 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 3, -2, -3 | [57.0, -22.6, -62.5] → [82.2, -21.1, -44.7] | 296.1 | 4.2 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 2, -2, -6 | [43.8, -22.6, -121.4] → [62.8, -20.9, -100.2] | 208.4 | 4.2 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 1, -2, -4 | [32.9, -22.5, -72.8] → [41.6, -21.7, -55.8] | 76.4 | 4.2 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 4, -2, -6 | [79.0, -22.6, -119.3] → [101.2, -20.7, -95.6] | 302.3 | 4.3 | 4.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 4, -2, -3 | [80.3, -22.6, -64.7] → [101.9, -21.8, -44.7] | 195.2 | 4.3 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | -1, -2, 5 | [-10.3, -24.7, 100.0] → [-1.4, -19.0, 105.3] | 45.5 | 17.4 | 6.2 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_84 | 199e9b4d_light (256×256) | -3, 1, 0 | [-43.3, -4.6, -3.5] → [-36.1, 51.2, 16.7] | 727.0 | 13.4 | 12.3 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | -12, -2, 2 | [-222.7, -31.1, 41.3] → [-215.8, -17.7, 44.5] | 32.6 | 21.6 | 12.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_6 | a3662c66_echoes_of_the_sea_06 (1024×1024) | -3, 2, -1 | [-50.6, 36.8, -17.7] → [-40.5, 51.0, 1.0] | 70.0 | 70.3 | 34.9 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_84 | 199e9b4d_light (256×256) | -1, 1, -1 | [-22.4, 2.8, -24.9] → [2.1, 51.7, 3.8] | 2888.6 | 14.9 | 12.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_84 | 199e9b4d_light (256×256) | -2, 1, 0 | [-43.3, -5.2, -8.3] → [-18.0, 51.7, 24.3] | 6404.7 | 14.7 | 12.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_17 | f693fbc4_rock_pillar_02 (1024×1024) | -3, 2, -1 | [-48.6, 37.4, -12.3] → [-46.3, 42.3, -9.7] | 11.6 | 67.2 | 49.4 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | 3, -1, 0 | [67.1, -22.1, 3.5] → [80.2, 3.9, 21.0] | 398.3 | 20.8 | 12.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_29 | cc9861ff_decoration_01 (1024×512) | 4, 4, -1 | [83.5, 89.5, -20.0] → [95.1, 100.6, -17.4] | 87.6 | 17.6 | 14.7 | 1.30 | 6.8 | 6.8 | matched | 6.7 | 0.1 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_29 | cc9861ff_decoration_01 (1024×512) | -1, 2, -7 | [-6.4, 44.1, -126.4] → [0.7, 56.7, -121.4] | 53.6 | 18.2 | 14.7 | 1.30 | 3.4 | 3.4 | matched | 3.4 | 0.1 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_29 | cc9861ff_decoration_01 (1024×512) | 0, 2, -7 | [-0.6, 44.1, -126.4] → [5.2, 56.2, -121.4] | 50.3 | 18.2 | 14.7 | 1.30 | 3.4 | 3.4 | matched | 3.4 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_15 | a3f967e4_magma_01 (256×256) | 6, 2, 5 | [118.9, 33.5, 99.5] → [139.7, 49.9, 104.6] | 77.8 | 4.6 | 5.2 | 1.30 | 0.4 | 1.1 | matched | 1.1 | -0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_15 | a3f967e4_magma_01 (256×256) | 7, 2, 5 | [139.7, 41.1, 99.7] → [162.2, 49.8, 116.4] | 163.9 | 6.3 | 6.2 | 1.30 | 0.2 | 0.4 | matched | 0.4 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 8, 1, 6 | [168.6, 20.4, 122.6] → [178.4, 59.4, 128.3] | 442.0 | 3.2 | 3.1 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 8, 2, 6 | [168.6, 20.4, 122.6] → [178.4, 59.4, 128.3] | 442.0 | 3.2 | 3.1 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 9, 2, 5 | [181.0, 31.1, 110.3] → [192.4, 101.2, 117.0] | 1538.6 | 4.0 | 3.7 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 9, 3, 5 | [181.0, 31.1, 110.3] → [192.4, 101.2, 117.0] | 854.3 | 4.3 | 3.7 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 6, 2, 6 | [133.9, 36.4, 127.3] → [141.0, 66.2, 128.8] | 387.5 | 4.6 | 4.4 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_15 | a3f967e4_magma_01 (256×256) | 6, 1, 5 | [118.9, 33.5, 101.9] → [122.7, 49.9, 104.6] | 76.2 | 4.8 | 5.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 9, 3, 4 | [187.9, 51.9, 86.9] → [188.6, 69.3, 90.6] | 67.2 | 5.1 | 5.2 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 9, 2, 4 | [187.9, 44.9, 86.9] → [188.6, 69.3, 90.6] | 120.8 | 6.1 | 5.2 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 6, 4, 4 | [133.0, 90.8, 97.1] → [133.6, 100.6, 99.9] | 56.8 | 8.2 | 8.7 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 6, 3, 4 | [133.0, 53.6, 97.1] → [133.6, 90.8, 99.9] | 215.0 | 8.2 | 8.7 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 9, 3, 3 | [179.2, 55.9, 68.1] → [183.0, 79.4, 69.8] | 189.2 | 6.8 | 6.2 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 6, 3, 3 | [130.3, 53.0, 64.0] → [137.4, 83.9, 79.0] | 549.8 | 5.2 | 4.4 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_7 | 9087564d_lava (512×128) | 9, 3, 4 | [187.9, 69.3, 86.9] → [193.7, 74.1, 90.8] | 26.8 | 7.3 | 7.3 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_7 | 9087564d_lava (512×128) | 6, 2, 4 | [133.0, 48.7, 97.1] → [133.6, 53.6, 99.9] | 28.2 | 12.0 | 10.4 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | -1, 1, -5 | [-13.5, 26.4, -95.6] → [4.8, 28.0, -88.9] | 62.5 | 13.9 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | 0, 1, -5 | [-5.1, 23.0, -97.2] → [23.2, 29.1, -88.9] | 155.4 | 14.1 | 10.4 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 1, 1, -4 | [19.4, 25.4, -70.8] → [24.2, 38.3, -60.2] | 44.8 | 15.3 | 12.3 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 1, -3 | [-20.6, 28.5, -54.7] → [-8.3, 38.1, -46.7] | 53.2 | 15.9 | 12.3 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 0, 1, -4 | [17.2, 30.2, -70.5] → [20.3, 38.9, -63.3] | 33.4 | 17.0 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 0, -2 | [-21.0, -0.1, -23.8] → [-9.6, 20.8, -19.6] | 26.4 | 22.9 | 20.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | -1, 0, -5 | [-21.1, 7.6, -91.4] → [1.7, 21.2, -84.1] | 245.3 | 23.7 | 20.7 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | 1, 0, -5 | [17.3, 1.8, -91.4] → [30.8, 21.2, -87.4] | 170.9 | 23.9 | 24.7 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | -1, -2, 0 | [-4.2, -37.5, 0.0] → [0.3, -22.5, 4.4] | 36.3 | 52.0 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | 0, -2, 0 | [-0.0, -37.5, 0.0] → [4.4, -22.5, 4.4] | 37.6 | 51.1 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 0, 1, -5 | [-2.1, 19.9, -91.4] → [22.7, 31.1, -85.8] | 52.9 | 33.8 | 29.3 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | 0, -2, -1 | [0.1, -37.5, -4.3] → [4.4, -22.5, 2.4] | 73.9 | 51.5 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | -1, -2, -1 | [-4.2, -37.5, -4.3] → [0.3, -22.5, 2.4] | 74.7 | 51.3 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 1, -5 | [-17.4, 19.9, -90.9] → [1.0, 29.7, -80.9] | 22.1 | 34.1 | 24.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 0, -5 | [-19.9, 0.4, -90.5] → [-2.3, 20.1, -79.7] | 22.1 | 27.7 | 20.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 0, 0, -5 | [12.7, -0.3, -89.8] → [22.4, 10.3, -83.8] | 37.1 | 29.5 | 24.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -3, 1, -2 | [-60.9, 19.4, -31.2] → [-51.4, 38.5, -21.7] | 753.2 | 6.6 | 6.2 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -3, 0, -2 | [-60.9, 0.3, -31.2] → [-51.4, 19.4, -21.7] | 753.2 | 6.6 | 6.2 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 1, -2 | [-74.2, 20.2, -34.5] → [-57.2, 38.5, -15.0] | 2367.4 | 7.0 | 7.3 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 0, -2 | [-74.2, 1.9, -34.5] → [-57.2, 23.9, -15.0] | 2369.8 | 7.0 | 7.3 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 0, -1 | [-66.9, 9.4, -24.6] → [-57.4, 23.9, -15.0] | 286.9 | 7.6 | 7.3 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -14, -2, -31 | [-278.6, -44.0, -620.7] → [-270.0, -12.6, -598.5] | 1484.7 | 9.4 | 8.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -11, 0, -5 | [-215.6, 4.2, -90.3] → [-199.5, 30.2, -79.6] | 2114.9 | 9.6 | 8.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -10, 0, -5 | [-200.2, 4.2, -90.6] → [-178.2, 30.2, -79.6] | 2941.8 | 9.7 | 8.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -3, 1, -5 | [-51.0, 29.8, -82.9] → [-48.9, 34.8, -76.7] | 12.5 | 36.1 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 1, 0 | [-73.3, 29.6, -2.6] → [-71.2, 34.6, 3.6] | 18.2 | 36.6 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -2, 1, 0 | [-34.9, 29.6, -2.6] → [-32.8, 34.6, 3.6] | 18.4 | 36.5 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -2, 1, -3 | [-30.1, 29.5, -44.1] → [-28.0, 34.5, -37.9] | 21.4 | 36.5 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -11, 0, -8 | [-210.9, 15.3, -152.4] → [-203.2, 21.6, -150.3] | 22.0 | 36.0 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -8, 1, -6 | [-161.1, 29.7, -101.0] → [-149.2, 37.4, -99.5] | 24.0 | 36.9 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -15, -2, -29 | [-301.5, -33.6, -577.0] → [-293.8, -27.3, -574.9] | 24.9 | 36.1 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -5, 1, -5 | [-97.4, 29.8, -82.9] → [-85.6, 34.8, -76.7] | 25.0 | 36.1 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 5, -2, -29 | [101.5, -39.2, -574.4] → [115.9, -15.8, -559.9] | 128.8 | 24.5 | 24.7 | 1.09 | 93.0 | 93.0 | matched | 92.8 | 0.3 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 4, -2, -29 | [87.4, -39.2, -574.4] → [101.5, -15.8, -559.9] | 122.8 | 24.5 | 24.7 | 1.09 | 87.0 | 87.0 | matched | 87.2 | -0.2 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_75 | a645ef8a_lymor grave_05 (1024×1024) | 4, -2, -31 | [77.2, -33.6, -619.6] → [101.5, -14.8, -596.0] | 252.4 | 19.3 | 14.7 | 1.83 | 80.7 | 80.7 | matched | 80.5 | 0.3 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 5, -1, -29 | [112.8, -27.5, -574.4] → [115.9, 7.6, -559.9] | 107.4 | 23.4 | 20.7 | 1.09 | 67.9 | 67.9 | matched | 67.5 | 0.4 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 4, -1, -29 | [87.4, -27.5, -574.4] → [90.1, 7.6, -559.9] | 99.3 | 23.4 | 20.7 | 1.09 | 59.9 | 59.9 | matched | 60.1 | -0.2 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_75 | a645ef8a_lymor grave_05 (1024×1024) | 4, 0, -31 | [77.2, 0.0, -618.7] → [101.5, 3.2, -596.0] | 152.0 | 19.5 | 14.7 | 1.83 | 51.8 | 51.8 | matched | 52.2 | -0.4 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_75 | a645ef8a_lymor grave_05 (1024×1024) | 6, -2, -31 | [121.5, -33.6, -619.6] → [129.6, -14.8, -596.0] | 171.9 | 17.1 | 14.7 | 1.30 | 51.2 | 51.2 | matched | 51.0 | 0.1 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 5, 0, -29 | [101.5, -4.1, -574.4] → [115.9, 7.6, -572.6] | 41.5 | — | — | — | 41.5 | 41.5 | matched | 41.8 | -0.3 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 2, 0, -5 | [36.5, 11.0, -88.1] → [63.7, 11.5, -88.1] | 10.7 | 1.8 | 1.8 | 60096.78 | 0.0 | 10.7 | matched | 10.6 | 0.2 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 0, 0, -5 | [1.1, 11.0, -88.1] → [22.2, 11.5, -88.1] | 11.1 | 1.8 | 1.8 | 60096.78 | 1.9 | 11.1 | matched | 11.0 | 0.2 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_56 | 4a817b06_quicksand_06 (256×64) | 6, 1, -23 | [119.3, 19.4, -449.4] → [125.4, 26.9, -444.2] | 58.2 | 4.3 | 3.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_56 | 4a817b06_quicksand_06 (256×64) | 5, -1, -25 | [108.2, -7.4, -492.0] → [120.1, 2.0, -482.0] | 181.3 | 4.3 | 3.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_56 | 4a817b06_quicksand_06 (256×64) | 1, -2, -22 | [26.2, -23.4, -437.6] → [38.1, -18.9, -431.7] | 71.6 | 4.3 | 4.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_56 | 4a817b06_quicksand_06 (256×64) | 2, 0, -23 | [52.9, -0.7, -445.9] → [56.9, 4.8, -443.9] | 23.8 | 4.4 | 4.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_56 | 4a817b06_quicksand_06 (256×64) | 4, -1, -23 | [78.9, -9.8, -449.1] → [93.5, 2.6, -444.8] | 235.3 | 4.4 | 3.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_56 | 4a817b06_quicksand_06 (256×64) | 2, -1, -22 | [36.6, -21.5, -441.5] → [46.2, -7.5, -435.6] | 111.6 | 4.5 | 4.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_77 | 1493b2f5_damage_01 (512×512) | 2, 0, -18 | [38.4, -2.5, -361.0] → [45.1, 7.0, -352.4] | 59.1 | 88.1 | 41.5 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_120 | e85e3c80_stonecoffin (512×512) | -9, -1, -12 | [-167.6, -3.8, -222.9] → [-165.1, -2.2, -221.7] | 10.7 | 41.6 | 20.7 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_87 | 36115aa0_stonestatue_wall (1024×1024) | 1, -2, -20 | [19.8, -25.9, -383.8] → [26.2, -19.3, -380.2] | 31.7 | 48.5 | 29.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_120 | e85e3c80_stonecoffin (512×512) | -8, -1, -14 | [-141.8, -3.5, -270.0] → [-139.4, -2.1, -259.9] | 35.0 | 42.0 | 24.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_59 | aa0edd48_light (256×256) | 4, 1, -25 | [82.7, 18.6, -487.2] → [103.1, 28.8, -466.7] | 206.7 | 15.0 | 14.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_59 | aa0edd48_light (256×256) | 5, 1, -23 | [98.3, 17.6, -469.9] → [122.8, 29.7, -446.5] | 420.8 | 16.5 | 14.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | -4, 1, 6 | [-80.6, 19.4, 119.4] → [-59.6, 31.6, 136.3] | 47.7 | 44.1 | 29.3 | 3.67 | 1.1 | 1.1 | matched | 1.1 | 0.0 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | -5, 1, -3 | [-89.0, 20.6, -60.7] → [-79.4, 38.2, -39.4] | 89.5 | 56.7 | 41.5 | 2.59 | 1.1 | 1.1 | matched | 1.0 | 0.1 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_22 | cd2d62d5_nest_cliff_f (1024×1024) | 6, 2, 3 | [125.3, 37.1, 69.2] → [128.8, 58.6, 81.4] | 161.2 | 27.5 | 24.7 | 1.83 | 0.9 | 0.9 | matched | 0.9 | -0.0 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | 0, 1, -7 | [-0.4, 21.1, -140.8] → [19.1, 34.3, -119.5] | 107.3 | 60.4 | 34.9 | 2.59 | 0.9 | 0.9 | matched | 0.8 | 0.1 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | -3, 1, 6 | [-60.5, 20.2, 127.7] → [-42.4, 27.2, 135.1] | 17.9 | 50.1 | 34.9 | 2.59 | 0.8 | 0.8 | matched | 0.8 | 0.0 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | 0, 1, -6 | [0.2, 21.1, -120.9] → [20.1, 38.7, -99.4] | 193.7 | 67.7 | 41.5 | 3.08 | 0.7 | 0.7 | matched | 0.9 | -0.2 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | -5, 1, -2 | [-89.2, 21.5, -40.6] → [-79.5, 36.2, -25.5] | 44.1 | 70.0 | 41.5 | 4.36 | 0.7 | 0.7 | matched | 0.7 | -0.0 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_106 | 7fc14bce_nest_stalactite_a (1024×1024) | 0, 1, -5 | [11.8, 21.3, -100.7] → [20.2, 31.2, -96.9] | 11.8 | 46.1 | 29.3 | 3.67 | 0.7 | 0.7 | matched | 0.7 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 4, 3, -7 | [87.3, 59.3, -137.6] → [100.4, 67.3, -131.0] | 328.5 | 13.7 | 10.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 1, -8 | [99.3, 16.7, -146.7] → [112.4, 41.0, -141.7] | 1546.6 | 13.8 | 10.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 2, -8 | [98.7, 39.5, -148.1] → [112.4, 55.3, -141.7] | 605.5 | 13.9 | 12.3 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_22 | cd2d62d5_nest_cliff_f (1024×1024) | 4, 3, 2 | [89.3, 56.2, 46.1] → [94.5, 65.8, 52.3] | 33.4 | 15.0 | 14.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 2, -7 | [99.3, 33.9, -141.0] → [101.2, 59.3, -136.4] | 93.3 | 16.4 | 12.3 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 4, 2, -7 | [85.5, 33.9, -140.8] → [101.0, 60.7, -131.4] | 1208.5 | 16.4 | 10.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 3, -7 | [99.7, 59.3, -138.4] → [101.8, 64.8, -135.6] | 44.0 | 16.4 | 12.3 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 4, 1, -8 | [91.6, 16.7, -145.4] → [102.2, 43.7, -138.8] | 517.2 | 17.0 | 12.3 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | 0, 1, -7 | [15.5, 22.5, -130.9] → [17.1, 55.5, -130.6] | 28.7 | 41.0 | 41.5 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | 0, 2, -7 | [15.5, 22.5, -130.9] → [17.1, 60.3, -130.6] | 37.0 | 46.0 | 41.5 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | -4, 1, -3 | [-72.7, 21.6, -56.5] → [-70.6, 59.0, -40.9] | 49.6 | 40.5 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | -4, 2, -3 | [-72.7, 21.6, -56.5] → [-70.6, 60.4, -40.9] | 53.7 | 42.5 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | -3, 1, 5 | [-46.5, 15.3, 106.6] → [-44.0, 48.3, 119.3] | 88.9 | 42.0 | 34.9 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | 0, 1, -6 | [17.2, 22.6, -115.4] → [17.6, 55.5, -115.2] | 15.4 | 43.3 | 41.5 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_8 | 5c5559f8_soul (512×64) | 0, 2, -6 | [17.2, 22.6, -115.4] → [17.6, 60.3, -115.2] | 19.8 | 47.2 | 41.5 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_102 | bb520f70_nor_phy_column (256×256) | 2, 2, -3 | [40.3, 42.8, -46.4] → [41.0, 47.2, -45.6] | 15.7 | 64.5 | 58.7 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_34 | 1622437c_magic_dragon_nest_08 (1024×1024) | 0, -8, -2 | [-0.5, -142.8, -41.4] → [18.5, -141.7, -19.7] | 46.3 | — | — | — | 46.3 | 46.3 | matched | 46.4 | -0.1 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_34 | 1622437c_magic_dragon_nest_08 (1024×1024) | -1, -8, -2 | [-19.4, -142.8, -41.4] → [-0.5, -141.7, -19.7] | 46.3 | — | — | — | 46.3 | 46.3 | matched | 46.4 | -0.1 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_34 | 1622437c_magic_dragon_nest_08 (1024×1024) | 0, -8, -3 | [-0.5, -142.8, -57.4] → [18.5, -141.7, -38.4] | 43.9 | — | — | — | 43.9 | 43.9 | matched | 44.0 | -0.1 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_34 | 1622437c_magic_dragon_nest_08 (1024×1024) | -1, -8, -3 | [-19.4, -142.8, -57.4] → [-0.5, -141.7, -38.4] | 43.9 | — | — | — | 43.9 | 43.9 | matched | 44.0 | -0.1 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | 0, 3, -9 | [3.2, 67.5, -174.3] → [11.0, 68.1, -162.8] | 74.0 | 1.0 | 0.9 | 469.51 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 3, -8 | [-56.0, 67.5, -151.8] → [-39.4, 67.7, -136.1] | 122.7 | 2.4 | 2.2 | 34.90 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 0, -7 | [-49.3, -19.0, -139.9] → [-39.8, 67.7, -136.1] | 924.7 | 2.7 | 2.6 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 1, -7 | [-49.3, -19.0, -139.9] → [-39.8, 67.7, -136.1] | 917.1 | 2.8 | 2.6 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_14 | 6fbe9046_magic_magma (256×256) | 3, -7, -2 | [47.0, -136.1, -77.8] → [100.0, -136.1, 29.3] | 2656.2 | 3.1 | 3.1 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 3, -7 | [-53.5, 67.5, -146.0] → [-46.8, 67.7, -136.1] | 10.6 | 3.2 | 3.1 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | 4, -5, -3 | [81.2, -113.0, -42.9] → [84.3, -30.3, -38.2] | 484.8 | 3.2 | 3.1 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | 4, -3, -2 | [81.1, -113.0, -41.4] → [82.5, -30.3, -33.7] | 698.5 | 3.3 | 3.1 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_31 | fce3ab65_magic_dragon_nest_07 (1024×1024) | 1, -3, -6 | [25.3, -55.9, -122.4] → [40.9, -39.6, -97.2] | 94.5 | 56.9 | 34.9 | 394.81 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | 0, 7, -3 | [-0.7, 121.7, -57.5] → [21.0, 195.8, -36.7] | 929.2 | 19.0 | 14.7 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | 0, 6, -3 | [-1.1, 109.4, -57.5] → [21.0, 168.3, -40.8] | 1028.6 | 16.8 | 10.4 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | -2, 6, -2 | [-34.1, 97.0, -44.6] → [-15.8, 147.2, -19.7] | 1743.8 | 11.9 | 6.2 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_31 | fce3ab65_magic_dragon_nest_07 (1024×1024) | 1, -3, -7 | [33.8, -55.0, -124.4] → [36.5, -52.4, -113.8] | 21.4 | 181.1 | 29.3 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | 0, 8, -3 | [-0.7, 129.0, -57.5] → [19.2, 209.6, -37.7] | 1340.5 | 14.5 | 10.4 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_31 | fce3ab65_magic_dragon_nest_07 (1024×1024) | -3, 0, 0 | [-57.1, 1.2, -0.9] → [-40.5, 5.7, 16.6] | 48.4 | 12.8 | 5.2 | 34.90 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -8, -1, 12 | [-167.9, -1.2, 239.6] → [-140.7, -0.8, 256.9] | 12.5 | — | — | — | 12.5 | 12.5 | matched | 12.7 | -0.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -8, -1, 11 | [-167.9, -1.2, 224.5] → [-140.7, -0.8, 239.6] | 11.7 | — | — | — | 11.7 | 11.7 | matched | 12.0 | -0.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -21, -1, 16 | [-416.1, -18.0, 322.1] → [-402.6, -17.7, 339.4] | 10.2 | — | — | — | 10.2 | 10.2 | matched | 10.4 | -0.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -23, -1, 16 | [-456.9, -18.0, 322.1] → [-443.3, -17.7, 339.4] | 10.2 | — | — | — | 10.2 | 10.2 | matched | 10.4 | -0.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -10, -1, 12 | [-195.0, -1.2, 239.6] → [-181.4, -0.8, 256.9] | 10.2 | — | — | — | 10.2 | 10.2 | matched | 10.4 | -0.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -2, -1, 20 | [-34.5, -1.2, 410.0] → [-17.3, -0.8, 423.6] | 10.2 | — | — | — | 10.2 | 10.2 | matched | 10.4 | -0.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -16, -2, -1 | [-324.9, -27.6, -21.9] → [-298.9, -20.5, 0.6] | 130.4 | 76.2 | 58.7 | 1.30 | 6.1 | 6.1 | matched | 6.6 | -0.5 | repeat/repeat |
| mystic-altar | wall | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -16, -2, -2 | [-324.9, -27.6, -35.0] → [-298.8, -20.5, -17.5] | 95.9 | 73.4 | 58.7 | 1.30 | 3.6 | 3.6 | matched | 4.0 | -0.3 | repeat/repeat |
| mystic-altar | floor | runewaker_material_236 | caa8475c_mystic_altar_floor (512×512) | -4, -1, 10 | [-67.4, -13.7, 199.5] → [-51.2, -12.7, 215.5] | 130.5 | 6.5 | 6.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -17, -1, 3 | [-326.9, -17.9, 66.3] → [-306.8, -11.6, 86.9] | 214.8 | 7.3 | 7.3 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -15, -1, 3 | [-306.8, -17.9, 66.3] → [-286.7, -13.0, 86.9] | 211.4 | 7.3 | 7.3 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_236 | caa8475c_mystic_altar_floor (512×512) | -3, -1, 10 | [-67.4, -13.8, 199.7] → [-34.9, -7.3, 215.6] | 449.8 | 7.5 | 6.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -12, -1, -2 | [-236.4, -19.6, -40.8] → [-215.0, -13.5, -17.4] | 522.3 | 8.1 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -15, -1, -6 | [-306.8, -15.5, -115.8] → [-287.2, -12.9, -104.3] | 115.2 | 9.9 | 10.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -12, -1, -1 | [-242.9, -19.6, -23.7] → [-215.0, -13.5, 5.9] | 643.1 | 9.9 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_236 | caa8475c_mystic_altar_floor (512×512) | -17, -1, 17 | [-341.8, -12.5, 338.4] → [-310.8, -7.7, 364.9] | 904.6 | 10.0 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -19, -1, 10 | [-363.8, -3.8, 200.9] → [-359.2, -0.2, 205.1] | 29.5 | 27.2 | 24.7 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -5, -1, 13 | [-92.3, -8.9, 259.5] → [-85.6, 0.3, 262.8] | 44.1 | 37.6 | 24.7 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -5, -1, 12 | [-91.8, -9.2, 248.3] → [-85.1, -0.3, 260.3] | 78.5 | 39.4 | 29.3 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -21, -1, 10 | [-405.6, -20.5, 202.8] → [-399.3, -11.1, 209.4] | 90.1 | 39.0 | 24.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -1, -1, 12 | [-12.6, -10.2, 254.8] → [-5.8, -0.7, 260.3] | 91.8 | 39.5 | 24.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -19, -1, 11 | [-379.8, -18.2, 222.2] → [-361.9, -8.7, 240.3] | 140.1 | 39.8 | 24.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -23, -1, 10 | [-457.0, -19.6, 216.1] → [-452.6, -14.0, 220.4] | 23.1 | 50.8 | 34.9 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_12 | 06e0fe3e_rock_01 (256×512) | -23, -1, 12 | [-457.7, -18.9, 251.7] → [-452.9, -13.2, 255.7] | 26.5 | 48.9 | 34.9 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -4, -1, -8 | [-118.0, -0.1, -164.2] → [-32.3, -0.1, -128.4] | 1536.3 | 4.3 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -5, -1, -8 | [-118.0, -0.1, -164.2] → [-32.3, -0.1, -128.4] | 1536.3 | 4.3 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -4, -1, -10 | [-118.0, -0.1, -195.2] → [-32.3, -0.1, -164.2] | 1329.1 | 4.6 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -5, -1, -9 | [-118.0, -0.1, -195.2] → [-32.3, -0.1, -164.2] | 1329.1 | 4.6 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | 3, -1, -12 | [61.8, -0.2, -256.0] → [88.1, -0.2, -157.8] | 1293.6 | 4.7 | 4.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | 3, -1, -10 | [61.8, -0.2, -256.0] → [88.1, -0.2, -157.8] | 1293.6 | 4.7 | 4.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_4 | a42613da_light_02 (128×128) | 10, 1, -15 | [181.5, -4.0, -294.7] → [237.0, 79.3, -278.8] | 1750.5 | 5.2 | 5.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_4 | a42613da_light_02 (128×128) | 10, 2, -15 | [182.3, -0.3, -294.7] → [237.0, 74.5, -283.9] | 873.8 | 5.2 | 5.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -4, -1, -5 | [-87.1, -0.1, -112.8] → [-71.9, -0.1, -28.0] | 647.2 | 6.1 | 6.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -5, -1, -5 | [-102.6, -0.1, -112.8] → [-87.1, -0.1, -28.0] | 656.5 | 6.0 | 6.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_4 | a42613da_light_02 (128×128) | 9, 2, -15 | [181.5, -4.0, -292.8] → [231.6, 79.3, -278.8] | 871.5 | 5.2 | 5.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -5, -1, -3 | [-102.6, -0.1, -112.8] → [-71.9, -0.1, -28.0] | 1303.7 | 6.0 | 6.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -1, 0, -4 | [-43.0, 0.0, -80.1] → [20.0, 3.4, -55.8] | 897.1 | 14.5 | 6.2 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -2, 0, -4 | [-43.0, 0.0, -93.8] → [20.0, 3.4, -55.8] | 1562.6 | 9.5 | 5.2 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -15, 5, 16 | [-300.0, 111.4, 318.3] → [-278.3, 114.1, 343.3] | 379.4 | 7.8 | 6.2 | 2.18 | 43.6 | 43.6 | matched | 43.6 | -0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -17, 4, 15 | [-339.8, 80.5, 303.1] → [-318.6, 82.6, 321.2] | 183.7 | 8.0 | 6.2 | 2.18 | 30.4 | 30.4 | matched | 30.5 | -0.1 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -15, 4, 14 | [-301.0, 80.6, 279.7] → [-278.5, 83.5, 300.2] | 317.4 | 10.8 | 7.3 | 2.59 | 30.2 | 30.2 | matched | 30.2 | 0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -13, 5, 15 | [-255.4, 111.8, 298.1] → [-239.6, 114.1, 317.7] | 262.8 | 6.3 | 5.2 | 2.59 | 25.9 | 25.9 | matched | 25.9 | 0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -17, 5, 17 | [-331.9, 111.4, 350.0] → [-317.7, 113.1, 363.5] | 72.8 | 6.0 | 6.2 | 1.83 | 25.3 | 25.3 | matched | 25.3 | 0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -18, 4, 16 | [-351.6, 80.5, 321.0] → [-338.9, 84.1, 335.9] | 214.2 | 8.2 | 7.3 | 1.54 | 20.6 | 20.6 | matched | 20.6 | -0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -14, 4, 13 | [-278.1, 80.0, 259.9] → [-262.0, 81.9, 280.5] | 193.5 | 7.7 | 6.2 | 1.83 | 18.8 | 18.8 | matched | 18.8 | -0.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -16, 4, 15 | [-320.9, 80.6, 299.3] → [-305.1, 84.0, 316.2] | 217.0 | 8.8 | 7.3 | 1.83 | 17.1 | 17.1 | matched | 17.2 | -0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -16, 4, 17 | [-321.5, 70.0, 342.4] → [-318.0, 107.6, 346.9] | 98.2 | 3.9 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -18, 0, 16 | [-354.4, 1.3, 317.4] → [-342.7, 20.3, 325.9] | 564.2 | 4.4 | 3.7 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -13, 3, 15 | [-259.9, 70.1, 299.1] → [-253.7, 89.0, 309.4] | 348.8 | 4.6 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -16, 3, 16 | [-302.9, 68.3, 326.1] → [-298.8, 88.9, 334.8] | 170.1 | 4.6 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -16, 4, 16 | [-302.9, 69.4, 329.7] → [-299.1, 107.7, 335.9] | 205.1 | 4.7 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -14, 0, 13 | [-281.5, 1.4, 266.5] → [-277.1, 20.3, 274.7] | 411.4 | 4.7 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -16, 5, 17 | [-321.5, 88.3, 342.4] → [-315.4, 115.1, 350.2] | 202.2 | 4.8 | 3.7 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 32435b59_gate_waterfall (256×256) | -17, 0, 15 | [-341.5, 1.3, 299.2] → [-323.2, 27.2, 317.8] | 1072.5 | 4.8 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 04325838_miraso_tree_bead (512×512) | -7, 8, 26 | [-142.1, 146.1, 516.3] → [-125.6, 196.1, 538.3] | 1758.7 | 7.8 | 1.8 | 139.58 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 69e7ef41_miraso_tree_bead_b (512×512) | -26, 7, 15 | [-511.3, 124.8, 297.3] → [-505.0, 168.3, 327.4] | 1836.6 | 6.3 | 1.5 | 139.58 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 04325838_miraso_tree_bead (512×512) | 10, 9, 1 | [198.2, 155.5, 23.8] → [221.2, 211.5, 41.6] | 2583.0 | 6.7 | 1.5 | 139.58 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 68c1b7ed_miraso_giant_tree_02_b (2048×2048) | -25, 12, 17 | [-497.4, 234.7, 338.5] → [-486.2, 254.3, 343.6] | 80.9 | 22.2 | 20.7 | 58.69 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | e3b60bec_miraso_giant_tree_02 (2048×2048) | -19, 13, -25 | [-368.5, 245.7, -497.0] → [-363.7, 269.0, -492.0] | 45.4 | 51.8 | 41.5 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | e3b60bec_miraso_giant_tree_02 (2048×2048) | -9, 13, 23 | [-162.7, 256.8, 465.5] → [-159.0, 278.3, 469.8] | 84.3 | 33.1 | 29.3 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 68c1b7ed_miraso_giant_tree_02_b (2048×2048) | -29, 13, 14 | [-581.3, 251.5, 285.4] → [-575.8, 276.1, 290.7] | 191.4 | 30.4 | 20.7 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | e3b60bec_miraso_giant_tree_02 (2048×2048) | 13, 15, 0 | [271.0, 294.2, 7.5] → [276.1, 319.3, 12.8] | 242.4 | 29.2 | 20.7 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | runewaker_material_1 | 577e6253_cactus_03 (256×256) | -1, 0, 1 | [-11.5, 0.5, 24.9] → [-10.0, 2.8, 26.3] | 10.7 | 243.1 | 234.8 | 4.36 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| sand-vortex | wall | runewaker_material_1 | 2423759f_rock02 (512×512) | 3, 0, 1 | [59.9, -0.6, 19.3] → [68.8, 4.8, 23.4] | 28.0 | 13.0 | 10.4 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | runewaker_material_1 | 2423759f_rock02 (512×512) | 2, 0, 0 | [56.7, -0.2, 11.1] → [60.2, 3.5, 18.8] | 25.0 | 14.8 | 12.3 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | floor | runewaker_material_1 | 2423759f_rock02 (512×512) | 3, 0, 1 | [60.1, 3.9, 19.3] → [68.6, 6.8, 23.2] | 13.2 | 15.4 | 12.3 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | floor | runewaker_material_1 | 2423759f_rock02 (512×512) | 3, 0, 0 | [59.8, 3.5, 9.9] → [68.6, 7.0, 20.5] | 77.4 | 15.4 | 12.3 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | floor | runewaker_material_1 | 2423759f_rock02 (512×512) | 2, 0, 0 | [57.0, 2.7, 11.5] → [61.0, 5.6, 18.5] | 12.2 | 15.8 | 12.3 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | runewaker_material_1 | 2423759f_rock02 (512×512) | 3, 0, 0 | [59.9, -0.3, 8.4] → [69.2, 5.5, 20.0] | 107.6 | 16.0 | 12.3 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | floor | runewaker_material_1 | 2423759f_rock02 (512×512) | 0, 0, -1 | [-1.4, 0.3, -8.4] → [4.8, 3.3, -2.4] | 16.4 | 23.9 | 17.4 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | runewaker_material_1 | 6f290430_rock03 (512×256) | 1, 0, 2 | [26.1, -0.3, 39.4] → [35.2, 4.6, 44.4] | 23.6 | 26.9 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x14-z16 default layer 1 | a06_dirt02.png (256×256) | -4, 1, 1 | [-80.0, 11.8, 19.5] → [-59.8, 42.1, 40.0] | 285.2 | 38.9 | 24.7 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x14-z14 default layer 1 | a06_dirt02.png (256×256) | 0, 1, 3 | [-0.5, 11.8, 63.8] → [10.0, 31.2, 75.2] | 76.5 | 46.1 | 29.3 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x14-z15 default layer 1 | a06_dirt02.png (256×256) | -1, 0, 4 | [-11.4, 10.7, 79.6] → [-5.0, 23.6, 80.9] | 34.4 | 38.0 | 29.3 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x15-z16 default layer 1 | a06_dirt02.png (256×256) | -3, 0, -1 | [-53.5, 10.6, -20.3] → [-40.3, 33.0, -9.1] | 91.5 | 40.3 | 29.3 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x16-z16 default layer 1 | a06_dirt02.png (256×256) | 1, 0, -4 | [19.8, 10.7, -73.7] → [38.6, 22.9, -59.5] | 124.3 | 46.4 | 29.3 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x14-z14 default layer 1 | a06_dirt02.png (256×256) | 0, 0, 3 | [-0.5, 10.7, 60.5] → [20.4, 24.3, 80.0] | 154.3 | 53.1 | 29.3 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x15-z16 default layer 1 | a06_dirt02.png (256×256) | -3, 1, -1 | [-52.9, 10.9, -20.3] → [-39.6, 37.1, -0.5] | 225.6 | 44.7 | 29.3 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sand-vortex | wall | chunk-x16-z16 default layer 1 | a06_dirt02.png (256×256) | 0, 0, -3 | [14.1, 10.7, -60.1] → [20.1, 25.3, -50.4] | 74.0 | 44.4 | 34.9 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | -9, 1, 2 | [-179.0, 14.6, 38.3] → [-158.2, 39.1, 61.2] | 741.6 | 30.2 | 24.7 | 1.09 | 3.0 | 3.0 | matched | 2.9 | 0.1 | repeat/repeat |
| sardo-castle | wall | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | 0, 0, -1 | [-4.7, -1.7, -25.2] → [8.1, 26.6, -12.6] | 398.6 | 32.8 | 29.3 | 1.09 | 1.5 | 1.5 | matched | 1.6 | -0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | 0, 1, -2 | [-4.7, 16.2, -44.5] → [24.0, 36.8, -18.7] | 669.0 | 31.0 | 29.3 | 1.09 | 1.5 | 1.5 | matched | 1.6 | -0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | 0, 1, -3 | [14.9, 19.3, -57.1] → [20.9, 36.8, -34.2] | 390.7 | 31.6 | 29.3 | 1.09 | 1.3 | 1.3 | matched | 1.4 | -0.1 | repeat/repeat |
| sardo-castle | wall | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | -1, 0, -1 | [-19.5, -1.7, -25.3] → [5.7, 26.6, -9.9] | 629.1 | 32.9 | 29.3 | 1.09 | 1.3 | 1.3 | matched | 1.4 | -0.1 | repeat/repeat |
| sardo-castle | wall | runewaker_material_123 | ab0e9683_sar_in_channel_01 (1024×1024) | 1, -1, -6 | [20.0, -19.7, -124.3] → [34.5, -1.6, -92.5] | 827.8 | 38.0 | 34.9 | 1.09 | 1.1 | 1.1 | matched | 1.1 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_98 | 909eeb6c_sarin_forth_01 (1024×1024) | -3, -2, -6 | [-61.8, -36.6, -123.1] → [-49.2, -25.1, -96.9] | 199.5 | 34.5 | 29.3 | 1.30 | 0.7 | 0.7 | matched | 0.7 | -0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_98 | 909eeb6c_sarin_forth_01 (1024×1024) | -3, -2, -5 | [-56.2, -36.6, -106.2] → [-39.8, -25.1, -79.3] | 230.0 | 36.5 | 29.3 | 1.30 | 0.4 | 0.4 | matched | 0.4 | -0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | -3, -5, -3 | [-197.3, -89.9, -163.1] → [206.6, -89.9, 176.8] | 68634.6 | 0.7 | 0.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | 3, -5, 3 | [-185.6, -89.9, -163.1] → [206.6, -89.9, 176.8] | 66465.8 | 0.7 | 0.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | 3, -5, -3 | [-197.3, -89.9, -163.1] → [206.6, -89.9, 177.0] | 137312.1 | 0.7 | 0.6 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | -3, -5, 3 | [-197.3, -89.9, -163.1] → [206.6, -89.9, 177.0] | 133330.6 | 0.7 | 0.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_91 | 9423f689_sarin_forth_02 (1024×1024) | 3, -5, -3 | [-197.3, -89.9, -163.1] → [206.5, -89.9, 176.8] | 68630.4 | 0.8 | 0.8 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_91 | 9423f689_sarin_forth_02 (1024×1024) | -3, -5, 3 | [-197.3, -89.9, -163.1] → [206.5, -89.9, 176.8] | 66640.6 | 0.8 | 0.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_128 | f9de3397_sarin_first_01 (1024×1024) | 3, -5, -3 | [-197.3, -89.8, -163.1] → [206.5, -89.8, 176.9] | 68643.1 | 0.8 | 0.8 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_128 | f9de3397_sarin_first_01 (1024×1024) | -3, -5, 3 | [-197.3, -89.8, -163.1] → [206.5, -89.8, 176.9] | 66653.0 | 0.8 | 0.8 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_77 | 7be4a5b1_gate_waterfall (256×256) | -3, 0, 2 | [-49.0, 19.5, 41.3] → [-39.9, 19.8, 55.3] | 23.0 | 20.9 | 12.3 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_111 | 61bebf67_sand (1024×128) | -7, -1, -2 | [-138.1, -17.5, -31.5] → [-125.2, 23.8, -22.6] | 74.6 | 58.3 | 49.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_111 | 61bebf67_sand (1024×128) | -7, 0, -2 | [-138.1, -17.5, -31.5] → [-125.2, 23.8, -22.6] | 74.6 | 58.3 | 49.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_77 | 7be4a5b1_gate_waterfall (256×256) | 5, -1, -3 | [118.3, -23.9, -54.2] → [119.3, 1.8, -53.3] | 91.3 | 69.6 | 49.4 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_77 | 7be4a5b1_gate_waterfall (256×256) | 7, -1, -3 | [158.5, -23.9, -54.7] → [159.4, 1.8, -53.8] | 91.3 | 69.6 | 49.4 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_77 | 7be4a5b1_gate_waterfall (256×256) | 7, -1, -5 | [158.0, -23.9, -94.9] → [158.9, 1.8, -94.0] | 91.4 | 69.6 | 49.4 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_77 | 7be4a5b1_gate_waterfall (256×256) | 5, -1, -5 | [117.7, -23.9, -94.6] → [118.6, 1.8, -93.6] | 91.4 | 69.6 | 49.4 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_77 | 7be4a5b1_gate_waterfall (256×256) | 5, -2, -3 | [118.3, -41.0, -54.3] → [119.3, -14.6, -53.3] | 109.6 | 63.3 | 41.5 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | -12, 2, 3 | [-242.2, 56.6, 57.4] → [-219.3, 60.9, 76.9] | 394.3 | 5.8 | 5.2 | 1.83 | 44.4 | 44.4 | matched | 44.4 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | -15, 2, 4 | [-302.8, 56.8, 76.7] → [-280.1, 60.0, 94.5] | 394.5 | 6.6 | 5.2 | 1.83 | 43.9 | 43.9 | matched | 44.0 | -0.1 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | 10, 3, 6 | [210.3, 62.5, 120.3] → [223.6, 65.3, 140.0] | 324.4 | 6.5 | 5.2 | 1.83 | 40.1 | 40.1 | matched | 40.1 | -0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | 10, 3, 9 | [211.4, 62.3, 178.5] → [221.8, 65.1, 198.5] | 298.1 | 5.4 | 5.2 | 2.18 | 39.8 | 39.8 | matched | 39.7 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | -14, 2, 4 | [-281.8, 56.7, 77.3] → [-263.3, 59.4, 86.6] | 116.0 | 6.6 | 5.2 | 3.08 | 36.5 | 36.5 | matched | 36.4 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | -17, 2, 4 | [-342.1, 56.8, 78.6] → [-322.0, 59.5, 102.3] | 367.3 | 5.4 | 4.4 | 2.59 | 34.1 | 34.1 | matched | 34.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | 10, 3, 3 | [212.5, 63.0, 64.0] → [223.0, 65.5, 82.6] | 183.7 | 4.9 | 4.4 | 2.59 | 26.8 | 26.8 | matched | 26.9 | -0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_13 | 32435b59_gate_waterfall (256×256) | 10, 3, 7 | [213.1, 62.3, 139.9] → [219.3, 65.0, 153.3] | 149.9 | 6.8 | 6.2 | 2.59 | 26.5 | 26.5 | matched | 26.4 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 15, 6, 6 | [267.8, 126.7, 119.3] → [327.4, 130.7, 149.6] | 833.9 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 14, 6, 4 | [267.8, 126.7, 80.3] → [303.6, 131.3, 119.3] | 356.6 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 14, 6, 5 | [267.8, 126.7, 84.9] → [312.6, 131.3, 119.3] | 361.0 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 13, 6, 4 | [259.8, 126.7, 80.3] → [287.9, 130.3, 119.3] | 464.6 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 12, 6, 5 | [240.7, 124.5, 87.0] → [267.8, 129.6, 126.4] | 586.7 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 15, 6, 5 | [267.8, 126.7, 96.3] → [327.4, 130.3, 121.9] | 814.5 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 14, 6, 7 | [267.8, 126.7, 119.3] → [319.2, 130.3, 163.0] | 814.6 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 13, 6, 7 | [254.4, 126.7, 119.3] → [292.9, 131.7, 167.7] | 942.7 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x10-z15 default layer 0 | ae07_grass03.png (256×256) | 11, 1, 8 | [231.1, -34.0, 160.2] → [232.7, 66.1, 161.6] | 90.3 | 10.0 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x11-z14 default layer 0 | ae07_grass03.png (256×256) | 11, 1, 9 | [229.3, -34.0, 195.3] → [230.2, 65.9, 199.9] | 277.6 | 10.7 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x10-z14 default layer 0 | ae07_grass03.png (256×256) | 10, -1, 8 | [199.6, -34.0, 160.0] → [220.5, 66.1, 168.7] | 295.6 | 11.5 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x11-z14 default layer 0 | ae07_grass03.png (256×256) | 9, -1, 8 | [182.8, -34.0, 167.9] → [199.9, 65.1, 180.2] | 336.4 | 12.5 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x11-z15 default layer 0 | ae07_grass03.png (256×256) | 12, 1, 8 | [239.7, -34.0, 175.4] → [248.6, 65.9, 180.1] | 345.7 | 12.8 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x14-z12 default layer 0 | ae07_grass03.png (256×256) | 5, 1, 16 | [109.4, -34.0, 330.9] → [111.7, 66.1, 333.1] | 362.7 | 10.0 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x10-z14 default layer 0 | ae07_grass03.png (256×256) | 10, 1, 8 | [199.6, -34.0, 160.1] → [219.7, 66.1, 168.7] | 418.0 | 13.4 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | wall | chunk-x11-z14 default layer 0 | ae07_grass03.png (256×256) | 11, -1, 10 | [228.4, -34.0, 199.8] → [230.1, 65.9, 208.1] | 427.5 | 10.9 | 8.7 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_146 | 16af35fc_sixking_05_body (1024×1024) | -7, 1, 2 | [-134.8, 15.8, 39.6] → [-122.2, 33.0, 56.3] | 287.0 | 32.1 | 24.7 | 2.18 | 4.8 | 4.8 | matched | 4.8 | -0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_11 | 21df9b12_sixking_03_body (1024×512) | -7, 1, 3 | [-124.0, 20.1, 69.4] → [-119.4, 34.3, 75.8] | 49.4 | 20.7 | 14.7 | 1.83 | 3.7 | 3.7 | matched | 3.7 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_143 | c5d91e59_sixking_04_body (1024×512) | -7, 1, 4 | [-133.2, 19.6, 86.1] → [-120.0, 29.7, 100.5] | 112.8 | 19.2 | 12.3 | 1.54 | 2.7 | 2.7 | matched | 2.7 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_140 | ec5b33bf_sixking_02_body (1024×1024) | -10, 0, 3 | [-199.8, 3.3, 61.3] → [-179.6, 20.5, 81.0] | 611.8 | 25.3 | 20.7 | 2.59 | 1.9 | 1.9 | matched | 1.9 | -0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | -3, -1, -13 | [-58.0, -20.8, -261.6] → [-40.2, 6.3, -240.4] | 420.4 | 48.3 | 41.5 | 2.18 | 1.8 | 1.8 | matched | 1.6 | 0.3 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | -5, -1, -14 | [-100.1, -20.6, -275.2] → [-78.0, -6.1, -265.2] | 184.0 | 47.8 | 49.4 | 1.09 | 1.7 | 1.7 | matched | 1.7 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | 0, -2, -12 | [0.9, -28.1, -233.2] → [21.9, -16.0, -220.4] | 110.9 | 55.9 | 41.5 | 2.59 | 1.6 | 1.6 | matched | 1.6 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_141 | ea1cc8ab_sixking_06_body (1024×1024) | -9, 1, 4 | [-180.4, 19.7, 93.5] → [-166.7, 31.5, 101.3] | 99.9 | 42.7 | 29.3 | 1.83 | 1.5 | 1.5 | matched | 1.5 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | 0, -4, 2 | [-93.4, -70.0, -75.3] → [59.0, -70.0, 98.0] | 13212.6 | 1.3 | 1.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | -3, -4, -1 | [-93.6, -70.0, -75.3] → [59.0, -70.0, 98.0] | 13228.6 | 1.7 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_45 | 08f2b62a_sevenhero_smroom_04 (1024×1024) | -3, -4, 2 | [-92.5, -70.0, -77.0] → [58.4, -70.0, 98.8] | 13262.0 | 1.9 | 1.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_45 | 08f2b62a_sevenhero_smroom_04 (1024×1024) | -3, -4, -1 | [-93.7, -70.0, -75.7] → [59.4, -70.0, 98.8] | 13360.6 | 1.9 | 1.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_45 | 08f2b62a_sevenhero_smroom_04 (1024×1024) | 0, -4, 2 | [-93.7, -70.0, -75.7] → [59.4, -70.0, 98.8] | 13360.6 | 1.9 | 1.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | 0, -4, -1 | [-91.3, -70.0, -78.4] → [59.9, -70.0, 97.4] | 26155.4 | 1.9 | 1.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | -3, -4, 1 | [-91.3, -70.0, -78.4] → [59.9, -70.0, 97.4] | 26155.4 | 1.9 | 1.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | 0, -4, -2 | [-94.1, -70.0, -78.7] → [60.5, -70.0, 96.4] | 26843.4 | 2.1 | 2.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_19 | 3b2822e4_gate_waterfall (256×256) | -1, -6, -9 | [-12.9, -125.8, -173.3] → [-5.1, -94.4, -163.1] | 278.3 | 8.5 | 7.3 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_15 | b594055e_room08_02 (1024×1024) | 11, 6, -1 | [232.0, 117.9, -17.7] → [240.7, 123.2, -11.9] | 34.2 | 26.4 | 14.7 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_19 | 3b2822e4_gate_waterfall (256×256) | 11, 6, 1 | [223.5, 110.9, 19.3] → [231.5, 126.7, 22.6] | 58.3 | 7.1 | 5.2 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_19 | 3b2822e4_gate_waterfall (256×256) | -3, -2, -6 | [-54.8, -47.3, -119.9] → [-53.6, -19.7, -117.3] | 78.8 | 22.8 | 17.4 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_40 | be800a2b_break (1024×1024) | 1, -1, -14 | [27.0, -11.5, -277.5] → [41.0, 0.7, -259.7] | 12.8 | 28.7 | 17.4 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_40 | be800a2b_break (1024×1024) | -1, -6, -6 | [-19.1, -133.0, -122.3] → [-8.9, -86.4, -112.5] | 78.8 | 38.9 | 29.3 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_19 | 3b2822e4_gate_waterfall (256×256) | 12, 6, -1 | [238.9, 120.2, -18.1] → [246.8, 126.1, -2.0] | 115.0 | 4.4 | 2.2 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_19 | 3b2822e4_gate_waterfall (256×256) | -1, -1, -10 | [-7.9, -22.2, -184.0] → [-3.6, 6.0, -182.6] | 183.4 | 21.6 | 17.4 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_232 | ba879ae0_treasury_antre_03 (1024×512) | -18, -1, -10 | [-378.0, -11.9, -216.5] → [-328.9, -10.4, -153.3] | 1007.7 | — | — | — | 1007.7 | 1007.7 | matched | 1007.6 | 0.1 | repeat/repeat |
| treasure-trove | floor | runewaker_material_232 | ba879ae0_treasury_antre_03 (1024×512) | -17, -1, -10 | [-357.9, -11.9, -216.5] → [-310.5, -10.4, -153.3] | 915.8 | — | — | — | 915.8 | 915.8 | matched | 915.1 | 0.6 | repeat/repeat |
| treasure-trove | floor | runewaker_material_232 | ba879ae0_treasury_antre_03 (1024×512) | -14, -1, -7 | [-270.2, -9.1, -142.5] → [-251.2, -8.6, -110.4] | 255.7 | — | — | — | 255.7 | 255.7 | matched | 256.1 | -0.4 | repeat/repeat |
| treasure-trove | floor | runewaker_material_232 | ba879ae0_treasury_antre_03 (1024×512) | -13, -1, -7 | [-270.2, -8.8, -140.3] → [-251.2, -8.3, -109.9] | 229.2 | — | — | — | 229.2 | 229.2 | matched | 229.5 | -0.3 | repeat/repeat |
| treasure-trove | wall | runewaker_material_230 | 1ab44406_treasury_antre_01 (1024×1024) | -22, 1, 3 | [-439.3, 16.6, 60.1] → [-426.6, 38.7, 78.4] | 330.6 | 10.8 | 10.4 | 1.09 | 19.2 | 19.2 | matched | 19.2 | -0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_174 | a1a51def_hull (1024×1024) | -28, -1, -4 | [-564.4, -18.5, -85.2] → [-536.6, -1.1, -56.1] | 605.3 | 10.6 | 4.4 | 1.83 | 2.2 | 2.2 | matched | 2.2 | -0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -26, -1, 2 | [-508.5, -18.8, 42.6] → [-485.8, -2.3, 64.3] | 60.3 | 15.9 | 12.3 | 3.08 | 1.4 | 1.4 | matched | 1.5 | -0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -29, -1, 2 | [-582.9, -21.5, 42.2] → [-574.8, -1.7, 66.0] | 426.6 | 13.1 | 12.3 | 1.09 | 1.1 | 1.1 | matched | 1.6 | -0.4 | repeat/repeat |
| treasure-trove | floor | runewaker_material_9 | 8115c445_light (256×256) | -27, 4, 6 | [-591.3, 66.8, 86.5] → [-506.2, 128.3, 192.2] | 4770.0 | 2.6 | 2.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_9 | 8115c445_light (256×256) | -29, 6, 7 | [-591.3, 101.5, 86.5] → [-506.2, 163.1, 192.2] | 4770.9 | 2.6 | 2.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_230 | 1ab44406_treasury_antre_01 (1024×1024) | -31, -2, 7 | [-611.9, -35.1, 137.8] → [-599.2, -26.6, 179.3] | 274.1 | 3.2 | 3.1 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -27, -3, 2 | [-539.2, -45.1, 32.8] → [-536.7, -36.4, 56.7] | 31.7 | 3.4 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -27, -2, 2 | [-539.3, -45.1, 32.8] → [-536.8, -36.4, 56.7] | 31.7 | 3.4 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -27, -3, 1 | [-539.2, -50.4, 19.4] → [-536.7, -45.1, 32.8] | 35.7 | 3.4 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -27, -3, -2 | [-539.0, -52.5, -39.0] → [-536.4, -51.2, -19.0] | 50.1 | 3.4 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -27, -3, 0 | [-539.2, -51.9, -4.8] → [-536.6, -50.4, 19.4] | 60.3 | 3.4 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_1 | 9813c7cb_tent_03 (512×512) | 16, 0, 1 | [322.0, -0.8, 28.7] → [328.2, 6.8, 34.7] | 21.1 | 49.2 | 29.3 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_231 | 72550d8f_treasury_antre_02 (1024×512) | -16, -1, -8 | [-320.5, -5.9, -156.5] → [-302.0, -4.4, -140.9] | 84.2 | 28.7 | 20.7 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_233 | 401aded9_treasury_antre_04 (512×512) | -10, -1, -9 | [-200.9, -3.4, -167.0] → [-189.3, 1.8, -159.2] | 49.4 | 25.1 | 20.7 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_231 | 72550d8f_treasury_antre_02 (1024×512) | -28, -1, -6 | [-551.1, -24.7, -118.9] → [-545.0, -16.0, -117.6] | 12.4 | 9.8 | 10.4 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | wall | runewaker_material_233 | 401aded9_treasury_antre_04 (512×512) | -1, -1, -6 | [-20.7, -2.9, -120.9] → [-6.3, 3.3, -100.7] | 181.6 | 22.2 | 17.4 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_231 | 72550d8f_treasury_antre_02 (1024×512) | -27, -2, -8 | [-542.8, -39.1, -153.7] → [-530.6, -28.4, -139.4] | 36.2 | 5.0 | 4.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_231 | 72550d8f_treasury_antre_02 (1024×512) | -28, -2, -6 | [-561.9, -40.4, -111.9] → [-537.6, -28.4, -99.7] | 122.8 | 6.9 | 5.2 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_231 | 72550d8f_treasury_antre_02 (1024×512) | -29, -1, -7 | [-574.8, -20.8, -143.0] → [-552.8, -13.3, -118.5] | 231.0 | 7.2 | 6.2 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | 1, -2, 1 | [18.2, -41.0, 18.5] → [46.4, -22.0, 46.8] | 759.1 | 8.4 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -2, -2, 1 | [-47.0, -41.0, 18.5] → [-18.8, -22.0, 46.8] | 759.1 | 8.4 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | 1, -2, -2 | [18.2, -41.0, -47.1] → [46.4, -22.0, -18.7] | 759.1 | 8.4 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -2, -2, -2 | [-47.0, -41.0, -47.1] → [-18.8, -22.0, -18.7] | 759.1 | 8.4 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -2, -3, -2 | [-47.0, -41.1, -47.1] → [-0.2, -41.0, -0.0] | 925.9 | 8.5 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -2, -2, 1 | [-47.1, -21.8, -0.2] → [-0.3, -21.7, 46.8] | 926.2 | 8.5 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | 1, -3, -2 | [-0.2, -41.1, -47.1] → [46.4, -41.0, -0.0] | 923.9 | 8.5 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -2, -2, -2 | [-47.1, -21.8, -47.1] → [-0.3, -21.7, -0.2] | 924.7 | 8.5 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | 2, 1, -1 | [46.8, 24.1, -19.1] → [59.4, 32.9, -17.5] | 14.3 | 32.4 | 29.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | -3, 1, -1 | [-59.4, 24.1, -19.1] → [-46.8, 32.9, -17.5] | 14.3 | 32.4 | 29.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | 0, 1, 2 | [11.6, 24.1, 45.7] → [13.2, 32.9, 58.4] | 14.4 | 32.3 | 29.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | -1, 1, 2 | [-13.3, 24.1, 45.7] → [-11.6, 32.9, 58.4] | 14.4 | 32.3 | 29.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | 1, 1, 2 | [23.8, 24.0, 40.5] → [40.2, 37.0, 57.2] | 25.2 | 31.2 | 24.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | -2, 1, 2 | [-40.2, 24.0, 40.5] → [-23.8, 37.0, 57.2] | 25.2 | 31.2 | 24.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | 2, 1, 1 | [41.5, 24.0, 23.6] → [58.8, 37.1, 40.1] | 25.2 | 31.3 | 24.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_11 | dbd9ffc3_flag_01 (512×128) | -3, 1, 1 | [-58.8, 24.0, 23.6] → [-41.5, 37.1, 40.1] | 25.2 | 31.3 | 24.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 16, 0, 0 | [324.4, 2.0, -1.7] → [341.5, 2.1, 14.1] | 192.6 | 93.1 | 98.7 | 1.09 | 8.5 | 8.5 | matched | 8.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, -17 | [-11.3, 11.6, -342.1] → [-0.5, 11.8, -322.8] | 153.5 | 93.1 | 98.7 | 1.09 | 7.9 | 7.9 | matched | 8.0 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, -17 | [-0.5, 11.6, -342.1] → [10.3, 11.8, -322.8] | 157.8 | 93.1 | 98.7 | 1.09 | 7.9 | 7.9 | matched | 8.0 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, 0 | [0.0, 0.0, -0.9] → [10.8, 0.2, 13.3] | 113.8 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, 0 | [-10.8, 0.0, -0.9] → [0.0, 0.2, 13.3] | 113.8 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 16, 0, -1 | [325.0, 2.0, -7.6] → [341.6, 2.1, 3.3] | 91.9 | 93.1 | 98.7 | 1.09 | 6.3 | 6.3 | matched | 6.3 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 17, 0, 0 | [338.6, 2.0, -0.1] → [346.1, 2.1, 13.6] | 56.5 | 93.1 | 98.7 | 1.09 | 5.1 | 5.1 | matched | 5.1 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, -1 | [0.0, 0.0, -8.4] → [10.3, 0.2, 2.4] | 68.8 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 16, 0, -2 | [318.8, 10.3, -31.3] → [342.4, 11.6, -13.4] | 226.3 | 7.0 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -19 | [-20.9, 12.2, -368.2] → [-0.6, 19.9, -357.2] | 100.2 | 7.4 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 1, -18 | [-21.5, 20.3, -364.3] → [1.1, 21.2, -347.1] | 213.0 | 7.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 1, -18 | [-0.6, 20.3, -364.3] → [20.3, 21.2, -347.1] | 214.0 | 7.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -19 | [-0.6, 11.9, -368.2] → [20.6, 19.9, -357.2] | 103.3 | 7.8 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -2 | [-0.1, 0.3, -32.1] → [21.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.3, 0.3, -32.1] → [-0.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 17, 0, -2 | [338.2, 2.3, -31.1] → [356.4, 11.6, -15.0] | 142.1 | 9.6 | 6.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -18 | [16.6, 11.5, -357.3] → [25.7, 19.6, -344.8] | 12.0 | 43.8 | 41.5 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -1 | [17.1, -0.1, -21.2] → [26.2, 8.7, -8.7] | 22.6 | 48.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, -0.1, -21.2] → [-17.3, 8.7, -8.7] | 29.2 | 49.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -18 | [-26.8, 11.5, -361.2] → [-17.8, 19.9, -344.8] | 54.3 | 43.2 | 34.9 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -18 | [16.6, 11.9, -357.3] → [25.7, 19.9, -344.8] | 29.5 | 26.4 | 20.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, 0 | [-6.1, 22.6, 2.5] → [0.4, 26.5, 7.0] | 12.5 | 80.6 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -17 | [2.8, 34.2, -337.4] → [7.3, 38.1, -330.8] | 16.4 | 81.4 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -1 | [-0.2, 22.6, -8.7] → [7.8, 26.5, -0.1] | 19.8 | 81.0 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, 0 | [0.0, 0.0, -0.9] → [10.8, 0.2, 13.3] | 113.8 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, 0 | [-10.8, 0.0, -0.9] → [0.0, 0.2, 13.3] | 113.8 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, -1 | [0.0, 0.0, -8.4] → [10.3, 0.2, 2.4] | 68.7 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, -1 | [-10.3, 0.0, -8.4] → [0.0, 0.2, 2.4] | 68.7 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 0, 1 | [-0.6, -0.0, 19.7] → [10.9, 16.8, 30.3] | 177.1 | 68.2 | 49.4 | 1.54 | 0.7 | 0.7 | matched | 0.7 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 0, 1 | [-11.0, -0.0, 19.7] → [0.6, 16.8, 30.3] | 177.2 | 68.1 | 49.4 | 1.54 | 0.7 | 0.7 | matched | 0.7 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -2 | [-0.1, 0.3, -32.1] → [21.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.3, 0.3, -32.1] → [-0.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -1 | [-21.0, 0.3, -28.1] → [-0.1, 9.6, -6.6] | 238.1 | 16.0 | 6.2 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -1 | [-0.1, 0.3, -28.1] → [20.8, 9.6, -6.6] | 240.4 | 16.1 | 6.2 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -2 | [-6.8, 15.5, -23.5] → [-1.4, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -2 | [1.3, 15.5, -23.5] → [6.6, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -1 | [-0.1, 9.7, -21.5] → [14.6, 17.8, -6.7] | 50.9 | 22.1 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -1 | [-14.8, 9.7, -21.5] → [-0.1, 17.8, -6.7] | 50.9 | 22.2 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -1 | [17.1, -0.1, -21.2] → [26.2, 8.7, -8.7] | 22.6 | 48.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, -0.1, -21.2] → [-17.3, 8.7, -8.7] | 29.2 | 49.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, 0 | [-6.1, 22.6, 2.5] → [0.4, 26.5, 7.0] | 12.5 | 80.5 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -1 | [-0.2, 22.6, -8.7] → [7.8, 26.5, -0.1] | 19.6 | 80.9 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, -1 | [-7.9, 22.6, -8.7] → [0.2, 26.5, -0.3] | 21.3 | 81.7 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, 0.3, -21.2] → [-17.3, 8.7, -8.7] | 50.4 | 26.4 | 20.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | -1, 0, 1 | [-12.2, 0.5, 19.5] → [-2.3, 2.4, 23.3] | 11.7 | 50.1 | 41.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | 0, 1, -1 | [-1.8, 26.1, -10.6] → [9.8, 28.2, -0.9] | 13.3 | 62.3 | 58.7 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, 0 | [0.0, 0.0, -0.9] → [10.8, 0.2, 13.3] | 113.8 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, 0 | [-10.8, 0.0, -0.9] → [0.0, 0.2, 13.3] | 113.8 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, -1 | [0.0, 0.0, -8.4] → [10.3, 0.2, 2.4] | 68.7 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, -1 | [-10.3, 0.0, -8.4] → [0.0, 0.2, 2.4] | 68.7 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 0, 1 | [-0.6, -0.0, 19.7] → [10.9, 16.8, 30.3] | 177.1 | 68.2 | 49.4 | 1.54 | 0.7 | 0.7 | matched | 0.7 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 0, 1 | [-11.0, -0.0, 19.7] → [0.6, 16.8, 30.3] | 177.2 | 68.1 | 49.4 | 1.54 | 0.7 | 0.7 | matched | 0.7 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -2 | [-0.1, 0.3, -32.1] → [21.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.3, 0.3, -32.1] → [-0.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -1 | [-21.0, 0.3, -28.1] → [-0.1, 9.6, -6.6] | 238.1 | 16.0 | 6.2 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -1 | [-0.1, 0.3, -28.1] → [20.8, 9.6, -6.6] | 240.4 | 16.1 | 6.2 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -2 | [-6.8, 15.5, -23.5] → [-1.4, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -2 | [1.3, 15.5, -23.5] → [6.6, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -1 | [-0.1, 9.7, -21.5] → [14.6, 17.8, -6.7] | 50.9 | 22.1 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -1 | [-14.8, 9.7, -21.5] → [-0.1, 17.8, -6.7] | 50.9 | 22.2 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -1 | [17.1, -0.1, -21.2] → [26.2, 8.7, -8.7] | 22.6 | 48.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, -0.1, -21.2] → [-17.3, 8.7, -8.7] | 29.2 | 49.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, 0 | [-6.1, 22.6, 2.5] → [0.4, 26.5, 7.0] | 12.5 | 80.5 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -1 | [-0.2, 22.6, -8.7] → [7.8, 26.5, -0.1] | 19.6 | 80.9 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, -1 | [-7.9, 22.6, -8.7] → [0.2, 26.5, -0.3] | 21.3 | 81.7 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, 0.3, -21.2] → [-17.3, 8.7, -8.7] | 50.4 | 26.4 | 20.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | -1, 0, 1 | [-12.2, 0.5, 19.5] → [-2.3, 2.4, 23.3] | 11.7 | 50.1 | 41.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | 0, 1, -1 | [-1.8, 26.1, -10.6] → [9.8, 28.2, -0.9] | 13.3 | 62.3 | 58.7 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 10, -2, -29 | [198.6, -49.0, -572.9] → [203.1, -23.9, -560.7] | 60.9 | 16.7 | 14.7 | 24.68 | 0.7 | 0.7 | matched | 1.1 | -0.4 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | 4, -3, -29 | [93.2, -57.4, -577.0] → [99.1, -54.4, -576.6] | 22.5 | 32.5 | 29.3 | 1.54 | 0.4 | 0.4 | matched | 0.4 | -0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | 4, -3, -28 | [93.2, -57.4, -546.2] → [99.1, -54.4, -545.9] | 22.5 | 32.5 | 29.3 | 1.54 | 0.4 | 0.4 | matched | 0.4 | -0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 2, -3, -30 | [39.7, -54.2, -594.0] → [42.7, -42.1, -584.9] | 10.9 | 81.9 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 2, -3, -27 | [39.7, -54.2, -537.5] → [42.7, -42.1, -528.4] | 11.0 | 81.1 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 1, 0, -1 | [25.1, 1.6, -16.3] → [34.2, 13.8, -12.8] | 11.3 | 82.1 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 1, 0, -3 | [25.1, 1.6, -55.5] → [34.2, 13.8, -52.0] | 11.8 | 82.2 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | -2, 0, -1 | [-31.7, 1.6, -16.3] → [-22.7, 13.8, -12.8] | 11.5 | 82.2 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -16 | [-234.8, -9.5, -348.9] → [-191.5, -9.5, -298.8] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -23 | [-101.2, -9.5, -475.2] → [-58.0, -9.5, -425.0] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -16 | [-108.3, -9.5, -341.8] → [-58.2, -9.5, -298.6] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -17 | [-227.8, -9.5, -348.9] → [-184.6, -9.5, -298.8] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -23 | [-108.1, -9.5, -475.2] → [-64.9, -9.5, -425.0] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -17 | [-108.3, -9.5, -348.7] → [-58.2, -9.5, -305.5] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -23 | [-234.6, -9.5, -475.3] → [-184.5, -9.5, -425.2] | 598.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -10, 1, -21 | [-184.6, 18.6, -419.7] → [-177.8, 25.0, -413.4] | 24.5 | 4.8 | 5.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_121 | 135ed67c_city_of_naga_front_part_011 (2048×1024) | 8, -1, -28 | [171.9, -10.9, -548.5] → [179.9, -4.4, -538.4] | 52.6 | 12.3 | 5.2 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_2 | 84fcf076_city_of_naga_front_part_004 (2048×1024) | 12, 3, -12 | [238.9, 59.7, -230.3] → [242.8, 62.4, -227.3] | 18.6 | 7.5 | 3.1 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 9, -3, -28 | [197.2, -48.9, -554.5] → [200.2, -28.2, -553.3] | 35.3 | 21.0 | 20.7 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 10, -2, -28 | [199.6, -49.0, -556.8] → [200.6, -23.9, -553.8] | 42.9 | 22.2 | 17.4 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 10, -3, -29 | [199.9, -49.0, -566.1] → [200.7, -28.2, -561.9] | 45.1 | 15.0 | 14.7 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 9, -2, -29 | [188.7, -48.9, -566.3] → [200.6, -10.2, -559.6] | 99.3 | 15.9 | 14.7 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_121 | 135ed67c_city_of_naga_front_part_011 (2048×1024) | 8, 1, -28 | [156.6, 21.8, -559.3] → [165.4, 27.9, -557.2] | 21.8 | 17.7 | 12.3 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 10, -2, -29 | [198.6, -49.0, -572.9] → [203.1, -23.9, -560.7] | 60.9 | 16.7 | 14.7 | 24.68 | 0.7 | 0.7 | matched | 1.1 | -0.4 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | 4, -3, -29 | [93.2, -57.4, -577.0] → [99.1, -54.4, -576.6] | 22.5 | 32.5 | 29.3 | 1.54 | 0.4 | 0.4 | matched | 0.4 | -0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | 4, -3, -28 | [93.2, -57.4, -546.2] → [99.1, -54.4, -545.9] | 22.5 | 32.5 | 29.3 | 1.54 | 0.4 | 0.4 | matched | 0.4 | -0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 2, -3, -30 | [39.7, -54.2, -594.0] → [42.7, -42.1, -584.9] | 10.9 | 81.9 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 2, -3, -27 | [39.7, -54.2, -537.5] → [42.7, -42.1, -528.4] | 11.0 | 81.1 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 1, 0, -1 | [25.1, 1.6, -16.3] → [34.2, 13.8, -12.8] | 11.3 | 82.1 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | 1, 0, -3 | [25.1, 1.6, -55.5] → [34.2, 13.8, -52.0] | 11.8 | 82.2 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_88 | d23937b4_zurhidon_stone_carving (1024×1024) | -2, 0, -1 | [-31.7, 1.6, -16.3] → [-22.7, 13.8, -12.8] | 11.5 | 82.2 | 49.4 | 2.59 | 0.1 | 0.1 | matched | 0.1 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -16 | [-234.8, -9.5, -348.9] → [-191.5, -9.5, -298.8] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -23 | [-101.2, -9.5, -475.2] → [-58.0, -9.5, -425.0] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -16 | [-108.3, -9.5, -341.8] → [-58.2, -9.5, -298.6] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -17 | [-227.8, -9.5, -348.9] → [-184.6, -9.5, -298.8] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -23 | [-108.1, -9.5, -475.2] → [-64.9, -9.5, -425.0] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -17 | [-108.3, -9.5, -348.7] → [-58.2, -9.5, -305.5] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -23 | [-234.6, -9.5, -475.3] → [-184.5, -9.5, -425.2] | 598.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -10, 1, -21 | [-184.6, 18.6, -419.7] → [-177.8, 25.0, -413.4] | 24.5 | 4.8 | 5.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_121 | 135ed67c_city_of_naga_front_part_011 (2048×1024) | 8, -1, -28 | [171.9, -10.9, -548.5] → [179.9, -4.4, -538.4] | 52.6 | 12.3 | 5.2 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_2 | 84fcf076_city_of_naga_front_part_004 (2048×1024) | 12, 3, -12 | [238.9, 59.7, -230.3] → [242.8, 62.4, -227.3] | 18.6 | 7.5 | 3.1 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 9, -3, -28 | [197.2, -48.9, -554.5] → [200.2, -28.2, -553.3] | 35.3 | 21.0 | 20.7 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 10, -2, -28 | [199.6, -49.0, -556.8] → [200.6, -23.9, -553.8] | 42.9 | 22.2 | 17.4 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 10, -3, -29 | [199.9, -49.0, -566.1] → [200.7, -28.2, -561.9] | 45.1 | 15.0 | 14.7 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_175 | 261ade57_magma_01 (512×256) | 9, -2, -29 | [188.7, -48.9, -566.3] → [200.6, -10.2, -559.6] | 99.3 | 15.9 | 14.7 | 24.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_121 | 135ed67c_city_of_naga_front_part_011 (2048×1024) | 8, 1, -28 | [156.6, 21.8, -559.3] → [165.4, 27.9, -557.2] | 21.8 | 17.7 | 12.3 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
## Source-to-shipped dimensions
Across 1615 embedded images, 0 are smaller than the matching source DDS and 0 match the previous 2048-side cap condition. The current audit reports only shipped files; it does not rebuild or replace those assets.
The pre-reoptimization snapshot is also re-linked against the now-indexed shared DDS cache: 1070/1070 source matches, 0 downsized images, 0 old 2048-cap hits. This corrects the frozen snapshot's original local-cache-only 823/1070 match coverage without changing its preserved UV measurements.
| Dungeon | Texture | Source DDS | Shipped KTX2 | Texels retained | Codec-only PSNR (dB) |
| --- | --- | ---: | ---: | ---: | ---: |
| — | No downsized matches recorded | — | — | — | — |
## KTX2 codec quality comparison
Pixel decoding was skipped for this run. For source PNG/KTX2 pairs, `ktx extract --transcode rgba8` decodes shipped ETC1S or UASTC level 0. RGB PSNR/MAE compares that decode with the source PNG resized to the shipped dimensions using Lanczos3, so it isolates codec loss from a resolution change. It does not score alpha, mip levels, display-space lighting, or a screenshot. Null PSNR with `rgbIdentical=true` means exact RGB equality. PSNR is a numerical image metric and does not substitute for in-game review.
| Dungeon | Compared | Median PSNR dB | P10 PSNR dB | Minimum PSNR dB | Mean RGB MAE |
| --- | ---: | ---: | ---: | ---: | ---: |
| — | 0 | — | — | — | — |
## Light metadata observed in the environment GLBs
`KHR_lights_punctual` and emissive material metadata are counted from each shipped environment GLB. The runtime presentation lights live in the dungeon definitions, and their illumination still requires a rendered review; those are covered in the separate RoM lighting pass.
- GLBs with embedded punctual lights: 0/30. The environments contain 0 light records and 0 emissive materials in total.
- Surface area with degenerate base-color UV triangles (≥0.02 m² triangle filter): 9675.2 m² on floors and 3495.6 m² on walls.
- Surface area with a rank-collapsed UV direction (≤0.01 px/m): 9675.5 m² on floors and 3694.4 m² on walls.
## Coverage gaps and interpretation limits
- The audit measures GLBs from the asset root specified by --asset-root (defaults to the shipped visual root); it does not rebuild assets, so producer-pipeline changes do not alter an existing asset root until packages are regenerated.
- Source dimensions are linked through the GLB image basename to preserved dungeon-local and shared resource-cache DDS files. Shared-cache duplicate basenames are accepted only when the DDS byte hashes are identical; conflicting candidates remain unmatched and are listed.
- Codec PSNR and MAE compare decoded ETC1S/UASTC RGB against resized source PNG pixels. They exclude alpha error, mip rendering, lighting, filtering, camera distance, and human visual perception. Null PSNR with rgbIdentical=true means exact RGB equality.
- This run skipped pixel decoding; codec PSNR/MAE fields are intentionally unavailable. Dimension comparison, mip-level counts, material references, and base-color UV geometry were still audited.
- UV density uses base-color texture coordinates for visual surfaces. Other texture channels and runtime material replacements are inventoried, but do not contribute to the floor/wall density ranking.
- Recipe-driven visual UV adaptations are reviewed runtime changes. Barren Caves retains its original UV0 in the canonical unoptimized document and samples a repeated UV1; the optimizer may prune or renumber unused UV channels. The converted-source comparison measures this annotated conversion, rather than proving that the rendered UVs equal the original ROS mapping.
- Native WDB terrain density diagnostics measure the default texture carrier. Runtime splat blending and optional steep-face side projections sample multiple source-scaled tile textures; a single-carrier metric does not describe their rendered density.
- The audit expands EXT_mesh_gpu_instancing per-instance translation, rotation, and scale before measuring world areas, spatial cells, and UV density. A before/after comparison excludes a baseline with GPU instances unless that baseline records expanded instance metrics.
- Surface classes are geometric-normal heuristics in the GLB frame. Floor includes upward surfaces; ceiling, slopes, and other faces are separately measured. This does not identify a semantic game level or classify sub-horizontal platform parts as authored floors.
- Audit hotspots use triangle area ≥0.02 m² and spatial cells at 20 m. Aggregate groups under 10 m² are excluded from the broad hotspot report, though their measurements remain in surface totals.
- Converted-source comparison reads runewaker-export-work/<slug>/converted/<slug>-visual.glb after static WDB assembly. Exact image basenames join by surface and 20 m cell. When source PNG pixels and base-color factors match, image aliases join as a coarse surface/cell aggregate: RGB identity only when OPAQUE materials have a fully opaque source alpha channel; non-opaque alpha is retained as RGBA identity when alpha mode or native use is unresolved, and MASK/BLEND materials always retain RGBA. This is not triangle-level correspondence. Separate dynamic WDB assets and resources marked unsupported are outside the static visual comparison; unmatched keys or expanded bounds need checking against the placement manifest before attribution.
- Embedded light metadata does not describe the runtime renderer's ambient, hemisphere, directional, or point-light interactions. Rendered captures are needed for lighting validation.
@@ -0,0 +1,723 @@
# RoM environment texture and UV quality audit
Asset snapshot: Git revision 6114a2295f14aa20a8444c61a960faeb88ac70c5; per-file manifest: artifacts/rom-environment-quality-audit/baseline-head-archive/rom-visual-manifest.json.
Coverage: **30/30 registered RoM environments**; 30 shipped visual GLBs; 1070 embedded textures; 1070 source DDS matches.
The registry scope is the generated RoM definitions plus the explicit Forsaken Abbey definition. The audit reads shipped world visual GLBs and the preserved ROS exporter source DDS/PNG caches. It excludes actor models and untextured collision/navigation GLBs.
UV density is measured from shipped geometry in dungeon meters after node transforms. Texture UVs are transformed through `KHR_texture_transform` if present. Face classes use geometric normals: floor `normal.y ≥ 0.7`, wall `|normal.y| ≤ 0.3`; ceilings, slopes and other faces are reported separately. Triangles below 0.02 m² are excluded from hotspot calculations to keep thin trim and decorative fragments from dominating. Broad hotspot candidates aggregate at least 10 m² inside 20 m spatial cells.
Per-triangle texel density is `sqrt(texture-space area / world-space area)` in source pixels per meter. Anisotropy is the ratio of the largest to smallest singular value of the local UV-to-world mapping. Zero UV area is counted separately; a smallest singular value at or below 0.01 px/m is counted as rank-collapsed. GPU instance translation, rotation, and scale are expanded for these calculations. The compact JSON retains aggregate metrics and selected floor/wall hotspots rather than every spatial group.
## Per-dungeon coverage
| Dungeon | Audit | Images | DDS linked | Downsized | 2048 cap | Median codec PSNR (dB) | Floor density (px/m) | Wall density (px/m) | Embedded lights |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| aeternal-circle | audited | 18 | 18/18 | 0 | 0 | — | 24.3 | 19.3 | 0 |
| barren-caves | audited | 8 | 8/8 | 0 | 0 | — | 21.2 | 45.5 | 0 |
| cave-water-dragon | audited | 24 | 24/24 | 0 | 0 | — | 16.2 | 15.8 | 0 |
| demon-stronghold | audited | 56 | 56/56 | 0 | 0 | — | 4.0 | 8.0 | 0 |
| demon-stronghold-125 | audited | 56 | 56/56 | 0 | 0 | — | 4.0 | 8.0 | 0 |
| dungeon-of-dalanis | audited | 34 | 34/34 | 0 | 0 | — | 24.7 | 30.2 | 0 |
| forsaken-abbey | audited | 37 | 37/37 | 0 | 0 | — | 52.4 | 55.6 | 0 |
| grafu-castle | audited | 44 | 44/44 | 0 | 0 | — | 16.5 | 35.9 | 0 |
| hall-of-survivors | audited | 33 | 33/33 | 0 | 0 | — | 12.2 | 15.6 | 0 |
| hoto | audited | 17 | 17/17 | 0 | 0 | — | 16.5 | 25.8 | 0 |
| ice-dwarf-kingdom | audited | 23 | 23/23 | 0 | 0 | — | 28.4 | 37.3 | 0 |
| kal-turok-hive | audited | 10 | 10/10 | 0 | 0 | — | 40.7 | 39.0 | 0 |
| kalins-shrine | audited | 17 | 17/17 | 0 | 0 | — | 50.5 | 49.0 | 0 |
| kawaks-tomb | audited | 41 | 41/41 | 0 | 0 | — | 9.6 | 22.8 | 0 |
| kulech-bones-nest | audited | 38 | 38/38 | 0 | 0 | — | 51.0 | 50.6 | 0 |
| lair-demon-dragon | audited | 17 | 17/17 | 0 | 0 | — | 12.7 | 15.1 | 0 |
| mystic-altar | audited | 13 | 13/13 | 0 | 0 | — | 22.1 | 31.1 | 0 |
| paspers-shrine | audited | 12 | 12/12 | 0 | 0 | — | 17.0 | 36.3 | 0 |
| raksha-temple | audited | 114 | 114/114 | 0 | 0 | — | 15.1 | 21.3 | 0 |
| sand-vortex | fidelity-gap | 0 | 0/0 | 0 | 0 | — | — | — | 0 |
| sardo-castle | audited | 35 | 35/35 | 0 | 0 | — | 1.0 | 34.6 | 0 |
| the-origin | audited | 139 | 139/139 | 0 | 0 | — | 19.0 | 26.5 | 0 |
| tomb-seven-heroes | audited | 62 | 62/62 | 0 | 0 | — | 3.8 | 25.2 | 0 |
| treasure-trove | audited | 70 | 70/70 | 0 | 0 | — | 15.6 | 12.4 | 0 |
| warnorken-arena | audited | 10 | 10/10 | 0 | 0 | — | 13.7 | 21.8 | 0 |
| ystra-labyrinth-boss1 | audited | 18 | 18/18 | 0 | 0 | — | 43.9 | 45.4 | 0 |
| ystra-labyrinth-boss2 | audited | 18 | 18/18 | 0 | 0 | — | 44.0 | 45.5 | 0 |
| ystra-labyrinth-boss3 | audited | 18 | 18/18 | 0 | 0 | — | 44.0 | 45.5 | 0 |
| zurhidon-stronghold | audited | 44 | 44/44 | 0 | 0 | — | 18.9 | 22.1 | 0 |
| zurhidon-stronghold-124 | audited | 44 | 44/44 | 0 | 0 | — | 18.9 | 22.1 | 0 |
`Downsized` compares the shipped KTX2 level 0 dimensions with the matching source DDS. `2048 cap` identifies source textures whose longer side exceeds 2048 and shipped longer side is exactly 2048. Floor and wall density are area-weighted geometric means over mapped faces at or above the triangle cutoff.
## Broad floor and wall mapping hotspots
Rows are the union of the largest rank-collapse/degenerate-UV areas, lowest density groups, and highest p90 anisotropy groups per dungeon, restricted to spatial groups at least 10 m². `cell` is the 20 m world-grid coordinate `[x,y,z]`; bounds are exact current shipped GLB coordinates in meters. `uvDegenerate` reports geometric surface area with zero-area UV triangles; `rank collapsed` reports area with a minimum singular value ≤0.01 px/m. Converted source comparison matches by surface, texture basename, and spatial cell; a positive `post−source rank m²` is a possible newly introduced collapse, subject to the source GLB coverage limit below.
| Dungeon | Face | Material | Texture | Cell | Shipped bounds min → max | Area m² | Mean px/m | P10 px/m | P90 anisotropy | UV-degenerate m² | Rank-collapsed m² | Source match | Source rank m² | Post−source rank m² | Wrap S/T |
| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | --- |
| aeternal-circle | wall | runewaker_material_36 | 70f3b984_outside_base_wall (1024×1024) | -1, 2, -3 | [-12.9, 41.6, -65.9] → [7.4, 65.2, -45.4] | 815.2 | 35.5 | 24.7 | 3.08 | 363.4 | 363.4 | matched | 0.0 | 363.4 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | -2, 12, -4 | [-21.2, 234.6, -76.9] → [-20.4, 259.4, -54.1] | 452.1 | 56.1 | 41.5 | 20.75 | 354.9 | 354.9 | matched | 0.0 | 354.9 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | -2, 13, -4 | [-21.2, 259.0, -76.9] → [-20.4, 279.8, -54.1] | 403.5 | 39.8 | 41.5 | 3.67 | 330.6 | 330.6 | matched | 0.0 | 330.6 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | -2, 13, -3 | [-21.2, 259.0, -58.1] → [-20.4, 279.8, -39.0] | 379.2 | 68.8 | 69.8 | 1.30 | 330.5 | 330.5 | matched | 0.0 | 330.5 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | 0, 15, -2 | [-5.9, 286.1, -29.4] → [16.8, 312.2, -29.4] | 296.4 | — | — | — | 296.4 | 296.4 | matched | 0.0 | 296.4 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_36 | 70f3b984_outside_base_wall (1024×1024) | 0, 3, -3 | [-8.4, 53.4, -66.3] → [13.5, 82.5, -44.4] | 1120.3 | 40.7 | 20.7 | 1.30 | 286.0 | 286.0 | matched | 0.0 | 286.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_36 | 70f3b984_outside_base_wall (1024×1024) | -1, 4, -3 | [-16.6, 76.7, -66.3] → [7.9, 99.0, -41.6] | 667.5 | 41.0 | 20.7 | 3.67 | 241.4 | 241.4 | matched | 0.0 | 241.4 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_29 | 5958e701_tower_obj_a (1024×1024) | 0, 12, -12 | [-4.9, 247.8, -226.9] → [18.4, 261.6, -222.1] | 236.3 | 47.7 | 49.4 | 1.09 | 230.0 | 230.0 | matched | 0.0 | 230.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | -1, -2, -1 | [-19.3, -40.2, -24.0] → [3.2, -36.2, -0.8] | 401.9 | 2.2 | 1.3 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | -2, -3, -1 | [-30.5, -42.3, -24.0] → [-19.3, -39.4, -0.8] | 136.6 | 4.7 | 4.4 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -2, -1 | [3.2, -37.0, -24.0] → [14.4, -29.9, -0.8] | 230.6 | 4.8 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | -6, 9, -3 | [-114.6, 191.7, -54.9] → [-105.5, 200.9, -42.1] | 61.8 | 4.9 | 5.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | -1, -2, 0 | [-19.3, -38.5, -0.8] → [3.2, -35.1, 22.4] | 397.3 | 4.9 | 3.7 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_25 | 620d2afc_cliff (512×512) | 0, -2, 0 | [3.2, -37.2, -0.8] → [14.4, -32.0, 22.4] | 280.1 | 5.0 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 14, -1 | [32.1, 292.8, -23.4] → [38.7, 305.5, -11.9] | 79.4 | 5.0 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 15, 0 | [19.3, 300.7, -1.3] → [34.0, 311.8, 3.1] | 64.2 | 5.0 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | -2, 9, -4 | [-21.2, 185.4, -76.7] → [-20.3, 185.4, -54.1] | 16.1 | 52.0 | 41.5 | 234.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | -1, 10, -2 | [-18.7, 210.0, -37.9] → [7.3, 210.0, -37.0] | 16.2 | 72.2 | 69.8 | 234.75 | 6.5 | 6.5 | matched | 0.0 | 6.5 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_25 | 620d2afc_cliff (512×512) | 1, 1, 3 | [19.2, 12.5, 52.5] → [33.7, 53.8, 72.4] | 370.1 | 7.1 | 2.2 | 197.40 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_34 | 434585ac_outside_wall (1024×1024) | -2, 12, -4 | [-21.2, 259.4, -76.7] → [-20.3, 259.4, -54.1] | 12.9 | 47.9 | 41.5 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_28 | a856e894_statue_a (1024×512) | 0, 14, -7 | [-0.5, 278.6, -128.4] → [7.5, 282.2, -118.4] | 23.4 | 45.2 | 10.4 | 83.00 | 3.1 | 3.1 | matched | 0.0 | 3.1 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_28 | a856e894_statue_a (1024×512) | -1, 14, -7 | [-8.8, 278.6, -128.5] → [1.0, 282.2, -118.5] | 30.9 | 46.7 | 10.4 | 83.00 | 5.0 | 5.0 | matched | 0.0 | 5.0 | repeat/repeat |
| aeternal-circle | wall | runewaker_material_28 | a856e894_statue_a (1024×512) | 1, 13, -12 | [18.5, 269.7, -222.8] → [36.0, 273.4, -219.3] | 42.8 | 41.4 | 10.4 | 83.00 | 9.7 | 9.7 | matched | 0.0 | 9.7 | repeat/repeat |
| aeternal-circle | floor | runewaker_material_26 | dff0db0b_ground_ceiling (1024×1024) | -1, 13, -6 | [-9.3, 262.6, -122.0] → [1.8, 278.2, -98.5] | 48.3 | 57.2 | 34.9 | 69.79 | 6.6 | 6.6 | matched | 0.0 | 6.6 | repeat/repeat |
| barren-caves | wall | runewaker_material_43 | ff8acd09_pit_mineral (256×256) | 3, -1, -2 | [61.8, -0.9, -33.8] → [67.7, 0.1, -30.9] | 15.1 | 65.9 | 58.7 | 4.36 | 4.3 | 4.3 | matched | 0.0 | 4.3 | repeat/repeat |
| barren-caves | wall | runewaker_material_43 | ff8acd09_pit_mineral (256×256) | 6, -1, -2 | [124.2, -1.9, -31.1] → [128.1, -0.4, -21.7] | 10.2 | 69.5 | 69.8 | 1.30 | 3.2 | 3.2 | matched | 0.0 | 3.2 | repeat/repeat |
| barren-caves | wall | runewaker_material_98 | f844e713_gnoll_tent_prop (256×256) | -1, 0, 1 | [-15.7, -0.1, 18.4] → [0.7, 7.1, 37.1] | 102.1 | 45.7 | 34.9 | 2.59 | 1.6 | 1.6 | matched | 0.0 | 1.6 | repeat/repeat |
| barren-caves | wall | runewaker_material_98 | f844e713_gnoll_tent_prop (256×256) | 1, 0, -1 | [26.1, 0.2, -11.2] → [40.4, 6.7, -2.6] | 76.1 | 44.1 | 34.9 | 2.59 | 1.2 | 1.2 | matched | 0.0 | 1.2 | repeat/repeat |
| barren-caves | wall | runewaker_material_98 | f844e713_gnoll_tent_prop (256×256) | 6, 0, 0 | [120.3, 1.3, 7.6] → [128.7, 7.4, 15.6] | 52.9 | 49.1 | 34.9 | 2.59 | 1.0 | 1.0 | matched | 0.0 | 1.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_98 | f844e713_gnoll_tent_prop (256×256) | 2, 0, -2 | [39.4, -0.3, -41.4] → [51.1, 5.8, -25.8] | 107.4 | 44.5 | 34.9 | 2.59 | 0.7 | 0.7 | matched | 0.0 | 0.7 | repeat/repeat |
| barren-caves | wall | runewaker_material_98 | f844e713_gnoll_tent_prop (256×256) | 3, 0, 0 | [64.6, -0.8, 9.3] → [78.9, 5.0, 20.9] | 67.0 | 52.6 | 34.9 | 2.59 | 0.7 | 0.7 | matched | 0.0 | 0.7 | repeat/repeat |
| barren-caves | wall | runewaker_material_98 | f844e713_gnoll_tent_prop (256×256) | 4, 0, -1 | [86.6, -0.7, -4.7] → [98.3, 5.7, 2.2] | 66.5 | 45.2 | 34.9 | 2.59 | 0.7 | 0.7 | matched | 0.0 | 0.7 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 8, -1, -2 | [142.8, -10.2, -43.2] → [193.7, 0.7, 13.2] | 1300.5 | 5.6 | 5.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 7, -1, -4 | [114.1, -5.8, -89.7] → [162.2, -0.0, -44.6] | 1078.0 | 5.7 | 5.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 7, -1, -1 | [136.0, -11.2, -43.2] → [186.4, 1.1, 13.2] | 1277.6 | 6.3 | 5.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 8, -1, 1 | [140.9, -4.8, 10.9] → [199.8, 0.9, 54.7] | 1526.7 | 7.2 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 1, -1, -4 | [4.9, -5.7, -81.3] → [50.4, 0.1, -44.0] | 852.0 | 7.5 | 6.2 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 1, -1, -3 | [11.4, -5.7, -81.3] → [50.4, 0.0, -44.0] | 672.4 | 7.9 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 3, -1, 3 | [39.4, -5.8, 53.3] → [76.6, 0.3, 91.1] | 743.1 | 8.0 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 2, -1, 3 | [39.4, -5.0, 48.2] → [73.4, 0.7, 83.2] | 642.2 | 8.4 | 7.3 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | -3, 0, 1 | [-41.7, 1.2, 29.1] → [-39.9, 6.6, 37.8] | 43.7 | 31.5 | 29.3 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | -3, 0, 2 | [-43.8, -0.4, 37.7] → [-39.4, 9.3, 66.6] | 117.5 | 38.8 | 29.3 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 3, 0, 0 | [65.8, -0.4, -0.4] → [83.3, 4.5, 22.6] | 143.2 | 38.7 | 24.7 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 9, 0, -2 | [178.2, 2.2, -22.6] → [185.3, 8.3, -17.5] | 25.3 | 29.4 | 29.3 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 2, 0, 2 | [57.4, 9.0, 43.8] → [60.4, 16.3, 48.0] | 31.9 | 41.8 | 24.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | floor | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | 4, 0, -2 | [85.6, -1.0, -42.7] → [100.6, 3.3, -19.2] | 41.4 | 38.8 | 24.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| barren-caves | wall | runewaker_material_12 | a6d9c718_wretched_tunnel (1024×512) | -2, 0, 2 | [-40.4, -0.4, 46.5] → [-26.6, 19.3, 60.5] | 46.3 | 40.0 | 24.7 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 3, 0, -6 | [64.5, -2.4, -126.5] → [80.4, 27.9, -90.3] | 2518.4 | 11.1 | 4.4 | 24.68 | 1802.1 | 1802.1 | matched | 0.0 | 1802.1 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 3, 1, -6 | [63.0, 17.1, -123.1] → [81.7, 45.6, -90.3] | 1710.8 | 13.9 | 8.7 | 4.36 | 1212.1 | 1212.1 | matched | 0.0 | 1212.1 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_133 | 3f8fb19c_fountain_a (1024×512) | 3, -1, -10 | [56.9, -0.9, -201.7] → [84.9, -0.0, -158.9] | 1206.7 | 24.0 | 24.7 | 1.09 | 1061.6 | 1061.6 | matched | 0.0 | 1061.6 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_133 | 3f8fb19c_fountain_a (1024×512) | 3, -1, -9 | [56.4, -0.7, -191.9] → [82.1, -0.0, -158.9] | 956.9 | 10.8 | 8.7 | 8.72 | 702.9 | 702.9 | matched | 0.0 | 702.9 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 4, 0, -12 | [80.1, -0.2, -242.9] → [91.7, 25.1, -216.2] | 1363.8 | 15.7 | 14.7 | 1.30 | 702.9 | 702.9 | matched | 0.0 | 702.9 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 3, 0, -12 | [59.9, -0.3, -234.1] → [80.7, 30.1, -210.6] | 1906.8 | 12.9 | 6.2 | 10.37 | 622.4 | 622.4 | matched | 0.0 | 622.4 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 3, 0, -5 | [64.5, -2.4, -114.3] → [80.4, 22.9, -90.3] | 705.2 | 17.2 | 17.4 | 1.09 | 593.4 | 593.4 | matched | 0.0 | 593.4 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 3, 0, -11 | [60.4, -0.3, -226.5] → [80.2, 30.1, -209.8] | 1555.3 | 16.2 | 8.7 | 6.17 | 591.9 | 591.9 | matched | 0.0 | 591.9 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_31 | 161c1d8f_precipice_04 (1024×1024) | 4, -3, -17 | [90.2, -43.2, -331.3] → [103.5, -43.2, -321.0] | 17.5 | 0.1 | 0.0 | 60096.78 | 0.0 | 17.5 | matched | 0.0 | 17.5 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | -1, 0, -17 | [-20.8, -11.3, -327.6] → [-18.8, 14.8, -320.8] | 83.4 | 3.0 | 3.1 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 10, -3, -25 | [203.9, -47.9, -484.1] → [206.1, -25.8, -476.8] | 73.2 | 3.2 | 3.1 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 10, -2, -25 | [202.7, -47.3, -484.1] → [206.1, -25.8, -478.3] | 71.3 | 3.3 | 3.1 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 4, -1, -16 | [76.9, -16.1, -314.5] → [98.0, -6.3, -306.1] | 123.9 | 3.6 | 2.6 | 2.18 | 7.0 | 7.0 | matched | 7.1 | -0.1 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 3, -1, -17 | [56.8, -18.0, -327.6] → [82.6, -5.8, -317.8] | 291.4 | 3.7 | 2.6 | 2.18 | 7.0 | 7.0 | matched | 7.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 12, -2, -24 | [237.6, -47.6, -471.5] → [260.6, -24.1, -467.4] | 1604.4 | 3.8 | 3.1 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_100 | 7a4e4c12_gate_waterfall (256×256) | 12, -1, -24 | [238.1, -26.1, -474.8] → [260.6, -1.8, -469.8] | 1920.6 | 3.8 | 3.1 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_86 | 4628949b_wave_01 (256×256) | 2, -1, -4 | [46.9, -0.9, -82.2] → [59.2, -0.6, -69.1] | 55.3 | 6.7 | 0.0 | 60096.78 | 0.0 | 6.8 | matched | 0.0 | 6.8 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_105 | c43554ca_fdoor_tower (1024×1024) | 4, 0, -6 | [83.1, 2.5, -106.2] → [93.7, 17.4, -99.0] | 205.0 | 22.8 | 1.1 | 1579.22 | 42.6 | 42.6 | matched | 0.0 | 42.6 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_132 | b76cbd5a_pedestal_a (512×512) | 5, -1, -8 | [95.1, -2.2, -160.2] → [122.0, 0.4, -134.5] | 209.7 | 16.0 | 14.7 | 166.00 | 93.9 | 93.9 | matched | 0.0 | 93.9 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_133 | 3f8fb19c_fountain_a (1024×512) | 3, -1, -6 | [65.1, -2.4, -129.8] → [79.9, -0.1, -93.0] | 124.1 | 15.0 | 12.3 | 117.38 | 42.0 | 42.0 | matched | 0.0 | 42.0 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_132 | b76cbd5a_pedestal_a (512×512) | 5, -1, -11 | [96.9, -2.2, -216.2] → [122.0, 0.4, -197.1] | 172.2 | 14.4 | 8.7 | 58.69 | 61.8 | 61.8 | matched | 0.0 | 61.8 | repeat/repeat |
| cave-water-dragon | wall | runewaker_material_133 | 3f8fb19c_fountain_a (1024×512) | 3, -1, -5 | [65.1, -2.4, -102.2] → [79.9, -0.1, -93.0] | 36.0 | 21.8 | 20.7 | 41.50 | 10.3 | 10.3 | matched | 0.0 | 10.3 | repeat/repeat |
| cave-water-dragon | floor | runewaker_material_104 | e64b65c5_lobby_door (1024×1024) | 3, 2, -6 | [65.7, 47.7, -117.0] → [79.2, 58.8, -98.9] | 179.4 | 15.3 | 6.2 | 41.50 | 166.1 | 166.1 | matched | 0.0 | 166.1 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 26, -2, -12 | [436.2, -21.9, -281.1] → [597.1, -21.9, -178.1] | 7551.4 | 0.0 | 0.0 | 60096.78 | 0.0 | 7551.4 | matched | 0.0 | 7551.4 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 24, -2, -11 | [429.1, -21.9, -254.7] → [597.1, -21.9, -178.1] | 4667.4 | 0.0 | 0.0 | 60096.78 | 0.0 | 4667.4 | matched | 0.0 | 4667.4 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -17, 0, -6 | [-336.4, -8.1, -189.2] → [-329.5, 28.3, -84.2] | 1616.6 | 23.6 | 20.7 | 1.30 | 1519.2 | 1519.2 | matched | 0.0 | 1519.2 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -20, 0, -8 | [-390.3, -8.1, -178.6] → [-389.2, 28.3, -84.2] | 1452.1 | 15.8 | 7.3 | 197.40 | 1234.3 | 1234.3 | matched | 0.0 | 1234.3 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_183 | b2a7017b_devil_room_platform (1024×1024) | 26, 1, -11 | [504.4, 23.4, -243.1] → [550.7, 23.4, -186.6] | 1071.6 | — | — | — | 1071.6 | 1071.6 | matched | 0.0 | 1071.6 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_174 | 83575ff7_room_08_0123 (512×512) | 19, 1, -11 | [381.5, 23.5, -243.3] → [417.1, 23.5, -189.5] | 958.8 | — | — | — | 958.8 | 958.8 | matched | 0.0 | 958.8 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_183 | b2a7017b_devil_room_platform (1024×1024) | 25, 1, -11 | [493.1, 20.3, -243.1] → [527.8, 23.4, -186.6] | 937.2 | 19.3 | 20.7 | 2.18 | 928.3 | 928.3 | matched | 0.0 | 928.3 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -17, -1, -8 | [-339.7, -10.2, -189.2] → [-329.2, 10.3, -84.2] | 1029.8 | 28.8 | 29.3 | 1.54 | 866.5 | 866.5 | matched | 5.6 | 861.0 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_161 | 9e09adcc_nor_survival_wave_white (256×256) | 1, -1, -14 | [37.1, -4.2, -275.6] → [40.1, -4.2, -256.8] | 14.4 | 0.0 | 0.0 | 60096.78 | 0.0 | 14.4 | matched | 0.0 | 14.4 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 21, -2, -11 | [427.6, -22.5, -243.9] → [431.9, -22.5, -188.9] | 119.1 | 0.0 | 0.0 | 60096.78 | 0.0 | 119.1 | matched | 0.0 | 119.1 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 27, -2, -15 | [528.2, -21.9, -304.8] → [577.9, -21.9, -281.1] | 120.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 120.8 | matched | 0.0 | 120.8 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 26, -2, -15 | [500.3, -21.9, -304.8] → [577.9, -21.9, -281.1] | 347.5 | 0.0 | 0.0 | 60096.78 | 0.0 | 347.5 | matched | 0.0 | 347.5 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_161 | 9e09adcc_nor_survival_wave_white (256×256) | 4, -1, -14 | [72.4, -4.3, -283.6] → [98.1, -4.2, -247.6] | 812.7 | 0.0 | 0.0 | 60096.78 | 0.0 | 420.4 | matched | 0.0 | 420.4 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 21, -2, -12 | [428.3, -22.5, -268.8] → [444.5, -21.9, -188.9] | 469.3 | 0.0 | 0.0 | 60096.78 | 0.0 | 348.3 | matched | 0.0 | 348.3 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -13 | [129.3, 67.9, -258.2] → [129.3, 71.9, -252.3] | 23.6 | 0.1 | 0.1 | 60096.78 | 0.0 | 23.6 | matched | 23.4 | 0.2 | repeat/repeat |
| demon-stronghold | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -14 | [129.3, 67.9, -270.1] → [129.3, 71.9, -258.2] | 47.3 | 0.1 | 0.1 | 60096.78 | 0.0 | 47.3 | matched | 47.2 | 0.1 | repeat/repeat |
| demon-stronghold | floor | runewaker_material_161 | 9e09adcc_nor_survival_wave_white (256×256) | 3, -1, -15 | [53.6, -4.3, -292.1] → [95.1, -4.2, -269.0] | 487.7 | 0.4 | 0.0 | 60096.78 | 0.0 | 203.0 | matched | 0.0 | 203.0 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 26, -2, -12 | [436.2, -21.9, -281.1] → [597.1, -21.9, -178.1] | 7551.4 | 0.0 | 0.0 | 60096.78 | 0.0 | 7551.4 | matched | 0.0 | 7551.4 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 24, -2, -11 | [429.1, -21.9, -254.7] → [597.1, -21.9, -178.1] | 4667.4 | 0.0 | 0.0 | 60096.78 | 0.0 | 4667.4 | matched | 0.0 | 4667.4 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -17, 0, -6 | [-336.4, -8.1, -189.2] → [-329.5, 28.3, -84.2] | 1616.6 | 23.6 | 20.7 | 1.30 | 1519.2 | 1519.2 | matched | 0.0 | 1519.2 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -20, 0, -8 | [-390.3, -8.1, -178.6] → [-389.2, 28.3, -84.2] | 1452.1 | 15.8 | 7.3 | 197.40 | 1234.3 | 1234.3 | matched | 0.0 | 1234.3 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_183 | b2a7017b_devil_room_platform (1024×1024) | 26, 1, -11 | [504.4, 23.4, -243.1] → [550.7, 23.4, -186.6] | 1071.6 | — | — | — | 1071.6 | 1071.6 | matched | 0.0 | 1071.6 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_174 | 83575ff7_room_08_0123 (512×512) | 19, 1, -11 | [381.5, 23.5, -243.3] → [417.1, 23.5, -189.5] | 958.8 | — | — | — | 958.8 | 958.8 | matched | 0.0 | 958.8 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_183 | b2a7017b_devil_room_platform (1024×1024) | 25, 1, -11 | [493.1, 20.3, -243.1] → [527.8, 23.4, -186.6] | 937.2 | 19.3 | 20.7 | 2.18 | 928.3 | 928.3 | matched | 0.0 | 928.3 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_90 | 055e3aad_room04_wall (1024×1024) | -17, -1, -8 | [-339.7, -10.2, -189.2] → [-329.2, 10.3, -84.2] | 1029.8 | 28.8 | 29.3 | 1.54 | 866.5 | 866.5 | matched | 5.6 | 861.0 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_161 | 9e09adcc_nor_survival_wave_white (256×256) | 1, -1, -14 | [37.1, -4.2, -275.6] → [40.1, -4.2, -256.8] | 14.4 | 0.0 | 0.0 | 60096.78 | 0.0 | 14.4 | matched | 0.0 | 14.4 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 21, -2, -11 | [427.6, -22.5, -243.9] → [431.9, -22.5, -188.9] | 119.1 | 0.0 | 0.0 | 60096.78 | 0.0 | 119.1 | matched | 0.0 | 119.1 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 27, -2, -15 | [528.2, -21.9, -304.8] → [577.9, -21.9, -281.1] | 120.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 120.8 | matched | 0.0 | 120.8 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 26, -2, -15 | [500.3, -21.9, -304.8] → [577.9, -21.9, -281.1] | 347.5 | 0.0 | 0.0 | 60096.78 | 0.0 | 347.5 | matched | 0.0 | 347.5 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_161 | 9e09adcc_nor_survival_wave_white (256×256) | 4, -1, -14 | [72.4, -4.3, -283.6] → [98.1, -4.2, -247.6] | 812.7 | 0.0 | 0.0 | 60096.78 | 0.0 | 420.4 | matched | 0.0 | 420.4 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_202 | 6c1c99ba_obj_forge01_lava (256×256) | 21, -2, -12 | [428.3, -22.5, -268.8] → [444.5, -21.9, -188.9] | 469.3 | 0.0 | 0.0 | 60096.78 | 0.0 | 348.3 | matched | 0.0 | 348.3 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -13 | [129.3, 67.9, -258.2] → [129.3, 71.9, -252.3] | 23.6 | 0.1 | 0.1 | 60096.78 | 0.0 | 23.6 | matched | 23.4 | 0.2 | repeat/repeat |
| demon-stronghold-125 | wall | runewaker_material_22 | 6b2c471f_aisle_ceiling_01 (512×512) | 6, 3, -14 | [129.3, 67.9, -270.1] → [129.3, 71.9, -258.2] | 47.3 | 0.1 | 0.1 | 60096.78 | 0.0 | 47.3 | matched | 47.2 | 0.1 | repeat/repeat |
| demon-stronghold-125 | floor | runewaker_material_161 | 9e09adcc_nor_survival_wave_white (256×256) | 3, -1, -15 | [53.6, -4.3, -292.1] → [95.1, -4.2, -269.0] | 487.7 | 0.4 | 0.0 | 60096.78 | 0.0 | 203.0 | matched | 0.0 | 203.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_80 | 9c5c5b8e_hand_of_truth_11 (1024×1024) | 7, 0, -1 | [139.1, 0.1, -41.6] → [164.0, 1.4, 22.7] | 955.9 | 25.5 | 12.3 | 24.68 | 507.4 | 507.4 | matched | 0.0 | 507.4 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 1, 0, 1 | [26.1, -0.5, 18.0] → [42.8, 22.6, 43.4] | 1679.6 | 25.4 | 20.7 | 2.59 | 461.4 | 504.8 | matched | 0.0 | 504.8 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 3, 0, 4 | [56.0, -0.4, 84.9] → [83.1, 22.6, 98.6] | 1379.2 | 27.2 | 24.7 | 1.30 | 462.3 | 492.4 | matched | 0.0 | 492.4 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 5, 0, 1 | [97.8, -0.1, 17.9] → [123.4, 22.0, 35.0] | 1597.0 | 26.8 | 20.7 | 2.59 | 442.4 | 478.3 | matched | 0.0 | 478.3 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_80 | 9c5c5b8e_hand_of_truth_11 (1024×1024) | 9, 0, 0 | [174.2, 0.3, -30.9] → [200.8, 4.2, 22.7] | 1054.3 | 36.7 | 24.7 | 6.17 | 450.0 | 450.0 | matched | 0.0 | 450.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 2, 0, 4 | [35.8, -0.1, 78.7] → [65.6, 22.6, 99.2] | 1403.3 | 23.6 | 24.7 | 1.54 | 375.7 | 425.7 | matched | 0.0 | 425.7 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 3, 0, 0 | [56.4, -0.1, 11.1] → [83.1, 22.6, 21.9] | 1164.4 | 22.4 | 14.7 | 5.19 | 362.8 | 398.1 | matched | 0.0 | 398.1 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 1, 0, 2 | [26.7, -0.1, 37.7] → [37.5, 22.6, 61.5] | 1217.5 | 27.0 | 29.3 | 1.09 | 343.9 | 371.3 | matched | 0.0 | 371.3 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_46 | b183ae20_hand_of_truth_04 (1024×1024) | 14, 2, 2 | [255.5, 44.2, 54.8] → [317.4, 44.2, 56.0] | 63.7 | 2.9 | 3.1 | 98.70 | 37.0 | 37.0 | matched | 0.0 | 37.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 4, -1, 2 | [87.6, -3.9, 25.0] → [109.7, -3.9, 85.9] | 670.5 | 3.4 | 3.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 5, -1, 3 | [87.6, -3.9, 25.0] → [109.7, -3.9, 85.9] | 670.5 | 3.4 | 3.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_46 | b183ae20_hand_of_truth_04 (1024×1024) | 15, 2, 5 | [286.4, 44.2, 99.7] → [317.4, 44.2, 100.9] | 18.3 | 3.7 | 3.7 | 98.70 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_94 | 398fc819_hand_of_truth_03 (1024×1024) | 3, -1, 3 | [61.1, -4.6, 7.6] → [95.3, -4.6, 105.5] | 1675.4 | 4.3 | 4.4 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_94 | 398fc819_hand_of_truth_03 (1024×1024) | 4, -1, 2 | [61.1, -4.6, 7.6] → [102.8, -0.0, 105.5] | 1841.7 | 5.4 | 4.4 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 1, -1, 1 | [23.1, -1.1, 8.7] → [45.1, -1.1, 27.4] | 206.3 | 6.2 | 6.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_94 | 398fc819_hand_of_truth_03 (1024×1024) | 6, 2, 1 | [116.8, 37.5, 37.2] → [135.4, 47.4, 37.2] | 82.7 | 6.7 | 6.2 | 69.79 | 18.2 | 18.2 | matched | 0.0 | 18.2 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 1, 1, 2 | [25.3, 18.4, 40.7] → [37.1, 23.0, 61.2] | 95.0 | 17.6 | 0.0 | 60096.78 | 11.3 | 20.1 | matched | 0.0 | 20.1 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 5, 1, 4 | [98.0, 17.9, 75.8] → [123.2, 22.9, 88.1] | 101.9 | 18.4 | 0.0 | 60096.78 | 55.8 | 61.6 | matched | 0.0 | 61.6 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 3, 1, 0 | [57.1, 18.7, 9.7] → [82.3, 23.0, 21.6] | 118.0 | 12.5 | 0.0 | 60096.78 | 41.3 | 53.0 | matched | 0.0 | 53.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 3, 1, 4 | [57.1, 18.4, 88.1] → [82.3, 23.0, 99.9] | 119.9 | 13.5 | 0.0 | 60096.78 | 30.4 | 42.9 | matched | 0.0 | 42.9 | repeat/repeat |
| dungeon-of-dalanis | floor | runewaker_material_117 | 672a35fb_hand_of_truth_01 (1024×1024) | 6, 0, 4 | [114.3, 0.6, 77.8] → [144.9, 19.9, 88.8] | 180.3 | 15.3 | 0.4 | 8933.44 | 76.1 | 89.8 | matched | 0.0 | 89.8 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_80 | 9c5c5b8e_hand_of_truth_11 (1024×1024) | 7, 0, -1 | [149.3, -0.2, -41.6] → [153.2, 1.4, 22.7] | 82.4 | 31.5 | 24.7 | 3756.05 | 74.0 | 74.0 | matched | 0.0 | 74.0 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_43 | 50046bcf_hand_of_truth_05 (512×512) | 8, 0, -3 | [161.3, 0.2, -41.4] → [182.8, 15.3, -40.4] | 239.3 | 30.4 | 29.3 | 1579.22 | 227.5 | 227.5 | matched | 0.0 | 227.5 | repeat/repeat |
| dungeon-of-dalanis | wall | runewaker_material_27 | fade0b46_hand_of_truth_06 (1024×1024) | 19, -1, 7 | [380.8, -0.7, 131.8] → [398.9, 0.0, 160.5] | 20.7 | 20.5 | 10.4 | 1327.96 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -2 | [276.2, -19.9, -53.8] → [349.8, -0.1, -19.3] | 914.3 | 6.0 | 1.3 | 20.75 | 863.2 | 863.2 | matched | 2.1 | 861.1 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 14, -1, -2 | [276.2, -19.9, -53.8] → [325.0, -0.1, -19.0] | 918.1 | 6.8 | 1.1 | 69.79 | 854.1 | 854.1 | matched | 2.1 | 852.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -3 | [279.3, -20.2, -54.5] → [346.4, 2.5, -44.3] | 1135.3 | 21.1 | 12.3 | 2.18 | 376.3 | 376.3 | matched | 0.0 | 376.3 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -2 | [279.3, -20.2, -28.9] → [346.4, 2.5, -20.0] | 984.9 | 21.0 | 10.4 | 2.18 | 365.3 | 365.3 | matched | 0.0 | 365.3 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -3 | [279.3, -20.2, -54.5] → [346.4, 2.5, -44.3] | 1066.8 | 22.0 | 10.4 | 2.18 | 354.6 | 354.6 | matched | 0.0 | 354.6 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_230 | e7d31840_monastary_b1_03 (1024×1024) | 12, -1, 0 | [230.1, -19.9, 0.7] → [259.4, -19.9, 23.3] | 386.7 | 43.7 | 41.5 | 1.09 | 331.6 | 331.6 | matched | 0.0 | 331.6 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -2 | [279.3, -20.2, -28.9] → [346.4, 2.5, -20.0] | 977.4 | 21.4 | 10.4 | 2.18 | 305.0 | 305.0 | matched | 0.0 | 305.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_7 | e259dc58_monastary_b3_01 (2048×2048) | 17, -1, -3 | [351.1, -19.9, -57.0] → [370.6, 1.0, -39.5] | 607.5 | 75.7 | 49.4 | 3.67 | 258.8 | 258.8 | matched | 0.0 | 258.8 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 16, -1, -3 | [276.2, -19.9, -54.4] → [349.8, -0.1, -36.6] | 724.3 | 1.5 | 1.3 | 234.75 | 25.1 | 25.1 | matched | 2.1 | 23.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 17, -1, -2 | [325.0, -19.9, -53.8] → [357.0, -0.1, -19.0] | 253.1 | 2.6 | 2.2 | 69.79 | 20.6 | 20.6 | matched | 1.1 | 19.6 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 14, -1, -5 | [292.3, -19.9, -82.0] → [297.0, -17.2, -80.8] | 28.4 | 4.3 | 1.3 | 2233.36 | 9.9 | 9.9 | matched | 0.0 | 9.9 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 16, -1, -5 | [328.1, -19.9, -82.0] → [333.2, -17.2, -80.8] | 28.4 | 4.3 | 1.3 | 2233.36 | 9.9 | 9.9 | matched | 0.0 | 9.9 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 18, -1, -2 | [375.9, -10.6, -36.6] → [380.3, 4.7, -25.8] | 183.4 | 6.5 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_9 | 1af1c123_monastary_light (128×128) | 18, -1, -3 | [375.9, -10.6, -47.4] → [380.3, 4.7, -36.6] | 183.4 | 6.5 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_47 | addd5d4d_monastary_b (1024×1024) | 15, -1, -3 | [298.3, -18.9, -54.4] → [319.3, -0.1, -44.3] | 76.2 | 6.6 | 1.1 | 83.00 | 15.2 | 15.2 | matched | 2.1 | 13.1 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 14, -1, 0 | [291.0, -19.9, -0.1] → [297.0, -17.2, 8.7] | 38.7 | 36.1 | 1.5 | 2233.36 | 10.5 | 10.5 | matched | 1.2 | 9.3 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 16, -1, 0 | [328.1, -19.9, -0.1] → [334.6, -17.2, 8.7] | 38.7 | 32.0 | 1.5 | 2233.36 | 3.7 | 3.7 | matched | 1.2 | 2.5 | repeat/repeat |
| forsaken-abbey | wall | runewaker_material_36 | b149f06d_wardrobe (256×256) | 16, -1, -4 | [323.8, -19.9, -78.9] → [334.6, -17.2, -59.7] | 63.0 | 19.1 | 1.5 | 2233.36 | 11.3 | 11.3 | matched | 2.3 | 8.9 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_36 | b149f06d_wardrobe (256×256) | 16, -1, -1 | [323.8, -19.3, -13.6] → [334.6, -17.2, -5.1] | 10.7 | 11.8 | 1.3 | 1878.02 | 1.6 | 1.6 | matched | 0.0 | 1.6 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_272 | 2eb30c66_monastary_b1_01 (2048×2048) | 12, 0, -1 | [217.7, 1.4, -9.2] → [280.3, 1.4, -7.7] | 65.9 | 67.3 | 69.8 | 1878.02 | 47.5 | 47.5 | matched | 0.0 | 47.5 | repeat/repeat |
| forsaken-abbey | floor | runewaker_material_36 | b149f06d_wardrobe (256×256) | 16, -1, -4 | [323.8, -19.3, -78.9] → [334.6, -17.2, -59.7] | 13.5 | 7.1 | 1.3 | 1579.22 | 2.3 | 2.3 | matched | 0.0 | 2.3 | repeat/repeat |
| grafu-castle | floor | runewaker_material_104 | 198ed5db_glafulcellar_floor (512×512) | 3, -1, 5 | [57.0, -4.1, 86.7] → [88.4, -2.8, 131.3] | 2265.4 | 14.7 | 8.7 | 1.83 | 1374.9 | 1374.9 | matched | 1375.5 | -0.6 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -5, -1, 3 | [-100.7, -19.6, 58.2] → [-85.0, -3.0, 80.2] | 1136.4 | 35.2 | 41.5 | 2.18 | 726.4 | 756.3 | matched | 0.0 | 756.3 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, 0, 5 | [-140.0, -3.1, 97.0] → [-132.9, 21.4, 142.0] | 779.6 | 48.9 | 41.5 | 1.54 | 614.3 | 614.3 | matched | 0.0 | 614.3 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -1, 6 | [-140.0, -20.1, 98.1] → [-132.8, -0.5, 143.2] | 701.8 | 44.3 | 34.9 | 1.54 | 561.1 | 561.1 | matched | 0.0 | 561.1 | repeat/repeat |
| grafu-castle | wall | runewaker_material_75 | fb9c40d5_glafulcellar_room01 (1024×1024) | 1, 1, 2 | [-5.8, 12.8, 52.5] → [46.3, 37.5, 66.8] | 799.4 | 7.2 | 0.8 | 939.01 | 525.1 | 525.1 | matched | 0.0 | 525.1 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | 4, -1, -9 | [78.1, -28.2, -173.6] → [111.2, 10.2, -170.4] | 709.2 | 55.9 | 49.4 | 1.30 | 510.4 | 510.4 | matched | 0.0 | 510.4 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | 5, -1, -9 | [78.2, -28.2, -173.1] → [122.6, 10.2, -160.2] | 618.9 | 43.6 | 49.4 | 1.30 | 500.0 | 503.7 | matched | 0.0 | 503.7 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -1, -3 | [-193.6, -18.4, -60.1] → [-176.4, -1.8, -37.3] | 914.0 | 36.9 | 41.5 | 1.83 | 468.4 | 487.1 | matched | 0.0 | 487.1 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -8, -2, -4 | [-181.0, -24.4, -71.5] → [-137.7, -23.7, -71.5] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -10, -2, -7 | [-187.8, -24.4, -158.7] → [-187.8, -23.7, -115.3] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -2, -8 | [-137.7, -24.4, -158.6] → [-94.3, -23.7, -158.6] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -8, -2, -8 | [-181.0, -24.4, -158.6] → [-137.7, -23.7, -158.6] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -9, -2, -4 | [-181.0, -24.4, -71.5] → [-137.7, -23.7, -71.5] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -6, -2, -8 | [-137.7, -24.4, -158.6] → [-94.3, -23.7, -158.6] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | wall | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | -7, -2, -4 | [-137.7, -24.4, -71.5] → [-94.3, -23.7, -71.5] | 15.8 | 0.0 | 0.0 | 60096.78 | 0.0 | 15.8 | matched | 0.0 | 15.8 | repeat/repeat |
| grafu-castle | floor | runewaker_material_58 | bb8f2512_glafulcellar_room03_b (1024×1024) | -10, -1, 1 | [-201.0, -18.8, -6.4] → [-179.7, -1.3, 81.5] | 707.9 | 0.0 | 0.0 | 60096.78 | 0.0 | 584.8 | matched | 0.0 | 584.8 | repeat/repeat |
| grafu-castle | floor | runewaker_material_106 | 63421b14_glafulcellar_wall_pillar (1024×1024) | 3, -1, -5 | [59.5, -19.0, -100.6] → [71.3, -14.7, -79.9] | 19.4 | 20.4 | 0.0 | 60096.78 | 8.5 | 10.3 | matched | 0.0 | 10.3 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 7, 0, 4 | [92.7, 1.5, 69.8] → [195.9, 1.5, 122.0] | 1321.2 | — | — | — | 1321.2 | 1321.2 | matched | 0.0 | 1321.2 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -10, -1, -2 | [-231.2, -17.6, -43.5] → [-155.7, -17.6, -10.4] | 1250.6 | — | — | — | 1250.6 | 1250.6 | matched | 0.0 | 1250.6 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_182 | dc5d3470_hall_of_the_ancients_access_05 (1024×1024) | -9, -2, -7 | [-187.3, -20.1, -150.0] → [-158.7, -20.1, -107.7] | 1206.4 | — | — | — | 1206.4 | 1206.4 | matched | 0.0 | 1206.4 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 3, -1, 13 | [35.9, -1.8, 240.1] → [87.6, -1.8, 284.0] | 1134.7 | — | — | — | 1134.7 | 1134.7 | matched | 0.0 | 1134.7 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 10, 0, 7 | [185.6, 1.5, 122.0] → [226.9, 1.5, 177.0] | 907.2 | — | — | — | 907.2 | 907.2 | matched | 0.0 | 907.2 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_45 | 7e7f14f4_hall_of_the_ancients_access_09 (1024×1024) | -2, -1, -13 | [-40.2, -18.7, -271.9] → [-15.1, -18.7, -199.1] | 872.5 | — | — | — | 872.5 | 872.5 | matched | 0.0 | 872.5 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -9, 0, -3 | [-182.6, -7.8, -72.2] → [-151.7, 19.0, -35.8] | 1717.7 | 9.9 | 7.3 | 6.17 | 870.2 | 870.2 | matched | 0.0 | 870.2 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | 8, 0, 7 | [154.5, 1.5, 122.0] → [195.9, 1.5, 177.0] | 854.6 | — | — | — | 854.6 | 854.6 | matched | 0.0 | 854.6 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -8, 1, -8 | [-168.9, 21.5, -144.2] → [-138.9, 21.7, -142.8] | 52.1 | 0.0 | 0.0 | 60096.78 | 22.4 | 52.1 | matched | 0.0 | 52.1 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -8, 0, 2 | [-144.9, 0.6, 25.1] → [-143.5, 2.7, 61.1] | 50.2 | 0.0 | 0.0 | 60096.78 | 0.0 | 44.7 | matched | 0.0 | 44.7 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -6, -5, -5 | [-115.4, -81.9, -88.4] → [-109.5, -81.8, -86.5] | 10.8 | 0.1 | 0.1 | 60096.78 | 0.0 | 10.0 | matched | 0.0 | 10.0 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | 9, 1, 6 | [177.4, 34.6, 122.7] → [190.1, 35.6, 157.8] | 331.5 | 0.2 | 0.0 | 60096.78 | 56.5 | 236.8 | matched | 0.0 | 236.8 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | 8, 1, 7 | [175.3, 35.4, 123.0] → [178.2, 35.6, 171.1] | 166.0 | 0.2 | 0.0 | 60096.78 | 0.0 | 107.5 | matched | 0.0 | 107.5 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -7, 3, -7 | [-132.1, 56.7, -151.3] → [-132.1, 76.5, -108.5] | 298.7 | 0.3 | 0.2 | 663.98 | 0.0 | 281.7 | matched | 0.0 | 281.7 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_60 | 2ffab050_hall_of_the_ancients_access (1024×1024) | -3, -1, 5 | [-48.0, -3.3, 88.4] → [-46.8, -2.3, 152.5] | 29.1 | 0.3 | 0.3 | 7512.10 | 13.4 | 29.1 | matched | 0.0 | 29.1 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_54 | b1b76b6d_hall_of_the_ancients_access_06 (1024×1024) | 1, -1, -5 | [19.3, -5.6, -96.7] → [40.1, -0.5, -91.8] | 100.9 | 0.5 | 0.0 | 60096.78 | 0.0 | 47.2 | matched | 0.0 | 47.2 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -3, -5, -5 | [-56.6, -81.9, -88.4] → [-50.6, -81.8, -86.5] | 10.8 | 0.9 | 0.1 | 60096.78 | 0.0 | 6.2 | matched | 0.0 | 6.2 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -8, -1, 2 | [-146.2, -17.1, 36.8] → [-144.6, -5.9, 42.9] | 10.9 | 2.5 | 0.1 | 60096.78 | 0.0 | 4.4 | matched | 0.0 | 4.4 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -3, 0, 3 | [-46.6, 0.3, 73.7] → [-44.6, 1.2, 87.5] | 17.6 | 2.2 | 0.0 | 60096.78 | 0.0 | 6.4 | matched | 0.0 | 6.4 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -9, -1, -1 | [-181.0, -17.6, -21.7] → [-166.9, -15.6, -18.3] | 24.3 | 13.8 | 0.0 | 60096.78 | 0.0 | 2.5 | matched | 0.0 | 2.5 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -11, -1, -1 | [-219.9, -17.6, -21.7] → [-205.8, -15.6, -18.3] | 24.3 | 7.0 | 0.1 | 60096.78 | 0.0 | 5.5 | matched | 0.0 | 5.5 | repeat/repeat |
| hall-of-survivors | wall | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -5, -2, -5 | [-90.4, -24.2, -88.7] → [-78.4, -17.8, -86.6] | 25.4 | 2.7 | 0.0 | 60096.78 | 0.0 | 9.2 | matched | 0.0 | 9.2 | repeat/repeat |
| hall-of-survivors | floor | runewaker_material_44 | 93d07bdf_hall_of_the_ancients_access_04 (1024×1024) | -10, -1, 0 | [-200.7, -17.4, 8.8] → [-193.1, -4.8, 10.6] | 25.6 | 6.8 | 0.0 | 60096.78 | 0.0 | 5.5 | matched | 0.0 | 5.5 | repeat/repeat |
| hoto | floor | runewaker_material_96 | 122cc01b_echoes_of_the_sea_01 (1024×1024) | -9, -1, 5 | [-179.6, -16.0, 96.8] → [-158.5, -14.5, 123.4] | 283.2 | 31.6 | 29.3 | 1.83 | 85.6 | 85.6 | matched | 0.0 | 85.6 | repeat/repeat |
| hoto | floor | runewaker_material_96 | 122cc01b_echoes_of_the_sea_01 (1024×1024) | 7, -2, -3 | [137.2, -28.2, -66.1] → [161.6, -21.0, -47.6] | 420.5 | 3.2 | 0.0 | 60096.78 | 0.0 | 92.8 | matched | 0.0 | 92.8 | repeat/repeat |
| hoto | floor | runewaker_material_96 | 122cc01b_echoes_of_the_sea_01 (1024×1024) | 1, -1, -1 | [23.8, -8.0, -17.0] → [43.3, 0.6, -3.8] | 188.7 | 23.4 | 24.7 | 1.83 | 35.8 | 43.3 | matched | 0.0 | 43.3 | repeat/repeat |
| hoto | floor | runewaker_material_96 | 122cc01b_echoes_of_the_sea_01 (1024×1024) | -3, -1, -2 | [-70.8, -11.9, -41.7] → [-45.9, -8.4, -15.8] | 291.9 | 43.6 | 24.7 | 1.83 | 19.6 | 19.6 | matched | 0.0 | 19.6 | repeat/repeat |
| hoto | floor | runewaker_material_96 | 122cc01b_echoes_of_the_sea_01 (1024×1024) | 7, -2, -4 | [138.0, -28.2, -84.4] → [163.9, -20.7, -53.9] | 476.6 | 5.0 | 17.4 | 1.54 | 0.0 | 31.1 | matched | 0.0 | 31.1 | repeat/repeat |
| hoto | wall | runewaker_material_17 | f693fbc4_rock_pillar_02 (1024×1024) | -10, -1, 0 | [-195.0, -22.5, 8.4] → [-189.6, 4.0, 14.8] | 168.3 | 116.6 | 69.8 | 3.67 | 8.5 | 8.5 | matched | 0.0 | 8.5 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | 4, -1, -3 | [80.4, -20.4, -58.9] → [100.7, 0.5, -44.0] | 486.0 | 22.7 | 20.7 | 1.30 | 3.9 | 3.9 | matched | 3.9 | -0.0 | repeat/repeat |
| hoto | floor | runewaker_material_17 | f693fbc4_rock_pillar_02 (1024×1024) | 4, -2, 2 | [80.1, -33.0, 38.8] → [95.5, -25.2, 55.3] | 78.0 | 53.3 | 41.5 | 1.54 | 3.3 | 3.3 | matched | 0.0 | 3.3 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 1, -2, -3 | [34.3, -22.5, -63.4] → [41.1, -21.7, -50.7] | 44.8 | 3.3 | 0.9 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 3, -2, -3 | [57.0, -22.6, -56.7] → [80.4, -21.1, -44.7] | 124.8 | 3.9 | 3.1 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 4, -2, -4 | [80.3, -22.6, -97.9] → [104.3, -22.4, -49.6] | 568.2 | 4.1 | 3.7 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 2, -2, -7 | [56.8, -22.4, -121.4] → [66.1, -21.8, -118.8] | 12.5 | 4.1 | 4.4 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 2, -2, -6 | [43.8, -22.6, -121.4] → [62.8, -20.9, -100.2] | 208.4 | 4.2 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 2, -2, -3 | [39.6, -22.6, -65.2] → [63.8, -21.7, -49.0] | 130.9 | 4.2 | 3.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 1, -2, -4 | [32.9, -22.5, -72.4] → [45.0, -21.7, -60.7] | 47.5 | 4.2 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 0, -2, 3 | [9.0, -30.3, 55.9] → [19.8, -30.2, 82.2] | 176.4 | 8.7 | 5.2 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 1, -2, 3 | [21.1, -24.2, 54.3] → [42.1, -23.4, 83.3] | 394.9 | 12.6 | 4.4 | 49.35 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | wall | runewaker_material_53 | 5c863840_echoes_of_the_sea_04 (1024×1024) | -1, -2, 5 | [-10.3, -24.7, 100.0] → [-1.4, -19.0, 105.3] | 45.5 | 17.4 | 6.2 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 7, -2, -2 | [134.1, -38.4, -42.4] → [164.7, -22.4, -19.0] | 366.8 | 12.9 | 5.2 | 20.75 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | 1, -1, 5 | [29.1, -19.5, 106.6] → [43.7, -19.3, 119.1] | 129.9 | 11.8 | 6.2 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| hoto | floor | runewaker_material_26 | 5672f062_echoes_of_the_sea_02 (1024×1024) | -6, -1, 4 | [-126.7, -11.0, 81.3] → [-94.2, -10.0, 98.8] | 295.5 | 11.1 | 8.7 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_156 | abc91e5f_pillar_a (1024×512) | -8, 0, -5 | [-151.4, 4.6, -94.6] → [-146.1, 22.8, -81.3] | 676.2 | 39.6 | 41.5 | 1.09 | 375.5 | 375.5 | matched | 0.0 | 375.5 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_156 | abc91e5f_pillar_a (1024×512) | -8, 2, -5 | [-151.3, 40.7, -96.5] → [-146.2, 60.3, -79.3] | 761.4 | 42.7 | 41.5 | 1.30 | 295.9 | 295.9 | matched | 0.0 | 295.9 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_29 | cc9861ff_decoration_01 (1024×512) | 6, 3, -12 | [126.0, 63.3, -246.6] → [137.5, 78.1, -226.9] | 482.7 | 19.9 | 14.7 | 1.83 | 292.8 | 292.8 | matched | 0.0 | 292.8 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_156 | abc91e5f_pillar_a (1024×512) | -3, 2, -14 | [-60.3, 45.3, -271.5] → [-39.5, 61.5, -265.5] | 664.2 | 42.0 | 41.5 | 5.19 | 276.8 | 276.8 | matched | 0.0 | 276.8 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_29 | cc9861ff_decoration_01 (1024×512) | 6, 3, -13 | [126.0, 63.3, -250.8] → [137.5, 78.1, -238.8] | 457.4 | 19.9 | 17.4 | 1.30 | 268.7 | 268.7 | matched | 0.0 | 268.7 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_29 | cc9861ff_decoration_01 (1024×512) | -7, 1, -5 | [-136.0, 21.7, -93.8] → [-116.3, 36.6, -82.2] | 509.4 | 19.3 | 17.4 | 2.18 | 259.3 | 259.3 | matched | 0.0 | 259.3 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_156 | abc91e5f_pillar_a (1024×512) | -3, 2, -11 | [-52.3, 40.3, -208.7] → [-39.5, 61.5, -202.9] | 518.7 | 41.9 | 41.5 | 14.67 | 240.1 | 240.1 | matched | 0.0 | 240.1 | repeat/repeat |
| ice-dwarf-kingdom | wall | runewaker_material_156 | abc91e5f_pillar_a (1024×512) | -8, 3, -5 | [-151.3, 60.5, -96.5] → [-146.2, 73.8, -79.3] | 618.8 | 43.2 | 41.5 | 2.18 | 231.8 | 231.8 | matched | 0.0 | 231.8 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_135 | 54fb4d98_beam_01 (512×512) | 6, 5, -8 | [119.3, 115.9, -149.1] → [126.4, 115.9, -142.2] | 24.3 | 0.0 | 0.0 | 60096.78 | 0.0 | 24.3 | matched | 0.0 | 24.3 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_135 | 54fb4d98_beam_01 (512×512) | -2, 4, -10 | [-32.3, 83.9, -191.2] → [-25.5, 83.9, -164.7] | 67.1 | 0.0 | 0.0 | 60096.78 | 0.0 | 67.1 | matched | 0.0 | 67.1 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -4, 1, -15 | [-71.2, 37.8, -300.4] → [-58.5, 40.3, -291.2] | 100.7 | 0.0 | 0.0 | 60096.78 | 0.0 | 100.7 | matched | 0.0 | 100.7 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -4, 2, -14 | [-71.2, 42.8, -281.8] → [-58.5, 46.5, -267.8] | 162.1 | 0.0 | 0.0 | 60096.78 | 0.0 | 162.1 | matched | 0.0 | 162.1 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -4, 2, -15 | [-71.2, 39.5, -294.3] → [-58.5, 43.6, -278.6] | 164.3 | 0.0 | 0.0 | 60096.78 | 124.1 | 164.3 | matched | 0.0 | 164.3 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_135 | 54fb4d98_beam_01 (512×512) | -8, 3, -4 | [-152.2, 74.4, -131.3] → [-145.3, 74.4, -45.2] | 295.2 | 0.0 | 0.0 | 60096.78 | 0.0 | 295.2 | matched | 0.0 | 295.2 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_135 | 54fb4d98_beam_01 (512×512) | 4, 4, -8 | [52.4, 97.3, -149.1] → [126.5, 97.8, -142.2] | 311.9 | 0.0 | 0.0 | 60096.78 | 0.0 | 311.9 | matched | 0.0 | 311.9 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_135 | 54fb4d98_beam_01 (512×512) | 4, 5, -8 | [59.1, 115.9, -149.1] → [126.4, 115.9, -142.2] | 230.6 | 0.0 | 0.0 | 60096.78 | 0.0 | 230.6 | matched | 0.0 | 230.6 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -6, 0, 0 | [-118.8, 8.2, 2.7] → [-117.3, 8.7, 10.0] | 10.9 | 9.2 | 0.1 | 60096.78 | 5.4 | 7.2 | matched | 0.0 | 7.2 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -5, 1, -16 | [-96.7, 38.0, -313.8] → [-94.3, 39.1, -306.5] | 17.9 | 34.9 | 0.1 | 60096.78 | 5.3 | 7.1 | matched | 0.0 | 7.1 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -6, 1, -15 | [-103.2, 38.0, -293.3] → [-100.7, 39.1, -286.0] | 17.9 | 39.7 | 0.1 | 60096.78 | 3.5 | 5.4 | matched | 0.0 | 5.4 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -11, 1, 3 | [-212.0, 20.1, 61.4] → [-209.5, 21.3, 68.7] | 18.1 | 23.1 | 0.1 | 60096.78 | 9.0 | 10.9 | matched | 0.0 | 10.9 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | 1, 1, -14 | [19.0, 22.2, -282.4] → [22.2, 23.8, -271.7] | 20.8 | 42.6 | 1.8 | 60096.78 | 0.0 | 3.2 | matched | 0.0 | 3.2 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -8, 0, 0 | [-157.8, 5.0, 5.2] → [-140.1, 16.9, 12.5] | 32.4 | 0.9 | 58.7 | 60096.78 | 22.4 | 24.2 | matched | 0.0 | 24.2 | repeat/repeat |
| ice-dwarf-kingdom | floor | runewaker_material_126 | 15e1f995_stairs (512×512) | -2, 1, -11 | [-40.3, 20.3, -207.9] → [-31.4, 22.2, -203.8] | 39.1 | 0.6 | 0.0 | 60096.78 | 9.6 | 29.2 | matched | 0.0 | 29.2 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_9 | f8d8c2bb_map_floor_03 (1024×512) | 1, 0, -2 | [19.2, 1.3, -40.7] → [28.5, 2.7, -31.0] | 68.0 | 118.0 | 117.4 | 2.18 | 1.5 | 1.5 | matched | 0.0 | 1.5 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | -1, 1, -5 | [-13.5, 26.4, -95.6] → [4.8, 28.0, -88.9] | 62.5 | 13.9 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | 0, 1, -5 | [-5.1, 23.0, -97.2] → [23.2, 29.1, -88.9] | 155.4 | 14.1 | 10.4 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 1, 1, -4 | [19.4, 25.4, -70.8] → [24.2, 38.3, -60.2] | 44.8 | 15.3 | 12.3 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 1, -3 | [-20.6, 28.5, -54.7] → [-8.3, 38.1, -46.7] | 53.2 | 15.9 | 12.3 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 0, 1, -4 | [17.2, 30.2, -70.5] → [20.3, 38.9, -63.3] | 33.4 | 17.0 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 0, -2 | [-21.0, -0.1, -23.8] → [-9.6, 20.8, -19.6] | 26.4 | 22.9 | 20.7 | 2.59 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | -1, 0, -5 | [-21.1, 7.6, -91.4] → [1.7, 21.2, -84.1] | 245.3 | 23.7 | 20.7 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_1 | fd98c79d_map_wall_03 (512×1024) | 1, 0, -5 | [17.3, 1.8, -91.4] → [30.8, 21.2, -87.4] | 170.9 | 23.9 | 24.7 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | -1, -2, 0 | [-4.2, -37.5, 0.0] → [0.3, -22.5, 4.4] | 36.3 | 52.0 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | 0, -2, 0 | [-0.0, -37.5, 0.0] → [4.4, -22.5, 4.4] | 37.6 | 51.1 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 0, 1, -5 | [-2.1, 19.9, -91.4] → [22.7, 31.1, -85.8] | 52.9 | 33.8 | 29.3 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | 0, -2, -1 | [0.1, -37.5, -4.3] → [4.4, -22.5, 2.4] | 73.9 | 51.5 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_8 | f9085f27_pillar_02 (512×1024) | -1, -2, -1 | [-4.2, -37.5, -4.3] → [0.3, -22.5, 2.4] | 74.7 | 51.3 | 49.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 1, -5 | [-17.4, 19.9, -90.9] → [1.0, 29.7, -80.9] | 22.1 | 34.1 | 24.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | floor | runewaker_material_4 | 04531090_accessory_03 (512×512) | -1, 0, -5 | [-19.9, 0.4, -90.5] → [-2.3, 20.1, -79.7] | 22.1 | 27.7 | 20.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kal-turok-hive | wall | runewaker_material_4 | 04531090_accessory_03 (512×512) | 0, 0, -5 | [12.7, -0.3, -89.8] → [22.4, 10.3, -83.8] | 37.1 | 29.5 | 24.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -13, -2, -29 | [-259.5, -44.4, -583.7] → [-240.3, -15.7, -559.7] | 427.5 | 65.9 | 49.4 | 1.30 | 310.6 | 310.6 | matched | 0.0 | 310.6 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -15, -2, -29 | [-291.1, -43.3, -578.9] → [-288.2, -15.7, -561.3] | 325.4 | 69.0 | 58.7 | 1.30 | 307.7 | 307.7 | matched | 0.0 | 307.7 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -13, -2, -30 | [-259.5, -44.4, -601.7] → [-240.3, -15.7, -577.8] | 399.6 | 62.5 | 41.5 | 1.54 | 162.5 | 162.5 | matched | 0.0 | 162.5 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -14, -3, -31 | [-282.5, -49.8, -617.0] → [-256.1, -43.1, -603.9] | 185.7 | 43.3 | 24.7 | 939.01 | 143.4 | 143.4 | matched | 0.0 | 143.4 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -15, -2, -31 | [-291.1, -43.3, -612.7] → [-288.3, -15.7, -609.9] | 148.1 | 79.2 | 69.8 | 117.38 | 115.2 | 115.2 | matched | 0.0 | 115.2 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -15, -2, -30 | [-291.1, -43.3, -598.0] → [-288.2, -15.7, -595.2] | 148.1 | 79.2 | 69.8 | 117.38 | 115.2 | 115.2 | matched | 0.0 | 115.2 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -10, -1, -4 | [-202.8, -20.7, -71.6] → [-178.8, 2.4, -60.5] | 639.7 | 67.7 | 49.4 | 2.18 | 86.2 | 86.2 | matched | 0.0 | 86.2 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -10, -1, -5 | [-200.9, -20.2, -101.1] → [-176.9, -6.9, -83.1] | 90.1 | 70.8 | 49.4 | 1.30 | 83.6 | 83.6 | matched | 0.0 | 83.6 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -3, 1, -2 | [-60.9, 19.4, -31.2] → [-51.4, 38.5, -21.7] | 753.2 | 6.6 | 6.2 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -3, 0, -2 | [-60.9, 0.3, -31.2] → [-51.4, 19.4, -21.7] | 753.2 | 6.6 | 6.2 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 0, -2 | [-74.2, 1.9, -34.5] → [-57.2, 38.5, -15.0] | 2214.5 | 6.9 | 7.3 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 1, -2 | [-74.2, 1.9, -34.5] → [-57.2, 38.5, -15.0] | 2522.8 | 7.0 | 7.3 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -4, 0, -1 | [-66.9, 9.4, -24.6] → [-57.4, 23.9, -15.0] | 286.9 | 7.6 | 7.3 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -14, -2, -31 | [-278.6, -44.0, -620.7] → [-270.0, -12.6, -598.5] | 1626.6 | 9.4 | 8.7 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -16, -1, -15 | [-310.1, -22.5, -295.3] → [-300.2, 19.5, -278.1] | 4400.0 | 9.6 | 8.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_122 | 8516bc5d_light_01 (128×128) | -11, 0, -5 | [-215.6, 4.2, -90.3] → [-199.5, 46.2, -79.6] | 1766.0 | 9.6 | 8.7 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_31 | 4833235f_stairs_01 (1024×1024) | -16, -3, -22 | [-312.0, -46.3, -444.1] → [-298.9, -38.3, -420.9] | 301.8 | 14.1 | 0.1 | 60096.78 | 0.0 | 53.9 | matched | 0.0 | 53.9 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_31 | 4833235f_stairs_01 (1024×1024) | -16, -2, -22 | [-312.0, -39.8, -433.8] → [-298.9, -37.0, -420.9] | 30.7 | 52.6 | 1.8 | 7512.10 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_31 | 4833235f_stairs_01 (1024×1024) | -11, -3, -24 | [-220.8, -55.2, -472.2] → [-202.8, -49.8, -459.0] | 38.2 | 57.4 | 1.8 | 7512.10 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_31 | 4833235f_stairs_01 (1024×1024) | -11, -3, -24 | [-225.8, -59.1, -472.2] → [-202.8, -49.8, -459.0] | 403.8 | 18.5 | 1.8 | 558.34 | 0.0 | 18.7 | matched | 0.0 | 18.7 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -12, -3, -15 | [-235.1, -48.1, -297.9] → [-226.1, -40.1, -285.3] | 27.0 | 47.1 | 17.4 | 331.99 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | wall | runewaker_material_31 | 4833235f_stairs_01 (1024×1024) | -16, -2, -22 | [-312.0, -42.1, -431.0] → [-298.9, -37.0, -420.9] | 72.1 | 18.1 | 2.6 | 331.99 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kalins-shrine | floor | runewaker_material_108 | 059f00fb_accessory_01 (2048×1024) | -12, -3, -16 | [-240.3, -48.1, -309.5] → [-234.6, -41.9, -300.9] | 35.5 | 42.9 | 29.3 | 117.38 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_65 | 7d64536e_wall (512×512) | 1, -2, -27 | [24.5, -64.2, -530.9] → [69.1, 29.5, -513.0] | 2921.7 | — | — | — | 2921.7 | 2921.7 | matched | 0.0 | 2921.7 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_75 | a645ef8a_lymor grave_05 (1024×1024) | 1, 1, -25 | [23.8, 21.8, -531.7] → [54.9, 21.8, -402.5] | 2005.6 | — | — | — | 2005.6 | 2005.6 | matched | 0.0 | 2005.6 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_75 | a645ef8a_lymor grave_05 (1024×1024) | 4, 1, -27 | [23.8, 21.8, -531.7] → [178.9, 21.8, -510.2] | 1669.5 | — | — | — | 1669.5 | 1669.5 | matched | 0.0 | 1669.5 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_65 | 7d64536e_wall (512×512) | 1, 1, -24 | [30.4, 6.0, -530.9] → [30.4, 29.5, -406.1] | 1460.6 | — | — | — | 1460.6 | 1460.6 | matched | 0.0 | 1460.6 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_75 | a645ef8a_lymor grave_05 (1024×1024) | 2, 1, -23 | [23.8, 21.8, -510.2] → [54.9, 21.8, -402.5] | 1337.0 | — | — | — | 1337.0 | 1337.0 | matched | 0.0 | 1337.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_65 | 7d64536e_wall (512×512) | 1, -3, -22 | [30.4, -64.2, -450.7] → [30.4, 6.0, -406.1] | 1251.9 | — | — | — | 1251.9 | 1251.9 | matched | 0.0 | 1251.9 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 5, -2, -18 | [73.8, -29.7, -372.9] → [125.0, -27.2, -320.2] | 1234.3 | — | — | — | 1234.3 | 1234.3 | matched | 0.0 | 1234.3 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_101 | e7440828_lymor grave_03 (1024×512) | -9, -1, -13 | [-204.4, -9.5, -261.1] → [-122.5, -9.5, -218.9] | 1188.3 | — | — | — | 1188.3 | 1188.3 | matched | 0.0 | 1188.3 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 1, 0, -4 | [5.1, 11.2, -79.6] → [53.6, 11.3, -53.4] | 860.8 | 0.0 | 0.0 | 60096.78 | 269.5 | 860.8 | matched | 0.0 | 860.8 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | -3, -1, -11 | [-63.7, -9.5, -229.5] → [-53.8, -6.3, -208.4] | 105.0 | 0.0 | 0.0 | 60096.78 | 0.0 | 100.6 | matched | 0.0 | 100.6 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 2, 0, -5 | [36.5, 11.5, -88.1] → [79.4, 13.1, -73.1] | 497.8 | 0.0 | 0.0 | 60096.78 | 293.2 | 497.8 | matched | 0.0 | 497.8 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | -6, -1, -15 | [-114.9, -14.1, -335.0] → [-89.3, -9.5, -250.6] | 1540.4 | 0.2 | 0.0 | 60096.78 | 0.0 | 943.8 | matched | 0.0 | 943.8 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 5, -2, -29 | [88.7, -39.2, -574.4] → [115.9, -15.8, -551.0] | 145.9 | 0.4 | 0.0 | 60096.78 | 118.5 | 135.5 | matched | 92.8 | 42.7 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_101 | e7440828_lymor grave_03 (1024×512) | -8, 0, -12 | [-155.9, 11.6, -228.9] → [-132.7, 11.6, -221.7] | 82.5 | 0.5 | 0.5 | 8933.44 | 0.0 | 80.1 | matched | 0.0 | 80.1 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | 5, -2, -20 | [89.3, -29.7, -420.2] → [112.6, -27.2, -381.5] | 380.6 | 1.0 | 0.9 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_113 | 85c4c5d7_lymor grave_02 (1024×512) | -1, 0, -10 | [-25.0, 2.1, -201.5] → [13.4, 12.4, -173.2] | 126.3 | 1.6 | 0.5 | 7512.10 | 19.4 | 71.0 | matched | 0.0 | 71.0 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 0, 0, -3 | [0.5, 11.0, -79.0] → [7.6, 13.1, -40.2] | 40.0 | 1.7 | 0.0 | 60096.78 | 0.0 | 7.9 | matched | 0.0 | 7.9 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 0, 0, -4 | [0.5, 11.0, -79.0] → [22.2, 13.1, -40.2] | 83.3 | 2.8 | 0.0 | 60096.78 | 35.7 | 43.5 | matched | 0.0 | 43.5 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_135 | f5f9c656_lymor grave_hall (1024×512) | 2, 0, -4 | [36.5, 11.0, -79.0] → [58.2, 13.1, -43.2] | 85.9 | 4.8 | 0.0 | 60096.78 | 35.7 | 43.5 | matched | 0.0 | 43.5 | repeat/repeat |
| kawaks-tomb | floor | runewaker_material_29 | 9ffb59bb_quicksand_01 (256×256) | -2, -2, -17 | [-27.9, -30.7, -345.9] → [-17.2, -27.0, -320.2] | 147.7 | 10.4 | 0.0 | 60096.78 | 0.0 | 17.7 | matched | 0.0 | 17.7 | repeat/repeat |
| kawaks-tomb | wall | runewaker_material_89 | 772d99bb_lymor grave_aisle_01 (1024×512) | -3, -1, -16 | [-60.5, -10.2, -317.3] → [-39.0, -6.5, -295.3] | 156.6 | 7.3 | 0.0 | 60096.78 | 32.8 | 47.9 | matched | 0.0 | 47.9 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_77 | b58cac78_nest_broken_bridges (1024×1024) | 3, 2, 3 | [59.1, 43.2, 56.9] → [72.5, 47.8, 69.5] | 135.4 | 57.1 | 58.7 | 1.30 | 108.4 | 108.4 | matched | 0.0 | 108.4 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_58 | 75aecb18_nest_cliff_e (1024×1024) | 4, 1, -7 | [79.2, 13.2, -134.0] → [101.7, 30.6, -129.9] | 285.1 | 72.1 | 69.8 | 1.54 | 78.9 | 78.9 | matched | 0.0 | 78.9 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_38 | 417fce81_nest_room_factitiouswall_a (1024×1024) | -3, -3, 1 | [-63.2, -51.9, 25.6] → [-38.6, -49.5, 31.4] | 217.0 | 63.6 | 58.7 | 1.30 | 78.5 | 78.5 | matched | 0.0 | 78.5 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_24 | 542f3ea4_nest_room_factitiouswall_b (1024×1024) | 3, 1, 1 | [72.9, 30.7, 32.1] → [84.0, 38.0, 38.9] | 105.4 | 56.5 | 58.7 | 1.30 | 67.1 | 67.1 | matched | 0.0 | 67.1 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_38 | 417fce81_nest_room_factitiouswall_a (1024×1024) | -3, -2, 1 | [-64.2, -42.4, 25.6] → [-33.5, -22.2, 29.3] | 457.6 | 82.6 | 69.8 | 1.09 | 63.9 | 63.9 | matched | 0.0 | 63.9 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_38 | 417fce81_nest_room_factitiouswall_a (1024×1024) | -3, -3, 1 | [-64.2, -53.4, 25.6] → [-38.6, -42.4, 29.3] | 247.4 | 81.6 | 69.8 | 1.09 | 55.4 | 55.4 | matched | 0.0 | 55.4 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_38 | 417fce81_nest_room_factitiouswall_a (1024×1024) | -3, -2, 1 | [-63.2, -31.7, 25.6] → [-33.5, -29.3, 31.4] | 219.2 | 66.1 | 58.7 | 1.09 | 55.0 | 55.0 | matched | 0.0 | 55.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_40 | 1cd36d98_nest_room_post_a (1024×1024) | -2, 0, 3 | [-39.9, 13.8, 61.0] → [-21.8, 26.7, 70.0] | 329.9 | 54.0 | 49.4 | 1.30 | 49.1 | 49.1 | matched | 0.0 | 49.1 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 4, 3, -7 | [87.3, 59.3, -137.6] → [100.4, 67.3, -131.0] | 328.5 | 13.7 | 10.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 1, -8 | [99.3, 16.7, -146.7] → [112.4, 41.0, -141.7] | 1546.6 | 13.8 | 10.4 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 2, -8 | [98.7, 39.5, -148.1] → [112.4, 55.3, -141.7] | 605.5 | 13.9 | 12.3 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_22 | cd2d62d5_nest_cliff_f (1024×1024) | 4, 3, 2 | [89.3, 56.2, 46.1] → [94.5, 65.8, 52.3] | 33.4 | 15.0 | 14.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 2, -7 | [99.3, 33.9, -141.0] → [101.2, 59.3, -136.4] | 93.3 | 16.4 | 12.3 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 4, 2, -7 | [85.5, 33.9, -140.8] → [101.0, 60.7, -131.4] | 1208.5 | 16.4 | 10.4 | 4.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 5, 3, -7 | [99.7, 59.3, -138.4] → [101.8, 64.8, -135.6] | 44.0 | 16.4 | 12.3 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_9 | 867dbb75_gate_waterfall (256×256) | 4, 1, -8 | [91.6, 16.7, -145.4] → [102.2, 43.7, -138.8] | 517.2 | 17.0 | 12.3 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_24 | 542f3ea4_nest_room_factitiouswall_b (1024×1024) | 2, 1, 5 | [55.5, 27.7, 96.2] → [56.6, 29.7, 107.8] | 28.2 | 60.6 | 41.5 | 139.58 | 5.9 | 5.9 | matched | 0.0 | 5.9 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_25 | a856e894_statue_a (1024×512) | 5, 2, -1 | [99.2, 53.7, -17.3] → [104.8, 58.4, 0.6] | 48.5 | 37.1 | 7.3 | 83.00 | 5.5 | 5.5 | matched | 0.0 | 5.5 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_25 | a856e894_statue_a (1024×512) | 4, 2, 1 | [80.1, 53.9, 32.0] → [100.3, 58.4, 33.0] | 58.0 | 37.4 | 7.3 | 83.00 | 9.8 | 9.8 | matched | 0.0 | 9.8 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_25 | a856e894_statue_a (1024×512) | -8, 2, 5 | [-146.7, 40.9, 113.3] → [-145.7, 45.2, 121.5] | 24.5 | 41.7 | 8.7 | 69.79 | 3.0 | 3.0 | matched | 0.0 | 3.0 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_82 | dff0db0b_ground_ceiling (1024×1024) | 3, 2, -2 | [71.0, 48.3, -38.3] → [72.3, 48.3, -18.6] | 26.6 | 61.9 | 58.7 | 49.35 | 6.6 | 6.6 | matched | 0.0 | 6.6 | repeat/repeat |
| kulech-bones-nest | floor | runewaker_material_24 | 542f3ea4_nest_room_factitiouswall_b (1024×1024) | 4, 1, 1 | [79.9, 30.9, 32.1] → [102.3, 38.3, 44.2] | 41.8 | 64.8 | 69.8 | 49.35 | 22.4 | 22.4 | matched | 0.0 | 22.4 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_24 | 542f3ea4_nest_room_factitiouswall_b (1024×1024) | 2, 1, 3 | [55.5, 27.7, 57.2] → [56.6, 29.7, 68.8] | 27.9 | 70.1 | 69.8 | 41.50 | 1.6 | 1.6 | matched | 0.0 | 1.6 | repeat/repeat |
| kulech-bones-nest | wall | runewaker_material_24 | 542f3ea4_nest_room_factitiouswall_b (1024×1024) | -7, 0, 6 | [-142.0, 15.0, 133.8] → [-117.7, 17.0, 134.9] | 50.5 | 65.2 | 41.5 | 41.50 | 5.3 | 5.3 | matched | 0.0 | 5.3 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_42 | 81d261fb_magic_dragon_nest_09 (1024×1024) | -7, -3, -4 | [-136.7, -51.0, -75.8] → [-118.9, -44.6, -58.0] | 145.2 | 82.7 | 58.7 | 6.17 | 83.7 | 83.7 | matched | 0.0 | 83.7 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_42 | 81d261fb_magic_dragon_nest_09 (1024×1024) | -4, -2, -4 | [-81.6, -31.8, -69.4] → [-67.7, -27.5, -57.4] | 105.3 | 58.1 | 41.5 | 1.83 | 81.2 | 81.2 | matched | 0.0 | 81.2 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_42 | 81d261fb_magic_dragon_nest_09 (1024×1024) | -7, -2, -4 | [-128.3, -31.7, -73.6] → [-118.2, -28.7, -60.4] | 95.0 | 56.3 | 41.5 | 2.59 | 64.0 | 64.0 | matched | 0.0 | 64.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_42 | 81d261fb_magic_dragon_nest_09 (1024×1024) | -2, -4, -3 | [-41.2, -72.7, -49.6] → [-27.1, -69.6, -36.6] | 112.3 | 84.5 | 49.4 | 6.17 | 62.7 | 62.7 | matched | 0.0 | 62.7 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_42 | 81d261fb_magic_dragon_nest_09 (1024×1024) | -6, -2, -4 | [-120.8, -31.0, -76.4] → [-103.0, -24.2, -59.8] | 142.7 | 86.2 | 58.7 | 6.17 | 56.3 | 56.3 | matched | 0.0 | 56.3 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_34 | 1622437c_magic_dragon_nest_08 (1024×1024) | 0, -8, -2 | [-0.5, -142.8, -41.4] → [18.5, -141.7, -19.7] | 46.3 | — | — | — | 46.3 | 46.3 | matched | 46.4 | -0.1 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_34 | 1622437c_magic_dragon_nest_08 (1024×1024) | -1, -8, -2 | [-19.4, -142.8, -41.4] → [-0.5, -141.7, -19.7] | 46.3 | — | — | — | 46.3 | 46.3 | matched | 46.4 | -0.1 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_35 | f62bc768_magic_dragon_nest_04 (2048×2048) | 0, 0, 4 | [0.1, 1.2, 69.2] → [19.3, 9.2, 106.3] | 305.5 | 41.9 | 41.5 | 1.54 | 45.4 | 45.4 | matched | 0.0 | 45.4 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | 0, 3, -9 | [3.2, 67.5, -174.3] → [11.0, 68.1, -162.8] | 74.0 | 1.0 | 0.9 | 469.51 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 3, -8 | [-56.0, 67.5, -151.8] → [-39.4, 67.7, -136.1] | 122.7 | 2.4 | 2.2 | 34.90 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 0, -7 | [-49.3, -19.0, -139.9] → [-39.8, 67.7, -136.1] | 924.7 | 2.7 | 2.6 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 1, -7 | [-49.3, -19.0, -139.9] → [-39.8, 67.7, -136.1] | 917.1 | 2.8 | 2.6 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_14 | 6fbe9046_magic_magma (256×256) | 3, -7, -2 | [47.0, -136.1, -77.8] → [100.0, -136.1, 29.3] | 2656.2 | 3.1 | 3.1 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | -3, 3, -7 | [-53.5, 67.5, -146.0] → [-46.8, 67.7, -136.1] | 10.6 | 3.2 | 3.1 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | 4, -5, -3 | [81.2, -113.0, -42.9] → [84.3, -30.3, -38.2] | 484.8 | 3.2 | 3.1 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_9 | d23b57c3_magic_magma_02 (512×128) | 4, -3, -2 | [81.1, -113.0, -41.4] → [82.5, -30.3, -33.7] | 698.5 | 3.3 | 3.1 | 17.45 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_31 | fce3ab65_magic_dragon_nest_07 (1024×1024) | 1, -3, -6 | [25.3, -55.9, -122.4] → [40.9, -39.6, -97.2] | 94.5 | 56.9 | 34.9 | 394.81 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | 0, 7, -3 | [-0.7, 121.7, -57.5] → [21.0, 195.8, -36.7] | 929.2 | 19.0 | 14.7 | 83.00 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_35 | f62bc768_magic_dragon_nest_04 (2048×2048) | -2, -2, -6 | [-27.6, -35.5, -110.6] → [-11.3, -23.8, -97.4] | 68.1 | 103.3 | 58.7 | 69.79 | 4.1 | 4.1 | matched | 0.0 | 4.1 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_35 | f62bc768_magic_dragon_nest_04 (2048×2048) | 1, -3, -5 | [24.6, -50.2, -100.8] → [43.0, -40.4, -84.3] | 89.2 | 123.4 | 69.8 | 69.79 | 24.7 | 24.7 | matched | 0.0 | 24.7 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | 0, 6, -3 | [-1.1, 109.4, -57.5] → [21.0, 168.3, -40.8] | 1028.6 | 16.8 | 10.4 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | wall | runewaker_material_36 | 93b48701_magic_dragon_nest_03 (1024×1024) | -2, 6, -2 | [-34.1, 97.0, -44.6] → [-15.8, 147.2, -19.7] | 1743.8 | 11.9 | 6.2 | 69.79 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| lair-demon-dragon | floor | runewaker_material_35 | f62bc768_magic_dragon_nest_04 (2048×2048) | 0, 0, 5 | [0.1, 1.3, 90.5] → [24.4, 15.0, 121.0] | 242.6 | 39.2 | 29.3 | 58.69 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | wall | runewaker_material_164 | 5bc180f4_mystic_altar_instance_element (1024×1024) | -23, 0, -3 | [-460.3, -1.2, -66.1] → [-445.7, 16.3, -42.2] | 313.8 | 33.3 | 29.3 | 1.54 | 138.3 | 138.3 | matched | 0.0 | 138.3 | repeat/repeat |
| mystic-altar | wall | runewaker_material_14 | 5d598bfe_mystic_altar_column (1024×512) | -19, 0, 6 | [-372.3, -7.9, 119.2] → [-364.3, 20.3, 141.1] | 394.0 | 40.7 | 29.3 | 1.54 | 123.8 | 123.8 | matched | 0.0 | 123.8 | repeat/repeat |
| mystic-altar | wall | runewaker_material_164 | 5bc180f4_mystic_altar_instance_element (1024×1024) | 1, 0, -2 | [13.9, 6.5, -34.5] → [39.9, 20.2, -34.2] | 157.7 | 29.7 | 24.7 | 58.69 | 123.6 | 123.6 | matched | 0.0 | 123.6 | repeat/repeat |
| mystic-altar | wall | runewaker_material_164 | 5bc180f4_mystic_altar_instance_element (1024×1024) | -14, 0, -9 | [-282.4, -1.2, -174.9] → [-258.6, 16.3, -159.6] | 225.8 | 34.4 | 29.3 | 1.54 | 84.3 | 84.3 | matched | 0.0 | 84.3 | repeat/repeat |
| mystic-altar | wall | runewaker_material_164 | 5bc180f4_mystic_altar_instance_element (1024×1024) | -15, -1, 14 | [-297.2, -7.8, 270.6] → [-287.1, -2.0, 308.2] | 255.0 | 29.7 | 29.3 | 49.35 | 79.2 | 79.2 | matched | 0.0 | 79.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_164 | 5bc180f4_mystic_altar_instance_element (1024×1024) | -1, -1, 14 | [-13.5, -7.8, 277.2] → [-13.2, -2.1, 304.1] | 106.1 | 29.6 | 29.3 | 1.09 | 76.9 | 76.9 | matched | 0.0 | 76.9 | repeat/repeat |
| mystic-altar | wall | runewaker_material_164 | 5bc180f4_mystic_altar_instance_element (1024×1024) | 3, 1, -1 | [52.1, 17.7, -19.3] → [90.3, 51.1, -19.0] | 207.2 | 21.9 | 20.7 | 29.34 | 67.2 | 67.2 | matched | 0.0 | 67.2 | repeat/repeat |
| mystic-altar | wall | runewaker_material_14 | 5d598bfe_mystic_altar_column (1024×512) | -23, 0, 6 | [-451.2, -7.9, 118.7] → [-443.1, 20.3, 141.5] | 381.2 | 39.2 | 29.3 | 1.54 | 64.4 | 64.4 | matched | 0.0 | 64.4 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -18, -1, -2 | [-365.0, -24.3, -23.3] → [-341.7, -16.5, -22.2] | 13.1 | 0.1 | 0.1 | 60096.78 | 0.0 | 13.1 | matched | 0.0 | 13.1 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -16, -1, -4 | [-324.9, -24.3, -77.4] → [-300.1, -16.4, -51.8] | 58.9 | 3.3 | 0.1 | 60096.78 | 0.0 | 26.3 | matched | 0.0 | 26.3 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -16, -1, 1 | [-325.2, -24.3, 17.5] → [-300.9, -16.5, 40.8] | 31.9 | 3.8 | 0.1 | 60096.78 | 0.0 | 13.1 | matched | 0.0 | 13.1 | repeat/repeat |
| mystic-altar | floor | runewaker_material_236 | caa8475c_mystic_altar_floor (512×512) | -4, -1, 10 | [-67.4, -13.7, 199.5] → [-51.2, -12.7, 215.5] | 130.5 | 6.5 | 6.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -17, -1, 3 | [-326.9, -17.9, 66.3] → [-306.8, -11.6, 86.9] | 214.8 | 7.3 | 7.3 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -15, -1, 3 | [-306.8, -17.9, 66.3] → [-286.7, -13.0, 86.9] | 211.4 | 7.3 | 7.3 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_236 | caa8475c_mystic_altar_floor (512×512) | -3, -1, 10 | [-67.4, -13.8, 199.7] → [-34.9, -7.3, 215.6] | 449.8 | 7.5 | 6.2 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_224 | 88a9aa0a_mystic_altar_floor02 (512×512) | -12, -1, -2 | [-236.4, -19.6, -40.8] → [-215.0, -14.9, 5.9] | 501.4 | 7.7 | 7.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -13, -1, -1 | [-266.6, -22.3, -17.2] → [-246.8, -16.4, 6.4] | 32.0 | 8.2 | 0.1 | 60096.78 | 0.0 | 9.9 | matched | 0.0 | 9.9 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -13, -1, -2 | [-266.6, -22.3, -40.7] → [-246.8, -16.4, -17.2] | 35.5 | 10.0 | 0.1 | 60096.78 | 0.0 | 9.9 | matched | 0.0 | 9.9 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -14, -2, -2 | [-278.9, -26.6, -38.5] → [-247.8, -16.5, -21.4] | 86.6 | 16.5 | 0.0 | 60096.78 | 3.3 | 16.4 | matched | 0.0 | 16.4 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -18, -2, -1 | [-366.4, -26.7, -17.2] → [-339.1, -16.5, 3.6] | 89.4 | 17.9 | 0.0 | 60096.78 | 3.3 | 16.4 | matched | 0.0 | 16.4 | repeat/repeat |
| mystic-altar | floor | runewaker_material_32 | 8d844886_mystic_altar_wall02_base_bar (256×256) | -16, -2, 1 | [-322.8, -26.7, 17.5] → [-300.8, -16.5, 42.2] | 117.1 | 21.6 | 0.0 | 60096.78 | 13.1 | 26.3 | matched | 0.0 | 26.3 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 12, 0, -13 | [227.3, 4.3, -271.1] → [247.4, 4.3, -246.3] | 248.5 | — | — | — | 248.5 | 248.5 | matched | 0.0 | 248.5 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 11, 0, -14 | [227.3, 4.3, -278.8] → [247.4, 4.3, -255.9] | 229.8 | — | — | — | 229.8 | 229.8 | matched | 0.0 | 229.8 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 12, 0, -15 | [227.3, 4.3, -301.7] → [247.4, 4.3, -278.8] | 229.1 | — | — | — | 229.1 | 229.1 | matched | 0.0 | 229.1 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 11, 0, -15 | [227.3, 4.3, -301.7] → [247.4, 4.3, -278.8] | 229.1 | — | — | — | 229.1 | 229.1 | matched | 0.0 | 229.1 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 9, 0, -13 | [146.0, 0.6, -258.9] → [247.4, 4.3, -246.3] | 224.0 | — | — | — | 224.0 | 224.0 | matched | 0.0 | 224.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 9, 0, -16 | [150.8, 0.6, -318.5] → [230.5, 4.3, -306.0] | 189.3 | — | — | — | 189.3 | 189.3 | matched | 0.0 | 189.3 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_11 | a932cf35_accessory_01 (1024×1024) | 4, 0, -13 | [77.8, 0.3, -247.8] → [98.5, 20.6, -239.3] | 819.3 | 33.7 | 29.3 | 1.54 | 175.7 | 175.7 | matched | 0.0 | 175.7 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_11 | a932cf35_accessory_01 (1024×1024) | -3, 0, -9 | [-60.8, 0.3, -174.5] → [-52.4, 20.6, -155.9] | 581.8 | 34.0 | 29.3 | 1.54 | 172.8 | 172.8 | matched | 0.0 | 172.8 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -4, -1, -8 | [-118.0, -0.1, -164.2] → [-32.3, -0.1, -128.4] | 1536.3 | 4.3 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -5, -1, -8 | [-118.0, -0.1, -164.2] → [-32.3, -0.1, -128.4] | 1536.3 | 4.3 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -4, -1, -10 | [-118.0, -0.1, -195.2] → [-32.3, -0.1, -164.2] | 1329.1 | 4.6 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | -5, -1, -9 | [-118.0, -0.1, -195.2] → [-32.3, -0.1, -164.2] | 1329.1 | 4.6 | 4.4 | 5.19 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | 3, -1, -12 | [61.8, -0.2, -256.0] → [88.1, -0.2, -157.8] | 1293.6 | 4.7 | 4.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_15 | 2dc7764f_wall_01 (1024×1024) | 3, -1, -10 | [61.8, -0.2, -256.0] → [88.1, -0.2, -157.8] | 1293.6 | 4.7 | 4.4 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_4 | a42613da_light_02 (128×128) | 10, 1, -15 | [181.5, -4.0, -294.7] → [237.0, 79.3, -278.8] | 1750.5 | 5.2 | 5.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_4 | a42613da_light_02 (128×128) | 10, 2, -15 | [182.3, -0.3, -294.7] → [237.0, 74.5, -283.9] | 873.8 | 5.2 | 5.2 | 12.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_18 | 6c1afbfc_darkgoat_altar_door (1024×512) | 10, 0, -13 | [198.5, 1.8, -256.3] → [218.5, 9.8, -245.4] | 141.9 | 16.2 | 0.5 | 12633.79 | 43.4 | 55.0 | matched | 0.0 | 55.0 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 9, 0, -16 | [154.7, 2.8, -314.6] → [226.5, 3.3, -314.5] | 15.8 | 10.6 | 10.4 | 1579.22 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 5, 0, -6 | [99.3, 4.3, -119.7] → [103.0, 9.6, -97.8] | 58.1 | 13.7 | 1.1 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 10, 0, -10 | [202.3, 0.6, -185.4] → [216.9, 1.0, -177.6] | 27.2 | 30.2 | 12.3 | 331.99 | 18.7 | 18.7 | matched | 0.0 | 18.7 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 10, 0, -9 | [201.2, 0.6, -184.4] → [216.9, 1.0, -161.9] | 58.5 | 25.4 | 12.3 | 117.38 | 52.2 | 52.2 | matched | 0.0 | 52.2 | repeat/repeat |
| paspers-shrine | wall | runewaker_material_60 | 51b5ca78_accessory_02 (1024×512) | 7, 0, -15 | [141.9, 0.3, -301.7] → [156.0, 14.7, -290.8] | 99.4 | 23.1 | 5.2 | 98.70 | 20.2 | 20.2 | matched | 0.0 | 20.2 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_18 | 6c1afbfc_darkgoat_altar_door (1024×512) | 9, 0, -16 | [184.1, 1.8, -319.5] → [199.9, 7.3, -308.6] | 90.3 | 20.2 | 20.7 | 58.69 | 22.3 | 22.3 | matched | 0.0 | 22.3 | repeat/repeat |
| paspers-shrine | floor | runewaker_material_18 | 6c1afbfc_darkgoat_altar_door (1024×512) | 11, 0, -14 | [220.5, 1.9, -280.9] → [233.0, 9.0, -261.2] | 82.7 | 21.3 | 6.2 | 49.35 | 5.1 | 5.1 | matched | 0.0 | 5.1 | repeat/repeat |
| raksha-temple | wall | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | -3, 0, 9 | [-77.9, 0.1, 183.1] → [-15.3, 21.1, 199.2] | 1968.7 | 43.1 | 49.4 | 1.09 | 1050.6 | 1050.6 | matched | 0.0 | 1050.6 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | dace15ec_front_gate_01 (2048×1024) | -1, 0, 0 | [-30.6, 13.4, -11.9] → [30.6, 13.4, 27.9] | 1045.8 | — | — | — | 1045.8 | 1045.8 | matched | 0.0 | 1045.8 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | f557fa28_base_01 (1024×1024) | -3, 0, 7 | [-62.9, 2.5, 136.8] → [-33.9, 4.8, 161.5] | 1102.8 | 67.3 | 58.7 | 1.09 | 1009.3 | 1009.3 | matched | 0.0 | 1009.3 | repeat/repeat |
| raksha-temple | wall | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | -2, 0, 9 | [-77.8, -0.3, 183.1] → [-15.3, 21.1, 199.6] | 2049.8 | 41.1 | 20.7 | 4.36 | 936.0 | 936.0 | matched | 0.0 | 936.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | 2, 1, 9 | [38.1, 22.3, 181.8] → [73.1, 23.0, 229.3] | 731.2 | — | — | — | 731.2 | 731.2 | matched | 0.0 | 731.2 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | dace15ec_front_gate_01 (2048×1024) | -1, 0, -2 | [-39.9, -1.8, -44.0] → [33.6, 13.4, -27.8] | 1116.4 | 28.4 | 29.3 | 29.34 | 718.9 | 718.9 | matched | 0.0 | 718.9 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | dace15ec_front_gate_01 (2048×1024) | 0, 0, 1 | [-33.5, -1.8, 16.9] → [33.6, 20.7, 44.0] | 1256.3 | 26.2 | 12.3 | 3.67 | 718.3 | 718.3 | matched | 0.0 | 718.3 | repeat/repeat |
| raksha-temple | floor | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | -5, 0, 6 | [-106.9, 5.0, 116.3] → [-76.5, 11.0, 149.0] | 842.0 | 10.4 | 8.7 | 14.67 | 704.8 | 704.8 | matched | 0.0 | 704.8 | repeat/repeat |
| raksha-temple | floor | runewaker_material_2 | 2b429cae_building_02 (1024×1024) | -6, 0, 13 | [-128.4, 17.8, 258.5] → [-87.3, 17.8, 263.8] | 95.0 | 1.3 | 1.3 | 558.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -16, 4, 17 | [-321.5, 70.0, 342.4] → [-317.9, 107.6, 346.9] | 98.3 | 3.9 | 3.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -18, 0, 16 | [-354.4, 1.3, 318.1] → [-342.7, 20.2, 325.9] | 569.7 | 4.4 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -14, 0, 13 | [-281.5, 1.4, 266.5] → [-277.1, 20.3, 274.7] | 371.2 | 4.5 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -13, 3, 15 | [-259.9, 70.1, 299.1] → [-253.7, 89.0, 309.4] | 348.9 | 4.6 | 3.7 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -16, 3, 16 | [-302.9, 68.3, 326.1] → [-298.8, 88.9, 334.9] | 170.4 | 4.6 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -16, 4, 16 | [-302.9, 69.4, 329.7] → [-299.1, 107.7, 335.8] | 204.4 | 4.7 | 4.4 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 32435b59_gate_waterfall (256×256) | -16, 5, 17 | [-321.5, 88.4, 342.4] → [-315.4, 115.1, 350.2] | 202.4 | 4.8 | 3.7 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_1 | 8ead54fc_miraso_largetree_03 (1024×1024) | 6, 8, 15 | [129.0, 160.9, 316.5] → [139.2, 173.3, 318.7] | 15.7 | 13.8 | 0.0 | 60096.78 | 0.0 | 3.0 | matched | 0.0 | 3.0 | repeat/repeat |
| raksha-temple | floor | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | 1, 0, 9 | [15.8, 5.0, 180.8] → [48.6, 16.4, 207.9] | 534.4 | 28.7 | 24.7 | 7512.10 | 530.4 | 530.4 | matched | 0.0 | 530.4 | repeat/repeat |
| raksha-temple | floor | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | -1, 0, 9 | [-25.3, 5.0, 183.1] → [7.5, 16.4, 205.5] | 535.7 | 42.2 | 41.5 | 7512.10 | 531.8 | 531.8 | matched | 0.0 | 531.8 | repeat/repeat |
| raksha-temple | floor | runewaker_material_1 | 8fc4b5d6_wall_tall_building_01 (1024×512) | -5, 0, 4 | [-94.3, 9.2, 96.9] → [-74.7, 16.2, 98.6] | 26.1 | 21.9 | 20.7 | 6316.90 | 17.0 | 17.0 | matched | 0.0 | 17.0 | repeat/repeat |
| raksha-temple | wall | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | 5, 2, 11 | [103.2, 40.2, 201.7] → [103.7, 47.3, 262.5] | 228.0 | 14.8 | 10.4 | 3158.45 | 206.2 | 206.2 | matched | 0.0 | 206.2 | repeat/repeat |
| raksha-temple | wall | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | -14, 1, 10 | [-276.4, 20.4, 195.8] → [-260.1, 21.6, 211.5] | 29.8 | 39.4 | 17.4 | 1878.02 | 14.5 | 14.5 | matched | 0.0 | 14.5 | repeat/repeat |
| raksha-temple | wall | runewaker_material_4 | 534900e9_wall_tall_01 (1024×512) | -15, 1, 10 | [-304.1, 20.4, 195.8] → [-284.9, 21.6, 211.5] | 40.8 | 35.2 | 17.4 | 1878.02 | 17.7 | 17.7 | matched | 0.0 | 17.7 | repeat/repeat |
| raksha-temple | wall | runewaker_material_2 | 2b429cae_building_02 (1024×1024) | -6, 0, 15 | [-123.5, 14.7, 312.2] → [-89.8, 20.7, 315.4] | 24.4 | 27.2 | 24.7 | 1327.96 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_123 | ab0e9683_sar_in_channel_01 (1024×1024) | 1, -1, -8 | [20.2, -19.7, -157.2] → [72.5, -1.6, -130.7] | 1514.1 | 34.7 | 20.7 | 6.17 | 840.7 | 840.7 | matched | 0.0 | 840.7 | repeat/repeat |
| sardo-castle | floor | runewaker_material_11 | 8ee16a46_wave_01 (256×256) | -9, -1, 2 | [-184.6, -17.1, 36.4] → [-151.1, -17.1, 69.1] | 1129.8 | 0.0 | 0.0 | 60096.78 | 515.5 | 918.6 | matched | 0.0 | 918.6 | repeat/repeat |
| sardo-castle | wall | runewaker_material_123 | ab0e9683_sar_in_channel_01 (1024×1024) | -4, -1, -1 | [-90.9, -26.1, -18.1] → [-52.2, 3.4, -17.5] | 672.1 | 33.3 | 29.3 | 1.30 | 637.9 | 637.9 | matched | 0.0 | 637.9 | repeat/repeat |
| sardo-castle | wall | runewaker_material_123 | ab0e9683_sar_in_channel_01 (1024×1024) | 2, -1, -8 | [20.4, -19.7, -157.2] → [72.5, -1.6, -142.9] | 1440.1 | 32.2 | 17.4 | 3.08 | 627.7 | 627.7 | matched | 0.0 | 627.7 | repeat/repeat |
| sardo-castle | wall | runewaker_material_63 | e03bbb46_mortuary_out_02 (1024×512) | -9, 0, 3 | [-169.2, 11.1, 58.1] → [-160.2, 22.2, 66.9] | 611.8 | 46.7 | 34.9 | 166.00 | 595.5 | 595.5 | matched | 0.0 | 595.5 | repeat/repeat |
| sardo-castle | wall | runewaker_material_88 | dae74dbd_sarin_second_01 (1024×1024) | -6, 0, -2 | [-126.1, -4.2, -46.6] → [-105.8, 23.8, -13.3] | 638.5 | 41.5 | 41.5 | 1.09 | 580.9 | 580.9 | matched | 0.0 | 580.9 | repeat/repeat |
| sardo-castle | floor | runewaker_material_11 | 8ee16a46_wave_01 (256×256) | -3, -1, 2 | [-58.4, -8.4, 32.7] → [-27.8, -8.4, 66.2] | 661.3 | 0.0 | 0.0 | 60096.78 | 441.7 | 661.3 | matched | 0.0 | 661.3 | repeat/repeat |
| sardo-castle | wall | runewaker_material_98 | 909eeb6c_sarin_forth_01 (1024×1024) | -2, -2, -8 | [-45.9, -46.8, -154.9] → [-20.6, -19.6, -153.0] | 559.9 | 41.7 | 34.9 | 2.18 | 547.5 | 547.5 | matched | 0.0 | 547.5 | repeat/repeat |
| sardo-castle | floor | runewaker_material_11 | 8ee16a46_wave_01 (256×256) | 0, -2, -2 | [-18.1, -25.9, -33.9] → [15.4, -25.8, -4.7] | 403.5 | 0.0 | 0.0 | 60096.78 | 201.7 | 403.5 | matched | 0.0 | 403.5 | repeat/repeat |
| sardo-castle | floor | runewaker_material_11 | 8ee16a46_wave_01 (256×256) | -1, -2, -1 | [-18.1, -25.9, -33.9] → [15.4, -25.8, -4.7] | 515.3 | 0.0 | 0.0 | 60096.78 | 257.8 | 515.3 | matched | 0.0 | 515.3 | repeat/repeat |
| sardo-castle | floor | runewaker_material_11 | 8ee16a46_wave_01 (256×256) | 1, -1, -2 | [16.2, -19.3, -57.9] → [36.3, -19.3, -24.4] | 303.2 | 0.1 | 0.0 | 60096.78 | 0.0 | 201.6 | matched | 0.0 | 201.6 | repeat/repeat |
| sardo-castle | floor | runewaker_material_11 | 8ee16a46_wave_01 (256×256) | 1, -1, -3 | [7.1, -19.3, -57.9] → [37.7, -19.3, -33.5] | 766.7 | 0.4 | 0.0 | 60096.78 | 0.0 | 219.2 | matched | 0.0 | 219.2 | repeat/repeat |
| sardo-castle | floor | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | -3, -5, -3 | [-197.3, -89.9, -163.1] → [206.6, -89.9, 176.8] | 68634.6 | 0.7 | 0.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | floor | runewaker_material_52 | 8c5af28a_sar_in_smallroom_01 (1024×1024) | 3, -5, 3 | [-185.6, -89.9, -163.1] → [206.6, -89.9, 176.8] | 66465.8 | 0.7 | 0.6 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| sardo-castle | wall | runewaker_material_116 | a3211e7a_sarin_third_01 (1024×512) | -6, 1, -3 | [-124.5, 21.4, -52.7] → [-106.9, 24.3, -35.4] | 32.7 | 0.7 | 0.6 | 60096.78 | 20.1 | 32.7 | matched | 0.0 | 32.7 | repeat/repeat |
| sardo-castle | wall | runewaker_material_116 | a3211e7a_sarin_third_01 (1024×512) | -6, 1, -2 | [-117.2, 21.4, -42.8] → [-106.9, 24.3, -13.6] | 40.2 | 10.7 | 0.6 | 60096.78 | 0.0 | 12.6 | matched | 0.0 | 12.6 | repeat/repeat |
| sardo-castle | wall | runewaker_material_116 | a3211e7a_sarin_third_01 (1024×512) | -8, 1, -1 | [-162.2, 19.7, -19.0] → [-141.2, 24.3, 2.6] | 109.7 | 22.3 | 0.6 | 60096.78 | 10.0 | 22.6 | matched | 0.0 | 22.6 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | 4, -1, -16 | [71.1, -15.7, -332.6] → [110.0, -14.6, -301.9] | 2418.1 | 95.4 | 98.7 | 1.09 | 1339.2 | 1339.2 | matched | 0.0 | 1339.2 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | 4, 0, 0 | [74.9, 8.7, -2.7] → [97.5, 9.8, 28.3] | 1912.3 | 98.8 | 98.7 | 1.09 | 1339.2 | 1339.2 | matched | 0.0 | 1339.2 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | -7, -1, -14 | [-151.0, -13.6, -287.1] → [-120.5, -12.4, -262.8] | 1339.2 | — | — | — | 1339.2 | 1339.2 | matched | 0.0 | 1339.2 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | 4, 0, 9 | [71.6, 14.4, 172.7] → [91.1, 15.5, 204.0] | 1339.2 | — | — | — | 1339.2 | 1339.2 | matched | 0.0 | 1339.2 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | -1, -1, -14 | [-32.7, -15.8, -293.0] → [-1.8, -14.7, -270.0] | 1339.2 | — | — | — | 1339.2 | 1339.2 | matched | 0.0 | 1339.2 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | -20, 1, 17 | [-410.0, 28.4, 323.6] → [-386.8, 29.3, 358.9] | 1155.4 | 98.8 | 98.7 | 1.09 | 1042.8 | 1042.8 | matched | 0.0 | 1042.8 | repeat/repeat |
| the-origin | floor | runewaker_material_20 | fd7a27ec_floor _01 (512×512) | -4, 0, -2 | [-85.7, 3.8, -51.5] → [-48.8, 4.9, -10.1] | 2606.7 | 84.1 | 41.5 | 4.36 | 1042.8 | 1042.8 | matched | 0.0 | 1042.8 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 996d802d_cathedral_01 (2048×2048) | -7, 1, 15 | [-149.2, 23.0, 293.4] → [-119.0, 37.9, 322.8] | 1029.9 | 4.8 | 3.1 | 6.17 | 906.9 | 906.9 | matched | 0.0 | 906.9 | repeat/repeat |
| the-origin | floor | runewaker_material_7 | 2a0c27e5_elves_bridge (256×256) | 4, -1, -13 | [78.4, -14.7, -259.6] → [100.3, -11.3, -239.2] | 170.9 | 0.3 | 0.0 | 60096.78 | 110.1 | 148.1 | matched | 0.0 | 148.1 | repeat/repeat |
| the-origin | floor | runewaker_material_27 | d60945c6_precipice_02 (1024×1024) | 10, -1, 5 | [197.9, -11.3, 98.6] → [222.2, -9.8, 119.5] | 290.2 | 0.6 | 0.0 | 60096.78 | 0.0 | 72.3 | matched | 0.0 | 72.3 | repeat/repeat |
| the-origin | floor | runewaker_material_27 | d60945c6_precipice_02 (1024×1024) | 11, -1, 7 | [220.4, -11.3, 136.2] → [241.1, -1.8, 163.6] | 292.0 | 1.1 | 0.0 | 60096.78 | 54.3 | 149.5 | matched | 0.0 | 149.5 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 15, 6, 6 | [267.8, 126.7, 119.3] → [327.4, 130.7, 149.6] | 833.9 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 14, 6, 4 | [267.8, 126.7, 80.3] → [303.6, 131.3, 119.3] | 356.6 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 14, 6, 5 | [267.8, 126.7, 84.9] → [312.6, 131.3, 119.3] | 361.0 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 13, 6, 4 | [259.8, 126.7, 80.3] → [287.9, 130.3, 119.3] | 464.6 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_1 | 02187fff_backdrop_01 (1024×512) | 12, 6, 5 | [240.7, 124.5, 87.0] → [267.8, 129.6, 126.4] | 586.7 | 1.9 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| the-origin | floor | runewaker_material_27 | d60945c6_precipice_02 (1024×1024) | -15, -1, 6 | [-286.9, -17.5, 118.6] → [-279.4, -16.1, 129.9] | 43.8 | 5.3 | 0.0 | 60096.78 | 0.0 | 12.0 | matched | 0.0 | 12.0 | repeat/repeat |
| the-origin | floor | runewaker_material_7 | 2a0c27e5_elves_bridge (256×256) | 4, -1, -14 | [78.9, -12.9, -281.4] → [85.2, -9.1, -259.5] | 123.1 | 6.0 | 0.0 | 60096.78 | 75.8 | 88.5 | matched | 0.0 | 88.5 | repeat/repeat |
| the-origin | floor | runewaker_material_27 | d60945c6_precipice_02 (1024×1024) | -15, -1, 5 | [-301.2, -17.3, 99.8] → [-279.4, -15.5, 121.3] | 222.7 | 3.6 | 0.0 | 60096.78 | 13.5 | 69.7 | matched | 0.0 | 69.7 | repeat/repeat |
| the-origin | wall | runewaker_material_4 | 27a713c2_floor_02 (256×256) | 9, -1, -7 | [171.8, -9.3, -148.8] → [206.4, -2.3, -107.3] | 103.3 | 94.7 | 69.8 | 10623.71 | 18.7 | 18.7 | matched | 0.0 | 18.7 | repeat/repeat |
| the-origin | wall | runewaker_material_3 | 33af9fe9_ladder_01 (256×256) | 2, 0, -1 | [51.3, 5.6, -14.8] → [60.5, 9.2, -0.8] | 13.8 | 36.4 | 8.7 | 5311.85 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_94 | 3dab6a3e_room06_door_01 (1024×1024) | 1, -1, -8 | [15.1, -13.8, -162.8] → [31.7, -1.2, -130.4] | 2133.0 | 18.7 | 14.7 | 5.19 | 1876.5 | 1876.5 | matched | 0.0 | 1876.5 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | -5, 0, -8 | [-94.6, -4.3, -153.9] → [-92.2, 23.9, -138.4] | 690.3 | 60.1 | 58.7 | 1.09 | 670.0 | 670.0 | matched | 0.0 | 670.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_41 | 33cfb348_sevenhero_smroom_02 (1024×1024) | -1, -1, -13 | [-11.4, -25.3, -261.2] → [-5.9, 4.5, -241.5] | 959.4 | 19.1 | 1.1 | 2233.36 | 598.2 | 612.2 | matched | 0.0 | 612.2 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_41 | 33cfb348_sevenhero_smroom_02 (1024×1024) | 1, -1, -14 | [20.2, -23.3, -277.4] → [37.7, 4.5, -259.9] | 924.1 | 43.0 | 41.5 | 41.50 | 592.2 | 592.2 | matched | 0.0 | 592.2 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_41 | 33cfb348_sevenhero_smroom_02 (1024×1024) | -6, 0, -7 | [-117.5, -9.1, -140.4] → [-109.7, 24.0, -118.6] | 916.6 | 42.0 | 41.5 | 1.30 | 550.9 | 550.9 | matched | 0.0 | 550.9 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_41 | 33cfb348_sevenhero_smroom_02 (1024×1024) | -6, 0, -8 | [-117.4, -9.1, -158.1] → [-109.7, 24.0, -138.1] | 1118.1 | 5.3 | 0.9 | 2233.36 | 547.5 | 547.5 | matched | 0.0 | 547.5 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_134 | 7eee5da3_room01_02 (1024×1024) | -8, 1, 1 | [-159.6, 2.0, 34.5] → [-139.1, 37.9, 42.1] | 803.6 | 34.3 | 34.9 | 1.30 | 544.5 | 544.5 | matched | 0.0 | 544.5 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | -8, -1, 2 | [-174.0, -1.2, 38.4] → [-127.2, -1.2, 62.3] | 612.9 | 34.0 | 29.3 | 3.67 | 540.7 | 540.7 | matched | 0.0 | 540.7 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_135 | 92cd49ea_room01_01 (1024×1024) | -10, 0, 1 | [-194.8, 3.5, 27.2] → [-177.7, 3.5, 44.3] | 128.7 | 0.9 | 0.9 | 2233.36 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_135 | 92cd49ea_room01_01 (1024×1024) | -6, 0, 2 | [-131.0, 3.5, 34.8] → [-106.4, 3.5, 51.8] | 128.7 | 0.9 | 0.9 | 939.01 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_135 | 92cd49ea_room01_01 (1024×1024) | -7, 0, 1 | [-131.0, 3.5, 27.2] → [-106.4, 3.5, 44.3] | 128.7 | 0.9 | 0.9 | 939.01 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_135 | 92cd49ea_room01_01 (1024×1024) | -10, 0, 4 | [-194.8, 1.0, 81.2] → [-162.6, 3.5, 100.5] | 137.8 | 1.2 | 0.9 | 939.01 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | 0, -4, 2 | [-93.4, -70.0, -75.3] → [59.0, -70.0, 98.0] | 13212.6 | 1.3 | 1.3 | 1.30 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_135 | 92cd49ea_room01_01 (1024×1024) | -9, 0, 4 | [-187.2, 0.0, 81.2] → [-160.3, 3.5, 100.8] | 178.5 | 1.7 | 0.9 | 939.01 | 25.5 | 25.5 | matched | 0.0 | 25.5 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_46 | 6b69a0c4_room01_03 (1024×1024) | -3, -4, -1 | [-93.6, -70.0, -75.3] → [59.0, -70.0, 98.0] | 13228.6 | 1.7 | 1.8 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_45 | 08f2b62a_sevenhero_smroom_04 (1024×1024) | -3, -4, 2 | [-92.5, -70.0, -77.0] → [58.4, -70.0, 98.8] | 13262.0 | 1.9 | 1.8 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_41 | 33cfb348_sevenhero_smroom_02 (1024×1024) | 0, -1, -12 | [1.6, -25.3, -233.8] → [21.4, 4.5, -228.5] | 891.8 | 23.4 | 1.1 | 60096.78 | 392.7 | 455.8 | matched | 0.0 | 455.8 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | 5, 0, -4 | [95.3, -1.0, -75.7] → [119.9, 1.8, -61.8] | 33.0 | 25.2 | 2.2 | 17866.88 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | 3, 0, -4 | [70.8, -2.6, -75.8] → [83.5, 1.8, -61.8] | 57.6 | 18.3 | 2.2 | 17866.88 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | 7, -1, -2 | [139.7, -20.8, -36.9] → [157.3, -2.8, -19.9] | 18.7 | 23.2 | 4.4 | 8933.44 | 3.2 | 3.2 | matched | 0.0 | 3.2 | repeat/repeat |
| tomb-seven-heroes | wall | runewaker_material_14 | 8b2bea87_room08_01 (1024×1024) | 11, -2, -1 | [212.8, -22.2, -8.4] → [252.8, -21.9, -7.4] | 11.7 | 28.6 | 17.4 | 5311.85 | 4.8 | 4.8 | matched | 0.0 | 4.8 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | -6, 0, -5 | [-119.1, 0.1, -91.6] → [-103.8, 21.4, -90.1] | 18.8 | 23.9 | 8.7 | 3756.05 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_18 | 69f567c5_sevenhero_smroom_01 (1024×1024) | 3, -2, -6 | [70.6, -34.5, -111.3] → [84.5, -25.7, -97.4] | 66.1 | 8.2 | 2.6 | 3756.05 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| tomb-seven-heroes | floor | runewaker_material_40 | be800a2b_break (1024×1024) | -2, -7, -7 | [-43.5, -135.4, -142.6] → [-16.8, -121.5, -115.8] | 477.3 | 9.4 | 0.3 | 3158.45 | 0.0 | 67.2 | matched | 0.0 | 67.2 | repeat/repeat |
| treasure-trove | floor | runewaker_material_232 | ba879ae0_treasury_antre_03 (1024×512) | -18, -1, -10 | [-378.0, -11.9, -216.5] → [-328.9, -10.4, -153.3] | 1007.7 | — | — | — | 1007.7 | 1007.7 | matched | 1007.6 | 0.1 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -28, -1, 2 | [-565.7, -7.3, 32.9] → [-538.0, -6.9, 66.6] | 1066.5 | 11.8 | 12.3 | 1.09 | 935.5 | 935.5 | matched | 0.0 | 935.5 | repeat/repeat |
| treasure-trove | floor | runewaker_material_232 | ba879ae0_treasury_antre_03 (1024×512) | -17, -1, -10 | [-357.9, -11.9, -216.5] → [-310.5, -10.4, -153.3] | 915.8 | — | — | — | 915.8 | 915.8 | matched | 915.1 | 0.6 | repeat/repeat |
| treasure-trove | floor | runewaker_material_91 | 26b66043_cabin (1024×1024) | -27, -2, -2 | [-556.5, -36.2, -44.6] → [-518.7, -36.0, -9.3] | 821.0 | 21.3 | 20.7 | 7.34 | 760.2 | 760.2 | matched | 0.0 | 760.2 | repeat/repeat |
| treasure-trove | floor | runewaker_material_91 | 26b66043_cabin (1024×1024) | -28, -2, -2 | [-561.1, -36.2, -45.0] → [-518.7, -32.3, -9.3] | 857.3 | 3.0 | 0.2 | 8933.44 | 635.5 | 736.3 | matched | 0.0 | 736.3 | repeat/repeat |
| treasure-trove | floor | runewaker_material_91 | 26b66043_cabin (1024×1024) | -27, -2, -3 | [-553.1, -36.0, -69.5] → [-518.6, -21.9, -35.8] | 758.1 | 18.7 | 10.4 | 20.75 | 664.7 | 664.7 | matched | 0.0 | 664.7 | repeat/repeat |
| treasure-trove | floor | runewaker_material_174 | a1a51def_hull (1024×1024) | -27, -1, 2 | [-538.1, -7.3, 33.0] → [-515.8, -6.9, 66.4] | 802.0 | 10.4 | 7.3 | 3.08 | 611.7 | 611.7 | matched | 0.0 | 611.7 | repeat/repeat |
| treasure-trove | floor | runewaker_material_91 | 26b66043_cabin (1024×1024) | -27, -2, 1 | [-556.3, -36.6, -2.3] → [-514.7, -20.1, 33.7] | 956.6 | 0.1 | 0.0 | 60096.78 | 357.9 | 761.4 | matched | 0.0 | 761.4 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -28, 1, 1 | [-555.4, -16.1, 20.0] → [-539.7, 45.5, 22.6] | 30.8 | 0.0 | 20.7 | 60096.78 | 0.0 | 23.3 | matched | 0.0 | 23.3 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -26, 4, 0 | [-519.6, 98.3, 19.7] → [-483.6, 99.5, 19.9] | 32.1 | 0.0 | 24.7 | 60096.78 | 0.0 | 21.4 | matched | 0.0 | 21.4 | repeat/repeat |
| treasure-trove | floor | runewaker_material_175 | 98b770e1_mast (1024×1024) | -29, 3, 0 | [-591.6, 61.8, 19.0] → [-537.6, 61.8, 20.4] | 54.1 | 0.0 | 14.7 | 60096.78 | 0.0 | 21.9 | matched | 0.0 | 21.9 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -28, 0, 1 | [-541.2, -16.1, 20.4] → [-539.7, 45.5, 24.9] | 123.8 | 0.0 | 29.3 | 60096.78 | 69.7 | 108.2 | matched | 0.0 | 108.2 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -27, 3, 1 | [-539.9, 45.5, 20.7] → [-536.0, 137.8, 24.6] | 313.1 | 0.1 | 17.4 | 60096.78 | 0.0 | 69.3 | matched | 0.0 | 69.3 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -27, 2, -3 | [-538.6, 5.7, -51.3] → [-533.2, 79.9, -47.7] | 48.7 | 0.2 | 12.3 | 60096.78 | 0.0 | 10.0 | matched | 0.0 | 10.0 | repeat/repeat |
| treasure-trove | floor | runewaker_material_91 | 26b66043_cabin (1024×1024) | -29, -2, 1 | [-561.3, -35.4, 13.9] → [-557.8, -22.5, 33.7] | 44.2 | 1.2 | 0.1 | 60096.78 | 9.1 | 16.5 | matched | 0.0 | 16.5 | repeat/repeat |
| treasure-trove | wall | runewaker_material_91 | 26b66043_cabin (1024×1024) | -28, -1, -4 | [-552.8, -23.0, -69.0] → [-541.8, -16.2, -49.0] | 55.3 | 7.0 | 0.0 | 60096.78 | 0.0 | 9.4 | matched | 0.0 | 9.4 | repeat/repeat |
| treasure-trove | wall | runewaker_material_175 | 98b770e1_mast (1024×1024) | -24, -1, 3 | [-488.9, -25.9, 59.1] → [-456.1, -12.8, 77.4] | 84.9 | 3.4 | 0.0 | 60096.78 | 0.0 | 21.1 | matched | 0.0 | 21.1 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -2, -1, -2 | [-48.6, -2.0, -48.2] → [-15.7, 0.7, -15.3] | 496.6 | 19.4 | 20.7 | 1.30 | 440.3 | 440.3 | matched | 0.0 | 440.3 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_3 | 1510a418_wana_dokken_arena_undercroft_walls (1024×1024) | -2, 0, -2 | [-41.6, -2.1, -41.2] → [-23.4, 21.0, -23.0] | 1159.1 | 29.2 | 24.7 | 1.83 | 426.0 | 426.0 | matched | 0.0 | 426.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_17 | 17bcf322_wana_dokken_arena_undercroft_sidewalk_and_balcony (1024×1024) | -4, 1, 3 | [-82.4, 24.1, 55.4] → [-50.4, 24.1, 93.3] | 471.8 | 16.7 | 17.4 | 2.18 | 377.3 | 377.3 | matched | 0.0 | 377.3 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_17 | 17bcf322_wana_dokken_arena_undercroft_sidewalk_and_balcony (1024×1024) | -5, 1, 4 | [-104.0, 24.1, 65.5] → [-78.2, 24.1, 103.1] | 373.0 | — | — | — | 373.0 | 373.0 | matched | 0.0 | 373.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_17 | 17bcf322_wana_dokken_arena_undercroft_sidewalk_and_balcony (1024×1024) | -3, -1, 2 | [-63.9, -2.0, 30.9] → [-36.1, -1.9, 64.2] | 366.5 | — | — | — | 366.5 | 366.5 | matched | 0.0 | 366.5 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_17 | 17bcf322_wana_dokken_arena_undercroft_sidewalk_and_balcony (1024×1024) | 2, -1, 2 | [36.0, -2.0, 31.0] → [64.1, -1.9, 63.9] | 366.0 | — | — | — | 366.0 | 366.0 | matched | 0.0 | 366.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_17 | 17bcf322_wana_dokken_arena_undercroft_sidewalk_and_balcony (1024×1024) | -3, 0, -7 | [-72.1, 17.4, -142.5] → [-30.7, 17.5, -125.6] | 348.3 | — | — | — | 348.3 | 348.3 | matched | 0.0 | 348.3 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_3 | 1510a418_wana_dokken_arena_undercroft_walls (1024×1024) | 0, 1, -4 | [-6.9, 17.2, -66.4] → [21.1, 39.9, -55.7] | 1146.2 | 27.9 | 24.7 | 1.83 | 321.3 | 321.3 | matched | 0.0 | 321.3 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_9 | e7695b3c_wana_dokken_arena_undercroft_doors (512×512) | -2, -2, -1 | [-40.9, -22.0, -9.8] → [-30.4, -22.0, 9.1] | 99.7 | 3.2 | 0.6 | 939.01 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_9 | e7695b3c_wana_dokken_arena_undercroft_doors (512×512) | -2, -2, 0 | [-40.9, -22.0, -5.0] → [-30.4, -22.0, 9.3] | 99.4 | 3.5 | 0.8 | 558.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_9 | e7695b3c_wana_dokken_arena_undercroft_doors (512×512) | 1, -2, -1 | [29.7, -22.0, -9.6] → [40.2, -22.0, 4.7] | 124.4 | 3.9 | 0.5 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_3 | 1510a418_wana_dokken_arena_undercroft_walls (1024×1024) | -5, 1, 0 | [-83.7, 39.3, -18.5] → [-80.1, 39.3, 34.2] | 95.6 | 4.4 | 4.4 | 29.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_17 | 17bcf322_wana_dokken_arena_undercroft_sidewalk_and_balcony (1024×1024) | 1, -1, -2 | [31.0, -2.0, -48.0] → [47.6, -1.9, -30.9] | 72.4 | 4.9 | 5.2 | 14.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_3 | 1510a418_wana_dokken_arena_undercroft_walls (1024×1024) | -4, 1, -2 | [-83.1, 39.3, -22.1] → [-49.0, 39.3, -18.5] | 61.8 | 5.5 | 5.2 | 10.37 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_3 | 1510a418_wana_dokken_arena_undercroft_walls (1024×1024) | -3, 1, 3 | [-69.5, 25.4, 41.4] → [-32.2, 39.3, 81.3] | 146.3 | 6.4 | 4.4 | 41.50 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | 1, -2, 1 | [18.2, -41.0, 18.5] → [46.4, -22.0, 46.8] | 759.1 | 8.4 | 8.7 | 1.09 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -1, 0, -2 | [-27.1, -0.7, -42.0] → [11.7, 17.4, -11.2] | 105.9 | 9.6 | 0.8 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -1, -1, -1 | [-26.7, -0.0, -26.7] → [5.5, -0.0, 5.5] | 511.0 | 13.9 | 2.6 | 139.58 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_3 | 1510a418_wana_dokken_arena_undercroft_walls (1024×1024) | -3, 1, 2 | [-69.5, 25.4, 41.4] → [-45.7, 39.3, 70.3] | 59.2 | 21.2 | 10.4 | 117.38 | 22.4 | 22.4 | matched | 0.0 | 22.4 | repeat/repeat |
| warnorken-arena | wall | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | -1, -2, -2 | [-10.3, -23.2, -41.4] → [8.9, -21.4, -29.8] | 75.4 | 19.1 | 17.4 | 117.38 | 35.5 | 35.5 | matched | 0.0 | 35.5 | repeat/repeat |
| warnorken-arena | floor | runewaker_material_16 | bfbd4b0b_wana_dokken_arena_undercroft_floor (1024×1024) | 2, -1, 0 | [32.9, -2.0, -17.8] → [46.1, -1.1, 19.2] | 75.8 | 19.5 | 17.4 | 117.38 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 16, 0, 1 | [321.8, 1.9, 19.7] → [341.2, 18.8, 31.1] | 284.9 | 67.5 | 49.4 | 1.54 | 31.1 | 31.1 | matched | 1.0 | 30.2 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | 16, 0, 1 | [331.3, 2.0, 27.8] → [339.3, 10.0, 27.8] | 46.8 | 131.0 | 139.6 | 1.30 | 24.0 | 24.0 | matched | 0.0 | 24.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 0, 1 | [-11.0, -0.0, 19.7] → [0.6, 16.8, 30.3] | 176.3 | 67.6 | 49.4 | 1.54 | 20.5 | 20.5 | matched | 0.7 | 19.9 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 0, 1 | [-4.0, -0.0, 19.7] → [10.9, 16.8, 30.3] | 178.1 | 67.4 | 49.4 | 1.54 | 16.2 | 16.2 | matched | 0.7 | 15.6 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | -1, 0, 1 | [-4.0, 0.0, 27.0] → [4.0, 8.0, 27.0] | 27.4 | 131.0 | 139.6 | 1.30 | 16.0 | 16.0 | matched | 0.0 | 16.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | -1, 0, -16 | [-4.5, 11.6, -309.1] → [3.5, 19.6, -309.1] | 27.4 | 131.0 | 139.6 | 1.30 | 16.0 | 16.0 | matched | 0.0 | 16.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -16 | [-4.5, 16.8, -317.2] → [12.9, 28.4, -307.1] | 122.3 | 63.1 | 49.4 | 1.54 | 14.3 | 14.3 | matched | 0.7 | 13.6 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, -16 | [-14.0, 11.6, -317.2] → [3.5, 28.4, -307.1] | 121.9 | 63.1 | 49.4 | 1.54 | 12.9 | 12.9 | matched | 0.7 | 12.2 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 16, 0, -2 | [318.8, 10.3, -31.3] → [342.4, 11.6, -13.4] | 226.3 | 7.0 | 5.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -19 | [-20.9, 12.2, -368.2] → [-0.6, 19.9, -357.2] | 101.6 | 7.4 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 1, -18 | [-21.5, 20.3, -364.3] → [1.1, 21.2, -347.1] | 213.6 | 7.5 | 5.2 | 1.83 | 1.1 | 1.1 | matched | 0.0 | 1.1 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 1, -18 | [-0.6, 20.3, -364.3] → [20.3, 21.2, -347.1] | 214.0 | 7.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -19 | [-0.6, 11.9, -368.2] → [20.6, 19.9, -357.2] | 104.0 | 7.8 | 6.2 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.3, 0.3, -32.1] → [-0.1, 9.6, -14.2] | 223.4 | 8.4 | 5.2 | 1.83 | 2.5 | 2.5 | matched | 0.0 | 2.5 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -2 | [-0.1, 0.3, -32.1] → [21.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 17, 0, -2 | [338.2, 2.3, -31.1] → [356.4, 11.6, -15.0] | 142.1 | 9.6 | 6.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -18 | [16.6, 11.5, -357.3] → [25.7, 19.6, -344.8] | 12.1 | 43.7 | 41.5 | 8.72 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_28 | 3806f5a8_close_door (256×256) | -1, 0, -18 | [-2.4, 11.7, -350.6] → [1.1, 16.4, -350.6] | 11.2 | 105.5 | 58.7 | 7.34 | 2.7 | 2.7 | matched | 0.0 | 2.7 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_28 | 3806f5a8_close_door (256×256) | 16, 0, -1 | [333.4, 2.1, -13.7] → [336.8, 6.8, -13.7] | 14.5 | 108.3 | 58.7 | 7.34 | 2.7 | 2.7 | matched | 0.0 | 2.7 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -1 | [17.1, -0.1, -21.2] → [26.2, 8.7, -8.7] | 22.6 | 48.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, -0.1, -21.2] → [-17.3, 8.7, -8.7] | 29.2 | 49.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -18 | [-26.8, 11.5, -361.2] → [-17.8, 19.9, -344.8] | 54.3 | 43.2 | 34.9 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 16, 0, 1 | [321.6, 2.8, 19.5] → [340.2, 18.0, 29.8] | 10.8 | 81.1 | 49.4 | 6.17 | 2.3 | 2.3 | matched | 0.0 | 2.3 | repeat/repeat |
| ystra-labyrinth-boss1 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -18 | [16.6, 11.9, -357.3] → [25.7, 19.9, -344.8] | 12.9 | 31.0 | 29.3 | 6.17 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | -1, 0, 1 | [-4.0, 0.0, 27.0] → [4.0, 8.0, 27.0] | 27.4 | 131.0 | 139.6 | 1.30 | 16.0 | 16.0 | matched | 0.0 | 16.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | 0, 0, 1 | [-2.0, 0.0, 27.0] → [4.0, 8.0, 27.0] | 19.4 | 131.0 | 139.6 | 1.30 | 8.0 | 8.0 | matched | 0.0 | 8.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, 0 | [-10.8, 0.0, -0.9] → [0.0, 0.2, 13.3] | 105.3 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, 0 | [-1.6, 0.0, -0.9] → [10.8, 0.2, 13.3] | 126.7 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -1 | [-25.1, -0.1, -21.2] → [-0.1, 9.6, -6.6] | 398.1 | 44.4 | 34.9 | 3.08 | 6.6 | 6.6 | matched | 0.0 | 6.6 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, -1 | [-10.3, 0.0, -8.4] → [0.0, 0.2, 2.4] | 60.2 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, -1 | [-1.6, 0.0, -8.4] → [10.3, 0.2, 2.4] | 73.0 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.4, -0.1, -28.9] → [-0.1, 9.6, -19.6] | 88.5 | 37.3 | 29.3 | 2.18 | 3.0 | 3.0 | matched | 0.0 | 3.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.3, 0.3, -32.1] → [-0.1, 9.6, -14.2] | 223.4 | 8.4 | 5.2 | 1.83 | 2.5 | 2.5 | matched | 0.0 | 2.5 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -2 | [-0.1, 0.3, -32.1] → [21.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -1 | [-0.1, 0.3, -28.1] → [20.8, 9.6, -6.6] | 230.8 | 15.7 | 6.2 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -1 | [-21.0, 0.3, -28.1] → [-0.1, 9.6, -6.6] | 237.9 | 15.8 | 6.2 | 3.08 | 2.1 | 2.1 | matched | 0.0 | 2.1 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -2 | [-6.8, 15.5, -23.5] → [-1.4, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -2 | [1.3, 15.5, -23.5] → [6.6, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -1 | [-0.1, 9.7, -21.5] → [14.6, 17.8, -6.7] | 50.9 | 22.1 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -1 | [-14.8, 9.7, -21.5] → [-0.1, 17.8, -6.7] | 50.9 | 22.2 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -1 | [17.1, -0.1, -21.2] → [26.2, 8.7, -8.7] | 22.6 | 48.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, -0.1, -21.2] → [-17.3, 8.7, -8.7] | 29.2 | 49.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, 0 | [-6.1, 22.6, 2.5] → [0.4, 26.5, 7.0] | 12.5 | 80.5 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -1 | [-0.2, 22.6, -8.7] → [7.8, 26.5, -0.1] | 19.6 | 80.9 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, -1 | [-7.9, 22.6, -8.7] → [0.2, 26.5, -0.3] | 21.3 | 81.7 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, 0.3, -21.2] → [-17.3, 8.7, -8.7] | 50.6 | 26.4 | 20.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | floor | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | -1, 0, 1 | [-12.2, 0.5, 19.5] → [-2.3, 2.4, 23.3] | 11.7 | 50.1 | 41.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss2 | wall | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | 0, 1, -1 | [-1.8, 26.1, -10.6] → [9.8, 28.2, -0.9] | 13.3 | 62.3 | 58.7 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | -1, 0, 1 | [-4.0, 0.0, 27.0] → [4.0, 8.0, 27.0] | 27.4 | 131.0 | 139.6 | 1.30 | 16.0 | 16.0 | matched | 0.0 | 16.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_11 | 4731310c_mystic_altar_wall03 (1024×512) | 0, 0, 1 | [-2.0, 0.0, 27.0] → [4.0, 8.0, 27.0] | 19.4 | 131.0 | 139.6 | 1.30 | 8.0 | 8.0 | matched | 0.0 | 8.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, 0 | [-10.8, 0.0, -0.9] → [0.0, 0.2, 13.3] | 105.3 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, 0 | [-1.6, 0.0, -0.9] → [10.8, 0.2, 13.3] | 126.7 | 93.1 | 98.7 | 1.09 | 6.8 | 6.8 | matched | 6.8 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -1 | [-25.1, -0.1, -21.2] → [-0.1, 9.6, -6.6] | 398.1 | 44.4 | 34.9 | 3.08 | 6.6 | 6.6 | matched | 0.0 | 6.6 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | -1, 0, -1 | [-10.3, 0.0, -8.4] → [0.0, 0.2, 2.4] | 60.2 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_31 | 83ef3fae_mystic_altar_ceiling (512×512) | 0, 0, -1 | [-1.6, 0.0, -8.4] → [10.3, 0.2, 2.4] | 73.0 | 93.1 | 98.7 | 1.09 | 4.5 | 4.5 | matched | 4.5 | -0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.4, -0.1, -28.9] → [-0.1, 9.6, -19.6] | 88.5 | 37.3 | 29.3 | 2.18 | 3.0 | 3.0 | matched | 0.0 | 3.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -2 | [-21.3, 0.3, -32.1] → [-0.1, 9.6, -14.2] | 223.4 | 8.4 | 5.2 | 1.83 | 2.5 | 2.5 | matched | 0.0 | 2.5 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -2 | [-0.1, 0.3, -32.1] → [21.1, 9.6, -14.2] | 223.4 | 8.6 | 5.2 | 1.83 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | 0, 0, -1 | [-0.1, 0.3, -28.1] → [20.8, 9.6, -6.6] | 230.8 | 15.7 | 6.2 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -1, 0, -1 | [-21.0, 0.3, -28.1] → [-0.1, 9.6, -6.6] | 237.9 | 15.8 | 6.2 | 3.08 | 2.1 | 2.1 | matched | 0.0 | 2.1 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -2 | [-6.8, 15.5, -23.5] → [-1.4, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -2 | [1.3, 15.5, -23.5] → [6.6, 17.8, -19.1] | 10.4 | 20.7 | 20.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | 0, 0, -1 | [-0.1, 9.7, -21.5] → [14.6, 17.8, -6.7] | 50.9 | 22.1 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_29 | 45a2e846_cattle_sculpture (512×512) | -1, 0, -1 | [-14.8, 9.7, -21.5] → [-0.1, 17.8, -6.7] | 50.9 | 22.2 | 14.7 | 2.18 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | 1, 0, -1 | [17.1, -0.1, -21.2] → [26.2, 8.7, -8.7] | 22.6 | 48.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, -0.1, -21.2] → [-17.3, 8.7, -8.7] | 29.2 | 49.2 | 41.5 | 7.34 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, 0 | [-6.1, 22.6, 2.5] → [0.4, 26.5, 7.0] | 12.5 | 80.5 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | 0, 1, -1 | [-0.2, 22.6, -8.7] → [7.8, 26.5, -0.1] | 19.6 | 80.9 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_12 | d5d61acf_mystic_altar_column (1024×512) | -1, 1, -1 | [-7.9, 22.6, -8.7] → [0.2, 26.5, -0.3] | 21.3 | 81.7 | 69.8 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_2 | 583479a0_temple (1024×512) | -2, 0, -1 | [-26.3, 0.3, -21.2] → [-17.3, 8.7, -8.7] | 50.6 | 26.4 | 20.7 | 3.67 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | floor | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | -1, 0, 1 | [-12.2, 0.5, 19.5] → [-2.3, 2.4, 23.3] | 11.7 | 50.1 | 41.5 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| ystra-labyrinth-boss3 | wall | runewaker_material_17 | 5ffe292d_mystic_altar_wall02_base_bar (256×256) | 0, 1, -1 | [-1.8, 26.1, -10.6] → [9.8, 28.2, -0.9] | 13.3 | 62.3 | 58.7 | 3.08 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_154 | c43c6653_carpet_01 (512×512) | 2, -3, -28 | [33.5, -56.0, -569.0] → [85.3, -56.0, -546.2] | 590.3 | — | — | — | 590.3 | 590.3 | matched | 0.0 | 590.3 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_138 | 98d5a655_hell_gate_01 (1024×1024) | 11, 0, -26 | [219.3, -12.9, -514.8] → [240.1, 17.7, -505.1] | 1571.2 | 24.0 | 17.4 | 1.30 | 495.0 | 495.0 | matched | 0.0 | 495.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 10, 0, -12 | [198.4, 9.5, -248.5] → [223.6, 15.6, -210.4] | 646.6 | 14.3 | 14.7 | 1.09 | 464.9 | 464.9 | matched | 0.0 | 464.9 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 11, 0, -12 | [218.2, 15.3, -248.5] → [243.9, 15.6, -208.3] | 652.4 | 10.0 | 8.7 | 29.34 | 316.9 | 316.9 | matched | 0.0 | 316.9 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 10, 0, -11 | [198.4, 9.5, -226.3] → [223.6, 15.6, -186.3] | 618.3 | 14.4 | 14.7 | 1.09 | 316.2 | 316.2 | matched | 0.0 | 316.2 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 13, 1, -12 | [246.5, 21.3, -246.2] → [276.2, 27.6, -210.7] | 570.0 | 9.5 | 8.7 | 3.08 | 277.2 | 277.2 | matched | 0.0 | 277.2 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 10, 0, -13 | [198.4, 9.5, -270.4] → [223.6, 15.6, -230.5] | 512.6 | 14.4 | 14.7 | 1.09 | 242.6 | 242.6 | matched | 0.0 | 242.6 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 12, 1, -11 | [236.1, 21.3, -228.4] → [268.8, 27.6, -198.3] | 485.7 | 14.4 | 14.7 | 8.72 | 238.4 | 238.4 | matched | 0.0 | 238.4 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -17 | [-227.8, -9.5, -351.6] → [-188.8, -9.5, -298.8] | 18.8 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -16 | [-234.8, -9.5, -348.9] → [-191.5, -9.5, -298.8] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -23 | [-101.2, -9.5, -475.2] → [-58.0, -9.5, -425.0] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -17 | [-111.0, -9.5, -348.7] → [-58.2, -9.5, -305.5] | 299.1 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -23 | [-108.1, -9.5, -475.2] → [-64.9, -9.5, -425.0] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -16 | [-111.0, -9.5, -344.5] → [-58.2, -9.5, -298.6] | 317.8 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -10, -1, -17 | [-227.8, -9.5, -351.6] → [-181.9, -9.5, -298.8] | 317.9 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | unmatched | — | — | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -23 | [-234.6, -9.5, -475.3] → [-184.5, -9.5, -418.3] | 346.8 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 11, -1, -23 | [213.9, -16.4, -459.3] → [241.3, -11.6, -444.3] | 359.8 | 13.3 | 1.3 | 7512.10 | 179.9 | 179.9 | matched | 0.0 | 179.9 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_73 | 7d4c443d_railings_01 (512×256) | 4, -3, -29 | [90.8, -56.1, -581.8] → [91.5, -55.9, -545.4] | 11.1 | 29.2 | 24.7 | 6316.90 | 7.1 | 7.1 | matched | 0.0 | 7.1 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | -8, -2, -24 | [-171.4, -39.7, -479.8] → [-126.9, -29.1, -476.5] | 67.7 | 31.9 | 29.3 | 5311.85 | 65.1 | 65.1 | matched | 0.0 | 65.1 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_73 | 7d4c443d_railings_01 (512×256) | 11, -1, -23 | [215.4, -15.7, -457.8] → [239.8, -15.5, -457.3] | 10.3 | 41.8 | 29.3 | 4466.72 | 1.6 | 1.6 | matched | 0.0 | 1.6 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_73 | 7d4c443d_railings_01 (512×256) | 11, -1, -23 | [215.5, -15.6, -457.8] → [239.8, -15.5, -457.3] | 11.5 | 35.5 | 29.3 | 1327.96 | 0.7 | 1.2 | matched | 0.0 | 1.2 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_99 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 1, 0, -7 | [22.3, 2.3, -155.6] → [39.6, 17.6, -98.7] | 40.5 | 53.4 | 34.9 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | wall | runewaker_material_99 | c310dc89_city_of_naga_front_part_001 (2048×2048) | -8, -5, -25 | [-186.1, -84.6, -496.0] → [-129.3, -76.4, -478.6] | 47.9 | 41.5 | 29.3 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold | floor | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | -9, -1, -24 | [-179.1, -19.2, -477.2] → [-160.3, -14.2, -476.5] | 23.5 | 27.8 | 29.3 | 939.01 | 17.0 | 17.0 | matched | 0.0 | 17.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_154 | c43c6653_carpet_01 (512×512) | 2, -3, -28 | [33.5, -56.0, -569.0] → [85.3, -56.0, -546.2] | 590.3 | — | — | — | 590.3 | 590.3 | matched | 0.0 | 590.3 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_138 | 98d5a655_hell_gate_01 (1024×1024) | 11, 0, -26 | [219.3, -12.9, -514.8] → [240.1, 17.7, -505.1] | 1571.2 | 24.0 | 17.4 | 1.30 | 495.0 | 495.0 | matched | 0.0 | 495.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 10, 0, -12 | [198.4, 9.5, -248.5] → [223.6, 15.6, -210.4] | 646.6 | 14.3 | 14.7 | 1.09 | 464.9 | 464.9 | matched | 0.0 | 464.9 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 11, 0, -12 | [218.2, 15.3, -248.5] → [243.9, 15.6, -208.3] | 652.4 | 10.0 | 8.7 | 29.34 | 316.9 | 316.9 | matched | 0.0 | 316.9 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 10, 0, -11 | [198.4, 9.5, -226.3] → [223.6, 15.6, -186.3] | 618.3 | 14.4 | 14.7 | 1.09 | 316.2 | 316.2 | matched | 0.0 | 316.2 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 13, 1, -12 | [246.5, 21.3, -246.2] → [276.2, 27.6, -210.7] | 570.0 | 9.5 | 8.7 | 3.08 | 277.2 | 277.2 | matched | 0.0 | 277.2 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 10, 0, -13 | [198.4, 9.5, -270.4] → [223.6, 15.6, -230.5] | 512.6 | 14.4 | 14.7 | 1.09 | 242.6 | 242.6 | matched | 0.0 | 242.6 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 12, 1, -11 | [236.1, 21.3, -228.4] → [268.8, 27.6, -198.3] | 485.7 | 14.4 | 14.7 | 8.72 | 238.4 | 238.4 | matched | 0.0 | 238.4 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -17 | [-227.8, -9.5, -351.6] → [-188.8, -9.5, -298.8] | 18.8 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -16 | [-234.8, -9.5, -348.9] → [-191.5, -9.5, -298.8] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -23 | [-101.2, -9.5, -475.2] → [-58.0, -9.5, -425.0] | 299.0 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -17 | [-111.0, -9.5, -348.7] → [-58.2, -9.5, -305.5] | 299.1 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -5, -1, -23 | [-108.1, -9.5, -475.2] → [-64.9, -9.5, -425.0] | 299.2 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -4, -1, -16 | [-111.0, -9.5, -344.5] → [-58.2, -9.5, -298.6] | 317.8 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -10, -1, -17 | [-227.8, -9.5, -351.6] → [-181.9, -9.5, -298.8] | 317.9 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | unmatched | — | — | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_5 | 67e92acf_city_of_naga_front_part_014 (2048×1024) | -11, -1, -23 | [-234.6, -9.5, -475.3] → [-184.5, -9.5, -418.3] | 346.8 | 4.7 | 4.4 | 1.54 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_142 | 150c8ebf_city_of_naga_front_part_007 (2048×2048) | 11, -1, -23 | [213.9, -16.4, -459.3] → [241.3, -11.6, -444.3] | 359.8 | 13.3 | 1.3 | 7512.10 | 179.9 | 179.9 | matched | 0.0 | 179.9 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_73 | 7d4c443d_railings_01 (512×256) | 4, -3, -29 | [90.8, -56.1, -581.8] → [91.5, -55.9, -545.4] | 11.1 | 29.2 | 24.7 | 6316.90 | 7.1 | 7.1 | matched | 0.0 | 7.1 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | -8, -2, -24 | [-171.4, -39.7, -479.8] → [-126.9, -29.1, -476.5] | 67.7 | 31.9 | 29.3 | 5311.85 | 65.1 | 65.1 | matched | 0.0 | 65.1 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_73 | 7d4c443d_railings_01 (512×256) | 11, -1, -23 | [215.4, -15.7, -457.8] → [239.8, -15.5, -457.3] | 10.3 | 41.8 | 29.3 | 4466.72 | 1.6 | 1.6 | matched | 0.0 | 1.6 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_73 | 7d4c443d_railings_01 (512×256) | 11, -1, -23 | [215.5, -15.6, -457.8] → [239.8, -15.5, -457.3] | 11.5 | 35.5 | 29.3 | 1327.96 | 0.7 | 1.2 | matched | 0.0 | 1.2 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_99 | c310dc89_city_of_naga_front_part_001 (2048×2048) | 1, 0, -7 | [22.3, 2.3, -155.6] → [39.6, 17.6, -98.7] | 40.5 | 53.4 | 34.9 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | wall | runewaker_material_99 | c310dc89_city_of_naga_front_part_001 (2048×2048) | -8, -5, -25 | [-186.1, -84.6, -496.0] → [-129.3, -76.4, -478.6] | 47.9 | 41.5 | 29.3 | 1116.68 | 0.0 | 0.0 | matched | 0.0 | 0.0 | repeat/repeat |
| zurhidon-stronghold-124 | floor | runewaker_material_74 | bf8bd16c_city_of_naga_front_part_002 (1024×1024) | -9, -1, -24 | [-179.1, -19.2, -477.2] → [-160.3, -14.2, -476.5] | 23.5 | 27.8 | 29.3 | 939.01 | 17.0 | 17.0 | matched | 0.0 | 17.0 | repeat/repeat |
## Source-to-shipped dimensions
Across 1070 embedded images, 0 are smaller than the matching source DDS and 0 match the previous 2048-side cap condition. The current audit reports only shipped files; it does not rebuild or replace those assets.
| Dungeon | Texture | Source DDS | Shipped KTX2 | Texels retained | Codec-only PSNR (dB) |
| --- | --- | ---: | ---: | ---: | ---: |
| — | No downsized matches recorded | — | — | — | — |
## ETC1S quality comparison
Pixel decoding was skipped for this run. For source PNG/KTX2 pairs, `ktx extract --transcode rgba8` decodes shipped ETC1S level 0. RGB PSNR/MAE compares that decode with the source PNG resized to the shipped dimensions using Lanczos3, so it isolates codec loss from a resolution change. It does not score alpha, mip levels, display-space lighting, or a screenshot. PSNR is a numerical image metric and does not substitute for in-game review.
| Dungeon | Compared | Median PSNR dB | P10 PSNR dB | Minimum PSNR dB | Mean RGB MAE |
| --- | ---: | ---: | ---: | ---: | ---: |
| — | 0 | — | — | — | — |
## Light metadata observed in the environment GLBs
`KHR_lights_punctual` and emissive material metadata are counted from each shipped environment GLB. The runtime presentation lights live in the dungeon definitions, and their illumination still requires a rendered review; those are covered in the separate RoM lighting pass.
- GLBs with embedded punctual lights: 0/30. The environments contain 0 light records and 0 emissive materials in total.
- Surface area with degenerate base-color UV triangles (≥0.02 m² triangle filter): 389511.6 m² on floors and 669833.4 m² on walls.
- Surface area with a rank-collapsed UV direction (≤0.01 px/m): 433300.7 m² on floors and 679360.2 m² on walls.
## Coverage gaps and interpretation limits
- The report measures current shipped visual GLBs. It does not rebuild assets, so changes to the producer pipeline do not change the shipped texture dimensions until packages are regenerated.
- Source dimensions are linked through the GLB image basename to preserved dungeon-local and shared resource-cache DDS files. Shared-cache duplicate basenames are accepted only when the DDS byte hashes are identical; conflicting candidates remain unmatched and are listed.
- Codec PSNR and MAE compare decoded ETC1S RGB against resized source PNG pixels. They exclude alpha error, mip rendering, lighting, filtering, camera distance, and human visual perception.
- The current final report run skipped pixel decoding; codec PSNR/MAE fields are intentionally unavailable. Dimension comparison, mip-level counts, material references, and base-color UV geometry were still audited.
- UV density uses base-color texture coordinates for visual surfaces. Other texture channels and runtime material replacements are inventoried, but do not contribute to the floor/wall density ranking.
- The audit expands EXT_mesh_gpu_instancing per-instance translation, rotation, and scale before measuring world areas, spatial cells, and UV density. A before/after comparison excludes a baseline with GPU instances unless that baseline records expanded instance metrics.
- Surface classes are geometric-normal heuristics in the GLB frame. Floor includes upward surfaces; ceiling, slopes, and other faces are separately measured. This does not identify a semantic game level or classify sub-horizontal platform parts as authored floors.
- Audit hotspots use triangle area ≥0.02 m² and spatial cells at 20 m. Aggregate groups under 10 m² are excluded from the broad hotspot report, though their measurements remain in surface totals.
- Converted-source comparison reads runewaker-export-work/<slug>/converted/<slug>-visual.glb after static WDB assembly, so it includes available static shared-resource placements before optimization. It joins by surface, image basename, and 20 m cell. Separate dynamic WDB assets and resources marked unsupported are outside this static visual comparison; unmatched keys or expanded bounds need checking against the placement manifest before attribution.
- Embedded light metadata does not describe the runtime renderer's ambient, hemisphere, directional, or point-light interactions. Rendered captures are needed for lighting validation.
@@ -0,0 +1,654 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import readline from "node:readline";
import { Document, NodeIO } from "@gltf-transform/core";
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
import {
isTrekkerPathEligible,
NativeCollisionPartAccumulator,
normalizeNativeResourcePath,
nativeResourceFile,
renderMeshOmissions,
transformNativeCollisionTriangle,
triangleTileKey,
TREKKER_WATER_FOLIAGE_EXCLUSION_MASK,
validateNativeCollisionCounts,
} from "./lib/native-collision-assembly.mjs";
import {
normalizedResourceKey,
readAndValidateNativeCollisionInventory,
sha256File,
writeNativeCollisionInventoryManifest,
} from "./lib/native-collision-inventory.mjs";
const DEFAULT_RECIPE = "scripts/runewaker-pipeline/recipes/the-origin.json";
const MAX_TRIANGLES_PER_GLB = 90_000;
const DEFAULT_TILE_SIZE_METERS = 256;
const USAGE = `Assemble bounded collision chunks from serialized native Runewaker ROS collision.
Usage:
node scripts/runewaker-pipeline/assemble-native-collision.mjs --recipe <recipe.json> --placement-manifest <wdb-placement-manifest.json> --output-root <directory> [options]
Required:
--recipe <path> Dungeon recipe; supplies source root, WDB, and coordinate scale
--placement-manifest <path> Exact WDB placement matrices and resource list
--output-root <path> Output directory for the deterministic run and default inventory
Options:
--source-root <path> Override recipe source root
--inventory-root <path> Reuse inventory with native-collision-inventory.json SHA index
--extractor <path> Native Rune Engine collision extractor executable
--collision-mode <mode> complete (default) or respect-options (honor ruENTITYOPTION_COLLISION)
--tile-size-meters <number> Spatial tile size (default ${DEFAULT_TILE_SIZE_METERS})
--report-output <path> Copy the complete run report to a stable workspace file
--help Show this help
Each collision GLB is limited to ${MAX_TRIANGLES_PER_GLB.toLocaleString()} triangles. No render-mesh fallback is used.`;
function parseArgs(argv) {
const options = {};
const valueOptions = new Set([
"recipe", "placement-manifest", "output-root", "source-root", "inventory-root", "extractor", "collision-mode", "tile-size-meters", "report-output",
]);
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help" || token === "-h") return { help: true };
if (!token.startsWith("--")) throw new Error(`Unexpected positional argument: ${token}`);
const name = token.slice(2);
if (!valueOptions.has(name)) throw new Error(`Unknown option --${name}.`);
if (options[name] !== undefined) throw new Error(`Option --${name} may only be supplied once.`);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Option --${name} requires a value.`);
options[name] = value;
index += 1;
}
if (!options.recipe) options.recipe = DEFAULT_RECIPE;
if (options["collision-mode"] === undefined) options["collision-mode"] = "complete";
if (!["complete", "respect-options"].includes(options["collision-mode"])) throw new Error("--collision-mode must be complete or respect-options.");
for (const required of ["placement-manifest", "output-root"]) {
if (!options[required]) throw new Error(`Missing required option --${required}.`);
}
return { help: false, ...options };
}
function resolveProjectPath(value) {
return path.resolve(projectRoot, value);
}
function resolveWorkspaceOutput(value) {
const output = resolveProjectPath(value);
const relative = path.relative(projectRoot, output);
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error("--output-root must stay inside the HealerMan workspace.");
}
return output;
}
function validatePlacementInputs(recipe, manifest) {
if (manifest.dungeonId && manifest.dungeonId !== recipe.slug) {
throw new Error(`Placement manifest dungeonId ${manifest.dungeonId} does not match recipe ${recipe.slug}.`);
}
if (!Array.isArray(manifest.staticPlacements) || manifest.staticPlacements.length === 0) {
throw new Error("Placement manifest must contain staticPlacements.");
}
if (!Array.isArray(manifest.resources) || manifest.resources.length === 0) {
throw new Error("Placement manifest must contain resources.");
}
if (Number(manifest.sourceDescriptorCount) !== manifest.staticPlacements.length) {
throw new Error("Placement manifest static placement count does not match sourceDescriptorCount.");
}
if (!Number.isFinite(Number(recipe.coordinateSystem?.metersPerSourceUnit)) || Number(recipe.coordinateSystem.metersPerSourceUnit) <= 0) {
throw new Error("Recipe must declare coordinateSystem.metersPerSourceUnit as a positive value.");
}
const placementResources = new Map();
for (const placement of manifest.staticPlacements) {
normalizeNativeResourcePath(placement.resource);
if (!Array.isArray(placement.transform?.primaryLocalThreeMatrix) || placement.transform.primaryLocalThreeMatrix.length !== 16) {
throw new Error(`Placement ${placement.guid ?? placement.id} lacks its exact 16-value primaryLocalThreeMatrix.`);
}
placementResources.set(normalizedResourceKey(placement.resource), placement.resource);
}
const manifestResources = new Set();
for (const row of manifest.resources) {
const key = normalizedResourceKey(row.resource);
if (manifestResources.has(key)) throw new Error(`Duplicate resource row in placement manifest: ${row.resource}`);
manifestResources.add(key);
}
for (const key of placementResources.keys()) {
if (!manifestResources.has(key)) throw new Error(`Placed ROS resource is absent from placement manifest resource index: ${key}`);
}
const staticResources = [...placementResources.keys()].map((key) => manifest.resources.find((row) => normalizedResourceKey(row.resource) === key));
return { staticPlacements: manifest.staticPlacements, resources: staticResources };
}
function runExtractor(extractor, resourceRoot, resource, reportFile, collisionMode) {
return new Promise((resolve, reject) => {
const child = spawn(extractor, [resourceRoot, resource, reportFile.replaceAll("\\", "/"), collisionMode], {
windowsHide: true,
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (part) => { stderr += part; });
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) reject(new Error(`Native collision extractor failed for ${resource} (exit ${code}): ${stderr.trim()}`));
else resolve();
});
});
}
async function extractInventory({ inventoryRoot, extractor, nativeExtractor, collisionMode, resourceRoot, expectedResources, placementManifestSha256, sourceWdbSha256 }) {
await mkdir(inventoryRoot, { recursive: true });
const rows = [];
const ordered = [...expectedResources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)));
for (let index = 0; index < ordered.length; index += 1) {
const resource = ordered[index].resource;
const sourceFile = nativeResourceFile(resourceRoot, resource);
const sourceBefore = await sha256File(sourceFile);
const basename = path.basename(normalizeNativeResourcePath(resource)).replace(/[^a-zd._-]/gi, "_");
const reportFile = path.join(inventoryRoot, `${String(index).padStart(4, "0")}-${basename}.json`);
await runExtractor(extractor, resourceRoot, resource, reportFile, collisionMode);
const sourceAfter = await sha256File(sourceFile);
if (sourceBefore !== sourceAfter) throw new Error(`Source ROS changed during extraction: ${resource}`);
const report = JSON.parse(await readFile(reportFile, "utf8"));
if (normalizedResourceKey(report.sourceModel) !== normalizedResourceKey(resource)) {
throw new Error(`Extractor report identity mismatch. Requested ${resource}, got ${report.sourceModel}.`);
}
if (report.collisionMode !== collisionMode) throw new Error(`Extractor collision mode mismatch for ${resource}.`);
const triangleCount = Number(report.nativeCollisionTriangleCount);
if (!Number.isSafeInteger(triangleCount) || triangleCount < 0) throw new Error(`Invalid native triangle count in report for ${resource}.`);
const objFile = `${reportFile}.obj`;
const flagsFile = `${reportFile}.flags.bin`;
rows.push({ resource, report, reportFile, objFile, flagsFile, sourceResourceSha256: sourceBefore });
if ((index + 1) % 25 === 0 || index + 1 === ordered.length) {
console.log(`extracted ${index + 1}/${ordered.length} native ROS collision resources`);
}
}
return writeNativeCollisionInventoryManifest({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
collisionMode,
resources: rows,
sourceResourceRoot: resourceRoot,
});
}
async function readObjTriangleSoup(file, expectedTriangles) {
const positions = new Float32Array(expectedTriangles * 9);
const reader = readline.createInterface({ input: createReadStream(file), crlfDelay: Infinity });
let vertex = 0;
let face = 0;
for await (const line of reader) {
if (line.startsWith("v ")) {
const parts = line.trim().split(/\s+/);
if (parts.length !== 4) throw new Error(`Malformed OBJ vertex in ${file}.`);
const offset = vertex * 3;
positions[offset] = Number(parts[1]);
positions[offset + 1] = Number(parts[2]);
positions[offset + 2] = Number(parts[3]);
if (![positions[offset], positions[offset + 1], positions[offset + 2]].every(Number.isFinite)) {
throw new Error(`Non-finite OBJ vertex in ${file}.`);
}
vertex += 1;
} else if (line.startsWith("f ")) {
const parts = line.trim().split(/\s+/).slice(1).map(Number);
const expected = [face * 3 + 1, face * 3 + 2, face * 3 + 3];
if (parts.length !== 3 || parts.some((value, index) => value !== expected[index])) {
throw new Error(`Native collision OBJ is not a sequential triangle soup at face ${face + 1}: ${file}`);
}
face += 1;
}
}
validateNativeCollisionCounts({
triangleCount: expectedTriangles,
flagBytes: expectedTriangles * 4,
positionVertexCount: vertex,
});
if (face !== expectedTriangles) throw new Error(`${file} has ${face} faces, expected ${expectedTriangles}.`);
return positions;
}
class TilePartWriter {
constructor({ variant, tile, outputDirectory, rootExtras }) {
this.variant = variant;
this.tile = tile;
this.outputDirectory = outputDirectory;
this.rootExtras = rootExtras;
this.partIndex = 0;
this.accumulator = new NativeCollisionPartAccumulator(MAX_TRIANGLES_PER_GLB);
this.files = [];
}
async add(points, flag) {
const complete = this.accumulator.add(points, flag);
if (complete) await this.writePart(complete);
}
async flush() {
const part = this.accumulator.finish();
if (part) await this.writePart(part);
}
async writePart(part) {
const document = new Document();
const { triangleCount, positions, sourceTriangleFlags: flags, flagCounts } = part;
document.getRoot().setExtras({
...this.rootExtras,
variant: this.variant,
tile: this.tile,
partIndex: this.partIndex,
triangleCount,
triangleFlagEncoding: "primitive.extras.sourceTriangleFlags maps one value to each consecutive triangle in POSITION order",
});
const buffer = document.createBuffer(`${this.variant}-${this.tile}-${this.partIndex}-buffer`);
const scene = document.createScene(`${this.variant}-${this.tile}-${this.partIndex}-scene`);
document.getRoot().setDefaultScene(scene);
const positionAccessor = document.createAccessor(`${this.variant}-${this.tile}-${this.partIndex}-position`, buffer)
.setType("VEC3")
.setArray(positions);
const primitive = document.createPrimitive()
.setAttribute("POSITION", positionAccessor)
.setExtras({ sourceTriangleFlags: flags, sourceTriangleFlagCounts: flagCounts });
const mesh = document.createMesh(`${this.variant}-tile-${this.tile}-part-${this.partIndex}`).addPrimitive(primitive);
scene.addChild(document.createNode(mesh.getName()).setMesh(mesh));
const filename = `${this.variant}-tile-${this.tile.replace(":", "_")}-part-${String(this.partIndex).padStart(3, "0")}.glb`;
const file = path.join(this.outputDirectory, filename);
await new NodeIO().write(file, document);
const bytes = await readFile(file);
this.files.push({
file,
sha256: createHash("sha256").update(bytes).digest("hex"),
byteLength: bytes.byteLength,
tile: this.tile,
partIndex: this.partIndex,
triangles: triangleCount,
flagCounts,
});
this.partIndex += 1;
}
}
class VariantTileCollection {
constructor({ variant, outputDirectory, rootExtras }) {
this.variant = variant;
this.outputDirectory = outputDirectory;
this.rootExtras = rootExtras;
this.tiles = new Map();
}
async add(points, flag, tileSize) {
const tile = triangleTileKey(points, tileSize);
let writer = this.tiles.get(tile);
if (!writer) {
writer = new TilePartWriter({ variant: this.variant, tile, outputDirectory: this.outputDirectory, rootExtras: this.rootExtras });
this.tiles.set(tile, writer);
}
await writer.add(points, flag);
}
async finish() {
for (const [tile, writer] of [...this.tiles.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))) {
await writer.flush();
}
return [...this.tiles.values()].flatMap((writer) => writer.files);
}
}
function appendMapCount(map, key, amount = 1) {
map.set(key, (map.get(key) ?? 0) + amount);
}
async function assemble({
recipe,
recipeSha256,
placementManifest,
placementManifestSha256,
sourceWdbSha256,
inventory,
outputRoot,
tileSizeMeters,
collisionMode,
}) {
const fingerprint = createHash("sha256")
.update(`${recipeSha256}\n${placementManifestSha256}\n${sourceWdbSha256}\n${inventory.manifestSha256}\n${TREKKER_WATER_FOLIAGE_EXCLUSION_MASK}\n${tileSizeMeters}\n${collisionMode}`)
.digest("hex");
const runRoot = path.join(outputRoot, "runs", fingerprint.slice(0, 24));
const allDirectory = path.join(runRoot, "collision-all");
const pathDirectory = path.join(runRoot, "collision-trekker-filtered");
await mkdir(allDirectory, { recursive: true });
await mkdir(pathDirectory, { recursive: true });
const meters = Number(recipe.coordinateSystem.metersPerSourceUnit);
const rootExtras = {
sourceManifestSha256: placementManifestSha256,
sourceWdbSha256,
nativeCollisionInventorySha256: inventory.manifestSha256,
nativeCollisionMode: collisionMode,
sourceCoordinateTransform: `pThree=S*pRunewaker; S=scale(${meters},${meters},-${meters}), then exact WDB primaryLocalThreeMatrix`,
collisionSource: "serialized native IRuCollisionObject / CRuCollisionStructure only; render-mesh fallback excluded",
trekkerWaterFoliageExclusionMask: TREKKER_WATER_FOLIAGE_EXCLUSION_MASK,
maxTrianglesPerGlb: MAX_TRIANGLES_PER_GLB,
};
const all = new VariantTileCollection({ variant: "all-serialized-native", outputDirectory: allDirectory, rootExtras });
const pathEligible = new VariantTileCollection({ variant: "trekker-water-foliage-excluded", outputDirectory: pathDirectory, rootExtras });
const resources = inventory.resources;
const perResource = new Map();
const perPlacement = [];
const overallFlags = new Map();
let allTriangles = 0;
let completeNativeTriangles = 0;
let collisionOptionOmittedTriangles = 0;
let eligibleTriangles = 0;
let excludedTriangles = 0;
let fallbackTriangles = 0;
for (let placementOrdinal = 0; placementOrdinal < placementManifest.staticPlacements.length; placementOrdinal += 1) {
const placement = placementManifest.staticPlacements[placementOrdinal];
const key = normalizedResourceKey(placement.resource);
const native = resources.get(key);
if (!native) throw new Error(`No hash-validated native collision inventory for ${placement.resource}.`);
const report = native.report;
const triangleCount = Number(report.nativeCollisionTriangleCount);
const completeNativeCount = Number(report.completeNativeCollisionTriangleCount ?? triangleCount);
const collisionOptionOmittedCount = Number(report.nativeTrianglesOmittedByCollisionOptions ?? Math.max(0, completeNativeCount - triangleCount));
const fallbackCount = Number(report.renderMeshFallbackTriangleCount ?? 0);
const flagBuffer = await readFile(native.flagsFile);
const positions = await readObjTriangleSoup(native.objFile, triangleCount);
validateNativeCollisionCounts({ triangleCount, flagBytes: flagBuffer.byteLength, positionVertexCount: positions.length / 3 });
const matrix = placement.transform.primaryLocalThreeMatrix.map(Number);
if (matrix.some((value) => !Number.isFinite(value))) throw new Error(`Non-finite matrix in placement ${placement.guid ?? placement.id}.`);
let placementEligible = 0;
let placementExcluded = 0;
const placementFlags = new Map();
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
const flag = flagBuffer.readUInt32LE(triangleIndex * 4);
appendMapCount(placementFlags, flag);
appendMapCount(overallFlags, flag);
const offset = triangleIndex * 9;
const sourceTriangle = [
positions.subarray(offset, offset + 3),
positions.subarray(offset + 3, offset + 6),
positions.subarray(offset + 6, offset + 9),
];
const worldTriangle = transformNativeCollisionTriangle(sourceTriangle, matrix, meters);
await all.add(worldTriangle, flag, tileSizeMeters);
if (isTrekkerPathEligible(flag)) {
await pathEligible.add(worldTriangle, flag, tileSizeMeters);
placementEligible += 1;
} else {
placementExcluded += 1;
}
}
allTriangles += triangleCount;
completeNativeTriangles += completeNativeCount;
collisionOptionOmittedTriangles += collisionOptionOmittedCount;
eligibleTriangles += placementEligible;
excludedTriangles += placementExcluded;
fallbackTriangles += fallbackCount;
let totals = perResource.get(key);
if (!totals) {
totals = {
resource: placement.resource,
placementCount: 0,
nativeTriangles: 0,
completeNativeTriangles: 0,
collisionOptionOmittedTriangles: 0,
disabledCollisionOptionEntityCount: Number(report.disabledCollisionOptionEntityCount ?? 0),
entityNodesSkippedByCollisionOptionGates: Number(report.entityNodesSkippedByCollisionOptionGates ?? 0),
trekkerFilteredTriangles: 0,
engineVisualFallbackTriangles: 0,
sourceReport: native.row.report,
};
perResource.set(key, totals);
}
totals.placementCount += 1;
totals.nativeTriangles += triangleCount;
totals.completeNativeTriangles += completeNativeCount;
totals.collisionOptionOmittedTriangles += collisionOptionOmittedCount;
totals.trekkerFilteredTriangles += placementEligible;
totals.engineVisualFallbackTriangles += fallbackCount;
perPlacement.push({
ordinal: placementOrdinal,
id: placement.id,
guid: placement.guid,
resource: placement.resource,
sourceMatrix: "manifest.staticPlacements[].transform.primaryLocalThreeMatrix (column-major)",
nativeSerializedTriangles: triangleCount,
completeNativeSerializedTriangles: completeNativeCount,
collisionOptionOmittedTriangles: collisionOptionOmittedCount,
trekkerEligibleTriangles: placementEligible,
maskExcludedTriangles: placementExcluded,
engineVisualFallbackTriangles: fallbackCount,
triangleFlagCounts: Object.fromEntries([...placementFlags.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
renderableSubmeshesWithoutSerializedCollision: renderMeshOmissions(report),
});
}
const allFiles = await all.finish();
const eligibleFiles = await pathEligible.finish();
const verifyFileCaps = (files, variant) => {
for (const file of files) {
if (file.triangles > MAX_TRIANGLES_PER_GLB) throw new Error(`${variant} output exceeds the ${MAX_TRIANGLES_PER_GLB}-triangle GLB limit.`);
}
};
verifyFileCaps(allFiles, "All-source");
verifyFileCaps(eligibleFiles, "Trekker-filtered");
const sumFiles = (files) => files.reduce((sum, row) => sum + row.triangles, 0);
if (sumFiles(allFiles) !== allTriangles || sumFiles(eligibleFiles) !== eligibleTriangles) {
throw new Error("Written collision chunk triangle totals do not match source inventory assembly totals.");
}
const relativeFiles = (files) => files.map((row) => ({
...row,
file: path.relative(projectRoot, row.file).split(path.sep).join("/"),
}));
const report = {
schemaVersion: 1,
purpose: "Repeatable static WDB source collision extraction and placement assembly; diagnostic assets only until reviewed and integrated.",
source: {
recipe: path.relative(projectRoot, path.resolve(projectRoot, recipe.file)).split(path.sep).join("/"),
recipeSha256,
placementManifest: placementManifest.file,
placementManifestSha256,
sourceWdb: recipe.source.wdb,
sourceWdbSha256,
nativeCollisionInventory: path.relative(projectRoot, inventory.manifestPath).split(path.sep).join("/"),
nativeCollisionInventorySha256: inventory.manifestSha256,
nativeExtractor: inventory.inventory.nativeExtractor ?? null,
sourceRosHashPolicy: "Each source ROS SHA-256 is captured before and after native extraction and revalidated against the inventory when assembled.",
},
nativeExtractorPolicy: {
query: collisionMode === "respect-options"
? "CRuEntity::QueryCollision with ignoreCollisionFlag=false; serialized collision traversal skips any node and subtree where ruENTITYOPTION_COLLISION is false"
: "Complete serialized collision-option audit with ignoreCollisionFlag=true; disabled entities are included",
collisionMode,
renderMeshFallbackUsed: false,
renderMeshFallbackTrianglesReportedButNotAssembled: fallbackTriangles,
absentSerializedCollision: "reported and omitted; there is no visual-triangle fallback",
},
placement: {
dungeonId: recipe.slug,
uniqueResourceCount: inventory.resources.size,
staticPlacementCount: placementManifest.staticPlacements.length,
dynamicPlacementCountExcludedFromThisAssembly: placementManifest.dynamicPlacements?.length ?? 0,
sourceBridge: `(x,y,z) -> (${meters}*x,${meters}*y,-${meters}*z), then primaryLocalThreeMatrix`,
matrixStorage: "column-major affine matrix from exact WDB placement manifest",
winding: "source-to-Three Z reflection reverses winding; swap vertices 1 and 2 exactly once after full point transform",
metersPerSourceUnit: meters,
},
trekkerPathPolicy: {
excludedFlagMask: TREKKER_WATER_FOLIAGE_EXCLUSION_MASK,
excludedBits: { water: 0x00200000, foliage: 0x00800000 },
sourceEvidence: "Rune Engine RuFusion_Trekker.cpp::IsPathClear and ::FindRestingHeight use m_exclusionMask=0x00A00000; RuWorld_Base.h defines WATER=0x00200000 and FOLIAGE=0x00800000.",
flagSidecar: "Little-endian uint32 per serialized source triangle in the native query output order.",
},
limits: {
maxTrianglesPerGlb: MAX_TRIANGLES_PER_GLB,
spatialTileSizeMeters: tileSizeMeters,
outputs: "Collision GLBs are non-indexed position-only triangle soups. Primitive extras preserve the exact source triangle flags in triangle order.",
noFallback: "Resources/submeshes with no serialized collision are listed in per-placement omissions and omitted from output GLBs.",
},
totals: {
nativeSerializedTriangles: allTriangles,
completeNativeSerializedTriangles: completeNativeTriangles,
collisionOptionOmittedTriangles,
trekkerEligibleTriangles: eligibleTriangles,
excludedByWaterFoliageMask: excludedTriangles,
engineVisualFallbackTrianglesReported: fallbackTriangles,
allSourceChunkCount: allFiles.length,
trekkerFilteredChunkCount: eligibleFiles.length,
sourceTriangleFlagCounts: Object.fromEntries([...overallFlags.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
},
collisionAssets: {
allSerializedSource: relativeFiles(allFiles),
trekkerFiltered: relativeFiles(eligibleFiles),
},
perResource: [...perResource.values()].sort((a, b) => a.resource.localeCompare(b.resource)),
perPlacement,
};
const reportPath = path.join(runRoot, "native-collision-assembly-report.json");
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
return { reportPath, report, runRoot };
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log(USAGE);
return;
}
const recipeFile = resolveProjectPath(options.recipe);
const recipe = await readJson(recipeFile);
recipe.file = recipeFile;
const placementManifestFile = resolveProjectPath(options["placement-manifest"]);
const placementManifestBytes = await readFile(placementManifestFile);
const placementManifest = JSON.parse(placementManifestBytes.toString("utf8"));
placementManifest.file = path.relative(projectRoot, placementManifestFile).split(path.sep).join("/");
const { resources } = validatePlacementInputs(recipe, placementManifest);
const recipeSha256 = await sha256File(recipeFile);
const placementManifestSha256 = createHash("sha256").update(placementManifestBytes).digest("hex");
const sourceRoot = options["source-root"]
? resolveProjectPath(options["source-root"])
: sourceRootFor(recipe);
const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot);
const sourceWdbFile = path.resolve(sourceRoot, recipe.source.wdb);
const sourceWdbSha256 = await sha256File(sourceWdbFile);
const outputRoot = resolveWorkspaceOutput(options["output-root"]);
const tileSizeMeters = Number(options["tile-size-meters"] ?? DEFAULT_TILE_SIZE_METERS);
if (!Number.isFinite(tileSizeMeters) || tileSizeMeters <= 0) throw new Error("--tile-size-meters must be a finite positive number.");
const extractor = options.extractor
? resolveProjectPath(options.extractor)
: path.join(path.dirname(fileURLToPath(import.meta.url)), "native", "bin", "native-collision-audit.exe");
const extractorSource = path.join(path.dirname(fileURLToPath(import.meta.url)), "native", "native-collision-audit.cpp");
if (!await import("node:fs/promises").then(({ access }) => access(extractor).then(() => true).catch(() => false))) {
throw new Error(`Native collision extractor not found at ${extractor}; build it with scripts/runewaker-pipeline/build-native-collision-audit.ps1 or pass --extractor.`);
}
const extractorPath = path.relative(projectRoot, extractor).split(path.sep).join("/");
const extractorSourcePath = path.relative(projectRoot, extractorSource).split(path.sep).join("/");
const nativeExtractor = {
executable: path.isAbsolute(extractorPath) || extractorPath.startsWith("..") ? extractor : extractorPath,
executableSha256: await sha256File(extractor),
source: extractorSourcePath,
sourceSha256: await sha256File(extractorSource),
collisionMode: options["collision-mode"],
};
let inventoryRoot;
let inventory;
if (options["inventory-root"]) {
inventoryRoot = resolveProjectPath(options["inventory-root"]);
} else {
// Immutable inventory roots keep separate source revisions from overwriting one another.
const currentSourceHashes = [];
for (const row of [...resources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)))) {
currentSourceHashes.push(`${normalizedResourceKey(row.resource)}:${await sha256File(nativeResourceFile(resourceRoot, row.resource))}`);
}
const inventoryKey = createHash("sha256")
.update(`${placementManifestSha256}\n${sourceWdbSha256}\n${currentSourceHashes.join("\n")}\n${nativeExtractor.executableSha256}\n${nativeExtractor.sourceSha256}\n${options["collision-mode"]}`)
.digest("hex").slice(0, 24);
inventoryRoot = path.join(outputRoot, "inventories", inventoryKey);
await mkdir(path.dirname(inventoryRoot), { recursive: true });
const existing = await readdir(inventoryRoot).catch(() => []);
if (existing.includes("native-collision-inventory.json")) {
inventory = await readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
collisionMode: options["collision-mode"],
nativeExtractor,
expectedResources: resources.map((row) => row.resource),
sourceResourceRoot: resourceRoot,
});
console.log(`reusing SHA-validated collision inventory: ${inventoryRoot}`);
}
}
if (!inventory) {
if (options["inventory-root"]) {
inventory = await readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
collisionMode: options["collision-mode"],
nativeExtractor,
expectedResources: resources.map((row) => row.resource),
sourceResourceRoot: resourceRoot,
});
} else {
await mkdir(inventoryRoot, { recursive: true });
const existing = await readdir(inventoryRoot);
if (existing.length > 0) throw new Error(`Inventory root is non-empty but has no valid manifest: ${inventoryRoot}`);
await extractInventory({
inventoryRoot, extractor, nativeExtractor, collisionMode: options["collision-mode"],
resourceRoot, expectedResources: resources, placementManifestSha256, sourceWdbSha256,
});
inventory = await readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
collisionMode: options["collision-mode"],
nativeExtractor,
expectedResources: resources.map((row) => row.resource),
sourceResourceRoot: resourceRoot,
});
}
}
const result = await assemble({
recipe, recipeSha256, placementManifest, placementManifestSha256, sourceWdbSha256,
inventory, outputRoot, tileSizeMeters, collisionMode: options["collision-mode"],
});
if (options["report-output"]) {
const reportOutput = resolveWorkspaceOutput(options["report-output"]);
await mkdir(path.dirname(reportOutput), { recursive: true });
await writeFile(reportOutput, `${JSON.stringify(result.report, null, 2)}\n`, "utf8");
}
console.log(JSON.stringify({
report: path.relative(projectRoot, result.reportPath).split(path.sep).join("/"),
inventory: path.relative(projectRoot, inventory.manifestPath).split(path.sep).join("/"),
nativeResources: inventory.resources.size,
staticPlacements: placementManifest.staticPlacements.length,
collisionMode: options["collision-mode"],
nativeTriangles: result.report.totals.nativeSerializedTriangles,
completeNativeTriangles: result.report.totals.completeNativeSerializedTriangles,
disabledSubtreeNativeTriangles: result.report.totals.collisionOptionOmittedTriangles,
trekkerEligibleTriangles: result.report.totals.trekkerEligibleTriangles,
waterFoliageExcludedTriangles: result.report.totals.excludedByWaterFoliageMask,
chunkCounts: {
all: result.report.totals.allSourceChunkCount,
trekkerFiltered: result.report.totals.trekkerFilteredChunkCount,
maximumTrianglesPerChunk: MAX_TRIANGLES_PER_GLB,
},
runRoot: path.relative(projectRoot, result.runRoot).split(path.sep).join("/"),
}, null, 2));
}
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Accessor, Document, NodeIO } from "@gltf-transform/core";
import { EXTMeshGPUInstancing } from "@gltf-transform/extensions";
import {
mappingMetrics,
nodeInstanceTransforms,
sourceImageAliasKey,
transformInstancedPoint,
triangleArea,
} from "./audit-environment-quality.mjs";
test("source image aliases preserve unresolved or used alpha and normalize only known-unused alpha", () => {
const source = new Uint8Array([
255, 64, 32, 0,
10, 20, 30, 255,
]);
const aliasWithDifferentAlpha = new Uint8Array([
255, 64, 32, 200,
10, 20, 30, 1,
]);
const opaqueA = sourceImageAliasKey({ width: 2, height: 1, rgba: source, alphaMode: "OPAQUE", alphaUsed: false });
const opaqueB = sourceImageAliasKey({ width: 2, height: 1, rgba: aliasWithDifferentAlpha, alphaMode: "OPAQUE", alphaUsed: false });
assert.equal(opaqueA, opaqueB, "opaque texture aliases are compared using RGB and dimensions");
assert.notEqual(sourceImageAliasKey({ width: 2, height: 1, rgba: source, alphaMode: "OPAQUE" }),
sourceImageAliasKey({ width: 2, height: 1, rgba: aliasWithDifferentAlpha, alphaMode: "OPAQUE" }),
"varying source alpha stays part of identity when native use is unresolved");
const uniformTranslucent = new Uint8Array([255, 64, 32, 128, 10, 20, 30, 128]);
const uniformOpaque = new Uint8Array([255, 64, 32, 255, 10, 20, 30, 255]);
assert.notEqual(sourceImageAliasKey({ width: 2, height: 1, rgba: uniformTranslucent, alphaMode: "OPAQUE" }),
sourceImageAliasKey({ width: 2, height: 1, rgba: uniformOpaque, alphaMode: "OPAQUE" }),
"uniform non-opaque alpha also remains part of identity when native use is unresolved");
const nativeAlphaA = sourceImageAliasKey({ width: 2, height: 1, rgba: source, alphaMode: "OPAQUE", alphaUsed: true });
const nativeAlphaB = sourceImageAliasKey({ width: 2, height: 1, rgba: aliasWithDifferentAlpha, alphaMode: "OPAQUE", alphaUsed: true });
assert.notEqual(nativeAlphaA, nativeAlphaB, "native translucency preserves RGBA even if the converted material says OPAQUE");
const blendedA = sourceImageAliasKey({ width: 2, height: 1, rgba: source, alphaMode: "BLEND" });
const blendedB = sourceImageAliasKey({ width: 2, height: 1, rgba: aliasWithDifferentAlpha, alphaMode: "BLEND" });
assert.notEqual(blendedA, blendedB, "materials that use alpha retain separate RGBA identities");
assert.notEqual(opaqueA, sourceImageAliasKey({ width: 1, height: 2, rgba: source, alphaMode: "OPAQUE", alphaUsed: false }),
"equal pixel bytes at another image shape do not alias");
assert.notEqual(opaqueA, sourceImageAliasKey({ width: 2, height: 1, rgba: source, alphaMode: "OPAQUE", alphaUsed: false, baseColorFactor: [0.5, 1, 1, 1] }),
"material base-color factors remain part of rendered source equivalence");
});
test("instance TRS expansion preserves UV rank and accounts for transformed area and cells", async () => {
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "rom-quality-instancing-test-"));
const fixtureFile = path.join(temporaryDirectory, "instances.glb");
try {
const document = new Document();
const buffer = document.createBuffer();
const positions = document.createAccessor("POSITION")
.setType(Accessor.Type.VEC3)
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
.setBuffer(buffer);
const primitive = document.createPrimitive().setAttribute("POSITION", positions);
const node = document.createNode("fixture-node")
.setMesh(document.createMesh("fixture-mesh").addPrimitive(primitive))
.setTranslation([5, 2, 0])
.setScale([2, 1, 1]);
const instanceExtension = document.createExtension(EXTMeshGPUInstancing);
const instancedMesh = instanceExtension.createInstancedMesh()
.setAttribute("TRANSLATION", document.createAccessor("TRANSLATION")
.setType(Accessor.Type.VEC3)
.setArray(new Float32Array([0, 0, 0, 40, 0, 0]))
.setBuffer(buffer))
.setAttribute("ROTATION", document.createAccessor("ROTATION")
.setType(Accessor.Type.VEC4)
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, Math.SQRT1_2, Math.SQRT1_2]))
.setBuffer(buffer))
.setAttribute("SCALE", document.createAccessor("SCALE")
.setType(Accessor.Type.VEC3)
.setArray(new Float32Array([2, 1, 1, 1, 3, 1]))
.setBuffer(buffer));
node.setExtension("EXT_mesh_gpu_instancing", instancedMesh);
document.createScene("fixture-scene").addChild(node);
const io = new NodeIO().registerExtensions([EXTMeshGPUInstancing]);
await io.write(fixtureFile, document);
const parsed = await io.read(fixtureFile);
const parsedNode = parsed.getRoot().listNodes().find((candidate) => candidate.getName() === "fixture-node");
const transforms = nodeInstanceTransforms(parsedNode);
assert.equal(transforms.length, 2, "NodeIO reads both instance records");
const worldMatrix = parsedNode.getWorldMatrix();
const triangle = [[0, 0, 0], [1, 0, 0], [0, 1, 0]];
const uvTriangle = [[0, 0], [1, 0], [0, 1]];
const collinearUvs = [[0, 0], [1, 1], [2, 2]];
const expandedTriangles = transforms.map((transform) => triangle.map((point) =>
transformInstancedPoint(worldMatrix, point, transform)));
const areas = expandedTriangles.map(triangleArea);
assert.ok(Math.abs(areas[0] - 2) < 1e-9 && Math.abs(areas[1] - 3) < 1e-6,
"instance and node scales transform each triangle's area");
assert.ok(Math.abs(areas.reduce((total, area) => total + area, 0) - 5) < 1e-6,
"both placed triangles contribute their transformed surface area");
const cells = expandedTriangles.map((points) => {
const centroidX = points.reduce((total, point) => total + point[0] / 3, 0);
return Math.floor(centroidX / 20);
});
assert.deepEqual(cells, [0, 4], "instance TRS precedes the non-identity node world transform");
for (const points of expandedTriangles) {
const valid = mappingMetrics(points, uvTriangle, 1024, 1024);
const degenerate = mappingMetrics(points, collinearUvs, 1024, 1024);
assert.ok(valid && valid.uvDoubleArea > 0 && valid.smallestStretchPxPerMeter > 0,
"an invertible texture mapping retains UV rank under instance TRS");
assert.equal(degenerate?.uvDoubleArea, 0, "collinear UVs remain degenerate under instance TRS");
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
@@ -3,6 +3,11 @@ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import {
alphaMaterialMatches,
effectiveAlphaMaterialRole,
preparedAlphaTextureFilename,
} from "./lib/native-alpha-audit-helpers.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
@@ -116,7 +121,7 @@ async function indexResourceTextures() {
}
async function resolveSourceTexture(localDirectory, imageName) {
const fileName = `${imageName}.png`;
const fileName = preparedAlphaTextureFilename(imageName);
const localFile = path.join(localDirectory, fileName);
if (await exists(localFile)) return localFile;
return (await indexResourceTextures()).get(fileName);
@@ -178,13 +183,13 @@ async function auditDungeon(recipe) {
const sourceFile = await resolveSourceTexture(preparedTextureDirectory, image.name);
if (!sourceFile) {
missingTextures.push(`${image.name}.png`);
missingTextures.push(preparedAlphaTextureFilename(image.name));
continue;
}
const expectedRole = await classifyAlpha(sourceFile);
const expectedAlphaMode = expectedRole === "blend" ? "BLEND" : expectedRole === "cutout" ? "MASK" : "OPAQUE";
const actualAlphaMode = material.alphaMode ?? "OPAQUE";
const actualRole = material.extras?.semanticRole ?? "missing";
const actualRole = effectiveAlphaMaterialRole(material.extras?.semanticRole, actualAlphaMode);
materialResults.push({
materialIndex,
material: material.name ?? `material-${materialIndex}`,
@@ -193,9 +198,9 @@ async function auditDungeon(recipe) {
expectedAlphaMode,
actualRole,
actualAlphaMode,
matches: expectedAlphaMode === actualAlphaMode && expectedRole === actualRole,
matches: alphaMaterialMatches(expectedAlphaMode, expectedRole, actualAlphaMode, actualRole),
});
if (fix && (expectedAlphaMode !== actualAlphaMode || expectedRole !== actualRole)) {
if (fix && !alphaMaterialMatches(expectedAlphaMode, expectedRole, actualAlphaMode, actualRole)) {
if (expectedAlphaMode === "OPAQUE") delete material.alphaMode;
else material.alphaMode = expectedAlphaMode;
if (expectedAlphaMode === "MASK") material.alphaCutoff = 0.5;
@@ -16,6 +16,8 @@ from pathlib import Path
import bpy
from mathutils import Vector
sys.path.insert(0, str(Path(__file__).resolve().parent))
from native_material_alpha import native_alpha_action, native_material_identity
COLLISION_CHUNK_TRIANGLE_LIMIT = 90_000
@@ -135,12 +137,14 @@ def image_alpha_role(image: bpy.types.Image | None) -> str:
return "cutout"
def preserve_material_alpha(material: bpy.types.Material) -> str:
image = material_image(material)
role = image_alpha_role(image)
if role == "opaque" or material.node_tree is None:
def apply_material_alpha_role(
material: bpy.types.Material,
image: bpy.types.Image | None,
role: str,
threshold: float = 0.5,
) -> str:
if role == "opaque" or image is None or material.node_tree is None:
return role
image_node = next(
node
for node in material.node_tree.nodes
@@ -167,32 +171,117 @@ def preserve_material_alpha(material: bpy.types.Material) -> str:
alpha_clip = material.node_tree.nodes.new("ShaderNodeMath")
alpha_clip.name = "RuneWaker Alpha Clip"
alpha_clip.operation = "GREATER_THAN"
alpha_clip.inputs[1].default_value = 0.5
alpha_clip.inputs[1].default_value = threshold
material.node_tree.links.new(alpha_output, alpha_clip.inputs[0])
material.node_tree.links.new(alpha_clip.outputs[0], alpha_input)
material.blend_method = "CLIP"
material.alpha_threshold = 0.5
material.alpha_threshold = threshold
return role
def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
def preserve_material_alpha(
material: bpy.types.Material,
source_report: dict[str, object] | None,
) -> tuple[str, dict[str, object] | None, dict[str, object]]:
image = material_image(material)
role = image_alpha_role(image)
image_path = bpy.path.abspath(image.filepath) if image is not None else ""
identity = native_material_identity(material.name, image_path, source_report)
decision = native_alpha_action(identity, role)
action = str(decision.get("action", "unsupported"))
if action == "pixel-alpha-heuristic":
applied_role = apply_material_alpha_role(material, image, role)
decision = {
"action": applied_role,
"reason": decision.get("reason", "no-verified-native-material-match"),
}
elif action in {"blend", "mask", "additive"}:
applied_role = apply_material_alpha_role(
material,
image,
"cutout" if action == "mask" else "blend",
float(decision.get("threshold", 0.5)),
)
decision = {**decision, "applied": applied_role}
if identity is not None:
material["nativeSourceModel"] = identity["sourceModel"]
material["nativeSourceResourceSha256"] = identity["sourceResourceSha256"]
material["nativeMaterialIndex"] = identity["materialIndex"]
material["nativeSourceTexture"] = identity["sourceTexture"]
material["nativeAlphaPolicy"] = str(decision.get("reason", action))
lighting = identity.get("lightingType")
lighting_name = lighting.get("name") if isinstance(lighting, dict) else None
if isinstance(lighting_name, str) and lighting_name.strip():
material["nativeLightingType"] = lighting_name.strip().lower()
elif "nativeLightingType" in material:
del material["nativeLightingType"]
if action == "additive":
material["nativeBlendMode"] = "additive"
elif "nativeBlendMode" in material:
del material["nativeBlendMode"]
return role, identity, decision
def deduplicate_materials(
objects: list[bpy.types.Object],
source_report: dict[str, object] | None,
) -> dict[str, object]:
canonical: dict[str, bpy.types.Material] = {}
replacements = 0
native_alpha_rows: dict[int, dict[str, object]] = {}
for obj in objects:
for slot in obj.material_slots:
material = slot.material
if material is None:
continue
key = material_image_key(material)
alpha_role, identity, decision = preserve_material_alpha(material, source_report)
semantics_key = (
repr((identity["sourceModel"], identity["sourceResourceSha256"],
identity["materialIndex"], identity["sourceTexture"],
sorted(identity["alphaBlendParameters"].items())))
if identity is not None
else "pixel-alpha-heuristic:" + alpha_role
)
key = material_image_key(material) + "|" + semantics_key
if identity is not None:
native_alpha_rows[identity["materialIndex"]] = {
"material": identity["materialName"],
"materialIndex": identity["materialIndex"],
"sourceModel": identity["sourceModel"],
"sourceResourceSha256": identity["sourceResourceSha256"],
"sourceTexture": identity["sourceTexture"],
"stagedTexture": identity["stagedTexture"],
"materialType": identity["materialType"],
"lightingType": identity["lightingType"],
"alphaBlendParameters": identity["alphaBlendParameters"],
"sourceImageAlphaRole": alpha_role,
"conversionPolicy": decision,
}
if key in canonical:
if slot.material != canonical[key]:
slot.material = canonical[key]
replacements += 1
else:
canonical[key] = material
material["semanticRole"] = preserve_material_alpha(material)
material["semanticRole"] = decision.get("action", alpha_role)
material["sourceFormat"] = "RuneWaker DDS"
return {"unique": len(canonical), "replacements": replacements}
source_model = source_report.get("sourceModel") if isinstance(source_report, dict) else None
source_hash = source_report.get("sourceResourceSha256") if isinstance(source_report, dict) else None
native_alpha_materials = [native_alpha_rows[index] for index in sorted(native_alpha_rows)]
return {
"unique": len(canonical),
"replacements": replacements,
"nativeAlpha": {
"sourceModel": source_model,
"sourceResourceSha256": source_hash,
"matchedMaterialCount": len(native_alpha_materials),
"materials": native_alpha_materials,
"unsupportedPolicies": [
row for row in native_alpha_materials
if row["conversionPolicy"].get("action") == "unsupported"
],
},
}
def export_glb(
@@ -302,6 +391,12 @@ def render_topdown_preview(
def main() -> None:
source_obj, output_dir, slug, unit_scale = arguments()
output_dir.mkdir(parents=True, exist_ok=True)
source_report_file = source_obj.parent / "export-report.json"
source_report = (
json.loads(source_report_file.read_text(encoding="utf-8"))
if source_report_file.is_file()
else None
)
for stale in output_dir.glob(f"{slug}-collision*.glb"):
stale.unlink()
@@ -330,7 +425,7 @@ def main() -> None:
obj["sourceFormat"] = "RuneWaker ROS via OBJ bridge"
obj["sourceUnitScaleMeters"] = unit_scale
material_stats = deduplicate_materials(visual)
material_stats = deduplicate_materials(visual, source_report)
bounds_min, bounds_max = three_bounds(visual)
source_triangles = sum(triangles(obj) for obj in visual)
@@ -364,12 +459,6 @@ def main() -> None:
blend_file = output_dir / f"{slug}.source.blend"
bpy.ops.wm.save_as_mainfile(filepath=str(blend_file))
source_report_file = source_obj.parent / "export-report.json"
source_report = (
json.loads(source_report_file.read_text(encoding="utf-8"))
if source_report_file.is_file()
else None
)
report = {
"schemaVersion": 1,
"dungeonId": slug,
@@ -379,6 +468,7 @@ def main() -> None:
"obj": source_obj.name,
"report": source_report_file.name if source_report is not None else None,
"model": source_report.get("sourceModel") if source_report else None,
"resourceSha256": source_report.get("sourceResourceSha256") if source_report else None,
},
"coordinateTransform": {
"nativeBridge": "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
@@ -0,0 +1,115 @@
"""Fail-closed matching and policy for native Runewaker material alpha flags."""
from __future__ import annotations
import os
import re
from pathlib import PurePosixPath
from typing import Any
_MATERIAL_NAME = re.compile(r"^runewaker_material_(\d+)$", re.IGNORECASE)
_SHA256 = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
def _resource_path(value: Any) -> str:
return str(value or "").replace("/", "\\").strip("\\").casefold()
def _image_basename(value: Any) -> str:
normalized = str(value or "").replace("\\", "/")
basename = normalized.rsplit("/", 1)[-1].casefold()
if basename.endswith(".dds"):
return basename[:-4] + ".png"
if not os.path.splitext(basename)[1]:
return basename + ".png"
return basename
def native_material_identity(
material_name: str,
image_path: str,
source_report: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""Join one imported material only when source, native index and diffuse image agree."""
if not isinstance(source_report, dict):
return None
source_model = str(source_report.get("sourceModel") or "")
source_hash = str(source_report.get("sourceResourceSha256") or "")
if not source_model or not _SHA256.fullmatch(source_hash):
return None
match = _MATERIAL_NAME.fullmatch(str(material_name or ""))
if not match:
return None
material_index = int(match.group(1))
rows = [row for row in source_report.get("materials", [])
if isinstance(row, dict) and row.get("name") == material_name]
if len(rows) != 1:
return None
row = rows[0]
if _image_basename(image_path) != _image_basename(row.get("stagedTexture")):
return None
source_texture = _resource_path(row.get("sourceTexture"))
diffuse_textures = {
_resource_path(channel.get("texture"))
for channel in row.get("sourceTextureChannels", [])
if isinstance(channel, dict) and str(channel.get("name", "")).casefold() == "diffuse"
}
if not source_texture or source_texture not in diffuse_textures:
return None
parameters = {
str(parameter.get("name")): parameter.get("value")
for parameter in row.get("alphaBlendParameters", [])
if isinstance(parameter, dict) and parameter.get("name")
}
return {
"sourceModel": source_model,
"sourceResourceSha256": source_hash.casefold(),
"materialName": material_name,
"materialIndex": material_index,
"sourceTexture": source_texture,
"stagedTexture": _image_basename(row.get("stagedTexture")),
"materialType": row.get("materialType"),
"lightingType": row.get("lightingType"),
"alphaBlendParameters": parameters,
}
def native_alpha_action(identity: dict[str, Any] | None, image_alpha_role: str) -> dict[str, Any]:
"""Translate only proven, supported native modes; report unknown modes explicitly."""
if identity is None:
return {"action": "pixel-alpha-heuristic", "reason": "no-verified-native-material-match"}
parameters = identity.get("alphaBlendParameters", {})
required = ("alphaTestMode", "alphaMode", "translucent", "alpha", "alphaModifier")
if any(name not in parameters for name in required):
return {"action": "unsupported", "reason": "native-alpha-parameters-incomplete"}
try:
test_mode = int(parameters["alphaTestMode"])
alpha_mode = int(parameters["alphaMode"])
translucent = bool(parameters["translucent"])
alpha = float(parameters["alpha"])
alpha_modifier = float(parameters["alphaModifier"])
except (TypeError, ValueError, OverflowError):
return {"action": "unsupported", "reason": "native-alpha-parameters-invalid"}
if abs(alpha - 1.0) > 1e-6 or abs(alpha_modifier - 1.0) > 1e-6:
return {"action": "unsupported", "reason": "native-global-alpha-factor-not-one"}
if test_mode == 0 and alpha_mode == 0 and translucent:
return {
"action": "blend" if image_alpha_role != "opaque" else "opaque",
"reason": "native-normal-translucent-alpha" if image_alpha_role != "opaque"
else "native-normal-translucent-texture-alpha-opaque",
}
if test_mode == 0 and alpha_mode == 0 and not translucent:
return {"action": "opaque", "reason": "native-normal-opaque-material"}
if test_mode == 0 and alpha_mode == 1 and translucent:
return {"action": "additive", "reason": "native-srcalpha-one-additive"}
if test_mode == 1 and alpha_mode == 0 and not translucent:
try:
threshold = max(0, min(255, int(parameters["alphaTestValue"]))) / 255.0
except (KeyError, TypeError, ValueError, OverflowError):
return {"action": "unsupported", "reason": "native-alpha-test-threshold-missing"}
return {
"action": "mask" if image_alpha_role != "opaque" else "opaque",
"reason": "native-greater-alpha-test",
"threshold": threshold,
}
return {"action": "unsupported", "reason": "native-alpha-mode-combination-unsupported"}
@@ -0,0 +1,105 @@
import unittest
from native_material_alpha import native_alpha_action, native_material_identity
class NativeMaterialAlphaTest(unittest.TestCase):
def setUp(self):
self.report = {
"sourceModel": r"model\dungeon\the_origin\act_precipice_02.ros",
"sourceResourceSha256": "a" * 64,
"materials": [
{
"name": "runewaker_material_2",
"sourceTexture": r"texture\dungeon\the_origin\gate_waterfall",
"stagedTexture": "textures/32435b59_gate_waterfall.dds",
"materialType": {"value": 0, "name": "standard"},
"lightingType": {"value": 0, "name": "standard"},
"sourceTextureChannels": [
{
"id": 1,
"name": "diffuse",
"texture": r"texture\dungeon\the_origin\gate_waterfall",
},
],
"alphaBlendParameters": [
{"name": "alphaTestMode", "value": 0},
{"name": "alphaTestValue", "value": 192},
{"name": "alphaMode", "value": 0},
{"name": "translucent", "value": True},
{"name": "alpha", "value": 1},
{"name": "alphaModifier", "value": 1},
],
},
],
}
def test_exact_resource_material_and_diffuse_identity_are_required(self):
identity = native_material_identity(
"runewaker_material_2",
r"C:\staging\textures\32435b59_gate_waterfall.png",
self.report,
)
self.assertIsNotNone(identity)
self.assertEqual(identity["materialIndex"], 2)
self.assertEqual(identity["sourceResourceSha256"], "a" * 64)
self.assertEqual(identity["sourceTexture"], r"texture\dungeon\the_origin\gate_waterfall")
self.assertEqual(native_material_identity(
"runewaker_material_3",
r"C:\staging\textures\32435b59_gate_waterfall.png",
self.report,
), None, "a name-only match is insufficient")
self.assertEqual(native_material_identity(
"runewaker_material_2",
r"C:\staging\textures\other.png",
self.report,
), None, "the staged diffuse basename must match")
invalid_provenance = {**self.report, "sourceResourceSha256": "not-a-hash"}
self.assertEqual(native_material_identity(
"runewaker_material_2",
r"C:\staging\textures\32435b59_gate_waterfall.png",
invalid_provenance,
), None, "source resource hash is mandatory")
def test_native_modes_distinguish_normal_blend_additive_and_opaque(self):
identity = native_material_identity(
"runewaker_material_2",
r"C:\staging\textures\32435b59_gate_waterfall.png",
self.report,
)
self.assertEqual(native_alpha_action(identity, "blend")["action"], "blend")
self.assertEqual(native_alpha_action(identity, "opaque")["action"], "opaque")
additive_parameters = dict(identity["alphaBlendParameters"], alphaMode=1)
additive = {**identity, "alphaBlendParameters": additive_parameters}
self.assertEqual(
native_alpha_action(additive, "blend"),
{"action": "additive", "reason": "native-srcalpha-one-additive"},
)
self.assertEqual(native_alpha_action(additive, "opaque")["action"], "additive")
additive_not_translucent = {**identity, "alphaBlendParameters": dict(additive_parameters, translucent=False)}
self.assertEqual(native_alpha_action(additive_not_translucent, "blend")["action"], "unsupported")
additive_alpha_tested = {**identity, "alphaBlendParameters": dict(additive_parameters, alphaTestMode=1)}
self.assertEqual(native_alpha_action(additive_alpha_tested, "blend")["action"], "unsupported")
additive_scaled = {**identity, "alphaBlendParameters": dict(additive_parameters, alpha=0.5)}
self.assertEqual(native_alpha_action(additive_scaled, "blend")["action"], "unsupported")
opaque_parameters = dict(identity["alphaBlendParameters"], translucent=False)
opaque_native = {**identity, "alphaBlendParameters": opaque_parameters}
self.assertEqual(native_alpha_action(opaque_native, "blend")["action"], "opaque")
def test_native_alpha_test_preserves_its_threshold(self):
identity = native_material_identity(
"runewaker_material_2",
r"C:\staging\textures\32435b59_gate_waterfall.png",
self.report,
)
parameters = dict(identity["alphaBlendParameters"], alphaTestMode=1, translucent=False, alphaTestValue=192)
decision = native_alpha_action({**identity, "alphaBlendParameters": parameters}, "cutout")
self.assertEqual(decision["action"], "mask")
self.assertAlmostEqual(decision["threshold"], 192 / 255)
if __name__ == "__main__":
unittest.main()
+319 -61
View File
@@ -18,7 +18,16 @@ import {
assembleInstancedGlb,
buildWdbPlacementPlan,
normalizeWdbResourcePath,
transformSourceHeadingToPrimaryThree,
} from "./lib/environment-assembly.mjs";
import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs";
import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs";
import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs";
import { applyRunewakerDerivedSupportStylingToFile } from "./lib/rom-derived-support-styling-file.mjs";
import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-support-styling.mjs";
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
import { writeBoundedCollisionGlbs } from "./lib/bounded-collision-glb.mjs";
import { validateNativeAlphaResourceCache } from "./lib/native-alpha-cache-validation.mjs";
const argv = process.argv.slice(2);
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
@@ -49,12 +58,57 @@ const texconv = path.resolve(process.env.TEXCONV_BIN ?? path.join(
));
const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker.py");
const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
const ktx2Compressor = path.join(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs");
const ktx2Compressor = path.join(pipelineDirectory, "compress-environment-textures.mjs");
const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
const fullWdbAssembly = !recipe.source.proceduralTerrainFallback
&& recipe.source.wdbAssembly?.mode !== "primary-only"
&& !argv.includes("--primary-only");
const collisionChunkTriangleTarget = 90_000;
if (recipe.source.nativeCollision && !fullWdbAssembly) {
throw new Error("Serialized native collision requires full WDB assembly.");
}
let nativeTerrain = null;
let nativeWater = null;
let nativeCollision = null;
let visualUvAdaptations = null;
let derivedSupportStyling = null;
async function buildNativeTerrain() {
if (!recipe.source.nativeTerrain) return;
if (recipe.source.nativeTerrain.kind !== "runewaker-wdb-terrain") {
throw new Error("Unsupported native terrain kind: " + recipe.source.nativeTerrain.kind);
}
await run(process.execPath, [
path.join(pipelineDirectory, "sand-vortex", "extract-native-terrain.mjs"),
"--recipe", recipeFile,
"--wdb", path.join(sourceRoot, recipe.source.wdb),
"--output", path.join(workDirectory, "native-terrain-source"),
], "recover native WDB terrain chunks and materials");
await run(process.execPath, [
path.join(pipelineDirectory, "sand-vortex", "build-native-terrain.mjs"),
"--recipe", recipeFile,
], "build native terrain visual and collision");
nativeTerrain = JSON.parse(await readFile(
path.join(convertedDirectory, "terrain", "conversion-report.json"), "utf8",
));
}
async function buildNativeWater() {
if (!recipe.source.nativeWater) return;
if (recipe.source.nativeWater.kind !== "runewaker-wdb-water-layer" || !nativeTerrain || !fullWdbAssembly) {
throw new Error("Native water requires runewaker-wdb-water-layer, native terrain, and full WDB assembly.");
}
const outputRoot = path.join(workDirectory, "native-water-source");
await run(process.execPath, [
path.join(pipelineDirectory, "build-native-water.mjs"),
"--recipe", recipeFile,
"--wdb-report", path.join(rawDirectory, "wdb-report.json"),
"--ground-manifest", path.join(workDirectory, "native-terrain-source", "terrain-native-source.json"),
"--output-root", outputRoot,
"--audit-cache-root", workDirectory,
], "recover native water layers and animated normal frames");
nativeWater = JSON.parse(await readFile(path.join(outputRoot, "conversion-report.json"), "utf8"));
}
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
@@ -158,13 +212,6 @@ async function exportSource() {
);
}
function sourcePointToPrimaryLocal(sourcePosition, placement) {
const point = new Vector3(...sourcePosition).sub(new Vector3(...placement.translation));
point.applyQuaternion(new Quaternion(...placement.rotation).invert());
point.divide(new Vector3(...placement.scale));
return point.toArray();
}
async function appendSourceAnchorSupport() {
const support = recipe.source.sourceAnchorSupport;
if (!support || fullWdbAssembly) return;
@@ -176,26 +223,12 @@ async function appendSourceAnchorSupport() {
let obj = await readFile(objFile, "utf8");
const vertexCount = (obj.match(/^v\s+/gm) ?? []).length;
const lines = ["", "# Source-anchor navigation support", "g runewaker_source_anchor_support", "usemtl runewaker_source_anchor_support"];
let nextVertex = vertexCount + 1;
for (const sourcePoint of support.points) {
const [x, y, z] = sourcePoint.map(Number);
const worldVertices = [
[x - half, y, z - half], [x + half, y, z - half],
[x + half, y, z + half], [x - half, y, z + half],
[x - half, y - thickness, z - half], [x + half, y - thickness, z - half],
[x + half, y - thickness, z + half], [x - half, y - thickness, z + half],
];
const localVertices = worldVertices.map((value) => sourcePointToPrimaryLocal(value, placement));
lines.push(...localVertices.map((value) => "v " + value.join(" ")));
const index = Array.from({ length: 8 }, (_, offset) => nextVertex + offset);
const faces = [
[0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7],
[0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5],
[2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7],
];
lines.push(...faces.map((face) => "f " + face.map((offset) => index[offset]).join(" ")));
nextVertex += 8;
}
const nextVertex = vertexCount + 1;
const geometry = sourceAnchorSupportGeometry(support.points, placement, {
halfExtent: half, thickness, omitPointIndices: support.omitNativeGroundedPointIndices ?? [],
});
for (let i = 0; i < geometry.positions.length; i += 3) lines.push("v " + geometry.positions.slice(i, i + 3).join(" "));
for (let i = 0; i < geometry.indices.length; i += 3) lines.push("f " + geometry.indices.slice(i, i + 3).map(index => nextVertex + index).join(" "));
obj += lines.join("\n") + "\n";
const mtl = await readFile(mtlFile, "utf8");
await Promise.all([
@@ -242,6 +275,8 @@ async function cachedResourceAsset(resourceRecord) {
}
const sourceSha256 = await sha256(sourceFile);
const converterSha256 = await sha256(blenderScript);
const exporterSha256 = await sha256(nativeExporter);
const nativeAlphaPolicySha256 = await sha256(path.join(pipelineDirectory, "blender", "native_material_alpha.py"));
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
const raw = path.join(cacheRoot, "raw");
const prepared = path.join(cacheRoot, "prepared");
@@ -249,12 +284,33 @@ async function cachedResourceAsset(resourceRecord) {
const manifestFile = path.join(cacheRoot, "cache-manifest.json");
if (!argv.includes("--force-resource-cache") && await exists(manifestFile)) {
const cached = JSON.parse(await readFile(manifestFile, "utf8"));
let nativeAlphaCache = { valid: false };
try {
const [rawReport, preparedReport] = await Promise.all([
readFile(path.join(raw, "export-report.json"), "utf8").then(JSON.parse),
readFile(path.join(prepared, "export-report.json"), "utf8").then(JSON.parse),
]);
nativeAlphaCache = validateNativeAlphaResourceCache({
manifest: cached,
rawReport,
preparedReport,
expectedResource: resourceRecord.normalizedResource,
expectedSourceSha256: sourceSha256,
expectedToolchain: {
converterSha256,
exporterSha256,
nativeAlphaPolicySha256,
},
});
} catch {
nativeAlphaCache = { valid: false };
}
const outputs = [cached.visual?.file, ...(cached.collision ?? []).map((entry) => entry.file)]
.filter(Boolean)
.map((file) => path.join(cacheRoot, file));
if (cached.source?.resource === resourceRecord.normalizedResource
&& cached.source?.sha256 === sourceSha256
&& cached.toolchain?.converterSha256 === converterSha256
&& nativeAlphaCache.valid
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
return {
@@ -278,7 +334,13 @@ async function cachedResourceAsset(resourceRecord) {
[resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw],
"export cached WDB resource " + resourceRecord.resource,
);
const rawReportFile = path.join(raw, "export-report.json");
const rawReport = JSON.parse(await readFile(rawReportFile, "utf8"));
rawReport.sourceResourceSha256 = sourceSha256;
await writeFile(rawReportFile, JSON.stringify(rawReport, null, 2) + "\n", "utf8");
await prepareCachedResourceTextures(raw, prepared);
const sourceReportFile = path.join(prepared, "export-report.json");
const sourceReport = JSON.parse(await readFile(sourceReportFile, "utf8"));
await run(
blender,
[
@@ -304,6 +366,8 @@ async function cachedResourceAsset(resourceRecord) {
},
toolchain: {
converterSha256,
exporterSha256,
nativeAlphaPolicySha256,
},
visual: {
file: path.relative(
@@ -343,8 +407,15 @@ async function prepareTextures() {
]);
return;
}
// Native terrain shares these audit caches, but its linear weight PNGs and
// opaque tile PNGs are prepared by its own decoder, not the ROS bridge.
const exportedDdsBasenames = recipe.source.nativeTerrain ? new Set(
JSON.parse(await readFile(path.join(rawDirectory, "export-report.json"), "utf8"))
.materials.map((material) => path.basename(material.stagedTexture ?? "").toLowerCase()),
) : null;
const ddsFiles = (await readdir(sourceTextures))
.filter((file) => file.toLowerCase().endsWith(".dds"))
.filter((file) => !exportedDdsBasenames || exportedDdsBasenames.has(file.toLowerCase()))
.sort()
.map((file) => path.join(sourceTextures, file));
if (!ddsFiles.length) throw new Error(recipe.title + " ROS exported without DDS textures.");
@@ -355,6 +426,10 @@ async function prepareTextures() {
);
await copyFile(path.join(rawDirectory, "scene.obj"), path.join(preparedDirectory, "scene.obj"));
await copyFile(path.join(rawDirectory, "export-report.json"), path.join(preparedDirectory, "export-report.json"));
const sourceReportFile = path.join(preparedDirectory, "export-report.json");
const sourceReport = JSON.parse(await readFile(sourceReportFile, "utf8"));
sourceReport.sourceResourceSha256 = await sha256(path.join(resourceRoot, recipe.source.model));
await writeFile(sourceReportFile, JSON.stringify(sourceReport, null, 2) + "\n", "utf8");
const sourceMtl = await readFile(path.join(rawDirectory, "scene.mtl"), "utf8");
await writeFile(
path.join(preparedDirectory, "scene.mtl"),
@@ -377,6 +452,25 @@ async function convertEnvironment() {
],
"build textured visual and provisional collision GLBs",
);
visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile(
path.join(convertedDirectory, `${slug}-visual.glb`),
{
dungeonId: slug,
policyIds: recipe.visualUvAdaptationPolicies ?? [],
surfaceQualityPolicyIds: recipe.visualSurfaceQualityPolicies ?? [],
sourceReport: recipe.visualUvAdaptationPolicies?.length || recipe.visualSurfaceQualityPolicies?.length
? JSON.parse(await readFile(path.join(preparedDirectory, "export-report.json"), "utf8"))
: undefined,
},
);
const supportPolicy = listRunewakerDerivedSupportStylingPolicies().find(p => p.id === recipe.derivedSupportStylingPolicy);
derivedSupportStyling = await applyRunewakerDerivedSupportStylingToFile(
path.join(convertedDirectory, `${slug}-visual.glb`), {
dungeonId: slug, policyId: recipe.derivedSupportStylingPolicy,
sourceReport: supportPolicy ? JSON.parse(await readFile(path.join(preparedDirectory, "export-report.json"), "utf8")) : undefined,
sourceDdsFile: supportPolicy ? path.join(rawDirectory, "textures", supportPolicy.stagedTexture) : null,
},
);
}
function assemblyTranslations() {
@@ -470,6 +564,21 @@ async function assembleAllWdbPlacements() {
placement,
assetFile: assets.get(placement.normalizedResource).visualFile,
}));
const terrainPlacement = {
id: "native-wdb-terrain",
guid: "native-wdb-terrain",
resource: recipe.source.wdb,
resourceCacheKey: "native-wdb-terrain",
transform: { primaryLocalThreeMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] },
};
if (nativeTerrain) visualRecords.push({
placement: terrainPlacement,
assetFile: path.join(convertedDirectory, "terrain", nativeTerrain.visual.file),
});
if (nativeWater) visualRecords.push({
placement: { ...terrainPlacement, id: "native-wdb-water", guid: "native-wdb-water", resourceCacheKey: "native-wdb-water" },
assetFile: path.resolve(projectRoot, nativeWater.outputs.waterGlb.path),
});
const assembledVisualFile = path.join(convertedDirectory, slug + "-visual.wdb-assembled.glb");
const visualAssembly = await assembleInstancedGlb(visualRecords, assembledVisualFile);
await copyFile(assembledVisualFile, path.join(convertedDirectory, slug + "-visual.glb"));
@@ -480,12 +589,53 @@ async function assembleAllWdbPlacements() {
assetFile: collision.absoluteFile,
triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0),
}))
)).sort((left, right) => (
));
if (nativeTerrain) collisionRecords.push({
placement: terrainPlacement,
assetFile: path.join(convertedDirectory, "terrain", nativeTerrain.collision.file),
triangles: nativeTerrain.collision.triangles,
});
collisionRecords.sort((left, right) => (
left.placement.id.localeCompare(right.placement.id)
|| left.assetFile.localeCompare(right.assetFile)
));
const collision = [];
for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) {
if (recipe.source.nativeCollision) {
if (recipe.source.nativeCollision.kind !== "runewaker-serialized-ros-trekker") {
throw new Error("Unsupported native collision kind: " + recipe.source.nativeCollision.kind);
}
// Keep the input manifest immutable; the final assembly plan also contains
// the resulting collision report and cannot be its own hash-pinned input.
const collisionManifest = path.join(convertedDirectory, "wdb-native-collision-placement-manifest.json");
await writeFile(collisionManifest, JSON.stringify({
schemaVersion: plan.schemaVersion, dungeonId: slug, resources: plan.resources,
sourceDescriptorCount: staticPlacements.length,
staticPlacements, dynamicPlacements: plan.dynamicPlacements,
}, null, 2) + "\n", "utf8");
const reportFile = path.join(convertedDirectory, "native-collision-report.json");
await run(process.execPath, [
path.join(pipelineDirectory, "assemble-native-collision.mjs"),
"--recipe", recipeFile, "--placement-manifest", collisionManifest,
"--output-root", path.join(workDirectory, "native-collision-source"),
"--collision-mode", "respect-options",
"--report-output", reportFile,
], "assemble source serialized collision with the native pathing mask");
nativeCollision = JSON.parse(await readFile(reportFile, "utf8"));
if (nativeCollision.nativeExtractorPolicy?.collisionMode !== "respect-options"
|| nativeCollision.nativeExtractorPolicy?.renderMeshFallbackUsed !== false) {
throw new Error("Native gameplay collision must honor source entity collision options without visual fallback.");
}
for (const [index, asset] of nativeCollision.collisionAssets.trekkerFiltered.entries()) {
const file = slug + "-native-collision-" + String(index).padStart(3, "0") + ".glb";
await copyFile(path.resolve(projectRoot, asset.file), path.join(convertedDirectory, file));
collision.push({ file, triangles: asset.triangles, sourceTriangleEstimate: asset.triangles, assembledFromWdb: true, nativeSerializedCollision: true });
}
if (nativeTerrain) {
const file = slug + "-terrain-collision.glb";
await copyFile(path.join(convertedDirectory, "terrain", nativeTerrain.collision.file), path.join(convertedDirectory, file));
collision.push({ file, triangles: nativeTerrain.collision.triangles, nativeWdbTerrain: true });
}
} else for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) {
const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb";
await assembleInstancedGlb(group.records, path.join(convertedDirectory, file));
collision.push({
@@ -527,6 +677,9 @@ async function assembleAllWdbPlacements() {
}
plan.resourceFailures = failures;
if (nativeTerrain) plan.nativeTerrain = nativeTerrain;
if (nativeWater) plan.nativeWater = nativeWater;
if (nativeCollision) plan.nativeCollision = nativeCollision;
plan.buildSummary = {
staticPlacements: staticPlacements.length,
dynamicPlacements: plan.dynamicPlacements.filter((placement) => (
@@ -546,10 +699,10 @@ async function assembleAllWdbPlacements() {
const visualTriangles = staticPlacements.reduce((total, placement) => (
total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0)
), 0);
), Number(nativeTerrain?.visual.triangles ?? 0) + (nativeWater?.geometry.layers ?? []).reduce((sum, layer) => sum + layer.emittedTriangles, 0));
const visualMeshObjects = staticPlacements.reduce((total, placement) => (
total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0)
), 0);
), Number(nativeTerrain?.visual.meshObjects ?? 0) + Number(nativeWater?.payloadDecode.serializedLayerCount ?? 0));
const conversion = {
schemaVersion: 2,
dungeonId: slug,
@@ -562,6 +715,9 @@ async function assembleAllWdbPlacements() {
primaryModel: recipe.source.model,
descriptorCount: plan.sourceDescriptorCount,
placementManifest: "wdb-placement-manifest.json",
...(nativeTerrain ? { nativeTerrain } : {}),
...(nativeWater ? { nativeWater } : {}),
...(nativeCollision ? { nativeCollision } : {}),
},
coordinateTransform: plan.coordinateTransform,
visual: {
@@ -575,7 +731,7 @@ async function assembleAllWdbPlacements() {
},
collision,
collisionPolicy: {
kind: "wdb-static-placement-trimesh",
kind: nativeCollision ? "runewaker-serialized-ros-trekker-static-placement" : "wdb-static-placement-trimesh",
maximumTrianglesPerChunk: 100_000,
workingChunkTarget: collisionChunkTriangleTarget,
excludesReviewedDynamicPlacements: true,
@@ -654,18 +810,21 @@ async function assembleRepeatedPlacements() {
async function optimizeVisual() {
const source = path.join(convertedDirectory, slug + "-visual.glb");
const output = path.join(convertedDirectory, slug + "-visual.optimized.glb");
// Establish lighting modes before dedup can merge otherwise identical mats.
const nativeMaterialSemantics = await applyNativeMaterialSemantics(source);
await run(
process.execPath,
[
gltfTransform, "optimize", source, output,
"--compress", "meshopt", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3", "--texture-size", "2048",
gltfTransform, ...environmentOptimizationArguments(source, output, {
preserveNativeWaterLayers: Boolean(nativeWater),
preserveMaterialVariantCarriers: Boolean(recipe.visualSurfaceQualityPolicies?.length || recipe.visualUvAdaptationPolicies?.length),
}),
],
"optimize the visual GLB with meshopt",
);
await run(
process.execPath,
[ktx2Compressor, output, "--concurrency", "1", "--jobs", "2"],
[ktx2Compressor, output, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.json")],
"compress the visual textures with KTX2",
);
await writeFile(path.join(convertedDirectory, "optimization-report.json"), JSON.stringify({
@@ -673,13 +832,55 @@ async function optimizeVisual() {
dungeonId: slug,
status: "review-required",
source: { file: path.basename(source), size: (await stat(source)).size },
optimized: { file: path.basename(output), size: (await stat(output)).size },
optimized: {
file: path.basename(output),
size: (await stat(output)).size,
checksum: await sha256(output),
},
compression: "meshopt+ktx2",
commands: [
"node scripts/runewaker-pipeline/lib/native-material-semantics.mjs <converted.glb> (before optimization)",
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
"node scripts/runewaker-pipeline/compress-environment-textures.mjs <optimized.glb> --concurrency 1 --jobs 2",
],
textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.json"), "utf8")),
nativeMaterialSemantics,
visualUvAdaptations,
derivedSupportStyling,
}, null, 2) + "\n", "utf8");
}
async function bakeNavigation() {
const conversion = JSON.parse(await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"));
if (recipe.source.primaryNativeCollision) {
if (fullWdbAssembly || recipe.source.primaryNativeCollision.kind !== "runewaker-native-core-and-source-supports"
|| recipe.source.primaryNativeCollision.collisionMode !== "respect-options") {
throw new Error("Native primary/support navigation requires primary-only assembly and respect-options collision.");
}
const outputRoot = path.join(workDirectory, "native-core-candidates", String(Date.now()));
await run(process.execPath, [path.join(pipelineDirectory, "build-native-core-support-candidate.mjs"),
slug, "--respect-options", "--output-root=" + projectPath(outputRoot)], "build options-enabled native primary collision and source support navigation");
const proof = JSON.parse(await readFile(path.join(outputRoot, "candidate-proof.json"), "utf8"));
if (proof.nativeCollision.queryRespectsEntityCollisionOptions !== true
|| !proof.navCandidate?.entryWithinHalfMeter) {
throw new Error("Native primary navigation must verify collision options and the source entry floor.");
}
const physicalSource = path.resolve(projectRoot, proof.fullCollisionCandidate.file);
if (await sha256(physicalSource) !== proof.fullCollisionCandidate.sha256) throw new Error("Native primary collision proof checksum mismatch.");
conversion.collision = await writeBoundedCollisionGlbs(physicalSource, convertedDirectory, slug, { sourceSha256: proof.fullCollisionCandidate.sha256 });
conversion.source.primaryNativeCollision = proof;
conversion.collisionPolicy = {
kind: "runewaker-serialized-primary-ros-and-source-supports", collisionMode: "respect-options",
renderMeshFallbackUsed: false, maximumTrianglesPerChunk: 90_000,
};
await writeFile(path.join(convertedDirectory, "conversion-report.json"), JSON.stringify(conversion, null, 2) + "\n", "utf8");
await writeFile(path.join(convertedDirectory, "primary-native-support-report.json"), JSON.stringify(proof, null, 2) + "\n", "utf8");
const navigationSource = path.resolve(projectRoot, proof.navCandidate.file);
if (await sha256(navigationSource) !== proof.navCandidate.sha256) throw new Error("Native primary navigation proof checksum mismatch.");
await copyFile(navigationSource, path.join(convertedDirectory, slug + "-navigation.glb"));
await copyFile(path.resolve(projectRoot, proof.navCandidate.recastReport), path.join(convertedDirectory, "recast-report.json"));
return;
}
const baseCollision = conversion.collision.filter((chunk) => !chunk.wdbSourceDelta);
const collisionAssets = baseCollision.map((chunk, index) => ({
id: "collision-" + String(index).padStart(3, "0"),
@@ -692,10 +893,11 @@ async function bakeNavigation() {
slug,
collisionAssets,
settings: recipe.navigation,
includeDetourTopology: recipe.navigation.includeDetourTopology === true,
output: navigationFile,
reportFile,
source: {
kind: "runewaker-primary-ros",
kind: nativeCollision ? "runewaker-serialized-ros-trekker-and-native-terrain" : fullWdbAssembly ? "runewaker-wdb-static-assembly" : "runewaker-primary-ros",
recipe: projectPath(recipeFile),
collisionChunkCount: collisionAssets.length,
},
@@ -735,18 +937,10 @@ async function appendSourceAnchorNavigation(navigationFile, reportFile) {
const { placement } = await primaryPlacement();
const half = Number(support.halfExtentSourceUnits ?? 60);
const meters = recipe.coordinateSystem.metersPerSourceUnit;
const positions = [];
for (const sourcePoint of support.points) {
const [x, y, z] = sourcePoint.map(Number);
const corners = [
[x - half, y, z - half], [x + half, y, z - half],
[x + half, y, z + half], [x - half, y, z + half],
].map((value) => {
const local = sourcePointToPrimaryLocal(value, placement);
return [local[0] * meters, local[1] * meters, -local[2] * meters];
});
for (const index of [0, 3, 2, 0, 2, 1]) positions.push(...corners[index]);
}
const { navigationPositions: positions } = sourceAnchorSupportGeometry(support.points, placement, {
halfExtent: half, thickness: Number(support.thicknessSourceUnits ?? 3), metersPerSourceUnit: meters,
omitPointIndices: support.omitNativeGroundedPointIndices ?? [],
});
const io = new NodeIO();
const document = await io.read(navigationFile);
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-buffer");
@@ -769,7 +963,8 @@ async function appendSourceAnchorNavigation(navigationFile, reportFile) {
report.geometry.vertices += positions.length / 3;
report.geometry.triangles += positions.length / 9;
report.source.sourceAnchorSupport = {
pointCount: support.points.length,
pointCount: support.points.length - (support.omitNativeGroundedPointIndices?.length ?? 0),
omittedNativeGroundedPointIndices: support.omitNativeGroundedPointIndices ?? [],
halfExtentSourceUnits: half,
provenance: support.evidence,
};
@@ -925,27 +1120,78 @@ async function stageShippingPackage() {
path.join(shippingDirectory, "wdb-placement-manifest.json"),
);
}
if (nativeCollision) {
for (const report of ["native-collision-report.json", "wdb-native-collision-placement-manifest.json"]) {
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
}
}
if (recipe.source.primaryNativeCollision) {
await copyFile(path.join(convertedDirectory, "primary-native-support-report.json"), path.join(shippingDirectory, "primary-native-support-report.json"));
}
const server = await sourceMetadata();
const { report, placement } = await primaryPlacement();
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
const localEntrance = sourcePointToLocalThree(sourceEntrance, placement);
const projectedEntrance = await projectNavigationPoint(localEntrance);
const yaw = server.playerSpawn.dir / server.playerSpawn.directionUnitsPerTurn * Math.PI * 2;
const authoredProjection = await projectNavigationPoint(localEntrance);
const sourceYaw = server.playerSpawn.dir / server.playerSpawn.directionUnitsPerTurn * Math.PI * 2;
let forward = transformSourceHeadingToPrimaryThree(sourceYaw, placement)
.map((value) => Number(value.toFixed(9)));
let footPosition = authoredProjection.position;
let navmeshDistance = authoredProjection.distance;
let yaw = (Math.atan2(forward[0], forward[2]) + Math.PI * 2) % (Math.PI * 2);
const entrance = {
sourcePosition: sourceEntrance,
sourceDirection: server.playerSpawn.dir,
localPosition: localEntrance.map((value) => Number(value.toFixed(6))),
footPosition: projectedEntrance.position,
navmeshDistance: projectedEntrance.distance,
footPosition,
navmeshDistance,
yaw: Number(yaw.toFixed(9)),
forward: [Number(Math.sin(yaw).toFixed(9)), 0, Number(Math.cos(yaw).toFixed(9))],
forward,
provenance: server.playerSpawn.source,
reviewRequired: server.playerSpawn.reviewRequired,
};
if (entrance.navmeshDistance > 75) {
throw new Error("Authoritative entrance is " + entrance.navmeshDistance + "m from navigation.");
if (authoredProjection.distance > 75) {
throw new Error("Authoritative entrance is " + authoredProjection.distance + "m from navigation.");
}
const entranceProjectionReview = entrance.navmeshDistance > 2;
const runtimeReview = recipe.source.reviewedRuntimeEntrance;
if (runtimeReview) {
const runtimeSourcePosition = runtimeReview.sourcePosition.map(Number);
const runtimeLocalPosition = sourcePointToLocalThree(runtimeSourcePosition, placement);
const runtimeProjection = await projectNavigationPoint(runtimeLocalPosition);
const maximumProjection = Number(runtimeReview.maximumNavmeshProjectionMeters ?? 2);
if (runtimeProjection.distance > maximumProjection) {
throw new Error("Reviewed runtime entrance is " + runtimeProjection.distance
+ "m from navigation; its recipe limit is " + maximumProjection + "m.");
}
const sourceForward = runtimeReview.sourceForwardVector.map(Number);
if (sourceForward.length !== 3 || sourceForward.some((value) => !Number.isFinite(value))
|| Math.hypot(sourceForward[0], sourceForward[2]) < 1e-8) {
throw new Error("reviewedRuntimeEntrance.sourceForwardVector must have a finite, nonzero XZ direction.");
}
const runtimeSourceYaw = Math.atan2(sourceForward[0], -sourceForward[2]);
forward = transformSourceHeadingToPrimaryThree(runtimeSourceYaw, placement)
.map((value) => Number(value.toFixed(9)));
yaw = (Math.atan2(forward[0], forward[2]) + Math.PI * 2) % (Math.PI * 2);
footPosition = runtimeProjection.position;
navmeshDistance = runtimeProjection.distance;
entrance.authoredNavmeshDistance = authoredProjection.distance;
entrance.reviewedRuntime = {
sourcePosition: runtimeSourcePosition,
localPosition: runtimeLocalPosition.map((value) => Number(value.toFixed(6))),
sourceForwardVector: sourceForward,
sourcePathNode: runtimeReview.sourcePathNode,
headingTarget: runtimeReview.headingTarget,
navmeshDistance: runtimeProjection.distance,
gameplayReviewed: runtimeReview.gameplayReviewed === true,
evidence: runtimeReview.evidence,
};
entrance.reviewRequired = runtimeReview.gameplayReviewed !== true;
}
entrance.footPosition = footPosition;
entrance.navmeshDistance = navmeshDistance;
entrance.yaw = Number(yaw.toFixed(9));
entrance.forward = forward;
const entranceProjectionReview = navmeshDistance > 2;
entrance.reviewRequired = entrance.reviewRequired || entranceProjectionReview;
const sourceWdb = path.join(sourceRoot, recipe.source.wdb);
const sourceRos = path.join(resourceRoot, recipe.source.model);
@@ -964,7 +1210,11 @@ async function stageShippingPackage() {
...(recipe.source.proceduralTerrainFallback ? {
proceduralTerrainFallback: recipe.source.proceduralTerrainFallback,
} : {}),
...(nativeTerrain ? { nativeTerrain } : {}),
...(nativeWater ? { nativeWater } : {}),
...(nativeCollision ? { nativeCollision } : {}),
server,
...(recipe.source.primaryNativeCollision ? { primaryNativeCollision: conversion.source.primaryNativeCollision } : {}),
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
...(fullWdbAssembly ? {
wdbAssembly: {
@@ -987,6 +1237,12 @@ async function stageShippingPackage() {
review: recipe.review,
exceptions: [
...recipe.exceptions,
...(runtimeReview ? [
"The Lua-authored entrance remains in provenance; runtime entry uses the source path node recorded in reviewedRuntimeEntrance.",
...(runtimeReview.gameplayReviewed === true ? [] : [
"The reviewed runtime entrance still requires ordinary in-game movement verification.",
]),
] : []),
...(entranceProjectionReview ? [
"The authoritative entrance was projected " + entrance.navmeshDistance
+ "m to the packaged primary navigation and requires an in-game entrance review.",
@@ -1005,6 +1261,8 @@ await buildNativeExporterIfNeeded();
await exportSource();
await prepareTextures();
await convertEnvironment();
await buildNativeTerrain();
await buildNativeWater();
if (fullWdbAssembly) await assembleAllWdbPlacements();
else await assembleRepeatedPlacements();
await optimizeVisual();
@@ -14,6 +14,9 @@ import { NodeIO } from "@gltf-transform/core";
import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Vector3 } from "three";
import { MeshBVH } from "three-mesh-bvh";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs";
import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs";
import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
@@ -68,8 +71,8 @@ const gltfTransform = path.join(
const ktx2Compressor = path.join(
projectRoot,
"scripts",
"asset-pipeline",
"compress-ktx2.mjs",
"runewaker-pipeline",
"compress-environment-textures.mjs",
);
const khronosValidator = path.join(
projectRoot,
@@ -173,6 +176,10 @@ async function prepareTextures() {
path.join(rawDirectory, "export-report.json"),
path.join(preparedDirectory, "export-report.json"),
);
const sourceReportFile = path.join(preparedDirectory, "export-report.json");
const sourceReport = JSON.parse(await readFile(sourceReportFile, "utf8"));
sourceReport.sourceResourceSha256 = await sha256(path.join(resourceRoot, recipe.source.model));
await writeFile(sourceReportFile, JSON.stringify(sourceReport, null, 2) + "\n", "utf8");
const sourceMtl = await readFile(path.join(rawDirectory, "scene.mtl"), "utf8");
const preparedMtl = sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png");
await writeFile(path.join(preparedDirectory, "scene.mtl"), preparedMtl, "utf8");
@@ -206,29 +213,22 @@ async function convertEnvironment() {
async function optimizeVisual() {
const source = path.join(convertedDirectory, `${slug}-visual.glb`);
const output = path.join(convertedDirectory, `${slug}-visual.optimized.glb`);
const nativeMaterialSemantics = await applyNativeMaterialSemantics(source);
const visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile(source, {
dungeonId: slug,
policyIds: recipe.visualUvAdaptationPolicies ?? [],
sourceReport: recipe.visualUvAdaptationPolicies?.length
? JSON.parse(await readFile(path.join(preparedDirectory, "export-report.json"), "utf8"))
: undefined,
});
await run(
process.execPath,
[
gltfTransform,
"optimize",
source,
output,
"--compress",
"meshopt",
"--meshopt-level",
"high",
"--instance",
"true",
"--instance-min",
"3",
"--texture-size",
"2048",
],
[gltfTransform, ...environmentOptimizationArguments(source, output)],
"optimize the visual GLB with HealerMan's standard settings",
);
await run(
process.execPath,
[ktx2Compressor, output, "--concurrency", "1", "--jobs", "2"],
[ktx2Compressor, output, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.json")],
"compress the visual textures with KTX2",
);
await writeFile(
@@ -238,12 +238,20 @@ async function optimizeVisual() {
dungeonId: slug,
status: "review-required",
source: { file: path.basename(source), size: (await stat(source)).size },
optimized: { file: path.basename(output), size: (await stat(output)).size },
optimized: {
file: path.basename(output),
size: (await stat(output)).size,
checksum: await sha256(output),
},
compression: "meshopt+ktx2",
commands: [
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --texture-size 2048",
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2",
"node scripts/runewaker-pipeline/lib/native-material-semantics.mjs <converted.glb> (before optimization)",
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
"node scripts/runewaker-pipeline/compress-environment-textures.mjs <optimized.glb> --concurrency 1 --jobs 2",
],
textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.json"), "utf8")),
nativeMaterialSemantics,
visualUvAdaptations,
}, null, 2)}\n`,
"utf8",
);
@@ -0,0 +1,68 @@
param(
[string]$RunewakerSourceRoot = $env:RUNEWAKER_SOURCE_ROOT,
[string]$PortableMsvcRoot = $env:MSVC_V142_ROOT,
[string]$WindowsSdkRoot = ${env:ProgramFiles(x86)} + '\Windows Kits\10'
)
$ErrorActionPreference = 'Stop'
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
if ([string]::IsNullOrWhiteSpace($RunewakerSourceRoot)) {
$RunewakerSourceRoot = [IO.Path]::GetFullPath((Join-Path $projectRoot '..\rom-pvt-server-backups\Runewaker\SourceCodeVS2019'))
}
if ([string]::IsNullOrWhiteSpace($PortableMsvcRoot)) {
$PortableMsvcRoot = Join-Path $env:USERPROFILE 'msvc-v142-portable\Contents\VC\Tools\MSVC'
}
if (-not (Test-Path -LiteralPath $RunewakerSourceRoot)) { throw "Runewaker source root not found: $RunewakerSourceRoot" }
if (-not (Test-Path -LiteralPath $PortableMsvcRoot)) { throw "Portable MSVC root not found: $PortableMsvcRoot" }
if (-not (Test-Path -LiteralPath $WindowsSdkRoot)) { throw "Windows SDK root not found: $WindowsSdkRoot" }
$msvcVersionRoot = Get-ChildItem -LiteralPath $PortableMsvcRoot -Directory |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'bin\Hostx64\x64\cl.exe') } |
Sort-Object Name -Descending | Select-Object -First 1
$sdkVersionRoot = Get-ChildItem -LiteralPath (Join-Path $WindowsSdkRoot 'Include') -Directory |
Where-Object {
(Test-Path -LiteralPath (Join-Path $_.FullName 'um\Windows.h')) -and
(Test-Path -LiteralPath (Join-Path $WindowsSdkRoot ('Lib\' + $_.Name + '\um\x64\kernel32.lib')))
} | Sort-Object Name -Descending | Select-Object -First 1
if ($null -eq $msvcVersionRoot) { throw 'cl.exe was not found.' }
if ($null -eq $sdkVersionRoot) { throw 'No usable Windows SDK was found.' }
$msvcRoot = $msvcVersionRoot.FullName
$sdkVersion = $sdkVersionRoot.Name
$sdkIncludeRoot = Join-Path $WindowsSdkRoot ('Include\' + $sdkVersion)
$sdkLibRoot = Join-Path $WindowsSdkRoot ('Lib\' + $sdkVersion)
$compilerRoot = Join-Path $msvcRoot 'bin\Hostx64\x64'
$env:Path = $compilerRoot + ';' + (Join-Path $WindowsSdkRoot ('bin\' + $sdkVersion + '\x64')) + ';' + $env:Path
$env:Include = @((Join-Path $msvcRoot 'include'), (Join-Path $sdkIncludeRoot 'ucrt'), (Join-Path $sdkIncludeRoot 'shared'), (Join-Path $sdkIncludeRoot 'um'), (Join-Path $sdkIncludeRoot 'winrt')) -join ';'
$env:Lib = @((Join-Path $msvcRoot 'lib\x64'), (Join-Path $sdkLibRoot 'ucrt\x64'), (Join-Path $sdkLibRoot 'um\x64')) -join ';'
$outRoot = Join-Path $PSScriptRoot 'native\bin'
New-Item -ItemType Directory -Path $outRoot -Force | Out-Null
$librariesRoot = Join-Path $RunewakerSourceRoot 'Libraries'
$engineRoot = Join-Path $librariesRoot 'Rune Engine'
$compileArguments = @(
'/nologo', '/EHsc', '/O2', '/MD', '/std:c++17', '/DNDEBUG', '/D_LIB', '/DCLOSE_DEBUG', '/DWINDOWS_IGNORE_PACKING_MISMATCH',
'/I', $librariesRoot,
'/I', (Join-Path $RunewakerSourceRoot 'SDK'),
'/I', (Join-Path $RunewakerSourceRoot 'SDK\dx9'),
'/I', (Join-Path $RunewakerSourceRoot 'SDK\fmod'),
'/I', (Join-Path $RunewakerSourceRoot 'SDK\lua'),
'/I', (Join-Path $engineRoot 'Tools'),
'/I', (Join-Path $librariesRoot 'Tools\tools'),
'/I', (Join-Path $librariesRoot 'Tools\tools\lua'),
'/I', (Join-Path $librariesRoot 'Tools\tools\luabind'),
('/Fo' + (Join-Path $outRoot 'native-collision-audit.obj')),
('/Fe' + (Join-Path $outRoot 'native-collision-audit.exe')),
(Join-Path $PSScriptRoot 'native\native-collision-audit.cpp'),
'/link', '/LTCG',
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot '_bin\Rune Engine\x64\Release')),
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot '_bin\Rune Engine NULL\x64\Release')),
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot '_bin\ToolsLib\Release')),
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot 'SDK\lib')),
('/LIBPATH:' + (Join-Path $engineRoot 'Tools\granny\lib')),
'Rune.lib', 'Rune_NULL.lib', 'ToolsLib.lib', 'kernel32.lib', 'user32.lib', 'advapi32.lib', 'shell32.lib', 'ole32.lib', 'oleaut32.lib', 'uuid.lib', 'winmm.lib', 'ws2_32.lib'
)
& (Join-Path $compilerRoot 'cl.exe') $compileArguments
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Built $(Join-Path $outRoot 'native-collision-audit.exe')"
@@ -0,0 +1,851 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { Matrix4, Vector3 } from "three";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
import { sourceTerrainToThreeMatrix } from "./sand-vortex/terrain-geometry.mjs";
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
import {
buildNativeCollisionAuditArguments,
validateNativeCoreCandidateProvenance,
} from "./lib/native-core-candidate-provenance.mjs";
import { sourceRootFor, validateEnvironmentRecipe } from "./lib/recipe.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const argv = process.argv.slice(2);
const slug = argv.find((argument) => !argument.startsWith("--")) ?? "";
const stageOnly = argv.includes("--stage-only");
const respectOptions = argv.includes("--respect-options");
const outputRootArgument = argv.find((argument) => argument.startsWith("--output-root="))?.slice("--output-root=".length);
const outputRoot = outputRootArgument
? path.resolve(projectRoot, outputRootArgument)
: path.join(workRoot, slug, "native-core-candidate-v1");
const nativeReportArgument = argv.find((argument) => argument.startsWith("--native-report="))?.slice("--native-report=".length);
const supportName = "runewaker_source_anchor_support";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
if (!["aeternal-circle", "demon-stronghold", "mystic-altar"].includes(slug)) {
throw new Error(`Unsupported native-core candidate target ${slug}.`);
}
if (!respectOptions) {
throw new Error("Native-core candidates require --respect-options so collision-disabled ROS subtrees cannot enter the bake.");
}
const outputRelativeToSlugWork = path.relative(path.join(workRoot, slug), outputRoot);
if (!outputRelativeToSlugWork || outputRelativeToSlugWork === ".."
|| outputRelativeToSlugWork.startsWith(`..${path.sep}`) || path.isAbsolute(outputRelativeToSlugWork)) {
throw new Error(`--output-root must be a new directory under runewaker-export-work/${slug}.`);
}
function emptyBounds() {
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
}
function include(bounds, point) {
for (let axis = 0; axis < 3; axis += 1) {
bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]);
bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]);
}
}
function measureTriangleList(positions) {
if (positions.length % 9 !== 0) throw new Error("Triangle-list POSITION data is not divisible into triangles.");
const bounds = emptyBounds();
for (let offset = 0; offset < positions.length; offset += 3) {
const point = [positions[offset], positions[offset + 1], positions[offset + 2]];
if (!point.every(Number.isFinite)) throw new Error("Triangle-list POSITION contains a non-finite coordinate.");
include(bounds, point);
}
return bounds;
}
function directBridgeBounds(nativePositions, meters) {
const bounds = emptyBounds();
for (let offset = 0; offset < nativePositions.length; offset += 3) {
include(bounds, [nativePositions[offset] * meters, nativePositions[offset + 1] * meters, -nativePositions[offset + 2] * meters]);
}
return bounds;
}
function upwardFloorHitsAtXZ(positions, point) {
const hits = [];
for (let offset = 0; offset < positions.length; offset += 9) {
const ax = positions[offset];
const ay = positions[offset + 1];
const az = positions[offset + 2];
const bx = positions[offset + 3];
const by = positions[offset + 4];
const bz = positions[offset + 5];
const cx = positions[offset + 6];
const cy = positions[offset + 7];
const cz = positions[offset + 8];
const denominator = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
if (Math.abs(denominator) < 1e-12) continue;
const weightA = ((bz - cz) * (point[0] - cx) + (cx - bx) * (point[2] - cz)) / denominator;
const weightB = ((cz - az) * (point[0] - cx) + (ax - cx) * (point[2] - cz)) / denominator;
const weightC = 1 - weightA - weightB;
if (Math.min(weightA, weightB, weightC) < -1e-6) continue;
const normalY = (bz - az) * (cx - ax) - (bx - ax) * (cz - az);
if (normalY <= 1e-9) continue;
hits.push({
y: weightA * ay + weightB * by + weightC * cy,
normalY: normalY / Math.hypot(
(by - ay) * (cz - az) - (bz - az) * (cy - ay),
normalY,
(bx - ax) * (cy - ay) - (by - ay) * (cx - ax),
),
triangle: offset / 9,
});
}
return hits.sort((left, right) => right.y - left.y || left.triangle - right.triangle);
}
function boundsDifference(left, right) {
let maximum = 0;
for (let axis = 0; axis < 3; axis += 1) {
maximum = Math.max(maximum,
Math.abs(left.minimum[axis] - right.minimum[axis]),
Math.abs(left.maximum[axis] - right.maximum[axis]));
}
return maximum;
}
function triangleListFromIndexedGeometry(geometry) {
const positions = [];
for (const index of geometry.indices) {
const offset = index * 3;
positions.push(geometry.positions[offset], geometry.positions[offset + 1], geometry.positions[offset + 2]);
}
return positions;
}
function runNativeCollisionExtractor(executable, args) {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
child.once("error", reject);
child.once("close", (code) => {
if (code !== 0) reject(new Error(`Native collision extraction failed (${code}): ${stderr || stdout}`));
else resolve({ stdout, stderr });
});
});
}
function assertStrictNativeQueryReport(report, slug, modelResource) {
if (String(report?.sourceModel ?? "").replaceAll("\\", "/").toLowerCase() !== modelResource.toLowerCase()
|| report.collisionMode !== "respect-options"
|| report.queryMasks?.ignoreEntityCollisionOption !== false
|| report.renderMeshFallbackTriangleCount !== 0
|| !Number.isInteger(report.nativeCollisionTriangleCount)
|| report.nativeCollisionTriangleCount <= 0
|| report.nativeCollisionTriangleCount !== report.enabledNativeCollisionTriangleCount) {
throw new Error(`${slug}: native extractor output is not a complete strict respect-options collision query.`);
}
}
function assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath) {
if (path.resolve(String(report?.geometryOutput ?? "")) !== path.resolve(objPath)
|| path.resolve(String(report?.triangleFlagOutput ?? "")) !== path.resolve(flagsPath)) {
throw new Error(`Native collision report outputs do not match its cache paths: ${reportPath}`);
}
}
async function obtainNativeCollisionReport({ recipe, recipeBytes, sourceModelSha256, sourceWdbSha256 }) {
const sourceRoot = sourceRootFor(recipe);
const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot);
const executable = path.join(projectRoot, "scripts/runewaker-pipeline/native/bin/native-collision-audit.exe");
const extractorSource = path.join(projectRoot, "scripts/runewaker-pipeline/native/native-collision-audit.cpp");
const [executableBytes, extractorSourceBytes] = await Promise.all([readFile(executable), readFile(extractorSource)]);
const provenanceInputs = {
schemaVersion: 1,
slug: recipe.slug,
sourceModel: recipe.source.model.replaceAll("\\", "/"),
sourceModelSha256,
sourceWdb: recipe.source.wdb.replaceAll("\\", "/"),
sourceWdbSha256,
recipeSha256: sha256(recipeBytes),
extractorSha256: sha256(executableBytes),
extractorSourceSha256: sha256(extractorSourceBytes),
extractorPathArgumentFormat: "forward-slashes-v2",
collisionMode: "respect-options",
};
const cacheKey = sha256(Buffer.from(JSON.stringify(provenanceInputs)));
const cacheRoot = path.join(workRoot, "resource-cache/native-core-collision", recipe.slug, cacheKey);
const reportPath = nativeReportArgument
? path.resolve(projectRoot, nativeReportArgument)
: path.join(cacheRoot, "native-collision-report.json");
const manifestPath = `${reportPath}.provenance.json`;
const objPath = `${reportPath}.obj`;
const flagsPath = `${reportPath}.flags.bin`;
const validateCached = async () => {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (JSON.stringify(manifest.inputs) !== JSON.stringify(provenanceInputs)) {
throw new Error(`${recipe.slug}: native collision cache manifest is not bound to current ROS/WDB/recipe/tool hashes.`);
}
const reportBytes = await readFile(reportPath);
const objBytes = await readFile(objPath);
const flagsBytes = await readFile(flagsPath);
if (sha256(reportBytes) !== manifest.outputs?.reportSha256
|| sha256(objBytes) !== manifest.outputs?.geometrySha256
|| sha256(flagsBytes) !== manifest.outputs?.flagsSha256) {
throw new Error(`${recipe.slug}: native collision cache output hash validation failed.`);
}
const report = JSON.parse(reportBytes.toString("utf8"));
assertStrictNativeQueryReport(report, recipe.slug, provenanceInputs.sourceModel);
assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath);
return {
report,
reportPath,
objPath,
flagsPath,
reportSha256: manifest.outputs.reportSha256,
geometrySha256: manifest.outputs.geometrySha256,
flagsSha256: manifest.outputs.flagsSha256,
manifestPath,
manifestSha256: sha256(await readFile(manifestPath)),
cacheKey,
inputs: provenanceInputs,
reused: true,
};
};
try {
return await validateCached();
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (nativeReportArgument) {
throw new Error(`${recipe.slug}: --native-report reuse requires a SHA-validated .provenance.json sidecar.`);
}
let existingEntries = [];
try {
existingEntries = await import("node:fs/promises").then(({ readdir }) => readdir(cacheRoot));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (existingEntries.length) {
throw new Error(`${recipe.slug}: refusing to overwrite an incomplete native collision cache at ${cacheRoot}.`);
}
await mkdir(cacheRoot, { recursive: true });
await runNativeCollisionExtractor(executable, buildNativeCollisionAuditArguments({
resourceRoot,
sourceModel: recipe.source.model,
reportPath,
}));
const reportBytes = await readFile(reportPath);
const objBytes = await readFile(objPath);
const flagsBytes = await readFile(flagsPath);
const report = JSON.parse(reportBytes.toString("utf8"));
assertStrictNativeQueryReport(report, recipe.slug, provenanceInputs.sourceModel);
assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath);
const outputs = {
reportSha256: sha256(reportBytes),
geometrySha256: sha256(objBytes),
flagsSha256: sha256(flagsBytes),
reportByteLength: reportBytes.byteLength,
geometryByteLength: objBytes.byteLength,
flagsByteLength: flagsBytes.byteLength,
};
const manifest = { schemaVersion: 1, inputs: provenanceInputs, outputs };
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
return {
report,
reportPath,
objPath,
flagsPath,
...outputs,
manifestPath,
manifestSha256: sha256(await readFile(manifestPath)),
cacheKey,
inputs: provenanceInputs,
reused: false,
};
}
function createTriangleMesh(document, scene, name, positions, extras) {
if (positions.length % 9 !== 0) throw new Error(`${name}: positions are not triangle-list data.`);
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("candidate-geometry-buffer");
const accessor = document.createAccessor(`${name}-position`)
.setType("VEC3")
.setArray(new Float32Array(positions))
.setBuffer(buffer);
const primitive = document.createPrimitive().setAttribute("POSITION", accessor);
const mesh = document.createMesh(name).addPrimitive(primitive);
const node = document.createNode(name).setMesh(mesh).setExtras(extras);
scene.addChild(node);
return node;
}
async function writeScene(file, components) {
const document = new Document();
const scene = document.createScene("native-core-support-candidate");
document.getRoot().setDefaultScene(scene);
document.createBuffer("candidate-geometry-buffer");
for (const component of components) {
createTriangleMesh(document, scene, component.name, component.positions, component.extras);
}
await io.write(file, document);
}
function parseNativeCollisionObj(text) {
const positions = [];
for (const line of text.split(/\r?\n/)) {
if (!line.startsWith("v ")) continue;
const values = line.trim().split(/\s+/).slice(1, 4).map(Number);
if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error("Native collision OBJ contains an invalid vertex.");
positions.push(...values);
}
if (positions.length % 9 !== 0) throw new Error("Native collision OBJ vertex count is not triangle-aligned.");
return positions;
}
function bridgeAndFilterNativeCollision(sourcePositions, flagsBuffer, exclusionMask, meters) {
const sourceTriangleCount = sourcePositions.length / 9;
if (flagsBuffer.byteLength !== sourceTriangleCount * 4) {
throw new Error(`Native collision flags contain ${flagsBuffer.byteLength / 4} entries for ${sourceTriangleCount} source triangles.`);
}
const flags = new DataView(flagsBuffer.buffer, flagsBuffer.byteOffset, flagsBuffer.byteLength);
const acceptedPositions = [];
let excludedTriangleCount = 0;
let nonzeroSourceFlagCount = 0;
for (let triangle = 0; triangle < sourceTriangleCount; triangle += 1) {
const flag = flags.getUint32(triangle * 4, true);
if (flag !== 0) nonzeroSourceFlagCount += 1;
if ((flag & exclusionMask) !== 0) {
excludedTriangleCount += 1;
continue;
}
const base = triangle * 9;
// The reflected-Z bridge reverses handedness; swap B/C exactly once.
for (const sourceVertex of [0, 2, 1]) {
const offset = base + sourceVertex * 3;
acceptedPositions.push(
sourcePositions[offset] * meters,
sourcePositions[offset + 1] * meters,
-sourcePositions[offset + 2] * meters,
);
}
}
return { positions: acceptedPositions, sourceTriangleCount, excludedTriangleCount, nonzeroSourceFlagCount };
}
async function appendRemoteSupportNavigation(navFile, remoteNavPositions, remotePointIndices, insetM) {
if (!remotePointIndices.length) return { triangleCount: 0, vertexCount: 0, mergedIntoPrimaryMesh: false };
const document = await io.read(navFile);
if (remoteNavPositions.length % 9 !== 0) throw new Error("Remote support navigation positions are not triangle aligned.");
const node = document.getRoot().listNodes().find((candidate) => candidate.getName() === "navigation" && candidate.getMesh());
const primitive = node?.getMesh()?.listPrimitives()[0];
const position = primitive?.getAttribute("POSITION");
if (!node || !primitive || !position) throw new Error("Recast output has no primary navigation mesh to receive support islands.");
if (document.getRoot().getExtras()?.runewakerNavigationTopology) {
throw new Error("Cannot merge disconnected support islands into source-pinned Detour detail geometry.");
}
const previousPositions = position.getArray();
const baseVertexCount = position.getCount();
const oldIndex = primitive.getIndices()?.getArray();
const oldIndexCount = oldIndex?.length ?? baseVertexCount;
if (oldIndexCount % 3 !== 0) throw new Error("Recast navigation index data is not triangle aligned.");
const mergedPositions = new Float32Array(previousPositions.length + remoteNavPositions.length);
mergedPositions.set(previousPositions);
mergedPositions.set(remoteNavPositions, previousPositions.length);
const mergedIndices = new Uint32Array(oldIndexCount + remoteNavPositions.length / 3);
if (oldIndex) mergedIndices.set(oldIndex);
else for (let index = 0; index < baseVertexCount; index += 1) mergedIndices[index] = index;
for (let index = 0; index < remoteNavPositions.length / 3; index += 1) {
mergedIndices[oldIndexCount + index] = baseVertexCount + index;
}
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("navigation-support-buffer");
const mergedPosition = document.createAccessor("navigation-position-with-isolated-supports")
.setType("VEC3")
.setArray(mergedPositions)
.setBuffer(buffer);
const mergedIndex = document.createAccessor("navigation-indices-with-isolated-supports")
.setType("SCALAR")
.setArray(mergedIndices)
.setBuffer(buffer);
primitive.setAttribute("POSITION", mergedPosition).setIndices(mergedIndex);
node.setExtras({
...node.getExtras(),
navigationComponentPolicy: "all-components",
isolatedDerivedSupportNavigation: {
source: supportName,
sourcePointIndices: remotePointIndices,
triangleCount: remoteNavPositions.length / 9,
clearanceInsetM: insetM,
inferredConnectivity: false,
},
});
await io.write(navFile, document);
const roundTrip = await io.read(navFile);
const retained = roundTrip.getRoot().listNodes().find((candidate) => candidate.getName() === node.getName());
const retainedPrimitive = retained?.getMesh()?.listPrimitives()[0];
const retainedPosition = retainedPrimitive?.getAttribute("POSITION");
const retainedIndex = retainedPrimitive?.getIndices();
if (!retainedPosition || !retainedIndex
|| retainedIndex.getCount() / 3 !== (oldIndexCount / 3) + remoteNavPositions.length / 9
|| retainedPosition.getCount() !== baseVertexCount + remoteNavPositions.length / 3) {
throw new Error("Merged remote support nav triangles failed GLB round-trip validation.");
}
return {
meshName: node.getName(),
triangleCount: remoteNavPositions.length / 9,
vertexCount: remoteNavPositions.length / 3,
remotePadCount: remotePointIndices.length,
sourcePointIndices: remotePointIndices,
clearanceInsetM: insetM,
mergedIntoPrimaryMesh: true,
mergedFinalTriangleCount: retainedIndex.getCount() / 3,
mergedFinalVertexCount: retainedPosition.getCount(),
navigationComponentPolicy: "all-components",
inferredConnectivity: false,
};
}
const recipeFile = path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`);
const recipeBytes = await readFile(recipeFile);
const recipe = validateEnvironmentRecipe(JSON.parse(recipeBytes.toString("utf8")), relative(recipeFile));
const recipeSha256 = sha256(recipeBytes);
const importMetadataPath = path.join(projectRoot, "src/assets/game/dungeons", slug, "import-metadata.json");
const importMetadata = JSON.parse(await readFile(importMetadataPath, "utf8"));
const sourceRoot = sourceRootFor(recipe);
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
const sourceModelPath = path.join(resourceRoot, recipe.source.model);
const sourceWdbPath = path.join(sourceRoot, recipe.source.wdb);
const sourceModelSha256 = sha256(await readFile(sourceModelPath));
const sourceWdbSha256 = sha256(await readFile(sourceWdbPath));
const queryCache = await obtainNativeCollisionReport({ recipe, recipeBytes, sourceModelSha256, sourceWdbSha256 });
const sourceReportPath = queryCache.reportPath;
const sourceReport = queryCache.report;
const nativeObjPath = queryCache.objPath;
const nativeFlagsPath = queryCache.flagsPath;
const nativeObjText = await readFile(nativeObjPath, "utf8");
const nativeSourcePositions = parseNativeCollisionObj(nativeObjText);
const meters = Number(recipe.coordinateSystem.metersPerSourceUnit);
const nativeData = bridgeAndFilterNativeCollision(
nativeSourcePositions,
await readFile(nativeFlagsPath),
Number(sourceReport.pathCollisionPolicy.exclusionMask),
meters,
);
if (nativeData.sourceTriangleCount !== sourceReport.nativeCollisionTriangleCount) {
throw new Error(`${slug}: native query OBJ triangle count does not match its options-aware report.`);
}
const nativeBounds = measureTriangleList(nativeData.positions);
const reportedBounds = directBridgeBounds(nativeSourcePositions, meters);
const nativeBoundsProofErrorM = boundsDifference(nativeBounds, reportedBounds);
if (nativeBoundsProofErrorM > 1e-4) throw new Error(`${slug}: bridge bounds do not agree with native source report (${nativeBoundsProofErrorM}m).`);
const wdbReportPath = path.join(workRoot, slug, "raw/wdb-report.json");
const exportReportPath = path.join(workRoot, slug, "prepared/export-report.json");
const conversionReportPath = path.join(workRoot, slug, "converted/conversion-report.json");
const wdbReportBytes = await readFile(wdbReportPath);
const exportReportBytes = await readFile(exportReportPath);
const conversionReportBytes = await readFile(conversionReportPath);
const wdbReport = JSON.parse(wdbReportBytes.toString("utf8"));
const exportReport = JSON.parse(exportReportBytes.toString("utf8"));
const collisionConversion = JSON.parse(conversionReportBytes.toString("utf8"));
const placement = buildWdbPlacementPlan(wdbReport, recipe).primaryPlacement;
const provenance = validateNativeCoreCandidateProvenance({
slug,
recipe,
recipeSha256,
importMetadata,
actualSourceModelSha256: sourceModelSha256,
actualSourceWdbSha256: sourceWdbSha256,
wdbReport,
wdbReportSha256: sha256(wdbReportBytes),
exportReport,
exportReportSha256: sha256(exportReportBytes),
conversionReportSha256: sha256(conversionReportBytes),
nativeCollisionReport: sourceReport,
nativeCollisionReportSha256: queryCache.reportSha256,
nativeCollisionObjSha256: queryCache.geometrySha256,
nativeCollisionFlagsSha256: queryCache.flagsSha256,
primaryPlacement: placement,
});
if (sourceReport.nativeCollisionTriangleCount !== sourceReport.enabledNativeCollisionTriangleCount) {
throw new Error(`${slug}: respecting-options geometry count does not match enabled serialized native collision triangles.`);
}
const points = recipe.source.sourceAnchorSupport.points.map((point) => point.map(Number));
const supportPolicy = recipe.source.sourceAnchorSupport;
const omittedPointIndices = new Set(supportPolicy.omitNativeGroundedPointIndices ?? []);
const retained = points.map((point, sourcePointIndex) => ({ point, sourcePointIndex })).filter(({ sourcePointIndex }) => !omittedPointIndices.has(sourcePointIndex));
const halfExtent = Number(supportPolicy.halfExtentSourceUnits ?? 60);
const thickness = Number(supportPolicy.thicknessSourceUnits ?? 3);
const expectedSupportGeometry = sourceAnchorSupportGeometry(retained.map(({ point }) => point), placement, {
halfExtent,
thickness,
metersPerSourceUnit: meters,
});
const expectedSupportWorldPositionHash = sha256(Buffer.from(new Float32Array(expectedSupportGeometry.positions).buffer));
let proofOmissionProbeRows = [];
if (omittedPointIndices.size) {
const omissionEvidence = supportPolicy.nativeGroundOmissionEvidence;
if (!omissionEvidence || omissionEvidence.primaryRosSha256 !== provenance.sourceModelSha256
|| !omittedPointIndices.has(omissionEvidence.sourcePointIndex)) {
throw new Error(`${slug}: omitted native-grounded anchors lack matching recipe evidence tied to the current ROS hash.`);
}
const omissionProbeRows = [];
for (const pointIndex of omittedPointIndices) {
const point = points[pointIndex];
const bridgedPoint = new Vector3(...point).applyMatrix4(sourceTerrainToThreeMatrix(placement, meters)).toArray();
const floorHits = upwardFloorHitsAtXZ(nativeData.positions, bridgedPoint);
const nearestHit = floorHits.find((hit) => Math.abs(hit.y - bridgedPoint[1]) <= 0.5);
if (!nearestHit) {
throw new Error(`${slug}: omitted source support ${pointIndex} no longer has a matching native upward floor hit within 0.5m.`);
}
omissionProbeRows.push({ sourcePointIndex: pointIndex, sourcePoint: point, bridgedPoint, nearestUpwardNativeHit: nearestHit });
}
proofOmissionProbeRows = omissionProbeRows;
}
const agentRadius = Number(recipe.navigation.agentRadius ?? 0.36);
const insetSourceUnits = agentRadius / meters;
const insetHalfExtent = halfExtent - insetSourceUnits;
if (insetHalfExtent <= 0) throw new Error(`${slug}: remote navigation inset consumes the full support pad.`);
const coreSupportTrianglePositions = [];
const allSupportTrianglePositions = [];
const remoteSupportNavPositions = [];
const corePointIndices = [];
const remotePointIndices = [];
const supportRows = [];
for (const { point, sourcePointIndex } of retained) {
const box = sourceAnchorSupportGeometry([point], placement, { halfExtent, thickness, metersPerSourceUnit: meters });
const boxBounds = measureTriangleList(triangleListFromIndexedGeometry(box));
const overlapsNativeCoreXZ = boxBounds.maximum[0] >= nativeBounds.minimum[0]
&& boxBounds.minimum[0] <= nativeBounds.maximum[0]
&& boxBounds.maximum[2] >= nativeBounds.minimum[2]
&& boxBounds.minimum[2] <= nativeBounds.maximum[2];
const boxTrianglePositions = triangleListFromIndexedGeometry(box);
allSupportTrianglePositions.push(...boxTrianglePositions);
if (overlapsNativeCoreXZ) {
corePointIndices.push(sourcePointIndex);
coreSupportTrianglePositions.push(...boxTrianglePositions);
} else {
remotePointIndices.push(sourcePointIndex);
const inset = sourceAnchorSupportGeometry([point], placement, {
halfExtent: insetHalfExtent,
thickness,
metersPerSourceUnit: meters,
});
remoteSupportNavPositions.push(...inset.navigationPositions);
}
supportRows.push({ sourcePointIndex, bounds: boxBounds, overlapsNativeCoreXZ });
}
const coreBounds = emptyBounds();
for (let offset = 0; offset < nativeData.positions.length; offset += 3) {
include(coreBounds, nativeData.positions.slice(offset, offset + 3));
}
if (coreSupportTrianglePositions.length) {
const extraBounds = measureTriangleList(coreSupportTrianglePositions);
include(coreBounds, extraBounds.minimum);
include(coreBounds, extraBounds.maximum);
}
const cellSize = Number(recipe.navigation.cellSize);
const predictedGrid = {
cellSize,
widthCells: Math.ceil((coreBounds.maximum[0] - coreBounds.minimum[0]) / cellSize),
depthCells: Math.ceil((coreBounds.maximum[2] - coreBounds.minimum[2]) / cellSize),
};
predictedGrid.cellCount = predictedGrid.widthCells * predictedGrid.depthCells;
await mkdir(outputRoot, { recursive: true });
const nativeCoreInput = path.join(outputRoot, `${slug}-native-core-input.glb`);
const fullCollisionCandidate = path.join(outputRoot, `${slug}-native-collision-and-all-supports-candidate.glb`);
const navFile = path.join(outputRoot, `${slug}-native-core-navigation.collision-candidate.glb`);
const recastReportFile = path.join(outputRoot, "recast-report.json");
const proofFile = path.join(outputRoot, "candidate-proof.json");
let stagedProof = null;
if (stageOnly) {
for (const file of [nativeCoreInput, fullCollisionCandidate, proofFile]) {
try {
await access(file);
throw new Error(`Refusing to overwrite staged candidate artifact ${file}.`);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
} else {
try {
stagedProof = JSON.parse(await readFile(proofFile, "utf8"));
if (stagedProof.status !== "staged-inputs-no-nav-bake") throw new Error(`${slug}: existing candidate proof is already baked or invalid.`);
for (const file of [nativeCoreInput, fullCollisionCandidate]) await access(file);
const stagedSource = stagedProof.source ?? {};
for (const [field, expected] of Object.entries({
recipeSha256,
sourceRosSha256: provenance.sourceModelSha256,
sourceWdbSha256: provenance.sourceWdbSha256,
wdbPlacementReportSha256: provenance.wdbReportSha256,
nativeExportReportSha256: provenance.exportReportSha256,
conversionReportSha256: provenance.conversionReportSha256,
nativeCollisionReportSha256: queryCache.reportSha256,
nativeCollisionObjSha256: queryCache.geometrySha256,
nativeCollisionFlagsSha256: queryCache.flagsSha256,
nativeCollisionCacheManifestSha256: queryCache.manifestSha256,
})) {
if (stagedSource[field] !== expected) throw new Error(`${slug}: staged source hash changed for ${field}.`);
}
if (stagedProof.nativeCoreInput.sha256 !== sha256(await readFile(nativeCoreInput))
|| stagedProof.fullCollisionCandidate.sha256 !== sha256(await readFile(fullCollisionCandidate))) {
throw new Error(`${slug}: staged candidate GLB hash disagrees with its proof.`);
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
stagedProof = null;
for (const file of [nativeCoreInput, fullCollisionCandidate, proofFile]) {
try {
await access(file);
throw new Error(`Refusing to overwrite incomplete candidate artifact ${file}.`);
} catch (checkError) {
if (checkError?.code !== "ENOENT") throw checkError;
}
}
}
for (const file of [navFile, recastReportFile]) {
try {
await access(file);
throw new Error(`Refusing to overwrite candidate output ${file}.`);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
}
const commonSourceExtras = {
candidateOnly: true,
exactNativeQuerySource: relative(sourceReportPath),
exactNativeQuerySourceSha256: queryCache.reportSha256,
exactNativeCollisionObjSha256: queryCache.geometrySha256,
exactNativeCollisionFlagsSha256: queryCache.flagsSha256,
sourceModelSha256: provenance.sourceModelSha256,
recipeSha256,
nativeQueryMode: "respect-options",
nativeQueryCacheKey: queryCache.cacheKey,
sourceCollisionTriangleCount: sourceReport.nativeCollisionTriangleCount,
sourcePathExclusionMask: Number(sourceReport.pathCollisionPolicy.exclusionMask),
excludedByPathFlagsTriangleCount: nativeData.excludedTriangleCount,
reflectedZBridgeAndWindingSwapAppliedOnce: true,
};
if (!stagedProof) {
await writeScene(nativeCoreInput, [
{ name: "native-query-collision-core", positions: nativeData.positions, extras: { ...commonSourceExtras, role: "native-core-collision" } },
{ name: "core-overlapping-derived-support-boxes", positions: coreSupportTrianglePositions, extras: {
role: "derived-collision-included-in-native-core-recast",
source: supportName,
sourcePointIndices: corePointIndices,
reason: "XZ footprint overlaps native collision bounds; full boxes are included for Recast surface/obstacle/clearance handling.",
} },
]);
await writeScene(fullCollisionCandidate, [
{ name: "native-query-collision-core", positions: nativeData.positions, extras: { ...commonSourceExtras, role: "native-core-collision" } },
{ name: "all-recipe-derived-support-boxes", positions: allSupportTrianglePositions, extras: {
role: "derived-collision-candidate",
source: supportName,
sourcePointIndices: retained.map(({ sourcePointIndex }) => sourcePointIndex),
sourceWorldPositionHash: expectedSupportWorldPositionHash,
generatedDirectlyFromRecipeSourceCoordinates: true,
canonicalGeneratedSupportWasNotReadOrTransformed: true,
} },
]);
}
const proof = {
schemaVersion: 1,
slug,
status: "staged-inputs-no-nav-bake",
source: {
recipe: relative(recipeFile),
recipeSha256,
importMetadata: relative(importMetadataPath),
importMetadataSha256: sha256(await readFile(importMetadataPath)),
sourceRosResource: recipe.source.model,
sourceRosSha256: provenance.sourceModelSha256,
sourceWdbResource: recipe.source.wdb,
sourceWdbSha256: provenance.sourceWdbSha256,
wdbPlacementReport: relative(wdbReportPath),
wdbPlacementReportSha256: provenance.wdbReportSha256,
nativeExportReport: relative(exportReportPath),
nativeExportReportSha256: provenance.exportReportSha256,
conversionReport: relative(conversionReportPath),
conversionReportSha256: provenance.conversionReportSha256,
sourceModel: sourceReport.sourceModel,
primaryPlacement: placement,
nativeCollisionCacheKey: queryCache.cacheKey,
nativeCollisionCacheInputs: queryCache.inputs,
nativeCollisionCacheManifest: relative(queryCache.manifestPath),
nativeCollisionCacheManifestSha256: queryCache.manifestSha256,
nativeCollisionCacheReused: queryCache.reused,
nativeCollisionReport: relative(sourceReportPath),
nativeCollisionReportSha256: queryCache.reportSha256,
nativeCollisionObj: relative(nativeObjPath),
nativeCollisionObjSha256: queryCache.geometrySha256,
nativeCollisionFlags: relative(nativeFlagsPath),
nativeCollisionFlagsSha256: queryCache.flagsSha256,
sourceSupportGeometry: "sourceAnchorSupportGeometry(recipe points, primary WDB placement, source-unit dimensions)",
sourceSupportWasGeneratedDirectly: true,
transformedCanonicalSupportWasNotConsumed: true,
nativeGroundedOmissionProbeRows: proofOmissionProbeRows,
collisionConversionReport: relative(conversionReportPath),
collisionChunksRetainedAsComparisonOnly: collisionConversion.collision.map((row) => row.file),
},
nativeCollision: {
queryTriangles: sourceReport.nativeCollisionTriangleCount,
completeSerializedTriangles: sourceReport.completeNativeCollisionTriangleCount ?? sourceReport.nativeCollisionTriangleCount,
collisionOptionEnabledTriangles: sourceReport.enabledNativeCollisionTriangleCount ?? sourceReport.nativeCollisionTriangleCount,
trianglesRemovedByCollisionOptionGates: sourceReport.disabledSubtreeNativeTriangles ?? 0,
queryRespectsEntityCollisionOptions: sourceReport.queryMasks?.ignoreEntityCollisionOption === false,
sourceNonzeroTriangleFlags: nativeData.nonzeroSourceFlagCount,
exclusionMask: Number(sourceReport.pathCollisionPolicy.exclusionMask),
excludedTriangleCount: nativeData.excludedTriangleCount,
acceptedTriangleCount: nativeData.positions.length / 9,
sourceDirectBridgeBounds: nativeBounds,
boundsErrorAgainstReportM: nativeBoundsProofErrorM,
convertedCollisionComparisonOnly: true,
},
supportSelection: {
originalCount: points.length,
omittedNativeGroundedPointIndices: [...omittedPointIndices],
retainedCount: retained.length,
sourceWorldPositionHash: expectedSupportWorldPositionHash,
generatedFromRawRecipeCoordinatesWithoutCanonicalSupportInput: true,
coreOverlappingCount: corePointIndices.length,
coreSourcePointIndices: corePointIndices,
remoteSeparateCount: remotePointIndices.length,
remoteSourcePointIndices: remotePointIndices,
remoteNavClearanceInsetM: agentRadius,
remoteNavTopInsetHalfExtentM: halfExtent * meters - agentRadius,
inferredRemoteConnectivity: false,
rows: supportRows,
},
nativeCoreInput: {
file: relative(nativeCoreInput),
sha256: sha256(await readFile(nativeCoreInput)),
triangleCount: (nativeData.positions.length + coreSupportTrianglePositions.length) / 9,
nativeCorePlusXZOverlappingSupportBounds: coreBounds,
predictedCoreGrid: predictedGrid,
settings: recipe.navigation,
},
fullCollisionCandidate: {
file: relative(fullCollisionCandidate),
sha256: sha256(await readFile(fullCollisionCandidate)),
triangleCount: (nativeData.positions.length + allSupportTrianglePositions.length) / 9,
sourceGeometryOnly: true,
existingShippingUntouched: true,
},
sourceGroundEntryAnchor: null,
navCandidate: null,
};
const entrySourcePoint = points[0];
proof.sourceGroundEntryAnchor = new Vector3(...entrySourcePoint)
.applyMatrix4(sourceTerrainToThreeMatrix(placement, meters))
.toArray();
if (!stageOnly) await writeFile(proofFile, `${JSON.stringify(proof, null, 2)}\n`, "utf8");
if (!stageOnly) {
console.log(`[native-core-nav-candidate] ${slug}: baking native core + ${corePointIndices.length} footprint-overlapping supports, ${predictedGrid.cellCount} predicted cells`);
const baked = await bakeNavigationFromCollisionAssets({
slug,
collisionAssets: [{ id: "native-core-and-overlapping-supports", role: "collision", path: relative(nativeCoreInput) }],
settings: { ...recipe.navigation },
output: navFile,
reportFile: recastReportFile,
includeDetourTopology: remotePointIndices.length === 0,
source: {
kind: "native-core-plus-overlapping-recipe-support-candidate",
candidateOnly: true,
collisionQueryMode: "respect-options",
nativeQueryReportSha256: commonSourceExtras.exactNativeQuerySourceSha256,
nativeQueryObjSha256: commonSourceExtras.exactNativeCollisionObjSha256,
nativeQueryFlagsSha256: commonSourceExtras.exactNativeCollisionFlagsSha256,
recipeSha256,
sourceModelSha256: provenance.sourceModelSha256,
sourceWdbSha256: provenance.sourceWdbSha256,
directRecipeSupportWorldPositionHash: expectedSupportWorldPositionHash,
sourceTransformAppliedOnce: true,
allRemoteSupportsRemainExcludedFromRecastInput: true,
},
detourRouteProbes: [{
id: "original-entry-anchor-projection",
start: proof.sourceGroundEntryAnchor,
end: proof.sourceGroundEntryAnchor,
maxStartSnapDistance: 0.5,
maxEndSnapDistance: 0.5,
}],
});
const remoteNode = await appendRemoteSupportNavigation(navFile, remoteSupportNavPositions, remotePointIndices, agentRadius);
const recastReport = JSON.parse(await readFile(recastReportFile, "utf8"));
const probe = recastReport.detourRouteProbes?.find((row) => row.id === "original-entry-anchor-projection");
if (remoteNode.triangleCount > 0) {
const recastGeometry = recastReport.geometry;
const baseTriangleCount = recastGeometry.triangles;
const baseVertexCount = recastGeometry.vertices;
const minimum = recastGeometry.bounds[0].slice();
const maximum = recastGeometry.bounds[1].slice();
for (let offset = 0; offset < remoteSupportNavPositions.length; offset += 3) {
for (let axis = 0; axis < 3; axis += 1) {
minimum[axis] = Math.min(minimum[axis], remoteSupportNavPositions[offset + axis]);
maximum[axis] = Math.max(maximum[axis], remoteSupportNavPositions[offset + axis]);
}
}
recastReport.navigationComponentPolicy = "all-components";
recastReport.geometry = {
...recastGeometry,
bounds: [minimum, maximum],
sourceRecastTriangles: baseTriangleCount,
sourceRecastVertices: baseVertexCount,
triangles: baseTriangleCount + remoteNode.triangleCount,
vertices: baseVertexCount + remoteNode.vertexCount,
};
recastReport.isolatedSupportNavigation = {
...remoteNode,
appendedWithoutLinks: true,
componentPolicy: "all-components",
};
}
proof.navCandidate = {
file: relative(navFile),
sha256: sha256(await readFile(navFile)),
recastReport: relative(recastReportFile),
recastReportSha256: sha256(await readFile(recastReportFile)),
buildMode: baked.build.mode,
partition: baked.build.partition,
tileCount: baked.build.tileCount,
populatedTileCount: baked.build.populatedTileCount,
inputTriangleCount: baked.input.triangleCount,
bakedNavigationTriangleCount: baked.geometry.triangles,
finalNavigationTriangleCount: remoteNode.mergedFinalTriangleCount ?? baked.geometry.triangles,
navigationComponentPolicy: remoteNode.triangleCount > 0 ? "all-components" : null,
entryProjection: probe?.start ?? null,
entryWithinHalfMeter: probe?.start?.found === true && probe.start.distance <= 0.5,
separateRemoteSupportNavigation: remoteNode,
};
recastReport.artifactOnlyNativeCoreCandidate = proof.navCandidate;
recastReport.outputSha256 = proof.navCandidate.sha256;
await writeFile(recastReportFile, `${JSON.stringify(recastReport, null, 2)}\n`, "utf8");
proof.navCandidate.recastReportSha256 = sha256(await readFile(recastReportFile));
proof.status = "candidate-nav-baked";
}
await writeFile(proofFile, `${JSON.stringify(proof, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
status: proof.status,
proof: relative(proofFile),
nativeTriangles: nativeData.positions.length / 9,
coreSupportCount: corePointIndices.length,
remoteSupportCount: remotePointIndices.length,
coreInputTriangles: proof.nativeCoreInput.triangleCount,
coreGrid: predictedGrid,
navCandidate: proof.navCandidate,
}, null, 2));
@@ -0,0 +1,702 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { inflateSync, constants as zlibConstants } from "node:zlib";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsVariants } from "@gltf-transform/extensions";
import sharp from "sharp";
import { dxt1ToRgba, parseTerrainChunk, readDataStore } from "./sand-vortex/extract-native-terrain.mjs";
import { buildNativeWaterGeometry } from "./lib/native-water-geometry.mjs";
import { decodeNativeWaterPayload } from "./lib/native-water-payload.mjs";
import { inspectNativeWaterDxt1Dds } from "./lib/native-water-dds.mjs";
import { nativeWaterRenderPolicy } from "./lib/native-water-render-policy.mjs";
import { writeNativeWaterAuditCacheAliases } from "./lib/native-water-audit-cache.mjs";
import { ensureWithinProject, parseNativeWaterBuildArgs, selectNativeWaterChunkKeys } from "./lib/native-water-build-utils.mjs";
import { sourceTerrainToThreeMatrix } from "./sand-vortex/terrain-geometry.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const REPEAT = 10497;
const USAGE = `Build native Runewaker water geometry and animated normal-frame carriers.
Usage:
node scripts/runewaker-pipeline/build-native-water.mjs [options]
Options:
--recipe <path> Recipe JSON (default: scripts/runewaker-pipeline/recipes/the-origin.json)
--source-root <path> Runewaker source root (default: recipe env var/relativeDefault)
--wdb-report <path> Descriptor-placement report (default: src/assets/.../<slug>/wdb-report.json)
--output-root <path> Workspace output directory (default: runewaker-export-work/<slug>/native-water-source)
--ground-manifest <path> Optional terrain-native-source.json; water output uses only its exact chunk coordinates
--audit-cache-root <path> Optional source/cache root; writes byte-verified raw/prepared texture aliases
--help Show this help`;
function resolveFromProject(value) {
return path.resolve(projectRoot, value);
}
function safeFilePart(value) {
const safe = String(value).replace(/[^a-z\d_-]+/gi, "_").replace(/^_+|_+$/g, "");
if (!safe) throw new Error("Water sequence has an empty descriptor GUID for output naming.");
return safe;
}
function recordBytes(wdb, record) {
return wdb.subarray(record.position, record.position + record.size);
}
function normalizeModel(value) {
return String(value ?? "").replaceAll("/", "\\").toLowerCase();
}
function selectPrimaryDescriptor(report, recipe) {
const wanted = normalizeModel(recipe.source.model);
const matches = (report.descriptors ?? []).filter((row) => normalizeModel(row.resource) === wanted);
if (matches.length === 0) throw new Error(`WDB report has no primary descriptor for ${recipe.source.model}.`);
if (matches.length === 1) return matches[0];
const wantedTranslation = recipe.source.primaryPlacementTranslation;
if (!Array.isArray(wantedTranslation) || wantedTranslation.length !== 3) {
throw new Error(`Primary descriptor has ${matches.length} placements, but recipe has no selection translation.`);
}
const selected = matches.filter((row) => row.translation?.every((value, index) => (
Math.abs(Number(value) - Number(wantedTranslation[index])) <= 0.0001
)));
if (selected.length !== 1) throw new Error(`Recipe translation selected ${selected.length} primary descriptors.`);
return selected[0];
}
function getProperty(descriptor, name) {
return descriptor.baseObject?.properties?.find((property) => property.name === name)?.value ?? null;
}
async function inspectNormalSequence(descriptor, sourceRootPath, outputRoot, auditCacheRoot = null) {
const textureName = getProperty(descriptor, "waterDesc:full:bump name");
const frameCount = Number(getProperty(descriptor, "waterDesc:full:bump frame count") ?? 1);
const frameRate = Number(getProperty(descriptor, "waterDesc:full:bump frame rate") ?? 30);
if (typeof textureName !== "string" || !textureName || !Number.isInteger(frameCount) || frameCount < 1 || frameCount > 1000) {
return { report: { status: "no-supported-sequence-fields", textureName, frameCount, frameRate }, carriers: [] };
}
const relativeStem = textureName.replaceAll("\\", "/").replace(/^\/+/, "");
const frames = [];
const carriers = [];
const sequenceOutputRoot = path.join(outputRoot, "normal-frames", safeFilePart(descriptor.guid));
for (let frame = 0; frame < frameCount; frame += 1) {
const name = `${relativeStem}_${frame}.dds`;
const file = path.resolve(sourceRootPath, name);
if (!file.toLowerCase().startsWith(path.resolve(sourceRootPath).toLowerCase() + path.sep.toLowerCase())) {
throw new Error(`Water normal path escapes the source root: ${name}`);
}
try {
const bytes = await readFile(file);
const dds = inspectNativeWaterDxt1Dds(bytes);
const { width, height, mipCount, formatFourCC } = dds;
const firstMip = bytes.subarray(dds.mips[0].offset, dds.mips[0].offset + dds.mips[0].byteLength);
const rgba = dxt1ToRgba(firstMip, width, height);
const png = await sharp(rgba, { raw: { width, height, channels: 4 } }).png().toBuffer();
const pngRgba = await sharp(png).raw().toBuffer({ resolveWithObject: true });
if (pngRgba.info.width !== width || pngRgba.info.height !== height || pngRgba.info.channels !== 4
|| !pngRgba.data.equals(rgba)) throw new Error("PNG normal carrier did not preserve decoded DDS RGBA exactly");
const variantName = `rom-water-normal-${descriptor.guid}-${String(frame).padStart(2, "0")}`;
const frameName = `frame-${String(frame).padStart(2, "0")}`;
const ddsArchivePath = path.join(sequenceOutputRoot, `${frameName}.dds`);
const pngCarrierPath = path.join(sequenceOutputRoot, `${frameName}.png`);
await mkdir(sequenceOutputRoot, { recursive: true });
await Promise.all([writeFile(ddsArchivePath, bytes), writeFile(pngCarrierPath, png)]);
const auditCacheAliases = auditCacheRoot ? await writeNativeWaterAuditCacheAliases({
projectRoot,
auditCacheRoot,
variantName,
ddsBytes: bytes,
pngBytes: png,
}) : null;
const [archivedDds, archivedPng] = await Promise.all([readFile(ddsArchivePath), readFile(pngCarrierPath)]);
if (sha256(archivedDds) !== dds.sha256 || sha256(archivedPng) !== sha256(png)) {
throw new Error("Archived source DDS or PNG carrier changed while writing.");
}
frames.push({
frame,
source: name.replaceAll("\\", "/"),
bytes: dds.fileBytes,
width,
height,
mipCount,
formatFourCC,
sha256: dds.sha256,
sourceDdsArchive: path.relative(outputRoot, ddsArchivePath).replaceAll("\\", "/"),
sourceDdsArchiveSha256: sha256(archivedDds),
mips: dds.mips,
exactMipChainConsumption: dds.exactMipChainConsumption,
topMipCompressedSha256: dds.mips[0].sha256,
topMipDecodedRgbaSha256: sha256(rgba),
pngCarrierSha256: sha256(png),
pngCarrierBytes: png.length,
pngCarrierArchive: path.relative(outputRoot, pngCarrierPath).replaceAll("\\", "/"),
pngCarrierArchiveSha256: sha256(archivedPng),
pngRgbaRoundTripByteEqual: true,
...(auditCacheAliases ? { auditCacheAliases } : {}),
variantName,
});
carriers.push({
frame,
variantName,
source: name.replaceAll("\\", "/"),
ddsSha256: dds.sha256,
rgbaSha256: sha256(rgba),
pngSha256: sha256(png),
width,
height,
mips: dds.mips,
ddsArchivePath,
pngCarrierPath,
ddsArchive: path.relative(outputRoot, ddsArchivePath).replaceAll("\\", "/"),
pngCarrierArchive: path.relative(outputRoot, pngCarrierPath).replaceAll("\\", "/"),
auditCacheAliases,
png,
});
} catch (error) {
frames.push({ frame, source: name.replaceAll("\\", "/"), unavailable: error.message });
}
}
const octaveValues = (kind, fallback) => Array.from({ length: 4 }, (_, index) => (
Number(getProperty(descriptor, `waterDesc:full:bump ${kind} ${index}`) ?? fallback[index])
));
return {
report: {
status: frames.length === frameCount && frames.every((frame) => frame.sha256) ? "all-source-frames-hashed-and-decoded" : "sequence-has-unavailable-frames",
samplerRole: "native full-water bump sequence; source shader samples DDS RGB as a procedural normal payload; alpha is unused",
sourceTextureStem: textureName,
frameCount,
frameRate,
bumpFactor: Number(getProperty(descriptor, "waterDesc:common:bump factor") ?? 0.85),
octaveScales: octaveValues("scale", [0.005, 0.01, 0.02, 0.04]),
octaveWeights: octaveValues("weight", [0.5, 0.25, 0.15, 0.1]),
octaveScrolls: Array.from({ length: 4 }, (_, index) => [
Number(getProperty(descriptor, `waterDesc:full:bump x scroll ${index}`) ?? 0),
Number(getProperty(descriptor, `waterDesc:full:bump y scroll ${index}`) ?? 0),
]),
carrierPixelPolicy: "DXT1 top mip decoded with the shared source decoder; PNG decode byte-compared against RGBA output; no sRGB transform or resampling",
frames,
},
carriers,
};
}
function createWaterMaterial(document, variants, descriptor, normalSequence, materialCache, carrierCache, variantCache, sourceFromAsset, sourceWdbSha256) {
const key = descriptor.guid;
let material = materialCache.get(key);
if (!material) {
material = document.createMaterial(`Native water ${key}`)
.setBaseColorFactor([1, 1, 1, 1])
.setMetallicFactor(0)
.setRoughnessFactor(0.38)
.setAlphaMode("BLEND")
.setDoubleSided(true)
.setExtras({ runewakerWater: runtimeWaterMetadata(descriptor, normalSequence, sourceFromAsset, sourceWdbSha256) });
materialCache.set(key, material);
}
const mapping = variants.createMappingList();
for (const frame of normalSequence.carriers) {
let carrier = carrierCache.get(frame.variantName);
if (!carrier) {
const texture = document.createTexture(frame.variantName)
.setImage(frame.png)
.setMimeType("image/png")
.setExtras({
runewakerWaterNormalFrame: {
variantName: frame.variantName,
sourceDDS: frame.source,
sourceDDSSha256: frame.ddsSha256,
decodedRgbaSha256: frame.rgbaSha256,
carrierPngSha256: frame.pngSha256,
sourceDdsArchive: frame.ddsArchive,
pngCarrierArchive: frame.pngCarrierArchive,
auditCacheAliases: frame.auditCacheAliases,
sourceMipChain: frame.mips,
width: frame.width,
height: frame.height,
colorSpace: "no conversion; source shader consumes RGB directly",
},
});
carrier = document.createMaterial(`Runewaker water normal frame ${frame.frame}`)
.setNormalTexture(texture)
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setAlphaMode("OPAQUE")
.setExtras({ runewakerWaterNormalFrameCarrier: { variantName: frame.variantName, frame: frame.frame } });
carrier.getNormalTextureInfo().setWrapS(REPEAT).setWrapT(REPEAT);
carrierCache.set(frame.variantName, carrier);
}
let variant = variantCache.get(frame.variantName);
if (!variant) {
variant = variants.createVariant(frame.variantName);
variantCache.set(frame.variantName, variant);
}
mapping.addMapping(variants.createMapping().setMaterial(carrier).addVariant(variant));
}
return { material, mapping };
}
function addAccessor(document, buffer, name, type, array) {
return document.createAccessor(name, buffer).setType(type).setArray(array);
}
function runtimeWaterMetadata(descriptor, normalSequence, sourceFromAsset, sourceWdbSha256) {
const sequence = normalSequence.report;
return {
version: 1,
frameVariants: normalSequence.carriers.map((frame) => frame.variantName),
frameRate: sequence.frameRate,
bumpFactor: sequence.bumpFactor,
octaveScales: sequence.octaveScales,
octaveWeights: sequence.octaveWeights,
octaveScrolls: sequence.octaveScrolls,
sourceFromAsset,
nativeWaterBodyGuid: descriptor.guid,
nativeDescriptorSha256: descriptor.sha256,
sourceWdbSha256,
effectTypeRaw: descriptor.effectType,
...nativeWaterRenderPolicy(descriptor),
effectTypeCaveat: descriptor.effectType === 4
? "raw value 4 is not declared by the checked-in enum; preserve it raw and do not infer a named effect"
: null,
sourceFramePixelPolicy: sequence.carrierPixelPolicy,
nativeRenderState: {
alphaBlending: true,
alphaTest: false,
doubleSided: true,
vertexStreams: ["POSITION", "DIFFUSE"],
nativeNormalAttribute: false,
nativeTextureCoordinateAttribute: false,
gltfCompatibilityTexcoord0: "source-world XZ carrier for KHR_materials_variants normalTexture validation; runtime shader samples transformed source-world XZ and ignores TEXCOORD_0",
samplerWrap: "repeat",
},
approximation: "Three.js StandardMaterial lighting with the source animated procedural bump sequence. The builder does not implement projected reflection/refraction or source SH lighting/fog; effectType semantics remain source-defined and are not inferred here.",
};
}
function addLayersToDocument(document, scene, mesh, geometryRows, normalSequences, sourceFromAsset, sourceWdbSha256) {
const sharedBuffer = document.createBuffer("Origin source water grids");
const variants = document.createExtension(KHRMaterialsVariants).setRequired(false);
const materialCache = new Map();
const carrierCache = new Map();
const variantCache = new Map();
for (let index = 0; index < geometryRows.length; index += 1) {
const row = geometryRows[index];
const geometry = row.geometry;
const descriptor = row.layer.descriptorJoin.descriptors[0];
const normalSequence = normalSequences.get(descriptor.guid);
const selectedMaterial = createWaterMaterial(document, variants, descriptor, normalSequence, materialCache,
carrierCache, variantCache, sourceFromAsset, sourceWdbSha256);
const primitive = document.createPrimitive()
.setAttribute("POSITION", addAccessor(document, sharedBuffer, `${row.id} positions`, "VEC3", geometry.positions))
.setAttribute("NORMAL", addAccessor(document, sharedBuffer, `${row.id} fixed-up normals`, "VEC3", geometry.normals))
.setAttribute("TEXCOORD_0", addAccessor(document, sharedBuffer, `${row.id} source-world XZ compatibility coordinates`, "VEC2", geometry.sourceWorldXZ))
.setAttribute("COLOR_0", addAccessor(document, sharedBuffer, `${row.id} native vertex diffuse`, "VEC4", geometry.colors))
.setIndices(addAccessor(document, sharedBuffer, `${row.id} source water indices`, "SCALAR", geometry.indices))
.setMaterial(selectedMaterial.material)
.setExtension(KHRMaterialsVariants.EXTENSION_NAME, selectedMaterial.mapping)
.setExtras({
runewakerNativeWaterLayer: {
version: row.layer.version,
waterBodyGuid: row.layer.waterBodyGuid,
sourceChunk: row.chunk,
sourceLayerIndex: row.layerIndex,
sourceSplitPlaneHeight: geometry.summary.sourceSplitPlaneHeight,
sourcePayloadSha256: row.sourcePayloadSha256,
sourceLayerByteRange: row.layer.sourceByteRange,
sourceLayerBytes: row.layer.sourceByteRange.end - row.layer.sourceByteRange.start,
sourceEntityClassGuid: row.layer.entity?.classGuid ?? null,
sourceEntityTransform: row.layer.transform,
sourceHeightParcel: {
origin: row.layer.height.origin,
spacing: row.layer.height.spacing,
overlap: row.layer.height.overlap,
width: row.layer.height.width,
height: row.layer.height.height,
quantizationStep: row.layer.height.quantizationStep,
},
sourceColorParcel: {
origin: row.layer.color.origin,
spacing: row.layer.color.spacing,
overlap: row.layer.color.overlap,
width: row.layer.color.width,
height: row.layer.color.height,
},
sourceFlagsParcel: {
origin: row.layer.flags.origin,
spacing: row.layer.flags.spacing,
overlap: row.layer.flags.overlap,
width: row.layer.flags.width,
height: row.layer.flags.height,
},
topology: geometry.summary,
},
});
mesh.addPrimitive(primitive);
}
scene.addChild(document.createNode("Native water surfaces").setMesh(mesh));
}
async function writeWaterGlb(file, geometryRows, normalSequences, title, sourceFromAsset, sourceWdbSha256) {
const document = new Document();
const scene = document.createScene(title);
const mesh = document.createMesh(title);
addLayersToDocument(document, scene, mesh, geometryRows, normalSequences, sourceFromAsset, sourceWdbSha256);
document.getRoot().setExtras({
runewakerWaterReview: {
version: 1,
title,
geometryPolicy: "native height samples, source transform, native pair topology and visibility flags; vertex color RGB from source parcel and opacity from flags low six bits; TEXCOORD_0 carries source-world XZ for glTF normalTexture validation only",
coordinateBridge: "same sourceTerrainToThreeMatrix as native terrain: inverse primary WDB TRS then reflected-Z meter scaling; each water entity TRS is applied before bridge",
sourceNormalPolicy: "native water has no NORMAL stream; fixed world-up vector from source water shader is carried as NORMAL only for static viewport preview",
knownApproximation: "MeshStandardMaterial with native alpha and animated source bump sequence; projected screen reflection/refraction, fog, dynamic native SH lighting and raw effectType=4 semantics are not emulated",
waterBodyDescriptorGuids: [...new Set(geometryRows.map((row) => row.layer.waterBodyGuid))],
nativeWaterLayerCount: geometryRows.length,
},
});
document.getRoot().setDefaultScene(scene);
await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(file, document);
const bytes = await readFile(file);
const jsonLength = bytes.readUInt32LE(12);
const gltf = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString("utf8"));
const expectedVariantNames = new Set(geometryRows.flatMap((row) => {
const guid = row.layer.waterBodyGuid;
return normalSequences.get(guid).carriers.map((frame) => frame.variantName);
}));
const actualVariants = gltf.extensions?.KHR_materials_variants?.variants ?? [];
const actualVariantNames = new Set(actualVariants.map((variant) => variant.name));
const carrierMaterials = (gltf.materials ?? []).filter((material) => material.extras?.runewakerWaterNormalFrameCarrier);
const waterMaterials = (gltf.materials ?? []).filter((material) => material.extras?.runewakerWater);
const primitiveMappings = (gltf.meshes ?? []).flatMap((entry) => entry.primitives ?? [])
.map((primitive) => primitive.extensions?.KHR_materials_variants?.mappings ?? []);
const carrierMaterialByName = new Map(carrierMaterials.map((material) => [
material.extras.runewakerWaterNormalFrameCarrier.variantName,
material,
]));
const expectedNamesMappedOnEveryPrimitive = primitiveMappings.every((mappings) => {
const mappedNames = new Set(mappings.flatMap((mapping) => mapping.variants.map((index) => actualVariants[index]?.name)));
return mappedNames.size === expectedVariantNames.size && [...expectedVariantNames].every((name) => mappedNames.has(name));
});
const carriersMatchSourceFrames = [...expectedVariantNames].every((name) => {
const material = carrierMaterialByName.get(name);
if (!material?.normalTexture) return false;
const texture = gltf.textures?.[material.normalTexture.index];
const image = texture?.source === undefined ? null : gltf.images?.[texture.source];
const sampler = texture?.sampler === undefined ? null : gltf.samplers?.[texture.sampler];
const imageExtras = image?.extras?.runewakerWaterNormalFrame;
return image && imageExtras?.variantName === name
&& imageExtras.sourceDDSSha256 && imageExtras.decodedRgbaSha256 && imageExtras.carrierPngSha256
&& (!sampler || (sampler.wrapS === REPEAT && sampler.wrapT === REPEAT));
});
if (actualVariants.length !== expectedVariantNames.size || actualVariantNames.size !== expectedVariantNames.size
|| [...expectedVariantNames].some((name) => !actualVariantNames.has(name))
|| carrierMaterials.length !== expectedVariantNames.size
|| waterMaterials.length !== new Set(geometryRows.map((row) => row.layer.waterBodyGuid)).size
|| primitiveMappings.length !== new Set(geometryRows.map((row) => row.id)).size
|| primitiveMappings.some((mappings) => mappings.length !== expectedVariantNames.size)
|| !expectedNamesMappedOnEveryPrimitive || !carriersMatchSourceFrames) {
throw new Error(`Staged water GLB carrier validation failed for ${file}.`);
}
return {
path: path.relative(projectRoot, file).replaceAll("\\", "/"),
bytes: bytes.length,
sha256: sha256(bytes),
carrierValidation: {
waterMaterials: waterMaterials.length,
frameVariants: actualVariants.length,
carrierMaterialsWithRepeatSampler: carrierMaterials.length,
everyPrimitiveMapsEveryFrame: expectedNamesMappedOnEveryPrimitive,
sourceFrameHashesPresent: carriersMatchSourceFrames,
},
};
}
async function main() {
const options = parseNativeWaterBuildArgs(process.argv.slice(2));
if (options.help) {
console.log(USAGE);
return;
}
const recipeFile = resolveFromProject(options.recipe ?? "scripts/runewaker-pipeline/recipes/the-origin.json");
const recipeBytes = await readFile(recipeFile);
const recipe = JSON.parse(recipeBytes.toString("utf8"));
const recipeSlug = recipe.slug;
if (typeof recipeSlug !== "string" || !/^[a-z0-9-]+$/.test(recipeSlug)) {
throw new Error("Recipe slug must be a lowercase dungeon id.");
}
if (recipe.source?.nativeTerrain?.kind !== "runewaker-wdb-terrain") {
throw new Error("Recipe source.nativeTerrain.kind must be 'runewaker-wdb-terrain'.");
}
const configuredSourceRoot = process.env[recipe.source.rootEnvironmentVariable];
const sourceRoot = path.resolve(options["source-root"]
?? configuredSourceRoot
?? path.resolve(projectRoot, recipe.source.relativeDefault ?? "../rom-pvt-server-backups/Runewaker"));
const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot ?? "Resource");
const wdbPath = path.resolve(sourceRoot, recipe.source.wdb);
const wdbReportPath = resolveFromProject(options["wdb-report"]
?? `src/assets/game/dungeons/${recipeSlug}/wdb-report.json`);
const outputRoot = resolveFromProject(options["output-root"]
?? path.join("runewaker-export-work", recipeSlug, "native-water-source"));
ensureWithinProject(projectRoot, outputRoot);
const auditCacheRoot = options["audit-cache-root"]
? ensureWithinProject(projectRoot, resolveFromProject(options["audit-cache-root"]))
: null;
const groundManifestPath = options["ground-manifest"] ? resolveFromProject(options["ground-manifest"]) : null;
const [wdb, wdbReportBytes, groundManifestBytes] = await Promise.all([
readFile(wdbPath),
readFile(wdbReportPath),
groundManifestPath ? readFile(groundManifestPath) : Promise.resolve(null),
]);
const wdbReport = JSON.parse(wdbReportBytes.toString("utf8"));
const groundManifest = groundManifestBytes ? JSON.parse(groundManifestBytes.toString("utf8")) : null;
const sourceWdbSha256 = sha256(wdb);
const datastore = readDataStore(wdb);
const chunkTable = datastore.tables.get("chunks");
const descriptorTable = datastore.tables.get("water_descriptor");
if (!chunkTable || !descriptorTable) throw new Error("Source WDB is missing chunks or water_descriptor table.");
const descriptorInputs = [...descriptorTable.records.entries()]
.map(([key, record]) => ({ key, payload: Buffer.from(recordBytes(wdb, record)) }));
const primaryPlacement = selectPrimaryDescriptor(wdbReport, recipe);
const metersPerSourceUnit = Number(recipe.coordinateSystem?.metersPerSourceUnit);
if (!Number.isFinite(metersPerSourceUnit) || metersPerSourceUnit <= 0) {
throw new Error("Recipe coordinateSystem.metersPerSourceUnit must be a positive number.");
}
const allChunkRows = [...chunkTable.records.entries()]
.map(([key, record]) => ({ key: Number(key) >>> 0, record }))
.sort((a, b) => a.key - b.key);
const chunkSelection = selectNativeWaterChunkKeys({
wdbChunkKeys: allChunkRows.map((row) => row.key),
groundManifest,
sourceWdbSha256,
});
const selectedKeySet = new Set(chunkSelection.selectedKeys);
const chunkRows = allChunkRows.filter(({ key }) => selectedKeySet.has(key));
const waterChunkSummaries = [];
const decodedRows = [];
const versionCounts = new Map();
for (const { key, record } of chunkRows) {
const x = key >>> 16;
const z = key & 0xffff;
const compressed = recordBytes(wdb, record);
const chunk = parseTerrainChunk(compressed, `[${x},${z}]`, { allowUnparsedWaterPayload: true });
if (chunk.waterLayerCount === 0) continue;
const decompressed = inflateSync(compressed, { finishFlush: zlibConstants.Z_SYNC_FLUSH });
const prefixBytes = decompressed.length - chunk.unconsumedBytes;
if (prefixBytes < 0 || prefixBytes > decompressed.length) {
throw new Error(`Chunk [${x},${z}] reports an invalid water offset.`);
}
const waterPayload = Buffer.from(decompressed.subarray(prefixBytes));
const decoded = decodeNativeWaterPayload(waterPayload, {
layerCount: chunk.waterLayerCount,
descriptors: descriptorInputs,
});
if (decoded.consumedBytes !== decoded.sourcePayloadBytes || decoded.trailingBytes !== 0) {
throw new Error(`Chunk [${x},${z}] water payload was not consumed exactly.`);
}
const chunkSummary = {
coordinate: { x, z },
key: `[${x},${z}]`,
keyUint32: key,
compressedBytes: compressed.length,
compressedSha256: sha256(compressed),
decompressedBytes: decompressed.length,
waterPayloadOffset: prefixBytes,
waterPayloadBytes: waterPayload.length,
waterPayloadSha256: decoded.sourcePayloadSha256,
layerCount: decoded.layerCount,
layerGuids: decoded.layers.map((layer) => layer.waterBodyGuid),
descriptorJoinStatuses: decoded.layers.map((layer) => layer.descriptorJoin.status),
layerVersions: decoded.layers.map((layer) => layer.version),
exactConsumption: {
consumed: decoded.consumedBytes,
total: decoded.sourcePayloadBytes,
trailing: decoded.trailingBytes,
},
};
waterChunkSummaries.push(chunkSummary);
for (let layerIndex = 0; layerIndex < decoded.layers.length; layerIndex += 1) {
const layer = decoded.layers[layerIndex];
versionCounts.set(layer.version, (versionCounts.get(layer.version) ?? 0) + 1);
decodedRows.push({
id: `${recipeSlug}-water-${x}-${z}-${layerIndex}`,
chunk: { x, z },
layerIndex,
sourcePayloadSha256: decoded.sourcePayloadSha256,
layer,
});
}
}
if (waterChunkSummaries.length === 0) throw new Error("Selected source WDB chunks contain no native water layers.");
for (const row of decodedRows) {
if (row.layer.descriptorJoin.status !== "matched") {
throw new Error(`${row.layer.waterBodyGuid} in chunk [${row.chunk.x},${row.chunk.z}] has ${row.layer.descriptorJoin.status} descriptor join.`);
}
}
const geometry = buildNativeWaterGeometry(decodedRows.map((row) => row.layer), primaryPlacement, metersPerSourceUnit);
if (geometry.skipped.length > 0) {
throw new Error(`Native water build skipped ${geometry.skipped.length} layer(s): ${JSON.stringify(geometry.skipped.slice(0, 3))}`);
}
const geometryRows = decodedRows.map((row, index) => ({ ...row, geometry: geometry.layers[index] }));
const descriptorByGuid = new Map();
for (const row of decodedRows) descriptorByGuid.set(row.layer.waterBodyGuid, row.layer.descriptorJoin.descriptors[0]);
const normalSequences = new Map();
for (const [guid, descriptor] of descriptorByGuid) {
normalSequences.set(guid, await inspectNormalSequence(descriptor, resourceRoot, outputRoot, auditCacheRoot));
}
for (const [guid, sequence] of normalSequences) {
if (sequence.report.status !== "all-source-frames-hashed-and-decoded"
|| !Number.isFinite(sequence.report.frameRate) || sequence.report.frameRate <= 0
|| sequence.carriers.length !== sequence.report.frameCount) {
throw new Error(`Native water frame sequence for ${guid} is incomplete; refusing a partial animation carrier.`);
}
}
await mkdir(outputRoot, { recursive: true });
const sourceFromAsset = sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit).invert().toArray();
const destinationWater = path.join(outputRoot, "native-water-visual.glb");
const waterGlb = await writeWaterGlb(destinationWater, geometryRows, normalSequences,
`${recipe.title ?? recipeSlug} native water · source normal-frame approximation`, sourceFromAsset, sourceWdbSha256);
const frames = [...normalSequences.values()].flatMap((sequence) => sequence.report.frames);
const auditCacheAliases = frames.flatMap((frame) => frame.auditCacheAliases ? [frame.auditCacheAliases] : []);
const effectTypes = [...new Set(decodedRows.map((row) => row.layer.descriptorJoin.descriptors[0].effectType))].sort((a, b) => a - b);
const sourceSelection = {
kind: chunkSelection.selection.kind,
chunkCount: chunkRows.length,
coordinates: chunkRows.map(({ key }) => ({ x: key >>> 16, z: key & 0xffff })),
...(groundManifestPath ? {
groundManifest: path.relative(projectRoot, groundManifestPath).replaceAll("\\", "/"),
groundManifestSha256: sha256(groundManifestBytes),
groundManifestSourceWdbSha256: groundManifest.source?.wdbSha256 ?? groundManifest.sourceWdbSha256,
} : {}),
};
const report = {
schemaVersion: 2,
tool: "scripts/runewaker-pipeline/build-native-water.mjs",
deterministic: true,
source: {
recipe: path.relative(projectRoot, recipeFile).replaceAll("\\", "/"),
recipeSha256: sha256(recipeBytes),
sourceRoot: path.relative(projectRoot, sourceRoot).replaceAll("\\", "/"),
resourceRoot: path.relative(sourceRoot, resourceRoot).replaceAll("\\", "/"),
wdb: path.relative(sourceRoot, wdbPath).replaceAll("\\", "/"),
wdbSha256: sourceWdbSha256,
wdbReport: path.relative(projectRoot, wdbReportPath).replaceAll("\\", "/"),
wdbReportSha256: sha256(wdbReportBytes),
wdbReportContainsSourceWdbHash: Boolean(wdbReport.sourceWdbSha256 ?? wdbReport.source?.wdbSha256),
chunkTableCount: chunkTable.count,
waterDescriptorTableCount: descriptorTable.count,
selectedPrimaryPlacement: {
guid: primaryPlacement.guid,
resource: primaryPlacement.resource,
scale: primaryPlacement.scale,
rotation: primaryPlacement.rotation,
translation: primaryPlacement.translation,
selectionTranslationFromRecipe: recipe.source.primaryPlacementTranslation,
},
metersPerSourceUnit,
sourceFromAsset,
},
chunkSelection: sourceSelection,
payloadDecode: {
chunksInWdb: allChunkRows.length,
chunksSelected: chunkRows.length,
chunksWithWater: waterChunkSummaries.length,
serializedLayerCount: decodedRows.length,
layerVersionCounts: Object.fromEntries([...versionCounts].sort(([a], [b]) => a - b)),
chunkWaterPayloadsExactlyConsumed: waterChunkSummaries.every((row) => (
row.exactConsumption.consumed === row.exactConsumption.total && row.exactConsumption.trailing === 0
)),
allBodyGuidsUniquelyJoined: decodedRows.every((row) => row.layer.descriptorJoin.status === "matched"),
nativeWaterChunks: waterChunkSummaries,
},
geometry: geometry.report,
normalSequences: [...normalSequences.values()].map(({ report: sequenceReport }) => sequenceReport),
outputs: {
waterGlb,
normalFrameArchiveRoot: path.relative(projectRoot, path.join(outputRoot, "normal-frames")).replaceAll("\\", "/"),
rawSourceDdsFrameCount: frames.length,
archivedPngCarrierCount: frames.filter((frame) => frame.pngCarrierArchive).length,
...(auditCacheRoot ? {
auditCacheRoot: path.relative(projectRoot, auditCacheRoot).replaceAll("\\", "/"),
auditCacheAliasCount: auditCacheAliases.length,
auditCacheAliases,
} : {}),
nativeMipCountByFrame: frames.map((frame) => ({
source: frame.source,
width: frame.width,
height: frame.height,
mipCount: frame.mipCount,
exactMipChainConsumption: frame.exactMipChainConsumption,
})),
},
sourceEngineReferences: [
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Scene/Terrain/RuEntityTerrain.cpp: water-layer serialization and descriptor GUID lookup",
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Scene/Terrain/RuWorld_Water.cpp: water versions 1–3 and legacy parcel-origin migration",
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Mojo/RuMojo_Base.h: parcel byte layout and quantized height deserialization",
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Mojo/RuMojo_Helper.cpp: native vertex diffuse generation, 0x80 two-cell-pair mask, even/odd topology",
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SM20EZ/RuShader_SM20EZ_Water.cpp: fixed world-up SH normal, diffuse modulation, world-XZ animated bump UVs and projected screen reflection/refraction",
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Scene/Terrain/RuWorld_WaterBodyDescriptor.cpp: serialized descriptor effect and material branch behavior",
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Renderer/Plugin/RuRendererPlugin_AquaFX.cpp: split-plane camera reflection and pre-water scene refraction",
],
nativeMaterialSemantics: {
renderState: "water material is two-sided, alpha blending enabled, alpha test disabled",
diffuseAndLighting: "POSITION|DIFFUSE vertex stream only; source RGB modulates native ambient/spherical-harmonic lighting and global diffuse; shader uses constant world-up normal",
opacity: "native vertex alpha is trunc((waterFlags & 0x3f) / 63 * 255) / 255; bit 0x80 separately controls visibility of each two-cell quad pair",
bump: "sample one animated normal-frame DDS four times at nativeWorldXZ * octaveScale + octaveScroll * elapsedSeconds; combine as sum(sample.rgb * octaveWeight) - 0.5, normalize, swap Y/Z, then lerp toward [0,1,0] by bumpFactor; no mesh UV stream",
gltfCompatibilityCoordinates: "TEXCOORD_0 is derived from source-world XZ to satisfy KHR_materials_variants normalTexture validation; the runtime shader does not use mesh UVs",
bumpSamplingMetadata: {
actualOriginScales: [...normalSequences.values()][0]?.report.octaveScales,
actualOriginWeights: [...normalSequences.values()][0]?.report.octaveWeights,
nativeShaderWeightBytes: [...normalSequences.values()][0]?.report.octaveWeights.map((weight) => Math.trunc(weight * 255) & 0xff),
nativeShaderWeights: [...normalSequences.values()][0]?.report.octaveWeights.map((weight) => (Math.trunc(weight * 255) & 0xff) / 255),
actualOriginScrolls: [...normalSequences.values()][0]?.report.octaveScrolls,
sourceFrameCount: [...normalSequences.values()][0]?.report.frameCount,
sourceFrameRate: [...normalSequences.values()][0]?.report.frameRate,
sourceFrameDimensions: [...new Set(frames.map((frame) => `${frame.width}x${frame.height}`))],
sourceMipCounts: [...new Set(frames.map((frame) => frame.mipCount))],
carrierTextureColorSpace: "linear data/no color-space conversion",
carrierPixels: "source DXT1 top mip decoded to RGBA and PNG round-trip byte-equal; native shader samples RGB only",
},
effectType: {
rawValue: effectTypes,
caveat: "Raw descriptor values are preserved. Effect type 4 is not declared in the checked-in RuWaterEffectType enum; its intended named semantics remain unknown and no behavior is inferred from that integer.",
},
viewportMaterial: "Two-sided BLEND material with native COLOR_0 and animated procedural bump sequence. The runtime uses source-enabled projected reflection/refraction, native .025 UV perturbation and Fresnel .02037/.97963 coefficients with Three.js lighting. Native SH and reflect-class filtering are not reproduced.",
},
limitations: [
"The output contains native water geometry only; the source-ground GLB, terrain collision, and terrain accessors are not modified or embedded.",
"The implementation preserves each water layer's own serialized TRS and source topology, but does not stitch adjacent water grids across WDB chunk boundaries.",
"Raw effectType values remain in the payload and report. In particular, effectType 4 is not assigned a guessed named meaning.",
"Browser water adapts the source full-enable material branch, split planes, bump perturbation and Fresnel reflection/refraction blend. It uses up to four 512px plane pairs per asset and at most ten two-pass captures per second. Native SH and reflect-class filtering remain unsupported; additional planes and below-plane views keep the lit animated fallback.",
"All source DDS bytes and mip-chain hashes are archived. GLB/runtime frame carriers contain exact decoded top-mip RGBA in lossless PNG form; the runtime GPU generates its own mip levels rather than consuming the archived compressed mip chain.",
"The placement report is pinned by SHA-256, but currently does not contain an authoritative source-WDB hash. The recipe translation selects the primary descriptor; per-layer water body GUIDs are joined directly against descriptors decoded from the hashed WDB.",
],
};
const reportFile = path.join(outputRoot, "conversion-report.json");
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
report: path.relative(projectRoot, reportFile).replaceAll("\\", "/"),
chunksSelected: report.payloadDecode.chunksSelected,
chunksWithWater: report.payloadDecode.chunksWithWater,
layers: report.payloadDecode.serializedLayerCount,
layerVersionCounts: report.payloadDecode.layerVersionCounts,
descriptorJoinsExact: report.payloadDecode.allBodyGuidsUniquelyJoined,
allLayersBuilt: report.geometry.builtLayerCount,
triangles: report.geometry.layers.reduce((sum, row) => sum + row.emittedTriangles, 0),
waterGlb,
sourceDdsFrames: frames.length,
reportSha256: sha256(Buffer.from(`${JSON.stringify(report, null, 2)}\n`)),
}, null, 2));
}
main().catch((error) => {
console.error(error?.stack ?? error);
process.exitCode = 1;
});
@@ -0,0 +1,68 @@
param(
[string]$RunewakerSourceRoot = $env:RUNEWAKER_SOURCE_ROOT,
[string]$PortableMsvcRoot = $env:MSVC_V142_ROOT,
[string]$WindowsSdkRoot = ${env:ProgramFiles(x86)} + '\Windows Kits\10'
)
$ErrorActionPreference = 'Stop'
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
if ([string]::IsNullOrWhiteSpace($RunewakerSourceRoot)) {
$RunewakerSourceRoot = [IO.Path]::GetFullPath((Join-Path $projectRoot '..\rom-pvt-server-backups\Runewaker\SourceCodeVS2019'))
}
if ([string]::IsNullOrWhiteSpace($PortableMsvcRoot)) {
$PortableMsvcRoot = Join-Path $env:USERPROFILE 'msvc-v142-portable\Contents\VC\Tools\MSVC'
}
if (-not (Test-Path -LiteralPath $RunewakerSourceRoot)) { throw "Runewaker source root not found: $RunewakerSourceRoot" }
if (-not (Test-Path -LiteralPath $PortableMsvcRoot)) { throw "Portable MSVC root not found: $PortableMsvcRoot" }
if (-not (Test-Path -LiteralPath $WindowsSdkRoot)) { throw "Windows SDK root not found: $WindowsSdkRoot" }
$msvcVersionRoot = Get-ChildItem -LiteralPath $PortableMsvcRoot -Directory |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'bin\Hostx64\x64\cl.exe') } |
Sort-Object Name -Descending | Select-Object -First 1
$sdkVersionRoot = Get-ChildItem -LiteralPath (Join-Path $WindowsSdkRoot 'Include') -Directory |
Where-Object {
(Test-Path -LiteralPath (Join-Path $_.FullName 'um\Windows.h')) -and
(Test-Path -LiteralPath (Join-Path $WindowsSdkRoot ('Lib\' + $_.Name + '\um\x64\kernel32.lib')))
} | Sort-Object Name -Descending | Select-Object -First 1
if ($null -eq $msvcVersionRoot) { throw 'cl.exe was not found.' }
if ($null -eq $sdkVersionRoot) { throw 'No usable Windows SDK was found.' }
$msvcRoot = $msvcVersionRoot.FullName
$sdkVersion = $sdkVersionRoot.Name
$sdkIncludeRoot = Join-Path $WindowsSdkRoot ('Include\' + $sdkVersion)
$sdkLibRoot = Join-Path $WindowsSdkRoot ('Lib\' + $sdkVersion)
$compilerRoot = Join-Path $msvcRoot 'bin\Hostx64\x64'
$env:Path = $compilerRoot + ';' + (Join-Path $WindowsSdkRoot ('bin\' + $sdkVersion + '\x64')) + ';' + $env:Path
$env:Include = @((Join-Path $msvcRoot 'include'), (Join-Path $sdkIncludeRoot 'ucrt'), (Join-Path $sdkIncludeRoot 'shared'), (Join-Path $sdkIncludeRoot 'um'), (Join-Path $sdkIncludeRoot 'winrt')) -join ';'
$env:Lib = @((Join-Path $msvcRoot 'lib\x64'), (Join-Path $sdkLibRoot 'ucrt\x64'), (Join-Path $sdkLibRoot 'um\x64')) -join ';'
$outRoot = Join-Path $projectRoot 'runewaker-export-work\native-lighting-source\bin'
New-Item -ItemType Directory -Path $outRoot -Force | Out-Null
$librariesRoot = Join-Path $RunewakerSourceRoot 'Libraries'
$engineRoot = Join-Path $librariesRoot 'Rune Engine'
$compileArguments = @(
'/nologo', '/EHsc', '/O2', '/MD', '/std:c++17', '/DNDEBUG', '/D_LIB', '/DCLOSE_DEBUG', '/DWINDOWS_IGNORE_PACKING_MISMATCH',
'/I', $librariesRoot,
'/I', (Join-Path $RunewakerSourceRoot 'SDK'),
'/I', (Join-Path $RunewakerSourceRoot 'SDK\dx9'),
'/I', (Join-Path $RunewakerSourceRoot 'SDK\fmod'),
'/I', (Join-Path $RunewakerSourceRoot 'SDK\lua'),
'/I', (Join-Path $engineRoot 'Tools'),
'/I', (Join-Path $librariesRoot 'Tools\tools'),
'/I', (Join-Path $librariesRoot 'Tools\tools\lua'),
'/I', (Join-Path $librariesRoot 'Tools\tools\luabind'),
('/Fo' + (Join-Path $outRoot 'runewaker-zone-lighting-exporter.obj')),
('/Fe' + (Join-Path $outRoot 'runewaker-zone-lighting-exporter.exe')),
(Join-Path $PSScriptRoot 'native\runewaker_zone_lighting_exporter.cpp'),
'/link', '/LTCG',
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot '_bin\Rune Engine\x64\Release')),
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot '_bin\Rune Engine NULL\x64\Release')),
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot '_bin\ToolsLib\Release')),
('/LIBPATH:' + (Join-Path $RunewakerSourceRoot 'SDK\lib')),
('/LIBPATH:' + (Join-Path $engineRoot 'Tools\granny\lib')),
'Rune.lib', 'Rune_NULL.lib', 'ToolsLib.lib', 'kernel32.lib', 'user32.lib', 'advapi32.lib', 'shell32.lib', 'ole32.lib', 'oleaut32.lib', 'uuid.lib', 'winmm.lib', 'ws2_32.lib'
)
& (Join-Path $compilerRoot 'cl.exe') $compileArguments
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Built $(Join-Path $outRoot 'runewaker-zone-lighting-exporter.exe')"
@@ -0,0 +1,597 @@
import { spawn } from "node:child_process";
import {
access,
copyFile,
mkdir,
readFile,
readdir,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const genericCompressor = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs");
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
const REVIEWED_TEXTURE_NAMES = [
"obj_forge01_lava",
"lava",
"magma_01",
"magic_magma",
"magic_magma_02",
"floor_m_02",
"lymor grave_relief_01",
"treasure",
"wretched_tunnel",
"rom-barren-wretched-tunnel-u0-v0",
"rom-barren-wretched-tunnel-u3-v0",
"rom-barren-wretched-tunnel-u0-v1",
"rom-barren-wretched-tunnel-u3-v1",
"rom-barren-wretched-tunnel-wall-detail-u1-v0",
"echoes_of_the_sea_01",
"echoes_of_the_sea_02",
"echoes_of_the_sea_03",
"echoes_of_the_sea_04",
"elves_vine",
];
const REVIEWED_TEXTURE_NAME_SET = new Set(REVIEWED_TEXTURE_NAMES.map((name) => name.toLowerCase()));
const TEMP_TARGET_NAME_PREFIX = "__rom_high_quality__";
const TEMP_TARGET_NAME_SUFFIX = "__";
const REVIEWED_TEXTURE_PATTERN = `${TEMP_TARGET_NAME_PREFIX}{${REVIEWED_TEXTURE_NAMES.join(",")}}${TEMP_TARGET_NAME_SUFFIX}`;
function argumentValue(args, name, fallback) {
const index = args.indexOf(name);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
}
function positionalArguments(args) {
const valueOptions = new Set(["--concurrency", "--jobs", "--report"]);
const result = [];
for (let index = 0; index < args.length; index += 1) {
if (valueOptions.has(args[index])) {
index += 1;
continue;
}
if (!args[index].startsWith("--")) result.push(args[index]);
}
return result;
}
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function collectGlbs(target) {
const resolved = path.resolve(projectRoot, target);
const details = await stat(resolved);
if (details.isFile()) {
if (!resolved.toLowerCase().endsWith(".glb")) {
throw new Error(`Expected a .glb input, received ${path.relative(projectRoot, resolved)}.`);
}
return [resolved];
}
const files = [];
for (const entry of await readdir(resolved, { withFileTypes: true })) {
const child = path.join(resolved, entry.name);
if (entry.isDirectory()) files.push(...await collectGlbs(child));
else if (entry.isFile() && !entry.name.startsWith(".") && entry.name.toLowerCase().endsWith(".glb")) {
files.push(child);
}
}
return files;
}
function parseGlb(buffer, label) {
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
throw new Error(`${label} is not a binary glTF file.`);
}
const chunks = [];
let json;
let jsonChunkIndex = -1;
let binaryChunk;
let offset = 12;
while (offset + 8 <= buffer.length) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
if (dataEnd > buffer.length) throw new Error(`${label} contains a truncated GLB chunk.`);
const data = buffer.subarray(dataStart, dataEnd);
chunks.push({ type, data });
if (type === 0x4e4f534a) {
jsonChunkIndex = chunks.length - 1;
json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
}
if (type === 0x004e4942) binaryChunk = data;
offset = dataEnd;
}
if (!json || !binaryChunk) throw new Error(`${label} must contain JSON and BIN chunks.`);
return { json, chunks, jsonChunkIndex, binaryChunk };
}
function serializeGlb(source, json, chunks, jsonChunkIndex) {
const encodedJson = Buffer.from(JSON.stringify(json));
const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20);
encodedJson.copy(paddedJson);
const updatedChunks = chunks.map((chunk, index) => (
index === jsonChunkIndex ? { ...chunk, data: paddedJson } : chunk
));
const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0);
const output = Buffer.alloc(totalLength);
source.copy(output, 0, 0, 12);
output.writeUInt32LE(totalLength, 8);
let offset = 12;
for (const chunk of updatedChunks) {
output.writeUInt32LE(chunk.data.length, offset);
output.writeUInt32LE(chunk.type, offset + 4);
chunk.data.copy(output, offset + 8);
offset += 8 + chunk.data.length;
}
return output;
}
function patchGlbJson(source, transform) {
const parsed = parseGlb(source, "temporary GLB");
transform(parsed.json);
return serializeGlb(source, parsed.json, parsed.chunks, parsed.jsonChunkIndex);
}
function basenameWithoutExtension(value) {
const basename = path.posix.basename(String(value ?? "").replaceAll("\\", "/"));
return basename.replace(/\.[^.]+$/u, "");
}
function canonicalTextureName(value) {
return basenameWithoutExtension(value).replace(/^[0-9a-f]{8}_/iu, "");
}
function imageIndexForTexture(texture) {
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
}
function imageBytes(parsed, image, label) {
if (image.bufferView === undefined) {
throw new Error(`${label} has an external or missing image buffer; embedded GLB images are required.`);
}
const view = parsed.json.bufferViews?.[image.bufferView];
if (!view) throw new Error(`${label} references missing buffer view ${image.bufferView}.`);
const start = (view.byteOffset ?? 0);
return parsed.binaryChunk.subarray(start, start + view.byteLength);
}
function textureSlotUsage(json) {
const baseColor = new Set();
for (const material of json.materials ?? []) {
const index = material.pbrMetallicRoughness?.baseColorTexture?.index;
if (Number.isInteger(index)) baseColor.add(index);
}
return baseColor;
}
function inspectKtx2(bytes, label) {
if (bytes.length < 48 || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) {
throw new Error(`${label} declares image/ktx2 but has an invalid KTX2 header.`);
}
const vkFormat = bytes.readUInt32LE(12);
const supercompressionScheme = bytes.readUInt32LE(44);
return {
width: bytes.readUInt32LE(20),
height: bytes.readUInt32LE(24),
mipLevels: Math.max(1, bytes.readUInt32LE(40)),
vkFormat,
supercompressionScheme,
codec: supercompressionScheme === 1 ? "ETC1S" : vkFormat === 0 ? "UASTC" : "unknown",
};
}
async function imageDimensions(parsed, image, label) {
const bytes = imageBytes(parsed, image, label);
if (image.mimeType === "image/ktx2") return inspectKtx2(bytes, label);
const metadata = await sharp(bytes).metadata();
if (!metadata.width || !metadata.height) throw new Error(`${label} has no readable dimensions.`);
return { width: metadata.width, height: metadata.height, mipLevels: 0, codec: "source" };
}
async function textureInventory(buffer, label) {
const parsed = parseGlb(buffer, label);
const baseColorTextures = textureSlotUsage(parsed.json);
const textures = [];
for (const [textureIndex, texture] of (parsed.json.textures ?? []).entries()) {
const imageIndex = imageIndexForTexture(texture);
const image = parsed.json.images?.[imageIndex];
if (!image) throw new Error(`${label} texture ${textureIndex} has no source image.`);
const imageName = canonicalTextureName(image.name ?? image.uri);
const bytes = imageBytes(parsed, image, `${label} image ${image.name ?? imageIndex}`);
const dimensions = image.mimeType === "image/ktx2"
? inspectKtx2(bytes, `${label} image ${image.name ?? imageIndex}`)
: await imageDimensions(parsed, image, `${label} image ${image.name ?? imageIndex}`);
textures.push({
textureIndex,
imageIndex,
name: imageName,
imageName: image.name ?? image.uri ?? `image-${imageIndex}`,
mimeType: image.mimeType,
isBaseColor: baseColorTextures.has(textureIndex),
dimensions,
alphaModes: (parsed.json.materials ?? [])
.filter((material) => material.pbrMetallicRoughness?.baseColorTexture?.index === textureIndex)
.map((material) => material.alphaMode ?? "OPAQUE"),
});
}
return { parsed, textures };
}
function normalizeTargetTextureNames(sourceBuffer, label) {
const parsed = parseGlb(sourceBuffer, label);
const baseColorTextures = textureSlotUsage(parsed.json);
// Decide from immutable image names before touching aliases. Several
// textures can reference one image while carrying independent samplers;
// mutating the image name on the first alias must not make the next alias
// look like a non-target.
const originalImageNames = (parsed.json.images ?? []).map((image) => canonicalTextureName(image.name ?? image.uri));
const baseColorImageIndexes = new Set();
for (const textureIndex of baseColorTextures) {
const imageIndex = imageIndexForTexture(parsed.json.textures?.[textureIndex]);
if (Number.isInteger(imageIndex)) baseColorImageIndexes.add(imageIndex);
}
const selectedImageNames = new Map();
for (const imageIndex of baseColorImageIndexes) {
const name = originalImageNames[imageIndex];
if (name && REVIEWED_TEXTURE_NAME_SET.has(name.toLowerCase())) selectedImageNames.set(imageIndex, name);
}
const selected = [];
for (const textureIndex of baseColorTextures) {
const texture = parsed.json.textures?.[textureIndex];
if (!texture) continue;
const imageIndex = imageIndexForTexture(texture);
const image = parsed.json.images?.[imageIndex];
const normalized = originalImageNames[imageIndex];
if (!image || !normalized) continue;
if (selectedImageNames.has(imageIndex)) {
const temporaryName = `${TEMP_TARGET_NAME_PREFIX}${normalized}${TEMP_TARGET_NAME_SUFFIX}`;
texture.name = temporaryName;
selected.push({ textureIndex, imageIndex, name: normalized, mimeType: image.mimeType });
} else {
// The CLI matcher uses micromatch's `contains: true` option, so bare
// names like "lava" would also match "lava_roughness". Keep non-target
// base-color names out of the reviewed-name matcher for this one pass.
const temporaryName = `__rom_quality_exclude_${textureIndex}__`;
texture.name = temporaryName;
}
}
for (const imageIndex of baseColorImageIndexes) {
const image = parsed.json.images?.[imageIndex];
if (!image) continue;
const targetName = selectedImageNames.get(imageIndex);
image.name = targetName
? `${TEMP_TARGET_NAME_PREFIX}${targetName}${TEMP_TARGET_NAME_SUFFIX}`
: `__rom_quality_exclude_image_${imageIndex}__`;
}
return {
buffer: selected.length ? serializeGlb(sourceBuffer, parsed.json, parsed.chunks, parsed.jsonChunkIndex) : sourceBuffer,
selected,
};
}
function runProcess(executable, args, environment, label) {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
cwd: projectRoot,
env: environment,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk) => { output += chunk; });
child.stderr.on("data", (chunk) => { output += chunk; });
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve(output);
else reject(new Error(`${label} failed with exit code ${code}.\n${output.trim()}`));
});
});
}
async function findToktxDirectory() {
const executable = process.platform === "win32" ? "toktx.exe" : "toktx";
const candidates = [
process.env.KTX_BIN_DIR,
path.join(projectRoot, ".tools/ktx/bin"),
...(process.env.PATH ?? "").split(path.delimiter),
].filter(Boolean);
for (const candidate of candidates) {
if (await exists(path.join(candidate, executable))) return path.resolve(candidate);
}
throw new Error("toktx was not found. Run scripts/asset-pipeline/bootstrap-ktx.ps1 or set KTX_BIN_DIR.");
}
function fullMipLevelCount(width, height) {
return Math.floor(Math.log2(Math.max(width, height))) + 1;
}
function sameOrderedValues(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
async function compressEnvironmentGlb(file, { jobs, ktxBinDirectory }) {
const sourceBuffer = await readFile(file);
const before = sourceBuffer.length;
const label = path.relative(projectRoot, file).replaceAll("\\", "/");
const inputInventory = await textureInventory(sourceBuffer, label);
const normalized = normalizeTargetTextureNames(sourceBuffer, label);
const selectedTargets = normalized.selected;
for (const target of selectedTargets) {
if (target.mimeType !== "image/png" && target.mimeType !== "image/jpeg" && target.mimeType !== "image/ktx2") {
throw new Error(`${label}: reviewed target ${target.name} has unsupported source type ${target.mimeType}.`);
}
const source = inputInventory.textures.find((entry) => entry.textureIndex === target.textureIndex);
if (target.mimeType === "image/ktx2"
&& (source?.dimensions.codec !== "UASTC" || source.dimensions.supercompressionScheme !== 2)) {
throw new Error(
`${label}: reviewed target ${target.name} is already KTX2 but is not UASTC+Zstandard. `
+ "Rebuild from its prepared PNG source to apply the reviewed codec policy.",
);
}
}
const directory = path.dirname(file);
const stem = path.basename(file, path.extname(file));
const nonce = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
const namedInput = path.join(directory, `.${stem}.${nonce}.named.glb`);
const uastcOutput = path.join(directory, `.${stem}.${nonce}.uastc.glb`);
const temporaryOutput = path.join(directory, `.${stem}.${nonce}.mixed.glb`);
const environment = {
...process.env,
KTX_BIN_DIR: ktxBinDirectory,
PATH: `${ktxBinDirectory}${path.delimiter}${process.env.PATH ?? ""}`,
};
try {
await writeFile(namedInput, normalized.buffer);
await runProcess(process.execPath, [
gltfTransformCli,
"uastc",
namedInput,
uastcOutput,
"--pattern",
REVIEWED_TEXTURE_PATTERN,
"--slots",
"baseColorTexture",
"--level",
"2",
"--zstd",
"18",
"--jobs",
String(jobs),
], environment, `UASTC pass for ${label}`);
const selectiveInventory = await textureInventory(await readFile(uastcOutput), label);
for (const target of selectedTargets) {
const output = selectiveInventory.textures.find((entry) => entry.textureIndex === target.textureIndex);
if (output?.dimensions.codec !== "UASTC") {
throw new Error(`${label}: UASTC pattern pass did not encode reviewed target ${target.name} `
+ `(texture ${output?.textureIndex ?? "missing"}, image ${output?.imageName ?? "missing"}, `
+ `${output?.mimeType ?? "missing"}, codec ${output?.dimensions?.codec ?? "missing"}; `
+ `output=${JSON.stringify(selectiveInventory.textures.map((entry) => ({ textureIndex: entry.textureIndex, imageIndex: entry.imageIndex, imageName: entry.imageName, mimeType: entry.mimeType, codec: entry.dimensions.codec })))}.`);
}
}
for (const source of inputInventory.textures) {
if (!source.isBaseColor || REVIEWED_TEXTURE_NAME_SET.has(source.name.toLowerCase())) continue;
const output = selectiveInventory.textures.find((entry) => entry.textureIndex === source.textureIndex);
if (source.mimeType !== "image/ktx2" && output?.mimeType === "image/ktx2") {
throw new Error(
`${label}: selective UASTC pattern matched non-target base-color ${source.name} `
+ "before the ETC1S fallback pass.",
);
}
}
await runProcess(process.execPath, [
genericCompressor,
uastcOutput,
"--force",
"--concurrency",
"1",
"--jobs",
String(jobs),
], environment, `ETC1S fallback pass for ${label}`);
const encodedBuffer = await readFile(uastcOutput);
const outputBuffer = patchGlbJson(encodedBuffer, (json) => {
for (const [textureIndex, originalTexture] of inputInventory.parsed.json.textures.entries()) {
const outputTexture = json.textures?.[textureIndex];
if (!outputTexture) throw new Error(`${label}: a texture was removed during compression.`);
if (originalTexture.name === undefined) delete outputTexture.name;
else outputTexture.name = originalTexture.name;
}
for (const [imageIndex, originalImage] of inputInventory.parsed.json.images.entries()) {
const outputImage = json.images?.[imageIndex];
if (!outputImage) throw new Error(`${label}: an image was removed during compression.`);
if (originalImage.name === undefined) delete outputImage.name;
else outputImage.name = originalImage.name;
}
});
const outputInventory = await textureInventory(outputBuffer, label);
const outputImageNames = outputInventory.textures.map((entry) => `${entry.imageIndex}:${entry.name}`);
const inputImageNames = inputInventory.textures.map((entry) => `${entry.imageIndex}:${entry.name}`);
if (!sameOrderedValues(inputImageNames, outputImageNames)) {
throw new Error(`${label}: texture/image linkage or ordering changed during compression.`);
}
if (outputInventory.textures.length !== inputInventory.textures.length) {
throw new Error(`${label}: texture count changed during compression.`);
}
for (const [textureIndex, sourceTexture] of inputInventory.parsed.json.textures.entries()) {
if (outputInventory.parsed.json.textures?.[textureIndex]?.name !== sourceTexture.name) {
throw new Error(`${label}: texture name ${textureIndex} changed during compression.`);
}
}
for (const [imageIndex, sourceImage] of inputInventory.parsed.json.images.entries()) {
if (outputInventory.parsed.json.images?.[imageIndex]?.name !== sourceImage.name) {
throw new Error(`${label}: image name ${imageIndex} changed during compression.`);
}
}
for (const source of inputInventory.textures) {
const output = outputInventory.textures.find((entry) => entry.textureIndex === source.textureIndex);
if (!output || output.mimeType !== "image/ktx2") {
throw new Error(`${label}: texture ${source.imageName} was not KTX2-compressed.`);
}
if (source.dimensions.width !== output.dimensions.width || source.dimensions.height !== output.dimensions.height) {
throw new Error(
`${label}: texture ${source.imageName} changed dimensions `
+ `${source.dimensions.width}x${source.dimensions.height} -> `
+ `${output.dimensions.width}x${output.dimensions.height}.`,
);
}
const expectedMips = fullMipLevelCount(source.dimensions.width, source.dimensions.height);
if (output.dimensions.mipLevels !== expectedMips) {
throw new Error(`${label}: texture ${source.imageName} has ${output.dimensions.mipLevels} mips; expected ${expectedMips}.`);
}
if (source.isBaseColor && !REVIEWED_TEXTURE_NAME_SET.has(source.name.toLowerCase())
&& output.dimensions.codec !== "ETC1S") {
throw new Error(
`${label}: non-target base-color ${source.name} did not remain ETC1S `
+ `(image ${output.imageName}, observed ${output.dimensions.codec}, `
+ `supercompression=${output.dimensions.supercompressionScheme}).`,
);
}
}
const materialModes = (buffer) => parseGlb(buffer, label).json.materials?.map((material) => ({
name: material.name ?? "",
alphaMode: material.alphaMode ?? "OPAQUE",
alphaCutoff: material.alphaCutoff ?? null,
})) ?? [];
if (JSON.stringify(materialModes(sourceBuffer)) !== JSON.stringify(materialModes(outputBuffer))) {
throw new Error(`${label}: material alpha mode/cutoff changed during compression.`);
}
const targetOutputs = selectedTargets.map((target) => {
const output = outputInventory.textures.find((entry) => entry.textureIndex === target.textureIndex);
if (output?.dimensions.codec !== "UASTC" || output.dimensions.supercompressionScheme !== 2) {
throw new Error(`${label}: selective UASTC encoder did not upgrade reviewed texture ${target.name}.`);
}
return {
name: target.name,
image: output.imageName,
width: output.dimensions.width,
height: output.dimensions.height,
supercompressionScheme: output.dimensions.supercompressionScheme,
};
});
await writeFile(temporaryOutput, outputBuffer);
await copyFile(temporaryOutput, file);
const after = (await stat(file)).size;
return {
file: label,
status: targetOutputs.length ? "mixed-quality-compressed" : "etc1s-compressed-no-targets",
beforeBytes: before,
afterBytes: after,
textureCounts: {
total: outputInventory.textures.length,
etc1s: outputInventory.textures.filter((entry) => entry.dimensions.codec === "ETC1S").length,
uastc: outputInventory.textures.filter((entry) => entry.dimensions.codec === "UASTC").length,
baseColorTargets: targetOutputs.length,
},
uastcTargets: targetOutputs,
};
} finally {
for (const temporaryFile of [namedInput, uastcOutput, temporaryOutput]) {
try {
await unlink(temporaryFile);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
}
}
async function main() {
const args = process.argv.slice(2);
const jobs = Math.max(1, Number.parseInt(argumentValue(args, "--jobs", "2"), 10));
const concurrency = Math.max(1, Number.parseInt(argumentValue(args, "--concurrency", "1"), 10));
const reportPath = argumentValue(args, "--report", null);
const targets = positionalArguments(args);
if (!targets.length) {
throw new Error("Provide one or more GLB files or directories; this wrapper does not scan the workspace implicitly.");
}
const ktxBinDirectory = await findToktxDirectory();
const files = [...new Set((await Promise.all(targets.map(collectGlbs))).flat())].sort();
if (!files.length) throw new Error("No GLB inputs were found.");
console.log(
`RoM environment texture policy: ${files.length} GLBs, UASTC targets=${REVIEWED_TEXTURE_NAMES.length}, `
+ `UASTC level 2 + Zstandard 18 (RDO disabled by CLI default), ETC1S fallback, concurrency=${concurrency}, jobs=${jobs}.`,
);
const results = [];
const errors = [];
let cursor = 0;
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
while (cursor < files.length) {
const file = files[cursor++];
try {
const result = await compressEnvironmentGlb(file, { jobs, ktxBinDirectory });
results.push(result);
console.log(
`[${results.length + errors.length}/${files.length}] ${result.status}: ${result.file} `
+ `${result.textureCounts.uastc} UASTC / ${result.textureCounts.etc1s} ETC1S`,
);
} catch (error) {
errors.push({ file, message: error.message });
console.error(`Texture compression failed: ${path.relative(projectRoot, file)}\n${error.message}`);
}
}
});
await Promise.all(workers);
const totals = results.reduce((total, result) => ({
files: total.files + 1,
textures: total.textures + result.textureCounts.total,
etc1s: total.etc1s + result.textureCounts.etc1s,
uastc: total.uastc + result.textureCounts.uastc,
baseColorTargets: total.baseColorTargets + result.textureCounts.baseColorTargets,
beforeBytes: total.beforeBytes + result.beforeBytes,
afterBytes: total.afterBytes + result.afterBytes,
}), { files: 0, textures: 0, etc1s: 0, uastc: 0, baseColorTargets: 0, beforeBytes: 0, afterBytes: 0 });
console.log(
`RoM texture compression complete: ${results.length} files, ${totals.baseColorTargets} reviewed UASTC targets, `
+ `${totals.etc1s} ETC1S / ${totals.uastc} UASTC textures, ${errors.length} failed.`,
);
if (reportPath) {
const absoluteReportPath = path.resolve(projectRoot, reportPath);
await mkdir(path.dirname(absoluteReportPath), { recursive: true });
await writeFile(absoluteReportPath, `${JSON.stringify({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
policy: {
reviewedBaseColorNames: REVIEWED_TEXTURE_NAMES,
targetCodec: "UASTC level 2, Zstandard 18, RDO disabled",
fallbackCodec: "ETC1S existing pipeline defaults",
preservesSourceDimensionsAndFullMipChains: true,
preservesMaterialAlphaModes: true,
},
totals,
files: results,
errors: errors.map(({ file, message }) => ({
file: path.relative(projectRoot, file).replaceAll("\\", "/"),
message,
})),
}, null, 2)}\n`, "utf8");
}
if (errors.length) process.exitCode = 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const wrapper = path.join(scriptDirectory, "compress-environment-textures.mjs");
const ktxBinDirectory = path.join(projectRoot, ".tools/ktx/bin");
const ktxExecutable = path.join(ktxBinDirectory, process.platform === "win32" ? "toktx.exe" : "toktx");
const hasToktx = await access(ktxExecutable).then(() => true).catch(() => false);
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const contents = Buffer.concat([typeBytes, data]);
const header = Buffer.alloc(4);
header.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(contents));
return Buffer.concat([header, contents, checksum]);
}
function makePng(width, height, seed) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const rows = [];
for (let y = 0; y < height; y += 1) {
const row = Buffer.alloc(1 + width * 4);
for (let x = 0; x < width; x += 1) {
const offset = 1 + x * 4;
row[offset] = (x * 17 + y * 3 + seed) % 256;
row[offset + 1] = (x * 5 + y * 11 + seed * 2) % 256;
row[offset + 2] = (x * 7 + y * 13 + seed * 3) % 256;
row[offset + 3] = (x + y) % 5 === 0 ? 127 : 255;
}
rows.push(row);
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(Buffer.concat(rows))),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function patchGlb(buffer, mutateJson) {
const chunks = [];
let json;
let jsonChunkIndex = -1;
let offset = 12;
while (offset + 8 <= buffer.length) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const start = offset + 8;
const end = start + length;
const data = buffer.subarray(start, end);
chunks.push({ type, data });
if (type === 0x4e4f534a) {
jsonChunkIndex = chunks.length - 1;
json = JSON.parse(data.toString("utf8").trim());
}
offset = end;
}
assert.ok(json);
mutateJson(json);
const encoded = Buffer.from(JSON.stringify(json));
const padded = Buffer.alloc(Math.ceil(encoded.length / 4) * 4, 0x20);
encoded.copy(padded);
const outputChunks = chunks.map((chunk, index) => (index === jsonChunkIndex ? { ...chunk, data: padded } : chunk));
const totalLength = 12 + outputChunks.reduce((sum, chunk) => sum + 8 + chunk.data.length, 0);
const output = Buffer.alloc(totalLength);
buffer.copy(output, 0, 0, 12);
output.writeUInt32LE(totalLength, 8);
offset = 12;
for (const chunk of outputChunks) {
output.writeUInt32LE(chunk.data.length, offset);
output.writeUInt32LE(chunk.type, offset + 4);
chunk.data.copy(output, offset + 8);
offset += 8 + chunk.data.length;
}
return output;
}
async function createFixture(file) {
const document = new Document();
const buffer = document.createBuffer();
const textureDefinitions = [
{ imageName: "11111111_lava", materialName: "lava-material", alphaMode: "BLEND", seed: 23 },
{ imageName: "22222222_lymor grave_relief_01", materialName: "relief-material", alphaMode: "MASK", seed: 59 },
{ imageName: "33333333_lava_roughness", materialName: "near-match-material", alphaMode: "OPAQUE", seed: 91 },
{ imageName: "44444444_elves_vine", materialName: "reviewed-vine-mask-material", alphaMode: "MASK", seed: 107 },
];
const primitives = [];
for (const [index, definition] of textureDefinitions.entries()) {
const texture = document.createTexture(definition.imageName)
.setMimeType("image/png")
.setImage(makePng(128, 64, definition.seed));
const material = document.createMaterial(definition.materialName)
.setBaseColorTexture(texture)
.setAlphaMode(definition.alphaMode)
.setAlphaCutoff(definition.alphaMode === "MASK" ? 0.37 : 0.5);
const x = index * 2;
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor(`position-${index}`)
.setType("VEC3")
.setArray(new Float32Array([x, 0, 0, x + 1, 0, 0, x, 1, 0]))
.setBuffer(buffer))
.setAttribute("TEXCOORD_0", document.createAccessor(`uv-${index}`)
.setType("VEC2")
.setArray(new Float32Array([0.03, 0.17, 0.91, 0.24, 0.08, 0.96]))
.setBuffer(buffer))
.setIndices(document.createAccessor(`indices-${index}`)
.setType("SCALAR")
.setArray(new Uint16Array([0, 1, 2]))
.setBuffer(buffer))
.setMaterial(material);
primitives.push(primitive);
}
const mesh = document.createMesh("texture-fixture-mesh");
for (const primitive of primitives) mesh.addPrimitive(primitive);
document.createScene("scene").addChild(document.createNode("texture-fixture").setMesh(mesh));
document.getRoot().setDefaultScene(document.getRoot().listScenes()[0]);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
await io.write(file, document);
const initial = await readFile(file);
const rawImageNames = textureDefinitions.map(({ imageName }) => imageName);
await writeFile(file, patchGlb(initial, (json) => {
for (const [index, texture] of json.textures.entries()) {
const imageIndex = texture.source;
json.images[imageIndex].name = rawImageNames[index];
delete texture.name;
}
// Exercise a real GLTF alias: two base-color texture objects share the
// same image bytes while retaining independent sampler settings.
json.samplers ??= [];
const aliasSampler = json.samplers.length;
json.samplers.push({ wrapS: 33071, wrapT: 33071 });
json.textures.push({ ...json.textures[0], name: "lava-clamp-sampler-alias", sampler: aliasSampler });
json.materials.push({
name: "lava-repeat-alias-material",
pbrMetallicRoughness: {
baseColorTexture: { index: json.textures.length - 1, texCoord: 0 },
metallicFactor: 0,
roughnessFactor: 1,
},
});
json.meshes[0].primitives.push({ ...json.meshes[0].primitives[0], material: json.materials.length - 1 });
}));
return { io, textureDefinitions };
}
function inspectGlbTextures(buffer) {
const jsonLength = buffer.readUInt32LE(12);
const json = JSON.parse(buffer.toString("utf8", 20, 20 + jsonLength));
const binStart = 20 + jsonLength + 8;
return json.images.map((image, imageIndex) => {
const view = json.bufferViews[image.bufferView];
const start = binStart + (view.byteOffset ?? 0);
const ktx = buffer.subarray(start, start + view.byteLength);
assert.equal(ktx.subarray(0, 12).toString("hex"), "ab4b5458203230bb0d0a1a0a", `${image.name} must be KTX2`);
const scheme = ktx.readUInt32LE(44);
return {
imageIndex,
name: image.name,
width: ktx.readUInt32LE(20),
height: ktx.readUInt32LE(24),
mipLevels: Math.max(1, ktx.readUInt32LE(40)),
supercompressionScheme: scheme,
codec: scheme === 1 ? "ETC1S" : "UASTC",
};
});
}
async function readMeshArrays(io, file) {
const document = await io.read(file);
return document.getRoot().listMeshes().map((mesh) => mesh.listPrimitives().map((primitive) => ({
positions: Array.from(primitive.getAttribute("POSITION")?.getArray() ?? []),
uvs: Array.from(primitive.getAttribute("TEXCOORD_0")?.getArray() ?? []),
indices: Array.from(primitive.getIndices()?.getArray() ?? []),
})));
}
test("selective environment KTX2 policy keeps reviewed base colors UASTC and other base colors ETC1S", {
skip: hasToktx ? false : "KTX-Software toktx is not available",
}, async (context) => {
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "runewaker-mixed-codec-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const file = path.join(workingDirectory, "environment.glb");
const report = path.join(workingDirectory, "quality-report.json");
const { io, textureDefinitions } = await createFixture(file);
const sourceBuffer = await readFile(file);
const sourceMeshArrays = await readMeshArrays(io, file);
const result = spawnSync(process.execPath, [
wrapper,
file,
"--jobs",
"2",
"--report",
report,
], {
cwd: projectRoot,
encoding: "utf8",
timeout: 120_000,
windowsHide: true,
env: { ...process.env, KTX_BIN_DIR: ktxBinDirectory },
});
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
const outputBuffer = await readFile(file);
const textures = inspectGlbTextures(outputBuffer);
assert.equal(textures.length, textureDefinitions.length);
const byName = new Map(textures.map((texture) => [texture.name, texture]));
for (const definition of textureDefinitions) {
const image = byName.get(definition.imageName);
assert.ok(image, `missing image ${definition.imageName}`);
assert.deepEqual([image.width, image.height, image.mipLevels], [128, 64, 8]);
const expectedCodec = definition.imageName.endsWith("_lava_roughness") ? "ETC1S" : "UASTC";
assert.equal(image.codec, expectedCodec, `${definition.imageName} codec`);
assert.equal(image.supercompressionScheme, expectedCodec === "ETC1S" ? 1 : 2);
}
const originalJsonLength = sourceBuffer.readUInt32LE(12);
const originalJson = JSON.parse(sourceBuffer.toString("utf8", 20, 20 + originalJsonLength));
const outputJsonLength = outputBuffer.readUInt32LE(12);
const outputJson = JSON.parse(outputBuffer.toString("utf8", 20, 20 + outputJsonLength));
assert.deepEqual(
outputJson.materials.map((material) => [material.name, material.alphaMode ?? "OPAQUE", material.alphaCutoff ?? null]),
originalJson.materials.map((material) => [material.name, material.alphaMode ?? "OPAQUE", material.alphaCutoff ?? null]),
);
assert.deepEqual(outputJson.textures.map((texture) => texture.name ?? null), originalJson.textures.map((texture) => texture.name ?? null));
assert.deepEqual(outputJson.images.map((image) => image.name ?? null), originalJson.images.map((image) => image.name ?? null));
assert.equal(outputJson.textures.length, originalJson.textures.length);
assert.deepEqual(outputJson.samplers, originalJson.samplers, "independent alias sampler settings must survive");
assert.equal(outputJson.textures.at(-1).source, outputJson.textures[0].source, "reviewed aliases keep one shared image");
assert.equal(outputJson.samplers[outputJson.textures.at(-1).sampler].wrapS, 33071);
assert.deepEqual(await readMeshArrays(io, file), sourceMeshArrays, "geometry, triangle topology, and UVs must be unchanged");
const qualityReport = JSON.parse(await readFile(report, "utf8"));
assert.deepEqual(qualityReport.totals, {
files: 1,
textures: 5,
etc1s: 1,
uastc: 4,
baseColorTargets: 4,
beforeBytes: sourceBuffer.length,
afterBytes: outputBuffer.length,
});
assert.deepEqual(qualityReport.files[0].uastcTargets.map((target) => target.name).sort(), [
"elves_vine",
"lava",
"lava",
"lymor grave_relief_01",
]);
});
@@ -0,0 +1,190 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { Matrix4, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const slug = process.argv[2] ?? "aeternal-circle";
const stageDungeon = path.join(workRoot, "source-anchor-support-correction-staged-v2", slug);
const supportName = "runewaker_source_anchor_support";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
function include(bounds, point) {
for (let axis = 0; axis < 3; axis += 1) {
bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]);
bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]);
}
}
function emptyBounds() {
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
}
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`), "utf8"));
const stageReport = JSON.parse(await readFile(path.join(stageDungeon, "staging-report.json"), "utf8"));
const conversion = JSON.parse(await readFile(path.join(workRoot, slug, "converted/conversion-report.json"), "utf8"));
const sourceReportPath = path.join(stageDungeon, "native-primary-collision-report.json");
const sourceReport = JSON.parse(await readFile(sourceReportPath, "utf8"));
const meters = Number(recipe.coordinateSystem.metersPerSourceUnit);
const sourceBounds = sourceReport.nativeCollisionBounds;
const expectedNativeBounds = {
minimum: [
Number(sourceBounds.min[0]) * meters,
Number(sourceBounds.min[1]) * meters,
-Number(sourceBounds.max[2]) * meters,
],
maximum: [
Number(sourceBounds.max[0]) * meters,
Number(sourceBounds.max[1]) * meters,
-Number(sourceBounds.min[2]) * meters,
],
};
const convertedNativeBounds = emptyBounds();
let convertedNativeTriangles = 0;
for (const record of conversion.collision ?? []) {
const stagedRecord = stageReport.staged.collision.find((item) => item.source === record.file);
const file = path.join(stageDungeon, stagedRecord?.staged ?? record.file);
const document = await io.read(file);
for (const node of document.getRoot().listNodes()) {
if (!node.getMesh() || node.getExtras()?.sourceChunk === supportName) continue;
const matrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
for (const primitive of node.getMesh().listPrimitives()) {
const position = primitive.getAttribute("POSITION");
const indices = primitive.getIndices();
convertedNativeTriangles += (indices?.getCount() ?? position?.getCount() ?? 0) / 3;
if (!position) continue;
const values = position.getArray();
for (let offset = 0; offset < values.length; offset += 3) {
include(convertedNativeBounds, new Vector3().fromArray(values, offset).applyMatrix4(matrix).toArray());
}
}
}
}
const placement = stageReport.source.primaryPlacement;
const sourcePoints = recipe.source.sourceAnchorSupport.points.map((point) => point.map(Number));
const omitted = new Set(stageReport.source.omittedNativeGroundedPointIndices);
const nativeXZ = {
minX: convertedNativeBounds.minimum[0], maxX: convertedNativeBounds.maximum[0],
minZ: convertedNativeBounds.minimum[2], maxZ: convertedNativeBounds.maximum[2],
};
const padRows = [];
const supportBounds = emptyBounds();
const coreSupportBounds = emptyBounds();
for (const [sourcePointIndex, sourcePoint] of sourcePoints.entries()) {
if (omitted.has(sourcePointIndex)) continue;
const geometry = sourceAnchorSupportGeometry([sourcePoint], placement, {
halfExtent: Number(stageReport.source.halfExtentSourceUnits),
thickness: Number(stageReport.source.thicknessSourceUnits),
metersPerSourceUnit: meters,
});
const top = [];
for (let vertex = 0; vertex < 4; vertex += 1) {
top.push(geometry.positions.slice(vertex * 3, vertex * 3 + 3));
}
const box = emptyBounds();
for (const point of geometry.positions.reduce((all, value, index) => {
if (index % 3 === 0) all.push(geometry.positions.slice(index, index + 3));
return all;
}, [])) {
include(box, point);
include(supportBounds, point);
}
const overlapsNativeXZ = box.maximum[0] >= nativeXZ.minX && box.minimum[0] <= nativeXZ.maxX
&& box.maximum[2] >= nativeXZ.minZ && box.minimum[2] <= nativeXZ.maxZ;
const yGapM = box.minimum[1] > convertedNativeBounds.maximum[1]
? box.minimum[1] - convertedNativeBounds.maximum[1]
: box.maximum[1] < convertedNativeBounds.minimum[1]
? convertedNativeBounds.minimum[1] - box.maximum[1]
: 0;
if (overlapsNativeXZ) {
for (const point of geometry.positions.reduce((all, value, index) => {
if (index % 3 === 0) all.push(geometry.positions.slice(index, index + 3));
return all;
}, [])) include(coreSupportBounds, point);
}
padRows.push({ sourcePointIndex, sourcePoint, center: top.reduce((center, point) => center.map((value, axis) => value + point[axis] / top.length), [0, 0, 0]),
bounds: box, overlapsNativeXZ, verticalGapToNativeBoundsM: yGapM });
}
const coreBounds = emptyBounds();
for (const point of [
[convertedNativeBounds.minimum[0], convertedNativeBounds.minimum[1], convertedNativeBounds.minimum[2]],
[convertedNativeBounds.maximum[0], convertedNativeBounds.maximum[1], convertedNativeBounds.maximum[2]],
[coreSupportBounds.minimum[0], coreSupportBounds.minimum[1], coreSupportBounds.minimum[2]],
[coreSupportBounds.maximum[0], coreSupportBounds.maximum[1], coreSupportBounds.maximum[2]],
]) {
if (point.every(Number.isFinite)) include(coreBounds, point);
}
const gridWidth = Math.ceil((coreBounds.maximum[0] - coreBounds.minimum[0]) / Number(recipe.navigation.cellSize));
const gridDepth = Math.ceil((coreBounds.maximum[2] - coreBounds.minimum[2]) / Number(recipe.navigation.cellSize));
const overlapRows = padRows.filter((row) => row.overlapsNativeXZ);
const remoteRows = padRows.filter((row) => !row.overlapsNativeXZ);
const report = {
schemaVersion: 1,
slug,
source: {
primaryModel: recipe.source.model,
primaryModelSha256: stageReport.source.sourceModelSha256,
nativeCollisionReport: relative(sourceReportPath),
nativeCollisionReportSha256: sha256(await readFile(sourceReportPath)),
nativeCollisionTriangles: sourceReport.nativeCollisionTriangleCount,
visualFallbackTriangles: sourceReport.renderMeshFallbackTriangleCount,
nativeTriangleFlags: sourceReport.nativeTriangleFlagCounts,
pathCollisionExclusionMask: sourceReport.pathCollisionPolicy.exclusionMask,
convertedCollisionChunks: conversion.collision.map((record) => record.file),
},
boundsProof: {
convertedCollisionExcludingSupport: convertedNativeBounds,
expectedNativeCollisionBoundsAfterUnitsAndZBridge: expectedNativeBounds,
comparisonMaximumErrorM: convertedNativeBounds.minimum.map((value, axis) => Math.max(
Math.abs(value - expectedNativeBounds.minimum[axis]),
Math.abs(convertedNativeBounds.maximum[axis] - expectedNativeBounds.maximum[axis]),
)).reduce((max, value) => Math.max(max, value), 0),
convertedNativeCollisionTriangles: convertedNativeTriangles,
sourceQueryNativeTriangles: sourceReport.nativeCollisionTriangleCount,
exactBoundsMatchToleranceM: 0.01,
nativeCoreBounds: convertedNativeBounds,
supportUnionBounds: supportBounds,
nativeCorePlusXZOverlappingSupportBounds: coreBounds,
},
selection: {
sourceAnchorPointCount: sourcePoints.length,
omittedNativeGroundedPointIndices: [...omitted],
retainedSupportCount: padRows.length,
coreXzOverlapCount: overlapRows.length,
isolatedRemoteCount: remoteRows.length,
coreSupportSourcePointIndices: overlapRows.map((row) => row.sourcePointIndex),
remoteSupportSourcePointIndices: remoteRows.map((row) => row.sourcePointIndex),
remoteSupportClearanceM: Number(recipe.navigation.agentRadius ?? 0.36),
remoteSupportTopInsetHalfExtentM: Number(stageReport.source.halfExtentSourceUnits) * meters - Number(recipe.navigation.agentRadius ?? 0.36),
},
predictedCoreRecastGrid: {
cellSize: Number(recipe.navigation.cellSize),
widthCells: gridWidth,
depthCells: gridDepth,
cellCount: gridWidth * gridDepth,
recipeTileSize: recipe.navigation.tileSize ?? null,
},
padRows,
conclusion: "This report only classifies source-grounded collision and support selection. It does not merge/bake any nav surfaces. Before staging, verify the bounds comparison and keep remote supports as separate explicit components with agent-radius inset; no route connectivity is inferred.",
};
const output = path.join(stageDungeon, "native-core-support-overlap.json");
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
report: relative(output),
nativeCollisionTriangles: sourceReport.nativeCollisionTriangleCount,
convertedNativeTriangles,
boundsErrorM: report.boundsProof.comparisonMaximumErrorM,
coreXzOverlapCount: overlapRows.length,
isolatedRemoteCount: remoteRows.length,
coreBounds,
grid: report.predictedCoreRecastGrid,
}, null, 2));
@@ -0,0 +1,198 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { Matrix4, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const slug = process.argv[2] ?? "aeternal-circle";
const stageDungeon = path.join(workRoot, "source-anchor-support-correction-staged-v2", slug);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
function includePoint(bounds, point) {
for (let axis = 0; axis < 3; axis += 1) {
bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]);
bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]);
}
}
function newBounds() {
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
}
function summarizeCollision(document) {
const global = newBounds();
const nativeBounds = newBounds();
let totalTriangles = 0;
let totalVertices = 0;
let degenerateTriangles = 0;
let invalidIndices = 0;
let nonFiniteCoordinates = 0;
const meshes = [];
const padCenters = [];
const edgeAb = new Vector3();
const edgeAc = new Vector3();
const normal = new Vector3();
for (const node of document.getRoot().listNodes()) {
if (!node.getMesh()) continue;
const support = node.getExtras()?.sourceChunk === "runewaker_source_anchor_support";
const nodeMatrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
let nodeTriangles = 0;
let nodeVertices = 0;
let nodeDegenerates = 0;
const nodeBounds = newBounds();
for (const primitive of node.getMesh().listPrimitives()) {
const position = primitive.getAttribute("POSITION");
const indices = primitive.getIndices();
if (!position) continue;
const positions = position.getArray();
const elements = indices?.getArray() ?? Array.from({ length: position.getCount() }, (_, index) => index);
nodeVertices += position.getCount();
totalVertices += position.getCount();
nodeTriangles += elements.length / 3;
totalTriangles += elements.length / 3;
for (let vertex = 0; vertex < position.getCount(); vertex += 1) {
const point = new Vector3().fromArray(positions, vertex * 3).applyMatrix4(nodeMatrix).toArray();
if (!point.every(Number.isFinite)) {
nonFiniteCoordinates += 1;
continue;
}
includePoint(global, point);
includePoint(nodeBounds, point);
if (!support) includePoint(nativeBounds, point);
}
for (let element = 0; element < elements.length; element += 3) {
const triangle = [elements[element], elements[element + 1], elements[element + 2]];
if (triangle.some((index) => !Number.isInteger(index) || index < 0 || index >= position.getCount())) {
invalidIndices += 1;
continue;
}
const a = new Vector3().fromArray(positions, triangle[0] * 3).applyMatrix4(nodeMatrix);
const b = new Vector3().fromArray(positions, triangle[1] * 3).applyMatrix4(nodeMatrix);
const c = new Vector3().fromArray(positions, triangle[2] * 3).applyMatrix4(nodeMatrix);
if (normal.crossVectors(edgeAb.subVectors(b, a), edgeAc.subVectors(c, a)).lengthSq() <= 1e-16) {
degenerateTriangles += 1;
nodeDegenerates += 1;
}
}
}
if (support) {
const primitive = node.getMesh().listPrimitives()[0];
const position = primitive.getAttribute("POSITION");
const values = position.getArray();
for (let offset = 0, pointIndex = 0; offset < position.getCount(); offset += 8, pointIndex += 1) {
const top = [];
for (let vertex = 0; vertex < 4; vertex += 1) {
top.push(new Vector3().fromArray(values, (offset + vertex) * 3).applyMatrix4(nodeMatrix).toArray());
}
const center = [0, 1, 2].map((axis) => top.reduce((sum, point) => sum + point[axis], 0) / top.length);
padCenters.push({ pointIndex, center });
}
}
meshes.push({
node: node.getName(),
sourceChunk: node.getExtras()?.sourceChunk ?? null,
generatedSupport: support,
vertices: nodeVertices,
triangles: nodeTriangles,
degenerateTriangles: nodeDegenerates,
bounds: nodeBounds,
spans: nodeBounds.minimum.map((value, axis) => nodeBounds.maximum[axis] - value),
});
}
return {
totals: { vertices: totalVertices, triangles: totalTriangles, degenerateTriangles, invalidIndices, nonFiniteCoordinates },
bounds: global,
nativeOnlyBounds: nativeBounds,
supportPadCount: padCenters.length,
supportCenters: padCenters,
largestMeshes: meshes.slice().sort((left, right) => right.triangles - left.triangles).slice(0, 12),
widestMeshes: meshes.slice().sort((left, right) => Math.max(...right.spans) - Math.max(...left.spans)).slice(0, 12),
meshes,
};
}
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`), "utf8"));
const stageReport = JSON.parse(await readFile(path.join(stageDungeon, "staging-report.json"), "utf8"));
const originalFile = path.join(workRoot, slug, "converted", stageReport.staged.collision[0].source);
const correctedFile = path.join(stageDungeon, stageReport.staged.collision[0].staged);
const [original, corrected] = await Promise.all([io.read(originalFile), io.read(correctedFile)]);
const originalSummary = summarizeCollision(original);
const correctedSummary = summarizeCollision(corrected);
const oldReport = JSON.parse(await readFile(path.join(workRoot, slug, "converted/recast-report.json"), "utf8"));
const sourceBounds = oldReport.input.bounds;
const sourceGrid = oldReport.build;
const cellSize = Number(recipe.navigation.cellSize);
const correctedWidth = Math.ceil((correctedSummary.bounds.maximum[0] - correctedSummary.bounds.minimum[0]) / cellSize);
const correctedHeight = Math.ceil((correctedSummary.bounds.maximum[2] - correctedSummary.bounds.minimum[2]) / cellSize);
const pointGeometry = sourceAnchorSupportGeometry(recipe.source.sourceAnchorSupport.points, stageReport.source.primaryPlacement, {
halfExtent: Number(stageReport.source.halfExtentSourceUnits),
thickness: Number(stageReport.source.thicknessSourceUnits),
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
omitPointIndices: stageReport.source.omittedNativeGroundedPointIndices,
});
const expectedPads = newBounds();
for (let offset = 0; offset < pointGeometry.positions.length; offset += 3) {
includePoint(expectedPads, pointGeometry.positions.slice(offset, offset + 3));
}
const report = {
schemaVersion: 1,
slug,
source: {
collisionBeforeCorrection: relative(originalFile),
collisionBeforeCorrectionSha256: stageReport.source.originalCollisionFiles[0].sha256,
correctedCollision: relative(correctedFile),
correctedCollisionSha256: sha256(await readFile(correctedFile)),
oldRecastInputBounds: sourceBounds,
oldRecastGrid: sourceGrid,
collisionTrianglesInOldRecastReport: oldReport.input.triangleCount,
recipeCellSize: cellSize,
expectedRetainedSupportBounds: expectedPads,
expectedRetainedSupportTriangles: pointGeometry.indices.length / 3,
expectedSupportNavigationTriangles: pointGeometry.navigationPositions.length / 9,
},
original: originalSummary,
corrected: correctedSummary,
diff: {
triangleDelta: correctedSummary.totals.triangles - originalSummary.totals.triangles,
nativeBoundsEqual: JSON.stringify(originalSummary.nativeOnlyBounds) === JSON.stringify(correctedSummary.nativeOnlyBounds),
correctedBounds: correctedSummary.bounds,
nativeOnlyBounds: correctedSummary.nativeOnlyBounds,
correctedGridWidthCells: correctedWidth,
correctedGridDepthCells: correctedHeight,
correctedGridCellCount: correctedWidth * correctedHeight,
tilesAtRecipe64: Math.ceil(correctedWidth / 64) * Math.ceil(correctedHeight / 64),
tilesAtDerived512: Math.ceil(correctedWidth / 512) * Math.ceil(correctedHeight / 512),
tilesAtDerived1024: Math.ceil(correctedWidth / 1024) * Math.ceil(correctedHeight / 1024),
allCollisionCoordinatesFinite: correctedSummary.totals.nonFiniteCoordinates === 0,
allIndicesValid: correctedSummary.totals.invalidIndices === 0,
},
conclusion: {
explanation: "Compare global/native-only bounds and mesh scales with old Recast report; unchanged native bounds mean support correction did not create a new world-space envelope. The all-dungeon rebake cost is therefore in rebuilding the broad provisional primary-ROS collision mesh, which copies decorative renderables, rather than the 1,812 support triangles.",
sourceAnchorPoint0Omitted: stageReport.source.omittedNativeGroundedPointIndices.includes(0),
},
};
const output = path.join(stageDungeon, "nav-rebake-diagnostic.json");
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
report: relative(output),
original: originalSummary.totals,
corrected: correctedSummary.totals,
nativeBoundsEqual: report.diff.nativeBoundsEqual,
fullBounds: correctedSummary.bounds,
nativeOnlyBounds: correctedSummary.nativeOnlyBounds,
gridCells: report.diff.correctedGridCellCount,
tilesAtRecipe64: report.diff.tilesAtRecipe64,
tilesAtDerived512: report.diff.tilesAtDerived512,
largestMeshes: correctedSummary.largestMeshes,
widestMeshes: correctedSummary.widestMeshes,
}, null, 2));
@@ -0,0 +1,205 @@
import { createReadStream } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { createInterface } from "node:readline";
import path from "node:path";
import process from "node:process";
import { projectRoot } from "./lib/recipe.mjs";
const cases = [
{ slug: "grafu-castle", material: "runewaker_material_104" },
{ slug: "treasure-trove", material: "runewaker_material_232" },
{ slug: "demon-stronghold", material: "runewaker_material_90" },
{ slug: "kawaks-tomb", material: "runewaker_material_89" },
{ slug: "cave-water-dragon", material: "runewaker_material_100" },
];
const outputArgument = process.argv.indexOf("--output");
const outputFile = outputArgument >= 0 ? path.resolve(process.argv[outputArgument + 1]) : null;
function createStats(group) {
return {
group,
triangles: 0,
surfaceAreaM2: 0,
horizontalFacingAreaM2: 0,
wallAreaM2: 0,
otherAreaM2: 0,
nearZeroUvAreaM2: 0,
nearZeroUvAreaTriangles: 0,
boundsSourceUnits: {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity],
},
};
}
function includePosition(stats, position) {
for (let axis = 0; axis < 3; axis += 1) {
stats.boundsSourceUnits.min[axis] = Math.min(stats.boundsSourceUnits.min[axis], position[axis]);
stats.boundsSourceUnits.max[axis] = Math.max(stats.boundsSourceUnits.max[axis], position[axis]);
}
}
function addTriangle(stats, vertices, uvs) {
const [a, b, c] = vertices;
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const cross = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
const sourceArea = Math.hypot(...cross) * 0.5;
const areaM2 = sourceArea * 0.01;
const normalY = sourceArea > 0 ? Math.abs(cross[1]) / (sourceArea * 2) : 0;
const [uvA, uvB, uvC] = uvs;
const uvArea = Math.abs(
(uvB[0] - uvA[0]) * (uvC[1] - uvA[1])
- (uvC[0] - uvA[0]) * (uvB[1] - uvA[1]),
) * 0.5;
stats.triangles += 1;
stats.surfaceAreaM2 += areaM2;
if (normalY >= 0.7) stats.horizontalFacingAreaM2 += areaM2;
else if (normalY <= 0.3) stats.wallAreaM2 += areaM2;
else stats.otherAreaM2 += areaM2;
if (uvArea <= 1e-12) {
stats.nearZeroUvAreaM2 += areaM2;
stats.nearZeroUvAreaTriangles += 1;
}
for (const position of vertices) includePosition(stats, position);
}
function parseFaceVertex(token, positions, texcoords) {
const [vertexIndex, texcoordIndex] = token.split("/");
const position = positions[Number(vertexIndex) - 1];
const texcoord = texcoords[Number(texcoordIndex) - 1];
return position && texcoord ? { position, texcoord } : null;
}
async function materialTextures(mtlFile) {
const result = new Map();
let material = null;
for (const line of (await readFile(mtlFile, "utf8")).split(/\r?\n/)) {
const match = line.match(/^newmtl\s+(.+)$/);
if (match) {
material = match[1].trim();
continue;
}
const map = line.match(/^map_Kd\s+(.+)$/);
if (material && map) result.set(material, map[1].trim());
}
return result;
}
async function imageDimensions(ddsFile) {
try {
const bytes = await readFile(ddsFile);
if (bytes.toString("ascii", 0, 4) !== "DDS ") return null;
return { width: bytes.readUInt32LE(16), height: bytes.readUInt32LE(12) };
} catch {
return null;
}
}
async function inspectObj({ slug, material }) {
const rawDirectory = path.join(projectRoot, "runewaker-export-work", slug, "raw");
const objFile = path.join(rawDirectory, "scene.obj");
const mtlFile = path.join(rawDirectory, "scene.mtl");
const materialName = material;
const textureMap = await materialTextures(mtlFile);
const stagedTexture = textureMap.get(materialName) ?? null;
const textureFile = stagedTexture ? path.join(rawDirectory, stagedTexture) : null;
const positions = [];
const texcoords = [];
const groups = new Map();
let currentGroup = "(default)";
let currentMaterial = null;
const lines = createInterface({ input: createReadStream(objFile), crlfDelay: Infinity });
for await (const line of lines) {
if (line.startsWith("v ")) {
const values = line.trim().split(/\s+/).slice(1, 4).map(Number);
if (values.length === 3 && values.every(Number.isFinite)) positions.push(values);
} else if (line.startsWith("vt ")) {
const values = line.trim().split(/\s+/).slice(1, 3).map(Number);
if (values.length === 2 && values.every(Number.isFinite)) texcoords.push(values);
} else if (line.startsWith("g ")) {
currentGroup = line.slice(2).trim() || "(unnamed)";
} else if (line.startsWith("usemtl ")) {
currentMaterial = line.slice(7).trim();
} else if (currentMaterial === materialName && line.startsWith("f ")) {
const face = line.trim().split(/\s+/).slice(1)
.map((token) => parseFaceVertex(token, positions, texcoords));
if (face.length < 3 || face.some((entry) => !entry)) continue;
const stats = groups.get(currentGroup) ?? createStats(currentGroup);
for (let index = 1; index < face.length - 1; index += 1) {
addTriangle(
stats,
[face[0].position, face[index].position, face[index + 1].position],
[face[0].texcoord, face[index].texcoord, face[index + 1].texcoord],
);
}
groups.set(currentGroup, stats);
}
}
const rows = [...groups.values()].sort((left, right) => right.nearZeroUvAreaM2 - left.nearZeroUvAreaM2);
const total = createStats("material-total");
for (const row of rows) {
total.triangles += row.triangles;
total.surfaceAreaM2 += row.surfaceAreaM2;
total.horizontalFacingAreaM2 += row.horizontalFacingAreaM2;
total.wallAreaM2 += row.wallAreaM2;
total.otherAreaM2 += row.otherAreaM2;
total.nearZeroUvAreaM2 += row.nearZeroUvAreaM2;
total.nearZeroUvAreaTriangles += row.nearZeroUvAreaTriangles;
}
const sourceBoundsMeters = (bounds) => ({
min: [bounds.min[0] * 0.1, bounds.min[1] * 0.1, -bounds.max[2] * 0.1],
max: [bounds.max[0] * 0.1, bounds.max[1] * 0.1, -bounds.min[2] * 0.1],
});
return {
slug,
material: materialName,
texture: stagedTexture,
textureDimensions: textureFile ? await imageDimensions(textureFile) : null,
sourceCoordinateNote: "Group bounds are native scene.obj model-local source units; meters below apply 0.1 scale and Three.js Z reflection, without WDB placement transforms. horizontalFacingAreaM2 uses absolute normal-Y, so it combines floor- and ceiling-facing faces.",
uvNearZeroThreshold: "UV triangle area <= 1e-12 (not an exact-zero test)",
totals: {
triangles: total.triangles,
surfaceAreaM2: Number(total.surfaceAreaM2.toFixed(3)),
horizontalFacingAreaM2: Number(total.horizontalFacingAreaM2.toFixed(3)),
wallAreaM2: Number(total.wallAreaM2.toFixed(3)),
otherAreaM2: Number(total.otherAreaM2.toFixed(3)),
nearZeroUvAreaM2: Number(total.nearZeroUvAreaM2.toFixed(3)),
nearZeroUvAreaTriangles: total.nearZeroUvAreaTriangles,
},
groups: rows.map((row) => ({
group: row.group,
triangles: row.triangles,
surfaceAreaM2: Number(row.surfaceAreaM2.toFixed(3)),
horizontalFacingAreaM2: Number(row.horizontalFacingAreaM2.toFixed(3)),
wallAreaM2: Number(row.wallAreaM2.toFixed(3)),
nearZeroUvAreaM2: Number(row.nearZeroUvAreaM2.toFixed(3)),
nearZeroUvAreaTriangles: row.nearZeroUvAreaTriangles,
boundsSourceUnits: {
min: row.boundsSourceUnits.min.map((value) => Number(value.toFixed(3))),
max: row.boundsSourceUnits.max.map((value) => Number(value.toFixed(3))),
},
boundsMetersWithoutWdbPlacement: sourceBoundsMeters(row.boundsSourceUnits),
})),
};
}
const report = {
schemaVersion: 1,
purpose: "Focused native OBJ/MTL provenance for residual UV hotspots; compare these groups with the converted-source and shipped cells in RUNEWAKER_ENVIRONMENT_QUALITY_REPORT.md.",
generatedAt: new Date().toISOString(),
materials: [],
};
for (const target of cases) report.materials.push(await inspectObj(target));
const output = `${JSON.stringify(report, null, 2)}\n`;
if (outputFile) await writeFile(outputFile, output, "utf8");
else process.stdout.write(output);
@@ -11,6 +11,7 @@ import {
normalizeWdbResourcePath,
placementMatrixInPrimaryThree,
resourceCacheKey,
transformSourceHeadingToPrimaryThree,
transformSourcePointToPrimaryThree,
} from "./lib/environment-assembly.mjs";
@@ -69,6 +70,30 @@ test("converts full placement translation, quaternion, and nonuniform scale", ()
assert.notDeepEqual(matrix.toArray(), new Matrix4().identity().toArray());
});
test("transforms source headings through inverse primary rotation and handedness bridge", () => {
const primary = identityPlacement({
rotation: [0, Math.sin(Math.PI / 6), 0, Math.cos(Math.PI / 6)],
});
assert.deepEqual(
transformSourceHeadingToPrimaryThree(Math.PI / 6, primary).map((value) => Number(value.toFixed(9))),
[1, 0, 0],
"a 30-degree source heading in a 60-degree primary frame points along local +X",
);
assert.deepEqual(
transformSourceHeadingToPrimaryThree(Math.PI / 3, identityPlacement()).map((value) => Number(value.toFixed(9))),
[0.866025404, 0, 0.5],
"an identity primary frame preserves the existing +Z-forward Three.js convention",
);
assert.deepEqual(
transformSourceHeadingToPrimaryThree(Math.PI / 4, identityPlacement({ scale: [2, 1, -4] }))
.map((value) => Number(value.toFixed(9))),
[0.894427191, 0, -0.447213595],
"inverse nonuniform and negative primary scales affect heading before planar normalization",
);
});
test("builds deterministic static, dynamic, review, and unsupported placement groups", () => {
const primary = identityPlacement();
const door = identityPlacement({
@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const contents = Buffer.concat([typeBytes, data]);
const header = Buffer.alloc(4);
header.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(contents));
return Buffer.concat([header, contents, checksum]);
}
function makePng(width, height) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const row = Buffer.alloc(1 + width * 4);
const pixels = Buffer.alloc(width * 4);
for (let offset = 0; offset < pixels.length; offset += 4) {
pixels[offset] = (offset / 4) % 256;
pixels[offset + 1] = 135;
pixels[offset + 2] = 65;
pixels[offset + 3] = 255;
}
pixels.copy(row, 1);
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([
signature,
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(row)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function pngDimensions(bytes) {
bytes = Buffer.from(bytes);
assert.equal(bytes.toString("ascii", 1, 4), "PNG");
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
}
function atlasGridGeometry(document, buffer, segments) {
const positions = new Float32Array((segments + 1) * (segments + 1) * 3);
const texcoords = new Float32Array((segments + 1) * (segments + 1) * 2);
const indices = new Uint16Array(segments * segments * 6);
const atlasColumns = [0.03, 0.94, 0.08, 0.89];
let indexOffset = 0;
for (let row = 0; row <= segments; row += 1) {
for (let column = 0; column <= segments; column += 1) {
const vertex = row * (segments + 1) + column;
positions.set([column, row, 0], vertex * 3);
texcoords.set([
atlasColumns[column % atlasColumns.length],
row / segments,
], vertex * 2);
if (row < segments && column < segments) {
const topLeft = vertex;
const topRight = vertex + 1;
const bottomLeft = vertex + segments + 1;
const bottomRight = bottomLeft + 1;
indices.set([
topLeft, bottomLeft, topRight,
topRight, bottomLeft, bottomRight,
], indexOffset);
indexOffset += 6;
}
}
}
return {
positions: document.createAccessor("positions")
.setType("VEC3")
.setArray(positions)
.setBuffer(buffer),
texcoords: document.createAccessor("texcoords")
.setType("VEC2")
.setArray(texcoords)
.setBuffer(buffer),
indices: document.createAccessor("indices")
.setType("SCALAR")
.setArray(indices)
.setBuffer(buffer),
};
}
function geometryUvStats(document) {
let triangles = 0;
let degenerateUvTriangles = 0;
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const texcoords = primitive.getAttribute("TEXCOORD_0")?.getArray();
const indices = primitive.getIndices()?.getArray();
if (!texcoords || !indices) continue;
triangles += indices.length / 3;
for (let offset = 0; offset < indices.length; offset += 3) {
const a = indices[offset] * 2;
const b = indices[offset + 1] * 2;
const c = indices[offset + 2] * 2;
const area = (texcoords[b] - texcoords[a]) * (texcoords[c + 1] - texcoords[a + 1])
- (texcoords[c] - texcoords[a]) * (texcoords[b + 1] - texcoords[a + 1]);
if (Math.abs(area) < 1e-8) degenerateUvTriangles += 1;
}
}
}
return { triangles, degenerateUvTriangles };
}
test("environment optimization preserves source texture dimensions and atlas UV topology", async (context) => {
const width = 4096;
const height = 1;
const gridSegments = 24;
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "runewaker-visual-quality-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const sourceFile = path.join(workingDirectory, "source.glb");
const outputFile = path.join(workingDirectory, "optimized.glb");
const simplifiedControlFile = path.join(workingDirectory, "simplified-control.glb");
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = new Document();
const buffer = document.createBuffer();
const texture = document.createTexture("wall-atlas")
.setMimeType("image/png")
.setImage(makePng(width, height));
const material = document.createMaterial("wall")
.setBaseColorTexture(texture);
const geometry = atlasGridGeometry(document, buffer, gridSegments);
const primitive = document.createPrimitive()
.setAttribute("POSITION", geometry.positions)
.setAttribute("TEXCOORD_0", geometry.texcoords)
.setIndices(geometry.indices)
.setMaterial(material);
const mesh = document.createMesh("wall").addPrimitive(primitive);
const scene = document.createScene("scene").addChild(document.createNode("wall").setMesh(mesh));
document.getRoot().setDefaultScene(scene);
await io.write(sourceFile, document);
const source = await io.read(sourceFile);
assert.equal(source.getRoot().listTextures().length, 1);
assert.ok(source.getRoot().listMaterials()[0].getBaseColorTexture());
const args = environmentOptimizationArguments(sourceFile, outputFile);
assert.deepEqual(args.slice(0, 3), ["optimize", sourceFile, outputFile]);
assert.deepEqual(args.slice(3), [
"--compress", "meshopt", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--simplify", "false", "--texture-compress", "false",
]);
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const optimized = await io.read(outputFile);
const optimizedStats = geometryUvStats(optimized);
assert.deepEqual(optimizedStats, {
triangles: gridSegments * gridSegments * 2,
degenerateUvTriangles: 0,
}, "atlas-grid geometry and its source UV seams must survive optimize intact");
const simplifyOption = args.indexOf("--simplify");
const simplifiedControlArgs = [...args];
simplifiedControlArgs[2] = simplifiedControlFile;
simplifiedControlArgs[simplifyOption + 1] = "true";
const simplifiedControl = spawnSync(process.execPath, [gltfTransformCli, ...simplifiedControlArgs], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(simplifiedControl.status, 0, `${simplifiedControl.stdout}\n${simplifiedControl.stderr}`);
const simplifiedStats = geometryUvStats(await io.read(simplifiedControlFile));
assert.ok(
simplifiedStats.triangles < optimizedStats.triangles || simplifiedStats.degenerateUvTriangles > 0,
"atlas-grid fixture must detect topology loss or UV collapse when simplification is enabled",
);
const textures = optimized.getRoot().listTextures();
assert.equal(textures.length, 1, JSON.stringify({
materials: optimized.getRoot().listMaterials().map((entry) => ({
name: entry.getName(),
texture: entry.getBaseColorTexture()?.getName() ?? null,
})),
}));
const outputTexture = textures[0];
assert.deepEqual(pngDimensions(outputTexture.getImage()), [width, height]);
});
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><link rel="icon" href="data:,"><title>RoM environment review</title><style>body{margin:0;background:#161820;color:white;font:14px sans-serif}canvas{display:block}#status{position:absolute;top:10px;left:10px;background:#111c;padding:10px;pointer-events:none}</style></head>
<body><div id="status">Loading environment…</div><script type="module" src="./environment-preview.ts"></script></body>
</html>
@@ -0,0 +1,249 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { FORSAKEN_ABBEY_DEFINITION } from "../../src/game/dungeons/forsakenAbbey";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "../../src/game/generated/runewakerDungeonDefinitions.generated";
import { prepareDungeonMaterial } from "../../src/scene/productionDungeonMaterials";
import { applyDungeonMeshGraphics } from "../../src/scene/graphicsMaterialQuality";
import { repairHallOfSurvivorsVisual } from "../../src/scene/hallOfSurvivorsVisual";
import { withRunewakerDungeonEnvironment } from "../../src/game/runewakerDungeonEnvironment";
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
import { configureRunewakerTerrainLoader } from "../../src/scene/runewakerTerrainMaterial";
import { configureRunewakerSurfaceDetailLoader } from "../../src/scene/runewakerSurfaceDetailMaterial";
import { configureRunewakerWaterLoader } from "../../src/scene/runewakerWaterMaterial";
import { resolveRunewakerNativeLighting } from "../../src/game/runewakerNativeLighting";
import { applyRunewakerNativeLighting } from "../../src/scene/runewakerNativeLightingRuntime";
const params = new URLSearchParams(location.search);
const id = params.get("dungeon") ?? "forsaken-abbey";
const definitions: readonly DungeonDefinition[] = [FORSAKEN_ABBEY_DEFINITION, ...RUNEWAKER_DUNGEON_DEFINITIONS];
const original = definitions.find((entry) => entry.id === id);
if (!original) throw new Error(`Unknown RoM dungeon ${id}`);
const definition = params.has("before") ? original : withRunewakerDungeonEnvironment(original);
const p = definition.presentation;
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(1);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(p.fog.color);
scene.fog = new THREE.Fog(p.fog.color, p.fog.near, p.fog.far);
const ambient = new THREE.AmbientLight(p.ambientLight.color, p.ambientLight.intensity);
const hemisphere = new THREE.HemisphereLight(p.hemisphereLight.skyColor, p.hemisphereLight.groundColor, p.hemisphereLight.intensity);
scene.add(ambient, hemisphere);
const light = new THREE.DirectionalLight(p.directionalLight.color, p.directionalLight.intensity);
light.castShadow = true;
light.shadow.mapSize.set(2048, 2048);
Object.assign(light.shadow.camera, { left: -26, right: 26, top: 26, bottom: -26, near: 0.5, far: 90 });
light.shadow.bias = -0.00025;
scene.add(light, light.target);
const sourceAmbient = new THREE.AmbientLight();
const sourceDirections = [new THREE.DirectionalLight(), new THREE.DirectionalLight(), new THREE.DirectionalLight()];
const sourceLights = new THREE.Group();
sourceLights.add(sourceAmbient, ...sourceDirections);
sourceLights.visible = false;
sourceDirections[0].castShadow = true;
sourceDirections[0].shadow.mapSize.set(2048, 2048);
Object.assign(sourceDirections[0].shadow.camera, { left: -26, right: 26, top: 26, bottom: -26, near: 0.5, far: 90 });
sourceDirections[0].shadow.bias = -0.00025;
scene.add(sourceLights, ...sourceDirections.map(source => source.target));
let sourceLightingEnabled = !params.has("before") && !params.has("profile-only");
let sourceLightingPoint = definition.entrance.footPosition;
let resolvedSourceLighting: ReturnType<typeof resolveRunewakerNativeLighting> = null;
function updateSourceLighting() {
resolvedSourceLighting = sourceLightingEnabled
? resolveRunewakerNativeLighting(definition, sourceLightingPoint, { cameraFarDistance: camera.far })
: null;
sourceLights.visible = resolvedSourceLighting !== null;
ambient.visible = hemisphere.visible = light.visible = resolvedSourceLighting === null;
if (resolvedSourceLighting) applyRunewakerNativeLighting(scene, sourceAmbient, sourceDirections, resolvedSourceLighting);
else {
scene.background = new THREE.Color(p.fog.color);
scene.fog = new THREE.Fog(p.fog.color, p.fog.near, p.fog.far);
delete scene.userData.runewakerNativeLighting;
}
}
for (const entry of p.pointLights ?? []) {
const point = new THREE.PointLight(entry.color, entry.intensity, entry.distance, 2);
point.position.fromArray(entry.position);
scene.add(point);
}
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000);
const controls = new OrbitControls(camera, renderer.domElement);
const loader = new GLTFLoader();
configureRunewakerTerrainLoader(loader);
configureRunewakerSurfaceDetailLoader(loader);
configureRunewakerWaterLoader(loader);
const decoder = new KTX2Loader().setTranscoderPath("/basis/").detectSupport(renderer);
loader.setKTX2Loader(decoder).setMeshoptDecoder(MeshoptDecoder);
const root = new THREE.Group();
root.position.fromArray(definition.transform.position);
root.rotation.fromArray([...definition.transform.rotation, "XYZ"]);
root.scale.setScalar(definition.transform.scale);
scene.add(root);
for (const asset of definition.assets.visual) {
const assetUrl = id === "the-origin" && params.get("stage") === "origin-water-cliffs"
? "/artifacts/playtest/rom-continuation/origin-full-ground/the-origin-full-native-water-cliffs-optimized.glb"
: id === "the-origin" && params.get("stage") === "origin-full-native-water"
? "/artifacts/playtest/rom-continuation/origin-full-ground/the-origin-full-native-water-optimized.glb"
: id === "the-origin" && params.get("stage") === "origin-full-ground"
? "/artifacts/playtest/rom-continuation/origin-full-ground/the-origin-full-native-optimized.glb"
: params.get("stage") === "converted"
? `/runewaker-export-work/${id}/converted/${id}-visual.glb`
: id === "sand-vortex" && params.get("stage") === "cliff-before"
? "/artifacts/rom-environment-quality-audit/sand-vortex-cliffs/before-cliff-projection.glb"
: `/src/assets/game/dungeons/${id}/${asset.fileName}`;
const gltf = await loader.loadAsync(assetUrl);
gltf.scene.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
node.material = Array.isArray(node.material)
? node.material.map((material) => prepareDungeonMaterial(material, p.materials, definition.environmentMode))
: prepareDungeonMaterial(node.material, p.materials, definition.environmentMode);
});
if (!params.has("unrepaired")) repairHallOfSurvivorsVisual(gltf.scene, id);
applyDungeonMeshGraphics(gltf.scene, "environment", "high", 16, renderer.capabilities.getMaxAnisotropy());
root.add(gltf.scene);
}
const viewpoints = [
{ name: "entrance", position: definition.entrance.footPosition, forward: definition.entrance.forward },
...definition.bosses.map((boss) => ({ name: boss.name, position: boss.position, forward: definition.entrance.forward })),
];
function view(index: number, yaw = 0) {
const entry = viewpoints[Math.min(index, viewpoints.length - 1)];
sourceLightingPoint = entry.position;
updateSourceLighting();
const eye = new THREE.Vector3(...entry.position).add(new THREE.Vector3(0, 2.2, 0));
const forward = new THREE.Vector3(...entry.forward).normalize().applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw);
camera.position.copy(eye);
controls.target.copy(eye).add(forward.multiplyScalar(8)).add(new THREE.Vector3(0, -1.2, 0));
controls.update();
document.querySelector("#status")!.textContent = `${definition.title} · ${entry.name} · ${params.has("before") ? "previous lighting" : "revised lighting"} · 16× filtering / High shadows`;
}
view(Number(params.get("view") ?? 0));
let frames = 0;
let frameStart = performance.now();
renderer.setAnimationLoop(() => {
controls.update();
light.target.position.copy(camera.position);
if (resolvedSourceLighting) {
sourceDirections.forEach((source, index) => {
source.target.position.copy(camera.position);
source.position.copy(camera.position).addScaledVector(new THREE.Vector3(...resolvedSourceLighting!.directionalLights[index].toLightDirection), 36);
source.target.updateMatrixWorld();
});
}
light.position.copy(camera.position).add(new THREE.Vector3(...p.directionalLight.offset));
renderer.render(scene, camera);
frames += 1;
});
function lighting(before: boolean) {
sourceLightingEnabled = !before;
updateSourceLighting();
const selected = before ? original!.presentation : definition.presentation;
ambient.color.set(selected.ambientLight.color);
ambient.intensity = selected.ambientLight.intensity;
hemisphere.color.set(selected.hemisphereLight.skyColor);
hemisphere.groundColor.set(selected.hemisphereLight.groundColor);
hemisphere.intensity = selected.hemisphereLight.intensity;
light.color.set(selected.directionalLight.color);
light.intensity = selected.directionalLight.intensity;
scene.background = new THREE.Color(selected.fog.color);
scene.fog = new THREE.Fog(selected.fog.color, selected.fog.near, selected.fog.far);
if (resolvedSourceLighting) applyRunewakerNativeLighting(scene, sourceAmbient, sourceDirections, resolvedSourceLighting);
const status = document.querySelector("#status")!;
status.textContent = status.textContent!.replace(/previous lighting|revised lighting/, before ? "previous lighting" : "revised lighting");
}
function meshDiagnostics() {
const meshes: unknown[] = [];
root.updateMatrixWorld(true);
root.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
const bounds = new THREE.Box3().setFromObject(node);
meshes.push({ name: node.name, material: Array.isArray(node.material) ? node.material.map((entry) => entry.name) : node.material.name,
instanced: (node as THREE.InstancedMesh).isInstancedMesh ?? false, min: bounds.min.toArray(), max: bounds.max.toArray() });
});
return meshes;
}
function floorProbe() {
root.updateMatrixWorld(true);
const ray = new THREE.Raycaster();
const position = new THREE.Vector3(...definition.entrance.footPosition);
const results: unknown[] = [];
for (const forward of [0, 3, 8, 15, 25]) {
const origin = position.clone().addScaledVector(new THREE.Vector3(...definition.entrance.forward), forward).add(new THREE.Vector3(0, 5, 0));
ray.set(origin, new THREE.Vector3(0, -1, 0));
const hit = ray.intersectObject(root, true)[0];
if (!hit?.face) continue;
const mesh = hit.object as THREE.Mesh;
const uv = mesh.geometry.getAttribute("uv");
const material = Array.isArray(mesh.material) ? mesh.material[hit.face.materialIndex] : mesh.material;
const map = (material as THREE.MeshStandardMaterial).map;
results.push({ forward, point: hit.point.toArray(), mesh: mesh.name, material: material.name, texture: map?.name,
face: hit.face, uv: hit.uv?.toArray(), triangleUvs: uv ? [hit.face.a, hit.face.b, hit.face.c].map((i) => [uv.getX(i), uv.getY(i)]) : null });
}
return results;
}
function resetFrameSample() {
frames = 0;
frameStart = performance.now();
}
function frameSample() {
const elapsedMs = performance.now() - frameStart;
return { frames, elapsedMs, fps: elapsedMs > 0 ? frames * 1000 / elapsedMs : null };
}
function inspectAt(position: [number, number, number], target: [number, number, number]) {
sourceLightingPoint = target;
updateSourceLighting();
camera.position.fromArray(position);
controls.target.fromArray(target);
controls.update();
camera.updateMatrixWorld(true);
document.querySelector("#status")!.textContent = `${definition.title} · mapping hotspot review · revised lighting`;
}
function raycastAt(x: number, y: number, limit = 6) {
// Diagnostic scans may change several poses between animation frames.
// Raycaster must use the selected pose, not the preceding rendered matrix.
camera.updateMatrixWorld(true);
const ray = new THREE.Raycaster();
ray.setFromCamera(new THREE.Vector2(x / innerWidth * 2 - 1, 1 - y / innerHeight * 2), camera);
return ray.intersectObject(root, true).slice(0, limit).map(hit => {
const mesh = hit.object as THREE.Mesh;
const material = Array.isArray(mesh.material) ? mesh.material[hit.face?.materialIndex ?? 0] : mesh.material;
return { node: mesh.name, instanceId: hit.instanceId ?? null, material: material.name,
texture: (material as THREE.MeshStandardMaterial).map?.name ?? null,
nativeSourceModel: material.userData.nativeSourceModel ?? null,
nativeMaterialIndex: material.userData.nativeMaterialIndex ?? null,
alphaTest: material.alphaTest, transparent: material.transparent,
point: hit.point.toArray(), uv: hit.uv?.toArray() ?? null, distance: hit.distance };
});
}
function waterDiagnostics() {
const water: unknown[] = [];
root.traverse(node => {
if (!(node instanceof THREE.Mesh)) return;
for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
const uniforms = (material as THREE.Material & { uniforms?: Record<string, { value: unknown }> }).uniforms;
const frame = uniforms?.romWaterNormalFrame?.value;
if (!(frame instanceof THREE.Texture)) continue;
const bounds = new THREE.Box3().setFromObject(node);
water.push({ node: node.name, min: bounds.min.toArray(), max: bounds.max.toArray(),
elapsed: uniforms?.romWaterElapsedSeconds.value, frame: frame.name, uuid: frame.uuid,
fullModelEnabled: material.userData.runewakerWater?.fullModelEnabled ?? false,
sourceSplitPlaneHeight: node.userData.runewakerWaterSourceSplitPlaneHeight ?? null,
screenEffectsReady: uniforms?.romWaterScreenEffectsReady?.value ?? 0,
reflection: (uniforms?.romWaterReflection?.value as THREE.Texture | null)?.uuid ?? null,
refraction: (uniforms?.romWaterRefraction?.value as THREE.Texture | null)?.uuid ?? null });
}
});
return water;
}
Object.assign(window, { romEnvironmentReview: { ready: true, id, viewpoints, view, inspectAt, raycastAt, lighting, floorProbe, meshDiagnostics, waterDiagnostics, resetFrameSample, frameSample, presentation: p,
sourceLighting: () => resolvedSourceLighting,
enableSourceLighting: (enabled: boolean) => { sourceLightingEnabled = enabled; updateSourceLighting(); },
drawCalls: () => renderer.info.render.calls } });
@@ -0,0 +1,203 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const recipesDirectory = path.join(scriptDirectory, "recipes");
const sourceRoot = path.resolve(
process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "rom-pvt-server-backups", "Runewaker"),
);
const outputRoot = path.resolve(
process.argv.find((value) => value.startsWith("--output="))?.slice("--output=".length)
?? path.join(projectRoot, "runewaker-export-work", "native-lighting-source"),
);
const executable = path.join(outputRoot, "bin", "runewaker-zone-lighting-exporter.exe");
const sourceMeters = 0.1;
function stable(value) {
return JSON.stringify(value, null, 2) + "\n";
}
function round(value) {
return Number(Number(value).toFixed(9));
}
function requireVector(value, label) {
if (!Array.isArray(value) || value.length !== 3 || value.some((component) => !Number.isFinite(Number(component)))) {
throw new Error(`${label} must be a three-number vector.`);
}
return value.map(Number);
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
async function sha256(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
async function findPrimaryPlacement(slug) {
const candidates = [
path.join(projectRoot, "runewaker-export-work", slug, "converted", "import-metadata.json"),
path.join(projectRoot, "src", "assets", "game", "dungeons", slug, "import-metadata.json"),
];
for (const candidate of candidates) {
try {
const metadata = await readJson(candidate);
const placement = metadata.source?.wdbReport?.primaryPlacement;
if (placement) return placement;
} catch {}
}
const legacy = await readJson(path.join(projectRoot, "runewaker-export-work", slug, "inspection", "wdb-environment-candidates.json"));
return legacy.recommendation?.placement ?? null;
}
async function parseLuaEntry(recipe) {
const prefix = recipe.source?.entryConstantPrefix;
const relative = recipe.source?.entryLua;
if (!prefix || !relative) return null;
const lua = await readFile(path.join(sourceRoot, relative), "utf8");
const readConstant = (suffix) => {
const match = lua.match(new RegExp(`^\\s*${prefix}_${suffix}\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)`, "m"));
if (!match) throw new Error(`${recipe.slug}: missing ${prefix}_${suffix} in ${relative}`);
return Number(match[1]);
};
return [readConstant("X"), readConstant("Y"), readConstant("Z")];
}
async function resolveAuthoredEntry(recipe) {
const configured = recipe.source?.entryCoordinates?.position;
if (configured) return { position: requireVector(configured, `${recipe.slug} entry`), evidence: recipe.source.entryCoordinates.evidence ?? "recipe source entry" };
const luaPosition = await parseLuaEntry(recipe);
if (luaPosition) return { position: luaPosition, evidence: `${recipe.source.entryLua}:${recipe.source.entryConstantPrefix}_X/Y/Z` };
return null;
}
async function nativeDump(wdbFile, outFile, probe) {
const args = [wdbFile, outFile];
if (probe) args.push(...probe.map((value) => String(round(value))));
execFileSync(executable, args, { stdio: "inherit" });
return readJson(outFile);
}
const recipeFiles = (await readdir(recipesDirectory))
.filter((file) => file.endsWith(".json") && !file.endsWith("-population.json"))
.sort();
const recipes = await Promise.all(recipeFiles.map((file) => readJson(path.join(recipesDirectory, file))));
const dungeons = {};
const missing = [];
await mkdir(path.join(outputRoot, "dumps"), { recursive: true });
for (const recipe of recipes) {
if (!recipe.slug || !recipe.source?.wdb) continue;
const wdbFile = path.resolve(sourceRoot, recipe.source.wdb);
const primaryPlacement = await findPrimaryPlacement(recipe.slug);
if (!primaryPlacement) throw new Error(`${recipe.slug}: primary WDB placement evidence is missing.`);
const wdbSha256 = await sha256(wdbFile);
const authoredEntry = await resolveAuthoredEntry(recipe);
const reviewedEntryPosition = recipe.source?.reviewedRuntimeEntrance?.sourcePosition
? requireVector(recipe.source.reviewedRuntimeEntrance.sourcePosition, `${recipe.slug} reviewed runtime entry`)
: null;
const dumpFile = path.join(outputRoot, "dumps", `${recipe.slug}.json`);
const native = await nativeDump(wdbFile, dumpFile, authoredEntry?.position ?? null);
const entrySelection = native.entryZoneSelection;
const entryCovered = Boolean(entrySelection?.triggerZoneId != null && entrySelection?.settingsZoneId != null);
const entryEvidence = authoredEntry
? { sourcePosition: authoredEntry.position, kind: "source-authored-entry", evidence: authoredEntry.evidence }
: null;
let reviewedRuntimeEntry = null;
if (reviewedEntryPosition) {
const reviewedFile = path.join(outputRoot, "dumps", `${recipe.slug}-runtime-entry.json`);
const reviewedDump = await nativeDump(wdbFile, reviewedFile, reviewedEntryPosition);
reviewedRuntimeEntry = {
sourcePosition: reviewedEntryPosition,
evidence: recipe.source.reviewedRuntimeEntrance.sourcePathNode?.graphKey
? `reviewed source path ${recipe.source.reviewedRuntimeEntrance.sourcePathNode.graphKey} node ${recipe.source.reviewedRuntimeEntrance.sourcePathNode.nodeId}`
: "reviewed runtime entrance metadata",
selection: reviewedDump.entryZoneSelection,
covered: Boolean(reviewedDump.entryZoneSelection?.triggerZoneId != null && reviewedDump.entryZoneSelection?.settingsZoneId != null),
};
}
if (native.zoneTriggerCount !== native.zoneTriggers.length) {
throw new Error(`${recipe.slug}: native exporter omitted zone triggers (${native.zoneTriggerCount} reported, ${native.zoneTriggers.length} written).`);
}
const metadataWdbSha = (await findMetadataWdbSha(recipe.slug)) ?? null;
if (metadataWdbSha && metadataWdbSha.toLowerCase() !== wdbSha256) {
throw new Error(`${recipe.slug}: import metadata WDB hash does not match the source file.`);
}
dungeons[recipe.slug] = {
source: {
wdb: recipe.source.wdb.replaceAll("\\", "/"),
wdbSha256,
primaryPlacement: {
scale: requireVector(primaryPlacement.scale, `${recipe.slug} primary scale`),
rotation: primaryPlacement.rotation.map(Number),
translation: requireVector(primaryPlacement.translation, `${recipe.slug} primary translation`),
},
metersPerSourceUnit: Number(recipe.coordinateSystem?.metersPerSourceUnit ?? sourceMeters),
coordinateSystem: recipe.coordinateSystem?.source ?? "left-handed Y-up",
},
zones: native.zones,
zoneTriggers: native.zoneTriggers,
authoredEntry: entryEvidence ? { ...entryEvidence, selection: entrySelection, covered: entryCovered } : null,
...(reviewedRuntimeEntry ? { reviewedRuntimeEntry } : {}),
evidence: {
sourceEngineZoneSettings: true,
sourceEngineZoneTriggerQuery: true,
zoneTriggerCount: native.zoneTriggerCount,
sourceDescriptorCount: native.zoneDescriptorCount,
deterministicDefaults: { timeGroup: 0, weatherGroup: 0 },
triggerSelection: "highest priority; closest vertical-segment hit; child zone on exact tie",
},
};
if (!entryCovered) missing.push({ slug: recipe.slug, reason: "authored entry did not resolve to a trigger and zone settings", triggerCount: native.zoneTriggerCount });
if (reviewedRuntimeEntry && !reviewedRuntimeEntry.covered) missing.push({ slug: recipe.slug, reason: "reviewed runtime entry did not resolve to a trigger and zone settings", triggerCount: native.zoneTriggerCount });
}
async function findMetadataWdbSha(slug) {
const candidates = [
path.join(projectRoot, "runewaker-export-work", slug, "converted", "import-metadata.json"),
path.join(projectRoot, "src", "assets", "game", "dungeons", slug, "import-metadata.json"),
];
for (const candidate of candidates) {
try { return (await readJson(candidate)).source?.wdbSha256 ?? null; } catch {}
}
return null;
}
const manifest = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
sourceEngine: "Runewaker Rune Engine WDB zone descriptors and trigger QueryCollision",
runtimeDefaults: { timeGroup: 0, weatherGroup: 0 },
colorPolicy: "ARGB bytes are normalized directly to [0,1], matching CRuColor4(RUCOLOR); no sRGB transfer is applied by Rune Engine.",
directionPolicy: "Native source vector convention from RuMathHelper_GenerateLightingVector; runtime resolver adapts vectors through inverse WDB placement, Z reflection, and dungeon transform.",
fogPolicy: "When fog end > 1, Rune Engine divides raw start/end by 1600 then multiplies by camera far distance; raw values and factors are retained.",
lightModel: "Zone settings synthesize ambient plus three diffuse directional lights; specular color/direction are separate renderer properties.",
limitations: [
"CRuEntity_Light serialization is a no-op; no serialized ROS point/spot/directional light records were recovered.",
"RuLightSettings has no cast-shadow field; source zone data does not author runtime shadow policy.",
"Rune Engine computes SH lighting from active lights at runtime; zone settings contain no precomputed SH coefficients.",
],
coverage: { dungeonCount: Object.keys(dungeons).length, entryMisses: missing },
dungeons,
};
await writeFile(path.join(outputRoot, "lighting-source-report.json"), stable(manifest));
const generatedFile = path.join(projectRoot, "src", "game", "generated", "runewakerNativeLighting.generated.ts");
const moduleText = [
"/* Generated by scripts/runewaker-pipeline/export-native-lighting.mjs. */",
`export const RUNEWAKER_NATIVE_LIGHTING_SOURCE = (${JSON.stringify({ schemaVersion: 1, runtimeDefaults: manifest.runtimeDefaults, dungeons }, null, 2)}) as const;`,
"",
].join("\n");
await writeFile(generatedFile, moduleText, "utf8");
console.log(`Exported native zone lighting for ${Object.keys(dungeons).length} RoM dungeons.`);
if (missing.length) {
console.warn(`${missing.length} entry probes lack a selected zone/settings pair; the runtime resolver returns null there and keeps the existing profile.`);
}
@@ -45,6 +45,22 @@ for (const dungeon of dungeons) {
fileName: entry.fileName,
checksum: entry.sha256,
size: entry.size,
...(entry.role === "navigation" && recast.detourTopology ? { detourTopology: {
schemaVersion: recast.detourTopology.schemaVersion,
polygonCount: recast.detourTopology.polygonCount,
directedLinkCount: recast.detourTopology.directedLinkCount,
detailTriangleCount: recast.detourTopology.detailTriangleCount,
assetChecksum: entry.sha256,
} } : {}),
...(entry.role === "navigation" && recast.navigationComponentPolicy ? (() => {
if (recast.navigationComponentPolicy !== "all-components") {
throw new Error(dungeon.slug + " has an unsupported navigation component policy: " + recast.navigationComponentPolicy);
}
if (recast.detourTopology) {
throw new Error(dungeon.slug + " cannot combine inferred all-components navigation with source-pinned Detour topology.");
}
return { navigationComponentPolicy: recast.navigationComponentPolicy };
})() : {}),
triangleCount: entry.role === "visual"
? conversion.visual.triangles
: entry.role === "navigation"
@@ -66,6 +82,12 @@ for (const dungeon of dungeons) {
const far = Math.max(220, Math.ceil(Math.hypot(width, height) * 0.85));
const warnings = [
...environmentRecipe.exceptions,
...(environmentRecipe.source.reviewedRuntimeEntrance ? [
"Runtime entry uses reviewed source path graph "
+ environmentRecipe.source.reviewedRuntimeEntrance.sourcePathNode.graphKey
+ " node " + environmentRecipe.source.reviewedRuntimeEntrance.sourcePathNode.nodeId
+ "; the authoritative Lua spawn and direction remain in the environment import metadata.",
] : []),
...((actorManifest.proceduralFallbacks ?? []).length ? [
String(actorManifest.proceduralFallbacks.length)
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
@@ -0,0 +1,341 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const work = path.join(projectRoot, "runewaker-export-work", "aeternal-circle");
const inspection = path.join(work, "inspection");
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json"), "utf8"));
const metadata = JSON.parse(await readFile(path.join(projectRoot, "src/assets/game/dungeons/aeternal-circle/import-metadata.json"), "utf8"));
const sourceReport = JSON.parse(await readFile(path.join(work, "prepared/export-report.json"), "utf8"));
const wdbReport = JSON.parse(await readFile(path.join(work, "raw/wdb-report.json"), "utf8"));
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(path.join(work, "converted/aeternal-circle-visual.glb"));
const root = document.getRoot();
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const pngDimensions = (bytes) => bytes.toString("hex", 0, 8) === "89504e470d0a1a0a"
? [bytes.readUInt32BE(16), bytes.readUInt32BE(20)]
: null;
const normalizeResource = (value) => String(value ?? "").replaceAll("/", "\\").toLowerCase();
const xyz = (matrix, point) => [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
const subtract = (a, b) => a.map((value, index) => value - b[index]);
const cross = (a, b) => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const normalize = (value) => {
const length = Math.hypot(...value);
return length ? value.map((component) => component / length) : [0, 0, 0];
};
const primary = wdbReport.descriptors.find((descriptor) => (
normalizeResource(descriptor.resource) === normalizeResource(recipe.source.model)
&& descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001)
));
if (!primary) throw new Error("Aeternal primary WDB placement could not be matched to the recipe.");
const sourceAnchor = recipe.source.sourceAnchorSupport.points[0];
const supportNode = root.listNodes().find((node) => node.getName() === "runewaker_source_anchor_support");
if (!supportNode?.getMesh()) throw new Error("Aeternal source-anchor support mesh is missing from the canonical converted visual.");
const supportPrimitive = supportNode.getMesh().listPrimitives()[0];
const supportPosition = supportPrimitive.getAttribute("POSITION");
const supportIndices = supportPrimitive.getIndices()?.getArray();
if (!supportPosition || !supportIndices) throw new Error("Aeternal support mesh is missing positions or indices.");
const supportMatrix = Array.from(supportNode.getWorldMatrix());
const supportAdjacency = Array.from({ length: supportPosition.getCount() }, () => new Set());
for (let index = 0; index < supportIndices.length; index += 3) {
const [a, b, c] = supportIndices.slice(index, index + 3);
supportAdjacency[a].add(b); supportAdjacency[a].add(c);
supportAdjacency[b].add(a); supportAdjacency[b].add(c);
supportAdjacency[c].add(a); supportAdjacency[c].add(b);
}
const supportComponents = [];
const visited = new Uint8Array(supportPosition.getCount());
for (let start = 0; start < visited.length; start += 1) {
if (visited[start]) continue;
const vertices = [start];
visited[start] = 1;
for (let cursor = 0; cursor < vertices.length; cursor += 1) {
for (const next of supportAdjacency[vertices[cursor]]) {
if (visited[next]) continue;
visited[next] = 1;
vertices.push(next);
}
}
const points = vertices.map((index) => xyz(supportMatrix, supportPosition.getElement(index, [0, 0, 0])));
const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
supportComponents.push({
vertices,
min,
max,
center: [(min[0] + max[0]) / 2, max[1], (min[2] + max[2]) / 2],
});
}
const expectedAnchorApprox = [
(sourceAnchor[0] - primary.translation[0]) * recipe.coordinateSystem.metersPerSourceUnit,
(sourceAnchor[1] - primary.translation[1]) * recipe.coordinateSystem.metersPerSourceUnit,
(sourceAnchor[2] - primary.translation[2]) * recipe.coordinateSystem.metersPerSourceUnit,
];
const pad = supportComponents
.map((component) => ({
...component,
distanceXZ: Math.hypot(component.center[0] - expectedAnchorApprox[0], component.center[2] - expectedAnchorApprox[2]),
}))
.sort((a, b) => a.distanceXZ - b.distanceXZ)[0];
if (!pad || pad.distanceXZ > 0.02) throw new Error(`Could not match the first source spawn anchor to one support pad (nearest ${pad?.distanceXZ ?? "none"}m).`);
const candidateTriangles = [];
for (const node of root.listNodes()) {
if (!node.getMesh() || node === supportNode) continue;
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const positions = primitive.getAttribute("POSITION");
const indices = primitive.getIndices()?.getArray();
if (!positions || !indices) continue;
const uv = primitive.getAttribute("TEXCOORD_0");
const material = primitive.getMaterial()?.getName() ?? "unassigned";
for (let index = 0; index < indices.length; index += 3) {
const triangleIndices = [indices[index], indices[index + 1], indices[index + 2]];
const [a, b, c] = triangleIndices.map((vertex) => xyz(matrix, positions.getElement(vertex, [0, 0, 0])));
const normal = normalize(cross(subtract(b, a), subtract(c, a)));
if (normal[1] < 0.75) continue;
if (Math.max(a[0], b[0], c[0]) < pad.min[0] || Math.min(a[0], b[0], c[0]) > pad.max[0]
|| Math.max(a[2], b[2], c[2]) < pad.min[2] || Math.min(a[2], b[2], c[2]) > pad.max[2]) continue;
if (Math.min(a[1], b[1], c[1]) > pad.max[1] + 0.001 || Math.max(a[1], b[1], c[1]) < pad.max[1] - 12) continue;
candidateTriangles.push({ node: node.getName(), material, indices: triangleIndices, positions, uv, a, b, c });
}
}
}
function barycentricXZ(x, z, a, b, c) {
const edgeAB = [b[0] - a[0], b[2] - a[2]];
const edgeAC = [c[0] - a[0], c[2] - a[2]];
const point = [x - a[0], z - a[2]];
const d00 = edgeAB[0] ** 2 + edgeAB[1] ** 2;
const d01 = edgeAB[0] * edgeAC[0] + edgeAB[1] * edgeAC[1];
const d11 = edgeAC[0] ** 2 + edgeAC[1] ** 2;
const d20 = point[0] * edgeAB[0] + point[1] * edgeAB[1];
const d21 = point[0] * edgeAC[0] + point[1] * edgeAC[1];
const denominator = d00 * d11 - d01 * d01;
if (Math.abs(denominator) < 1e-12) return null;
const v = (d11 * d20 - d01 * d21) / denominator;
const w = (d00 * d21 - d01 * d20) / denominator;
return [1 - v - w, v, w];
}
const nativeMaterialMap = new Map(sourceReport.materials.map((material) => [material.name, material]));
const sampleHits = [];
for (const dx of [-5.5, -3, 0, 3, 5.5]) {
for (const dz of [-5.5, -3, 0, 3, 5.5]) {
const x = pad.center[0] + dx;
const z = pad.center[2] + dz;
let highest = null;
for (const triangle of candidateTriangles) {
const barycentric = barycentricXZ(x, z, triangle.a, triangle.b, triangle.c);
if (!barycentric || barycentric.some((weight) => weight < -1e-5)) continue;
const y = barycentric[0] * triangle.a[1] + barycentric[1] * triangle.b[1] + barycentric[2] * triangle.c[1];
if (y > pad.max[1] + 0.001 || y < pad.max[1] - 12) continue;
if (highest && y <= highest.y) continue;
let uv = null;
if (triangle.uv) {
const values = triangle.indices.map((index) => triangle.uv.getElement(index, [0, 0]));
uv = [0, 1].map((axis) => values[0][axis] * barycentric[0] + values[1][axis] * barycentric[1] + values[2][axis] * barycentric[2]);
}
highest = {
y,
dropFromSupportTopMeters: pad.max[1] - y,
node: triangle.node,
nativeMaterial: triangle.material,
nativeTexture: nativeMaterialMap.get(triangle.material)?.sourceTexture ?? null,
uv0: uv,
};
}
sampleHits.push({ offsetFromPadCenterMeters: [dx, dz], ...(highest ?? { nativeSurface: null }) });
}
}
const hitSummary = new Map();
for (const hit of sampleHits) {
const name = hit.nativeMaterial ?? "no-native-upward-surface";
const record = hitSummary.get(name) ?? { count: 0, nodeNames: new Set(), texture: hit.nativeTexture ?? null, uv0: [] };
record.count += 1;
if (hit.node) record.nodeNames.add(hit.node);
if (hit.uv0) record.uv0.push(hit.uv0);
hitSummary.set(name, record);
}
const summarizeUv = (values) => values.length ? {
sampleCount: values.length,
min: [0, 1].map((axis) => Math.min(...values.map((uv) => uv[axis]))),
max: [0, 1].map((axis) => Math.max(...values.map((uv) => uv[axis]))),
} : null;
const atlasCandidates = ["station", "building_statue", "convey_ground", "production_center_ground_01", "ground_ceiling"];
const textures = [];
for (const leaf of atlasCandidates) {
const source = sourceReport.materials.find((material) => material.sourceTexture?.toLowerCase().endsWith(`\\${leaf}`));
if (!source) continue;
const stagedName = path.basename(source.stagedTexture);
const stem = path.parse(stagedName).name;
const dds = await readFile(path.join(work, "raw/textures", `${stem}.dds`));
const png = await readFile(path.join(work, "prepared/textures", `${stem}.png`));
const gltfMaterial = root.listMaterials().find((material) => material.getName() === source.name);
const image = Buffer.from(gltfMaterial?.getBaseColorTexture()?.getImage() ?? []);
textures.push({
sourceTexture: source.sourceTexture,
nativeMaterial: source.name,
stagedDds: stagedName,
ddsSha256: sha256(dds),
preparedPngSha256: sha256(png),
gltfImageSha256: sha256(image),
imageSize: pngDimensions(png),
atlasAssessment: {
station: "multi-region atlas: floor-like slate patch plus gold and trim regions",
building_statue: "multi-region statue/architecture atlas, not a full-tile floor",
convey_ground: "512-square repeated 2x2 stone-tile pattern, but the matching ROS surface is remote from this entry pad",
production_center_ground_01: "multi-region architecture/ground atlas",
ground_ceiling: "multi-region tile/ornament/trim atlas",
}[leaf],
});
}
const candidateMaterialNames = new Set(textures.map((texture) => texture.nativeMaterial));
const candidateMeshLocations = [];
for (const node of root.listNodes()) {
if (!node.getMesh()) continue;
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const material = primitive.getMaterial()?.getName();
if (!candidateMaterialNames.has(material)) continue;
const positions = primitive.getAttribute("POSITION");
const indices = primitive.getIndices()?.getArray();
if (!positions || !indices) continue;
const points = Array.from({ length: positions.getCount() }, (_, index) => xyz(matrix, positions.getElement(index, [0, 0, 0])));
const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
let upwardTriangleCount = 0;
for (let index = 0; index < indices.length; index += 3) {
const [a, b, c] = [indices[index], indices[index + 1], indices[index + 2]].map((vertex) => points[vertex]);
if (normalize(cross(subtract(b, a), subtract(c, a)))[1] > 0.75) upwardTriangleCount += 1;
}
const center = min.map((value, axis) => (value + max[axis]) / 2);
candidateMeshLocations.push({
nativeMaterial: material,
sourceTexture: nativeMaterialMap.get(material)?.sourceTexture ?? null,
node: node.getName(),
primitiveTriangles: indices.length / 3,
upwardTriangleCount,
bounds: { min, max },
center,
entryPadCenterDistanceXZMeters: Math.hypot(center[0] - pad.center[0], center[2] - pad.center[2]),
});
}
}
const point = sourceAnchor;
const secondary = wdbReport.descriptors
.filter((descriptor) => descriptor.guid !== primary.guid)
.map((descriptor) => {
const center = descriptor.worldBounds?.center ?? descriptor.translation;
const dx = center[0] - point[0];
const dz = center[2] - point[2];
const distanceXZ = Math.hypot(dx, dz);
const radius = Number(descriptor.worldBounds?.radius ?? 0);
return {
guid: descriptor.guid,
resource: descriptor.resource,
translation: descriptor.translation,
worldBoundsCenter: center,
worldBoundsRadius: radius,
distanceXZSourceUnits: distanceXZ,
lowerBoundHorizontalGapSourceUnits: Math.max(0, distanceXZ - radius),
};
})
.sort((a, b) => a.distanceXZSourceUnits - b.distanceXZSourceUnits)
.slice(0, 10);
const output = {
schemaVersion: 1,
kind: "aeternal-circle-generated-support-floor-source-triage",
generatedFrom: {
recipe: "scripts/runewaker-pipeline/recipes/aeternal-circle.json",
recipeWdbPolicy: recipe.review.wdbPlacements,
canonicalVisual: "runewaker-export-work/aeternal-circle/converted/aeternal-circle-visual.glb",
sourceModel: metadata.source.model,
sourceModelSha256: metadata.source.modelSha256,
sourceWdb: metadata.source.wdb,
sourceWdbSha256: metadata.source.wdbSha256,
sourceWdbDescriptorCount: wdbReport.descriptorCount,
recipeSupportAnchorCount: recipe.source.sourceAnchorSupport.points.length,
convertedSupportPrimitive: {
node: supportNode.getName(),
material: supportPrimitive.getMaterial()?.getName(),
vertices: supportPosition.getCount(),
triangles: supportIndices.length / 3,
disconnectedPadCount: supportComponents.length,
},
},
entryPad: {
sourceAnchor: point,
matchedWdbPrimary: {
guid: primary.guid,
translation: primary.translation,
rotation: primary.rotation,
scale: primary.scale,
},
actualConvertedSupportPad: {
worldCenterXZ: [pad.center[0], pad.center[2]],
topY: pad.max[1],
bounds: { min: pad.min, max: pad.max },
sideLengthMeters: [pad.max[0] - pad.min[0], pad.max[2] - pad.min[2]],
thicknessMeters: pad.max[1] - pad.min[1],
topSupportMeshTriangles: supportIndices.length / 3,
},
nativeUnderlyingSurfaceProbe: {
gridOffsetsMeters: [-5.5, -3, 0, 3, 5.5],
upwardNormalYThreshold: 0.75,
maximumBelowSupportTopMeters: 12,
sampleCount: sampleHits.length,
highestNativeSurfaceByMaterial: [...hitSummary.entries()].map(([nativeMaterial, record]) => ({
nativeMaterial,
sourceTexture: record.texture,
sampleCount: record.count,
nodes: [...record.nodeNames],
sampleUv0Bounds: summarizeUv(record.uv0),
dropRangeMeters: sampleHits.filter((hit) => hit.nativeMaterial === nativeMaterial)
.reduce((range, hit) => [Math.min(range[0], hit.dropFromSupportTopMeters), Math.max(range[1], hit.dropFromSupportTopMeters)], [Infinity, -Infinity]),
})),
samples: sampleHits,
interpretation: "Underlying upward-facing triangles vary between station and statue atlas meshes and do not form one continuous, same-height native floor across the generated support pad.",
},
},
candidateTextures: textures,
candidateTextureMeshLocations: candidateMeshLocations,
visibleReference: "artifacts/playtest/rom-environment-quality/aeternal-circle-view-0.png",
nearbyWdbPlacements: {
entriesAreMeasuredFrom: "first Aeternal support/source spawn anchor in native source units",
nearestSecondaryByBoundsCenter: secondary,
resourceNamesContainingGroundFloorTerrain: wdbReport.descriptors
.filter((descriptor) => /ground|floor|terrain/i.test(descriptor.resource))
.map((descriptor) => descriptor.resource),
interpretation: "No secondary WDB descriptor is explicitly named as floor/ground/terrain; nearest secondary geometry centers are tens of meters from the entry. This does not exclude ground surfaces embedded in large tower ROS resources or prove their placements irrelevant.",
},
recommendation: {
safeTextureFallbackProven: false,
nativeFloorRecoveryProven: false,
reason: "The adjacent native surfaces use multi-region station/statue atlases with different heights and UV islands. convey_ground is a visually repeatable tile but its matching primary-ROS mesh is over 200 m from the entry pad, so the evidence does not spatially justify borrowing it.",
nextEvidenceNeeded: "Review/export full secondary WDB placements and inspect walkable source triangles for a continuous native floor surface; only after that can a nearby repeatable source tile or native floor recovery be justified.",
},
};
await mkdir(inspection, { recursive: true });
const reportFile = path.join(inspection, "aeternal-derived-support-ground-triage.json");
await writeFile(reportFile, `${JSON.stringify(output, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ report: path.relative(projectRoot, reportFile), sourceModelSha256: metadata.source.modelSha256, sourceWdbSha256: metadata.source.wdbSha256, supportPads: supportComponents.length, sampleHits: Object.fromEntries([...hitSummary].map(([name, record]) => [name, record.count])) }, null, 2));
@@ -0,0 +1,60 @@
import { Document, NodeIO } from "@gltf-transform/core";
import { Matrix4, Vector3 } from "three";
import path from "node:path";
import { mkdir } from "node:fs/promises";
/** Preserve world-space source triangles while bounding each physical GLB. */
export async function writeBoundedCollisionGlbs(sourceFile, outputDirectory, slug, {
maximumTriangles = 90_000,
sourceSha256,
} = {}) {
if (!Number.isSafeInteger(maximumTriangles) || maximumTriangles < 1) {
throw new Error("Collision chunk limit must be a positive integer.");
}
const io = new NodeIO();
const source = await io.read(sourceFile);
const scene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
if (!scene) throw new Error("Native collision source has no scene.");
const positions = [];
scene.traverse((node) => {
const mesh = node.getMesh();
if (!mesh) return;
const matrix = new Matrix4().fromArray(node.getWorldMatrix());
const reverse = matrix.determinant() < 0;
for (const primitive of mesh.listPrimitives()) {
if (primitive.getMode() !== 4) throw new Error("Collision must contain triangle primitives.");
const position = primitive.getAttribute("POSITION");
const index = primitive.getIndices();
const count = index?.getCount() ?? position?.getCount() ?? 0;
if (!position || count % 3) throw new Error("Invalid collision triangle positions.");
for (let triangle = 0; triangle < count; triangle += 3) {
for (const corner of reverse ? [0, 2, 1] : [0, 1, 2]) {
const vertex = index ? index.getScalar(triangle + corner) : triangle + corner;
const point = new Vector3(...position.getElement(vertex, [])).applyMatrix4(matrix);
if (!point.toArray().every(Number.isFinite)) throw new Error("Non-finite collision coordinate.");
positions.push(...point.toArray());
}
}
}
});
if (!positions.length) throw new Error("Native collision source is empty.");
await mkdir(outputDirectory, { recursive: true });
const records = [];
for (let begin = 0; begin < positions.length; begin += maximumTriangles * 9) {
const document = new Document();
const buffer = document.createBuffer();
const targetScene = document.createScene("native-options-and-source-supports");
document.getRoot().setDefaultScene(targetScene);
const array = new Float32Array(positions.slice(begin, begin + maximumTriangles * 9));
const primitive = document.createPrimitive().setAttribute("POSITION", document.createAccessor("collision-positions", buffer).setType("VEC3").setArray(array));
targetScene.addChild(document.createNode("collision").setMesh(document.createMesh().addPrimitive(primitive)));
document.getRoot().setExtras({
collisionMode: "respect-options", sourceCandidateSha256: sourceSha256 ?? null,
triangleRange: [begin / 9, begin / 9 + array.length / 9],
});
const file = `${slug}-collision-${String(records.length).padStart(3, "0")}.glb`;
await io.write(path.join(outputDirectory, file), document);
records.push({ file, triangles: array.length / 9, nativeSerializedCollision: true, derivedSourceAnchorSupport: true });
}
return records;
}
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { writeBoundedCollisionGlbs } from "./bounded-collision-glb.mjs";
test("collision chunking preserves transformed triangles, reflected winding and hard caps", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-bounded-collision-"));
try {
const io = new NodeIO(), document = new Document(), buffer = document.createBuffer();
const position = document.createAccessor("position", buffer).setType("VEC3").setArray(new Float32Array([0,0,0, 1,0,0, 0,0,1]));
const indices = document.createAccessor("indices", buffer).setType("SCALAR").setArray(new Uint16Array([0,1,2, 0,1,2, 0,1,2]));
const scene = document.createScene(); document.getRoot().setDefaultScene(scene);
scene.addChild(document.createNode().setTranslation([10,2,3]).setScale([-1,1,1]).setMesh(document.createMesh().addPrimitive(document.createPrimitive().setAttribute("POSITION", position).setIndices(indices))));
const input = path.join(directory, "source.glb"); await io.write(input, document);
const records = await writeBoundedCollisionGlbs(input, directory, "fixture", { maximumTriangles: 2 });
assert.deepEqual(records.map(row => row.triangles), [2,1]);
const arrays = [];
for (const row of records) arrays.push(...(await io.read(path.join(directory, row.file))).getRoot().listAccessors()[0].getArray());
assert.deepEqual(arrays, Array(3).fill([10,2,3, 10,2,4, 9,2,3]).flat());
} finally {
assert.ok(path.resolve(directory).startsWith(path.resolve(os.tmpdir()) + path.sep)
&& path.basename(directory).startsWith("rom-bounded-collision-"));
await rm(directory, { recursive: true, force: true });
}
});
@@ -81,6 +81,31 @@ export function transformSourcePointToPrimaryThree(
return [point.x * meters, point.y * meters, -point.z * meters];
}
export function transformSourceHeadingToPrimaryThree(sourceYawRadians, primary) {
const yaw = Number(sourceYawRadians);
if (!Number.isFinite(yaw)) throw new Error("sourceYawRadians must be finite.");
const rotation = new Quaternion(...finiteTuple(primary?.rotation, 4, "primary.rotation"));
if (rotation.lengthSq() < 1e-12) throw new Error("primary.rotation cannot be zero.");
rotation.normalize().invert();
const scale = finiteTuple(primary?.scale, 3, "primary.scale");
if (scale.some((value) => Math.abs(value) < 1e-12)) {
throw new Error("primary.scale cannot contain zero.");
}
// RuneWaker's left-handed forward is -Z at yaw zero. Rotate in the source
// basis first, apply inverse primary scale, then reflect Z into Three.js and
// normalize the planar vector.
const forward = new Vector3(Math.sin(yaw), 0, -Math.cos(yaw)).applyQuaternion(rotation);
forward.set(forward.x / scale[0], forward.y / scale[1], forward.z / scale[2]);
forward.y = 0;
forward.z *= -1;
const length = Math.hypot(forward.x, forward.z);
if (!Number.isFinite(length) || length < 1e-8) {
throw new Error("Source heading collapsed when transformed into the primary frame.");
}
return [forward.x / length, 0, forward.z / length];
}
function cloneNodeTree(document, node) {
const clone = document.createNode(node.getName())
.setMatrix(node.getMatrix())
@@ -0,0 +1,25 @@
/**
* Preserve source topology because simplification damages the atlas UV seams
* used by RoM environment meshes. Keep optimization focused on mesh size and
* repeated placements. Texture work is disabled here because the CLI's
* default auto-compressor resizes images to 2048px; a separate KTX2 pass encodes
* the full-size source images without changing their dimensions.
*/
export function environmentOptimizationArguments(sourceFile, outputFile, {
preserveNativeWaterLayers = false,
preserveMaterialVariantCarriers = false,
} = {}) {
return [
"optimize", sourceFile, outputFile,
"--compress", "meshopt", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--simplify", "false",
"--texture-compress", "false",
// KHR_materials_variants can carry native water maps or browser-adapted
// surface-detail maps. Joining/pruning sees only the default material and
// can discard a carrier, its UVs, or its mapping before runtime selection.
...((preserveNativeWaterLayers || preserveMaterialVariantCarriers)
? ["--join", "false", "--prune", "false", "--prune-attributes", "false"]
: []),
];
}
@@ -0,0 +1,22 @@
export function preparedAlphaTextureFilename(imageName) {
const name = String(imageName ?? "");
return /\.png$/iu.test(name) ? name : `${name}.png`;
}
function normalizedRole(role) {
if (role === "cutout" || role === "mask") return "mask";
if (role === "blend" || role === "additive") return "blend";
return role;
}
export function alphaSemanticRolesMatch(expectedRole, actualRole) {
return normalizedRole(expectedRole) === normalizedRole(actualRole);
}
export function effectiveAlphaMaterialRole(semanticRole, alphaMode) {
return semanticRole ?? (alphaMode === "OPAQUE" ? "opaque" : "missing");
}
export function alphaMaterialMatches(expectedAlphaMode, expectedRole, actualAlphaMode, actualRole) {
return expectedAlphaMode === actualAlphaMode && alphaSemanticRolesMatch(expectedRole, actualRole);
}
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
alphaMaterialMatches,
alphaSemanticRolesMatch,
effectiveAlphaMaterialRole,
preparedAlphaTextureFilename,
} from "./native-alpha-audit-helpers.mjs";
test("prepared texture lookup does not append .png to an image name that already has it", () => {
assert.equal(preparedAlphaTextureFilename("ae07_grass03"), "ae07_grass03.png");
assert.equal(preparedAlphaTextureFilename("ae07_grass03.png"), "ae07_grass03.png");
assert.equal(preparedAlphaTextureFilename("ae07_grass03.PNG"), "ae07_grass03.PNG");
});
test("alpha audit treats source cutout/mask and blend/additive labels as equivalent while checking alpha mode", () => {
assert.equal(alphaSemanticRolesMatch("cutout", "mask"), true);
assert.equal(alphaSemanticRolesMatch("blend", "additive"), true);
assert.equal(alphaMaterialMatches("MASK", "cutout", "MASK", "mask"), true);
assert.equal(alphaMaterialMatches("BLEND", "blend", "BLEND", "additive"), true);
assert.equal(alphaMaterialMatches("BLEND", "blend", "OPAQUE", "additive"), false);
assert.equal(alphaSemanticRolesMatch("opaque", "mask"), false);
assert.equal(effectiveAlphaMaterialRole(undefined, "OPAQUE"), "opaque");
assert.equal(effectiveAlphaMaterialRole(undefined, "BLEND"), "missing");
});
@@ -0,0 +1,98 @@
const SHA256 = /^[\da-f]{64}$/i;
const REQUIRED_ALPHA_PARAMETERS = ["alphaTestMode", "alphaMode", "translucent", "alpha", "alphaModifier"];
function normalizeResource(value) {
return String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
}
function reportErrors(report, label, expectedResource, expectedSourceSha256) {
const errors = [];
if (!report || typeof report !== "object") return [`${label} report is missing`];
if (normalizeResource(report.sourceModel) !== normalizeResource(expectedResource)) {
errors.push(`${label} source model does not match the cached resource`);
}
if (!SHA256.test(String(report.sourceResourceSha256 ?? ""))
|| report.sourceResourceSha256.toLowerCase() !== expectedSourceSha256.toLowerCase()) {
errors.push(`${label} source resource SHA-256 is missing or stale`);
}
if (!Array.isArray(report.materials) || report.materials.length === 0) {
errors.push(`${label} native material rows are missing`);
return errors;
}
for (const material of report.materials) {
const identity = String(material?.name ?? "");
if (!/^runewaker_material_\d+$/i.test(identity)) {
errors.push(`${label} has a material row without a native material index`);
continue;
}
if (!Array.isArray(material.sourceTextureChannels)) {
errors.push(`${label} ${identity} has no source texture channel inventory`);
}
const parameters = new Set((material.alphaBlendParameters ?? [])
.filter((parameter) => parameter && typeof parameter.name === "string")
.map((parameter) => parameter.name));
for (const required of REQUIRED_ALPHA_PARAMETERS) {
if (!parameters.has(required)) {
errors.push(`${label} ${identity} lacks native alpha parameter ${required}`);
}
}
const alphaValues = Object.fromEntries((material.alphaBlendParameters ?? [])
.filter(parameter => parameter && typeof parameter.name === "string")
.map(parameter => [parameter.name, parameter.value]));
if (alphaValues.alphaTestMode === 1
&& (!Number.isInteger(alphaValues.alphaTestValue) || alphaValues.alphaTestValue < 0 || alphaValues.alphaTestValue > 255)) {
errors.push(`${label} ${identity} lacks a valid native alpha-test cutoff`);
}
if (material.sourceTexture) {
const diffuse = (material.sourceTextureChannels ?? [])
.filter((channel) => String(channel?.name ?? "").toLowerCase() === "diffuse")
.map((channel) => normalizeResource(channel.texture));
if (!diffuse.includes(normalizeResource(material.sourceTexture))) {
errors.push(`${label} ${identity} diffuse channel does not match its source texture`);
}
}
}
return errors;
}
/** Fail closed when a resource cache predates exact ROS material-alpha provenance. */
export function validateNativeAlphaResourceCache({
manifest,
rawReport,
preparedReport,
expectedResource,
expectedSourceSha256,
expectedToolchain,
}) {
const errors = [];
if (!manifest || typeof manifest !== "object") {
errors.push("cache manifest is missing");
} else {
if (normalizeResource(manifest.source?.resource) !== normalizeResource(expectedResource)) {
errors.push("cache manifest source resource does not match");
}
if (!SHA256.test(String(expectedSourceSha256 ?? ""))
|| manifest.source?.sha256?.toLowerCase() !== expectedSourceSha256.toLowerCase()) {
errors.push("cache manifest source SHA-256 does not match");
}
for (const name of ["converterSha256", "exporterSha256", "nativeAlphaPolicySha256"]) {
const expected = expectedToolchain?.[name];
const actual = manifest.toolchain?.[name];
if (!SHA256.test(String(expected ?? "")) || actual?.toLowerCase() !== expected.toLowerCase()) {
errors.push(`cache toolchain ${name} is missing or stale`);
}
}
}
errors.push(...reportErrors(rawReport, "raw", expectedResource, expectedSourceSha256));
errors.push(...reportErrors(preparedReport, "prepared", expectedResource, expectedSourceSha256));
if (rawReport && preparedReport
&& JSON.stringify(rawReport.materials) !== JSON.stringify(preparedReport.materials)) {
errors.push("prepared material rows differ from the raw native exporter report");
}
return {
valid: errors.length === 0,
errors,
nativeMaterialCount: Array.isArray(rawReport?.materials) ? rawReport.materials.length : 0,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateNativeAlphaResourceCache } from "./native-alpha-cache-validation.mjs";
const sha = "a".repeat(64);
const toolchain = {
converterSha256: "b".repeat(64),
exporterSha256: "c".repeat(64),
nativeAlphaPolicySha256: "d".repeat(64),
};
function report() {
return {
sourceModel: "model\\element\\dungeon\\waterfall.ros",
sourceResourceSha256: sha,
materials: [{
name: "runewaker_material_9",
sourceTexture: "texture\\dungeon\\gate_waterfall",
sourceTextureChannels: [{ name: "diffuse", texture: "texture\\dungeon\\gate_waterfall" }],
alphaBlendParameters: [
{ name: "alphaTestMode", value: 0 },
{ name: "alphaMode", value: 0 },
{ name: "translucent", value: true },
{ name: "alpha", value: 1 },
{ name: "alphaModifier", value: 1 },
],
}],
};
}
function input(overrides = {}) {
const rawReport = report();
return {
manifest: {
source: { resource: "model/element/dungeon/waterfall.ros", sha256: sha },
toolchain,
},
rawReport,
preparedReport: structuredClone(rawReport),
expectedResource: "model/element/dungeon/waterfall.ros",
expectedSourceSha256: sha,
expectedToolchain: toolchain,
...overrides,
};
}
test("resource cache accepts matching raw/prepared native alpha provenance", () => {
const result = validateNativeAlphaResourceCache(input());
assert.deepEqual(result, { valid: true, errors: [], nativeMaterialCount: 1 });
});
test("resource cache rejects old missing toolchain and source hash stamps", () => {
const options = input();
delete options.manifest.toolchain;
delete options.rawReport.sourceResourceSha256;
delete options.preparedReport.sourceResourceSha256;
const result = validateNativeAlphaResourceCache(options);
assert.equal(result.valid, false);
assert.ok(result.errors.some((error) => error.includes("toolchain exporterSha256")));
assert.ok(result.errors.some((error) => error.includes("raw source resource SHA-256")));
assert.ok(result.errors.some((error) => error.includes("prepared source resource SHA-256")));
});
test("resource cache rejects native material rows missing alpha flags or source-channel joins", () => {
const options = input();
options.rawReport.materials[0].alphaBlendParameters = [];
options.preparedReport.materials[0].alphaBlendParameters = [];
options.rawReport.materials[0].sourceTextureChannels = [];
options.preparedReport.materials[0].sourceTextureChannels = [];
const result = validateNativeAlphaResourceCache(options);
assert.equal(result.valid, false);
assert.ok(result.errors.some((error) => error.includes("lacks native alpha parameter alphaMode")));
assert.ok(result.errors.some((error) => error.includes("diffuse channel does not match")));
});
test("resource cache rejects raw/prepared report drift even when stamps match", () => {
const options = input();
options.preparedReport.materials[0].alphaBlendParameters[2].value = false;
const result = validateNativeAlphaResourceCache(options);
assert.equal(result.valid, false);
assert.ok(result.errors.includes("prepared material rows differ from the raw native exporter report"));
});
test("resource cache cannot accept cutout material flags without the source cutoff", () => {
const options = input();
for (const report of [options.rawReport, options.preparedReport]) {
report.materials[0].alphaBlendParameters[0].value = 1;
}
assert.equal(validateNativeAlphaResourceCache(options).valid, false);
for (const report of [options.rawReport, options.preparedReport]) {
report.materials[0].alphaBlendParameters.push({ name: "alphaTestValue", value: 192 });
}
assert.equal(validateNativeAlphaResourceCache(options).valid, true);
});
@@ -0,0 +1,244 @@
import { createHash } from "node:crypto";
const SHA256 = /^[\da-f]{64}$/i;
const JSON_CHUNK = 0x4e4f534a;
const BIN_CHUNK = 0x004e4942;
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const normalizePath = (value) => String(value ?? "").replaceAll("/", "\\").replace(/^\\+/, "").toLowerCase();
const basename = (value) => String(value ?? "").replaceAll("\\", "/").split("/").at(-1).replace(/\.dds$/i, "").replace(/\.png$/i, "");
function parseGlb(buffer) {
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF" || buffer.readUInt32LE(4) !== 2) {
throw new Error("Input is not a glTF 2 GLB.");
}
const chunks = [];
let json = null;
let bin = null;
for (let offset = 12; offset + 8 <= buffer.length;) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const start = offset + 8;
const end = start + length;
if (end > buffer.length) throw new Error("GLB chunk exceeds the file size.");
const data = buffer.subarray(start, end);
chunks.push({ type, data });
if (type === JSON_CHUNK) json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
if (type === BIN_CHUNK) bin = data;
offset = end;
}
if (!json || !bin) throw new Error("GLB must contain JSON and BIN chunks.");
return { json, bin, chunks };
}
function serializeGlb(source, json, chunks) {
const encoded = Buffer.from(JSON.stringify(json));
const padded = Buffer.alloc(Math.ceil(encoded.length / 4) * 4, 0x20);
encoded.copy(padded);
const rewritten = chunks.map((chunk) => chunk.type === JSON_CHUNK ? { ...chunk, data: padded } : chunk);
const length = 12 + rewritten.reduce((sum, chunk) => sum + 8 + chunk.data.length, 0);
const output = Buffer.alloc(length);
source.copy(output, 0, 0, 12);
output.writeUInt32LE(length, 8);
let offset = 12;
for (const chunk of rewritten) {
output.writeUInt32LE(chunk.data.length, offset);
output.writeUInt32LE(chunk.type, offset + 4);
chunk.data.copy(output, offset + 8);
offset += 8 + chunk.data.length;
}
return output;
}
function embeddedImageBytes(json, bin, image) {
const view = json.bufferViews?.[image?.bufferView];
if (!view || (view.buffer !== undefined && view.buffer !== 0)) throw new Error("Target GLB image is not embedded in BIN.");
const start = view.byteOffset ?? 0;
return bin.subarray(start, start + view.byteLength);
}
function nativeAction(row, alphaRole) {
const params = Object.fromEntries((row.alphaBlendParameters ?? [])
.filter((entry) => entry && typeof entry.name === "string")
.map((entry) => [entry.name, entry.value]));
for (const key of ["alphaTestMode", "alphaMode", "translucent", "alpha", "alphaModifier"]) {
if (!Object.hasOwn(params, key)) throw new Error(`${row.name} is missing native alpha parameter ${key}.`);
}
if (Number(params.alpha) !== 1 || Number(params.alphaModifier) !== 1) {
throw new Error(`${row.name} has a non-unit native global alpha factor.`);
}
const testMode = Number(params.alphaTestMode);
const alphaMode = Number(params.alphaMode);
const translucent = Boolean(params.translucent);
if (testMode === 0 && alphaMode === 1 && translucent) return { action: "additive", alphaMode: "BLEND", reason: "native-srcalpha-one-additive" };
if (testMode === 0 && alphaMode === 0 && translucent) {
return alphaRole === "opaque"
? { action: "opaque", alphaMode: "OPAQUE", reason: "native-normal-translucent-texture-alpha-opaque" }
: { action: "blend", alphaMode: "BLEND", reason: "native-normal-translucent-alpha" };
}
if (testMode === 0 && alphaMode === 0 && !translucent) return { action: "opaque", alphaMode: "OPAQUE", reason: "native-normal-opaque-material" };
if (testMode === 1 && alphaMode === 0 && !translucent) {
const threshold = Math.max(0, Math.min(255, Number(params.alphaTestValue))) / 255;
if (!Number.isFinite(threshold)) throw new Error(`${row.name} is missing a valid native alpha-test threshold.`);
return alphaRole === "opaque"
? { action: "opaque", alphaMode: "OPAQUE", reason: "native-alpha-test-texture-alpha-opaque" }
: { action: "mask", alphaMode: "MASK", alphaCutoff: threshold, reason: "native-greater-alpha-test" };
}
throw new Error(`${row.name} has an unsupported native alpha combination.`);
}
/** Apply exact source-report + source-image guarded material actions to a review GLB. */
export function patchNativeAlphaBindings(sourceBuffer, overrides) {
if (!Array.isArray(overrides) || overrides.length === 0) throw new Error("At least one pinned native alpha override is required.");
const { json, bin, chunks } = parseGlb(sourceBuffer);
json.materials ??= [];
json.meshes ??= [];
json.nodes ??= [];
json.images ??= [];
const beforeBinSha256 = sha256(bin);
const bindings = [];
const materialCloneCache = new Map();
for (const override of overrides) {
const { sourceModel, sourceResourceSha256, nativeMaterialRow: row, groupNames, imageName,
sourcePngBytes, sourcePngSha256, sourceTextureSha256, sourceAlphaRole, sourceAlphaStats } = override;
if (!String(sourceModel ?? "").toLowerCase().endsWith(".ros") || !SHA256.test(String(sourceResourceSha256 ?? ""))) {
throw new Error("Native alpha override lacks a pinned ROS path or SHA-256.");
}
if (!row || !/^runewaker_material_\d+$/i.test(String(row.name ?? ""))) throw new Error("Native alpha override lacks a native material index.");
if (!SHA256.test(String(sourcePngSha256 ?? "")) || sha256(sourcePngBytes) !== sourcePngSha256.toLowerCase()) {
throw new Error(`${row.name} source PNG hash does not match its supplied bytes.`);
}
if (!SHA256.test(String(sourceTextureSha256 ?? ""))) throw new Error(`${row.name} source DDS hash is missing.`);
if (normalizePath(row.sourceTexture) !== normalizePath(override.sourceTexture)) {
throw new Error(`${row.name} native diffuse source path differs from the pinned source texture.`);
}
const diffuse = (row.sourceTextureChannels ?? [])
.filter((channel) => String(channel?.name ?? "").toLowerCase() === "diffuse")
.map((channel) => normalizePath(channel.texture));
if (!diffuse.includes(normalizePath(override.sourceTexture))) {
throw new Error(`${row.name} native diffuse channel does not match the pinned source texture.`);
}
if (basename(row.stagedTexture) !== basename(imageName)) throw new Error(`${row.name} staged texture alias does not match the GLB image.`);
if (!Array.isArray(groupNames) || groupNames.length === 0) throw new Error(`${row.name} has no native OBJ groups to bind.`);
const alphaDecision = nativeAction(row, sourceAlphaRole);
const rowIndex = Number(row.name.match(/(\d+)$/)?.[1]);
if (!Number.isInteger(rowIndex)) throw new Error(`${row.name} has an invalid native index.`);
const imageIndexForMaterial = (materialIndex) => {
const material = json.materials[materialIndex];
const textureIndex = material?.pbrMetallicRoughness?.baseColorTexture?.index;
const texture = json.textures?.[textureIndex];
const imageIndex = texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
return imageIndex;
};
const imageIndexes = json.images.map((image, index) => basename(image.name) === basename(imageName) ? index : -1).filter((index) => index >= 0);
const exactImageIndexes = imageIndexes.filter((index) => {
const image = json.images[index];
if (image.mimeType !== "image/png") return false;
return embeddedImageBytes(json, bin, image).equals(Buffer.from(sourcePngBytes));
});
if (exactImageIndexes.length === 0) {
throw new Error(`${row.name} found no embedded PNG byte-identical to its source texture.`);
}
const exactImageIndexSet = new Set(exactImageIndexes);
const matchingNodes = json.nodes.map((node, index) => ({ node, index }))
.filter(({ node }) => groupNames.includes(node.name));
if (matchingNodes.length === 0) throw new Error(`${row.name} native OBJ groups are absent from the target GLB.`);
const expectedNodeNames = new Set(groupNames);
const meshUsers = new Map();
for (const [index, node] of json.nodes.entries()) {
if (node.mesh === undefined) continue;
const users = meshUsers.get(node.mesh) ?? [];
users.push(index);
meshUsers.set(node.mesh, users);
}
for (const { node, index: nodeIndex } of matchingNodes) {
const originalMeshIndex = node.mesh;
const originalMesh = json.meshes[originalMeshIndex];
if (!originalMesh) throw new Error(`${row.name} native OBJ group ${node.name} has no GLB mesh.`);
let meshIndex = originalMeshIndex;
const users = meshUsers.get(originalMeshIndex) ?? [];
if (users.some((userIndex) => !expectedNodeNames.has(json.nodes[userIndex]?.name))) {
meshIndex = json.meshes.length;
json.meshes.push(structuredClone(originalMesh));
node.mesh = meshIndex;
}
const mesh = json.meshes[meshIndex];
let matchedPrimitiveCount = 0;
for (const primitive of mesh.primitives ?? []) {
const sourceMaterialIndex = primitive.material;
const imageIndex = imageIndexForMaterial(sourceMaterialIndex);
if (!exactImageIndexSet.has(imageIndex)) continue;
const oldMaterial = json.materials[sourceMaterialIndex];
if (!oldMaterial) throw new Error(`${row.name} target GLB primitive has a missing material.`);
const identityKey = `${sourceMaterialIndex}:${sourceModel}:${sourceResourceSha256}:${rowIndex}:${alphaDecision.action}`;
let targetMaterialIndex = materialCloneCache.get(identityKey);
if (targetMaterialIndex === undefined) {
const clone = structuredClone(oldMaterial);
clone.alphaMode = alphaDecision.alphaMode;
if (alphaDecision.alphaMode === "MASK") clone.alphaCutoff = alphaDecision.alphaCutoff;
else delete clone.alphaCutoff;
clone.extras = {
...(clone.extras ?? {}),
semanticRole: alphaDecision.action,
nativeSourceModel: sourceModel,
nativeSourceResourceSha256: sourceResourceSha256.toLowerCase(),
nativeMaterialIndex: rowIndex,
nativeSourceTexture: normalizePath(override.sourceTexture),
nativeAlphaPolicy: alphaDecision.reason,
nativeAlphaParameters: Object.fromEntries((row.alphaBlendParameters ?? []).map((parameter) => [parameter.name, parameter.value])),
nativeSourceTextureSha256: sourceTextureSha256.toLowerCase(),
nativeSourcePngSha256: sourcePngSha256.toLowerCase(),
nativeSourceAlphaRange: sourceAlphaStats?.range ?? null,
};
if (row.lightingType?.name) clone.extras.nativeLightingType = String(row.lightingType.name).toLowerCase();
if (alphaDecision.action === "additive") clone.extras.nativeBlendMode = "additive";
else delete clone.extras.nativeBlendMode;
targetMaterialIndex = json.materials.length;
json.materials.push(clone);
materialCloneCache.set(identityKey, targetMaterialIndex);
}
primitive.material = targetMaterialIndex;
matchedPrimitiveCount += 1;
}
if (matchedPrimitiveCount === 0) throw new Error(`${row.name} native OBJ group ${node.name} has no matching source-texture primitive.`);
bindings.push({
sourceModel,
sourceResourceSha256: sourceResourceSha256.toLowerCase(),
nativeMaterialName: row.name,
nativeMaterialIndex: rowIndex,
nativeSourceTexture: normalizePath(override.sourceTexture),
sourceTextureSha256: sourceTextureSha256.toLowerCase(),
sourcePngSha256: sourcePngSha256.toLowerCase(),
imageName,
sourceAlphaRange: sourceAlphaStats?.range ?? null,
action: alphaDecision.action,
gltfAlphaMode: alphaDecision.alphaMode,
reason: alphaDecision.reason,
groupName: node.name,
gltfMeshIndex: meshIndex,
targetPrimitiveCount: matchedPrimitiveCount,
});
}
}
if (bindings.length === 0) throw new Error("No source-proven alpha bindings were staged.");
const outputBuffer = serializeGlb(sourceBuffer, json, chunks);
const { bin: outputBin } = parseGlb(outputBuffer);
if (sha256(outputBin) !== beforeBinSha256) throw new Error("Review alpha patch unexpectedly changed GLB BIN data.");
return {
buffer: outputBuffer,
report: {
schemaVersion: 1,
beforeBinSha256,
afterBinSha256: sha256(outputBin),
binBytesUnchanged: true,
bindingCount: bindings.length,
bindings,
},
};
}
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { patchNativeAlphaBindings } from "./native-alpha-glb-staging.mjs";
const hash = (value) => createHash("sha256").update(value).digest("hex");
function makeGlb() {
const imageBytes = Buffer.from([11, 22, 33, 44]);
const json = {
asset: { version: "2.0" },
buffers: [{ byteLength: 4 }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 4 }],
images: [{ name: "gate_waterfall", mimeType: "image/png", bufferView: 0 }],
textures: [{ source: 0 }],
materials: [{ name: "old-deduplicated-material", pbrMetallicRoughness: { baseColorTexture: { index: 0 } }, extras: { old: true } }],
meshes: [{ name: "waterfall", primitives: [{ attributes: {}, material: 0 }] }],
nodes: [{ name: "waterfall", mesh: 0 }],
scenes: [{ nodes: [0] }],
scene: 0,
};
const jsonBytes = Buffer.from(JSON.stringify(json));
const jsonPadding = Buffer.alloc((4 - jsonBytes.length % 4) % 4, 0x20);
const binPadding = Buffer.alloc((4 - imageBytes.length % 4) % 4);
const jsonChunk = Buffer.concat([jsonBytes, jsonPadding]);
const binChunk = Buffer.concat([imageBytes, binPadding]);
const length = 12 + 8 + jsonChunk.length + 8 + binChunk.length;
const glb = Buffer.alloc(length);
glb.write("glTF", 0);
glb.writeUInt32LE(2, 4);
glb.writeUInt32LE(length, 8);
glb.writeUInt32LE(jsonChunk.length, 12);
glb.writeUInt32LE(0x4e4f534a, 16);
jsonChunk.copy(glb, 20);
const binHeader = 20 + jsonChunk.length;
glb.writeUInt32LE(binChunk.length, binHeader);
glb.writeUInt32LE(0x004e4942, binHeader + 4);
binChunk.copy(glb, binHeader + 8);
return { glb, imageBytes };
}
function makeOverride(imageBytes, nativeAlphaParameters) {
return {
sourceModel: "model\\element\\test\\waterfall.ros",
sourceResourceSha256: "a".repeat(64),
nativeMaterialRow: {
name: "runewaker_material_9",
sourceTexture: "texture\\dungeon\\gate_waterfall",
stagedTexture: "textures/gate_waterfall.dds",
sourceTextureChannels: [{ name: "diffuse", texture: "texture\\dungeon\\gate_waterfall" }],
alphaBlendParameters: nativeAlphaParameters,
lightingType: { name: "standard" },
},
sourceTexture: "texture\\dungeon\\gate_waterfall",
groupNames: ["waterfall"],
imageName: "gate_waterfall",
sourcePngBytes: imageBytes,
sourcePngSha256: hash(imageBytes),
sourceTextureSha256: "b".repeat(64),
sourceAlphaRole: "blend",
sourceAlphaStats: { range: [0, 173] },
};
}
const normalTranslucent = [
{ name: "alphaTestMode", value: 0 },
{ name: "alphaMode", value: 0 },
{ name: "translucent", value: true },
{ name: "alpha", value: 1 },
{ name: "alphaModifier", value: 1 },
];
test("source-pinned native alpha patch changes only material JSON and keeps texture BIN bytes", () => {
const { glb, imageBytes } = makeGlb();
const result = patchNativeAlphaBindings(glb, [makeOverride(imageBytes, normalTranslucent)]);
assert.equal(result.report.bindingCount, 1);
assert.equal(result.report.binBytesUnchanged, true);
assert.deepEqual(result.report.bindings[0].sourceAlphaRange, [0, 173]);
const offset = 12;
const jsonLength = result.buffer.readUInt32LE(offset);
const document = JSON.parse(result.buffer.subarray(offset + 8, offset + 8 + jsonLength).toString().trim());
const target = document.materials[document.meshes[0].primitives[0].material];
assert.equal(target.alphaMode, "BLEND");
assert.equal(target.extras.nativeAlphaPolicy, "native-normal-translucent-alpha");
assert.equal(target.extras.nativeSourceResourceSha256, "a".repeat(64));
assert.equal(document.materials[0].alphaMode, undefined, "the source material remains intact for non-target uses");
});
test("source alpha override preserves native additive precedence", () => {
const { glb, imageBytes } = makeGlb();
const additive = [
{ name: "alphaTestMode", value: 0 },
{ name: "alphaMode", value: 1 },
{ name: "translucent", value: true },
{ name: "alpha", value: 1 },
{ name: "alphaModifier", value: 1 },
];
const result = patchNativeAlphaBindings(glb, [makeOverride(imageBytes, additive)]);
const jsonLength = result.buffer.readUInt32LE(12);
const document = JSON.parse(result.buffer.subarray(20, 20 + jsonLength).toString().trim());
const target = document.materials[document.meshes[0].primitives[0].material];
assert.equal(target.alphaMode, "BLEND");
assert.equal(target.extras.semanticRole, "additive");
assert.equal(target.extras.nativeBlendMode, "additive");
});
test("source alpha override fails closed when diffuse texture bytes differ", () => {
const { glb } = makeGlb();
const wrongTexture = Buffer.from([99, 99, 99, 99]);
assert.throws(
() => patchNativeAlphaBindings(glb, [makeOverride(wrongTexture, normalTranslucent)]),
/no embedded PNG byte-identical/,
);
});
@@ -0,0 +1,146 @@
import path from "node:path";
export const TREKKER_WATER_FOLIAGE_EXCLUSION_MASK = 0x00a00000;
export function normalizeNativeResourcePath(resource) {
if (typeof resource !== "string" || !resource.trim()) {
throw new Error("Native collision resource path must be a non-empty string.");
}
const normalized = resource.replaceAll("/", "\\").replace(/^\\+/, "");
const parts = normalized.split("\\");
if (parts.some((part) => !part || part === "." || part === "..")) {
throw new Error(`Unsafe native collision resource path: ${resource}`);
}
return parts.join("\\");
}
export function nativeResourceFile(resourceRoot, resource) {
const normalized = normalizeNativeResourcePath(resource);
const file = path.resolve(resourceRoot, ...normalized.split("\\"));
const relative = path.relative(path.resolve(resourceRoot), file);
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Native resource escaped the resource root: ${resource}`);
}
return file;
}
export function transformNativeCollisionPoint(source, columnMajorMatrix, metersPerSourceUnit) {
if (!Array.isArray(source) && !ArrayBuffer.isView(source)) throw new Error("Source point must contain three coordinates.");
if (source.length !== 3 || !Array.isArray(columnMajorMatrix) || columnMajorMatrix.length !== 16) {
throw new Error("Native collision transform requires a three-value point and 16-value matrix.");
}
if (!Number.isFinite(metersPerSourceUnit) || metersPerSourceUnit <= 0) {
throw new Error("Meters per source unit must be a finite positive number.");
}
const x = Number(source[0]) * metersPerSourceUnit;
const y = Number(source[1]) * metersPerSourceUnit;
const z = -Number(source[2]) * metersPerSourceUnit;
const point = [
columnMajorMatrix[0] * x + columnMajorMatrix[4] * y + columnMajorMatrix[8] * z + columnMajorMatrix[12],
columnMajorMatrix[1] * x + columnMajorMatrix[5] * y + columnMajorMatrix[9] * z + columnMajorMatrix[13],
columnMajorMatrix[2] * x + columnMajorMatrix[6] * y + columnMajorMatrix[10] * z + columnMajorMatrix[14],
];
if (point.some((value) => !Number.isFinite(value))) throw new Error("Native collision placement produced a non-finite point.");
return point;
}
export function transformNativeCollisionTriangle(sourcePoints, columnMajorMatrix, metersPerSourceUnit) {
if (!Array.isArray(sourcePoints) || sourcePoints.length !== 3) {
throw new Error("Native collision triangle must contain exactly three points.");
}
const [a, b, c] = sourcePoints.map((point) => (
transformNativeCollisionPoint(point, columnMajorMatrix, metersPerSourceUnit)
));
// The source-to-Three bridge reflects Z. Reverse winding exactly once here.
return [a, c, b];
}
export function triangleTileKey(points, tileSizeMeters) {
if (!Number.isFinite(tileSizeMeters) || tileSizeMeters <= 0) throw new Error("Tile size must be finite and positive.");
const centerX = (points[0][0] + points[1][0] + points[2][0]) / 3;
const centerZ = (points[0][2] + points[1][2] + points[2][2]) / 3;
return `${Math.floor(centerX / tileSizeMeters)}:${Math.floor(centerZ / tileSizeMeters)}`;
}
export function isTrekkerPathEligible(triangleFlag) {
return ((Number(triangleFlag) >>> 0) & TREKKER_WATER_FOLIAGE_EXCLUSION_MASK) === 0;
}
export class NativeCollisionPartAccumulator {
constructor(maxTriangles) {
if (!Number.isSafeInteger(maxTriangles) || maxTriangles < 1 || maxTriangles > 90_000) {
throw new Error("Native collision GLB part size must be an integer from 1 to 90,000 triangles.");
}
this.maxTriangles = maxTriangles;
this.positions = new Float32Array(maxTriangles * 9);
this.flags = [];
this.flagCounts = new Map();
}
get triangleCount() {
return this.flags.length;
}
add(points, flag) {
if (this.triangleCount >= this.maxTriangles) throw new Error("Flush the full native collision part before adding another triangle.");
if (!Array.isArray(points) || points.length !== 3 || points.some((point) => point.length !== 3 || [...point].some((value) => !Number.isFinite(Number(value))))) {
throw new Error("Native collision part requires a finite 3D triangle.");
}
const offset = this.triangleCount * 9;
this.positions.set([...points[0], ...points[1], ...points[2]], offset);
const normalizedFlag = Number(flag) >>> 0;
this.flags.push(normalizedFlag);
this.flagCounts.set(normalizedFlag, (this.flagCounts.get(normalizedFlag) ?? 0) + 1);
return this.triangleCount === this.maxTriangles ? this.finish() : null;
}
finish() {
if (this.triangleCount === 0) return null;
const result = {
triangleCount: this.triangleCount,
positions: this.positions.slice(0, this.triangleCount * 9),
sourceTriangleFlags: this.flags.slice(),
flagCounts: Object.fromEntries([...this.flagCounts.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
};
this.positions = new Float32Array(this.maxTriangles * 9);
this.flags = [];
this.flagCounts = new Map();
return result;
}
}
export function validateNativeCollisionCounts({ triangleCount, flagBytes, positionVertexCount }) {
if (!Number.isSafeInteger(triangleCount) || triangleCount < 0) throw new Error("Native collision report has an invalid triangle count.");
if (flagBytes !== triangleCount * 4) {
throw new Error(`Native triangle flags contain ${flagBytes} bytes, expected ${triangleCount * 4}.`);
}
if (positionVertexCount !== triangleCount * 3) {
throw new Error(`Native collision OBJ contains ${positionVertexCount} vertices, expected ${triangleCount * 3}.`);
}
}
export function renderMeshOmissions(report) {
const omitted = [];
for (const entity of report.entityNodes ?? []) {
for (const [meshIndex, kind] of (entity.meshCollisionTypes ?? []).entries()) {
if (kind === "unsupported mesh class") {
omitted.push({
entity: entity.name,
entityType: entity.type,
meshIndex,
reason: "unsupported mesh class in native collision traversal",
entityCollisionObjectReplacesChildren: entity.hasCollisionObject,
});
} else if (String(kind).startsWith("none(serialized)")) {
omitted.push({
entity: entity.name,
entityType: entity.type,
meshIndex,
reason: "no serialized mesh collision structure; render-mesh fallback is excluded",
entityCollisionObjectReplacesChildren: entity.hasCollisionObject,
});
}
}
}
return omitted;
}
@@ -0,0 +1,167 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { normalizeNativeResourcePath, nativeResourceFile } from "./native-collision-assembly.mjs";
export async function sha256File(file) {
const bytes = await readFile(file);
return createHash("sha256").update(bytes).digest("hex");
}
export function normalizedResourceKey(value) {
return normalizeNativeResourcePath(value).toLowerCase();
}
function resolveInventoryFile(inventoryRoot, relativePath) {
if (typeof relativePath !== "string" || !relativePath.trim()) throw new Error("Inventory file path is missing.");
const resolved = path.resolve(inventoryRoot, relativePath);
const relative = path.relative(path.resolve(inventoryRoot), resolved);
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Inventory file escaped its root: ${relativePath}`);
}
return resolved;
}
export async function writeNativeCollisionInventoryManifest({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
collisionMode = "complete",
resources,
sourceResourceRoot,
}) {
if (!new Set(["complete", "respect-options"]).has(collisionMode)) throw new Error(`Unsupported native collision mode: ${collisionMode}`);
const rows = [];
for (const resource of [...resources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)))) {
const sourcePath = nativeResourceFile(sourceResourceRoot, resource.resource);
const sourceBefore = await sha256File(sourcePath);
if (resource.sourceResourceSha256 && resource.sourceResourceSha256 !== sourceBefore) {
throw new Error(`Source ROS changed after native extraction: ${resource.resource}`);
}
const reportSha256 = await sha256File(resource.reportFile);
const objSha256 = await sha256File(resource.objFile);
const flagsSha256 = await sha256File(resource.flagsFile);
const sourceAfter = await sha256File(sourcePath);
if (sourceBefore !== sourceAfter) throw new Error(`Source ROS changed while indexing collision extraction: ${resource.resource}`);
const relative = (file) => {
const rel = path.relative(inventoryRoot, file);
if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) {
throw new Error(`Collision extraction output is outside inventory root: ${file}`);
}
return rel.split(path.sep).join("/");
};
rows.push({
resource: resource.resource,
sourceResourceSha256: resource.sourceResourceSha256 ?? sourceBefore,
report: relative(resource.reportFile),
reportSha256,
obj: relative(resource.objFile),
objSha256,
triangleFlags: relative(resource.flagsFile),
triangleFlagsSha256: flagsSha256,
nativeTriangleCount: Number(resource.report.nativeCollisionTriangleCount),
completeNativeTriangleCount: Number(resource.report.completeNativeCollisionTriangleCount ?? resource.report.nativeCollisionTriangleCount),
nativeTrianglesOmittedByCollisionOptions: Number(resource.report.nativeTrianglesOmittedByCollisionOptions ?? 0),
disabledCollisionOptionEntityCount: Number(resource.report.disabledCollisionOptionEntityCount ?? 0),
entityNodesSkippedByCollisionOptionGates: Number(resource.report.entityNodesSkippedByCollisionOptionGates ?? 0),
renderMeshFallbackTriangleCount: Number(resource.report.renderMeshFallbackTriangleCount ?? 0),
});
}
const manifest = {
schemaVersion: 2,
extraction: "Rune Engine serialized IRuCollisionObject / CRuCollisionStructure; render-mesh fallback excluded",
collisionMode,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
triangleFlagsEncoding: "one little-endian uint32 per serialized native collision triangle, in OBJ triangle order",
resources: rows,
};
const file = path.join(inventoryRoot, "native-collision-inventory.json");
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
return { manifest, file, sha256: await sha256File(file) };
}
export async function readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
collisionMode = "complete",
nativeExtractor,
expectedResources,
sourceResourceRoot,
}) {
const manifestPath = path.join(inventoryRoot, "native-collision-inventory.json");
const inventory = JSON.parse(await readFile(manifestPath, "utf8"));
if (![1, 2].includes(inventory.schemaVersion)) throw new Error(`Unsupported native collision inventory schema: ${inventory.schemaVersion}`);
const indexedCollisionMode = inventory.collisionMode ?? "complete";
if (indexedCollisionMode !== collisionMode) {
throw new Error(`Native collision inventory mode ${indexedCollisionMode} does not match requested mode ${collisionMode}.`);
}
if (nativeExtractor) {
for (const field of ["executableSha256", "sourceSha256"]) {
if (inventory.nativeExtractor?.[field] !== nativeExtractor[field]) {
throw new Error(`Native collision inventory ${field} does not match the current extractor.`);
}
}
if ((inventory.nativeExtractor?.collisionMode ?? "complete") !== collisionMode) {
throw new Error("Native collision inventory extractor mode does not match the requested mode.");
}
}
if (inventory.placementManifestSha256 !== placementManifestSha256) {
throw new Error("Native collision inventory is bound to a different placement manifest.");
}
if (inventory.sourceWdbSha256 !== sourceWdbSha256) throw new Error("Native collision inventory is bound to a different source WDB.");
if (!Array.isArray(inventory.resources)) throw new Error("Native collision inventory has no resource rows.");
const expected = new Map(expectedResources.map((resource) => [normalizedResourceKey(resource), resource]));
const validated = new Map();
for (const row of inventory.resources) {
const key = normalizedResourceKey(row.resource);
if (!expected.has(key)) throw new Error(`Unexpected resource in collision inventory: ${row.resource}`);
if (validated.has(key)) throw new Error(`Duplicate resource in collision inventory: ${row.resource}`);
const sourcePath = nativeResourceFile(sourceResourceRoot, row.resource);
const actualSourceHash = await sha256File(sourcePath);
if (actualSourceHash !== row.sourceResourceSha256) {
throw new Error(`Source ROS SHA-256 mismatch for ${row.resource}; regenerate the collision inventory.`);
}
const reportFile = resolveInventoryFile(inventoryRoot, row.report);
const objFile = resolveInventoryFile(inventoryRoot, row.obj);
const flagsFile = resolveInventoryFile(inventoryRoot, row.triangleFlags);
if (await sha256File(reportFile) !== row.reportSha256) throw new Error(`Collision report changed after inventory was indexed: ${row.resource}`);
if (await sha256File(objFile) !== row.objSha256) throw new Error(`Collision OBJ changed after inventory was indexed: ${row.resource}`);
if (await sha256File(flagsFile) !== row.triangleFlagsSha256) throw new Error(`Collision flag sidecar changed after inventory was indexed: ${row.resource}`);
const report = JSON.parse(await readFile(reportFile, "utf8"));
if (normalizedResourceKey(report.sourceModel) !== key) throw new Error(`Collision report resource identity mismatch for ${row.resource}`);
if ((report.collisionMode ?? "complete") !== collisionMode) throw new Error(`Collision report mode mismatch for ${row.resource}`);
if (Number(report.nativeCollisionTriangleCount) !== Number(row.nativeTriangleCount)) {
throw new Error(`Collision report triangle count mismatch for ${row.resource}`);
}
const completeCount = Number(report.completeNativeCollisionTriangleCount ?? report.nativeCollisionTriangleCount);
if (!Number.isSafeInteger(completeCount) || completeCount < Number(row.nativeTriangleCount)) {
throw new Error(`Invalid complete collision triangle count for ${row.resource}`);
}
if (Number(row.completeNativeTriangleCount ?? completeCount) !== completeCount) {
throw new Error(`Complete collision triangle count mismatch for ${row.resource}`);
}
const fallbackCount = Number(report.renderMeshFallbackTriangleCount ?? 0);
if (fallbackCount !== Number(row.renderMeshFallbackTriangleCount ?? 0)) {
throw new Error(`Collision report fallback count mismatch for ${row.resource}`);
}
if (fallbackCount < 0 || !Number.isSafeInteger(fallbackCount)) throw new Error(`Invalid collision fallback count for ${row.resource}`);
validated.set(key, {
resource: expected.get(key),
row,
report,
reportFile,
objFile,
flagsFile,
});
}
const missing = [...expected.keys()].filter((key) => !validated.has(key));
if (missing.length) throw new Error(`Native collision inventory omits ${missing.length} WDB resource(s): ${missing.slice(0, 5).join(", ")}`);
return { inventory, manifestPath, manifestSha256: await sha256File(manifestPath), resources: validated };
}
@@ -0,0 +1,124 @@
const normalizeResource = (value) => String(value ?? "")
.replaceAll("\\", "/")
.replace(/^\/+/, "")
.toLowerCase();
/**
* The Windows native audit tool currently emits its input/output path strings
* directly into JSON. Forward slashes are accepted by the filesystem and
* prevent unescaped backslashes from making that report invalid JSON.
*/
export function buildNativeCollisionAuditArguments({ resourceRoot, sourceModel, reportPath }) {
const normalizePath = (value, label) => {
const normalized = String(value ?? "").replaceAll("\\", "/");
if (!normalized) throw new Error(`${label} is required for native collision audit.`);
return normalized;
};
return [
normalizePath(resourceRoot, "Resource root"),
normalizePath(sourceModel, "Source ROS path"),
normalizePath(reportPath, "Native report path"),
"respect-options",
];
}
function requireHash(value, label) {
const hash = String(value ?? "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error(`${label} must be a SHA-256 hash.`);
return hash;
}
function requireEqual(actual, expected, label) {
if (actual !== expected) throw new Error(`${label} does not match.`);
}
function samePlacement(actual, expected) {
if (!actual || !expected) return false;
if (String(actual.guid ?? "").toLowerCase() !== String(expected.guid ?? "").toLowerCase()) return false;
if (normalizeResource(actual.resource) !== normalizeResource(expected.resource)) return false;
for (const field of ["translation", "rotation", "scale"]) {
if (!Array.isArray(actual[field]) || !Array.isArray(expected[field]) || actual[field].length !== expected[field].length) return false;
for (let index = 0; index < actual[field].length; index += 1) {
if (Math.abs(Number(actual[field][index]) - Number(expected[field][index])) > 1e-5) return false;
}
}
return true;
}
/**
* Verifies that native-core collision candidates are bound to one exact
* recipe, primary ROS/WDB source cache, import metadata, WDB placement, and
* strict engine-query geometry report before any candidate files are emitted.
*/
export function validateNativeCoreCandidateProvenance(input) {
const {
slug,
recipe,
recipeSha256,
importMetadata,
actualSourceModelSha256,
actualSourceWdbSha256,
wdbReport,
wdbReportSha256,
exportReport,
exportReportSha256,
conversionReportSha256,
nativeCollisionReport,
nativeCollisionReportSha256,
nativeCollisionObjSha256,
nativeCollisionFlagsSha256,
primaryPlacement,
} = input;
requireEqual(recipe?.slug, slug, "Native-core candidate recipe slug");
requireEqual(importMetadata?.dungeonId, slug, "Native-core import metadata dungeon ID");
const recipeModel = normalizeResource(recipe?.source?.model);
const recipeWdb = normalizeResource(recipe?.source?.wdb);
requireEqual(normalizeResource(importMetadata.source?.model), recipeModel, "Imported source ROS path");
requireEqual(normalizeResource(importMetadata.source?.wdb), recipeWdb, "Imported source WDB path");
const sourceModelSha256 = requireHash(actualSourceModelSha256, "Current source ROS SHA-256");
const sourceWdbSha256 = requireHash(actualSourceWdbSha256, "Current source WDB SHA-256");
requireEqual(requireHash(importMetadata.source?.modelSha256, "Imported source ROS SHA-256"), sourceModelSha256, "Imported source ROS hash");
requireEqual(requireHash(importMetadata.source?.wdbSha256, "Imported source WDB SHA-256"), sourceWdbSha256, "Imported source WDB hash");
const recipeHash = requireHash(recipeSha256, "Recipe file SHA-256");
const wdbReportHash = requireHash(wdbReportSha256, "WDB descriptor report SHA-256");
const exportReportHash = requireHash(exportReportSha256, "Native export report SHA-256");
const conversionHash = requireHash(conversionReportSha256, "Converted collision report SHA-256");
const nativeReportHash = requireHash(nativeCollisionReportSha256, "Native collision query report SHA-256");
const nativeObjHash = requireHash(nativeCollisionObjSha256, "Native collision query OBJ SHA-256");
const nativeFlagsHash = requireHash(nativeCollisionFlagsSha256, "Native collision query flags SHA-256");
const primaryDescriptor = wdbReport?.descriptors?.find((descriptor) => samePlacement(descriptor, primaryPlacement));
if (!primaryDescriptor || normalizeResource(primaryDescriptor.resource) !== recipeModel) {
throw new Error("Native-core primary WDB descriptor does not match the resolved placement and recipe ROS.");
}
requireEqual(normalizeResource(exportReport?.sourceModel), recipeModel, "Native exporter source ROS path");
requireEqual(normalizeResource(nativeCollisionReport?.sourceModel), recipeModel, "Native collision query source ROS path");
requireEqual(nativeCollisionReport?.collisionMode, "respect-options", "Native collision query mode");
requireEqual(nativeCollisionReport?.queryMasks?.ignoreEntityCollisionOption, false, "Native collision entity-option gate policy");
requireEqual(nativeCollisionReport?.renderMeshFallbackTriangleCount, 0, "Native collision render-mesh fallback count");
if (
!Number.isInteger(nativeCollisionReport?.nativeCollisionTriangleCount)
|| nativeCollisionReport.nativeCollisionTriangleCount <= 0
|| nativeCollisionReport.nativeCollisionTriangleCount !== nativeCollisionReport.enabledNativeCollisionTriangleCount
) {
throw new Error("Options-aware collision query count must equal the enabled serialized native triangle count.");
}
return Object.freeze({
recipeSha256: recipeHash,
sourceModelSha256,
sourceWdbSha256,
wdbReportSha256: wdbReportHash,
exportReportSha256: exportReportHash,
conversionReportSha256: conversionHash,
nativeCollisionReportSha256: nativeReportHash,
nativeCollisionObjSha256: nativeObjHash,
nativeCollisionFlagsSha256: nativeFlagsHash,
nativeEnabledTriangleCount: nativeCollisionReport.nativeCollisionTriangleCount,
primaryPlacementGuid: primaryDescriptor.guid,
});
}
@@ -0,0 +1,87 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsUnlit } from "@gltf-transform/extensions";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const SHA256 = /^[\da-f]{64}$/i;
export function hasVerifiedNativeMaterialIdentity(extras) {
return typeof extras?.nativeSourceModel === "string"
&& extras.nativeSourceModel.toLowerCase().endsWith(".ros")
&& typeof extras.nativeSourceResourceSha256 === "string"
&& SHA256.test(extras.nativeSourceResourceSha256)
&& Number.isInteger(extras.nativeMaterialIndex)
&& extras.nativeMaterialIndex >= 0
&& typeof extras.nativeSourceTexture === "string"
&& extras.nativeSourceTexture.trim().length > 0;
}
/** Preserve only source-proven lighting modes on native ROS materials. */
export async function applyNativeMaterialSemantics(inputFile, outputFile = inputFile) {
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(inputFile);
const materials = document.getRoot().listMaterials();
const prelitRows = [];
const additiveRows = [];
for (const material of materials) {
const extras = material.getExtras() ?? {};
if (!hasVerifiedNativeMaterialIdentity(extras)) {
if (extras.nativeLightingType === "prelit" || extras.nativeBlendMode === "additive") {
throw new Error(`Native lighting/blend metadata lacks verified material provenance: ${material.getName()}`);
}
continue;
}
if (extras.nativeLightingType === "prelit") prelitRows.push(material);
if (extras.nativeBlendMode === "additive"
&& extras.nativeAlphaPolicy === "native-srcalpha-one-additive") additiveRows.push(material);
}
if (prelitRows.length > 0) {
const unlit = document.createExtension(KHRMaterialsUnlit).setRequired(false).createUnlit();
for (const material of prelitRows) {
// Blender exports specular metadata even for source-prelit surfaces.
// glTF forbids its combination with unlit; it has no effect on prelit RGB.
material.setExtension("KHR_materials_specular", null);
material.setExtension("KHR_materials_unlit", unlit);
}
}
await io.write(outputFile, document);
const report = {
schemaVersion: 1,
input: path.resolve(inputFile),
output: path.resolve(outputFile),
khrMaterialsUnlit: prelitRows.map(material => ({
material: material.getName(),
sourceModel: material.getExtras().nativeSourceModel,
sourceResourceSha256: material.getExtras().nativeSourceResourceSha256,
nativeMaterialIndex: material.getExtras().nativeMaterialIndex,
sourceTexture: material.getExtras().nativeSourceTexture,
})),
nativeAdditiveMaterials: additiveRows.map(material => ({
material: material.getName(),
sourceModel: material.getExtras().nativeSourceModel,
sourceResourceSha256: material.getExtras().nativeSourceResourceSha256,
nativeMaterialIndex: material.getExtras().nativeMaterialIndex,
sourceTexture: material.getExtras().nativeSourceTexture,
alphaMode: material.getAlphaMode(),
})),
};
return report;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const inputFile = process.argv[2];
if (!inputFile) throw new Error("Usage: node apply-native-material-semantics.mjs <input.glb> [report.json]");
const report = await applyNativeMaterialSemantics(inputFile);
const reportFile = process.argv[3];
if (reportFile) await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
output: report.output,
unlitMaterialCount: report.khrMaterialsUnlit.length,
additiveMaterialCount: report.nativeAdditiveMaterials.length,
}));
}
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsSpecular } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { environmentOptimizationArguments } from "./environment-optimization.mjs";
import { applyNativeMaterialSemantics, hasVerifiedNativeMaterialIdentity } from "./native-material-semantics.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const nativeIdentity = {
nativeSourceModel: "model\\dungeon\\the_origin\\act_precipice_01.ros",
nativeSourceResourceSha256: "b".repeat(64),
nativeMaterialIndex: 8,
nativeSourceTexture: "texture\\dungeon\\the_origin\\wave_01",
};
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const contents = Buffer.concat([typeBytes, data]);
const header = Buffer.alloc(4);
header.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(contents));
return Buffer.concat([header, contents, checksum]);
}
function patternedPng() {
const header = Buffer.alloc(13);
header.writeUInt32BE(2, 0);
header.writeUInt32BE(2, 4);
header[8] = 8;
header[9] = 6;
const rows = Buffer.from([
0, 210, 170, 125, 255, 15, 80, 210, 255,
0, 25, 240, 95, 255, 160, 20, 180, 255,
]);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(rows)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
async function writeFixture(file, extras) {
const document = new Document();
const buffer = document.createBuffer();
const position = document.createAccessor()
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
.setBuffer(buffer);
const material = document.createMaterial("native wave")
.setAlphaMode("BLEND")
.setExtras(extras);
material.setExtension("KHR_materials_specular",
document.createExtension(KHRMaterialsSpecular).createSpecular().setSpecularFactor(0));
const primitive = document.createPrimitive()
.setAttribute("POSITION", position)
.setMaterial(material);
const mesh = document.createMesh("wave mesh").addPrimitive(primitive);
const node = document.createNode("wave node").setMesh(mesh);
const scene = document.createScene("fixture scene").addChild(node);
document.getRoot().setDefaultScene(scene);
await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(file, document);
}
test("adds KHR_materials_unlit only to exact-proven prelit materials and retains additive extras", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-native-material-"));
try {
const file = path.join(directory, "wave.glb");
await writeFixture(file, {
...nativeIdentity,
nativeBlendMode: "additive",
nativeAlphaPolicy: "native-srcalpha-one-additive",
nativeLightingType: "prelit",
});
const report = await applyNativeMaterialSemantics(file);
assert.equal(report.khrMaterialsUnlit.length, 1);
assert.equal(report.nativeAdditiveMaterials.length, 1);
assert.equal(report.nativeAdditiveMaterials[0].alphaMode, "BLEND");
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(file);
const material = document.getRoot().listMaterials()[0];
assert.ok(material.getExtension("KHR_materials_unlit"));
assert.equal(material.getExtension("KHR_materials_specular"), null);
assert.equal(material.getExtras().nativeBlendMode, "additive");
assert.equal(material.getAlphaMode(), "BLEND");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("rejects prelit claims without complete native material identity", async () => {
assert.equal(hasVerifiedNativeMaterialIdentity(nativeIdentity), true);
assert.equal(hasVerifiedNativeMaterialIdentity({ ...nativeIdentity, nativeSourceResourceSha256: "bad" }), false);
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-native-material-invalid-"));
try {
const file = path.join(directory, "bad.glb");
await writeFixture(file, { nativeLightingType: "prelit", ...nativeIdentity, nativeMaterialIndex: -1 });
await assert.rejects(() => applyNativeMaterialSemantics(file), /lacks verified material provenance/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("gltf-transform optimize keeps shared-map standard and prelit materials distinct", async (context) => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-native-material-optimize-"));
context.after(async () => rm(directory, { recursive: true, force: true }));
const sourceFile = path.join(directory, "source.glb");
const outputFile = path.join(directory, "optimized.glb");
const document = new Document();
const buffer = document.createBuffer();
const image = document.createTexture("shared-water-image")
.setMimeType("image/png")
.setImage(patternedPng());
const positions = document.createAccessor("triangle positions")
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
.setBuffer(buffer);
const texcoords = document.createAccessor("triangle UVs")
.setType("VEC2")
.setArray(new Float32Array([0, 0, 1, 0, 0, 1]))
.setBuffer(buffer);
const indices = document.createAccessor("triangle indices")
.setType("SCALAR")
.setArray(new Uint16Array([0, 1, 2]))
.setBuffer(buffer);
const scene = document.createScene("shared scene");
for (const [name, lightingType, nativeMaterialIndex] of [
["standard water", "standard", 8],
["prelit water", "prelit", 9],
]) {
const material = document.createMaterial(name)
.setBaseColorTexture(image)
.setExtras({ ...nativeIdentity, nativeMaterialIndex, nativeLightingType: lightingType });
const primitive = document.createPrimitive()
.setAttribute("POSITION", positions)
.setAttribute("TEXCOORD_0", texcoords)
.setIndices(indices)
.setMaterial(material);
const mesh = document.createMesh(`${name} mesh`).addPrimitive(primitive);
scene.addChild(document.createNode(name).setMesh(mesh));
}
document.getRoot().setDefaultScene(scene);
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
await io.write(sourceFile, document);
assert.ok((await io.read(sourceFile)).getRoot().listMaterials().every((material) => material.getBaseColorTexture()));
const semantics = await applyNativeMaterialSemantics(sourceFile);
assert.equal(semantics.khrMaterialsUnlit.length, 1);
// Keep the fixture materials identical apart from the glTF lighting extension,
// isolating the behavior at risk from optimizer material de-duplication.
const annotated = await io.read(sourceFile);
for (const material of annotated.getRoot().listMaterials()) material.setExtras({});
await io.write(sourceFile, annotated);
assert.equal((await io.read(sourceFile)).getRoot().listMaterials().filter((material) => material.getBaseColorTexture()).length, 2);
const args = environmentOptimizationArguments(sourceFile, outputFile);
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const optimized = await io.read(outputFile);
const materials = optimized.getRoot().listMaterials();
assert.equal(materials.length, 2, "optimization must not merge materials with different lighting modes");
const unlitMaterials = materials.filter((material) => material.getExtension("KHR_materials_unlit"));
assert.equal(unlitMaterials.length, 1);
assert.equal(materials.filter((material) => !material.getExtension("KHR_materials_unlit")).length, 1);
assert.ok(materials.every((material) => material.getBaseColorTexture()), JSON.stringify(materials.map((material) => ({
name: material.getName(),
texture: material.getBaseColorTexture()?.getName() ?? null,
unlit: Boolean(material.getExtension("KHR_materials_unlit")),
})).concat([{
textureCount: optimized.getRoot().listTextures().length,
sceneChildren: optimized.getRoot().listScenes().flatMap((entry) => entry.listChildren()).length,
primitives: optimized.getRoot().listMeshes().flatMap((mesh) => mesh.listPrimitives()).map((primitive) => ({
material: primitive.getMaterial()?.getName(),
attributes: Object.keys(primitive.listAttributes()),
})),
}])));
});
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { ensureWithinProject } from "./native-water-build-utils.mjs";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
async function writeOrVerify(file, bytes, expectedHash) {
await mkdir(path.dirname(file), { recursive: true });
try {
await writeFile(file, bytes, { flag: "wx" });
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
const persisted = await readFile(file);
const actualHash = sha256(persisted);
if (actualHash !== expectedHash || !persisted.equals(bytes)) {
throw new Error(`Refusing to replace non-identical native water audit-cache alias: ${file}`);
}
return { sha256: actualHash, bytes: persisted.length };
}
/** Write exact source and prepared-frame aliases for the environment-quality audit. */
export async function writeNativeWaterAuditCacheAliases({
projectRoot,
auditCacheRoot,
variantName,
ddsBytes,
pngBytes,
}) {
if (!auditCacheRoot) return null;
if (!/^[a-z\d][a-z\d_-]*$/i.test(variantName)) {
throw new Error(`Native water audit alias must be a safe texture basename: ${variantName}`);
}
ensureWithinProject(projectRoot, auditCacheRoot);
const root = path.resolve(auditCacheRoot);
const ddsHash = sha256(ddsBytes);
const pngHash = sha256(pngBytes);
const ddsFile = path.join(root, "raw", "textures", `${variantName}.dds`);
const pngFile = path.join(root, "prepared", "textures", `${variantName}.png`);
const [dds, png] = await Promise.all([
writeOrVerify(ddsFile, ddsBytes, ddsHash),
writeOrVerify(pngFile, pngBytes, pngHash),
]);
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
return {
textureBasename: variantName,
rawDds: { path: relative(ddsFile), ...dds },
preparedPng: { path: relative(pngFile), ...png },
};
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { writeNativeWaterAuditCacheAliases } from "./native-water-audit-cache.mjs";
test("water audit-cache aliases preserve exact DDS/PNG bytes under a unique texture basename", async () => {
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "native-water-audit-cache-"));
const auditCacheRoot = path.join(projectRoot, "water-audit-cache");
const ddsBytes = Buffer.from("source-dds-frame");
const pngBytes = Buffer.from("prepared-lossless-png-frame");
const options = { projectRoot, auditCacheRoot, variantName: "rom-water-normal-1234-00", ddsBytes, pngBytes };
try {
const first = await writeNativeWaterAuditCacheAliases(options);
const second = await writeNativeWaterAuditCacheAliases(options);
assert.deepEqual(second, first, "existing exact aliases are safe to reuse");
assert.match(first.rawDds.path, /raw\/textures\/rom-water-normal-1234-00\.dds$/);
assert.match(first.preparedPng.path, /prepared\/textures\/rom-water-normal-1234-00\.png$/);
assert.equal(first.rawDds.bytes, ddsBytes.length);
assert.equal(first.preparedPng.bytes, pngBytes.length);
await assert.rejects(writeNativeWaterAuditCacheAliases({
...options,
ddsBytes: Buffer.from("different source frame"),
}), /non-identical/);
await assert.rejects(writeNativeWaterAuditCacheAliases({
...options,
variantName: "../escape",
}), /safe texture basename/);
} finally {
await rm(projectRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,96 @@
import path from "node:path";
function parseCoordinate(value, label) {
let x;
let z;
if (Array.isArray(value) && value.length === 2) {
[x, z] = value;
} else if (value && typeof value === "object") {
({ x, z } = value);
} else if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffffffff) {
x = value >>> 16;
z = value & 0xffff;
} else if (typeof value === "string") {
const normalized = value.trim().replace(/^\[/, "").replace(/\]$/, "");
const match = /^(?:chunk-x)?(-?\d+)(?:-z|,)(-?\d+)$/i.exec(normalized);
if (match) [, x, z] = match;
}
x = Number(x);
z = Number(z);
if (!Number.isInteger(x) || !Number.isInteger(z) || x < 0 || x > 0xffff || z < 0 || z > 0xffff) {
throw new Error(`${label} must identify non-negative uint16 chunk coordinates.`);
}
return { x, z, key: ((x << 16) | z) >>> 0 };
}
export function parseNativeWaterBuildArgs(argv) {
const options = {};
const valueOptions = new Set([
"recipe", "source-root", "wdb-report", "output-root", "ground-manifest", "audit-cache-root",
]);
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help" || token === "-h") return { help: true };
if (!token.startsWith("--")) throw new Error(`Unexpected positional argument: ${token}`);
const name = token.slice(2);
if (!valueOptions.has(name)) throw new Error(`Unknown option --${name}.`);
if (options[name] !== undefined) throw new Error(`Option --${name} may only be supplied once.`);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Option --${name} requires a value.`);
options[name] = value;
index += 1;
}
return { help: false, ...options };
}
export function chunkCoordinateFromManifestRow(row) {
if (row?.coordinate !== undefined) return parseCoordinate(row.coordinate, "Ground-manifest chunk coordinate");
if (row?.key !== undefined) return parseCoordinate(row.key, "Ground-manifest chunk key");
if (row?.id !== undefined) return parseCoordinate(row.id, "Ground-manifest chunk id");
throw new Error("Ground-manifest chunk row has no coordinate, key, or id.");
}
export function selectNativeWaterChunkKeys({ wdbChunkKeys, groundManifest, sourceWdbSha256 }) {
const available = new Set(wdbChunkKeys.map((key) => Number(key) >>> 0));
if (!groundManifest) return { selectedKeys: [...available].sort((a, b) => a - b), selection: { kind: "all-wdb-chunks" } };
const manifestSha = groundManifest.source?.wdbSha256 ?? groundManifest.sourceWdbSha256;
if (typeof manifestSha !== "string" || !/^[a-f\d]{64}$/i.test(manifestSha)) {
throw new Error("Ground manifest must include source.wdbSha256 or sourceWdbSha256 to bind it to its source WDB.");
}
if (manifestSha.toLowerCase() !== sourceWdbSha256.toLowerCase()) {
throw new Error("Ground manifest source WDB SHA-256 does not match the water source WDB.");
}
if (!Array.isArray(groundManifest.chunks) || groundManifest.chunks.length === 0) {
throw new Error("Ground manifest must contain a non-empty chunks array.");
}
const coordinates = groundManifest.chunks.map(chunkCoordinateFromManifestRow);
const selectedKeys = new Set();
for (const coordinate of coordinates) {
if (!available.has(coordinate.key)) {
throw new Error(`Ground-manifest chunk [${coordinate.x},${coordinate.z}] is absent from the source WDB chunk table.`);
}
if (selectedKeys.has(coordinate.key)) {
throw new Error(`Ground manifest repeats chunk [${coordinate.x},${coordinate.z}].`);
}
selectedKeys.add(coordinate.key);
}
return {
selectedKeys: [...selectedKeys].sort((a, b) => a - b),
selection: {
kind: "ground-manifest-chunks",
chunkCount: selectedKeys.size,
coordinates: coordinates.map(({ x, z }) => ({ x, z })),
},
};
}
export function ensureWithinProject(projectRoot, outputPath) {
const resolved = path.resolve(outputPath);
const relative = path.relative(path.resolve(projectRoot), resolved);
if (!relative || relative === ".") return resolved;
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Output path must stay inside the project workspace: ${outputPath}`);
}
return resolved;
}
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseNativeWaterBuildArgs,
selectNativeWaterChunkKeys,
} from "./native-water-build-utils.mjs";
test("water builder parses recipe, source and output options without losing paths", () => {
assert.deepEqual(parseNativeWaterBuildArgs([
"--recipe", "recipes/the-origin.json",
"--source-root", "F:/rom source",
"--output-root", "runewaker-export-work/the-origin/native-water-source",
"--audit-cache-root", "runewaker-export-work/the-origin",
]), {
help: false,
recipe: "recipes/the-origin.json",
"source-root": "F:/rom source",
"output-root": "runewaker-export-work/the-origin/native-water-source",
"audit-cache-root": "runewaker-export-work/the-origin",
});
assert.throws(() => parseNativeWaterBuildArgs(["--typo", "x"]), /Unknown option/);
assert.throws(() => parseNativeWaterBuildArgs(["--recipe", "one", "--recipe", "two"]), /only be supplied once/);
});
test("water builder binds a ground selection to the identical WDB and uses exact coordinates", () => {
const sourceWdbSha256 = "a".repeat(64);
const selected = selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002, 0x00030004, 0x00040005],
sourceWdbSha256,
groundManifest: {
source: { wdbSha256: sourceWdbSha256 },
chunks: [
{ id: "chunk-x2-z2", coordinate: { x: 2, z: 2 } },
{ key: 0x00030004 },
],
},
});
assert.deepEqual(selected.selectedKeys, [0x00020002, 0x00030004]);
assert.equal(selected.selection.chunkCount, 2);
assert.throws(() => selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002],
sourceWdbSha256,
groundManifest: { source: { wdbSha256: "b".repeat(64) }, chunks: [{ coordinate: { x: 2, z: 2 } }] },
}), /does not match/);
assert.throws(() => selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002],
sourceWdbSha256,
groundManifest: { source: { wdbSha256: sourceWdbSha256 }, chunks: [{ id: "chunk-x2-z2" }, { id: "chunk-x2-z2" }] },
}), /repeats chunk/);
assert.throws(() => selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002],
sourceWdbSha256,
groundManifest: { source: { wdbSha256: sourceWdbSha256 }, chunks: [{ coordinate: { x: 9, z: 9 } }] },
}), /absent from the source WDB/);
});
test("water builder defaults to every WDB chunk when no ground manifest is supplied", () => {
const result = selectNativeWaterChunkKeys({ wdbChunkKeys: [9, 3, 5], sourceWdbSha256: "c".repeat(64) });
assert.deepEqual(result.selectedKeys, [3, 5, 9]);
assert.equal(result.selection.kind, "all-wdb-chunks");
});
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Validate a legacy DXT1 DDS and describe every source mip without decoding or resampling it. */
export function inspectNativeWaterDxt1Dds(bytes) {
if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes);
if (bytes.length < 128 || bytes.toString("ascii", 0, 4) !== "DDS ") {
throw new Error("Native water frame is not a complete DDS file.");
}
const headerSize = bytes.readUInt32LE(4);
const height = bytes.readUInt32LE(12);
const width = bytes.readUInt32LE(16);
const pixelFormatSize = bytes.readUInt32LE(76);
const formatFourCC = bytes.toString("ascii", 84, 88);
const mipCount = Math.max(1, bytes.readUInt32LE(28));
if (headerSize !== 124 || pixelFormatSize !== 32) {
throw new Error(`Unsupported DDS header sizes ${headerSize}/${pixelFormatSize}.`);
}
if (formatFourCC !== "DXT1") throw new Error(`Unsupported native water frame format ${formatFourCC}.`);
if (width < 1 || height < 1 || width % 4 !== 0 || height % 4 !== 0) {
throw new Error(`Native water DXT1 dimensions must be positive multiples of four; got ${width}x${height}.`);
}
const maxMipCount = 1 + Math.floor(Math.log2(Math.max(width, height)));
if (mipCount > maxMipCount) throw new Error(`DDS declares ${mipCount} mips for ${width}x${height}.`);
let offset = 128;
let mipWidth = width;
let mipHeight = height;
const mips = [];
for (let level = 0; level < mipCount; level += 1) {
const byteLength = Math.ceil(mipWidth / 4) * Math.ceil(mipHeight / 4) * 8;
const end = offset + byteLength;
if (end > bytes.length) throw new Error(`DDS mip ${level} is truncated.`);
const data = bytes.subarray(offset, end);
mips.push({
level,
width: mipWidth,
height: mipHeight,
offset,
byteLength,
sha256: sha256(data),
});
offset = end;
mipWidth = Math.max(1, Math.floor(mipWidth / 2));
mipHeight = Math.max(1, Math.floor(mipHeight / 2));
}
if (offset !== bytes.length) {
throw new Error(`DDS has ${bytes.length - offset} trailing bytes after its declared mip chain.`);
}
return {
width,
height,
mipCount,
formatFourCC,
headerBytes: 128,
fileBytes: bytes.length,
sha256: sha256(bytes),
mips,
exactMipChainConsumption: offset === bytes.length,
};
}
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import { inspectNativeWaterDxt1Dds } from "./native-water-dds.mjs";
function ddsFixture({ width = 8, height = 8, mipCount = 3, trailingBytes = 0, fourCC = "DXT1" } = {}) {
const mipSizes = [];
let mipWidth = width;
let mipHeight = height;
for (let level = 0; level < mipCount; level += 1) {
mipSizes.push(Math.ceil(mipWidth / 4) * Math.ceil(mipHeight / 4) * 8);
mipWidth = Math.max(1, Math.floor(mipWidth / 2));
mipHeight = Math.max(1, Math.floor(mipHeight / 2));
}
const bytes = Buffer.alloc(128 + mipSizes.reduce((sum, size) => sum + size, 0) + trailingBytes);
bytes.write("DDS ", 0, "ascii");
bytes.writeUInt32LE(124, 4);
bytes.writeUInt32LE(height, 12);
bytes.writeUInt32LE(width, 16);
bytes.writeUInt32LE(mipCount, 28);
bytes.writeUInt32LE(32, 76);
bytes.write(fourCC, 84, "ascii");
for (let index = 128; index < bytes.length; index += 1) bytes[index] = index & 0xff;
return bytes;
}
test("native water DDS inspection hashes each declared mip and confirms exact consumption", () => {
const result = inspectNativeWaterDxt1Dds(ddsFixture());
assert.equal(result.formatFourCC, "DXT1");
assert.equal(result.width, 8);
assert.equal(result.height, 8);
assert.equal(result.mipCount, 3);
assert.equal(result.exactMipChainConsumption, true);
assert.deepEqual(result.mips.map(({ width, height, byteLength }) => ({ width, height, byteLength })), [
{ width: 8, height: 8, byteLength: 32 },
{ width: 4, height: 4, byteLength: 8 },
{ width: 2, height: 2, byteLength: 8 },
]);
assert.ok(result.mips.every((mip) => /^[a-f\d]{64}$/.test(mip.sha256)));
});
test("native water DDS inspection rejects truncated, trailing and non-DXT1 data", () => {
const complete = ddsFixture();
assert.throws(() => inspectNativeWaterDxt1Dds(complete.subarray(0, complete.length - 1)), /truncated/);
assert.throws(() => inspectNativeWaterDxt1Dds(ddsFixture({ trailingBytes: 1 })), /trailing bytes/);
assert.throws(() => inspectNativeWaterDxt1Dds(ddsFixture({ fourCC: "DXT5" })), /Unsupported native water frame format/);
});
@@ -0,0 +1,214 @@
import { Matrix4, Quaternion, Vector3 } from "three";
import { sourceTerrainToThreeMatrix } from "../sand-vortex/terrain-geometry.mjs";
const EVEN_FACES = [0, 3, 4, 0, 4, 1, 1, 4, 2, 2, 4, 5];
const ODD_FACES = [0, 3, 1, 1, 3, 4, 1, 5, 2, 1, 4, 5];
const NATIVE_UP = [0, 1, 0];
function checkedParcel(layer, parcel, label) {
if (!parcel || !ArrayBuffer.isView(parcel.values)) {
throw new Error(`${layer.waterBodyGuid} ${label} parcel has no decoded values.`);
}
const expected = parcel.width * parcel.height;
if (parcel.values.length !== expected) {
throw new Error(`${layer.waterBodyGuid} ${label} parcel has ${parcel.values.length} samples; expected ${expected}.`);
}
const sideX = parcel.width - parcel.overlap * 2;
const sideZ = parcel.height - parcel.overlap * 2;
if (sideX < 1 || sideZ < 1) throw new Error(`${layer.waterBodyGuid} ${label} parcel has no inner grid.`);
}
function layerSourceTransform(layer) {
const transform = layer.transform;
if (!transform) return new Matrix4();
const rotation = new Quaternion(...transform.rotation);
if (rotation.lengthSq() < 1e-12) throw new Error(`${layer.waterBodyGuid} has a zero rotation quaternion.`);
rotation.normalize();
const scale = new Vector3(...transform.scale);
const translation = new Vector3(...transform.translation);
if (![...scale.toArray(), ...translation.toArray()].every(Number.isFinite)
|| scale.x === 0 || scale.y === 0 || scale.z === 0) {
throw new Error(`${layer.waterBodyGuid} has an invalid entity transform.`);
}
return new Matrix4().compose(translation, rotation, scale);
}
function finitePosition(vector, label) {
if (![vector.x, vector.y, vector.z].every(Number.isFinite)) throw new Error(`${label} transformed to a non-finite position.`);
return vector;
}
function sourceColor(value, flag) {
const packed = Number(value) >>> 0;
const alphaByte = Math.trunc(((Number(flag) & 0x3f) / 63) * 255);
return [
((packed >>> 16) & 0xff) / 255,
((packed >>> 8) & 0xff) / 255,
(packed & 0xff) / 255,
alphaByte / 255,
];
}
function buildLayer(layer, sourceToThree) {
if (!layer.color?.present || !ArrayBuffer.isView(layer.color?.values)) {
throw new Error(`${layer.waterBodyGuid} cannot be built faithfully: ${layer.unbuildableReason ?? "serialized color data is missing"}.`);
}
if (layer.descriptorJoin && layer.descriptorJoin.status !== "matched") {
throw new Error(`${layer.waterBodyGuid} descriptor GUID join is ${layer.descriptorJoin.status}; refusing material guessing.`);
}
const { height, color, flags } = layer;
checkedParcel(layer, height, "height");
checkedParcel(layer, color, "color");
checkedParcel(layer, flags, "flags");
const width = height.width - height.overlap * 2;
const depth = height.height - height.overlap * 2;
if (color.width - color.overlap * 2 !== width || color.height - color.overlap * 2 !== depth
|| flags.width - flags.overlap * 2 !== width || flags.height - flags.overlap * 2 !== depth) {
throw new Error(`${layer.waterBodyGuid} water height/color/flag inner grids differ.`);
}
if (width < 3 || width % 2 !== 1 || depth < 2) {
throw new Error(`${layer.waterBodyGuid} source topology requires an odd inner width ≥3 and a depth ≥2; got ${width}x${depth}.`);
}
const vertexCount = width * depth;
if (vertexCount > 0x100000000) throw new Error(`${layer.waterBodyGuid} water grid is too large.`);
const positions = new Float32Array(vertexCount * 3);
const sourceWorldXZ = new Float32Array(vertexCount * 2);
const normals = new Float32Array(vertexCount * 3);
const colors = new Float32Array(vertexCount * 4);
const entityTransform = layerSourceTransform(layer);
let sourceSplitPlaneHeight = null;
for (let z = 0; z < depth; z += 1) {
for (let x = 0; x < width; x += 1) {
const vertex = z * width + x;
const heightSample = (x + height.overlap) + (z + height.overlap) * height.width;
const colorSample = (x + color.overlap) + (z + color.overlap) * color.width;
const alphaSample = (x + flags.overlap) + (z + flags.overlap) * flags.width;
const source = new Vector3(
x * height.spacing + height.origin[0] + height.spacing * height.overlap,
Number(height.values[heightSample]),
z * height.spacing + height.origin[2] + height.spacing * height.overlap,
);
const sourceWorld = finitePosition(source.applyMatrix4(entityTransform), `Water source vertex ${vertex}`);
if (vertex === 0) sourceSplitPlaneHeight = sourceWorld.y;
const output = finitePosition(sourceWorld.clone().applyMatrix4(sourceToThree), `Water vertex ${vertex}`);
positions.set(output.toArray(), vertex * 3);
// KHR_materials_variants normalTexture carriers require TEXCOORD_0 for
// Khronos validation. This native-world XZ stream is only a glTF
// compatibility carrier; the runtime shader recovers its own source
// position from world transforms and does not sample this attribute.
sourceWorldXZ.set([sourceWorld.x, sourceWorld.z], vertex * 2);
// The native vertex format is POSITION|DIFFUSE. Its water shader uses a
// fixed world-up normal for spherical-harmonic lighting; this NORMAL is
// only a static viewport/rendering carrier for that source behavior.
normals.set(NATIVE_UP, vertex * 3);
colors.set(sourceColor(color.values[colorSample], flags.values[alphaSample]), vertex * 4);
}
}
const indices = [];
const visiblePairs = [];
let consideredPairs = 0;
for (let z = 0; z < depth - 1; z += 1) {
const faces = (z & 1) === 0 ? EVEN_FACES : ODD_FACES;
for (let x = 0; x < width - 1; x += 2) {
consideredPairs += 1;
const rowStart = z * width + x;
const indexSet = [rowStart, rowStart + 1, rowStart + 2,
rowStart + width, rowStart + width + 1, rowStart + width + 2];
const pairIndices = faces.map((slot) => indexSet[slot]);
let visible = false;
for (const sourceIndex of pairIndices) {
// Match Mojo_BuildMeshFromHeightField: visibility checks the direct flat
// attribute index. Per-vertex diffuse alpha, in contrast, accounts for
// the flags parcel's overlap gap above.
if (sourceIndex >= flags.values.length) {
throw new Error(`${layer.waterBodyGuid} visibility index ${sourceIndex} exceeds the native flags array (${flags.values.length}).`);
}
if ((flags.values[sourceIndex] & 0x80) !== 0) visible = true;
}
if (!visible) continue;
for (let face = 0; face < pairIndices.length; face += 3) {
const a = pairIndices[face];
const b = pairIndices[face + 1];
const c = pairIndices[face + 2];
// sourceTerrainToThreeMatrix includes the same reflected-Z bridge used
// by native terrain; invert the source triangle order once for glTF.
indices.push(a, c, b);
}
visiblePairs.push({ gridX: x, gridZ: z });
}
}
const IndexArray = vertexCount > 0xffff ? Uint32Array : Uint16Array;
let minHeight = Number.POSITIVE_INFINITY;
let maxHeight = Number.NEGATIVE_INFINITY;
for (let offset = 1; offset < positions.length; offset += 3) {
minHeight = Math.min(minHeight, positions[offset]);
maxHeight = Math.max(maxHeight, positions[offset]);
}
return {
layer,
positions,
sourceWorldXZ,
normals,
colors,
indices: IndexArray.from(indices),
summary: {
waterBodyGuid: layer.waterBodyGuid,
layerVersion: layer.version,
descriptorJoin: layer.descriptorJoin,
grid: { width, height: depth, sourceVertexCount: vertexCount },
consideredTwoCellQuadPairs: consideredPairs,
emittedTwoCellQuadPairs: visiblePairs.length,
emittedTriangles: indices.length / 3,
emittedIndexCount: indices.length,
planAreaSquareSourceUnits: visiblePairs.length * 2 * height.spacing * height.spacing,
sourceWaterSpacing: height.spacing,
sourceSplitPlaneHeight,
outputHeightRangeMeters: { min: minHeight, max: maxHeight },
alpha: {
sourceBits: "flags & 0x3f",
nativeByteConversion: "trunc((flags & 0x3f) / 63 * 255) / 255",
visibilityBit: "0x80, applied at 2-cell quad-pair topology level",
},
positionsTransform: "terrain sourceToThree matrix (inverse primary WDB TRS + reflected-Z meter bridge) multiplied by serialized water entity TRS",
normals: "constant world-up [0,1,0] carrier; native water source has no NORMAL attribute",
textureCoordinates: "TEXCOORD_0 carries source-world XZ for glTF normal-texture compatibility only; runtime animation samples transformed world coordinates and ignores mesh UVs",
nativePairSamples: visiblePairs.slice(0, 12),
},
};
}
/** Build source water layers in the exact primary-terrain coordinate bridge. */
export function buildNativeWaterGeometry(layers, primaryPlacement, metersPerSourceUnit) {
if (!Array.isArray(layers)) throw new TypeError("Native water layers must be an array.");
const sourceToThree = sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit);
const built = [];
const skipped = [];
for (const layer of layers) {
if (layer.unbuildableReason) {
skipped.push({ waterBodyGuid: layer.waterBodyGuid, reason: layer.unbuildableReason });
continue;
}
built.push(buildLayer(layer, sourceToThree));
}
return {
layers: built,
skipped,
report: {
layerCount: layers.length,
builtLayerCount: built.length,
skippedLayerCount: skipped.length,
sourceToThree: "same sourceTerrainToThreeMatrix used by native terrain: reflected-Z meter bridge multiplied by inverse primary WDB descriptor TRS",
nativeWinding: "source triangle index order is reversed exactly once after the reflected-Z bridge",
nativeGeometryResponsibility: "this module serializes water geometry, native visibility masks, vertex RGBA, topology, and source placement transforms; animated source bump normals are supplied by src/scene/runewakerWaterMaterial.ts",
nativeMaterialLimit: "browser scene reflection/refraction uses the source enable property and Fresnel equation; native SH, reflect-class filtering, and unknown effectType names remain unsupported",
layers: built.map((entry) => entry.summary),
skipped,
},
};
}
export const NATIVE_WATER_FACE_CONSTRUCTION = Object.freeze({ evenRows: EVEN_FACES, oddRows: ODD_FACES });
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildNativeWaterGeometry } from "./native-water-geometry.mjs";
const primaryPlacement = {
translation: [2, 3, 4],
rotation: [0, 0, 0, 1],
scale: [1, 1, 1],
};
function fixtureLayer({ flags = [0xff, 0xdf, 0x9f, 0xbf, 0x80, 0x00, 0xff, 0x9f, 0xbf] } = {}) {
const colors = [
0xff112233, 0xff445566, 0xff778899,
0xffaabbcc, 0xffddeeff, 0xff010203,
0xff102030, 0xff405060, 0xff708090,
];
return {
classGuid: "test-water",
version: 3,
waterBodyGuid: "exact-body-guid",
transform: { scale: [2, 1, 1], rotation: [0, 0, 0, 1], translation: [100, 5, 200] },
height: {
origin: [0, 0, 0], spacing: 10, overlap: 0, width: 3, height: 3,
values: Float32Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
color: {
present: true, origin: [0, 0, 0], spacing: 10, overlap: 0, width: 3, height: 3,
values: Uint32Array.from(colors),
},
flags: {
origin: [0, 0, 0], spacing: 10, overlap: 0, width: 3, height: 3,
values: Uint8Array.from(flags),
},
descriptorJoin: {
strategy: "exact serialized body GUID bytes",
status: "matched",
descriptorKeys: ["body-row"],
descriptors: [{ key: "body-row", guid: "exact-body-guid", effectType: 4 }],
},
unbuildableReason: null,
};
}
function assertClose(actual, expected, epsilon = 1e-6) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < expected.length; index += 1) {
assert.ok(Math.abs(actual[index] - expected[index]) <= epsilon, `value[${index}] ${actual[index]} != ${expected[index]}`);
}
}
test("native water geometry expands even and odd pair patterns with one reflected-Z winding reversal", () => {
const built = buildNativeWaterGeometry([fixtureLayer()], primaryPlacement, 0.1);
const [water] = built.layers;
assert.equal(water.summary.consideredTwoCellQuadPairs, 2);
assert.equal(water.summary.emittedTwoCellQuadPairs, 2);
assert.equal(water.summary.emittedTriangles, 8);
assert.deepEqual(Array.from(water.indices.slice(0, 12)), [0, 4, 3, 0, 1, 4, 1, 2, 4, 2, 5, 4]);
assert.deepEqual(Array.from(water.indices.slice(12, 24)), [3, 4, 6, 4, 7, 6, 4, 5, 8, 4, 8, 7]);
assertClose(Array.from(water.positions.slice(0, 3)), [9.8, 0.3, -19.6]);
assert.equal(water.summary.sourceSplitPlaneHeight, 6);
assertClose(Array.from(water.positions.slice(3, 6)), [11.8, 0.4, -19.6]);
assertClose(Array.from(water.sourceWorldXZ.slice(0, 6)), [100, 200, 120, 200, 140, 200]);
assertClose(Array.from(water.sourceWorldXZ.slice(6, 12)), [100, 210, 120, 210, 140, 210]);
assert.deepEqual(Array.from(water.normals.slice(0, 3)), [0, 1, 0]);
assertClose(Array.from(water.colors.slice(0, 4)), [17 / 255, 34 / 255, 51 / 255, 1]);
assertClose([water.colors[1 * 4 + 3]], [125 / 255]);
assert.equal(water.layer.descriptorJoin.descriptors[0].effectType, 4);
});
test("water visibility bit masks pair geometry while preserving per-vertex diffuse alpha", () => {
const hidden = buildNativeWaterGeometry([fixtureLayer({ flags: new Array(9).fill(0x3f) })], primaryPlacement, 0.1);
assert.equal(hidden.layers[0].indices.length, 0);
assert.equal(hidden.layers[0].colors[3], 1);
const visibleFromOneVertex = buildNativeWaterGeometry([fixtureLayer({ flags: [0x80, 0, 0, 0, 0, 0, 0, 0, 0] })], primaryPlacement, 0.1);
assert.equal(visibleFromOneVertex.layers[0].summary.emittedTriangles, 4);
assert.equal(visibleFromOneVertex.layers[0].colors[3], 0);
});
test("native water builder refuses non-exact material joins and missing source color bytes", () => {
const unmatched = fixtureLayer();
unmatched.descriptorJoin.status = "missing";
assert.throws(() => buildNativeWaterGeometry([unmatched], primaryPlacement, 0.1), /descriptor GUID join is missing/);
const v1 = fixtureLayer();
v1.color = { present: false };
v1.unbuildableReason = "native v1 color absent";
const result = buildNativeWaterGeometry([v1], primaryPlacement, 0.1);
assert.equal(result.layers.length, 0);
assert.equal(result.skipped[0].reason, "native v1 color absent");
});
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsVariants } from "@gltf-transform/extensions";
import sharp from "sharp";
import { MeshoptDecoder } from "meshoptimizer";
import { environmentOptimizationArguments } from "../lib/environment-optimization.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
test("real glTF Transform optimize preserves opted-in material variant carriers and UVs", async (context) => {
await MeshoptDecoder.ready;
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "runewaker-water-variant-opt-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const sourceFile = path.join(workingDirectory, "water-variant-source.glb");
const outputFile = path.join(workingDirectory, "water-variant-optimized.glb");
const document = new Document();
const buffer = document.createBuffer("fixture");
const variants = document.createExtension(KHRMaterialsVariants).setRequired(false);
const normalPng = await sharp({
create: { width: 4, height: 4, channels: 4, background: { r: 128, g: 128, b: 255, alpha: 1 } },
}).png().toBuffer();
const normalTexture = document.createTexture("native-normal-frame")
.setImage(normalPng)
.setMimeType("image/png");
const regularMaterial = document.createMaterial("source-water").setAlphaMode("BLEND");
const frameMaterial = document.createMaterial("normal-frame-00")
.setNormalTexture(normalTexture)
.setAlphaMode("OPAQUE");
frameMaterial.getNormalTextureInfo().setWrapS(10497).setWrapT(10497);
const variant = variants.createVariant("native-frame-00");
const mapping = variants.createMappingList().addMapping(
variants.createMapping().setMaterial(frameMaterial).addVariant(variant),
);
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor("positions", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 0, 1])))
.setAttribute("NORMAL", document.createAccessor("normals", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0])))
.setAttribute("TEXCOORD_0", document.createAccessor("source-world-xz", buffer)
.setType("VEC2")
.setArray(new Float32Array([100, 200, 110, 200, 100, 210])))
.setIndices(document.createAccessor("indices", buffer).setArray(new Uint16Array([0, 1, 2])))
.setMaterial(regularMaterial)
.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mapping);
const mesh = document.createMesh("native-water").addPrimitive(primitive);
const scene = document.createScene("scene").addChild(document.createNode("water-layer").setMesh(mesh));
document.getRoot().setDefaultScene(scene);
await io.write(sourceFile, document);
const args = environmentOptimizationArguments(sourceFile, outputFile, { preserveMaterialVariantCarriers: true });
assert.equal(args[args.indexOf("--join") + 1], "false");
assert.equal(args[args.indexOf("--prune") + 1], "false");
assert.equal(args[args.indexOf("--prune-attributes") + 1], "false");
const waterArgs = environmentOptimizationArguments(sourceFile, outputFile, { preserveNativeWaterLayers: true });
assert.equal(waterArgs[waterArgs.indexOf("--join") + 1], "false", "native water keeps its existing carrier-preservation behavior");
const optimized = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(optimized.status, 0, `${optimized.stdout}\n${optimized.stderr}`);
const output = await io.read(outputFile);
const outputMesh = output.getRoot().listMeshes().find((candidate) => candidate.getName() === "native-water");
const outputPrimitive = outputMesh?.listPrimitives()[0];
assert.ok(outputPrimitive, "optimized water primitive must remain present");
const texcoords = outputPrimitive.getAttribute("TEXCOORD_0");
assert.ok(texcoords, "normal-texture variant must retain its required UV0 attribute");
assert.equal(texcoords.getType(), "VEC2");
assert.deepEqual(Array.from(texcoords.getArray()), [100, 200, 110, 200, 100, 210]);
const outputMapping = outputPrimitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)?.listMappings()[0];
assert.ok(outputMapping, "KHR_materials_variants mapping must survive optimize");
assert.equal(outputMapping.getMaterial()?.getNormalTexture()?.getName(), "native-normal-frame");
assert.deepEqual(outputMapping.listVariants().map((item) => item.getName()), ["native-frame-00"]);
assert.ok((await readFile(outputFile)).length > 0);
});
@@ -0,0 +1,341 @@
import { createHash } from "node:crypto";
const MAX_PARCEL_SAMPLES = 8_000_000;
const MAX_OBJECT_PROPERTIES = 4096;
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
class Reader {
constructor(buffer, label) {
if (!Buffer.isBuffer(buffer)) throw new TypeError(`${label} must be a Buffer.`);
this.buffer = buffer;
this.offset = 0;
this.label = label;
}
take(length, name) {
if (!Number.isSafeInteger(length) || length < 0 || this.offset + length > this.buffer.length) {
throw new Error(`${this.label} ${name} is truncated at ${this.offset}+${length}/${this.buffer.length}.`);
}
const value = this.buffer.subarray(this.offset, this.offset + length);
this.offset += length;
return value;
}
bytes(length, name) { return this.take(length, name); }
i8(name) { return this.take(1, name).readInt8(0); }
u8(name) { return this.take(1, name).readUInt8(0); }
i16(name) { return this.take(2, name).readInt16LE(0); }
u16(name) { return this.take(2, name).readUInt16LE(0); }
i32(name) { return this.take(4, name).readInt32LE(0); }
u32(name) { return this.take(4, name).readUInt32LE(0); }
i64(name) { return this.take(8, name).readBigInt64LE(0).toString(); }
u64(name) { return this.take(8, name).readBigUInt64LE(0).toString(); }
f32(name) {
const value = this.take(4, name).readFloatLE(0);
if (!Number.isFinite(value)) throw new Error(`${this.label} ${name} is non-finite.`);
return value;
}
cString(name) {
const length = this.i32(`${name} length`);
if (length < 0 || length > 1_000_000) throw new Error(`${this.label} ${name} length ${length} is invalid.`);
return this.take(length, name).toString("utf8").replace(/\0+$/, "");
}
finish(name = this.label) {
if (this.offset !== this.buffer.length) {
throw new Error(`${name} consumed ${this.offset} of ${this.buffer.length} bytes (${this.buffer.length - this.offset} trailing).`);
}
}
}
function parseObject(reader, depth = 0) {
if (depth > 8) throw new Error("Rune object property nesting exceeds the supported depth of 8.");
const classGuid = reader.bytes(16, "object class GUID").toString("hex");
const version = reader.i32("object version");
if (version !== 1 && version !== 2) throw new Error(`Unsupported Rune object version ${version}.`);
const name = reader.cString("object name");
const properties = [];
if (version >= 2) {
const count = reader.i32("object property count");
if (count < 0 || count > MAX_OBJECT_PROPERTIES) throw new Error(`Invalid Rune object property count ${count}.`);
for (let index = 0; index < count; index += 1) {
const propertyName = reader.cString(`property[${index}] name`);
const type = reader.i32(`property[${index}] type`);
let value;
switch (type) {
case 0: value = reader.i8("int8 property"); break;
case 1: value = reader.i16("int16 property"); break;
case 2: value = reader.i32("int32 property"); break;
case 3: value = reader.i64("int64 property"); break;
case 4: value = reader.u8("uint8 property"); break;
case 5: value = reader.u16("uint16 property"); break;
case 6: value = reader.u32("uint32 property"); break;
case 7: value = reader.u64("uint64 property"); break;
case 8: value = reader.f32("float32 property"); break;
case 9: value = null; break;
case 10: value = Array.from({ length: 4 }, (_, component) => reader.f32(`color property[${component}]`)); break;
case 11: value = parseObject(reader, depth + 1); break;
case 12: value = reader.cString("string property"); break;
default: throw new Error(`Unknown Rune object property type ${type} at ${propertyName}.`);
}
properties.push({ name: propertyName, type, value });
}
}
return { classGuid, version, name, properties };
}
function parseParcelHeader(reader, label) {
const origin = Array.from({ length: 3 }, (_, index) => reader.f32(`${label} origin[${index}]`));
const spacing = reader.f32(`${label} spacing`);
const overlap = reader.i32(`${label} overlap`);
const width = reader.i32(`${label} width`);
const height = reader.i32(`${label} height`);
const sampleCount = width * height;
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1
|| overlap < 0 || overlap * 2 >= Math.min(width, height)
|| !Number.isSafeInteger(sampleCount) || sampleCount > MAX_PARCEL_SAMPLES
|| spacing <= 0) {
throw new Error(`${label} parcel dimensions/origin/spacing are invalid (${width}x${height}, overlap ${overlap}, spacing ${spacing}).`);
}
return { origin, spacing, overlap, width, height, sampleCount };
}
function parseHeightParcel(reader) {
const header = parseParcelHeader(reader, "water height");
const minimum = reader.f32("water height minimum");
const maximum = reader.f32("water height maximum");
if (minimum > maximum) throw new Error("Water height minimum exceeds maximum.");
const rawQuantized = new Uint16Array(header.sampleCount);
const values = new Float32Array(header.sampleCount);
// CRuWorld's REAL is float32; retain the engine's per-operation rounding in
// Mojo_SerializeFrom rather than only rounding after the complete equation.
const range = Math.fround(maximum - minimum);
for (let index = 0; index < header.sampleCount; index += 1) {
const quantized = reader.u16(`water height sample[${index}]`);
rawQuantized[index] = quantized;
const normalized = Math.fround(quantized / 60000);
const scaled = Math.fround(normalized * range);
values[index] = Math.fround(scaled + minimum);
}
return { ...header, minimum, maximum, quantizationStep: range / 60000, rawQuantized, values };
}
function parseGenericParcel(reader, label, type) {
const header = parseParcelHeader(reader, `water ${label}`);
if (type === "color") {
const raw = reader.bytes(header.sampleCount * 4, `water ${label} values`);
const values = new Uint32Array(header.sampleCount);
for (let index = 0; index < values.length; index += 1) values[index] = raw.readUInt32LE(index * 4);
return { ...header, values };
}
if (type === "flags") {
return { ...header, values: Uint8Array.from(reader.bytes(header.sampleCount, `water ${label} values`)) };
}
throw new Error(`Unsupported generic water parcel type ${type}.`);
}
function validateRenderableEntity(reader) {
const entityClassGuid = reader.bytes(16, "renderable entity class GUID").toString("hex");
const version = reader.i32("renderable entity version");
if (version < 1 || version > 2) throw new Error(`Unsupported CRuEntity version ${version}.`);
const baseObject = parseObject(reader);
const scale = Array.from({ length: 3 }, (_, index) => reader.f32(`entity scale[${index}]`));
const rotation = Array.from({ length: 4 }, (_, index) => reader.f32(`entity rotation[${index}]`));
const translation = Array.from({ length: 3 }, (_, index) => reader.f32(`entity translation[${index}]`));
const optionFlags0 = reader.u32("entity option flags 0");
const optionFlags1 = version >= 2 ? reader.u32("entity option flags 1") : null;
const collisionDataExists = reader.i32("entity collision data flag");
if (collisionDataExists !== 0) {
throw new Error("Water entity carries embedded collision data; its payload format is not implemented, so exact layer decoding is unsafe.");
}
if (scale.some((value) => value === 0)) throw new Error("Water entity transform has a zero scale component.");
const rotationLength = Math.hypot(...rotation);
if (rotationLength < 1e-12) throw new Error("Water entity rotation quaternion is zero.");
return {
entity: { classGuid: entityClassGuid, version, baseObject },
transform: { scale, rotation, translation, optionFlags0, optionFlags1, collisionDataExists },
};
}
function parseLayer(reader, layerIndex) {
const start = reader.offset;
const classGuid = reader.bytes(16, `water layer ${layerIndex} class GUID`).toString("hex");
const version = reader.i32(`water layer ${layerIndex} version`);
if (version < 1 || version > 3) throw new Error(`Unsupported water layer version ${version} at layer ${layerIndex}.`);
const waterBodyGuid = reader.bytes(16, `water layer ${layerIndex} body GUID`).toString("hex");
let entity = null;
let transform = { scale: [1, 1, 1], rotation: [0, 0, 0, 1], translation: [0, 0, 0], legacy: version < 3 };
const objectOffset = reader.offset;
if (version >= 3) {
const parsedEntity = validateRenderableEntity(reader);
entity = parsedEntity.entity;
transform = parsedEntity.transform;
}
const height = parseHeightParcel(reader);
const color = version === 1 ? null : parseGenericParcel(reader, "color", "color");
const flags = parseGenericParcel(reader, "flags", "flags");
if (version < 3) {
// Native loader migration in CRuWorld_Water::SerializeFrom: color origin is
// promoted to entity translation, then all old parcel origins are reset.
const legacyTranslation = color?.origin ?? flags.origin;
transform = {
scale: [1, 1, 1],
rotation: [0, 0, 0, 1],
translation: [...legacyTranslation],
legacy: true,
migration: "v1/v2 parcel origin becomes entity translation; height origin resets to negative overlap spacing; color/flags origins reset to zero",
};
height.origin = [-height.overlap * height.spacing, 0, -height.overlap * height.spacing];
if (color) color.origin = [0, 0, 0];
flags.origin = [0, 0, 0];
}
if (!color) {
// v1 allocates an uninitialized color parcel from dimensions only. The
// bytes do not exist in the WDB and cannot be faithfully reconstructed.
return {
classGuid,
version,
waterBodyGuid,
entity,
transform,
height,
color: { present: false, reason: "v1 native loader allocates but does not initialize the water color parcel" },
flags,
sourceByteRange: { start, end: reader.offset },
unbuildableReason: "native v1 water color is not serialized; refusing guessed vertex RGB",
entityByteOffset: objectOffset,
};
}
const effective = (parcel) => [parcel.width - parcel.overlap * 2, parcel.height - parcel.overlap * 2];
if (effective(height).join("x") !== effective(color).join("x")
|| effective(height).join("x") !== effective(flags).join("x")) {
throw new Error(`Water layer ${layerIndex} height/color/flags effective grids do not align.`);
}
return {
classGuid,
version,
waterBodyGuid,
entity,
transform,
height,
color: { present: true, ...color },
flags,
sourceByteRange: { start, end: reader.offset },
unbuildableReason: null,
effectiveGrid: { width: effective(height)[0], height: effective(height)[1] },
nativeTopology: {
visibilityFlag: "0x80",
visibleTest: "for each 2-cell quad pair, test flag bytes at every source index referenced by the selected 12 native face indices; source helper reads this as a direct flat flags index",
alphaBits: "flags & 0x3f; native converts to byte alpha by trunc((alphaBits/63)*255)",
evenRowFaces: [0, 3, 4, 0, 4, 1, 1, 4, 2, 2, 4, 5],
oddRowFaces: [0, 3, 1, 1, 3, 4, 1, 5, 2, 1, 4, 5],
vertexFormat: "POSITION | DIFFUSE; no serialized NORMAL or TEXCOORD streams",
},
};
}
function parseDescriptorRecord({ key, payload }) {
if (!Buffer.isBuffer(payload)) throw new TypeError(`Descriptor ${key} payload must be a Buffer.`);
const reader = new Reader(payload, `water descriptor ${key}`);
const classGuid = reader.bytes(16, "descriptor class GUID").toString("hex");
const version = reader.i32("descriptor version");
if (version < 1 || version > 5) throw new Error(`Unsupported water descriptor version ${version} at record ${key}.`);
const baseObject = version >= 2 ? parseObject(reader) : null;
const guid = reader.bytes(16, "water descriptor body GUID").toString("hex");
const bodyType = version >= 3 ? reader.i32("water descriptor body type") : null;
const effectType = reader.i32("water descriptor effect type");
let legacy = null;
let submerged = null;
if (version <= 4) {
const legacyFloatParameters = Array.from({ length: 6 }, (_, index) => reader.f32(`legacy water parameter[${index}]`));
const legacyFlags = reader.u32("legacy water flags");
const baseFrames = reader.i32("base-map frame count");
const normalFrames = reader.i32("normal-map frame count");
const cubeFrames = reader.i32("cube-map frame count");
const names = ["base", "normal", "cube"].map((name) => reader.cString(`${name} map name`));
legacy = { legacyFloatParameters, legacyFlags, baseFrames, normalFrames, cubeFrames, names };
if (version === 4) {
submerged = {
argb: reader.u32("submerged ARGB color"),
constantFog: reader.f32("submerged constant fog"),
fogStart: reader.f32("submerged fog start"),
fogEnd: reader.f32("submerged fog end"),
};
}
} else {
submerged = {
argb: reader.u32("submerged ARGB color"),
constantFog: reader.f32("submerged constant fog"),
fogStart: reader.f32("submerged fog start"),
fogEnd: reader.f32("submerged fog end"),
};
}
reader.finish(`water descriptor ${key}`);
return {
key: String(key),
sha256: sha256(payload),
classGuid,
version,
baseObject,
guid,
bodyType,
effectType,
legacy,
submerged,
sourcePayloadBytes: payload.length,
};
}
/**
* Decode the serialized water objects following a native terrain chunk's
* water-layer count. Supported native layer versions are v1–v3. Version 1 is
* decoded but cannot produce faithful geometry because its native color parcel
* has no serialized data. Descriptor rows are joined only by their 16-byte body
* GUID, never by a display name or texture basename.
*/
export function decodeNativeWaterPayload(payload, { layerCount, descriptors = [] } = {}) {
if (!Buffer.isBuffer(payload)) throw new TypeError("Native water payload must be a Buffer.");
if (!Number.isInteger(layerCount) || layerCount < 0 || layerCount > 1024) {
throw new Error(`Invalid native water layer count ${layerCount}.`);
}
if (!Array.isArray(descriptors)) throw new TypeError("Water descriptors must be an array of {key, payload} records.");
const descriptorRows = descriptors.map(parseDescriptorRecord);
const descriptorByGuid = new Map();
for (const row of descriptorRows) {
const rows = descriptorByGuid.get(row.guid) ?? [];
rows.push(row);
descriptorByGuid.set(row.guid, rows);
}
const reader = new Reader(payload, "native terrain water payload");
const layers = [];
for (let index = 0; index < layerCount; index += 1) {
const layer = parseLayer(reader, index);
const matches = descriptorByGuid.get(layer.waterBodyGuid) ?? [];
layers.push({
...layer,
descriptorJoin: {
strategy: "exact serialized body GUID bytes, hex normalized lowercase",
status: matches.length === 1 ? "matched" : matches.length === 0 ? "missing" : "ambiguous",
descriptorKeys: matches.map((row) => row.key),
descriptors: matches,
},
});
}
reader.finish("native terrain water payload");
return {
schemaVersion: 1,
layerCount,
sourcePayloadBytes: payload.length,
sourcePayloadSha256: sha256(payload),
consumedBytes: reader.offset,
trailingBytes: 0,
descriptorRowCount: descriptorRows.length,
layers,
};
}
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decodeNativeWaterPayload } from "./native-water-payload.mjs";
function putI32(value) {
const out = Buffer.alloc(4);
out.writeInt32LE(value);
return out;
}
function putU32(value) {
const out = Buffer.alloc(4);
out.writeUInt32LE(value >>> 0);
return out;
}
function putF32(value) {
const out = Buffer.alloc(4);
out.writeFloatLE(value);
return out;
}
function putCString(value) {
const bytes = Buffer.from(`${value}\0`, "utf8");
return Buffer.concat([putI32(bytes.length), bytes]);
}
function guidBytes(fill) {
return Buffer.alloc(16, fill);
}
function objectBytes(properties = [], version = 2) {
const prefix = [guidBytes(0x11), putI32(version), putCString("")];
if (version >= 2) {
prefix.push(putI32(properties.length));
for (const property of properties) {
prefix.push(putCString(property.name), putI32(property.type));
if (property.type === 2) prefix.push(putI32(property.value));
else if (property.type === 8) prefix.push(putF32(property.value));
else if (property.type === 12) prefix.push(putCString(property.value));
else throw new Error(`Fixture does not implement property type ${property.type}.`);
}
}
return Buffer.concat(prefix);
}
function parcelHeader({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2 }) {
return Buffer.concat([
...origin.map(putF32), putF32(spacing), putI32(overlap), putI32(width), putI32(height),
]);
}
function heightParcel({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2, values = [100, 100, 100, 100, 100, 100] } = {}) {
const quantized = Buffer.alloc(values.length * 2);
values.forEach((value, index) => quantized.writeUInt16LE(value, index * 2));
return Buffer.concat([
parcelHeader({ origin, spacing, overlap, width, height }),
putF32(0), putF32(10), quantized,
]);
}
function colorParcel({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2, values = [0xff112233, 0xff445566, 0xff778899, 0xffaabbcc, 0xffddeeff, 0xff010203] } = {}) {
const data = Buffer.alloc(values.length * 4);
values.forEach((value, index) => data.writeUInt32LE(value >>> 0, index * 4));
return Buffer.concat([parcelHeader({ origin, spacing, overlap, width, height }), data]);
}
function flagsParcel({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2, values = [0xff, 0xdf, 0x9f, 0xbf, 0x80, 0x00] } = {}) {
return Buffer.concat([parcelHeader({ origin, spacing, overlap, width, height }), Buffer.from(values)]);
}
function layerBytes({ version = 3, bodyFill = 0x22, height = heightParcel(), color = colorParcel(), flags = flagsParcel(), translation = [0, 0, 0] } = {}) {
const prefix = [guidBytes(0x33), putI32(version), guidBytes(bodyFill)];
if (version >= 3) {
prefix.push(
guidBytes(0x44), putI32(2), objectBytes(),
...[1, 1, 1].map(putF32), ...[0, 0, 0, 1].map(putF32), ...translation.map(putF32),
putU32(0), putU32(0), putI32(0),
);
}
if (version === 1) return Buffer.concat([...prefix, height, flags]);
return Buffer.concat([...prefix, height, color, flags]);
}
function descriptorBytes({ bodyFill = 0x22, version = 5, properties = [] } = {}) {
const prefix = [guidBytes(0x55), putI32(version)];
if (version >= 2) prefix.push(objectBytes(properties));
prefix.push(guidBytes(bodyFill));
if (version >= 3) prefix.push(putI32(0));
prefix.push(putI32(4)); // Preserve undeclared source value, do not infer its effect.
if (version <= 4) {
prefix.push(...Array.from({ length: 6 }, () => putF32(0)), putU32(0), putI32(0), putI32(0), putI32(0));
prefix.push(putCString(""), putCString(""), putCString(""));
}
if (version === 4 || version === 5) prefix.push(putU32(0xff000000), putF32(0), putF32(0), putF32(1));
return Buffer.concat(prefix);
}
test("native water v3 payload decodes exact dimensions, vertex channels, transform and GUID descriptor", () => {
const payload = layerBytes({ translation: [10, 20, 30] });
const descriptor = descriptorBytes({ properties: [{ name: "waterDesc:full:bump name", type: 12, value: "texture\\water\\wave8" }] });
const decoded = decodeNativeWaterPayload(payload, {
layerCount: 1,
descriptors: [{ key: "body-row", payload: descriptor }],
});
const [layer] = decoded.layers;
assert.equal(decoded.consumedBytes, payload.length);
assert.equal(decoded.trailingBytes, 0);
assert.equal(layer.version, 3);
assert.deepEqual(layer.transform.translation, [10, 20, 30]);
for (const value of layer.height.values) assert.ok(Math.abs(value - (100 / 60000 * 10)) < 1e-7);
assert.equal(layer.color.values[0], 0xff112233);
assert.equal(layer.flags.values[0], 0xff);
assert.equal(layer.descriptorJoin.status, "matched");
assert.equal(layer.descriptorJoin.descriptors[0].effectType, 4);
assert.equal(layer.descriptorJoin.descriptors[0].baseObject.properties[0].value, "texture\\water\\wave8");
});
test("legacy v2 origin migration uses color parcel origin as transform and resets parcel origins", () => {
const payload = layerBytes({
version: 2,
bodyFill: 0x23,
height: heightParcel({ origin: [3, 7, 9], overlap: 1, width: 5, height: 4, values: new Array(20).fill(12) }),
color: colorParcel({ origin: [50, 60, 70], width: 3, height: 2 }),
flags: flagsParcel({ origin: [8, 9, 10], width: 3, height: 2 }),
});
const decoded = decodeNativeWaterPayload(payload, { layerCount: 1 });
const [layer] = decoded.layers;
assert.deepEqual(layer.transform.translation, [50, 60, 70]);
assert.deepEqual(layer.height.origin, [-1, 0, -1]);
assert.deepEqual(layer.color.origin, [0, 0, 0]);
assert.deepEqual(layer.flags.origin, [0, 0, 0]);
});
test("legacy v1 layer is decoded but explicitly unbuildable because color bytes are absent", () => {
const payload = layerBytes({
version: 1,
bodyFill: 0x24,
height: heightParcel(),
flags: flagsParcel({ origin: [40, 50, 60] }),
});
const decoded = decodeNativeWaterPayload(payload, { layerCount: 1 });
const [layer] = decoded.layers;
assert.equal(layer.color.present, false);
assert.equal(layer.unbuildableReason, "native v1 water color is not serialized; refusing guessed vertex RGB");
assert.deepEqual(layer.transform.translation, [40, 50, 60]);
});
test("descriptor matching is exact and refuses missing or duplicate GUID matches", () => {
const payload = layerBytes();
const wrongDescriptor = descriptorBytes({ bodyFill: 0x21 });
const missing = decodeNativeWaterPayload(payload, { layerCount: 1, descriptors: [{ key: "wrong", payload: wrongDescriptor }] });
assert.equal(missing.layers[0].descriptorJoin.status, "missing");
const ambiguous = decodeNativeWaterPayload(payload, {
layerCount: 1,
descriptors: [
{ key: "first", payload: descriptorBytes() },
{ key: "second", payload: descriptorBytes() },
],
});
assert.equal(ambiguous.layers[0].descriptorJoin.status, "ambiguous");
assert.deepEqual(ambiguous.layers[0].descriptorJoin.descriptorKeys, ["first", "second"]);
});
test("decoder requires exact payload and descriptor consumption", () => {
assert.throws(() => decodeNativeWaterPayload(Buffer.concat([layerBytes(), Buffer.from([0])]), { layerCount: 1 }), /trailing/);
assert.throws(() => decodeNativeWaterPayload(layerBytes().subarray(0, 100), { layerCount: 1 }), /truncated/);
assert.throws(() => decodeNativeWaterPayload(layerBytes(), {
layerCount: 1,
descriptors: [{ key: "descriptor", payload: Buffer.concat([descriptorBytes(), Buffer.from([1])]) }],
}), /trailing/);
});
@@ -0,0 +1,15 @@
/** Mirrors BuildWaterBodyMaterial's branch without assigning names to raw 4. */
export function nativeWaterRenderPolicy(descriptor) {
const property = descriptor.baseObject?.properties?.find(row => row.name === "waterDesc:full:enable");
if (property && (property.type !== 2 || !Number.isInteger(property.value))) {
throw new Error("Native water full-model enable must be a serialized BOOL.");
}
const fullModelRequested = property ? property.value !== 0 : false;
const eligibleBody = descriptor.bodyType === 0 || descriptor.bodyType === 2;
return {
fullModelEnabled: eligibleBody && descriptor.effectType !== 2 && fullModelRequested,
bodyTypeRaw: descriptor.bodyType,
fullModelRequested,
sourceBranch: "NORMAL/SOLID body, non-DIRECT effect, waterDesc:full:enable; browser enables reflection/refraction together",
};
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { nativeWaterRenderPolicy } from "./native-water-render-policy.mjs";
const descriptor = (bodyType = 0, effectType = 4, value = 1, type = 2) => ({
bodyType, effectType,
baseObject: { properties: [{ name: "waterDesc:full:enable", type, value }] },
});
test("follows the source non-DIRECT branch for raw 4 without guessing its name", () => {
assert.equal(nativeWaterRenderPolicy(descriptor()).fullModelEnabled, true);
assert.equal(nativeWaterRenderPolicy(descriptor(2)).fullModelEnabled, true);
assert.equal(nativeWaterRenderPolicy(descriptor(0, 2)).fullModelEnabled, false);
assert.equal(nativeWaterRenderPolicy(descriptor(1)).fullModelEnabled, false);
assert.equal(nativeWaterRenderPolicy(descriptor(0, 4, 0)).fullModelEnabled, false);
assert.equal(nativeWaterRenderPolicy({ bodyType: 0, effectType: 4 }).fullModelEnabled, false);
assert.throws(() => nativeWaterRenderPolicy(descriptor(0, 4, 1, 8)), /BOOL/);
});
@@ -0,0 +1,136 @@
const SHA256 = /^[a-f0-9]{64}$/i;
function resourceKey(value) {
return String(value ?? "").replaceAll("/", "\\").toLowerCase();
}
function requireObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
return value;
}
export function buildOriginNativeMaterialProvenance({ metadata, assembly, semantics, conversionReports }) {
requireObject(metadata, "Import metadata");
requireObject(assembly, "Assembly evidence");
requireObject(semantics, "Native semantics report");
if (!Array.isArray(assembly.stagedOverrides) || assembly.stagedOverrides.length < 1) {
throw new Error("Assembly evidence must contain one or more staged overrides.");
}
if (!Array.isArray(assembly.resources)) {
throw new Error("Assembly evidence is missing its authoritative source resource rows.");
}
if (!Array.isArray(semantics.khrMaterialsUnlit) || !Array.isArray(semantics.nativeAdditiveMaterials)) {
throw new Error("Native semantics report is missing its verified unlit/additive material lists.");
}
if (!SHA256.test(assembly.sourceWdbSha256 ?? "") || !SHA256.test(assembly.assembledVisual?.sha256 ?? "")) {
throw new Error("Assembly evidence is missing a valid source WDB or assembled-source SHA-256.");
}
if (assembly.sourceWdbReportSha256 != null && !SHA256.test(assembly.sourceWdbReportSha256)) {
throw new Error("Assembly evidence has an invalid source WDB report SHA-256.");
}
const reportEntries = conversionReports instanceof Map
? conversionReports
: new Map(Object.entries(conversionReports ?? {}).map(([key, value]) => [resourceKey(key), value]));
const nativeAlphaResources = [];
const metadataResources = [];
const seenResources = new Set();
const authoritativeHashes = new Map();
for (const override of assembly.stagedOverrides) {
const key = resourceKey(override.resource);
if (!key || seenResources.has(key)) throw new Error(`Staged override resource is missing or duplicated: ${override.resource}.`);
seenResources.add(key);
const sourceRows = assembly.resources.filter((row) => resourceKey(row.resource) === key);
if (sourceRows.length !== 1) throw new Error(`Expected one authoritative assembly resource row for ${override.resource}; found ${sourceRows.length}.`);
const sourceRow = sourceRows[0];
if (!SHA256.test(sourceRow.sourceSha256 ?? "")) throw new Error(`Invalid assembly source SHA-256 for ${override.resource}.`);
if (Number(sourceRow.placementCount) !== Number(override.placements)) {
throw new Error(`Placement count mismatch for ${override.resource}.`);
}
const conversion = reportEntries.get(key);
const nativeAlpha = conversion?.visual?.materials?.nativeAlpha;
if (!nativeAlpha || !Array.isArray(nativeAlpha.materials)) {
throw new Error(`Missing native alpha material evidence for ${override.resource}.`);
}
const sourceSha256 = nativeAlpha.sourceResourceSha256;
if (!SHA256.test(sourceSha256 ?? "")) {
throw new Error(`Invalid native source hash for ${override.resource}.`);
}
if (resourceKey(nativeAlpha.sourceModel) !== key) {
throw new Error(`Source model identity mismatch for ${override.resource}: ${nativeAlpha.sourceModel}.`);
}
if (resourceKey(conversion.source?.model) !== key || conversion.source?.resourceSha256 !== sourceSha256) {
throw new Error(`Conversion source provenance mismatch for ${override.resource}.`);
}
if (sourceRow.sourceSha256 !== sourceSha256) {
throw new Error(`Conversion source SHA-256 does not match the authoritative assembly row for ${override.resource}.`);
}
if (nativeAlpha.materials.some((material) =>
resourceKey(material.sourceModel) !== key || material.sourceResourceSha256 !== sourceSha256)) {
throw new Error(`Material-level native identity does not match ${override.resource}.`);
}
authoritativeHashes.set(key, sourceSha256);
nativeAlphaResources.push({
resource: override.resource,
placements: override.placements,
sourceResourceSha256: sourceSha256,
nativeAlpha,
});
metadataResources.push({
resource: override.resource,
placements: override.placements,
sourceResourceSha256: sourceSha256,
});
}
for (const semanticMaterial of [...semantics.khrMaterialsUnlit, ...semantics.nativeAdditiveMaterials]) {
const key = resourceKey(semanticMaterial.sourceModel);
if (!key || !authoritativeHashes.has(key) || semanticMaterial.sourceResourceSha256 !== authoritativeHashes.get(key)) {
throw new Error(`Unlit/additive material has no exact staged source identity: ${semanticMaterial.sourceModel ?? "<missing>"}.`);
}
}
const assemblyProvenance = {
sourceWdbSha256: assembly.sourceWdbSha256,
sourceWdbReportSha256: assembly.sourceWdbReportSha256 ?? null,
assembledSourceSha256: assembly.assembledVisual.sha256,
staticPlacements: assembly.staticPlacementCount,
uniqueResources: assembly.uniqueResourceCount,
dynamicPlacements: assembly.dynamicPlacementCount,
stagedOverrideCount: assembly.stagedOverrides.length,
primaryResource: assembly.primaryResource,
primarySourceSha256: assembly.primarySourceSha256,
};
const alphaReport = {
schemaVersion: 2,
source: "native material conversion reports joined by exact ROS resource identity and source SHA-256",
assembly: assemblyProvenance,
resources: nativeAlphaResources,
};
const semanticsReport = {
schemaVersion: 1,
source: "native material semantics annotated before gltf-transform optimization",
assembly: assemblyProvenance,
materialCounts: semantics.materialCounts ?? null,
sharedMapLightingPair: semantics.sharedMapLightingPair ?? null,
khrMaterialsUnlit: semantics.khrMaterialsUnlit,
nativeAdditiveMaterials: semantics.nativeAdditiveMaterials,
limitation: "This report records source-proven material semantics for the listed staged overrides; it does not certify every surface as visually correct.",
};
const updatedMetadata = structuredClone(metadata);
updatedMetadata.nativeMaterialAlpha = {
report: "native-material-alpha-report.json",
resources: metadataResources,
};
updatedMetadata.nativeMaterialSemantics = {
report: "native-material-semantics-report.json",
stagedOverrideCount: assembly.stagedOverrides.length,
additiveMaterialCount: semantics.nativeAdditiveMaterials.length,
unlitMaterialCount: semantics.khrMaterialsUnlit.length,
assembledSourceSha256: assembly.assembledVisual.sha256,
};
return { metadata: updatedMetadata, alphaReport, semanticsReport };
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildOriginNativeMaterialProvenance } from "./origin-native-material-provenance.mjs";
const resources = [
"model\\dungeon\\the_origin\\act_precipice_01.ros",
"model\\dungeon\\the_origin\\act_precipice_02.ros",
"model\\element\\wailing_mountains\\prop\\waterfall\\act_waterfall02.ros",
"model\\element\\elves_island\\vegetation\\gaint_tree_a.ros",
"model\\element\\weeping_coast\\vegetation\\n_tree_02.ros",
"model\\dungeon\\the_origin\\tree_01_01.ros",
"model\\dungeon\\the_origin\\act_cocoon_01.ros",
];
function fixture() {
const conversionReports = new Map();
const stagedOverrides = resources.map((resource, index) => {
const sha256 = String(index + 1).padStart(64, "0");
conversionReports.set(resource.toLowerCase(), {
source: { model: resource, resourceSha256: sha256 },
visual: {
materials: {
nativeAlpha: {
sourceModel: resource,
sourceResourceSha256: sha256,
materials: [{ sourceModel: resource, sourceResourceSha256: sha256, materialIndex: 1 }],
},
},
},
});
return { resource, file: `staging/${index}/visual.glb`, placements: index + 1 };
});
return {
conversionReports,
assembly: {
sourceWdbSha256: "a".repeat(64),
sourceWdbReportSha256: "b".repeat(64),
primaryResource: "model\\dungeon\\the_origin\\cathedral_01_b.ros",
primarySourceSha256: "c".repeat(64),
staticPlacementCount: 693,
uniqueResourceCount: 220,
dynamicPlacementCount: 0,
resources: stagedOverrides.map((override, index) => ({
resource: override.resource.replaceAll("\\", "/"),
placementCount: override.placements,
sourceSha256: String(index + 1).padStart(64, "0"),
})),
stagedOverrides,
assembledVisual: { sha256: "d".repeat(64) },
},
semantics: {
khrMaterialsUnlit: [{ sourceModel: resources[2], sourceResourceSha256: String(3).padStart(64, "0") }],
nativeAdditiveMaterials: [{ sourceModel: resources[0], sourceResourceSha256: String(1).padStart(64, "0"), alphaMode: "BLEND" }],
materialCounts: { total: 2 },
sharedMapLightingPair: { note: "same map, different native lighting identity" },
},
metadata: {
title: "The Origin",
entry: { x: 1, y: 2, z: 3 },
sourceTerrain: { resource: "terrain/source.wdb", chunkCount: 4 },
nativeTerrain: { report: "terrain-report.json", sourceSha256: "e".repeat(64) },
assets: [{ role: "visual", sha256: "old-visual-hash" }],
nativeMaterialAlpha: { report: "old-report.json", resources: [] },
},
};
}
test("joins staged Origin overrides by authoritative native ROS identity while retaining current metadata", () => {
const data = fixture();
const originalEntry = structuredClone(data.metadata.entry);
const originalSourceTerrain = structuredClone(data.metadata.sourceTerrain);
const originalNativeTerrain = structuredClone(data.metadata.nativeTerrain);
const originalAssets = structuredClone(data.metadata.assets);
const output = buildOriginNativeMaterialProvenance(data);
assert.equal(output.alphaReport.resources.length, resources.length);
assert.equal(output.alphaReport.resources[2].nativeAlpha.materials[0].sourceModel, resources[2]);
assert.equal(output.metadata.nativeMaterialAlpha.resources[6].placements, 7);
assert.equal(output.metadata.nativeMaterialSemantics.unlitMaterialCount, 1);
assert.equal(output.metadata.nativeMaterialSemantics.additiveMaterialCount, 1);
assert.deepEqual(output.metadata.entry, originalEntry);
assert.deepEqual(output.metadata.sourceTerrain, originalSourceTerrain);
assert.deepEqual(output.metadata.nativeTerrain, originalNativeTerrain);
assert.deepEqual(output.metadata.assets, originalAssets);
assert.equal(data.metadata.nativeMaterialAlpha.report, "old-report.json");
});
test("rejects conversion evidence whose ROS identity or source hash does not match its placement override", () => {
const data = fixture();
const first = data.assembly.stagedOverrides[0].resource.toLowerCase();
data.conversionReports.get(first).visual.materials.nativeAlpha.sourceResourceSha256 = "not-a-sha";
assert.throws(() => buildOriginNativeMaterialProvenance(data), /Invalid native source hash/);
});
test("rejects staged source hashes that disagree with authoritative assembly resource rows", () => {
const data = fixture();
data.assembly.resources[0].sourceSha256 = "f".repeat(64);
assert.throws(() => buildOriginNativeMaterialProvenance(data), /does not match the authoritative assembly row/);
});
@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { applyRunewakerDerivedSupportStyling } from "./rom-derived-support-styling.mjs";
export async function applyRunewakerDerivedSupportStylingToFile(file, options) {
if (!options.policyId) return { status: "not-requested", geometryChanged: false };
const sourceDdsBytes = await readFile(options.sourceDdsFile);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(file);
const report = applyRunewakerDerivedSupportStyling(document, { ...options, sourceDdsBytes });
if (report.status === "applied") await io.write(file, document);
return report;
}
@@ -0,0 +1,384 @@
import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
const POLICIES = Object.freeze({
"rom-demon-stronghold-derived-support-entrance-floor-02": Object.freeze({
id: "rom-demon-stronghold-derived-support-entrance-floor-02",
version: 1,
dungeonId: "demon-stronghold",
sourceModel: "model/dungeon/devil_city/act_devil_city.ros",
sourceModelSha256: "44f2f455b1895192689e9bc70fa80a5aef70a32c98799540827b30d6c9e7d47b",
sourceMaterial: "runewaker_material_32",
sourceTexture: "texture/dungeon/devil_city/entrance_floor_02",
stagedTexture: "5825da77_entrance_floor_02.dds",
sourceDdsSha256: "58911f1b62882c68b7f1f1024f4af0df47f86586ae37def4153e1bc77e2fff6c",
gltfTexture: "5825da77_entrance_floor_02",
convertedPngSha256: "99d89ac06969dbc39a9071b68b264b97ea7a91c8139e8983d1e2b8ef5f1d5980",
imageSize: [512, 512],
supportNode: "runewaker_source_anchor_support",
supportMaterial: "runewaker_source_anchor_support",
expectedPadCount: 186,
expectedTriangleCount: 2232,
metersPerUv: 1 / 0.0707851783,
uvPerMeter: 0.0707851783,
anchorWorld: [-5.025092, -0.220868, -0.382562],
anchorUv: [0.5238620214, 4.69202905],
}),
"rom-mystic-altar-derived-support-mystic-floor": Object.freeze({
id: "rom-mystic-altar-derived-support-mystic-floor",
version: 1,
dungeonId: "mystic-altar",
sourceModel: "model/dungeon/mystic_altar/act_mystic_altar_instance.ros",
sourceModelSha256: "14974678994c48a7315a588c7fbcdd6cdcae9c3313cf1b48a470ed736a14b85e",
sourceMaterial: "runewaker_material_236",
sourceTexture: "texture/dungeon/mystic_altar/mystic_altar_floor",
stagedTexture: "caa8475c_mystic_altar_floor.dds",
sourceDdsSha256: "58db1826f84f913676e79c89f40126afadb9569daa056c789ad7d73faafc7223",
gltfTexture: "caa8475c_mystic_altar_floor",
convertedPngSha256: "ef1d7d6d6876ca650d9318804eaba7e706feeb9a3df66c7099a735d63f8b3dd6",
imageSize: [512, 512],
supportNode: "runewaker_source_anchor_support",
supportMaterial: "runewaker_source_anchor_support",
expectedPadCount: 250,
expectedTriangleCount: 3000,
metersPerUv: 1 / 0.043312560155488414,
uvPerMeter: 0.043312560155488414,
anchorWorld: [-16.99230033164831, -0.9904689643118673, -28.474957258355573],
anchorUv: [1.0586127324699568, 0.5470526715423152],
nativeWorldUvBasis: Object.freeze({
dUdx: 0.043312560155488414,
dUdz: 0,
dVdx: 0,
dVdz: 0.04331255484774635,
sideUvDelta: [0.043312560155488414, 0.04331255484774635],
sourcePrimitive: "Object03_236_mesh_0",
sourceTriangle: 13,
}),
}),
});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
const accessorHash = (accessor) => {
const array = accessor.getArray();
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
};
function proveSource(policy, dungeonId, sourceReport, sourceDdsBytes) {
if (dungeonId !== policy.dungeonId) throw new Error(`Derived-support style ${policy.id} cannot run for ${dungeonId}.`);
if (normalizeResource(sourceReport?.sourceModel) !== policy.sourceModel.toLowerCase()) {
throw new Error(`Derived-support style ${policy.id} source ROS path does not match the reviewed model.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== policy.sourceModelSha256) {
throw new Error(`Derived-support style ${policy.id} source ROS SHA-256 does not match the reviewed model.`);
}
const materials = (sourceReport.materials ?? []).filter((material) => material.name === policy.sourceMaterial);
if (materials.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one exact native material record; found ${materials.length}.`);
const material = materials[0];
if (normalizeResource(material.sourceTexture) !== policy.sourceTexture.toLowerCase()) {
throw new Error(`Derived-support style ${policy.id} native diffuse texture path does not match the reviewed floor.`);
}
if (normalizeResource(material.stagedTexture).split("/").at(-1) !== policy.stagedTexture.toLowerCase()) {
throw new Error(`Derived-support style ${policy.id} staged DDS basename does not match the reviewed floor.`);
}
if (!sourceDdsBytes || sha256(sourceDdsBytes) !== policy.sourceDdsSha256) {
throw new Error(`Derived-support style ${policy.id} source DDS hash does not match the reviewed floor.`);
}
return {
sourceModel: policy.sourceModel,
sourceModelSha256: policy.sourceModelSha256,
sourceNativeMaterial: policy.sourceMaterial,
sourceTexture: policy.sourceTexture,
stagedTexture: policy.stagedTexture,
sourceDdsSha256: policy.sourceDdsSha256,
convertedPngSha256: policy.convertedPngSha256,
};
}
function pngDimensions(bytes) {
if (bytes.length < 24 || bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") return null;
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
}
function findTarget(document, policy) {
const root = document.getRoot();
const floorMaterials = root.listMaterials().filter((material) => material.getName() === policy.sourceMaterial);
if (floorMaterials.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one converted native floor material; found ${floorMaterials.length}.`);
const floorMaterial = floorMaterials[0];
const floorTexture = floorMaterial.getBaseColorTexture();
if (floorTexture?.getName() !== policy.gltfTexture || !floorMaterial.getBaseColorTextureInfo()) {
throw new Error(`Derived-support style ${policy.id} converted floor image does not match the reviewed texture.`);
}
const image = Buffer.from(floorTexture.getImage() ?? []);
if (sha256(image) !== policy.convertedPngSha256) throw new Error(`Derived-support style ${policy.id} converted floor image hash changed.`);
const dimensions = pngDimensions(image);
if (!dimensions || dimensions[0] !== policy.imageSize[0] || dimensions[1] !== policy.imageSize[1]) {
throw new Error(`Derived-support style ${policy.id} converted floor image dimensions changed.`);
}
if (floorMaterial.getBaseColorTextureInfo().getTexCoord() !== 0) {
throw new Error(`Derived-support style ${policy.id} expected the native floor to use TEXCOORD_0.`);
}
if (floorMaterial.getBaseColorTextureInfo().getExtension("KHR_texture_transform")
|| [floorMaterial.getNormalTexture(), floorMaterial.getOcclusionTexture(),
floorMaterial.getEmissiveTexture(), floorMaterial.getMetallicRoughnessTexture()].some(Boolean)) {
throw new Error(`Derived-support style ${policy.id} refuses unreviewed texture transforms or secondary maps.`);
}
const targets = [];
for (const node of root.listNodes()) {
if (node.getName() !== policy.supportNode || !node.getMesh()) continue;
for (const [primitiveIndex, primitive] of node.getMesh().listPrimitives().entries()) {
targets.push({ node, primitive, primitiveIndex });
}
}
if (targets.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one support primitive; found ${targets.length}.`);
return { floorMaterial, floorTexture, image, ...targets[0] };
}
function transformPoint(matrix, point) {
return [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
}
function supportComponents(position, indices, matrix) {
const count = position.getCount();
const adjacency = Array.from({ length: count }, () => new Set());
for (let i = 0; i < indices.length; i += 3) {
const a = indices[i];
const b = indices[i + 1];
const c = indices[i + 2];
adjacency[a].add(b); adjacency[a].add(c);
adjacency[b].add(a); adjacency[b].add(c);
adjacency[c].add(a); adjacency[c].add(b);
}
const worldPositions = Array.from({ length: count }, (_, i) => transformPoint(matrix, position.getElement(i, [0, 0, 0])));
const componentByVertex = new Int32Array(count).fill(-1);
const components = [];
for (let start = 0; start < count; start += 1) {
if (componentByVertex[start] !== -1) continue;
const componentIndex = components.length;
const queue = [start];
componentByVertex[start] = componentIndex;
for (let cursor = 0; cursor < queue.length; cursor += 1) {
for (const neighbor of adjacency[queue[cursor]]) {
if (componentByVertex[neighbor] !== -1) continue;
componentByVertex[neighbor] = componentIndex;
queue.push(neighbor);
}
}
components.push(queue);
}
const topByVertex = new Float64Array(count);
for (const component of components) {
if (component.length !== 8) throw new Error(`Derived-support style expected eight box vertices per support; found ${component.length}.`);
const topY = Math.max(...component.map((index) => worldPositions[index][1]));
if (component.filter((index) => Math.abs(worldPositions[index][1] - topY) <= 1e-4).length !== 4) {
throw new Error("Derived-support style could not identify one four-vertex top face.");
}
for (const index of component) topByVertex[index] = topY;
}
return { worldPositions, components, topByVertex };
}
function styleMarker(policy, proof) {
return {
id: policy.id,
version: policy.version,
kind: "derived-support-texture-fallback",
nativeReconstruction: false,
adaptedAttribute: "TEXCOORD_1",
uvPerMeter: policy.uvPerMeter,
...(policy.nativeWorldUvBasis ? { nativeWorldUvBasis: policy.nativeWorldUvBasis } : {}),
sourceNativeMaterial: policy.sourceMaterial,
sourceTexture: policy.gltfTexture,
...proof,
};
}
function validateApplied(policy, primitive, marker) {
const uv1 = primitive.getAttribute("TEXCOORD_1");
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
const material = primitive.getMaterial();
if (!uv1 || uv1.getCount() !== position?.getCount() || material?.getBaseColorTexture()?.getName() !== policy.gltfTexture
|| material.getBaseColorTextureInfo()?.getTexCoord() !== 1) {
throw new Error(`Previously applied derived-support style ${policy.id} is incomplete.`);
}
if (primitive.getExtras()?.romDerivedSupportStyling?.id !== policy.id) {
throw new Error(`Previously applied derived-support style ${policy.id} has a mismatched marker.`);
}
const prior = primitive.getExtras().romDerivedSupportStyling;
if (prior.sourcePositionHash !== accessorHash(position) || prior.sourceNormalHash !== accessorHash(normal)
|| prior.sourceIndexHash !== accessorHash(indices) || prior.adaptedUvHash !== accessorHash(uv1)) {
throw new Error(`Previously applied derived-support style ${policy.id} accessors no longer match the policy report.`);
}
return marker;
}
function validateFullRankUvBasis(policy) {
if (!policy.nativeWorldUvBasis) return null;
const { dUdx, dUdz, dVdx, dVdz, sideUvDelta } = policy.nativeWorldUvBasis;
const determinants = {
topXZ: dUdx * dVdz - dUdz * dVdx,
sideXY: dUdx * sideUvDelta[1] - sideUvDelta[0] * dVdx,
sideZY: dUdz * sideUvDelta[1] - sideUvDelta[0] * dVdz,
};
if (Object.values(determinants).some((determinant) => Math.abs(determinant) < 1e-8)) {
throw new Error(`Derived-support style ${policy.id} native floor projection is not full-rank on every box face.`);
}
return determinants;
}
/**
* Applies the reviewed Demon Stronghold floor texture to its generated source-anchor
* support boxes. This is an explicit derived-geometry fallback, not native floor recovery.
* Existing geometry and attributes remain untouched; only a TEXCOORD_1 accessor and a
* cloned base-color material are added.
*/
export function applyRunewakerDerivedSupportStyling(document, {
dungeonId,
policyId,
sourceReport,
sourceDdsBytes,
} = {}) {
const policy = POLICIES[policyId];
if (!policy) throw new Error(`Unknown RoM derived-support style policy: ${policyId}.`);
const uvFaceDeterminants = validateFullRankUvBasis(policy);
const proof = proveSource(policy, dungeonId, sourceReport, sourceDdsBytes);
const { floorMaterial, floorTexture, image, node, primitive, primitiveIndex } = findTarget(document, policy);
if (primitive.getExtras()?.romDerivedSupportStyling) {
const marker = primitive.getExtras().romDerivedSupportStyling;
validateApplied(policy, primitive, marker);
return {
schemaVersion: 1,
dungeonId,
status: "already-applied",
policy: marker,
primitiveCount: 1,
triangleCount: primitive.getIndices()?.getCount() / 3,
padCount: policy.expectedPadCount,
geometryChanged: false,
sourceGeometryPreserved: true,
nativeLightingMetadataChanged: false,
};
}
if (primitive.getMaterial()?.getName() !== policy.supportMaterial) {
throw new Error(`Derived-support style ${policy.id} target material changed.`);
}
if (primitive.getAttribute("TEXCOORD_0") || primitive.getAttribute("TEXCOORD_1")) {
throw new Error(`Derived-support style ${policy.id} refuses to overwrite existing UV attributes.`);
}
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
if (!position || !normal || !indices || position.getType() !== Accessor.Type.VEC3 || normal.getCount() !== position.getCount()) {
throw new Error(`Derived-support style ${policy.id} target lacks matching POSITION, NORMAL, or indices.`);
}
if (indices.getCount() / 3 !== policy.expectedTriangleCount) {
throw new Error(`Derived-support style ${policy.id} support topology changed: expected ${policy.expectedTriangleCount} triangles.`);
}
const buffer = document.getRoot().listBuffers()[0];
if (!buffer) throw new Error(`Derived-support style ${policy.id} has no GLB buffer for TEXCOORD_1.`);
const indexValues = indices.getArray();
const matrix = Array.from(node.getWorldMatrix());
const { worldPositions, components, topByVertex } = supportComponents(position, indexValues, matrix);
if (components.length !== policy.expectedPadCount) {
throw new Error(`Derived-support style ${policy.id} expected ${policy.expectedPadCount} disconnected support boxes; found ${components.length}.`);
}
const [anchorX, anchorY, anchorZ] = policy.anchorWorld;
const [anchorU, anchorV] = policy.anchorUv;
const scale = policy.uvPerMeter;
const values = new Float32Array(position.getCount() * 2);
for (let index = 0; index < position.getCount(); index += 1) {
const [x, y, z] = worldPositions[index];
const vertical = y - topByVertex[index];
if (policy.nativeWorldUvBasis) {
const basis = policy.nativeWorldUvBasis;
values[index * 2] = anchorU + basis.dUdx * (x - anchorX) + basis.dUdz * (z - anchorZ)
+ basis.sideUvDelta[0] * vertical;
values[index * 2 + 1] = anchorV + basis.dVdx * (x - anchorX) + basis.dVdz * (z - anchorZ)
+ basis.sideUvDelta[1] * vertical;
} else {
values[index * 2] = anchorU - scale * (z - anchorZ) + scale * vertical;
values[index * 2 + 1] = anchorV + scale * (x - anchorX) + scale * vertical;
}
}
const sourceHashes = {
sourcePositionHash: accessorHash(position),
sourceNormalHash: accessorHash(normal),
sourceIndexHash: accessorHash(indices),
};
const uv1 = document.createAccessor(`${node.getName()}:${policy.id}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2)
.setArray(values)
.setBuffer(buffer);
const adaptedMaterial = floorMaterial.clone()
.setName("runewaker_source_anchor_support_entrance_floor_02_adapted")
.setExtras({
...(floorMaterial.getExtras() ?? {}),
romDerivedSupportStyling: {
id: policy.id,
kind: "derived-support-texture-fallback",
nativeReconstruction: false,
sourceTexture: policy.gltfTexture,
},
});
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1);
const marker = {
...styleMarker(policy, proof),
padCount: components.length,
triangleCount: indices.getCount() / 3,
sourcePositionHash: sourceHashes.sourcePositionHash,
sourceNormalHash: sourceHashes.sourceNormalHash,
sourceIndexHash: sourceHashes.sourceIndexHash,
adaptedUvHash: sha256(Buffer.from(values.buffer, values.byteOffset, values.byteLength)),
geometryChanged: false,
sourceGeometryPreserved: true,
sourceNativeMaterialPreserved: true,
nativeLightingMetadataChanged: false,
};
primitive.setAttribute("TEXCOORD_1", uv1);
primitive.setMaterial(adaptedMaterial);
primitive.setExtras({ ...(primitive.getExtras() ?? {}), romDerivedSupportStyling: marker });
return {
schemaVersion: 1,
dungeonId,
status: "applied",
policy: marker,
primitiveCount: 1,
primitiveIndex,
triangleCount: indices.getCount() / 3,
padCount: components.length,
uvChannel: "TEXCOORD_1",
imageSize: policy.imageSize,
geometryChanged: false,
sourceGeometryPreserved: true,
nativeLightingMetadataChanged: false,
sourceTextureImageSha256: sha256(image),
metersPerUv: policy.metersPerUv,
sourceFloorReference: {
material: policy.sourceMaterial,
texture: policy.gltfTexture,
anchorWorld: policy.anchorWorld,
anchorUv: policy.anchorUv,
uvPerMeter: policy.uvPerMeter,
densityPixelsPerMeter: policy.uvPerMeter * policy.imageSize[0],
topFaceMatchesReferencePhase: true,
sideFacesUseVerticalWorldProjection: true,
...(policy.nativeWorldUvBasis ? {
nativeWorldUvBasis: policy.nativeWorldUvBasis,
fullRankFaceDeterminants: uvFaceDeterminants,
} : {}),
},
};
}
export function listRunewakerDerivedSupportStylingPolicies() {
return Object.values(POLICIES).map((policy) => ({ ...policy, imageSize: [...policy.imageSize], anchorWorld: [...policy.anchorWorld], anchorUv: [...policy.anchorUv] }));
}
@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { applyRunewakerDerivedSupportStyling, listRunewakerDerivedSupportStylingPolicies } from "./rom-derived-support-styling.mjs";
import { applyRunewakerDerivedSupportStylingToFile } from "./rom-derived-support-styling-file.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const mysticVisual = path.join(repositoryRoot, "runewaker-export-work/mystic-altar/converted/mystic-altar-visual.glb");
const mysticReport = path.join(repositoryRoot, "runewaker-export-work/mystic-altar/prepared/export-report.json");
const mysticMetadata = path.join(repositoryRoot, "src/assets/game/dungeons/mystic-altar/import-metadata.json");
const mysticDds = path.join(repositoryRoot, "runewaker-export-work/mystic-altar/raw/textures/caa8475c_mystic_altar_floor.dds");
const mysticRos = path.resolve(repositoryRoot, "../rom-pvt-server-backups/Runewaker/Resource/model/dungeon/mystic_altar/act_mystic_altar_instance.ros");
const sourceSha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
function accessorSnapshot(document) {
return document.getRoot().listAccessors().map((accessor) => {
const array = accessor.getArray();
return JSON.stringify({
name: accessor.getName(),
type: accessor.getType(),
count: accessor.getCount(),
hash: array ? sourceSha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength)) : null,
});
}).sort();
}
function imageSnapshot(document) {
return document.getRoot().listTextures().map((texture) => JSON.stringify({
name: texture.getName(),
hash: sourceSha256(Buffer.from(texture.getImage() ?? [])),
})).sort();
}
test("unlisted packages do not read or mutate a converted source", async () => {
assert.deepEqual(await applyRunewakerDerivedSupportStylingToFile("missing-source.glb", {}), {
status: "not-requested", geometryChanged: false,
});
});
test("the base Demon support policy refuses the level125 variant before mutation", () => {
const document = new Document();
assert.throws(() => applyRunewakerDerivedSupportStyling(document, {
dungeonId: "demon-stronghold-125", policyId: listRunewakerDerivedSupportStylingPolicies()[0].id,
}), /cannot run/);
assert.equal(document.getRoot().listMaterials().length, 0);
});
test("the reviewed floor cannot be substituted with mismatched source DDS content", () => {
const policy = listRunewakerDerivedSupportStylingPolicies()[0];
const document = new Document();
assert.throws(() => applyRunewakerDerivedSupportStyling(document, {
dungeonId: policy.dungeonId, policyId: policy.id,
sourceReport: { sourceModel: policy.sourceModel, sourceResourceSha256: policy.sourceModelSha256,
materials: [{ name: policy.sourceMaterial, sourceTexture: policy.sourceTexture, stagedTexture: policy.stagedTexture }] },
sourceDdsBytes: Buffer.from("unreviewed floor"),
}), /source DDS hash/);
assert.equal(document.getRoot().listMaterials().length, 0);
});
test("Mystic derived pads use the source floor UV mapping without changing native accessors or images", {
skip: ![mysticVisual, mysticReport, mysticMetadata, mysticDds, mysticRos].every(existsSync),
}, async () => {
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
await MeshoptDecoder.ready;
const document = await io.read(mysticVisual);
const policy = listRunewakerDerivedSupportStylingPolicies().find(({ dungeonId }) => dungeonId === "mystic-altar");
assert.ok(policy);
const sourceReport = JSON.parse(await readFile(mysticReport, "utf8"));
const metadata = JSON.parse(await readFile(mysticMetadata, "utf8"));
sourceReport.sourceResourceSha256 = metadata.source.modelSha256;
const sourceDdsBytes = await readFile(mysticDds);
assert.equal(sourceSha256(await readFile(mysticRos)), policy.sourceModelSha256);
const originalAccessors = accessorSnapshot(document);
const originalImages = imageSnapshot(document);
const floorMaterial = document.getRoot().listMaterials().find((material) => material.getName() === policy.sourceMaterial);
const floorUv = floorMaterial?.getBaseColorTextureInfo()?.getTexCoord();
const floorImageHash = sourceSha256(Buffer.from(floorMaterial?.getBaseColorTexture()?.getImage() ?? []));
const result = applyRunewakerDerivedSupportStyling(document, {
dungeonId: "mystic-altar",
policyId: policy.id,
sourceReport,
sourceDdsBytes,
});
assert.equal(result.status, "applied");
assert.equal(result.padCount, 250);
assert.equal(result.triangleCount, 3000);
assert.deepEqual(result.sourceFloorReference.anchorWorld, policy.anchorWorld);
assert.deepEqual(result.sourceFloorReference.anchorUv, policy.anchorUv);
assert.equal(result.sourceFloorReference.densityPixelsPerMeter, 22.176030799610068);
assert.ok(Object.values(result.sourceFloorReference.fullRankFaceDeterminants).every((value) => Math.abs(value) > 1e-8));
const support = document.getRoot().listNodes().find((node) => node.getName() === policy.supportNode);
const supportPrimitive = support?.getMesh()?.listPrimitives()[0];
assert.ok(supportPrimitive?.getAttribute("TEXCOORD_1"));
assert.equal(supportPrimitive.getAttribute("TEXCOORD_0"), null);
assert.equal(supportPrimitive.getExtras().romDerivedSupportStyling.sourcePositionHash, result.policy.sourcePositionHash);
assert.equal(supportPrimitive.getExtras().romDerivedSupportStyling.sourceIndexHash, result.policy.sourceIndexHash);
const styledAccessors = accessorSnapshot(document);
for (const original of originalAccessors) assert.ok(styledAccessors.includes(original), `missing original accessor ${original}`);
assert.deepEqual(imageSnapshot(document), originalImages);
const unchangedFloorMaterial = document.getRoot().listMaterials().find((material) => material.getName() === policy.sourceMaterial);
assert.equal(unchangedFloorMaterial?.getBaseColorTextureInfo()?.getTexCoord(), floorUv);
assert.equal(sourceSha256(Buffer.from(unchangedFloorMaterial?.getBaseColorTexture()?.getImage() ?? [])), floorImageHash);
});
@@ -0,0 +1,746 @@
import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
import { KHRMaterialsVariants } from "@gltf-transform/extensions";
import sharp from "sharp";
const BARREN_POLICY_ID = "rom-barren-caves-wretched-tunnel-surface-quality-v2";
const HOTO_POLICY_ID = "rom-hoto-source-rock-detail-v2";
const HOTO_LEGACY_POLICY_ID = "rom-hoto-source-rock-detail-v1";
const BARREN_SOURCE = Object.freeze({
model: "model/dungeon/wretched_grotto/act_world_wretched_grotto.ros",
sha256: "d2de5b83f63248b9b6f6d17e13104ee3cddbc4e77903d4eb561ca5731556bab7",
material: "runewaker_material_12",
texture: "texture/dungeon/wretched_grotto/wretched_tunnel",
image: "a6d9c718_wretched_tunnel",
imageSha256: "0f05ae62b2486ae90a9836873fac287fd89169abcef962ac7dfacd81fb469a7c",
});
const HOTO_SOURCE = Object.freeze({
model: "model/dungeon/echoes_of_the_sea/act_echoes_of_the_sea.ros",
sha256: "a0bd77c09045f8bfa1507484bbb2c2ff0cc72d397df4f769fc4e6d5aab14f8a2",
});
const HOTO_TARGETS = Object.freeze({
runewaker_material_96: Object.freeze({
nativeMaterial: "runewaker_material_96", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_01",
image: "122cc01b_echoes_of_the_sea_01", imageSha256: "ea395afa1d0c65aa57ed7e675a352ca47aab0b1f7a629a3bebdf36e03621a3b1",
floorStrength: 0.85, floorBaseStrength: 0, wallStrength: 0.85, legacyWallStrength: 0.45,
wallBaseContrast: 0.6, wallDetailVariation: 1, useOwnBaseMean: true,
}),
runewaker_material_53: Object.freeze({
nativeMaterial: "runewaker_material_53", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_04",
image: "5c863840_echoes_of_the_sea_04", imageSha256: "fbb0eb3ac74689cf2e7b6bc043bc15f1c3cbc4638fc15675ff0710037e9666f9",
floorStrength: 0.85, floorBaseStrength: 0, wallStrength: 0.85, legacyWallStrength: 0.45,
wallBaseContrast: 0.6, wallDetailVariation: 1, useOwnBaseMean: true,
}),
runewaker_material_26: Object.freeze({
nativeMaterial: "runewaker_material_94", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_02",
image: "5672f062_echoes_of_the_sea_02", imageSha256: "bc32a6751da4db246d50f66d2c9fb625f640be652956d7022a4feb649d62a1d2",
floorVariantRole: "floor", floorStrength: 0, floorBaseStrength: 1, wallStrength: 0, legacyWallStrength: 0,
wallBaseContrast: 1, wallDetailVariation: 0,
}),
runewaker_material_18: Object.freeze({
nativeMaterial: "runewaker_material_95", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_03",
image: "b2ababb9_echoes_of_the_sea_03", imageSha256: "7e20e5f22744d638fa9bd983c13844800bc316e3502ab092bf6d8b6f68a1d018",
floorVariantRole: "wall", floorStrength: 0, floorBaseStrength: 1, wallStrength: 0.45, legacyWallStrength: 0.45,
wallBaseContrast: 1, wallDetailVariation: 0,
}),
});
const HOTO_DETAIL = Object.freeze({
floor: Object.freeze({
nativeMaterial: "runewaker_material_94", canonicalMaterial: "runewaker_material_26",
texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_02",
image: "5672f062_echoes_of_the_sea_02", imageSha256: "bc32a6751da4db246d50f66d2c9fb625f640be652956d7022a4feb649d62a1d2",
}),
wall: Object.freeze({
nativeMaterial: "runewaker_material_95", canonicalMaterial: "runewaker_material_18",
texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_03",
image: "b2ababb9_echoes_of_the_sea_03", imageSha256: "7e20e5f22744d638fa9bd983c13844800bc316e3502ab092bf6d8b6f68a1d018",
}),
});
const HOTO_VARIANTS = Object.freeze({ floor: "rom-detail-hoto-floor", wall: "rom-detail-hoto-wall" });
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
const accessorHash = (accessor) => {
const array = accessor?.getArray();
return array ? sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength)) : null;
};
function verifySourceReport(sourceReport, source) {
if (normalizeResource(sourceReport?.sourceModel) !== source.model.toLowerCase()) {
throw new Error(`Surface policy source ROS path does not match ${source.model}.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== source.sha256) {
throw new Error(`Surface policy source ROS SHA-256 does not match ${source.model}.`);
}
}
function verifyNativeMaterial(sourceReport, nativeName, texturePath) {
const rows = (sourceReport?.materials ?? []).filter((row) => row.name === nativeName);
if (rows.length !== 1 || normalizeResource(rows[0].sourceTexture) !== texturePath.toLowerCase()) {
throw new Error(`Expected exact native material/texture binding ${nativeName} -> ${texturePath}.`);
}
return rows[0];
}
function listVariants(document) {
const root = document.getRoot();
const extension = root.listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME)
?? document.createExtension(KHRMaterialsVariants).setRequired(false);
const byName = new Map(extension.listVariants().map((variant) => [variant.getName(), variant]));
const getOrCreate = (name) => byName.get(name) ?? (byName.set(name, extension.createVariant(name)), byName.get(name));
return { extension, getOrCreate };
}
function addSurfaceMappings(document, primitive, carrierByName, variantByName, names = [HOTO_VARIANTS.floor, HOTO_VARIANTS.wall]) {
if (primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)) {
throw new Error("Surface-quality policy will not replace an existing KHR_materials_variants mapping.");
}
const variants = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const mappingList = variants.createMappingList();
for (const name of names) {
const carrier = carrierByName.get(name);
const variant = variantByName.get(name);
if (!carrier || !variant) throw new Error(`Missing surface-detail carrier or variant ${name}.`);
mappingList.addMapping(variants.createMapping().setMaterial(carrier).addVariant(variant));
}
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappingList);
}
function getOrCreateCarrier(document, sourceTexture, name, extras) {
const root = document.getRoot();
const found = root.listMaterials().find((material) => material.getName() === `${name}:source-tile-carrier`);
if (found) {
if (found.getBaseColorTexture() !== sourceTexture || found.getExtras()?.runewakerSurfaceDetailCarrier?.policyId !== extras.policyId) {
throw new Error(`Existing surface-detail carrier ${name} does not match its pinned source.`);
}
return found;
}
return document.createMaterial(`${name}:source-tile-carrier`)
.setBaseColorTexture(sourceTexture)
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setExtras({ runewakerSurfaceDetailCarrier: extras });
}
async function meanLinearFromPng(bytes) {
const { data, info } = await sharp(bytes).removeAlpha().raw().toBuffer({ resolveWithObject: true });
if (info.channels < 3 || info.width < 1 || info.height < 1) throw new Error("Surface-detail source image must contain RGB pixels.");
const sums = [0, 0, 0];
for (let offset = 0; offset < data.length; offset += info.channels) {
for (let channel = 0; channel < 3; channel += 1) {
const srgb = data[offset + channel] / 255;
sums[channel] += srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
}
}
const count = info.width * info.height;
return sums.map((value) => value / count);
}
function trianglePositions(matrix, position, indices) {
return indices.map((index) => {
const value = position.getElement(index, [0, 0, 0]);
return [
matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
];
});
}
function sub3(left, right) { return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]; }
function cross3(left, right) {
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
}
function length3(value) { return Math.hypot(value[0], value[1], value[2]); }
const BARREN_PATCHES = Object.freeze([
{ key: "u0-v0", col: 0, row: 0, rect: { left: 0, top: 0, width: 256, height: 256 } },
{ key: "u3-v0", col: 3, row: 0, rect: { left: 768, top: 0, width: 256, height: 256 } },
{ key: "u0-v1", col: 0, row: 1, rect: { left: 0, top: 256, width: 256, height: 256 } },
{ key: "u3-v1", col: 3, row: 1, rect: { left: 768, top: 256, width: 256, height: 256 } },
].map((patch) => Object.freeze({
...patch,
uvRect: { u0: patch.col / 4, u1: (patch.col + 1) / 4, v0: patch.row / 2, v1: (patch.row + 1) / 2 },
})));
const BARREN_WALL_DETAIL_PATCH = Object.freeze({
key: "wall-u1-v0",
rect: Object.freeze({ left: 256, top: 0, width: 256, height: 256 }),
textureName: "rom-barren-wretched-tunnel-wall-detail-u1-v0",
});
function barrenPatchForTriangle(uvs, worldNormalY) {
if (worldNormalY < 0.7) return null;
const tolerance = 0.0025;
const minU = Math.min(...uvs.map((uv) => uv[0]));
const maxU = Math.max(...uvs.map((uv) => uv[0]));
const minV = Math.min(...uvs.map((uv) => uv[1]));
const maxV = Math.max(...uvs.map((uv) => uv[1]));
return BARREN_PATCHES.find((patch) => minU >= patch.uvRect.u0 - tolerance && maxU <= patch.uvRect.u1 + tolerance
&& minV >= patch.uvRect.v0 - tolerance && maxV <= patch.uvRect.v1 + tolerance)?.key ?? null;
}
async function applyBarrenFloorPolicy(document, sourceReport) {
verifySourceReport(sourceReport, BARREN_SOURCE);
verifyNativeMaterial(sourceReport, BARREN_SOURCE.material, BARREN_SOURCE.texture);
const root = document.getRoot();
const sourceMaterial = root.listMaterials().find((material) => material.getName() === BARREN_SOURCE.material);
if (!sourceMaterial) throw new Error("Barren atlas-safe policy cannot find native material 12.");
const sourceTexture = sourceMaterial.getBaseColorTexture();
const sourcePng = Buffer.from(sourceTexture?.getImage() ?? []);
if (sourceTexture?.getName() !== BARREN_SOURCE.image || sha256(sourcePng) !== BARREN_SOURCE.imageSha256) {
throw new Error("Barren material 12 embedded atlas differs from the pinned native prepared PNG.");
}
const imageMeta = await sharp(sourcePng).metadata();
if (imageMeta.width !== 1024 || imageMeta.height !== 512) throw new Error("Barren atlas dimensions differ from the reviewed 1024×512 source.");
const baseMeanLinear = await meanLinearFromPng(sourcePng);
// Migrate the previous whole-atlas x4 experiment back to authentic UV0
// before splitting only faces proven to stay inside a native floor island.
let legacyRepeatPrimitivesRestored = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
for (const primitive of mesh.listPrimitives()) {
if (primitive.getExtras()?.romUvAdaptation?.id !== "rom-barren-caves-wretched-tunnel-repeat-x4") continue;
const material = primitive.getMaterial();
const uv0 = primitive.getAttribute("TEXCOORD_0");
const uv1 = primitive.getAttribute("TEXCOORD_1");
if (material?.getBaseColorTexture()?.getName() !== BARREN_SOURCE.image || !uv0 || !uv1
|| uv0.getCount() !== uv1.getCount()) {
throw new Error("Cannot safely restore an earlier Barren whole-atlas x4 primitive.");
}
for (let index = 0; index < uv0.getCount(); index += 1) {
const original = uv0.getElement(index, [0, 0]);
const adapted = uv1.getElement(index, [0, 0]);
if (Math.abs(adapted[0] - original[0] * 4) > 1e-6 || Math.abs(adapted[1] - original[1] * 4) > 1e-6) {
throw new Error("Earlier Barren repeat marker does not contain the expected x4 UV1 values.");
}
}
const extras = { ...(primitive.getExtras() ?? {}) };
delete extras.romUvAdaptation;
primitive.setExtras(extras);
primitive.setAttribute("TEXCOORD_1", null);
primitive.setMaterial(sourceMaterial);
legacyRepeatPrimitivesRestored += 1;
}
}
const alreadyApplied = sourceMaterial.getExtras()?.runewakerSurfaceDetail?.policyId === BARREN_POLICY_ID;
if (alreadyApplied) {
const surfaceDetail = sourceMaterial.getExtras()?.runewakerSurfaceDetail;
const cropTextures = root.listTextures().filter((texture) => texture.getExtras()?.romSourcePixelCrop?.policyId === BARREN_POLICY_ID);
const cropMaterials = root.listMaterials().filter((material) => material.getExtras()?.romUvAdaptation?.id === BARREN_POLICY_ID);
const expectedCropMaterialNames = new Set(BARREN_PATCHES.map((patch) => `${BARREN_SOURCE.material}_${patch.key}_crop_repeat4`));
const actualCropMaterialNames = new Set(cropMaterials.map((material) => material.getName()));
const wallTexture = root.listTextures().find((texture) => texture.getName() === BARREN_WALL_DETAIL_PATCH.textureName);
const wallCarrier = root.listMaterials().find((material) => material.getName() === "rom-detail-barren-caves-wall:source-tile-carrier");
const wallInfo = wallCarrier?.getBaseColorTextureInfo();
if (surfaceDetail.wallBaseContrast !== 0.25 || surfaceDetail.wallStrength !== 0.85 || surfaceDetail.wallDetailVariation !== 1
|| JSON.stringify(surfaceDetail.baseMeanLinear) !== JSON.stringify(baseMeanLinear)
|| cropTextures.length !== BARREN_PATCHES.length + 1 || !wallTexture || wallCarrier?.getBaseColorTexture() !== wallTexture
|| wallInfo?.getWrapS() !== 33648 || wallInfo?.getWrapT() !== 33648
|| actualCropMaterialNames.size !== BARREN_PATCHES.length
|| [...expectedCropMaterialNames].some((name) => !actualCropMaterialNames.has(name))) {
throw new Error("Barren atlas-safe policy marker is present but its exact floor/wall crop carriers are incomplete.");
}
return {
id: BARREN_POLICY_ID, status: "already-applied", cropTextureCount: cropTextures.length,
cropMaterialCount: actualCropMaterialNames.size, cropMaterialInstanceCount: cropMaterials.length,
wallDetailTextureName: wallTexture.getName(), wallDetailWrap: "mirrored-repeat",
repeatFactor: 4, trianglePartitionChanged: true,
sourceModel: BARREN_SOURCE.model, sourceModelSha256: BARREN_SOURCE.sha256,
};
}
if (root.listTextures().some((texture) => texture.getExtras()?.romSourcePixelCrop?.policyId === BARREN_POLICY_ID)) {
throw new Error("Barren source GLB already contains partial atlas-safe floor crops.");
}
const buffer = root.listBuffers()[0];
if (!buffer) throw new Error("Barren canonical GLB has no binary buffer for UV1.");
const cropped = new Map();
for (const patch of BARREN_PATCHES) {
const png = await sharp(sourcePng).extract(patch.rect).png().toBuffer();
const rgba = await sharp(png).ensureAlpha().raw().toBuffer();
const meanLinear = await meanLinearFromPng(png);
const sourcePixelCrop = {
version: 1,
sourceImageName: BARREN_SOURCE.image,
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
sourceWidth: 1024,
sourceHeight: 512,
rect: patch.rect,
croppedPngSha256: sha256(png),
croppedRgbaSha256: sha256(rgba),
policyId: BARREN_POLICY_ID,
};
const texture = document.createTexture(`rom-barren-wretched-tunnel-${patch.key}`)
.setMimeType("image/png")
.setImage(png)
.setExtras({ romSourcePixelCrop: sourcePixelCrop });
cropped.set(patch.key, { patch, png, meanLinear, sourcePixelCrop, texture });
}
const wallDetailPng = await sharp(sourcePng).extract(BARREN_WALL_DETAIL_PATCH.rect).png().toBuffer();
const wallDetailRgba = await sharp(wallDetailPng).ensureAlpha().raw().toBuffer();
const wallDetailCrop = {
version: 1,
sourceImageName: BARREN_SOURCE.image,
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
sourceWidth: 1024,
sourceHeight: 512,
rect: BARREN_WALL_DETAIL_PATCH.rect,
croppedPngSha256: sha256(wallDetailPng),
croppedRgbaSha256: sha256(wallDetailRgba),
policyId: BARREN_POLICY_ID,
};
const wallDetailTexture = document.createTexture(BARREN_WALL_DETAIL_PATCH.textureName)
.setMimeType("image/png")
.setImage(wallDetailPng)
.setExtras({ romSourcePixelCrop: wallDetailCrop });
const wallDetailMeanLinear = await meanLinearFromPng(wallDetailPng);
const variantInfo = listVariants(document);
const variantNames = ["rom-detail-barren-caves-floor", "rom-detail-barren-caves-wall"];
const variantByName = new Map(variantNames.map((name) => [name, variantInfo.getOrCreate(name)]));
const baseExtras = sourceMaterial.getExtras() ?? {};
const cropForDetail = cropped.get("u0-v0");
const sourceProof = {
sourceModel: BARREN_SOURCE.model,
sourceModelSha256: BARREN_SOURCE.sha256,
sourceMaterial: BARREN_SOURCE.material,
sourceTexture: BARREN_SOURCE.texture,
sourceImageName: BARREN_SOURCE.image,
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
sourcePixelCrop: cropForDetail.sourcePixelCrop,
baseMeanLinear,
wallBaseContrast: 0.25,
wallDetailVariation: 1,
wallDetailImageName: BARREN_WALL_DETAIL_PATCH.textureName,
wallDetailSourcePixelCrop: wallDetailCrop,
wallDetailMeanLinear,
wallDetailWrap: "mirrored-repeat",
};
sourceMaterial.setExtras({
...baseExtras,
runewakerSurfaceDetail: {
version: 1,
floorVariant: variantNames[0],
wallVariant: variantNames[1],
periodMeters: 2,
floorStrength: 0,
wallStrength: 0.85,
baseMeanLinear,
wallBaseContrast: 0.25,
wallDetailVariation: 1,
floorMeanLinear: cropForDetail.meanLinear,
wallMeanLinear: wallDetailMeanLinear,
policyId: BARREN_POLICY_ID,
sourceProof,
},
});
const carrierExtras = { version: 1, policyId: BARREN_POLICY_ID, sourceImageName: BARREN_SOURCE.image, sourcePixelCrop: cropForDetail.sourcePixelCrop };
const carriers = new Map([
[variantNames[0], getOrCreateCarrier(document, cropForDetail.texture, variantNames[0], { ...carrierExtras, variantName: variantNames[0] })],
[variantNames[1], getOrCreateCarrier(document, wallDetailTexture, variantNames[1], {
...carrierExtras,
variantName: variantNames[1],
sourcePixelCrop: wallDetailCrop,
sourceImageName: BARREN_WALL_DETAIL_PATCH.textureName,
wrap: "mirrored-repeat",
})],
]);
carriers.get(variantNames[1]).getBaseColorTextureInfo().setWrapS(33648).setWrapT(33648);
const reports = [];
let adaptedTriangles = 0;
let untouchedTriangles = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
const originalPrimitives = [...mesh.listPrimitives()];
for (const [primitiveIndex, primitive] of originalPrimitives.entries()) {
if (primitive.getMaterial() !== sourceMaterial) continue;
if (primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)) throw new Error("Barren material 12 already has variant mappings.");
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const uv0 = primitive.getAttribute("TEXCOORD_0");
const indices = primitive.getIndices();
if (!position || !normal || !uv0 || !indices || uv0.getType() !== Accessor.Type.VEC2) {
throw new Error(`${node.getName()} material 12 primitive ${primitiveIndex} lacks position/normal/index/UV0 data.`);
}
const indexArray = indices.getArray();
const indicesByCrop = new Map(BARREN_PATCHES.map((patch) => [patch.key, []]));
const untouched = [];
for (let offset = 0; offset < indexArray.length; offset += 3) {
const triangle = [indexArray[offset], indexArray[offset + 1], indexArray[offset + 2]];
const points = trianglePositions(matrix, position, triangle);
const cross = cross3(sub3(points[1], points[0]), sub3(points[2], points[0]));
const magnitude = length3(cross);
const normalY = magnitude > 1e-12 ? Math.abs(cross[1]) / magnitude : 0;
const triangleUvs = triangle.map((index) => uv0.getElement(index, [0, 0]));
const key = barrenPatchForTriangle(triangleUvs, normalY);
(key ? indicesByCrop.get(key) : untouched).push(...triangle);
}
const activeCrops = [...indicesByCrop].filter(([, group]) => group.length > 0);
if (!activeCrops.length) continue;
const originalTriangleCount = indexArray.length / 3;
const before = accessorHash(indices);
const primitiveOutputs = [];
if (untouched.length) {
primitive.setIndices(document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:untouched-indices`)
.setType(Accessor.Type.SCALAR).setArray(new indexArray.constructor(untouched)).setBuffer(buffer));
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, (() => {
const current = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const mappingList = current.createMappingList();
for (const name of variantNames) mappingList.addMapping(current.createMapping().setMaterial(carriers.get(name)).addVariant(variantByName.get(name)));
return mappingList;
})());
primitiveOutputs.push({ primitive, triangles: untouched.length / 3, category: "untouched-atlas" });
} else mesh.removePrimitive(primitive);
for (const [key, group] of activeCrops) {
const { patch, texture, sourcePixelCrop } = cropped.get(key);
const adaptedMaterial = sourceMaterial.clone()
.setName(`${BARREN_SOURCE.material}_${key}_crop_repeat4`)
.setBaseColorTexture(texture);
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1).setWrapS(10497).setWrapT(10497);
adaptedMaterial.setExtras({
...baseExtras,
romUvAdaptation: {
version: 1, id: BARREN_POLICY_ID, atlasRect: patch.rect, uvRect: patch.uvRect,
repeatFactor: 4, nativeBaseTexture: BARREN_SOURCE.image, nativeBaseTexturePngSha256: BARREN_SOURCE.imageSha256,
sourcePixelCrop,
},
});
const values = new Float32Array(uv0.getCount() * 2);
for (let index = 0; index < uv0.getCount(); index += 1) {
const uv = uv0.getElement(index, [0, 0]);
values[index * 2] = ((uv[0] - patch.uvRect.u0) / (patch.uvRect.u1 - patch.uvRect.u0)) * 4;
values[index * 2 + 1] = ((uv[1] - patch.uvRect.v0) / (patch.uvRect.v1 - patch.uvRect.v0)) * 4;
}
const uv1 = document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:${key}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2).setArray(values).setBuffer(buffer);
const adapted = primitive.clone()
.setIndices(document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:${key}:indices`)
.setType(Accessor.Type.SCALAR).setArray(new indexArray.constructor(group)).setBuffer(buffer))
.setAttribute("TEXCOORD_1", uv1)
.setMaterial(adaptedMaterial)
.setExtras({
...(primitive.getExtras() ?? {}),
romUvAdaptation: {
version: 1, id: BARREN_POLICY_ID, atlasRect: patch.rect, uvRect: patch.uvRect, repeatFactor: 4,
sourceMaterial: BARREN_SOURCE.material, sourceTexture: BARREN_SOURCE.image,
sourcePngSha256: BARREN_SOURCE.imageSha256, sourcePixelCrop,
},
});
const variants = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const mappings = variants.createMappingList();
for (const name of variantNames) mappings.addMapping(variants.createMapping().setMaterial(carriers.get(name)).addVariant(variantByName.get(name)));
adapted.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappings);
mesh.addPrimitive(adapted);
primitiveOutputs.push({ primitive: adapted, triangles: group.length / 3, category: key });
adaptedTriangles += group.length / 3;
}
untouchedTriangles += untouched.length / 3;
const outputTriangles = primitiveOutputs.reduce((sum, item) => sum + item.triangles, 0);
if (outputTriangles !== originalTriangleCount) throw new Error(`Barren triangle partition failed on ${node.getName()} primitive ${primitiveIndex}.`);
for (const { primitive: outputPrimitive } of primitiveOutputs) {
if (outputPrimitive.getAttribute("POSITION") !== position || outputPrimitive.getAttribute("NORMAL") !== normal
|| outputPrimitive.getAttribute("TEXCOORD_0") !== uv0) {
throw new Error(`Barren UV policy changed source position/normal/UV0 accessors on ${node.getName()}.`);
}
}
reports.push({
node: node.getName(), sourcePrimitiveIndex: primitiveIndex, sourceTriangles: originalTriangleCount,
sourceIndexHash: before, sourcePositionHash: accessorHash(position), sourceNormalHash: accessorHash(normal),
sourceUv0Hash: accessorHash(uv0),
outputGroups: primitiveOutputs.map(({ category, triangles }) => ({ category, triangles })),
triangleCoverageExact: true, positionNormalUv0AccessorsShared: true, indexStreamLayoutChanged: true,
});
}
}
if (!reports.length) throw new Error("Barren atlas-safe policy found no floor triangles in the four reviewed source-image quadrants.");
const patchReports = [...cropped.values()].map(({ patch, png, sourcePixelCrop, meanLinear }) => ({
key: patch.key, sourcePixelRect: patch.rect, sourceUvRect: patch.uvRect, repeatFactor: 4,
croppedPngSha256: sourcePixelCrop.croppedPngSha256, croppedRgbaSha256: sourcePixelCrop.croppedRgbaSha256,
bytes: png.length, meanLinear,
}));
patchReports.push({
key: BARREN_WALL_DETAIL_PATCH.key,
role: "wall-detail-carrier",
sourcePixelRect: BARREN_WALL_DETAIL_PATCH.rect,
repeatMode: "mirrored-repeat",
croppedPngSha256: wallDetailCrop.croppedPngSha256,
croppedRgbaSha256: wallDetailCrop.croppedRgbaSha256,
bytes: wallDetailPng.length,
meanLinear: wallDetailMeanLinear,
});
return {
id: BARREN_POLICY_ID,
status: "applied",
sourceModel: BARREN_SOURCE.model,
sourceModelSha256: BARREN_SOURCE.sha256,
sourceMaterial: BARREN_SOURCE.material,
sourceTexture: BARREN_SOURCE.texture,
sourceImage: BARREN_SOURCE.image,
sourceImageSha256: BARREN_SOURCE.imageSha256,
sourceImageDimensions: [imageMeta.width, imageMeta.height],
repeatFactor: 4,
worldNormalYThreshold: 0.7,
atlasSeamToleranceUv: 0.0025,
trianglePolicy: "floor triangles whose UV0 corners remain inside a reviewed 256x256 floor island receive crop-localized repeat UV1; other faces keep the full native atlas",
trianglePartitionChanged: true,
legacyRepeatPrimitivesRestored,
sourcePositionNormalUv0Preserved: true,
untouchedTriangles,
adaptedTriangles,
primitiveReports: reports,
crops: patchReports,
wallDetail: {
textureName: BARREN_WALL_DETAIL_PATCH.textureName,
sourcePixelCrop: wallDetailCrop,
meanLinear: wallDetailMeanLinear,
samplerWrap: "mirrored-repeat",
},
wallBaseContrast: 0.25,
wallDetailVariation: 1,
baseMeanLinear,
variants: variantNames,
};
}
async function applyHotoDetailPolicy(document, sourceReport) {
verifySourceReport(sourceReport, HOTO_SOURCE);
for (const target of Object.values(HOTO_TARGETS)) verifyNativeMaterial(sourceReport, target.nativeMaterial, target.texture);
const root = document.getRoot();
const sourceTextures = new Map();
const targetMaterials = new Map();
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
const material = root.listMaterials().find((item) => item.getName() === materialName);
if (!material) throw new Error(`HOTO surface policy cannot find canonical material ${materialName}.`);
const texture = material.getBaseColorTexture();
const image = Buffer.from(texture?.getImage() ?? []);
if (texture?.getName() !== target.image || sha256(image) !== target.imageSha256) {
throw new Error(`HOTO ${materialName} base atlas/tile is not the pinned source PNG.`);
}
if (material.getNormalTexture() || material.getMetallicRoughnessTexture() || material.getEmissiveTexture()) {
throw new Error(`HOTO surface policy refuses unexpected auxiliary native texture slots on ${materialName}.`);
}
targetMaterials.set(materialName, material);
sourceTextures.set(materialName, texture);
}
const detailSources = new Map();
for (const [role, detail] of Object.entries(HOTO_DETAIL)) {
const material = targetMaterials.get(detail.canonicalMaterial);
const texture = sourceTextures.get(detail.canonicalMaterial);
if (texture?.getName() !== detail.image || sha256(texture.getImage() ?? Buffer.alloc(0)) !== detail.imageSha256) {
throw new Error(`HOTO ${role} detail tile is not the native, byte-identical image.`);
}
detailSources.set(role, { ...detail, sourceTexturePath: detail.texture, textureObject: texture, meanLinear: await meanLinearFromPng(Buffer.from(texture.getImage())) });
}
const expectedByMaterial = new Map();
const detailProofs = {};
for (const [role, detail] of detailSources) {
detailProofs[role] = {
nativeMaterial: detail.nativeMaterial,
canonicalMaterialName: detail.canonicalMaterial,
sourceTexture: detail.sourceTexturePath,
sourceImage: detail.image,
sourcePreparedPngSha256: detail.imageSha256,
meanLinear: detail.meanLinear,
};
}
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
const material = targetMaterials.get(materialName);
const baseTexture = sourceTextures.get(materialName);
const baseMeanLinear = target.useOwnBaseMean ? await meanLinearFromPng(Buffer.from(baseTexture.getImage())) : undefined;
const floorRole = target.floorVariantRole ?? "floor";
const sourceProof = {
sourceModel: HOTO_SOURCE.model,
sourceModelSha256: HOTO_SOURCE.sha256,
sourceMaterial: target.nativeMaterial,
canonicalMaterialName: materialName,
sourceTexture: target.texture,
sourceTextureName: target.image,
sourcePreparedPngSha256: target.imageSha256,
floorDetailSource: detailProofs[floorRole],
wallDetailSource: detailProofs.wall,
floorVariantRole: floorRole,
floorBaseStrength: target.floorBaseStrength,
floorStrength: target.floorStrength,
wallStrength: target.wallStrength,
wallBaseContrast: target.wallBaseContrast,
wallDetailVariation: target.wallDetailVariation,
};
const metadata = {
version: 1,
floorVariant: HOTO_VARIANTS[floorRole],
wallVariant: HOTO_VARIANTS.wall,
periodMeters: 2,
floorBaseStrength: target.floorBaseStrength,
floorStrength: target.floorStrength,
wallStrength: target.wallStrength,
floorMeanLinear: detailSources.get(floorRole).meanLinear,
wallMeanLinear: detailSources.get("wall").meanLinear,
wallBaseContrast: target.wallBaseContrast,
wallDetailVariation: target.wallDetailVariation,
policyId: HOTO_POLICY_ID,
sourceProof,
};
if (baseMeanLinear) metadata.baseMeanLinear = baseMeanLinear;
expectedByMaterial.set(materialName, metadata);
}
const carrierNames = new Map(Object.entries(HOTO_VARIANTS).map(([role, variantName]) => [variantName, role]));
const existingMaterialPolicies = [...targetMaterials.values()].map((material) => material.getExtras()?.runewakerSurfaceDetail?.policyId ?? null);
const hasExistingPolicy = existingMaterialPolicies.some(Boolean);
if (hasExistingPolicy) {
if (existingMaterialPolicies.some((id) => id !== HOTO_POLICY_ID && id !== HOTO_LEGACY_POLICY_ID)) {
throw new Error("HOTO surface metadata contains an unknown or partial policy marker.");
}
const existingPolicy = existingMaterialPolicies[0];
if (!existingPolicy || existingMaterialPolicies.some((id) => id !== existingPolicy)) {
throw new Error("HOTO source-rock detail policy is only partially applied.");
}
const extension = root.listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const variantByName = new Map((extension?.listVariants() ?? []).map((variant) => [variant.getName(), variant]));
if (!extension || [...carrierNames.keys()].some((name) => !variantByName.has(name))) {
throw new Error("HOTO source-rock detail policy marker exists but its material variants are absent.");
}
const carrierByName = new Map();
for (const [variantName, role] of carrierNames) {
const detail = detailSources.get(role);
const carrier = root.listMaterials().find((material) => material.getName() === `${variantName}:source-tile-carrier`);
const carrierProof = carrier?.getExtras()?.runewakerSurfaceDetailCarrier;
if (!carrier || carrier.getBaseColorTexture() !== detail.textureObject
|| carrierProof?.policyId !== existingPolicy
|| carrierProof?.sourceResourceSha256 !== HOTO_SOURCE.sha256
|| carrierProof?.sourceTextureName !== detail.image
|| carrierProof?.sourcePreparedPngSha256 !== detail.imageSha256) {
throw new Error(`HOTO ${variantName} carrier does not match the pinned source tile/policy.`);
}
carrierByName.set(variantName, carrier);
}
let mappedPrimitiveCount = 0;
for (const node of root.listNodes()) for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
const materialName = primitive.getMaterial()?.getName();
if (!targetMaterials.has(materialName)) continue;
const mappingList = primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)?.listMappings() ?? [];
const mappedNames = new Set();
for (const mapping of mappingList) {
if (mapping.listVariants().length !== 1) throw new Error(`HOTO ${materialName} has a bundled or ambiguous surface-detail mapping.`);
const variantName = mapping.listVariants()[0].getName();
if (!carrierByName.has(variantName) || mapping.getMaterial() !== carrierByName.get(variantName) || mappedNames.has(variantName)) {
throw new Error(`HOTO ${materialName} surface-detail mapping does not point to its verified source carrier.`);
}
mappedNames.add(variantName);
}
if (mappedNames.size !== 2 || [...carrierNames.keys()].some((name) => !mappedNames.has(name))) {
throw new Error(`HOTO ${materialName} is missing one of the pinned source-detail mappings.`);
}
mappedPrimitiveCount += 1;
}
if (mappedPrimitiveCount !== 36) throw new Error(`HOTO source-rock detail mappings are incomplete: expected 36, found ${mappedPrimitiveCount}.`);
if (existingPolicy === HOTO_LEGACY_POLICY_ID) {
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
const old = targetMaterials.get(materialName).getExtras()?.runewakerSurfaceDetail;
const proof = old?.sourceProof;
if (old?.version !== 1 || old.floorVariant !== HOTO_VARIANTS.floor || old.wallVariant !== HOTO_VARIANTS.wall
|| old.periodMeters !== 2 || old.floorStrength !== 0.65 || old.floorBaseStrength !== undefined && old.floorBaseStrength !== 0
|| old.wallStrength !== (target.legacyWallStrength ?? target.wallStrength)
|| old.wallBaseContrast !== undefined && old.wallBaseContrast !== 1
|| old.wallDetailVariation !== undefined && old.wallDetailVariation !== 0
|| JSON.stringify(old.floorMeanLinear) !== JSON.stringify(detailSources.get("floor").meanLinear)
|| JSON.stringify(old.wallMeanLinear) !== JSON.stringify(detailSources.get("wall").meanLinear)
|| proof?.sourceModelSha256 !== HOTO_SOURCE.sha256 || proof?.canonicalMaterialName !== materialName
|| proof?.sourceMaterial !== target.nativeMaterial || proof?.sourceTexture !== target.texture
|| proof?.sourceTextureName !== target.image || proof?.sourcePreparedPngSha256 !== target.imageSha256) {
throw new Error(`HOTO legacy v1 metadata on ${materialName} is not the reviewed migration source.`);
}
}
for (const carrier of carrierByName.values()) {
const extras = carrier.getExtras() ?? {};
carrier.setExtras({ ...extras, runewakerSurfaceDetailCarrier: { ...extras.runewakerSurfaceDetailCarrier, policyId: HOTO_POLICY_ID } });
}
for (const [materialName, metadata] of expectedByMaterial) {
const material = targetMaterials.get(materialName);
material.setExtras({ ...(material.getExtras() ?? {}), runewakerSurfaceDetail: metadata });
}
return { id: HOTO_POLICY_ID, status: "migrated-v1", migratedFrom: HOTO_LEGACY_POLICY_ID, mappedPrimitiveCount, materials: Object.keys(HOTO_TARGETS) };
}
for (const [variantName, role] of carrierNames) {
const detail = detailSources.get(role);
const proof = carrierByName.get(variantName).getExtras()?.runewakerSurfaceDetailCarrier;
if (proof?.variantName !== variantName || proof?.sourceMaterial !== detail.nativeMaterial
|| proof?.canonicalMaterialName !== detail.canonicalMaterial || proof?.sourceTexturePath !== detail.texture) {
throw new Error(`HOTO ${variantName} carrier provenance does not match the pinned source.`);
}
}
for (const [materialName, metadata] of expectedByMaterial) {
const existing = targetMaterials.get(materialName).getExtras()?.runewakerSurfaceDetail;
if (JSON.stringify(existing) !== JSON.stringify(metadata)) throw new Error(`HOTO v2 surface metadata on ${materialName} differs from the reviewed policy.`);
}
return { id: HOTO_POLICY_ID, status: "already-applied", mappedPrimitiveCount, materials: Object.keys(HOTO_TARGETS) };
}
if (root.listMaterials().some((material) => material.getExtras()?.runewakerSurfaceDetailCarrier?.policyId === HOTO_LEGACY_POLICY_ID
|| material.getExtras()?.runewakerSurfaceDetailCarrier?.policyId === HOTO_POLICY_ID)) {
throw new Error("HOTO source-tile carrier markers exist without the complete source-detail policy.");
}
const { getOrCreate } = listVariants(document);
const variantByName = new Map(Object.values(HOTO_VARIANTS).map((name) => [name, getOrCreate(name)]));
const carrierByName = new Map();
for (const [role, detail] of detailSources) {
const variantName = HOTO_VARIANTS[role];
const extras = {
version: 1, variantName, policyId: HOTO_POLICY_ID, sourceResourceSha256: HOTO_SOURCE.sha256,
sourceMaterial: detail.nativeMaterial, canonicalMaterialName: detail.canonicalMaterial,
sourceTexturePath: detail.sourceTexturePath, sourceTextureName: detail.image, sourcePreparedPngSha256: detail.imageSha256,
};
carrierByName.set(variantName, getOrCreateCarrier(document, detail.textureObject, variantName, extras));
}
let mappedPrimitiveCount = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
for (const primitive of mesh.listPrimitives()) {
const materialName = primitive.getMaterial()?.getName();
if (targetMaterials.get(materialName) !== primitive.getMaterial()) continue;
addSurfaceMappings(document, primitive, carrierByName, variantByName);
mappedPrimitiveCount += 1;
}
}
if (mappedPrimitiveCount !== 36) throw new Error(`HOTO source-rock detail target count changed: expected 36, found ${mappedPrimitiveCount}.`);
for (const [materialName, metadata] of expectedByMaterial) {
const material = targetMaterials.get(materialName);
material.setExtras({ ...(material.getExtras() ?? {}), runewakerSurfaceDetail: metadata });
}
return {
id: HOTO_POLICY_ID,
status: "applied",
sourceModel: HOTO_SOURCE.model,
sourceModelSha256: HOTO_SOURCE.sha256,
detail: { periodMeters: 2, sourceSpace: "browser-adapted world-space detail; native base UV0 retained" },
detailSources: detailProofs,
materials: Object.keys(HOTO_TARGETS).map((name) => ({ name, metadata: expectedByMaterial.get(name) })),
mappedPrimitiveCount,
sourceGeometryChanged: false,
nativeBaseTexturesAndMaterialSemanticsPreserved: true,
migration: "v1 metadata migrates only when every target material, tile carrier and variant mapping matches the reviewed legacy configuration",
};
}
/** Source-pinned atlas-safe crops and native source-rock detail carriers. */
export async function applyRunewakerSurfaceQualityPolicies(document, { dungeonId, policyIds = [], sourceReport } = {}) {
if (new Set(policyIds).size !== policyIds.length) throw new Error("RoM surface-quality policy IDs must be unique.");
const results = [];
for (const policyId of policyIds) {
if (dungeonId === "barren-caves" && policyId === BARREN_POLICY_ID) results.push(await applyBarrenFloorPolicy(document, sourceReport));
else if (dungeonId === "hoto" && policyId === HOTO_POLICY_ID) results.push(await applyHotoDetailPolicy(document, sourceReport));
else throw new Error(`Unknown or mismatched RoM surface-quality policy ${policyId} for ${dungeonId}.`);
}
return { schemaVersion: 1, dungeonId, status: policyIds.length ? "applied" : "no-policy", policies: results };
}
export const runewakerSurfaceQualityPolicyIds = Object.freeze({ barrenCaves: BARREN_POLICY_ID, hoto: HOTO_POLICY_ID });
@@ -0,0 +1,22 @@
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { applyRunewakerVisualUvAdaptations } from "./rom-visual-uv-adaptations.mjs";
import { applyRunewakerSurfaceQualityPolicies } from "./rom-surface-quality-policies.mjs";
export async function applyRunewakerVisualUvAdaptationsToFile(file, options) {
const policyIds = options.policyIds ?? [];
const surfaceQualityPolicyIds = options.surfaceQualityPolicyIds ?? [];
if (!policyIds.length && !surfaceQualityPolicyIds.length) {
return applyRunewakerVisualUvAdaptations(new Document(), { ...options, policyIds: [] });
}
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(file);
const visualUv = applyRunewakerVisualUvAdaptations(document, { ...options, policyIds });
const surfaceQuality = await applyRunewakerSurfaceQualityPolicies(document, {
dungeonId: options.dungeonId,
policyIds: surfaceQualityPolicyIds,
sourceReport: options.sourceReport,
});
await io.write(file, document);
return { ...visualUv, surfaceQuality };
}
@@ -0,0 +1,203 @@
import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
// Reviewed browser surface fixes live in rom-surface-quality-policies.mjs.
// This registry remains for future UV-only adaptations whose projection is
// safe to express as one source-UV transform.
const POLICIES = Object.freeze({});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const accessorHash = (accessor) => {
const array = accessor.getArray();
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
};
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
function sourceProof(policy, dungeonId, sourceReport) {
if (dungeonId !== policy.dungeonId) throw new Error(`UV policy ${policy.id} cannot run for ${dungeonId}.`);
if (normalizeResource(sourceReport?.sourceModel) !== policy.sourceModel.toLowerCase()) {
throw new Error(`UV policy ${policy.id} source ROS path does not match the pinned model.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== policy.sourceModelSha256) {
throw new Error(`UV policy ${policy.id} source ROS SHA-256 does not match the reviewed source.`);
}
const matches = (sourceReport.materials ?? []).filter((material) => material.name === policy.sourceMaterial);
if (matches.length !== 1) throw new Error(`UV policy ${policy.id} expected one exact native material record; found ${matches.length}.`);
const sourceMaterial = matches[0];
if (normalizeResource(sourceMaterial.sourceTexture) !== policy.sourceTexture.toLowerCase()) {
throw new Error(`UV policy ${policy.id} native diffuse texture path does not match the reviewed source.`);
}
if (normalizeResource(sourceMaterial.stagedTexture).split("/").at(-1) !== policy.stagedTexture.toLowerCase()) {
throw new Error(`UV policy ${policy.id} staged texture basename does not match the reviewed source.`);
}
return {
sourceModel: policy.sourceModel,
sourceModelSha256: policy.sourceModelSha256,
sourceMaterial: policy.sourceMaterial,
sourceTexture: policy.sourceTexture,
stagedTexture: policy.stagedTexture,
};
}
function policyMarker(policy) {
return {
id: policy.id,
version: 1,
repeat: [...policy.repeat],
sourceAttribute: "TEXCOORD_0",
adaptedAttribute: "TEXCOORD_1",
sourceMaterial: policy.sourceMaterial,
sourceTexture: policy.gltfTexture,
};
}
function validateAdaptedPrimitive(primitive, policy) {
const info = primitive.getMaterial()?.getBaseColorTextureInfo();
const uv0 = primitive.getAttribute("TEXCOORD_0");
const uv1 = primitive.getAttribute("TEXCOORD_1");
if (!uv0 || !uv1 || info?.getTexCoord() !== 1) throw new Error(`Previously adapted primitive is incomplete for ${policy.id}.`);
if (primitive.getExtras()?.romUvAdaptation?.id !== policy.id) throw new Error(`Previously adapted primitive has a mismatched marker for ${policy.id}.`);
if (uv0.getCount() !== uv1.getCount()) throw new Error(`Previously adapted UV accessor counts differ for ${policy.id}.`);
for (let index = 0; index < uv0.getCount(); index += 1) {
const source = uv0.getElement(index, [0, 0]);
const adapted = uv1.getElement(index, [0, 0]);
if (Math.abs(adapted[0] - source[0] * policy.repeat[0]) > 1e-6
|| Math.abs(adapted[1] - source[1] * policy.repeat[1]) > 1e-6) {
throw new Error(`Previously adapted UV values are invalid for ${policy.id}.`);
}
}
}
/**
* Applies explicitly opted-in, source-pinned RoM UV corrections to a canonical
* unoptimized visual document. The source UV0 and all geometry attributes stay
* intact; the cloned base-color material alone reads repeated UV1.
*/
export function applyRunewakerVisualUvAdaptations(document, { dungeonId, policyIds = [], sourceReport } = {}) {
if (new Set(policyIds).size !== policyIds.length) throw new Error("RoM visual UV adaptation policy IDs must be unique.");
const root = document.getRoot();
const result = {
schemaVersion: 1,
dungeonId,
status: policyIds.length ? "applied" : "no-policy",
policies: [],
materialLightingMetadataChanged: false,
geometryChanged: false,
sourceUv0Changed: false,
};
for (const policyId of policyIds) {
const policy = POLICIES[policyId];
if (!policy) throw new Error(`Unknown RoM visual UV adaptation policy: ${policyId}.`);
const proof = sourceProof(policy, dungeonId, sourceReport);
const marker = policyMarker(policy);
const originalMaterial = root.listMaterials().find((material) => material.getName() === policy.sourceMaterial);
if (!originalMaterial) throw new Error(`UV policy ${policy.id} source material is absent from the canonical GLB.`);
const originalTexture = originalMaterial.getBaseColorTexture();
if (originalTexture?.getName() !== policy.gltfTexture || !originalMaterial.getBaseColorTextureInfo()) {
throw new Error(`UV policy ${policy.id} base-color image does not match the source report.`);
}
if (originalMaterial.getNormalTexture() || originalMaterial.getMetallicRoughnessTexture() || originalMaterial.getEmissiveTexture()) {
throw new Error(`UV policy ${policy.id} has unexpected secondary texture slots.`);
}
const primitives = [];
const alreadyAdapted = [];
for (const node of root.listNodes()) {
if (!node.getMesh()) continue;
for (const [primitiveIndex, primitive] of node.getMesh().listPrimitives().entries()) {
if (primitive.getMaterial()?.getName() === policy.sourceMaterial) {
if (primitive.getMaterial().getBaseColorTexture()?.getName() !== policy.gltfTexture) {
throw new Error(`UV policy ${policy.id} material use maps to an unexpected image.`);
}
primitives.push({ node, primitive, primitiveIndex });
} else if (primitive.getExtras()?.romUvAdaptation?.id === policy.id) {
alreadyAdapted.push({ node, primitive, primitiveIndex });
}
}
}
if (alreadyAdapted.length) {
if (primitives.length) throw new Error(`UV policy ${policy.id} document contains a partial adaptation.`);
for (const { primitive } of alreadyAdapted) validateAdaptedPrimitive(primitive, policy);
result.status = "already-applied";
result.policies.push({
...proof,
...marker,
status: "already-applied",
primitiveCount: alreadyAdapted.length,
triangleCount: alreadyAdapted.reduce((sum, { primitive }) => sum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0),
nodes: alreadyAdapted.map(({ node, primitive }) => ({ node: node.getName(), sourceUvHash: accessorHash(primitive.getAttribute("TEXCOORD_0")), adaptedUvHash: accessorHash(primitive.getAttribute("TEXCOORD_1")) })),
lightingMetadataPreserved: true,
});
continue;
}
if (!primitives.length) throw new Error(`UV policy ${policy.id} found no matching canonical GLB primitives.`);
const buffer = root.listBuffers()[0];
if (!buffer) throw new Error(`UV policy ${policy.id} document has no buffer for new accessors.`);
const preflight = primitives.map(({ node, primitive, primitiveIndex }) => {
if (primitive.getExtras()?.romUvAdaptation) throw new Error(`UV policy ${policy.id} target is marked by another adaptation.`);
if (primitive.getAttribute("TEXCOORD_1")) throw new Error(`UV policy ${policy.id} refuses to overwrite existing TEXCOORD_1.`);
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
const uv0 = primitive.getAttribute("TEXCOORD_0");
if (!position || !normal || !indices || !uv0 || uv0.getType() !== Accessor.Type.VEC2) {
throw new Error(`UV policy ${policy.id} target is missing a required POSITION, NORMAL, index, or VEC2 TEXCOORD_0 accessor.`);
}
return { node, primitive, primitiveIndex, position, normal, indices, uv0 };
});
const adaptedMaterial = originalMaterial.clone().setName(`${policy.sourceMaterial}_repeat${policy.repeat[0]}x${policy.repeat[1]}_adapted`);
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1);
const policyReport = {
...proof,
...marker,
status: "applied",
primitiveCount: primitives.length,
triangleCount: 0,
nodes: [],
lightingMetadataPreserved: true,
noGeometryChange: true,
noOtherUvSetChanged: true,
};
for (const { node, primitive, primitiveIndex, position, normal, indices, uv0 } of preflight) {
const sourceHashes = {
position: accessorHash(position),
normal: accessorHash(normal),
indices: accessorHash(indices),
uv0: accessorHash(uv0),
};
const values = new Float32Array(uv0.getCount() * 2);
for (let index = 0; index < uv0.getCount(); index += 1) {
const uv = uv0.getElement(index, [0, 0]);
values[index * 2] = uv[0] * policy.repeat[0];
values[index * 2 + 1] = uv[1] * policy.repeat[1];
}
const uv1 = document.createAccessor(`${node.getName()}:${policy.id}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2)
.setArray(values)
.setBuffer(buffer);
primitive.setAttribute("TEXCOORD_1", uv1);
primitive.setMaterial(adaptedMaterial);
primitive.setExtras({ ...(primitive.getExtras() ?? {}), romUvAdaptation: marker });
policyReport.triangleCount += indices.getCount() / 3;
policyReport.nodes.push({
node: node.getName(),
primitiveIndex,
triangles: indices.getCount() / 3,
vertices: position.getCount(),
sourcePositionHash: sourceHashes.position,
sourceNormalHash: sourceHashes.normal,
sourceIndexHash: sourceHashes.indices,
sourceUvHash: sourceHashes.uv0,
adaptedUvHash: sha256(Buffer.from(values.buffer, values.byteOffset, values.byteLength)),
});
}
result.policies.push(policyReport);
}
return result;
}
export function listRunewakerVisualUvAdaptationPolicies() {
return Object.values(POLICIES).map((policy) => ({ ...policy, repeat: [...policy.repeat] }));
}
@@ -0,0 +1,35 @@
import { sourceTerrainToThreeMatrix } from '../sand-vortex/terrain-geometry.mjs';
import { Vector3 } from 'three';
const BOX_FACES = [
[0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7],
[0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5],
[2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7],
];
/** Match the native ROS exporter: inverse primary TRS followed by reflected Z. */
export function sourceAnchorSupportGeometry(points, placement, {
halfExtent = 60, thickness = 3, metersPerSourceUnit = 1, omitPointIndices = [],
} = {}) {
if (!Array.isArray(points) || !Number.isFinite(halfExtent) || halfExtent <= 0
|| !Number.isFinite(thickness) || thickness <= 0) throw new Error('Invalid source-anchor support dimensions.');
const transform = sourceTerrainToThreeMatrix(placement, metersPerSourceUnit);
if (!Array.isArray(omitPointIndices) || new Set(omitPointIndices).size !== omitPointIndices.length
|| omitPointIndices.some(i => !Number.isInteger(i) || i < 0 || i >= points.length)) throw new Error('Invalid source-anchor support omissions.');
const omitted = new Set(omitPointIndices);
const faces = transform.determinant() < 0 ? BOX_FACES.map(([a, b, c]) => [a, c, b]) : BOX_FACES;
const positions = [], indices = [], navigationPositions = [];
for (const [pointIndex, point] of points.entries()) {
if (omitted.has(pointIndex)) continue;
if (!Array.isArray(point) || point.length !== 3 || !point.every(Number.isFinite)) throw new Error('Invalid source-anchor support point.');
const [x, y, z] = point, h = halfExtent;
const vertices = [[x-h,y,z-h],[x+h,y,z-h],[x+h,y,z+h],[x-h,y,z+h],
[x-h,y-thickness,z-h],[x+h,y-thickness,z-h],[x+h,y-thickness,z+h],[x-h,y-thickness,z+h]]
.map(p => new Vector3(...p).applyMatrix4(transform).toArray());
const offset = positions.length / 3;
positions.push(...vertices.flat());
for (const face of faces) indices.push(...face.map(i => offset + i));
for (const face of faces.slice(0, 2)) for (const i of face) navigationPositions.push(...vertices[i]);
}
return { positions, indices, navigationPositions };
}
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Matrix4, Vector3 } from 'three';
import { sourceAnchorSupportGeometry } from './source-anchor-support.mjs';
import { descriptorSourceMatrix } from './environment-assembly.mjs';
test('support OBJ and nav use the ROS reflected-Z bridge and upward floor winding', () => {
const placement = { translation: [10, 20, 30], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
const obj = sourceAnchorSupportGeometry([[12, 25, 60]], placement, { halfExtent: 1, thickness: 2 });
const nav = sourceAnchorSupportGeometry([[12, 25, 60]], placement, { halfExtent: 1, thickness: 2, metersPerSourceUnit: 0.1 });
assert.deepEqual(obj.positions.slice(0, 3), [1, 5, -29]);
assert.ok(nav.navigationPositions.every((v, i) => Math.abs(v - obj.navigationPositions[i] * 0.1) < 1e-12));
const [a, b, c] = obj.indices.slice(0, 3).map(i => new Vector3().fromArray(obj.positions, i * 3));
assert.ok(b.sub(a).cross(c.sub(a)).y > 0);
});
test('rotated nonuniform primary placements preserve the source support footprint', () => {
const placement = { translation: [10, 20, 30], rotation: [0, Math.sin(0.4), 0, Math.cos(0.4)], scale: [2, 3, 4] };
const geometry = sourceAnchorSupportGeometry([[12, 25, 60]], placement, { halfExtent: 1, thickness: 2, metersPerSourceUnit: 0.1 });
const toSource = descriptorSourceMatrix(placement).multiply(new Matrix4().makeScale(10, 10, -10));
const first = new Vector3().fromArray(geometry.positions).applyMatrix4(toSource);
assert.ok(first.distanceTo(new Vector3(11, 25, 59)) < 1e-10);
assert.equal(geometry.indices.length, 36);
assert.equal(geometry.navigationPositions.length, 18);
});
test('a reviewed native-ground omission removes the same footprint from mesh and nav', () => {
const placement = { translation: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
const result = sourceAnchorSupportGeometry([[0, 0, 1], [20, 0, 2]], placement, { omitPointIndices: [0] });
assert.equal(result.positions.length, 24);
assert.equal(result.indices.length, 36);
assert.equal(result.navigationPositions.length, 18);
assert.ok(result.positions.filter((_, i) => i % 3 === 0).every(x => x === -40 || x === 80));
assert.throws(() => sourceAnchorSupportGeometry([[0, 0, 1]], placement, { omitPointIndices: [1] }), /omissions/);
});
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import sharp from "sharp";
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
const validHash = value => typeof value === "string" && /^[a-f\d]{64}$/u.test(value);
/** Verifies an exact native atlas island, rather than exempting a resized image. */
export async function verifySourcePixelCrop(metadata, sourcePng, canonicalCropPng, shipped) {
assert.equal(metadata?.version, 1, "Unsupported source crop proof.");
assert.ok(typeof metadata.policyId === "string" && metadata.policyId.length > 0);
assert.ok(typeof metadata.sourceImageName === "string" && !/[\\/]/u.test(metadata.sourceImageName));
for (const key of ["sourcePreparedPngSha256", "croppedPngSha256", "croppedRgbaSha256"]) {
assert.ok(validHash(metadata[key]), `Invalid ${key}.`);
}
assert.equal(digest(sourcePng), metadata.sourcePreparedPngSha256, "Native atlas PNG changed.");
assert.equal(digest(canonicalCropPng), metadata.croppedPngSha256, "Canonical crop PNG changed.");
const source = await sharp(sourcePng).metadata();
assert.equal(source.width, metadata.sourceWidth);
assert.equal(source.height, metadata.sourceHeight);
const rect = metadata.rect;
assert.ok(rect && [rect.left, rect.top, rect.width, rect.height].every(Number.isInteger));
assert.ok(rect.left >= 0 && rect.top >= 0 && rect.width > 0 && rect.height > 0
&& rect.left + rect.width <= source.width && rect.top + rect.height <= source.height,
"Source crop exceeds its verified native atlas.");
const crop = await sharp(canonicalCropPng).metadata();
assert.equal(crop.width, rect.width, "Canonical crop was resized.");
assert.equal(crop.height, rect.height, "Canonical crop was resized.");
assert.equal(shipped.width, rect.width, "Shipped crop was resized.");
assert.equal(shipped.height, rect.height, "Shipped crop was resized.");
if (shipped.levelCount !== undefined) {
assert.equal(shipped.levelCount, Math.floor(Math.log2(Math.max(rect.width, rect.height))) + 1,
"Shipped crop lost its full native island mip chain.");
}
const sourcePixels = await sharp(sourcePng).extract(rect).ensureAlpha().raw().toBuffer();
const cropPixels = await sharp(canonicalCropPng).ensureAlpha().raw().toBuffer();
assert.equal(digest(sourcePixels), metadata.croppedRgbaSha256, "Native crop pixel proof changed.");
assert.deepEqual(cropPixels, sourcePixels, "Derived crop changed native RGBA pixels.");
return { ...metadata, status: "verified-exact-source-pixel-crop", canonicalPixelsByteIdentical: true,
shippedDimensionsPreserved: true, compressionPixelsRequireSeparateDecode: true };
}
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import sharp from "sharp";
import { verifySourcePixelCrop } from "./source-pixel-crop-proof.mjs";
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
async function fixture() {
const pixels = Buffer.from([10, 20, 30, 0, 40, 50, 60, 255, 70, 80, 90, 128, 100, 110, 120, 255]);
const source = await sharp(pixels, { raw: { width: 2, height: 2, channels: 4 } }).png().toBuffer();
const rect = { left: 0, top: 0, width: 1, height: 2 };
const crop = await sharp(source).extract(rect).png().toBuffer();
const cropPixels = await sharp(crop).ensureAlpha().raw().toBuffer();
const metadata = { version: 1, policyId: "fixture-island", sourceImageName: "native-atlas", sourceWidth: 2, sourceHeight: 2,
sourcePreparedPngSha256: digest(source), rect, croppedPngSha256: digest(crop), croppedRgbaSha256: digest(cropPixels) };
return { source, crop, metadata, shipped: { width: 1, height: 2 } };
}
test("exact source crop retains native RGBA and distinguishes island dimensions from downsampling", async () => {
const f = await fixture();
assert.equal((await verifySourcePixelCrop(f.metadata, f.source, f.crop, f.shipped)).canonicalPixelsByteIdentical, true);
});
test("crop proof rejects changed pixels, out-of-bounds metadata, and shipping downsampling", async () => {
const f = await fixture();
const changed = await sharp(f.crop).removeAlpha().png().toBuffer();
await assert.rejects(verifySourcePixelCrop({ ...f.metadata, croppedPngSha256: digest(changed) }, f.source, changed, f.shipped), /RGBA pixels/u);
await assert.rejects(verifySourcePixelCrop({ ...f.metadata, rect: { ...f.metadata.rect, left: 2 } }, f.source, f.crop, f.shipped), /exceeds/u);
await assert.rejects(verifySourcePixelCrop(f.metadata, f.source, f.crop, { width: 1, height: 1 }), /Shipped crop/u);
await assert.rejects(verifySourcePixelCrop(f.metadata, f.source, f.crop, { ...f.shipped, levelCount: 1 }), /mip chain/u);
});
@@ -0,0 +1,241 @@
import { createHash } from "node:crypto";
import { Matrix3, Matrix4, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./source-anchor-support.mjs";
const POSITION_TOLERANCE = 2e-4;
function matrixForNode(node) {
return new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
}
function applyPoint(matrix, values) {
return new Vector3().fromArray(values).applyMatrix4(matrix).toArray();
}
function reflectWorldZ(values) {
return values.map((value, index) => index % 3 === 2 ? -value : value);
}
function reverseTriangleWinding(indices) {
const reversed = Array.from(indices);
if (reversed.length % 3 !== 0) throw new Error("Support index buffer is not a triangle list.");
for (let i = 0; i < reversed.length; i += 3) [reversed[i + 1], reversed[i + 2]] = [reversed[i + 2], reversed[i + 1]];
return reversed;
}
function oldNavigationOrder(correctedPositions) {
const oldOrder = [];
if (correctedPositions.length % 9 !== 0) throw new Error("Support navigation positions are not a triangle list.");
for (let i = 0; i < correctedPositions.length; i += 9) {
oldOrder.push(
...correctedPositions.slice(i, i + 3),
...correctedPositions.slice(i + 6, i + 9),
...correctedPositions.slice(i + 3, i + 6),
);
}
return oldOrder;
}
function closeArrays(actual, expected, label, tolerance = POSITION_TOLERANCE) {
if (actual.length !== expected.length) throw new Error(`${label} length ${actual.length} does not match expected ${expected.length}.`);
let maximumError = 0;
for (let i = 0; i < actual.length; i += 1) {
const error = Math.abs(Number(actual[i]) - Number(expected[i]));
maximumError = Math.max(maximumError, error);
if (!Number.isFinite(error) || error > tolerance) {
throw new Error(`${label} mismatches reviewed source geometry at scalar ${i}: ${actual[i]} vs ${expected[i]} (error ${error}).`);
}
}
return maximumError;
}
function accessorHash(accessor) {
if (!accessor) return null;
const values = accessor.getArray();
return createHash("sha256").update(Buffer.from(values.buffer, values.byteOffset, values.byteLength)).digest("hex");
}
function addAccessor(document, primitive, semantic, oldAccessor, array, type = oldAccessor.getType()) {
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-correction-buffer");
const accessor = document.createAccessor(`${oldAccessor.getName() || semantic}:z-corrected`)
.setType(type)
.setArray(array)
.setNormalized(oldAccessor.getNormalized())
.setExtras({ ...oldAccessor.getExtras() })
.setBuffer(buffer);
primitive.setAttribute(semantic, accessor);
return accessor;
}
function keptVertexIndices(pointCount, omittedPointIndices) {
const omitted = new Set(omittedPointIndices);
for (const index of omitted) {
if (!Number.isInteger(index) || index < 0 || index >= pointCount) {
throw new Error(`Omitted source-anchor point index ${index} is outside [0, ${pointCount}).`);
}
}
return Array.from({ length: pointCount }, (_, index) => index).filter((index) => !omitted.has(index));
}
/**
* Corrects only a generated support primitive after proving its old world-space
* vertices and winding match the pre-bridge source-local support writer.
*/
export function stageCorrectSourceAnchorSupportPrimitive(document, {
node,
primitive,
points,
placement,
halfExtent = 60,
thickness = 3,
metersPerSourceUnit = 0.1,
omitPointIndices = [],
} = {}) {
if (!node?.getMesh() || !primitive || !Array.isArray(points) || !points.length) {
throw new Error("Source-anchor correction requires one generated support mesh and its source points.");
}
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
if (!position || !indices || position.getType() !== "VEC3" || position.getCount() !== points.length * 8
|| indices.getCount() !== points.length * 36) {
throw new Error("Generated support topology does not match 8 vertices / 36 indices per source point.");
}
const matrix = matrixForNode(node);
if (Math.abs(matrix.determinant()) < 1e-12) throw new Error("Generated support node transform is singular.");
const inverse = matrix.clone().invert();
const allExpected = sourceAnchorSupportGeometry(points, placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const oldWorldPositions = [];
const localPositionValues = Array.from(position.getArray());
for (let offset = 0; offset < localPositionValues.length; offset += 3) {
oldWorldPositions.push(...applyPoint(matrix, localPositionValues.slice(offset, offset + 3)));
}
const maxOldPositionError = closeArrays(oldWorldPositions, reflectWorldZ(allExpected.positions), "Old support source-local position proof");
const indexValues = Array.from(indices.getArray());
const expectedOldWinding = reverseTriangleWinding(allExpected.indices);
const maxOldWindingMismatch = indexValues.length === expectedOldWinding.length
? indexValues.reduce((m, value, index) => Math.max(m, Math.abs(value - expectedOldWinding[index])), 0)
: Infinity;
if (maxOldWindingMismatch !== 0) throw new Error("Generated support winding does not match the old unreflected source-local box faces.");
if (normal && normal.getCount() !== position.getCount()) throw new Error("Generated support NORMAL count differs from POSITION.");
const keptPoints = keptVertexIndices(points.length, omitPointIndices);
const corrected = sourceAnchorSupportGeometry(keptPoints.map((index) => points[index]), placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const correctedLocalPositions = [];
for (let offset = 0; offset < corrected.positions.length; offset += 3) {
correctedLocalPositions.push(...applyPoint(inverse, corrected.positions.slice(offset, offset + 3)));
}
const correctedPositionAccessor = addAccessor(
document, primitive, "POSITION", position, new Float32Array(correctedLocalPositions), position.getType(),
);
let correctedNormalAccessor = null;
if (normal) {
const normalMatrix = new Matrix3().getNormalMatrix(matrix);
const inverseNormalMatrix = normalMatrix.clone().invert();
const oldNormalValues = normal.getArray();
const correctedNormals = [];
for (const pointIndex of keptPoints) {
for (let vertex = 0; vertex < 8; vertex += 1) {
const scalar = (pointIndex * 8 + vertex) * 3;
const worldNormal = new Vector3().fromArray(oldNormalValues, scalar).applyMatrix3(normalMatrix).normalize();
worldNormal.z *= -1;
const localNormal = worldNormal.applyMatrix3(inverseNormalMatrix).normalize();
correctedNormals.push(localNormal.x, localNormal.y, localNormal.z);
}
}
correctedNormalAccessor = addAccessor(
document, primitive, "NORMAL", normal, new Float32Array(correctedNormals), normal.getType(),
);
}
const oldIndexHash = accessorHash(indices);
const indexArray = indices.getArray();
const correctedIndexValues = new indexArray.constructor(corrected.indices);
const indexBuffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-correction-buffer");
const correctedIndexAccessor = document.createAccessor(`${indices.getName() || "support-indices"}:z-corrected`)
.setType(indices.getType())
.setArray(correctedIndexValues)
.setNormalized(indices.getNormalized())
.setExtras({ ...indices.getExtras() })
.setBuffer(indexBuffer);
primitive.setIndices(correctedIndexAccessor);
return {
status: "staged",
sourcePointCount: points.length,
omittedPointIndices: [...omitPointIndices],
retainedPadCount: keptPoints.length,
oldPositionHash: accessorHash(position),
correctedPositionHash: accessorHash(correctedPositionAccessor),
oldNormalHash: accessorHash(normal),
correctedNormalHash: accessorHash(correctedNormalAccessor),
oldIndexHash,
correctedIndexHash: accessorHash(correctedIndexAccessor),
sourceLocalPositionProofMaxError: maxOldPositionError,
sourceLocalWindingProofMaxIndexDifference: maxOldWindingMismatch,
correctedTriangleCount: corrected.indices.length / 3,
correctedNavigationTriangleCount: corrected.navigationPositions.length / 9,
worldZReflectedForGeneratedSupportOnly: true,
reflectedSupportNormalsAndCorrectedWinding: true,
nativePrimitivesTouched: false,
};
}
/** Corrects the explicitly appended support-nav triangles; the baked Recast mesh is untouched. */
export function stageCorrectSourceAnchorNavigationPrimitive(document, {
node,
primitive,
points,
placement,
halfExtent = 60,
thickness = 3,
metersPerSourceUnit = 0.1,
omitPointIndices = [],
} = {}) {
if (!node?.getMesh() || !primitive || !Array.isArray(points) || !points.length) {
throw new Error("Source-anchor navigation correction requires the generated navigation primitive and source points.");
}
const position = primitive.getAttribute("POSITION");
if (!position || position.getType() !== "VEC3" || position.getCount() !== points.length * 6) {
throw new Error("Generated support navigation topology does not match 6 vertices per source point.");
}
const matrix = matrixForNode(node);
if (Math.abs(matrix.determinant()) < 1e-12) throw new Error("Generated support navigation node transform is singular.");
const inverse = matrix.clone().invert();
const allExpected = sourceAnchorSupportGeometry(points, placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const oldWorldPositions = [];
const oldLocal = Array.from(position.getArray());
for (let offset = 0; offset < oldLocal.length; offset += 3) {
oldWorldPositions.push(...applyPoint(matrix, oldLocal.slice(offset, offset + 3)));
}
const maxOldPositionError = closeArrays(oldWorldPositions, oldNavigationOrder(allExpected.navigationPositions), "Old support-nav source position/winding proof");
const keptPoints = keptVertexIndices(points.length, omitPointIndices);
const corrected = sourceAnchorSupportGeometry(keptPoints.map((index) => points[index]), placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const correctedLocal = [];
for (let offset = 0; offset < corrected.navigationPositions.length; offset += 3) {
correctedLocal.push(...applyPoint(inverse, corrected.navigationPositions.slice(offset, offset + 3)));
}
const accessor = addAccessor(
document, primitive, "POSITION", position, new Float32Array(correctedLocal), position.getType(),
);
return {
status: "staged",
sourcePointCount: points.length,
omittedPointIndices: [...omitPointIndices],
retainedPadCount: keptPoints.length,
oldPositionHash: accessorHash(position),
correctedPositionHash: accessorHash(accessor),
sourceLocalPositionAndOldWindingProofMaxError: maxOldPositionError,
correctedTriangleCount: corrected.navigationPositions.length / 9,
bakedRecastNavigationPreservedUnchanged: true,
};
}
@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Document, Accessor } from "@gltf-transform/core";
import { Matrix3, Matrix4, Quaternion, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./source-anchor-support.mjs";
import {
stageCorrectSourceAnchorNavigationPrimitive,
stageCorrectSourceAnchorSupportPrimitive,
} from "./stage-source-anchor-support-correction.mjs";
const placement = { translation: [12, 5, -3], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
const points = [[18, 32, 44], [50, 12, -8]];
const geometry = sourceAnchorSupportGeometry(points, placement, {
halfExtent: 3, thickness: 2, metersPerSourceUnit: 0.1,
});
function makeDocumentWithOldGeneratedSupport() {
const document = new Document();
const buffer = document.createBuffer("test-buffer");
const rotation = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 2);
const world = new Matrix4().compose(new Vector3(0, 0, 0), rotation, new Vector3(0.1, 0.1, 0.1));
const inverse = world.clone().invert();
const oldWorldPositions = geometry.positions.map((value, index) => index % 3 === 2 ? -value : value);
const oldLocalPositions = [];
for (let i = 0; i < oldWorldPositions.length; i += 3) {
oldLocalPositions.push(...new Vector3().fromArray(oldWorldPositions, i).applyMatrix4(inverse).toArray());
}
const oldIndices = Array.from(geometry.indices);
for (let i = 0; i < oldIndices.length; i += 3) [oldIndices[i + 1], oldIndices[i + 2]] = [oldIndices[i + 2], oldIndices[i + 1]];
const normalMatrix = new Matrix3().getNormalMatrix(world);
const inverseNormal = normalMatrix.clone().invert();
const oldNormals = [];
for (let i = 0; i < oldLocalPositions.length; i += 3) {
const worldNormal = new Vector3(0.25, 0.5, Math.sqrt(0.6875));
oldNormals.push(...worldNormal.applyMatrix3(inverseNormal).normalize().toArray());
}
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor("old-position", buffer).setType(Accessor.Type.VEC3).setArray(new Float32Array(oldLocalPositions)))
.setAttribute("NORMAL", document.createAccessor("old-normal", buffer).setType(Accessor.Type.VEC3).setArray(new Float32Array(oldNormals)))
.setIndices(document.createAccessor("old-index", buffer).setType(Accessor.Type.SCALAR).setArray(new Uint16Array(oldIndices)));
const supportNode = document.createNode("runewaker_source_anchor_support")
.setRotation(rotation.toArray()).setScale([0.1, 0.1, 0.1])
.setMesh(document.createMesh("support-mesh").addPrimitive(primitive));
const nativePrimitive = document.createPrimitive().setAttribute("POSITION", document.createAccessor("native-position", buffer)
.setType(Accessor.Type.VEC3).setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 0, 1])));
const nativeNode = document.createNode("native-ros-mesh").setMesh(document.createMesh("native-mesh").addPrimitive(nativePrimitive));
const scene = document.createScene("scene").addChild(supportNode).addChild(nativeNode);
document.getRoot().setDefaultScene(scene);
const nativeBefore = Array.from(nativePrimitive.getAttribute("POSITION").getArray());
return { document, supportNode, primitive, nativePrimitive, nativeBefore, normalMatrix };
}
test("staged support correction proves source-local input, reflects generated geometry, and omits only requested source points", () => {
const { document, supportNode, primitive, nativePrimitive, nativeBefore, normalMatrix } = makeDocumentWithOldGeneratedSupport();
const report = stageCorrectSourceAnchorSupportPrimitive(document, {
node: supportNode,
primitive,
points,
placement,
halfExtent: 3,
thickness: 2,
metersPerSourceUnit: 0.1,
omitPointIndices: [0],
});
assert.equal(report.sourceLocalPositionProofMaxError < 1e-4, true);
assert.equal(report.retainedPadCount, 1);
assert.equal(primitive.getAttribute("POSITION").getCount(), 8);
assert.equal(primitive.getAttribute("NORMAL").getCount(), 8);
assert.equal(primitive.getIndices().getCount(), 36);
assert.deepEqual(Array.from(nativePrimitive.getAttribute("POSITION").getArray()), nativeBefore);
const matrix = new Matrix4().fromArray(Array.from(supportNode.getWorldMatrix()));
const localPosition = primitive.getAttribute("POSITION").getArray();
const worldPosition = new Vector3().fromArray(localPosition, 0).applyMatrix4(matrix).toArray();
const expected = geometry.positions.slice(8 * 3, 16 * 3);
assert.ok(worldPosition.every((value, axis) => Math.abs(value - expected[axis]) < 1e-4));
const newLocalNormal = primitive.getAttribute("NORMAL").getArray();
const newWorldNormal = new Vector3().fromArray(newLocalNormal, 0).applyMatrix3(normalMatrix).normalize().toArray();
assert.ok(Math.abs(newWorldNormal[0] - 0.25) < 1e-5);
assert.ok(Math.abs(newWorldNormal[1] - 0.5) < 1e-5);
assert.ok(Math.abs(newWorldNormal[2] + Math.sqrt(0.6875)) < 1e-5);
});
test("support-nav correction verifies the old reflected positions and reverses only appended triangles", () => {
const document = new Document();
const buffer = document.createBuffer("nav-buffer");
const oldPositions = [];
for (let i = 0; i < geometry.navigationPositions.length; i += 9) {
oldPositions.push(
...geometry.navigationPositions.slice(i, i + 3),
...geometry.navigationPositions.slice(i + 6, i + 9),
...geometry.navigationPositions.slice(i + 3, i + 6),
);
}
const primitive = document.createPrimitive().setAttribute("POSITION", document.createAccessor("old-nav-position", buffer)
.setType(Accessor.Type.VEC3).setArray(new Float32Array(oldPositions)));
const node = document.createNode("source-anchor-support-navigation").setMesh(document.createMesh("support-nav").addPrimitive(primitive));
document.getRoot().setDefaultScene(document.createScene("nav-scene").addChild(node));
const report = stageCorrectSourceAnchorNavigationPrimitive(document, {
node, primitive, points, placement, halfExtent: 3, thickness: 2, metersPerSourceUnit: 0.1, omitPointIndices: [0],
});
assert.ok(report.sourceLocalPositionAndOldWindingProofMaxError < 1e-4);
assert.equal(report.retainedPadCount, 1);
const actual = Array.from(primitive.getAttribute("POSITION").getArray());
const expected = geometry.navigationPositions.slice(18);
assert.equal(actual.length, expected.length);
assert.equal(primitive.getAttribute("POSITION").getCount(), 6);
for (let index = 0; index < actual.length; index += 1) {
assert.ok(Math.abs(actual[index] - expected[index]) < 1e-6, `nav scalar ${index} differs`);
}
});
@@ -0,0 +1,24 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
const output = path.resolve("artifacts/playtest/rom-environment-quality");
const { results } = JSON.parse(await readFile(path.join(output, "results.json"), "utf8"));
const columns = ["before", "view-0", "view-1", "view-2"];
for (let start = 0; start < results.length; start += 5) {
const subset = results.slice(start, start + 5);
const inputs = [];
for (const [row, result] of subset.entries()) {
for (const [column, suffix] of columns.entries()) {
try {
const input = await sharp(path.join(output, `${result.slug}-${suffix}.png`)).resize(384, 240).toBuffer();
inputs.push({ input, left: column * 384, top: row * 240 });
} catch { /* Failed captures remain empty and recorded in results.json. */ }
}
}
const file = path.join(output, `review-sheet-${Math.floor(start / 5) + 1}.jpg`);
await sharp({ create: { width: 1536, height: subset.length * 240, channels: 3, background: "#161820" } })
.composite(inputs).jpeg({ quality: 90 }).toFile(file);
await writeFile(file.replace(/\.jpg$/, ".json"), `${JSON.stringify({ columns, dungeons: subset.map((entry) => entry.slug) }, null, 2)}\n`);
console.log(file);
}
@@ -0,0 +1,52 @@
import { readFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { buildOriginNativeMaterialProvenance } from "./lib/origin-native-material-provenance.mjs";
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const name = argv[index];
if (!name.startsWith("--")) throw new Error(`Unexpected argument: ${name}`);
const value = argv[++index];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}.`);
args[name.slice(2)] = value;
}
for (const name of ["metadata", "assembly", "semantics", "output-dir"]) {
if (!args[name]) throw new Error(`Missing --${name}.`);
}
return args;
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
const args = parseArgs(process.argv.slice(2));
const [metadata, assembly, semantics] = await Promise.all([
readJson(args.metadata),
readJson(args.assembly),
readJson(args.semantics),
]);
const conversionReports = new Map();
for (const override of assembly.stagedOverrides ?? []) {
const visualPath = path.resolve(process.cwd(), override.file.replaceAll("\\", path.sep));
const conversionPath = path.join(path.dirname(visualPath), "conversion-report.json");
const report = await readJson(conversionPath);
conversionReports.set(override.resource.replaceAll("/", "\\").toLowerCase(), report);
}
const output = buildOriginNativeMaterialProvenance({ metadata, assembly, semantics, conversionReports });
const outputDirectory = path.resolve(args["output-dir"]);
await mkdir(outputDirectory, { recursive: true });
await Promise.all([
writeFile(path.join(outputDirectory, "import-metadata.json"), `${JSON.stringify(output.metadata, null, 2)}\n`),
writeFile(path.join(outputDirectory, "native-material-alpha-report.json"), `${JSON.stringify(output.alphaReport, null, 2)}\n`),
writeFile(path.join(outputDirectory, "native-material-semantics-report.json"), `${JSON.stringify(output.semanticsReport, null, 2)}\n`),
]);
console.log(JSON.stringify({
outputDirectory,
stagedOverrides: output.metadata.nativeMaterialAlpha.resources.length,
alphaMaterials: output.alphaReport.resources.reduce((count, row) => count + row.nativeAlpha.materials.length, 0),
additiveMaterials: output.semanticsReport.nativeAdditiveMaterials.length,
unlitMaterials: output.semanticsReport.khrMaterialsUnlit.length,
packageAssetsPreserved: output.metadata.assets?.length ?? 0,
}, null, 2));
@@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
isTrekkerPathEligible,
NativeCollisionPartAccumulator,
nativeResourceFile,
normalizeNativeResourcePath,
transformNativeCollisionTriangle,
} from "./lib/native-collision-assembly.mjs";
import {
readAndValidateNativeCollisionInventory,
sha256File,
writeNativeCollisionInventoryManifest,
} from "./lib/native-collision-inventory.mjs";
test("native collision points apply the Z reflection, placement matrix, and one winding reversal", () => {
const placement = [
2, 0, 0, 0,
0, 3, 0, 0,
0, 0, 4, 0,
7, 11, 13, 1,
];
const triangle = transformNativeCollisionTriangle(
[[1, 0, 0], [0, 1, 0], [0, 0, 1]],
placement,
0.1,
);
assert.deepEqual(triangle, [
[7.2, 11, 13],
[7, 11, 12.6],
[7, 11.3, 13],
]);
});
test("resource paths stay rooted and reject traversal", () => {
assert.equal(normalizeNativeResourcePath("/Model/Dungeon/Test/Room.ROS"), "Model\\Dungeon\\Test\\Room.ROS");
assert.throws(() => normalizeNativeResourcePath("model\\..\\outside.ros"), /Unsafe/);
assert.equal(path.basename(nativeResourceFile("F:\\resource-root", "model/dungeon/test.ros")), "test.ros");
});
test("collision part accumulator preserves triangle order and flags across the hard part boundary", () => {
const writer = new NativeCollisionPartAccumulator(2);
assert.equal(writer.add([[0, 0, 0], [1, 0, 0], [0, 1, 0]], 0x00100000), null);
const first = writer.add([[2, 0, 0], [3, 0, 0], [2, 1, 0]], 0x00200000);
assert.equal(first.triangleCount, 2);
assert.deepEqual(first.sourceTriangleFlags, [0x00100000, 0x00200000]);
assert.deepEqual(first.flagCounts, { "1048576": 1, "2097152": 1 });
assert.deepEqual([...first.positions.slice(0, 9)], [0, 0, 0, 1, 0, 0, 0, 1, 0]);
assert.deepEqual([...first.positions.slice(9, 18)], [2, 0, 0, 3, 0, 0, 2, 1, 0]);
writer.add([[4, 0, 0], [5, 0, 0], [4, 1, 0]], 0x00800000);
const last = writer.finish();
assert.equal(last.triangleCount, 1);
assert.deepEqual(last.sourceTriangleFlags, [0x00800000]);
assert.equal(writer.finish(), null);
assert.throws(() => new NativeCollisionPartAccumulator(90_001), /1 to 90,000/);
});
test("Trekker mask excludes water and foliage bits while preserving other native flags", () => {
assert.equal(isTrekkerPathEligible(0), true);
assert.equal(isTrekkerPathEligible(0x00100000), true);
assert.equal(isTrekkerPathEligible(0x00200000), false);
assert.equal(isTrekkerPathEligible(0x00800000), false);
assert.equal(isTrekkerPathEligible(0x00400000), true);
assert.equal(isTrekkerPathEligible(0x00a00000), false);
});
test("inventory index binds each native report and sidecar to exact placement manifest and source ROS hashes", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "runewaker-native-collision-"));
t.after(() => rm(root, { recursive: true, force: true }));
const sourceRoot = path.join(root, "source");
const inventoryRoot = path.join(root, "inventory");
const sourceFile = path.join(sourceRoot, "model", "test", "room.ros");
const reportFile = path.join(inventoryRoot, "room.json");
const objFile = `${reportFile}.obj`;
const flagsFile = `${reportFile}.flags.bin`;
await mkdir(path.dirname(sourceFile), { recursive: true });
await mkdir(inventoryRoot, { recursive: true });
await writeFile(sourceFile, Buffer.from("native ROS bytes"));
const report = {
schemaVersion: 2,
sourceModel: "model\\test\\room.ros",
collisionMode: "complete",
nativeCollisionTriangleCount: 1,
completeNativeCollisionTriangleCount: 1,
enabledNativeCollisionTriangleCount: 1,
disabledSubtreeNativeTriangles: 0,
disabledCollisionOptionEntityCount: 0,
renderMeshFallbackTriangleCount: 3,
};
await writeFile(reportFile, JSON.stringify(report));
await writeFile(objFile, "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n");
await writeFile(flagsFile, Buffer.from([0, 0, 16, 0]));
const sourceResourceSha256 = await sha256File(sourceFile);
const nativeExtractor = { executableSha256: "c".repeat(64), sourceSha256: "d".repeat(64), collisionMode: "complete" };
const created = await writeNativeCollisionInventoryManifest({
inventoryRoot,
placementManifestSha256: "a".repeat(64),
sourceWdbSha256: "b".repeat(64),
collisionMode: "complete",
nativeExtractor,
sourceResourceRoot: sourceRoot,
resources: [{
resource: "model\\test\\room.ros",
sourceResourceSha256,
report,
reportFile,
objFile,
flagsFile,
}],
});
assert.match(created.sha256, /^[a-f\d]{64}$/);
const verified = await readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256: "a".repeat(64),
sourceWdbSha256: "b".repeat(64),
collisionMode: "complete",
nativeExtractor,
expectedResources: ["model/test/room.ros"],
sourceResourceRoot: sourceRoot,
});
assert.equal(verified.resources.size, 1);
assert.equal(verified.inventory.collisionMode, "complete");
await assert.rejects(readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256: "a".repeat(64),
sourceWdbSha256: "b".repeat(64),
collisionMode: "respect-options",
nativeExtractor: { ...nativeExtractor, collisionMode: "respect-options" },
expectedResources: ["model/test/room.ros"],
sourceResourceRoot: sourceRoot,
}), /mode complete does not match requested mode respect-options/);
await assert.rejects(readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256: "a".repeat(64),
sourceWdbSha256: "b".repeat(64),
collisionMode: "complete",
nativeExtractor: { ...nativeExtractor, executableSha256: "e".repeat(64) },
expectedResources: ["model/test/room.ros"],
sourceResourceRoot: sourceRoot,
}), /executableSha256 does not match/);
await writeFile(sourceFile, Buffer.from("changed ROS bytes"));
await assert.rejects(readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256: "a".repeat(64),
sourceWdbSha256: "b".repeat(64),
collisionMode: "complete",
nativeExtractor,
expectedResources: ["model/test/room.ros"],
sourceResourceRoot: sourceRoot,
}), /Source ROS SHA-256 mismatch/);
const indexed = JSON.parse(await readFile(created.file, "utf8"));
assert.equal(indexed.resources[0].renderMeshFallbackTriangleCount, 3);
});
@@ -0,0 +1,108 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildNativeCollisionAuditArguments,
validateNativeCoreCandidateProvenance,
} from "./lib/native-core-candidate-provenance.mjs";
const hash = (letter) => letter.repeat(64);
function fixture() {
const placement = {
guid: "A-B-C",
resource: "model\\dungeon\\sample.ros",
translation: [1, 2, 3],
rotation: [0, 0, 0, 1],
scale: [1, 1, 1],
};
const recipe = {
slug: "sample-dungeon",
source: { model: "model/dungeon/sample.ros", wdb: "Resource/wdb/dungeon/sample.wdb" },
};
const sourceModelSha256 = hash("a");
const sourceWdbSha256 = hash("b");
const exportReportSha256 = hash("c");
return {
slug: recipe.slug,
recipe,
recipeSha256: hash("d"),
primaryPlacement: placement,
importMetadata: {
dungeonId: recipe.slug,
source: {
model: recipe.source.model,
modelSha256: sourceModelSha256,
wdb: recipe.source.wdb,
wdbSha256: sourceWdbSha256,
},
},
actualSourceModelSha256: sourceModelSha256,
actualSourceWdbSha256: sourceWdbSha256,
wdbReport: { descriptors: [{
...placement,
resource: "model/dungeon/sample.ros",
translation: [...placement.translation],
rotation: [...placement.rotation],
scale: [...placement.scale],
}] },
wdbReportSha256: hash("e"),
exportReport: { sourceModel: "model/dungeon/sample.ros" },
exportReportSha256,
conversionReportSha256: hash("f"),
nativeCollisionReport: {
sourceModel: "model/dungeon/sample.ros",
collisionMode: "respect-options",
queryMasks: { ignoreEntityCollisionOption: false },
nativeCollisionTriangleCount: 12,
enabledNativeCollisionTriangleCount: 12,
renderMeshFallbackTriangleCount: 0,
},
nativeCollisionReportSha256: hash("1"),
nativeCollisionObjSha256: hash("2"),
nativeCollisionFlagsSha256: hash("3"),
};
}
test("native-core provenance accepts one exact ROS/WDB/export/query source chain", () => {
const provenance = validateNativeCoreCandidateProvenance(fixture());
assert.equal(provenance.nativeEnabledTriangleCount, 12);
assert.equal(provenance.primaryPlacementGuid, "A-B-C");
assert.equal(provenance.recipeSha256, hash("d"));
});
test("native-core provenance rejects changed source cache hashes", () => {
const input = fixture();
input.actualSourceModelSha256 = hash("9");
assert.throws(() => validateNativeCoreCandidateProvenance(input), /Imported source ROS hash/);
});
test("native-core provenance rejects a different WDB primary descriptor", () => {
const input = fixture();
input.wdbReport.descriptors[0].translation[0] += 0.01;
assert.throws(() => validateNativeCoreCandidateProvenance(input), /primary WDB descriptor/);
});
test("native-core provenance rejects option-blind or render-fallback collision reports", () => {
for (const update of [
(report) => { report.queryMasks.ignoreEntityCollisionOption = true; },
(report) => { report.renderMeshFallbackTriangleCount = 1; },
(report) => { report.enabledNativeCollisionTriangleCount -= 1; },
]) {
const input = fixture();
update(input.nativeCollisionReport);
assert.throws(() => validateNativeCoreCandidateProvenance(input));
}
});
test("native collision extractor arguments use JSON-safe forward-slash paths", () => {
assert.deepEqual(buildNativeCollisionAuditArguments({
resourceRoot: "F:\\Runewaker\\Resource",
sourceModel: "model\\dungeon\\sample.ros",
reportPath: "F:\\work\\query-report.json",
}), [
"F:/Runewaker/Resource",
"model/dungeon/sample.ros",
"F:/work/query-report.json",
"respect-options",
]);
});
@@ -0,0 +1,385 @@
#include <cstdint>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <string>
#include <vector>
#include "Rune Engine/Rune/Rune.h"
#include "Rune Engine/Rune/Rune Engine NULL.h"
#include "Rune Engine/Rune/Scene/Base/RuEntityBase.h"
#include "Rune Engine/Rune/Collision/RuType_Collision.h"
#include "Rune Engine/Rune/Engine/Geometry/RuMeshBase.h"
namespace
{
struct EntitySummary
{
CRuEntity *entity = NULL;
std::string name;
std::string type;
bool hasCollisionObject = false;
std::string collisionType;
bool collisionOptionEnabled = false;
bool collisionOptionPathReachable = false;
int meshCount = 0;
int meshCollisionStructureCount = 0;
int serializedCollisionTriangleCount = 0;
int completeSerializedCollisionTriangleCount = 0;
std::vector<std::string> meshCollisionTypes;
};
void Visit(CRuEntity *entity, bool parentCollisionPathReachable, std::vector<EntitySummary> &entities)
{
if(!entity)
return;
entity->UpdateTransformation();
EntitySummary summary;
summary.entity = entity;
summary.name = entity->GetObjectName() ? entity->GetObjectName() : "";
summary.type = entity->GetType().GetTypeName();
summary.collisionOptionEnabled = entity->GetOption(ruENTITYOPTION_COLLISION) != 0;
summary.collisionOptionPathReachable = parentCollisionPathReachable && summary.collisionOptionEnabled;
if(entity->GetCollisionObject())
{
summary.hasCollisionObject = true;
summary.collisionType = entity->GetCollisionObject()->GetType().GetTypeName();
}
if(entity->GetType().IsSubClassOf(IRuEntity_Renderable::Type()))
{
IRuEntity_Renderable *renderable = static_cast<IRuEntity_Renderable *>(entity);
summary.meshCount = renderable->GetNumMeshes();
for(INT32 i = 0; i < summary.meshCount; ++i)
{
IRuMesh *mesh = NULL;
IRuMaterial *material = NULL;
if(renderable->GetMesh(i, &mesh, &material) && mesh && mesh->GetType().IsSubClassOf(CRuMesh::Type()))
{
IRuCollisionStructure *structure = static_cast<CRuMesh *>(mesh)->GetCollisionStructure();
if(structure)
{
++summary.meshCollisionStructureCount;
summary.meshCollisionTypes.push_back(structure->GetType().GetTypeName());
}
else
{
summary.meshCollisionTypes.push_back("none(serialized); QueryCollision would build default from render mesh");
}
}
else
{
summary.meshCollisionTypes.push_back("unsupported mesh class");
}
}
}
entities.push_back(summary);
CRuEntity *child = entity->GetFirstChild();
while(child)
{
Visit(child, summary.collisionOptionPathReachable, entities);
child = child->GetNextSibling();
}
}
void QueryObject(CRuEntity *entity, IRuCollisionObject *object, CRuCollisionQuery_OBB_TriEnum &query)
{
CRuMatrix4x4 *originalWorldTransform = query.m_worldTransform;
CRuMatrix4x4 *originalInvWorldTransform = query.m_invWorldTransform;
query.m_worldTransform = const_cast<CRuMatrix4x4 *>(&entity->GetWorldTransform());
query.m_invWorldTransform = const_cast<CRuMatrix4x4 *>(&entity->GetInverseWorldTransform());
object->QueryCollision(&query);
query.m_worldTransform = originalWorldTransform;
query.m_invWorldTransform = originalInvWorldTransform;
}
void QuerySerializedDirectOnly(CRuEntity *entity, CRuCollisionQuery_OBB_TriEnum &query)
{
if(!entity)
return;
entity->UpdateTransformation();
if(entity->GetCollisionObject())
{
QueryObject(entity, entity->GetCollisionObject(), query);
return;
}
if(entity->GetType().IsSubClassOf(CRuModel::Type()))
{
CRuModel *model = static_cast<CRuModel *>(entity);
for(INT32 i = 0; i < model->GetNumMeshes(); ++i)
{
IRuMesh *mesh = NULL;
IRuMaterial *material = NULL;
if(model->GetMesh(i, &mesh, &material) && mesh && mesh->GetType().IsSubClassOf(CRuMesh::Type()))
{
IRuCollisionStructure *structure = static_cast<CRuMesh *>(mesh)->GetCollisionStructure();
if(structure)
{
CRuMatrix4x4 *originalWorldTransform = query.m_worldTransform;
CRuMatrix4x4 *originalInvWorldTransform = query.m_invWorldTransform;
query.m_worldTransform = const_cast<CRuMatrix4x4 *>(&entity->GetWorldTransform());
query.m_invWorldTransform = const_cast<CRuMatrix4x4 *>(&entity->GetInverseWorldTransform());
structure->QueryCollision(&query);
query.m_worldTransform = originalWorldTransform;
query.m_invWorldTransform = originalInvWorldTransform;
}
}
}
return;
}
}
void QuerySerializedOnly(CRuEntity *entity, CRuCollisionQuery_OBB_TriEnum &query, bool respectCollisionOptions)
{
if(!entity)
return;
entity->UpdateTransformation();
if(respectCollisionOptions && entity->GetOption(ruENTITYOPTION_COLLISION) == 0)
return;
QuerySerializedDirectOnly(entity, query);
if(entity->GetCollisionObject() || entity->GetType().IsSubClassOf(CRuModel::Type()))
return;
CRuEntity *child = entity->GetFirstChild();
while(child)
{
QuerySerializedOnly(child, query, respectCollisionOptions);
child = child->GetNextSibling();
}
}
bool WriteReport(const std::string &outputPath, const std::string &resourcePath,
const std::string &collisionMode, CRuEntity *root, std::vector<EntitySummary> &entities)
{
const bool respectCollisionOptions = collisionMode == "respect-options";
// A broad source-local box asks the native collision API to enumerate all
// source collision triangles. The selected query also invokes the actual
// engine hierarchy query with the requested entity-option semantics.
CRuCollisionQuery_OBB_TriEnum query;
query.m_ignoreCollisionFlag = respectCollisionOptions ? FALSE : TRUE;
query.m_exclusionMask = 0x00000000;
query.m_inclusionMask = 0x00000000;
query.m_colOBB = CRuOBB(CRuAABB(
CRuVector3(-10000.0f, -10000.0f, -10000.0f),
CRuVector3(10000.0f, 10000.0f, 10000.0f)));
CRuCollisionQuery_OBB_TriEnum serializedQuery;
serializedQuery.m_ignoreCollisionFlag = respectCollisionOptions ? FALSE : TRUE;
serializedQuery.m_colOBB = query.m_colOBB;
QuerySerializedOnly(root, serializedQuery, respectCollisionOptions);
CRuCollisionQuery_OBB_TriEnum completeSerializedQuery;
completeSerializedQuery.m_ignoreCollisionFlag = TRUE;
completeSerializedQuery.m_colOBB = query.m_colOBB;
if(respectCollisionOptions)
QuerySerializedOnly(root, completeSerializedQuery, false);
CRuCollisionQuery_OBB_TriEnum effectiveSerializedQuery;
effectiveSerializedQuery.m_ignoreCollisionFlag = FALSE;
effectiveSerializedQuery.m_colOBB = query.m_colOBB;
if(!respectCollisionOptions)
QuerySerializedOnly(root, effectiveSerializedQuery, true);
root->QueryCollision(&query);
std::ofstream out(outputPath.c_str(), std::ios::out | std::ios::trunc);
if(!out)
return false;
const INT32 vertexCount = query.m_colTriVerts->Count();
const INT32 serializedVertexCount = serializedQuery.m_colTriVerts->Count();
const INT32 completeSerializedVertexCount = respectCollisionOptions ? completeSerializedQuery.m_colTriVerts->Count() : serializedVertexCount;
const INT32 effectiveSerializedVertexCount = respectCollisionOptions ? serializedVertexCount : effectiveSerializedQuery.m_colTriVerts->Count();
for(auto &entity : entities)
{
if(entity.entity)
{
CRuCollisionQuery_OBB_TriEnum perEntityQuery;
perEntityQuery.m_ignoreCollisionFlag = TRUE;
perEntityQuery.m_colOBB = query.m_colOBB;
QuerySerializedDirectOnly(entity.entity, perEntityQuery);
entity.completeSerializedCollisionTriangleCount = perEntityQuery.m_colTriVerts->Count() / 3;
if(entity.collisionOptionPathReachable)
{
CRuCollisionQuery_OBB_TriEnum effectivePerEntityQuery;
effectivePerEntityQuery.m_ignoreCollisionFlag = FALSE;
effectivePerEntityQuery.m_colOBB = query.m_colOBB;
QuerySerializedDirectOnly(entity.entity, effectivePerEntityQuery);
entity.serializedCollisionTriangleCount = effectivePerEntityQuery.m_colTriVerts->Count() / 3;
}
}
}
CRuVector3 minima(0.0f, 0.0f, 0.0f);
CRuVector3 maxima(0.0f, 0.0f, 0.0f);
if(serializedVertexCount > 0)
{
minima = maxima = (*serializedQuery.m_colTriVerts)[0];
for(INT32 i = 1; i < serializedVertexCount; ++i)
{
const CRuVector3 &v = (*serializedQuery.m_colTriVerts)[i];
minima.x = min(minima.x, v.x); minima.y = min(minima.y, v.y); minima.z = min(minima.z, v.z);
maxima.x = max(maxima.x, v.x); maxima.y = max(maxima.y, v.y); maxima.z = max(maxima.z, v.z);
}
}
out << std::setprecision(9);
out << "{\n \"schemaVersion\": 2,\n";
out << " \"sourceModel\": \"" << resourcePath << "\",\n";
out << " \"collisionMode\": \"" << collisionMode << "\",\n";
out << " \"query\": \"CRuEntity::QueryCollision(CRuCollisionQuery_OBB_TriEnum), source-local OBB +/-10000, "
<< (respectCollisionOptions ? "ignoreCollisionFlag=false; ruENTITYOPTION_COLLISION gates respected" : "ignoreCollisionFlag=true; complete collision-option audit") << "\",\n";
out << " \"geometryOutput\": \"" << outputPath << ".obj\",\n";
out << " \"triangleFlagOutput\": \"" << outputPath << ".flags.bin\",\n";
out << " \"queryMasks\": {\"exclusionMask\": 0, \"inclusionMask\": 0, \"ignoreEntityCollisionOption\": "
<< (respectCollisionOptions ? "false" : "true") << "},\n";
out << " \"pathCollisionPolicy\": {\"exclusionMask\": 10485760, \"excludedBits\": {\"water\": 2097152, \"foliage\": 8388608}},\n";
out << " \"entityCount\": " << entities.size() << ",\n";
out << " \"entitiesWithCollisionObject\": ";
int colliderCount = 0;
for(const auto &entity : entities)
colliderCount += entity.hasCollisionObject ? 1 : 0;
out << colliderCount << ",\n";
out << " \"engineQueryTriangleCountIncludingRenderFallbacks\": " << vertexCount / 3 << ",\n";
out << " \"nativeCollisionTriangleCount\": " << serializedVertexCount / 3 << ",\n";
out << " \"nativeCollisionVertexCount\": " << serializedVertexCount << ",\n";
out << " \"completeNativeCollisionTriangleCount\": " << completeSerializedVertexCount / 3 << ",\n";
out << " \"enabledNativeCollisionTriangleCount\": " << effectiveSerializedVertexCount / 3 << ",\n";
out << " \"disabledSubtreeNativeTriangles\": " << (completeSerializedVertexCount - effectiveSerializedVertexCount) / 3 << ",\n";
out << " \"nativeTrianglesOmittedByCollisionOptions\": " << (completeSerializedVertexCount - effectiveSerializedVertexCount) / 3 << ",\n";
out << " \"renderMeshFallbackTriangleCount\": " << (vertexCount - serializedVertexCount) / 3 << ",\n";
std::map<UINT32, INT32> triangleFlagCounts;
for(INT32 i = 0; i < serializedQuery.m_colTriFlags->Count(); ++i)
++triangleFlagCounts[(*serializedQuery.m_colTriFlags)[i]];
out << " \"nativeTriangleFlagCounts\": {";
bool firstFlag = true;
for(const auto &entry : triangleFlagCounts)
{
if(!firstFlag)
out << ", ";
firstFlag = false;
out << "\"" << entry.first << "\": " << entry.second;
}
out << "},\n";
INT32 pathEligibleTriangleCount = 0;
for(INT32 i = 0; i < serializedQuery.m_colTriFlags->Count(); ++i)
{
if(((*serializedQuery.m_colTriFlags)[i] & 0x00A00000) == 0)
++pathEligibleTriangleCount;
}
out << " \"nativeTrianglesAfterTrekkerWaterFoliageExclusion\": " << pathEligibleTriangleCount << ",\n";
INT32 disabledOptionCount = 0;
INT32 unreachableEntityCount = 0;
for(const auto &entity : entities)
{
disabledOptionCount += entity.collisionOptionEnabled ? 0 : 1;
unreachableEntityCount += entity.collisionOptionPathReachable ? 0 : 1;
}
out << " \"disabledCollisionOptionEntityCount\": " << disabledOptionCount << ",\n";
out << " \"disabledEntityCount\": " << disabledOptionCount << ",\n";
out << " \"entityNodesSkippedByCollisionOptionGates\": " << unreachableEntityCount << ",\n";
out << " \"collisionOptionUnreachableEntityCount\": " << unreachableEntityCount << ",\n";
out << " \"nativeCollisionBounds\": {\"min\": [" << minima.x << ", " << minima.y << ", " << minima.z
<< "], \"max\": [" << maxima.x << ", " << maxima.y << ", " << maxima.z << "]},\n";
out << " \"colliderEntities\": [\n";
bool first = true;
for(const auto &entity : entities)
{
if(!entity.hasCollisionObject)
continue;
if(!first)
out << ",\n";
first = false;
out << " {\"name\": \"" << entity.name << "\", \"entityType\": \"" << entity.type
<< "\", \"collisionType\": \"" << entity.collisionType << "\", \"renderMeshCount\": "
<< entity.meshCount << "}";
}
out << "\n ],\n \"entityNodes\": [\n";
for(std::size_t i = 0; i < entities.size(); ++i)
{
const auto &entity = entities[i];
if(i)
out << ",\n";
out << " {\"name\": \"" << entity.name << "\", \"type\": \"" << entity.type
<< "\", \"renderMeshCount\": " << entity.meshCount << ", \"hasCollisionObject\": "
<< (entity.hasCollisionObject ? "true" : "false") << ", \"meshCollisionStructures\": " << entity.meshCollisionStructureCount
<< ", \"collisionOptionEnabled\": " << (entity.collisionOptionEnabled ? "true" : "false")
<< ", \"collisionOptionPathReachable\": " << (entity.collisionOptionPathReachable ? "true" : "false")
<< ", \"serializedCollisionTriangles\": " << entity.serializedCollisionTriangleCount
<< ", \"completeSerializedCollisionTriangles\": " << entity.completeSerializedCollisionTriangleCount
<< ", \"meshCollisionTypes\": [";
for(std::size_t meshIndex = 0; meshIndex < entity.meshCollisionTypes.size(); ++meshIndex)
{
if(meshIndex)
out << ", ";
out << "\"" << entity.meshCollisionTypes[meshIndex] << "\"";
}
out << "]}";
}
out << "\n ]\n}\n";
out.close();
// The optional raw OBJ is diagnostic evidence only. Its positions are the
// native query's model-local, Runewaker left-handed Y-up coordinates.
std::ofstream obj((outputPath + ".obj").c_str(), std::ios::out | std::ios::trunc);
if(!obj)
return false;
obj << "# Native Runewaker CRuCollisionQuery_OBB_TriEnum output; model-local LH Y-up\n";
obj << std::setprecision(9);
for(INT32 i = 0; i < serializedVertexCount; ++i)
{
const CRuVector3 &v = (*serializedQuery.m_colTriVerts)[i];
obj << "v " << v.x << " " << v.y << " " << v.z << "\n";
}
for(INT32 i = 0; i + 2 < serializedVertexCount; i += 3)
obj << "f " << (i + 1) << " " << (i + 2) << " " << (i + 3) << "\n";
std::ofstream flags((outputPath + ".flags.bin").c_str(), std::ios::out | std::ios::binary | std::ios::trunc);
if(!flags)
return false;
for(INT32 i = 0; i < serializedQuery.m_colTriFlags->Count(); ++i)
{
const UINT32 flag = static_cast<UINT32>((*serializedQuery.m_colTriFlags)[i]);
flags.write(reinterpret_cast<const char *>(&flag), sizeof(flag));
}
if(!flags)
return false;
return static_cast<bool>(obj);
}
}
int main(int argc, char **argv)
{
if(argc != 4 && argc != 5)
{
std::cerr << "Usage: native-collision-audit <resource-root> <model-resource-path> <output-json> [complete|respect-options]\n";
return 2;
}
const std::string collisionMode = argc == 5 ? argv[4] : "complete";
if(collisionMode != "complete" && collisionMode != "respect-options")
{
std::cerr << "Collision mode must be 'complete' or 'respect-options'.\n";
return 2;
}
if(RuInitialize_Core() == FALSE || RuInitialize_NULL() == FALSE)
{
std::cerr << "Rune initialization failed.\n";
RuShutdown();
return 3;
}
g_ruResourceManager->SetRootDirectory(argv[1]);
CRuEntity *root = g_ruResourceManager->LoadEntity(argv[2]);
if(!root)
{
std::cerr << "Unable to load ROS entity: " << argv[2] << "\n";
RuShutdown();
return 4;
}
std::vector<EntitySummary> entities;
Visit(root, true, entities);
const bool written = WriteReport(argv[3], argv[2], collisionMode, root, entities);
ruSAFE_RELEASE(root);
RuShutdown();
if(!written)
{
std::cerr << "Unable to write native collision report.\n";
return 5;
}
std::cout << "Wrote native collision query report for " << argv[2] << "\n";
return 0;
}
@@ -60,9 +60,14 @@ namespace
{
std::string name;
std::string shaderName;
std::string shaderObjectName;
std::string sourceTexture;
std::string resolvedTexture;
std::string stagedTexture;
INT32 materialType = -1;
INT32 lightingType = -1;
std::vector<std::string> textureChannels;
std::vector<std::string> alphaParameters;
bool textureCopied = false;
};
@@ -284,6 +289,98 @@ namespace
return path;
}
const char *MaterialTypeName(INT32 value)
{
switch(value)
{
case ruMATERIAL_TYPE_STANDARD: return "standard";
case ruMATERIAL_TYPE_TRANSLUCENT: return "translucent-deprecated";
case ruMATERIAL_TYPE_FX: return "fx-deprecated";
default: return "unknown";
}
}
const char *LightingTypeName(INT32 value)
{
switch(value)
{
case ruMATERIAL_LIGHTINGTYPE_STANDARD: return "standard";
case ruMATERIAL_LIGHTINGTYPE_PRELIT: return "prelit";
case ruMATERIAL_LIGHTINGTYPE_RESERVED: return "reserved-self-illuminated";
case ruMATERIAL_LIGHTINGTYPE_COLORED: return "colored";
default: return "unknown";
}
}
const char *TextureChannelName(INT32 value)
{
switch(value)
{
case ruTEXCHANNEL_DIFFUSEMAP: return "diffuse";
case ruTEXCHANNEL_DETAILMAP: return "detail";
case ruTEXCHANNEL_LIGHTMAP: return "lightmap";
case ruTEXCHANNEL_DIFFUSECUBEMAP: return "diffuse-cubemap";
case ruTEXCHANNEL_1DATTENUATIONMAP: return "attenuation-1d";
case ruTEXCHANNEL_2DATTENUATIONMAP: return "attenuation-2d";
case ruTEXCHANNEL_3DATTENUATIONMAP: return "attenuation-3d";
case ruTEXCHANNEL_ENVIRONMENTMAP: return "environment";
case ruTEXCHANNEL_REFLECTIONMAP: return "reflection";
case ruTEXCHANNEL_REFRACTIONMAP: return "refraction";
case ruTEXCHANNEL_CAUSTICMAP: return "caustic";
case ruTEXCHANNEL_BUMPMAP: return "bump";
case ruTEXCHANNEL_BUMPMAP2: return "bump-2";
case ruTEXCHANNEL_NORMALMAP: return "normal";
case ruTEXCHANNEL_NORMALMAP2: return "normal-2";
case ruTEXCHANNEL_DISPLACEMENTMAP: return "displacement";
case ruTEXCHANNEL_NORMALIZATIONCUBEMAP: return "normalization-cubemap";
case ruTEXCHANNEL_SHADOWMAP: return "shadow";
case ruTEXCHANNEL_SHADOWDEPTHMAP: return "shadow-depth";
case ruTEXCHANNEL_SHADOWOVERLAY: return "shadow-overlay";
case ruTEXCHANNEL_GLOSSMAP: return "gloss";
case ruTEXCHANNEL_TOONGRADIENTMAP: return "toon-gradient";
case ruTEXCHANNEL_DISTORTMAP: return "distortion";
case ruTEXCHANNEL_BACKBUFFER: return "backbuffer";
case ruTEXCHANNEL_RTLIGHTMAP: return "rt-lightmap";
case ruTEXCHANNEL_GLOWMAP: return "glow";
default: return "unknown";
}
}
const char *AlphaParameterName(INT32 value)
{
switch(value)
{
case ruPARAMTYPE_MATERIAL_ALPHATESTMODE: return "alphaTestMode";
case ruPARAMTYPE_MATERIAL_ALPHATESTVALUE: return "alphaTestValue";
case ruPARAMTYPE_MATERIAL_ALPHAMODE: return "alphaMode";
case ruPARAMTYPE_MATERIAL_TRANSLUCENT: return "translucent";
case ruPARAMTYPE_MATERIAL_ALPHA: return "alpha";
case ruPARAMTYPE_MATERIAL_ALPHAMODIFIER: return "alphaModifier";
case ruPARAMTYPE_MATERIAL_SMOOTHBLEND: return "smoothBlend";
case ruPARAMTYPE_MATERIAL_COLORWRITEENABLE: return "colorWriteEnable";
default: return NULL;
}
}
std::string AlphaParameterValue(INT32 type, UINT64 raw)
{
std::ostringstream out;
if(type == ruPARAMTYPE_MATERIAL_ALPHA || type == ruPARAMTYPE_MATERIAL_ALPHAMODIFIER)
{
out << std::setprecision(9) << RuCastINT64ToFloat(raw);
}
else if(type == ruPARAMTYPE_MATERIAL_TRANSLUCENT || type == ruPARAMTYPE_MATERIAL_SMOOTHBLEND
|| type == ruPARAMTYPE_MATERIAL_COLORWRITEENABLE)
{
out << (raw ? "true" : "false");
}
else
{
out << raw;
}
return out.str();
}
bool FileExists(const std::string &path)
{
const DWORD attributes = GetFileAttributesA(path.c_str());
@@ -621,9 +718,54 @@ namespace
if(material)
{
material->BindTextures(TRUE);
const char *shaderName = material->GetShaderName();
if(shaderName)
info.shaderName = shaderName;
IRuShader *shader = material->GetShader();
if(shader && shader->GetShaderName())
info.shaderObjectName = shader->GetShaderName();
info.materialType = static_cast<INT32>(material->GetMaterialType());
info.lightingType = static_cast<INT32>(material->GetLightingType());
for(INT32 channelIndex = 0; channelIndex < material->GetNumTextureChannels(); ++channelIndex)
{
const RuTextureChannel channel = material->GetTextureChannel(channelIndex);
IRuBaseTexture *channelTexture = material->GetTexture(channel);
std::ostringstream channelRow;
channelRow << "{\"id\":" << static_cast<INT32>(channel)
<< ",\"name\":\"" << TextureChannelName(static_cast<INT32>(channel))
<< "\",\"texture\":\""
<< JsonEscape(channelTexture && channelTexture->GetTextureName() ? channelTexture->GetTextureName() : "")
<< "\"}";
info.textureChannels.push_back(channelRow.str());
}
CRuParameterBlock *parameters = material->GetParameterBlock();
const INT32 alphaParameterTypes[] = {
ruPARAMTYPE_MATERIAL_ALPHATESTMODE,
ruPARAMTYPE_MATERIAL_ALPHATESTVALUE,
ruPARAMTYPE_MATERIAL_ALPHAMODE,
ruPARAMTYPE_MATERIAL_TRANSLUCENT,
ruPARAMTYPE_MATERIAL_ALPHA,
ruPARAMTYPE_MATERIAL_ALPHAMODIFIER,
ruPARAMTYPE_MATERIAL_SMOOTHBLEND,
ruPARAMTYPE_MATERIAL_COLORWRITEENABLE
};
for(std::size_t parameterIndex = 0;
parameterIndex < sizeof(alphaParameterTypes) / sizeof(alphaParameterTypes[0]);
++parameterIndex)
{
const INT32 parameterType = alphaParameterTypes[parameterIndex];
UINT64 rawValue = 0;
if(parameters && parameters->GetParameter(static_cast<RuParameterType>(parameterType), rawValue))
{
std::ostringstream parameterRow;
parameterRow << "{\"id\":" << parameterType
<< ",\"name\":\"" << AlphaParameterName(parameterType)
<< "\",\"value\":" << AlphaParameterValue(parameterType, rawValue)
<< ",\"rawHex\":\"0x" << std::hex << rawValue << std::dec << "\"}";
info.alphaParameters.push_back(parameterRow.str());
}
}
info.sourceTexture = NormalizeResourcePath(FindMaterialTexture(material));
}
@@ -923,6 +1065,25 @@ namespace
report << " {\n";
report << " \"name\": \"" << JsonEscape(info.name) << "\",\n";
report << " \"shader\": \"" << JsonEscape(info.shaderName) << "\",\n";
report << " \"shaderObject\": \"" << JsonEscape(info.shaderObjectName) << "\",\n";
report << " \"materialType\": {\"value\":" << info.materialType
<< ",\"name\":\"" << MaterialTypeName(info.materialType) << "\"},\n";
report << " \"lightingType\": {\"value\":" << info.lightingType
<< ",\"name\":\"" << LightingTypeName(info.lightingType) << "\"},\n";
report << " \"sourceTextureChannels\": [";
for(std::size_t channelIndex = 0; channelIndex < info.textureChannels.size(); ++channelIndex)
{
if(channelIndex > 0) report << ",";
report << info.textureChannels[channelIndex];
}
report << "],\n";
report << " \"alphaBlendParameters\": [";
for(std::size_t parameterIndex = 0; parameterIndex < info.alphaParameters.size(); ++parameterIndex)
{
if(parameterIndex > 0) report << ",";
report << info.alphaParameters[parameterIndex];
}
report << "],\n";
report << " \"sourceTexture\": \"" << JsonEscape(info.sourceTexture) << "\",\n";
report << " \"resolvedTexture\": \"" << JsonEscape(info.resolvedTexture) << "\",\n";
report << " \"stagedTexture\": \"" << JsonEscape(info.stagedTexture) << "\",\n";
@@ -0,0 +1,286 @@
#include <windows.h>
#include <cstdint>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <string>
#include "Rune Engine/Rune/Rune.h"
#include "Rune Engine/Rune/Rune Engine NULL.h"
#include "Rune Engine/Rune/Core/DataStore/RuDataStore_Disk.h"
#include "Rune Engine/Rune/Helper/RuHelper_Math.h"
#include "Rune Engine/Rune/Scene/Terrain/RuWorld_EntitySystem.h"
#include "Rune Engine/Rune/Scene/Terrain/RuWorld_ZoneDescriptorContainer.h"
namespace
{
std::string JsonEscape(const char *value)
{
std::string out;
for(const char *p = value ? value : ""; *p; ++p)
{
if(*p == '\\' || *p == '"')
{
out += '\\';
out += *p;
}
else if(*p == '\n') out += "\\n";
else if(*p == '\r') out += "\\r";
else if(*p == '\t') out += "\\t";
else out += *p;
}
return out;
}
void WriteColor(std::ostream &out, DWORD color)
{
out << "{\"argb\":\"0x" << std::uppercase << std::hex << std::setw(8) << std::setfill('0') << color
<< std::dec << std::setfill(' ') << "\",\"rgba\":["
<< ((color >> 16) & 0xFF) / 255.0 << ','
<< ((color >> 8) & 0xFF) / 255.0 << ','
<< (color & 0xFF) / 255.0 << ','
<< ((color >> 24) & 0xFF) / 255.0 << "]}";
}
void WriteVector(std::ostream &out, const CRuVector3 &vector)
{
out << '[' << vector.x << ',' << vector.y << ',' << vector.z << ']';
}
}
int main(int argc, char **argv)
{
if(argc != 3 && argc != 6)
{
std::cerr << "Usage: runewaker_zone_lighting_exporter <wdb-path> <output-json> [probe-x probe-y probe-z]\n";
return 2;
}
if(RuInitialize_Core() == FALSE || RuInitialize_NULL() == FALSE)
{
std::cerr << "Rune engine initialization failed.\n";
return 3;
}
CRuDataStore_Disk *dataStore = ruNEW CRuDataStore_Disk();
if(dataStore->Open(argv[1], TRUE) == FALSE)
{
std::cerr << "Unable to open WDB: " << argv[1] << "\n";
ruSAFE_RELEASE(dataStore);
RuShutdown();
return 4;
}
CRuWorld_ZoneDescriptorContainer *container = ruNEW CRuWorld_ZoneDescriptorContainer();
if(container->Attach(dataStore) == FALSE)
{
std::cerr << "Unable to attach world-zone descriptor table.\n";
ruSAFE_RELEASE(container);
ruSAFE_RELEASE(dataStore);
RuShutdown();
return 5;
}
CRuWorld_ObjectContainer *objectContainer = ruNEW CRuWorld_ObjectContainer(
CRuAABB(CRuVector3(-100000.0f, -100000.0f, -100000.0f), CRuVector3(100000.0f, 100000.0f, 100000.0f)),
1000.0f);
if(objectContainer->Attach(dataStore) == FALSE)
{
std::cerr << "Unable to attach world object table.\n";
ruSAFE_RELEASE(objectContainer);
ruSAFE_RELEASE(container);
ruSAFE_RELEASE(dataStore);
RuShutdown();
return 7;
}
CRuArrayList<IRuObject *> triggers;
objectContainer->EnumerateAllObjectsByType(CRuWorld_Trigger::Type(), triggers);
CRuWorld_Trigger *entryZoneTrigger = NULL;
INT32 entryZoneTriggerIndex = -1;
INT32 entryZonePriority = 0xFFFFFFFF;
REAL entryZoneDistance = FLT_MAX;
CRuVector3 entryProbe;
const BOOL hasEntryProbe = argc == 6;
if(hasEntryProbe)
{
entryProbe.Set(static_cast<REAL>(atof(argv[3])), static_cast<REAL>(atof(argv[4])), static_cast<REAL>(atof(argv[5])));
CRuCollisionQuery_Segment_Nearest query;
CRuMatrix4x4 identityMatrix;
query.m_worldTransform = &identityMatrix;
query.m_invWorldTransform = &identityMatrix;
query.m_ignoreCollisionFlag = TRUE;
CRuSegment3 segments[2] = {
CRuSegment3(entryProbe, entryProbe + CRuVector3(0.0f, -10000.0f, 0.0f)),
CRuSegment3(entryProbe, entryProbe + CRuVector3(0.0f, 10000.0f, 0.0f))
};
for(INT32 ti = 0; ti < triggers.Count(); ++ti)
{
CRuWorld_Trigger *trigger = static_cast<CRuWorld_Trigger *>(triggers[ti]);
if(trigger->GetTriggerType() != RuWorld::ruTRIGGERTYPE_ZONE) continue;
RuPropertyType propertyValue;
const INT32 priority = trigger->GetProperty("CRuWorld_Trigger:Priority", propertyValue)
? boost::get<INT32>(propertyValue) : 0;
for(INT32 si = 0; si < 2; ++si)
{
query.m_positiveHit = FALSE;
query.m_collisionT = FLT_MAX;
query.m_colSegment = segments[si];
trigger->QueryCollision(&query);
if(!query.m_positiveHit) continue;
const REAL distance = query.m_collisionT;
BOOL prefer = priority > entryZonePriority
|| (priority == entryZonePriority && distance < entryZoneDistance);
if(!prefer && priority == entryZonePriority && distance == entryZoneDistance && entryZoneTrigger)
{
INT32 parentID = trigger->GetZoneID();
CRuWorld_ZoneDescriptor *parent = container->GetZoneDescriptor(parentID);
while(parent)
{
if(parent->GetZoneID() == entryZoneTrigger->GetZoneID())
{
prefer = TRUE;
break;
}
parent = container->GetZoneDescriptor(parent->GetParentZoneID());
}
}
if(prefer)
{
entryZoneTrigger = trigger;
entryZoneTriggerIndex = ti;
entryZonePriority = priority;
entryZoneDistance = distance;
}
}
}
}
std::ofstream out(argv[2], std::ios::out | std::ios::trunc);
if(!out)
{
std::cerr << "Unable to write JSON: " << argv[2] << "\n";
ruSAFE_RELEASE(container);
ruSAFE_RELEASE(dataStore);
RuShutdown();
return 6;
}
out << std::setprecision(9) << "{\n \"sourceWdb\": \"" << JsonEscape(argv[1])
<< "\",\n \"zoneDescriptorCount\": " << container->GetNumZoneDescriptors() << ",\n \"zones\": [\n";
for(INT32 zi = 0; zi < container->GetNumZoneDescriptors(); ++zi)
{
CRuWorld_ZoneDescriptor *zone = container->GetZoneDescriptorByIndex(zi);
out << " {\"id\":" << zone->GetZoneID() << ",\"parentId\":" << zone->GetParentZoneID()
<< ",\"name\":\"" << JsonEscape(zone->GetName()) << "\",\"zoneType\":"
<< static_cast<INT32>(zone->GetZoneType()) << ",\"settings\":[";
for(INT32 si = 0; si < zone->GetNumZoneSettings(); ++si)
{
CRuWorld_ZoneSettings *s = zone->GetZoneSettingsByIndex(si);
if(si) out << ',';
out << "{\"timeGroup\":" << s->m_timeGroup << ",\"weatherGroup\":" << s->m_weatherGroup
<< ",\"primary\":"; WriteColor(out, s->m_color_Primary);
out << ",\"secondary\":"; WriteColor(out, s->m_color_Secondary);
out << ",\"ambient\":"; WriteColor(out, s->m_color_Ambient);
out << ",\"specular\":"; WriteColor(out, s->m_color_Specular);
out << ",\"fogColor\":"; WriteColor(out, s->m_color_Fog);
out << ",\"fog\":{\"constant\":" << s->m_fog_Constant << ",\"start\":" << s->m_fog_Start
<< ",\"end\":" << s->m_fog_End << "},\"standardAnglesRadians\":["
<< s->m_theta_Standard << ',' << s->m_phi_Standard << "],\"specularAnglesRadians\":["
<< s->m_theta_Specular << ',' << s->m_phi_Specular << "],\"nativeDirectionsLeftHandedYUp\":{\"primary\":";
WriteVector(out, RuMathHelper_GenerateLightingVector(s->m_theta_Standard, s->m_phi_Standard));
out << ",\"secondary0\":";
WriteVector(out, RuMathHelper_GenerateLightingVector(s->m_theta_Standard + ruPI * 2.0f / 3.0f, 0.0f));
out << ",\"secondary1\":";
WriteVector(out, RuMathHelper_GenerateLightingVector(s->m_theta_Standard + ruPI * 4.0f / 3.0f, 0.0f));
out << ",\"specular\":";
WriteVector(out, RuMathHelper_GenerateLightingVector(s->m_theta_Specular, s->m_phi_Specular) * -1.0f);
out << "},\"weatherSystem\":\""
<< JsonEscape(s->m_weatherSystem) << "\",\"skyGradientTexture\":\""
<< JsonEscape(s->m_skyGradientTextureName) << "\",\"customSkyBox\":\""
<< JsonEscape(s->m_customSkyBoxName) << "\",\"cloudCover\":" << s->m_cloudCover
<< ",\"cloudSharpness\":" << s->m_cloudSharpness << ",\"cloudColors\":[";
WriteColor(out, s->m_cloudColor0);
out << ',';
WriteColor(out, s->m_cloudColor1);
out << "]}";
}
out << "]}" << (zi + 1 < container->GetNumZoneDescriptors() ? "," : "") << "\n";
}
INT32 zoneTriggerCount = 0;
for(INT32 ti = 0; ti < triggers.Count(); ++ti)
{
if(static_cast<CRuWorld_Trigger *>(triggers[ti])->GetTriggerType() == RuWorld::ruTRIGGERTYPE_ZONE)
{
++zoneTriggerCount;
}
}
out << " ],\n \"zoneTriggerCount\": " << zoneTriggerCount << ",\n \"zoneTriggers\": [\n";
INT32 writtenZoneTriggers = 0;
for(INT32 ti = 0; ti < triggers.Count(); ++ti)
{
CRuWorld_Trigger *trigger = static_cast<CRuWorld_Trigger *>(triggers[ti]);
if(trigger->GetTriggerType() != RuWorld::ruTRIGGERTYPE_ZONE) continue;
CRuOBB bounds = trigger->GetTriggerBounds();
const CRuVector3 &center = bounds.Center();
const CRuVector3 *axes = bounds.Axis();
const float *extents = bounds.Extents();
RuPropertyType propertyValue;
const INT32 priority = trigger->GetProperty("CRuWorld_Trigger:Priority", propertyValue)
? boost::get<INT32>(propertyValue) : 0;
out << " {\"triggerType\":" << static_cast<INT32>(trigger->GetTriggerType())
<< ",\"zoneId\":" << trigger->GetZoneID() << ",\"zoneType\":"
<< static_cast<INT32>(trigger->GetZoneType()) << ",\"priority\":" << priority << ",\"command\":\""
<< JsonEscape(trigger->GetCommandString()) << "\",\"obb\":{\"center\":["
<< center.x << ',' << center.y << ',' << center.z << "],\"axes\":["
<< '[' << axes[0].x << ',' << axes[0].y << ',' << axes[0].z << "],"
<< '[' << axes[1].x << ',' << axes[1].y << ',' << axes[1].z << "],"
<< '[' << axes[2].x << ',' << axes[2].y << ',' << axes[2].z << "]],\"halfExtents\":["
<< extents[0] << ',' << extents[1] << ',' << extents[2] << "]}}"
<< (++writtenZoneTriggers < zoneTriggerCount ? "," : "") << "\n";
}
out << " ],\n \"entryZoneSelection\":";
if(!hasEntryProbe)
{
out << "null\n";
}
else
{
out << "{\"probe\":[" << entryProbe.x << ',' << entryProbe.y << ',' << entryProbe.z << "],\"triggerIndex\":"
<< entryZoneTriggerIndex << ",\"triggerZoneId\":";
if(entryZoneTrigger) out << entryZoneTrigger->GetZoneID(); else out << "null";
out << ",\"triggerPriority\":";
if(entryZoneTrigger) out << entryZonePriority; else out << "null";
out << ",\"boundaryDistance\":";
if(entryZoneTrigger) out << entryZoneDistance; else out << "null";
out << ",\"settingsZoneId\":";
INT32 settingsZoneID = -1;
CRuWorld_ZoneSettings *entrySettings = NULL;
CRuWorld_ZoneDescriptor *selectedZone = entryZoneTrigger
? container->GetZoneDescriptor(entryZoneTrigger->GetZoneID()) : NULL;
while(selectedZone)
{
entrySettings = selectedZone->GetZoneSettings(0, 0);
if(entrySettings)
{
settingsZoneID = selectedZone->GetZoneID();
break;
}
selectedZone = container->GetZoneDescriptor(selectedZone->GetParentZoneID());
}
if(entrySettings) out << settingsZoneID; else out << "null";
out << ",\"timeGroup\":0,\"weatherGroup\":0,\"selectedTimeGroup\":";
if(entrySettings) out << entrySettings->m_timeGroup; else out << "null";
out << ",\"selectedWeatherGroup\":";
if(entrySettings) out << entrySettings->m_weatherGroup; else out << "null";
out << "}\n";
}
out << "}\n";
out.close();
std::cout << "Wrote " << container->GetNumZoneDescriptors() << " source zone descriptors.\n";
for(INT32 ti = 0; ti < triggers.Count(); ++ti) ruSAFE_RELEASE(triggers[ti]);
ruSAFE_RELEASE(objectContainer);
ruSAFE_RELEASE(container);
ruSAFE_RELEASE(dataStore);
RuShutdown();
return 0;
}
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { GLTFLoader, MeshoptDecoder } from "three-stdlib";
import { DUNGEON_REGISTRY } from "../../src/game/dungeonRegistry";
import { findPartyNavigationPath, type PartyNavigationPosition } from "../../src/game/partyNavigation";
import { preparePartyNavigationGraph } from "../../src/game/partyNavigationAssetCache";
// Observational plan only. The companion playtest uses ordinary UI/WASD and
// reports physical blocking; this planner never relocates a runtime player.
const output = path.resolve(process.argv.find(value => value.startsWith("--output="))?.slice(9)
?? "artifacts/playtest/rom-deep-review/walking-plans.json");
const maximumDistance = 45;
const plans: Record<string, unknown> = {};
const reports: unknown[] = [];
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
function boundedWaypoints(route: readonly PartyNavigationPosition[]) {
const points: number[][] = [];
let distance = 0;
let sinceCapture = 0;
for (let index = 1; index < route.length; index += 1) {
const a = route[index - 1], b = route[index];
const length = Math.hypot(b[0] - a[0], b[2] - a[2]);
const retainedLength = Math.min(length, maximumDistance - distance);
const segments = Math.max(1, Math.ceil(retainedLength / 4));
for (let step = 1; step <= segments; step++) {
const ratio = length ? retainedLength / length * step / segments : 1;
const point = a.map((value, axis) => value + (b[axis] - value) * ratio);
sinceCapture += retainedLength / segments;
// Keep portal crossings even on short legs so walking follows actual links.
if (sinceCapture > 0.5 || step === segments) points.push(point);
sinceCapture = 0;
}
distance += retainedLength;
if (distance >= maximumDistance) break;
}
return { points, plannedDistance: distance };
}
for (const definition of Object.values(DUNGEON_REGISTRY).filter(row => row.expansion === "runewaker" && !row.id.startsWith("bite-sized-"))) {
const asset = definition.assets.navigation;
if (!asset) continue;
const file = `src/assets/game/dungeons/${definition.id}/${asset.fileName}`;
try {
const bytes = await readFile(file);
const checksum = createHash("sha256").update(bytes).digest("hex");
const gltf = await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
const graph = preparePartyNavigationGraph(file, gltf.scene, definition.navigationLinks ?? [],
definition.traversalRamps ?? [], 250_000, false, gltf.userData.runewakerNavigationTopology,
asset.detourTopology, asset.navigationComponentPolicy, checksum);
const entry = definition.entrance.footPosition;
const targets = definition.bosses.map(boss => ({ name: boss.name, position: boss.position }));
const bossRoutes = targets.map(target => ({ target, route: findPartyNavigationPath(graph, entry, target.position, {
maxStartSnapDistance: 3, maxEndSnapDistance: 8,
}) }));
let selected = bossRoutes.find(row => row.route && row.route.length > 1);
if (!selected) {
const forward = definition.entrance.forward;
// Try a retained surface ahead of the entry when no boss is connected.
for (const distance of [45, 25, 12, 6]) {
const position = entry.map((value, axis) => value + forward[axis] * distance) as unknown as PartyNavigationPosition;
const route = findPartyNavigationPath(graph, entry, position, { maxStartSnapDistance: 3, maxEndSnapDistance: 8 });
if (route && route.length > 1) {
selected = { target: { name: `forward surface ${distance}m`, position }, route };
break;
}
}
}
const bounded = selected?.route ? boundedWaypoints(selected.route) : {
points: [3, 6].map(distance => entry.map((value, axis) => value + definition.entrance.forward[axis] * distance)),
plannedDistance: 6,
source: "ordinary short forward collision probe; navigation did not certify a connected target",
};
plans[definition.id] = bounded;
reports.push({ id: definition.id, checksum, target: selected?.target.name ?? null,
bossRoutes: bossRoutes.map(row => ({ name: row.target.name, connected: row.route !== null })), ...bounded });
console.log(`${definition.id}: ${bounded.points.length} waypoints, ${bounded.plannedDistance.toFixed(1)}m, ${selected?.target.name ?? "no connected target"}`);
} catch (error) {
reports.push({ id: definition.id, error: String(error) });
plans[definition.id] = { points: [] };
console.log(`${definition.id}: ${String(error)}`);
}
}
await mkdir(path.dirname(output), { recursive: true });
await writeFile(output, `${JSON.stringify(plans, null, 2)}\n`);
await writeFile(output.replace(/\.json$/, "-proof.json"), `${JSON.stringify({ maximumDistance, reports }, null, 2)}\n`);
@@ -0,0 +1,217 @@
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { BufferAttribute, BufferGeometry, Vector3 } from "three";
import { MeshBVH } from "three-mesh-bvh";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const work = path.join(projectRoot, "runewaker-export-work", "aeternal-circle");
const inspection = path.join(work, "inspection");
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json"), "utf8"));
const wdbReport = JSON.parse(await readFile(path.join(work, "raw/wdb-report.json"), "utf8"));
const sourceFile = path.join(work, "converted/aeternal-circle-visual.glb");
const cropFile = path.join(inspection, "aeternal-native-entry-roi.glb");
const navFile = path.join(inspection, "aeternal-native-entry-roi-navigation.glb");
const navReportFile = path.join(inspection, "aeternal-native-entry-roi-recast-report.json");
const outputFile = path.join(inspection, "aeternal-native-entry-roi-probe.json");
const primary = wdbReport.descriptors.find((descriptor) => (
descriptor.resource.replaceAll("/", "\\").toLowerCase() === recipe.source.model.replaceAll("/", "\\").toLowerCase()
&& descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001)
));
if (!primary) throw new Error("Cannot resolve the recipe's primary WDB placement.");
const sourceAnchor = recipe.source.sourceAnchorSupport.points[0].map(Number);
const scale = recipe.coordinateSystem.metersPerSourceUnit;
const entry = [
(sourceAnchor[0] - primary.translation[0]) * scale,
(sourceAnchor[1] - primary.translation[1]) * scale,
-(sourceAnchor[2] - primary.translation[2]) * scale,
];
const roi = { halfExtent: 28, minY: entry[1] - 5, maxY: entry[1] + 2 };
const transformPoint = (matrix, p) => [
matrix[0] * p[0] + matrix[4] * p[1] + matrix[8] * p[2] + matrix[12],
matrix[1] * p[0] + matrix[5] * p[1] + matrix[9] * p[2] + matrix[13],
matrix[2] * p[0] + matrix[6] * p[1] + matrix[10] * p[2] + matrix[14],
];
const inputDocument = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(sourceFile);
const allPoints = [];
let retainedTriangles = 0;
let skippedSupportTriangles = 0;
for (const node of inputDocument.getRoot().listNodes()) {
if (!node.getMesh()) continue;
if (node.getName() === "runewaker_source_anchor_support") {
skippedSupportTriangles += node.getMesh().listPrimitives().reduce((sum, primitive) => (
sum + (primitive.getIndices()?.getCount() ?? 0) / 3
), 0);
continue;
}
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const position = primitive.getAttribute("POSITION");
const array = primitive.getIndices()?.getArray();
if (!position || !array) continue;
for (let offset = 0; offset < array.length; offset += 3) {
const triangle = [array[offset], array[offset + 1], array[offset + 2]]
.map((index) => transformPoint(matrix, position.getElement(index, [0, 0, 0])));
const min = [0, 1, 2].map((axis) => Math.min(...triangle.map((point) => point[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...triangle.map((point) => point[axis])));
if (max[0] < entry[0] - roi.halfExtent || min[0] > entry[0] + roi.halfExtent
|| max[2] < entry[2] - roi.halfExtent || min[2] > entry[2] + roi.halfExtent
|| max[1] < roi.minY || min[1] > roi.maxY) continue;
allPoints.push(...triangle.flat());
retainedTriangles += 1;
}
}
}
if (!retainedTriangles) throw new Error("No source-native triangles intersect the Aeternal entry ROI.");
const cropDocument = new Document();
const buffer = cropDocument.createBuffer("aeternal-native-entry-roi-buffer");
const positions = cropDocument.createAccessor("native-source-positions", buffer)
.setType("VEC3").setArray(new Float32Array(allPoints));
const primitive = cropDocument.createPrimitive().setAttribute("POSITION", positions);
const mesh = cropDocument.createMesh("aeternal-native-entry-roi").addPrimitive(primitive);
cropDocument.createScene("aeternal-native-entry-roi-scene")
.addChild(cropDocument.createNode("native-primary-ros-roi").setMesh(mesh));
await mkdir(inspection, { recursive: true });
await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(cropFile, cropDocument);
const navReport = await bakeNavigationFromCollisionAssets({
slug: "aeternal-native-entry-roi-inspection",
collisionAssets: [{ id: "native-entry-roi", role: "collision", path: path.relative(projectRoot, cropFile).replaceAll("\\", "/") }],
settings: { ...recipe.navigation, partition: "monotone", retainTileIntermediates: false },
output: navFile,
reportFile: navReportFile,
source: {
kind: "inspection-only-native-primary-ROS-entry-ROI",
canonicalVisual: path.relative(projectRoot, sourceFile).replaceAll("\\", "/"),
excludedGeneratedSupportNode: "runewaker_source_anchor_support",
excludedSupportTriangles: skippedSupportTriangles,
bounds: roi,
},
});
const navDocument = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(navFile);
const navNode = navDocument.getRoot().listNodes().find((node) => node.getName() === "navigation");
if (!navNode?.getMesh()) throw new Error("Local native-only Recast output has no navigation node.");
const navTrianglePositions = [];
for (const navPrimitive of navNode.getMesh().listPrimitives()) {
const position = navPrimitive.getAttribute("POSITION");
const indices = navPrimitive.getIndices()?.getArray();
if (!position || !indices) continue;
const matrix = Array.from(navNode.getWorldMatrix());
for (let offset = 0; offset < indices.length; offset += 3) {
navTrianglePositions.push([indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => {
const p = position.getElement(index, [0, 0, 0]);
return transformPoint(matrix, p);
}));
}
}
const navGeometry = new BufferGeometry();
navGeometry.setAttribute("position", new BufferAttribute(new Float32Array(navTrianglePositions.flat(2)), 3));
const bvh = new MeshBVH(navGeometry);
const pointKey = (point) => point.map((value) => Math.round(value * 10000)).join(",");
const edgeKey = (a, b) => [pointKey(a), pointKey(b)].sort().join("|");
const edgeUsers = new Map();
for (const [triangleIndex, triangle] of navTrianglePositions.entries()) {
for (const [a, b] of [[0, 1], [1, 2], [2, 0]]) {
const key = edgeKey(triangle[a], triangle[b]);
const users = edgeUsers.get(key) ?? [];
users.push(triangleIndex);
edgeUsers.set(key, users);
}
}
const adjacency = Array.from({ length: navTrianglePositions.length }, () => new Set());
for (const users of edgeUsers.values()) {
for (const from of users) for (const to of users) if (from !== to) adjacency[from].add(to);
}
const componentForTriangle = new Int32Array(navTrianglePositions.length).fill(-1);
const components = [];
for (let start = 0; start < navTrianglePositions.length; start += 1) {
if (componentForTriangle[start] !== -1) continue;
const id = components.length;
const queue = [start];
componentForTriangle[start] = id;
for (let cursor = 0; cursor < queue.length; cursor += 1) {
for (const next of adjacency[queue[cursor]]) {
if (componentForTriangle[next] !== -1) continue;
componentForTriangle[next] = id;
queue.push(next);
}
}
const points = queue.flatMap((triangleIndex) => navTrianglePositions[triangleIndex]);
components.push({
id,
triangleCount: queue.length,
bounds: {
min: [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))),
max: [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))),
},
});
}
const projections = [];
for (let dx = -24; dx <= 24; dx += 4) for (let dz = -24; dz <= 24; dz += 4) {
const target = new Vector3(entry[0] + dx, entry[1], entry[2] + dz);
const closest = bvh.closestPointToPoint(target);
if (!closest) continue;
const triangle = navTrianglePositions[closest.faceIndex];
const a = triangle[1].map((value, index) => value - triangle[0][index]);
const b = triangle[2].map((value, index) => value - triangle[0][index]);
const normal = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const length = Math.hypot(...normal) || 1;
projections.push({
offsetMeters: [dx, dz],
point: closest.point.toArray().map((value) => Number(value.toFixed(5))),
distanceMeters: Number(closest.distance.toFixed(5)),
upwardNormalY: Number((normal[1] / length).toFixed(5)),
triangleIndex: closest.faceIndex,
componentId: componentForTriangle[closest.faceIndex],
});
}
const entryProjection = projections.find((projection) => projection.offsetMeters[0] === 0 && projection.offsetMeters[1] === 0) ?? null;
const report = {
schemaVersion: 1,
kind: "aeternal-circle-native-entry-roi-navigation-probe",
input: {
canonicalVisual: path.relative(projectRoot, sourceFile).replaceAll("\\", "/"),
diagnosticCrop: path.relative(projectRoot, cropFile).replaceAll("\\", "/"),
supportNodeExcluded: "runewaker_source_anchor_support",
supportTrianglesExcluded: skippedSupportTriangles,
entryRuntimeLocal: entry,
roi: { halfExtentMeters: roi.halfExtent, minY: roi.minY, maxY: roi.maxY },
retainedNativeTriangles: retainedTriangles,
},
nativeOnlyRecast: {
geometry: navReport.geometry,
build: navReport.build,
settings: navReport.settings,
triangleCount: navTrianglePositions.length,
componentCount: components.length,
largestComponents: components.slice().sort((a, b) => b.triangleCount - a.triangleCount).slice(0, 10),
entryConnectedComponent: entryProjection ? components[entryProjection.componentId] : null,
projections,
entryProjection,
},
interpretation: {
localNativeWalkSurfaceFound: Boolean(entryProjection && entryProjection.distanceMeters <= 0.5 && entryProjection.upwardNormalY >= 0.75),
entryProjectionBelongsToConnectedComponent: Boolean(entryProjection && components[entryProjection.componentId]?.triangleCount > 1),
entryProjectionMaxLocalDistanceMeters: 0.5,
minimumUpwardNormalY: 0.75,
recommendation: "The source primary ROS supplies a locally connected walk surface at the entry anchor without the generated support mesh. Remove only this entry's generated pad after checking the shipped coordinate correction; keep the remaining pads pending per-anchor native-route evidence.",
limitation: "The diagnostic crops only the primary ROS to a 56m entry ROI. It does not certify complete dungeon traversal or assess secondary WDB placements.",
},
caveat: "This inspection-only 56m ROI tests native primary-ROS Recast support. It excludes all generated source-anchor support and all secondary WDB placements; it cannot establish a full-dungeon route by itself.",
};
await (await import("node:fs/promises")).writeFile(outputFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
navGeometry.dispose();
console.log(JSON.stringify({
report: path.relative(projectRoot, outputFile).replaceAll("\\", "/"),
entry,
retainedNativeTriangles: retainedTriangles,
navTriangles: navTrianglePositions.length,
gridCells: navReport.build.gridCells,
entryProjection: report.nativeOnlyRecast.entryProjection,
}, null, 2));
@@ -0,0 +1,221 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const work = path.join(projectRoot, "runewaker-export-work", "aeternal-circle");
const inspection = path.join(work, "inspection");
const visualFile = path.join(work, "converted/aeternal-circle-visual.glb");
const recipeFile = path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json");
const wdbFile = path.join(work, "raw/wdb-report.json");
const reportFile = path.join(inspection, "aeternal-entry-native-ground-probe.json");
const existingTriageFile = path.join(inspection, "aeternal-derived-support-ground-triage.json");
const recipe = JSON.parse(await readFile(recipeFile, "utf8"));
const wdbReport = JSON.parse(await readFile(wdbFile, "utf8"));
const existingTriage = JSON.parse(await readFile(existingTriageFile, "utf8"));
const visualBytes = await readFile(visualFile);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const primary = wdbReport.descriptors.find((descriptor) => (
descriptor.resource.replaceAll("/", "\\").toLowerCase() === recipe.source.model.replaceAll("/", "\\").toLowerCase()
&& descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001)
));
if (!primary) throw new Error("Cannot resolve the recipe's primary WDB placement.");
const sourceAnchor = recipe.source.sourceAnchorSupport.points[0].map(Number);
const meters = recipe.coordinateSystem.metersPerSourceUnit;
const runtimeAnchor = [
(sourceAnchor[0] - primary.translation[0]) * meters,
(sourceAnchor[1] - primary.translation[1]) * meters,
-(sourceAnchor[2] - primary.translation[2]) * meters,
];
const reflectedWriterPosition = [runtimeAnchor[0], runtimeAnchor[1], -runtimeAnchor[2]];
const matrixPoint = (matrix, point) => [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
const sub = (a, b) => a.map((value, index) => value - b[index]);
const cross = (a, b) => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const normalize = (v) => {
const length = Math.hypot(...v);
return length ? v.map((value) => value / length) : [0, 0, 0];
};
const barycentricXZ = (x, z, a, b, c) => {
const ab = [b[0] - a[0], b[2] - a[2]];
const ac = [c[0] - a[0], c[2] - a[2]];
const p = [x - a[0], z - a[2]];
const d00 = ab[0] * ab[0] + ab[1] * ab[1];
const d01 = ab[0] * ac[0] + ab[1] * ac[1];
const d11 = ac[0] * ac[0] + ac[1] * ac[1];
const d20 = p[0] * ab[0] + p[1] * ab[1];
const d21 = p[0] * ac[0] + p[1] * ac[1];
const determinant = d00 * d11 - d01 * d01;
if (Math.abs(determinant) < 1e-12) return null;
const v = (d11 * d20 - d01 * d21) / determinant;
const w = (d00 * d21 - d01 * d20) / determinant;
const weights = [1 - v - w, v, w];
return weights.some((weight) => weight < -1e-6) ? null : weights;
};
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(visualFile);
const sourceReport = JSON.parse(await readFile(path.join(work, "prepared/export-report.json"), "utf8"));
const sourceMaterials = new Map(sourceReport.materials.map((material) => [material.name, material]));
const triangles = [];
for (const node of document.getRoot().listNodes()) {
if (!node.getMesh() || node.getName() === "runewaker_source_anchor_support") continue;
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const positions = primitive.getAttribute("POSITION");
const indexArray = primitive.getIndices()?.getArray();
if (!positions || !indexArray) continue;
const uvs = primitive.getAttribute("TEXCOORD_0");
const materialName = primitive.getMaterial()?.getName() ?? "unassigned";
const material = sourceMaterials.get(materialName);
for (let offset = 0; offset < indexArray.length; offset += 3) {
const indices = [indexArray[offset], indexArray[offset + 1], indexArray[offset + 2]];
const points = indices.map((index) => matrixPoint(matrix, positions.getElement(index, [0, 0, 0])));
const normal = normalize(cross(sub(points[1], points[0]), sub(points[2], points[0])));
if (normal[1] < 0.75) continue;
if (Math.max(...points.map((point) => Math.abs(point[0] - runtimeAnchor[0]))) > 8
|| Math.max(...points.map((point) => Math.abs(point[2] - runtimeAnchor[2]))) > 8) continue;
triangles.push({
node: node.getName(), materialName, sourceTexture: material?.sourceTexture ?? null,
points, normal, indices, uvs, uvValues: uvs ? indices.map((index) => uvs.getElement(index, [0, 0])) : null,
});
}
}
}
const offsets = [-5.5, -3, 0, 3, 5.5];
const queryGrid = (center, label) => {
const hits = [];
for (const dx of offsets) for (const dz of offsets) {
const x = center[0] + dx;
const z = center[2] + dz;
let highest = null;
for (const triangle of triangles) {
const weights = barycentricXZ(x, z, ...triangle.points);
if (!weights) continue;
const y = weights.reduce((sum, weight, index) => sum + weight * triangle.points[index][1], 0);
if (y > center[1] + 0.25 || y < center[1] - 12) continue;
if (highest && y <= highest.point[1]) continue;
const uv0 = triangle.uvValues?.[0].map((_, axis) => weights.reduce((sum, weight, index) => sum + weight * triangle.uvValues[index][axis], 0)) ?? null;
highest = {
node: triangle.node,
material: triangle.materialName,
sourceTexture: triangle.sourceTexture,
point: [x, y, z].map((value) => Number(value.toFixed(6))),
dropFromAnchorMeters: Number((center[1] - y).toFixed(6)),
normal: triangle.normal.map((value) => Number(value.toFixed(6))),
uv0: uv0?.map((value) => Number(value.toFixed(6))) ?? null,
};
}
hits.push({ offsetMeters: [dx, dz], hit: highest });
}
const bySurface = new Map();
for (const { hit } of hits) {
const key = hit ? `${hit.material}|${hit.sourceTexture}` : "no-upward-surface";
bySurface.set(key, (bySurface.get(key) ?? 0) + 1);
}
return { label, center, sampleCount: hits.length, hitSurfaceCounts: Object.fromEntries(bySurface), hits };
};
const originalPadCenter = reflectedWriterPosition;
const correctedPadCenter = runtimeAnchor;
const halfExtent = Number(recipe.source.sourceAnchorSupport.halfExtentSourceUnits ?? 60) * meters;
const primaryDescriptor = primary;
const secondaryAnchors = recipe.source.sourceAnchorSupport.points.map((point, index) => {
const local = [
(Number(point[0]) - primary.translation[0]) * meters,
(Number(point[1]) - primary.translation[1]) * meters,
-(Number(point[2]) - primary.translation[2]) * meters,
];
const nearest = wdbReport.descriptors
.filter((descriptor) => descriptor !== primaryDescriptor)
.map((descriptor) => {
const center = descriptor.worldBounds?.center ?? descriptor.worldBoundsCenter ?? descriptor.translation;
const radius = Number(descriptor.worldBounds?.radius ?? descriptor.worldBoundsRadius ?? descriptor.localBounds?.radius ?? 0);
const distanceXZ = Math.hypot(Number(point[0]) - center[0], Number(point[2]) - center[2]);
return {
guid: descriptor.guid,
resource: descriptor.resource,
distanceXZSourceUnits: Number(distanceXZ.toFixed(3)),
worldBoundsRadiusSourceUnits: radius,
sphereLowerBoundGapSourceUnits: Number((distanceXZ - radius).toFixed(3)),
};
})
.sort((a, b) => a.distanceXZSourceUnits - b.distanceXZSourceUnits || a.sphereLowerBoundGapSourceUnits - b.sphereLowerBoundGapSourceUnits)
.slice(0, 3);
return { index, sourcePosition: point, runtimeLocal: local.map((value) => Number(value.toFixed(4))), nearestSecondaryDescriptors: nearest };
});
const nearSecondaryResourceCounts = new Map();
for (const anchor of secondaryAnchors) {
for (const descriptor of anchor.nearestSecondaryDescriptors.slice(0, 1)) {
nearSecondaryResourceCounts.set(descriptor.resource, (nearSecondaryResourceCounts.get(descriptor.resource) ?? 0) + 1);
}
}
const report = {
schemaVersion: 1,
kind: "aeternal-circle-entry-native-ground-probe",
inputs: {
recipe: path.relative(projectRoot, recipeFile).replaceAll("\\", "/"),
sourceRos: recipe.source.model,
sourceRosSha256: existingTriage.generatedFrom.sourceModelSha256,
sourceWdbSha256: existingTriage.generatedFrom.sourceWdbSha256,
canonicalVisual: path.relative(projectRoot, visualFile).replaceAll("\\", "/"),
canonicalVisualSha256: sha256(visualBytes),
exporterSource: "scripts/runewaker-pipeline/native/runewaker_model_exporter.cpp (ROS vertex Z reflected before OBJ emission)",
supportWriter: "scripts/runewaker-pipeline/build-environment.mjs::appendSourceAnchorSupport (writes primary-local source coordinates without the OBJ Z reflection)",
},
placement: {
sourceAnchor,
primaryTranslation: primary.translation,
runtimeLocalAnchor: runtimeAnchor,
observedCurrentVisualPadCenter: originalPadCenter,
proposedCorrectedPadCenter: correctedPadCenter,
supportHalfExtentMeters: halfExtent,
proposedCorrectedVisualTopY: runtimeAnchor[1],
proposedCorrectedWorldBounds: {
min: [correctedPadCenter[0] - halfExtent, correctedPadCenter[1] - 0.3, correctedPadCenter[2] - halfExtent],
max: [correctedPadCenter[0] + halfExtent, correctedPadCenter[1], correctedPadCenter[2] + halfExtent],
},
},
nativeSurfaceProbe: {
queryYRangeMeters: [runtimeAnchor[1] - 12, runtimeAnchor[1] + 0.25],
upwardNormalYThreshold: 0.75,
nativeTrianglesConsidered: triangles.length,
atRuntimeSpawn: queryGrid(runtimeAnchor, "runtime spawn footprint"),
atErroneousReflectedPad: queryGrid(originalPadCenter, "current reflected visual pad footprint"),
},
secondaryWdbProximity: {
sourceAnchorCount: secondaryAnchors.length,
method: "For each NPCData/source support anchor, rank the nearest non-primary WDB descriptors by horizontal distance to descriptor world-bounds center. A bounds-sphere gap is recorded only as a coarse candidate hint; it does not prove a floor surface or vertical intersection.",
nearestSecondaryResourceCounts: [...nearSecondaryResourceCounts].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([resource, anchorCount]) => ({ resource, anchorCount })),
anchorsWithSecondaryBoundsCenterWithin20m: secondaryAnchors.filter((anchor) => anchor.nearestSecondaryDescriptors[0]?.distanceXZSourceUnits <= 200).length,
anchors: secondaryAnchors,
groundFloorTerrainResourceCount: wdbReport.descriptors.filter((descriptor) => /(?:ground|floor|terrain)/i.test(descriptor.resource)).length,
},
recommendation: {
nativeEntryLandingFound: true,
surface: "room_a_outside_statue_39_mesh_0 / runewaker_material_39 / texture\\dungeon\\wizards_haunt\\building_statue",
interpretation: "The native up-facing surface under the runtime anchor samples the stone-cobble inset in the building_statue atlas, consistent with a textured landing/plinth top rather than a blank derived pad. The local native-only Recast test is in aeternal-native-entry-roi-probe.json.",
removeOnlyEntryDerivedPadCandidate: true,
limitation: "This is a local entry-footprint finding. It does not certify a complete path beyond the cropped ROI or justify removing support at the other 151 anchors.",
safeTextureFallbackProven: false,
},
};
await mkdir(inspection, { recursive: true });
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
report: path.relative(projectRoot, reportFile).replaceAll("\\", "/"),
currentPadCenter: originalPadCenter,
correctedPadCenter,
nativeTrianglesConsidered: triangles.length,
spawnSurfaceCounts: report.nativeSurfaceProbe.atRuntimeSpawn.hitSurfaceCounts,
reflectedSurfaceCounts: report.nativeSurfaceProbe.atErroneousReflectedPad.hitSurfaceCounts,
}, null, 2));
@@ -0,0 +1,378 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
// This promoter intentionally handles only the HOTO v1 -> v2 metadata update.
// It has no effect unless invoked with the explicit --apply flag.
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const reviewDir = "artifacts/playtest/rom-deep-review/uv-stretch-review";
const stageDir = `${reviewDir}/staged`;
const stageReportPath = `${stageDir}/hoto-surface-quality-v2-metadata-stage-report.json`;
const visualProofPath = `${reviewDir}/hoto-surface-v2-visual-proof.json`;
const sourceLayoutProofPath = `${stageDir}/hoto-surface-v2-source-layout-proof.json`;
const independentLayoutProofPath = `${stageDir}/hoto-v2-root-source-layout-proof.json`;
const textureQualityProofPath = `${stageDir}/compressed-source-texture-fidelity-policy.json`;
const policyId = "rom-hoto-source-rock-detail-v2";
const sourceModel = "model/dungeon/echoes_of_the_sea/act_echoes_of_the_sea.ros";
const sourceRosSha256 = "a0bd77c09045f8bfa1507484bbb2c2ff0cc72d397df4f769fc4e6d5aab14f8a2";
const canonicalOldSha256 = "1015a8483096c551b4600f772f5f4cc888b3a7ab93dd58fe199cf113488e0078";
const canonicalCandidateSha256 = "5f4329e5dce0651ec30861d3140c130ddf646aac920670aaa6ba901682d258b6";
const optimizedOldSha256 = "f745c41b834647ff660db96665a9c474772162db2382c5a5ffa1a41e6a7e841c";
const optimizedCandidateSha256 = "b80b60cba6e5a0eb3e13bd7e048f9408f21bf870c0c81fc8de5c195d3a3eaee3";
const reviewedProofHashes = new Map([
[stageReportPath, "a896070024f0e03eaef952357e76a3da04b23490da0d12f57fe795eeb253e958"],
[visualProofPath, "da90fb979266efd65ac4a9dd3e8a2447c9c5cc5200520fed60d5547482eb0d51"],
[sourceLayoutProofPath, "d508f442ce3e95b6eed1f628cf4bfad250434f37b15b538c55e5c078b1610297"],
[independentLayoutProofPath, "d508f442ce3e95b6eed1f628cf4bfad250434f37b15b538c55e5c078b1610297"],
[textureQualityProofPath, "ce7d787e502c6283e6bcd64d8c801dfc6724e084d7ff807a3a67063a833e54ca"],
]);
const metadataBaselines = new Map([
["src/assets/game/dungeons/hoto/import-metadata.json", "1e2525fc5c9320125beeb5f5536d8be258c63b0b1bff0fcf958321f8da1bf362"],
["src/assets/game/dungeons/hoto/optimization-report.json", "a23ab1161a743b679eceeefe576ca55c00fb7f672ec071aaa30e7b669bfe7c44"],
["src/assets/game/dungeons/hoto/conversion-report.json", "2abb3bda4b758f05c993f80bf09bc880e7d0d6b78d33c257d6da229713ddf723"],
["runewaker-export-work/hoto/converted/optimization-report.json", "317ec5195ac2f8e8f6c96860cf92905564c5274deacef5c5ffde28165909dbeb"],
["runewaker-export-work/hoto/converted/conversion-report.json", "2abb3bda4b758f05c993f80bf09bc880e7d0d6b78d33c257d6da229713ddf723"],
]);
const canonicalFile = "runewaker-export-work/hoto/converted/hoto-visual.glb";
const optimizedFile = "runewaker-export-work/hoto/converted/hoto-visual.optimized.glb";
const shippingFile = "src/assets/game/dungeons/hoto/hoto-visual.glb";
const targetMaterialNames = [
"runewaker_material_96", "runewaker_material_53", "runewaker_material_26", "runewaker_material_18",
];
const carrierNames = ["rom-detail-hoto-floor:source-tile-carrier", "rom-detail-hoto-wall:source-tile-carrier"];
const approvedMetadataKeys = ["runewakerSurfaceDetail", "runewakerSurfaceDetailCarrier"];
const reviewedScreenshotHashes = new Map([
[`${reviewDir}/hoto-surface-v2-entry-shipped.png`, "b410b8866b3b0732ebe8888eae7c6dea4f9529c8272d6ff47c85d14292363b0c"],
[`${reviewDir}/hoto-surface-v2-entry-candidate.png`, "e68f92d523198700265774c6ad07a8bd0e1372fbf45f4dd28e71787b3062bcb2"],
[`${reviewDir}/hoto-surface-v2-material26-low-density-floor-shipped.png`, "63a1e7832767c952ae927e6a675e8c660a25d6d6de12b94ffb4a54e1339b7224"],
[`${reviewDir}/hoto-surface-v2-material26-low-density-floor-candidate.png`, "6cc22165911aba6835ab0d23e8bf16b1ef5b0992099bcab177d9d57ca11bb841"],
]);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const readRepo = (file) => readFile(path.resolve(repo, file));
const readJson = async (file) => JSON.parse((await readRepo(file)).toString("utf8"));
const repoPath = (file) => path.resolve(repo, file);
function parseGlb(bytes, label) {
assert.equal(bytes.toString("ascii", 0, 4), "glTF", `${label} is not a GLB.`);
assert.equal(bytes.readUInt32LE(4), 2, `${label} is not glTF 2.0.`);
assert.equal(bytes.readUInt32LE(8), bytes.length, `${label} header length is invalid.`);
const chunks = [];
for (let offset = 12; offset < bytes.length;) {
assert.ok(offset + 8 <= bytes.length, `${label} has a truncated chunk header.`);
const length = bytes.readUInt32LE(offset);
const type = bytes.readUInt32LE(offset + 4);
const data = Buffer.from(bytes.subarray(offset + 8, offset + 8 + length));
assert.equal(data.length, length, `${label} has a truncated chunk.`);
chunks.push({ type, data });
offset += 8 + length;
}
const jsonChunk = chunks.find((chunk) => chunk.type === 0x4e4f534a);
assert.ok(jsonChunk, `${label} has no JSON chunk.`);
return {
chunks,
json: JSON.parse(jsonChunk.data.toString("utf8").trim()),
nonJsonChunks: chunks.filter((chunk) => chunk.type !== 0x4e4f534a),
};
}
function withoutApprovedMetadata(json) {
const copy = structuredClone(json);
for (const material of copy.materials ?? []) {
if (!material.extras) continue;
if (targetMaterialNames.includes(material.name)) delete material.extras.runewakerSurfaceDetail;
if (carrierNames.includes(material.name)) delete material.extras.runewakerSurfaceDetailCarrier;
}
return copy;
}
function verifyOnlyReviewedMetadataChanged(source, candidate, label, stageEntry) {
const before = parseGlb(source, `${label} baseline`);
const after = parseGlb(candidate, `${label} candidate`);
assert.deepEqual(withoutApprovedMetadata(after.json), withoutApprovedMetadata(before.json),
`${label} JSON changed outside the six approved HOTO metadata fields.`);
const beforeChunks = before.nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data), chunk.data.length]);
const afterChunks = after.nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data), chunk.data.length]);
assert.deepEqual(afterChunks, beforeChunks, `${label} non-JSON GLB chunks changed.`);
assert.equal(stageEntry.nonJsonChunksByteIdentical, true);
assert.deepEqual(stageEntry.nonJsonChunks.map((chunk) => [Number(chunk.type), chunk.sha256, chunk.bytes]), beforeChunks,
`${label} stage report does not match the byte-identity proof.`);
const materialByName = new Map((after.json.materials ?? []).map((material) => [material.name, material]));
return { before, after, materialByName };
}
function expectedCarrierMetadata(variantName, sourceProof) {
const source = sourceProof;
return {
version: 1,
variantName,
policyId,
sourceResourceSha256: sourceRosSha256,
sourceMaterial: source.nativeMaterial,
canonicalMaterialName: source.canonicalMaterialName,
sourceTexturePath: source.sourceTexture,
sourceTextureName: source.sourceImage,
sourcePreparedPngSha256: source.sourcePreparedPngSha256,
};
}
const stageReportBytes = await readRepo(stageReportPath);
const visualProofBytes = await readRepo(visualProofPath);
for (const [file, expectedSha] of reviewedProofHashes) {
assert.equal(digest(await readRepo(file)), expectedSha, `${file} differs from the frozen reviewed proof.`);
}
const stageReport = JSON.parse(stageReportBytes.toString("utf8"));
const visualProof = JSON.parse(visualProofBytes.toString("utf8"));
assert.equal(stageReport.status, "staged-metadata-only-not-promoted");
assert.equal(stageReport.policyId, policyId);
assert.equal(stageReport.source.sourceModel, sourceModel);
assert.equal(stageReport.source.sourceRosSha256, sourceRosSha256);
assert.equal(stageReport.freshPolicySmoke.status, "applied");
assert.equal(stageReport.migrationSmoke.status, "passed");
assert.equal(stageReport.migrationSmoke.firstApplication, "migrated-v1");
assert.equal(stageReport.migrationSmoke.secondApplication, "already-applied");
assert.deepEqual(stageReport.metadataScope.materials, targetMaterialNames);
assert.deepEqual(stageReport.metadataScope.carriers, carrierNames);
assert.deepEqual(stageReport.metadataScope.changedFields, approvedMetadataKeys);
assert.equal(stageReport.metadataScope.decodedGeometryUvTexturesAndAllNonJsonChunksUnchanged, true);
assert.equal(stageReport.metadataScope.level1CompressorOrGeometryOptimizerRun, false);
assert.equal(stageReport.historicalVisualEvidence.v1FinalScreenshots, "superseded-and-unavailable");
const sourceRosBytes = await readFile(path.resolve(repo, stageReport.source.sourceRos));
assert.equal(digest(sourceRosBytes), sourceRosSha256, "The pinned HOTO source ROS changed.");
const recipe = await readJson("scripts/runewaker-pipeline/recipes/hoto.json");
assert.deepEqual(recipe.visualSurfaceQualityPolicies, [policyId], "HOTO recipe does not select the durable v2 policy.");
// Verify every source image identity that the staged surface policy cites.
const sourcePngs = new Map();
for (const metadata of Object.values(stageReport.freshPolicySmoke.targetMetadata)) {
const proof = metadata.sourceProof;
assert.equal(proof.sourceModelSha256, sourceRosSha256);
for (const source of [proof, proof.floorDetailSource, proof.wallDetailSource].filter(Boolean)) {
const imageName = source.sourceTextureName ?? source.sourceImage;
const sha256 = source.sourcePreparedPngSha256;
if (!imageName || !sha256) continue;
const prior = sourcePngs.get(imageName);
if (prior) assert.equal(prior, sha256);
else sourcePngs.set(imageName, sha256);
}
}
for (const [imageName, expectedSha] of sourcePngs) {
const png = await readRepo(`runewaker-export-work/hoto/prepared/textures/${imageName}.png`);
assert.equal(digest(png), expectedSha, `Prepared source image ${imageName} changed.`);
}
const canonicalSource = await readRepo(canonicalFile);
const optimizedSource = await readRepo(optimizedFile);
const shippingSource = await readRepo(shippingFile);
const canonicalCandidate = await readRepo(stageReport.canonical.candidate);
const optimizedCandidate = await readRepo(stageReport.optimized.candidate);
assert.equal(digest(canonicalSource), canonicalOldSha256);
assert.equal(digest(optimizedSource), optimizedOldSha256);
assert.equal(digest(shippingSource), optimizedOldSha256);
assert.equal(digest(canonicalCandidate), canonicalCandidateSha256);
assert.equal(digest(optimizedCandidate), optimizedCandidateSha256);
assert.equal(canonicalCandidate.length, stageReport.canonical.candidateBytes);
assert.equal(optimizedCandidate.length, stageReport.optimized.candidateBytes);
assert.equal(stageReport.canonical.originalSha256, canonicalOldSha256);
assert.equal(stageReport.optimized.originalSha256, optimizedOldSha256);
assert.equal(stageReport.shipping.sha256, optimizedOldSha256);
assert.equal(stageReport.canonical.candidateSha256, canonicalCandidateSha256);
assert.equal(stageReport.optimized.candidateSha256, optimizedCandidateSha256);
const canonical = verifyOnlyReviewedMetadataChanged(canonicalSource, canonicalCandidate, "canonical HOTO", stageReport.canonical);
const optimized = verifyOnlyReviewedMetadataChanged(optimizedSource, optimizedCandidate, "optimized HOTO", stageReport.optimized);
const targetMetadata = stageReport.freshPolicySmoke.targetMetadata;
for (const name of targetMaterialNames) {
assert.deepEqual(canonical.materialByName.get(name)?.extras?.runewakerSurfaceDetail, targetMetadata[name],
`Canonical ${name} metadata differs from the source-pinned policy output.`);
assert.deepEqual(optimized.materialByName.get(name)?.extras?.runewakerSurfaceDetail, targetMetadata[name],
`Optimized ${name} metadata differs from the source-pinned policy output.`);
}
const carriers = [
[carrierNames[0], "rom-detail-hoto-floor", targetMetadata.runewaker_material_26.sourceProof.floorDetailSource],
[carrierNames[1], "rom-detail-hoto-wall", targetMetadata.runewaker_material_18.sourceProof.wallDetailSource],
];
for (const [name, variantName, sourceProof] of carriers) {
const expected = expectedCarrierMetadata(variantName, sourceProof);
for (const document of [canonical, optimized]) {
assert.deepEqual(document.materialByName.get(name)?.extras?.runewakerSurfaceDetailCarrier, expected,
`${name} carrier source provenance changed.`);
}
}
assert.equal(canonical.after.json.images.length, 17, "Canonical candidate changed the source image inventory.");
assert.equal(optimized.after.json.images.length, 17, "Optimized candidate changed the source image inventory.");
const sourceLayoutProof = await readJson(sourceLayoutProofPath);
const independentLayoutProof = await readJson(independentLayoutProofPath);
for (const proof of [sourceLayoutProof, independentLayoutProof]) {
assert.equal(digest(await readRepo(proof.beforeFile)), proof.beforeSha256);
assert.equal(proof.beforeSha256, canonicalOldSha256);
assert.equal(proof.afterSha256, canonicalCandidateSha256);
assert.equal(proof.hierarchyAndTransformsPreserved, true);
assert.equal(proof.orderedSourceTriangleCornersPreserved, true);
assert.equal(proof.allSourceAttributesExceptAdaptedUv1Preserved, true);
assert.equal(proof.originalSourceImagesPreserved, true);
assert.equal(proof.triangleCount, 189855);
assert.equal(proof.originalSourceImageCount, 17);
assert.deepEqual(proof.addedSourceRegionImages, []);
}
const textureQualityProof = await readJson(textureQualityProofPath);
const historicalCodec = textureQualityProof.results.find((result) => result.id === "hoto");
assert.ok(historicalCodec, "Historical HOTO codec proof is missing.");
assert.equal(historicalCodec.glbSha256, optimizedOldSha256);
assert.equal(historicalCodec.textures.length, 4);
assert.ok(historicalCodec.textures.every((texture) => texture.maeAlpha === 0));
assert.ok(historicalCodec.textures.some((texture) => texture.name === "5672f062_echoes_of_the_sea_02" && texture.width === 1024 && texture.height === 1024));
assert.ok(historicalCodec.textures.some((texture) => texture.name === "b2ababb9_echoes_of_the_sea_03" && texture.width === 1024 && texture.height === 1024));
assert.deepEqual(optimized.after.nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data)]),
parseGlb(optimizedSource, "old optimized HOTO").nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data)]),
"The v2 metadata-only candidate no longer carries the historically verified KTX2 bytes.");
assert.equal(visualProof.status, "passed");
assert.equal(visualProof.review?.status, "accepted", "The parent review acceptance is not recorded.");
assert.deepEqual(visualProof.review.views, ["entry", "material26-low-density-floor"]);
const visualRows = visualProof.results;
assert.equal(visualRows.length, 4);
for (const viewName of visualProof.review.views) {
const pair = visualRows.filter((row) => row.view === viewName);
const shipped = pair.find((row) => row.stage === "shipped");
const candidate = pair.find((row) => row.stage === "candidate");
assert.ok(shipped && candidate, `Missing the shipped/candidate ${viewName} view pair.`);
assert.deepEqual(shipped.camera, candidate.camera, `${viewName} camera changed between captures.`);
assert.deepEqual(shipped.errors, []);
assert.deepEqual(candidate.errors, []);
assert.equal(shipped.glbSha256, optimizedOldSha256);
assert.equal(candidate.glbSha256, optimizedCandidateSha256);
assert.equal(shipped.screenshot, `${reviewDir}/hoto-surface-v2-${viewName}-shipped.png`);
assert.equal(candidate.screenshot, `${reviewDir}/hoto-surface-v2-${viewName}-candidate.png`);
for (const row of [shipped, candidate]) {
const screenshot = await readRepo(row.screenshot);
const expectedSha = reviewedScreenshotHashes.get(row.screenshot);
assert.ok(expectedSha, `No reviewed screenshot hash is pinned for ${row.screenshot}.`);
assert.equal(row.screenshotSha256, expectedSha, `${row.screenshot} report hash changed.`);
assert.equal(digest(screenshot), expectedSha, `${row.screenshot} image bytes changed.`);
assert.ok(screenshot.length > 1000);
}
assert.ok(candidate.candidateResponses.some((response) => response.status === 200 && response.bodySha256 === optimizedCandidateSha256));
assert.ok(candidate.timing?.sampleFrames?.averageFps > 0);
}
const visualSummary = visualRows.map((row) => ({
view: row.view, stage: row.stage, camera: row.camera, screenshot: row.screenshot,
screenshotSha256: row.screenshotSha256, glbSha256: row.glbSha256, drawCalls: row.drawCalls, timing: row.timing,
}));
const review = {
status: "reviewed-metadata-only",
policyId,
sourceModel,
sourceRosSha256,
stageReport: stageReportPath,
sourceLayoutProof: sourceLayoutProofPath,
independentSourceLayoutProof: independentLayoutProofPath,
historicalTextureQualityProof: textureQualityProofPath,
visualProof: visualProofPath,
visualReview: visualProof.review,
historicalVisualEvidence: stageReport.historicalVisualEvidence,
canonical: { file: canonicalFile, beforeSha256: canonicalOldSha256, candidate: stageReport.canonical.candidate, candidateSha256: canonicalCandidateSha256, candidateBytes: canonicalCandidate.length },
optimized: { file: optimizedFile, beforeSha256: optimizedOldSha256, candidate: stageReport.optimized.candidate, candidateSha256: optimizedCandidateSha256, candidateBytes: optimizedCandidate.length },
shipping: { file: shippingFile, beforeSha256: optimizedOldSha256, afterSha256: optimizedCandidateSha256 },
nonJsonChunksByteIdentical: true,
metadataOnlyFields: approvedMetadataKeys,
migrationSmoke: stageReport.migrationSmoke,
visualRows: visualSummary,
};
const metadataEdits = [];
for (const [file, expectedSha] of metadataBaselines) {
const bytes = await readRepo(file);
assert.equal(digest(bytes), expectedSha, `${file} changed after this promoter was reviewed.`);
const data = JSON.parse(bytes.toString("utf8"));
if (file.endsWith("import-metadata.json")) {
const visualAsset = data.assets?.find((asset) => asset.role === "visual");
assert.equal(visualAsset?.sha256, optimizedOldSha256);
assert.equal(visualAsset.size, shippingSource.length);
visualAsset.sha256 = optimizedCandidateSha256;
visualAsset.size = optimizedCandidate.length;
data.continuationReview = { ...data.continuationReview, surfaceQuality: review };
} else if (file.endsWith("optimization-report.json")) {
assert.equal(data.source?.checksum, canonicalOldSha256);
assert.equal(data.optimized?.checksum, optimizedOldSha256);
data.source = { ...data.source, checksum: canonicalCandidateSha256, size: canonicalCandidate.length };
data.optimized = { ...data.optimized, checksum: optimizedCandidateSha256, size: optimizedCandidate.length };
data.supersededSurfaceQualityV1 ??= data.visualUvAdaptations?.surfaceQuality;
data.visualUvAdaptations ??= {};
data.visualUvAdaptations.surfaceQuality = review;
data.surfaceQuality = review;
// textureQuality remains a historical report for the old optimized SHA;
// the candidate's KTX2 and all other BIN bytes are proven identical above.
} else {
data.continuationIntegrityProof = {
...data.continuationIntegrityProof,
surfaceQuality: review,
};
}
metadataEdits.push({ file, expectedSha, bytes: Buffer.from(`${JSON.stringify(data, null, 2)}\n`, "utf8") });
}
const apply = process.argv.includes("--apply");
const preview = {
ready: true,
applied: false,
dungeonId: "hoto",
policyId,
assetCopies: [
{ file: canonicalFile, sha256: canonicalOldSha256, candidateSha256: canonicalCandidateSha256, bytes: canonicalCandidate.length },
{ file: optimizedFile, sha256: optimizedOldSha256, candidateSha256: optimizedCandidateSha256, bytes: optimizedCandidate.length },
{ file: shippingFile, sha256: optimizedOldSha256, candidateSha256: optimizedCandidateSha256, bytes: optimizedCandidate.length },
],
metadataFiles: metadataEdits.map(({ file, expectedSha, bytes }) => ({ file, sha256: expectedSha, candidateBytes: bytes.length })),
review,
};
if (!apply) {
console.log(JSON.stringify(preview, null, 2));
process.exit(0);
}
const backupRoot = `${reviewDir}/pre-hoto-surface-v2-promotion-backup`;
const proofPath = `${reviewDir}/hoto-surface-v2-promotion-proof.json`;
for (const pathToCheck of [backupRoot, proofPath]) {
await stat(repoPath(pathToCheck)).then(
() => assert.fail(`Refusing to reuse existing promotion output: ${pathToCheck}`),
(error) => assert.equal(error.code, "ENOENT", `Unable to verify promotion output path ${pathToCheck}.`),
);
}
await mkdir(repoPath(backupRoot), { recursive: false });
const protectedFiles = [canonicalFile, optimizedFile, shippingFile, ...metadataEdits.map((edit) => edit.file)];
for (const file of protectedFiles) {
const backupPath = repoPath(path.join(backupRoot, file).replaceAll("\\", "/"));
await mkdir(path.dirname(backupPath), { recursive: true });
await copyFile(repoPath(file), backupPath, constants.COPYFILE_EXCL);
}
// Stage replacement bytes to exclusive sibling files before swapping any live
// path. The reviewed backup above is the recovery point if a later rename fails.
const replacements = [
{ file: canonicalFile, bytes: canonicalCandidate },
{ file: optimizedFile, bytes: optimizedCandidate },
{ file: shippingFile, bytes: optimizedCandidate },
...metadataEdits,
];
const stagedReplacements = [];
for (let index = 0; index < replacements.length; index += 1) {
const replacement = replacements[index];
const temp = `${replacement.file}.surface-v2-${process.pid}-${index}.tmp`;
await stat(repoPath(temp)).then(
() => assert.fail(`Refusing to overwrite a staged promotion file: ${temp}`),
(error) => assert.equal(error.code, "ENOENT", `Unable to verify staged replacement path ${temp}.`),
);
if (replacement.bytes) await writeFile(repoPath(temp), replacement.bytes, { flag: "wx" });
else await copyFile(repoPath(replacement.candidate), repoPath(temp), constants.COPYFILE_EXCL);
stagedReplacements.push({ file: replacement.file, temp });
}
for (const replacement of stagedReplacements) await rename(repoPath(replacement.temp), repoPath(replacement.file));
await writeFile(repoPath(proofPath), `${JSON.stringify({ ...preview, applied: true, backupRoot: backupRoot, proofPath }, null, 2)}\n`, { flag: "wx" });
console.log(JSON.stringify({ ...preview, applied: true, backupRoot, proofPath }, null, 2));
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const stage = "artifacts/playtest/rom-deep-review/water-screen-effects";
const proof = JSON.parse(await readFile(`${stage}/proof.json`, "utf8"));
const browser = JSON.parse(await readFile(`${stage}/browser-proof.json`, "utf8"));
assert.ok(browser.some(row => row.staged && !row.errors.length && row.water.some(layer => layer.screenEffectsReady === 1)));
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
const writeJson = (file, data) => writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
const canonical = proof.files.find(row => row.kind === "canonical");
const shipping = proof.files.find(row => row.kind === "shipping");
const backup = `${stage}/pre-promotion-backup`;
const directories = ["src/assets/game/dungeons/the-origin", "runewaker-export-work/the-origin/converted"];
const edits = [];
for (const directory of directories) {
for (const name of ["import-metadata.json", "optimization-report.json", "conversion-report.json"]) {
const file = `${directory}/${name}`;
const bytes = await readFile(file);
const data = JSON.parse(bytes);
if (name === "import-metadata.json") {
const visual = data.assets.find(row => row.role === "visual");
assert.equal(visual.sha256, shipping.previousSha256);
visual.sha256 = shipping.sha256;
visual.size = shipping.bytes;
data.nativeMaterialSemantics.assembledSourceSha256 = canonical.sha256;
data.continuationReview.waterScreenEffects = proof;
} else if (name === "optimization-report.json") {
assert.equal(data.source.checksum, canonical.previousSha256);
assert.equal(data.optimized.checksum, shipping.previousSha256);
data.source.checksum = canonical.sha256;
data.source.size = canonical.bytes;
data.optimized.checksum = shipping.sha256;
data.optimized.size = shipping.bytes;
data.waterScreenEffects = proof;
} else {
data.continuationIntegrityProof.waterScreenEffects = proof;
}
edits.push({ file, bytes, data });
}
}
const nativeFile = "src/assets/game/dungeons/the-origin/native-water-report.json";
const nativeBytes = await readFile(nativeFile);
const native = JSON.parse(nativeBytes);
native.waterScreenEffects = proof;
native.nativeMaterialSemantics.viewportMaterial = "Source-enabled browser reflection/refraction with native split planes, .025 bump perturbation and Fresnel .02037/.97963 coefficients. Three.js lighting replaces native SH; reflect-class filtering is unsupported.";
native.limitations = native.limitations.filter(row => !row.includes("projected screen reflection/refraction paths"));
native.limitations.push("Browser screen effects use up to four 512px plane pairs per asset and at most ten two-pass captures per second. Additional planes and below-plane views retain the lit animated fallback. Native SH and reflect-class filtering remain unsupported.");
edits.push({ file: nativeFile, bytes: nativeBytes, data: native });
// Verify every expected old/new identity before any canonical mutation.
for (const row of proof.files) {
assert.equal(sha(await readFile(row.file)), row.previousSha256);
assert.equal(sha(await readFile(row.staged)), row.sha256);
}
for (const row of [...proof.files, ...edits]) {
const destination = path.join(backup, row.file);
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(row.file, destination, constants.COPYFILE_EXCL);
}
for (const row of proof.files) await copyFile(row.staged, row.file);
for (const row of edits) await writeJson(row.file, row.data);
console.log(JSON.stringify({ promoted: proof.files.map(row => ({ file: row.file, sha256: row.sha256 })), backup }));
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const magma = process.argv.includes("--magma");
const stage = magma ? "artifacts/playtest/rom-room-coverage/lair-magma-alpha-stage" : "artifacts/playtest/rom-room-coverage/native-alpha-stage";
const slugs = magma ? ["lair-demon-dragon"] : ["cave-water-dragon", "kulech-bones-nest", "mystic-altar", "raksha-temple", "sardo-castle", "tomb-seven-heroes"];
const proofKey = magma ? "nativeMagmaAlpha" : "nativeWaterfallAlpha";
const backup = `${stage}/pre-promotion-backup`;
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
const json = async file => JSON.parse(await readFile(file, "utf8"));
const copies = [];
const edits = [];
const proofs = [];
const visuals = await json(`${stage}/${magma ? "lair-magma-alpha-ab.json" : "gate-waterfall-triangle-pairs-report.json"}`);
for (const slug of slugs) {
const directory = magma ? stage : `${stage}/${slug}`;
const reportFile = `${directory}/${magma ? "lair-magma-alpha-stage-report.json" : "native-waterfall-alpha-stage-report.json"}`;
const report = await json(reportFile);
assert.equal(report.dungeonId, slug);
const visual = magma ? visuals : visuals.dungeons.find(row => row.slug === slug);
if (magma) {
for (const value of Object.values(visual.checks)) assert.equal(value, true);
assert.equal(visual.candidateSha256, report.optimized.sha256);
assert.equal(report.optimized.targetAlphaMode, "MASK");
assert.equal(report.optimized.targetAlphaCutoff, 192 / 255);
assert.equal(report.optimized.targetUnlit, true);
} else {
assert.equal(visual?.status, "direct-hit-paired", `${slug}: require a same-camera visible waterfall pair`);
assert.equal(visual.errors.length, 0);
assert.equal(visual.afterFirstHit.transparent, true, `${slug}: candidate must actually supply native blending`);
assert.ok(visual.afterFirstHit.nativeSourceModel, `${slug}: candidate provenance missing from runtime hit`);
assert.equal(visual.stagedResponseSha256, report.optimized.sha256);
}
assert.ok(report.alphaPatch.bindingCount > 0);
assert.equal(report.alphaPatch.binBytesUnchanged, true);
assert.equal(report.alphaPatch.beforeBinSha256, report.alphaPatch.afterBinSha256);
const layout = await json(`${directory}/final-source-layout-proof.json`);
assert.equal(layout.geometryAndHierarchyUnchanged, true);
assert.equal(layout.sourceImageBytesUnchanged, true);
assert.equal(layout.sourceSha256, report.sourceVisual.sha256);
assert.equal(layout.stagedSha256, report.stagedSource.sha256);
const alphaImages = magma ? [report.optimized.decodedKtxAlpha] : report.optimized.gateWaterfallKtxAlpha;
assert.ok(alphaImages?.length > 0, `${slug}: decode alpha before promotion`);
for (const image of alphaImages) {
if (!magma) assert.equal(image.hasAlpha, true);
assert.ok(image.decodedAlphaRange[0] < image.decodedAlphaRange[1] && image.decodedAlphaRange[1] <= 255);
if (!magma) assert.ok(image.decodedAlphaRange[1] < 255);
}
for (const source of report.sourceTextureProvenance) {
assert.equal(sha(await readFile(source.sourceTexturePath)), source.sourceTextureSha256);
assert.equal(sha(await readFile(source.sourcePngPath)), source.sourcePngSha256);
}
assert.equal(sha(await readFile(report.sourceVisual.file)), report.sourceVisual.sha256);
const canonicalBytes = await readFile(report.stagedSource.file);
assert.equal(sha(canonicalBytes), report.stagedSource.sha256);
assert.equal(canonicalBytes.length, report.stagedSource.bytes);
assert.equal(sha(await readFile(report.optimized.file)), report.optimized.sha256);
const ship = `src/assets/game/dungeons/${slug}`;
const converted = `runewaker-export-work/${slug}/converted`;
const oldShipping = sha(await readFile(`${ship}/${slug}-visual.glb`));
assert.equal(sha(await readFile(`${converted}/${slug}-visual.optimized.glb`)), oldShipping);
copies.push(
{ file: report.sourceVisual.file, staged: report.stagedSource.file, previousSha256: report.sourceVisual.sha256, sha256: report.stagedSource.sha256 },
...[`${ship}/${slug}-visual.glb`, `${converted}/${slug}-visual.optimized.glb`].map(file => ({ file, staged: report.optimized.file, previousSha256: oldShipping, sha256: report.optimized.sha256 })),
);
const proof = {
stageReport: reportFile,
canonicalBefore: report.sourceVisual.sha256, canonicalAfter: report.stagedSource.sha256,
shippingBefore: oldShipping, shippingAfter: report.optimized.sha256,
bindingCount: report.alphaPatch.bindingCount,
sourceTextureProvenance: report.sourceTextureProvenance,
alphaPatch: report.alphaPatch,
finalSourceLayout: layout,
directVisualProof: visual,
decodedKtxAlpha: alphaImages,
};
proofs.push({ dungeonId: slug, ...proof });
for (const directory of [ship, converted]) {
for (const name of ["import-metadata.json", "optimization-report.json", "conversion-report.json"]) {
const file = `${directory}/${name}`;
const bytes = await readFile(file).catch(error => {
if (directory === converted && name === "import-metadata.json" && error.code === "ENOENT") return null;
throw error;
});
if (!bytes) continue;
const data = JSON.parse(bytes);
if (name === "import-metadata.json") {
const visual = data.assets.find(row => row.role === "visual");
if (directory === ship) assert.equal(visual.sha256, oldShipping);
visual.sha256 = report.optimized.sha256;
visual.size = report.optimized.bytes;
data.continuationReview ??= {};
data.continuationReview[proofKey] = proof;
if (data.nativeMaterialSemantics?.assembledSourceSha256) data.nativeMaterialSemantics.assembledSourceSha256 = report.stagedSource.sha256;
} else if (name === "optimization-report.json") {
if (directory === ship) assert.equal(data.optimized.checksum, oldShipping);
data.source.checksum = report.stagedSource.sha256;
data.source.size = report.stagedSource.bytes;
data.optimized.checksum = report.optimized.sha256;
data.optimized.size = report.optimized.bytes;
data.compression = "meshopt+ktx2";
data.textureQuality = await json(report.optimized.textureQualityReport);
data.nativeMaterialSemantics = report.nativeMaterialSemantics;
data[proofKey] = proof;
} else {
data.continuationIntegrityProof ??= {};
data.continuationIntegrityProof[proofKey] = proof;
}
edits.push({ file, bytes, data });
}
}
}
// All old and candidate identities have been checked before the first write.
// Exclusive backups make an accidental repeat fail without losing the baseline.
for (const row of [...copies, ...edits]) {
const destination = path.join(backup, row.file);
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(row.file, destination, constants.COPYFILE_EXCL);
}
for (const row of copies) await copyFile(row.staged, row.file);
for (const row of edits) await writeFile(row.file, `${JSON.stringify(row.data, null, 2)}\n`);
await writeFile(`${stage}/promotion-proof.json`, `${JSON.stringify({ schemaVersion: 1, backup, files: copies, dungeons: proofs }, null, 2)}\n`);
console.log(JSON.stringify({ promoted: slugs, files: copies.length, backup }));
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
// Promotion consumes reviewed, immutable candidates rather than regenerating
// them. Hash guards and exclusive backups protect unrelated continuation work.
const vine = process.argv.includes("--vine");
const stage = vine ? "artifacts/playtest/rom-deep-review/origin-root-card-attribution"
: "artifacts/playtest/rom-deep-review/origin-tree-alpha";
const field = vine ? "originVineAlpha" : "originTreeAlpha";
const cutoff = (vine ? 170 : 192) / 255;
const imageName = vine ? "72d48adc_elves_vine" : "da39b4ca_elves_gaint_tree";
const proof = JSON.parse(await readFile(`${stage}/staged-patch-proof.json`, "utf8"));
const visual = JSON.parse(await readFile(`${stage}/visual-ab-proof.json`, "utf8"));
assert.equal(visual.accepted, true, "Same-camera, served-identity tree review must be accepted first.");
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
function glbParts(bytes) {
assert.equal(bytes.toString("ascii", 0, 4), "glTF");
assert.equal(bytes.readUInt32LE(8), bytes.length);
const jsonLength = bytes.readUInt32LE(12);
const json = JSON.parse(bytes.toString("utf8", 20, 20 + jsonLength));
const binStart = 20 + jsonLength + 8;
return { json, bin: bytes.subarray(binStart) };
}
function verifyVinePayloadPreservation(beforeBytes, afterBytes, patchedMaterials, role) {
const before = glbParts(beforeBytes), after = glbParts(afterBytes);
for (const key of ["accessors", "meshes", "nodes", "scenes", "scene", "textures", "samplers", "extensions"]) {
assert.deepEqual(after.json[key], before.json[key], `Origin ${key} changed outside the vine material/image patch.`);
}
assert.equal(after.json.materials.length, before.json.materials.length);
for (const [index, material] of before.json.materials.entries()) {
const candidate = after.json.materials[index];
if (!patchedMaterials.includes(index)) assert.deepEqual(candidate, material);
else {
const strip = value => { const { alphaMode, alphaCutoff, extras, ...rest } = value; return rest; };
assert.deepEqual(strip(candidate), strip(material));
assert.equal(candidate.alphaMode, "MASK");
assert.equal(candidate.alphaCutoff, cutoff);
for (const [key, value] of Object.entries(material.extras ?? {})) {
if (key !== "semanticRole") assert.deepEqual(candidate.extras?.[key], value);
}
}
}
const target = before.json.images.map((image, index) => image.name === imageName ? index : -1).filter(index => index >= 0);
assert.equal(target.length, role === "canonical-source" ? 2 : 1);
const targetViews = new Set(target.map(index => before.json.images[index].bufferView));
if (role === "canonical-source") assert.ok(after.bin.equals(before.bin), "Canonical vine patch changed source BIN.");
assert.equal(after.json.images.length, before.json.images.length);
for (const [index, image] of before.json.images.entries()) if (!target.includes(index)) assert.deepEqual(after.json.images[index], image);
for (const [index, view] of before.json.bufferViews.entries()) {
if (targetViews.has(index)) continue;
assert.deepEqual(after.json.bufferViews[index], view);
const offset = view.byteOffset ?? 0;
assert.ok(after.bin.subarray(offset, offset + view.byteLength).equals(before.bin.subarray(offset, offset + view.byteLength)),
`Non-target Origin bufferView ${index} changed.`);
}
}
const canonical = proof.stagedCopies.find(row => row.role === "canonical-source");
const shipping = proof.stagedCopies.find(row => row.role === "shipping-copy");
assert.ok(canonical && shipping && proof.stagedCopies.length === 3);
if (vine) {
assert.deepEqual(proof.replacementKtx2.dimensions, [256, 256]);
assert.equal(proof.replacementKtx2.mipLevels, 9);
assert.equal(proof.replacementKtx2.sourcePixelsPassingThreshold, 10407);
assert.equal(proof.replacementKtx2.decodedPixelsPassingThreshold, 10407);
assert.deepEqual(proof.replacementKtx2.thresholdMaskMismatches, { transparentBecamePassing: 0, opaqueBecameFailing: 0 });
assert.equal(digest(await readFile(proof.replacementKtx2.file)), proof.replacementKtx2.sha256);
}
assert.ok(visual.before.servedResponses.some(row => row.status === 200 && row.bodySha256 === shipping.inputSha256));
assert.ok(visual.after.servedResponses.some(row => row.status === 200 && row.bodySha256 === shipping.outputSha256));
assert.deepEqual(visual.before.errors, []);
assert.deepEqual(visual.after.errors, []);
assert.equal(visual.before.targetMaterialFirstHit, true);
assert.equal(visual.after.targetMaterialFirstHit, true);
assert.equal(visual.after.firstHit.texture, imageName);
assert.equal(visual.after.firstHit.alphaTest, cutoff);
assert.deepEqual(visual.before.camera.eye, visual.after.camera.eye);
assert.deepEqual(visual.before.camera.target, visual.after.camera.target);
assert.deepEqual(visual.before.lighting, visual.after.lighting);
for (const phase of [visual.before, visual.after]) {
assert.equal(digest(await readFile(phase.screenshot)), phase.screenshotSha256);
}
assert.equal(proof.gltfPolicy.alphaMode, "MASK");
assert.equal(proof.gltfPolicy.alphaCutoff, cutoff);
assert.equal(proof.gltfPolicy.lighting, "standard and unchanged");
const family = JSON.parse(await readFile(proof.sourceFamilyProof, "utf8"));
assert.equal(family.sourceRows.length, vine ? 2 : 7);
assert.equal(family.sharedDDSIdentity.sha256, proof.sourceTextureDdsSha256);
for (const row of family.sourceRows) {
assert.equal(digest(await readFile(row.nativeExporterReport)), row.nativeExporterReportSha256);
assert.equal(digest(await readFile(row.sourceDDS)), row.sourceDDSSha256);
assert.equal(digest(await readFile(row.preparedPNG)), row.preparedPNGSha256);
}
for (const row of proof.stagedCopies) {
if (!vine || row.role === "canonical-source") assert.equal(row.nonJsonChunksByteIdentical, true);
else assert.equal(row.nonTargetBufferViewsByteIdentical, true);
assert.equal(row.geometryWaterLightingAndAllNonTargetJsonPreserved, true);
const beforeBytes = await readFile(row.input), afterBytes = await readFile(row.output);
assert.equal(digest(beforeBytes), row.inputSha256, `${row.input} changed since staging.`);
assert.equal(digest(afterBytes), row.outputSha256, `${row.output} changed since review.`);
if (vine) verifyVinePayloadPreservation(beforeBytes, afterBytes, row.patchedMaterialIndexes, row.role);
}
const review = { stagedProof: `${stage}/staged-patch-proof.json`, visualProof: `${stage}/visual-ab-proof.json`, sourceFamilyProof: proof.sourceFamilyProof, sourceTextureDdsSha256: proof.sourceTextureDdsSha256, canonicalSha256: canonical.outputSha256, shippingSha256: shipping.outputSha256, alphaMode: "MASK", alphaCutoff: cutoff, geometryWaterLightingAndAllNonTargetJsonPreserved: true };
const edits = [];
for (const directory of ["src/assets/game/dungeons/the-origin", "runewaker-export-work/the-origin/converted"]) {
for (const name of ["import-metadata.json", "optimization-report.json", "conversion-report.json"]) {
const file = `${directory}/${name}`;
const data = JSON.parse(await readFile(file, "utf8"));
if (name === "import-metadata.json") {
const asset = data.assets.find(row => row.role === "visual");
assert.equal(asset.sha256, shipping.inputSha256);
asset.sha256 = shipping.outputSha256;
asset.size = shipping.outputBytes;
assert.equal(data.nativeMaterialSemantics.assembledSourceSha256, canonical.inputSha256);
data.nativeMaterialSemantics.assembledSourceSha256 = canonical.outputSha256;
data.continuationReview = { ...data.continuationReview, [field]: review };
} else if (name === "optimization-report.json") {
assert.equal(data.source.checksum, canonical.inputSha256);
assert.equal(data.optimized.checksum, shipping.inputSha256);
data.source.checksum = canonical.outputSha256;
data.source.size = canonical.outputBytes;
data.optimized.checksum = shipping.outputSha256;
data.optimized.size = shipping.outputBytes;
data[field] = review;
} else {
data.continuationIntegrityProof = { ...data.continuationIntegrityProof, [field]: review };
}
edits.push({ file, data });
}
}
const backup = `${stage}/pre-promotion-backup`;
for (const file of [...proof.stagedCopies.map(row => row.input), ...edits.map(row => row.file)]) {
const destination = path.join(backup, file);
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file, destination, constants.COPYFILE_EXCL);
}
for (const row of proof.stagedCopies) await copyFile(row.output, row.input);
for (const row of edits) await writeFile(row.file, `${JSON.stringify(row.data, null, 2)}\n`);
await writeFile(`${stage}/promotion-proof.json`, `${JSON.stringify({ promoted: proof.stagedCopies.map(row => ({ file: row.input, sha256: row.outputSha256 })), backup, review }, null, 2)}\n`);
console.log(JSON.stringify({ promoted: proof.stagedCopies.map(row => row.input), backup }));
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const stage = "artifacts/playtest/rom-deep-review/uv-stretch-review";
const manifestFile = process.argv[2] ?? `${stage}/final-surface-policy-stage-manifest.json`;
const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
const json = async file => JSON.parse(await readFile(file, "utf8"));
assert.deepEqual(manifest.dungeons.map(row => row.dungeonId).sort(), ["barren-caves", "hoto"]);
const copies = [], edits = [], reviews = [];
for (const row of manifest.dungeons) {
const id = row.dungeonId;
const canonical = await readFile(row.canonical.file);
const optimized = await readFile(row.optimized.file);
const shipping = await readFile(row.shipping.file);
assert.equal(digest(canonical), row.canonical.oldSha256);
assert.equal(digest(optimized), row.optimized.oldSha256);
assert.equal(digest(shipping), row.shipping.oldSha256);
// Historical converted copies can predate later shipping compression. Guard
// each baseline independently; promotion gives both roles one reviewed output.
for (const asset of [row.canonical, row.optimized]) {
const bytes = await readFile(asset.candidate);
assert.equal(digest(bytes), asset.candidateSha256);
assert.equal(bytes.length, asset.candidateBytes);
}
const layout = await json(row.sourceLayoutProof);
assert.equal(digest(await readFile(layout.beforeFile)), layout.beforeSha256);
assert.equal(layout.afterSha256, row.canonical.candidateSha256);
assert.equal(layout.hierarchyAndTransformsPreserved, true);
assert.equal(layout.orderedSourceTriangleCornersPreserved, true);
assert.equal(layout.allSourceAttributesExceptAdaptedUv1Preserved, true);
assert.equal(layout.originalSourceImagesPreserved, true);
const policy = await json(row.policyReport);
assert.equal(policy.sourceCanonicalSha256, row.canonical.oldSha256);
assert.equal(policy.outputSha256, row.canonical.candidateSha256);
assert.equal(digest(await readFile(policy.sourceRos)), policy.sourceResourceSha256);
assert.equal(policy.policyReport.surfaceQuality.status, "applied");
const quality = await json(row.textureQualityReport);
assert.deepEqual(quality.errors, []);
assert.equal(quality.policy.preservesSourceDimensionsAndFullMipChains, true);
const codec = (await json(row.codecProof)).results.find(result => result.id === id);
assert.ok(codec && codec.textures.length >= (id === "hoto" ? 4 : 6));
assert.equal(codec.glbSha256, row.optimized.candidateSha256);
assert.ok(codec.textures.every(texture => texture.maeAlpha === 0));
const visual = await json(row.visualProof);
assert.equal(visual.status, "passed");
const views = visual.results.filter(result => result.dungeonId === id);
assert.ok(views.filter(result => result.stage === "candidate").length >= 2);
for (const view of views) {
assert.deepEqual(view.errors, []);
assert.equal(view.glbSha256, view.stage === "candidate" ? row.optimized.candidateSha256 : row.shipping.oldSha256);
assert.ok((await readFile(view.screenshot)).length > 1000);
if (view.stage === "candidate") assert.ok(view.candidateResponses.some(response =>
response.status === 200 && response.bodySha256 === row.optimized.candidateSha256));
}
const review = { manifest: manifestFile, policyReport: row.policyReport, sourceLayoutProof: row.sourceLayoutProof,
textureQualityReport: row.textureQualityReport, codecProof: row.codecProof, visualProof: row.visualProof,
canonicalBefore: row.canonical.oldSha256, canonicalAfter: row.canonical.candidateSha256,
shippingBefore: row.shipping.oldSha256, shippingAfter: row.optimized.candidateSha256 };
reviews.push({ dungeonId: id, ...review });
copies.push({ file: row.canonical.file, candidate: row.canonical.candidate },
{ file: row.optimized.file, candidate: row.optimized.candidate },
{ file: row.shipping.file, candidate: row.optimized.candidate });
for (const directory of [`src/assets/game/dungeons/${id}`, `runewaker-export-work/${id}/converted`]) {
for (const name of ["import-metadata.json", "optimization-report.json", "conversion-report.json"]) {
const file = `${directory}/${name}`;
const data = await json(file).catch(error => {
if (error.code === "ENOENT" && directory.startsWith("runewaker-export-work") && name === "import-metadata.json") return null;
throw error;
});
if (!data) continue;
if (name === "import-metadata.json") {
const asset = data.assets.find(asset => asset.role === "visual");
assert.equal(asset.sha256, row.shipping.oldSha256);
asset.sha256 = row.optimized.candidateSha256;
asset.size = row.optimized.candidateBytes;
if (data.nativeMaterialSemantics?.assembledSourceSha256) data.nativeMaterialSemantics.assembledSourceSha256 = row.canonical.candidateSha256;
data.continuationReview = { ...data.continuationReview, surfaceQuality: review };
} else if (name === "optimization-report.json") {
if (directory.startsWith("src/")) assert.equal(data.optimized.checksum, row.shipping.oldSha256);
data.source = { ...data.source, checksum: row.canonical.candidateSha256, size: row.canonical.candidateBytes };
data.optimized = { ...data.optimized, checksum: row.optimized.candidateSha256, size: row.optimized.candidateBytes };
data.compression = "meshopt+ktx2";
data.textureQuality = quality;
data.supersededVisualUvAdaptations ??= data.visualUvAdaptations;
data.visualUvAdaptations = policy.policyReport;
data.commands = [
"applyRunewakerVisualUvAdaptationsToFile with the source-pinned visualSurfaceQualityPolicies recipe opt-in",
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false --join false --prune false --prune-attributes false",
"node scripts/runewaker-pipeline/compress-environment-textures.mjs <optimized.glb> --concurrency 1 --jobs 2",
];
data.surfaceQuality = review;
} else data.continuationIntegrityProof = { ...data.continuationIntegrityProof, surfaceQuality: review };
edits.push({ file, data });
}
}
}
// Validate every current/candidate identity before the first mutation. Exclusive
// backups preserve the complete previous assets and prevent accidental replays.
const backup = `${stage}/pre-surface-promotion-backup`;
for (const row of [...copies, ...edits]) {
const target = path.join(backup, row.file);
await mkdir(path.dirname(target), { recursive: true });
await copyFile(row.file, target, constants.COPYFILE_EXCL);
}
for (const row of copies) await copyFile(row.candidate, row.file);
for (const row of edits) await writeFile(row.file, `${JSON.stringify(row.data, null, 2)}\n`);
await writeFile(`${stage}/surface-promotion-proof.json`, `${JSON.stringify({ manifest: manifestFile, backup, reviews, copies }, null, 2)}\n`);
console.log(JSON.stringify({ promoted: reviews.map(row => row.dungeonId), backup }));
@@ -784,7 +784,25 @@
"ROM_Global.dbo.NPCData source coordinates",
"Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
],
"reviewRequired": true
"reviewRequired": true,
"omitNativeGroundedPointIndices": [
0
],
"nativeGroundOmissionEvidence": {
"sourcePointIndex": 0,
"reason": "Native station/statue landing supports the original entry; omit generated floor at that anchor",
"primaryRosSha256": "2d56edc6da1693a368ff2994c142650b16c3597a65f2ab07947c3d067ee06780",
"nativeNavProjectionMeters": 0.12105,
"report": "runewaker-export-work/aeternal-circle/inspection/aeternal-native-entry-roi-probe.json",
"gameplayReviewRequired": false,
"ordinaryGameplay": {
"artifact": "artifacts/playtest/rom-continuation/support-packaged-gameplay/results.json",
"legsReached": 3,
"groundedSamples": 9,
"exploredMeters": 9.736700779426137,
"scope": "Short entry walking only; no full-clear certification."
}
}
},
"modelSelection": {
"rule": "largest unique model/dungeon ROS",
@@ -803,6 +821,14 @@
"direction": 0,
"reviewRequired": false,
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
},
"wdbAssembly": {
"mode": "primary-only",
"reason": "Reviewed native primary ROS with explicit source-anchor support geometry."
},
"primaryNativeCollision": {
"kind": "runewaker-native-core-and-source-supports",
"collisionMode": "respect-options"
}
},
"coordinateSystem": {
@@ -824,14 +850,14 @@
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "native-options-and-corrected-source-supports-entry-reviewed",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"Collision initially includes decorative primary-ROS geometry.",
"Physical collision honors native entity collision options; generated source-anchor supports remain explicit fallback geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
]
@@ -2,6 +2,9 @@
"schemaVersion": 2,
"slug": "barren-caves",
"title": "Barren Caves",
"visualSurfaceQualityPolicies": [
"rom-barren-caves-wretched-tunnel-surface-quality-v2"
],
"source": {
"rootEnvironmentVariable": "RUNEWAKER_ROOT",
"relativeDefault": "../rom-pvt-server-backups/Runewaker",
@@ -973,6 +973,14 @@
"direction": 180,
"reviewRequired": false,
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
},
"wdbAssembly": {
"mode": "primary-only",
"reason": "Reviewed native primary ROS with explicit source-anchor support geometry."
},
"primaryNativeCollision": {
"kind": "runewaker-native-core-and-source-supports",
"collisionMode": "respect-options"
}
},
"coordinateSystem": {
@@ -990,19 +998,21 @@
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"tileSize": 64,
"retainTileIntermediates": false
"retainTileIntermediates": false,
"includeDetourTopology": true
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "native-options-and-corrected-source-supports-entry-reviewed",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"Collision initially includes decorative primary-ROS geometry.",
"Physical collision honors native entity collision options; generated source-anchor supports remain explicit fallback geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
]
],
"derivedSupportStylingPolicy": "rom-demon-stronghold-derived-support-entrance-floor-02"
}
@@ -2,6 +2,9 @@
"schemaVersion": 2,
"slug": "hoto",
"title": "HOTO",
"visualSurfaceQualityPolicies": [
"rom-hoto-source-rock-detail-v2"
],
"source": {
"rootEnvironmentVariable": "RUNEWAKER_ROOT",
"relativeDefault": "../rom-pvt-server-backups/Runewaker",
@@ -1293,6 +1293,14 @@
"direction": 359,
"reviewRequired": false,
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
},
"wdbAssembly": {
"mode": "primary-only",
"reason": "Reviewed native primary ROS with explicit source-anchor support geometry."
},
"primaryNativeCollision": {
"kind": "runewaker-native-core-and-source-supports",
"collisionMode": "respect-options"
}
},
"coordinateSystem": {
@@ -1310,18 +1318,20 @@
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"tileSize": 64,
"retainTileIntermediates": false
"retainTileIntermediates": false,
"includeDetourTopology": true
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "native-options-and-corrected-source-supports-entry-reviewed",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
},
"derivedSupportStylingPolicy": "rom-mystic-altar-derived-support-mystic-floor",
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"Collision initially includes decorative primary-ROS geometry.",
"Physical collision honors native entity collision options; generated source-anchor supports remain explicit fallback geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
]
@@ -13,33 +13,8 @@
-107.254128,
-34.0010147
],
"proceduralTerrainFallback": {
"kind": "flat-source-evidence-arena",
"reason": "The authoritative WDB contains placed props but no model/dungeon terrain ROS.",
"sourceBounds": {
"min": [
-664.9794238,
0,
-716.2595060000001
],
"max": [
180.7593881,
0,
117.0531387
]
},
"floorYSource": -102.1572036743164,
"paddingSourceUnits": 20,
"thicknessSourceUnits": 4,
"evidence": [
"ObjectEdit.dbo.ZoneObjectDB.mapfile",
"WDB descriptor world bounds",
"Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
],
"reviewRequired": true
},
"modelSelection": {
"rule": "largest WDB prop retained as the coordinate anchor; playable terrain generated from authoritative WDB bounds",
"rule": "largest WDB prop retained as coordinate anchor; native terrain recovered from WDB chunk tables",
"score": 21.020558,
"placements": 1,
"dominance": 1.0569
@@ -55,6 +30,31 @@
"direction": 43,
"reviewRequired": false,
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
},
"nativeTerrain": {
"kind": "runewaker-wdb-terrain",
"cliffProjection": {
"kind": "source-scaled-side-projection",
"reason": "Native XZ projection visibly stretches steep canyon faces; retain floor UVs and splat weights while blending height-aware side projections on steep slopes."
},
"sourceBounds": {
"min": [
-664.9794238,
0,
-716.2595060000001
],
"max": [
180.7593881,
0,
117.0531387
]
},
"selection": "all native chunks intersecting authoritative encounter bounds",
"evidence": [
"WDB header/chunks/texture_list tables",
"RuTerrain_Chunk::SerializeFrom",
"RuShader_SM20EZ_Terrain.cpp"
]
}
},
"coordinateSystem": {
@@ -75,16 +75,15 @@
"retainTileIntermediates": false
},
"review": {
"environment": "authoritative-wdb-has-no-terrain-procedural-fallback-review-required",
"environment": "native-wdb-terrain-and-all-available-ros",
"collision": "generated-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-available-ros"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"Collision initially includes decorative primary-ROS geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"The source WDB has no terrain ROS; a flat playable arena is derived from source descriptor bounds and must be visually reviewed."
"Native terrain uses original chunk topology, packed normals, tile scales, and blend weights; reviewed encounter bounds select nine terrain chunks from the broader zone."
]
}
@@ -8,6 +8,28 @@
"resourceRoot": "Resource",
"wdb": "Resource/wdb/dungeon/dgn_the_origin.wdb",
"model": "model/dungeon/the_origin/cathedral_01_b.ros",
"nativeTerrain": {
"kind": "runewaker-wdb-terrain",
"sourceBounds": {
"min": [
0.001,
0,
0.001
],
"max": [
7679.999,
0,
7679.999
]
},
"allowUnparsedWaterPayload": true,
"waterPayloadNote": "Ground conversion preserves water payloads for the separate native water decoder; all selected water layers are decoded by source.nativeWater.",
"partitionVisualChunks": true,
"cliffProjection": {
"kind": "source-scaled-side-projection",
"reason": "Preserve native XZ floor mapping and source terrain detail on steep cliff faces."
}
},
"primaryPlacementTranslation": [
3652.60107,
340.348389,
@@ -1043,6 +1065,79 @@
"direction": 343,
"reviewRequired": false,
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
},
"reviewedRuntimeEntrance": {
"sourcePosition": [
1191.3493621451016,
204.22288891288287,
1076.4324733477843
],
"sourceForwardVector": [
16,
0,
20
],
"maximumNavmeshProjectionMeters": 0.5,
"gameplayReviewed": true,
"sourcePathNode": {
"table": "pathds_graphs",
"graphKey": 1026,
"nodeId": 854,
"center": [
1172,
203.90098571777344,
1076
],
"nativeGroundHeight": 203.88973931206598,
"terrainChunk": [
2,
2
],
"terrainCell": [
28,
15
],
"terrainVertexFlags": [
255,
255,
255,
255
]
},
"headingTarget": {
"table": "pathds_graphs",
"graphKey": 1026,
"nodeId": 80,
"center": [
1188,
207.56016540527344,
1096
],
"kind": "source-heading-reference-only"
},
"evidence": {
"sourceWdbSha256": "0595a457bca04ed6ad2537a7158f96ae88f0c688d35723fbf650172fb2370028",
"pathNodeToNativeNavProjectionMeters": 1.9356462382234216,
"priorCandidateGameplayReview": "Node798 was grounded but movement slid against blocking geometry and its camera was obstructed; evidence artifacts/playtest/rom-visual-routes/final-origin-barren.",
"authoredSpawnGameplayReview": "Original Lua spawn and direction remain in provenance; original projected position was blocked.",
"sourcePoseNote": "Runtime sourcePosition is the exact inverse transform of the reviewed nav point, rather than the unprojected path-node center.",
"terrainCollisionTriangles": 8146,
"navigationTriangles": 98298,
"visualHeadingReview": "Positive-X source tangent aims into native floor/forest; reviewed path-node854-positive-x-preview.png. Heading reference node80 is not a proven walking waypoint.",
"ordinaryMovement": {
"artifact": "artifacts/playtest/rom-visual-routes/origin-854-final/results.json",
"exploredMeters": 2.845908186289541,
"legsReached": 2,
"scope": "Two short entrance legs only; party routing farther inside remains blocked, full route/collision certification is outstanding."
}
}
},
"nativeWater": {
"kind": "runewaker-wdb-water-layer"
},
"nativeCollision": {
"kind": "runewaker-serialized-ros-trekker",
"collisionMode": "respect-options"
}
},
"coordinateSystem": {
@@ -1062,7 +1157,8 @@
"tileSize": 512,
"partition": "monotone",
"forceTiled": true,
"retainTileIntermediates": false
"retainTileIntermediates": false,
"includeDetourTopology": true
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
@@ -0,0 +1,245 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFile,
readFile,
readdir,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs";
import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs";
import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs";
import { applyRunewakerDerivedSupportStylingToFile } from "./lib/rom-derived-support-styling-file.mjs";
import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-support-styling.mjs";
import { projectRoot, readJson } from "./lib/recipe.mjs";
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const assetRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
const gltfTransformCli = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
const ktx2Compressor = path.join(projectRoot, "scripts", "runewaker-pipeline", "compress-environment-textures.mjs");
const args = process.argv.slice(2);
const selectedSlug = args.includes("--slug") ? args[args.indexOf("--slug") + 1] : null;
const applyStaged = args.includes("--apply-staged");
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, commandArgs, label) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
cwd: projectRoot,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${label} exited with ${code}.`));
});
});
}
async function collectDungeons() {
const recipeFiles = (await readdir(recipeDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith("-population.json"))
.map((entry) => path.join(recipeDirectory, entry.name));
const recipes = [];
for (const recipeFile of recipeFiles) {
const recipe = await readJson(recipeFile);
if (recipe.slug && recipe.source?.model && recipe.source?.wdb) recipes.push(recipe);
}
recipes.sort((left, right) => left.slug.localeCompare(right.slug));
const expectedRecipes = selectedSlug ? recipes.filter((recipe) => recipe.slug === selectedSlug) : recipes;
if (selectedSlug && !expectedRecipes.length) {
throw new Error(`No converted and shipped environment found for ${selectedSlug}.`);
}
const dungeons = [];
for (const recipe of expectedRecipes) {
const slug = recipe.slug;
const convertedDirectory = path.join(workRoot, slug, "converted");
const source = path.join(convertedDirectory, `${slug}-visual.glb`);
const metadataFile = path.join(assetRoot, slug, "import-metadata.json");
try {
await stat(source);
await stat(metadataFile);
} catch (error) {
throw new Error(`Expected RoM environment ${slug} is incomplete: ${error.message}`);
}
dungeons.push({ slug, recipe, convertedDirectory, source, metadataFile });
}
if (!dungeons.length) throw new Error("No RoM environment recipes were found.");
return dungeons;
}
async function stageDungeon(dungeon, index, count) {
const { slug, convertedDirectory, source } = dungeon;
const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`);
const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json");
let sourceReport;
if (dungeon.recipe.visualUvAdaptationPolicies?.length || dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.derivedSupportStylingPolicy) {
sourceReport = await readJson(path.join(workRoot, slug, "prepared", "export-report.json"));
sourceReport.sourceResourceSha256 ??= (await readJson(dungeon.metadataFile)).source?.modelSha256;
}
const visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile(source, {
dungeonId: slug,
policyIds: dungeon.recipe.visualUvAdaptationPolicies ?? [],
surfaceQualityPolicyIds: dungeon.recipe.visualSurfaceQualityPolicies ?? [],
sourceReport,
});
const supportPolicy = listRunewakerDerivedSupportStylingPolicies().find(p => p.id === dungeon.recipe.derivedSupportStylingPolicy);
const derivedSupportStyling = await applyRunewakerDerivedSupportStylingToFile(source, {
dungeonId: slug, policyId: dungeon.recipe.derivedSupportStylingPolicy, sourceReport,
sourceDdsFile: supportPolicy ? path.join(workRoot, slug, "raw", "textures", supportPolicy.stagedTexture) : null,
});
const nativeMaterialSemantics = await applyNativeMaterialSemantics(source);
const sourceSize = (await stat(source)).size;
await rm(stagedVisual, { force: true });
console.log(`\n[${index}/${count}] Optimizing ${slug} from converted geometry (simplification disabled).`);
await run(
process.execPath,
[gltfTransformCli, ...environmentOptimizationArguments(source, stagedVisual, {
preserveNativeWaterLayers: Boolean(dungeon.recipe.source?.nativeWater),
preserveMaterialVariantCarriers: Boolean(dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.visualUvAdaptationPolicies?.length),
})],
`${slug} glTF optimization`,
);
await run(
process.execPath,
[ktx2Compressor, stagedVisual, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.reoptimized.json")],
`${slug} KTX2 texture compression`,
);
const optimizedBytes = await readFile(stagedVisual);
const report = {
schemaVersion: 1,
dungeonId: slug,
status: "review-required",
source: { file: path.basename(source), size: sourceSize },
optimized: {
file: `${slug}-visual.optimized.glb`,
size: optimizedBytes.length,
checksum: sha256(optimizedBytes),
},
compression: "meshopt+ktx2",
commands: [
"node scripts/runewaker-pipeline/lib/native-material-semantics.mjs <converted.glb> (before optimization)",
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
"node scripts/runewaker-pipeline/compress-environment-textures.mjs <optimized.glb> --concurrency 1 --jobs 2",
],
textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.reoptimized.json"), "utf8")),
nativeMaterialSemantics,
visualUvAdaptations,
derivedSupportStyling,
};
await writeFile(stagedOptimizationReport, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`[${index}/${count}] Staged ${slug}: ${optimizedBytes.length} bytes, SHA-256 ${report.optimized.checksum}.`);
return { ...dungeon, stagedVisual, stagedOptimizationReport };
}
async function validateStagedDungeon(dungeon) {
const { slug, convertedDirectory } = dungeon;
const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`);
const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json");
const stagedBytes = await readFile(stagedVisual);
const stagedReport = JSON.parse(await readFile(stagedOptimizationReport, "utf8"));
if (stagedReport.dungeonId !== slug
|| stagedReport.optimized?.size !== stagedBytes.length
|| stagedReport.optimized?.checksum !== sha256(stagedBytes)) {
throw new Error(`${slug} staged visual and optimization report do not match.`);
}
return { ...dungeon, stagedVisual, stagedOptimizationReport, stagedReport, stagedBytes };
}
async function preparePackage(dungeon) {
const {
slug,
convertedDirectory,
stagedVisual,
stagedOptimizationReport,
metadataFile,
stagedBytes,
} = dungeon;
const shippingDirectory = path.dirname(metadataFile);
const shippingVisual = path.join(shippingDirectory, `${slug}-visual.glb`);
const shippingReport = path.join(shippingDirectory, "optimization-report.json");
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
const visualEntry = metadata.assets?.find((entry) => entry.role === "visual" && entry.fileName === path.basename(shippingVisual));
if (!visualEntry) throw new Error(`${slug} import metadata has no matching visual asset record.`);
const stagedOptimized = path.join(convertedDirectory, `${slug}-visual.optimized.pending.glb`);
const stagedShipping = path.join(shippingDirectory, `.${slug}-visual.pending.glb`);
const stagedShippingReport = path.join(shippingDirectory, ".optimization-report.pending.json");
const stagedMetadata = `${metadataFile}.pending`;
await rm(stagedOptimized, { force: true });
await rm(stagedShipping, { force: true });
await rm(stagedShippingReport, { force: true });
await rm(stagedMetadata, { force: true });
visualEntry.size = stagedBytes.length;
visualEntry.sha256 = sha256(stagedBytes);
metadata.generatedAt = new Date().toISOString();
await copyFile(stagedVisual, stagedOptimized);
await copyFile(stagedVisual, stagedShipping);
await copyFile(stagedOptimizationReport, stagedShippingReport);
await writeFile(stagedMetadata, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
return {
...dungeon,
stagedOptimized,
stagedShipping,
stagedShippingReport,
stagedMetadata,
shippingVisual,
shippingReport,
metadataFile,
};
}
async function applyPackage(dungeon) {
const {
slug,
convertedDirectory,
stagedVisual,
stagedOptimizationReport,
stagedOptimized,
stagedShipping,
stagedShippingReport,
stagedMetadata,
shippingVisual,
shippingReport,
metadataFile,
} = dungeon;
await rename(stagedOptimized, path.join(convertedDirectory, `${slug}-visual.optimized.glb`));
await rename(stagedShipping, shippingVisual);
await rename(stagedShippingReport, shippingReport);
await rename(stagedMetadata, metadataFile);
await rm(stagedVisual, { force: true });
await rm(stagedOptimizationReport, { force: true });
console.log(`[package] ${slug}: visual checksum/size and optimization report refreshed.`);
}
const dungeons = await collectDungeons();
console.log(`[runewaker-reoptimize] ${dungeons.length} environment visual(s) selected.`);
let staged;
if (applyStaged) {
staged = [];
for (const dungeon of dungeons) staged.push(await validateStagedDungeon(dungeon));
const preparedPackages = [];
for (const dungeon of staged) preparedPackages.push(await preparePackage(dungeon));
for (const dungeon of preparedPackages) await applyPackage(dungeon);
console.log(`[runewaker-reoptimize] Applied ${staged.length} staged visuals without re-encoding textures.`);
} else {
staged = [];
for (const [index, dungeon] of dungeons.entries()) {
staged.push(await stageDungeon(dungeon, index + 1, dungeons.length));
}
console.log(`[runewaker-reoptimize] Staged ${staged.length} corrected visuals. After the audit baseline is frozen, re-run with --apply-staged to replace shipped visuals.`);
}
Loaded 100 of 593 files, more files were not shown because too many files have changed in this diff. Show more