Compare commits

...
8 Commits
Author SHA1 Message Date
phenom f6f2375145 Release Healer Man 0.1.21 2026-09-08 10:40:26 -04:00
phenom 699fc858c9 Release Healer Man 0.1.20 2026-09-06 14:38:59 -04:00
phenom d9bebb4e81 Release Healer Man 0.1.19 2026-09-06 11:06:03 -04:00
phenom eaddad4134 Release Healer Man 0.1.18 2026-09-05 22:15:48 -04:00
phenom fc8a7a722a Release Healer Man 0.1.17 2026-09-05 21:27:30 -04:00
phenom b57d2b2839 Release Healer Man 0.1.16 2026-09-05 20:42:38 -04:00
phenom f49e54f35f Release Healer Man 0.1.15 2026-09-05 15:33:06 -04:00
phenom 7b41b102fa Release Healer Man 0.1.14 2026-09-05 11:40:50 -04:00
402 changed files with 1153376 additions and 6809 deletions

No files matched your search

+1
View File
@@ -20,5 +20,6 @@ releases/
/content/
dist-android/
.android-public/
.web-public/
__pycache__/
*.pyc
+66 -33
View File
@@ -1,10 +1,12 @@
# Local-first content updates and Android releases
# Local-first content updates for browser and Android
Healer Man separates the installed game client from large game content. The
Android APK contains the React/Three.js runtime, native Android bridge, menus,
local profile and save support, UI assets, and the Wailing Caverns starter
pack. Other GLBs, textures, audio, character packs, dungeons, and Manastorm
stages are published by TrueNAS as optional content.
Healer Man separates the installed game client from large game content. Both
the production browser build and Android APK contain a compact required set:
the React/Three.js runtime, menus, local profile and save support, UI assets,
starter equipment, Wailing Caverns starter content, and the basic Human
character model with its default male and female appearances. Android also
contains its native bridge. Other GLBs, textures, audio, character/skin packs,
dungeons, and Manastorm stages are published by TrueNAS as optional content.
Single-player is local-first. Starting the game, loading a character, entering
installed content, simulating combat, and saving progress never require the
@@ -27,16 +29,12 @@ UI behavior, shaders or loaders, native Android code, permissions, or the
content-manifest schema. A manifest can set `minimumAppVersion` so an older APK
will not activate incompatible content.
The browser build remains a full build served by TrueNAS. It receives client
code when the app checkout is pulled, rebuilt, and restarted. The Android build
is deliberately separate and compact so the APK does not inherit the complete
multi-gigabyte browser asset catalog.
Measured on August 14, 2026, the production browser `dist` is 3.88 GiB across
18,895 files; GLBs account for 3.75 GiB. The compact Android `dist-android` is
148.0 MiB across 5,561 files, including 47.8 MiB of starter GLBs. The final
signed APK will be smaller because Android packages compress eligible files;
record its exact size after the first release build.
Production builds deliberately stage only required content. `npm run build`
prepares `.web-public` and emits the compact browser `dist`; Android prepares
`.android-public` and emits `dist-android`. The development server continues to
use the source `public` tree so asset-pipeline work remains convenient. The
complete source catalog and published immutable objects stay on TrueNAS, not in
every player's initial browser or Android installation.
## Content manifest
@@ -56,8 +54,9 @@ The runtime continues to request stable paths such as:
```
The content resolver maps that path to a verified file in Android app-private
storage. Filenames are therefore not used as version numbers and runtime code
does not need to know the hash.
storage or, in production browsers, to the app-managed content cache through a
same-origin service worker. Filenames are therefore not used as version numbers
and runtime code does not need to know the hash.
## Safe publication and activation
@@ -68,17 +67,34 @@ Publication order is intentionally atomic:
3. Write the versioned manifest.
4. Replace `manifest.json` only after all referenced objects exist.
On a device, the client fetches the small manifest and compares it with its
installed manifest. Only missing or hash-mismatched objects are downloaded.
Android streams each object to a `.part` file, supports HTTP range resume,
verifies its size and SHA-256, then renames it into the object cache. The active
logical-path map changes only after the complete pack verifies. An interrupted
or corrupt update leaves the previous pack playable.
The client fetches the small manifest and compares it with its bundled and
installed file identities. Only missing or hash-mismatched objects are
downloaded. Browsers download into a temporary Cache Storage staging area,
verify every object's byte count and SHA-256, and promote the pack to the active
cache only after the complete pack succeeds. Android streams each object to a
`.part` file, supports HTTP range resume, verifies its size and SHA-256, then
renames it into the object cache. An interrupted or corrupt update leaves the
previous pack playable on either platform.
The starter pack is bundled into the APK. Downloaded packs live under Android's
app-private files directory, outside the packaged web bundle, and survive an
APK update. Players may remove optional packs through the content manager; save
data is stored separately and is never removed with content.
The Downloads screen uses the same lifecycle in browser and Android. A missing
pack offers **Download**, an installed pack with changed files offers
**Update**, and an installed optional pack offers **Remove**. Required packs are
marked as included and cannot be removed. Browser packs live in the app-managed
cache; Android packs live in app-private storage and survive APK updates. Save
data and roster identities are stored separately and are never removed with
content.
Character packs are presentation-only. When a selected race's pack is absent,
removed, disabled, or fails to load, the shared character renderer uses the
matching-gender basic Human model and default appearance. The saved race, class,
equipment, progression, and appearance choices remain intact and become visible
again after the pack is installed.
Generated spell effects and synthesized combat cues are the default. The
`core:combat-media` pack containing packaged original spell visuals and sounds
is never part of required bootstrap content. Original mode becomes available
only after an explicit download and falls back to generated effects if the pack
is later removed.
## Offline behavior
@@ -87,6 +103,8 @@ data is stored separately and is never removed with content.
- The last verified content map is read synchronously from local storage.
- A failed update check is silent outside the content manager.
- A failed or interrupted download never replaces active content.
- Missing optional character packs fall back to the basic Human model.
- Generated spell effects remain available without original spell media.
- Local saves are authoritative during play and cloud synchronization retries
when connectivity returns.
- Content that has never been downloaded is clearly marked as unavailable
@@ -104,9 +122,10 @@ Keeping the runtime in the APK is what makes offline startup reliable.
/mnt/usbssds/apps/healer-man/backups Database backups
```
The Node server exposes the content volume at `/content`. Manifests use
`no-cache`; immutable hashed objects use long-lived cache headers and support
byte ranges for resumed downloads.
The Node server exposes the compact production browser client at `/` and the
complete downloadable-content volume at `/content`. Manifests use `no-cache`;
immutable hashed objects use long-lived cache headers and support byte ranges
for resumed downloads.
After pulling source on TrueNAS, publish content before restarting the app:
@@ -121,15 +140,29 @@ discover a manifest whose files have not been published.
## Pack policy
Packs are player-facing groups while files remain individually addressable.
This permits a fast initial pack install and small later patches. Current pack
IDs are derived predictably:
This permits a compact initial install and small later patches. Required pack
IDs include:
```text
core:runtime
core:ui
core:equipment
core:character-basic
dungeon:wailing-caverns
```
`core:character-basic` owns the two basic Human rigs, the character manifest,
and only the default texture selections needed by the fallback. The remaining
human customizations and every non-Human character model/skin are optional.
Downloadable pack IDs include:
```text
dungeon:forsaken-abbey
characters:wow335a-v1
characters:ascension-v1
characters:runewaker-v1
core:combat-media
manastorm:<stage-id>
shared:creatures
```
+24 -18
View File
@@ -24,8 +24,9 @@ container, rebuilding `dist`, or pulling source must not replace the database.
TrueNAS Gitea stores the source repository and APK release attachments; it is
not the game database.
Large runtime assets are published separately under `/app/content`. Android
clients download verified content objects from `/content` and retain them in
Large runtime assets are published separately under `/app/content`. Production
browser and Android clients download verified content objects from `/content`;
browsers retain them in an app-managed cache and Android retains them in
app-private storage for offline play. See `CONTENT_UPDATES.md` for the manifest,
atomic activation, compatibility, and rollback contract.
@@ -55,9 +56,10 @@ Container/host port:
```
The Gitea bare repository is not runnable. Clone it with Git; never copy it
with `cp`. The checkout contains several gigabytes of required GLB assets, so
the initial clone and first build will take substantially longer than later
fast-forward updates.
with `cp`. The checkout contains several gigabytes of content-source GLB assets,
so the initial clone and first content publication will take substantially
longer than later fast-forward updates. Production browser and Android bundles
stage only their compact required-content subset.
Do not turn the GLBs into Git LFS pointers without also changing this deployment
procedure. Gitea keeps LFS objects outside the bare repository, so a bare-path
@@ -80,7 +82,8 @@ git branch --show-current
If the repository slug differs, change both the remote URL here and every
`healer-man.git` bare-repository path below. Do not commit `.env` files,
keystores, API tokens, `content`, `dist`, `dist-android`, or `.android-public`.
keystores, API tokens, `content`, `dist`, `dist-android`, `.web-public`, or
`.android-public`.
On TrueNAS, confirm the bare repository path. If the first command fails, use
the search command:
@@ -154,8 +157,9 @@ account/cloud-save data; `/app/content` owns downloadable manifests and
immutable content objects. Never mount a volume at `/app/data`, because that
would hide the checkout's required `/app/data/loot/schema.sql`. The
startup command installs locked dependencies, initializes the schema, publishes
new or changed content objects, builds the full browser bundle, and starts the
combined static/API/content server. Existing content objects are reused.
new or changed content objects, builds the compact required-content browser
bundle, and starts the combined static/API/content server. Existing content
objects are reused.
After startup, test on the LAN:
@@ -220,8 +224,8 @@ git rev-parse HEAD
Before `git commit`, read the staged file list printed by `git status --short`.
Do not commit a `.env` file, password, API token, keystore, APK, database,
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final
command prints the exact deployed commit ID.
`content/`, `dist/`, `dist-android/`, `.web-public/`, or `.android-public/`
output. The final command prints the exact deployed commit ID.
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
and run these exact commands:
@@ -247,11 +251,12 @@ sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
The TrueNAS commit ID must match the one printed on the development PC. Start
`healer-man` in the TrueNAS Apps UI afterward. Container startup
publishes immutable content objects and replaces `manifest.json` only after all
objects exist, then rebuilds the browser client and starts the server. Android
devices discover the small new manifest and download only missing or changed
files. Asset-only updates do not require a new APK. A failed fast-forward pull
deliberately leaves the running checkout unchanged; resolve diverged history on
the development machine rather than forcing the TrueNAS checkout.
objects exist, then rebuilds the browser client and starts the server. Browser
and Android clients discover the small new manifest and download only missing
or changed files for packs the player installed. Asset-only updates do not
require a new APK. A failed fast-forward pull deliberately leaves the running
checkout unchanged; resolve diverged history on the development machine rather
than forcing the TrueNAS checkout.
After the logs say the server is listening on port `4173`, run on TrueNAS:
@@ -286,9 +291,10 @@ copies; it does not replace dataset-level protection.
## APK releases and Obtainium
Keep APK binaries out of the Git branch. The Android build uses `dist-android`,
which contains the game runtime, UI, equipment, and Wailing Caverns starter
content rather than the full multi-gigabyte browser catalog. Downloaded packs
survive APK updates.
which contains the game runtime, UI, starter equipment, basic Human character,
and Wailing Caverns starter content. Optional character/skin packs, original
spell media, dungeons, and other large content remain outside the APK.
Downloaded packs survive APK updates.
Install JDK 21 and Android SDK Platform 36 on the development PC and set
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
+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.
+156
View File
@@ -1572,3 +1572,159 @@ Vite's existing large-chunk advisory remains informational.
106 environment assets passed their performance audit with no regressions,
the KTX2 audit found no fallback textures, and the release-candidate check
passed. The existing large-chunk advisory remains informational.
# 2026-09-04 — optional content offload and basic Human fallback
- Built and served the production browser output from a clean localhost origin.
A fresh Undead female Priest preview rendered with the real bundled Human
female rig and default textures while retaining the Undead/Priest identity in
the roster. No optional character pack had been installed.
- Entered Wailing Caverns as healer from a separate clean browser origin. The
Undead player, Undead tank, and Goblin party member all rendered through the
matching basic Human fallbacks while their saved race, role, class, equipment,
and party labels remained intact. The dungeon stayed playable and emitted no
new error; only the existing third-party initialization deprecation warning
appeared.
- Exercised the browser Downloads lifecycle with a small local two-revision
content fixture. Missing character and original-media packs showed Download;
a verified install showed Remove; the changed revision showed Update and
Remove together; and applying Update returned the pack to offline-ready state.
- Reloaded after service-worker activation. The application and lazy JavaScript
chunks loaded normally, while the content worker continued to reserve managed
character, creature, equipment, game, and UI paths for bundled or verified
cached content. The initial broad `/assets/` interception found during this
playtest was corrected before the final run.
- In-dungeon Options showed Generated as the active default, kept Original
disabled until its optional spell-effects-and-sounds pack is downloaded, and
exposed a Download action for that pack.
- Evidence:
`playtest-artifacts/2026-09-04-optional-content/roster-undead-priest-basic-human-fallback.png`,
`dungeon-optional-models-basic-human-fallback.png`,
`browser-downloads-optional-packs.png`, and
`browser-downloads-update-and-remove.png`.
- Verification: all 43 avatar tests passed; the complete Vitest suite passed all
896 cases across 164 files; production browser and Android builds completed;
both binary inventories contained only the two Human rigs, starter equipment,
and Wailing Caverns content; and neither output contained optional character
packs, original combat media, or WAV files. KTX2 and environment-performance
audits remained green, and the release-candidate check passed. The existing
large-chunk advisory remains informational.
# 2026-09-05 — Power Word: Shield live character anchoring
- Installed the optional Original Spell Effects & Sounds pack through the
production Downloads screen, enabled it, and selected Original spell visuals.
- Entered Wailing Caverns as a Human Priest and cast Power Word: Shield on both
the party tank and the player while exercising party movement and combat. The
persistent original aura stayed at the rendered character root instead of
following the throttled gameplay-position snapshot, with no inherited camera
or character rotation.
- Reviewed live dungeon screenshots before and during the movement test. The
browser emitted no new errors; only the existing third-party initialization
deprecation warning remained.
- Verification: all 899 Vitest cases passed across 165 files, including three
new visual-anchor registry cases. The production build completed with 920
modules transformed, and asset audits remained green. The existing
large-chunk advisory remains informational.
# 2026-09-05 — modular RuneWaker boss mechanics
- Built a temporary local production content feed for Forsaken Abbey and the
first two Ystra Labyrinth boss rooms, then installed each pack through the
browser Downloads workflow with checksum verification.
- Loaded the source-backed Specter Guardian Creator encounter. Its mechanic
panel appeared below the minimap and buff/debuff strips with Guard Bomb,
Transport Magic, and Meteor Shower countdowns. A live Meteor Shower cast
showed its one-second cast warning over the boss.
- The first pass exposed that four countdown rows could crowd the boss's
pending 80%, 60%, 40%, and 20% phase triggers out of the panel. The timeline
now limits only countdown rows and always retains every pending health
threshold. The corrected production build displayed all eight rows without
covering the central playfield.
- Loaded Rune Guardian Leader in Ystra Labyrinth Boss Room 2. Its source-backed
Manastorms Duo Lv5 mechanic displayed a large purple circular floor warning
while the matching panel row counted down at 0.6 seconds. The area indicator,
cast timing, party damage, and effect/debuff presentation all advanced in the
live encounter.
- Browser logs contained no uncaught application errors. The existing
third-party initialization deprecation warning remained; WebGL context-lost
messages corresponded to intentional production rebuild reloads and opening
replacement test tabs.
- Verification: the full suite passed all 913 cases across 169 files, and the
corrected production build completed with 923 modules transformed. KTX2 and
environment-performance audits remained green, and the release-candidate
check passed.
# 2026-09-05 — specialization-aware heirlooms and endgame loot
- Activated the legacy level-10 Priest `Rolecheck`. The mandatory Class Hall
picker appeared before the main menu, offered Discipline, Holy, and Shadow,
and presented Cancel, Keep talents, and Reset talents after choosing Holy.
Keeping talents returned to the main menu with the specialization saved.
- Opened Wailing Caverns from Dungeon Finder. Role selection was restricted to
Healer, matching the saved Holy specialization; no Tank or DPS override was
available.
- Reviewed the roster preview and a live Wailing Caverns combat scene. The
player body, staff, and all four AI party bodies/equipment silhouettes were
visible and correctly anchored without obscuring the HUD or playfield.
- Opened Inventory & Equipment in the dungeon. The player had zero earned
items but every applicable slot resolved to a level-10 Priest healer
heirloom and reported average item level 10. Morvane, the Blood Death Knight
companion, showed a complete tank heirloom loadout with the intended
two-handed weapon layout and empty conflicting slots.
- Browser logs contained no uncaught application errors. Only the existing
third-party initialization deprecation warning appeared.
- Automated coverage validates every classic and CoA specialization, Feral
Bear/Cat separation, RoM-derived profiles, player and party scaling caps,
same-level catalog dominance at levels 1/40/79, real item-level-85 upgrade
availability for every gear archetype, two-hand replacement scoring,
fallback reappearance, percentage-preserving stat refreshes, specialization
persistence and talent reset behavior, and the item-level-255 save limit.
- Verification: the complete Vitest suite passed all 928 cases across 171
files, including all 43 avatar cases. All 15 server persistence/API tests
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.
+52 -12
View File
@@ -41,6 +41,32 @@ 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
runtime, interface, starter equipment, Wailing Caverns starter content, and a
basic male/female Human character model with its default appearance. Large
character and skin libraries, additional dungeons, Manastorm stages, and the
original spell-effects and sound pack are optional downloads.
Open **Downloads** from the main menu to download, update, or remove optional
packs on either platform. Browser downloads live in an app-managed verified
cache; Android downloads live in app-private storage. Removing a pack never
removes a roster or save. If a character's model/skin pack is unavailable, the
shared character renderer displays the matching-gender basic Human while
preserving that character's identity and progress for a later reinstall.
Generated spell effects and synthesized combat cues are the default and need
no original-media download. Players who want the packaged original visuals and
sounds can download that pack explicitly, select **Original** effects, and
remove the pack again to reclaim its storage. See
[`CONTENT_UPDATES.md`](CONTENT_UPDATES.md) for the content lifecycle and release
contract.
## Conquest of Azeroth classes
All 21 CoA classes use a checked-in live Vol'Jin catalog generated from
@@ -81,12 +107,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
for a future loot-browser screen.
Every Wailing Caverns boss rolls one item when it dies. The item instance
captures the player's level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. The SQL loot source
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
weapon speed, regeneration, and spell-power values for each item. A scaled
drop preserves that original stat distribution instead of replacing it with a
generic item-power budget.
captures its item level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. Drops use character
level through 79; level-80 drops begin the first endgame tier at item level 85.
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
block, weapon damage, weapon speed, regeneration, and spell-power values for
each item. A scaled drop preserves that original stat distribution instead of
replacing it with a generic item-power budget.
Every WoW and Conquest of Azeroth character has a saved specialization that
locks its activity role and selects a complete specialization-aware heirloom
loadout. Runes of Magic derives the same profile from its active primary class
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
rescale through item level 79 for players and 80 for AI party members, never
enter inventory, and automatically return when earned equipment is removed.
During leveling their score is normalized above compatible same-level catalog
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
the equipment and loot interfaces highlight without equipping automatically.
The equipment runtime uses the Wrath primary attributes and itemizable combat
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
@@ -181,12 +218,13 @@ npm run android:apk
The debug APK is written under `android/app/build/outputs/apk/debug/`. To build
and install directly onto a connected device, use `npm run android:install`.
Android uses a separate compact `dist-android` build. It bundles the client,
interface, equipment, and Wailing Caverns starter content while optional
dungeons, character packs, and Manastorm stages are downloaded into app-private
storage. Installed content and local saves remain playable without a server
connection and survive APK updates. See `CONTENT_UPDATES.md` for the update
contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
Android uses a compact `dist-android` build with the same required-content
boundary as the production browser build. Optional dungeons, character/skin
packs, Manastorm stages, and original spell media are downloaded into
app-private storage. Installed content and local saves remain playable without
a server connection and survive APK updates. See `CONTENT_UPDATES.md` for the
update contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea
releases.
## Runtime details
@@ -206,6 +244,8 @@ contract and `DEPLOYMENT.md` for TrueNAS publishing and signed Gitea releases.
compressed in GPU memory instead of expanding to uncompressed RGBA textures.
- Missing production GLBs activate a small cave proxy; malformed assets activate
the recoverable scene-error screen.
- Missing optional character or skin packs render the built-in basic Human
model instead of hiding the player, party member, or roster preview.
- The controller action HUD displays only the active eight-slot layer. Its
face-button diamond, shoulders, and D-pad keep the physical controller layout
visible without showing all 24 bindings over the playfield.
+8 -4
View File
@@ -71,9 +71,12 @@ file, use the shorter procedure at the end. Those updates can be published by
the server and downloaded by an already-installed APK without an Obtainium
release.
Single-player remains offline-capable. A fresh APK includes the runtime and
starter content. Additional content that a player has downloaded is retained
in Android app-private storage and can be used offline after download.
Single-player remains offline-capable. A fresh APK includes the runtime,
interface, starter equipment, Wailing Caverns, and the basic Human character
fallback. Additional content that a player has downloaded is retained in
Android app-private storage and can be used offline after download. Generated
spell effects are available without downloading the optional original-media
pack.
## Fixed locations
@@ -287,7 +290,8 @@ git diff --cached --check
```
Do not continue if the staged list contains a keystore, APK, `.env` file,
database, `content/`, `dist/`, `dist-android/`, or `.android-public/` output.
database, `content/`, `dist/`, `dist-android/`, `.web-public/`, or
`.android-public/` output.
Unstage any accidental file with `git restore --staged -- <path>` without
deleting the local file.
+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.
+18 -1
View File
@@ -73,6 +73,23 @@ attaches native animated client models by display ID. It writes:
- `src/game/generated/dungeonCampaignCatalog.compressed.ts`, the bundled catalog
- `src/game/generated/dungeonAvailability.json`, the generated availability list
Boss specials are compiled as independent mechanic records. AzerothCore
SmartAI timers and health events are imported automatically; C++ encounter
spells are imported from the pinned script checkout. Add or override a boss in
`dungeon-pipeline/boss-mechanics.json` when an encounter needs hand-authored
targeting, summon behavior, timing, or telegraph geometry. Use `mode: "replace"`
to make that list authoritative or `mode: "append"` to layer entries ahead of
the imported mechanics, then run `npm run dungeon:catalog`. The generated
`combat.mechanicIds` list is the stable loadout boundary intended for later
encounter randomization.
The same catalog also stores Runewaker kits under
`rw-<dungeon>-<entity-id>` keys. Run `npm run runewaker:bosses:refresh` to
rebuild those records from preserved RuneWaker spell/Lua evidence, regenerate
the population packages, and run `npm run runewaker:audit:gameplay`. A record
whose preserved source has no recoverable combat ability is explicitly marked
`source.kind: "healer-man-source-gap"` rather than being claimed as authentic.
Stock AzerothCore population is used for the standard dungeons. The three
Ascension-specific instances—Tinkertech Showdown, Frozen Reach, and Forgotten
Mine—keep their client encounter identities and use an explicit procedural
@@ -229,4 +246,4 @@ Kresh is intentionally recorded as a source-faithful melee-only boss: entry
3653 has no spell or SmartAI row in the pinned AzerothCore snapshot. Trial of
the Champion is likewise documented as a boss-arena population rather than a
trash route. These exceptions are asserted by exact dungeon/entity IDs; any
new basic-only boss or mob-less dungeon fails the batch.
new basic-only boss or mob-less dungeon fails the batch.
+121 -105
View File
@@ -1,17 +1,17 @@
{
"schemaVersion": 1,
"status": "green",
"scope": "HealerMan WoW five-player campaign",
"scope": "HealerMan WoW five-player combat and animation coverage",
"summary": {
"dungeons": 54,
"mobs": 1031,
"bosses": 253,
"attacks": 2953,
"specialAttacks": 1669,
"attacks": 3765,
"specialAttacks": 2481,
"nativeModels": 1266,
"proceduralModels": 18,
"uniqueNativeModels": 1166,
"nativeStateFallbacks": 16
"nativeStateFallbacks": 18
},
"documentedExceptions": [
{
@@ -47,6 +47,14 @@
],
"renderer": "OriginalCreatureModel combat fallback"
},
{
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
"state": "move",
"users": [
"dire-maul:entry-14386"
],
"renderer": "AnimatedMobBody locomotion fallback"
},
{
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
"state": "attack",
@@ -56,7 +64,15 @@
"renderer": "OriginalCreatureModel combat fallback"
},
{
"url": "/assets/game/creatures/shared/2fbb8d2226a7fdf3e9a7af13568f6bb46cb1b27afc008732832b9fd42c2e510e.glb",
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
"state": "wound",
"users": [
"dire-maul:entry-14386"
],
"renderer": "OriginalCreatureModel combat fallback"
},
{
"url": "/assets/game/creatures/shared/52c8b3aea63f21f8e98ad84eaf601f2918d1df17a05525d41f5b3e17da53c8ff.glb",
"state": "wound",
"users": [
"steamvault:entry-21694",
@@ -170,8 +186,8 @@
"source": "azerothcore",
"mobs": 24,
"bosses": 7,
"attacks": 58,
"specialAttacks": 27,
"attacks": 65,
"specialAttacks": 34,
"nativeModels": 31,
"proceduralModels": 0,
"status": "green"
@@ -182,8 +198,8 @@
"source": "azerothcore",
"mobs": 6,
"bosses": 5,
"attacks": 32,
"specialAttacks": 21,
"attacks": 42,
"specialAttacks": 31,
"nativeModels": 11,
"proceduralModels": 0,
"status": "green"
@@ -194,8 +210,8 @@
"source": "azerothcore",
"mobs": 15,
"bosses": 7,
"attacks": 48,
"specialAttacks": 26,
"attacks": 55,
"specialAttacks": 33,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
@@ -218,8 +234,8 @@
"source": "azerothcore",
"mobs": 31,
"bosses": 7,
"attacks": 78,
"specialAttacks": 40,
"attacks": 87,
"specialAttacks": 49,
"nativeModels": 38,
"proceduralModels": 0,
"status": "green"
@@ -230,8 +246,8 @@
"source": "azerothcore",
"mobs": 21,
"bosses": 6,
"attacks": 58,
"specialAttacks": 31,
"attacks": 65,
"specialAttacks": 38,
"nativeModels": 27,
"proceduralModels": 0,
"status": "green"
@@ -242,8 +258,8 @@
"source": "azerothcore",
"mobs": 31,
"bosses": 7,
"attacks": 85,
"specialAttacks": 47,
"attacks": 104,
"specialAttacks": 66,
"nativeModels": 38,
"proceduralModels": 0,
"status": "green"
@@ -254,8 +270,8 @@
"source": "azerothcore",
"mobs": 27,
"bosses": 4,
"attacks": 68,
"specialAttacks": 37,
"attacks": 82,
"specialAttacks": 51,
"nativeModels": 31,
"proceduralModels": 0,
"status": "green"
@@ -266,8 +282,8 @@
"source": "azerothcore",
"mobs": 29,
"bosses": 4,
"attacks": 86,
"specialAttacks": 53,
"attacks": 93,
"specialAttacks": 60,
"nativeModels": 33,
"proceduralModels": 0,
"status": "green"
@@ -278,8 +294,8 @@
"source": "azerothcore",
"mobs": 19,
"bosses": 6,
"attacks": 55,
"specialAttacks": 30,
"attacks": 63,
"specialAttacks": 38,
"nativeModels": 25,
"proceduralModels": 0,
"status": "green"
@@ -290,8 +306,8 @@
"source": "azerothcore",
"mobs": 32,
"bosses": 5,
"attacks": 89,
"specialAttacks": 52,
"attacks": 107,
"specialAttacks": 70,
"nativeModels": 37,
"proceduralModels": 0,
"status": "green"
@@ -302,8 +318,8 @@
"source": "azerothcore",
"mobs": 16,
"bosses": 5,
"attacks": 53,
"specialAttacks": 32,
"attacks": 69,
"specialAttacks": 48,
"nativeModels": 21,
"proceduralModels": 0,
"status": "green"
@@ -314,8 +330,8 @@
"source": "azerothcore",
"mobs": 63,
"bosses": 15,
"attacks": 210,
"specialAttacks": 132,
"attacks": 273,
"specialAttacks": 195,
"nativeModels": 78,
"proceduralModels": 0,
"status": "green"
@@ -326,8 +342,8 @@
"source": "azerothcore",
"mobs": 48,
"bosses": 13,
"attacks": 145,
"specialAttacks": 84,
"attacks": 182,
"specialAttacks": 121,
"nativeModels": 61,
"proceduralModels": 0,
"status": "green"
@@ -338,8 +354,8 @@
"source": "azerothcore",
"mobs": 30,
"bosses": 8,
"attacks": 115,
"specialAttacks": 77,
"attacks": 150,
"specialAttacks": 112,
"nativeModels": 38,
"proceduralModels": 0,
"status": "green"
@@ -350,8 +366,8 @@
"source": "azerothcore",
"mobs": 41,
"bosses": 9,
"attacks": 131,
"specialAttacks": 81,
"attacks": 176,
"specialAttacks": 126,
"nativeModels": 50,
"proceduralModels": 0,
"status": "green"
@@ -362,8 +378,8 @@
"source": "azerothcore",
"mobs": 25,
"bosses": 8,
"attacks": 83,
"specialAttacks": 50,
"attacks": 95,
"specialAttacks": 62,
"nativeModels": 33,
"proceduralModels": 0,
"status": "green"
@@ -374,8 +390,8 @@
"source": "azerothcore",
"mobs": 7,
"bosses": 4,
"attacks": 21,
"specialAttacks": 10,
"attacks": 23,
"specialAttacks": 12,
"nativeModels": 11,
"proceduralModels": 0,
"status": "green"
@@ -386,8 +402,8 @@
"source": "azerothcore",
"mobs": 63,
"bosses": 12,
"attacks": 184,
"specialAttacks": 109,
"attacks": 242,
"specialAttacks": 167,
"nativeModels": 75,
"proceduralModels": 0,
"status": "green"
@@ -398,8 +414,8 @@
"source": "azerothcore",
"mobs": 29,
"bosses": 3,
"attacks": 74,
"specialAttacks": 42,
"attacks": 92,
"specialAttacks": 60,
"nativeModels": 32,
"proceduralModels": 0,
"status": "green"
@@ -410,8 +426,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 3,
"attacks": 44,
"specialAttacks": 27,
"attacks": 53,
"specialAttacks": 36,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -422,8 +438,8 @@
"source": "azerothcore",
"mobs": 10,
"bosses": 2,
"attacks": 36,
"specialAttacks": 24,
"attacks": 41,
"specialAttacks": 29,
"nativeModels": 12,
"proceduralModels": 0,
"status": "green"
@@ -434,8 +450,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 3,
"attacks": 50,
"specialAttacks": 33,
"attacks": 63,
"specialAttacks": 46,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -446,8 +462,8 @@
"source": "azerothcore",
"mobs": 18,
"bosses": 4,
"attacks": 59,
"specialAttacks": 37,
"attacks": 80,
"specialAttacks": 58,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
@@ -458,8 +474,8 @@
"source": "azerothcore",
"mobs": 18,
"bosses": 3,
"attacks": 53,
"specialAttacks": 32,
"attacks": 60,
"specialAttacks": 39,
"nativeModels": 21,
"proceduralModels": 0,
"status": "green"
@@ -470,8 +486,8 @@
"source": "azerothcore",
"mobs": 25,
"bosses": 4,
"attacks": 73,
"specialAttacks": 44,
"attacks": 105,
"specialAttacks": 76,
"nativeModels": 29,
"proceduralModels": 0,
"status": "green"
@@ -482,8 +498,8 @@
"source": "azerothcore",
"mobs": 24,
"bosses": 5,
"attacks": 67,
"specialAttacks": 38,
"attacks": 85,
"specialAttacks": 56,
"nativeModels": 29,
"proceduralModels": 0,
"status": "green"
@@ -494,8 +510,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 3,
"attacks": 45,
"specialAttacks": 28,
"attacks": 69,
"specialAttacks": 52,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -506,8 +522,8 @@
"source": "azerothcore",
"mobs": 18,
"bosses": 4,
"attacks": 46,
"specialAttacks": 24,
"attacks": 67,
"specialAttacks": 45,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
@@ -518,8 +534,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 2,
"attacks": 39,
"specialAttacks": 23,
"attacks": 50,
"specialAttacks": 34,
"nativeModels": 16,
"proceduralModels": 0,
"status": "green"
@@ -530,8 +546,8 @@
"source": "azerothcore",
"mobs": 11,
"bosses": 3,
"attacks": 33,
"specialAttacks": 19,
"attacks": 46,
"specialAttacks": 32,
"nativeModels": 14,
"proceduralModels": 0,
"status": "green"
@@ -542,8 +558,8 @@
"source": "azerothcore",
"mobs": 12,
"bosses": 2,
"attacks": 30,
"specialAttacks": 16,
"attacks": 44,
"specialAttacks": 30,
"nativeModels": 14,
"proceduralModels": 0,
"status": "green"
@@ -554,8 +570,8 @@
"source": "azerothcore",
"mobs": 15,
"bosses": 3,
"attacks": 33,
"specialAttacks": 15,
"attacks": 57,
"specialAttacks": 39,
"nativeModels": 18,
"proceduralModels": 0,
"status": "green"
@@ -566,8 +582,8 @@
"source": "azerothcore",
"mobs": 4,
"bosses": 3,
"attacks": 20,
"specialAttacks": 13,
"attacks": 25,
"specialAttacks": 18,
"nativeModels": 7,
"proceduralModels": 0,
"status": "green"
@@ -578,8 +594,8 @@
"source": "azerothcore",
"mobs": 26,
"bosses": 3,
"attacks": 45,
"specialAttacks": 16,
"attacks": 53,
"specialAttacks": 24,
"nativeModels": 29,
"proceduralModels": 0,
"status": "green"
@@ -590,8 +606,8 @@
"source": "azerothcore",
"mobs": 19,
"bosses": 3,
"attacks": 35,
"specialAttacks": 13,
"attacks": 44,
"specialAttacks": 22,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
@@ -602,8 +618,8 @@
"source": "azerothcore",
"mobs": 13,
"bosses": 4,
"attacks": 32,
"specialAttacks": 15,
"attacks": 44,
"specialAttacks": 27,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -614,8 +630,8 @@
"source": "azerothcore",
"mobs": 21,
"bosses": 5,
"attacks": 50,
"specialAttacks": 24,
"attacks": 61,
"specialAttacks": 35,
"nativeModels": 26,
"proceduralModels": 0,
"status": "green"
@@ -626,8 +642,8 @@
"source": "azerothcore",
"mobs": 8,
"bosses": 4,
"attacks": 27,
"specialAttacks": 15,
"attacks": 33,
"specialAttacks": 21,
"nativeModels": 12,
"proceduralModels": 0,
"status": "green"
@@ -638,8 +654,8 @@
"source": "azerothcore",
"mobs": 16,
"bosses": 4,
"attacks": 41,
"specialAttacks": 21,
"attacks": 44,
"specialAttacks": 24,
"nativeModels": 20,
"proceduralModels": 0,
"status": "green"
@@ -650,8 +666,8 @@
"source": "azerothcore",
"mobs": 13,
"bosses": 4,
"attacks": 32,
"specialAttacks": 15,
"attacks": 42,
"specialAttacks": 25,
"nativeModels": 16,
"proceduralModels": 1,
"status": "green"
@@ -662,8 +678,8 @@
"source": "azerothcore",
"mobs": 18,
"bosses": 4,
"attacks": 38,
"specialAttacks": 16,
"attacks": 45,
"specialAttacks": 23,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
@@ -674,8 +690,8 @@
"source": "azerothcore",
"mobs": 10,
"bosses": 3,
"attacks": 25,
"specialAttacks": 12,
"attacks": 30,
"specialAttacks": 17,
"nativeModels": 13,
"proceduralModels": 0,
"status": "green"
@@ -686,8 +702,8 @@
"source": "azerothcore",
"mobs": 16,
"bosses": 4,
"attacks": 35,
"specialAttacks": 15,
"attacks": 49,
"specialAttacks": 29,
"nativeModels": 20,
"proceduralModels": 0,
"status": "green"
@@ -698,8 +714,8 @@
"source": "azerothcore",
"mobs": 15,
"bosses": 5,
"attacks": 43,
"specialAttacks": 23,
"attacks": 53,
"specialAttacks": 33,
"nativeModels": 20,
"proceduralModels": 0,
"status": "green"
@@ -710,8 +726,8 @@
"source": "azerothcore",
"mobs": 2,
"bosses": 3,
"attacks": 19,
"specialAttacks": 14,
"attacks": 24,
"specialAttacks": 19,
"nativeModels": 5,
"proceduralModels": 0,
"status": "green"
@@ -722,8 +738,8 @@
"source": "azerothcore",
"mobs": 19,
"bosses": 5,
"attacks": 43,
"specialAttacks": 19,
"attacks": 60,
"specialAttacks": 36,
"nativeModels": 24,
"proceduralModels": 0,
"status": "green"
@@ -734,8 +750,8 @@
"source": "azerothcore",
"mobs": 11,
"bosses": 2,
"attacks": 21,
"specialAttacks": 8,
"attacks": 23,
"specialAttacks": 10,
"nativeModels": 13,
"proceduralModels": 0,
"status": "green"
@@ -746,8 +762,8 @@
"source": "azerothcore",
"mobs": 0,
"bosses": 5,
"attacks": 22,
"specialAttacks": 17,
"attacks": 88,
"specialAttacks": 83,
"nativeModels": 5,
"proceduralModels": 0,
"status": "green"
@@ -758,8 +774,8 @@
"source": "azerothcore",
"mobs": 27,
"bosses": 3,
"attacks": 43,
"specialAttacks": 13,
"attacks": 49,
"specialAttacks": 19,
"nativeModels": 30,
"proceduralModels": 0,
"status": "green"
@@ -770,8 +786,8 @@
"source": "azerothcore",
"mobs": 8,
"bosses": 2,
"attacks": 26,
"specialAttacks": 16,
"attacks": 38,
"specialAttacks": 28,
"nativeModels": 10,
"proceduralModels": 0,
"status": "green"
+55 -53
View File
@@ -1,64 +1,66 @@
# WoW dungeon content report
# WoW dungeon combat and animation report
Status: **green**
Coverage: 54 dungeons, 1031 mobs, 253 bosses, 2953 executable attacks, and 1669 special attacks/mechanics.
Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 16 missing native states use the shared procedural fallback.
Coverage: 54 dungeons, 1031 mobs, 253 bosses, 3765 executable attacks, and 2481 special attacks/mechanics.
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 18 missing native states use the shared procedural fallback.
| Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| Shadowfang Keep | 24 | 7 | 58 | 27 | 31 | 0 | green |
| Stormwind Stockade | 6 | 5 | 32 | 21 | 11 | 0 | green |
| The Deadmines | 15 | 7 | 48 | 26 | 22 | 0 | green |
| Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
| Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
| The Deadmines | 15 | 7 | 55 | 33 | 22 | 0 | green |
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
| Razorfen Kraul | 31 | 7 | 78 | 40 | 38 | 0 | green |
| Blackfathom Deeps | 21 | 6 | 58 | 31 | 27 | 0 | green |
| Uldaman | 31 | 7 | 85 | 47 | 38 | 0 | green |
| Gnomeregan | 27 | 4 | 68 | 37 | 31 | 0 | green |
| Sunken Temple | 29 | 4 | 86 | 53 | 33 | 0 | green |
| Razorfen Downs | 19 | 6 | 55 | 30 | 25 | 0 | green |
| Scarlet Monastery | 32 | 5 | 89 | 52 | 37 | 0 | green |
| Zul'Farrak | 16 | 5 | 53 | 32 | 21 | 0 | green |
| Blackrock Spire | 63 | 15 | 210 | 132 | 78 | 0 | green |
| Blackrock Depths | 48 | 13 | 145 | 84 | 61 | 0 | green |
| Scholomance | 30 | 8 | 115 | 77 | 38 | 0 | green |
| Stratholme | 41 | 9 | 131 | 81 | 50 | 0 | green |
| Maraudon | 25 | 8 | 83 | 50 | 33 | 0 | green |
| Ragefire Chasm | 7 | 4 | 21 | 10 | 11 | 0 | green |
| Dire Maul | 63 | 12 | 184 | 109 | 75 | 0 | green |
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 74 | 42 | 32 | 0 | green |
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 44 | 27 | 17 | 0 | green |
| Hellfire Citadel: Ramparts | 10 | 2 | 36 | 24 | 12 | 0 | green |
| Coilfang: The Steamvault | 14 | 3 | 50 | 33 | 17 | 0 | green |
| Coilfang: The Underbog | 18 | 4 | 59 | 37 | 22 | 0 | green |
| Coilfang: The Slave Pens | 18 | 3 | 53 | 32 | 21 | 0 | green |
| Tempest Keep: The Arcatraz | 25 | 4 | 73 | 44 | 29 | 0 | green |
| Tempest Keep: The Botanica | 24 | 5 | 67 | 38 | 29 | 0 | green |
| Tempest Keep: The Mechanar | 14 | 3 | 45 | 28 | 17 | 0 | green |
| Auchindoun: Shadow Labyrinth | 18 | 4 | 46 | 24 | 22 | 0 | green |
| Auchindoun: Sethekk Halls | 14 | 2 | 39 | 23 | 16 | 0 | green |
| Auchindoun: Mana-Tombs | 11 | 3 | 33 | 19 | 14 | 0 | green |
| Auchindoun: Auchenai Crypts | 12 | 2 | 30 | 16 | 14 | 0 | green |
| Magister's Terrace | 15 | 3 | 33 | 15 | 18 | 0 | green |
| The Black Morass | 4 | 3 | 20 | 13 | 7 | 0 | green |
| The Escape From Durnholde | 26 | 3 | 45 | 16 | 29 | 0 | green |
| Utgarde Keep | 19 | 3 | 35 | 13 | 22 | 0 | green |
| Utgarde Pinnacle | 13 | 4 | 32 | 15 | 17 | 0 | green |
| The Nexus | 21 | 5 | 50 | 24 | 26 | 0 | green |
| The Oculus | 8 | 4 | 27 | 15 | 12 | 0 | green |
| The Culling of Stratholme | 16 | 4 | 41 | 21 | 20 | 0 | green |
| Halls of Stone | 13 | 4 | 32 | 15 | 16 | 1 | green |
| Drak'Tharon Keep | 18 | 4 | 38 | 16 | 22 | 0 | green |
| Azjol-Nerub | 10 | 3 | 25 | 12 | 13 | 0 | green |
| Halls of Lightning | 16 | 4 | 35 | 15 | 20 | 0 | green |
| Gundrak | 15 | 5 | 43 | 23 | 20 | 0 | green |
| Violet Hold | 2 | 3 | 19 | 14 | 5 | 0 | green |
| Ahn'kahet: The Old Kingdom | 19 | 5 | 43 | 19 | 24 | 0 | green |
| The Forge of Souls | 11 | 2 | 21 | 8 | 13 | 0 | green |
| Trial of the Champion | 0 | 5 | 22 | 17 | 5 | 0 | green |
| Pit of Saron | 27 | 3 | 43 | 13 | 30 | 0 | green |
| Halls of Reflection | 8 | 2 | 26 | 16 | 10 | 0 | green |
| Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
| Blackfathom Deeps | 21 | 6 | 65 | 38 | 27 | 0 | green |
| Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
| Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green |
| Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green |
| Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green |
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
| Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green |
| Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green |
| Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green |
| Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green |
| Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green |
| Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
| Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
| Dire Maul | 63 | 12 | 242 | 167 | 75 | 0 | green |
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 0 | green |
| Hellfire Citadel: Ramparts | 10 | 2 | 41 | 29 | 12 | 0 | green |
| Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
| Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green |
| Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green |
| Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green |
| Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green |
| Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
| Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
| Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green |
| Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green |
| Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green |
| Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green |
| The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green |
| The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green |
| Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green |
| Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green |
| The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green |
| The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green |
| The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green |
| Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green |
| Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green |
| Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green |
| Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green |
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
| Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green |
| Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green |
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
| Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green |
| Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green |
| Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green |
| Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green |
| Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green |
| Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green |
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "blades-edge-arena",
"title": "Blade's Edge Arena",
"mapId": 562,
"lfgDungeonIds": [],
"expansion": "the-burning-crusade",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/bladesedgearena",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "dalaran-sewers",
"title": "Dalaran Sewers",
"mapId": 617,
"lfgDungeonIds": [],
"expansion": "wrath-of-the-lich-king",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/DalaranArena",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "nagrand-arena",
"title": "Nagrand Arena",
"mapId": 559,
"lfgDungeonIds": [],
"expansion": "the-burning-crusade",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/PVPZone01",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "ring-of-valor",
"title": "Ring of Valor",
"mapId": 618,
"lfgDungeonIds": [],
"expansion": "wrath-of-the-lich-king",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/OrgrimmarArena",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "ruins-of-lordaeron",
"title": "Ruins of Lordaeron",
"mapId": 572,
"lfgDungeonIds": [],
"expansion": "the-burning-crusade",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/PVPLordaeron",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.13",
"version": "0.1.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.13",
"version": "0.1.21",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+11 -3
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.13",
"version": "0.1.21",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -10,6 +10,7 @@
"db:backup": "node scripts/backup-game-database.mjs",
"server:test": "node --test server/*.test.mjs",
"content:publish": "node scripts/content/publish-content.mjs",
"web:prepare": "node scripts/content/prepare-web-bootstrap.mjs",
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
"content:test": "node --test scripts/content/*.test.mjs",
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio && node scripts/combat-effects/package-priest-original.mjs",
@@ -62,6 +63,8 @@
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
"arena:assets:build": "node scripts/arena-pipeline/build-wmo-arenas.mjs && node scripts/arena-pipeline/build-custom-arenas.mjs && node scripts/arena-pipeline/prepare-warnorken.mjs",
"arena:assets:validate": "node scripts/arena-pipeline/validate-arenas.mjs",
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
"runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only",
@@ -73,6 +76,9 @@
"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",
"runewaker:paperdolls:refresh": "node scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs",
@@ -82,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",
@@ -96,7 +104,7 @@
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
"android:sync": "npm run build:android:web && cap sync android",
@@ -0,0 +1,127 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 258876,
"gridHeight": 564,
"gridWidth": 459,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "ashamanes-fall",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
16.435361862182617,
-2.676706314086914,
4.506431579589844
],
[
65.03536224365234,
26.523292541503906,
43.1064338684082
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 353,
"sourceVertices": 1059,
"triangles": 353,
"vertices": 1059
},
"input": {
"bounds": [
[
-6.364639759063721,
-19.976707458496094,
-59.093570709228516
],
[
85.27696990966797,
36.782005310058594,
53.62468338012695
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 2783,
"vertexCount": 8349
},
"inputChunkChecksums": {
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb": "cdc39b903da6a6f9f21bd9e5e0b2ca61e76b09e13575b02349ca92d68815e5a8"
},
"inputChunks": [
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb"
],
"output": "public/assets/game/arenas/ashamanes-fall/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"adtFiles": [
"valsharaharena_20_24.adt",
"valsharaharena_20_25.adt",
"valsharaharena_20_26.adt",
"valsharaharena_21_24.adt",
"valsharaharena_21_25.adt",
"valsharaharena_21_26.adt",
"valsharaharena_22_24.adt",
"valsharaharena_22_25.adt",
"valsharaharena_22_26.adt"
],
"gameplayBounds": {
"max": [
58,
16,
36
],
"min": [
14,
-4,
2
]
},
"kind": "adt-placed-wmo-repair",
"legacyCollision": "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
]
}
}
@@ -0,0 +1,99 @@
{
"schemaVersion": 1,
"id": "ashamanes-fall",
"title": "Ashamane's Fall",
"status": "generated-review-required",
"source": {
"mapDirectory": "patch-WB2/world/maps/valsharaharena",
"adtFiles": [
"valsharaharena_20_24.adt",
"valsharaharena_20_25.adt",
"valsharaharena_20_26.adt",
"valsharaharena_21_24.adt",
"valsharaharena_21_25.adt",
"valsharaharena_21_26.adt",
"valsharaharena_22_24.adt",
"valsharaharena_22_25.adt",
"valsharaharena_22_26.adt"
],
"placementCount": 16,
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
]
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
6.0437164306640625,
-37.49217987060547,
30.740463256835938
],
"alignmentOffset": [
34.174934,
-32.347685,
20.31486
],
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
},
"bounds": {
"min": [
-289.133544921875,
-32.347686767578125,
-200.8459930419922
],
"max": [
357.4833984375,
316.2064514160156,
241.47572326660156
]
},
"geometry": {
"groups": 18,
"triangles": 82815,
"collisionTriangles": 82815,
"collisionChunks": 1
},
"navigation": {
"triangles": 209,
"vertices": 627,
"sourceComponents": 20,
"spawnDistances": [
0.5941920148506428,
0.0212655626862784
],
"rawTriangles": 353,
"report": "recast-report.json"
},
"assets": [
{
"file": "visual.glb",
"bytes": 2611788,
"sha256": "25e8a0d63d420559a6bc965dc2b930039d951de8cdc189d785a00edc3610b112"
},
{
"file": "collision.glb",
"bytes": 1805536,
"sha256": "48f45c434687f07969a618e1251e05e8672061d16a1f3a88338dbc667e33e78e"
},
{
"file": "navigation.glb",
"bytes": 10936,
"sha256": "ac69ef1e845f632576598a8b2a5d0bbfa8086b21ed5d7fd17af06d5a565a5248"
}
]
}
Binary file not shown.
@@ -0,0 +1,114 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 2074478,
"gridHeight": 994,
"gridWidth": 2087,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "black-rook-hold-arena",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-92.08255767822266,
-16.14961814880371,
-279.4311218261719
],
[
321.9174499511719,
37.25038528442383,
-82.83113098144531
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 7170,
"sourceVertices": 21510,
"triangles": 7170,
"vertices": 21510
},
"input": {
"bounds": [
[
-93.08255767822266,
-39.549617767333984,
-280.0311279296875
],
[
324.1624450683594,
69.1881103515625,
-81.26593780517578
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 49821,
"vertexCount": 149463
},
"inputChunkChecksums": {
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb": "285bdce7d5b5085eef7d25f82a5b6a5b380605a310c539e8e94984182c835749"
},
"inputChunks": [
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb"
],
"output": "public/assets/game/arenas/black-rook-hold-arena/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"adtFiles": [
"blackrookholdarena_28_28.adt",
"blackrookholdarena_28_29.adt",
"blackrookholdarena_28_30.adt",
"blackrookholdarena_29_28.adt",
"blackrookholdarena_29_29.adt",
"blackrookholdarena_29_30.adt",
"blackrookholdarena_30_28.adt",
"blackrookholdarena_30_29.adt",
"blackrookholdarena_30_30.adt"
],
"gameplayBounds": {
"max": [
312,
30,
-110
],
"min": [
-82,
-10,
-242
]
},
"kind": "adt-placed-wmo-repair",
"legacyCollision": "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
]
}
}
@@ -0,0 +1,101 @@
{
"schemaVersion": 1,
"id": "black-rook-hold-arena",
"title": "Black Rook Hold Arena",
"status": "generated-review-required",
"source": {
"mapDirectory": "patch-WB1/world/maps/blackrookholdarena",
"adtFiles": [
"blackrookholdarena_28_28.adt",
"blackrookholdarena_28_29.adt",
"blackrookholdarena_28_30.adt",
"blackrookholdarena_29_28.adt",
"blackrookholdarena_29_29.adt",
"blackrookholdarena_29_30.adt",
"blackrookholdarena_30_28.adt",
"blackrookholdarena_30_29.adt",
"blackrookholdarena_30_30.adt"
],
"placementCount": 3,
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
]
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-117.73179244995117,
-104.28124237060547,
60.68927764892578
],
"alignmentOffset": [
117.542488,
-102.29949,
-224.463489
],
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
},
"bounds": {
"min": [
-110.3111343383789,
-102.29949188232422,
-519.4838256835938
],
"max": [
345.3961181640625,
410.11322021484375,
70.55683898925781
]
},
"geometry": {
"groups": 20,
"triangles": 298817,
"collisionTriangles": 298817,
"collisionChunks": 4
},
"navigation": {
"triangles": 4948,
"vertices": 14844,
"sourceComponents": 805,
"spawnDistances": [
0.009180110009213026,
0.01568599673120942
],
"rawTriangles": 7170,
"report": "recast-report.json"
},
"assets": [
{
"file": "visual.glb",
"bytes": 9908648,
"sha256": "622c0832eb5a3bca85837de418b02be6dc5dad54db3b05e69e740d93c870e319"
},
{
"file": "collision.glb",
"bytes": 2059780,
"sha256": "27a698c59e08744bd7bf6cfe9585f643a28aea45850da85020e3b4bac45ce92f"
},
{
"file": "collision-001.glb",
"bytes": 2443032,
"sha256": "914702646d8a6a657d677bd9f69b1b0aea13f4033952c143ff85d8932127a82f"
},
{
"file": "collision-002.glb",
"bytes": 2377504,
"sha256": "33622cbbc5e82cebee74049a53cd9fbb4e1ca8196322697f751916a3dd9d30ec"
},
{
"file": "collision-003.glb",
"bytes": 916280,
"sha256": "2f405e27efcf4e7b4d41d2286293dcbe700d854edf4d5aa276bf5b0523e9748a"
},
{
"file": "navigation.glb",
"bytes": 238420,
"sha256": "e463a0220842a4793b2150e95f4fd704f575def288813c781792b021afe8d3f0"
}
]
}
@@ -0,0 +1,89 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 652881,
"gridHeight": 849,
"gridWidth": 769,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "blades-edge-arena",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-75.85150146484375,
6.099999904632568,
-80.292236328125
],
[
76.14849853515625,
82.0999984741211,
80.3077621459961
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 3932,
"sourceVertices": 11796,
"triangles": 3932,
"vertices": 11796
},
"input": {
"bounds": [
[
-76.85150146484375,
0,
-84.8922348022461
],
[
76.85150146484375,
82.12013244628906,
84.8922348022461
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 7327,
"vertexCount": 7086
},
"inputChunkChecksums": {
"public/assets/game/arenas/blades-edge-arena/collision.glb": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
},
"inputChunks": [
"public/assets/game/arenas/blades-edge-arena/collision.glb"
],
"output": "public/assets/game/arenas/blades-edge-arena/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium_000.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,92 @@
{
"schemaVersion": 1,
"id": "blades-edge-arena",
"title": "Blade's Edge Arena",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
"groupCount": 1
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-0.32387542724609375,
-3.3863677978515625,
-0.1770477294921875
]
},
"sourceBounds": {
"min": [
-77.17537689208984,
-3.3863677978515625,
-85.06928253173828
],
"max": [
76.52762603759766,
78.7337646484375,
84.7151870727539
]
},
"bounds": {
"min": [
-76.85150146484375,
0,
-84.8922348022461
],
"max": [
76.85150146484375,
82.12013244628906,
84.8922348022461
]
},
"geometry": {
"groups": 1,
"vertices": 7086,
"triangles": 7327,
"collisionTriangles": 7327,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 680,
"36": 616,
"38": 137,
"72": 2729,
"100": 2922,
"102": 243
}
}
]
},
"navigation": {
"triangles": 465,
"vertices": 1395,
"sourceComponents": 134,
"spawnDistances": [
0.0026893801684426334,
0.004853620635976984
],
"rawTriangles": 3932,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 259116,
"sha256": "5dc8c6662bccf04892b059c819de91b1134cce6011a1f707c5ee9645491e5279"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 173784,
"sha256": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 23228,
"sha256": "d99822c69df3f6363c9fab1c6c67c70c3afa4e94ed0e66fa992adf8b6724cf2b"
}
]
}
@@ -0,0 +1,90 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 529759,
"gridHeight": 713,
"gridWidth": 743,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "dalaran-sewers",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-73.60513305664062,
0.30000001192092896,
-69.85077667236328
],
[
73.5948715209961,
79,
70.14923095703125
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 4054,
"sourceVertices": 12162,
"triangles": 4054,
"vertices": 12162
},
"input": {
"bounds": [
[
-74.20513153076172,
0,
-71.25077819824219
],
[
74.20513153076172,
92.40137481689453,
71.25077819824219
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 2,
"nonFiniteCoordinateCount": 0,
"triangleCount": 15469,
"vertexCount": 13094
},
"inputChunkChecksums": {
"public/assets/game/arenas/dalaran-sewers/collision.glb": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
},
"inputChunks": [
"public/assets/game/arenas/dalaran-sewers/collision.glb"
],
"output": "public/assets/game/arenas/dalaran-sewers/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_000.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_001.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,105 @@
{
"schemaVersion": 1,
"id": "dalaran-sewers",
"title": "Dalaran Sewers",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
"groupCount": 2
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-9.411468505859375,
-16.876672744750977,
10.746021270751953
]
},
"sourceBounds": {
"min": [
-83.6166000366211,
-16.876672744750977,
-60.5047607421875
],
"max": [
64.79366302490234,
75.52470397949219,
81.9968032836914
]
},
"bounds": {
"min": [
-74.20513153076172,
0,
-71.25077819824219
],
"max": [
74.20513153076172,
92.40137481689453,
71.25077819824219
]
},
"geometry": {
"groups": 2,
"vertices": 13094,
"triangles": 15469,
"collisionTriangles": 15469,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 85,
"32": 545,
"36": 573,
"72": 40,
"96": 2835,
"100": 98
}
},
{
"group": "group-001",
"counts": {
"8": 165,
"10": 480,
"32": 766,
"36": 2362,
"72": 136,
"74": 416,
"96": 2053,
"100": 4915
}
}
]
},
"navigation": {
"triangles": 458,
"vertices": 1374,
"sourceComponents": 104,
"spawnDistances": [
0.004929398584727517,
0.0049300317713031425
],
"rawTriangles": 4054,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 501848,
"sha256": "91b37f5df3c5c0d2ecad9e1096177abfe8eb36acc384df36a949ce246a0155ba"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 344140,
"sha256": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 22896,
"sha256": "2e1db39fe16bd3d4a747710e1da774d841bc9cd9ada446c666a1d95a4e1aafb2"
}
]
}
Binary file not shown.
@@ -0,0 +1,89 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 605259,
"gridHeight": 783,
"gridWidth": 773,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "nagrand-arena",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-75.41183471679688,
1.2000000476837158,
-74.84174346923828
],
[
75.9881591796875,
72.80000305175781,
65.15826416015625
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 3536,
"sourceVertices": 10608,
"triangles": 3536,
"vertices": 10608
},
"input": {
"bounds": [
[
-77.21183776855469,
0,
-78.24174499511719
],
[
77.21183776855469,
82.11756134033203,
78.24174499511719
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 7727,
"vertexCount": 8197
},
"inputChunkChecksums": {
"public/assets/game/arenas/nagrand-arena/collision.glb": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
},
"inputChunks": [
"public/assets/game/arenas/nagrand-arena/collision.glb"
],
"output": "public/assets/game/arenas/nagrand-arena/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01_000.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,94 @@
{
"schemaVersion": 1,
"id": "nagrand-arena",
"title": "Nagrand Arena",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
"groupCount": 1
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
49.19449806213379,
-10.582855224609375,
30.535707473754883
]
},
"sourceBounds": {
"min": [
-28.017337799072266,
-10.582855224609375,
-47.70603561401367
],
"max": [
126.40633392333984,
71.53470611572266,
108.77745056152344
]
},
"bounds": {
"min": [
-77.21183776855469,
0,
-78.24174499511719
],
"max": [
77.21183776855469,
82.11756134033203,
78.24174499511719
]
},
"geometry": {
"groups": 1,
"vertices": 8197,
"triangles": 7727,
"collisionTriangles": 7727,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 783,
"32": 35,
"36": 831,
"38": 2,
"72": 2230,
"96": 502,
"100": 3291,
"102": 53
}
}
]
},
"navigation": {
"triangles": 1450,
"vertices": 4350,
"sourceComponents": 155,
"spawnDistances": [
0.0025342739142059146,
0.0025336963502397706
],
"rawTriangles": 3536,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 290580,
"sha256": "6730970440c31df26e4fe90a6eb3913e6402f0027a334d966fb2b57c2e7b3e7c"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 191916,
"sha256": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 70512,
"sha256": "cd9027c8d3cf175a8879efad1eb51584f3fbce1bc2eb9355959676471e06798a"
}
]
}
Binary file not shown.
@@ -0,0 +1,89 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 2185272,
"gridHeight": 1208,
"gridWidth": 1809,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "ring-of-valor",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-180.21835327148438,
0.10000000149011612,
-117.37078094482422
],
[
180.181640625,
100.80000305175781,
120.02922058105469
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 4405,
"sourceVertices": 13215,
"triangles": 4405,
"vertices": 13215
},
"input": {
"bounds": [
[
-180.818359375,
0,
-120.77078247070312
],
[
180.818359375,
116.26216125488281,
120.77078247070312
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 9713,
"vertexCount": 12167
},
"inputChunkChecksums": {
"public/assets/game/arenas/ring-of-valor/collision.glb": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
},
"inputChunks": [
"public/assets/game/arenas/ring-of-valor/collision.glb"
],
"output": "public/assets/game/arenas/ring-of-valor/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena_000.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,94 @@
{
"schemaVersion": 1,
"id": "ring-of-valor",
"title": "Ring of Valor",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
"groupCount": 1
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-0.44493865966796875,
-32.01484298706055,
0
]
},
"sourceBounds": {
"min": [
-181.26329040527344,
-32.01484298706055,
-120.77078247070312
],
"max": [
180.3734130859375,
84.24732208251953,
120.77078247070312
]
},
"bounds": {
"min": [
-180.818359375,
0,
-120.77078247070312
],
"max": [
180.818359375,
116.26216125488281,
120.77078247070312
]
},
"geometry": {
"groups": 1,
"vertices": 12167,
"triangles": 9713,
"collisionTriangles": 9713,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 18,
"32": 1063,
"36": 590,
"38": 586,
"72": 126,
"96": 1307,
"100": 5028,
"102": 995
}
}
]
},
"navigation": {
"triangles": 702,
"vertices": 2106,
"sourceComponents": 61,
"spawnDistances": [
1.1157695927091633,
1.258558140579426
],
"rawTriangles": 4405,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 409692,
"sha256": "32d3dc93c8f5026d29f3e9bf2efb299145e8e191e358b201a62dc987e0fa3c46"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 263388,
"sha256": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 34608,
"sha256": "db986ef21767aecf08edb435f11674a094996213685f83cd7d2172886949374d"
}
]
}
Binary file not shown.
@@ -0,0 +1,93 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 1226343,
"gridHeight": 1213,
"gridWidth": 1011,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "ruins-of-lordaeron",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-100.48125457763672,
3.5,
-119.45735168457031
],
[
96.71875,
77.70000457763672,
120.54264831542969
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 7958,
"sourceVertices": 23874,
"triangles": 7958,
"vertices": 23874
},
"input": {
"bounds": [
[
-101.08125305175781,
0,
-121.25735473632812
],
[
101.08125305175781,
77.79415893554688,
121.25735473632812
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 5,
"nonFiniteCoordinateCount": 0,
"triangleCount": 23642,
"vertexCount": 26192
},
"inputChunkChecksums": {
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
},
"inputChunks": [
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb"
],
"output": "public/assets/game/arenas/ruins-of-lordaeron/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_000.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_001.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_002.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_003.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_004.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,138 @@
{
"schemaVersion": 1,
"id": "ruins-of-lordaeron",
"title": "Ruins of Lordaeron",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
"groupCount": 5
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
4.71783447265625,
-31.21632957458496,
15.956310272216797
]
},
"sourceBounds": {
"min": [
-96.36341857910156,
-31.21632957458496,
-105.3010482788086
],
"max": [
105.79908752441406,
46.57782745361328,
137.2136688232422
]
},
"bounds": {
"min": [
-101.08125305175781,
0,
-121.25735473632812
],
"max": [
101.08125305175781,
77.79415893554688,
121.25735473632812
]
},
"geometry": {
"groups": 5,
"vertices": 26192,
"triangles": 23642,
"collisionTriangles": 23642,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 1135,
"36": 3730,
"72": 730,
"100": 10270
}
},
{
"group": "group-001",
"counts": {
"8": 1,
"9": 2,
"32": 106,
"33": 22,
"36": 21,
"37": 14,
"72": 275,
"73": 47,
"96": 225,
"97": 236,
"100": 2550,
"101": 117
}
},
{
"group": "group-002",
"counts": {
"32": 21,
"96": 132
}
},
{
"group": "group-003",
"counts": {
"8": 13,
"9": 2,
"32": 185,
"33": 22,
"36": 53,
"37": 14,
"72": 252,
"73": 56,
"96": 276,
"97": 229,
"100": 2579,
"101": 174
}
},
{
"group": "group-004",
"counts": {
"36": 21,
"100": 132
}
}
]
},
"navigation": {
"triangles": 2085,
"vertices": 6255,
"sourceComponents": 157,
"spawnDistances": [
0.00292914523132256,
0.004205306248988376
],
"rawTriangles": 7958,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 916788,
"sha256": "b00bcd87bd72bbabcf3e7e7edab99e8f7ddbe820b849a40df90510096e9f2f4e"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 601048,
"sha256": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 100996,
"sha256": "be7aa6c4822f0baecafe4eb148a5952d3ce8001c7617f351e7ab257d61e7cb59"
}
]
}
@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"id": "warnorken-arena",
"title": "Warnorken Arena",
"status": "validated",
"source": {
"kind": "validated-runewaker-package",
"navigationSource": "navigation-source.glb"
},
"navigation": {
"sourceComponents": 96,
"triangles": 365,
"vertices": 1095,
"spawnDistances": [
0.00402499431590021,
0.004012153291366225
]
},
"assets": [
{
"file": "visual.glb",
"bytes": 3752136,
"triangles": 55200,
"sha256": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9"
},
{
"file": "collision.glb",
"bytes": 3005760,
"triangles": 60751,
"sha256": "0b1ce7555da0d2900465e3539e1bf17ac244d23a0f5eb57a2106f29570757c5c"
},
{
"file": "navigation.glb",
"bytes": 18436,
"triangles": 365,
"sha256": "c6085ffc96b40b1f437b8bb7e111604c135a856b02fd9b6ccae20243183cf417"
}
]
}
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
const CONTENT_CACHE_NAME = "healer-man-content-v1";
const MANAGED_ASSET_PREFIXES = [
"/assets/characters/",
"/assets/creatures/",
"/assets/equipment/",
"/assets/game/",
"/assets/ui/",
];
const BUNDLED_ASSET_PREFIXES = [
"/assets/ui/",
"/assets/equipment/",
"/assets/characters/wow335a-v1/manifest.json",
"/assets/characters/wow335a-v1/human/",
"/assets/creatures/wailing-caverns/",
"/assets/game/dungeons/wailing-caverns/",
];
const BUNDLED_ASSET_PATHS = new Set([
"/assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
"/assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
]);
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names
.filter((name) => name.startsWith(`${CONTENT_CACHE_NAME}-staging-`))
.map((name) => caches.delete(name)));
await self.clients.claim();
})());
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin
|| !MANAGED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
event.respondWith((async () => {
const cache = await caches.open(CONTENT_CACHE_NAME);
const cached = await cache.match(request, { ignoreSearch: true });
if (cached) return cached;
if (BUNDLED_ASSET_PATHS.has(url.pathname)
|| BUNDLED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
return fetch(request);
}
return new Response("Optional content is not installed.", {
status: 404,
headers: { "content-type": "text/plain; charset=utf-8" },
});
})());
});
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.13",
"versionCode": 1013
"versionName": "0.1.21",
"versionCode": 1021
}
@@ -0,0 +1,322 @@
import { createHash } from "node:crypto";
import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
import { MeshoptDecoder } from "meshoptimizer";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
geometryBounds,
parseRootGroupCount,
parseWmoGroup,
rebaseWmoGroups,
wmoChunks,
writeWmoGeometry,
} from "./build-wmo-arenas.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const extractedRoot = path.resolve(projectRoot, "../LadiksMPQEditor");
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
const sourceLayers = ["patch-WC1", "patch-WB2", "patch-WB1", "patch-2", "patch", "lichking", "common-2.mpq"];
const DEFINITIONS = Object.freeze([
{
slug: "black-rook-hold-arena",
title: "Black Rook Hold Arena",
mapDirectory: "patch-WB1/world/maps/blackrookholdarena",
primaryPattern: /7vs_blackrookhold_arena\.wmo$/i,
alignmentOffset: [117.542488, -102.29949, -224.463489],
legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
gameplayBounds: { min: [-82, -10, -242], max: [312, 30, -110] },
legacyNavigation: "public/assets/game/manastorm/1784-blackrookholdarena/navigation.glb",
teamSpawns: [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
},
{
slug: "ashamanes-fall",
title: "Ashamane's Fall",
mapDirectory: "patch-WB2/world/maps/valsharaharena",
primaryPattern: /custom_valsharah_arena\.wmo$/i,
alignmentOffset: [34.174934, -32.347685, 20.31486],
legacyCollision: "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
gameplayBounds: { min: [14, -4, 2], max: [58, 16, 36] },
teamSpawns: [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]],
},
]);
function strings(buffer) {
return buffer.toString("utf8").split("\0").filter(Boolean);
}
export function parseAdtPlacements(buffer) {
const top = wmoChunks(buffer);
const mwmo = top.get("MWMO");
const mwid = top.get("MWID");
const modf = top.get("MODF");
if (!mwmo || !mwid || !modf) return [];
const names = new Map();
for (let offset = 0; offset < mwmo.length;) {
const end = mwmo.indexOf(0, offset);
if (end < 0) break;
if (end > offset) names.set(offset, mwmo.toString("utf8", offset, end));
offset = end + 1;
}
const offsets = [];
for (let offset = 0; offset + 4 <= mwid.length; offset += 4) offsets.push(mwid.readUInt32LE(offset));
const placements = [];
for (let offset = 0; offset + 64 <= modf.length; offset += 64) {
const nameIndex = modf.readUInt32LE(offset);
placements.push({
path: names.get(offsets[nameIndex]) ?? strings(mwmo)[nameIndex] ?? null,
uniqueId: modf.readUInt32LE(offset + 4),
position: [modf.readFloatLE(offset + 8), modf.readFloatLE(offset + 12), modf.readFloatLE(offset + 16)],
rotation: [modf.readFloatLE(offset + 20), modf.readFloatLE(offset + 24), modf.readFloatLE(offset + 28)],
scale: modf.readUInt16LE(offset + 62) / 1024 || 1,
});
}
return placements.filter((placement) => placement.path);
}
async function exists(file) {
try { await access(file); return true; } catch { return false; }
}
async function resolveWmo(relativePath) {
const normalized = relativePath.replace(/\\/g, "/");
for (const layer of sourceLayers) {
const file = path.join(extractedRoot, layer, ...normalized.split("/"));
if (await exists(file)) return file;
}
return null;
}
function rotate([x, y, z], [xDegrees, yDegrees, zDegrees]) {
const radians = [xDegrees, yDegrees, zDegrees].map((degrees) => degrees * Math.PI / 180);
let [nextX, nextY, nextZ] = [x, y, z];
let cosine = Math.cos(radians[0]); let sine = Math.sin(radians[0]);
[nextY, nextZ] = [nextY * cosine - nextZ * sine, nextY * sine + nextZ * cosine];
cosine = Math.cos(radians[1]); sine = Math.sin(radians[1]);
[nextX, nextZ] = [nextX * cosine + nextZ * sine, -nextX * sine + nextZ * cosine];
cosine = Math.cos(radians[2]); sine = Math.sin(radians[2]);
[nextX, nextY] = [nextX * cosine - nextY * sine, nextX * sine + nextY * cosine];
return [nextX, nextY, nextZ];
}
function placementBase(position) {
return [17066.6666666667 - position[2], 17066.6666666667 - position[0], position[1]];
}
function placeGroup(group, placement, primaryBase) {
const base = placementBase(placement.position);
const positions = new Float32Array(group.positions.length);
const normals = group.normals ? new Float32Array(group.normals.length) : null;
for (let offset = 0; offset < group.positions.length; offset += 3) {
// Undo the direct parser's Three-axis conversion before applying the MODF transform.
const local = [-group.positions[offset], group.positions[offset + 2], group.positions[offset + 1]];
const transformed = rotate(local.map((value) => value * placement.scale), placement.rotation);
positions[offset] = -(base[0] + transformed[0] - primaryBase[0]);
positions[offset + 1] = base[2] + transformed[2] - primaryBase[2];
positions[offset + 2] = base[1] + transformed[1] - primaryBase[1];
if (normals) {
const localNormal = [-group.normals[offset], group.normals[offset + 2], group.normals[offset + 1]];
const transformedNormal = rotate(localNormal, placement.rotation);
normals[offset] = -transformedNormal[0];
normals[offset + 1] = transformedNormal[2];
normals[offset + 2] = transformedNormal[1];
}
}
return { ...group, positions, normals };
}
async function loadPlacementGroups(placement, placementIndex, primaryBase) {
const rootFile = await resolveWmo(placement.path);
if (!rootFile) throw new Error(`Missing placed WMO: ${placement.path}`);
const root = await readFile(rootFile);
const groupCount = parseRootGroupCount(root);
const extension = path.extname(rootFile);
const base = rootFile.slice(0, -extension.length);
const groups = [];
for (let groupIndex = 0; groupIndex < groupCount; groupIndex += 1) {
const groupFile = `${base}_${String(groupIndex).padStart(3, "0")}${extension}`;
let parsed;
try {
parsed = parseWmoGroup(await readFile(groupFile), `placement-${placementIndex}-group-${groupIndex}`);
} catch (error) {
throw new Error(`${placement.path} group ${groupIndex}: ${error.message}`, { cause: error });
}
groups.push(placeGroup(parsed, placement, primaryBase));
}
return { rootFile, groups };
}
function splitCollisionGroups(groups, triangleLimit = 90000) {
const chunks = [];
let current = [];
let currentTriangles = 0;
for (const group of groups) {
let indexOffset = 0;
while (indexOffset < group.collisionIndices.length) {
const availableTriangles = triangleLimit - currentTriangles;
if (availableTriangles <= 0) {
chunks.push(current);
current = [];
currentTriangles = 0;
continue;
}
const takeIndices = Math.min(group.collisionIndices.length - indexOffset, availableTriangles * 3);
current.push({ ...group, collisionIndices: group.collisionIndices.slice(indexOffset, indexOffset + takeIndices) });
currentTriangles += takeIndices / 3;
indexOffset += takeIndices;
}
}
if (current.length) chunks.push(current);
return chunks;
}
async function hash(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
function triangleInsideGameplayBounds(points, bounds) {
const centroid = [0, 1, 2].map((axis) => (points[0][axis] + points[1][axis] + points[2][axis]) / 3);
return centroid[0] >= bounds.min[0] - 6 && centroid[0] <= bounds.max[0] + 6
&& centroid[1] >= bounds.min[1] - 6 && centroid[1] <= bounds.max[1] + 16
&& centroid[2] >= bounds.min[2] - 6 && centroid[2] <= bounds.max[2] + 6;
}
async function croppedNavigationGroup(legacyCollisionFile, groups, bounds) {
await MeshoptDecoder.ready;
const bytes = await readFile(legacyCollisionFile);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const scene = await new Promise((resolve, reject) => new GLTFLoader()
.setMeshoptDecoder(MeshoptDecoder)
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
scene.updateMatrixWorld(true);
const output = [];
const a = new Vector3();
const b = new Vector3();
const c = new Vector3();
scene.traverse((object) => {
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
const positions = object.geometry.getAttribute("position");
const indices = object.geometry.getIndex();
const count = indices?.count ?? positions.count;
const vertexIndex = (element) => indices ? indices.getX(element) : element;
for (let element = 0; element < count; element += 3) {
const points = [a, b, c].map((point, corner) => point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray());
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
}
});
for (const group of groups) {
for (let offset = 0; offset < group.collisionIndices.length; offset += 3) {
const points = [0, 1, 2].map((corner) => {
const index = group.collisionIndices[offset + corner] * 3;
return [group.positions[index], group.positions[index + 1], group.positions[index + 2]];
});
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
}
}
const positions = new Float32Array(output);
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
return { name: "rated-gameplay-collision", positions, normals: null, indices, collisionIndices: indices, flagCounts: {} };
}
async function build(definition) {
const mapRoot = path.join(extractedRoot, ...definition.mapDirectory.split("/"));
const adtFiles = (await readdir(mapRoot)).filter((name) => name.toLowerCase().endsWith(".adt")).sort();
const placementsById = new Map();
for (const adt of adtFiles) {
for (const placement of parseAdtPlacements(await readFile(path.join(mapRoot, adt)))) placementsById.set(placement.uniqueId, placement);
}
const placements = [...placementsById.values()];
const primary = placements.find((placement) => definition.primaryPattern.test(placement.path));
if (!primary) throw new Error(`${definition.slug}: primary arena WMO placement was not found.`);
const primaryBase = placementBase(primary.position);
const groups = [];
const sources = [];
for (const [index, placement] of placements.entries()) {
const loaded = await loadPlacementGroups(placement, index, primaryBase);
groups.push(...loaded.groups);
sources.push(path.relative(projectRoot, loaded.rootFile).replace(/\\/g, "/"));
}
const transform = rebaseWmoGroups(groups);
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
group.positions[offset] += definition.alignmentOffset[0];
group.positions[offset + 1] += definition.alignmentOffset[1];
group.positions[offset + 2] += definition.alignmentOffset[2];
}
}
const alignedBounds = geometryBounds(groups);
const outputDirectory = path.join(outputRoot, definition.slug);
await mkdir(outputDirectory, { recursive: true });
const visualFile = path.join(outputDirectory, "visual.glb");
await writeWmoGeometry(visualFile, groups, true);
const collisionChunks = splitCollisionGroups(groups);
const collisionFiles = [];
for (const [index, chunk] of collisionChunks.entries()) {
const file = path.join(outputDirectory, index === 0 ? "collision.glb" : `collision-${String(index).padStart(3, "0")}.glb`);
await writeWmoGeometry(file, chunk, false);
collisionFiles.push(file);
}
const navigationFile = path.join(outputDirectory, "navigation.glb");
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
const legacyCollisionFile = path.join(projectRoot, ...definition.legacyCollision.split("/"));
const navigationCollisionFile = path.join(outputDirectory, "navigation-collision.glb");
const navigationGroup = await croppedNavigationGroup(legacyCollisionFile, groups, definition.gameplayBounds);
await writeWmoGeometry(navigationCollisionFile, [navigationGroup], false);
const navigation = await bakeNavigationFromCollisionAssets({
slug: definition.slug,
collisionAssets: [{ id: "rated-gameplay-collision", role: "collision", path: path.relative(projectRoot, navigationCollisionFile).replace(/\\/g, "/") }],
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
output: rawNavigationFile,
reportFile: path.join(outputDirectory, "recast-report.json"),
source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds },
});
const selectionSource = definition.legacyNavigation
? path.join(projectRoot, ...definition.legacyNavigation.split("/"))
: rawNavigationFile;
const connectedNavigation = await extractNavigationComponent({
source: selectionSource,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
await Promise.all([unlink(rawNavigationFile), unlink(navigationCollisionFile)]);
const files = [visualFile, ...collisionFiles, navigationFile];
const report = {
schemaVersion: 1,
id: definition.slug,
title: definition.title,
status: "generated-review-required",
source: { mapDirectory: definition.mapDirectory, adtFiles, placementCount: placements.length, placedWmos: sources },
coordinateSystem: {
conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
rebasedOrigin: transform.origin,
alignmentOffset: definition.alignmentOffset,
alignment: "primary WMO matched to the existing HealerMan stage coordinate frame",
},
bounds: alignedBounds,
geometry: {
groups: groups.length,
triangles: groups.reduce((sum, group) => sum + group.indices.length / 3, 0),
collisionTriangles: groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0),
collisionChunks: collisionFiles.length,
},
navigation: {
triangles: connectedNavigation.triangles,
vertices: connectedNavigation.vertices,
sourceComponents: connectedNavigation.sourceComponents,
spawnDistances: connectedNavigation.spawnDistances,
rawTriangles: navigation.geometry.triangles,
report: "recast-report.json",
},
assets: await Promise.all(files.map(async (file) => ({ file: path.basename(file), bytes: (await readFile(file)).length, sha256: await hash(file) }))),
};
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(`${definition.slug}: ${placements.length} placed WMOs, ${report.geometry.triangles} triangles, ${report.navigation.triangles} nav triangles`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const requested = new Set(process.argv.slice(2));
for (const definition of DEFINITIONS.filter((entry) => !requested.size || requested.has(entry.slug))) await build(definition);
}
+249
View File
@@ -0,0 +1,249 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const sourceRoot = path.resolve(projectRoot, "../LadiksMPQEditor/common-2.mpq");
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
const ARENAS = Object.freeze([
{ slug: "nagrand-arena", title: "Nagrand Arena", root: "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", teamSpawns: [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]] },
{ slug: "blades-edge-arena", title: "Blade's Edge Arena", root: "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", teamSpawns: [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]] },
{ slug: "ruins-of-lordaeron", title: "Ruins of Lordaeron", root: "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", teamSpawns: [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]] },
{ slug: "dalaran-sewers", title: "Dalaran Sewers", root: "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", teamSpawns: [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]] },
{ slug: "ring-of-valor", title: "Ring of Valor", root: "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", teamSpawns: [[0, 27, 25], [0, 27, -25]] },
]);
function tag(buffer, offset) {
return buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
}
export function wmoChunks(buffer, start = 0, end = buffer.length) {
const result = new Map();
let offset = start;
while (offset + 8 <= end) {
const id = tag(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const payloadStart = offset + 8;
let payloadEnd = payloadStart + size;
// Some Legion-era custom group files report the original uncompressed MOGP
// container length even though trailing optional chunks were stripped.
// MOGP is the terminal top-level container, so the physical EOF is safe.
if (id === "MOGP" && payloadEnd > end) payloadEnd = end;
if (payloadEnd > end) throw new Error(`Invalid ${id} chunk at ${offset}: ${size} bytes exceeds the WMO boundary.`);
result.set(id, buffer.subarray(payloadStart, payloadEnd));
offset = payloadEnd;
}
return result;
}
export function parseRootGroupCount(buffer) {
const root = wmoChunks(buffer);
const header = root.get("MOHD");
if (!header || header.length < 8) throw new Error("WMO root is missing MOHD.");
return header.readUInt32LE(4);
}
export function parseWmoGroup(buffer, name) {
const top = wmoChunks(buffer);
const group = top.get("MOGP");
if (!group || group.length <= 68) throw new Error(`${name}: missing MOGP geometry.`);
const nested = wmoChunks(group, 68, group.length);
const vertexBytes = nested.get("MOVT");
const normalBytes = nested.get("MONR");
const indexBytes = nested.get("MOVI");
const materialBytes = nested.get("MOPY");
if (!vertexBytes || !indexBytes) throw new Error(`${name}: missing MOVT or MOVI.`);
const positions = new Float32Array(vertexBytes.length / 4);
for (let offset = 0; offset < vertexBytes.length; offset += 4) positions[offset / 4] = vertexBytes.readFloatLE(offset);
const sourceNormals = normalBytes ? new Float32Array(normalBytes.length / 4) : null;
if (normalBytes) for (let offset = 0; offset < normalBytes.length; offset += 4) sourceNormals[offset / 4] = normalBytes.readFloatLE(offset);
const indices = new Uint32Array(indexBytes.length / 2);
for (let offset = 0; offset < indexBytes.length; offset += 2) indices[offset / 2] = indexBytes.readUInt16LE(offset);
const transformed = new Float32Array(positions.length);
const normals = sourceNormals?.length === positions.length ? new Float32Array(positions.length) : null;
for (let index = 0; index < positions.length; index += 3) {
transformed[index] = -positions[index];
transformed[index + 1] = positions[index + 2];
transformed[index + 2] = positions[index + 1];
if (normals) {
normals[index] = -sourceNormals[index];
normals[index + 1] = sourceNormals[index + 2];
normals[index + 2] = sourceNormals[index + 1];
}
}
const flagCounts = {};
const collisionIndexValues = [];
if (materialBytes) {
for (let offset = 0; offset < materialBytes.length; offset += 2) {
const flag = materialBytes[offset];
flagCounts[flag] = (flagCounts[flag] ?? 0) + 1;
if (flag & 0x04) {
const triangleOffset = (offset / 2) * 3;
collisionIndexValues.push(indices[triangleOffset], indices[triangleOffset + 1], indices[triangleOffset + 2]);
}
}
}
return {
name, positions: transformed, normals, indices, flagCounts,
// Retain every structural WMO triangle for authoritative collision. MOPY
// bit meanings vary by client generation and several arena floors use flag
// combinations that would otherwise be omitted; Recast filters slopes.
collisionIndices: indices,
flaggedCollisionIndices: materialBytes ? new Uint32Array(collisionIndexValues) : indices,
};
}
export function geometryBounds(groups) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], group.positions[offset + axis]);
max[axis] = Math.max(max[axis], group.positions[offset + axis]);
}
}
}
return { min, max };
}
export function rebaseWmoGroups(groups) {
const sourceBounds = geometryBounds(groups);
const center = [
(sourceBounds.min[0] + sourceBounds.max[0]) / 2,
sourceBounds.min[1],
(sourceBounds.min[2] + sourceBounds.max[2]) / 2,
];
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
group.positions[offset] -= center[0];
group.positions[offset + 1] -= center[1];
group.positions[offset + 2] -= center[2];
}
}
return { sourceBounds, origin: center, bounds: geometryBounds(groups) };
}
export async function writeWmoGeometry(file, groups, visual) {
const document = new Document();
const buffer = document.createBuffer("arena-buffer");
const scene = document.createScene("arena");
const colors = [
[0.37, 0.32, 0.22, 1], [0.29, 0.34, 0.3, 1], [0.42, 0.4, 0.34, 1],
[0.31, 0.27, 0.24, 1], [0.34, 0.37, 0.42, 1],
];
for (const [index, group] of groups.entries()) {
const groupIndices = visual ? group.indices : group.collisionIndices;
if (!groupIndices.length) continue;
const position = document.createAccessor(`${group.name}-position`, buffer).setType("VEC3").setArray(group.positions);
const indices = document.createAccessor(`${group.name}-indices`, buffer).setType("SCALAR").setArray(groupIndices);
const primitive = document.createPrimitive().setAttribute("POSITION", position).setIndices(indices);
if (visual && group.normals) {
primitive.setAttribute("NORMAL", document.createAccessor(`${group.name}-normal`, buffer).setType("VEC3").setArray(group.normals));
}
if (visual) {
primitive.setMaterial(document.createMaterial(`${group.name}-stone`)
.setBaseColorFactor(colors[index % colors.length])
.setRoughnessFactor(0.84)
.setMetallicFactor(0.02)
.setDoubleSided(true));
}
const mesh = document.createMesh(group.name).addPrimitive(primitive);
scene.addChild(document.createNode(group.name).setMesh(mesh));
}
await new NodeIO().write(file, document);
}
async function sha256(file) {
const buffer = await readFile(file);
return createHash("sha256").update(buffer).digest("hex");
}
async function buildArena(definition) {
const rootFile = path.join(sourceRoot, ...definition.root.split("/"));
const root = await readFile(rootFile);
const groupCount = parseRootGroupCount(root);
const extension = path.extname(rootFile);
const base = rootFile.slice(0, -extension.length);
const groups = [];
const sourceFiles = [rootFile];
for (let index = 0; index < groupCount; index += 1) {
const groupFile = `${base}_${String(index).padStart(3, "0")}${extension}`;
const data = await readFile(groupFile);
sourceFiles.push(groupFile);
groups.push(parseWmoGroup(data, `group-${String(index).padStart(3, "0")}`));
}
const transform = rebaseWmoGroups(groups);
const outputDirectory = path.join(outputRoot, definition.slug);
await mkdir(outputDirectory, { recursive: true });
const visualFile = path.join(outputDirectory, "visual.glb");
const collisionFile = path.join(outputDirectory, "collision.glb");
const navigationFile = path.join(outputDirectory, "navigation.glb");
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
const recastReport = path.join(outputDirectory, "recast-report.json");
await writeWmoGeometry(visualFile, groups, true);
await writeWmoGeometry(collisionFile, groups, false);
const triangles = groups.reduce((sum, group) => sum + group.indices.length / 3, 0);
const collisionTriangles = groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0);
if (collisionTriangles > 100000) throw new Error(`${definition.slug}: ${collisionTriangles} collision triangles exceed the rated limit.`);
const relativeCollision = path.relative(projectRoot, collisionFile).replace(/\\/g, "/");
const navigation = await bakeNavigationFromCollisionAssets({
slug: definition.slug,
collisionAssets: [{ id: "collision", role: "collision", path: relativeCollision }],
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
output: rawNavigationFile,
reportFile: recastReport,
source: { kind: "direct-wmo-parser", files: sourceFiles.map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")) },
});
const connectedNavigation = await extractNavigationComponent({
source: rawNavigationFile,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
await unlink(rawNavigationFile);
const assets = await Promise.all([
["visual", visualFile], ["collision", collisionFile], ["navigation", navigationFile],
].map(async ([role, file]) => ({ role, file: path.basename(file), bytes: (await readFile(file)).length, sha256: await sha256(file) })));
const report = {
schemaVersion: 1,
id: definition.slug,
title: definition.title,
status: "generated-review-required",
source: { root: path.relative(projectRoot, rootFile).replace(/\\/g, "/"), groupCount },
coordinateSystem: { conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)", rebasedOrigin: transform.origin },
sourceBounds: transform.sourceBounds,
bounds: transform.bounds,
geometry: {
groups: groups.length,
vertices: groups.reduce((sum, group) => sum + group.positions.length / 3, 0),
triangles,
collisionTriangles,
triangleFlags: groups.map((group) => ({ group: group.name, counts: group.flagCounts })),
},
navigation: {
triangles: connectedNavigation.triangles,
vertices: connectedNavigation.vertices,
sourceComponents: connectedNavigation.sourceComponents,
spawnDistances: connectedNavigation.spawnDistances,
rawTriangles: navigation.geometry.triangles,
report: "recast-report.json",
},
assets,
};
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const requested = new Set(process.argv.slice(2));
const selected = requested.size ? ARENAS.filter((arena) => requested.has(arena.slug)) : ARENAS;
if (!selected.length) throw new Error(`No matching arena. Choose: ${ARENAS.map((arena) => arena.slug).join(", ")}`);
for (const arena of selected) {
const report = await buildArena(arena);
console.log(`${arena.slug}: ${report.geometry.triangles} triangles, ${report.navigation.triangles} navigation triangles`);
}
}
@@ -0,0 +1,101 @@
import { readFile } from "node:fs/promises";
import { MeshoptDecoder } from "meshoptimizer";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { writeWmoGeometry } from "./build-wmo-arenas.mjs";
globalThis.self = globalThis;
globalThis.ProgressEvent ??= class ProgressEvent {
constructor(type, values = {}) { this.type = type; Object.assign(this, values); }
};
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) {
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
return this.parent[value];
}
union(left, right) {
const a = this.find(left);
const b = this.find(right);
if (a !== b) this.parent[b] = a;
}
}
function key(point) {
return point.map((value) => Math.round(value * 1000)).join(":");
}
async function loadTriangles(file) {
await MeshoptDecoder.ready;
const bytes = await readFile(file);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const scene = await new Promise((resolve, reject) => new GLTFLoader()
.setMeshoptDecoder(MeshoptDecoder)
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
scene.updateMatrixWorld(true);
const triangles = [];
const points = [new Vector3(), new Vector3(), new Vector3()];
scene.traverse((object) => {
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
const positions = object.geometry.getAttribute("position");
const indices = object.geometry.getIndex();
const count = indices?.count ?? positions.count;
const vertexIndex = (element) => indices ? indices.getX(element) : element;
for (let element = 0; element < count; element += 3) {
triangles.push(points.map((point, corner) => [
...point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray(),
]));
}
});
return triangles;
}
function distanceSquared(point, target) {
return (point[0] - target[0]) ** 2 + (point[1] - target[1]) ** 2 + (point[2] - target[2]) ** 2;
}
export async function extractNavigationComponent({ source, output, teamSpawns }) {
const triangles = await loadTriangles(source);
const union = new UnionFind(triangles.length);
const ownerByVertex = new Map();
for (const [triangleIndex, triangle] of triangles.entries()) {
for (const point of triangle) {
const positionKey = key(point);
const owner = ownerByVertex.get(positionKey);
if (owner == null) ownerByVertex.set(positionKey, triangleIndex);
else union.union(owner, triangleIndex);
}
}
const components = new Map();
for (let index = 0; index < triangles.length; index += 1) {
const root = union.find(index);
const component = components.get(root) ?? [];
component.push(triangles[index]);
components.set(root, component);
}
const scored = [...components.values()].map((component) => {
const vertices = component.flat();
const spawnDistances = teamSpawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => distanceSquared(point, spawn)))));
return { component, spawnDistances, score: Math.max(...spawnDistances) };
}).sort((left, right) => left.score - right.score || right.component.length - left.component.length);
const selected = scored[0];
if (!selected?.component.length) throw new Error(`${source}: no navigation component could be selected.`);
const values = selected.component.flat(2);
const positions = new Float32Array(values);
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
await writeWmoGeometry(output, [{
name: "rated-connected-navigation",
positions,
normals: null,
indices,
collisionIndices: indices,
flagCounts: {},
}], false);
return {
sourceComponents: components.size,
triangles: selected.component.length,
vertices: positions.length / 3,
spawnDistances: selected.spawnDistances,
};
}
@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
const io = new NodeIO();
const requested = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
const detail = process.argv.includes("--all");
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) { if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]); return this.parent[value]; }
union(left, right) { const a = this.find(left); const b = this.find(right); if (a !== b) this.parent[b] = a; }
}
function round(values) { return values.map((value) => Math.round(value * 100) / 100); }
for (const stage of catalog.stages) {
if (requested.size && !requested.has(stage.id)) continue;
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
let document;
try { document = await io.read(path.join(base, "navigation.glb")); } catch { continue; }
const vertices = [];
const triangles = [];
const byPosition = new Map();
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION")?.getArray();
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = [];
for (let corner = 0; corner < 3; corner += 1) {
const index = indices[offset + corner];
const vertex = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
const key = vertex.map((value) => Math.round(value * 1000)).join(":");
if (!byPosition.has(key)) {
byPosition.set(key, vertices.length);
vertices.push(vertex);
}
triangle.push(byPosition.get(key));
}
triangles.push(triangle);
}
}
}
const union = new UnionFind(vertices.length);
for (const triangle of triangles) {
union.union(triangle[0], triangle[1]);
union.union(triangle[0], triangle[2]);
}
const grouped = new Map();
for (let index = 0; index < vertices.length; index += 1) {
const root = union.find(index);
const component = grouped.get(root) ?? [];
component.push(vertices[index]);
grouped.set(root, component);
}
const components = [...grouped.values()];
components.sort((left, right) => right.length - left.length);
const main = components[0] ?? [];
const min = [0, 1, 2].map((axis) => Math.min(...main.map((vertex) => vertex[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...main.map((vertex) => vertex[axis])));
const center = min.map((value, axis) => (value + max[axis]) / 2);
const axis = max[0] - min[0] >= max[2] - min[2] ? 0 : 2;
const sorted = [...main].sort((left, right) => left[axis] - right[axis]);
const inset = Math.max(1, Math.floor(sorted.length * 0.08));
const teamSpawns = [sorted[inset] ?? sorted[0], sorted[sorted.length - inset - 1] ?? sorted.at(-1)];
console.log(JSON.stringify({
id: stage.id,
componentCount: components.length,
mainVertices: main.length,
nextVertices: components[1]?.length ?? 0,
mainBounds: { min: round(min), max: round(max) },
center: round(center),
suggestedTeamSpawns: teamSpawns.map(round),
topComponents: components.slice(0, detail ? components.length : 8).map((component) => ({
vertices: component.length,
min: round([0, 1, 2].map((componentAxis) => Math.min(...component.map((vertex) => vertex[componentAxis])))),
max: round([0, 1, 2].map((componentAxis) => Math.max(...component.map((vertex) => vertex[componentAxis])))),
})),
}));
}
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const directory = path.join(projectRoot, "public/assets/game/arenas/warnorken-arena");
const source = path.join(directory, "navigation-source.glb");
const output = path.join(directory, "navigation.glb");
const teamSpawns = [[-42.7, -21.75, 14.12], [41.3, -21.65, -6.58]];
const connected = await extractNavigationComponent({ source, output, teamSpawns });
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const assets = [];
for (const name of ["visual", "collision", "navigation"]) {
const file = path.join(directory, `${name}.glb`);
const bytes = await readFile(file);
const document = await io.read(file);
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
assets.push({ file: `${name}.glb`, bytes: (await stat(file)).size, triangles: Math.round(triangles), sha256: createHash("sha256").update(bytes).digest("hex") });
}
await writeFile(path.join(directory, "stage-pack.json"), `${JSON.stringify({
schemaVersion: 1,
id: "warnorken-arena",
title: "Warnorken Arena",
status: "validated",
source: { kind: "validated-runewaker-package", navigationSource: "navigation-source.glb" },
navigation: connected,
assets,
}, null, 2)}\n`);
console.log(`warnorken-arena: ${connected.triangles} connected navigation triangles`);
+118
View File
@@ -0,0 +1,118 @@
import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
const failures = [];
const reports = [];
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) {
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
return this.parent[value];
}
union(left, right) {
const a = this.find(left);
const b = this.find(right);
if (a !== b) this.parent[b] = a;
}
}
function inspectConnectedNavigation(document, spawns) {
const vertices = [];
const triangles = [];
const byPosition = new Map();
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION")?.getArray();
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = [];
for (let corner = 0; corner < 3; corner += 1) {
const index = indices[offset + corner];
const point = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
const key = point.map((value) => Math.round(value * 1000)).join(":");
if (!byPosition.has(key)) {
byPosition.set(key, vertices.length);
vertices.push(point);
}
triangle.push(byPosition.get(key));
}
triangles.push(triangle);
}
}
}
const union = new UnionFind(vertices.length);
for (const triangle of triangles) {
union.union(triangle[0], triangle[1]);
union.union(triangle[0], triangle[2]);
}
const components = new Set(vertices.map((_, index) => union.find(index)));
const spawnDistances = spawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => (
(point[0] - spawn[0]) ** 2 + (point[1] - spawn[1]) ** 2 + (point[2] - spawn[2]) ** 2
)))));
return { components: components.size, vertices: vertices.length, triangles: triangles.length, spawnDistances };
}
function validateVec3(value, label, stage) {
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) failures.push(`${stage.id}: invalid ${label}.`);
}
for (const stage of catalog.stages) {
validateVec3(stage.bounds?.min, "minimum bounds", stage);
validateVec3(stage.bounds?.max, "maximum bounds", stage);
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) validateVec3(spawn, `team spawn ${index}`, stage);
if (stage.teamSpawns?.length !== 2) failures.push(`${stage.id}: two team spawns are required.`);
if (stage.shadowSight?.length !== 2) failures.push(`${stage.id}: two Shadow Sight locations are required.`);
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) {
if (spawn.some((value, axis) => value < stage.bounds.min[axis] || value > stage.bounds.max[axis])) failures.push(`${stage.id}: team spawn ${index} is outside the sealed gameplay bounds.`);
}
for (const object of stage.lineOfSight ?? []) {
validateVec3(object.position, `${object.id} LOS position`, stage);
validateVec3(object.size, `${object.id} LOS size`, stage);
if (object.position[0] < stage.bounds.min[0] || object.position[0] > stage.bounds.max[0]
|| object.position[2] < stage.bounds.min[2] || object.position[2] > stage.bounds.max[2]) failures.push(`${stage.id}: ${object.id} LOS geometry is outside the gameplay bounds.`);
}
const dynamicIds = new Set((stage.dynamicObjects ?? []).map((object) => object.id));
for (const object of stage.lineOfSight ?? []) {
if (object.dynamicObjectId && !dynamicIds.has(object.dynamicObjectId)) failures.push(`${stage.id}: ${object.id} references a missing dynamic object.`);
}
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
const assets = {};
for (const name of ["visual", "collision", "navigation"]) {
const file = path.join(base, `${name}.glb`);
try {
await access(file);
const info = await stat(file);
const document = await io.read(file);
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
assets[name] = { bytes: info.size, triangles: Math.round(triangles) };
if (name === "navigation") {
const navigation = inspectConnectedNavigation(document, stage.teamSpawns);
assets[name].components = navigation.components;
assets[name].spawnDistances = navigation.spawnDistances.map((distance) => Math.round(distance * 1000) / 1000);
if (navigation.components !== 1) failures.push(`${stage.id}: rated navigation must contain one connected gameplay component.`);
for (const [index, distance] of navigation.spawnDistances.entries()) {
if (distance > 3) failures.push(`${stage.id}: team spawn ${index} is ${distance.toFixed(2)}m from the navigation surface.`);
}
}
if (!triangles) failures.push(`${stage.id}: ${name}.glb has no triangles.`);
} catch {
if (stage.validation.status === "validated") failures.push(`${stage.id}: required ${name}.glb is missing or unreadable.`);
}
}
if (assets.visual?.bytes > 25 * 1024 * 1024) failures.push(`${stage.id}: visual asset exceeds 25 MiB.`);
if (stage.validation.status === "validated" && ["traversal", "collision", "spawnSafety", "lineOfSight", "performance"].some((key) => stage.validation[key] !== true)) {
failures.push(`${stage.id}: validated stages must carry every validation flag.`);
}
reports.push({ id: stage.id, validation: stage.validation, assets });
}
console.log(JSON.stringify({ schemaVersion: 1, arenaCount: reports.length, failures, stages: reports }, null, 2));
if (failures.length) process.exitCode = 1;
@@ -1,6 +1,7 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
@@ -12,12 +13,29 @@ if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirect
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
+75
View File
@@ -0,0 +1,75 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
await copyFile(
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
path.join(outputDirectory, "content-cache-sw.js"),
);
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
+58 -7
View File
@@ -18,14 +18,35 @@ export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
const RUNTIME_EXTENSIONS = new Set([
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png",
".svg", ".wav", ".webm", ".webp",
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
".svg", ".wasm", ".wav", ".webm", ".webp",
]);
export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([
"assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
]);
const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS);
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
{
directory: path.join(PROJECT_ROOT, "public", "assets"),
@@ -49,18 +70,34 @@ function packLabel(packId) {
const [category, id = category] = packId.split(":", 2);
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
if (category === "dungeon") return name;
if (category === "characters") return `${name} Characters`;
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
if (category === "characters") return `${name} Character Models & Skins`;
if (category === "manastorm") return `${name} Manastorm`;
if (packId === "shared:creatures") return "Shared Creatures";
if (packId === "core:ui") return "Interface";
if (packId === "core:equipment") return "Starter Equipment";
if (packId === "core:character-basic") return "Basic Human Character Model";
if (packId === "core:runtime") return "Game Runtime";
if (packId === "core:arenas") return "Rated Arenas";
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
return name;
}
export function contentPackForPath(logicalPath) {
const segments = normalizedRelativePath(logicalPath).split("/");
const normalizedPath = normalizedRelativePath(logicalPath);
const segments = normalizedPath.split("/");
if (segments[0] === "basis" || segments[0] === "data") return "core:runtime";
if (segments[0] !== "assets") return "core:misc";
if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`;
if (segments[1] === "characters" && segments[2]) {
const basicWrathHuman = segments[2] === "wow335a-v1" && (
segments[3] === "manifest.json"
|| segments[3] === "human"
|| BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath)
);
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
}
if (segments[1] === "creatures" && segments[2]) {
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
}
@@ -70,15 +107,21 @@ export function contentPackForPath(logicalPath) {
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
return `dungeon:${segments[3]}`;
}
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
return `manastorm:${segments[3]}`;
}
if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media";
if (segments[1] === "ui") return "core:ui";
if (segments[1] === "equipment") return "core:equipment";
return "core:misc";
}
async function walkFiles(directory) {
const rootStats = await stat(directory);
if (rootStats.isFile()) {
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
}
const files = [];
async function visit(current) {
const entries = await readdir(current, { withFileTypes: true });
@@ -111,7 +154,9 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
const files = await walkFiles(sourceDirectory);
for (const sourcePath of files) {
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`);
const logicalPath = normalizedRelativePath(relative
? `${source.logicalPrefix}/${relative}`
: source.logicalPrefix);
const fileStats = await stat(sourcePath);
const sha256 = await sha256File(sourcePath);
const prior = discovered.get(logicalPath);
@@ -170,11 +215,17 @@ export function createContentManifest(files, options = {}) {
if (packs.has("shared:creatures")) {
for (const pack of packs.values()) {
if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) {
if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns")
|| pack.id.startsWith("manastorm:")) {
pack.dependencies = ["shared:creatures"];
}
}
}
if (packs.has("core:character-basic")) {
for (const pack of packs.values()) {
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
}
}
return {
schemaVersion: CONTENT_SCHEMA_VERSION,
+15 -1
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
BUNDLED_ANDROID_PACKS,
contentPackForPath,
createContentManifest,
publishContent,
@@ -13,8 +14,21 @@ import {
test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
assert.equal(
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
"core:combat-media",
);
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:combat-media"), false);
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
});
test("publishes objects before a deterministic latest manifest", async () => {
@@ -116,10 +116,12 @@ function reportMarkdown(report) {
dungeon.proceduralModels, "|", dungeon.status, "|",
].join(" "));
return [
"# WoW dungeon content report",
"# WoW dungeon combat and animation report",
"",
"Status: **" + report.status + "**",
"",
"Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.",
"",
"Coverage: " + report.summary.dungeons + " dungeons, " + report.summary.mobs
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
@@ -177,6 +179,10 @@ for (const dungeon of definitions) {
let specialAttacks = 0;
let nativeModels = 0;
let proceduralModels = 0;
const optionalBossEntityIds = new Set((dungeon.objectiveOrder ?? [])
.filter((objective) => objective.optional)
.map((objective) => dungeon.staticSpawns?.find((spawn) => spawn.id === objective.id)?.entityId)
.filter(Boolean));
if (!entities.length) dungeonIssues.push("has no combat entities.");
if (!bosses.length) dungeonIssues.push("has no bosses.");
@@ -203,7 +209,7 @@ for (const dungeon of definitions) {
attacks += entityAttacks.length;
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
specialAttacks += specials.length;
if (entity.kind === "boss" && !specials.length) {
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
const exceptionKey = dungeon.id + ":" + entityId;
if (!basicOnlyBossExceptions.has(exceptionKey)) {
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
@@ -297,7 +303,7 @@ const summary = dungeons.reduce((result, dungeon) => ({
const report = {
schemaVersion: 1,
status: issues.length ? "blocked" : "green",
scope: "HealerMan WoW five-player campaign",
scope: "HealerMan WoW five-player combat and animation coverage",
summary,
documentedExceptions,
animationFallbacks,
@@ -317,4 +323,4 @@ console.log(JSON.stringify({
documentedExceptions: report.documentedExceptions,
issues: report.issues,
}, null, 2));
if (issues.length) process.exitCode = 1;
if (issues.length) process.exitCode = 1;
@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
"generated",
"azerothCoreReference.json",
);
const azerothCoreWorldAiFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreWorldAi.json",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
const epochCatalogFile = path.join(
@@ -533,8 +541,206 @@ function fallbackSpecial(id, boss) {
};
}
function combatFor(entity, boss, spells, scriptCatalog = undefined) {
function validateBossMechanicsCatalog(catalog) {
if (catalog.schemaVersion !== 1) {
throw new Error(`Unsupported boss mechanic schema ${catalog.schemaVersion}.`);
}
const deliveries = new Set(["melee", "projectile", "area"]);
const targets = new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]);
const schools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const animations = new Set(["attack", "cast"]);
for (const [entry, boss] of Object.entries(catalog.bosses ?? {})) {
if (!/^\d+$/.test(entry) && !/^rw-[a-z0-9-]+-\d+$/.test(entry)) {
throw new Error(`Boss mechanic key ${entry} is not a creature entry or RuneWaker entity id.`);
}
if (!["append", "replace"].includes(boss.mode)) {
throw new Error(`${entry}: mechanic mode must be append or replace.`);
}
if (!Array.isArray(boss.mechanics)) throw new Error(`${entry}: mechanics must be an array.`);
const ids = new Set();
for (const mechanic of boss.mechanics) {
if (!mechanic.id || ids.has(mechanic.id)) {
throw new Error(`${entry}: mechanic ids must be non-empty and unique (${mechanic.id}).`);
}
ids.add(mechanic.id);
if (!mechanic.name || !deliveries.has(mechanic.delivery) || !targets.has(mechanic.target)
|| !schools.has(mechanic.school) || !animations.has(mechanic.animation)
|| !Number.isFinite(mechanic.cooldownMs) || mechanic.cooldownMs <= 0
|| !Number.isFinite(mechanic.range) || mechanic.range < 0) {
throw new Error(`${entry}:${mechanic.id}: mechanic fields are incomplete or invalid.`);
}
if (mechanic.telegraph && !["circle", "cone", "line"].includes(mechanic.telegraph.shape)) {
throw new Error(`${entry}:${mechanic.id}: unsupported telegraph shape.`);
}
}
}
}
function smartAiEventProperties(row) {
if (row.event_type === 0) {
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
return {
initialCooldownMs: initial,
repeatCooldownMs: repeat,
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
};
}
if (row.event_type === 2) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
condition: {
kind: "self-health-percent",
minimum: Math.max(0, Number(row.event_param1) || 0),
maximum: Math.min(100, Number(row.event_param2) || 100),
},
};
}
if (row.event_type === 4) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
};
}
if (row.event_type === 14) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
cooldownMs: 1_000,
condition: {
kind: "friendly-missing-health",
amount: Math.max(1, Number(row.event_param1) || 1),
range: Math.max(1, Number(row.event_param2) || 30),
},
};
}
return null;
}
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!boss) return [];
const entry = Number(entity.entry);
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
const mechanics = [];
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
const event = smartAiEventProperties(trigger);
if (!event) continue;
const chain = [];
let row = trigger;
const visited = new Set();
while (row && !visited.has(Number(row.id))) {
visited.add(Number(row.id));
chain.push(row);
row = Number(row.link) ? rowsById.get(Number(row.link)) : undefined;
}
for (const action of chain) {
const actionType = Number(action.action_type);
const source = {
entry,
sourceType: Number(action.source_type) || 0,
rowId: Number(action.id) || 0,
eventType: Number(trigger.event_type) || 0,
actionType,
targetType: Number(action.target_type) || 0,
comment: String(action.comment ?? trigger.comment ?? ""),
};
if (actionType === 11) {
const spellId = Number(action.action_param1);
const spell = spells.get(spellId);
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target: [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target,
source,
});
} else if (actionType === 12) {
const creatureEntry = Number(action.action_param1);
if (!creatureEntry) continue;
mechanics.push({
id: `smart-${entry}-${action.id}-summon-${creatureEntry}`,
name: String(action.comment ?? "Summon Reinforcements")
.replace(/^.*? - (?:In Combat|Health Between [^-]+) - /, ""),
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count: 1 },
...event,
source,
});
} else if (actionType === 39) {
const radius = Math.max(1, Number(action.action_param1) || 30);
mechanics.push({
id: `smart-${entry}-${action.id}-call-for-help`,
name: "Call for Help",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 800,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "call-for-help", radius },
...event,
source,
});
}
}
}
return mechanics;
}
function initializationSpawnChance(entry, worldAi) {
const despawnRows = (worldAi?.smartScriptsByEntry?.[String(entry)] ?? []).filter((row) => (
Number(row.event_type) === 37
&& Number(row.action_type) === 41
&& Number(row.target_type) === 1
));
if (!despawnRows.length) return 1;
return rounded(despawnRows.reduce((chance, row) => (
chance * (1 - Math.max(0, Math.min(100, Number(row.event_chance) || 0)) / 100)
), 1));
}
function uniqueMechanics(mechanics) {
const seenIds = new Set();
const seenSpellIds = new Set();
return mechanics.filter((mechanic) => {
if (seenIds.has(mechanic.id)) return false;
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
seenIds.add(mechanic.id);
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
return true;
});
}
function combatFor(
entity,
boss,
spells,
scriptCatalog = undefined,
worldAi = undefined,
bossMechanics = undefined,
) {
const hash = stableHash(`${entity.id}:${entity.name}`);
const entry = Number(entity.entry);
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
.filter((spell) => spell.combat);
@@ -565,20 +771,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
: null;
})
.filter(Boolean)
.slice(0, boss ? 4 : 2)
.map((spell) => spellAttack(spell, hash, boss));
if (!spellAttacks.length && boss) spellAttacks.push(fallbackSpecial(entity.id, true));
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
let mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
mechanics = uniqueMechanics(mechanics);
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
const level = levels.length
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
: undefined;
return {
...(entry ? { serverEntry: entry } : {}),
...(level ? { level } : {}),
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
damageMultiplier: boss ? 1.12 : 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 70 : 52,
attacks: [basicAttack(basicCooldown), ...spellAttacks],
...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
attacks: [basicAttack(basicCooldown), ...mechanics],
};
}
@@ -596,6 +809,7 @@ function wailingServerCombatFor(entity, boss, reference) {
damageMultiplier: 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 55 : 42,
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
attacks: [{
...basicAttack(basicCooldown),
initialCooldownMs: [450, 450],
@@ -664,7 +878,7 @@ function offsetPosition(position, index, radius = 3.2) {
];
}
function syntheticEntity(id, name, boss, level) {
function syntheticEntity(id, name, boss, level, bossMechanics = undefined) {
const entity = {
id,
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
@@ -681,7 +895,7 @@ function syntheticEntity(id, name, boss, level) {
name,
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
combat: {
...combatFor(entity, boss, new Map()),
...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
},
visual: visualFor(id, name, boss, entity.rank),
@@ -764,6 +978,9 @@ async function compile() {
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
? await readJson(scriptSpellCatalogFile)
: { scripts: {} };
const azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
const bossMechanics = await readJson(bossMechanicsFile);
validateBossMechanicsCatalog(bossMechanics);
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
@@ -798,6 +1015,14 @@ async function compile() {
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
}
for (const row of azerothCoreWorldAi.smartScriptsByEntry?.[String(entity.entry)] ?? []) {
if (Number(row.action_type) === 11) wantedSpellIds.add(Number(row.action_param1));
}
}
}
for (const boss of Object.values(bossMechanics.bosses ?? {})) {
for (const mechanic of boss.mechanics ?? []) {
if (mechanic.spellId) wantedSpellIds.add(Number(mechanic.spellId));
}
}
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
@@ -897,7 +1122,14 @@ async function compile() {
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, boss, azerothCoreReference)
: combatFor(source, boss, spells, scriptSpellCatalog),
: combatFor(
source,
boss,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor(
source.id,
source.name,
@@ -920,7 +1152,14 @@ async function compile() {
hasLoot: true,
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, true, azerothCoreReference)
: combatFor(source, true, spells, scriptSpellCatalog),
: combatFor(
source,
true,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor(
entityId,
encounter.name,
@@ -949,6 +1188,7 @@ async function compile() {
encounter.name,
true,
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
bossMechanics,
);
}
}
@@ -1005,6 +1245,7 @@ async function compile() {
const id = encounter.eventOnly
? "mutanus-spawn"
: candidate?.id ?? `encounter-${encounter.encounterId}`;
const spawnChance = initializationSpawnChance(encounter.creatureId, azerothCoreWorldAi);
usedBossSpawnIds.add(id);
return {
id,
@@ -1012,6 +1253,7 @@ async function compile() {
name: encounter.name,
position,
yaw: candidate?.yaw ?? 0,
...(spawnChance < 1 ? { optional: true } : {}),
...(encounter.eventOnly ? { eventOnly: true } : {}),
};
});
@@ -1020,6 +1262,7 @@ async function compile() {
const roamingPacks = [];
for (const spawn of sourceSpawns) {
const entry = Number(spawn.entityId.replace("entry-", ""));
const spawnChance = initializationSpawnChance(entry, azerothCoreWorldAi);
const isBoss = bossEntries.has(entry);
const transformedPosition = mapPosition(spawn.position);
if (!isBoss && spawn.waypoints?.length >= 2) {
@@ -1041,6 +1284,7 @@ async function compile() {
pathId: spawn.pathId,
formationSource: "database-solo",
spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
waypoints,
members: [{
id: "member",
@@ -1058,6 +1302,7 @@ async function compile() {
position: transformedPosition,
yaw: spawn.yaw,
spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
});
}
for (const boss of bossObjectives) {
@@ -1109,10 +1354,11 @@ async function compile() {
...roamingPacks.flatMap((pack) => pack.waypoints),
];
const bounds = finiteBounds(allPoints);
const bosses = bossObjectives.map(({ id, name, position, eventOnly }) => ({
const bosses = bossObjectives.map(({ id, name, position, optional, eventOnly }) => ({
id,
name,
position,
...(optional ? { optional: true } : {}),
...(eventOnly ? { eventOnly: true } : {}),
}));
const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length
@@ -19,14 +19,15 @@ const outputFile = path.join(
"generated",
"azerothCoreScriptSpells.json",
);
const epochSourceFile = path.join(
const campaignSourceFile = path.join(
projectRoot,
"dungeon-pipeline",
"epoch-five-player-instances.json",
"dungeon-campaign.json",
);
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
const sourceRoots = [
"src/server/scripts/Kalimdor/CavernsOfTime",
"src/server/scripts/EasternKingdoms",
"src/server/scripts/Kalimdor",
"src/server/scripts/Northrend",
"src/server/scripts/Outland",
"src/server/scripts/World",
@@ -45,7 +46,10 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function runGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, {
const safeArguments = cwd === sourceRoot
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
: argumentsList;
const child = spawn("git", safeArguments, {
cwd,
stdio: "inherit",
windowsHide: true,
@@ -61,7 +65,10 @@ function runGit(argumentsList, cwd = projectRoot) {
function readGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, {
const safeArguments = cwd === sourceRoot
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
: argumentsList;
const child = spawn("git", safeArguments, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
@@ -203,8 +210,8 @@ if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
);
}
const epochSource = await readJson(epochSourceFile);
const wantedSlugs = new Set(epochSource.dungeons.map((dungeon) => dungeon.slug));
const campaignSource = await readJson(campaignSourceFile);
const wantedSlugs = new Set(campaignSource.dungeons.map((dungeon) => dungeon.slug));
const scriptNames = new Set();
for (const slug of wantedSlugs) {
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
+24 -10
View File
@@ -13,6 +13,7 @@ import {
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "meshoptimizer";
import {
exists,
fixturesRoot,
@@ -27,6 +28,7 @@ import {
import {
nextTiledNavMeshSize,
planNavigationBuild,
shouldRetryFailedTiledBuildWithMonotone,
shouldUseMonotonePartition,
validateTiledBuild,
} from "./recast-policy.mjs";
@@ -39,9 +41,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
async function loadScene(file) {
await MeshoptDecoder.ready;
const bytes = await readFile(file);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return new Promise((resolve, reject) => new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
}
function meshesIn(scene) {
@@ -273,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,
@@ -323,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,
@@ -349,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 (
@@ -362,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.
+77 -12
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:
@@ -105,8 +134,9 @@ NPC spell evidence is exported from the preserved read-only RuneWaker client
catalogs into a checked-in portable snapshot:
~~~powershell
npm run runewaker:spells:refresh
npm run runewaker:bosses:refresh
npm run runewaker:build:all -- --phase population --force
npm run runewaker:forsaken:population
npm run runewaker:audit:gameplay
~~~
@@ -120,14 +150,34 @@ 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
and writes modular `rw-<dungeon>-<entity-id>` kits to
`dungeon-pipeline/boss-mechanics.json`. Each special is an independent record
with stable ID, trigger, target, effects, and explicit circle, cone, or line
telegraph geometry. Both population builders copy those IDs into
`combat.mechanicIds`, so the same runtime loadout boundary is shared by
Runewaker and WoW encounters and can later support reviewed randomization.
Source-backed records identify the RuneWaker Lua and spell evidence they came
from. If the preserved scripts expose only encounter-progression hooks, the
importer creates a conservative playable mechanic marked
`source.kind: "healer-man-source-gap"`; it never labels that fallback as an
original RuneWaker ability. Edit or append a boss kit in the shared catalog,
then rerun the population build and gameplay audit to update an encounter.
The gameplay audit also:
- verifies every executable attack.spellId against both its source template
assignment and the portable spell catalog;
- rejects evidence-only spell IDs if they enter runtime combat data;
- requires every boss objective to resolve a modular catalog kit and validates
its source label, telegraph, timed trigger, and health trigger;
- reuses the live party-navigation graph to inspect boss approach points
projected to the retained surface within 6m;
- validates native animation families, with Rune Device's missing locomotion
@@ -146,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
```
@@ -8,11 +8,16 @@
"runtimeSpawns": 4086,
"runtimeEntities": 436,
"bossObjectives": 61,
"runtimeAttacks": 824,
"runtimeAttacks": 1068,
"genericDefaultAttacks": 436,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 388,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 111,
"healthTriggeredBossMechanics": 28,
"sourceBackedDamageFamilyAttacks": 483,
"sourceSpellTemplates": 293,
"sourceSpellAssignments": 659,
"scriptedTemplates": 357,
@@ -27,9 +32,9 @@
"dungeons": 30,
"uniqueNativeModels": 321,
"uniqueSourceSpellIds": 411,
"portableSpellCatalogRecords": 411,
"portableDamageFamilyRecords": 227,
"portableEvidenceOnlyRecords": 184
"portableSpellCatalogRecords": 3199,
"portableDamageFamilyRecords": 1342,
"portableEvidenceOnlyRecords": 1857
},
"mechanicsCategories": {
"death": 75,
@@ -53,23 +58,24 @@
"boss spawn membership"
],
"genericRuntimeSystems": [
"unmapped attacks",
"source-gap attacks",
"health/damage scaling",
"movement/leash",
"clustering/aggro",
"combat/death state",
"loot policy",
"loot item stats/slots",
"objective ordering",
"procedural animation"
],
"deferredMechanics": [
"doors/gates",
"summons/waves",
"phases",
"complex doors/gates",
"unmapped multi-actor waves",
"unmapped phases",
"patrols",
"interactions",
"encounter resets",
"source loot"
"blocked source-loot predicates",
"source item stat/slot mapping"
],
"blockerGroups": {
"navigation": {
@@ -216,7 +222,10 @@
},
"sourceMechanics": {
"status": "review-required",
"executableMechanicsGenerated": false,
"executableBossMechanicsGenerated": true,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"scriptedTemplates": 357,
"evidenceCounts": {
"death": 75,
@@ -237,9 +246,19 @@
"safety": {
"runtimeSqlDependency": false,
"unknownEffectsExecute": false,
"coefficientsAreSourceExact": false
"coefficientsAreSourceExact": false,
"unavailableNativeInputsDefaulted": false,
"pointValuedDamageUsesLabeledScaledAdapter": true
},
"mappingPolicy": "Only positively classified direct-HP damage families execute; unknown/buff/heal/summon/script effects remain evidence-only."
"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": [
{
@@ -257,9 +276,8 @@
"reviewNotes": [
"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.",
"436 executable attacks do not carry an audited source-spell mapping.",
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"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": [
@@ -284,6 +302,11 @@
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 16,
"proceduralBindings": 0,
@@ -333,10 +356,15 @@
]
}
],
"runtimeAttacks": 14,
"runtimeAttacks": 15,
"genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 7,
"nativeBindings": 7,
"proceduralBindings": 0,
@@ -400,11 +428,16 @@
]
}
],
"runtimeAttacks": 9,
"runtimeAttacks": 20,
"genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2,
"catalogBossMechanics": 11,
"sourceBackedBossMechanics": 11,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 7,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -453,11 +486,16 @@
]
}
],
"runtimeAttacks": 28,
"runtimeAttacks": 32,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -491,6 +529,11 @@
"genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0,
"proceduralBindings": 0,
@@ -525,6 +568,11 @@
"genericDefaultAttacks": 17,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 17,
"proceduralBindings": 0,
@@ -700,11 +748,16 @@
]
}
],
"runtimeAttacks": 15,
"runtimeAttacks": 26,
"genericDefaultAttacks": 15,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 11,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 15,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -738,6 +791,11 @@
"genericDefaultAttacks": 11,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 10,
"nativeBindings": 11,
"proceduralBindings": 0,
@@ -931,11 +989,16 @@
]
}
],
"runtimeAttacks": 74,
"runtimeAttacks": 122,
"genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34,
"catalogBossMechanics": 49,
"sourceBackedBossMechanics": 49,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 23,
"healthTriggeredBossMechanics": 2,
"sourceBackedDamageFamilyAttacks": 60,
"nativeBindings": 39,
"proceduralBindings": 1,
"proxyBindings": 0,
@@ -1052,11 +1115,16 @@
]
}
],
"runtimeAttacks": 54,
"runtimeAttacks": 75,
"genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 28,
"catalogBossMechanics": 25,
"sourceBackedBossMechanics": 25,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1173,11 +1241,16 @@
]
}
],
"runtimeAttacks": 68,
"runtimeAttacks": 80,
"genericDefaultAttacks": 32,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 36,
"catalogBossMechanics": 16,
"sourceBackedBossMechanics": 16,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 39,
"nativeBindings": 32,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1211,6 +1284,11 @@
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 4,
"nativeBindings": 1,
"proceduralBindings": 0,
@@ -1316,11 +1394,16 @@
]
}
],
"runtimeAttacks": 31,
"runtimeAttacks": 48,
"genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 17,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 20,
"nativeBindings": 14,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1369,11 +1452,16 @@
]
}
],
"runtimeAttacks": 27,
"runtimeAttacks": 33,
"genericDefaultAttacks": 20,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 7,
"catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 20,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1422,11 +1510,16 @@
]
}
],
"runtimeAttacks": 34,
"runtimeAttacks": 40,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 18,
"catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 22,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1460,6 +1553,11 @@
"genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 14,
"proceduralBindings": 0,
@@ -1545,10 +1643,15 @@
]
}
],
"runtimeAttacks": 38,
"runtimeAttacks": 50,
"genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 11,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 26,
"nativeBindings": 12,
"proceduralBindings": 0,
@@ -1670,11 +1773,16 @@
]
}
],
"runtimeAttacks": 18,
"runtimeAttacks": 52,
"genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 6,
"catalogBossMechanics": 34,
"sourceBackedBossMechanics": 34,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 9,
"healthTriggeredBossMechanics": 16,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 12,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1719,11 +1827,16 @@
]
}
],
"runtimeAttacks": 61,
"runtimeAttacks": 65,
"genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 21,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 25,
"nativeBindings": 40,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1772,11 +1885,16 @@
]
}
],
"runtimeAttacks": 1,
"runtimeAttacks": 5,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1861,11 +1979,16 @@
]
}
],
"runtimeAttacks": 28,
"runtimeAttacks": 31,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1924,11 +2047,16 @@
]
}
],
"runtimeAttacks": 55,
"runtimeAttacks": 64,
"genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 29,
"catalogBossMechanics": 9,
"sourceBackedBossMechanics": 9,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1962,6 +2090,11 @@
"genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 32,
"nativeBindings": 25,
"proceduralBindings": 0,
@@ -2035,11 +2168,16 @@
]
}
],
"runtimeAttacks": 55,
"runtimeAttacks": 63,
"genericDefaultAttacks": 21,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34,
"catalogBossMechanics": 12,
"sourceBackedBossMechanics": 12,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 36,
"nativeBindings": 21,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2073,6 +2211,11 @@
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 1,
"proceduralBindings": 0,
@@ -2150,11 +2293,16 @@
]
}
],
"runtimeAttacks": 5,
"runtimeAttacks": 25,
"genericDefaultAttacks": 3,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2,
"catalogBossMechanics": 22,
"sourceBackedBossMechanics": 22,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 3,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2203,11 +2351,16 @@
]
}
],
"runtimeAttacks": 2,
"runtimeAttacks": 8,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 1,
"catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2256,11 +2409,16 @@
]
}
],
"runtimeAttacks": 1,
"runtimeAttacks": 8,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 3,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2294,6 +2452,11 @@
"genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 23,
"nativeBindings": 25,
"proceduralBindings": 0,
@@ -2328,6 +2491,11 @@
"genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0,
"proceduralBindings": 0,
@@ -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,
@@ -9,11 +9,14 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
## Coverage
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
- 61 boss objectives and 824 executable attacks.
- 61 boss objectives and 1068 executable attacks.
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
- 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
@@ -21,17 +24,19 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
directions, model paths, source hashes, and boss spawn membership.
- Navmesh-projected placements derived from each packaged environment anchor.
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.
## HealerMan-generic or deferred
- 436 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions without source mapping.
- 388 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.
- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.
- 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, 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.
@@ -42,33 +47,33 @@ Animation columns are native/procedural/proxy entity bindings.
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 14 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 28 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 20 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 32 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 15 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 54 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 27 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 48 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 33 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 40 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 18 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 55 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 52 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 65 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 5 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 31 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 64 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 55 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 63 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 25 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 8 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 8 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
@@ -111,7 +116,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.
Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
- death: 75
- door: 22
@@ -139,7 +144,6 @@ Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, wi
- 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.
- 436 executable attacks do not carry an audited source-spell mapping.
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- 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).
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff. Show more