Release Healer Man 0.1.21

This commit is contained in:
phenom committed 2026-09-08 10:40:26 -04:00
1 parent 699fc858c9
commit f6f2375145
210 files changed
+976039 -6247

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
# Encounter authoring and fidelity
Healer Man keeps boss abilities, dungeon orchestration, presentation, and source
evidence separate. Boss abilities are modular `EnemyAttackDefinition` records;
dungeon-wide doors, waves, checkpoints, and transitions are modular
`DungeonEncounterDefinition` records. Neither runtime stores Three.js or React
objects, so the same definitions can later be selected or randomized without
rewriting a boss component.
## Adding or editing a boss mechanic
1. Add or edit the attack in the boss entity's `combat.attacks` list. Imported
RuneWaker lists are generated under `src/game/generated`; authored WoW
exceptions live with their dungeon content.
2. Give the mechanic a stable `id`, timing, condition, target, effect, and source
record. Health mechanics use `self-health-percent`; timed mechanics use
`initialCooldownMs` and `repeatCooldownMs`.
3. Author a `telegraph` when the source supplies geometry. Harmful area attacks
without explicit geometry receive a conservative circle/cone/line inference,
but inferred geometry is not described as source-exact.
4. Add a guide override only when the generated explanation needs encounter
context that cannot be derived from the mechanic. The Boss Guide otherwise
reads the runtime definition directly, including its trigger, timer, effect,
telegraph, counterplay, fidelity, and test procedure.
5. Run the focused mechanic, telegraph-geometry, campaign, timeline, and guide
tests, then perform the browser playtest described in `PLAYTEST_NOTES.md`.
Telegraph geometry is shared by presentation and hit resolution. A circle,
frontal cone, or line shown on the floor is therefore the area the resolver
tests when the cast completes. `warningMs` is also enforced as a minimum cast
time, so a telegraph cannot appear after its damage resolves.
## Adding a dungeon encounter rule
Attach a `DungeonEncounterDefinition` to `DungeonDefinition.encounters`. Rules
combine a trigger, optional guards, and reusable actions. Available primitives
cover tick/elapsed time, signals, interactions, entity engagement/death/health,
all-members-dead checks, phases, flags, counters, timers, spawn groups, dynamic
world-object state, objective completion, checkpoints, party teleports, loot,
and signal chaining.
Definitions and snapshots are JSON-safe and stable-ID based. Actions are
idempotent by default. Loot actions must use `persistence: "run"`; this prevents
a wipe or checkpoint reload from duplicating a reward. Declare encounter-owned
mob waves in `DungeonDefinition.encounterSpawnGroups`. Declare doors, switches,
chests, keys, bridges, NPC prompts, and quest objects in
`DungeonDefinition.serverGameObjects`; their collision, sight blocking, visual,
prompt, and allowed interaction states follow the current encounter snapshot.
Online groups use party-leader authority. A follower interaction is sent as a
request, then revalidated by the leader against the follower's synchronized
position, range, line of sight, object state, and object ID before any rule is
dispatched. The leader publishes serializable encounter snapshots to followers.
## Save behavior
Character profiles store a versioned safe dungeon checkpoint. The checkpoint
projection retains only actions marked `checkpoint` or `run`; active timers,
temporary waves, and a half-finished pull are reset. Entering the same dungeon
hydrates that safe projection and its spawn after definitions are registered.
Loading a different dungeon starts at its normal entrance.
## Source fidelity and remaining review
The deterministic ledgers are:
- `scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md`
- `scripts/runewaker-pipeline/RUNEWAKER_GAMEPLAY_CONTENT_REPORT.md`
- `scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md`
- `scripts/runewaker-pipeline/RUNEWAKER_NAVIGATION_GAP_REPORT.md`
Current encounter evidence covers 30 RuneWaker packages, 4,113 deferred source
rows, and 1,524 unique direct evidence records. Of those evidence records, 388
have exact executable spell mappings and 1,136 still need source review. Four
bosses have clearly labeled, playable Healer Man fallback kits because the
preserved data does not expose their original executable behavior. Eight
non-empty packages do not contain source-confirmed boss membership; two
authoritative variants are intentionally empty.
The upgraded WDB environment builder can assemble all referenced ROS resources
with full placement translation, quaternion, and nonuniform scale, source-hash
caching, shared meshes, collision chunking, and a separate dynamic-placement
manifest. The integration pass regenerated Raksha Temple and The Origin through
that full-WDB path; remaining shipping packages retain their last validated
build until they are individually regenerated. The current audit finds 107
filename-only dynamic candidates; they remain static and review-required
because filenames alone do not prove door/switch behavior.
Treasure Trove's 908-placement full-WDB staging export is reproducible and its
487,380-triangle visual/collision assembly, meshopt pass, and KTX2 compression
all succeed. Its 0.3-metre, 512-cell full-resolution Recast navigation bake did
not complete: the watershed run was stopped after nearly two CPU-hours when it
showed the smaller-tile retry signature, and the explicit monotone run was
stopped after more than four CPU-hours on its first pass. The fail-closed
pipeline therefore did not publish that staged package. The last validated
shipping package remains active. The recipe retains the monotone tiled policy
for a future offline bake or a reviewed spatial-partitioning optimization;
lowering navigation resolution was not silently substituted.
The route report contains 19 unreachable legs across eight dungeons, all from a
distance-from-entrance objective-order heuristic. Those legs are evidence that
objective order needs review, not proof that an off-mesh link should exist.
Links are added only when source geometry or a reviewed traversal justifies
them.
Run `npm run runewaker:audit:gameplay`, `npm run
runewaker:audit:environment-assembly`, and `npm run runewaker:audit:navigation`
after importing new source data. A report can be structurally green while its
content status remains `review-required`; this is intentional and prevents an
incomplete source inference from being presented as an original mechanic.
+43
View File
@@ -1685,3 +1685,46 @@ Vite's existing large-chunk advisory remains informational.
passed. The production build completed with 925 modules transformed, KTX2
and environment-performance audits remained green, and the release-candidate
check passed. The existing large-chunk advisory remains informational.
# 2026-09-07 — modular encounter completion and boss guide
- Entered Wailing Caverns through the live offline roster and verified the 3D
dungeon, party HUD, minimap, and buff/debuff strips remained readable. The
encounter timeline occupies the same top-right HUD column beneath those
elements, keeping timed casts and pending health thresholds out of the
central playfield.
- Opened the new Boss Guide from the in-dungeon pause menu. The three-column
field manual listed both WoW and RuneWaker dungeons, their bosses, source
provenance, triggers, timings, target/effect descriptions, warning geometry,
counterplay, and a concrete test procedure for every listed mechanic.
- Selected Archaedas in Uldaman and confirmed all four authored mechanics:
Awaken Earthen Custodian every 10 seconds, the two-second 18-metre Ground
Tremor warning circle, six Earthen Guardians at 70% health, and two Vault
Warders at 40% health. Bosses with no sourced special attack are explicitly
labelled with zero special mechanics instead of receiving invented behavior.
- The encounter authoring layer now drives serializable timers, health gates,
guards, actions, summon groups, chained signals, stateful world objects,
teleports, rewards, checkpoints, and online leader-authoritative interaction
validation. Harmful area mechanics use the same geometry for both the world
warning and hit resolution.
- Browser review found no visible layout or interaction regressions. The only
previously observed browser message remains Rapier's third-party
initialization deprecation warning.
- Source limitations and the exact remaining RuneWaker evidence, navigation,
formula, loot, animation, and asset-fidelity gaps are recorded in the linked
deterministic audit reports and authoring documentation rather than silently
approximated.
- Full-WDB environment packages for Raksha Temple and The Origin completed and
passed their GLB gates. Treasure Trove's 908-placement visual/collision stage
also completed, but its full-resolution 36-tile Recast bake did not terminate
after nearly two CPU-hours on the watershed path and more than four CPU-hours
on the monotone path. The fail-closed pipeline preserved the prior validated
shipping package; this remaining offline bake is documented in
`ENCOUNTER_AUTHORING.md` instead of lowering navigation resolution.
- Verification passed: the complete Vitest suite passed all 981 cases across
185 files; online authority passed all 4 server cases; RuneWaker gameplay,
encounter, environment, navigation, formula/loot, and animation audits passed;
and the production build completed with 955 modules transformed. The KTX2
audit covered 12,347 compressed textures with zero fallbacks, and the
environment-performance audit found no regressions. The existing large-chunk
advisory and Rapier initialization deprecation warning remain informational.
+4
View File
@@ -41,6 +41,10 @@ npm test
npm run build
```
Boss-mechanic and dungeon-event extension points, telegraph rules, checkpoint
behavior, test workflow, and the explicit source-gap ledger are documented in
[`ENCOUNTER_AUTHORING.md`](ENCOUNTER_AUTHORING.md).
## Content downloads
Production browser and Android builds ship a compact required set: the game
+64
View File
@@ -0,0 +1,64 @@
# RuneWaker encounter content catalog
This is the reviewed runtime layer above the exhaustive, machine-generated RuneWaker
encounter audit. It intentionally contains only source behavior that can be mapped without
guessing. The executable catalog is `src/game/runewakerEncounterCatalog.ts`.
## Bounded reviewed coverage
| Dungeon | Deferred source rows mapped | Runtime behavior | Fidelity |
| --- | --- | --- | --- |
| Forsaken Abbey | `12230`, `12236`, `12238` | Three two-member Ghoul/Carrion Spider formations are created on encounter initialization. After the source `sleep(20)` (2 seconds because RuneWaker sleep units are tenths of a second), the encounter enters its patrol phase. | source-backed adapted |
| Treasure Trove | `5952428`, `5952438` | Killing Boulder Sidaar (`5952494`) arms switch `112100`; interacting with it activates the switch and lowers drawbridge `112098` once. | source-backed adapted |
The Forsaken mapping is backed by preserved Lua `00571.lua` and matrix helper `00727.lua`.
The Treasure Trove mapping is backed by preserved Lua `01159.lua`. Snapshot, mechanics-audit,
and source hashes are attached to each catalog review record.
“Source-backed adapted” is deliberate. Source IDs, trigger order, spawn membership, timing,
and placement transforms are retained. HealerMan supplies serializable phases, procedural
world-object geometry, projected heights, interaction range, and the packaged canonical Ghoul
visual/combat definition for the source-only level-scaled Ghoul template `100653`.
## Effective ledger after this review
The generated audit baseline inventories 4,113 deferred source rows and 1,136 unresolved
mechanics-evidence records. This catalog maps five unique deferred rows and clears five stable
evidence IDs, leaving:
| Ledger item | Baseline | Reviewed here | Remaining |
| --- | ---: | ---: | ---: |
| Deferred source rows | 4,113 | 5 | 4,108 |
| Unresolved mechanics-evidence records | 1,136 | 5 | 1,131 |
Per affected package, Forsaken Abbey moves from 101 to 98 deferred rows and 77 to 74
unresolved evidence records. Treasure Trove moves from 188 to 186 deferred rows and 47 to 45
unresolved evidence records. All other package counts remain unchanged.
The exhaustive generated JSON remains the immutable audit baseline until its generator learns
to consume the runtime catalog. The effective counts above are exported as
`RUNEWAKER_ENCOUNTER_CONTENT_LEDGER` and are regression tested.
## Explicit remaining gaps
- Forsaken formations remain at their source start positions after the two-second hold. The
source uses endless flag-path patrols; the encounter runtime has no reviewed path-following
action yet.
- Forsaken source template `100653` is a level-scaled Ghoul variant created only by Lua and has
no packaged actor row. The runtime alias reuses canonical Ghoul `100072`; this is not claimed
as an exact visual or formula mapping.
- The Forsaken keeper gate (`7883`) remains deferred. Its four Necromage deaths source-summon
Ghoul Drake `100073`, but that summon has neither a packaged entity nor a reviewed combat
definition. Opening the gate without that fight would be false fidelity.
- Black Mary's Hold Door (`5952433`) remains deferred because its source interaction consumes
inventory item `203550`; encounter guards currently expose no inventory predicate.
- Treasure Trove uses procedural bridge/switch proxies because source object meshes and ACT
motions are not packaged.
- The remaining 4,108 rows require the same source-by-source review. Bulk promotion from names,
model paths, or regex categories is not evidence of executable behavior.
## Validation
`src/game/runewakerEncounterCatalog.test.ts` validates each encounter definition and requires
every referenced runtime spawn, entity, encounter spawn group, world object state, and objective
to exist. It also simulates both reviewed state machines and locks the ledger arithmetic.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.20",
"version": "0.1.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.20",
"version": "0.1.21",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.20",
"version": "0.1.21",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -76,6 +76,8 @@
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
"runewaker:loot:refresh": "node scripts/runewaker-pipeline/export-loot-catalog.mjs",
"runewaker:audit:formula-loot": "node --test scripts/runewaker-pipeline/runewaker-formula-export.test.mjs scripts/runewaker-pipeline/runewaker-loot-import.test.mjs && vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts",
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
@@ -86,7 +88,9 @@
"runewaker:audit:pose-batch": "node scripts/runewaker-pipeline/audit-animated-poses.mjs",
"runewaker:audit:pose-master": "node scripts/runewaker-pipeline/generate-master-actor-pose-audit.mjs",
"runewaker:audit:animations": "npm run runewaker:audit:animation-readiness && vitest run src/game/runewakerDungeonAnimation.test.ts src/game/mobAnimation.test.ts src/game/runewakerDungeonRegistry.test.ts",
"runewaker:audit:gameplay": "node scripts/runewaker-pipeline/audit-gameplay-content.mjs",
"runewaker:audit:gameplay": "node scripts/runewaker-pipeline/audit-gameplay-content.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs",
"runewaker:audit:encounters": "node scripts/runewaker-pipeline/audit-encounter-implementation.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs",
"runewaker:audit:environment-assembly": "node scripts/runewaker-pipeline/audit-environment-assembly.mjs && node --test scripts/runewaker-pipeline/environment-assembly.test.mjs",
"runewaker:audit:navigation": "node scripts/runewaker-pipeline/analyze-navigation-gaps.mjs && vitest run src/game/runewakerObjectiveRouteAudit.test.ts",
"assets:wailing:instance": "gltf-transform instance ../wow335a/dungeon-export-work/WailingCaverns/converted/wailing-caverns.visual.glb src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb --min 3",
"assets:wailing:creatures:animated": "node scripts/prepareAnimatedCreatures.mjs",
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.20",
"versionCode": 1020
"versionName": "0.1.21",
"versionCode": 1021
}
+21 -9
View File
@@ -28,6 +28,7 @@ import {
import {
nextTiledNavMeshSize,
planNavigationBuild,
shouldRetryFailedTiledBuildWithMonotone,
shouldUseMonotonePartition,
validateTiledBuild,
} from "./recast-policy.mjs";
@@ -275,9 +276,11 @@ export async function bakeNavigationFromCollisionAssets({
const meshes = scenes.flatMap(meshesIn);
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
const input = inspectNavigationInput(meshes);
let build = planNavigationBuild(input.bounds, settings.cellSize, {
tileSize: settings.tileSize,
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
tileSize,
...(settings.forceTiled === true ? { maxSoloAxis: 0, maxSoloCells: 0 } : {}),
});
let build = navigationPlan(settings.tileSize);
const generatorConfig = {
cs: settings.cellSize,
ch: settings.cellHeight,
@@ -325,7 +328,20 @@ export async function bakeNavigationFromCollisionAssets({
retainTileIntermediates,
);
}
if (!generated.success) throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
if (!generated.success) {
buildAttempts.push({
partition: tiledPartition,
tileSize: build.tileSize,
tileCount: build.tileCount,
valid: false,
failures: [{ x: null, y: null, message: generated.error }],
});
if (shouldRetryFailedTiledBuildWithMonotone(generated.error, tiledPartition)) {
tiledPartition = "monotone";
continue;
}
throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
}
tiledValidation = validateTiledBuild(generated.intermediates, {
errorCategory: Recast.RC_LOG_ERROR,
warningCategory: Recast.RC_LOG_WARNING,
@@ -351,9 +367,7 @@ export async function bakeNavigationFromCollisionAssets({
releaseTiledIntermediates(generated.intermediates);
generated.navMesh.destroy();
generated = null;
build = planNavigationBuild(input.bounds, settings.cellSize, {
tileSize: nextTileSize,
});
build = navigationPlan(nextTileSize);
continue;
}
if (
@@ -364,9 +378,7 @@ export async function bakeNavigationFromCollisionAssets({
generated.navMesh.destroy();
generated = null;
tiledPartition = "monotone";
build = planNavigationBuild(input.bounds, settings.cellSize, {
tileSize: settings.tileSize,
});
build = navigationPlan(settings.tileSize);
continue;
}
const sample = tiledValidation.failures
@@ -146,3 +146,8 @@ export function shouldUseMonotonePartition(validation) {
(failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message),
);
}
export function shouldRetryFailedTiledBuildWithMonotone(error, partition) {
return partition === "watershed"
&& /failed to triangulate contours/i.test(String(error ?? ""));
}
@@ -25,16 +25,31 @@ async function main() {
new Uint8Array(fs.readFileSync(file.path)),
{ uri: file.file, maxIssues: 1_000 },
);
const validatorMessages = report.issues.messages;
const hasBasisu = validatorMessages.some((issue) => (
issue.code === "UNSUPPORTED_EXTENSION" && issue.message.includes("'KHR_texture_basisu'")
));
const actionable = validatorMessages.filter((issue) => {
if (issue.code === "UNSUPPORTED_EXTENSION"
&& /'(?:EXT_mesh_gpu_instancing|KHR_texture_basisu)'/.test(issue.message)) return false;
if (issue.code === "VALUE_NOT_IN_LIST"
&& hasBasisu
&& /^\/images\/\d+\/mimeType$/.test(issue.pointer ?? "")
&& issue.message.includes("'image/ktx2'")) return false;
return true;
});
rows.push({
file: file.file,
errors: report.issues.numErrors,
warnings: report.issues.numWarnings,
errors: actionable.filter((issue) => issue.severity === 0).length,
warnings: actionable.filter((issue) => issue.severity === 1).length,
ignoredKnownToolingIssues: validatorMessages.length - actionable.length,
});
}
const result = {
files: rows.length,
errors: rows.reduce((sum, row) => sum + row.errors, 0),
warnings: rows.reduce((sum, row) => sum + row.warnings, 0),
ignoredKnownToolingIssues: rows.reduce((sum, row) => sum + row.ignoredKnownToolingIssues, 0),
nonGreen: rows.filter((row) => row.errors || row.warnings),
};
console.log(JSON.stringify(result, null, 2));
@@ -47,15 +47,23 @@ reviewed mechanics passes.
## Coordinate conversion
RuneWaker server positions are world coordinates. The environment ROS is
placed by one WDB descriptor. Spawns must be moved into that ROS's local frame
before the RuneWaker-to-Three coordinate conversion:
RuneWaker server positions are world coordinates. The reviewed primary WDB
descriptor remains the coordinate anchor, while every static and reviewed
dynamic WDB resource is assembled relative to that anchor. Spawns use:
```text
sourceLocal = inverse(primaryWdbPlacement) * serverPosition
threeLocal = (sourceLocal.x, sourceLocal.y, -sourceLocal.z) * metersPerSourceUnit
```
Every resource placement uses the full matrix equivalent, including translation,
quaternion rotation, and nonuniform scale:
```text
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
```
The current calibrated scale is `0.1` meter per RuneWaker source unit. The
authoritative test-dungeon table uses 360 degrees per turn:
@@ -283,12 +291,17 @@ Review:
- collision chunks for decorative blockers;
- Recast report and entrance projection;
- WDB anchor in `import-metadata.json`;
- complete placement accounting in `wdb-placement-manifest.json`;
- source-evidenced dynamic placements are absent from static collision and have separately packaged runtime assets;
- dynamic filename candidates remain static and review-required until a reviewed recipe rule exists;
- Khronos validation output.
Secondary WDB doodads/effects remain audited. Repeated primary room placements
and documented source-coordinate support/fallback geometry are automated; a
complete arbitrary multi-ROS WDB scene still requires explicit placement,
material, collision, and performance review.
Every valid WDB ROS resource is exported once into
`runewaker-export-work/resource-cache/<resource-key>`, then reused for all of its
placements and across dungeon builds. Missing ROS files, non-ROS descriptors,
export failures, and non-mesh particle/light/sound/script components are recorded
instead of receiving substitute geometry. The preview and editable Blender file
remain primary-ROS diagnostics; review the assembled GLB for final coverage.
### 6. Review and build creature actors
@@ -401,6 +414,13 @@ Each mechanic must specify trigger, targets, timing, reset behavior, persistence
offline behavior, and multiplayer synchronization boundary before being marked
implemented.
Run `npm run runewaker:audit:encounters` after changing source snapshots,
mechanics evidence, generated populations, or the shared boss-mechanic catalog.
Its JSON report is the exhaustive implementation ledger. A source-spawn row or
evidence record may be promoted from
`deferred-source-gap` only when an exact executable mapping and its clearing test
are both present; a regex category or suggestive object name is not sufficient.
### 9. Register the dungeon
Use the generated environment metadata and population constants to create the
@@ -443,6 +463,8 @@ errors, WebGL errors, and mobile/desktop performance.
| `public/assets/creatures/<slug>/*` | Browser-shipping actor GLBs/manifest. |
| `src/game/generated/*Population.generated.ts` | Runtime entities/spawns/objectives. |
| `src/game/generated/*Mechanics.generated.ts` | Non-executable mechanics evidence summary. |
| `scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.*` | Deterministic per-row/per-signal fidelity and blocker ledger. |
| `scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.*` | Non-mutating WDB resource/placement coverage and unsupported-type audit. |
Generated runtime data must be reproducible from recipes plus portable snapshots
without SQL or network access.
@@ -462,13 +484,13 @@ without SQL or network access.
## Current deliberate limits
The generic scripts automate source discovery, primary environment conversion,
The generic scripts automate source discovery, source-hash-validated multi-resource WDB environment conversion,
portable population recovery, direct GR2 actor conversion, exact paperdoll
assembly, source-safe direct-damage attacks, spawn generation, repeated-room
assembly, source-anchor navigation, mechanics evidence extraction, runtime
assembly, full placement transforms, static navigation, reviewed dynamic-object packaging, mechanics evidence extraction, runtime
registration, and validation. They do not pretend that arbitrary Lua/AutoPlot
code can be safely translated without review. Disconnected navmesh islands,
doors, phases, summons, patrols, timers, interactions, loot semantics, arbitrary
multi-ROS placement assembly, source-exact damage formulas, and multiplayer
source behavior for unreviewed dynamic candidates, source-exact damage formulas, and multiplayer
encounter synchronization remain explicit per-instance engineering work until
implemented and playtested.
+57 -11
View File
@@ -3,11 +3,12 @@
This pipeline converts preserved RuneWaker `.ros` dungeon assets into the same
browser-facing GLB roles used by HealerMan's WoW dungeon pipeline:
1. the preserved Rune engine reads the WDB entity table and exports the primary ROS as an OBJ/MTL bridge;
2. Microsoft `texconv` decodes the legacy DDS textures to PNG;
3. Blender converts the Y-up source to textured visual and chunked collision GLBs;
4. glTF Transform optimizes the visual asset with meshopt;
5. HealerMan's Recast stage bakes navigation and the Khronos validator gates every GLB.
1. the preserved Rune engine reads every WDB descriptor and exports each unique ROS once into a normalized-path cache validated by the source SHA-256;
2. Microsoft `texconv` decodes each cached resource's legacy DDS textures to PNG;
3. Blender converts the Y-up resources to reusable visual and collision GLBs;
4. the assembler applies every placement's full translation/quaternion/scale transform relative to the reviewed primary WDB anchor;
5. glTF Transform instances and optimizes the assembled visual with meshopt;
6. HealerMan's Recast stage bakes static navigation and the Khronos validator gates every GLB.
Run the Forsaken Abbey pilot from the HealerMan root:
@@ -24,10 +25,29 @@ Optional environment variables:
The shipping environment is written to
`src/assets/game/dungeons/forsaken-abbey` and registered as a playable
dungeon. The build reads all 184 WDB entity descriptors,
uses the primary ROS placement as the server-to-local anchor, reads the entrance
from `Resource/luascript/03242.lua`, and requires that entrance to project within
2 meters of the generated navmesh.
dungeon. The build reads all 184 WDB entity descriptors, uses the primary ROS
placement only as the server-to-local coordinate anchor, reads the entrance from
`Resource/luascript/03242.lua`, and requires that entrance to project within 2
meters of the generated navmesh.
All valid ROS placement resources are assembled by default. Use `--primary-only`
only for a deliberate compatibility/debug build. The build writes
`wdb-placement-manifest.json` with stable placement IDs, source TRS, the complete
primary-local Three.js matrix, resource-cache keys, bounds, flags, asset status,
and reviewed dynamic-object metadata. Static placements enter visual, collision,
and navigation assets. Explicit dynamic placements ship separately so runtime
state can move, hide, or replace them without baking their collision into the
static world.
Audit the checked-in WDB coverage without regenerating GLBs:
```powershell
npm run runewaker:audit:environment-assembly
```
See
[RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md](./RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md)
for unsupported types and dynamic candidates that still need source review.
Population and creature assets are separate, offline-capable stages:
@@ -36,6 +56,15 @@ npm run runewaker:forsaken:actors
npm run runewaker:forsaken:population
```
Source-shaped combat coefficients and boss loot are refreshed independently:
```powershell
npm run runewaker:spells:refresh
npm run runewaker:loot:refresh
```
See [RUNEWAKER_FORMULA_AND_LOOT.md](./RUNEWAKER_FORMULA_AND_LOOT.md) for the exact formula mapping boundary and [RUNEWAKER_LOOT_IMPORT_REPORT.md](./RUNEWAKER_LOOT_IMPORT_REPORT.md) for every boss's loot status.
Only an explicit forensic refresh connects to the isolated development SQL
instance:
@@ -121,8 +150,10 @@ Spell conversion is deliberately fail-closed. Only a MagicObject component
positively classified as direct HP damage becomes an executable HealerMan
attack. Buffs, healing, summons, plot hooks, and unknown effects remain
evidence-only. The portable mapping preserves source spell identity, name,
damage family, school, range, and cooldown. The current 0.85 damage coefficient
is HealerMan-authored and is not represented as the original RuneWaker formula.
damage family, school, range, cooldown, source power fields, and source-code
formula provenance. Live damage uses a labeled HealerMan baseline adapter because
the required native ATK/MATK, DEF/MDEF, weapon, resistance, critical, level-rate,
and global-rate inputs are not packaged; it is not represented as source-exact.
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
statically follows each packaged boss's preserved AutoPlot/template callbacks
@@ -165,6 +196,21 @@ accepts that reviewed override only as `bossObjectiveOrder: { kind:
"source-authored", spawnIds: [...], evidence: [...] }`; it rejects missing
evidence, duplicates, or an order that does not account for every boss spawn.
The same gameplay audit also regenerates the deterministic
[RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md](./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md)
and its exhaustive JSON companion. The ledger records all deferred source spawn
rows and direct mechanics evidence with stable IDs, source files and hashes,
fidelity, implementation status, blocker, next work, and a clearing test.
Category totals are overlapping review labels, never a claimed count of unique
mechanics. Unproven behavior remains `deferred-source-gap` instead of being
silently inferred from a name or regex match.
Run only that ledger and its pipeline tests with:
~~~powershell
npm run runewaker:audit:encounters
~~~
Run the component-level navigation review separately:
~~~powershell
+20 -1
View File
@@ -13,7 +13,7 @@ Required top-level fields:
- `title`: display/source title.
- `source`: preserved WDB, primary ROS, config, and entrance evidence.
- `coordinateSystem`: source axes, units, and direction units.
- `navigation`: Recast settings.
- `navigation`: Recast settings. `partition` may select `watershed` or `monotone`; `forceTiled` is an explicit source-geometry workaround when a compact solo heightfield cannot triangulate its contours.
- `review`: human review states.
- `exceptions`: known limitations retained in metadata.
@@ -27,6 +27,25 @@ Required top-level fields:
- `entryLua` / `entryConstantPrefix`: authoritative entry constants; or
- `entryCoordinates`: explicit position/direction with `reviewRequired`.
Optional `source.wdbAssembly` fields:
- `mode`: `all-ros` by default, or `primary-only` for a deliberate compatibility build.
- `dynamicPlacements`: reviewed dynamic object selectors. Each entry requires a
stable `id`, `kind`, `evidence`, and either an exact WDB `guid` or normalized
`resource`; optional `initialState` and `collision` describe the runtime contract.
Valid ROS placements not selected as dynamic are static. Filename hints are
reported for review but never infer stateful behavior. Non-ROS descriptors,
invalid transforms, missing resources, and exporter failures remain explicit
unsupported records in the placement manifest.
The full placement transform is preserved as:
```text
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
```
## Population recipe
Required fields:
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,121 @@
# RuneWaker encounter implementation and fidelity ledger
Structural validation: **green**
Content completion: **review-required**
This report is deterministic and offline. Its JSON companion inventories every deferred
source spawn row and every direct mechanics evidence record. A regex category membership
is a review label, not a unique mechanic; one evidence record may belong to several categories.
## Coverage
- 30 dungeon packages.
- 4113 deferred source rows with stable spawn IDs.
- 1524 unique mechanics evidence records: 329 Lua lines and 1195 template references.
- 1146 overlapping category memberships; this number is intentionally not reported as mechanics.
- 388 evidence records have an exact executable source-spell mapping.
- 1136 evidence records remain source-review gaps.
## Status definitions
- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.
- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.
- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.
- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.
- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.
- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.
- `intentional-empty`: the authoritative packaged snapshot contains no population rows.
## Per-dungeon inventory
| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| aeternal-circle | 514 | 391 | 47 | 13 | 34 | 0 | deferred-source-gap |
| barren-caves | 63 | 15 | 14 | 7 | 7 | 1 | verified |
| cave-water-dragon | 144 | 47 | 15 | 2 | 13 | 2 | verified |
| demon-stronghold | 439 | 256 | 38 | 12 | 26 | 1 | verified |
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
| dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap |
| forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified |
| grafu-castle | 249 | 193 | 50 | 10 | 40 | 0 | deferred-source-gap |
| hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified |
| hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified |
| ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified |
| kal-turok-hive | 4 | 3 | 10 | 4 | 6 | 0 | deferred-source-gap |
| kalins-shrine | 289 | 124 | 294 | 17 | 277 | 5 | verified |
| kawaks-tomb | 293 | 176 | 46 | 7 | 39 | 1 | verified |
| kulech-bones-nest | 287 | 133 | 59 | 18 | 41 | 1 | verified |
| lair-demon-dragon | 532 | 429 | 28 | 9 | 19 | 0 | deferred-source-gap |
| mystic-altar | 398 | 147 | 68 | 26 | 42 | 3 | verified |
| paspers-shrine | 255 | 31 | 22 | 6 | 16 | 5 | verified |
| raksha-temple | 487 | 364 | 85 | 21 | 64 | 1 | verified |
| sand-vortex | 2 | 1 | 2 | 0 | 2 | 1 | verified |
| sardo-castle | 247 | 176 | 53 | 12 | 41 | 3 | verified |
| the-origin | 510 | 322 | 65 | 29 | 36 | 2 | verified |
| tomb-seven-heroes | 439 | 199 | 101 | 32 | 69 | 0 | deferred-source-gap |
| treasure-trove | 685 | 188 | 81 | 34 | 47 | 3 | verified |
| warnorken-arena | 40 | 39 | 2 | 0 | 2 | 0 | deferred-source-gap |
| ystra-labyrinth-boss1 | 17 | 14 | 12 | 2 | 10 | 3 | verified |
| ystra-labyrinth-boss2 | 3 | 2 | 3 | 1 | 2 | 1 | verified |
| ystra-labyrinth-boss3 | 7 | 6 | 2 | 0 | 2 | 1 | verified |
| zurhidon-stronghold | 400 | 247 | 58 | 23 | 35 | 0 | deferred-source-gap |
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
## Four explicit fallback bosses
| Boss | Runtime ID | Status | Fidelity | Blocker |
| --- | --- | --- | --- | --- |
| Necromage | `rw-fa-100070` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Flowing Chaos | `rw-fa-100561` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Cruel Eater | `rw-fa-100562` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Hollow Shell | `rw-fa-100563` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
These attacks are intentionally playable and telegraphed HealerMan behavior. They are not
presented as original RuneWaker mechanics until authoritative source behavior is recovered.
## Non-empty packages without confirmed boss objectives
- `aeternal-circle`: 514 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `dungeon-of-dalanis`: 229 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `grafu-castle`: 249 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `kal-turok-hive`: 4 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `lair-demon-dragon`: 532 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `tomb-seven-heroes`: 439 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `warnorken-arena`: 40 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `zurhidon-stronghold`: 400 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
## Intentional empty variants
- `demon-stronghold-125` (Zone 125): The authoritative packaged snapshot contains no active population rows.
- `zurhidon-stronghold-124` (Zone 124): The authoritative packaged snapshot contains no active population rows.
## Evidence category memberships
| Category | Evidence records carrying the label |
| --- | ---: |
| death | 100 |
| door | 23 |
| interaction | 166 |
| loot | 77 |
| movement | 6 |
| phase | 58 |
| spell | 679 |
| summon | 11 |
| timer | 26 |
## Remaining work ledger
| Blocker group | Items | Clearing work |
| --- | ---: | --- |
| deferredSourceRows | 4113 | Review source semantics and add explicit source-spawn encounter mappings. |
| unresolvedMechanicsEvidence | 1136 | Review surrounding Lua/template context; specify full trigger, effect, and reset behavior. |
| adaptedSpellFormula | 388 | Recover native formula inputs before claiming coefficient-level fidelity. |
| sourceGapFallbackBosses | 4 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
| nonEmptyZeroBossPackages | 8 | Recover authoritative boss membership and objective order. |
The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source
files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.
## Validation issues
- None.
@@ -0,0 +1,476 @@
{
"schemaVersion": 1,
"generatedBy": "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
"deterministic": true,
"validationStatus": "green",
"reviewStatus": "review-required",
"summary": {
"dungeons": 30,
"sourceDescriptors": 15976,
"uniqueDungeonResources": 2799,
"staticPlacements": 15976,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 107,
"unsupportedPlacements": 0,
"missingSourceResources": 0
},
"unsupportedResourceTypes": [
"WDB placement resources other than ROS are recorded but not converted.",
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry."
],
"issues": [],
"dungeons": [
{
"id": "aeternal-circle",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle.json",
"wdbReport": "src/assets/game/dungeons/aeternal-circle/wdb-report.json",
"sourceDescriptors": 575,
"uniqueResources": 182,
"staticPlacements": 575,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 54,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "barren-caves",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves.json",
"wdbReport": "src/assets/game/dungeons/barren-caves/wdb-report.json",
"sourceDescriptors": 1,
"uniqueResources": 1,
"staticPlacements": 1,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "cave-water-dragon",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon.json",
"wdbReport": "src/assets/game/dungeons/cave-water-dragon/wdb-report.json",
"sourceDescriptors": 121,
"uniqueResources": 16,
"staticPlacements": 121,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "demon-stronghold",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold.json",
"wdbReport": "src/assets/game/dungeons/demon-stronghold/wdb-report.json",
"sourceDescriptors": 90,
"uniqueResources": 4,
"staticPlacements": 90,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "demon-stronghold-125",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125.json",
"wdbReport": "src/assets/game/dungeons/demon-stronghold-125/wdb-report.json",
"sourceDescriptors": 90,
"uniqueResources": 4,
"staticPlacements": 90,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "dungeon-of-dalanis",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis.json",
"wdbReport": "src/assets/game/dungeons/dungeon-of-dalanis/wdb-report.json",
"sourceDescriptors": 652,
"uniqueResources": 124,
"staticPlacements": 652,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 5,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "forsaken-abbey",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey.json",
"wdbReport": "src/assets/game/dungeons/forsaken-abbey/wdb-report.json",
"sourceDescriptors": 184,
"uniqueResources": 26,
"staticPlacements": 184,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 1,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "grafu-castle",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json",
"wdbReport": "src/assets/game/dungeons/grafu-castle/wdb-report.json",
"sourceDescriptors": 899,
"uniqueResources": 286,
"staticPlacements": 899,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 17,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "hall-of-survivors",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors.json",
"wdbReport": "src/assets/game/dungeons/hall-of-survivors/wdb-report.json",
"sourceDescriptors": 296,
"uniqueResources": 32,
"staticPlacements": 296,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "hoto",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/hoto.json",
"wdbReport": "src/assets/game/dungeons/hoto/wdb-report.json",
"sourceDescriptors": 1627,
"uniqueResources": 119,
"staticPlacements": 1627,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ice-dwarf-kingdom",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom.json",
"wdbReport": "src/assets/game/dungeons/ice-dwarf-kingdom/wdb-report.json",
"sourceDescriptors": 1054,
"uniqueResources": 209,
"staticPlacements": 1054,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kal-turok-hive",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive.json",
"wdbReport": "src/assets/game/dungeons/kal-turok-hive/wdb-report.json",
"sourceDescriptors": 2,
"uniqueResources": 2,
"staticPlacements": 2,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 1,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kalins-shrine",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json",
"wdbReport": "src/assets/game/dungeons/kalins-shrine/wdb-report.json",
"sourceDescriptors": 943,
"uniqueResources": 133,
"staticPlacements": 943,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kawaks-tomb",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb.json",
"wdbReport": "src/assets/game/dungeons/kawaks-tomb/wdb-report.json",
"sourceDescriptors": 573,
"uniqueResources": 60,
"staticPlacements": 573,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kulech-bones-nest",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest.json",
"wdbReport": "src/assets/game/dungeons/kulech-bones-nest/wdb-report.json",
"sourceDescriptors": 199,
"uniqueResources": 52,
"staticPlacements": 199,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "lair-demon-dragon",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon.json",
"wdbReport": "src/assets/game/dungeons/lair-demon-dragon/wdb-report.json",
"sourceDescriptors": 530,
"uniqueResources": 96,
"staticPlacements": 530,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "mystic-altar",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar.json",
"wdbReport": "src/assets/game/dungeons/mystic-altar/wdb-report.json",
"sourceDescriptors": 2096,
"uniqueResources": 48,
"staticPlacements": 2096,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "paspers-shrine",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json",
"wdbReport": "src/assets/game/dungeons/paspers-shrine/wdb-report.json",
"sourceDescriptors": 564,
"uniqueResources": 143,
"staticPlacements": 564,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "raksha-temple",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json",
"wdbReport": "src/assets/game/dungeons/raksha-temple/wdb-report.json",
"sourceDescriptors": 1028,
"uniqueResources": 228,
"staticPlacements": 1028,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 11,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "sand-vortex",
"mode": "primary-only-procedural-fallback",
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex.json",
"wdbReport": "src/assets/game/dungeons/sand-vortex/wdb-report.json",
"sourceDescriptors": 20,
"uniqueResources": 14,
"staticPlacements": 20,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "sardo-castle",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle.json",
"wdbReport": "src/assets/game/dungeons/sardo-castle/wdb-report.json",
"sourceDescriptors": 436,
"uniqueResources": 132,
"staticPlacements": 436,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "the-origin",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json",
"wdbReport": "src/assets/game/dungeons/the-origin/wdb-report.json",
"sourceDescriptors": 693,
"uniqueResources": 220,
"staticPlacements": 693,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 17,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "tomb-seven-heroes",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes.json",
"wdbReport": "src/assets/game/dungeons/tomb-seven-heroes/wdb-report.json",
"sourceDescriptors": 450,
"uniqueResources": 56,
"staticPlacements": 450,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "treasure-trove",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove.json",
"wdbReport": "src/assets/game/dungeons/treasure-trove/wdb-report.json",
"sourceDescriptors": 908,
"uniqueResources": 193,
"staticPlacements": 908,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 1,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "warnorken-arena",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena.json",
"wdbReport": "src/assets/game/dungeons/warnorken-arena/wdb-report.json",
"sourceDescriptors": 256,
"uniqueResources": 14,
"staticPlacements": 256,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ystra-labyrinth-boss1",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json",
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss1/wdb-report.json",
"sourceDescriptors": 3,
"uniqueResources": 1,
"staticPlacements": 3,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ystra-labyrinth-boss2",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2.json",
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss2/wdb-report.json",
"sourceDescriptors": 3,
"uniqueResources": 1,
"staticPlacements": 3,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ystra-labyrinth-boss3",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3.json",
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss3/wdb-report.json",
"sourceDescriptors": 3,
"uniqueResources": 1,
"staticPlacements": 3,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "zurhidon-stronghold",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold.json",
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold/wdb-report.json",
"sourceDescriptors": 840,
"uniqueResources": 201,
"staticPlacements": 840,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "zurhidon-stronghold-124",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124.json",
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold-124/wdb-report.json",
"sourceDescriptors": 840,
"uniqueResources": 201,
"staticPlacements": 840,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
}
]
}
@@ -0,0 +1,73 @@
# RuneWaker WDB environment assembly audit
Structural validation: **green**
Source-behavior review: **review-required**
This deterministic audit reads the checked-in WDB reports and environment recipes. It does
not convert or replace shipping GLBs. The builder now supports every valid ROS placement
resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale
transform relative to the reviewed primary placement.
## Coverage
- 30 dungeon WDB packages and 15976 source descriptors.
- 2799 per-dungeon unique ROS resources (resources reused across dungeons share cache keys).
- 15976 static placements and 0 explicitly reviewed dynamic placements.
- 107 filename-based dynamic candidates remain static and review-required; names never prove behavior.
- 0 unsupported placement records and 0 missing source ROS resources.
| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| aeternal-circle | all-ros | 575 | 182 | 575 | 0 | 54 | 0 | 0 |
| barren-caves | all-ros | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| cave-water-dragon | all-ros | 121 | 16 | 121 | 0 | 0 | 0 | 0 |
| demon-stronghold | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
| demon-stronghold-125 | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
| dungeon-of-dalanis | all-ros | 652 | 124 | 652 | 0 | 5 | 0 | 0 |
| forsaken-abbey | all-ros | 184 | 26 | 184 | 0 | 1 | 0 | 0 |
| grafu-castle | all-ros | 899 | 286 | 899 | 0 | 17 | 0 | 0 |
| hall-of-survivors | all-ros | 296 | 32 | 296 | 0 | 0 | 0 | 0 |
| hoto | all-ros | 1627 | 119 | 1627 | 0 | 0 | 0 | 0 |
| ice-dwarf-kingdom | all-ros | 1054 | 209 | 1054 | 0 | 0 | 0 | 0 |
| kal-turok-hive | all-ros | 2 | 2 | 2 | 0 | 1 | 0 | 0 |
| kalins-shrine | all-ros | 943 | 133 | 943 | 0 | 0 | 0 | 0 |
| kawaks-tomb | all-ros | 573 | 60 | 573 | 0 | 0 | 0 | 0 |
| kulech-bones-nest | all-ros | 199 | 52 | 199 | 0 | 0 | 0 | 0 |
| lair-demon-dragon | all-ros | 530 | 96 | 530 | 0 | 0 | 0 | 0 |
| mystic-altar | all-ros | 2096 | 48 | 2096 | 0 | 0 | 0 | 0 |
| paspers-shrine | all-ros | 564 | 143 | 564 | 0 | 0 | 0 | 0 |
| raksha-temple | all-ros | 1028 | 228 | 1028 | 0 | 11 | 0 | 0 |
| sand-vortex | primary-only-procedural-fallback | 20 | 14 | 20 | 0 | 0 | 0 | 0 |
| sardo-castle | all-ros | 436 | 132 | 436 | 0 | 0 | 0 | 0 |
| the-origin | all-ros | 693 | 220 | 693 | 0 | 17 | 0 | 0 |
| tomb-seven-heroes | all-ros | 450 | 56 | 450 | 0 | 0 | 0 | 0 |
| treasure-trove | all-ros | 908 | 193 | 908 | 0 | 1 | 0 | 0 |
| warnorken-arena | all-ros | 256 | 14 | 256 | 0 | 0 | 0 | 0 |
| ystra-labyrinth-boss1 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| ystra-labyrinth-boss2 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| ystra-labyrinth-boss3 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| zurhidon-stronghold | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
| zurhidon-stronghold-124 | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
## Runtime-facing output
A full build writes `wdb-placement-manifest.json` beside the environment package. Each
placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local
Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.
Static placements are assembled into the optimized visual and navigation collision. Reviewed
dynamic placements are omitted from static collision and packaged as separately addressable GLBs.
## Unsupported or review-required behavior
- WDB placement resources other than ROS are recorded but not converted.
- Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.
- Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.
- A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.
- The primary-only top-down preview and editable `.blend` do not include the final merged WDB
assembly; the generated GLB and placement manifest are authoritative.
- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a
reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.
## Validation issues
- None.
@@ -0,0 +1,45 @@
# RuneWaker combat formula and loot fidelity
## Combat damage
Imported RuneWaker damage mechanics now carry a `runewakerFormula` descriptor from `magicobject.db` into the generated enemy attack. The descriptor keeps the source spell/component ids, spell level, damage power, fixed value, damage-over-time base, calculation family, and source-code provenance.
The preserved implementation is in `SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp`:
- `CalPhyDamage` begins at line 3366.
- `CalMagicDamage` begins at line 3651.
- Both use the source ATK/DEF or MATK/MDEF ratio `max(0.2, 1 + ((attack + 100) - (defense + 200)) / max(attack + 100, defense + 200))`.
- Both also depend on native weapon or magic damage, level-difference tables, resistance processing, critical power, fixed-attribute lookup, and global NPC/PvP/AoE/target rates.
HealerMan does not have equivalent RuneWaker ATK, MATK, DEF, MDEF, `FixType`, native weapon/magic damage, level-rate tables, or global rate arrays. The adapter therefore does not label its live result as exact RuneWaker damage:
- Negative `Atk.DmgPower` values with magnitude at most 4 are treated as source ratios against HealerMan's level-scaled enemy damage budget; the native 0.2 minimum is retained.
- Point-valued source powers use the explicit `runtimeBaselineMultiplier` balancing adapter stored with the formula.
- HealerMan's existing hit table, armor, resistance, critical, aura, and absorb pipeline runs afterward and remains identified as HealerMan/WotLK-shaped mitigation.
- Unclassified formula records return zero from the adapter rather than executing guessed damage.
`resolveRunewakerNativeResolvedPower` exposes the source-shaped tail for later use. It requires every native-only input as an argument and supplies no silent one-valued defaults.
## Loot
`export-loot-catalog.mjs` reads each source-classified boss's 15 independent `NPCObjectDB.DropItem` slots. It preserves the original rate denominator of 100,000 and recursively expands `TreasureObjectDB`'s weighted, mutually exclusive outcomes and fixed `DropCount` quantities.
Runtime rules are intentionally bounded:
- Recognized leaves come only from `itemobject.db`, `armorobject.db`, `weaponobject.db`, `cardobject.db`, `runeobject.db`, `recipeobject.db`, and `keyitemobject.db`.
- Treasure recursion stops at 12 levels and cycles fail closed.
- Unknown object ids fail closed.
- Treasure tables with a Lua predicate fail closed because executing arbitrary preserved server Lua is outside the browser runtime.
- `NeedDLv` requirements are preserved and evaluated from the caller-provided source level delta.
- Source names are retained, but source stats, quality, and equipment slots are not inferred. Portable drops enter inventory as common, non-equipable `misc` items with zero stats.
The detailed status for every boss is generated in `RUNEWAKER_LOOT_IMPORT_REPORT.md`. The full probability graph is available in `public/data/runewaker-loot-catalog.json` and the runtime snapshot.
## Refresh and verification
```powershell
npm run runewaker:spells:refresh
npm run runewaker:loot:refresh
node --test scripts/runewaker-pipeline/runewaker-loot-import.test.mjs
vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts
```
@@ -63,7 +63,7 @@
"movement/leash",
"clustering/aggro",
"combat/death state",
"loot policy",
"loot item stats/slots",
"objective ordering",
"procedural animation"
],
@@ -74,7 +74,8 @@
"patrols",
"interactions",
"encounter resets",
"source loot"
"blocked source-loot predicates",
"source item stat/slot mapping"
],
"blockerGroups": {
"navigation": {
@@ -245,10 +246,20 @@
"safety": {
"runtimeSqlDependency": false,
"unknownEffectsExecute": false,
"coefficientsAreSourceExact": false
"coefficientsAreSourceExact": false,
"unavailableNativeInputsDefaulted": false,
"pointValuedDamageUsesLabeledScaledAdapter": true
},
"mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
},
"encounterImplementationLedger": {
"json": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
"markdown": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
"validationStatus": "green",
"completionStatus": "review-required",
"deferredSourceRows": 4113,
"uniqueMechanicsEvidence": 1524
},
"resolvedRuntimeObservations": [
{
"dungeonId": "paspers-shrine",
@@ -266,7 +277,7 @@
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
"Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.",
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
],
"dungeons": [
@@ -1,6 +1,6 @@
# RuneWaker gameplay content audit
Structural validation: **green**
Structural validation: **green**
Original-mechanics fidelity: **review-required**
This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,
@@ -16,6 +16,7 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
- The exhaustive encounter implementation ledger tracks 4113 deferred source rows and 1524 unique mechanics evidence records without inflating overlapping regex labels.
## Authentic source data currently used
@@ -30,10 +31,12 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
- 436 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions outside the modular boss catalog.
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,
source power fields, and the exact native formula shape/provenance.
- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,
DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.
- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.
- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
attack, cast, wound, and death families.
@@ -142,5 +145,5 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
- Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
@@ -0,0 +1,763 @@
{
"schemaVersion": 1,
"status": "portable-with-explicit-fallbacks",
"generatedFrom": {
"sourceFiles": [
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/npcobject.db",
"sha256": "b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/treasureobject.db",
"sha256": "6e7433190f4b9f49e3cea32e346128a57c62a5993dd3b98b0de1cd11152895a0"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/itemobject.db",
"sha256": "00e082b4c18d561d29c893a6400bfe62e3daf90ae539728cb05d1c2893a69880"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/armorobject.db",
"sha256": "a2f0017c4772165d562ebb08ca0ac51625cdc4453e3670a1501df7d741d2bad3"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/weaponobject.db",
"sha256": "2bc28eebbe413bd6118f027e3fec1a3953cef371476937d0b65622c542971a67"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/cardobject.db",
"sha256": "4d154fe733e5359ca9e40c0aefd458ace0cf109b1b01e20da0f6e0683966a59f"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/runeobject.db",
"sha256": "8ea064e1ae8220a69e3c72bbd5b64dbd149738a2c86f1a5d51201a7f3a799f85"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/recipeobject.db",
"sha256": "0f7d6ec38f8c52c403065d3f6f5b996117344c194ba42dd22f4d52061a6984e5"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/keyitemobject.db",
"sha256": "ea06ed6dd9e4111cfd308b3d26d50c9e39a26ad358584db8dee6812e9a91358a"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/string_enus.db",
"sha256": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
}
],
"npcDropAlgorithm": {
"file": "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
"firstRelevantLine": 4707,
"rateDenominator": 100000
},
"treasureAlgorithm": {
"file": "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
"firstRelevantLine": 3033,
"rateDenominator": 100000
},
"layout": "RuneWaker v0.20 fixed records; read-only export"
},
"runtimePolicy": {
"rollsAreIndependent": true,
"treasureOutcomesAreMutuallyExclusive": true,
"unknownObjectsExecute": false,
"luaPredicatesExecute": false,
"sourceItemStatsInvented": false,
"importedItemsUseGenericNonEquipableInventoryRepresentation": true
},
"leafCatalogs": [
{
"name": "itemobject.db",
"count": 13431,
"objectType": 2,
"recordSize": 1776
},
{
"name": "armorobject.db",
"count": 15050,
"objectType": 2,
"recordSize": 1776
},
{
"name": "weaponobject.db",
"count": 4843,
"objectType": 2,
"recordSize": 1776
},
{
"name": "cardobject.db",
"count": 1765,
"objectType": 2,
"recordSize": 1776
},
{
"name": "runeobject.db",
"count": 1781,
"objectType": 2,
"recordSize": 1776
},
{
"name": "recipeobject.db",
"count": 3443,
"objectType": 10,
"recordSize": 416
},
{
"name": "keyitemobject.db",
"count": 10001,
"objectType": 9,
"recordSize": 196
}
],
"bossCount": 56,
"bossWithSourceRollsCount": 47,
"portableBossCount": 34,
"partialBossCount": 13,
"noSourceDropsBossCount": 9,
"blockedBossCount": 0,
"executableOutcomeCount": 8741,
"blockedOutcomeCount": 27,
"bosses": [
{
"bossKey": "rw-barren-caves-101462",
"dungeonId": "barren-caves",
"sourceNpcId": 101462,
"bossName": "Anglo",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 76,
"blockedOutcomeCount": 3,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-cave-water-dragon-102623",
"dungeonId": "cave-water-dragon",
"sourceNpcId": 102623,
"bossName": "Lytfir",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-cave-water-dragon-104161",
"dungeonId": "cave-water-dragon",
"sourceNpcId": 104161,
"bossName": "Template 104161",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-demon-stronghold-102701",
"dungeonId": "demon-stronghold",
"sourceNpcId": 102701,
"bossName": "Naos",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 228,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-fa-100070",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100070,
"bossName": "Necromage",
"status": "portable",
"sourceRollCount": 12,
"executableOutcomeCount": 117,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-fa-100074",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100074,
"bossName": "Demon Witch Ancalon",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 130,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-fa-100561",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100561,
"bossName": "Flowing Chaos",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 127,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-fa-100562",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100562,
"bossName": "Cruel Eater",
"status": "partial",
"sourceRollCount": 12,
"executableOutcomeCount": 131,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-fa-100563",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100563,
"bossName": "Hollow Shell",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 129,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102395",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102395,
"bossName": "Guldamor",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 228,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102421",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102421,
"bossName": "Andriol",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 227,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102425",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102425,
"bossName": "Zygro",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 229,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102430",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102430,
"bossName": "Andaphelmor",
"status": "partial",
"sourceRollCount": 14,
"executableOutcomeCount": 232,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
]
},
{
"bossKey": "rw-hall-of-survivors-102452",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102452,
"bossName": "Mantarick",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102475",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102475,
"bossName": "Glamo",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 226,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102476",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102476,
"bossName": "Divine Punishment Pursuer",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102560",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102560,
"bossName": "Prison Pursuer",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102643",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102643,
"bossName": "Horror Seeker",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102021",
"dungeonId": "hoto",
"sourceNpcId": 102021,
"bossName": "Jiasha",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 219,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102040",
"dungeonId": "hoto",
"sourceNpcId": 102040,
"bossName": "Medusa",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 222,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
]
},
{
"bossKey": "rw-hoto-102041",
"dungeonId": "hoto",
"sourceNpcId": 102041,
"bossName": "Teeth of the Reef",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 217,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102046",
"dungeonId": "hoto",
"sourceNpcId": 102046,
"bossName": "Bloodthirsty Claw",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 218,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102063",
"dungeonId": "hoto",
"sourceNpcId": 102063,
"bossName": "Geba",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 220,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101268",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101268,
"bossName": "Pangkor",
"status": "portable",
"sourceRollCount": 9,
"executableOutcomeCount": 210,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101584",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101584,
"bossName": "Thynos",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 211,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101881",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101881,
"bossName": "Icebound Guardian",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 188,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101883",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101883,
"bossName": "Crasset Hairyfoot",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 181,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101884",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101884,
"bossName": "Mole King",
"status": "portable",
"sourceRollCount": 8,
"executableOutcomeCount": 208,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-kalins-shrine-101500",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101500,
"bossName": "Goddess of Art's Disciple",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 318,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101501",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101501,
"bossName": "Yusalien",
"status": "partial",
"sourceRollCount": 14,
"executableOutcomeCount": 326,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101502",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101502,
"bossName": "Locatha",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 325,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101503",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101503,
"bossName": "Ensia",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 325,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101507",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101507,
"bossName": "Iron Rune Warrior",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 323,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kawaks-tomb-104228",
"dungeonId": "kawaks-tomb",
"sourceNpcId": 104228,
"bossName": "Linobia",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-kulech-bones-nest-107306",
"dungeonId": "kulech-bones-nest",
"sourceNpcId": 107306,
"bossName": "Template 107306",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-mystic-altar-100138",
"dungeonId": "mystic-altar",
"sourceNpcId": 100138,
"bossName": "Cursed lron Golem",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 128,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-mystic-altar-100154",
"dungeonId": "mystic-altar",
"sourceNpcId": 100154,
"bossName": "Razeela",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 142,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-mystic-altar-100611",
"dungeonId": "mystic-altar",
"sourceNpcId": 100611,
"bossName": "Dorlos",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 124,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-100794",
"dungeonId": "paspers-shrine",
"sourceNpcId": 100794,
"bossName": "Blackhorn Hafiz",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 128,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101018",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101018,
"bossName": "Blackhorn Afalen",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 109,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101019",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101019,
"bossName": "Blackhorn Bareth",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 103,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101020",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101020,
"bossName": "Blackhorn Nisorn",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 105,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101021",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101021,
"bossName": "Blackhorn Dorglas",
"status": "portable",
"sourceRollCount": 13,
"executableOutcomeCount": 112,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-raksha-temple-103946",
"dungeonId": "raksha-temple",
"sourceNpcId": 103946,
"bossName": "Shadoj",
"status": "portable",
"sourceRollCount": 7,
"executableOutcomeCount": 71,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-sand-vortex-100645",
"dungeonId": "sand-vortex",
"sourceNpcId": 100645,
"bossName": "Sandlord Sesphiroth",
"status": "portable",
"sourceRollCount": 9,
"executableOutcomeCount": 101,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-sardo-castle-105701",
"dungeonId": "sardo-castle",
"sourceNpcId": 105701,
"bossName": "Undead Guard",
"status": "portable",
"sourceRollCount": 7,
"executableOutcomeCount": 91,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-the-origin-102307",
"dungeonId": "the-origin",
"sourceNpcId": 102307,
"bossName": "Holy Tree Falynum",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-the-origin-102349",
"dungeonId": "the-origin",
"sourceNpcId": 102349,
"bossName": "Life Leecher",
"status": "portable",
"sourceRollCount": 12,
"executableOutcomeCount": 279,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-treasure-trove-101533",
"dungeonId": "treasure-trove",
"sourceNpcId": 101533,
"bossName": "Boulder Sidaar",
"status": "partial",
"sourceRollCount": 10,
"executableOutcomeCount": 313,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-treasure-trove-101534",
"dungeonId": "treasure-trove",
"sourceNpcId": 101534,
"bossName": "Lookout Luke",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 315,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-treasure-trove-101535",
"dungeonId": "treasure-trove",
"sourceNpcId": 101535,
"bossName": "Snow Blake",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 322,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-ystra-labyrinth-boss1-101432",
"dungeonId": "ystra-labyrinth-boss1",
"sourceNpcId": 101432,
"bossName": "Rune Guardian Leader",
"status": "portable",
"sourceRollCount": 12,
"executableOutcomeCount": 92,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss1-101433",
"dungeonId": "ystra-labyrinth-boss1",
"sourceNpcId": 101433,
"bossName": "Raichika the Destroyer",
"status": "portable",
"sourceRollCount": 13,
"executableOutcomeCount": 125,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss1-102195",
"dungeonId": "ystra-labyrinth-boss1",
"sourceNpcId": 102195,
"bossName": "Specter Guardian Creator",
"status": "portable",
"sourceRollCount": 8,
"executableOutcomeCount": 84,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss2-102196",
"dungeonId": "ystra-labyrinth-boss2",
"sourceNpcId": 102196,
"bossName": "Rune Guardian Leader",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 99,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss3-102197",
"dungeonId": "ystra-labyrinth-boss3",
"sourceNpcId": 102197,
"bossName": "Raichika the Destroyer",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 107,
"blockedOutcomeCount": 0,
"blockedReasons": []
}
]
}
@@ -0,0 +1,72 @@
# RuneWaker loot import report
Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.
- Boss templates audited: 56
- Bosses with source rolls: 47
- Fully portable bosses: 34
- Partially portable bosses: 13
- Bosses with no authored drop slots: 9
- Executable item outcomes: 8741
- Blocked outcomes: 27
Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.
| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |
| --- | --- | --- | ---: | ---: | ---: | --- |
| barren-caves | Anglo | partial | 13 | 76 | 3 | Treasure table has no positive, resolvable outcomes. |
| cave-water-dragon | Lytfir | no-source-drops | 0 | 0 | 0 | — |
| cave-water-dragon | Template 104161 | no-source-drops | 0 | 0 | 0 | — |
| demon-stronghold | Naos | portable | 10 | 228 | 0 | — |
| forsaken-abbey | Necromage | portable | 12 | 117 | 0 | — |
| forsaken-abbey | Demon Witch Ancalon | partial | 13 | 130 | 2 | Treasure table has no positive, resolvable outcomes. |
| forsaken-abbey | Flowing Chaos | portable | 11 | 127 | 0 | — |
| forsaken-abbey | Cruel Eater | partial | 12 | 131 | 2 | Treasure table has no positive, resolvable outcomes. |
| forsaken-abbey | Hollow Shell | portable | 11 | 129 | 0 | — |
| hall-of-survivors | Guldamor | portable | 10 | 228 | 0 | — |
| hall-of-survivors | Andriol | portable | 10 | 227 | 0 | — |
| hall-of-survivors | Zygro | portable | 10 | 229 | 0 | — |
| hall-of-survivors | Andaphelmor | partial | 14 | 232 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
| hall-of-survivors | Mantarick | no-source-drops | 0 | 0 | 0 | — |
| hall-of-survivors | Glamo | portable | 10 | 226 | 0 | — |
| hall-of-survivors | Divine Punishment Pursuer | no-source-drops | 0 | 0 | 0 | — |
| hall-of-survivors | Prison Pursuer | no-source-drops | 0 | 0 | 0 | — |
| hall-of-survivors | Horror Seeker | no-source-drops | 0 | 0 | 0 | — |
| hoto | Jiasha | portable | 10 | 219 | 0 | — |
| hoto | Medusa | partial | 13 | 222 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
| hoto | Teeth of the Reef | portable | 10 | 217 | 0 | — |
| hoto | Bloodthirsty Claw | portable | 10 | 218 | 0 | — |
| hoto | Geba | portable | 10 | 220 | 0 | — |
| ice-dwarf-kingdom | Pangkor | portable | 9 | 210 | 0 | — |
| ice-dwarf-kingdom | Thynos | portable | 10 | 211 | 0 | — |
| ice-dwarf-kingdom | Icebound Guardian | portable | 10 | 188 | 0 | — |
| ice-dwarf-kingdom | Crasset Hairyfoot | portable | 10 | 181 | 0 | — |
| ice-dwarf-kingdom | Mole King | portable | 8 | 208 | 0 | — |
| kalins-shrine | Goddess of Art's Disciple | partial | 13 | 318 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Yusalien | partial | 14 | 326 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Locatha | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Ensia | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Iron Rune Warrior | partial | 11 | 323 | 2 | Treasure table has no positive, resolvable outcomes. |
| kawaks-tomb | Linobia | no-source-drops | 0 | 0 | 0 | — |
| kulech-bones-nest | Template 107306 | no-source-drops | 0 | 0 | 0 | — |
| mystic-altar | Cursed lron Golem | portable | 10 | 128 | 0 | — |
| mystic-altar | Razeela | portable | 10 | 142 | 0 | — |
| mystic-altar | Dorlos | portable | 11 | 124 | 0 | — |
| paspers-shrine | Blackhorn Hafiz | portable | 11 | 128 | 0 | — |
| paspers-shrine | Blackhorn Afalen | portable | 10 | 109 | 0 | — |
| paspers-shrine | Blackhorn Bareth | portable | 10 | 103 | 0 | — |
| paspers-shrine | Blackhorn Nisorn | portable | 10 | 105 | 0 | — |
| paspers-shrine | Blackhorn Dorglas | portable | 13 | 112 | 0 | — |
| raksha-temple | Shadoj | portable | 7 | 71 | 0 | — |
| sand-vortex | Sandlord Sesphiroth | portable | 9 | 101 | 0 | — |
| sardo-castle | Undead Guard | portable | 7 | 91 | 0 | — |
| the-origin | Holy Tree Falynum | no-source-drops | 0 | 0 | 0 | — |
| the-origin | Life Leecher | portable | 12 | 279 | 0 | — |
| treasure-trove | Boulder Sidaar | partial | 10 | 313 | 2 | Treasure table has no positive, resolvable outcomes. |
| treasure-trove | Lookout Luke | partial | 11 | 315 | 2 | Treasure table has no positive, resolvable outcomes. |
| treasure-trove | Snow Blake | partial | 13 | 322 | 2 | Treasure table has no positive, resolvable outcomes. |
| ystra-labyrinth-boss1 | Rune Guardian Leader | portable | 12 | 92 | 0 | — |
| ystra-labyrinth-boss1 | Raichika the Destroyer | portable | 13 | 125 | 0 | — |
| ystra-labyrinth-boss1 | Specter Guardian Creator | portable | 8 | 84 | 0 | — |
| ystra-labyrinth-boss2 | Rune Guardian Leader | portable | 10 | 99 | 0 | — |
| ystra-labyrinth-boss3 | Raichika the Destroyer | portable | 10 | 107 | 0 | — |
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,9 @@ against every raw Recast surface component and against the retained runtime surf
The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed
source-authored order. It does not create links or infer doors/teleports.
Unreachable heuristic legs analyzed: **19** across **8** dungeons.
Unreachable currently ordered legs analyzed: **19** across **8** dungeons.
Source-authored fixed orders implemented by the audited packages: **0**.
Authored off-mesh links added by this review: **0**.
| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |
| --- | --- | --- | --- | --- | --- |
@@ -20,9 +22,9 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
| kalins-shrine | Yusalien -> Locatha | separate-recast-surface-components | 2/0m | 12/0m | 245.55m -> 122.731m |
| kalins-shrine | Locatha -> Goddess of Art's Disciple | separate-recast-surface-components | 12/0m | 3/0m | 122.731m -> 12.741m |
| kalins-shrine | Goddess of Art's Disciple -> Ensia | separate-recast-surface-components | 3/0m | 0/0m | 12.741m -> 0m |
| raksha-temple | entrance -> Shadoj | missing-primary-environment-geometry-source-support-pad | 41/0m | 165/0m | 0.203m -> 200.079m |
| the-origin | entrance -> Life Leecher | missing-primary-environment-geometry-source-support-pad | 106/0m | 302/0m | 466.405m -> 234.143m |
| the-origin | Life Leecher -> Holy Tree Falynum | missing-primary-environment-geometry-source-support-pad | 302/0m | 305/0m | 234.143m -> 361.263m |
| raksha-temple | entrance -> Shadoj | objective-or-entrance-outside-all-navigation | 6069/0m | 2982/7.159m | 578.214m -> 699.329m |
| the-origin | entrance -> Life Leecher | separate-recast-surface-components | 4276/0m | 2868/1.099m | 456.333m -> 258.631m |
| the-origin | Life Leecher -> Holy Tree Falynum | separate-recast-surface-components | 2868/1.099m | 4857/5.632m | 258.631m -> 559.406m |
| treasure-trove | entrance -> Lookout Luke | missing-primary-environment-geometry-source-support-pad | 487/0m | 854/0m | 0.184m -> 519.5m |
| treasure-trove | Lookout Luke -> Snow Blake | missing-primary-environment-geometry-source-support-pad | 854/0m | 490/0m | 519.5m -> 525.287m |
| treasure-trove | Snow Blake -> Boulder Sidaar | missing-primary-environment-geometry-source-support-pad | 490/0m | 489/0m | 525.287m -> 565.311m |
@@ -35,8 +37,8 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- hoto: 260 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 5777 welded nodes discarded by largest-surface retention.
- ice-dwarf-kingdom: 599 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 6237 welded nodes discarded by largest-surface retention.
- kalins-shrine: 93 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 4858 welded nodes discarded by largest-surface retention.
- raksha-temple: 167 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1262 welded nodes discarded by largest-surface retention.
- the-origin: 306 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1678 welded nodes discarded by largest-surface retention.
- raksha-temple: 13137 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 180838 welded nodes discarded by largest-surface retention.
- the-origin: 6736 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 97103 welded nodes discarded by largest-surface retention.
- treasure-trove: 1025 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 16502 welded nodes discarded by largest-surface retention.
- ystra-labyrinth-boss1: 36 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 901 welded nodes discarded by largest-surface retention.
@@ -46,7 +48,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: gated-wave-and-door-review.
- Safe automatic link: no.
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.
- Remaining requirement: encounter-gate-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:21
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:42
@@ -55,7 +62,11 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: source-order-required-before-linking.
- Safe automatic link: no.
- Objective order: source-objective-review-required.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.
- Remaining requirement: source-objective-review.
- Remaining requirement: navigation-geometry-review.
- Evidence: scripts/runewaker-pipeline/build-population.mjs:634
- Evidence: scripts/runewaker-pipeline/recipes/hoto-population.json
@@ -63,7 +74,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: door-gated-room-review.
- Safe automatic link: no.
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.
- Remaining requirement: encounter-gate-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:36
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:46
@@ -73,30 +89,48 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: teleport-room-mechanic.
- Safe automatic link: no.
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.
- Objective order: source-randomized-not-linear.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.
- Remaining requirement: nonlinear-objectives.
- Remaining requirement: encounter-teleport-state.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354
### raksha-temple
- Disposition: missing-modular-environment-assembly.
- Safe automatic link: no.
- Objective order: not-applicable-single-objective.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.
- Remaining requirement: modular-environment-assembly.
- Evidence: scripts/runewaker-pipeline/recipes/raksha-temple.json:16
### the-origin
- Disposition: missing-modular-environment-assembly.
- Safe automatic link: no.
- Objective order: source-objective-review-required.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.
- Remaining requirement: modular-environment-assembly.
- Remaining requirement: source-objective-review.
- Evidence: scripts/runewaker-pipeline/recipes/the-origin.json:16
### treasure-trove
- Disposition: missing-modular-environment-plus-bridge-door-mechanics.
- Safe automatic link: no.
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.
- Remaining requirement: modular-environment-assembly.
- Remaining requirement: encounter-gate-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: scripts/runewaker-pipeline/recipes/treasure-trove.json:16
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:38
@@ -106,9 +140,29 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: separate-authored-room-placements.
- Safe automatic link: no.
- Objective order: independent-room-order-unresolved.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.
- Remaining requirement: encounter-teleport-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1423
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1425
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1426
## Resolution boundary
No off-mesh link was emitted. Every failed leg is either controlled by encounter state,
depends on environment geometry that is not yet in the runtime package, represents
separate authored rooms, or still lacks a complete source-authored boss sequence.
A static link would let companions bypass a door, bridge, wave, or teleport mechanic.
The structured requirement counts below are deterministic and identify the remaining work.
- dynamic-navigation-transition: 5 dungeon(s).
- encounter-gate-state: 3 dungeon(s).
- encounter-teleport-state: 2 dungeon(s).
- modular-environment-assembly: 3 dungeon(s).
- navigation-geometry-review: 1 dungeon(s).
- nonlinear-objectives: 1 dungeon(s).
- source-objective-review: 6 dungeon(s).
@@ -29,6 +29,8 @@ const reviewedFindings = {
"cave-water-dragon": {
disposition: "gated-wave-and-door-review",
safeAutomaticLink: false,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
finding: "Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.",
evidence: [
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
@@ -39,6 +41,8 @@ const reviewedFindings = {
hoto: {
disposition: "source-order-required-before-linking",
safeAutomaticLink: false,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["source-objective-review", "navigation-geometry-review"],
finding: "The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.",
evidence: [
"scripts/runewaker-pipeline/build-population.mjs:634",
@@ -48,6 +52,8 @@ const reviewedFindings = {
"ice-dwarf-kingdom": {
disposition: "door-gated-room-review",
safeAutomaticLink: false,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
finding: "Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.",
evidence: [
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
@@ -59,28 +65,41 @@ const reviewedFindings = {
"kalins-shrine": {
disposition: "teleport-room-mechanic",
safeAutomaticLink: false,
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.",
objectiveOrderStatus: "source-randomized-not-linear",
requirements: ["nonlinear-objectives", "encounter-teleport-state", "dynamic-navigation-transition"],
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.",
evidence: [
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713",
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298",
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363",
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354",
],
},
"raksha-temple": {
disposition: "missing-modular-environment-assembly",
safeAutomaticLink: false,
objectiveOrderStatus: "not-applicable-single-objective",
requirements: ["modular-environment-assembly"],
finding: "The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.",
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
},
"the-origin": {
disposition: "missing-modular-environment-assembly",
safeAutomaticLink: false,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["modular-environment-assembly", "source-objective-review"],
finding: "Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.",
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
},
"treasure-trove": {
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
safeAutomaticLink: false,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: [
"modular-environment-assembly",
"encounter-gate-state",
"source-objective-review",
"dynamic-navigation-transition",
],
finding: "Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.",
evidence: [
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
@@ -92,6 +111,8 @@ const reviewedFindings = {
"ystra-labyrinth-boss1": {
disposition: "separate-authored-room-placements",
safeAutomaticLink: false,
objectiveOrderStatus: "independent-room-order-unresolved",
requirements: ["encounter-teleport-state", "source-objective-review", "dynamic-navigation-transition"],
finding: "The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.",
evidence: [
"scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16",
@@ -418,7 +439,9 @@ function renderMarkdown(report) {
"The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed",
"source-authored order. It does not create links or infer doors/teleports.",
"",
`Unreachable heuristic legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
`Unreachable currently ordered legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
`Source-authored fixed orders implemented by the audited packages: **${report.resolutionSummary.sourceAuthoredFixedOrders}**.`,
`Authored off-mesh links added by this review: **${report.resolutionSummary.authoredOffMeshLinksAdded}**.`,
"",
"| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |",
"| --- | --- | --- | --- | --- | --- |",
@@ -442,9 +465,21 @@ function renderMarkdown(report) {
const finding = dungeon.reviewedFinding;
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
`- Safe automatic link: ${finding.safeAutomaticLink ? "yes" : "no"}.`,
`- Objective order: ${finding.objectiveOrderStatus}.`,
`- Current package order: ${dungeon.objectiveOrdering.kind} (${dungeon.objectiveOrdering.authoritative ? "authoritative" : "non-authoritative"}).`,
`- Finding: ${finding.finding}`,
...finding.requirements.map((entry) => `- Remaining requirement: ${entry}.`),
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
}
lines.push("## Resolution boundary", "",
"No off-mesh link was emitted. Every failed leg is either controlled by encounter state,",
"depends on environment geometry that is not yet in the runtime package, represents",
"separate authored rooms, or still lacks a complete source-authored boss sequence.",
"A static link would let companions bypass a door, bridge, wave, or teleport mechanic.",
"The structured requirement counts below are deterministic and identify the remaining work.", "");
for (const [requirement, count] of Object.entries(report.resolutionSummary.requirementDungeonCounts)) {
lines.push(`- ${requirement}: ${count} dungeon(s).`);
}
lines.push("");
return lines.join("\n");
}
@@ -474,6 +509,11 @@ for (const recipeName of recipeNames) {
constantCase(recipe.dungeonId) + "_BOSSES",
" as const satisfies readonly DungeonBossObjective[];",
);
const populationProvenance = generatedJson(
source,
constantCase(recipe.dungeonId) + "_POPULATION_PROVENANCE",
" as const;",
);
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
const retainedTriangleComponents = triangleComponents(graph);
const raw = analyzeRawComponents(positions);
@@ -519,9 +559,12 @@ for (const recipeName of recipeNames) {
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
disposition: "manual-review-required",
safeAutomaticLink: false,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["source-objective-review"],
finding: "No reviewed source-backed navigation disposition is recorded.",
evidence: [],
},
objectiveOrdering: populationProvenance.objectiveOrder,
rawComponents: raw.components.map((component) => ({
id: component.id,
vertexCount: component.vertices.length,
@@ -532,15 +575,26 @@ for (const recipeName of recipeNames) {
});
}
const requirementDungeonCounts = {};
for (const dungeon of dungeons) {
for (const requirement of new Set(dungeon.reviewedFinding.requirements)) {
requirementDungeonCounts[requirement] = (requirementDungeonCounts[requirement] ?? 0) + 1;
}
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
schemaVersion: 2,
generator: "scripts/runewaker-pipeline/analyze-navigation-gaps.mjs",
methodology: {
weldTolerance,
liveStartSnapDistance: 3,
liveObjectiveSnapDistance: 6,
mutatesAssets: false,
},
resolutionSummary: {
sourceAuthoredFixedOrders: dungeons.filter((dungeon) => dungeon.objectiveOrdering.authoritative).length,
authoredOffMeshLinksAdded: 0,
requirementDungeonCounts: Object.fromEntries(Object.entries(requirementDungeonCounts).sort()),
},
unreachableLegs: dungeons.flatMap((dungeon) => dungeon.legs).filter((leg) => !leg.reachable).length,
dungeons,
};
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
const result = await generateEncounterImplementationReport();
console.log(JSON.stringify({
validationStatus: result.report.validationStatus,
completionStatus: result.report.completionStatus,
reportJson: result.reportJson,
reportMarkdown: result.reportMarkdown,
summary: result.report.summary,
issues: result.report.issues,
}, null, 2));
if (result.report.issues.length) process.exitCode = 1;
@@ -0,0 +1,181 @@
#!/usr/bin/env node
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
import { sourceRootFor } from "./lib/recipe.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
const reportJsonFile = path.join(
root,
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.json",
);
const reportMarkdownFile = path.join(
root,
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md",
);
const projectPath = (file) => path.relative(root, file).split(path.sep).join("/");
async function exists(file) {
try {
await stat(file);
return true;
} catch {
return false;
}
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.mode, "|", dungeon.sourceDescriptors, "|",
dungeon.uniqueResources, "|", dungeon.staticPlacements, "|",
dungeon.dynamicPlacements, "|", dungeon.dynamicReviewCandidates, "|",
dungeon.unsupportedPlacements, "|", dungeon.missingSourceResources, "|",
].join(" "));
return [
"# RuneWaker WDB environment assembly audit",
"",
"Structural validation: **" + report.validationStatus + "**",
"Source-behavior review: **" + report.reviewStatus + "**",
"",
"This deterministic audit reads the checked-in WDB reports and environment recipes. It does",
"not convert or replace shipping GLBs. The builder now supports every valid ROS placement",
"resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale",
"transform relative to the reviewed primary placement.",
"",
"## Coverage",
"",
"- " + report.summary.dungeons + " dungeon WDB packages and "
+ report.summary.sourceDescriptors + " source descriptors.",
"- " + report.summary.uniqueDungeonResources
+ " per-dungeon unique ROS resources (resources reused across dungeons share cache keys).",
"- " + report.summary.staticPlacements + " static placements and "
+ report.summary.dynamicPlacements + " explicitly reviewed dynamic placements.",
"- " + report.summary.dynamicReviewCandidates
+ " filename-based dynamic candidates remain static and review-required; names never prove behavior.",
"- " + report.summary.unsupportedPlacements + " unsupported placement records and "
+ report.summary.missingSourceResources + " missing source ROS resources.",
"",
"| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...rows,
"",
"## Runtime-facing output",
"",
"A full build writes `wdb-placement-manifest.json` beside the environment package. Each",
"placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local",
"Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.",
"Static placements are assembled into the optimized visual and navigation collision. Reviewed",
"dynamic placements are omitted from static collision and packaged as separately addressable GLBs.",
"",
"## Unsupported or review-required behavior",
"",
...report.unsupportedResourceTypes.map((item) => "- " + item),
"- The primary-only top-down preview and editable `.blend` do not include the final merged WDB",
" assembly; the generated GLB and placement manifest are authoritative.",
"- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a",
" reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.",
"",
"## Validation issues",
"",
...(report.issues.length ? report.issues.map((item) => "- " + item) : ["- None."]),
"",
].join("\n");
}
export async function buildEnvironmentAssemblyAudit() {
const recipeNames = (await readdir(recipeDirectory))
.filter((name) => name.endsWith(".json")
&& !name.endsWith("-population.json")
&& name !== "runewaker-player-avatars.json")
.sort();
const dungeons = [];
const issues = [];
for (const recipeName of recipeNames) {
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeName), "utf8"));
const wdbReportFile = path.join(
root,
"src/assets/game/dungeons",
recipe.slug,
"wdb-report.json",
);
if (!await exists(wdbReportFile)) {
issues.push(recipe.slug + ": checked-in WDB report is missing.");
continue;
}
try {
const wdbReport = JSON.parse(await readFile(wdbReportFile, "utf8"));
const plan = buildWdbPlacementPlan(wdbReport, recipe);
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
const missingResources = [];
for (const resource of plan.resources) {
const file = path.join(resourceRoot, ...resource.normalizedResource.split("\\"));
if (!await exists(file)) missingResources.push(resource.resource);
}
dungeons.push({
id: recipe.slug,
mode: recipe.source.proceduralTerrainFallback
? "primary-only-procedural-fallback"
: recipe.source.wdbAssembly?.mode ?? "all-ros",
recipe: projectPath(path.join(recipeDirectory, recipeName)),
wdbReport: projectPath(wdbReportFile),
sourceDescriptors: plan.sourceDescriptorCount,
uniqueResources: plan.resources.length,
staticPlacements: plan.staticPlacements.length,
dynamicPlacements: plan.dynamicPlacements.length,
dynamicReviewCandidates: plan.reviewRequiredPlacements.length,
unsupportedPlacements: plan.unsupportedPlacements.length,
missingSourceResources: missingResources.length,
missingResources,
unsupported: plan.unsupportedPlacements,
});
} catch (error) {
issues.push(recipe.slug + ": " + error.message);
}
}
dungeons.sort((left, right) => left.id.localeCompare(right.id));
const sum = (field) => dungeons.reduce((total, dungeon) => total + dungeon[field], 0);
const unsupportedResourceTypes = [
"WDB placement resources other than ROS are recorded but not converted.",
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.",
];
return {
schemaVersion: 1,
generatedBy: "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
deterministic: true,
validationStatus: issues.length ? "blocked" : "green",
reviewStatus: sum("dynamicReviewCandidates") || sum("unsupportedPlacements")
|| sum("missingSourceResources") ? "review-required" : "green",
summary: {
dungeons: dungeons.length,
sourceDescriptors: sum("sourceDescriptors"),
uniqueDungeonResources: sum("uniqueResources"),
staticPlacements: sum("staticPlacements"),
dynamicPlacements: sum("dynamicPlacements"),
dynamicReviewCandidates: sum("dynamicReviewCandidates"),
unsupportedPlacements: sum("unsupportedPlacements"),
missingSourceResources: sum("missingSourceResources"),
},
unsupportedResourceTypes,
issues,
dungeons,
};
}
const report = await buildEnvironmentAssemblyAudit();
await Promise.all([
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
]);
console.log(JSON.stringify({
validationStatus: report.validationStatus,
reviewStatus: report.reviewStatus,
reportJson: projectPath(reportJsonFile),
reportMarkdown: projectPath(reportMarkdownFile),
summary: report.summary,
issues: report.issues,
}, null, 2));
if (report.issues.length) process.exitCode = 1;
@@ -7,6 +7,7 @@ import { BufferAttribute, BufferGeometry, Matrix4, Vector3 } from "three";
import {
buildPartyNavigationGraph,
} from "../../src/game/partyNavigation.ts";
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -156,6 +157,19 @@ function validateAttack(dungeonId, entityId, attack, issues) {
}
}
function formulaMatchesCatalog(attackFormula, catalogFormula) {
if (!catalogFormula) return attackFormula === undefined;
if (!attackFormula || typeof attackFormula !== "object") return false;
const expected = {
...catalogFormula,
// NPC assignments carry their source spell level. Script-derived boss
// references use the documented zero sentinel because no assignment level
// exists in the packaged row.
spellLevel: attackFormula.spellLevel,
};
return JSON.stringify(attackFormula) === JSON.stringify(expected);
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
@@ -171,7 +185,7 @@ function renderMarkdown(report) {
return [
"# RuneWaker gameplay content audit",
"",
"Structural validation: **" + report.validationStatus + "** ",
"Structural validation: **" + report.validationStatus + "**",
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
"",
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
@@ -200,6 +214,10 @@ function renderMarkdown(report) {
"- Boss signaling includes " + summary.telegraphedBossMechanics
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
+ " visible health-triggered mechanics.",
"- The exhaustive encounter implementation ledger tracks "
+ report.encounterImplementationLedger.deferredSourceRows + " deferred source rows and "
+ report.encounterImplementationLedger.uniqueMechanicsEvidence
+ " unique mechanics evidence records without inflating overlapping regex labels.",
"",
"## Authentic source data currently used",
"",
@@ -215,10 +233,12 @@ function renderMarkdown(report) {
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
"- " + summary.sourceBackedDamageFamilyAttacks
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
"- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,",
" source power fields, and the exact native formula shape/provenance.",
"- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,",
" DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.",
"- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.",
"- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.",
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
" attack, cast, wound, and death families.",
"",
@@ -530,11 +550,16 @@ for (const recipeName of recipeNames) {
+ executableSpellId + ".");
} else {
counts.sourceBackedDamageFamilyAttacks += 1;
if (attack.spellId && [
const runtimeMappingDrift = !catalogAuthored && [
"name", "school", "animation", "cooldownMs", "damageMultiplier",
].some((field) => attack[field] !== (
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
))) {
));
const formulaDrift = !formulaMatchesCatalog(
attack.runewakerFormula,
catalogSpell.runtime.formula,
);
if (attack.spellId && (runtimeMappingDrift || formulaDrift)) {
issue(issues, dungeonId, entityId + " spell " + executableSpellId
+ " drifted from the portable source-backed runtime mapping.");
}
@@ -749,8 +774,8 @@ summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ??
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
reviewNotes.push(summary.sourceGapBossMechanics
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
+ "formula/stat scaling has not been reconstructed.");
reviewNotes.push("Source formula structure is preserved, but live damage uses a labeled HealerMan "
+ "baseline adapter because the required native combat-stat inputs are not packaged.");
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
dungeon.objectiveRouteEvidence
.filter((route) => route.orderAuthoritative && !route.reachable)
@@ -784,6 +809,12 @@ if (summary.derivedObjectiveRoutes) {
+ summary.unreachableDerivedObjectiveRoutes + " unreachable heuristic legs remain navigation/order review evidence.");
}
const encounterImplementation = await generateEncounterImplementationReport();
if (encounterImplementation.report.issues.length) {
issues.push("Encounter implementation ledger is blocked: "
+ encounterImplementation.report.issues.join("; "));
}
const report = {
schemaVersion: 1,
validationStatus: issues.length ? "blocked" : "green",
@@ -797,11 +828,11 @@ const report = {
],
genericRuntimeSystems: [
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"combat/death state", "loot policy", "objective ordering", "procedural animation",
"combat/death state", "loot item stats/slots", "objective ordering", "procedural animation",
],
deferredMechanics: [
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
"encounter resets", "source loot",
"encounter resets", "blocked source-loot predicates", "source item stat/slot mapping",
],
blockerGroups: {
navigation: {
@@ -829,6 +860,14 @@ const report = {
mappingPolicy: "Only positively classified direct-HP damage families execute; "
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
},
encounterImplementationLedger: {
json: encounterImplementation.reportJson,
markdown: encounterImplementation.reportMarkdown,
validationStatus: encounterImplementation.report.validationStatus,
completionStatus: encounterImplementation.report.completionStatus,
deferredSourceRows: encounterImplementation.report.summary.deferredSourceRows,
uniqueMechanicsEvidence: encounterImplementation.report.summary.uniqueMechanicsEvidence,
},
resolvedRuntimeObservations,
documentedExceptions,
issues,
@@ -14,6 +14,11 @@ import {
projectRoot,
sourceRootFor,
} from "./lib/recipe.mjs";
import {
assembleInstancedGlb,
buildWdbPlacementPlan,
normalizeWdbResourcePath,
} from "./lib/environment-assembly.mjs";
const argv = process.argv.slice(2);
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
@@ -23,6 +28,7 @@ const workDirectory = path.join(projectRoot, "runewaker-export-work", slug);
const rawDirectory = path.join(workDirectory, "raw");
const preparedDirectory = path.join(workDirectory, "prepared");
const convertedDirectory = path.join(workDirectory, "converted");
const resourceCacheDirectory = path.join(projectRoot, "runewaker-export-work", "resource-cache");
const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug);
const sourceRoot = sourceRootFor(recipe);
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
@@ -45,6 +51,10 @@ const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker
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 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;
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
@@ -67,6 +77,15 @@ async function requirePath(file, guidance) {
catch { throw new Error(guidance + "\nMissing: " + file); }
}
async function exists(file) {
try {
await stat(file);
return true;
} catch {
return false;
}
}
async function sha256(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
@@ -148,7 +167,7 @@ function sourcePointToPrimaryLocal(sourcePosition, placement) {
async function appendSourceAnchorSupport() {
const support = recipe.source.sourceAnchorSupport;
if (!support) return;
if (!support || fullWdbAssembly) return;
const { placement } = await primaryPlacement();
const half = Number(support.halfExtentSourceUnits ?? 60);
const thickness = Number(support.thicknessSourceUnits ?? 3);
@@ -185,6 +204,128 @@ async function appendSourceAnchorSupport() {
]);
}
function resourceSourceFile(resource) {
return path.join(resourceRoot, ...normalizeWdbResourcePath(resource).split("\\"));
}
async function prepareCachedResourceTextures(raw, prepared) {
const sourceTextures = path.join(raw, "textures");
const preparedTextures = path.join(prepared, "textures");
await mkdir(preparedTextures, { recursive: true });
const ddsFiles = await exists(sourceTextures)
? (await readdir(sourceTextures))
.filter((file) => file.toLowerCase().endsWith(".dds"))
.sort()
.map((file) => path.join(sourceTextures, file))
: [];
if (ddsFiles.length) {
await run(
texconv,
["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles],
"convert " + ddsFiles.length + " cached DDS textures",
);
}
await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj"));
await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json"));
const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8");
await writeFile(
path.join(prepared, "scene.mtl"),
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
"utf8",
);
}
async function cachedResourceAsset(resourceRecord) {
const sourceFile = resourceSourceFile(resourceRecord.resource);
if (!await exists(sourceFile)) {
throw new Error("source ROS is missing: " + projectPath(sourceFile));
}
const sourceSha256 = await sha256(sourceFile);
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
const raw = path.join(cacheRoot, "raw");
const prepared = path.join(cacheRoot, "prepared");
const converted = path.join(cacheRoot, "converted");
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"));
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.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
return {
...cached,
cacheHit: true,
visualFile: path.join(cacheRoot, cached.visual.file),
collision: cached.collision.map((entry) => ({
...entry,
absoluteFile: path.join(cacheRoot, entry.file),
})),
};
}
}
await Promise.all([
mkdir(raw, { recursive: true }),
mkdir(prepared, { recursive: true }),
mkdir(converted, { recursive: true }),
]);
await run(
nativeExporter,
[resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw],
"export cached WDB resource " + resourceRecord.resource,
);
await prepareCachedResourceTextures(raw, prepared);
await run(
blender,
[
"--background", "--factory-startup", "--python", blenderScript, "--",
path.join(prepared, "scene.obj"),
converted,
resourceRecord.cacheKey,
String(recipe.coordinateSystem.metersPerSourceUnit),
],
"convert cached WDB resource " + resourceRecord.resource,
);
const conversion = JSON.parse(await readFile(path.join(converted, "conversion-report.json"), "utf8"));
const manifest = {
schemaVersion: 1,
source: {
resource: resourceRecord.normalizedResource,
file: projectPath(sourceFile),
sha256: sourceSha256,
},
coordinateSystem: {
nativeBridge: recipe.coordinateSystem.nativeBridge,
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
},
visual: {
file: path.relative(
cacheRoot,
path.join(converted, resourceRecord.cacheKey + "-visual.glb"),
).split(path.sep).join("/"),
triangles: conversion.visual.triangles,
meshObjects: conversion.visual.meshObjects,
bounds: conversion.visual.bounds,
},
collision: conversion.collision.map((entry) => ({
...entry,
file: path.relative(cacheRoot, path.join(converted, entry.file)).split(path.sep).join("/"),
})),
};
await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
return {
...manifest,
cacheHit: false,
visualFile: path.join(cacheRoot, manifest.visual.file),
collision: manifest.collision.map((entry) => ({
...entry,
absoluteFile: path.join(cacheRoot, entry.file),
})),
};
}
async function prepareTextures() {
const sourceTextures = path.join(rawDirectory, "textures");
const preparedTextures = path.join(preparedDirectory, "textures");
@@ -252,8 +393,208 @@ function cloneNodeTree(document, node) {
return clone;
}
function groupCollisionRecords(records) {
const groups = [];
let current = [];
let triangles = 0;
for (const record of records) {
if (current.length && triangles + record.triangles > collisionChunkTriangleTarget) {
groups.push({ records: current, triangles });
current = [];
triangles = 0;
}
current.push(record);
triangles += record.triangles;
}
if (current.length) groups.push({ records: current, triangles });
return groups;
}
async function assembleAllWdbPlacements() {
const wdbReport = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8"));
const plan = buildWdbPlacementPlan(wdbReport, recipe);
const primaryResource = normalizeWdbResourcePath(plan.primaryPlacement.resource);
const primaryConversion = JSON.parse(
await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"),
);
const assets = new Map();
assets.set(primaryResource, {
cacheHit: true,
cacheKind: "primary-conversion",
visualFile: path.join(convertedDirectory, slug + "-visual.glb"),
visual: primaryConversion.visual,
collision: primaryConversion.collision.map((entry) => ({
...entry,
absoluteFile: path.join(convertedDirectory, entry.file),
})),
});
const failures = [];
for (const resource of plan.resources) {
if (resource.normalizedResource === primaryResource) continue;
try {
assets.set(resource.normalizedResource, await cachedResourceAsset(resource));
} catch (error) {
failures.push({
resource: resource.resource,
normalizedResource: resource.normalizedResource,
placementCount: resource.placementCount,
reason: error.message,
});
}
}
const unavailable = new Set(failures.map((failure) => failure.normalizedResource));
for (const resource of plan.resources) {
const asset = assets.get(resource.normalizedResource);
resource.assetStatus = asset ? "available" : "unsupported";
resource.cacheHit = asset?.cacheHit ?? false;
resource.cacheKind = asset?.cacheKind ?? "normalized-path-cache-with-source-hash-validation";
if (!asset) resource.blocker = failures.find((entry) => (
entry.normalizedResource === resource.normalizedResource
))?.reason;
}
for (const placement of [...plan.staticPlacements, ...plan.dynamicPlacements]) {
placement.assetStatus = unavailable.has(placement.normalizedResource) ? "unsupported" : "available";
placement.assetId = "wdb-resource:" + placement.resourceCacheKey;
}
const staticPlacements = plan.staticPlacements.filter((placement) => (
placement.assetStatus === "available"
));
if (!staticPlacements.length) throw new Error("WDB assembly produced no available static placements.");
const visualRecords = staticPlacements.map((placement) => ({
placement,
assetFile: assets.get(placement.normalizedResource).visualFile,
}));
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"));
const collisionRecords = staticPlacements.flatMap((placement) => (
assets.get(placement.normalizedResource).collision.map((collision) => ({
placement,
assetFile: collision.absoluteFile,
triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0),
}))
)).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()) {
const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb";
await assembleInstancedGlb(group.records, path.join(convertedDirectory, file));
collision.push({
file,
triangles: group.triangles,
sourceTriangleEstimate: group.triangles,
placementCount: group.records.length,
assembledFromWdb: true,
});
}
const dynamicDirectory = path.join(convertedDirectory, "dynamic");
const dynamicAssets = [];
const dynamicResources = plan.resources.filter((resource) => (
resource.dynamicPlacementCount > 0 && resource.assetStatus === "available"
));
if (dynamicResources.length) await mkdir(dynamicDirectory, { recursive: true });
for (const resource of dynamicResources) {
const asset = assets.get(resource.normalizedResource);
const visualName = resource.cacheKey + "-visual.glb";
await copyFile(asset.visualFile, path.join(dynamicDirectory, visualName));
dynamicAssets.push({
role: "dynamic-visual",
file: "dynamic/" + visualName,
assetId: "wdb-resource:" + resource.cacheKey,
resource: resource.resource,
});
for (const [index, sourceCollision] of asset.collision.entries()) {
const collisionName = resource.cacheKey + "-collision-" + String(index).padStart(3, "0") + ".glb";
await copyFile(sourceCollision.absoluteFile, path.join(dynamicDirectory, collisionName));
dynamicAssets.push({
role: "dynamic-collision",
file: "dynamic/" + collisionName,
assetId: "wdb-resource:" + resource.cacheKey,
resource: resource.resource,
triangles: Number(sourceCollision.triangles ?? 0),
});
}
}
plan.resourceFailures = failures;
plan.buildSummary = {
staticPlacements: staticPlacements.length,
dynamicPlacements: plan.dynamicPlacements.filter((placement) => (
placement.assetStatus === "available"
)).length,
unavailablePlacements: [...plan.staticPlacements, ...plan.dynamicPlacements]
.filter((placement) => placement.assetStatus === "unsupported").length,
uniqueResources: plan.resources.length,
availableResources: assets.size,
unavailableResources: failures.length,
resourceCacheHits: [...assets.values()].filter((asset) => (
asset.cacheHit && asset.cacheKind !== "primary-conversion"
)).length,
};
const placementManifestFile = path.join(convertedDirectory, "wdb-placement-manifest.json");
await writeFile(placementManifestFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
const visualTriangles = staticPlacements.reduce((total, placement) => (
total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0)
), 0);
const visualMeshObjects = staticPlacements.reduce((total, placement) => (
total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0)
), 0);
const conversion = {
schemaVersion: 2,
dungeonId: slug,
status: failures.length || plan.reviewRequiredPlacements.length
? "review-required"
: "source-packaged",
environmentMode: "runewaker-wdb-assembly",
source: {
wdb: recipe.source.wdb,
primaryModel: recipe.source.model,
descriptorCount: plan.sourceDescriptorCount,
placementManifest: "wdb-placement-manifest.json",
},
coordinateTransform: plan.coordinateTransform,
visual: {
file: slug + "-visual.glb",
meshObjects: visualMeshObjects,
triangles: visualTriangles,
sourceTriangles: visualTriangles,
bounds: visualAssembly.bounds,
assembledWdbPlacements: staticPlacements.length,
uniqueResources: new Set(staticPlacements.map((placement) => placement.normalizedResource)).size,
},
collision,
collisionPolicy: {
kind: "wdb-static-placement-trimesh",
maximumTrianglesPerChunk: 100_000,
workingChunkTarget: collisionChunkTriangleTarget,
excludesReviewedDynamicPlacements: true,
includesUnreviewedDynamicCandidates: true,
},
dynamicAssets,
preview: slug + "-preview.png",
editableSource: slug + ".source.blend",
wdbAssembly: plan.buildSummary,
warnings: [
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence.",
...failures.map((failure) => failure.resource + ": " + failure.reason),
],
};
await writeFile(
path.join(convertedDirectory, "conversion-report.json"),
JSON.stringify(conversion, null, 2) + "\n",
"utf8",
);
}
async function assembleRepeatedPlacements() {
const translations = assemblyTranslations();
const translations = fullWdbAssembly ? [] : assemblyTranslations();
if (!translations.length) return;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
@@ -385,7 +726,7 @@ async function bakeNavigation() {
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
const support = recipe.source.sourceAnchorSupport;
if (!support) return;
if (!support || fullWdbAssembly) return;
const { placement } = await primaryPlacement();
const half = Number(support.halfExtentSourceUnits ?? 60);
const meters = recipe.coordinateSystem.metersPerSourceUnit;
@@ -556,13 +897,29 @@ async function stageShippingPackage() {
const files = [
{ role: "visual", source: path.join(convertedDirectory, slug + "-visual.optimized.glb"), target: path.join(shippingDirectory, slug + "-visual.glb") },
...conversion.collision.map((chunk) => ({ role: "collision", source: path.join(convertedDirectory, chunk.file), target: path.join(shippingDirectory, chunk.file) })),
...(conversion.dynamicAssets ?? []).map((asset) => ({
role: asset.role,
source: path.join(convertedDirectory, asset.file),
target: path.join(shippingDirectory, asset.file),
assetId: asset.assetId,
resource: asset.resource,
})),
{ role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") },
];
for (const entry of files) await copyFile(entry.source, entry.target);
for (const entry of files) {
await mkdir(path.dirname(entry.target), { recursive: true });
await copyFile(entry.source, entry.target);
}
for (const report of ["conversion-report.json", "optimization-report.json", "recast-report.json", slug + "-preview.png"]) {
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
}
await copyFile(path.join(rawDirectory, "wdb-report.json"), path.join(shippingDirectory, "wdb-report.json"));
if (fullWdbAssembly) {
await copyFile(
path.join(convertedDirectory, "wdb-placement-manifest.json"),
path.join(shippingDirectory, "wdb-placement-manifest.json"),
);
}
const server = await sourceMetadata();
const { report, placement } = await primaryPlacement();
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
@@ -604,12 +961,21 @@ async function stageShippingPackage() {
} : {}),
server,
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
...(fullWdbAssembly ? {
wdbAssembly: {
mode: "all-ros",
placementManifest: "wdb-placement-manifest.json",
...conversion.wdbAssembly,
},
} : {}),
},
coordinateSystem: recipe.coordinateSystem,
entrance,
assets: await Promise.all(files.map(async (entry) => ({
role: entry.role,
fileName: path.basename(entry.target),
relativeFile: path.relative(shippingDirectory, entry.target).split(path.sep).join("/"),
...(entry.assetId ? { assetId: entry.assetId, sourceResource: entry.resource } : {}),
size: (await stat(entry.target)).size,
sha256: await sha256(entry.target),
}))),
@@ -634,7 +1000,8 @@ await buildNativeExporterIfNeeded();
await exportSource();
await prepareTextures();
await convertEnvironment();
await assembleRepeatedPlacements();
if (fullWdbAssembly) await assembleAllWdbPlacements();
else await assembleRepeatedPlacements();
await optimizeVisual();
await bakeNavigation();
await stageShippingPackage();
@@ -460,6 +460,12 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
damageMultiplier: spell.runtime.damageMultiplier,
...(spell.runtime.formula ? {
runewakerFormula: {
...spell.runtime.formula,
spellLevel: Number(sourceSpell.level ?? 0),
},
} : {}),
}];
});
const recipeAttacks = Array.isArray(template.attacks)
@@ -471,7 +477,34 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
const mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
const sourceSpellLevels = new Map(
(sourceSpells ?? []).map((sourceSpell) => [Number(sourceSpell.id), Number(sourceSpell.level ?? 0)]),
);
for (const spellId of authored?.source?.spellIds ?? []) {
if (!sourceSpellLevels.has(Number(spellId))) {
// Script-derived spell evidence has no NPC assignment level in the packaged row.
// Zero is the catalog's explicit unassigned-level sentinel and is not used by
// the HealerMan baseline adapter.
sourceSpellLevels.set(Number(spellId), 0);
}
}
const withSourceFormula = mechanics.map((attack) => {
const spellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
const spell = spellCatalog.get(spellId);
if (!sourceSpellLevels.has(spellId)
|| spell?.mappingStatus !== "damage-family"
|| !spell.runtime?.formula) {
return attack;
}
return {
...attack,
runewakerFormula: {
...spell.runtime.formula,
spellLevel: sourceSpellLevels.get(spellId),
},
};
});
const unique = [...new Map(withSourceFormula.map((attack) => [attack.id, attack])).values()];
return [basicAttack, ...unique];
}
@@ -706,7 +739,7 @@ async function generatePopulation() {
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
sourceSpellCatalog: projectPath(spellCatalogFile),
sourceSpellCatalogSha256: await sha256(spellCatalogFile),
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
};
const reportFile = resolveProject(recipe.files.report);
await mkdir(path.dirname(reportFile), { recursive: true });
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import {
FIDELITY_LEVELS,
IMPLEMENTATION_STATUSES,
buildEncounterImplementationReport,
} from "./lib/encounter-implementation-report.mjs";
test("encounter ledger exhaustively classifies portable RuneWaker evidence", async () => {
const report = await buildEncounterImplementationReport();
assert.equal(report.validationStatus, "green", report.issues.join("\n"));
assert.equal(report.summary.dungeons, 30);
assert.equal(report.summary.deferredSourceRows, 4_113);
assert.equal(report.summary.uniqueMechanicsEvidence, 1_524);
assert.equal(report.summary.luaEvidenceSignals, 329);
assert.equal(report.summary.templateEvidenceSignals, 1_195);
const inventory = report.dungeons.flatMap((dungeon) => [
...dungeon.deferredSourceRows,
...dungeon.mechanicsEvidence,
]);
assert.equal(new Set(inventory.map((item) => item.id)).size, inventory.length);
for (const item of inventory) {
assert.ok(IMPLEMENTATION_STATUSES.includes(item.status), item.id);
assert.ok(FIDELITY_LEVELS.includes(item.fidelity), item.id);
assert.ok(item.blocker, item.id);
assert.ok(item.nextWork, item.id);
assert.ok(item.clearingTest, item.id);
}
for (const row of report.dungeons.flatMap((dungeon) => dungeon.deferredSourceRows)) {
assert.ok(row.sourceIds.spawnId > 0, row.id);
assert.ok(row.sourceIds.templateId > 0, row.id);
assert.ok(row.source.snapshot.path.endsWith(".json"), row.id);
assert.match(row.source.snapshot.sha256, /^[a-f0-9]{64}$/, row.id);
assert.match(row.source.snapshot.contentSha256, /^[a-f0-9]{64}$/, row.id);
}
});
test("regex labels are memberships rather than inflated mechanic records", async () => {
const report = await buildEncounterImplementationReport();
const evidence = report.dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
const multiCategory = evidence.find((item) => item.categories.length > 1);
assert.ok(multiCategory, "expected at least one source line with overlapping review labels");
assert.equal(evidence.filter((item) => item.id === multiCategory.id).length, 1);
assert.equal(
report.summary.evidenceCategoryMembershipCount,
evidence.reduce((total, item) => total + item.categories.length, 0),
);
assert.notEqual(report.summary.evidenceCategoryMembershipCount, evidence.length);
});
test("source gaps, fallbacks, zero-boss packages, and empty variants stay explicit", async () => {
const report = await buildEncounterImplementationReport();
assert.deepEqual(
report.specialCases.fallbackBosses.map((boss) => boss.entityId),
["rw-fa-100070", "rw-fa-100561", "rw-fa-100562", "rw-fa-100563"],
);
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
boss.status === "implemented" && boss.fidelity === "healer-man-authored"
)));
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
boss.mechanicIds.length > 0
&& boss.telegraphedMechanicIds.length === boss.mechanicIds.length
)));
assert.deepEqual(
report.specialCases.nonEmptyZeroBossPackages.map((entry) => entry.dungeonId),
[
"aeternal-circle",
"dungeon-of-dalanis",
"grafu-castle",
"kal-turok-hive",
"lair-demon-dragon",
"tomb-seven-heroes",
"warnorken-arena",
"zurhidon-stronghold",
],
);
assert.deepEqual(
report.specialCases.intentionalEmptyPackages.map((entry) => entry.dungeonId),
["demon-stronghold-125", "zurhidon-stronghold-124"],
);
assert.equal(report.summary.deferredRowStatuses["deferred-source-gap"], 4_113);
});
test("the report has no timestamps and is byte-deterministic in memory", async () => {
const first = await buildEncounterImplementationReport();
const second = await buildEncounterImplementationReport();
assert.equal(JSON.stringify(first), JSON.stringify(second));
assert.equal(Object.hasOwn(first, "generatedAt"), false);
});
test("checked-in JSON is the current deterministic report", async () => {
const built = await buildEncounterImplementationReport();
const checkedIn = JSON.parse(await readFile(
new URL("./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json", import.meta.url),
"utf8",
));
assert.deepEqual(checkedIn, built);
});
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { Matrix4, Vector3 } from "three";
import {
assembleInstancedGlb,
buildWdbPlacementPlan,
normalizeWdbResourcePath,
placementMatrixInPrimaryThree,
resourceCacheKey,
transformSourcePointToPrimaryThree,
} from "./lib/environment-assembly.mjs";
const identityPlacement = (overrides = {}) => ({
guid: "PRIMARY",
resource: "model\\dungeon\\fixture\\room.ros",
scale: [1, 1, 1],
rotation: [0, 0, 0, 1],
translation: [10, 20, 30],
localBounds: { center: [0, 0, 0], radius: 1 },
worldBounds: { center: [10, 20, 30], radius: 1 },
...overrides,
});
const fixtureRecipe = (source = {}) => ({
slug: "fixture",
coordinateSystem: {
source: "left-handed Y-up",
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
metersPerSourceUnit: 0.1,
},
source: {
model: "model/dungeon/fixture/room.ros",
primaryPlacementTranslation: [10, 20, 30],
...source,
},
});
test("normalizes resource paths and makes case-insensitive stable cache keys", () => {
assert.equal(
normalizeWdbResourcePath("/Model/Dungeon/Fixture/Room.ROS"),
"model\\dungeon\\fixture\\room.ros",
);
assert.equal(
resourceCacheKey("Model/Dungeon/Fixture/Room.ROS"),
resourceCacheKey("model\\dungeon\\fixture\\room.ros"),
);
});
test("converts full placement translation, quaternion, and nonuniform scale", () => {
const primary = identityPlacement();
const placed = identityPlacement({
guid: "PLACED",
resource: "model\\dungeon\\fixture\\bridge.ros",
translation: [20, 22, 40],
rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2],
scale: [2, 3, 4],
});
const matrix = placementMatrixInPrimaryThree(placed, primary, 0.1);
const sourcePoint = [3, 4, 5];
const viaMatrix = new Vector3(sourcePoint[0] * 0.1, sourcePoint[1] * 0.1, -sourcePoint[2] * 0.1)
.applyMatrix4(new Matrix4().fromArray(matrix.toArray()))
.toArray();
const direct = transformSourcePointToPrimaryThree(sourcePoint, placed, primary, 0.1);
assert.deepEqual(viaMatrix.map((value) => Number(value.toFixed(9))), direct.map((value) => Number(value.toFixed(9))));
assert.notDeepEqual(matrix.toArray(), new Matrix4().identity().toArray());
});
test("builds deterministic static, dynamic, review, and unsupported placement groups", () => {
const primary = identityPlacement();
const door = identityPlacement({
guid: "DOOR",
resource: "model\\dungeon\\fixture\\boss_door.ros",
translation: [15, 20, 30],
});
const hintedBridge = identityPlacement({
guid: "BRIDGE",
resource: "model\\dungeon\\fixture\\drawbridge.ros",
translation: [20, 20, 30],
});
const unsupported = identityPlacement({
guid: "FX",
resource: "effect\\fixture\\mist.eft",
});
const recipe = fixtureRecipe({
wdbAssembly: {
mode: "all-ros",
dynamicPlacements: [{
id: "boss-door",
guid: "DOOR",
kind: "door",
initialState: "closed",
collision: "stateful",
evidence: ["fixture.lua:10"],
}],
},
});
const first = buildWdbPlacementPlan({
sourceWdb: "fixture.wdb",
descriptors: [unsupported, hintedBridge, door, primary],
}, recipe);
const second = buildWdbPlacementPlan({
sourceWdb: "fixture.wdb",
descriptors: [unsupported, hintedBridge, door, primary],
}, recipe);
assert.deepEqual(first, second);
assert.equal(first.staticPlacements.length, 2);
assert.equal(first.dynamicPlacements.length, 1);
assert.equal(first.dynamicPlacements[0].dynamic.kind, "door");
assert.equal(first.reviewRequiredPlacements.length, 1);
assert.equal(first.reviewRequiredPlacements[0].guid, "BRIDGE");
assert.equal(first.unsupportedPlacements.length, 1);
});
test("assembles repeated cached GLBs as transformed resource instances", async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), "healer-man-wdb-assembly-"));
try {
const sourceFile = path.join(temporary, "source.glb");
const outputFile = path.join(temporary, "assembled.glb");
const source = new Document();
const buffer = source.createBuffer("fixture-buffer");
const position = source.createAccessor("fixture-position", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const primitive = source.createPrimitive().setAttribute("POSITION", position);
const mesh = source.createMesh("fixture-mesh").addPrimitive(primitive);
source.createScene("fixture-scene").addChild(source.createNode("fixture-node").setMesh(mesh));
const io = new NodeIO();
await io.write(sourceFile, source);
const makePlacement = (id, x) => ({
id,
guid: id.toUpperCase(),
resource: "model/dungeon/fixture/room.ros",
resourceCacheKey: resourceCacheKey("model/dungeon/fixture/room.ros"),
transform: {
primaryLocalThreeMatrix: new Matrix4().makeTranslation(x, 0, 0).toArray(),
},
});
const result = await assembleInstancedGlb([
{ assetFile: sourceFile, placement: makePlacement("first", 0) },
{ assetFile: sourceFile, placement: makePlacement("second", 5) },
], outputFile);
const assembled = await io.read(outputFile);
const scene = assembled.getRoot().getDefaultScene() ?? assembled.getRoot().listScenes()[0];
assert.deepEqual(scene.listChildren().map((node) => node.getName()), ["first", "second"]);
assert.deepEqual(result.bounds, { min: [0, 0, 0], max: [6, 1, 0] });
assert.equal(assembled.getRoot().listMeshes().length, 1, "repeated placements share cached mesh data");
} finally {
await rm(temporary, { recursive: true, force: true });
}
});
test("rejects dynamic rules that would remove the primary coordinate anchor", () => {
const primary = identityPlacement();
assert.throws(() => buildWdbPlacementPlan({ descriptors: [primary] }, fixtureRecipe({
wdbAssembly: {
dynamicPlacements: [{
id: "invalid-primary-door",
guid: "PRIMARY",
kind: "door",
evidence: ["fixture.lua:1"],
}],
},
})), /primary coordinate-anchor placement cannot be dynamic/);
});
test("current modular WDB reports account for every descriptor without guessed dynamics", async () => {
for (const slug of ["raksha-temple", "the-origin", "treasure-trove"]) {
const [recipe, report] = await Promise.all([
readFile(new URL("./recipes/" + slug + ".json", import.meta.url), "utf8").then(JSON.parse),
readFile(new URL("../../src/assets/game/dungeons/" + slug + "/wdb-report.json", import.meta.url), "utf8").then(JSON.parse),
]);
const plan = buildWdbPlacementPlan(report, recipe);
assert.equal(
plan.staticPlacements.length + plan.dynamicPlacements.length + plan.unsupportedPlacements.length,
report.descriptorCount,
slug,
);
assert.equal(plan.dynamicPlacements.length, 0, slug + " must not infer stateful behavior from names");
assert.ok(plan.resources.length > 1, slug + " should expose arbitrary resource assembly");
assert.equal(plan.primaryPlacement.resource.toLowerCase(), recipe.source.model.toLowerCase());
}
});
@@ -0,0 +1,401 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const DATA_START = 140;
const COUNT_OFFSET = 132;
const NPC_RECORD_SIZE = 2224;
const NPC_OBJECT_TYPE = 1;
const NPC_DROP_START = 0xf4;
const NPC_DROP_COUNT = 15;
const TREASURE_RECORD_SIZE = 1516;
const TREASURE_OBJECT_TYPE = 20;
const TREASURE_NEED_LEVEL_DELTA = 0xac;
const TREASURE_ITEM_COUNT = 0xb0;
const TREASURE_ITEMS_START = 0xb4;
const TREASURE_MAX_ITEMS = 100;
const TREASURE_LUA_START = 0x5ac;
const TREASURE_LUA_LENGTH = 64;
const RATE_DENOMINATOR = 100_000;
const MAX_TREASURE_DEPTH = 12;
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const recipeNames = (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
const recipes = await Promise.all(recipeNames.map(async (name) => (
readJson(path.join(recipesDirectory, name))
)));
if (!recipes.length) throw new Error("No RuneWaker population recipes were found.");
const sourceRoot = sourceRootFor(recipes[0]);
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
const databaseNames = [
"npcobject.db",
"treasureobject.db",
"itemobject.db",
"armorobject.db",
"weaponobject.db",
"cardobject.db",
"runeobject.db",
"recipeobject.db",
"keyitemobject.db",
"string_enus.db",
];
const buffers = new Map(await Promise.all(databaseNames.map(async (name) => (
[name, await readFile(path.join(dataRoot, name))]
))));
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function validateFixedCatalog(buffer, recordSize, objectType, label) {
const count = int32(buffer, COUNT_OFFSET);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
throw new Error(label + " has an incompatible record count or size.");
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(buffer, DATA_START + index * recordSize + 4) !== objectType) {
throw new Error(label + " object-type sentinel drifted.");
}
}
return count;
}
function detectRecordSize(buffer, label) {
const count = int32(buffer, COUNT_OFFSET);
const objectType = int32(buffer, DATA_START + 4);
for (let recordSize = 100; recordSize <= 20_000; recordSize += 4) {
if (DATA_START + recordSize * count > buffer.length) continue;
const sample = [1, 2, 3, Math.floor(count / 2), count - 1];
if (sample.every((index) => (
int32(buffer, DATA_START + index * recordSize + 4) === objectType
))) return { count, objectType, recordSize };
}
throw new Error(label + " fixed-record layout could not be detected safely.");
}
function indexCatalog(buffer, count, recordSize) {
const offsets = new Map();
for (let index = 0; index < count; index += 1) {
const offset = DATA_START + index * recordSize;
const id = int32(buffer, offset);
if (id > 0) offsets.set(id, offset);
}
return offsets;
}
const npc = buffers.get("npcobject.db");
const treasure = buffers.get("treasureobject.db");
const strings = buffers.get("string_enus.db");
const npcCount = validateFixedCatalog(npc, NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
const treasureCount = validateFixedCatalog(
treasure, TREASURE_RECORD_SIZE, TREASURE_OBJECT_TYPE, "TreasureObjectDB",
);
const npcOffsets = indexCatalog(npc, npcCount, NPC_RECORD_SIZE);
const treasureOffsets = indexCatalog(treasure, treasureCount, TREASURE_RECORD_SIZE);
const leafObjects = new Map();
const leafCatalogs = [];
for (const name of databaseNames.filter((candidate) => ![
"npcobject.db", "treasureobject.db", "string_enus.db",
].includes(candidate))) {
const buffer = buffers.get(name);
const layout = detectRecordSize(buffer, name);
const offsets = indexCatalog(buffer, layout.count, layout.recordSize);
leafCatalogs.push({ name, ...layout, count: offsets.size });
for (const id of offsets.keys()) {
if (!leafObjects.has(id)) leafObjects.set(id, { catalog: name, objectType: layout.objectType });
}
}
const stringCache = new Map();
function stringValue(key) {
if (stringCache.has(key)) return stringCache.get(key);
const marker = Buffer.concat([Buffer.from(key, "ascii"), Buffer.from([0])]);
const keyPosition = strings.indexOf(marker);
if (keyPosition < 0) {
stringCache.set(key, null);
return null;
}
const start = keyPosition + marker.length;
const end = Math.min(strings.length - 4, start + 512);
for (let offset = start; offset < end; offset += 1) {
const length = strings.readInt32LE(offset);
if (length < 1 || length > 4001 || offset + 4 + length > strings.length) continue;
const value = strings.subarray(offset + 4, offset + 3 + length);
if (strings[offset + 3 + length] !== 0 || value.includes(0)) continue;
try {
const result = new TextDecoder("utf-8", { fatal: true }).decode(value).trim() || null;
stringCache.set(key, result);
return result;
} catch {
// Metadata can resemble a string length; continue to the next byte.
}
}
stringCache.set(key, null);
return null;
}
function fixedString(buffer, offset, length) {
const end = buffer.indexOf(0, offset);
const boundedEnd = end < 0 || end > offset + length ? offset + length : end;
return buffer.subarray(offset, boundedEnd).toString("utf8").trim();
}
function cleanProbability(value) {
return Number(Math.max(0, Math.min(1, value)).toFixed(12));
}
function blockedOutcome(objectId, chance, quantity, sourcePath, reason, constraints = {}) {
return {
objectId,
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Object " + objectId,
sourceCatalog: null,
runtimeStatus: "blocked",
blockedReason: reason,
conditionalChance: cleanProbability(chance),
quantity,
sourcePath,
...constraints,
};
}
function expandObject(objectId, chance, quantity, sourcePath, stack = [], constraints = {}) {
if (chance <= 0 || quantity <= 0) return [];
const leaf = leafObjects.get(objectId);
if (leaf) {
return [{
objectId,
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Item " + objectId,
sourceCatalog: leaf.catalog,
sourceObjectType: leaf.objectType,
runtimeStatus: "executable-generic-item",
blockedReason: null,
conditionalChance: cleanProbability(chance),
quantity,
sourcePath,
...constraints,
}];
}
const offset = treasureOffsets.get(objectId);
if (offset === undefined) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Object is absent from the bounded item and treasure source catalogs.", constraints,
)];
}
if (stack.includes(objectId) || stack.length >= MAX_TREASURE_DEPTH) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure recursion is cyclic or exceeds the audited depth limit.", constraints,
)];
}
const luaCheck = fixedString(treasure, offset + TREASURE_LUA_START, TREASURE_LUA_LENGTH);
if (luaCheck) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure table requires unavailable Lua predicate " + luaCheck + ".",
{ ...constraints, luaCheck },
)];
}
const needLevelDelta = int32(treasure, offset + TREASURE_NEED_LEVEL_DELTA);
const nextConstraints = {
...constraints,
minimumLevelDelta: Math.max(constraints.minimumLevelDelta ?? -2_147_483_648, needLevelDelta),
};
const itemCount = int32(treasure, offset + TREASURE_ITEM_COUNT);
if (itemCount < 0 || itemCount > TREASURE_MAX_ITEMS) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure item count is outside the preserved 0..100 layout.", nextConstraints,
)];
}
const outcomes = [];
for (let index = 0; index < itemCount; index += 1) {
const itemOffset = offset + TREASURE_ITEMS_START + index * 12;
const childId = int32(treasure, itemOffset);
const childWeight = int32(treasure, itemOffset + 4);
const childQuantity = int32(treasure, itemOffset + 8);
if (childId <= 0 || childWeight <= 0 || childQuantity <= 0) continue;
outcomes.push(...expandObject(
childId,
chance * Math.min(RATE_DENOMINATOR, childWeight) / RATE_DENOMINATOR,
quantity * childQuantity,
[...sourcePath, objectId],
[...stack, objectId],
nextConstraints,
));
}
if (outcomes.length) return outcomes;
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure table has no positive, resolvable outcomes.", nextConstraints,
)];
}
const bosses = [];
for (const recipe of recipes) {
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId;
for (const template of recipe.templates.filter((candidate) => candidate.classification === "boss")) {
const bossKey = runtimePrefix + "-" + template.id;
const npcOffset = npcOffsets.get(template.id);
if (npcOffset === undefined) {
bosses.push({
bossKey,
dungeonId: recipe.dungeonId,
sourceNpcId: template.id,
bossName: template.name,
sourceLevel: Number(template.level ?? 1),
status: "blocked",
blockedReasons: ["NPCObjectDB record is missing."],
rolls: [],
});
continue;
}
const rolls = [];
for (let slot = 0; slot < NPC_DROP_COUNT; slot += 1) {
const dropOffset = npcOffset + NPC_DROP_START + slot * 8;
const sourceObjectId = int32(npc, dropOffset);
const sourceRate = int32(npc, dropOffset + 4);
if (sourceObjectId <= 0 || sourceRate <= 0) continue;
const rootChance = cleanProbability(Math.min(RATE_DENOMINATOR, sourceRate) / RATE_DENOMINATOR);
const outcomes = expandObject(sourceObjectId, 1, 1, []);
rolls.push({
sourceSlot: slot + 1,
sourceObjectId,
sourceRatePer100000: sourceRate,
chancePerKill: rootChance,
outcomes,
});
}
const executable = rolls.flatMap((roll) => roll.outcomes)
.filter((outcome) => outcome.runtimeStatus === "executable-generic-item");
const blocked = rolls.flatMap((roll) => roll.outcomes)
.filter((outcome) => outcome.runtimeStatus === "blocked");
bosses.push({
bossKey,
dungeonId: recipe.dungeonId,
sourceNpcId: template.id,
bossName: template.name,
sourceLevel: Number(template.level ?? 1),
status: !rolls.length ? "no-source-drops" : blocked.length ? "partial" : "portable",
blockedReasons: [...new Set(blocked.map((outcome) => outcome.blockedReason))],
rolls,
executableOutcomeCount: executable.length,
blockedOutcomeCount: blocked.length,
});
}
}
bosses.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|| left.sourceNpcId - right.sourceNpcId);
const sourceFiles = databaseNames.map((name) => ({
file: projectPath(path.join(dataRoot, name)),
sha256: sha256(buffers.get(name)),
}));
const catalog = {
schemaVersion: 1,
status: "portable-with-explicit-fallbacks",
generatedFrom: {
sourceFiles,
npcDropAlgorithm: {
file: "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
firstRelevantLine: 4707,
rateDenominator: RATE_DENOMINATOR,
},
treasureAlgorithm: {
file: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
firstRelevantLine: 3033,
rateDenominator: RATE_DENOMINATOR,
},
layout: "RuneWaker v0.20 fixed records; read-only export",
},
runtimePolicy: {
rollsAreIndependent: true,
treasureOutcomesAreMutuallyExclusive: true,
unknownObjectsExecute: false,
luaPredicatesExecute: false,
sourceItemStatsInvented: false,
importedItemsUseGenericNonEquipableInventoryRepresentation: true,
},
leafCatalogs,
bossCount: bosses.length,
bossWithSourceRollsCount: bosses.filter((boss) => boss.rolls.length).length,
portableBossCount: bosses.filter((boss) => boss.status === "portable").length,
partialBossCount: bosses.filter((boss) => boss.status === "partial").length,
noSourceDropsBossCount: bosses.filter((boss) => boss.status === "no-source-drops").length,
blockedBossCount: bosses.filter((boss) => boss.status === "blocked").length,
executableOutcomeCount: bosses.reduce((total, boss) => total + (boss.executableOutcomeCount ?? 0), 0),
blockedOutcomeCount: bosses.reduce((total, boss) => total + (boss.blockedOutcomeCount ?? 0), 0),
bosses,
};
const runtimeFile = path.join(projectRoot, "src", "game", "generated", "runewakerLootCatalog.json");
const publicFile = path.join(projectRoot, "public", "data", "runewaker-loot-catalog.json");
const reportFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.json");
const reportMarkdownFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.md");
await Promise.all([
mkdir(path.dirname(runtimeFile), { recursive: true }),
mkdir(path.dirname(publicFile), { recursive: true }),
]);
const serialized = JSON.stringify(catalog, null, 2) + "\n";
const reportSummary = {
...Object.fromEntries(Object.entries(catalog).filter(([key]) => key !== "bosses")),
bosses: bosses.map((boss) => ({
bossKey: boss.bossKey,
dungeonId: boss.dungeonId,
sourceNpcId: boss.sourceNpcId,
bossName: boss.bossName,
status: boss.status,
sourceRollCount: boss.rolls.length,
executableOutcomeCount: boss.executableOutcomeCount ?? 0,
blockedOutcomeCount: boss.blockedOutcomeCount ?? 0,
blockedReasons: boss.blockedReasons,
})),
};
const markdown = [
"# RuneWaker loot import report",
"",
"Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.",
"",
`- Boss templates audited: ${catalog.bossCount}`,
`- Bosses with source rolls: ${catalog.bossWithSourceRollsCount}`,
`- Fully portable bosses: ${catalog.portableBossCount}`,
`- Partially portable bosses: ${catalog.partialBossCount}`,
`- Bosses with no authored drop slots: ${catalog.noSourceDropsBossCount}`,
`- Executable item outcomes: ${catalog.executableOutcomeCount}`,
`- Blocked outcomes: ${catalog.blockedOutcomeCount}`,
"",
"Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.",
"",
"| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |",
"| --- | --- | --- | ---: | ---: | ---: | --- |",
...reportSummary.bosses.map((boss) => (
`| ${boss.dungeonId} | ${boss.bossName.replaceAll("|", "\\|")} | ${boss.status} | ${boss.sourceRollCount} | ${boss.executableOutcomeCount} | ${boss.blockedOutcomeCount} | ${(boss.blockedReasons.join("; ") || "—").replaceAll("|", "\\|")} |`
)),
"",
].join("\n");
await Promise.all([
writeFile(runtimeFile, serialized, "utf8"),
writeFile(publicFile, serialized, "utf8"),
writeFile(reportFile, JSON.stringify(reportSummary, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, markdown, "utf8"),
]);
console.log("[runewaker-loot] exported " + catalog.bossCount + " bosses, "
+ catalog.executableOutcomeCount + " executable outcomes, and "
+ catalog.blockedOutcomeCount + " blocked outcomes.");
console.log("[runewaker-loot] " + projectPath(runtimeFile));
@@ -147,6 +147,35 @@ function schoolFor(component) {
?? "arcane";
}
function formulaFor(spellId, component) {
const magnitude = Math.abs(component.damagePower);
const powerKind = component.damagePower < 0
? magnitude <= 4 ? "source-ratio" : "source-points"
: component.dotBase < 0
? "dot-only"
: "unresolved";
return {
schemaVersion: 1,
spellId,
componentId: component.id,
spellLevel: 0,
calculationPath: component.effectType === 1 ? "physical" : "magic",
powerKind,
damagePower: component.damagePower,
fixedValue: component.fixedValue,
dotBase: component.dotBase,
runtimeBaselineMultiplier: powerKind === "source-ratio"
? Math.max(0.2, Math.min(4, magnitude))
: powerKind === "unresolved" ? 0 : 0.85,
provenance: {
sourceFile: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
physicalFunctionLine: 3366,
magicFunctionLine: 3651,
magicObjectDatabase: "Resource/data/magicobject.db",
},
};
}
const assignmentsBySpell = new Map();
for (const recipe of recipes) {
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
@@ -270,7 +299,8 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
? sourceCooldownSeconds * 1000
: 6500)),
damageMultiplier: 0.85,
damageMultiplier: formulaFor(spellId, primaryDamage).runtimeBaselineMultiplier,
formula: formulaFor(spellId, primaryDamage),
},
} : {}),
components,
@@ -298,6 +328,8 @@ const report = {
runtimeSqlDependency: false,
unknownEffectsExecute: false,
coefficientsAreSourceExact: false,
unavailableNativeInputsDefaulted: false,
pointValuedDamageUsesLabeledScaledAdapter: true,
},
spells,
};
@@ -0,0 +1,702 @@
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
const reportJsonFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
);
const reportMarkdownFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
);
export const IMPLEMENTATION_STATUSES = Object.freeze([
"implemented",
"verified",
"deferred-source-gap",
"deferred-asset-gap",
"deferred-runtime-gap",
"excluded-non-gameplay",
"intentional-empty",
]);
export const FIDELITY_LEVELS = Object.freeze([
"source-exact",
"source-backed-adapted",
"healer-man-authored",
]);
const EXPECTED_FALLBACK_BOSSES = Object.freeze([
"rw-fa-100070",
"rw-fa-100561",
"rw-fa-100562",
"rw-fa-100563",
]);
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
"aeternal-circle",
"dungeon-of-dalanis",
"grafu-castle",
"kal-turok-hive",
"lair-demon-dragon",
"tomb-seven-heroes",
"warnorken-arena",
"zurhidon-stronghold",
]);
const EXPECTED_INTENTIONAL_EMPTY_PACKAGES = Object.freeze([
"demon-stronghold-125",
"zurhidon-stronghold-124",
]);
const SIGNAL_PATTERNS = Object.freeze([
["summon", /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i],
["door", /door|gate|seal|barrier|open.*object|close.*object/i],
["timer", /timer|delay|sleep|wait|schedule/i],
["movement", /moveto|move_to|patrol|waypoint|autoplot|path/i],
["spell", /cast|magic|spell|skill/i],
["death", /ondead|on_dead|dead|death|onkill|on_kill/i],
["phase", /phase|stage|state|wave/i],
["interaction", /click|useobject|talk|speak|interact|trigger/i],
["loot", /loot|treasure|drop|reward|chest/i],
]);
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
const projectPath = (file) => path.relative(projectRoot, file).split(path.sep).join("/");
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const sha256File = async (file) => sha256(await readFile(file));
const compareText = (left, right) => String(left).localeCompare(String(right));
const sameMembers = (left, right) => (
left.length === right.length && left.every((value, index) => value === right[index])
);
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
function generatedJson(source, name, suffix) {
const marker = "export const " + name + " = ";
const start = source.indexOf(marker);
if (start < 0) throw new Error("Missing generated constant " + name + ".");
const valueStart = start + marker.length;
const valueEnd = source.indexOf(suffix, valueStart);
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
return JSON.parse(source.slice(valueStart, valueEnd));
}
function runtimeTemplateId(entityId) {
const match = String(entityId).match(/-(\d+)$/);
return match ? Number(match[1]) : null;
}
function categoriesFor(value) {
return SIGNAL_PATTERNS
.filter(([, pattern]) => pattern.test(String(value ?? "")))
.map(([kind]) => kind);
}
function statusCounts(items) {
return Object.fromEntries(IMPLEMENTATION_STATUSES.map((status) => [
status,
items.filter((item) => item.status === status).length,
]));
}
function fidelityCounts(items) {
return Object.fromEntries(FIDELITY_LEVELS.map((fidelity) => [
fidelity,
items.filter((item) => item.fidelity === fidelity).length,
]));
}
function sourceFile(pathName, fileSha256, contentSha256 = null) {
return {
path: pathName,
sha256: fileSha256,
...(contentSha256 ? { contentSha256 } : {}),
};
}
function deferredRowRecord({ dungeonId, zoneId, row, snapshotPath, snapshotFileSha256, snapshot }) {
const evidenceText = [
row.name,
row.sourceName,
row.modelPath,
row.source?.roleName,
row.source?.autoPlot,
row.source?.plotClassName,
...Object.values(row.source?.templateScripts ?? {}),
].filter(Boolean).join(" ");
return {
id: "row:" + dungeonId + ":" + row.spawnId,
kind: "deferred-source-row",
dungeonId,
zoneId,
sourceIds: {
spawnId: row.spawnId,
templateId: row.templateId,
roomId: Number(row.source?.roomId ?? 0),
},
name: row.name,
sourceName: row.sourceName,
sourceClassification: row.classification,
sourcePosition: row.sourcePosition,
direction: row.direction,
modelPath: row.modelPath ?? null,
heuristicCategories: categoriesFor(evidenceText),
status: "deferred-source-gap",
fidelity: "source-exact",
source: {
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
},
blocker: "The source row preserves identity and placement, but no reviewed evidence proves its gameplay behavior.",
nextWork: "Identify the authoritative trigger, state transition, reset/persistence rules, and asset requirements; then add an explicit source-spawn mapping.",
clearingTest: "Add a deterministic fixture proving that source spawn " + row.spawnId
+ " executes the reviewed behavior and resets idempotently.",
};
}
function runtimeSpellMatches(entities, templateId, spellId) {
return Object.entries(entities).flatMap(([entityId, entity]) => {
if (runtimeTemplateId(entityId) !== templateId) return [];
return (entity.combat?.attacks ?? [])
.filter((attack) => Number(attack.spellId ?? attack.source?.spellId ?? 0) === spellId)
.map((attack) => ({
entityId,
attackId: attack.id,
telegraph: attack.telegraph ?? null,
delivery: attack.delivery,
}));
});
}
function baseEvidenceRecord({
id,
evidenceKind,
dungeonId,
zoneId,
templateId = null,
categories,
value,
source,
}) {
return {
id,
kind: "mechanics-evidence",
evidenceKind,
dungeonId,
zoneId,
sourceIds: templateId == null ? {} : { templateId },
categories: [...new Set(categories)].sort(compareText),
value,
status: "deferred-source-gap",
fidelity: "source-exact",
source,
blocker: "This is source evidence, but it does not prove a complete executable mechanic by itself.",
nextWork: "Review the surrounding source to establish trigger, target, timing, state effects, reset, persistence, and multiplayer behavior.",
clearingTest: "Add a focused encounter fixture tied to this stable evidence ID and verify its full state transition and reset behavior.",
};
}
function templateEvidenceRecords({
dungeonId,
zoneId,
mechanics,
mechanicsPath,
mechanicsFileSha256,
snapshot,
snapshotPath,
snapshotFileSha256,
entities,
}) {
const records = [];
const commonSource = {
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
};
for (const candidate of mechanics.templateCandidates ?? []) {
const templateId = Number(candidate.templateId);
for (const value of candidate.autoPlots ?? []) {
records.push(baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":autoplot:" + sha256(value).slice(0, 12),
evidenceKind: "auto-plot",
dungeonId,
zoneId,
templateId,
categories: categoriesFor(value),
value,
source: commonSource,
}));
}
for (const value of candidate.plotClassNames ?? []) {
records.push(baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":plot-class:" + sha256(value).slice(0, 12),
evidenceKind: "plot-class",
dungeonId,
zoneId,
templateId,
categories: categoriesFor(value),
value,
source: commonSource,
}));
}
for (const script of candidate.templateScripts ?? []) {
const value = script.event + ":" + script.script;
records.push(baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":script:"
+ script.event + ":" + sha256(script.script).slice(0, 12),
evidenceKind: "template-script",
dungeonId,
zoneId,
templateId,
categories: categoriesFor(value),
value: { event: script.event, script: script.script },
source: commonSource,
}));
}
for (const spell of candidate.spells ?? []) {
const spellId = Number(spell.id);
const matches = runtimeSpellMatches(entities, templateId, spellId);
const record = baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":spell:" + spellId,
evidenceKind: "source-spell",
dungeonId,
zoneId,
templateId,
categories: ["spell"],
value: { spellId, level: Number(spell.level ?? 0) },
source: commonSource,
});
if (matches.length) {
record.status = "verified";
record.fidelity = "source-backed-adapted";
record.runtimeMappings = matches;
record.blocker = "The original RuneWaker coefficient and native stat inputs are not available in the portable package.";
record.nextWork = "Retain the exact spell identity and revisit numerical fidelity only if authoritative formula inputs are recovered.";
record.clearingTest = "npm run runewaker:audit:gameplay verifies the source spell ID and runtime attack remain aligned.";
}
records.push(record);
}
}
return records;
}
function luaEvidenceRecords({ dungeonId, zoneId, mechanics, mechanicsPath, mechanicsFileSha256 }) {
const luaHashByFile = new Map(
(mechanics.luaSources ?? []).map((source) => [source.file, source.sha256]),
);
return (mechanics.luaSignals ?? []).map((signal) => baseEvidenceRecord({
id: "signal:" + dungeonId + ":lua:" + signal.file + ":" + signal.line,
evidenceKind: "lua-line",
dungeonId,
zoneId,
categories: signal.kinds ?? [],
value: { line: signal.line, text: signal.text },
source: {
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
lua: sourceFile(signal.file, luaHashByFile.get(signal.file) ?? null),
},
}));
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.deferredSourceRows.length, "|",
dungeon.mechanicsEvidence.length, "|", dungeon.evidenceStatusCounts.verified, "|",
dungeon.evidenceStatusCounts["deferred-source-gap"], "|", dungeon.bossObjectives, "|",
dungeon.packageAssessment.status, "|",
].join(" "));
const categoryRows = Object.entries(report.summary.evidenceCategoryMemberships)
.map(([kind, count]) => "| " + kind + " | " + count + " |");
const blockerRows = Object.entries(report.blockerGroups)
.map(([key, value]) => "| " + key + " | " + value.items + " | " + value.nextWork + " |");
return [
"# RuneWaker encounter implementation and fidelity ledger",
"",
"Structural validation: **" + report.validationStatus + "**",
"Content completion: **" + report.completionStatus + "**",
"",
"This report is deterministic and offline. Its JSON companion inventories every deferred",
"source spawn row and every direct mechanics evidence record. A regex category membership",
"is a review label, not a unique mechanic; one evidence record may belong to several categories.",
"",
"## Coverage",
"",
"- " + report.summary.dungeons + " dungeon packages.",
"- " + report.summary.deferredSourceRows + " deferred source rows with stable spawn IDs.",
"- " + report.summary.uniqueMechanicsEvidence + " unique mechanics evidence records: "
+ report.summary.luaEvidenceSignals + " Lua lines and "
+ report.summary.templateEvidenceSignals + " template references.",
"- " + report.summary.evidenceCategoryMembershipCount
+ " overlapping category memberships; this number is intentionally not reported as mechanics.",
"- " + report.summary.verifiedEvidence + " evidence records have an exact executable source-spell mapping.",
"- " + report.summary.sourceGapEvidence + " evidence records remain source-review gaps.",
"",
"## Status definitions",
"",
"- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.",
"- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.",
"- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.",
"- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.",
"- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.",
"- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.",
"- `intentional-empty`: the authoritative packaged snapshot contains no population rows.",
"",
"## Per-dungeon inventory",
"",
"| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
...rows,
"",
"## Four explicit fallback bosses",
"",
"| Boss | Runtime ID | Status | Fidelity | Blocker |",
"| --- | --- | --- | --- | --- |",
...report.specialCases.fallbackBosses.map((boss) => "| " + boss.name + " | `" + boss.entityId
+ "` | " + boss.status + " | " + boss.fidelity + " | " + boss.blocker + " |"),
"",
"These attacks are intentionally playable and telegraphed HealerMan behavior. They are not",
"presented as original RuneWaker mechanics until authoritative source behavior is recovered.",
"",
"## Non-empty packages without confirmed boss objectives",
"",
...report.specialCases.nonEmptyZeroBossPackages.map((entry) => "- `" + entry.dungeonId + "`: "
+ entry.sourceRows + " rows; " + entry.blocker + " Next: " + entry.nextWork),
"",
"## Intentional empty variants",
"",
...report.specialCases.intentionalEmptyPackages.map((entry) => "- `" + entry.dungeonId + "` (Zone "
+ entry.zoneId + "): " + entry.blocker),
"",
"## Evidence category memberships",
"",
"| Category | Evidence records carrying the label |",
"| --- | ---: |",
...categoryRows,
"",
"## Remaining work ledger",
"",
"| Blocker group | Items | Clearing work |",
"| --- | ---: | --- |",
...blockerRows,
"",
"The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source",
"files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.",
"",
"## Validation issues",
"",
...(report.issues.length ? report.issues.map((entry) => "- " + entry) : ["- None."]),
"",
].join("\n");
}
function validateReport(report) {
const issues = [];
const inventory = report.dungeons.flatMap((dungeon) => [
...dungeon.deferredSourceRows,
...dungeon.mechanicsEvidence,
]);
const ids = new Set();
for (const item of inventory) {
if (ids.has(item.id)) issues.push("Duplicate inventory ID: " + item.id + ".");
ids.add(item.id);
if (!IMPLEMENTATION_STATUSES.includes(item.status)) {
issues.push(item.id + " has unsupported status " + item.status + ".");
}
if (!FIDELITY_LEVELS.includes(item.fidelity)) {
issues.push(item.id + " has unsupported fidelity " + item.fidelity + ".");
}
if (!item.blocker || !item.nextWork || !item.clearingTest) {
issues.push(item.id + " does not document blocker, next work, and clearing test.");
}
}
const fallbackIds = report.specialCases.fallbackBosses.map((boss) => boss.entityId).sort(compareText);
if (!sameMembers(fallbackIds, [...EXPECTED_FALLBACK_BOSSES])) {
issues.push("Expected exactly the four documented Forsaken Abbey fallback bosses; found "
+ fallbackIds.join(", ") + ".");
}
const zeroBossIds = report.specialCases.nonEmptyZeroBossPackages
.map((entry) => entry.dungeonId).sort(compareText);
if (!sameMembers(zeroBossIds, [...EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES])) {
issues.push("Non-empty zero-boss package set drifted: " + zeroBossIds.join(", ") + ".");
}
const emptyIds = report.specialCases.intentionalEmptyPackages
.map((entry) => entry.dungeonId).sort(compareText);
if (!sameMembers(emptyIds, [...EXPECTED_INTENTIONAL_EMPTY_PACKAGES])) {
issues.push("Intentional-empty package set drifted: " + emptyIds.join(", ") + ".");
}
return issues;
}
export async function buildEncounterImplementationReport() {
const recipeNames = (await readdir(recipeDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort(compareText);
const bossMechanicsPath = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
const [bossMechanics, bossMechanicsFileSha256] = await Promise.all([
readJson(bossMechanicsPath),
sha256File(bossMechanicsPath),
]);
const dungeons = [];
for (const recipeName of recipeNames) {
const recipeFile = path.join(recipeDirectory, recipeName);
const recipe = await readJson(recipeFile);
const dungeonId = recipe.dungeonId;
const zoneId = recipe.zoneId;
const snapshotFile = path.resolve(projectRoot, recipe.files.snapshot);
const mechanicsFile = path.resolve(
projectRoot,
recipe.files.mechanicsReport
?? "src/assets/game/dungeons/" + dungeonId + "/mechanics-audit.json",
);
const generatedSourceFile = path.resolve(projectRoot, recipe.files.generatedSource);
const [
recipeFileSha256,
snapshot,
snapshotFileSha256,
mechanics,
mechanicsFileSha256,
generatedSource,
generatedSourceSha256,
] = await Promise.all([
sha256File(recipeFile),
readJson(snapshotFile),
sha256File(snapshotFile),
readJson(mechanicsFile),
sha256File(mechanicsFile),
readFile(generatedSourceFile, "utf8"),
sha256File(generatedSourceFile),
]);
const prefix = constantCase(dungeonId);
const entities = generatedJson(
generatedSource,
prefix + "_ENTITIES",
" as const satisfies PopulationDefinitionMap;",
);
const bosses = generatedJson(
generatedSource,
prefix + "_BOSSES",
" as const satisfies readonly DungeonBossObjective[];",
);
const snapshotPath = projectPath(snapshotFile);
const mechanicsPath = projectPath(mechanicsFile);
const deferredSourceRows = (snapshot.rows ?? [])
.filter((row) => String(row.classification).startsWith("deferred"))
.map((row) => deferredRowRecord({
dungeonId,
zoneId,
row,
snapshotPath,
snapshotFileSha256,
snapshot,
}))
.sort((left, right) => left.sourceIds.spawnId - right.sourceIds.spawnId);
const mechanicsEvidence = [
...luaEvidenceRecords({
dungeonId,
zoneId,
mechanics,
mechanicsPath,
mechanicsFileSha256,
}),
...templateEvidenceRecords({
dungeonId,
zoneId,
mechanics,
mechanicsPath,
mechanicsFileSha256,
snapshot,
snapshotPath,
snapshotFileSha256,
entities,
}),
].sort((left, right) => compareText(left.id, right.id));
const packageStatus = snapshot.rows.length === 0
? "intentional-empty"
: bosses.length === 0
? "deferred-source-gap"
: "verified";
dungeons.push({
id: dungeonId,
zoneId,
sourceRows: snapshot.rows.length,
bossObjectives: bosses.length,
sourceArtifacts: {
recipe: sourceFile(projectPath(recipeFile), recipeFileSha256),
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
generatedRuntime: sourceFile(projectPath(generatedSourceFile), generatedSourceSha256),
upstreamHashes: snapshot.sourceHashes ?? {},
},
packageAssessment: {
status: packageStatus,
fidelity: "source-exact",
blocker: packageStatus === "intentional-empty"
? "The authoritative snapshot contains zero population rows; no combat population is fabricated."
: packageStatus === "deferred-source-gap"
? "The package is non-empty, but its preserved source classification confirms no boss objectives."
: "No package-level population blocker is recorded.",
nextWork: packageStatus === "deferred-source-gap"
? "Recover authoritative boss membership and encounter order before creating boss objectives."
: "Re-run the ledger whenever portable source content changes.",
clearingTest: packageStatus === "deferred-source-gap"
? "Add a source-backed boss-membership fixture and verify the generated objective list."
: "npm run runewaker:audit:gameplay",
},
deferredRowStatusCounts: statusCounts(deferredSourceRows),
evidenceStatusCounts: statusCounts(mechanicsEvidence),
evidenceFidelityCounts: fidelityCounts(mechanicsEvidence),
deferredSourceRows,
mechanicsEvidence,
});
}
dungeons.sort((left, right) => compareText(left.id, right.id));
const allRows = dungeons.flatMap((dungeon) => dungeon.deferredSourceRows);
const allEvidence = dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
const fallbackBosses = Object.entries(bossMechanics.bosses ?? {})
.filter(([, boss]) => boss.source?.kind === "healer-man-source-gap")
.map(([entityId, boss]) => {
const dungeon = dungeons.find((entry) => entry.id === boss.source.dungeonId);
return {
entityId,
name: boss.name,
dungeonId: boss.source.dungeonId,
templateId: Number(boss.source.templateId),
mechanicIds: (boss.mechanics ?? []).map((mechanic) => mechanic.id).sort(compareText),
telegraphedMechanicIds: (boss.mechanics ?? [])
.filter((mechanic) => mechanic.telegraph)
.map((mechanic) => mechanic.id)
.sort(compareText),
status: "implemented",
fidelity: "healer-man-authored",
source: {
bossMechanicsCatalog: sourceFile(projectPath(bossMechanicsPath), bossMechanicsFileSha256),
snapshot: dungeon?.sourceArtifacts.snapshot ?? null,
roots: boss.source.roots ?? [],
functions: boss.source.functions ?? [],
spellIds: boss.source.spellIds ?? [],
},
blocker: "Preserved source evidence does not expose an executable original combat kit.",
nextWork: "Replace the fallback only after authoritative spell timing, targeting, and effects are recovered; preserve modular IDs and telegraphs.",
clearingTest: "Add source-backed mechanic fixtures and assert the catalog source kind changes from healer-man-source-gap to runewaker.",
};
})
.sort((left, right) => compareText(left.entityId, right.entityId));
const nonEmptyZeroBossPackages = dungeons
.filter((dungeon) => dungeon.sourceRows > 0 && dungeon.bossObjectives === 0)
.map((dungeon) => ({
dungeonId: dungeon.id,
zoneId: dungeon.zoneId,
sourceRows: dungeon.sourceRows,
status: "deferred-source-gap",
fidelity: "source-exact",
source: dungeon.sourceArtifacts.snapshot,
blocker: "The portable source package contains population rows but no source-confirmed boss membership.",
nextWork: "Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.",
clearingTest: "Verify at least one source-backed boss objective, or add reviewed evidence that this package intentionally has none.",
}));
const intentionalEmptyPackages = dungeons
.filter((dungeon) => dungeon.sourceRows === 0)
.map((dungeon) => ({
dungeonId: dungeon.id,
zoneId: dungeon.zoneId,
sourceRows: 0,
status: "intentional-empty",
fidelity: "source-exact",
source: dungeon.sourceArtifacts.snapshot,
blocker: "The authoritative packaged snapshot contains no active population rows.",
nextWork: "No population work is scheduled unless a different authoritative source snapshot is supplied.",
clearingTest: "Keep the zero-row snapshot hash and intentional-empty classification stable in this audit.",
}));
const categoryMemberships = {};
for (const evidence of allEvidence) {
for (const category of evidence.categories) {
categoryMemberships[category] = (categoryMemberships[category] ?? 0) + 1;
}
}
const luaEvidenceSignals = allEvidence.filter((item) => item.evidenceKind === "lua-line").length;
const templateEvidenceSignals = allEvidence.length - luaEvidenceSignals;
const report = {
schemaVersion: 1,
validationStatus: "green",
completionStatus: allRows.length || allEvidence.some((item) => item.status.startsWith("deferred"))
? "review-required"
: "complete",
scope: "All configured offline RuneWaker dungeon packages",
generatedBy: "scripts/runewaker-pipeline/audit-encounter-implementation.mjs",
deterministic: true,
policy: {
statuses: IMPLEMENTATION_STATUSES,
fidelities: FIDELITY_LEVELS,
evidenceRule: "Direct source rows and source references are inventoried once. Regex category memberships may overlap and are never counted as unique mechanics.",
failClosedRule: "When static evidence cannot prove complete behavior, the item remains deferred-source-gap rather than receiving a guessed implementation status.",
sourceExactRule: "source-exact describes preserved evidence identity and provenance, not an assertion that deferred gameplay behavior is known.",
},
summary: {
dungeons: dungeons.length,
deferredSourceRows: allRows.length,
uniqueMechanicsEvidence: allEvidence.length,
luaEvidenceSignals,
templateEvidenceSignals,
evidenceCategoryMembershipCount: Object.values(categoryMemberships)
.reduce((total, count) => total + count, 0),
evidenceCategoryMemberships: Object.fromEntries(
Object.entries(categoryMemberships).sort(([left], [right]) => compareText(left, right)),
),
deferredRowStatuses: statusCounts(allRows),
evidenceStatuses: statusCounts(allEvidence),
evidenceFidelities: fidelityCounts(allEvidence),
verifiedEvidence: allEvidence.filter((item) => item.status === "verified").length,
sourceGapEvidence: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
fallbackBosses: fallbackBosses.length,
nonEmptyZeroBossPackages: nonEmptyZeroBossPackages.length,
intentionalEmptyPackages: intentionalEmptyPackages.length,
},
blockerGroups: {
deferredSourceRows: {
items: allRows.length,
nextWork: "Review source semantics and add explicit source-spawn encounter mappings.",
},
unresolvedMechanicsEvidence: {
items: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
nextWork: "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior.",
},
adaptedSpellFormula: {
items: allEvidence.filter((item) => item.status === "verified"
&& item.fidelity === "source-backed-adapted").length,
nextWork: "Recover native formula inputs before claiming coefficient-level fidelity.",
},
sourceGapFallbackBosses: {
items: fallbackBosses.length,
nextWork: "Recover authoritative combat kits and replace only with source-backed modular mechanics.",
},
nonEmptyZeroBossPackages: {
items: nonEmptyZeroBossPackages.length,
nextWork: "Recover authoritative boss membership and objective order.",
},
},
specialCases: {
fallbackBosses,
nonEmptyZeroBossPackages,
intentionalEmptyPackages,
},
issues: [],
dungeons,
};
report.issues = validateReport(report);
report.validationStatus = report.issues.length ? "blocked" : "green";
return report;
}
export async function generateEncounterImplementationReport() {
const report = await buildEncounterImplementationReport();
await Promise.all([
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
]);
return {
report,
reportJson: projectPath(reportJsonFile),
reportMarkdown: projectPath(reportMarkdownFile),
};
}
@@ -0,0 +1,383 @@
import { createHash } from "node:crypto";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds, mergeDocuments, unpartition } from "@gltf-transform/functions";
import { Matrix4, Quaternion, Vector3 } from "three";
const SUPPORTED_RESOURCE_EXTENSIONS = new Set([".ros"]);
const DYNAMIC_HINT_PATTERN = /(?:^|[\\/_-])(door|gate|bridge|drawbridge|lift|elevator|switch|lever|chest|treasure|portal)(?:[\\/_.-]|$)/i;
const DYNAMIC_KINDS = new Set([
"door",
"gate",
"bridge",
"lift",
"switch",
"chest",
"portal",
"other",
]);
export function normalizeWdbResourcePath(value) {
return String(value ?? "")
.replaceAll("/", "\\")
.replace(/^\\+/, "")
.toLowerCase();
}
export function resourceCacheKey(value) {
return createHash("sha256")
.update(normalizeWdbResourcePath(value))
.digest("hex")
.slice(0, 24);
}
function finiteTuple(value, length, name) {
if (!Array.isArray(value) || value.length !== length
|| value.some((entry) => !Number.isFinite(Number(entry)))) {
throw new Error(name + " must contain exactly " + length + " finite numbers.");
}
return value.map(Number);
}
export function descriptorSourceMatrix(descriptor) {
const translation = finiteTuple(descriptor.translation, 3, "descriptor.translation");
const rotation = finiteTuple(descriptor.rotation, 4, "descriptor.rotation");
const scale = finiteTuple(descriptor.scale, 3, "descriptor.scale");
const quaternion = new Quaternion(...rotation);
if (quaternion.lengthSq() < 1e-12) throw new Error("descriptor.rotation cannot be zero.");
quaternion.normalize();
return new Matrix4().compose(
new Vector3(...translation),
quaternion,
new Vector3(...scale),
);
}
export function placementMatrixInPrimaryThree(descriptor, primary, metersPerSourceUnit) {
const meters = Number(metersPerSourceUnit);
if (!Number.isFinite(meters) || meters <= 0) {
throw new Error("metersPerSourceUnit must be a positive finite number.");
}
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
const threeToSource = sourceToThree.clone().invert();
return sourceToThree
.clone()
.multiply(descriptorSourceMatrix(primary).invert())
.multiply(descriptorSourceMatrix(descriptor))
.multiply(threeToSource);
}
export function transformSourcePointToPrimaryThree(
sourcePoint,
descriptor,
primary,
metersPerSourceUnit,
) {
const point = new Vector3(...finiteTuple(sourcePoint, 3, "sourcePoint"));
point.applyMatrix4(descriptorSourceMatrix(descriptor));
point.applyMatrix4(descriptorSourceMatrix(primary).invert());
const meters = Number(metersPerSourceUnit);
return [point.x * meters, point.y * meters, -point.z * meters];
}
function cloneNodeTree(document, node) {
const clone = document.createNode(node.getName())
.setMatrix(node.getMatrix())
.setExtras({ ...node.getExtras() })
.setMesh(node.getMesh())
.setCamera(node.getCamera())
.setSkin(node.getSkin())
.setWeights([...node.getWeights()]);
for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child));
return clone;
}
export async function assembleInstancedGlb(records, outputFile) {
if (!records.length) throw new Error("Cannot assemble an empty WDB GLB.");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const target = new Document();
const scene = target.createScene("WDB assembly");
const recordsByAsset = new Map();
for (const record of records) {
const rows = recordsByAsset.get(record.assetFile) ?? [];
rows.push(record);
recordsByAsset.set(record.assetFile, rows);
}
for (const assetFile of [...recordsByAsset.keys()].sort()) {
const source = await io.read(assetFile);
const sourceScene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
if (!sourceScene) throw new Error("Cached WDB resource has no scene: " + assetFile);
const mapping = mergeDocuments(target, source);
const mergedScene = mapping.get(sourceScene);
if (!mergedScene) throw new Error("Could not merge cached WDB resource: " + assetFile);
const roots = mergedScene.listChildren();
if (!roots.length) throw new Error("Cached WDB resource has no scene roots: " + assetFile);
for (const [index, record] of recordsByAsset.get(assetFile).entries()) {
const placement = target.createNode(record.placement.id)
.setMatrix(record.placement.transform.primaryLocalThreeMatrix)
.setExtras({
wdbPlacementId: record.placement.id,
wdbGuid: record.placement.guid,
sourceResource: record.placement.resource,
resourceCacheKey: record.placement.resourceCacheKey,
});
const nodes = index === 0
? roots
: roots.map((rootNode) => cloneNodeTree(target, rootNode));
for (const rootNode of nodes) placement.addChild(rootNode);
scene.addChild(placement);
}
mergedScene.dispose();
}
await target.transform(unpartition());
const bounds = getBounds(scene);
await io.write(outputFile, target);
return {
bounds: {
min: bounds.min.map((value) => Number(value.toFixed(6))),
max: bounds.max.map((value) => Number(value.toFixed(6))),
},
};
}
function matrixArray(matrix) {
return matrix.toArray().map((value) => Number(value.toFixed(10)));
}
function selectPrimaryDescriptor(report, recipe) {
const target = normalizeWdbResourcePath(recipe.source.model);
const matches = (report.descriptors ?? []).filter((descriptor) => (
normalizeWdbResourcePath(descriptor.resource) === target
));
if (!matches.length) {
throw new Error("Expected at least one WDB placement for " + recipe.source.model + ".");
}
if (matches.length === 1) return matches[0];
const expected = recipe.source.primaryPlacementTranslation;
if (!Array.isArray(expected) || expected.length !== 3) {
throw new Error("Multiple primary-resource placements require source.primaryPlacementTranslation.");
}
const match = matches.find((descriptor) => descriptor.translation.every((value, index) => (
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
)));
if (!match) throw new Error("Configured primary WDB placement was not found.");
return match;
}
function reviewedDynamicRules(recipe) {
const rules = recipe.source.wdbAssembly?.dynamicPlacements ?? [];
const ids = new Set();
return rules.map((rule, index) => {
if (!rule || typeof rule !== "object") {
throw new Error("source.wdbAssembly.dynamicPlacements[" + index + "] must be an object.");
}
if (!String(rule.id ?? "").trim()) throw new Error("Dynamic placement rule requires a stable id.");
if (ids.has(rule.id)) throw new Error("Duplicate dynamic placement id " + rule.id + ".");
ids.add(rule.id);
if (!DYNAMIC_KINDS.has(rule.kind)) {
throw new Error("Dynamic placement " + rule.id + " has unsupported kind " + rule.kind + ".");
}
if (!rule.guid && !rule.resource) {
throw new Error("Dynamic placement " + rule.id + " requires guid or resource evidence.");
}
if (!Array.isArray(rule.evidence) || !rule.evidence.length) {
throw new Error("Dynamic placement " + rule.id + " requires reviewed evidence.");
}
return {
...rule,
normalizedResource: rule.resource ? normalizeWdbResourcePath(rule.resource) : null,
};
});
}
function matchingDynamicRule(descriptor, rules) {
const resource = normalizeWdbResourcePath(descriptor.resource);
return rules.find((rule) => (
(rule.guid && String(rule.guid).toLowerCase() === String(descriptor.guid).toLowerCase())
|| (rule.normalizedResource && rule.normalizedResource === resource)
));
}
function placementRecord(descriptor, index, primary, recipe, classification, dynamicRule = null) {
const resource = normalizeWdbResourcePath(descriptor.resource);
const id = classification === "dynamic"
? dynamicRule.id + ":" + String(descriptor.guid ?? index)
: "wdb:" + String(descriptor.guid ?? index);
return {
id,
guid: descriptor.guid ?? null,
resource: String(descriptor.resource).replaceAll("\\", "/"),
normalizedResource: resource,
resourceCacheKey: resourceCacheKey(resource),
classification,
transform: {
source: {
translation: descriptor.translation.map(Number),
rotation: descriptor.rotation.map(Number),
scale: descriptor.scale.map(Number),
},
primaryLocalThreeMatrix: matrixArray(placementMatrixInPrimaryThree(
descriptor,
primary,
recipe.coordinateSystem.metersPerSourceUnit,
)),
},
sourceBounds: {
local: descriptor.localBounds ?? null,
world: descriptor.worldBounds ?? null,
},
sourceFlags: {
zoneFlags: Number(descriptor.zoneFlags ?? 0),
detailClass: Number(descriptor.detailClass ?? 0),
zoneType: Number(descriptor.zoneType ?? 0),
},
...(dynamicRule ? {
dynamic: {
definitionId: dynamicRule.id,
kind: dynamicRule.kind,
initialState: dynamicRule.initialState ?? "default",
collision: dynamicRule.collision ?? "stateful",
evidence: dynamicRule.evidence,
},
} : {}),
};
}
export function buildWdbPlacementPlan(report, recipe) {
if (!report || !Array.isArray(report.descriptors)) {
throw new Error("WDB report must contain descriptors.");
}
const primary = selectPrimaryDescriptor(report, recipe);
const dynamicRules = reviewedDynamicRules(recipe);
const matchedDynamicRuleIds = new Set();
const staticPlacements = [];
const dynamicPlacements = [];
const reviewRequiredPlacements = [];
const unsupportedPlacements = [];
const resourceMap = new Map();
for (const [index, descriptor] of report.descriptors.entries()) {
const resource = normalizeWdbResourcePath(descriptor.resource);
const extension = path.extname(resource);
if (!SUPPORTED_RESOURCE_EXTENSIONS.has(extension)) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: extension
? "unsupported WDB placement resource extension " + extension
: "WDB placement resource has no extension",
});
continue;
}
let matrix;
try {
matrix = placementMatrixInPrimaryThree(
descriptor,
primary,
recipe.coordinateSystem.metersPerSourceUnit,
);
} catch (error) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: "invalid WDB transform: " + error.message,
});
continue;
}
if (matrix.elements.some((value) => !Number.isFinite(value))) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: "WDB transform produced a non-finite matrix",
});
continue;
}
const dynamicRule = matchingDynamicRule(descriptor, dynamicRules);
if (descriptor === primary && dynamicRule) {
throw new Error("The primary coordinate-anchor placement cannot be dynamic ("
+ dynamicRule.id + ").");
}
const classification = dynamicRule ? "dynamic" : "static";
const placement = placementRecord(
descriptor,
index,
primary,
recipe,
classification,
dynamicRule,
);
if (dynamicRule) {
matchedDynamicRuleIds.add(dynamicRule.id);
dynamicPlacements.push(placement);
} else {
staticPlacements.push(placement);
if (DYNAMIC_HINT_PATTERN.test(resource)) {
reviewRequiredPlacements.push({
placementId: placement.id,
guid: placement.guid,
resource: placement.resource,
reason: "Resource name resembles a movable or interactive object, but no reviewed dynamic-placement rule proves its behavior.",
});
}
}
if (!resourceMap.has(resource)) {
resourceMap.set(resource, {
resource: placement.resource,
normalizedResource: resource,
cacheKey: placement.resourceCacheKey,
placementCount: 0,
staticPlacementCount: 0,
dynamicPlacementCount: 0,
});
}
const resourceRecord = resourceMap.get(resource);
resourceRecord.placementCount += 1;
resourceRecord[classification + "PlacementCount"] += 1;
}
const unmatchedDynamicRules = dynamicRules
.filter((rule) => !matchedDynamicRuleIds.has(rule.id))
.map((rule) => ({
id: rule.id,
guid: rule.guid ?? null,
resource: rule.resource ?? null,
reason: "Reviewed dynamic-placement selector matched no WDB descriptor.",
}));
if (unmatchedDynamicRules.length) {
throw new Error(unmatchedDynamicRules.map((rule) => rule.reason + " " + rule.id).join("\n"));
}
return {
schemaVersion: 1,
dungeonId: recipe.slug,
sourceWdb: report.sourceWdb,
sourceDescriptorCount: report.descriptors.length,
primaryPlacement: {
guid: primary.guid ?? null,
resource: String(primary.resource).replaceAll("\\", "/"),
translation: primary.translation.map(Number),
rotation: primary.rotation.map(Number),
scale: primary.scale.map(Number),
},
coordinateTransform: {
source: recipe.coordinateSystem.source,
nativeBridge: recipe.coordinateSystem.nativeBridge,
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
formula: "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)",
},
resources: [...resourceMap.values()].sort((left, right) => (
left.normalizedResource.localeCompare(right.normalizedResource)
)),
staticPlacements: staticPlacements.sort((left, right) => left.id.localeCompare(right.id)),
dynamicPlacements: dynamicPlacements.sort((left, right) => left.id.localeCompare(right.id)),
reviewRequiredPlacements: reviewRequiredPlacements
.sort((left, right) => left.placementId.localeCompare(right.placementId)),
unsupportedPlacements: unsupportedPlacements.sort((left, right) => left.id.localeCompare(right.id)),
unsupportedResourceTypes: [
"WDB placement resources other than ROS",
"particle, light, sound, camera, and script-only components not represented as ROS mesh descriptors",
"animated or stateful behavior without a reviewed source.wdbAssembly.dynamicPlacements rule",
],
};
}
@@ -694,15 +694,15 @@
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"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.",
"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."
"The full WDB static assembly replaces the former source-anchor support pads; entrance and combat anchors still require live collision/navigation review."
]
}
@@ -1059,20 +1059,22 @@
"agentRadius": 0.36,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"tileSize": 64,
"tileSize": 512,
"partition": "monotone",
"forceTiled": true,
"retainTileIntermediates": false
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"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.",
"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."
"The full static WDB collision requires a tiled monotone Recast build because the solo watershed contour triangulator rejects this source geometry."
]
}
@@ -2719,20 +2719,22 @@
"agentRadius": 0.36,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"tileSize": 64,
"tileSize": 512,
"partition": "monotone",
"forceTiled": true,
"retainTileIntermediates": false
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"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.",
"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."
"The full WDB static assembly replaces the former source-anchor support pads; source-backed switch and drawbridge behavior is represented by encounter world objects pending original object-mesh animation mapping."
]
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const catalogFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
);
test("every executable RuneWaker damage family carries source-shaped formula evidence", async () => {
const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
const executable = catalog.spells.filter((spell) => spell.mappingStatus === "damage-family");
assert.equal(executable.length, catalog.damageFamilyCount);
for (const spell of executable) {
const primary = spell.components.find((component) => component.directHpDamage);
assert.ok(primary, `spell ${spell.spellId} has no primary damage component`);
assert.equal(spell.runtime.formula.spellId, spell.spellId);
assert.equal(spell.runtime.formula.componentId, primary.id);
assert.equal(spell.runtime.formula.damagePower, primary.damagePower);
assert.equal(spell.runtime.formula.fixedValue, primary.fixedValue);
assert.equal(spell.runtime.formula.dotBase, primary.dotBase);
assert.equal(spell.runtime.formula.provenance.physicalFunctionLine, 3366);
assert.equal(spell.runtime.formula.provenance.magicFunctionLine, 3651);
if (spell.runtime.formula.powerKind === "source-ratio") {
assert.ok(Math.abs(primary.damagePower) <= 4);
assert.equal(spell.runtime.damageMultiplier, spell.runtime.formula.runtimeBaselineMultiplier);
}
}
assert.equal(catalog.safety.unavailableNativeInputsDefaulted, false);
assert.equal(catalog.safety.pointValuedDamageUsesLabeledScaledAdapter, true);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const reportFile = path.join(projectRoot, "scripts/runewaker-pipeline/RUNEWAKER_LOOT_IMPORT_REPORT.json");
const catalogFile = path.join(projectRoot, "src/game/generated/runewakerLootCatalog.json");
async function json(file) {
return JSON.parse(await readFile(file, "utf8"));
}
test("RuneWaker loot catalog covers every source-classified boss", async () => {
const [catalog, recipeNames] = await Promise.all([
json(catalogFile),
readdir(path.join(projectRoot, "scripts/runewaker-pipeline/recipes")),
]);
let expectedBosses = 0;
for (const name of recipeNames.filter((candidate) => candidate.endsWith("-population.json"))) {
const recipe = await json(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", name));
expectedBosses += recipe.templates.filter((template) => template.classification === "boss").length;
}
assert.equal(catalog.bossCount, expectedBosses);
assert.equal(catalog.bosses.length, expectedBosses);
assert.equal(new Set(catalog.bosses.map((boss) => boss.bossKey)).size, expectedBosses);
});
test("source probabilities and fail-closed outcomes are internally valid", async () => {
const catalog = await json(catalogFile);
for (const boss of catalog.bosses) {
assert.ok(["portable", "partial", "no-source-drops", "blocked"].includes(boss.status));
for (const roll of boss.rolls) {
assert.ok(roll.chancePerKill > 0 && roll.chancePerKill <= 1);
const total = roll.outcomes.reduce((sum, outcome) => sum + outcome.conditionalChance, 0);
assert.ok(total <= 1.000000001, `${boss.bossKey} slot ${roll.sourceSlot} exceeds probability 1`);
for (const outcome of roll.outcomes) {
assert.ok(outcome.conditionalChance > 0 && outcome.conditionalChance <= 1);
if (outcome.runtimeStatus === "blocked") assert.ok(outcome.blockedReason);
else assert.ok(outcome.sourceCatalog);
}
}
}
});
test("the compact report agrees with the full catalog and pins every source hash", async () => {
const [catalog, report] = await Promise.all([json(catalogFile), json(reportFile)]);
for (const key of [
"bossCount",
"bossWithSourceRollsCount",
"portableBossCount",
"partialBossCount",
"noSourceDropsBossCount",
"blockedBossCount",
"executableOutcomeCount",
"blockedOutcomeCount",
]) assert.equal(report[key], catalog[key]);
for (const source of report.generatedFrom.sourceFiles) {
const bytes = await readFile(path.resolve(projectRoot, source.file));
assert.equal(createHash("sha256").update(bytes).digest("hex"), source.sha256);
}
});
File diff suppressed because it is too large. Load diff
+23 -1
View File
@@ -4,12 +4,15 @@ const PLAYER_TIMEOUT_MS = 15_000;
const ROOM_TIMEOUT_MS = 30 * 60_000;
const MAX_EVENTS = 256;
const MAX_BATCH_EVENTS = 48;
const MAX_WORLD_BYTES = 4 * 1024 * 1024;
const ALLOWED_EVENT_KINDS = new Set([
"damage",
"healing",
"resurrection",
"loot",
"portal",
"interact",
"party-teleport",
]);
const ALLOWED_SCHOOLS = new Set([
"physical",
@@ -43,6 +46,23 @@ function jsonClone(value, fallback = null) {
}
}
function boundedWorldClone(value) {
let serialized;
try {
serialized = JSON.stringify(value);
} catch {
throw new GameDatabaseError("Shared world state must be JSON serializable.", 400, "invalid_world_state");
}
if (!serialized || Buffer.byteLength(serialized, "utf8") > MAX_WORLD_BYTES) {
throw new GameDatabaseError("Shared world state exceeds the 4 MiB activity limit.", 413, "world_state_too_large");
}
const clone = JSON.parse(serialized);
if (Array.isArray(clone.encounters) && clone.encounters.length > 128) {
throw new GameDatabaseError("Shared world state has too many encounter snapshots.", 400, "invalid_world_state");
}
return clone;
}
function requireActivity(account, snapshot) {
const group = snapshot?.group;
const activity = group?.activity;
@@ -90,6 +110,7 @@ function sanitizeEvent(input, accountId, leaderAccountId) {
if (!targetActorId && kind !== "portal") return null;
const sourceActorId = boundedString(input?.sourceActorId);
const localPlayerActorId = `online-player:${accountId}`;
if (kind === "party-teleport" && accountId !== leaderAccountId) return null;
// Non-leaders may submit only their own player actions. Enemy and AI events
// are produced by the activity authority and cannot be forged by members.
if (accountId !== leaderAccountId && sourceActorId !== localPlayerActorId) return null;
@@ -105,6 +126,7 @@ function sanitizeEvent(input, accountId, leaderAccountId) {
effectiveAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.effectiveAmount))),
critical: input?.critical === true,
occurredAt: Math.max(0, Math.trunc(finite(input?.occurredAt, Date.now()))),
...(kind === "party-teleport" ? { position: vector3(input?.position) } : {}),
};
}
@@ -154,7 +176,7 @@ export function createOnlineSessionManager() {
throw new GameDatabaseError("Shared world state must be an object.", 400, "invalid_world_state");
}
room.world = {
...jsonClone(input.world, {}),
...boundedWorldClone(input.world),
publishedAt: now,
publishedBy: account.id,
};
+49
View File
@@ -112,3 +112,52 @@ test("rejects non-leader world updates and forged enemy events", () => {
});
assert.equal(state.events.length, 0);
});
test("relays member interactions but bounds authoritative world snapshots", () => {
const manager = createOnlineSessionManager();
const snapshot = groupSnapshot();
const interaction = manager.sync({ id: "healer" }, snapshot, {
presence,
events: [{
clientEventId: "interact:door:1",
kind: "interact",
sourceActorId: "online-player:healer",
targetActorId: "temple-door",
}],
});
assert.equal(interaction.events[0]?.kind, "interact");
assert.equal(interaction.events[0]?.targetActorId, "temple-door");
assert.throws(() => manager.sync({ id: "leader" }, snapshot, {
presence,
world: { padding: "x".repeat(4 * 1024 * 1024) },
}), (error) => error instanceof GameDatabaseError && error.code === "world_state_too_large");
});
test("accepts party teleports only from the activity leader", () => {
const manager = createOnlineSessionManager();
const snapshot = groupSnapshot();
const forged = manager.sync({ id: "healer" }, snapshot, {
presence,
events: [{
clientEventId: "teleport:forged",
kind: "party-teleport",
sourceActorId: "online-player:healer",
targetActorId: "party",
position: [9, 8, 7],
}],
});
assert.equal(forged.events.length, 0);
const accepted = manager.sync({ id: "leader" }, snapshot, {
presence,
events: [{
clientEventId: "teleport:leader",
kind: "party-teleport",
sourceActorId: "online-player:leader",
targetActorId: "party",
position: [9, 8, 7],
}],
});
assert.deepEqual(accepted.events[0]?.position, [9, 8, 7]);
});
+8 -1
View File
@@ -22,6 +22,10 @@ import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
import { GmWorldEditor } from "./ui/GmWorldEditor";
import { GmReviewCard } from "./ui/GmReviewCard";
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -33,7 +37,7 @@ function InputBridge() {
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"),
toggleTalents: () => useGameStore.getState().toggleOverlay("talents"),
lootActiveBoss: () => lootCurrentManastormBoss(),
lootActiveBoss: () => interactWithActiveDungeonObject() || lootCurrentManastormBoss(),
castActionBinding: (layer, control) => {
const game = useGameStore.getState();
useCombatStore.getState().castActionBinding(layer, control, game.playerPosition, Date.now());
@@ -87,11 +91,14 @@ export default function GameRuntime() {
<main className="game-shell">
<InputBridge />
<OnlineSessionBridge />
<DungeonEncounterEffectBridge />
<DungeonRunCheckpointBridge />
<CombatBridge />
<PartyBridge />
<ManastormBridge />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<DungeonInteractionPrompt />
<MapPanel />
<GameplayPanels />
<PauseMenu />
+31 -1
View File
@@ -10,6 +10,7 @@ import {
restoreSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterProgression,
@@ -31,6 +32,7 @@ import {
setManastormLoadout,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "../game/dungeonRunCheckpoint";
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
@@ -40,6 +42,34 @@ class MemoryStorage implements StorageLike {
}
describe("local profile repository", () => {
it("persists a safe dungeon checkpoint per character", () => {
const storage = new MemoryStorage();
const character = createCharacter({
ownerId: "offline-roster",
name: "Delver",
categoryId: "wow",
raceId: "human",
classId: "priest",
specializationId: "priest:holy",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const updated = updateCharacterDungeonRunCheckpoint("offline-roster", character.id, {
version: DUNGEON_RUN_CHECKPOINT_VERSION,
dungeonId: "wailing-caverns",
difficultyId: "normal",
checkpointId: "entrance",
spawn: { footPosition: [-158, -82, 123], yaw: 0 },
encounters: [],
savedAt: 42,
}, storage);
expect(updated?.dungeonRunCheckpoint).toMatchObject({
dungeonId: "wailing-caverns",
checkpointId: "entrance",
});
expect(listCharacters("offline-roster", storage)[0]?.dungeonRunCheckpoint?.savedAt).toBe(42);
});
it("persists specializations and supports Class Hall keep/reset choices", () => {
const storage = new MemoryStorage();
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
@@ -182,7 +212,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
}
const legacyV3Storage = new MemoryStorage();
+35 -5
View File
@@ -27,6 +27,10 @@ import {
normalizeSpecializationId,
specializationById,
} from "../game/specializationCatalog";
import {
normalizeDungeonRunCheckpoint,
type DungeonRunCheckpoint,
} from "../game/dungeonRunCheckpoint";
const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1";
@@ -56,7 +60,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 4;
version: 5;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -98,7 +102,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 5, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -126,6 +130,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
secondaryClassId?: unknown;
actionLoadouts?: unknown;
specializationId?: unknown;
dungeonRunCheckpoint?: unknown;
};
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
@@ -170,6 +175,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
? { ...(legacy.settings as Partial<GameplaySettings>) }
: undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return {
...value,
level,
@@ -183,6 +189,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
inventory,
equipment,
manastormProgress,
dungeonRunCheckpoint,
settings,
};
}
@@ -196,7 +203,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
rosters?: unknown;
presentationSettingsByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -236,8 +243,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized];
}),
);
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -762,6 +769,29 @@ export function updateCharacterManastormProgress(
return updated;
}
export function updateCharacterDungeonRunCheckpoint(
ownerId: string,
characterId: string,
checkpoint: DungeonRunCheckpoint | null,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
const database = readDatabase(storage);
const normalized = checkpoint === null ? null : normalizeDungeonRunCheckpoint(checkpoint);
if (checkpoint !== null && !normalized) return null;
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
updated = normalizeCharacterProfile({
...character,
dungeonRunCheckpoint: normalized,
});
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
}
export function deleteCharacter(
ownerId: string,
characterId: string,
+7 -1
View File
@@ -50,7 +50,13 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
});
return false;
}
if (!activateDungeonSession(dungeonId)) {
const resume = activeCharacter.dungeonRunCheckpoint?.dungeonId === dungeonId
? activeCharacter.dungeonRunCheckpoint
: null;
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
spawn: resume?.spawn,
encounterSnapshots: resume?.encounters,
})) {
useShellStore.setState({
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
});
+6 -1
View File
@@ -5,6 +5,7 @@ import type { InventoryItem } from "../game/lootTypes";
import type { PartyMember } from "../game/partyStore";
import type { OnlineGroupActivity, OnlineGroupCharacter } from "./onlineGroupTypes";
import type { PartyRole } from "../game/partyRoles";
import type { DungeonEncounterSnapshot } from "../game/dungeonEncounterTypes";
export type OnlineSessionVector = readonly [number, number, number];
@@ -34,6 +35,9 @@ export interface OnlineMobTransform {
}
export interface OnlineSharedWorld {
/** Identifies the package that owns encounter snapshots and prevents cross-dungeon hydration. */
readonly dungeonId?: string;
readonly encounters?: readonly DungeonEncounterSnapshot[];
readonly mobs: Readonly<Record<string, MobCombatState>>;
readonly mobTransforms: Readonly<Record<string, OnlineMobTransform>>;
readonly partyMembers: readonly PartyMember[];
@@ -50,7 +54,7 @@ export interface OnlineSharedWorld {
readonly publishedBy?: string;
}
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal";
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal" | "interact" | "party-teleport";
export interface OnlineRelayEventInput {
readonly clientEventId: string;
@@ -63,6 +67,7 @@ export interface OnlineRelayEventInput {
readonly effectiveAmount?: number;
readonly critical?: boolean;
readonly occurredAt?: number;
readonly position?: OnlineSessionVector;
}
export interface OnlineRelayEvent extends OnlineRelayEventInput {
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+17
View File
@@ -12,6 +12,7 @@ import {
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
@@ -46,6 +47,7 @@ import {
} from "../game/combatPresentation";
import type { PartyRole } from "../game/partyRoles";
import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import {
@@ -112,6 +114,7 @@ export interface ShellState {
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
resetCombatPresentationSettings: () => void;
saveActiveManastormProgress: (progress: ManastormProgress) => void;
saveActiveDungeonRunCheckpoint: (checkpoint: DungeonRunCheckpoint | null) => void;
saveSelectedManastormLoadout: (loadout: readonly string[]) => boolean;
setNotice: (notice: string) => void;
}
@@ -576,6 +579,20 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveDungeonRunCheckpoint: (checkpoint) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterDungeonRunCheckpoint(
session.ownerId,
activeCharacter.id,
checkpoint,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveSelectedManastormLoadout: (loadout) => {
const runtime = manastormShellRuntime();
if (runtime) return runtime.saveSelectedLoadout(loadout);
+3
View File
@@ -4,6 +4,7 @@ import type { InventoryItem } from "../game/lootTypes";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
export type SessionKind = "account" | "offline";
@@ -42,6 +43,8 @@ export interface CharacterProfile {
equipment: EquipmentAssignments;
/** Present on normalized and newly created profiles; optional for legacy callers. */
manastormProgress?: ManastormProgress;
/** Safe resume point only; active combat, timers, and temporary waves are never persisted. */
dungeonRunCheckpoint?: DungeonRunCheckpoint | null;
settings?: Partial<GameplaySettings>;
location: string;
createdAt: number;
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle-population.json",
"actorManifest": "public/assets/creatures/aeternal-circle/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves-population.json",
"actorManifest": "public/assets/creatures/barren-caves/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon-population.json",
"actorManifest": "public/assets/creatures/cave-water-dragon/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125-population.json",
"actorManifest": "public/assets/creatures/demon-stronghold-125/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-population.json",
"actorManifest": "public/assets/creatures/demon-stronghold/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis-population.json",
"actorManifest": "public/assets/creatures/dungeon-of-dalanis/manifest.json",
@@ -1,11 +1,18 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"dungeonId": "forsaken-abbey",
"zoneId": 102,
"sourceRowCount": 251,
"importedSpawnCount": 150,
"excludedRowCount": 101,
"bossSpawnCount": 8,
"objectiveOrder": {
"authoritative": false,
"kind": "healerMan-distance-derived",
"evidence": [
"scripts/runewaker-pipeline/build-population.mjs"
]
},
"sourceSnapshot": "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json",
"sourceContentSha256": "13ef6edd3df6e71389aa92d01201889148c0794cf9ecaafdbaf90d3145d2bf7b",
"sourceHashes": {
@@ -19,7 +26,7 @@
"Resource/data/string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
}
},
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(meters,meters,-meters)",
"navmeshProjection": {
"maximumAllowedDistance": 2,
"maximumObservedDistance": 2.5965,
@@ -28,6 +35,9 @@
},
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey-population.json",
"actorManifest": "public/assets/creatures/forsaken-abbey/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle-population.json",
"actorManifest": "public/assets/creatures/grafu-castle/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors-population.json",
"actorManifest": "public/assets/creatures/hall-of-survivors/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/hoto-population.json",
"actorManifest": "public/assets/creatures/hoto/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom-population.json",
"actorManifest": "public/assets/creatures/ice-dwarf-kingdom/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive-population.json",
"actorManifest": "public/assets/creatures/kal-turok-hive/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine-population.json",
"actorManifest": "public/assets/creatures/kalins-shrine/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb-population.json",
"actorManifest": "public/assets/creatures/kawaks-tomb/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest-population.json",
"actorManifest": "public/assets/creatures/kulech-bones-nest/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon-population.json",
"actorManifest": "public/assets/creatures/lair-demon-dragon/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar-population.json",
"actorManifest": "public/assets/creatures/mystic-altar/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine-population.json",
"actorManifest": "public/assets/creatures/paspers-shrine/manifest.json",
@@ -1,57 +1,112 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"dungeonId": "raksha-temple",
"status": "review-required",
"environmentMode": "runewaker-ros",
"environmentMode": "runewaker-wdb-assembly",
"source": {
"obj": "scene.obj",
"report": "export-report.json",
"model": "model\\dungeon\\pesche_temple\\front_gate_01.ros"
"wdb": "Resource/wdb/dungeon/dgn_pesche_temple.wdb",
"primaryModel": "model/dungeon/pesche_temple/front_gate_01.ros",
"descriptorCount": 1028,
"placementManifest": "wdb-placement-manifest.json"
},
"coordinateTransform": {
"source": "left-handed Y-up",
"nativeBridge": "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
"unitScaleMeters": 0.1,
"calibration": "16.4977-unit fake_pc mesh height -> approximately 1.65 m"
"metersPerSourceUnit": 0.1,
"formula": "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)"
},
"visual": {
"file": "raksha-temple-visual.glb",
"meshObjects": 3,
"triangles": 4302,
"sourceTriangles": 4302,
"meshObjects": 1362,
"triangles": 605820,
"sourceTriangles": 605820,
"bounds": {
"min": [
-327.82031,
-15.38933,
-321.33469
-764.460151,
-118.862537,
-658.700334
],
"max": [
49.02461,
51.30486,
322.06534
397.479134,
368.792163,
606.421358
]
},
"materials": {
"unique": 3,
"replacements": 0
}
"assembledWdbPlacements": 1028,
"uniqueResources": 228
},
"collision": [
{
"file": "raksha-temple-collision.glb",
"triangles": 4302,
"sourceTriangleEstimate": 4302
"file": "raksha-temple-wdb-collision-000.glb",
"triangles": 87109,
"sourceTriangleEstimate": 87109,
"placementCount": 183,
"assembledFromWdb": true
},
{
"file": "raksha-temple-wdb-collision-001.glb",
"triangles": 86228,
"sourceTriangleEstimate": 86228,
"placementCount": 144,
"assembledFromWdb": true
},
{
"file": "raksha-temple-wdb-collision-002.glb",
"triangles": 89719,
"sourceTriangleEstimate": 89719,
"placementCount": 158,
"assembledFromWdb": true
},
{
"file": "raksha-temple-wdb-collision-003.glb",
"triangles": 89776,
"sourceTriangleEstimate": 89776,
"placementCount": 130,
"assembledFromWdb": true
},
{
"file": "raksha-temple-wdb-collision-004.glb",
"triangles": 85897,
"sourceTriangleEstimate": 85897,
"placementCount": 156,
"assembledFromWdb": true
},
{
"file": "raksha-temple-wdb-collision-005.glb",
"triangles": 89896,
"sourceTriangleEstimate": 89896,
"placementCount": 136,
"assembledFromWdb": true
},
{
"file": "raksha-temple-wdb-collision-006.glb",
"triangles": 77195,
"sourceTriangleEstimate": 77195,
"placementCount": 121,
"assembledFromWdb": true
}
],
"collisionPolicy": {
"kind": "provisional-trimesh-copy",
"kind": "wdb-static-placement-trimesh",
"maximumTrianglesPerChunk": 100000,
"workingChunkTarget": 90000,
"includesDecorativeMeshes": true
"excludesReviewedDynamicPlacements": true,
"includesUnreviewedDynamicCandidates": true
},
"dynamicAssets": [],
"preview": "raksha-temple-preview.png",
"editableSource": "raksha-temple.source.blend",
"wdbAssembly": {
"staticPlacements": 1028,
"dynamicPlacements": 0,
"unavailablePlacements": 0,
"uniqueResources": 228,
"availableResources": 228,
"unavailableResources": 0,
"resourceCacheHits": 227
},
"warnings": [
"Collision is generated from all primary ROS meshes for the pilot; replace or filter decorative geometry after visual review.",
"The primary ROS does not include every WDB-managed doodad, effect, or runtime placement."
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence."
]
}
@@ -2,8 +2,8 @@
"schemaVersion": 2,
"dungeonId": "raksha-temple",
"title": "Raksha Temple",
"status": "source-packaged",
"generatedAt": "2026-08-01T01:34:54.790Z",
"status": "review-required",
"generatedAt": "2026-09-07T18:42:17.069Z",
"source": {
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json",
"wdb": "Resource/wdb/dungeon/dgn_pesche_temple.wdb",
@@ -97,6 +97,17 @@
"detailClass": 0,
"zoneType": 0
}
},
"wdbAssembly": {
"mode": "all-ros",
"placementManifest": "wdb-placement-manifest.json",
"staticPlacements": 1028,
"dynamicPlacements": 0,
"unavailablePlacements": 0,
"uniqueResources": 228,
"availableResources": 228,
"unavailableResources": 0,
"resourceCacheHits": 227
}
},
"coordinateSystem": {
@@ -119,11 +130,11 @@
11.334656
],
"footPosition": [
-0.020312,
21.013313,
11.334656
0.021786,
10.664467,
17.560761
],
"navmeshDistance": 0,
"navmeshDistance": 12.077449,
"yaw": 6.108652382,
"forward": [
-0.173648178,
@@ -131,40 +142,85 @@
0.984807753
],
"provenance": "environment recipe entryCoordinates",
"reviewRequired": false
"reviewRequired": true
},
"assets": [
{
"role": "visual",
"fileName": "raksha-temple-visual.glb",
"size": 937772,
"sha256": "3439e64f2a49d0545651bac35276867af83c3eb57b3c859f767de0c49c3dce66",
"compression": "meshopt+ktx2"
"relativeFile": "raksha-temple-visual.glb",
"size": 23554528,
"sha256": "3a61098e772a0c656aa25d88aee357f78b84c738e45150fca6fd5059c2b675f7"
},
{
"role": "collision",
"fileName": "raksha-temple-collision.glb",
"size": 161552,
"sha256": "a0b19ff9c1c5ef6abfbaee6080bec91bdcdc8eb3d2b8f6b3e3fad092fd71e548"
"fileName": "raksha-temple-wdb-collision-000.glb",
"relativeFile": "raksha-temple-wdb-collision-000.glb",
"size": 2584136,
"sha256": "22cfdc239b638f906f3cb77c9fb7b94c66eb483cfcbbe59e059bf8d5cbaad8a0"
},
{
"role": "collision",
"fileName": "raksha-temple-wdb-collision-001.glb",
"relativeFile": "raksha-temple-wdb-collision-001.glb",
"size": 2785392,
"sha256": "3a3f4448aa92276faa94c8b99ae49aa48660423fb42edf0f2403615e5e3b3453"
},
{
"role": "collision",
"fileName": "raksha-temple-wdb-collision-002.glb",
"relativeFile": "raksha-temple-wdb-collision-002.glb",
"size": 2788332,
"sha256": "b4b9e92646bbaf290263b601e7af7d6547f85a7b3fdae4b931ab930a263c2a36"
},
{
"role": "collision",
"fileName": "raksha-temple-wdb-collision-003.glb",
"relativeFile": "raksha-temple-wdb-collision-003.glb",
"size": 2676988,
"sha256": "846ac1301549d8e05c8efb59b9ddfc0ebec2fb36cf216a0d5c143ab3eb5ae423"
},
{
"role": "collision",
"fileName": "raksha-temple-wdb-collision-004.glb",
"relativeFile": "raksha-temple-wdb-collision-004.glb",
"size": 2328332,
"sha256": "100f27d9a2c46f1d30b0e395d60d99a3b62dfabc82474ed574f137c1513fb1bb"
},
{
"role": "collision",
"fileName": "raksha-temple-wdb-collision-005.glb",
"relativeFile": "raksha-temple-wdb-collision-005.glb",
"size": 2960700,
"sha256": "cb22cb168e559fa8ed6983109bdc02bc4fd11f7d8834649bfab1d9cd0448ba87"
},
{
"role": "collision",
"fileName": "raksha-temple-wdb-collision-006.glb",
"relativeFile": "raksha-temple-wdb-collision-006.glb",
"size": 2345864,
"sha256": "d8f6bd33ada7b6e0ee1b7cb4aacdf1c32e8ceb614f45d60ac2f24c9f1e0cef18"
},
{
"role": "navigation",
"fileName": "raksha-temple-navigation.glb",
"size": 60088,
"sha256": "21e0aa6d58f8958fc6a21912cf72ec86188c13e87d70695504cee9e910b754b0"
"relativeFile": "raksha-temple-navigation.glb",
"size": 8686564,
"sha256": "1e1d1ad26907dc8c4fd0a917dba40b52d2aaaea4a0335af06871ee28230eddf1"
}
],
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"Collision initially includes decorative primary-ROS geometry.",
"Original skeletal animations and complex scripted mechanics remain later passes.",
"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."
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"The full WDB static assembly replaces the former source-anchor support pads; entrance and combat anchors still require live collision/navigation review.",
"The authoritative entrance was projected 12.077449m to the packaged primary navigation and requires an in-game entrance review."
]
}
@@ -4,15 +4,11 @@
"status": "review-required",
"source": {
"file": "raksha-temple-visual.glb",
"size": 4419040
"size": 241813992
},
"optimized": {
"file": "raksha-temple-visual.optimized.glb",
"size": 937772,
"checksum": "3439e64f2a49d0545651bac35276867af83c3eb57b3c859f767de0c49c3dce66"
"size": 23554528
},
"compression": "meshopt+ktx2",
"commands": [
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2"
]
"compression": "meshopt+ktx2"
}
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple-population.json",
"actorManifest": "public/assets/creatures/raksha-temple/manifest.json",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 860 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -3,71 +3,84 @@
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"partition": "watershed",
"tileCount": 4026,
"tileSize": 64,
"valid": true
}
],
"gridCells": 2696265,
"gridHeight": 2145,
"gridWidth": 1257,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"gridCells": 16340532,
"gridHeight": 4218,
"gridWidth": 3874,
"ignoredEmptyTilePackingFailures": 675,
"intermediateRetention": "released",
"mode": "tiled",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
"populatedTileCount": 2322,
"tileColumns": 61,
"tileCount": 4026,
"tileRows": 66,
"tileSize": 64
},
"dungeonId": "raksha-temple",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-327.82031200000006,
-14.189345359802246,
-322.06534400000004
-764.9183959960938,
-42.916473388671875,
-659.2423706054688
],
[
30.37969970703125,
51.31066131591797,
321.334656
397.8696594238281,
281.383544921875,
585.4577026367188
]
],
"boundsTolerance": 0.6,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 1035,
"sourceVertices": 3105,
"triangles": 1287,
"vertices": 3861
"rejectedOutOfBoundsTriangles": 1268,
"sourceTriangles": 182220,
"sourceVertices": 546660,
"triangles": 180952,
"vertices": 542856
},
"input": {
"bounds": [
[
-327.8203173849033,
-15.389344724541413,
-321.33471451070653
-764.4601509553564,
-118.86253668324147,
-658.7003338651648
],
[
49.02461010552361,
51.30486985910282,
322.0653817840023
397.4791343891476,
368.79216332820624,
606.4212380635847
]
],
"degenerateTriangleCount": 0,
"degenerateTriangleCount": 150,
"invalidIndexCount": 0,
"meshCount": 3,
"meshCount": 1362,
"nonFiniteCoordinateCount": 0,
"triangleCount": 4302,
"vertexCount": 4386
"triangleCount": 605820,
"vertexCount": 602853
},
"inputChunkChecksums": {
"runewaker-export-work/raksha-temple/converted/raksha-temple-collision.glb": "a0b19ff9c1c5ef6abfbaee6080bec91bdcdc8eb3d2b8f6b3e3fad092fd71e548"
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-000.glb": "22cfdc239b638f906f3cb77c9fb7b94c66eb483cfcbbe59e059bf8d5cbaad8a0",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-001.glb": "3a3f4448aa92276faa94c8b99ae49aa48660423fb42edf0f2403615e5e3b3453",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-002.glb": "b4b9e92646bbaf290263b601e7af7d6547f85a7b3fdae4b931ab930a263c2a36",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-003.glb": "846ac1301549d8e05c8efb59b9ddfc0ebec2fb36cf216a0d5c143ab3eb5ae423",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-004.glb": "100f27d9a2c46f1d30b0e395d60d99a3b62dfabc82474ed574f137c1513fb1bb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-005.glb": "cb22cb168e559fa8ed6983109bdc02bc4fd11f7d8834649bfab1d9cd0448ba87",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-006.glb": "d8f6bd33ada7b6e0ee1b7cb4aacdf1c32e8ceb614f45d60ac2f24c9f1e0cef18"
},
"inputChunks": [
"runewaker-export-work/raksha-temple/converted/raksha-temple-collision.glb"
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-000.glb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-001.glb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-002.glb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-003.glb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-004.glb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-005.glb",
"runewaker-export-work/raksha-temple/converted/raksha-temple-wdb-collision-006.glb"
],
"output": "runewaker-export-work/raksha-temple/converted/raksha-temple-navigation.glb",
"schemaVersion": 1,
@@ -82,16 +95,8 @@
"tileSize": 64
},
"source": {
"collisionChunkCount": 1,
"collisionChunkCount": 7,
"kind": "runewaker-primary-ros",
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json",
"sourceAnchorSupport": {
"pointCount": 126,
"halfExtentSourceUnits": 60,
"provenance": [
"ROM_Global.dbo.NPCData source coordinates",
"Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
]
}
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json"
}
}
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_pesche_temple.wdb",
"sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_pesche_temple.wdb",
"coordinateSystem": "runewaker-left-handed-y-up",
"descriptorCount": 1028,
"descriptors": [
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex-population.json",
"actorManifest": "public/assets/creatures/sand-vortex/manifest.json",
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle-population.json",
"actorManifest": "public/assets/creatures/sardo-castle/manifest.json",
@@ -1,57 +1,168 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"dungeonId": "the-origin",
"status": "review-required",
"environmentMode": "runewaker-ros",
"environmentMode": "runewaker-wdb-assembly",
"source": {
"obj": "scene.obj",
"report": "export-report.json",
"model": "model\\dungeon\\the_origin\\cathedral_01_b.ros"
"wdb": "Resource/wdb/dungeon/dgn_the_origin.wdb",
"primaryModel": "model/dungeon/the_origin/cathedral_01_b.ros",
"descriptorCount": 693,
"placementManifest": "wdb-placement-manifest.json"
},
"coordinateTransform": {
"source": "left-handed Y-up",
"nativeBridge": "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
"unitScaleMeters": 0.1,
"calibration": "16.4977-unit fake_pc mesh height -> approximately 1.65 m"
"metersPerSourceUnit": 0.1,
"formula": "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)"
},
"visual": {
"file": "the-origin-visual.glb",
"meshObjects": 5,
"triangles": 2664,
"sourceTriangles": 2664,
"meshObjects": 2144,
"triangles": 1176881,
"sourceTriangles": 1176881,
"bounds": {
"min": [
-386.52997,
-46.51506,
-324.07977
-630.039165,
-94.763691,
-431.593932
],
"max": [
107.21951,
11.46503,
309.25803
382.64336,
260.724523,
541.440422
]
},
"materials": {
"unique": 5,
"replacements": 0
}
"assembledWdbPlacements": 693,
"uniqueResources": 220
},
"collision": [
{
"file": "the-origin-collision.glb",
"triangles": 2664,
"sourceTriangleEstimate": 2664
"file": "the-origin-wdb-collision-000.glb",
"triangles": 78675,
"sourceTriangleEstimate": 78675,
"placementCount": 45,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-001.glb",
"triangles": 83558,
"sourceTriangleEstimate": 83558,
"placementCount": 41,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-002.glb",
"triangles": 84659,
"sourceTriangleEstimate": 84659,
"placementCount": 48,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-003.glb",
"triangles": 61945,
"sourceTriangleEstimate": 61945,
"placementCount": 34,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-004.glb",
"triangles": 87835,
"sourceTriangleEstimate": 87835,
"placementCount": 57,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-005.glb",
"triangles": 88513,
"sourceTriangleEstimate": 88513,
"placementCount": 81,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-006.glb",
"triangles": 86404,
"sourceTriangleEstimate": 86404,
"placementCount": 49,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-007.glb",
"triangles": 66883,
"sourceTriangleEstimate": 66883,
"placementCount": 58,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-008.glb",
"triangles": 87544,
"sourceTriangleEstimate": 87544,
"placementCount": 31,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-009.glb",
"triangles": 84937,
"sourceTriangleEstimate": 84937,
"placementCount": 58,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-010.glb",
"triangles": 88994,
"sourceTriangleEstimate": 88994,
"placementCount": 44,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-011.glb",
"triangles": 89430,
"sourceTriangleEstimate": 89430,
"placementCount": 49,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-012.glb",
"triangles": 89857,
"sourceTriangleEstimate": 89857,
"placementCount": 47,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-013.glb",
"triangles": 85511,
"sourceTriangleEstimate": 85511,
"placementCount": 47,
"assembledFromWdb": true
},
{
"file": "the-origin-wdb-collision-014.glb",
"triangles": 12136,
"sourceTriangleEstimate": 12136,
"placementCount": 4,
"assembledFromWdb": true
}
],
"collisionPolicy": {
"kind": "provisional-trimesh-copy",
"kind": "wdb-static-placement-trimesh",
"maximumTrianglesPerChunk": 100000,
"workingChunkTarget": 90000,
"includesDecorativeMeshes": true
"excludesReviewedDynamicPlacements": true,
"includesUnreviewedDynamicCandidates": true
},
"dynamicAssets": [],
"preview": "the-origin-preview.png",
"editableSource": "the-origin.source.blend",
"wdbAssembly": {
"staticPlacements": 693,
"dynamicPlacements": 0,
"unavailablePlacements": 0,
"uniqueResources": 220,
"availableResources": 220,
"unavailableResources": 0,
"resourceCacheHits": 219
},
"warnings": [
"Collision is generated from all primary ROS meshes for the pilot; replace or filter decorative geometry after visual review.",
"The primary ROS does not include every WDB-managed doodad, effect, or runtime placement."
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence."
]
}
@@ -2,8 +2,8 @@
"schemaVersion": 2,
"dungeonId": "the-origin",
"title": "The Origin",
"status": "source-packaged",
"generatedAt": "2026-08-01T01:34:32.531Z",
"status": "review-required",
"generatedAt": "2026-09-07T19:09:55.496Z",
"source": {
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json",
"wdb": "Resource/wdb/dungeon/dgn_the_origin.wdb",
@@ -97,6 +97,17 @@
"detailClass": 0,
"zoneType": 0
}
},
"wdbAssembly": {
"mode": "all-ros",
"placementManifest": "wdb-placement-manifest.json",
"staticPlacements": 693,
"dynamicPlacements": 0,
"unavailablePlacements": 0,
"uniqueResources": 220,
"availableResources": 220,
"unavailableResources": 0,
"resourceCacheHits": 219
}
},
"coordinateSystem": {
@@ -119,11 +130,11 @@
-292.973182
],
"footPosition": [
-380.368111,
-7.734726,
-292.973182
-377.445404,
-13.802109,
-267.742004
],
"navmeshDistance": 0,
"navmeshDistance": 26.114511,
"yaw": 5.986479334,
"forward": [
-0.292371705,
@@ -131,40 +142,141 @@
0.956304756
],
"provenance": "environment recipe entryCoordinates",
"reviewRequired": false
"reviewRequired": true
},
"assets": [
{
"role": "visual",
"fileName": "the-origin-visual.glb",
"size": 251132,
"sha256": "a6db29db31f88a45bd082d27130725d70b2fa1db82030530750492a54b3f9bf8",
"compression": "meshopt+ktx2"
"relativeFile": "the-origin-visual.glb",
"size": 28683180,
"sha256": "d1ded0316b2cbaa07458c56864c47c01afaa85e4f33dd13f5d95984d7305fb92"
},
{
"role": "collision",
"fileName": "the-origin-collision.glb",
"size": 72976,
"sha256": "7cc2e9dfc649742d546a7771fb836cde3e5f708f2a7088bfc111457eb516c17a"
"fileName": "the-origin-wdb-collision-000.glb",
"relativeFile": "the-origin-wdb-collision-000.glb",
"size": 2703604,
"sha256": "7eb6e18cf11ac9a15b24c6300f8c62773df9d2cb045b16c9c4732e18016b18e7"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-001.glb",
"relativeFile": "the-origin-wdb-collision-001.glb",
"size": 2757368,
"sha256": "31ec6603c7c100af9c0177cbbae3f13f029266a13c5b9690731089d8193deeb2"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-002.glb",
"relativeFile": "the-origin-wdb-collision-002.glb",
"size": 2538872,
"sha256": "ccce739574edbeef58ed1bc502d9be77cb0565213c381113bcdb9944323b0be0"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-003.glb",
"relativeFile": "the-origin-wdb-collision-003.glb",
"size": 2438228,
"sha256": "7f83d131a71bee2fb3f47e7c2fe94c39cb3f3f9bd7f333a994627528465e07d1"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-004.glb",
"relativeFile": "the-origin-wdb-collision-004.glb",
"size": 3127712,
"sha256": "044bc594aa521c7b4a441b477c369b03083189068dbe6b17572851beb23531b1"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-005.glb",
"relativeFile": "the-origin-wdb-collision-005.glb",
"size": 2827072,
"sha256": "8cd5228f6443dceb983e5e8c457ed97fdfdac2083049dce036157f3573a0f9ff"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-006.glb",
"relativeFile": "the-origin-wdb-collision-006.glb",
"size": 3210756,
"sha256": "abca4c48c25d495e40399dfd5a9fbcab8941a056af1b8159ff0b4d3f453b0b52"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-007.glb",
"relativeFile": "the-origin-wdb-collision-007.glb",
"size": 2364920,
"sha256": "9693440956d31489d888e30fd97de0427e9bf65bd02174a6bbad34b78a8b6a29"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-008.glb",
"relativeFile": "the-origin-wdb-collision-008.glb",
"size": 2811712,
"sha256": "89f95739e8ac2384d0c125c1ab013e1803031f67f11d35fa94355719a4db2251"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-009.glb",
"relativeFile": "the-origin-wdb-collision-009.glb",
"size": 3077192,
"sha256": "2bbddf84d5754100c3499d5e35cb1da45dfa920940c59a3007c0848b975d609d"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-010.glb",
"relativeFile": "the-origin-wdb-collision-010.glb",
"size": 2989496,
"sha256": "73012e2dcb9892ad3aeccd3536e0c36f4365276226b265f82326a25e45d4ec4e"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-011.glb",
"relativeFile": "the-origin-wdb-collision-011.glb",
"size": 3128952,
"sha256": "d19124247096c475e344ae467274be6a0320192cf2292f891970b102eb4fc1be"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-012.glb",
"relativeFile": "the-origin-wdb-collision-012.glb",
"size": 2825584,
"sha256": "23870416ad12c179e576959df6f72370e209365e03d3510ceb3b1745f30fe930"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-013.glb",
"relativeFile": "the-origin-wdb-collision-013.glb",
"size": 2856020,
"sha256": "5c9bb9f4b5d118c834f3b1d0b515b30d553a1e21423767c9e7dafcbd6d78d1c2"
},
{
"role": "collision",
"fileName": "the-origin-wdb-collision-014.glb",
"relativeFile": "the-origin-wdb-collision-014.glb",
"size": 533188,
"sha256": "0743835882534516c770766cbbf3585386e05e96e95a2bdc24e3ca5d90482c57"
},
{
"role": "navigation",
"fileName": "the-origin-navigation.glb",
"size": 54768,
"sha256": "a9b97bc8cc62279486292d42aad3ae0a8e70638e7dc223dd0c392f32b3811781"
"relativeFile": "the-origin-navigation.glb",
"size": 4667144,
"sha256": "70c0a78073d7c3cd7a95e0dc3531c25c0de9815376001f9b80cfbb59a72596cd"
}
],
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"Collision initially includes decorative primary-ROS geometry.",
"Original skeletal animations and complex scripted mechanics remain later passes.",
"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."
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"The full static WDB collision requires a tiled monotone Recast build because the solo watershed contour triangulator rejects this source geometry.",
"The authoritative entrance was projected 26.114511m to the packaged primary navigation and requires an in-game entrance review."
]
}
@@ -4,15 +4,11 @@
"status": "review-required",
"source": {
"file": "the-origin-visual.glb",
"size": 898204
"size": 259670824
},
"optimized": {
"file": "the-origin-visual.optimized.glb",
"size": 251132,
"checksum": "a6db29db31f88a45bd082d27130725d70b2fa1db82030530750492a54b3f9bf8"
"size": 28683180
},
"compression": "meshopt+ktx2",
"commands": [
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2"
]
"compression": "meshopt+ktx2"
}
@@ -36,8 +36,8 @@
"runeWakerIdsAreNamespaced": true,
"serverEntryPolicy": "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
"sourceSpellCatalog": "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
"sourceSpellCatalogSha256": "c47e67013e68ecf9c7ae30ef4b2058a850505385739dfe8c32ce8874e6ed077c",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
"sourceSpellCatalogSha256": "36bb578bbdda61af6ab864084907b347ff9a4f4c2fff671a2cb6586d535666ec",
"sourceSpellPolicy": "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
"status": "green",
"recipe": "scripts/runewaker-pipeline/recipes/the-origin-population.json",
"actorManifest": "public/assets/creatures/the-origin/manifest.json",
@@ -3,71 +3,106 @@
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"partition": "monotone",
"tileCount": 49,
"tileSize": 512,
"valid": true
}
],
"gridCells": 3476352,
"gridHeight": 2112,
"gridWidth": 1646,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
"gridCells": 10951744,
"gridHeight": 3244,
"gridWidth": 3376,
"ignoredEmptyTilePackingFailures": 4,
"intermediateRetention": "released",
"mode": "tiled",
"partition": "monotone",
"partitionProvenance": {
"operation": "replace tiled buildRegions watershed call with buildRegionsMonotone",
"package": "@recast-navigation/generators@0.43.1",
"upstreamChecksum": "293eab40944b1bdbf1460aea569e3560d54425e5ff14a1d62c5cbcb59cc12546",
"upstreamEntry": "node_modules/@recast-navigation/generators/dist/index.mjs"
},
"populatedTileCount": 41,
"tileColumns": 7,
"tileCount": 49,
"tileRows": 7,
"tileSize": 512
},
"dungeonId": "the-origin",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-386.5299468981716,
-46.21504096880001,
-323.1798095703125
-549.3453979492188,
-89.20210266113281,
-432.14202880859375
],
[
107.21950989869195,
11.484966278076172,
324.07975311467277
383.1546325683594,
190.2978973388672,
487.3580322265625
]
],
"boundsTolerance": 0.6,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 813,
"sourceVertices": 2439,
"triangles": 1213,
"vertices": 3639
"rejectedOutOfBoundsTriangles": 2565,
"sourceTriangles": 99779,
"sourceVertices": 299337,
"triangles": 97214,
"vertices": 291642
},
"input": {
"bounds": [
[
-386.5299618144327,
-46.515034268603216,
-324.07981134566637
-630.040175761931,
-94.76418001874038,
-431.59393225149614
],
[
107.21950843363265,
11.465017374849538,
309.2580730454315
382.64336048901225,
260.7245227290317,
541.440421539961
]
],
"degenerateTriangleCount": 0,
"degenerateTriangleCount": 971,
"invalidIndexCount": 0,
"meshCount": 5,
"meshCount": 2144,
"nonFiniteCoordinateCount": 0,
"triangleCount": 2664,
"vertexCount": 2003
"triangleCount": 1176881,
"vertexCount": 1121613
},
"inputChunkChecksums": {
"runewaker-export-work/the-origin/converted/the-origin-collision.glb": "7cc2e9dfc649742d546a7771fb836cde3e5f708f2a7088bfc111457eb516c17a"
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-000.glb": "7eb6e18cf11ac9a15b24c6300f8c62773df9d2cb045b16c9c4732e18016b18e7",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-001.glb": "31ec6603c7c100af9c0177cbbae3f13f029266a13c5b9690731089d8193deeb2",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-002.glb": "ccce739574edbeef58ed1bc502d9be77cb0565213c381113bcdb9944323b0be0",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-003.glb": "7f83d131a71bee2fb3f47e7c2fe94c39cb3f3f9bd7f333a994627528465e07d1",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-004.glb": "044bc594aa521c7b4a441b477c369b03083189068dbe6b17572851beb23531b1",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-005.glb": "8cd5228f6443dceb983e5e8c457ed97fdfdac2083049dce036157f3573a0f9ff",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-006.glb": "abca4c48c25d495e40399dfd5a9fbcab8941a056af1b8159ff0b4d3f453b0b52",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-007.glb": "9693440956d31489d888e30fd97de0427e9bf65bd02174a6bbad34b78a8b6a29",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-008.glb": "89f95739e8ac2384d0c125c1ab013e1803031f67f11d35fa94355719a4db2251",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-009.glb": "2bbddf84d5754100c3499d5e35cb1da45dfa920940c59a3007c0848b975d609d",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-010.glb": "73012e2dcb9892ad3aeccd3536e0c36f4365276226b265f82326a25e45d4ec4e",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-011.glb": "d19124247096c475e344ae467274be6a0320192cf2292f891970b102eb4fc1be",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-012.glb": "23870416ad12c179e576959df6f72370e209365e03d3510ceb3b1745f30fe930",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-013.glb": "5c9bb9f4b5d118c834f3b1d0b515b30d553a1e21423767c9e7dafcbd6d78d1c2",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-014.glb": "0743835882534516c770766cbbf3585386e05e96e95a2bdc24e3ca5d90482c57"
},
"inputChunks": [
"runewaker-export-work/the-origin/converted/the-origin-collision.glb"
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-000.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-001.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-002.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-003.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-004.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-005.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-006.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-007.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-008.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-009.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-010.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-011.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-012.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-013.glb",
"runewaker-export-work/the-origin/converted/the-origin-wdb-collision-014.glb"
],
"output": "runewaker-export-work/the-origin/converted/the-origin-navigation.glb",
"schemaVersion": 1,
@@ -78,20 +113,14 @@
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.3,
"forceTiled": true,
"partition": "monotone",
"retainTileIntermediates": false,
"tileSize": 64
"tileSize": 512
},
"source": {
"collisionChunkCount": 1,
"collisionChunkCount": 15,
"kind": "runewaker-primary-ros",
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json",
"sourceAnchorSupport": {
"pointCount": 200,
"halfExtentSourceUnits": 60,
"provenance": [
"ROM_Global.dbo.NPCData source coordinates",
"Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
]
}
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 873 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more