Compare commits

...
13 Commits
Author SHA1 Message Date
phenom 544c1bee91 Release Healer Man 0.1.29 2026-09-11 14:26:15 -04:00
phenom 8c9809d3e0 Release Healer Man 0.1.28 2026-09-10 14:14:02 -04:00
phenom ed8b9e3719 Release Healer Man 0.1.27 2026-09-10 09:54:12 -04:00
phenom 147c2804d4 Release Healer Man 0.1.26 2026-09-09 22:18:15 -04:00
phenom e7cc588bfc Release Healer Man 0.1.25 2026-09-09 16:48:50 -04:00
phenom bac0da9300 Release Healer Man 0.1.24 2026-09-09 16:43:37 -04:00
phenom c4346751a9 Release Healer Man 0.1.23 2026-09-09 14:26:13 -04:00
phenom 7f6001723a Release Healer Man 0.1.22 2026-09-09 11:21:33 -04:00
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
560 changed files with 1038454 additions and 8370 deletions

No files matched your search

+6
View File
@@ -207,6 +207,7 @@ $ReleaseVersion = [ordered]@{
npm run content:test
npm run server:test
npm test
npm run content:publish
npm run build
npm test -- --run src/avatar
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
@@ -222,6 +223,11 @@ git push origin main
git rev-parse HEAD
```
`content:publish` is a fail-closed pre-build release check. It must report all
54 WoW campaign dungeon packs and refuses to replace the manifest when a pack,
dependency, or catalog-referenced asset is missing. Its local `content/` output
is ignored and must not be staged.
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/`, `.web-public/`, or `.android-public/`
+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.
+473
View File
@@ -4,6 +4,39 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-10 Personal UI templates and desktop action bars
- Opened Wailing Caverns through the normal offline roster, dungeon, and role
selection screens, then opened Pause > Edit UI & templates.
- Verified five bars containing 12 buttons each, including 2 x 6 and 3 x 4
arrangements. Default stacking accounts for each bar's height. The editor
can collapse or dock on either side so drag handles remain reachable.
- Clicked Renew and pressed Shift+Q in keybind edit mode. Reassigned Shift+Q
to bar 5/button 12 and verified the previous keybind cleared. Assigned Renew
to that button through the ability picker; after saving, Shift+Q cast it and
displayed its cooldown. No spell fired during key capture.
- Dragged the minimap, hid party frames, saved/renamed/overwrote PC slot 1,
loaded it, and round-tripped all layouts through export text and import.
The imported JSON retained the minimap position, hidden party, bar geometry,
ability assignment, key chord, and template name. Restoring a hidden widget
works, and Cancel discards that visibility edit.
- At a 390 x 844 viewport, the editor remained within the viewport and the
Save UI button was visible and 36 CSS pixels high. The in-app browser's
screenshot preview scales the page unusually at this override; DOM bounds
confirmed the editor was 310 CSS pixels wide. This was responsive browser
verification, not an Android hardware playtest.
- Evidence: `playtest-artifacts/ui-layout-desktop.png` and
`playtest-artifacts/ui-layout-phone-viewport.png`. Restored the offline UI
to defaults and removed the temporary template via import after testing.
- Automated coverage includes migration to six slots, owner isolation,
cloud upload/hydration into a separate device cache with a mocked endpoint,
malformed imports, key conflicts, and input precedence. No production
account credentials were used for the cloud round-trip test.
- `npm run build` and the focused 67-test suite passed. The development server
was restarted after an existing watcher hit EBUSY on generated web-bootstrap
assets during a concurrent build; no runtime UI error was logged in the
subsequent playtest.
## 2026-08-18 GM Manastorm administration - COMPLETE
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
@@ -1685,3 +1718,443 @@ Vite's existing large-chunk advisory remains informational.
passed. The production build completed with 925 modules transformed, KTX2
and environment-performance audits remained green, and the release-candidate
check passed. The existing large-chunk advisory remains informational.
# 2026-09-07 — modular encounter completion and boss guide
- Entered Wailing Caverns through the live offline roster and verified the 3D
dungeon, party HUD, minimap, and buff/debuff strips remained readable. The
encounter timeline occupies the same top-right HUD column beneath those
elements, keeping timed casts and pending health thresholds out of the
central playfield.
- Opened the new Boss Guide from the in-dungeon pause menu. The three-column
field manual listed both WoW and RuneWaker dungeons, their bosses, source
provenance, triggers, timings, target/effect descriptions, warning geometry,
counterplay, and a concrete test procedure for every listed mechanic.
- Selected Archaedas in Uldaman and confirmed all four authored mechanics:
Awaken Earthen Custodian every 10 seconds, the two-second 18-metre Ground
Tremor warning circle, six Earthen Guardians at 70% health, and two Vault
Warders at 40% health. Bosses with no sourced special attack are explicitly
labelled with zero special mechanics instead of receiving invented behavior.
- The encounter authoring layer now drives serializable timers, health gates,
guards, actions, summon groups, chained signals, stateful world objects,
teleports, rewards, checkpoints, and online leader-authoritative interaction
validation. Harmful area mechanics use the same geometry for both the world
warning and hit resolution.
- Browser review found no visible layout or interaction regressions. The only
previously observed browser message remains Rapier's third-party
initialization deprecation warning.
- Source limitations and the exact remaining RuneWaker evidence, navigation,
formula, loot, animation, and asset-fidelity gaps are recorded in the linked
deterministic audit reports and authoring documentation rather than silently
approximated.
- Full-WDB environment packages for Raksha Temple and The Origin completed and
passed their GLB gates. Treasure Trove's 908-placement visual/collision stage
also completed, but its full-resolution 36-tile Recast bake did not terminate
after nearly two CPU-hours on the watershed path and more than four CPU-hours
on the monotone path. The fail-closed pipeline preserved the prior validated
shipping package; this remaining offline bake is documented in
`ENCOUNTER_AUTHORING.md` instead of lowering navigation resolution.
- Verification passed: the complete Vitest suite passed all 981 cases across
185 files; online authority passed all 4 server cases; RuneWaker gameplay,
encounter, environment, navigation, formula/loot, and animation audits passed;
and the production build completed with 955 modules transformed. The KTX2
audit covered 12,347 compressed textures with zero fallbacks, and the
environment-performance audit found no regressions. The existing large-chunk
advisory and Rapier initialization deprecation warning remain informational.
# 2026-09-08 — WoW dungeon production-functionality audit
- Audited all 54 WoW five-player catalog entries against live runtime contracts,
pinned AzerothCore population/object drafts, and instance-script source. The
composed-runtime content audit is green for 1,193 mobs, 273 bosses, 4,026
attacks, 2,572 specials, and 1,275 native models, but the stricter production-
fidelity gate remains blocked at 0/54.
- The audit found 3,943 source game-object spawns, including 2,321 actionable
doors, buttons, chests/resources, transports, traps, and related objects. Raw
database rows are no longer passed into the typed world-object renderer; all
54 live definitions now have zero malformed world-object records.
- The follow-up implementation sweep completed source review for all 54
instances. Fifty-one now have runtime-wired instance orchestration and the three
genuinely empty stock instance scripts are pinned as source-exact no-ops;
there are no unreviewed instance scripts left. Wailing Caverns remains the
only entry with a reviewed off-mesh link, and the three Ascension-specific
dungeons still lack authoritative server population data.
- Entered Wailing Caverns from the local browser roster as a Priest healer. The
3D environment, populated combat route, party, objective, minimap, and HUD all
loaded; the minimap strip showed one Power Word: Fortitude entry. No uncaught
client runtime errors appeared. Rapier's existing initialization deprecation
warning remained informational.
- The local content manager exposed all 54 WoW entries but only Wailing Caverns
was installed; the other 53 were marked download required. Those dungeons were
therefore checked by source/runtime-contract audit in this pass, not claimed
as completed visual traversal playtests.
- The follow-up world-object compiler converted all 3,943 source rows—including
all 2,321 actionable doors, chests/resources, switches, traps, and
transports—into stable typed definitions with transformed coordinates,
type-aware states, conservative dimensions, and source/template/spawn/type
provenance. Wailing Caverns receives its 57 generated records through the
authored-definition enrichment path as well.
- Client `GameObjectDisplayInfo` metadata resolves a visual display ID for
3,905 records, a native model path for 3,837, and exact bounds for 3,547. The
generated export manifest deduplicates those instances into 458 native legacy
assets, records 38 intentional no-display spell-focus objects, and isolates
the remaining nine missing-model display IDs and 68 no-bounds display IDs for
review.
- An initial live pass exposed colored procedural markers for adapted
chests/resources. Unreviewed source objects are now deliberately hidden,
non-blocking, and non-interactive so incomplete door scripts cannot soft-lock
a dungeon and placeholder art cannot leak into normal play. A second live
Wailing Caverns pass confirmed the clean 3D scene, party combat, HUD, minimap,
and single Fortitude entry. No uncaught client errors appeared; duplicate
KTX2-loader warnings occurred only across deliberate hot-reload cycles.
- The final runtime audit reports 54/54 reviewed dungeons. Fifty-one dungeons use
52 rule-bearing instance-controller definitions (Blackrock Spire has separate
lower- and upper-spire controllers), while three use verified source-exact
no-op scripts. Every catalog objective has a completion action, and the audit
found zero malformed-object, duplicate-id, or encounter-contract issues. All
51 rule-bearing dungeons retain explicit adaptation/gap notes; native object
models and full-route live traversal are not claimed complete.
- The source-combat follow-up gives every runtime-spawnable boss a sourced kit
unless the pinned source really is melee-only. The six deliberate exceptions
are Kresh, Deviate Faerie Dragon, Spirestone Battle Lord, Spirestone Butcher,
Crystal Fang, and Grubbis. Crystal Fang retains its source-exact 15% rare-spawn
behavior and death summon. Trial of the Champion, Utgarde Keep, Hellfire
Ramparts, Sethekk Halls, Mana-Tombs, and Slave Pens event bosses now use their
pinned spell IDs and timing windows; unsupported vehicle, proc, queue, shared-
health, phase, stack, and healing-reduction semantics are explicitly recorded
as adaptations instead of being silently invented.
- Wipe/reset validation exhaustively covered 673,664 reachable encounter states,
180 reset paths, every one of the 68 non-initial reset targets, and all 135
entity-timeout rules. Durable progression now fails closed on a missing entity,
legacy snapshots with unordered action history fail closed, and Frozen Reach's
Arktos is attempt-scoped so a wipe respawns him and can still advance the duo
encounter on the next pull. The two viable post-reset resume states in Scarlet
Cathedral and Halls of Stone are intentional, not soft locks.
- Same-process checkpoint restore and online follower hydration now accept their
trusted save/leader snapshots even when a retained local provider has a newer
reset revision; ordinary stale imports remain rejected. A renderer restart
preserves the current encounter snapshot. Encounter effects use one delivery
path at a time, and obsolete headless queues are cleared on reset or dungeon
switch instead of retaining already-applied envelopes for the process lifetime.
- The generated download topology now contains an installable logical pack for
every one of the 54 campaign dungeons. The three custom dungeons without their
own creature payload are represented by dependency-only packs, while every
non-bundled dungeon pulls its exact environment and shared-creature closure.
The existing live content feed was not deployed or mutated during this pass;
browser traversal therefore still reflects the packs already available there.
- Installed packs now persist v2 verification receipts containing their exact
file set, byte counts, hashes, and dependencies. Browser-cache and native-file
presence are revalidated before entry, production dungeon entry fails closed
when a pack or dependency is missing or stale, and `content:publish` validates
the complete 54-dungeon dependency graph before any release output is written.
- Run completion now leaves the final room active so loot can be collected before
the player explicitly exits and clears the checkpoint. A wipe returns the party
to the saved run spawn while preserving already-cleared bosses and durable run
progress; attempt-scoped actors and effects reset for the next pull.
- The live Priest check used the saved `Rolecheck` character, which advanced from
level 11 to level 12 during the dungeon passes. The latest Wailing Caverns run
displayed 677/677 health, one Power Word: Fortitude alongside Arcane Intellect
(and Holy Concentration while active), and one Frostbolt plus one Icy Touch
debuff. Repeated Frostbolt applications refreshed that one entry from 7.1 to
21 seconds rather than duplicating it. The deterministic geared level-8 check
produced 3,285 mana and increased health from 407 to 437 when Fortitude was
applied.
- Verification remains deliberately split: source coverage, runtime contracts,
reset reachability, and automated behavior are green, while the combined
production-fidelity audit remains 0/54. Its remaining blockers include native
object export/collision, explicitly listed source-semantic gaps, and end-to-end
traversal evidence for every production pack—not unfinished instance-script
review.
- Final release verification passed all 1,109 Vitest cases across 202 files,
TypeScript project compilation, and the production build with 967 modules
transformed. The build staged 5,587 browser-bootstrap files, validated all
eight arenas, audited 12,347 compressed textures with zero fallbacks, and found
no environment-performance regressions. The release-candidate check accepted
all 118 changed paths. The existing large-chunk advisory and Rapier
initialization deprecation warning remain informational.
# 2026-09-09 — fixed per-rank classic mana costs
- Imported WoW mana percentages are now converted into fixed costs at each
spell rank's source level. Player level, Intellect, equipment, and maximum-mana
modifiers no longer increase the cost of an already learned rank; legitimate
talent and aura cost reductions are still applied afterward.
- Shadow Word: Pain rank 4 now costs 133 mana at every level from 26 through 33,
including level 32. Learning rank 5 at level 34 raises the cost to 188 mana.
- AI companions now use real character-scale mana pools so fixed spell costs do
not prevent healing or resurrection. The companion display also resolves the
active spell rank and the same final affordability result as the primary HUD.
- Verification passed all 1,133 Vitest cases across 208 files. The production
build completed with 974 modules transformed; arena, KTX2, and environment-
performance audits remained green, and the release-candidate check passed.
# 2026-09-09 — dungeon wall and nameplate performance regressions
- The 0.1.24 material fallback honored BLEND tags on legacy WoW WMO walls,
floors, and ceilings, disabling their depth writes. WoW environments now use
their authored material roles again; RuneWaker environments retain their
source MASK/BLEND semantics. Shipped Wailing Caverns wall materials, vines,
and water are covered by regression tests.
- The 0.1.25 boss-name occlusion scanned the complete rendered scene with
Three.js raycasts. A live Kalin's Shrine CPU profile was dominated by triangle
intersection and vertex decoding. Boss names and nearby NPC prompts now use
bounded camera-to-label queries against Rapier's existing physical spatial
index, at most ten times per second, skipping offscreen labels. Walls, floors,
and ceilings occlude labels; actors and trigger sensors do not.
- In an isolated offline Edge playtest at 1440 x 900 on an AMD RX 9070 XT,
Kalin's Shrine improved from about 10 FPS (98.5 ms/frame, 167 draw calls,
DPR 1.125) to 55.5-56.1 FPS (17.8-18.0 ms/frame, 168-170 draw calls,
DPR 1.5). This is a representative entrance/combat comparison, not an audit
of all thirty RoM dungeons. The environment alone took about 0.8 ms CPU and
0.5 ms GPU with 14 draw calls.
- Wailing Caverns screenshots show continuous opaque cavern walls and ceilings.
A controlled toggle of the old material flags reproduced the missing wall
sections at the same camera position; restoring depth-writing materials fixed
them. Its full scene ran at about 57 FPS. No uncaught client errors appeared.
- Evidence and repeatable local benchmark helpers are under
`playtest-artifacts/2026-09-09-dungeon-rendering/`: `kalin-before.cpuprofile`,
`kalin-after.cpuprofile`, `kalin-after-metrics.json`, `kalin-before.png`,
`kalin-after.png`, `wailing-regression.png`, and `wailing-restored.png`.
- All 26 focused material, label occlusion, combat sight, aggro visibility,
instancing, and NPC-prompt tests passed. Physical-occlusion tests exercise
actual Rapier worlds rather than just source-code assertions.
# 2026-09-09 — campaign rendering audit and Thor performance preparation
- Audited the actual visual GLB headers for all 54 WoW campaign dungeons:
9,402 materials, including 3,586 legacy BLEND materials restored to opaque
rendering by the shared policy. Every WoW package contained affected
materials. A permanent shipped-asset test covers all 54 definitions and
retains explicit cutout, blend, and additive roles. This is material-policy
coverage, not a claim that every room has been visually traversed.
- Live screenshots checked Wailing Caverns, Shadowfang Keep, Utgarde Pinnacle,
the Nexus, and Tinkertech Showdown. The sampled walls, floors, stairs, and
ceilings remain solid; authored lighting effects remain transparent. Kalin's
Shrine and Forsaken Abbey provided RoM rendering checks. Roster and in-dungeon
screenshots show character bodies and equipped weapon silhouettes after the
shared character animation change.
- A controlled Utgarde Pinnacle entrance test found about 1,739 draw calls,
1.95 million triangles, and 30.5 FPS on the desktop GPU. Clipping at the
existing full-fog distance reduced this to about 209 calls, 567,000 triangles,
and 60 FPS at the same viewpoint. The camera now follows each dungeon's fog
distance, including transitions that retain the existing Canvas/camera.
The background uses the fog color to avoid a clipping seam at the horizon.
All 84 dungeon definitions have render-distance coverage.
- Static dungeon visual transforms are cached until the root or its parent's
world transform changes. Tests verify imported matrices, repeated unchanged
frames, root movement, and parent movement. Characters, moving world objects,
and physics remain outside this immutable visual cache.
The final 63-second Utgarde sample held 60 FPS with 213 draw calls:
main-thread work averaged 9.2 ms/frame, renderer CPU averaged 7.5 ms
(p95 8.3 ms), and GPU averaged 2.7 ms (p95 4.1 ms). Movement and jump input
continued to animate the player while the environment remained stationary.
- Character locomotion and jump clip selections are cached by animation
library and weapon stance. The final Kalin CPU profile contains no sampled
per-frame base-clip selection/name parsing. Frame-cap deadlines now tolerate
sub-millisecond timestamp rounding without drifting or emitting catch-up
frames; tests cover 60 FPS on rounded 60 Hz and 120 Hz callbacks.
- A 31.5-second Kalin sample with a connected browser companion display held
60 FPS: main-thread work averaged 8.7 ms/frame, renderer CPU averaged 5.4 ms
(p95 5.9 ms), and GPU averaged 2.4 ms (p95 3.4 ms). The companion used about
5.1 ms of main-thread time per second. Recent ability-cost snapshot work was
not a dominant hotspot in this sample. Startup warmup is bounded to loading;
companion snapshots are already limited to ten per second during gameplay.
- These are isolated offline Edge desktop samples on an AMD RX 9070 XT at
1920 x 1080 CSS pixels, DPR 1.5 (2880 x 1620 renderbuffer). Party composition
can change between dungeon entries. They do not certify Android performance
or sustained busy-combat headroom. ADB listed no connected Android device.
- Thor acceptance should use the actual model, both native displays, target
resolution, and a sustained 20-minute traversal/combat run after warmup.
Provisional headroom target: p95 main-thread and GPU work each at or below
12 ms, with p99 presented frame intervals within the 16.7 ms 60-FPS budget.
Test large pulls, boss effects, menus, and repeated dungeon transitions;
record thermal behavior and frame-time spikes, not only average FPS.
The environment asset audit still has 60 grandfathered budget exceptions;
its green result means no new asset regression, not a Thor FPS guarantee.
- Evidence is in `playtest-artifacts/2026-09-09-thor-audit/`, including roster,
dungeon, and companion screenshots, CPU profiles, and CPU/GPU JSON samples.
The full material report is
`playtest-artifacts/2026-09-09-dungeon-rendering/wow-material-audit.json`.
- Validation passed 227 focused tests covering avatar rendering and animation,
camera distances, material policies, frame pacing, static transforms, label
visibility, and companion snapshots. The final static-cache change also
passed the 105-test scene subset. The production build and release-candidate
check passed; the existing large-bundle advisory remains.
# 2026-09-09 — additional CPU headroom from culling and shared skeletons
- Prepared dungeon visuals now group static siblings into conservative spatial
bounds. One scene hook checks these bounds after the current camera and world
matrices update, before Three.js traverses individual meshes. Bounds include
complete wall geometry and all instances; lights, animated meshes, and meshes
explicitly disabling frustum culling remain outside these groups. The hook
supports streamed chunks and restores its predecessor after cleanup.
- At the same Utgarde Pinnacle entrance, only 16 of 1,383 spatial groups were
visible. The renderer examined 108 static meshes instead of 7,002, retaining
the same 206 draw calls and 566,913 rendered triangles. Controlled culling
toggles produced byte-identical canvas PNGs across eight camera headings.
- In the same running scene, spatial grouping reduced average main-thread work
from 9.45 to 6.83 ms/frame, and renderer CPU from 7.81 to 5.31 ms/frame.
Removing containers emptied by static instancing reduced the final fresh-run
averages further to 6.49 ms main-thread and 4.85 ms renderer CPU (p95 5.2 ms).
The final sample lasted 87.8 seconds and held 60 FPS. Authored empty scene
nodes are preserved; only containers emptied by our batching are removed.
- The shared character renderer still uses SkeletonUtils.clone, then reuses
equivalent skeleton palettes within that owned clone. Bone identities, order,
and inverse bind matrices must all match; per-mesh bind transforms remain
unchanged. Different characters retain independent bones and animations.
Owned bone textures are now explicitly disposed during character teardown.
- A live character audit found 66 skeleton objects for one player palette and
256 objects for four party palettes. A Wailing Caverns toggle removed 309
duplicate skeletons and produced an identical rendered image. Tests verify
posed vertices, differing bind transforms, incompatible palettes, independent
cloned characters, and redundant texture disposal.
In the same Wailing scene, main-thread work fell from 7.40 to 6.70 ms/frame;
renderer CPU fell from 5.51 to 4.86 ms/frame, and allocated GPU textures fell
from 292 to 217. This measures palette sharing after spatial culling was
already enabled. Both samples held 60 FPS.
- The final built runtime showed a complete roster body and staff with one
skeleton for 66 skinned meshes. Kalin's Shrine showed one player palette and
four independent party palettes; walking retained the player body and weapon
silhouette. Wailing Caverns and Utgarde screenshots retained solid dungeon
surfaces. There were no uncaught client errors during these playtests.
- All 210 targeted avatar, visibility, instancing, material, camera-distance,
and label tests passed. The production build and release-candidate check
passed. The existing large-bundle advisory remains.
- Evidence is under `playtest-artifacts/2026-09-09-static-culling/`: paired PNGs,
eight-heading pixel comparisons, profiles, before/after timing JSON, and
screenshots. Measurements use the same desktop Edge/RX 9070 XT setup as the
preceding audit. These CPU improvements do not certify Thor performance.
# 2026-09-09 — bundled arena assets blocked by the browser content worker
- Reproduced the reported collision-matched fallback in the production build
with the content worker active. Warnorken's `visual.glb?arena-runtime=1`
returned 404 even though the file was bundled and directly served with 200.
The worker omitted `/assets/game/arenas/` from its bundled asset prefixes and
incorrectly rejected these files as uninstalled optional content.
- Added the arena prefix to the worker. Installed updates still take priority;
missing optional dungeon content still returns 404. Behavioral worker tests
failed for all eight arenas before the fix and now cover visual, collision,
and navigation requests with the runtime query parameter.
- The production build passed. Reloading the same browser session activated
the fixed worker without clearing storage or downloading content. Warnorken
rendered textured stone geometry and Nagrand rendered its authored arena
geometry; neither showed the loading/fallback banner after loading, and no
new console errors were recorded. Checks used offline practice in the Codex
browser at `http://127.0.0.1:4174/`, backed by a local test server on port 4173.
- All 22 content tests and the eight-arena asset validator passed. Screenshots,
before/after error logs, asset validation, and build output are saved under
`playtest-artifacts/2026-09-09-arena-loading/`. The existing large-bundle
advisory remains. This verifies the local browser build, not a deployment
or an Android package.
# 2026-09-10 - arena character and gameplay integration
- Replaced the limited arena spell interpreter with a private bundle of the
existing character combat engine. Offline practice and server matches share
casting, channels, resources, auras, pets, talents and equipment mechanics.
Each actor/match keeps its own state; opening panels does not run a second
client simulation or overwrite dungeon state.
- The controlled character uses CharacterModel with saved appearance, gear,
class, specialization, talents and bindings. Other participants also use
character models. The HUD displays actual resources, runes, combo points,
cast/channel status, cooldowns, buffs, pet commands and PC/controller labels.
Spellbook, character, talents, bindings, keybinds, map and options work in
arena. Equipment and talents remain locked to the entered match loadout.
- Fixed Keybinds opening with a shortened custom PC bar: missing slots now
display as empty instead of crashing the React tree. Live checks opened
Keybinds, Character, Talents and Options; the four-slot Q/E/R/T bar cast
Flash Heal and Power Word: Shield with resource/cast/aura feedback.
- Fixed NaN facing, spectator-camera elevation, obstructed starting positions
and camera collision. Shared right-button drag ends on release, blur, lost
capture, menus and teardown without pointer lock. Movement, jump, targeting,
ground placement, feedback, animation and combat effects use arena state.
- Authority uses all collision chunks and navigation triangles. Removed
invisible static proxy walls. All 3v3 starts are grounded, and bots follow
connected routes around walls/ramps. Shortcuts stay on level ground to avoid
falling below waypoints. Detailed scenes use a shaped support surface; the
loading/error fallback now matches collision instead of using a flat box.
- Black Rook's old navigation/anchors were on terrain above the arena. Rebuilt
against restored collision, selected its connected interior component (783
triangles), and placed both starts on the floor near Y=-28.3. Updated the
asset metadata and generator to stop restoring the old terrain navigation.
- Added PvP defender stats, shields, resource drains, pushback, school locks
and control diminishing returns. Fixed duplicate control entries counting
twice, aligned aura/control duration, and made dispels remove movement
restrictions. Dispel Magic supports friends and enemies. Healers can heal
allied pets; melee bots start weapon attacks to generate rage.
- Automated checks: 38 server arena tests and 160 distinct avatar, input,
camera, arena character/rules, combat store and ability catalog tests.
Coverage includes channels/cancellation, runes, combos, pet orders/damage/
healing, interrupts, absorbs, dispels, DR, ground spells, mana burns, RoM
secondary resources, dampening, isolation, matchmaking and match results.
Both-direction walks between team starts pass across all eight arenas.
- Live checks use an isolated in-memory level-80 priest, ArenaPriest, without
changing the user's roster. Warnorken showed its interior, the selected
character and saved spell labels. Movement changed both actor and camera.
Bots traversed, cast spells, reduced health and settled a defeat when idle.
No detailed-asset warning appeared. Evidence uses the arena-complete prefix
under playtest-artifacts; earlier Nagrand/custom-bar evidence retains the
arena-player prefix. The drag probe uses installed input code with scripted
right-button events; rotation changes while held, stops after release, and
pointerLockElement remains null. This is not a physical-device test.
- Final screenshots show the Warnorken interior and character/action bar,
Black Rook's corrected interior floor, the roster body/staff, and the
character/party after returning to Wailing Caverns. Files:
arena-complete-warnorken.png, arena-complete-black-rook.png,
arena-complete-roster.png, arena-complete-dungeon.png and
arena-complete-keybinds.png in playtest-artifacts.
- Production build and release checks passed, with the existing bundle-size
advisory. Run npm run arena:abilities:build after catalog/asset changes;
prebuild also regenerates the engine and geometry. Verification is local
offline practice plus authoritative server tests. Hosted online PvP,
deployment and Android hardware were not exercised.
# 2026-09-10 — profile keybinds and movement-conflict recovery
- Added Pause / Options → Keybinds with 133 actions, PC and Controller tabs,
search/categories, capture and touch selectors, explicit conflict replacement,
clear/reset controls, and Restore movement. Desktop defaults to PC; mobile
and native Android default to Controller. Both tabs remain available.
- Live offline browser check: assigning W to bar 1/button 1 named Move forward
in the conflict prompt; cancelling preserved W. Rebinding forward to T,
saving, closing, and reopening retained T. Restore movement returned it to W.
- At 390 × 844, controller selectors assigned L1 + Triangle to Jump, prompted
before clearing the old layer-2 ability binding, and retained the saved chord
after reopening. Restored the test bindings afterward. The final phone panel
starts at (0, 0), has a 443px scrolling action list, and keeps the footer within
the viewport. Desktop layout checked at 1280 × 900.
- Fixed the inherited 610px panel-height cap, mobile backdrop padding, action
button accessible names, and companion-toggle overlap found during QA.
- 89 targeted tests passed across input dispatch, mouse-drag lifecycle, analog
remapping, conflict recovery, menus, account isolation, cloud transfer, UI
imports, and dual-display synchronization. The full production build and
release candidate check passed; the existing bundle-size advisory remains.
- Controller chord conflicts also cover a modifier button’s standalone action,
preventing combinations such as View + A from opening the companion first.
- Hardware controller / Android APK validation and a physical right-button
drag were unavailable through this browser session. Drag lifecycle and null
pointer-lock assertions passed in the input tests. Browser viewport screenshots
are scaled by the in-app compositor; DOM geometry supplied the viewport checks.
## 2026-09-10 - Original spell media defaults and visual fallback
- Original spell visuals and authentic sounds are enabled by default. The original media pack is prepared before gameplay; download failure leaves generated visuals available and missing sounds silent.
- Saves using the previous generated-only default migrate once to original media while retaining quality, group visibility, mute, and volume settings. Explicit later preferences remain saved.
- Removed synthesized combat audio. Original mode now uses generated visuals when no original phase is mapped or when all original graphs/textures for that phase fail to load; usable original layers remain preferred.
- Validation: 64 targeted tests passed, covering defaults, persistence migration, media installation/retry, rendering selection, original audio playback, silence on missing/invalid audio, and existing effect lifecycles. Production TypeScript and Vite build passed after correcting the installer test mock type; prebuild content/asset checks also passed.
- Browser verification used the production CombatEffects and OriginalCombatEffect components with isolated store/media fixtures and the actual packaged Flash Heal graph/textures. The original case rendered one original emitter and zero generated emitters. Unmapped spell, graph HTTP 404, and texture HTTP 404 cases each rendered zero original emitters and one visible generated effect. Combat reset removed the fallback.
- Evidence: playtest-artifacts/spell-media-20260910/original.png, bad-graph.png, bad-texture.png, and missing.png. The first Vite preview timed out; the bundled isolated preview completed successfully. This was an effect-focused browser check, not a full dungeon or device audio audition.
- Final release check: blocked by existing trailing whitespace in server/generated/arena-combat-runtime.mjs. The files changed for spell media pass the whitespace check; the unrelated generated arena file was left intact.
## 2026-09-10 - All regular dungeons bundled with lossless shared storage
- Web and Android packaging include all 84 regular dungeons and their asset dependencies. Shared Manastorm maps are stored once while every Manastorm pack keeps its optional install flag. Manastorm-only maps remain outside the initial package.
- Packaged assets fell from 3,440,440,928 logical bytes to 1,600,690,686 stored bytes, including 179,063,519 bytes saved by sharing exact duplicate files. No source models, texture resolutions, animations, or audio were changed or generated.
- Gzip representations retain both compressed and original SHA-256/size metadata. Publishing keeps original remote objects for older clients. Browser installs verify both representations, retain compressed shared objects with small aliases, and preserve shared objects until their final pack is removed. Native optional downloads remain resumable and verify their expanded originals before activation.
- Android's packager automatically expanded and renamed .gz files; final objects use .gzip with noCompress instead. Added an APK verifier to the normal Android build workflow, checking every object, every original hash, exact object inventory, and the 84-dungeon/optional-Manastorm boundary.
- Final debug APK: 1,622,100,512 bytes. All 9,106 unique objects (9,194 logical asset references) passed verification from inside the actual APK. Verification report and test APK: artifacts/dungeon-texture-audit-20260910/apk-verification.json and healer-man-0.1.28-debug.apk.
- Validation: production TypeScript/Vite build and asset checks passed; 80 targeted content, Downloads, and avatar tests passed across the final runs, plus nine publisher/server tests. Android Java compilation and assembleDebug passed. Existing bundle-size/import advisories remain.
- Live production browser check used an isolated offline Gziphealer character. Roster and Deadmines rendered the player body, party bodies, equipped weapon silhouettes, and textured dungeon geometry using the bundled compressed objects. Downloads showed all regular dungeon packs included and 75 Manastorm rows not installed. Evidence: artifacts/dungeon-texture-audit-20260910/roster-final.png, deadmines-final.png, and downloads-dom.txt. No new console errors appeared in the final-format dungeon run.
- A concurrent build replaced the shared dist directory during the first preview, causing temporary asset 404s. Repeated visual validation against an isolated build completed successfully. Deadmines displayed a route-blocked party status at its entrance; this was a loading/rendering check, not a full dungeon traversal. Physical Android hardware was not exercised.
+4
View File
@@ -41,6 +41,10 @@ npm test
npm run build
```
Boss-mechanic and dungeon-event extension points, telegraph rules, checkpoint
behavior, test workflow, and the explicit source-gap ledger are documented in
[`ENCOUNTER_AUTHORING.md`](ENCOUNTER_AUTHORING.md).
## Content downloads
Production browser and Android builds ship a compact required set: the game
+7 -1
View File
@@ -237,10 +237,15 @@ Gitea tag after the signed APK builds successfully.
npm run content:test
npm run server:test
npm test
npm run content:publish
npm run build
```
All four commands must succeed.
All five commands must succeed. `content:publish` is a fail-closed release gate:
it validates that the generated manifest contains all 54 WoW campaign dungeon
packs, their exact dependency closure, and every catalog-referenced asset before
it writes the ignored local `content/` output. Do not build or stage a release
if that validation fails.
Apply these additional targeted checks whenever the matching area was edited:
@@ -540,6 +545,7 @@ Do **not** bump the Android version. Run these exact PowerShell commands:
```powershell
Set-Location F:\Projects\HealerMan
npm run content:test
npm run content:publish
npm run build
git status --short
npm run release:check
+64
View File
@@ -0,0 +1,64 @@
# RuneWaker encounter content catalog
This is the reviewed runtime layer above the exhaustive, machine-generated RuneWaker
encounter audit. It intentionally contains only source behavior that can be mapped without
guessing. The executable catalog is `src/game/runewakerEncounterCatalog.ts`.
## Bounded reviewed coverage
| Dungeon | Deferred source rows mapped | Runtime behavior | Fidelity |
| --- | --- | --- | --- |
| Forsaken Abbey | `12230`, `12236`, `12238` | Three two-member Ghoul/Carrion Spider formations are created on encounter initialization. After the source `sleep(20)` (2 seconds because RuneWaker sleep units are tenths of a second), the encounter enters its patrol phase. | source-backed adapted |
| Treasure Trove | `5952428`, `5952438` | Killing Boulder Sidaar (`5952494`) arms switch `112100`; interacting with it activates the switch and lowers drawbridge `112098` once. | source-backed adapted |
The Forsaken mapping is backed by preserved Lua `00571.lua` and matrix helper `00727.lua`.
The Treasure Trove mapping is backed by preserved Lua `01159.lua`. Snapshot, mechanics-audit,
and source hashes are attached to each catalog review record.
“Source-backed adapted” is deliberate. Source IDs, trigger order, spawn membership, timing,
and placement transforms are retained. HealerMan supplies serializable phases, procedural
world-object geometry, projected heights, interaction range, and the packaged canonical Ghoul
visual/combat definition for the source-only level-scaled Ghoul template `100653`.
## Effective ledger after this review
The generated audit baseline inventories 4,113 deferred source rows and 1,136 unresolved
mechanics-evidence records. This catalog maps five unique deferred rows and clears five stable
evidence IDs, leaving:
| Ledger item | Baseline | Reviewed here | Remaining |
| --- | ---: | ---: | ---: |
| Deferred source rows | 4,113 | 5 | 4,108 |
| Unresolved mechanics-evidence records | 1,136 | 5 | 1,131 |
Per affected package, Forsaken Abbey moves from 101 to 98 deferred rows and 77 to 74
unresolved evidence records. Treasure Trove moves from 188 to 186 deferred rows and 47 to 45
unresolved evidence records. All other package counts remain unchanged.
The exhaustive generated JSON remains the immutable audit baseline until its generator learns
to consume the runtime catalog. The effective counts above are exported as
`RUNEWAKER_ENCOUNTER_CONTENT_LEDGER` and are regression tested.
## Explicit remaining gaps
- Forsaken formations remain at their source start positions after the two-second hold. The
source uses endless flag-path patrols; the encounter runtime has no reviewed path-following
action yet.
- Forsaken source template `100653` is a level-scaled Ghoul variant created only by Lua and has
no packaged actor row. The runtime alias reuses canonical Ghoul `100072`; this is not claimed
as an exact visual or formula mapping.
- The Forsaken keeper gate (`7883`) remains deferred. Its four Necromage deaths source-summon
Ghoul Drake `100073`, but that summon has neither a packaged entity nor a reviewed combat
definition. Opening the gate without that fight would be false fidelity.
- Black Mary's Hold Door (`5952433`) remains deferred because its source interaction consumes
inventory item `203550`; encounter guards currently expose no inventory predicate.
- Treasure Trove uses procedural bridge/switch proxies because source object meshes and ACT
motions are not packaged.
- The remaining 4,108 rows require the same source-by-source review. Bulk promotion from names,
model paths, or regex categories is not evidence of executable behavior.
## Validation
`src/game/runewakerEncounterCatalog.test.ts` validates each encounter definition and requires
every referenced runtime spawn, entity, encounter spawn group, world object state, and objective
to exist. It also simulates both reviewed state machines and locks the ledger arithmetic.
+2
View File
@@ -24,6 +24,8 @@ android {
versionName releaseVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Dungeon objects already carry verified lossless gzip compression.
noCompress 'gzip'
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
@@ -0,0 +1,85 @@
package com.phenomrom.healerman;
import android.content.res.AssetManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import com.getcapacitor.Bridge;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.GZIPInputStream;
/** Streams shared APK objects at their original logical URLs; no expanded disk copy. */
final class BundledContentAssets {
private final Bridge bridge;
private final AssetManager assets;
private JSONObject files;
BundledContentAssets(Bridge bridge) {
this.bridge = bridge;
this.assets = bridge.getContext().getAssets();
}
private synchronized JSONObject files() throws Exception {
if (files == null) {
try (InputStream input = assets.open("public/content/bootstrap-manifest.json")) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int count;
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
files = new JSONObject(output.toString("UTF-8")).getJSONObject("files");
}
}
return files;
}
WebResourceResponse intercept(WebResourceRequest request) {
if (!"GET".equals(request.getMethod()) || !bridge.getHost().equals(request.getUrl().getAuthority())
|| !bridge.getScheme().equals(request.getUrl().getScheme())) return null;
String path = request.getUrl().getPath();
if (path == null || !(path.startsWith("/assets/") || path.startsWith("/basis/") || path.startsWith("/data/"))) return null;
try {
JSONObject entry = files().optJSONObject(path);
if (entry == null) return null;
JSONObject download = entry.optJSONObject("download");
String objectUrl = (download == null ? entry : download).getString("url");
if (!objectUrl.matches("/content/bundled/[a-f0-9]{64}\\.[a-z0-9]+(?:\\.g(?:z|zip))?")) return null;
InputStream input = assets.open("public" + objectUrl);
if (download != null) {
try { input = new GZIPInputStream(input, 128 * 1024); }
catch (Exception error) { input.close(); throw error; }
}
Map<String, String> headers = new HashMap<>();
headers.put("Content-Length", String.valueOf(entry.getLong("bytes")));
headers.put("Cache-Control", "public, max-age=3600");
return new WebResourceResponse(mimeType(path), null, 200, "OK", headers, input);
} catch (Exception error) {
return new WebResourceResponse("text/plain", "UTF-8", 500, "Bundled asset unavailable", new HashMap<>(),
new ByteArrayInputStream("Bundled asset unavailable.".getBytes(StandardCharsets.UTF_8)));
}
}
private static String mimeType(String path) {
String extension = path.substring(path.lastIndexOf('.') + 1);
switch (extension) {
case "glb": return "model/gltf-binary";
case "json": return "application/json";
case "js": return "text/javascript";
case "wasm": return "application/wasm";
case "ktx2": return "image/ktx2";
case "png": return "image/png";
case "webp": return "image/webp";
case "avif": return "image/avif";
case "svg": return "image/svg+xml";
case "mp3": return "audio/mpeg";
case "ogg": return "audio/ogg";
case "wav": return "audio/wav";
case "webm": return "video/webm";
default: return "application/octet-stream";
}
}
}
@@ -8,6 +8,10 @@ import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.BridgeWebViewClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
@@ -20,6 +24,7 @@ import java.net.URI;
import java.net.URL;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
/** Streams verified, resumable content objects into Android app-private storage. */
@CapacitorPlugin(name = "ContentStorage")
@@ -27,6 +32,18 @@ public class ContentStoragePlugin extends Plugin {
private static final int BUFFER_BYTES = 128 * 1024;
private static final long PROGRESS_INTERVAL_MS = 200L;
@Override
public void load() {
BundledContentAssets bundled = new BundledContentAssets(bridge);
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
WebResourceResponse response = bundled.intercept(request);
return response != null ? response : super.shouldInterceptRequest(view, request);
}
});
}
@PluginMethod
public void download(PluginCall call) {
execute(() -> {
@@ -36,6 +53,10 @@ public class ContentStoragePlugin extends Plugin {
String sha256 = normalizedSha(requiredString(call, "sha256"));
String extension = normalizedExtension(requiredString(call, "extension"));
long expectedBytes = numericLong(call, "bytes", -1L);
boolean compressed = "gzip".equals(call.getString("encoding"));
String transferSha = compressed ? normalizedSha(requiredString(call, "downloadSha256")) : sha256;
long transferBytes = compressed ? numericLong(call, "downloadBytes", -1L) : expectedBytes;
if (expectedBytes < 0 || transferBytes < 0) throw new IllegalArgumentException("Missing content size.");
validateRemoteUrl(url);
File destination = objectFile(sha256, extension);
@@ -47,54 +68,89 @@ public class ContentStoragePlugin extends Plugin {
throw new IllegalStateException("Unable to replace invalid cached content.");
}
File partial = new File(destination.getParentFile(), destination.getName() + ".part");
long existingBytes = partial.isFile() ? partial.length() : 0L;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(45_000);
connection.setRequestProperty("Accept-Encoding", "identity");
if (existingBytes > 0L) connection.setRequestProperty("Range", "bytes=" + existingBytes + "-");
int status = connection.getResponseCode();
boolean resumed = existingBytes > 0L && status == HttpURLConnection.HTTP_PARTIAL;
if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
connection.disconnect();
throw new IllegalStateException("Content server returned HTTP " + status + ".");
}
if (!resumed) existingBytes = 0L;
long downloadedBytes = existingBytes;
long totalBytes = expectedBytes > 0L
? expectedBytes
: existingBytes + Math.max(0L, connection.getContentLengthLong());
long lastProgressAt = 0L;
try (
InputStream input = new BufferedInputStream(connection.getInputStream(), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(partial, resumed), BUFFER_BYTES)
) {
byte[] buffer = new byte[BUFFER_BYTES];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
downloadedBytes += count;
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
notifyProgress(logicalPath, downloadedBytes, totalBytes);
lastProgressAt = now;
File partial = new File(destination.getParentFile(), destination.getName() + (compressed ? "." + transferSha + ".gz" : "") + ".part");
if (!verifies(partial, transferSha, transferBytes)) {
if (partial.isFile() && partial.length() >= transferBytes && !partial.delete()) {
throw new IllegalStateException("Unable to reset an invalid partial download.");
}
long existingBytes = partial.isFile() ? partial.length() : 0L;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(45_000);
connection.setRequestProperty("Accept-Encoding", "identity");
if (existingBytes > 0L) connection.setRequestProperty("Range", "bytes=" + existingBytes + "-");
int status = connection.getResponseCode();
boolean resumed = existingBytes > 0L && status == HttpURLConnection.HTTP_PARTIAL;
if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
connection.disconnect();
throw new IllegalStateException("Content server returned HTTP " + status + ".");
}
if (status == HttpURLConnection.HTTP_PARTIAL) {
String range = connection.getHeaderField("Content-Range");
if (!resumed || range == null || !range.startsWith("bytes " + existingBytes + "-")) {
connection.disconnect();
partial.delete();
throw new IllegalStateException("Content server returned an invalid resume range.");
}
}
} finally {
connection.disconnect();
}
notifyProgress(logicalPath, downloadedBytes, totalBytes);
if (!resumed) existingBytes = 0L;
long downloadedBytes = existingBytes;
long totalBytes = transferBytes > 0L
? transferBytes
: existingBytes + Math.max(0L, connection.getContentLengthLong());
long lastProgressAt = 0L;
if (!verifies(partial, sha256, expectedBytes)) {
try (
InputStream input = new BufferedInputStream(connection.getInputStream(), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(partial, resumed), BUFFER_BYTES)
) {
byte[] buffer = new byte[BUFFER_BYTES];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
downloadedBytes += count;
if (downloadedBytes > transferBytes) throw new IllegalStateException("Downloaded content exceeds expected size.");
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
notifyProgress(logicalPath, downloadedBytes, totalBytes);
lastProgressAt = now;
}
}
} finally {
connection.disconnect();
}
notifyProgress(logicalPath, downloadedBytes, totalBytes);
}
if (!verifies(partial, transferSha, transferBytes)) {
partial.delete();
throw new IllegalStateException("Downloaded content failed SHA-256 or size verification.");
}
File verified = partial;
if (compressed) {
verified = new File(destination.getParentFile(), destination.getName() + ".decoded");
try (InputStream input = new GZIPInputStream(new FileInputStream(partial), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(verified), BUFFER_BYTES)) {
byte[] buffer = new byte[BUFFER_BYTES];
long decodedBytes = 0;
int count;
while ((count = input.read(buffer)) != -1) {
decodedBytes += count;
if (decodedBytes > expectedBytes) throw new IllegalStateException("Expanded content exceeds expected size.");
output.write(buffer, 0, count);
}
} catch (Exception error) { verified.delete(); throw error; }
if (!verifies(verified, sha256, expectedBytes)) {
verified.delete();
partial.delete();
throw new IllegalStateException("Expanded content failed SHA-256 or size verification.");
}
partial.delete();
}
if (destination.exists() && !destination.delete()) {
throw new IllegalStateException("Unable to activate downloaded content.");
}
if (!partial.renameTo(destination)) {
if (!verified.renameTo(destination)) {
throw new IllegalStateException("Unable to atomically activate downloaded content.");
}
call.resolve(fileResult(destination, false));
@@ -100,6 +100,8 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
addAxisTokens(currentTokens, rightStickX, "Axis2-", "Axis2+");
addAxisTokens(currentTokens, rightStickY, "Axis3-", "Axis3+");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE), "Button6");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS), "Button7");
@@ -288,7 +288,7 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
webView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return bridge.getLocalServer().shouldInterceptRequest(request);
return bridge.getWebViewClient().shouldInterceptRequest(view, request);
}
});
}
+45
View File
@@ -0,0 +1,45 @@
# Lossless game content packaging
`npm run web:prepare` and `npm run android:prepare` include all 84 regular
dungeon packs, the existing core packs, and the dungeons' dependency closure.
Manastorm packs keep their optional install flag even when a regular dungeon
uses the same map. Manastorm-only maps and extra character packs stay optional.
The staging directories contain one object per original SHA-256 and extension
under `content/bundled`. Gzip is used only when it saves space. The packager
expands each compressed object and compares every byte with its source before
writing the bootstrap catalog. Source GLBs, texture resolutions, animations,
and audio are unchanged. No media is generated by this pipeline.
Compressed objects end in `.gzip`, not `.gz`: Android's asset packager expands
and renames `.gz` inputs. The `.gzip` extension is explicitly stored without
additional ZIP compression so the manifest and APK preserve identical bytes.
Each logical asset keeps its original byte count and SHA-256 in the manifest.
An optional `download` record describes its gzip representation, including a
separate compressed hash and byte count. The schema remains compatible with
existing catalogs. Remote publishing retains original objects for older apps;
new clients prefer the compressed representation and verify both hashes.
The Node server and Vite preview middleware resolve bundled logical URLs on
first launch. The browser service worker does the same after activation.
Android streams bundled gzip through its WebView asset handler, including the
companion display, without writing an expanded copy to app storage. Deploy the
browser build with the project's Node server so first-launch asset aliases work.
Browser optional installs store compressed objects once and keep small logical
aliases. Legacy uncompressed cache entries remain readable. Removing a pack
removes an object only after its last installed reference is gone. Android
optional downloads resume the compressed transfer, verify it, expand and verify
the original, and atomically activate the existing shared native object file.
`content/storage-report.json` inside each staging/build directory records actual
unique object bytes and duplicate savings. These are packaged asset sizes, not
GPU memory estimates. Optional Android downloads occupy their expanded size;
bundled Android assets stay compressed inside the APK.
Validation: `npm run content:test`, the content/avatar Vitest suites, the
bundled-content server tests, `npm run build`, Android compilation, and a live
roster plus dungeon screenshot check. Run `npm run release:check` before handoff.
Android build commands also run `scripts/content/verify-android-package.py`
against the resulting APK, checking every stored object and every original hash.
+35 -18
View File
@@ -1,8 +1,17 @@
# Five-player dungeon import checklist
Each dungeon is complete only when its actual instance environment, collision,
This checklist tracks the native asset and population import, not complete
instance gameplay. A checked dungeon has its environment, collision,
navigation, AzerothCore population, boss roster, and native animated creature
models are packaged under `public/assets/game/dungeons/<slug>/`.
models packaged under `public/assets/game/dungeons/<slug>/`. Use
`WOW_DUNGEON_FUNCTIONALITY_REPORT.md` for the separate production-fidelity gate
covering doors, objects, scripted progression, waves, escorts, vehicles,
traversal, resets, checkpoints, and completion playtests.
Instance-source review status (2026-09-08): 54/54 reviewed, 51/54 backed by an
executable instance controller, and 3/54 pinned as source-exact no-op instance
scripts. These counts describe implemented runtime coverage; they do not close
the native game-object export or end-to-end playtest gates.
Run one dungeon at a time:
@@ -88,24 +97,28 @@ TypeScript/production bundle checks passed.
## Ascension-specific five-player instances
- [ ] Tinkertech Showdown (`821`) - blocked on authoritative Ascension server
population/model data. Encounter identity exists for Mechatron (`663012`),
but no creature template, display mapping, or spawn rows exist locally.
- [ ] Frozen Reach (`908`) - blocked on authoritative Ascension server
population/model data. Encounter identity exists for Alva (`97700`), but no
creature template, display mapping, or spawn rows exist locally.
- [ ] Forgotten Mine (`1781`) - blocked on authoritative Ascension server
population/model data. Encounter identity exists for Geolord Vek (`254272`),
but no creature template, display mapping, or spawn rows exist locally.
- [ ] Tinkertech Showdown (`821`) - playable HealerMan-authored adaptation.
Mechatron (`663012`) and the environment are exact; the candidate machine
spell cluster, trash, cadence, positions, models, and phase rules remain
explicitly authored because no server binding or spawn snapshot survived.
- [ ] Frozen Reach (`908`) - playable source-backed adaptation. Alva (`97700`),
Arktos (`97701`), 85 exact spell rows, and summon links for entries `97703`,
`97731`, and `97770` were recovered. Server cadence, positions, stats,
models, targeting, and exact phase behavior remain authored.
- [ ] Forgotten Mine (`1781`) - playable source-backed adaptation. Geolord Vek
(`254272`), 33 exact spell rows, Geospike (`254278`), and Resonite Shard
(`254051`) were recovered. Server cadence, positions, trash, models, and the
exact crystal rule remain authored.
The individual command has been run for all three blocked instances. Their maps
and encounter identities are present in the installed client, but the installed
client and available creature caches do not provide the server-only
entry-to-display and spawn data needed to package their actual mobs/bosses.
They intentionally remain on the documented procedural fallback until an
Ascension server snapshot or reviewed creature/spawn export is supplied.
The individual import command has been run for all three source-limited
instances. Their checksummed recovery manifest lives under `ascension/` and is
validated by `npm run dungeon:ascension:recovery:validate`. Runtime adapters
consume the recovered evidence while retaining explicit `source-backed-adapted`
or `healer-man-authored` labels. The checklist remains open until an
authoritative Ascension creature/spawn/object/server-script snapshot and native
display mappings replace the remaining authored portions.
## Per-dungeon completion gate
## Per-dungeon asset/import gate
These gates passed for every checked dungeon above:
@@ -116,3 +129,7 @@ These gates passed for every checked dungeon above:
- [x] Boss order, entrance, and coordinates reviewed.
- [x] Generated campaign catalog points at `/assets/game/dungeons/<slug>/`.
- [x] Catalog verification, tests, type-check, and production bundle pass.
These checks do not certify instance-script behavior. A dungeon remains
functionally incomplete until its entry in `WOW_DUNGEON_FUNCTIONALITY_REPORT.md`
is green.
+54 -15
View File
@@ -92,8 +92,10 @@ whose preserved source has no recoverable combat ability is explicitly marked
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
trash-population fallback. Non-Epoch recipes keep explicit procedural actor
Mine—keep their recovered client identities, spell/summon evidence where
available, and explicit source-backed or HealerMan-authored runtime adaptations.
Their remaining procedural population is never labeled authoritative.
Non-Epoch recipes keep explicit procedural actor
bodies until reviewed display exports are available. Epoch actors use packaged
client GLBs with their complete native animation libraries; only event-only
controllers without a creature display fall back.
@@ -160,10 +162,13 @@ The audit verifies the complete 54-dungeon catalog, authoritative/fallback
classification, every native environment and creature URL, file sizes,
checksums, animation presence, and compiled entry-to-model bindings. The
2026-07-26 batch packaged 50 native imports; together with the Wailing Caverns
golden fixture, 51/54 dungeons are authoritative. Tinkertech Showdown, Frozen
Reach, and Forgotten Mine remain documented fallbacks because available local
sources contain their maps and encounter identities but no authoritative
Ascension creature templates, display mappings, or spawn rows.
golden fixture, 51/54 dungeons have authoritative WoW population packages.
Tinkertech Showdown, Frozen Reach, and Forgotten Mine now have playable,
provenance-labeled runtime adaptations backed by
`ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json`. They remain outside the
authoritative package count because the recovered client evidence does not
include complete Ascension creature templates, display mappings, spawn/object
coordinates, or server-side cadence.
1. `npm run dungeon:discover -- <slug>` writes deterministic client/server
snapshots, a provenance lock, review-required overrides, and—when server
@@ -224,9 +229,14 @@ The default pass recompiles the 54-dungeon campaign, verifies all packaged
environment and creature assets, audits every mob and boss attack/mechanic,
opens every unique native creature GLB to verify idle, movement, attack, wound,
and death clips, proves the procedural animation path for model-less actors,
and runs the actual runtime-registry coverage test. It writes deterministic
JSON and Markdown reports to WOW_DUNGEON_CONTENT_REPORT.json and
WOW_DUNGEON_CONTENT_REPORT.md.
runs the actual runtime-registry coverage test, and audits instance-level
functionality. It writes deterministic JSON and Markdown reports to
WOW_DUNGEON_CONTENT_REPORT.json, WOW_DUNGEON_CONTENT_REPORT.md,
WOW_DUNGEON_FUNCTIONALITY_REPORT.json, and
WOW_DUNGEON_FUNCTIONALITY_REPORT.md. Catalog compilation also writes
WOW_GAMEOBJECT_ASSET_MANIFEST.json, which deduplicates the source-backed object
spawns by client model path, preserves per-dungeon/display usage, assigns the
planned shipping GLB URL, and lists unresolved display models and bounds.
Useful batch options:
@@ -239,11 +249,40 @@ Useful batch options:
# Re-run green packages, then build the app after validation.
npm run dungeon:wow:all -- --refresh --force --build
# Only re-run combat, mechanic, and animation coverage against current output.
# Re-run combat, animation, and instance-functionality audits against current output.
npm run dungeon:wow:all -- --audit-only
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.
The functionality report is the production-fidelity gate. It separately checks
typed world-object contracts, source instance scripts, encounter orchestration,
required interactions, traversal evidence, resets, checkpoints, and completion
playtests. A green asset or combat audit does not imply that doors, escorts,
waves, vehicles, keys, chests, or scripted instance progression are implemented.
The report fails closed and lists concrete edit targets for every remaining
dungeon gap.
The 2026-09-08 implementation sweep completed an instance-level source review
for all 54 campaign dungeons. Fifty-one now have runtime-wired reviewed
orchestration in addition to fleet boss/checkpoint/completion declarations; the
three stock instance scripts that are genuinely empty are pinned and recorded
as source-exact no-ops. This means there is no remaining unreviewed instance
script, not that every source feature is already exact or live-traversal validated. Per-dungeon adaptations,
missing primitives, native-object export work, and live traversal evidence stay
open in `WOW_DUNGEON_FUNCTIONALITY_REPORT.md`.
Source-backed object records stay hidden, non-blocking, and non-interactive
until their manifest asset has been exported and the corresponding instance
script transition has been reviewed. This lets the typed data ship through the
runtime contract without leaking placeholder geometry or turning an incomplete
door/transport port into a dungeon soft lock.
Kresh, the Deviate Faerie Dragon, Grubbis, Spirestone Battle Lord, Spirestone
Butcher, and Crystal Fang are intentionally recorded as source-faithful
melee-only bosses. Kresh entry 3653 and the Deviate Faerie Dragon entry 5912
have no spell or SmartAI row in the pinned AzerothCore snapshot; the latter is
also despawned by the Wailing Caverns initialization event. Grubbis entry 7361
has only aggro dialogue and death-completion rows, with no combat spell cast.
The three Blackrock Spire entries are 15% rare spawns whose database rows
contain no active combat cast; Crystal Fang's exact on-death Spire Spiderling
summon is implemented by the reviewed instance adapter. One shared exception
table supplies the runtime test and composed-content audit; stale, duplicate,
or newly introduced basic-only boss keys fail the batch.
+243 -212
View File
@@ -1,22 +1,22 @@
{
"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,
"nativeModels": 1266,
"proceduralModels": 18,
"uniqueNativeModels": 1166,
"nativeStateFallbacks": 16
"mobs": 1193,
"bosses": 273,
"attacks": 4026,
"specialAttacks": 2572,
"nativeModels": 1275,
"proceduralModels": 191,
"uniqueNativeModels": 1167,
"nativeStateFallbacks": 18
},
"documentedExceptions": [
{
"type": "basic-only-boss",
"key": "wailing-caverns:entry-3653",
"key": "wailing-caverns:kresh",
"reason": "Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653."
},
{
@@ -25,9 +25,24 @@
"reason": "The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event."
},
{
"type": "no-trash-instance",
"key": "trial-of-the-champion",
"reason": "Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route."
"type": "basic-only-boss",
"key": "gnomeregan:wow-classic-batch-three-entry-7361",
"reason": "Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast."
},
{
"type": "basic-only-boss",
"key": "blackrock-spire:entry-9218",
"reason": "Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack."
},
{
"type": "basic-only-boss",
"key": "blackrock-spire:entry-9219",
"reason": "Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack."
},
{
"type": "basic-only-boss",
"key": "blackrock-spire:entry-10376",
"reason": "Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter."
}
],
"animationFallbacks": [
@@ -47,6 +62,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 +79,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",
@@ -164,14 +195,26 @@
],
"issues": [],
"dungeons": [
{
"id": "wailing-caverns",
"title": "Wailing Caverns",
"source": "azerothcore",
"mobs": 23,
"bosses": 9,
"attacks": 72,
"specialAttacks": 40,
"nativeModels": 24,
"proceduralModels": 8,
"status": "green"
},
{
"id": "shadowfang-keep",
"title": "Shadowfang Keep",
"source": "azerothcore",
"mobs": 24,
"bosses": 7,
"attacks": 58,
"specialAttacks": 27,
"attacks": 65,
"specialAttacks": 34,
"nativeModels": 31,
"proceduralModels": 0,
"status": "green"
@@ -182,8 +225,8 @@
"source": "azerothcore",
"mobs": 6,
"bosses": 5,
"attacks": 32,
"specialAttacks": 21,
"attacks": 42,
"specialAttacks": 31,
"nativeModels": 11,
"proceduralModels": 0,
"status": "green"
@@ -194,32 +237,20 @@
"source": "azerothcore",
"mobs": 15,
"bosses": 7,
"attacks": 48,
"specialAttacks": 26,
"attacks": 55,
"specialAttacks": 33,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
},
{
"id": "wailing-caverns",
"title": "Wailing Caverns",
"source": "azerothcore",
"mobs": 12,
"bosses": 8,
"attacks": 51,
"specialAttacks": 31,
"nativeModels": 15,
"proceduralModels": 5,
"status": "green"
},
{
"id": "razorfen-kraul",
"title": "Razorfen Kraul",
"source": "azerothcore",
"mobs": 31,
"bosses": 7,
"attacks": 78,
"specialAttacks": 40,
"attacks": 87,
"specialAttacks": 49,
"nativeModels": 38,
"proceduralModels": 0,
"status": "green"
@@ -228,12 +259,12 @@
"id": "blackfathom-deeps",
"title": "Blackfathom Deeps",
"source": "azerothcore",
"mobs": 21,
"mobs": 23,
"bosses": 6,
"attacks": 58,
"specialAttacks": 31,
"attacks": 67,
"specialAttacks": 38,
"nativeModels": 27,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
@@ -242,8 +273,8 @@
"source": "azerothcore",
"mobs": 31,
"bosses": 7,
"attacks": 85,
"specialAttacks": 47,
"attacks": 104,
"specialAttacks": 66,
"nativeModels": 38,
"proceduralModels": 0,
"status": "green"
@@ -252,36 +283,36 @@
"id": "gnomeregan",
"title": "Gnomeregan",
"source": "azerothcore",
"mobs": 27,
"bosses": 4,
"attacks": 68,
"specialAttacks": 37,
"mobs": 28,
"bosses": 5,
"attacks": 84,
"specialAttacks": 51,
"nativeModels": 31,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
"id": "sunken-temple",
"title": "Sunken Temple",
"source": "azerothcore",
"mobs": 29,
"mobs": 31,
"bosses": 4,
"attacks": 86,
"specialAttacks": 53,
"attacks": 95,
"specialAttacks": 60,
"nativeModels": 33,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
"id": "razorfen-downs",
"title": "Razorfen Downs",
"source": "azerothcore",
"mobs": 19,
"mobs": 21,
"bosses": 6,
"attacks": 55,
"specialAttacks": 30,
"attacks": 65,
"specialAttacks": 38,
"nativeModels": 25,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
@@ -290,8 +321,8 @@
"source": "azerothcore",
"mobs": 32,
"bosses": 5,
"attacks": 89,
"specialAttacks": 52,
"attacks": 107,
"specialAttacks": 70,
"nativeModels": 37,
"proceduralModels": 0,
"status": "green"
@@ -300,60 +331,60 @@
"id": "zulfarrak",
"title": "Zul'Farrak",
"source": "azerothcore",
"mobs": 16,
"mobs": 23,
"bosses": 5,
"attacks": 53,
"specialAttacks": 32,
"attacks": 76,
"specialAttacks": 48,
"nativeModels": 21,
"proceduralModels": 0,
"proceduralModels": 7,
"status": "green"
},
{
"id": "blackrock-spire",
"title": "Blackrock Spire",
"source": "azerothcore",
"mobs": 63,
"bosses": 15,
"attacks": 210,
"specialAttacks": 132,
"mobs": 66,
"bosses": 17,
"attacks": 285,
"specialAttacks": 202,
"nativeModels": 78,
"proceduralModels": 0,
"proceduralModels": 5,
"status": "green"
},
{
"id": "blackrock-depths",
"title": "Blackrock Depths",
"source": "azerothcore",
"mobs": 48,
"mobs": 58,
"bosses": 13,
"attacks": 145,
"specialAttacks": 84,
"attacks": 192,
"specialAttacks": 121,
"nativeModels": 61,
"proceduralModels": 0,
"proceduralModels": 10,
"status": "green"
},
{
"id": "scholomance",
"title": "Scholomance",
"source": "azerothcore",
"mobs": 30,
"mobs": 31,
"bosses": 8,
"attacks": 115,
"specialAttacks": 77,
"attacks": 151,
"specialAttacks": 112,
"nativeModels": 38,
"proceduralModels": 0,
"proceduralModels": 1,
"status": "green"
},
{
"id": "stratholme",
"title": "Stratholme",
"source": "azerothcore",
"mobs": 41,
"mobs": 45,
"bosses": 9,
"attacks": 131,
"specialAttacks": 81,
"attacks": 180,
"specialAttacks": 126,
"nativeModels": 50,
"proceduralModels": 0,
"proceduralModels": 4,
"status": "green"
},
{
@@ -362,8 +393,8 @@
"source": "azerothcore",
"mobs": 25,
"bosses": 8,
"attacks": 83,
"specialAttacks": 50,
"attacks": 95,
"specialAttacks": 62,
"nativeModels": 33,
"proceduralModels": 0,
"status": "green"
@@ -374,8 +405,8 @@
"source": "azerothcore",
"mobs": 7,
"bosses": 4,
"attacks": 21,
"specialAttacks": 10,
"attacks": 23,
"specialAttacks": 12,
"nativeModels": 11,
"proceduralModels": 0,
"status": "green"
@@ -386,8 +417,8 @@
"source": "azerothcore",
"mobs": 63,
"bosses": 12,
"attacks": 184,
"specialAttacks": 109,
"attacks": 242,
"specialAttacks": 167,
"nativeModels": 75,
"proceduralModels": 0,
"status": "green"
@@ -398,8 +429,8 @@
"source": "azerothcore",
"mobs": 29,
"bosses": 3,
"attacks": 74,
"specialAttacks": 42,
"attacks": 92,
"specialAttacks": 60,
"nativeModels": 32,
"proceduralModels": 0,
"status": "green"
@@ -410,8 +441,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 3,
"attacks": 44,
"specialAttacks": 27,
"attacks": 53,
"specialAttacks": 36,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -420,12 +451,12 @@
"id": "hellfire-ramparts",
"title": "Hellfire Citadel: Ramparts",
"source": "azerothcore",
"mobs": 10,
"bosses": 2,
"attacks": 36,
"specialAttacks": 24,
"mobs": 11,
"bosses": 4,
"attacks": 47,
"specialAttacks": 32,
"nativeModels": 12,
"proceduralModels": 0,
"proceduralModels": 3,
"status": "green"
},
{
@@ -434,8 +465,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 3,
"attacks": 50,
"specialAttacks": 33,
"attacks": 63,
"specialAttacks": 46,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -444,48 +475,48 @@
"id": "underbog",
"title": "Coilfang: The Underbog",
"source": "azerothcore",
"mobs": 18,
"mobs": 20,
"bosses": 4,
"attacks": 59,
"specialAttacks": 37,
"attacks": 82,
"specialAttacks": 58,
"nativeModels": 22,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
"id": "slave-pens",
"title": "Coilfang: The Slave Pens",
"source": "azerothcore",
"mobs": 18,
"bosses": 3,
"attacks": 53,
"specialAttacks": 32,
"mobs": 21,
"bosses": 4,
"attacks": 66,
"specialAttacks": 43,
"nativeModels": 21,
"proceduralModels": 0,
"proceduralModels": 4,
"status": "green"
},
{
"id": "arcatraz",
"title": "Tempest Keep: The Arcatraz",
"source": "azerothcore",
"mobs": 25,
"mobs": 28,
"bosses": 4,
"attacks": 73,
"specialAttacks": 44,
"attacks": 108,
"specialAttacks": 76,
"nativeModels": 29,
"proceduralModels": 0,
"proceduralModels": 3,
"status": "green"
},
{
"id": "botanica",
"title": "Tempest Keep: The Botanica",
"source": "azerothcore",
"mobs": 24,
"mobs": 28,
"bosses": 5,
"attacks": 67,
"specialAttacks": 38,
"attacks": 89,
"specialAttacks": 56,
"nativeModels": 29,
"proceduralModels": 0,
"proceduralModels": 4,
"status": "green"
},
{
@@ -494,8 +525,8 @@
"source": "azerothcore",
"mobs": 14,
"bosses": 3,
"attacks": 45,
"specialAttacks": 28,
"attacks": 69,
"specialAttacks": 52,
"nativeModels": 17,
"proceduralModels": 0,
"status": "green"
@@ -506,8 +537,8 @@
"source": "azerothcore",
"mobs": 18,
"bosses": 4,
"attacks": 46,
"specialAttacks": 24,
"attacks": 67,
"specialAttacks": 45,
"nativeModels": 22,
"proceduralModels": 0,
"status": "green"
@@ -516,72 +547,72 @@
"id": "sethekk-halls",
"title": "Auchindoun: Sethekk Halls",
"source": "azerothcore",
"mobs": 14,
"bosses": 2,
"attacks": 39,
"specialAttacks": 23,
"mobs": 19,
"bosses": 3,
"attacks": 59,
"specialAttacks": 37,
"nativeModels": 16,
"proceduralModels": 0,
"proceduralModels": 6,
"status": "green"
},
{
"id": "mana-tombs",
"title": "Auchindoun: Mana-Tombs",
"source": "azerothcore",
"mobs": 11,
"bosses": 3,
"attacks": 33,
"specialAttacks": 19,
"mobs": 12,
"bosses": 4,
"attacks": 50,
"specialAttacks": 34,
"nativeModels": 14,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
"id": "auchenai-crypts",
"title": "Auchindoun: Auchenai Crypts",
"source": "azerothcore",
"mobs": 12,
"bosses": 2,
"attacks": 30,
"specialAttacks": 16,
"mobs": 13,
"bosses": 3,
"attacks": 48,
"specialAttacks": 32,
"nativeModels": 14,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
"id": "magisters-terrace",
"title": "Magister's Terrace",
"source": "azerothcore",
"mobs": 15,
"mobs": 23,
"bosses": 3,
"attacks": 33,
"specialAttacks": 15,
"attacks": 65,
"specialAttacks": 39,
"nativeModels": 18,
"proceduralModels": 0,
"proceduralModels": 8,
"status": "green"
},
{
"id": "black-morass",
"title": "The Black Morass",
"source": "azerothcore",
"mobs": 4,
"mobs": 13,
"bosses": 3,
"attacks": 20,
"specialAttacks": 13,
"attacks": 34,
"specialAttacks": 18,
"nativeModels": 7,
"proceduralModels": 0,
"proceduralModels": 9,
"status": "green"
},
{
"id": "escape-from-durnholde",
"title": "The Escape From Durnholde",
"source": "azerothcore",
"mobs": 26,
"mobs": 32,
"bosses": 3,
"attacks": 45,
"specialAttacks": 16,
"attacks": 59,
"specialAttacks": 24,
"nativeModels": 29,
"proceduralModels": 0,
"proceduralModels": 6,
"status": "green"
},
{
@@ -589,107 +620,107 @@
"title": "Utgarde Keep",
"source": "azerothcore",
"mobs": 19,
"bosses": 3,
"attacks": 35,
"specialAttacks": 13,
"bosses": 6,
"attacks": 55,
"specialAttacks": 30,
"nativeModels": 22,
"proceduralModels": 0,
"proceduralModels": 3,
"status": "green"
},
{
"id": "utgarde-pinnacle",
"title": "Utgarde Pinnacle",
"source": "azerothcore",
"mobs": 13,
"mobs": 19,
"bosses": 4,
"attacks": 32,
"specialAttacks": 15,
"attacks": 50,
"specialAttacks": 27,
"nativeModels": 17,
"proceduralModels": 0,
"proceduralModels": 6,
"status": "green"
},
{
"id": "the-nexus",
"title": "The Nexus",
"source": "azerothcore",
"mobs": 21,
"mobs": 24,
"bosses": 5,
"attacks": 50,
"specialAttacks": 24,
"attacks": 64,
"specialAttacks": 35,
"nativeModels": 26,
"proceduralModels": 0,
"proceduralModels": 3,
"status": "green"
},
{
"id": "the-oculus",
"title": "The Oculus",
"source": "azerothcore",
"mobs": 8,
"mobs": 17,
"bosses": 4,
"attacks": 27,
"specialAttacks": 15,
"attacks": 42,
"specialAttacks": 21,
"nativeModels": 12,
"proceduralModels": 0,
"proceduralModels": 9,
"status": "green"
},
{
"id": "culling-of-stratholme",
"title": "The Culling of Stratholme",
"source": "azerothcore",
"mobs": 16,
"mobs": 21,
"bosses": 4,
"attacks": 41,
"specialAttacks": 21,
"attacks": 49,
"specialAttacks": 24,
"nativeModels": 20,
"proceduralModels": 0,
"proceduralModels": 5,
"status": "green"
},
{
"id": "halls-of-stone",
"title": "Halls of Stone",
"source": "azerothcore",
"mobs": 13,
"mobs": 16,
"bosses": 4,
"attacks": 32,
"specialAttacks": 15,
"attacks": 45,
"specialAttacks": 25,
"nativeModels": 16,
"proceduralModels": 1,
"proceduralModels": 4,
"status": "green"
},
{
"id": "draktharon-keep",
"title": "Drak'Tharon Keep",
"source": "azerothcore",
"mobs": 18,
"mobs": 20,
"bosses": 4,
"attacks": 38,
"specialAttacks": 16,
"attacks": 47,
"specialAttacks": 23,
"nativeModels": 22,
"proceduralModels": 0,
"proceduralModels": 2,
"status": "green"
},
{
"id": "azjol-nerub",
"title": "Azjol-Nerub",
"source": "azerothcore",
"mobs": 10,
"mobs": 18,
"bosses": 3,
"attacks": 25,
"specialAttacks": 12,
"attacks": 38,
"specialAttacks": 17,
"nativeModels": 13,
"proceduralModels": 0,
"proceduralModels": 8,
"status": "green"
},
{
"id": "halls-of-lightning",
"title": "Halls of Lightning",
"source": "azerothcore",
"mobs": 16,
"mobs": 19,
"bosses": 4,
"attacks": 35,
"specialAttacks": 15,
"attacks": 52,
"specialAttacks": 29,
"nativeModels": 20,
"proceduralModels": 0,
"proceduralModels": 3,
"status": "green"
},
{
@@ -698,8 +729,8 @@
"source": "azerothcore",
"mobs": 15,
"bosses": 5,
"attacks": 43,
"specialAttacks": 23,
"attacks": 53,
"specialAttacks": 33,
"nativeModels": 20,
"proceduralModels": 0,
"status": "green"
@@ -708,24 +739,24 @@
"id": "violet-hold",
"title": "Violet Hold",
"source": "azerothcore",
"mobs": 2,
"mobs": 17,
"bosses": 3,
"attacks": 19,
"specialAttacks": 14,
"attacks": 39,
"specialAttacks": 19,
"nativeModels": 5,
"proceduralModels": 0,
"proceduralModels": 15,
"status": "green"
},
{
"id": "ahnkahet-old-kingdom",
"title": "Ahn'kahet: The Old Kingdom",
"source": "azerothcore",
"mobs": 19,
"mobs": 23,
"bosses": 5,
"attacks": 43,
"specialAttacks": 19,
"attacks": 64,
"specialAttacks": 36,
"nativeModels": 24,
"proceduralModels": 0,
"proceduralModels": 4,
"status": "green"
},
{
@@ -734,8 +765,8 @@
"source": "azerothcore",
"mobs": 11,
"bosses": 2,
"attacks": 21,
"specialAttacks": 8,
"attacks": 23,
"specialAttacks": 10,
"nativeModels": 13,
"proceduralModels": 0,
"status": "green"
@@ -744,36 +775,36 @@
"id": "trial-of-the-champion",
"title": "Trial of the Champion",
"source": "azerothcore",
"mobs": 0,
"bosses": 5,
"attacks": 22,
"specialAttacks": 17,
"mobs": 6,
"bosses": 12,
"attacks": 123,
"specialAttacks": 105,
"nativeModels": 5,
"proceduralModels": 0,
"proceduralModels": 13,
"status": "green"
},
{
"id": "pit-of-saron",
"title": "Pit of Saron",
"source": "azerothcore",
"mobs": 27,
"mobs": 31,
"bosses": 3,
"attacks": 43,
"specialAttacks": 13,
"attacks": 53,
"specialAttacks": 19,
"nativeModels": 30,
"proceduralModels": 0,
"proceduralModels": 4,
"status": "green"
},
{
"id": "halls-of-reflection",
"title": "Halls of Reflection",
"source": "azerothcore",
"mobs": 8,
"mobs": 16,
"bosses": 2,
"attacks": 26,
"specialAttacks": 16,
"attacks": 46,
"specialAttacks": 28,
"nativeModels": 10,
"proceduralModels": 0,
"proceduralModels": 8,
"status": "green"
},
{
@@ -782,8 +813,8 @@
"source": "procedural-fallback",
"mobs": 3,
"bosses": 1,
"attacks": 8,
"specialAttacks": 4,
"attacks": 13,
"specialAttacks": 12,
"nativeModels": 0,
"proceduralModels": 4,
"status": "green"
@@ -792,24 +823,24 @@
"id": "frozen-reach",
"title": "Frozen Reach",
"source": "procedural-fallback",
"mobs": 3,
"mobs": 2,
"bosses": 1,
"attacks": 8,
"specialAttacks": 4,
"attacks": 19,
"specialAttacks": 19,
"nativeModels": 0,
"proceduralModels": 4,
"proceduralModels": 3,
"status": "green"
},
{
"id": "forgotten-mine",
"title": "Forgotten Mine",
"source": "procedural-fallback",
"mobs": 3,
"mobs": 4,
"bosses": 1,
"attacks": 8,
"specialAttacks": 4,
"attacks": 13,
"specialAttacks": 12,
"nativeModels": 0,
"proceduralModels": 4,
"proceduralModels": 5,
"status": "green"
}
]
+64 -59
View File
@@ -1,73 +1,78 @@
# 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, 1193 mobs, 273 bosses, 4026 executable attacks, and 2572 special attacks/mechanics.
Animation paths: 1275 native model bindings and 191 procedural bindings across 1167 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 |
| 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 |
| 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 |
| Wailing Caverns | 23 | 9 | 72 | 40 | 24 | 8 | 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 |
| Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
| Blackfathom Deeps | 23 | 6 | 67 | 38 | 27 | 2 | green |
| Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
| Gnomeregan | 28 | 5 | 84 | 51 | 31 | 2 | green |
| Sunken Temple | 31 | 4 | 95 | 60 | 33 | 2 | green |
| Razorfen Downs | 21 | 6 | 65 | 38 | 25 | 2 | green |
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
| Zul'Farrak | 23 | 5 | 76 | 48 | 21 | 7 | green |
| Blackrock Spire | 66 | 17 | 285 | 202 | 78 | 5 | green |
| Blackrock Depths | 58 | 13 | 192 | 121 | 61 | 10 | green |
| Scholomance | 31 | 8 | 151 | 112 | 38 | 1 | green |
| Stratholme | 45 | 9 | 180 | 126 | 50 | 4 | 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 | 11 | 4 | 47 | 32 | 12 | 3 | green |
| Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
| Coilfang: The Underbog | 20 | 4 | 82 | 58 | 22 | 2 | green |
| Coilfang: The Slave Pens | 21 | 4 | 66 | 43 | 21 | 4 | green |
| Tempest Keep: The Arcatraz | 28 | 4 | 108 | 76 | 29 | 3 | green |
| Tempest Keep: The Botanica | 28 | 5 | 89 | 56 | 29 | 4 | 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 | 19 | 3 | 59 | 37 | 16 | 6 | green |
| Auchindoun: Mana-Tombs | 12 | 4 | 50 | 34 | 14 | 2 | green |
| Auchindoun: Auchenai Crypts | 13 | 3 | 48 | 32 | 14 | 2 | green |
| Magister's Terrace | 23 | 3 | 65 | 39 | 18 | 8 | green |
| The Black Morass | 13 | 3 | 34 | 18 | 7 | 9 | green |
| The Escape From Durnholde | 32 | 3 | 59 | 24 | 29 | 6 | green |
| Utgarde Keep | 19 | 6 | 55 | 30 | 22 | 3 | green |
| Utgarde Pinnacle | 19 | 4 | 50 | 27 | 17 | 6 | green |
| The Nexus | 24 | 5 | 64 | 35 | 26 | 3 | green |
| The Oculus | 17 | 4 | 42 | 21 | 12 | 9 | green |
| The Culling of Stratholme | 21 | 4 | 49 | 24 | 20 | 5 | green |
| Halls of Stone | 16 | 4 | 45 | 25 | 16 | 4 | green |
| Drak'Tharon Keep | 20 | 4 | 47 | 23 | 22 | 2 | green |
| Azjol-Nerub | 18 | 3 | 38 | 17 | 13 | 8 | green |
| Halls of Lightning | 19 | 4 | 52 | 29 | 20 | 3 | green |
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
| Violet Hold | 17 | 3 | 39 | 19 | 5 | 15 | green |
| Ahn'kahet: The Old Kingdom | 23 | 5 | 64 | 36 | 24 | 4 | green |
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
| Trial of the Champion | 6 | 12 | 123 | 105 | 5 | 13 | green |
| Pit of Saron | 31 | 3 | 53 | 19 | 30 | 4 | green |
| Halls of Reflection | 16 | 2 | 46 | 28 | 10 | 8 | green |
| Tinkertech Showdown | 3 | 1 | 13 | 12 | 0 | 4 | green |
| Frozen Reach | 2 | 1 | 19 | 19 | 0 | 3 | green |
| Forgotten Mine | 4 | 1 | 13 | 12 | 0 | 5 | green |
## Documented source-faithful exceptions
- wailing-caverns:entry-3653: Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.
- wailing-caverns:kresh: Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.
- wailing-caverns:deviate-faerie-dragon: The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.
- trial-of-the-champion: Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route.
- gnomeregan:wow-classic-batch-three-entry-7361: Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast.
- blackrock-spire:entry-9218: Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.
- blackrock-spire:entry-9219: Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.
- blackrock-spire:entry-10376: Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter.
## Issues
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,858 @@
{
"schemaVersion": 1,
"scope": "ascension-custom-five-player-source-recovery",
"status": "partial-authoritative-recovery",
"sourcePolicy": {
"exact": "Values copied from installed-client DBC rows or already checksummed map packages.",
"spellLinked": "Creature and gameobject IDs recovered from summon effects in installed-client Spell.dbc. The link is exact even when the missing creature_template or gameobject_template name is inferred from the spell context.",
"authored": "Timing, phase ownership, placement, health tuning, and any entity explicitly marked authored are adaptation decisions, not claims about Ascension server behavior."
},
"sources": [
{
"id": "ascension-area52-spell-dbc",
"kind": "installed-client-dbc",
"pathHint": "../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
"sha256": "2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce",
"header": {
"signature": "WDBC",
"recordCount": 238891,
"fieldCount": 234,
"recordSize": 936,
"stringBlockSize": 14058299
},
"localeField": "enUS",
"confidence": "exact"
},
{
"id": "manastorm-runtime-catalog",
"kind": "installed-client-encounter-export",
"path": "src/game/generated/manastormRuntimeCatalog.json",
"confidence": "exact"
},
{
"id": "manastorm-asset-packages",
"kind": "installed-client-map-export",
"path": "src/game/generated/manastormAssetPackages.json",
"confidence": "exact-environment-provisional-semantics"
},
{
"id": "campaign-runtime-snapshot",
"kind": "generated-runtime-catalog",
"path": "src/game/generated/dungeonCampaignCatalog.json",
"confidence": "exact-current-state"
},
{
"id": "ascension-db-alva",
"kind": "official-public-database",
"url": "https://db.ascension.gg/?npc=97700",
"confidence": "exact"
},
{
"id": "ascension-db-arktos",
"kind": "official-public-database",
"url": "https://db.ascension.gg/?npc=97701",
"confidence": "exact"
},
{
"id": "ascension-changelog-frozen-reach-blast-arrow",
"kind": "official-changelog",
"url": "https://ascension.gg/en/changelog/1?page=7",
"confidence": "exact-wording-name-differs"
},
{
"id": "ascension-changelog-frozen-reach-rebirth",
"kind": "official-changelog",
"url": "https://ascension.gg/en/changelog/1?page=8",
"confidence": "exact"
},
{
"id": "ascension-changelog-forgotten-mine-crystals",
"kind": "official-changelog",
"url": "https://ascension.gg/en/changelog/1?page=51",
"confidence": "exact"
},
{
"id": "ascension-changelog-forgotten-mine-geospike",
"kind": "official-changelog",
"url": "https://ascension.gg/en/changelog/1?page=29",
"confidence": "exact"
}
],
"localSearchAudit": {
"searchedRoots": [
"../wow-pvt-server-backups-2",
"../WoW-Pvt-Server-Backups",
"../wow-private-server-notes",
"../LadiksMPQEditor",
"../wow335a/Cache",
"../resources/client/Cache"
],
"searchedArtifacts": [
"creature_template and creature spawn SQL",
"gameobject_template and gameobject spawn SQL",
"SmartAI and ScriptName bindings",
"Lua/XML/JSON/CSV encounter data",
"creaturecache.wdb and gameobjectcache.wdb records"
],
"missingAuthoritativeData": [
"creature templates and display mappings for 663012, 97700, and 254272",
"world spawn rows for maps 821, 908, and 1781",
"server-side encounter scripts and cast cadence",
"authoritative trash population and patrol routes",
"authoritative gameobject placements"
],
"publicSnapshotCheck": {
"repository": "https://github.com/Ascension-Addons/Questie-X-AscensionDB.git",
"commit": "cf6710ef94c264d842cf2ea94c4b50e9ece6dd61",
"result": "No target boss IDs, names, spawns, or encounter scripts were present."
}
},
"dungeons": [
{
"mapId": 821,
"id": "tinkertech-showdown",
"title": "Tinkertech Showdown",
"mapDirectory": "TinketechShowdown",
"sourceClassification": "identity-and-environment-only",
"environment": {
"sourceHash": "323c47c081d0a6c39daf9878284863c21e3e38dbced2ba3f58d1d157d080d41d",
"rootWmo": "World/wmo/Dungeon/AZ_Subway/Subway_Dungeon.wmo",
"anchors": {
"player": [
3.648781,
-4.167801,
66.263252
],
"trash": [
10.220209,
-126.067818,
1266.206177
],
"boss": [
-15.779793,
-4.267807,
2439.406006
]
},
"semanticStatus": "provisional-geometry-derived"
},
"encounters": [
{
"encounterId": 9013,
"modeId": 2,
"name": "Mechatron",
"creatureId": 663012,
"identityConfidence": "exact"
}
],
"currentRuntime": {
"bossEntityId": "entry-663012",
"proceduralTrashSpawnCount": 15,
"proceduralMobEntries": [
{
"entityId": "tinkertech-showdown-guardian",
"serverEntry": 2629079128
},
{
"entityId": "tinkertech-showdown-caster",
"serverEntry": 3849447491
},
{
"entityId": "tinkertech-showdown-brute",
"serverEntry": 1787979165
}
]
},
"sourceRecovery": {
"association": "candidate-client-cluster-only",
"associationConfidence": "low",
"reason": "These adjacent client rows form a coherent mechanical-machine kit and include Sound: Goblin Head Machine Loop, but no recovered server row links them to creature 663012.",
"spellRows": {
"257438": "Rocket",
"257459": "Gatling Missile Barrage",
"257460": "Gatling Missile",
"257461": "Gatling Missile",
"257462": "Sound: Goblin Head Machine Loop",
"257470": "Incineration Beam",
"257471": "Energy Ball",
"257472": "Electrified Zone",
"257473": "Electric Shock"
},
"families": {
"rocket": [
257438
],
"gatlingMissileBarrage": [
257459,
257460,
257461
],
"incinerationBeam": [
257470
],
"electrifiedZone": [
257472,
257473
]
}
},
"adaptation": {
"status": "custom-authoring-required",
"populationPlan": {
"disposition": "retain-current-route-as-explicit-adaptation",
"packCount": 5,
"membersPerPack": 3,
"authoredMobs": [
{
"serverEntry": 2629079128,
"name": "Tinkertech Guardbot",
"role": "melee-interceptor"
},
{
"serverEntry": 3849447491,
"name": "Tinkertech Coilcaster",
"role": "electrical-ranged"
},
{
"serverEntry": 1787979165,
"name": "Tinkertech Demolisher",
"role": "rocket-bruiser"
}
]
},
"encounterPlan": [
"Mechatron remains creature 663012 at the provisional boss anchor.",
"Rocket uses a 2.0 second target reticle and splash damage on an authored 8 second cadence.",
"Gatling Missile Barrage uses a 3.0 second warning followed by eight lane impacts over 4 seconds on an authored 14 second cadence.",
"Incineration Beam uses a 2.5 second wind-up and sweeps the subway lane for 5 seconds; immutable web geometry means it visually disables lane sections instead of permanently destroying client platforms.",
"Electrified Zone alternates floor strips after a 1.5 second warning, remains active for 8 seconds, and applies a 1.5 second stun on contact.",
"At 35 percent health, cadence accelerates by 20 percent and two authored Coilcasters enter from the previous pack anchor."
],
"mustBeCustomAuthored": [
"all trash identities, placements, and AI",
"all boss timing and phase thresholds",
"the decision to associate candidate spells 257438 and 257459-257473 with Mechatron",
"platform or lane disable behavior",
"boss and trash visual composites",
"loot and completion scripting"
]
},
"unresolved": [
"No authoritative Mechatron spell-to-creature binding was found.",
"No authoritative trash or gameobject IDs were found.",
"The custom ADT placement transform and all semantic anchors still require playtest approval."
]
},
{
"mapId": 908,
"id": "frozen-reach",
"title": "Frozen Reach",
"mapDirectory": "Alvaencounter",
"sourceClassification": "encounter-spells-and-summons-recovered",
"environment": {
"sourceHash": "5ab771df79c0e062cea3ebda4e416a9245bcf8a4cc6da048077df5e674081f97",
"anchors": {
"player": [
1858.534668,
78.860199,
2229.334473
],
"trash": [
2100.134766,
80.860199,
2132.93457
],
"boss": [
2344.134766,
62.460205,
2134.334717
]
},
"semanticStatus": "provisional-elevated-encounter-component"
},
"encounters": [
{
"encounterId": 2168,
"modeId": 0,
"name": "Alva",
"creatureId": 97700,
"identityConfidence": "exact"
},
{
"encounterId": 2170,
"modeId": 2,
"name": "Alva",
"creatureId": 97700,
"identityConfidence": "exact"
}
],
"entities": [
{
"entryId": 97700,
"name": "Alva",
"role": "boss",
"sourceKind": "encounter-dbc-and-official-db"
},
{
"entryId": 97701,
"name": "Arktos",
"role": "boss-companion",
"sourceKind": "official-db"
},
{
"entryId": 97703,
"name": "Blast Arrow summoned unit",
"role": "area-trigger",
"sourceKind": "spell-linked-name-inferred"
},
{
"entryId": 97731,
"name": "Explosive Trap summoned unit",
"role": "trap",
"sourceKind": "spell-linked-name-inferred"
},
{
"entryId": 97770,
"name": "Alvajar summoned unit",
"role": "add",
"sourceKind": "spell-linked-name-inferred"
}
],
"currentRuntime": {
"bossEntityId": "entry-97700",
"proceduralTrashSpawnCount": 15,
"proceduralMobEntries": [
{
"entityId": "frozen-reach-guardian",
"serverEntry": 1606324391
},
{
"entityId": "frozen-reach-caster",
"serverEntry": 854807736
},
{
"entityId": "frozen-reach-brute",
"serverEntry": 2855026892
}
]
},
"sourceRecovery": {
"association": "explicit-alva-arktos-client-cluster",
"associationConfidence": "high",
"spellRows": {
"255347": "Auto Shot",
"255349": "Rain of Arrows",
"255351": "Rain of Arrows",
"255352": "Unbridled Arrows",
"255353": "Unbridled Arrows",
"255358": "Blast Arrow",
"255359": "Blast Arrow",
"255360": "Pact of the Hunt",
"255362": "Blast Arrow",
"255363": "Blast Arrow",
"255374": "Ice Rock",
"255375": "Ice Rock",
"255377": "Ice Rock",
"255378": "Unholy Frenzy",
"255379": "Carnivorous Bite",
"255380": "Frost Shield",
"255381": "Set Ablaze",
"255382": "Blaze",
"255387": "Frost Strike",
"255389": "Shadow Shroud Surge",
"255390": "Shadow Shroud Rebirth",
"255393": "Sphere of Grief and Wrath",
"255394": "Vengeful Bolt",
"255395": "Vengeful Bolt",
"255397": "Shadow Veil Lance",
"255398": "Shadow Veil Lance",
"255399": "Frost Roar",
"255401": "Cluster Shot",
"255402": "Cluster Shot",
"255403": "Cluster Shot",
"255410": "Alva's Grief",
"255411": "Alva's Grief",
"255412": "Frost orb",
"255413": "Frozen Pulse",
"255417": "Shield Wall",
"255418": "Shield Wall",
"255419": "Alva's Wrath",
"255420": "Throw Explosive Trap",
"255421": "Explosive Trap",
"255423": "Trap Explosion",
"255424": "Charging",
"255426": "Explosive Trap",
"255427": "Frostblade Strike",
"255428": "Frostblade Strike",
"255432": "Army of the Dead",
"255433": "Army of the Dead",
"255434": "Lacerate",
"255435": "Lacerate",
"255436": "Lacerate",
"255437": "Arcing Slice",
"255438": "Rain of Arrows",
"255439": "Rain of Arrows",
"255440": "Cluster Shot",
"255441": "Cluster Shot",
"255442": "Unbridled Arrows",
"255443": "Unbridled Arrows",
"255444": "Blast Arrow",
"255445": "Blast Arrow",
"255446": "Frost Roar",
"255447": "Frost Roar",
"255448": "Vengeful Bolt",
"255449": "Vengeful Bolt",
"255450": "Ice Rock",
"255451": "Ice Rock",
"255452": "Frostblade Strike",
"255453": "Frostblade Strike",
"255454": "Shadow Shroud Surge",
"255455": "Shadow Shroud Surge",
"255456": "Shadow Shroud Rebirth",
"255457": "Shadow Shroud Rebirth",
"255458": "Shadow Veil Lance",
"255459": "Shadow Veil Lance",
"255460": "Alva's Wrath",
"255461": "Alva's Wrath",
"255462": "Sphere of Grief and Wrath",
"255463": "Sphere of Grief and Wrath",
"255464": "Frost Strike",
"255465": "Frost Strike",
"255466": "Predator's Fury",
"255467": "Alva's Grief",
"255468": "Alva's Grief",
"255469": "Trap Explosion",
"255470": "Trap Explosion",
"255471": "Frozen Pulse",
"255472": "Frozen Pulse"
},
"families": {
"alvaRanged": [
255347,
255349,
255351,
255352,
255353,
255358,
255359,
255401,
255402,
255403,
255420,
255421,
255423,
255424
],
"arktos": [
255360,
255374,
255375,
255377,
255378,
255379,
255380,
255381,
255382,
255387,
255399,
255417,
255418,
255434
],
"alvaVengeance": [
255389,
255390,
255393,
255394,
255395,
255397,
255398,
255410,
255411,
255419,
255432,
255433
],
"difficultyOneAndTwoVariants": [
255435,
255436,
255438,
255439,
255440,
255441,
255442,
255443,
255444,
255445,
255446,
255447,
255448,
255449,
255450,
255451,
255452,
255453,
255454,
255455,
255456,
255457,
255458,
255459,
255460,
255461,
255462,
255463,
255464,
255465,
255467,
255468,
255469,
255470,
255471,
255472
]
},
"summonLinks": [
{
"spellId": 255363,
"effectIndex": 0,
"effectType": 28,
"targetKind": "creature",
"entryId": 97703
},
{
"spellId": 255421,
"effectIndex": 0,
"effectType": 28,
"targetKind": "creature",
"entryId": 97731
},
{
"spellId": 255433,
"effectIndex": 0,
"effectType": 28,
"targetKind": "creature",
"entryId": 97770
}
]
},
"adaptation": {
"status": "source-backed-custom-cadence-required",
"populationPlan": {
"disposition": "remove-unverified-generic-trash",
"reason": "Alvaencounter and the recovered spell cluster describe a contained Alva/Arktos encounter; no evidence supports the current 15 generic hallway creatures.",
"encounterEntities": [
97700,
97701,
97703,
97731,
97770
]
},
"encounterPlan": [
"Phase one starts Alva 97700 and Arktos 97701 together, with Pact of the Hunt 255360 active while they remain close.",
"Alva uses Rain of Arrows, Unbridled Arrows, Blast Arrow, Cluster Shot, and summoned Explosive Traps; Arktos uses Carnivorous Bite, Ice Rock, Frost Roar, Lacerate, and defensive Frost Shield or Shield Wall.",
"Blast Arrow can strike Arktos and apply Set Ablaze 255381, causing panic movement, Blaze 255382, and increased damage taken. This matches the official changelog's Blast Shot/Arktos interaction despite the DBC naming it Blast Arrow.",
"Arktos death leaves Alva's Grief 255410 around the corpse and transitions Alva to the vengeance kit.",
"The transition casts Shadow Shroud Rebirth 255390 and summons four Alvajar 97770 around the arena. Rebirth damage must scale by living group size, reaching full value at five or more players per the official changelog.",
"Phase two rotates Vengeful Bolt, Shadow Veil Lance, Sphere of Grief and Wrath, Army of the Dead, and stacking Alva's Wrath as the soft enrage.",
"Wire ASC D0 rows to the current normal experience. Preserve D1 and D2 rows but do not assign public difficulty labels until an authoritative mode mapping is recovered."
],
"authoredCadence": {
"rainOfArrowsMs": 12000,
"unbridledArrowsMs": 20000,
"blastArrowMs": 15000,
"clusterShotMs": 18000,
"explosiveTrapMs": 16000,
"arktosIceRockMs": 12000,
"arktosFrostRoarMs": 18000,
"vengefulBoltMs": 7000,
"shadowVeilLanceMs": 12000,
"sphereOfGriefMs": 20000,
"alvasWrathMs": 25000
},
"mustBeCustomAuthored": [
"all cast cadence and target-selection rules",
"Arktos initial placement and companion pathing",
"phase transition and exact death threshold behavior",
"Alvajar formation and spawn placement",
"trap and area-trigger web-runtime behavior",
"boss and add visual composites",
"loot and completion scripting"
]
},
"unresolved": [
"The server-side spell schedule and encounter script are absent.",
"Creature template stats, display IDs, and exact positions are absent.",
"ASC D0/D1/D2 spell ranks cannot be mapped safely beyond D0 without a surviving difficulty script."
]
},
{
"mapId": 1781,
"id": "forgotten-mine",
"title": "Forgotten Mine",
"mapDirectory": "ForgottenMine",
"sourceClassification": "encounter-spells-creature-summon-and-gameobject-recovered",
"environment": {
"sourceHash": "693b7067aa1109e14beb944bc95aed8299bf64e2ec6ae3dc43f4fab7d18f57b6",
"anchors": {
"player": [
1285.886108,
149.58075,
1724.264526
],
"trash": [
1857.461304,
136.080749,
1871.884277
],
"boss": [
2246.06665,
198.180786,
2147.866699
]
},
"semanticStatus": "provisional-main-navigation-component"
},
"encounters": [
{
"encounterId": 5069,
"modeId": 0,
"name": "Geolord Vek",
"creatureId": 254272,
"identityConfidence": "exact"
},
{
"encounterId": 5071,
"modeId": 2,
"name": "Geolord Vek",
"creatureId": 254272,
"identityConfidence": "exact"
}
],
"entities": [
{
"entryId": 254272,
"name": "Geolord Vek",
"role": "boss",
"sourceKind": "encounter-dbc"
},
{
"entryId": 254278,
"name": "Geospike summoned unit",
"role": "hazard",
"sourceKind": "spell-linked-name-inferred"
}
],
"gameObjects": [
{
"entryId": 254051,
"name": "Resonite Shard",
"role": "clickable-encounter-countermeasure",
"sourceKind": "spell-linked-name-from-create-spell"
}
],
"currentRuntime": {
"bossEntityId": "entry-254272",
"proceduralTrashSpawnCount": 15,
"proceduralMobEntries": [
{
"entityId": "forgotten-mine-guardian",
"serverEntry": 4175632753
},
{
"entityId": "forgotten-mine-caster",
"serverEntry": 647988074
},
{
"entityId": "forgotten-mine-brute",
"serverEntry": 2677742134
}
]
},
"sourceRecovery": {
"association": "explicit-vek-client-cluster",
"associationConfidence": "high",
"reason": "The contiguous cluster contains Vek Death and Vek Reset control rows alongside the complete earth and Resonite kit.",
"spellRows": {
"354000": "Stonebreaker",
"354001": "Quake",
"354002": "Stone Shield",
"354003": "Cosmetic Fear",
"354004": "Geospike",
"354005": "Geospike",
"354006": "Geospike",
"354007": "Vek Death",
"354008": "Vek Reset",
"354009": "Quake",
"354010": "Quake",
"354011": "Stone Shield",
"354012": "Stone Shield",
"354013": "Resonance",
"354014": "Resonance",
"354015": "Geode Barrage",
"354016": "Geode Barrage",
"354017": "Ruptured Earth",
"354018": "Ruptured Earth",
"354019": "Stoneskin",
"354020": "Stoneskin",
"354021": "Stonebreaker",
"354022": "Stonebreaker",
"354023": "Stonebreaker",
"354024": "Stonebreaker",
"354025": "Geospike",
"354026": "Geospike",
"354027": "Geode Barrage",
"354028": "Geode Barrage",
"354029": "Geode Barrage",
"354030": "Geode Barrage",
"354031": "Resonite Cleanse",
"354032": "Create Resonite Shard"
},
"families": {
"control": [
354007,
354008
],
"quake": [
354001,
354009,
354010
],
"stoneShield": [
354002,
354011,
354012
],
"geospike": [
354004,
354005,
354006,
354025,
354026
],
"resonance": [
354013,
354014
],
"geodeBarrage": [
354015,
354016,
354027,
354028,
354029,
354030
],
"rupturedEarth": [
354017,
354018
],
"stoneskin": [
354019,
354020
],
"stonebreaker": [
354000,
354021,
354022,
354023,
354024
],
"resonite": [
354031,
354032
]
},
"summonLinks": [
{
"spellId": 354004,
"effectIndex": 0,
"effectType": 28,
"targetKind": "creature",
"entryId": 254278
},
{
"spellId": 354032,
"effectIndex": 0,
"effectType": 76,
"targetKind": "gameobject",
"entryId": 254051
}
]
},
"adaptation": {
"status": "source-backed-custom-cadence-required",
"populationPlan": {
"disposition": "retain-current-route-as-explicit-adaptation",
"packCount": 5,
"membersPerPack": 3,
"authoredMobs": [
{
"serverEntry": 4175632753,
"name": "Forgotten Mine Stoneguard",
"role": "shielding-melee"
},
{
"serverEntry": 647988074,
"name": "Forgotten Mine Geomancer",
"role": "geode-ranged"
},
{
"serverEntry": 2677742134,
"name": "Forgotten Mine Quarry Brute",
"role": "frontal-bruiser"
}
]
},
"encounterPlan": [
"Geolord Vek 254272 remains at the provisional boss anchor and opens with Stone Shield.",
"Stonebreaker is a telegraphed frontal strike; Quake is a short-range raid pulse; Geode Barrage repeatedly targets one player; Ruptured Earth creates a marked split-damage pillar.",
"Geospike 354004 summons entry 254278 after a clearly visible ground telegraph. The hazard erupts with the matching Geospike damage row and then despawns.",
"Three clickable Resonite Shard gameobjects 254051 appear at authored offsets around the boss. Clicking consumes one and applies Resonite Cleanse 354031, which removes one active earth protection or prevents the next Resonance hit.",
"At 50 percent health, Resonance begins and Vek alternates Quake with Ruptured Earth. Missing crystals respawn once per 30 second cycle.",
"The official changelog requires large, obvious clickable crystal bounds and a high-contrast Geospike telegraph."
],
"authoredGameObjectOffsetsFromBoss": [
[
-12,
0,
0
],
[
6,
0,
10.392
],
[
6,
0,
-10.392
]
],
"authoredCadence": {
"stonebreakerMs": 6500,
"geodeBarrageMs": 11000,
"geospikeMs": 15000,
"quakeMs": 18000,
"rupturedEarthMs": 22000,
"resoniteCycleMs": 30000
},
"mustBeCustomAuthored": [
"all trash identities, placements, and AI",
"all boss cadence, target selection, and phase thresholds",
"the exact Resonite Cleanse interaction because the server script is absent",
"crystal placement, interaction bounds, and respawn rules",
"Geospike hazard placement and lifetime",
"boss, hazard, trash, and crystal visual composites",
"loot and completion scripting"
]
},
"unresolved": [
"Paired low/high-value spell rows do not expose a safe difficulty-label mapping.",
"The authoritative creature and gameobject spawn coordinates are absent.",
"Creature template stats and visual display IDs are absent.",
"The exact Resonite click result is only constrained by client spell text and official changelog behavior."
]
}
]
}
@@ -0,0 +1,97 @@
# Ascension custom dungeon source recovery
This report separates recoverable client facts from the behavior that HealerMan must author for Tinkertech Showdown (821), Frozen Reach (908), and Forgotten Mine (1781). The machine-readable companion is `ASCENSION_CUSTOM_DUNGEON_RECOVERY.json`; its validator checks both the current generated catalogs and the original installed-client `Spell.dbc`.
## Result
| Map | Recovered exactly | Still requires adaptation |
| --- | --- | --- |
| 821 Tinkertech Showdown | Client environment, encounter 9013, boss creature 663012 | Every spawn, trash identity, gameobject, spell binding, boss schedule, phase, visual, loot, and completion rule |
| 908 Frozen Reach | Client environment, encounters 2168/2170, Alva 97700, Arktos 97701, three summoned entries, and 85 encounter spell rows | Positions, stats, cast schedule, target rules, phase scripting, web hazards, visuals, loot, and completion rules |
| 1781 Forgotten Mine | Client environment, encounters 5069/5071, Geolord Vek 254272, Geospike summon 254278, Resonite Shard gameobject 254051, and 33 encounter spell rows | Positions, stats, cast schedule, trash roster, crystal behavior, hazard lifetime, visuals, loot, and completion rules |
The local server/reference repositories, installed WDB caches, addon databases, and the public Questie Ascension snapshot do not contain surviving creature templates, world spawn rows, SmartAI, or encounter scripts for these maps. Client spell rows prove names and summon targets, but they do not prove cast cadence, ownership in every case, or coordinates.
## 821: Tinkertech Showdown
Exact client facts:
- Map directory: `TinketechShowdown` (the client spelling omits the second `r`). Source hash: `323c47c081d0a6c39daf9878284863c21e3e38dbced2ba3f58d1d157d080d41d`.
- The recovered root WMO is `World/wmo/Dungeon/AZ_Subway/Subway_Dungeon.wmo`.
- Manastorm encounter 9013 names Mechatron and binds it to creature 663012 in mode 2.
- The player, trash, and boss anchors in the manifest are geometry-derived and provisional, not recovered server spawn points.
Candidate-only evidence:
- Spell 257438 `Rocket`.
- Spells 257459-257461 `Gatling Missile Barrage` / `Gatling Missile`.
- Spell 257462 `Sound: Goblin Head Machine Loop`.
- Spell 257470 `Incineration Beam`, spell 257471 `Energy Ball`, and spells 257472-257473 `Electrified Zone` / `Electric Shock`.
Those rows form a coherent goblin-machine cluster, but no surviving source binds them to creature 663012. They must stay labeled as an adaptation if used.
Concrete adaptation:
- Retain the current route as five authored three-creature packs. Recast generated entries 2629079128, 3849447491, and 1787979165 as `Tinkertech Guardbot`, `Tinkertech Coilcaster`, and `Tinkertech Demolisher`; these IDs and names remain HealerMan-owned.
- Keep Mechatron 663012 at the provisional boss anchor.
- Use an authored rotation of Rocket reticles, Gatling lane impacts, Incineration Beam lane sweeps, and alternating Electrified Zones. At 35% health, accelerate the rotation and add two Coilcasters.
- Model platform destruction as temporary lane disabling because the imported web geometry is immutable.
Everything in that combat implementation except the boss identity and candidate spell names is custom-authored. It should not be represented as recovered Ascension parity.
## 908: Frozen Reach
Exact client facts:
- Map directory: `Alvaencounter`. Source hash: `5ab771df79c0e062cea3ebda4e416a9245bcf8a4cc6da048077df5e674081f97`.
- Manastorm encounters 2168 and 2170 name Alva and bind her to creature 97700 in modes 0 and 2.
- The official Ascension database identifies Alva as creature 97700 and Arktos as creature 97701.
- Spell 255363 summons creature 97703, spell 255421 summons creature 97731, and spell 255433 summons creature 97770. In context these are the Blast Arrow unit, Explosive Trap, and Alvajar add.
- The client cluster spans 85 exact rows from 255347 through 255472. The principal families are Rain of Arrows, Unbridled Arrows, Blast Arrow, Cluster Shot, Pact of the Hunt, Ice Rock, Carnivorous Bite, Frost Shield, Set Ablaze, Blaze, Frost Strike, Frost Roar, Lacerate, Shield Wall, Shadow Shroud Surge/Rebirth, Vengeful Bolt, Shadow Veil Lance, Sphere of Grief and Wrath, Alva's Grief/Wrath, Explosive Trap, and Army of the Dead.
- The official changelog confirms that the Blast Arrow/“Blast Shot” interaction can hit Arktos and that Shadow Shroud Rebirth reaches full damage scaling at five or more players.
Concrete adaptation:
- Remove the current 15 procedural hallway creatures (entries 1606324391, 854807736, and 2855026892). Available evidence describes a contained Alva/Arktos encounter and provides no support for that generic trash population.
- Start Alva 97700 and Arktos 97701 together with Pact of the Hunt. Give Alva the recovered ranged/trap kit and Arktos the recovered bite, ice, roar, lacerate, and defensive kit.
- Allow Blast Arrow to apply Set Ablaze to Arktos, forcing panic movement and increased damage taken.
- On Arktos's death, leave Alva's Grief at the corpse, cast Shadow Shroud Rebirth, summon four Alvajar 97770, and move Alva into the recovered vengeance spell family.
- Preserve D1/D2 spell variants as source data but use D0 for the current normal experience until a surviving difficulty mapping is found.
The exact cadence in the JSON is an explicit HealerMan proposal. Initial positions, transition rules, summon formation, stats, targeting, and hazard implementation all remain custom-authored.
## 1781: Forgotten Mine
Exact client facts:
- Map directory: `ForgottenMine`. Source hash: `693b7067aa1109e14beb944bc95aed8299bf64e2ec6ae3dc43f4fab7d18f57b6`.
- Manastorm encounters 5069 and 5071 name Geolord Vek and bind him to creature 254272 in modes 0 and 2.
- The contiguous 354000-354032 spell cluster explicitly contains `Vek Death` and `Vek Reset`, tying the cluster to Vek.
- The recovered families are Stonebreaker, Quake, Stone Shield, Geospike, Resonance, Geode Barrage, Ruptured Earth, Stoneskin, Resonite Cleanse, and Create Resonite Shard.
- Spell 354004 summons Geospike creature 254278. Spell 354032 creates gameobject 254051, named by the spell as a Resonite Shard.
- Client text says Resonite counters the power of Earth. Official changelogs additionally require larger clickable crystal bounds and a visible Geospike telegraph.
Concrete adaptation:
- Retain five authored three-creature route packs only as an explicit approximation. Recast generated entries 4175632753, 647988074, and 2677742134 as `Forgotten Mine Stoneguard`, `Forgotten Mine Geomancer`, and `Forgotten Mine Quarry Brute`; these are not recovered Ascension creatures.
- Keep Geolord Vek 254272 at the provisional boss anchor. Rotate Stonebreaker, Quake, Geode Barrage, Geospike, and Ruptured Earth, then begin Resonance at 50% health.
- Spawn three clickable Resonite Shards 254051 at the authored triangular offsets in the JSON. Consuming a shard applies Resonite Cleanse 354031 and removes one active earth protection or prevents the next Resonance hit.
- Give Geospike 254278 a high-contrast ground warning, eruption, and despawn sequence. Make crystal interaction bounds deliberately generous.
The crystal effect is constrained by spell text and changelog behavior but its exact server-side rule did not survive. Trash, timing, targeting, positions, respawn cadence, and visuals are custom-authored.
## Integration boundary
The recovery files intentionally do not change `compile-campaign-catalog.mjs`. Runtime integration should consume the recovered IDs and family lists while preserving three provenance labels:
- `exact`: copied from checksummed client/catalog data.
- `spellLinked`: an entry ID read directly from a DBC summon effect; its descriptive name can still be contextual.
- `authored`: HealerMan timing, placement, visuals, or behavior.
Validate the recovery against the current catalogs and installed client with:
```powershell
node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs
```
Use `--skip-dbc` only on machines without the checksummed Area-52 client, or pass another byte-identical file with `--dbc <path>`.
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/TempestKeepArcane",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Arcane.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounDraenei",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/Draenei_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/HellfireDemon",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/hellfire_DemonWing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/TempestKeepAtrium",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Atrium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -10,7 +10,10 @@
"environmentMode": "global-wmo",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [1262.4427, -60.8902, 1622.5397], "yawDegrees": 262.143},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_ramparts.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/Sunwell5ManFix",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [70, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [70, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/QT_Sunwell_5Man/Sunwell_5Man.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounEthereal",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/ethereal_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/TempestKeepFactory",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_TempestKeep/TK_Factory.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounDemon",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/demon_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/AuchindounShadow",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Auchindoun/shadow_council_wing.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/HellfireMilitary",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/Hellfire/Hellfire_Military.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/CoilfangDraenei",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Draenei.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/CoilfangPumping",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Pumping.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+4 -1
View File
@@ -9,7 +9,10 @@
"mapDirectory": "World/Maps/CoilfangMarsh",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"difficultyVariants": [
{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]},
{"id": "heroic", "difficulty": "heroic", "spawnMask": 2, "levelRange": [70, 70]}
],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_Coilfang/Coilfang_Marsh.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.16",
"version": "0.1.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.16",
"version": "0.1.29",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+13 -4
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.16",
"version": "0.1.29",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -52,8 +52,11 @@
"dungeon:five-player:import": "node scripts/dungeon-pipeline/import-five-player-dungeon.mjs",
"dungeon:five-player:import:remaining": "node scripts/dungeon-pipeline/import-remaining-five-player-dungeons.mjs",
"dungeon:five-player:verify": "node scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs",
"dungeon:wow:audit": "node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
"dungeon:wow:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs",
"dungeon:wow:functionality:audit": "vite-node scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts",
"dungeon:wow:test": "vitest run src/game/wowDungeonContent.test.ts",
"dungeon:wow:export-tool:test": "node --test scripts/dungeon-pipeline/wow-export-tool.test.mjs",
"dungeon:ascension:recovery:validate": "node scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs --skip-dbc",
"dungeon:wow:all": "node scripts/dungeon-pipeline/run-all-wow-dungeons.mjs",
"dungeon:extract": "node scripts/dungeon-pipeline/cli.mjs extract",
"dungeon:creatures": "node scripts/dungeon-pipeline/cli.mjs creatures",
@@ -65,6 +68,7 @@
"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",
"arena:abilities:build": "node scripts/arena-pipeline/build-combat-runtime.mjs && node scripts/arena-pipeline/build-runtime-geometry.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",
@@ -76,6 +80,8 @@
"runewaker:register:all": "node scripts/runewaker-pipeline/cli.mjs register-all",
"runewaker:refresh:all": "node scripts/runewaker-pipeline/cli.mjs refresh-all",
"runewaker:spells:refresh": "node scripts/runewaker-pipeline/export-spell-catalog.mjs",
"runewaker:loot:refresh": "node scripts/runewaker-pipeline/export-loot-catalog.mjs",
"runewaker:audit:formula-loot": "node --test scripts/runewaker-pipeline/runewaker-formula-export.test.mjs scripts/runewaker-pipeline/runewaker-loot-import.test.mjs && vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts",
"runewaker:bosses:refresh": "npm run runewaker:spells:refresh && node scripts/runewaker-pipeline/import-boss-mechanics.mjs",
"runewaker:players:refresh": "node scripts/runewaker-pipeline/export-player-catalog.mjs && npm run runewaker:players:avatars",
"runewaker:players:avatars": "node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-player-avatars.json --actors-only && node scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs",
@@ -86,8 +92,11 @@
"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",
"runewaker:audit:material-alpha": "node scripts/runewaker-pipeline/audit-material-alpha.mjs",
"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",
"assets:wailing:creatures:combat": "node scripts/add-combat-creature-clips.mjs",
@@ -100,7 +109,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 web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"prebuild": "npm run loot:generate && npm run arena:abilities:build && 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",
@@ -8,9 +8,9 @@
"valid": true
}
],
"gridCells": 2074478,
"gridHeight": 994,
"gridWidth": 2087,
"gridCells": 4046196,
"gridHeight": 1812,
"gridWidth": 2233,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
@@ -21,55 +21,55 @@
"tileRows": 1,
"tileSize": null
},
"dungeonId": "black-rook-hold-arena",
"dungeonId": "arena-runtime-black-rook",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-92.08255767822266,
-16.14961814880371,
-279.4311218261719
-102.83599853515625,
-49.58357238769531,
-373.1059875488281
],
[
321.9174499511719,
37.25038528442383,
-82.83113098144531
340.9640197753906,
410.1164245605469,
-28.105987548828125
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 7170,
"sourceVertices": 21510,
"triangles": 7170,
"vertices": 21510
"sourceTriangles": 26724,
"sourceVertices": 80172,
"triangles": 26724,
"vertices": 80172
},
"input": {
"bounds": [
[
-93.08255767822266,
-39.549617767333984,
-280.0311279296875
-103.83599853515625,
-98.98357391357422,
-375.3059997558594
],
[
324.1624450683594,
69.1881103515625,
-81.26593780517578
342.7250061035156,
410.1130065917969,
-13.022000312805176
]
],
"degenerateTriangleCount": 0,
"degenerateTriangleCount": 33,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 49821,
"vertexCount": 149463
"triangleCount": 192419,
"vertexCount": 577257
},
"inputChunkChecksums": {
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb": "285bdce7d5b5085eef7d25f82a5b6a5b380605a310c539e8e94984182c835749"
"playtest-artifacts/black-rook-combined.glb": "aa4f39620e2562cea0bf39ec54ff377b19eef799eaa08f0e9ea81896f8a31aba"
},
"inputChunks": [
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb"
"playtest-artifacts/black-rook-combined.glb"
],
"output": "public/assets/game/arenas/black-rook-hold-arena/navigation-raw.glb",
"output": "playtest-artifacts/black-rook-navigation.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
@@ -80,35 +80,6 @@
"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"
]
"kind": "runtime-collision-repair"
}
}
@@ -56,15 +56,16 @@
"collisionChunks": 4
},
"navigation": {
"triangles": 4948,
"vertices": 14844,
"sourceComponents": 805,
"triangles": 783,
"vertices": 2349,
"sourceComponents": 1078,
"spawnDistances": [
0.009180110009213026,
0.01568599673120942
2.095,
1.026
],
"rawTriangles": 7170,
"report": "recast-report.json"
"rawTriangles": 26724,
"report": "recast-report.json",
"repair": "Rebaked against restored collision. Both teams spawn on the connected interior floor, not the terrain above the arena."
},
"assets": [
{
@@ -94,8 +95,8 @@
},
{
"file": "navigation.glb",
"bytes": 238420,
"sha256": "e463a0220842a4793b2150e95f4fd704f575def288813c781792b021afe8d3f0"
"bytes": 38500,
"sha256": "ee2df9f0c2dbd14d53e84fd66efcc4b48a93ac99c57c27a7d1ea876d29899910"
}
]
}
+56 -1
View File
@@ -1,10 +1,51 @@
const CONTENT_CACHE_NAME = "healer-man-content-v1";
const OBJECT_HEADER = "x-healer-man-content-object";
const ENCODING_HEADER = "x-healer-man-content-encoding";
let bundledManifest;
function mimeType(path) {
const extension = path.split(".").pop();
return ({ glb: "model/gltf-binary", json: "application/json", js: "text/javascript", wasm: "application/wasm",
ktx2: "image/ktx2", png: "image/png", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml",
mp3: "audio/mpeg", ogg: "audio/ogg", wav: "audio/wav", webm: "video/webm" })[extension] ?? "application/octet-stream";
}
async function decodedResponse(response, path, encoding) {
if (encoding !== "gzip") return response;
const headers = new Headers(response.headers);
headers.set("content-type", mimeType(path));
headers.delete("content-length");
headers.delete("content-encoding");
headers.delete(ENCODING_HEADER);
return new Response(response.body.pipeThrough(new DecompressionStream("gzip")), { status: 200, headers });
}
async function bundledAsset(path) {
if (!bundledManifest) {
bundledManifest = fetch("/content/bootstrap-manifest.json", { cache: "no-cache" })
.then((response) => response.ok ? response.json() : null)
.catch(() => null);
}
const manifest = await bundledManifest;
if (!manifest) { bundledManifest = null; return null; }
const entry = manifest.files?.[path];
if (!entry) return null;
const object = entry.download ?? entry;
// Older bootstrap catalogs served their original logical paths directly.
if (!object.url.startsWith("/content/bundled/")) return fetch(path);
const response = await fetch(object.url);
if (!response.ok) return response;
if (entry.download) return decodedResponse(response, path, entry.download.encoding);
return new Response(response.body, { headers: { "content-type": mimeType(path) } });
}
const MANAGED_ASSET_PREFIXES = [
"/assets/characters/",
"/assets/creatures/",
"/assets/equipment/",
"/assets/game/",
"/assets/ui/",
"/basis/",
"/data/",
];
const BUNDLED_ASSET_PREFIXES = [
"/assets/ui/",
@@ -12,6 +53,7 @@ const BUNDLED_ASSET_PREFIXES = [
"/assets/characters/wow335a-v1/manifest.json",
"/assets/characters/wow335a-v1/human/",
"/assets/creatures/wailing-caverns/",
"/assets/game/arenas/",
"/assets/game/dungeons/wailing-caverns/",
];
const BUNDLED_ASSET_PATHS = new Set([
@@ -35,6 +77,10 @@ self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("message", (event) => {
if (event.data?.type === "refresh-content-bootstrap") bundledManifest = null;
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
@@ -55,7 +101,16 @@ self.addEventListener("fetch", (event) => {
event.respondWith((async () => {
const cache = await caches.open(CONTENT_CACHE_NAME);
const cached = await cache.match(request, { ignoreSearch: true });
if (cached) return cached;
if (cached) {
const objectPath = cached.headers.get(OBJECT_HEADER);
if (!objectPath) return cached;
if (objectPath.startsWith("/content/cache-objects/")) {
const object = await cache.match(new URL(objectPath, self.location.origin).toString());
if (object) return decodedResponse(object, url.pathname, object.headers.get(ENCODING_HEADER));
}
}
const bundled = await bundledAsset(url.pathname);
if (bundled) return bundled;
if (BUNDLED_ASSET_PATHS.has(url.pathname)
|| BUNDLED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
return fetch(request);
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.16",
"versionCode": 1016
"versionName": "0.1.29",
"versionCode": 1029
}
@@ -0,0 +1,38 @@
import { build } from "esbuild";
import { resolve, dirname } from "node:path";
import { readFileSync, readdirSync } from "node:fs";
// A private copy of the existing engine keeps dedicated matches and offline
// practice isolated from the React player's stores. No second spell interpreter.
await build({
entryPoints: ["scripts/arena-pipeline/combat-runtime.ts"],
outfile: "server/generated/arena-combat-runtime.mjs",
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
target: "es2022",
// Escape catalog line breaks in string literals, preserving tooltip text
// without generating source lines that end in whitespace.
supported: { "template-literal": false },
minify: true,
define: { "import.meta.env.MODE": '"test"', "import.meta.env.DEV": "false", "import.meta.env.BASE_URL": '"/"', "process.env.NODE_ENV": '"production"' },
plugins: [{
name: "arena-engine-host",
setup(builder) {
builder.onLoad({ filter: /coaAbilityCatalogLoader\.ts$/ }, ({ path }) => {
const paths = readdirSync(resolve(dirname(path), "generated/coaAbilities")).filter((name) => name.endsWith(".json"))
.map((name) => `./generated/coaAbilities/${name}`);
return { contents: readFileSync(path, "utf8").replace(/import\.meta\.glob<[^>]+>\([^;]+\)/, `{${paths.map((name) => `${JSON.stringify(name)}: () => import(${JSON.stringify(name)})`).join(",")}}`), loader: "ts" };
});
builder.onResolve({ filter: /^zustand$/ }, () => ({ path: resolve("scripts/arena-pipeline/runtime-store.ts") }));
builder.onResolve({ filter: /^\.\/store$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "game-store", namespace: "arena-host" }) : undefined);
builder.onResolve({ filter: /^\.\/dungeonEncounterRuntime$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "encounter", namespace: "arena-host" }) : undefined);
builder.onResolve({ filter: /^\.\/onlineSessionRuntime$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "online", namespace: "arena-host" }) : undefined);
builder.onLoad({ filter: /.*/, namespace: "arena-host" }, ({ path }) => ({ contents: path === "game-store"
? 'export const useGameStore = { getState: () => ({ gameMode: "arena" }) };'
: path === "online" ? 'export const onlinePlayerRuntimeActors = () => []; export const onlinePlayerRuntimeActor = () => null;'
: 'export const dispatchDungeonEncounterSignal = () => {}; export const dungeonEncounterStateMatches = () => false;', loader: "js" }));
},
}],
});
@@ -28,9 +28,8 @@ const DEFINITIONS = Object.freeze([
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]],
gameplayBounds: { min: [-37, -32, -286], max: [126, 20, -138] },
teamSpawns: [[-35.043, -28.317, -204.435], [108.031, -28.317, -205.573]],
},
{
slug: "ashamanes-fall",
@@ -273,11 +272,8 @@ async function build(definition) {
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,
source: rawNavigationFile,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
@@ -0,0 +1,38 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { Matrix4, Vector3 } from "three";
import { writeFileSync, readdirSync } from "node:fs";
import catalog from "../../shared/arena-stages.json" with { type: "json" };
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const stages = {};
for (const stage of catalog.stages) {
stages[stage.id] = {};
for (const kind of ["navigation", "collision"]) {
const triangles = [];
const files = kind === "collision" ? readdirSync(`public${stage.assetBaseUrl}`).filter((name) => /^collision(?:-\d+)?\.glb$/.test(name)) : ["navigation.glb"];
for (const file of files) {
const document = await io.read(`public${stage.assetBaseUrl}/${file}`);
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION");
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Array.from({ length: positions.getCount() }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = Array.from({ length: 3 }, (_, corner) => new Vector3().fromArray(positions.getElement(indices[offset + corner], [])).applyMatrix4(matrix).toArray());
if ([0, 2].some((axis) => triangle.every((point) => point[axis] < stage.bounds.min[axis] - 3) || triangle.every((point) => point[axis] > stage.bounds.max[axis] + 3))) continue;
triangles.push(...triangle.flat().map((value) => Math.round(value * 1000) / 1000));
}
}
}
}
stages[stage.id][kind] = triangles;
}
console.log(`${stage.id}: ${stages[stage.id].navigation.length / 9} navigation, ${stages[stage.id].collision.length / 9} collision triangles`);
}
writeFileSync("server/generated/arena-geometry.json", `${JSON.stringify(stages)}\n`);
writeFileSync("server/generated/arena-navigation.json", `${JSON.stringify(Object.fromEntries(Object.entries(stages).map(([id, stage]) => [id, stage.navigation])))}\n`);
+281
View File
@@ -0,0 +1,281 @@
import { useCombatStore, PLAYER_AURA_ENTITY_ID, characterCombatDefender, abilityDamageSchool, type CombatState, type CharacterCombatSeed } from "../../src/game/combatStore";
import { usePartyStore } from "../../src/game/partyStore";
import { installCombatClock } from "../../src/game/combatClock";
import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
import { targetingRulesForAbility } from "../../src/game/abilityRules";
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog";
import { deriveCharacterStats } from "../../src/game/wotlkStats";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { arenaDampening } from "../../server/arena-rules.mjs";
import { createDefaultAppearance } from "../../src/app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "../../src/game/equipment";
const PLAYER = PLAYER_AURA_ENTITY_ID;
const combatDefaults = useCombatStore.getState();
const partyDefaults = usePartyStore.getState();
let presentation: any[] = [];
subscribeCombatPresentation((message) => { if (message.kind === "event") presentation.push(message.event); });
const remap = (id: string | null | undefined, from: string, to: string) => id === from ? to : id;
const mapAura = (aura: any, from: string, to: string) => ({ ...aura, sourceId: remap(aura.sourceId, from, to), targetId: remap(aura.targetId, from, to) });
const mapEffect = (effect: any, from: string, to: string) => ({ ...effect, sourceActorId: remap(effect.sourceActorId, from, to), targetId: remap(effect.targetId, from, to) });
export function initializeArenaCharacter(actor: any, now: number) {
if (actor.combat) return;
const kit = actor.characterKit;
const seed: CharacterCombatSeed = kit?.seed ?? { classId: kit?.classId ?? (actor.role === "healer" ? "priest" : "warrior"), level: kit?.level ?? 80, actionBindings: kit?.actionBindings };
const restore = installCombatClock(() => now);
try {
usePartyStore.setState({ ...partyDefaults, members: [], playerRole: actor.role === "healer" ? "healer" : "damage" }, true);
useCombatStore.setState(combatDefaults, true);
useCombatStore.getState().initializeCharacter(seed);
actor.combat = useCombatStore.getState();
const state = actor.combat;
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level)
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; });
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
actor.health = state.health;
actor.maxHealth = state.maxHealth;
actor.resource = state.resource;
actor.maxResource = state.maxResource;
actor.auras = state.auras.map((aura: any) => mapAura(aura, PLAYER, actor.id));
actor.statuses = [];
} finally { restore(); }
}
export function arenaKitFromSeed(seed: CharacterCombatSeed) {
const actor: any = { id: "kit", role: seed.role ?? "damage", characterKit: { seed } };
initializeArenaCharacter(actor, 0);
return { ...actor.characterKit, stats: { ...deriveCharacterStats(actor.combat.classId, actor.combat.level, actor.combat.gearStats, actor.combat.raceId, {}, actor.combat.secondaryClassId), health: actor.maxHealth } };
}
function partyMember(actor: any) {
const state = actor.combat;
return { id: actor.id, name: actor.name, classId: state.classId, raceId: state.raceId, gender: "male", appearance: {},
specialization: "", specializationId: state.specializationId, talentTreeId: "", talentRanks: state.talentRanks,
role: actor.role === "healer" ? "healer" : "damage", level: state.level, health: actor.health, maxHealth: actor.maxHealth,
resourceType: actor.characterKit.resource.type, resourceName: state.resourceName, resource: actor.resource, maxResource: actor.maxResource,
resourcePools: state.resourcePools, lastResourceSpentAt: state.lastResourceSpentAt, cooldowns: state.cooldowns,
globalCooldownEndsAt: state.globalCooldownEndsAt, activeCast: state.activeCast, activeCastTargetId: actor.targetActorId,
schoolLockedUntil: state.schoolLockedUntil, runes: state.deathKnightRunes, gearStats: state.gearStats,
status: actor.alive ? "ready" : "down", nextActionAt: 0, tauntReadyAt: 0,
controlledUntil: state.controlledUntil, controlMechanic: state.controlMechanic };
}
function enter(simulation: any, actor: any, now: number, ticking: boolean) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
presentation = [];
const allies = [...simulation.actors.values()].filter((other: any) => other !== actor && other.team === actor.team);
const friendlyPets = allies.flatMap((owner: any) => owner.combat.summons.map((summon: any) => ({ owner, summon })));
const members = [...allies.map(partyMember), ...friendlyPets.map(({ owner, summon }: any) => ({ ...partyMember(owner),
id: summon.id, name: summon.name, health: summon.health, maxHealth: summon.maxHealth,
resource: 0, maxResource: 0, activeCast: null, status: summon.health > 0 ? "ready" : "down" }))];
usePartyStore.setState({ ...partyDefaults, members, playerRole: actor.role === "healer" ? "healer" : "damage",
selectedMemberId: allies.some((other: any) => other.id === actor.targetActorId) ? actor.targetActorId : null,
selfSelected: actor.targetActorId === actor.id }, true);
const effects = simulation.combatEffects ?? [];
const auras = [...simulation.actors.values()].flatMap((other: any) => other.auras).concat(simulation.summonAuras ?? []).map((aura: any) => mapAura(aura, actor.id, PLAYER));
useCombatStore.setState({ ...actor.combat, health: actor.health, maxHealth: actor.maxHealth,
resource: actor.resource, maxResource: actor.maxResource, playerPosition: [...actor.position],
selectedTargetId: simulation.targetActor(actor.targetActorId)?.team !== actor.team ? actor.targetActorId : null,
selectedFriendlyActorId: actor.targetActorId === actor.id ? PLAYER : simulation.targetActor(actor.targetActorId)?.team === actor.team ? actor.targetActorId : null,
auras, effects: effects.filter((effect: any) => !ticking || effect.sourceActorId === actor.id).map((effect: any) => mapEffect(effect, actor.id, PLAYER)),
mobs: {}, actors: {}, combatEvents: [], settings: { ...actor.combat.settings, autoTargetNearest: false } }, true);
for (const other of simulation.actors.values()) {
if (other === actor) continue;
if (other.team === actor.team) { registerPartyRuntimePosition(other.id, other.position); continue; }
registerMobRuntime(other.id, other.position, other.facing);
useCombatStore.getState().registerMob(other.id, { name: other.name, level: other.combat.level, maxHealth: other.maxHealth, xpReward: 0 });
const stats = deriveCharacterStats(other.combat.classId, other.combat.level, other.combat.gearStats, other.combat.raceId, {}, other.combat.secondaryClassId);
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [other.id]: { ...state.mobs[other.id],
health: other.health, maxHealth: other.maxHealth, dead: !other.alive, armor: stats.armor, maxMana: other.maxResource,
characterDefender: characterCombatDefender({ ...other.combat, auras: other.auras.map((aura: any) => mapAura(aura, other.id, PLAYER)) }),
characterShield: other.combat.shield, characterResources: other.combat.resourcePools,
statuses: other.statuses ?? [], controlDiminishingReturns: other.controlDiminishingReturns ?? {},
mechanicImmunities: other.auras.flatMap((aura: any) => aura.definition.controlImmunities ?? []),
activeCast: other.combat.activeCast ? { ...other.combat.activeCast, attackId: other.combat.activeCast.abilityId, interruptible: true } : null,
schoolLockedUntil: other.combat.schoolLockedUntil, engaged: true, experienceGranted: true,
resistances: { arcane: stats.arcaneResistance, fire: stats.fireResistance, frost: stats.frostResistance, nature: stats.natureResistance, shadow: stats.shadowResistance, holy: 0 },
} } }));
}
for (const owner of simulation.actors.values()) for (const summon of owner.combat.summons) {
if (owner.team === actor.team) {
if (owner !== actor) registerPartyRuntimePosition(summon.id, summon.position);
continue;
}
registerMobRuntime(summon.id, summon.position, owner.facing);
useCombatStore.getState().registerMob(summon.id, { name: summon.name, level: owner.combat.level, maxHealth: summon.maxHealth, xpReward: 0 });
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [summon.id]: { ...state.mobs[summon.id], health: summon.health, dead: summon.health <= 0, experienceGranted: true } } }));
}
useCombatStore.getState().synchronizeActors();
const restoreClock = installCombatClock(() => now);
const restoreSpatial = registerCombatSpatialProvider({
actor: (id) => { const target = id === PLAYER ? actor : simulation.targetActor(id); return target ? { id, position: target.position, yaw: target.facing } : null; },
hasLineOfSight: (from, to) => simulation.hasLineOfSight({ position: from }, { position: to }),
projectGroundPoint: (requested, range, origin = actor.position) => simulation.projectGroundPoint(origin, requested, range),
});
return () => { restoreSpatial(); restoreClock(); };
}
function commit(simulation: any, actor: any, now: number, ticking: boolean) {
const state = useCombatStore.getState();
const dampening = simulation.activeStartedAt == null ? 0 : arenaDampening(now - simulation.activeStartedAt);
const healed = new Map<string, number>();
const damaged = new Map<string, number>();
const previous = new Map<string, { health: number; maxHealth: number }>([...simulation.actors.values()].map((other: any) => [other.id, { health: other.health, maxHealth: other.maxHealth }]));
for (const event of state.combatEvents) if (event.kind === "healing") {
const id = remap(event.targetActorId, PLAYER, actor.id)!;
healed.set(id, (healed.get(id) ?? 0) + event.effectiveAmount);
}
for (const event of state.combatEvents) if (event.kind === "damage") {
const id = remap(event.targetActorId, PLAYER, actor.id)!;
damaged.set(id, (damaged.get(id) ?? 0) + event.effectiveAmount);
}
const healthAfterHealing = (id: string, health: number, maxHealth = previous.get(id)?.maxHealth ?? health) => {
const before = previous.get(id);
const inferred = before ? Math.max(0, health - before.health + (damaged.get(id) ?? 0) - Math.max(0, maxHealth - before.maxHealth)) : 0;
return Math.max(0, health - Math.round(Math.max(healed.get(id) ?? 0, inferred) * dampening));
};
actor.combat = { ...state, mobs: {}, actors: {}, effects: [], auras: [], health: actor.health <= 0 ? 0 : healthAfterHealing(actor.id, state.health, state.maxHealth) };
actor.health = actor.combat.health;
actor.maxHealth = state.maxHealth;
actor.resource = state.resource;
actor.maxResource = state.maxResource;
actor.position = state.playerPosition.some((value, axis) => value !== actor.position[axis])
? simulation.resolvePosition(actor, state.playerPosition, true) : [...actor.position];
actor.cooldowns = new Map(Object.entries(state.cooldowns));
actor.gcdUntil = state.globalCooldownEndsAt;
actor.casting = state.activeCast ? { ...state.activeCast, targetActorId: remap(state.activeCast.targetId, PLAYER, actor.id) } : null;
actor.statuses = (actor.statuses ?? []).filter((status: any) => status.endsAt > now && !(state.controlledUntil === 0 && ["stun", "fear", "sleep", "freeze", "knockdown"].includes(status.kind)));
for (const member of usePartyStore.getState().members) {
const other = simulation.actor(member.id);
if (!other) {
for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === member.id)) {
owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === member.id ? { ...summon, health: healthAfterHealing(member.id, member.health) } : summon) };
}
continue;
}
other.health = healthAfterHealing(other.id, member.health, member.maxHealth);
other.maxHealth = member.maxHealth;
other.resource = member.resource;
const moved = getPartyRuntimePosition(member.id);
if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true);
other.combat = { ...other.combat, resourcePools: member.resourcePools, schoolLockedUntil: member.schoolLockedUntil,
controlledUntil: member.controlledUntil, controlMechanic: member.controlMechanic, deathKnightRunes: member.runes };
}
for (const mob of Object.values(state.mobs)) {
const other = simulation.actor(mob.id);
if (!other) {
for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === mob.id)) {
owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === mob.id ? { ...summon, health: mob.health } : summon).filter((summon: any) => summon.health > 0) };
}
continue;
}
other.health = mob.health;
other.statuses = mob.statuses;
other.controlDiminishingReturns = mob.controlDiminishingReturns;
const moved = getMobPosition(mob.id);
if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true);
const control = mob.statuses.filter((status) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a, b) => b.endsAt - a.endsAt)[0];
other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null,
shield: mob.characterShield ?? other.combat.shield, resourcePools: mob.characterResources ?? other.combat.resourcePools,
schoolLockedUntil: mob.schoolLockedUntil, activeCast: mob.activeCast ? { ...other.combat.activeCast, ...mob.activeCast } : null };
other.resource = other.combat.resourcePools[other.characterKit.resource.type] ?? other.resource;
other.casting = other.combat.activeCast;
}
const worldAuras = state.auras.map((aura) => mapAura(aura, PLAYER, actor.id));
simulation.summonAuras = worldAuras.filter((aura) => !simulation.actors.has(aura.targetId));
for (const other of simulation.actors.values()) {
other.auras = worldAuras.filter((aura) => aura.targetId === other.id);
other.statuses = (other.statuses ?? []).filter((status: any) => status.endsAt > now
&& (!status.sourceAbilityId.startsWith("aura:") || other.auras.some((aura: any) => `aura:${aura.definition.id}` === status.sourceAbilityId))
&& !other.auras.some((aura: any) => aura.definition.controlImmunities?.includes(status.kind)));
const control = other.statuses.filter((status: any) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a: any, b: any) => b.endsAt - a.endsAt)[0];
other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null };
other.alive = other.health > 0;
if (!other.alive) { other.movement = [0, 0]; other.casting = null; other.combat = { ...other.combat, activeCast: null }; }
}
simulation.combatEffects = [
...(ticking ? (simulation.combatEffects ?? []).filter((effect: any) => effect.sourceActorId !== actor.id) : []),
...state.effects.map((effect) => mapEffect(effect, PLAYER, actor.id)),
];
simulation.combatPresentation = [...(simulation.combatPresentation ?? []), ...presentation.map((event) => ({ ...event,
sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-80);
simulation.combatEvents = [...(simulation.combatEvents ?? []), ...state.combatEvents.map((event) => ({ ...event,
sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-100);
}
export function useArenaCharacterAbility(simulation: any, actor: any, abilityId: string, targetActorId: string | null, now: number, groundPosition?: readonly number[]) {
const ability = actor.characterKit.abilities[abilityId];
if (ability && (actor.statuses ?? []).some((status: any) => status.kind === "silence" && status.endsAt > now)
&& (ability.castRules?.school ?? abilityDamageSchool(actor.combat.classId, ability)) !== "physical") return { accepted: false, reason: "controlled", message: "You are silenced." };
const restore = enter(simulation, actor, now, false);
try {
const requestedTarget = targetActorId ?? actor.targetActorId;
if (requestedTarget === actor.id) {
useCombatStore.setState({ selectedFriendlyActorId: PLAYER, selectedTargetId: null });
usePartyStore.setState({ selfSelected: true, selectedMemberId: null });
}
const result = useCombatStore.getState().castAbility(abilityId, { now, origin: actor.position,
targetActorId: requestedTarget === actor.id ? null : requestedTarget, groundPosition: groundPosition as any });
const message = useCombatStore.getState().feedback?.message;
commit(simulation, actor, now, false);
if (result.ok) { actor.lastAbilityId = abilityId; actor.abilityRevision += 1; }
return { accepted: result.ok, reason: result.reason, message, casting: Boolean(actor.casting) };
} finally { restore(); }
}
export function tickArenaCharacter(simulation: any, actor: any, now: number, delta: number) {
const restore = enter(simulation, actor, now, true);
try {
if (actor.casting && !actor.casting.canCastWhileMoving && Math.hypot(...actor.movement) > .01) useCombatStore.getState().cancelCast("movement");
useCombatStore.getState().tick(delta, now);
commit(simulation, actor, now, true);
} finally { restore(); }
}
export function arenaCharacterSnapshot(actor: any, now: number) {
const state: CombatState = actor.combat;
return { resourcePools: state.resourcePools, maxResourcePools: state.maxResourcePools, runes: state.deathKnightRunes,
talentRanks: state.talentRanks, gearStats: state.gearStats, specializationId: state.specializationId,
comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, shield: state.shield,
activeCast: state.activeCast, playerAnimationEvent: state.playerAnimationEvent, summons: state.summons.map((summon) => ({ ...summon, ownerActorId: actor.id })),
areaEffects: state.areaEffects.map((area) => ({ ...area, sourceActorId: actor.id })),
controlledUntil: state.controlledUntil, schoolLockedUntil: state.schoolLockedUntil, now };
}
export function damageArenaCharacter(simulation: any, actor: any, amount: number, school: any, now: number) {
const restore = enter(simulation, actor, now, false);
try {
useCombatStore.getState().damagePlayer(amount, school);
commit(simulation, actor, now, false);
} finally { restore(); }
}
export function commandArenaCharacter(simulation: any, actor: any, command: string, now: number) {
if (command === "cancel-cast") {
const restore = enter(simulation, actor, now, false);
try { useCombatStore.getState().cancelCast(); commit(simulation, actor, now, false); } finally { restore(); }
}
if (command === "attack" || command === "defend" || command === "stop" || command === "recall") {
const targetId = command === "attack" ? actor.targetActorId : null;
actor.combat = { ...actor.combat, autoAttackTargetId: targetId, summons: actor.combat.summons.map((summon: any) => ({ ...summon,
targetId, ...(command === "recall" ? { position: [...actor.position] } : {}), nextActionAt: command === "stop" ? Number.MAX_SAFE_INTEGER : now })) };
}
}
export function bindArenaCharacter(actor: any, bindings: Record<string, string | null>) {
const next = Object.fromEntries(Object.keys(actor.characterKit.actionBindings).map((id) => [id,
bindings[id] && actor.characterKit.abilities[bindings[id]!] ? bindings[id] : null]));
actor.characterKit = { ...actor.characterKit, actionBindings: next };
actor.combat = { ...actor.combat, actionBindings: next };
}
+18
View File
@@ -0,0 +1,18 @@
// Same vanilla store contract used by Zustand, without a React dependency on
// the dedicated server. Each arena operation restores its own saved state.
export function create<T>(initialize: (set: any, get: () => T, api: any) => T) {
let state: T;
const listeners = new Set<(state: T, previous: T) => void>();
const api = {
getState: () => state,
setState(update: any, replace = false) {
const previous = state;
const patch = typeof update === "function" ? update(state) : update;
state = replace ? patch : Object.assign({}, state, patch);
if (state !== previous) for (const listener of listeners) listener(state, previous);
},
subscribe(listener: (state: T, previous: T) => void) { listeners.add(listener); return () => listeners.delete(listener); },
};
state = initialize(api.setState, api.getState, api);
return api;
}
+62
View File
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { gzip, gunzip } from "node:zlib";
const compress = promisify(gzip);
const decompress = promisify(gunzip);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Original hashes remain the runtime contract; gzip is only a storage representation. */
export async function prepareContentObjects(files, { directory, baseUrl, keepOriginal = false }) {
await mkdir(directory, { recursive: true });
const unique = new Map();
for (const file of files) unique.set(`${file.sha256}${file.extension}`, file);
const objects = [...unique.entries()];
const prepared = new Map();
let next = 0;
let writtenObjects = 0;
const writeVerified = async (name, bytes) => {
const destination = path.join(directory, name);
try {
const existing = await readFile(destination);
if (!existing.equals(bytes)) throw new Error(`Published object differs from its hash: ${destination}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
await writeFile(destination, bytes, { flag: "wx" });
writtenObjects++;
}
};
await Promise.all(Array.from({ length: 4 }, async () => {
while (next < objects.length) {
const [name, file] = objects[next++];
const original = await readFile(file.sourcePath);
if (original.length !== file.bytes || digest(original) !== file.sha256) {
throw new Error(`Asset changed during packaging: ${file.logicalPath}`);
}
const compressed = await compress(original, { level: 6 });
const useful = compressed.length + 64 < original.length;
let download;
if (useful) {
if (!(await decompress(compressed)).equals(original)) throw new Error(`Gzip round trip failed: ${file.logicalPath}`);
const compressedHash = digest(compressed);
// AAPT expands and renames .gz assets even with noCompress. Keep gzip
// bytes under a neutral suffix so APK and browser catalogs stay identical.
const compressedName = `${compressedHash}${file.extension}.gzip`;
await writeVerified(compressedName, compressed);
download = { url: `${baseUrl}/${compressedName}`, encoding: "gzip", sha256: compressedHash, bytes: compressed.length };
}
if (keepOriginal || !useful) await writeVerified(name, original);
prepared.set(name, { objectUrl: `${baseUrl}/${name}`, ...(download ? { download } : {}), storedBytes: useful ? compressed.length : original.length });
}
}));
return {
files: files.map((file) => ({ ...file, ...prepared.get(`${file.sha256}${file.extension}`) })),
writtenObjects,
uniqueObjects: objects.length,
logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
uniqueBytes: objects.reduce((sum, [, file]) => sum + file.bytes, 0),
storedBytes: [...prepared.values()].reduce((sum, object) => sum + object.storedBytes, 0),
};
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gunzipSync } from "node:zlib";
import { prepareContentObjects } from "./content-objects.mjs";
import { selectBootstrapContent } from "./prepare-bootstrap.mjs";
import { createContentManifest } from "./publish-content.mjs";
test("ships one lossless object for identical assets and verifies original bytes", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-content-compression-"));
try {
const sourcePath = path.join(directory, "original.glb");
const original = Buffer.from("original geometry, textures, and animation\n".repeat(4096));
await writeFile(sourcePath, original);
const common = { sourcePath, bytes: original.length, sha256: createHash("sha256").update(original).digest("hex"), extension: ".glb" };
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `dungeon:${id}` }));
const result = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
assert.equal(result.uniqueObjects, 1);
assert.equal(result.uniqueBytes, original.length);
assert.equal(result.logicalBytes, original.length * 2);
assert.ok(result.storedBytes < original.length / 10);
assert.equal((await readdir(path.join(directory, "objects"))).length, 1);
assert.deepEqual(result.files[0].download, result.files[1].download);
assert.ok(result.files[0].download.url.endsWith(".gzip"), "avoid Android's automatic .gz expansion");
const stored = await readFile(path.join(directory, "objects", path.basename(result.files[0].download.url)));
assert.deepEqual(gunzipSync(stored), original);
assert.deepEqual(await readFile(sourcePath), original);
const again = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
assert.equal(again.writtenObjects, 0);
assert.equal(createContentManifest(result.files).version, createContentManifest(again.files).version);
await writeFile(sourcePath, "changed");
await assert.rejects(prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" }), /changed during packaging/);
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("bundles regular dungeons and exact map dependencies without installing Manastorm", () => {
const ids = ["core:ui", "dungeon:first", "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"];
const files = ids.map((packId, index) => ({ logicalPath: `assets/${index}.glb`, packId, sha256: String(index).repeat(64), bytes: 100, extension: ".glb" }));
const result = selectBootstrapContent(files, [
{ id: "dungeon:first", dependencies: ["manastorm:shared-map", "shared:creatures"] },
{ id: "dungeon:second", dependencies: [] },
]);
assert.deepEqual(result.files.map((file) => file.packId), ids.slice(0, 5));
const packs = new Map(result.packDefinitions.map((pack) => [pack.id, pack]));
assert.equal(packs.get("dungeon:first").bundledOnAndroid, true);
assert.equal(packs.get("dungeon:second").bundledOnAndroid, true);
assert.equal(packs.get("manastorm:shared-map").bundledOnAndroid, false);
assert.equal(packs.has("manastorm:optional-map"), false);
});
+2 -64
View File
@@ -1,65 +1,3 @@
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";
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".android-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".android-public") {
throw new Error(`Refusing to replace unexpected Android staging directory: ${outputDirectory}`);
}
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, "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);
}
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 Android bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
await prepareBootstrap("android");
+63
View File
@@ -0,0 +1,63 @@
import { copyFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BUNDLED_ANDROID_PACKS, DEFAULT_CONTENT_SOURCES, PROJECT_ROOT,
createContentManifest, loadCampaignDungeonPackDefinitions, scanContentSources,
} from "./publish-content.mjs";
import { prepareContentObjects } from "./content-objects.mjs";
export function selectBootstrapContent(files, packDefinitions) {
const manifest = createContentManifest(files, { packDefinitions });
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
const included = new Set();
function include(id) {
if (included.has(id)) return;
included.add(id);
for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency);
}
for (const pack of manifest.packs) {
if (BUNDLED_ANDROID_PACKS.includes(pack.id) || pack.id.startsWith("dungeon:")) include(pack.id);
}
return {
files: files.filter((file) => included.has(file.packId)),
packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({
...pack,
// Shared maps can be physically bundled without installing Manastorm gameplay.
bundledOnAndroid: !pack.id.startsWith("manastorm:"),
})),
};
}
export async function prepareBootstrap(platform) {
if (!["web", "android"].includes(platform)) throw new Error(`Unknown bootstrap platform: ${platform}`);
const outputDirectory = path.resolve(PROJECT_ROOT, `.${platform}-public`);
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== `.${platform}-public`) {
throw new Error(`Refusing to replace unexpected staging directory: ${outputDirectory}`);
}
const files = await scanContentSources([
...DEFAULT_CONTENT_SOURCES,
{ directory: path.join(PROJECT_ROOT, "public/basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public/data"), logicalPrefix: "data" },
]);
const selected = selectBootstrapContent(files, await loadCampaignDungeonPackDefinitions());
await rm(outputDirectory, { recursive: true, force: true });
const prepared = await prepareContentObjects(selected.files, {
directory: path.join(outputDirectory, "content/bundled"), baseUrl: "/content/bundled",
});
const manifest = createContentManifest(prepared.files, {
baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions,
});
await writeFile(path.join(outputDirectory, "content/bootstrap-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
await copyFile(path.join(PROJECT_ROOT, "public/content-cache-sw.js"), path.join(outputDirectory, "content-cache-sw.js"));
const storage = {
logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes,
duplicateBytesRemoved: prepared.logicalBytes - prepared.uniqueBytes,
uniqueObjects: prepared.uniqueObjects, logicalFiles: selected.files.length,
bundledDungeons: manifest.packs.filter((pack) => pack.id.startsWith("dungeon:")).length,
bundledManastormPacks: manifest.packs.filter((pack) => pack.id.startsWith("manastorm:") && pack.bundledOnAndroid).length,
};
await writeFile(path.join(outputDirectory, "content/storage-report.json"), `${JSON.stringify(storage, null, 2)}\n`);
console.log(`Prepared ${platform} bootstrap: ${storage.bundledDungeons} dungeons, ${prepared.uniqueObjects} shared objects`);
console.log(`Original: ${storage.logicalBytes} bytes; duplicates removed: ${storage.duplicateBytesRemoved} bytes; shipped: ${storage.storedBytes} bytes`);
return { manifest, storage, outputDirectory };
}
+2 -70
View File
@@ -1,71 +1,3 @@
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";
import { prepareBootstrap } from "./prepare-bootstrap.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, "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}`);
await prepareBootstrap("web");
+219 -28
View File
@@ -1,8 +1,7 @@
import { createHash } from "node:crypto";
import {
constants,
copyFile,
mkdir,
readFile,
readdir,
rename,
stat,
@@ -10,16 +9,26 @@ import {
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { prepareContentObjects } from "./content-objects.mjs";
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
const WOW_CAMPAIGN_CATALOG_PATH = path.join(
PROJECT_ROOT,
"src",
"game",
"generated",
"dungeonCampaignCatalog.json",
);
export const CONTENT_SCHEMA_VERSION = 1;
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
@@ -79,6 +88,7 @@ function packLabel(packId) {
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;
}
@@ -105,6 +115,7 @@ 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]}`;
}
@@ -176,15 +187,112 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
}
function contentVersion(files) {
function contentVersion(files, packs) {
const digest = createHash("sha256");
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`);
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\0${JSON.stringify(file.download ?? null)}\n`);
for (const pack of packs) {
digest.update(`pack\0${pack.id}\0${pack.label}\0${pack.bundledOnAndroid}\0${pack.dependencies.join(",")}\n`);
}
return `content-${digest.digest("hex").slice(0, 16)}`;
}
function assetPackId(url) {
if (typeof url !== "string" || !url.startsWith("/")) return null;
return contentPackForPath(url.slice(1));
}
/**
* Builds the logical install topology for the 54-dungeon WoW campaign.
* Environment files intentionally remain in their independently downloadable
* Manastorm packs; a dungeon pack owns its creatures and pulls in exactly the
* environment/shared-creature packs referenced by the generated definition.
*/
export function campaignDungeonPackDefinitions(definitions) {
const packs = new Map();
for (const definition of definitions) {
const dungeonPackId = `dungeon:${definition.id}`;
if (definition.id === "wailing-caverns") {
packs.set(dungeonPackId, {
id: dungeonPackId,
label: definition.title ?? packLabel(dungeonPackId),
bundledOnAndroid: true,
dependencies: [],
});
continue;
}
const environmentUrls = [
...(definition.assets?.visual ?? []).map((asset) => asset.url),
...(definition.assets?.collision ?? []).map((asset) => asset.url),
definition.assets?.navigation?.url,
].filter(Boolean);
const resolvedEnvironmentPacks = environmentUrls.map(assetPackId);
if (!resolvedEnvironmentPacks.length || resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
throw new Error(`${definition.id} has an environment asset outside a Manastorm content pack.`);
}
const environmentPackIds = new Set(resolvedEnvironmentPacks);
if (environmentPackIds.size !== 1) {
throw new Error(`${definition.id} must resolve to exactly one Manastorm environment pack.`);
}
const [environmentPackId] = environmentPackIds;
packs.set(environmentPackId, {
id: environmentPackId,
label: packLabel(environmentPackId),
bundledOnAndroid: false,
dependencies: [],
});
const creatureUrls = Object.values(definition.entities ?? {})
.map((entity) => entity.visual?.model?.url)
.filter(Boolean);
const resolvedCreaturePacks = creatureUrls.map(assetPackId);
if (resolvedCreaturePacks.some((id) => !id)) {
throw new Error(`${definition.id} has a creature asset outside a content pack.`);
}
const referencedCreaturePacks = new Set(resolvedCreaturePacks.filter((id) => id !== dungeonPackId));
const unexpectedCreaturePacks = [...referencedCreaturePacks].filter((id) => id !== "shared:creatures");
if (unexpectedCreaturePacks.length) {
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
}
packs.set(dungeonPackId, {
id: dungeonPackId,
label: definition.title ?? packLabel(dungeonPackId),
bundledOnAndroid: false,
dependencies: [environmentPackId, ...referencedCreaturePacks],
});
}
return [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
}
export async function loadCampaignDungeonPackDefinitions(
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
) {
const definitions = await loadCampaignDungeonDefinitions(catalogPath);
return campaignDungeonPackDefinitions(definitions);
}
export async function loadCampaignDungeonDefinitions(
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
) {
const campaign = JSON.parse(await readFile(catalogPath, "utf8"));
if (!Array.isArray(campaign.definitions)) {
throw new Error(`WoW campaign catalog is missing definitions: ${catalogPath}`);
}
if (campaign.definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
throw new Error(
`WoW campaign catalog must contain ${WOW_CAMPAIGN_DUNGEON_COUNT} dungeons; found ${campaign.definitions.length}.`,
);
}
const dungeonIds = campaign.definitions.map((definition) => definition?.id);
if (dungeonIds.some((id) => typeof id !== "string" || !id)
|| new Set(dungeonIds).size !== dungeonIds.length) {
throw new Error("WoW campaign catalog contains a missing or duplicate dungeon id.");
}
return campaign.definitions;
}
export function createContentManifest(files, options = {}) {
const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, "");
const version = options.version ?? contentVersion(files);
const packs = new Map();
const fileEntries = {};
@@ -196,6 +304,7 @@ export function createContentManifest(files, options = {}) {
url: `${baseUrl}/${encodeUrlPath(objectName)}`,
sha256: file.sha256,
bytes: file.bytes,
...(file.download ? { download: file.download } : {}),
};
const pack = packs.get(file.packId) ?? {
id: file.packId,
@@ -224,32 +333,96 @@ export function createContentManifest(files, options = {}) {
}
}
for (const declared of options.packDefinitions ?? []) {
const current = packs.get(declared.id) ?? {
id: declared.id,
label: packLabel(declared.id),
bytes: 0,
fileCount: 0,
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(declared.id),
dependencies: [],
};
packs.set(declared.id, {
...current,
label: declared.label ?? current.label,
bundledOnAndroid: declared.bundledOnAndroid ?? current.bundledOnAndroid,
dependencies: [...declared.dependencies],
});
}
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
for (const pack of sortedPacks) {
const objects = new Map(files.filter((file) => file.packId === pack.id)
.map((file) => [file.sha256, file.download?.bytes ?? file.bytes]));
pack.downloadBytes = [...objects.values()].reduce((sum, bytes) => sum + bytes, 0);
}
const version = options.version ?? contentVersion(files, sortedPacks);
return {
schemaVersion: CONTENT_SCHEMA_VERSION,
version,
createdAt: options.createdAt ?? new Date().toISOString(),
minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION,
packs: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)),
packs: sortedPacks,
files: fileEntries,
};
}
async function copyObjectIfMissing(file, objectDirectory) {
const objectName = `${file.sha256}${file.extension}`;
const destination = path.join(objectDirectory, objectName);
try {
const destinationStats = await stat(destination);
if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`);
return false;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
export function assertCampaignDungeonManifest(manifest, definitions) {
if (!Array.isArray(definitions) || definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
throw new Error(`Release content requires exactly ${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon definitions.`);
}
try {
await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL);
} catch (error) {
if (error?.code !== "EEXIST") throw error;
const expectedDefinitions = campaignDungeonPackDefinitions(definitions);
const manifestPacks = new Map(manifest.packs.map((pack) => [pack.id, pack]));
if (manifestPacks.size !== manifest.packs.length) {
throw new Error("Release content manifest contains duplicate pack ids.");
}
for (const expected of expectedDefinitions) {
const actual = manifestPacks.get(expected.id);
if (!actual) throw new Error(`Release content manifest is missing ${expected.id}.`);
if (actual.bundledOnAndroid !== expected.bundledOnAndroid
|| actual.dependencies.length !== expected.dependencies.length
|| actual.dependencies.some((dependency, index) => dependency !== expected.dependencies[index])) {
throw new Error(`Release content manifest has stale dependencies for ${expected.id}.`);
}
}
for (const pack of manifest.packs) {
const packFiles = Object.values(manifest.files).filter((entry) => entry.packId === pack.id);
const packBytes = packFiles.reduce((total, entry) => total + entry.bytes, 0);
if (pack.fileCount !== packFiles.length || pack.bytes !== packBytes) {
throw new Error(`Release content manifest has invalid file totals for ${pack.id}.`);
}
for (const dependency of pack.dependencies) {
if (!manifestPacks.has(dependency)) {
throw new Error(`Release content manifest is missing dependency ${dependency} for ${pack.id}.`);
}
}
}
for (const definition of definitions) {
const referencedUrls = [
...(definition.assets?.visual ?? []).map((asset) => asset.url),
...(definition.assets?.collision ?? []).map((asset) => asset.url),
definition.assets?.navigation?.url,
...Object.values(definition.entities ?? {}).map((entity) => entity.visual?.model?.url),
].filter(Boolean);
for (const logicalPath of referencedUrls) {
if (!manifest.files[logicalPath]) {
throw new Error(`Release content manifest is missing ${definition.id} asset ${logicalPath}.`);
}
}
}
const dungeonPackCount = definitions.filter((definition) => (
manifestPacks.has(`dungeon:${definition.id}`)
)).length;
if (dungeonPackCount !== WOW_CAMPAIGN_DUNGEON_COUNT) {
throw new Error(
`Release content manifest contains ${dungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon packs.`,
);
}
return true;
}
async function replaceJsonAtomically(destination, value) {
@@ -260,20 +433,37 @@ async function replaceJsonAtomically(destination, value) {
export async function publishContent(options = {}) {
const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content"));
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
const includeCampaignPacks = options.includeCampaignPacks !== false;
const campaignDefinitions = includeCampaignPacks ? await loadCampaignDungeonDefinitions() : null;
const campaignPackDefinitions = campaignDefinitions
? campaignDungeonPackDefinitions(campaignDefinitions)
: [];
const packDefinitions = [
...campaignPackDefinitions,
...(options.packDefinitions ?? []),
];
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
const objectDirectory = path.join(contentDirectory, "objects");
const manifestDirectory = path.join(contentDirectory, "manifests");
await mkdir(objectDirectory, { recursive: true });
await mkdir(manifestDirectory, { recursive: true });
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
let copiedObjects = 0;
for (const file of files) {
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
}
const manifest = createContentManifest(files, options);
const prepared = await prepareContentObjects(files, {
directory: objectDirectory,
baseUrl: String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""),
keepOriginal: true,
});
const manifest = createContentManifest(prepared.files, { ...options, packDefinitions });
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
return { contentDirectory, copiedObjects, manifest };
return {
contentDirectory,
copiedObjects: prepared.writtenObjects,
storage: { logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes },
manifest,
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
};
}
function parseCliArguments(argv) {
@@ -293,6 +483,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
const bytes = result.manifest.packs.reduce((total, pack) => total + pack.bytes, 0);
console.log(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`);
console.log(`Content version: ${result.manifest.version}`);
console.log(`WoW campaign dungeon packs: ${result.campaignDungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT}`);
console.log(`New immutable objects: ${result.copiedObjects}`);
console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`);
}
+172 -3
View File
@@ -1,12 +1,15 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
BUNDLED_ANDROID_PACKS,
PROJECT_ROOT,
assertCampaignDungeonManifest,
contentPackForPath,
createContentManifest,
loadCampaignDungeonPackDefinitions,
publishContent,
scanContentSources,
} from "./publish-content.mjs";
@@ -20,6 +23,8 @@ test("assigns stable player-facing packs from logical asset paths", () => {
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"),
@@ -29,6 +34,160 @@ test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
});
test("gives every WoW campaign dungeon an exact install dependency closure", async () => {
const campaign = JSON.parse(await readFile(path.join(
PROJECT_ROOT,
"src",
"game",
"generated",
"dungeonCampaignCatalog.json",
), "utf8"));
const packDefinitions = await loadCampaignDungeonPackDefinitions();
const dungeonDefinitions = packDefinitions.filter((pack) => pack.id.startsWith("dungeon:"));
const wailingCreatureManifest = JSON.parse(await readFile(path.join(
PROJECT_ROOT,
"public",
"assets",
"creatures",
"wailing-caverns",
"animated-manifest.json",
), "utf8"));
assert.equal(campaign.definitions.length, 54);
assert.equal(dungeonDefinitions.length, 54);
assert.equal(wailingCreatureManifest.creatures.length, 16);
const referencedSourceFiles = [
...campaign.definitions.flatMap((dungeon) => [
...[
...dungeon.assets.visual,
...dungeon.assets.collision,
dungeon.assets.navigation,
].map((asset) => ({ dungeonId: dungeon.id, url: asset.url })),
...Object.values(dungeon.entities).flatMap((entity) => (
entity.visual?.model?.url ? [{ dungeonId: dungeon.id, url: entity.visual.model.url }] : []
)),
]),
...wailingCreatureManifest.creatures.map((creature) => ({
dungeonId: "wailing-caverns",
url: creature.url,
})),
];
await Promise.all(referencedSourceFiles.map(async ({ dungeonId, url }) => {
try {
await access(path.join(PROJECT_ROOT, "public", ...url.slice(1).split("/")));
} catch {
assert.fail(`${dungeonId} is missing ${url}`);
}
}));
await Promise.all([
"wailing-caverns-visual.glb",
"wailing-caverns-collision.glb",
"wailing-caverns-navigation.glb",
].map((fileName) => access(path.join(
PROJECT_ROOT,
"src",
"assets",
"game",
"dungeons",
"wailing-caverns",
fileName,
))));
const logicalFiles = new Map();
const addFile = (logicalPath) => logicalFiles.set(logicalPath, {
logicalPath,
sourcePath: logicalPath,
sha256: "a".repeat(64),
bytes: 1,
extension: path.extname(logicalPath),
packId: contentPackForPath(logicalPath),
});
addFile("assets/game/dungeons/wailing-caverns/visual.glb");
for (const dungeon of campaign.definitions) {
for (const asset of [
...dungeon.assets.visual,
...dungeon.assets.collision,
dungeon.assets.navigation,
]) addFile(asset.url.slice(1));
for (const entity of Object.values(dungeon.entities)) {
if (entity.visual?.model?.url) addFile(entity.visual.model.url.slice(1));
}
}
const manifest = createContentManifest([...logicalFiles.values()], {
packDefinitions,
version: "campaign-contract",
});
assert.doesNotThrow(() => assertCampaignDungeonManifest(manifest, campaign.definitions));
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
for (const dungeon of campaign.definitions) {
const pack = packs.get(`dungeon:${dungeon.id}`);
assert.ok(pack, dungeon.id);
if (dungeon.id === "wailing-caverns") {
assert.equal(pack.bundledOnAndroid, true);
assert.deepEqual(pack.dependencies, []);
continue;
}
const environmentPack = contentPackForPath(dungeon.assets.visual[0].url.slice(1));
const needsSharedCreatures = Object.values(dungeon.entities).some((entity) => (
entity.visual?.model?.url?.startsWith("/assets/game/creatures/shared/")
));
assert.deepEqual(
pack.dependencies,
[environmentPack, ...(needsSharedCreatures ? ["shared:creatures"] : [])],
dungeon.id,
);
assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
}
for (const [dungeonId, environmentPack] of [
["tinkertech-showdown", "manastorm:821-tinketechshowdown"],
["frozen-reach", "manastorm:908-alvaencounter"],
["forgotten-mine", "manastorm:1781-forgottenmine"],
]) {
const pack = packs.get(`dungeon:${dungeonId}`);
assert.equal(pack.fileCount, 0);
assert.equal(pack.bytes, 0);
assert.deepEqual(pack.dependencies, [environmentPack]);
}
});
test("default release publishing fails before writing an incomplete campaign manifest", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-incomplete-content-test-"));
const sourceDirectory = path.join(directory, "source");
const contentDirectory = path.join(directory, "content");
await mkdir(sourceDirectory, { recursive: true });
await writeFile(path.join(sourceDirectory, "placeholder.glb"), Buffer.from("incomplete"));
try {
await assert.rejects(publishContent({
contentDirectory,
sources: [{ directory: sourceDirectory, logicalPrefix: "assets/game/dungeons/placeholder" }],
}), /Release content manifest is missing/);
await assert.rejects(access(contentDirectory), { code: "ENOENT" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("content versions change when only logical pack dependencies change", () => {
const files = [{
logicalPath: "assets/game/dungeons/test/creature.glb",
sourcePath: "creature.glb",
sha256: "b".repeat(64),
bytes: 1,
extension: ".glb",
packId: "dungeon:test",
}];
const first = createContentManifest(files, {
packDefinitions: [{ id: "dungeon:test", dependencies: ["manastorm:first"] }],
});
const second = createContentManifest(files, {
packDefinitions: [{ id: "dungeon:test", dependencies: ["manastorm:second"] }],
});
assert.notEqual(first.version, second.version);
});
test("publishes objects before a deterministic latest manifest", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-content-test-"));
const sourceDirectory = path.join(directory, "source");
@@ -43,7 +202,12 @@ test("publishes objects before a deterministic latest manifest", async () => {
assert.equal(Object.keys(firstManifest.files).length, 1);
assert.equal(firstManifest.packs[0].id, "dungeon:wailing-caverns");
const first = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" });
const first = await publishContent({
contentDirectory,
sources,
includeCampaignPacks: false,
createdAt: "2026-08-14T00:00:00.000Z",
});
assert.equal(first.copiedObjects, 1);
const latest = JSON.parse(await readFile(path.join(contentDirectory, "manifest.json"), "utf8"));
assert.equal(latest.version, first.manifest.version);
@@ -51,7 +215,12 @@ test("publishes objects before a deterministic latest manifest", async () => {
assert.match(entry.url, /^\/content\/objects\/[a-f0-9]{64}\.glb$/);
assert.equal(await readFile(path.join(contentDirectory, "objects", path.basename(entry.url)), "utf8"), "glb-one");
const second = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" });
const second = await publishContent({
contentDirectory,
sources,
includeCampaignPacks: false,
createdAt: "2026-08-14T00:00:00.000Z",
});
assert.equal(second.copiedObjects, 0);
assert.equal(second.manifest.version, first.manifest.version);
} finally {
+58
View File
@@ -0,0 +1,58 @@
"""Verify the packaged bytes, including Android asset-name/compression behavior."""
import argparse
import gzip
import hashlib
import json
from pathlib import Path
from zipfile import ZIP_STORED, ZipFile
def verify_package(apk: Path) -> dict:
with ZipFile(apk) as archive:
manifest = json.loads(archive.read("assets/public/content/bootstrap-manifest.json"))
checked = set()
object_bytes = 0
for logical_path, entry in manifest["files"].items():
stored = entry.get("download", entry)
if stored["url"] in checked:
continue
checked.add(stored["url"])
name = "assets/public" + stored["url"]
data = archive.read(name)
if len(data) != stored["bytes"] or hashlib.sha256(data).hexdigest() != stored["sha256"]:
raise ValueError(f"Packaged object failed verification: {logical_path}")
object_bytes += len(data)
if "download" in entry:
if archive.getinfo(name).compress_type != ZIP_STORED:
raise ValueError(f"Android recompressed an already compressed object: {name}")
data = gzip.decompress(data)
if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
raise ValueError(f"Expanded object failed verification: {logical_path}")
dungeons = sum(pack["id"].startswith("dungeon:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
expected_objects = {"assets/public" + url for url in checked}
actual_objects = {name for name in archive.namelist() if name.startswith("assets/public/content/bundled/") and not name.endswith("/")}
if actual_objects != expected_objects:
raise ValueError("APK includes missing or unreferenced bundled objects.")
manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
if dungeons != 84 or manastorms != 0:
raise ValueError(f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs")
return {
"apk": str(apk.resolve()), "apkBytes": apk.stat().st_size,
"manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]),
"verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes,
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms,
"allOriginalHashesMatch": True,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("apk", type=Path)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
result = json.dumps(verify_package(args.apk), indent=2) + "\n"
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(result, encoding="utf-8")
print(result, end="")
@@ -2,6 +2,11 @@
import { open, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DUNGEON_DEFINITIONS } from "../../src/game/dungeonRegistry";
import {
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS,
WOW_DUNGEON_NO_TRASH_EXCEPTIONS,
} from "../../src/game/wowDungeonContentExceptions";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(projectRoot, "public");
@@ -18,24 +23,14 @@ const validTargets = new Set([
]);
const validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const validEffects = new Set([
"damage", "heal", "aura", "control", "transform", "summon", "call-for-help", "encounter-event",
]);
const basicOnlyBossExceptions = new Map([
[
"wailing-caverns:entry-3653",
"Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
],
[
"wailing-caverns:deviate-faerie-dragon",
"The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
],
]);
const noTrashExceptions = new Map([
[
"trial-of-the-champion",
"Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route.",
],
"damage", "heal", "aura", "target-aura", "control", "transform", "summon", "call-for-help", "encounter-event",
]);
const basicOnlyBossExceptions = new Map(
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
);
const noTrashExceptions = new Map(
WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
);
const animationIds = {
idle: new Set([0]),
move: new Set([4, 5]),
@@ -116,10 +111,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.",
@@ -150,13 +147,21 @@ const [campaign, catalog, wailingFixture] = await Promise.all([
readJson("dungeon-pipeline/fixtures/wailing-caverns/runtime-fixture.json"),
]);
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
const definitions = catalog.definitions.filter((definition) => campaignIds.has(definition.id));
const definitions = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id));
const coverage = new Map(catalog.coverage.map((entry) => [entry.dungeonId, entry]));
const wailingModels = wailingFixtureModels(wailingFixture);
const issues = [];
if (basicOnlyBossExceptions.size !== WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length) {
issues.push("The shared basic-only boss exception contract contains a duplicate key.");
}
if (noTrashExceptions.size !== WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length) {
issues.push("The shared no-trash exception contract contains a duplicate key.");
}
const modelUsers = new Map();
const animationFallbacks = [];
const dungeons = [];
const actualBasicOnlyBossKeys = new Set();
const actualNoTrashDungeonKeys = new Set();
if (campaign.dungeons.length !== 54) {
issues.push("Expected 54 campaign dungeons; found " + campaign.dungeons.length + ".");
@@ -177,11 +182,15 @@ for (const dungeon of definitions) {
let specialAttacks = 0;
let nativeModels = 0;
let proceduralModels = 0;
if (!entities.length) dungeonIssues.push("has no combat entities.");
if (!bosses.length) dungeonIssues.push("has no bosses.");
if (!mobs.length && !noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length)) dungeonIssues.push("has no combat spawns.");
if (!mobs.length) {
actualNoTrashDungeonKeys.add(dungeon.id);
if (!noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
}
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length || dungeon.encounterSpawnGroups?.some((group) => group.spawns?.length))) {
dungeonIssues.push("has no combat spawns.");
}
for (const spawn of dungeon.staticSpawns ?? []) {
if (!dungeon.entities?.[spawn.entityId]) {
@@ -195,6 +204,13 @@ for (const dungeon of definitions) {
}
}
}
for (const group of dungeon.encounterSpawnGroups ?? []) {
for (const spawn of group.spawns ?? []) {
if (!dungeon.entities?.[spawn.entityId]) {
dungeonIssues.push("encounter group " + group.id + " references missing entity " + spawn.entityId + ".");
}
}
}
for (const [entityId, entity] of entities) {
const entityAttacks = entity.combat?.attacks ?? [];
@@ -205,6 +221,7 @@ for (const dungeon of definitions) {
specialAttacks += specials.length;
if (entity.kind === "boss" && !specials.length) {
const exceptionKey = dungeon.id + ":" + entityId;
actualBasicOnlyBossKeys.add(exceptionKey);
if (!basicOnlyBossExceptions.has(exceptionKey)) {
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
}
@@ -241,6 +258,17 @@ for (const dungeon of definitions) {
});
}
for (const key of basicOnlyBossExceptions.keys()) {
if (!actualBasicOnlyBossKeys.has(key)) {
issues.push("Stale basic-only boss exception does not match a runtime boss: " + key + ".");
}
}
for (const key of noTrashExceptions.keys()) {
if (!actualNoTrashDungeonKeys.has(key)) {
issues.push("Stale no-trash exception does not match a runtime dungeon: " + key + ".");
}
}
for (const [url, users] of modelUsers) {
try {
const names = await readGlbAnimationNames(url);
@@ -297,7 +325,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 +345,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;
File diff suppressed because it is too large. Load diff
@@ -10,6 +10,11 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const gameObjectAssetManifestFile = path.join(
projectRoot,
"dungeon-pipeline",
"WOW_GAMEOBJECT_ASSET_MANIFEST.json",
);
const compressedOutputFile = path.join(
projectRoot,
"src",
@@ -81,6 +86,19 @@ const epochSpellDbc = path.join(
"DBFilesClient",
"Spell.dbc",
);
const standardGameObjectDisplayDbc = path.join(
workRoot,
"deadmines",
"client-dbc",
"DBFilesClient",
"GameObjectDisplayInfo.dbc",
);
const epochGameObjectDisplayDbc = path.join(
workRoot,
"epoch-client",
"DBFilesClient",
"GameObjectDisplayInfo.dbc",
);
const WAILING_SERVER_ENCOUNTERS = Object.freeze([
[3671, "Lady Anacondra"],
@@ -200,6 +218,100 @@ function slugPart(value) {
.replace(/^-|-$/g, "") || "unknown";
}
function compileBossProgressionEncounter(recipe, bosses, syntheticTrash) {
const runtimeBosses = bosses.filter((boss) => !boss.eventOnly);
const requiredBosses = runtimeBosses.filter((boss) => !boss.optional);
const rules = runtimeBosses.map((boss, index) => ({
id: `boss-${index + 1}-defeated`,
trigger: { kind: "entities-dead", entityIds: [boss.id] },
actions: [
{
id: `record-boss-${index + 1}`,
kind: "complete-objective",
objectiveId: boss.id,
persistence: "run",
},
...(!boss.optional ? [{
id: `checkpoint-boss-${index + 1}`,
kind: "set-checkpoint",
checkpointId: boss.id,
persistence: "checkpoint",
}] : []),
],
}));
const finalBoss = requiredBosses.at(-1);
// Event-only objectives belong to detailed instance orchestration. A
// baseline must not declare the dungeon complete before that event fires.
if (finalBoss && !bosses.some((boss) => boss.eventOnly)) {
rules.push({
id: "all-required-bosses-defeated",
// Re-evaluate persisted objectives independently of kill order. Tying
// completion to the catalog's final actor can strand a run when players
// defeat that actor early and finish another required boss afterward.
trigger: { kind: "tick" },
guards: requiredBosses.map((boss) => ({
kind: "objective-complete",
objectiveId: boss.id,
equals: true,
})),
actions: [
{
id: "mark-dungeon-complete",
kind: "set-flag",
flagId: "dungeon-complete",
value: true,
persistence: "run",
},
{
id: "enter-complete-phase",
kind: "set-phase",
phase: "complete",
persistence: "run",
},
],
});
}
const sourceBacked = !syntheticTrash;
return {
id: `${recipe.slug}:boss-progression`,
dungeonId: recipe.slug,
initialPhase: "progression",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
source: {
fidelity: sourceBacked ? "source-backed-adapted" : "healer-man-authored",
scope: "boss-progression",
files: sourceBacked
? [
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
"src/game/generated/azerothCoreScriptSpells.json",
"src/game/generated/azerothCoreWorldAi.json",
]
: [
`dungeon-pipeline/recipes/${recipe.slug}.json`,
"src/game/generated/manastormRuntimeCatalog.json",
],
reviewedBehavior: sourceBacked
? "Live source-backed boss runtime IDs record persistent objective completion and stable post-boss checkpoints; all required non-event bosses complete the baseline run."
: "Catalog boss identities record persistent objective completion and stable post-boss checkpoints; all required bosses complete the authored fallback run.",
adaptations: [
"Boss progression is intentionally order-independent unless a detailed encounter definition supplies an explicit gate.",
"Optional rare bosses record completion when present but do not gate the final completion state.",
"A checkpoint retains completed objectives and resets temporary combat state; it does not invent a source graveyard or teleport.",
],
remainingGaps: [
...(sourceBacked ? [] : [
"Replace the authored fallback with authoritative Ascension population and instance-script data when those sources become available.",
]),
"Port exact instance-script doors, waves, escorts, vehicles, dialogue, gossip, quests, area triggers, and event-only bosses where the source requires them.",
"Enable source game-object visuals, collision, and state transitions only after their client models and script bindings are reviewed.",
"Record an end-to-end traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest before marking the dungeon validated.",
],
},
rules,
};
}
function inferEnvironmentMode(recipe, assetPackage) {
if (recipe.environmentMode !== "detect") return recipe.environmentMode;
const sourceFiles = assetPackage.visual.flatMap((chunk) => chunk.provenance?.sourceFiles ?? []);
@@ -214,6 +326,395 @@ const ADT_SOURCE_BASES = new Set([
]);
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
const SOURCE_GAME_OBJECT_TYPES = new Set(Array.from({ length: 36 }, (_, type) => type));
function rowValue(row, names, fallback = undefined) {
for (const name of names) {
if (row?.[name] !== undefined && row[name] !== null) return row[name];
}
return fallback;
}
function finiteNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function sourceGameObjectPosition(row) {
const wowX = finiteNumber(rowValue(row, ["position_x", "positionX", "x"]));
const wowY = finiteNumber(rowValue(row, ["position_y", "positionY", "y"]));
const wowZ = finiteNumber(rowValue(row, ["position_z", "positionZ", "z"]));
return [-wowX, wowZ, wowY];
}
function sourceGameObjectKind(type, name) {
const normalized = normalizedName(name);
if (type === 0 || type === 35) {
if (normalized.includes("bridge") || normalized.includes("drawbridge")) return "bridge";
if (normalized.includes("gate") || normalized.includes("portcullis")) return "gate";
return "door";
}
if (type === 1 || type === 31) return "switch";
if (type === 2 || type === 10 || type === 23) return "quest-object";
if (type === 3) return /vein|deposit|herb|plant|school|node/i.test(name) ? "resource" : "chest";
if (type === 5) return "generic";
if (type === 6 || type === 12) return "trap";
if (type === 7 || type === 32) return "chair";
if (type === 8) return "spell-focus";
if (type === 9) return "text";
if (type === 11 || type === 15) return "transport";
if (type === 13) return "camera";
if (type === 18) return "ritual";
if (type === 19) return "mailbox";
if (type === 22) return "spellcaster";
if (type === 25) return "fishing-hole";
if (type === 33) return "destructible";
return type === 30 ? "aura-generator" : "generic";
}
function sourceGameObjectHalfExtents(kind, sourceSize) {
const size = clamp(finiteNumber(sourceSize, 1), 0.1, 6);
if (kind === "door" || kind === "gate") return vector([1.25 * size, 2 * size, 0.2 * size]);
if (kind === "bridge") return vector([2.5 * size, 0.2 * size, 1.25 * size]);
if (kind === "switch") return vector([0.35 * size, 0.5 * size, 0.35 * size]);
if (kind === "chest" || kind === "resource") return vector([0.6 * size, 0.45 * size, 0.45 * size]);
if (kind === "trap" || kind === "trigger") return vector([0.8 * size, 0.1 * size, 0.8 * size]);
if (kind === "transport") return vector([2 * size, 0.25 * size, 2 * size]);
if (kind === "destructible") return vector([1.5 * size, 1.5 * size, 1.5 * size]);
return vector([0.45 * size, 0.65 * size, 0.45 * size]);
}
function clientGameObjectHalfExtents(display, sourceSize) {
if (!display?.bounds) return null;
const size = clamp(finiteNumber(sourceSize, 1), 0.01, 100);
const spans = display.bounds.max.map((value, axis) => value - display.bounds.min[axis]);
if (!spans.every((value) => Number.isFinite(value) && value > 0 && value <= 1_000)) return null;
// Client bounds are x/y/z. Runtime draft space is -x/z/y.
return vector([
clamp(spans[0] * size / 2, 0.025, 500),
clamp(spans[2] * size / 2, 0.025, 500),
clamp(spans[1] * size / 2, 0.025, 500),
]);
}
function parseGameObjectDisplays(buffer) {
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
if (fieldCount !== 19 || recordSize !== fieldCount * 4) return new Map();
const stringsOffset = 20 + recordCount * recordSize;
const readString = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const displays = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
const modelPath = readString(buffer.readUInt32LE(base + 4)).replace(/\\/g, "/");
const min = [12, 13, 14].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
const max = [15, 16, 17].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
displays.set(id, {
id,
modelPath,
bounds: { min, max },
});
}
return displays;
}
function sourceGameObjectStates(kind) {
if (kind === "door" || kind === "gate" || kind === "bridge") {
return [
// Adapted source objects remain non-blocking until an instance-script
// transition has been reviewed; otherwise a closed door with no opener
// would turn an incomplete import into an unavoidable soft lock.
{ id: "closed", label: "Closed", visible: true, blocksMovement: false, blocksSight: false },
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false },
];
}
if (kind === "trap") {
return [
{ id: "armed", label: "Armed", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "disabled", label: "Disabled", visible: false, blocksMovement: false, interactionEnabled: false },
];
}
if (kind === "transport") {
return [
{ id: "stationary", label: "Stationary", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "moving", label: "Moving", visible: true, blocksMovement: false, interactionEnabled: false },
];
}
if (kind === "destructible") {
return [
{ id: "intact", label: "Intact", visible: true, blocksMovement: false, blocksSight: false },
{ id: "destroyed", label: "Destroyed", visible: false, blocksMovement: false, blocksSight: false },
];
}
return [
{ id: "available", label: "Available", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "inactive", label: "Inactive", visible: false, blocksMovement: false, interactionEnabled: false },
];
}
function sourceGameObjectInitialState(kind, row) {
const sourceState = finiteNumber(rowValue(row, ["state", "State"]), 1);
if (kind === "door" || kind === "gate" || kind === "bridge") return sourceState === 0 ? "open" : "closed";
if (kind === "trap") return sourceState === 0 ? "disabled" : "armed";
if (kind === "transport") return sourceState === 0 ? "moving" : "stationary";
if (kind === "destructible") return sourceState === 2 ? "destroyed" : "intact";
return sourceState === 0 ? "inactive" : "available";
}
function sourceGameObjectVisual(kind) {
const colors = {
door: "#5f4932",
gate: "#4d4a46",
bridge: "#66543c",
switch: "#c38d35",
chest: "#8f682f",
resource: "#54704b",
trap: "#713b36",
transport: "#53616b",
camera: "#475f78",
ritual: "#74518f",
spellcaster: "#496e87",
"spell-focus": "#5f698d",
"aura-generator": "#586f80",
destructible: "#6b5849",
"quest-object": "#8a773b",
generic: "#65584b",
chair: "#725942",
text: "#806c48",
mailbox: "#596778",
"fishing-hole": "#3d6878",
trigger: "#58606a",
};
return { hidden: true, color: colors[kind] ?? "#65584b" };
}
function compileSourceGameObjects(draft, mapPosition, recipe, gameObjectDisplays) {
const templates = new Map((draft?.gameObjectTemplates ?? []).map((template) => [
finiteNumber(rowValue(template, ["entry", "id"]), -1),
template,
]));
return (draft?.gameObjects ?? [])
.map((row, index) => {
const templateId = finiteNumber(rowValue(row, ["id", "entry"]), -1);
const template = templates.get(templateId);
const type = finiteNumber(rowValue(template, ["type", "Type"]), -1);
if (!template || !SOURCE_GAME_OBJECT_TYPES.has(type)) return null;
const spawnId = finiteNumber(rowValue(row, ["guid", "spawnId"]), index + 1);
const name = String(rowValue(template, ["name", "Name"], `Game object ${templateId}`)).trim()
|| `Game object ${templateId}`;
const kind = sourceGameObjectKind(type, name);
const displayId = finiteNumber(rowValue(template, ["displayId", "displayID", "DisplayID"]), 0);
const display = gameObjectDisplays.get(displayId);
const sourceSize = rowValue(template, ["size", "Size"], 1);
const clientHalfExtents = clientGameObjectHalfExtents(display, sourceSize);
return {
id: `gameobject-${spawnId}`,
name,
kind,
position: mapPosition(sourceGameObjectPosition(row)),
yaw: rounded(-finiteNumber(rowValue(row, ["orientation", "yaw"]))),
halfExtents: clientHalfExtents ?? sourceGameObjectHalfExtents(kind, sourceSize),
initialState: sourceGameObjectInitialState(kind, row),
states: sourceGameObjectStates(kind),
visual: sourceGameObjectVisual(kind),
source: {
fidelity: "source-backed-adapted",
files: [
`dungeon-pipeline/work/${recipe.slug}/source-snapshot.json`,
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
],
templateId,
spawnId,
gameObjectType: type,
...(displayId > 0 ? { clientDisplayId: displayId } : {}),
...(display?.modelPath ? { clientModelPath: display.modelPath } : {}),
...(clientHalfExtents ? { clientBounds: display.bounds } : {}),
note: clientHalfExtents
? "Source transform/state and exact client display bounds/model path are preserved. Visual assets and collision remain hidden until the model export and instance-script transition are reviewed; interactions remain disabled until ported."
: "Source position, orientation, type, initial state, and template scale are preserved. Visuals and collision remain hidden until client-display bounds/models are reviewed; encounter-script interactions remain disabled until ported.",
},
};
})
.filter(Boolean)
.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
}
function increment(map, key, amount = 1) {
map.set(key, (map.get(key) ?? 0) + amount);
}
function sortedCountEntries(map, keyName) {
return [...map.entries()]
.sort(([left], [right]) => String(left).localeCompare(String(right), undefined, { numeric: true }))
.map(([key, instances]) => ({ [keyName]: key, instances }));
}
function gameObjectAssetManifestFor(definitions) {
const assets = new Map();
const nonVisualObjects = new Map();
const unresolvedModels = new Map();
const unresolvedBounds = new Map();
let typedInstances = 0;
let visualDisplayInstances = 0;
let resolvedModelInstances = 0;
let boundedInstances = 0;
for (const dungeon of definitions) {
for (const object of dungeon.serverGameObjects ?? []) {
if (object.source?.fidelity !== "source-backed-adapted") continue;
typedInstances += 1;
const displayId = Number(object.source.clientDisplayId);
const modelPath = String(object.source.clientModelPath ?? "").replace(/\\/g, "/");
const hasBounds = Boolean(object.source.clientBounds);
if (!Number.isFinite(displayId) || displayId <= 0) {
const templateId = Number(object.source.templateId) || 0;
let nonVisual = nonVisualObjects.get(templateId);
if (!nonVisual) {
nonVisual = {
templateId,
instances: 0,
dungeons: new Map(),
kinds: new Map(),
};
nonVisualObjects.set(templateId, nonVisual);
}
nonVisual.instances += 1;
increment(nonVisual.dungeons, dungeon.id);
increment(nonVisual.kinds, object.kind);
continue;
}
visualDisplayInstances += 1;
if (hasBounds) boundedInstances += 1;
if (!modelPath) {
const key = displayId;
let unresolved = unresolvedModels.get(key);
if (!unresolved) {
unresolved = {
displayId: key,
instances: 0,
dungeons: new Map(),
kinds: new Map(),
templateIds: new Set(),
};
unresolvedModels.set(key, unresolved);
}
unresolved.instances += 1;
increment(unresolved.dungeons, dungeon.id);
increment(unresolved.kinds, object.kind);
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
} else {
resolvedModelInstances += 1;
const key = modelPath.toLowerCase();
let asset = assets.get(key);
if (!asset) {
const extension = path.extname(modelPath).toLowerCase().replace(/^\./, "") || "unknown";
const targetPath = modelPath.replace(/\.[^/.]+$/, ".glb");
asset = {
sourceModelPath: modelPath,
sourceType: extension,
targetUrl: `/assets/game/world-objects/${targetPath}`,
instances: 0,
boundedInstances: 0,
displayIds: new Set(),
dungeons: new Map(),
kinds: new Map(),
};
assets.set(key, asset);
}
asset.instances += 1;
if (hasBounds) asset.boundedInstances += 1;
asset.displayIds.add(displayId);
increment(asset.dungeons, dungeon.id);
increment(asset.kinds, object.kind);
}
if (!hasBounds) {
const key = displayId;
let unresolved = unresolvedBounds.get(key);
if (!unresolved) {
unresolved = {
displayId: key,
modelPath: modelPath || undefined,
instances: 0,
dungeons: new Map(),
kinds: new Map(),
templateIds: new Set(),
};
unresolvedBounds.set(key, unresolved);
}
unresolved.instances += 1;
increment(unresolved.dungeons, dungeon.id);
increment(unresolved.kinds, object.kind);
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
}
}
}
const serializeUnresolved = (entry) => ({
displayId: entry.displayId,
...(entry.modelPath ? { modelPath: entry.modelPath } : {}),
instances: entry.instances,
templateIds: [...entry.templateIds].sort((left, right) => left - right),
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
kinds: sortedCountEntries(entry.kinds, "kind"),
});
return {
schemaVersion: 1,
generatedBy: "scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
summary: {
typedInstances,
visualDisplayInstances,
nonVisualInstances: typedInstances - visualDisplayInstances,
resolvedModelInstances,
boundedInstances,
uniqueAssets: assets.size,
unresolvedModelInstances: visualDisplayInstances - resolvedModelInstances,
unresolvedBoundInstances: visualDisplayInstances - boundedInstances,
unresolvedModelDisplayIds: unresolvedModels.size,
unresolvedBoundDisplayIds: unresolvedBounds.size,
},
assets: [...assets.values()]
.sort((left, right) => left.sourceModelPath.localeCompare(right.sourceModelPath))
.map((asset) => ({
sourceModelPath: asset.sourceModelPath,
sourceType: asset.sourceType,
targetUrl: asset.targetUrl,
instances: asset.instances,
boundedInstances: asset.boundedInstances,
displayIds: [...asset.displayIds].sort((left, right) => left - right),
dungeons: sortedCountEntries(asset.dungeons, "dungeonId"),
kinds: sortedCountEntries(asset.kinds, "kind"),
})),
nonVisualObjects: [...nonVisualObjects.values()]
.sort((left, right) => left.templateId - right.templateId)
.map((entry) => ({
templateId: entry.templateId,
instances: entry.instances,
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
kinds: sortedCountEntries(entry.kinds, "kind"),
})),
unresolvedModels: [...unresolvedModels.values()]
.sort((left, right) => left.displayId - right.displayId)
.map(serializeUnresolved),
unresolvedBounds: [...unresolvedBounds.values()]
.sort((left, right) => left.displayId - right.displayId)
.map(serializeUnresolved),
};
}
function sourceBasisFor(recipe, environmentMode) {
if (environmentMode !== "adt-hybrid") {
@@ -371,13 +872,44 @@ function uniqueDraftEncounters(encounterRows, mapId) {
...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }),
};
const key = `${creatureId}:${normalizedName(encounter.name)}`;
if (!byIdentity.has(key)) byIdentity.set(key, encounter);
const previous = byIdentity.get(key);
if (!previous) {
byIdentity.set(key, encounter);
continue;
}
byIdentity.set(key, {
...previous,
modeIds: [...new Set([...previous.modeIds, ...encounter.modeIds])].sort((left, right) => left - right),
sourceRowIds: [...new Set([
...(previous.sourceRowIds ?? []),
...(encounter.sourceRowIds ?? []),
])].sort((left, right) => left - right),
orderIndex: Math.min(previous.orderIndex, encounter.orderIndex),
});
}
return [...byIdentity.values()].sort((left, right) => (
left.orderIndex - right.orderIndex || left.encounterId - right.encounterId
));
}
function encounterSpawnMask(recipe, encounter, candidate) {
const declaredMask = recipe.difficultyVariants.reduce(
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
0,
);
const candidateMask = Number(candidate?.spawnMask ?? 0) & declaredMask;
if (candidateMask > 0) return candidateMask;
const modeIds = new Set((encounter.modeIds ?? []).map(Number));
const rowMask = recipe.difficultyVariants.reduce((mask, difficulty) => (
difficulty.mapDifficultyId !== undefined
&& modeIds.has(Number(difficulty.mapDifficultyId))
? mask | Number(difficulty.spawnMask ?? 0)
: mask
), 0);
return rowMask || declaredMask || 1;
}
function archetypeFor(name) {
const value = normalizedName(name);
if (/murloc|murk|glub|fin/.test(value)) return "murloc";
@@ -707,6 +1239,18 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
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();
@@ -1020,6 +1564,12 @@ async function compile() {
const epochSpells = await exists(epochSpellDbc)
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
: ascensionSpells;
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))
: new Map();
const epochGameObjectDisplays = await exists(epochGameObjectDisplayDbc)
? parseGameObjectDisplays(await readFile(epochGameObjectDisplayDbc))
: standardGameObjectDisplays;
const previousOutput = await exists(outputFile)
? await readJson(outputFile)
@@ -1091,6 +1641,14 @@ async function compile() {
?? draft?.spawns?.[0]?.position
?? playerAnchor.position,
);
const serverGameObjects = compileSourceGameObjects(
draft,
mapPosition,
recipe,
recipe.clientBuild === "3.3.5a-Epoch"
? epochGameObjectDisplays
: standardGameObjectDisplays,
);
const sourceEntities = (draft?.entities ?? [])
.filter((entity) => (
!(recipe.slug === "wailing-caverns" && Number(entity.entry) === 5912)
@@ -1218,6 +1776,11 @@ async function compile() {
const usedBossSpawnIds = new Set();
const bossObjectives = encounters.map((encounter, index) => {
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
const spawnMask = encounterSpawnMask(recipe, encounter, candidate);
const declaredDifficultyMask = recipe.difficultyVariants.reduce(
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
0,
);
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
const routeAlpha = 0.18 + alpha * 0.82;
const nativeRoutePosition = nativeRoutePositions.length
@@ -1233,6 +1796,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,
@@ -1240,6 +1804,10 @@ async function compile() {
name: encounter.name,
position,
yaw: candidate?.yaw ?? 0,
spawnMask,
...(spawnChance < 1 || (spawnMask & declaredDifficultyMask) !== declaredDifficultyMask
? { optional: true }
: {}),
...(encounter.eventOnly ? { eventOnly: true } : {}),
};
});
@@ -1248,6 +1816,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) {
@@ -1269,6 +1838,7 @@ async function compile() {
pathId: spawn.pathId,
formationSource: "database-solo",
spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
waypoints,
members: [{
id: "member",
@@ -1286,6 +1856,7 @@ async function compile() {
position: transformedPosition,
yaw: spawn.yaw,
spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
});
}
for (const boss of bossObjectives) {
@@ -1296,7 +1867,7 @@ async function compile() {
entityId: boss.entityId,
position: boss.position,
yaw: boss.yaw,
spawnMask: 1,
spawnMask: boss.spawnMask,
});
}
@@ -1337,10 +1908,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
@@ -1408,7 +1980,8 @@ async function compile() {
bosses,
objectiveOrder: bosses,
navigationLinks: [],
serverGameObjects: draft?.gameObjects ?? [],
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
serverGameObjects,
provenance: {
clientBuild: recipe.clientBuild,
coordinateAlignment,
@@ -1472,7 +2045,13 @@ async function compile() {
};
const serialized = JSON.stringify(output);
const compressed = gzipSync(Buffer.from(serialized), { level: 9 });
const gameObjectAssetManifest = gameObjectAssetManifestFor(definitions);
await writeFile(outputFile, `${serialized}\n`, "utf8");
await writeFile(
gameObjectAssetManifestFile,
`${JSON.stringify(gameObjectAssetManifest, null, 2)}\n`,
"utf8",
);
await writeFile(
compressedOutputFile,
`// Generated by npm run dungeon:catalog. Do not edit.\nexport default "${compressed.toString("base64")}";\n`,
@@ -1499,6 +2078,7 @@ async function compile() {
status: "complete",
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
compressedOutput: path.relative(projectRoot, compressedOutputFile).replace(/\\/g, "/"),
gameObjectAssetManifest: path.relative(projectRoot, gameObjectAssetManifestFile).replace(/\\/g, "/"),
compressedBytes: compressed.length,
dungeons: definitions.length,
encounters: coverage.reduce((total, row) => total + row.encounters, 0),
@@ -1507,6 +2087,11 @@ async function compile() {
roamingPacks: coverage.reduce((total, row) => total + row.roamingPacks, 0),
authoritativeDungeons: coverage.filter((row) => row.source === "azerothcore").length,
fallbackDungeons: coverage.filter((row) => row.source === "procedural-fallback").length,
gameObjectInstances: gameObjectAssetManifest.summary.typedInstances,
nonVisualGameObjectInstances: gameObjectAssetManifest.summary.nonVisualInstances,
gameObjectAssets: gameObjectAssetManifest.summary.uniqueAssets,
unresolvedGameObjectModelInstances: gameObjectAssetManifest.summary.unresolvedModelInstances,
unresolvedGameObjectBoundInstances: gameObjectAssetManifest.summary.unresolvedBoundInstances,
}, null, 2));
}
+25 -23
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import {
clientRootForRecipe,
@@ -10,24 +10,21 @@ import {
recipesRoot,
workRoot,
} from "./lib.mjs";
import { runWowExporter } from "./wow-export-tool.mjs";
async function runExporter(argumentsList) {
const executable = path.resolve(projectRoot, "../wow335a/dungeon-export-work/tools/wow.export-0.2.19/wow.export.exe");
if (!await exists(executable)) throw new Error("The patched portable wow.export executable was not found.");
await new Promise((resolve, reject) => {
const child = spawn(executable, ["--disable-auto-update", ...argumentsList], {
cwd: path.dirname(executable),
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`wow.export exited with code ${code}.`)));
});
async function resultMtime(output) {
const resultFile = path.join(output, "automation-result.json");
return await exists(resultFile) ? (await stat(resultFile)).mtimeMs : 0;
}
async function checkedResult(slug, output) {
async function checkedResult(slug, output, previousMtime) {
const resultFile = path.join(output, "automation-result.json");
if (!await exists(resultFile)) return { status: "blocked", dungeonId: slug, blockers: ["wow.export produced no automation-result.json."] };
if (!await exists(resultFile)) return { status: "blocked", dungeonId: slug, blockers: [`wow.export produced no automation result at ${resultFile}.`] };
if ((await stat(resultFile)).mtimeMs <= previousMtime) return {
status: "blocked",
dungeonId: slug,
blockers: [`wow.export did not refresh ${resultFile}; refusing to accept a stale result from an earlier run.`],
};
const result = await readJson(resultFile);
if (!result.ok) return {
status: "blocked",
@@ -43,13 +40,14 @@ export async function exportDungeonSource(slug) {
const recipe = await loadRecipe(slug);
const clientRoot = clientRootForRecipe(recipe);
const output = path.join(workRoot, slug, "source-export");
await runExporter([
const previousMtime = await resultMtime(output);
await runWowExporter([
"--export-dungeon",
"--recipe", path.join(recipesRoot, `${slug}.json`),
"--client-root", clientRoot,
"--output", output,
]);
const blocked = await checkedResult(slug, output);
], { projectRoot });
const blocked = await checkedResult(slug, output, previousMtime);
if (blocked) return blocked;
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
}
@@ -58,12 +56,15 @@ export async function exportClientDiscoveryTables(slug) {
const recipe = await loadRecipe(slug);
const clientRoot = clientRootForRecipe(recipe);
const output = path.join(workRoot, slug, "client-dbc");
await runExporter([
const previousMtime = await resultMtime(output);
await runWowExporter([
"--discover-dungeon",
"--recipe", path.join(recipesRoot, `${slug}.json`),
"--client-root", clientRoot,
"--output", output,
]);
], { projectRoot });
const blocked = await checkedResult(slug, output, previousMtime);
if (blocked) throw new Error(blocked.blockers.join("\n"));
return output;
}
@@ -88,13 +89,14 @@ export async function exportCreatureSource(slug) {
blockers: unresolved.map((creature) => `${creature.name ?? creature.slug} has an unresolved display/composite recipe.`),
};
const output = path.join(workRoot, slug, "creature-source");
await runExporter([
const previousMtime = await resultMtime(output);
await runWowExporter([
"--export-creatures",
"--recipe", selected,
"--client-root", clientRoot,
"--output", output,
]);
const blocked = await checkedResult(slug, output);
], { projectRoot });
const blocked = await checkedResult(slug, output, previousMtime);
if (blocked) return blocked;
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
}
+21 -9
View File
@@ -28,6 +28,7 @@ import {
import {
nextTiledNavMeshSize,
planNavigationBuild,
shouldRetryFailedTiledBuildWithMonotone,
shouldUseMonotonePartition,
validateTiledBuild,
} from "./recast-policy.mjs";
@@ -275,9 +276,11 @@ export async function bakeNavigationFromCollisionAssets({
const meshes = scenes.flatMap(meshesIn);
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
const input = inspectNavigationInput(meshes);
let build = planNavigationBuild(input.bounds, settings.cellSize, {
tileSize: settings.tileSize,
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
tileSize,
...(settings.forceTiled === true ? { maxSoloAxis: 0, maxSoloCells: 0 } : {}),
});
let build = navigationPlan(settings.tileSize);
const generatorConfig = {
cs: settings.cellSize,
ch: settings.cellHeight,
@@ -325,7 +328,20 @@ export async function bakeNavigationFromCollisionAssets({
retainTileIntermediates,
);
}
if (!generated.success) throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
if (!generated.success) {
buildAttempts.push({
partition: tiledPartition,
tileSize: build.tileSize,
tileCount: build.tileCount,
valid: false,
failures: [{ x: null, y: null, message: generated.error }],
});
if (shouldRetryFailedTiledBuildWithMonotone(generated.error, tiledPartition)) {
tiledPartition = "monotone";
continue;
}
throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
}
tiledValidation = validateTiledBuild(generated.intermediates, {
errorCategory: Recast.RC_LOG_ERROR,
warningCategory: Recast.RC_LOG_WARNING,
@@ -351,9 +367,7 @@ export async function bakeNavigationFromCollisionAssets({
releaseTiledIntermediates(generated.intermediates);
generated.navMesh.destroy();
generated = null;
build = planNavigationBuild(input.bounds, settings.cellSize, {
tileSize: nextTileSize,
});
build = navigationPlan(nextTileSize);
continue;
}
if (
@@ -364,9 +378,7 @@ export async function bakeNavigationFromCollisionAssets({
generated.navMesh.destroy();
generated = null;
tiledPartition = "monotone";
build = planNavigationBuild(input.bounds, settings.cellSize, {
tileSize: settings.tileSize,
});
build = navigationPlan(settings.tileSize);
continue;
}
const sample = tiledValidation.failures
@@ -146,3 +146,8 @@ export function shouldUseMonotonePartition(validation) {
(failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message),
);
}
export function shouldRetryFailedTiledBuildWithMonotone(error, partition) {
return partition === "watershed"
&& /failed to triangulate contours/i.test(String(error ?? ""));
}
@@ -45,6 +45,9 @@ function runNpm(script, argumentsList = []) {
async function main() {
if (options.has("--audit-only")) {
await runNpm("dungeon:wow:audit");
await runNpm("dungeon:wow:functionality:audit");
await runNpm("dungeon:ascension:recovery:validate");
await runNpm("dungeon:wow:export-tool:test");
await runNpm("dungeon:wow:test");
return;
}
@@ -63,8 +66,11 @@ async function main() {
await runNpm("dungeon:catalog");
await runNpm("dungeon:five-player:verify");
await runNpm("dungeon:wow:audit");
await runNpm("dungeon:wow:functionality:audit");
await runNpm("dungeon:ascension:recovery:validate");
await runNpm("dungeon:wow:export-tool:test");
await runNpm("dungeon:wow:test");
if (options.has("--build")) await runNpm("build");
}
await main();
await main();
@@ -0,0 +1,300 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "..", "..");
const manifestPath = path.join(
projectRoot,
"dungeon-pipeline",
"ascension",
"ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
);
function fail(message) {
throw new Error(message);
}
function assert(condition, message) {
if (!condition) {
fail(message);
}
}
function assertDeepEqual(actual, expected, message) {
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
}
async function readJson(relativePath) {
const contents = await readFile(path.join(projectRoot, relativePath), "utf8");
return JSON.parse(contents);
}
function resolveDbcPath(manifest) {
const dbcArgumentIndex = process.argv.indexOf("--dbc");
if (dbcArgumentIndex !== -1) {
const value = process.argv[dbcArgumentIndex + 1];
assert(value, "--dbc requires a path");
return path.resolve(process.cwd(), value);
}
if (process.env.ASCENSION_SPELL_DBC) {
return path.resolve(process.env.ASCENSION_SPELL_DBC);
}
const source = manifest.sources.find(
(candidate) => candidate.id === "ascension-area52-spell-dbc",
);
assert(source?.pathHint, "Spell.dbc source path is missing from the manifest");
return path.resolve(projectRoot, source.pathHint);
}
function readCString(buffer, offset) {
const end = buffer.indexOf(0, offset);
assert(end !== -1, `Unterminated DBC string at byte ${offset}`);
return buffer.toString("utf8", offset, end);
}
async function validateSpellDbc(manifest) {
if (process.argv.includes("--skip-dbc")) {
return { skipped: true, reason: "requested with --skip-dbc" };
}
const source = manifest.sources.find(
(candidate) => candidate.id === "ascension-area52-spell-dbc",
);
assert(source, "Spell.dbc source metadata is missing");
const dbcPath = resolveDbcPath(manifest);
let buffer;
try {
buffer = await readFile(dbcPath);
} catch (error) {
if (error?.code === "ENOENT") {
return { skipped: true, reason: `not installed at ${dbcPath}` };
}
throw error;
}
const digest = createHash("sha256").update(buffer).digest("hex");
assert(digest === source.sha256, `Spell.dbc SHA-256 mismatch: ${digest}`);
const header = {
signature: buffer.toString("ascii", 0, 4),
recordCount: buffer.readUInt32LE(4),
fieldCount: buffer.readUInt32LE(8),
recordSize: buffer.readUInt32LE(12),
stringBlockSize: buffer.readUInt32LE(16),
};
assertDeepEqual(header, source.header, "Spell.dbc header does not match the manifest");
const expectedNames = new Map();
const summonLinks = new Map();
for (const dungeon of manifest.dungeons) {
for (const [spellId, name] of Object.entries(dungeon.sourceRecovery.spellRows)) {
expectedNames.set(Number(spellId), name);
}
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
const links = summonLinks.get(link.spellId) ?? [];
links.push({ ...link, mapId: dungeon.mapId });
summonLinks.set(link.spellId, links);
}
}
const recordStart = 20;
const stringStart = recordStart + header.recordCount * header.recordSize;
const expectedLength = stringStart + header.stringBlockSize;
assert(
expectedLength === buffer.length,
`Spell.dbc length mismatch: expected ${expectedLength}, got ${buffer.length}`,
);
const found = new Map();
for (let recordIndex = 0; recordIndex < header.recordCount; recordIndex += 1) {
const offset = recordStart + recordIndex * header.recordSize;
const spellId = buffer.readUInt32LE(offset);
if (!expectedNames.has(spellId)) {
continue;
}
const nameOffset = buffer.readUInt32LE(offset + 136 * 4);
found.set(spellId, {
name: readCString(buffer, stringStart + nameOffset),
offset,
});
}
for (const [spellId, expectedName] of expectedNames) {
const row = found.get(spellId);
assert(row, `Spell.dbc row ${spellId} is missing`);
assert(
row.name === expectedName,
`Spell.dbc row ${spellId} name mismatch: expected ${JSON.stringify(expectedName)}, got ${JSON.stringify(row.name)}`,
);
}
for (const [spellId, links] of summonLinks) {
const row = found.get(spellId);
assert(row, `Summon spell ${spellId} was not loaded`);
for (const link of links) {
const effectType = buffer.readUInt32LE(row.offset + (71 + link.effectIndex) * 4);
const entryId = buffer.readInt32LE(row.offset + (110 + link.effectIndex) * 4);
assert(
effectType === link.effectType,
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} type mismatch: expected ${link.effectType}, got ${effectType}`,
);
assert(
entryId === link.entryId,
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} entry mismatch: expected ${link.entryId}, got ${entryId}`,
);
}
}
return {
skipped: false,
path: dbcPath,
spellRows: found.size,
summonLinks: [...summonLinks.values()].flat().length,
};
}
function validateCatalogs(manifest, campaign, runtime, assets) {
assert(manifest.schemaVersion === 1, "Unsupported recovery manifest schema");
assert(manifest.status === "partial-authoritative-recovery", "Unexpected manifest status");
assertDeepEqual(
manifest.dungeons.map((dungeon) => dungeon.mapId),
[821, 908, 1781],
"Recovery manifest must contain exactly maps 821, 908, and 1781 in that order",
);
let spellRows = 0;
let summonLinks = 0;
for (const dungeon of manifest.dungeons) {
const definition = campaign.definitions.find(
(candidate) => candidate.mapId === dungeon.mapId,
);
assert(definition, `Campaign definition for map ${dungeon.mapId} is missing`);
assert(definition.id === dungeon.id, `Map ${dungeon.mapId} campaign ID drifted`);
assert(definition.title === dungeon.title, `Map ${dungeon.mapId} title drifted`);
const assetPackage = assets.packages.find(
(candidate) => candidate.mapId === dungeon.mapId,
);
assert(assetPackage, `Asset package for map ${dungeon.mapId} is missing`);
assert(
assetPackage.source.mapDirectory === dungeon.mapDirectory,
`Map ${dungeon.mapId} client map directory drifted`,
);
assert(
Object.values(assetPackage.source.checksums).includes(dungeon.environment.sourceHash),
`Map ${dungeon.mapId} source checksum drifted`,
);
for (const kind of ["player", "trash", "boss"]) {
const anchor = assetPackage.anchors.find((candidate) => candidate.kind === kind);
assert(anchor, `Map ${dungeon.mapId} ${kind} anchor is missing`);
assertDeepEqual(
anchor.position,
dungeon.environment.anchors[kind],
`Map ${dungeon.mapId} ${kind} anchor drifted`,
);
}
for (const encounter of dungeon.encounters) {
const runtimeEncounter = runtime.encounters.find(
(candidate) =>
candidate.encounterId === encounter.encounterId &&
candidate.mapIds.includes(dungeon.mapId) &&
candidate.modeIds.includes(encounter.modeId),
);
assert(
runtimeEncounter,
`Map ${dungeon.mapId} encounter ${encounter.encounterId} mode ${encounter.modeId} is missing`,
);
assert(
runtimeEncounter.name === encounter.name,
`Encounter ${encounter.encounterId} name drifted`,
);
assert(
runtimeEncounter.creatureId === encounter.creatureId,
`Encounter ${encounter.encounterId} creature ID drifted`,
);
}
const bossEntity = definition.entities[dungeon.currentRuntime.bossEntityId];
const bossCreatureId = dungeon.encounters[0].creatureId;
assert(bossEntity, `Map ${dungeon.mapId} current boss entity is missing`);
assert(
bossEntity.combat.serverEntry === bossCreatureId,
`Map ${dungeon.mapId} current boss server entry drifted`,
);
const generatedTrash = definition.staticSpawns.filter((spawn) =>
spawn.id.startsWith("generated-trash-"),
);
assert(
generatedTrash.length === dungeon.currentRuntime.proceduralTrashSpawnCount,
`Map ${dungeon.mapId} procedural trash count drifted`,
);
for (const proceduralEntity of dungeon.currentRuntime.proceduralMobEntries) {
const entity = definition.entities[proceduralEntity.entityId];
assert(entity, `Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} is missing`);
assert(
entity.combat.serverEntry === proceduralEntity.serverEntry,
`Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} server entry drifted`,
);
}
const rowIds = new Set(
Object.keys(dungeon.sourceRecovery.spellRows).map((spellId) => Number(spellId)),
);
spellRows += rowIds.size;
for (const [family, familyRows] of Object.entries(dungeon.sourceRecovery.families)) {
for (const spellId of familyRows) {
assert(
rowIds.has(spellId),
`Map ${dungeon.mapId} family ${family} references absent spell ${spellId}`,
);
}
}
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
summonLinks += 1;
assert(rowIds.has(link.spellId), `Map ${dungeon.mapId} summon spell ${link.spellId} is absent`);
const targetCollection = link.targetKind === "gameobject" ? dungeon.gameObjects : dungeon.entities;
assert(
targetCollection?.some((target) => target.entryId === link.entryId),
`Map ${dungeon.mapId} summon target ${link.targetKind} ${link.entryId} is absent`,
);
}
assert(
dungeon.adaptation.mustBeCustomAuthored.length > 0,
`Map ${dungeon.mapId} must explicitly list custom-authored gaps`,
);
}
return { dungeons: manifest.dungeons.length, spellRows, summonLinks };
}
const manifest = await readJson(
"dungeon-pipeline/ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
);
const campaign = await readJson("src/game/generated/dungeonCampaignCatalog.json");
const runtime = await readJson("src/game/generated/manastormRuntimeCatalog.json");
const assets = await readJson("src/game/generated/manastormAssetPackages.json");
const catalogResult = validateCatalogs(manifest, campaign, runtime, assets);
const dbcResult = await validateSpellDbc(manifest);
console.log(
`Validated ${catalogResult.dungeons} Ascension custom dungeons, ${catalogResult.spellRows} recovered spell rows, and ${catalogResult.summonLinks} summon links.`,
);
if (dbcResult.skipped) {
console.log(`Spell.dbc verification skipped: ${dbcResult.reason}.`);
} else {
console.log(
`Spell.dbc verified (${dbcResult.spellRows} rows, ${dbcResult.summonLinks} summon links): ${dbcResult.path}`,
);
}
@@ -0,0 +1,125 @@
import { spawn as spawnChild } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
export const WOW_EXPORT_AUTOMATION_VERSION = "0.2.19";
export const WOW_EXPORT_REQUIRED_FLAGS = Object.freeze([
"--disable-auto-update",
"--discover-dungeon",
"--export-dungeon",
"--export-creatures",
]);
export function defaultWowExportExecutable(projectRoot) {
return path.resolve(
projectRoot,
`../wow335a/dungeon-export-work/tools/wow.export-${WOW_EXPORT_AUTOMATION_VERSION}/wow.export.exe`,
);
}
async function inspectCandidate(file, statFile) {
try {
const info = await statFile(file);
return info.isFile()
? { usable: true, reason: null }
: { usable: false, reason: "path exists but is not a file" };
} catch (error) {
if (error?.code === "ENOENT") return { usable: false, reason: "file does not exist" };
return { usable: false, reason: `cannot inspect file (${error?.code ?? error?.message ?? "unknown error"})` };
}
}
function unavailableMessage(checks, configured) {
const checked = checks.map(({ label, file, reason }) => ` - ${label}: ${file} (${reason})`).join("\n");
const selection = configured
? "WOW_EXPORT_BIN is set, so the default location was not used. Correct or unset that variable."
: "Restore the patched portable bundle at the default location, or set WOW_EXPORT_BIN to its exact executable path.";
return [
"HealerMan's patched wow.export automation bridge is unavailable.",
`Required build: wow.export ${WOW_EXPORT_AUTOMATION_VERSION} with these automation flags: ${WOW_EXPORT_REQUIRED_FLAGS.join(", ")}.`,
"Checked:",
checked,
selection,
"A stock GUI-only wow.export executable is not a drop-in replacement unless it includes the HealerMan automation patch.",
"No export was started and no placeholder assets were created.",
].join("\n");
}
export class WowExportUnavailableError extends Error {
constructor(checks, configured) {
super(unavailableMessage(checks, configured));
this.name = "WowExportUnavailableError";
this.code = "WOW_EXPORT_AUTOMATION_UNAVAILABLE";
this.checks = checks;
this.requiredVersion = WOW_EXPORT_AUTOMATION_VERSION;
this.requiredFlags = [...WOW_EXPORT_REQUIRED_FLAGS];
}
}
export async function resolveWowExportExecutable({
projectRoot,
environment = process.env,
statFile = stat,
} = {}) {
if (!projectRoot) throw new TypeError("resolveWowExportExecutable requires projectRoot.");
const configuredValue = environment.WOW_EXPORT_BIN?.trim();
const candidates = configuredValue
? [{ label: "WOW_EXPORT_BIN", file: path.resolve(projectRoot, configuredValue) }]
: [{ label: "default portable path", file: defaultWowExportExecutable(projectRoot) }];
const checks = [];
for (const candidate of candidates) {
const inspection = await inspectCandidate(candidate.file, statFile);
if (inspection.usable) return candidate.file;
checks.push({ ...candidate, reason: inspection.reason });
}
throw new WowExportUnavailableError(checks, Boolean(configuredValue));
}
function exporterFailureMessage(executable, argumentsList, detail) {
const operation = argumentsList.find((argument) => argument.startsWith("--")) ?? "unknown operation";
return [
`wow.export automation failed during ${operation}: ${detail}`,
`Executable: ${executable}`,
`Required automation flags: ${WOW_EXPORT_REQUIRED_FLAGS.join(", ")}.`,
"Verify that WOW_EXPORT_BIN points to the HealerMan-patched build, not the stock GUI-only release.",
"No placeholder assets were created.",
].join("\n");
}
export async function runWowExporter(argumentsList, {
projectRoot,
environment = process.env,
statFile = stat,
spawn = spawnChild,
} = {}) {
const executable = await resolveWowExportExecutable({ projectRoot, environment, statFile });
await new Promise((resolve, reject) => {
const child = spawn(executable, ["--disable-auto-update", ...argumentsList], {
cwd: path.dirname(executable),
stdio: "inherit",
windowsHide: true,
});
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
reject(error);
};
child.once("error", (error) => fail(new Error(exporterFailureMessage(
executable,
argumentsList,
`process could not start (${error?.code ?? error?.message ?? "unknown error"})`,
))));
child.once("exit", (code, signal) => {
if (settled) return;
settled = true;
if (code === 0) resolve();
else reject(new Error(exporterFailureMessage(
executable,
argumentsList,
signal ? `process exited after signal ${signal}` : `process exited with code ${code}`,
)));
});
});
return executable;
}
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import path from "node:path";
import test from "node:test";
import {
defaultWowExportExecutable,
resolveWowExportExecutable,
runWowExporter,
WOW_EXPORT_REQUIRED_FLAGS,
} from "./wow-export-tool.mjs";
const projectRoot = path.resolve("fixture/HealerMan");
const fileInfo = { isFile: () => true };
test("resolveWowExportExecutable uses the versioned portable path by default", async () => {
const expected = defaultWowExportExecutable(projectRoot);
const inspected = [];
const resolved = await resolveWowExportExecutable({
projectRoot,
environment: {},
statFile: async (file) => {
inspected.push(file);
return fileInfo;
},
});
assert.equal(resolved, expected);
assert.deepEqual(inspected, [expected]);
});
test("resolveWowExportExecutable honors an explicit relative WOW_EXPORT_BIN", async () => {
const expected = path.resolve(projectRoot, "tools/patched/wow.export.exe");
const resolved = await resolveWowExportExecutable({
projectRoot,
environment: { WOW_EXPORT_BIN: "tools/patched/wow.export.exe" },
statFile: async (file) => {
assert.equal(file, expected);
return fileInfo;
},
});
assert.equal(resolved, expected);
});
test("resolveWowExportExecutable rejects a directory with actionable diagnostics", async () => {
const configured = path.resolve(projectRoot, "tools/not-an-executable");
await assert.rejects(
resolveWowExportExecutable({
projectRoot,
environment: { WOW_EXPORT_BIN: configured },
statFile: async () => ({ isFile: () => false }),
}),
(error) => {
assert.equal(error.code, "WOW_EXPORT_AUTOMATION_UNAVAILABLE");
assert.match(error.message, /path exists but is not a file/);
assert.match(error.message, /Correct or unset that variable/);
assert.match(error.message, /No export was started and no placeholder assets were created/);
for (const flag of WOW_EXPORT_REQUIRED_FLAGS) assert.ok(error.message.includes(flag));
return true;
},
);
});
test("resolveWowExportExecutable reports the exact missing default path", async () => {
const expected = defaultWowExportExecutable(projectRoot);
await assert.rejects(
resolveWowExportExecutable({
projectRoot,
environment: {},
statFile: async () => {
const error = new Error("missing");
error.code = "ENOENT";
throw error;
},
}),
(error) => {
assert.ok(error.message.includes(expected));
assert.match(error.message, /set WOW_EXPORT_BIN/);
assert.match(error.message, /stock GUI-only wow\.export executable is not a drop-in replacement/);
return true;
},
);
});
test("runWowExporter supplies the non-updating automation invocation", async () => {
const executable = path.resolve(projectRoot, "tools/wow.export.exe");
let invocation;
const spawn = (file, args, options) => {
invocation = { file, args, options };
const child = new EventEmitter();
queueMicrotask(() => child.emit("exit", 0, null));
return child;
};
const result = await runWowExporter(["--export-dungeon", "--recipe", "fixture.json"], {
projectRoot,
environment: { WOW_EXPORT_BIN: executable },
statFile: async () => fileInfo,
spawn,
});
assert.equal(result, executable);
assert.deepEqual(invocation.args, ["--disable-auto-update", "--export-dungeon", "--recipe", "fixture.json"]);
assert.equal(invocation.options.cwd, path.dirname(executable));
assert.equal(invocation.options.windowsHide, true);
});
test("runWowExporter identifies an incompatible executable after a failed launch", async () => {
const executable = path.resolve(projectRoot, "tools/wow.export.exe");
const spawn = () => {
const child = new EventEmitter();
queueMicrotask(() => child.emit("exit", 2, null));
return child;
};
await assert.rejects(
runWowExporter(["--export-creatures"], {
projectRoot,
environment: { WOW_EXPORT_BIN: executable },
statFile: async () => fileInfo,
spawn,
}),
(error) => {
assert.match(error.message, /process exited with code 2/);
assert.match(error.message, /HealerMan-patched build/);
assert.match(error.message, /No placeholder assets were created/);
return true;
},
);
});
@@ -25,16 +25,34 @@ 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;
if (issue.code === "IMAGE_UNRECOGNIZED_FORMAT"
&& hasBasisu
&& /^\/images\/\d+$/.test(issue.pointer ?? "")) 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));
+23 -12
View File
@@ -75,6 +75,18 @@ class ReleaseVersion:
version_code: int
def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
"""Commands that must succeed before a release can be staged."""
return (
([npm, "run", "content:test"], "Content tests"),
([npm, "run", "server:test"], "Server tests"),
([npm, "test"], "Application tests"),
([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"),
([npm, "run", "build"], "Production build"),
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
)
def parse_semver(value: str) -> tuple[int, int, int]:
match = SEMVER_PATTERN.fullmatch(value.strip())
if not match:
@@ -239,11 +251,13 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
cwd=project_root,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
output = result.stdout.decode("utf-8", errors="replace").strip()
output = b"\n".join((result.stdout, result.stderr)).decode(
"utf-8", errors="replace"
).strip()
raise ReleaseError(
f"Release-candidate Git check failed ({result.returncode}): "
f"git {' '.join(args)}\n{output}"
@@ -269,11 +283,15 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stderr=subprocess.PIPE,
check=False,
)
if whitespace_result.returncode != 0:
detail = whitespace_result.stdout.strip()
detail = "\n".join(
part.strip()
for part in (whitespace_result.stdout, whitespace_result.stderr)
if part.strip()
)
raise ReleaseError(
"Release-candidate whitespace check failed. This check includes "
f"tracked and untracked files:\n{detail}"
@@ -1096,14 +1114,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
f"Recorded Android version {version.version_name} / {version.version_code}."
)
checks = (
([npm, "run", "content:test"], "Content tests"),
([npm, "run", "server:test"], "Server tests"),
([npm, "test"], "Application tests"),
([npm, "run", "build"], "Production build"),
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
)
for command, label in checks:
for command, label in full_release_checks(npm):
self._run_command(command, label)
self._run_command(["git", "add", "-A"], "Stage release")
staged = True
+7
View File
@@ -47,6 +47,13 @@ class SafetyTests(unittest.TestCase):
self.assertIn("pull --ff-only", block)
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
def test_full_release_publishes_verified_content_before_building(self):
checks = release_manager.full_release_checks("npm")
commands = [command for command, _label in checks]
publish_index = commands.index(["npm", "run", "content:publish"])
self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
class ReleaseCandidateTests(unittest.TestCase):
def test_checks_untracked_files_without_changing_the_real_index(self):
+7
View File
@@ -56,6 +56,13 @@ try {
if ($LASTEXITCODE -ne 0) {
throw "Gradle $Task failed with exit code $LASTEXITCODE"
}
$packageType = if ($Task -eq 'assembleRelease') { 'release' } else { 'debug' }
$packageDirectory = Join-Path $projectRoot "android\app\build\outputs\apk\$packageType"
$builtPackage = Get-ChildItem -LiteralPath $packageDirectory -Filter '*.apk' |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $builtPackage) { throw "No APK was produced in $packageDirectory" }
& python (Join-Path $projectRoot 'scripts\content\verify-android-package.py') $builtPackage.FullName
if ($LASTEXITCODE -ne 0) { throw "Packaged content verification failed." }
} finally {
Pop-Location
}
@@ -47,15 +47,23 @@ reviewed mechanics passes.
## Coordinate conversion
RuneWaker server positions are world coordinates. The environment ROS is
placed by one WDB descriptor. Spawns must be moved into that ROS's local frame
before the RuneWaker-to-Three coordinate conversion:
RuneWaker server positions are world coordinates. The reviewed primary WDB
descriptor remains the coordinate anchor, while every static and reviewed
dynamic WDB resource is assembled relative to that anchor. Spawns use:
```text
sourceLocal = inverse(primaryWdbPlacement) * serverPosition
threeLocal = (sourceLocal.x, sourceLocal.y, -sourceLocal.z) * metersPerSourceUnit
```
Every resource placement uses the full matrix equivalent, including translation,
quaternion rotation, and nonuniform scale:
```text
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
```
The current calibrated scale is `0.1` meter per RuneWaker source unit. The
authoritative test-dungeon table uses 360 degrees per turn:
@@ -283,12 +291,17 @@ Review:
- collision chunks for decorative blockers;
- Recast report and entrance projection;
- WDB anchor in `import-metadata.json`;
- complete placement accounting in `wdb-placement-manifest.json`;
- source-evidenced dynamic placements are absent from static collision and have separately packaged runtime assets;
- dynamic filename candidates remain static and review-required until a reviewed recipe rule exists;
- Khronos validation output.
Secondary WDB doodads/effects remain audited. Repeated primary room placements
and documented source-coordinate support/fallback geometry are automated; a
complete arbitrary multi-ROS WDB scene still requires explicit placement,
material, collision, and performance review.
Every valid WDB ROS resource is exported once into
`runewaker-export-work/resource-cache/<resource-key>`, then reused for all of its
placements and across dungeon builds. Missing ROS files, non-ROS descriptors,
export failures, and non-mesh particle/light/sound/script components are recorded
instead of receiving substitute geometry. The preview and editable Blender file
remain primary-ROS diagnostics; review the assembled GLB for final coverage.
### 6. Review and build creature actors
@@ -401,6 +414,13 @@ Each mechanic must specify trigger, targets, timing, reset behavior, persistence
offline behavior, and multiplayer synchronization boundary before being marked
implemented.
Run `npm run runewaker:audit:encounters` after changing source snapshots,
mechanics evidence, generated populations, or the shared boss-mechanic catalog.
Its JSON report is the exhaustive implementation ledger. A source-spawn row or
evidence record may be promoted from
`deferred-source-gap` only when an exact executable mapping and its clearing test
are both present; a regex category or suggestive object name is not sufficient.
### 9. Register the dungeon
Use the generated environment metadata and population constants to create the
@@ -443,6 +463,8 @@ errors, WebGL errors, and mobile/desktop performance.
| `public/assets/creatures/<slug>/*` | Browser-shipping actor GLBs/manifest. |
| `src/game/generated/*Population.generated.ts` | Runtime entities/spawns/objectives. |
| `src/game/generated/*Mechanics.generated.ts` | Non-executable mechanics evidence summary. |
| `scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.*` | Deterministic per-row/per-signal fidelity and blocker ledger. |
| `scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.*` | Non-mutating WDB resource/placement coverage and unsupported-type audit. |
Generated runtime data must be reproducible from recipes plus portable snapshots
without SQL or network access.
@@ -462,13 +484,13 @@ without SQL or network access.
## Current deliberate limits
The generic scripts automate source discovery, primary environment conversion,
The generic scripts automate source discovery, source-hash-validated multi-resource WDB environment conversion,
portable population recovery, direct GR2 actor conversion, exact paperdoll
assembly, source-safe direct-damage attacks, spawn generation, repeated-room
assembly, source-anchor navigation, mechanics evidence extraction, runtime
assembly, full placement transforms, static navigation, reviewed dynamic-object packaging, mechanics evidence extraction, runtime
registration, and validation. They do not pretend that arbitrary Lua/AutoPlot
code can be safely translated without review. Disconnected navmesh islands,
doors, phases, summons, patrols, timers, interactions, loot semantics, arbitrary
multi-ROS placement assembly, source-exact damage formulas, and multiplayer
source behavior for unreviewed dynamic candidates, source-exact damage formulas, and multiplayer
encounter synchronization remain explicit per-instance engineering work until
implemented and playtested.
+57 -11
View File
@@ -3,11 +3,12 @@
This pipeline converts preserved RuneWaker `.ros` dungeon assets into the same
browser-facing GLB roles used by HealerMan's WoW dungeon pipeline:
1. the preserved Rune engine reads the WDB entity table and exports the primary ROS as an OBJ/MTL bridge;
2. Microsoft `texconv` decodes the legacy DDS textures to PNG;
3. Blender converts the Y-up source to textured visual and chunked collision GLBs;
4. glTF Transform optimizes the visual asset with meshopt;
5. HealerMan's Recast stage bakes navigation and the Khronos validator gates every GLB.
1. the preserved Rune engine reads every WDB descriptor and exports each unique ROS once into a normalized-path cache validated by the source SHA-256;
2. Microsoft `texconv` decodes each cached resource's legacy DDS textures to PNG;
3. Blender converts the Y-up resources to reusable visual and collision GLBs;
4. the assembler applies every placement's full translation/quaternion/scale transform relative to the reviewed primary WDB anchor;
5. glTF Transform instances and optimizes the assembled visual with meshopt;
6. HealerMan's Recast stage bakes static navigation and the Khronos validator gates every GLB.
Run the Forsaken Abbey pilot from the HealerMan root:
@@ -24,10 +25,29 @@ Optional environment variables:
The shipping environment is written to
`src/assets/game/dungeons/forsaken-abbey` and registered as a playable
dungeon. The build reads all 184 WDB entity descriptors,
uses the primary ROS placement as the server-to-local anchor, reads the entrance
from `Resource/luascript/03242.lua`, and requires that entrance to project within
2 meters of the generated navmesh.
dungeon. The build reads all 184 WDB entity descriptors, uses the primary ROS
placement only as the server-to-local coordinate anchor, reads the entrance from
`Resource/luascript/03242.lua`, and requires that entrance to project within 2
meters of the generated navmesh.
All valid ROS placement resources are assembled by default. Use `--primary-only`
only for a deliberate compatibility/debug build. The build writes
`wdb-placement-manifest.json` with stable placement IDs, source TRS, the complete
primary-local Three.js matrix, resource-cache keys, bounds, flags, asset status,
and reviewed dynamic-object metadata. Static placements enter visual, collision,
and navigation assets. Explicit dynamic placements ship separately so runtime
state can move, hide, or replace them without baking their collision into the
static world.
Audit the checked-in WDB coverage without regenerating GLBs:
```powershell
npm run runewaker:audit:environment-assembly
```
See
[RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md](./RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md)
for unsupported types and dynamic candidates that still need source review.
Population and creature assets are separate, offline-capable stages:
@@ -36,6 +56,15 @@ npm run runewaker:forsaken:actors
npm run runewaker:forsaken:population
```
Source-shaped combat coefficients and boss loot are refreshed independently:
```powershell
npm run runewaker:spells:refresh
npm run runewaker:loot:refresh
```
See [RUNEWAKER_FORMULA_AND_LOOT.md](./RUNEWAKER_FORMULA_AND_LOOT.md) for the exact formula mapping boundary and [RUNEWAKER_LOOT_IMPORT_REPORT.md](./RUNEWAKER_LOOT_IMPORT_REPORT.md) for every boss's loot status.
Only an explicit forensic refresh connects to the isolated development SQL
instance:
@@ -121,8 +150,10 @@ Spell conversion is deliberately fail-closed. Only a MagicObject component
positively classified as direct HP damage becomes an executable HealerMan
attack. Buffs, healing, summons, plot hooks, and unknown effects remain
evidence-only. The portable mapping preserves source spell identity, name,
damage family, school, range, and cooldown. The current 0.85 damage coefficient
is HealerMan-authored and is not represented as the original RuneWaker formula.
damage family, school, range, cooldown, source power fields, and source-code
formula provenance. Live damage uses a labeled HealerMan baseline adapter because
the required native ATK/MATK, DEF/MDEF, weapon, resistance, critical, level-rate,
and global-rate inputs are not packaged; it is not represented as source-exact.
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
statically follows each packaged boss's preserved AutoPlot/template callbacks
@@ -165,6 +196,21 @@ accepts that reviewed override only as `bossObjectiveOrder: { kind:
"source-authored", spawnIds: [...], evidence: [...] }`; it rejects missing
evidence, duplicates, or an order that does not account for every boss spawn.
The same gameplay audit also regenerates the deterministic
[RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md](./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md)
and its exhaustive JSON companion. The ledger records all deferred source spawn
rows and direct mechanics evidence with stable IDs, source files and hashes,
fidelity, implementation status, blocker, next work, and a clearing test.
Category totals are overlapping review labels, never a claimed count of unique
mechanics. Unproven behavior remains `deferred-source-gap` instead of being
silently inferred from a name or regex match.
Run only that ledger and its pipeline tests with:
~~~powershell
npm run runewaker:audit:encounters
~~~
Run the component-level navigation review separately:
~~~powershell
+20 -1
View File
@@ -13,7 +13,7 @@ Required top-level fields:
- `title`: display/source title.
- `source`: preserved WDB, primary ROS, config, and entrance evidence.
- `coordinateSystem`: source axes, units, and direction units.
- `navigation`: Recast settings.
- `navigation`: Recast settings. `partition` may select `watershed` or `monotone`; `forceTiled` is an explicit source-geometry workaround when a compact solo heightfield cannot triangulate its contours.
- `review`: human review states.
- `exceptions`: known limitations retained in metadata.
@@ -27,6 +27,25 @@ Required top-level fields:
- `entryLua` / `entryConstantPrefix`: authoritative entry constants; or
- `entryCoordinates`: explicit position/direction with `reviewRequired`.
Optional `source.wdbAssembly` fields:
- `mode`: `all-ros` by default, or `primary-only` for a deliberate compatibility build.
- `dynamicPlacements`: reviewed dynamic object selectors. Each entry requires a
stable `id`, `kind`, `evidence`, and either an exact WDB `guid` or normalized
`resource`; optional `initialState` and `collision` describe the runtime contract.
Valid ROS placements not selected as dynamic are static. Filename hints are
reported for review but never infer stateful behavior. Non-ROS descriptors,
invalid transforms, missing resources, and exporter failures remain explicit
unsupported records in the placement manifest.
The full placement transform is preserved as:
```text
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
```
## Population recipe
Required fields:
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,121 @@
# RuneWaker encounter implementation and fidelity ledger
Structural validation: **green**
Content completion: **review-required**
This report is deterministic and offline. Its JSON companion inventories every deferred
source spawn row and every direct mechanics evidence record. A regex category membership
is a review label, not a unique mechanic; one evidence record may belong to several categories.
## Coverage
- 30 dungeon packages.
- 4113 deferred source rows with stable spawn IDs.
- 1524 unique mechanics evidence records: 329 Lua lines and 1195 template references.
- 1146 overlapping category memberships; this number is intentionally not reported as mechanics.
- 388 evidence records have an exact executable source-spell mapping.
- 1136 evidence records remain source-review gaps.
## Status definitions
- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.
- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.
- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.
- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.
- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.
- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.
- `intentional-empty`: the authoritative packaged snapshot contains no population rows.
## Per-dungeon inventory
| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| aeternal-circle | 514 | 391 | 47 | 13 | 34 | 0 | deferred-source-gap |
| barren-caves | 63 | 15 | 14 | 7 | 7 | 1 | verified |
| cave-water-dragon | 144 | 47 | 15 | 2 | 13 | 2 | verified |
| demon-stronghold | 439 | 256 | 38 | 12 | 26 | 1 | verified |
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
| dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap |
| forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified |
| grafu-castle | 249 | 193 | 50 | 10 | 40 | 0 | deferred-source-gap |
| hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified |
| hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified |
| ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified |
| kal-turok-hive | 4 | 3 | 10 | 4 | 6 | 0 | deferred-source-gap |
| kalins-shrine | 289 | 124 | 294 | 17 | 277 | 5 | verified |
| kawaks-tomb | 293 | 176 | 46 | 7 | 39 | 1 | verified |
| kulech-bones-nest | 287 | 133 | 59 | 18 | 41 | 1 | verified |
| lair-demon-dragon | 532 | 429 | 28 | 9 | 19 | 0 | deferred-source-gap |
| mystic-altar | 398 | 147 | 68 | 26 | 42 | 3 | verified |
| paspers-shrine | 255 | 31 | 22 | 6 | 16 | 5 | verified |
| raksha-temple | 487 | 364 | 85 | 21 | 64 | 1 | verified |
| sand-vortex | 2 | 1 | 2 | 0 | 2 | 1 | verified |
| sardo-castle | 247 | 176 | 53 | 12 | 41 | 3 | verified |
| the-origin | 510 | 322 | 65 | 29 | 36 | 2 | verified |
| tomb-seven-heroes | 439 | 199 | 101 | 32 | 69 | 0 | deferred-source-gap |
| treasure-trove | 685 | 188 | 81 | 34 | 47 | 3 | verified |
| warnorken-arena | 40 | 39 | 2 | 0 | 2 | 0 | deferred-source-gap |
| ystra-labyrinth-boss1 | 17 | 14 | 12 | 2 | 10 | 3 | verified |
| ystra-labyrinth-boss2 | 3 | 2 | 3 | 1 | 2 | 1 | verified |
| ystra-labyrinth-boss3 | 7 | 6 | 2 | 0 | 2 | 1 | verified |
| zurhidon-stronghold | 400 | 247 | 58 | 23 | 35 | 0 | deferred-source-gap |
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
## Four explicit fallback bosses
| Boss | Runtime ID | Status | Fidelity | Blocker |
| --- | --- | --- | --- | --- |
| Necromage | `rw-fa-100070` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Flowing Chaos | `rw-fa-100561` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Cruel Eater | `rw-fa-100562` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
| Hollow Shell | `rw-fa-100563` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
These attacks are intentionally playable and telegraphed HealerMan behavior. They are not
presented as original RuneWaker mechanics until authoritative source behavior is recovered.
## Non-empty packages without confirmed boss objectives
- `aeternal-circle`: 514 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `dungeon-of-dalanis`: 229 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `grafu-castle`: 249 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `kal-turok-hive`: 4 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `lair-demon-dragon`: 532 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `tomb-seven-heroes`: 439 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `warnorken-arena`: 40 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
- `zurhidon-stronghold`: 400 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
## Intentional empty variants
- `demon-stronghold-125` (Zone 125): The authoritative packaged snapshot contains no active population rows.
- `zurhidon-stronghold-124` (Zone 124): The authoritative packaged snapshot contains no active population rows.
## Evidence category memberships
| Category | Evidence records carrying the label |
| --- | ---: |
| death | 100 |
| door | 23 |
| interaction | 166 |
| loot | 77 |
| movement | 6 |
| phase | 58 |
| spell | 679 |
| summon | 11 |
| timer | 26 |
## Remaining work ledger
| Blocker group | Items | Clearing work |
| --- | ---: | --- |
| deferredSourceRows | 4113 | Review source semantics and add explicit source-spawn encounter mappings. |
| unresolvedMechanicsEvidence | 1136 | Review surrounding Lua/template context; specify full trigger, effect, and reset behavior. |
| adaptedSpellFormula | 388 | Recover native formula inputs before claiming coefficient-level fidelity. |
| sourceGapFallbackBosses | 4 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
| nonEmptyZeroBossPackages | 8 | Recover authoritative boss membership and objective order. |
The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source
files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.
## Validation issues
- None.
@@ -0,0 +1,476 @@
{
"schemaVersion": 1,
"generatedBy": "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
"deterministic": true,
"validationStatus": "green",
"reviewStatus": "review-required",
"summary": {
"dungeons": 30,
"sourceDescriptors": 15976,
"uniqueDungeonResources": 2799,
"staticPlacements": 15976,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 107,
"unsupportedPlacements": 0,
"missingSourceResources": 0
},
"unsupportedResourceTypes": [
"WDB placement resources other than ROS are recorded but not converted.",
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry."
],
"issues": [],
"dungeons": [
{
"id": "aeternal-circle",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle.json",
"wdbReport": "src/assets/game/dungeons/aeternal-circle/wdb-report.json",
"sourceDescriptors": 575,
"uniqueResources": 182,
"staticPlacements": 575,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 54,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "barren-caves",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves.json",
"wdbReport": "src/assets/game/dungeons/barren-caves/wdb-report.json",
"sourceDescriptors": 1,
"uniqueResources": 1,
"staticPlacements": 1,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "cave-water-dragon",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon.json",
"wdbReport": "src/assets/game/dungeons/cave-water-dragon/wdb-report.json",
"sourceDescriptors": 121,
"uniqueResources": 16,
"staticPlacements": 121,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "demon-stronghold",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold.json",
"wdbReport": "src/assets/game/dungeons/demon-stronghold/wdb-report.json",
"sourceDescriptors": 90,
"uniqueResources": 4,
"staticPlacements": 90,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "demon-stronghold-125",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125.json",
"wdbReport": "src/assets/game/dungeons/demon-stronghold-125/wdb-report.json",
"sourceDescriptors": 90,
"uniqueResources": 4,
"staticPlacements": 90,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "dungeon-of-dalanis",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis.json",
"wdbReport": "src/assets/game/dungeons/dungeon-of-dalanis/wdb-report.json",
"sourceDescriptors": 652,
"uniqueResources": 124,
"staticPlacements": 652,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 5,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "forsaken-abbey",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey.json",
"wdbReport": "src/assets/game/dungeons/forsaken-abbey/wdb-report.json",
"sourceDescriptors": 184,
"uniqueResources": 26,
"staticPlacements": 184,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 1,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "grafu-castle",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json",
"wdbReport": "src/assets/game/dungeons/grafu-castle/wdb-report.json",
"sourceDescriptors": 899,
"uniqueResources": 286,
"staticPlacements": 899,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 17,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "hall-of-survivors",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors.json",
"wdbReport": "src/assets/game/dungeons/hall-of-survivors/wdb-report.json",
"sourceDescriptors": 296,
"uniqueResources": 32,
"staticPlacements": 296,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "hoto",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/hoto.json",
"wdbReport": "src/assets/game/dungeons/hoto/wdb-report.json",
"sourceDescriptors": 1627,
"uniqueResources": 119,
"staticPlacements": 1627,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ice-dwarf-kingdom",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom.json",
"wdbReport": "src/assets/game/dungeons/ice-dwarf-kingdom/wdb-report.json",
"sourceDescriptors": 1054,
"uniqueResources": 209,
"staticPlacements": 1054,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kal-turok-hive",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive.json",
"wdbReport": "src/assets/game/dungeons/kal-turok-hive/wdb-report.json",
"sourceDescriptors": 2,
"uniqueResources": 2,
"staticPlacements": 2,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 1,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kalins-shrine",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json",
"wdbReport": "src/assets/game/dungeons/kalins-shrine/wdb-report.json",
"sourceDescriptors": 943,
"uniqueResources": 133,
"staticPlacements": 943,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kawaks-tomb",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb.json",
"wdbReport": "src/assets/game/dungeons/kawaks-tomb/wdb-report.json",
"sourceDescriptors": 573,
"uniqueResources": 60,
"staticPlacements": 573,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "kulech-bones-nest",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest.json",
"wdbReport": "src/assets/game/dungeons/kulech-bones-nest/wdb-report.json",
"sourceDescriptors": 199,
"uniqueResources": 52,
"staticPlacements": 199,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "lair-demon-dragon",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon.json",
"wdbReport": "src/assets/game/dungeons/lair-demon-dragon/wdb-report.json",
"sourceDescriptors": 530,
"uniqueResources": 96,
"staticPlacements": 530,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "mystic-altar",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar.json",
"wdbReport": "src/assets/game/dungeons/mystic-altar/wdb-report.json",
"sourceDescriptors": 2096,
"uniqueResources": 48,
"staticPlacements": 2096,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "paspers-shrine",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json",
"wdbReport": "src/assets/game/dungeons/paspers-shrine/wdb-report.json",
"sourceDescriptors": 564,
"uniqueResources": 143,
"staticPlacements": 564,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "raksha-temple",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json",
"wdbReport": "src/assets/game/dungeons/raksha-temple/wdb-report.json",
"sourceDescriptors": 1028,
"uniqueResources": 228,
"staticPlacements": 1028,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 11,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "sand-vortex",
"mode": "primary-only-procedural-fallback",
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex.json",
"wdbReport": "src/assets/game/dungeons/sand-vortex/wdb-report.json",
"sourceDescriptors": 20,
"uniqueResources": 14,
"staticPlacements": 20,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "sardo-castle",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle.json",
"wdbReport": "src/assets/game/dungeons/sardo-castle/wdb-report.json",
"sourceDescriptors": 436,
"uniqueResources": 132,
"staticPlacements": 436,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "the-origin",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json",
"wdbReport": "src/assets/game/dungeons/the-origin/wdb-report.json",
"sourceDescriptors": 693,
"uniqueResources": 220,
"staticPlacements": 693,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 17,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "tomb-seven-heroes",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes.json",
"wdbReport": "src/assets/game/dungeons/tomb-seven-heroes/wdb-report.json",
"sourceDescriptors": 450,
"uniqueResources": 56,
"staticPlacements": 450,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "treasure-trove",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove.json",
"wdbReport": "src/assets/game/dungeons/treasure-trove/wdb-report.json",
"sourceDescriptors": 908,
"uniqueResources": 193,
"staticPlacements": 908,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 1,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "warnorken-arena",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena.json",
"wdbReport": "src/assets/game/dungeons/warnorken-arena/wdb-report.json",
"sourceDescriptors": 256,
"uniqueResources": 14,
"staticPlacements": 256,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ystra-labyrinth-boss1",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json",
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss1/wdb-report.json",
"sourceDescriptors": 3,
"uniqueResources": 1,
"staticPlacements": 3,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ystra-labyrinth-boss2",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2.json",
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss2/wdb-report.json",
"sourceDescriptors": 3,
"uniqueResources": 1,
"staticPlacements": 3,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "ystra-labyrinth-boss3",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3.json",
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss3/wdb-report.json",
"sourceDescriptors": 3,
"uniqueResources": 1,
"staticPlacements": 3,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "zurhidon-stronghold",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold.json",
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold/wdb-report.json",
"sourceDescriptors": 840,
"uniqueResources": 201,
"staticPlacements": 840,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
},
{
"id": "zurhidon-stronghold-124",
"mode": "all-ros",
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124.json",
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold-124/wdb-report.json",
"sourceDescriptors": 840,
"uniqueResources": 201,
"staticPlacements": 840,
"dynamicPlacements": 0,
"dynamicReviewCandidates": 0,
"unsupportedPlacements": 0,
"missingSourceResources": 0,
"missingResources": [],
"unsupported": []
}
]
}
@@ -0,0 +1,73 @@
# RuneWaker WDB environment assembly audit
Structural validation: **green**
Source-behavior review: **review-required**
This deterministic audit reads the checked-in WDB reports and environment recipes. It does
not convert or replace shipping GLBs. The builder now supports every valid ROS placement
resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale
transform relative to the reviewed primary placement.
## Coverage
- 30 dungeon WDB packages and 15976 source descriptors.
- 2799 per-dungeon unique ROS resources (resources reused across dungeons share cache keys).
- 15976 static placements and 0 explicitly reviewed dynamic placements.
- 107 filename-based dynamic candidates remain static and review-required; names never prove behavior.
- 0 unsupported placement records and 0 missing source ROS resources.
| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| aeternal-circle | all-ros | 575 | 182 | 575 | 0 | 54 | 0 | 0 |
| barren-caves | all-ros | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| cave-water-dragon | all-ros | 121 | 16 | 121 | 0 | 0 | 0 | 0 |
| demon-stronghold | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
| demon-stronghold-125 | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
| dungeon-of-dalanis | all-ros | 652 | 124 | 652 | 0 | 5 | 0 | 0 |
| forsaken-abbey | all-ros | 184 | 26 | 184 | 0 | 1 | 0 | 0 |
| grafu-castle | all-ros | 899 | 286 | 899 | 0 | 17 | 0 | 0 |
| hall-of-survivors | all-ros | 296 | 32 | 296 | 0 | 0 | 0 | 0 |
| hoto | all-ros | 1627 | 119 | 1627 | 0 | 0 | 0 | 0 |
| ice-dwarf-kingdom | all-ros | 1054 | 209 | 1054 | 0 | 0 | 0 | 0 |
| kal-turok-hive | all-ros | 2 | 2 | 2 | 0 | 1 | 0 | 0 |
| kalins-shrine | all-ros | 943 | 133 | 943 | 0 | 0 | 0 | 0 |
| kawaks-tomb | all-ros | 573 | 60 | 573 | 0 | 0 | 0 | 0 |
| kulech-bones-nest | all-ros | 199 | 52 | 199 | 0 | 0 | 0 | 0 |
| lair-demon-dragon | all-ros | 530 | 96 | 530 | 0 | 0 | 0 | 0 |
| mystic-altar | all-ros | 2096 | 48 | 2096 | 0 | 0 | 0 | 0 |
| paspers-shrine | all-ros | 564 | 143 | 564 | 0 | 0 | 0 | 0 |
| raksha-temple | all-ros | 1028 | 228 | 1028 | 0 | 11 | 0 | 0 |
| sand-vortex | primary-only-procedural-fallback | 20 | 14 | 20 | 0 | 0 | 0 | 0 |
| sardo-castle | all-ros | 436 | 132 | 436 | 0 | 0 | 0 | 0 |
| the-origin | all-ros | 693 | 220 | 693 | 0 | 17 | 0 | 0 |
| tomb-seven-heroes | all-ros | 450 | 56 | 450 | 0 | 0 | 0 | 0 |
| treasure-trove | all-ros | 908 | 193 | 908 | 0 | 1 | 0 | 0 |
| warnorken-arena | all-ros | 256 | 14 | 256 | 0 | 0 | 0 | 0 |
| ystra-labyrinth-boss1 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| ystra-labyrinth-boss2 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| ystra-labyrinth-boss3 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| zurhidon-stronghold | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
| zurhidon-stronghold-124 | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
## Runtime-facing output
A full build writes `wdb-placement-manifest.json` beside the environment package. Each
placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local
Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.
Static placements are assembled into the optimized visual and navigation collision. Reviewed
dynamic placements are omitted from static collision and packaged as separately addressable GLBs.
## Unsupported or review-required behavior
- WDB placement resources other than ROS are recorded but not converted.
- Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.
- Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.
- A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.
- The primary-only top-down preview and editable `.blend` do not include the final merged WDB
assembly; the generated GLB and placement manifest are authoritative.
- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a
reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.
## Validation issues
- None.
@@ -0,0 +1,45 @@
# RuneWaker combat formula and loot fidelity
## Combat damage
Imported RuneWaker damage mechanics now carry a `runewakerFormula` descriptor from `magicobject.db` into the generated enemy attack. The descriptor keeps the source spell/component ids, spell level, damage power, fixed value, damage-over-time base, calculation family, and source-code provenance.
The preserved implementation is in `SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp`:
- `CalPhyDamage` begins at line 3366.
- `CalMagicDamage` begins at line 3651.
- Both use the source ATK/DEF or MATK/MDEF ratio `max(0.2, 1 + ((attack + 100) - (defense + 200)) / max(attack + 100, defense + 200))`.
- Both also depend on native weapon or magic damage, level-difference tables, resistance processing, critical power, fixed-attribute lookup, and global NPC/PvP/AoE/target rates.
HealerMan does not have equivalent RuneWaker ATK, MATK, DEF, MDEF, `FixType`, native weapon/magic damage, level-rate tables, or global rate arrays. The adapter therefore does not label its live result as exact RuneWaker damage:
- Negative `Atk.DmgPower` values with magnitude at most 4 are treated as source ratios against HealerMan's level-scaled enemy damage budget; the native 0.2 minimum is retained.
- Point-valued source powers use the explicit `runtimeBaselineMultiplier` balancing adapter stored with the formula.
- HealerMan's existing hit table, armor, resistance, critical, aura, and absorb pipeline runs afterward and remains identified as HealerMan/WotLK-shaped mitigation.
- Unclassified formula records return zero from the adapter rather than executing guessed damage.
`resolveRunewakerNativeResolvedPower` exposes the source-shaped tail for later use. It requires every native-only input as an argument and supplies no silent one-valued defaults.
## Loot
`export-loot-catalog.mjs` reads each source-classified boss's 15 independent `NPCObjectDB.DropItem` slots. It preserves the original rate denominator of 100,000 and recursively expands `TreasureObjectDB`'s weighted, mutually exclusive outcomes and fixed `DropCount` quantities.
Runtime rules are intentionally bounded:
- Recognized leaves come only from `itemobject.db`, `armorobject.db`, `weaponobject.db`, `cardobject.db`, `runeobject.db`, `recipeobject.db`, and `keyitemobject.db`.
- Treasure recursion stops at 12 levels and cycles fail closed.
- Unknown object ids fail closed.
- Treasure tables with a Lua predicate fail closed because executing arbitrary preserved server Lua is outside the browser runtime.
- `NeedDLv` requirements are preserved and evaluated from the caller-provided source level delta.
- Source names are retained, but source stats, quality, and equipment slots are not inferred. Portable drops enter inventory as common, non-equipable `misc` items with zero stats.
The detailed status for every boss is generated in `RUNEWAKER_LOOT_IMPORT_REPORT.md`. The full probability graph is available in `public/data/runewaker-loot-catalog.json` and the runtime snapshot.
## Refresh and verification
```powershell
npm run runewaker:spells:refresh
npm run runewaker:loot:refresh
node --test scripts/runewaker-pipeline/runewaker-loot-import.test.mjs
vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts
```
@@ -63,7 +63,7 @@
"movement/leash",
"clustering/aggro",
"combat/death state",
"loot policy",
"loot item stats/slots",
"objective ordering",
"procedural animation"
],
@@ -74,7 +74,8 @@
"patrols",
"interactions",
"encounter resets",
"source loot"
"blocked source-loot predicates",
"source item stat/slot mapping"
],
"blockerGroups": {
"navigation": {
@@ -245,10 +246,20 @@
"safety": {
"runtimeSqlDependency": false,
"unknownEffectsExecute": false,
"coefficientsAreSourceExact": false
"coefficientsAreSourceExact": false,
"unavailableNativeInputsDefaulted": false,
"pointValuedDamageUsesLabeledScaledAdapter": true
},
"mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
},
"encounterImplementationLedger": {
"json": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
"markdown": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
"validationStatus": "green",
"completionStatus": "review-required",
"deferredSourceRows": 4113,
"uniqueMechanicsEvidence": 1524
},
"resolvedRuntimeObservations": [
{
"dungeonId": "paspers-shrine",
@@ -266,7 +277,7 @@
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
"Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.",
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
],
"dungeons": [
@@ -1,6 +1,6 @@
# RuneWaker gameplay content audit
Structural validation: **green**
Structural validation: **green**
Original-mechanics fidelity: **review-required**
This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,
@@ -16,6 +16,7 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
- The exhaustive encounter implementation ledger tracks 4113 deferred source rows and 1524 unique mechanics evidence records without inflating overlapping regex labels.
## Authentic source data currently used
@@ -30,10 +31,12 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
- 436 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions outside the modular boss catalog.
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,
source power fields, and the exact native formula shape/provenance.
- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,
DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.
- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.
- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
attack, cast, wound, and death families.
@@ -142,5 +145,5 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
- Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
@@ -0,0 +1,763 @@
{
"schemaVersion": 1,
"status": "portable-with-explicit-fallbacks",
"generatedFrom": {
"sourceFiles": [
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/npcobject.db",
"sha256": "b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/treasureobject.db",
"sha256": "6e7433190f4b9f49e3cea32e346128a57c62a5993dd3b98b0de1cd11152895a0"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/itemobject.db",
"sha256": "00e082b4c18d561d29c893a6400bfe62e3daf90ae539728cb05d1c2893a69880"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/armorobject.db",
"sha256": "a2f0017c4772165d562ebb08ca0ac51625cdc4453e3670a1501df7d741d2bad3"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/weaponobject.db",
"sha256": "2bc28eebbe413bd6118f027e3fec1a3953cef371476937d0b65622c542971a67"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/cardobject.db",
"sha256": "4d154fe733e5359ca9e40c0aefd458ace0cf109b1b01e20da0f6e0683966a59f"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/runeobject.db",
"sha256": "8ea064e1ae8220a69e3c72bbd5b64dbd149738a2c86f1a5d51201a7f3a799f85"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/recipeobject.db",
"sha256": "0f7d6ec38f8c52c403065d3f6f5b996117344c194ba42dd22f4d52061a6984e5"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/keyitemobject.db",
"sha256": "ea06ed6dd9e4111cfd308b3d26d50c9e39a26ad358584db8dee6812e9a91358a"
},
{
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/string_enus.db",
"sha256": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
}
],
"npcDropAlgorithm": {
"file": "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
"firstRelevantLine": 4707,
"rateDenominator": 100000
},
"treasureAlgorithm": {
"file": "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
"firstRelevantLine": 3033,
"rateDenominator": 100000
},
"layout": "RuneWaker v0.20 fixed records; read-only export"
},
"runtimePolicy": {
"rollsAreIndependent": true,
"treasureOutcomesAreMutuallyExclusive": true,
"unknownObjectsExecute": false,
"luaPredicatesExecute": false,
"sourceItemStatsInvented": false,
"importedItemsUseGenericNonEquipableInventoryRepresentation": true
},
"leafCatalogs": [
{
"name": "itemobject.db",
"count": 13431,
"objectType": 2,
"recordSize": 1776
},
{
"name": "armorobject.db",
"count": 15050,
"objectType": 2,
"recordSize": 1776
},
{
"name": "weaponobject.db",
"count": 4843,
"objectType": 2,
"recordSize": 1776
},
{
"name": "cardobject.db",
"count": 1765,
"objectType": 2,
"recordSize": 1776
},
{
"name": "runeobject.db",
"count": 1781,
"objectType": 2,
"recordSize": 1776
},
{
"name": "recipeobject.db",
"count": 3443,
"objectType": 10,
"recordSize": 416
},
{
"name": "keyitemobject.db",
"count": 10001,
"objectType": 9,
"recordSize": 196
}
],
"bossCount": 56,
"bossWithSourceRollsCount": 47,
"portableBossCount": 34,
"partialBossCount": 13,
"noSourceDropsBossCount": 9,
"blockedBossCount": 0,
"executableOutcomeCount": 8741,
"blockedOutcomeCount": 27,
"bosses": [
{
"bossKey": "rw-barren-caves-101462",
"dungeonId": "barren-caves",
"sourceNpcId": 101462,
"bossName": "Anglo",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 76,
"blockedOutcomeCount": 3,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-cave-water-dragon-102623",
"dungeonId": "cave-water-dragon",
"sourceNpcId": 102623,
"bossName": "Lytfir",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-cave-water-dragon-104161",
"dungeonId": "cave-water-dragon",
"sourceNpcId": 104161,
"bossName": "Template 104161",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-demon-stronghold-102701",
"dungeonId": "demon-stronghold",
"sourceNpcId": 102701,
"bossName": "Naos",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 228,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-fa-100070",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100070,
"bossName": "Necromage",
"status": "portable",
"sourceRollCount": 12,
"executableOutcomeCount": 117,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-fa-100074",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100074,
"bossName": "Demon Witch Ancalon",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 130,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-fa-100561",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100561,
"bossName": "Flowing Chaos",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 127,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-fa-100562",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100562,
"bossName": "Cruel Eater",
"status": "partial",
"sourceRollCount": 12,
"executableOutcomeCount": 131,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-fa-100563",
"dungeonId": "forsaken-abbey",
"sourceNpcId": 100563,
"bossName": "Hollow Shell",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 129,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102395",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102395,
"bossName": "Guldamor",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 228,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102421",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102421,
"bossName": "Andriol",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 227,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102425",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102425,
"bossName": "Zygro",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 229,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102430",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102430,
"bossName": "Andaphelmor",
"status": "partial",
"sourceRollCount": 14,
"executableOutcomeCount": 232,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
]
},
{
"bossKey": "rw-hall-of-survivors-102452",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102452,
"bossName": "Mantarick",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102475",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102475,
"bossName": "Glamo",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 226,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102476",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102476,
"bossName": "Divine Punishment Pursuer",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102560",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102560,
"bossName": "Prison Pursuer",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hall-of-survivors-102643",
"dungeonId": "hall-of-survivors",
"sourceNpcId": 102643,
"bossName": "Horror Seeker",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102021",
"dungeonId": "hoto",
"sourceNpcId": 102021,
"bossName": "Jiasha",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 219,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102040",
"dungeonId": "hoto",
"sourceNpcId": 102040,
"bossName": "Medusa",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 222,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
]
},
{
"bossKey": "rw-hoto-102041",
"dungeonId": "hoto",
"sourceNpcId": 102041,
"bossName": "Teeth of the Reef",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 217,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102046",
"dungeonId": "hoto",
"sourceNpcId": 102046,
"bossName": "Bloodthirsty Claw",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 218,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-hoto-102063",
"dungeonId": "hoto",
"sourceNpcId": 102063,
"bossName": "Geba",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 220,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101268",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101268,
"bossName": "Pangkor",
"status": "portable",
"sourceRollCount": 9,
"executableOutcomeCount": 210,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101584",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101584,
"bossName": "Thynos",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 211,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101881",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101881,
"bossName": "Icebound Guardian",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 188,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101883",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101883,
"bossName": "Crasset Hairyfoot",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 181,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ice-dwarf-kingdom-101884",
"dungeonId": "ice-dwarf-kingdom",
"sourceNpcId": 101884,
"bossName": "Mole King",
"status": "portable",
"sourceRollCount": 8,
"executableOutcomeCount": 208,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-kalins-shrine-101500",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101500,
"bossName": "Goddess of Art's Disciple",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 318,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101501",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101501,
"bossName": "Yusalien",
"status": "partial",
"sourceRollCount": 14,
"executableOutcomeCount": 326,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101502",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101502,
"bossName": "Locatha",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 325,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101503",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101503,
"bossName": "Ensia",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 325,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kalins-shrine-101507",
"dungeonId": "kalins-shrine",
"sourceNpcId": 101507,
"bossName": "Iron Rune Warrior",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 323,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-kawaks-tomb-104228",
"dungeonId": "kawaks-tomb",
"sourceNpcId": 104228,
"bossName": "Linobia",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-kulech-bones-nest-107306",
"dungeonId": "kulech-bones-nest",
"sourceNpcId": 107306,
"bossName": "Template 107306",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-mystic-altar-100138",
"dungeonId": "mystic-altar",
"sourceNpcId": 100138,
"bossName": "Cursed lron Golem",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 128,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-mystic-altar-100154",
"dungeonId": "mystic-altar",
"sourceNpcId": 100154,
"bossName": "Razeela",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 142,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-mystic-altar-100611",
"dungeonId": "mystic-altar",
"sourceNpcId": 100611,
"bossName": "Dorlos",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 124,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-100794",
"dungeonId": "paspers-shrine",
"sourceNpcId": 100794,
"bossName": "Blackhorn Hafiz",
"status": "portable",
"sourceRollCount": 11,
"executableOutcomeCount": 128,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101018",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101018,
"bossName": "Blackhorn Afalen",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 109,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101019",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101019,
"bossName": "Blackhorn Bareth",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 103,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101020",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101020,
"bossName": "Blackhorn Nisorn",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 105,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-paspers-shrine-101021",
"dungeonId": "paspers-shrine",
"sourceNpcId": 101021,
"bossName": "Blackhorn Dorglas",
"status": "portable",
"sourceRollCount": 13,
"executableOutcomeCount": 112,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-raksha-temple-103946",
"dungeonId": "raksha-temple",
"sourceNpcId": 103946,
"bossName": "Shadoj",
"status": "portable",
"sourceRollCount": 7,
"executableOutcomeCount": 71,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-sand-vortex-100645",
"dungeonId": "sand-vortex",
"sourceNpcId": 100645,
"bossName": "Sandlord Sesphiroth",
"status": "portable",
"sourceRollCount": 9,
"executableOutcomeCount": 101,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-sardo-castle-105701",
"dungeonId": "sardo-castle",
"sourceNpcId": 105701,
"bossName": "Undead Guard",
"status": "portable",
"sourceRollCount": 7,
"executableOutcomeCount": 91,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-the-origin-102307",
"dungeonId": "the-origin",
"sourceNpcId": 102307,
"bossName": "Holy Tree Falynum",
"status": "no-source-drops",
"sourceRollCount": 0,
"executableOutcomeCount": 0,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-the-origin-102349",
"dungeonId": "the-origin",
"sourceNpcId": 102349,
"bossName": "Life Leecher",
"status": "portable",
"sourceRollCount": 12,
"executableOutcomeCount": 279,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-treasure-trove-101533",
"dungeonId": "treasure-trove",
"sourceNpcId": 101533,
"bossName": "Boulder Sidaar",
"status": "partial",
"sourceRollCount": 10,
"executableOutcomeCount": 313,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-treasure-trove-101534",
"dungeonId": "treasure-trove",
"sourceNpcId": 101534,
"bossName": "Lookout Luke",
"status": "partial",
"sourceRollCount": 11,
"executableOutcomeCount": 315,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-treasure-trove-101535",
"dungeonId": "treasure-trove",
"sourceNpcId": 101535,
"bossName": "Snow Blake",
"status": "partial",
"sourceRollCount": 13,
"executableOutcomeCount": 322,
"blockedOutcomeCount": 2,
"blockedReasons": [
"Treasure table has no positive, resolvable outcomes."
]
},
{
"bossKey": "rw-ystra-labyrinth-boss1-101432",
"dungeonId": "ystra-labyrinth-boss1",
"sourceNpcId": 101432,
"bossName": "Rune Guardian Leader",
"status": "portable",
"sourceRollCount": 12,
"executableOutcomeCount": 92,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss1-101433",
"dungeonId": "ystra-labyrinth-boss1",
"sourceNpcId": 101433,
"bossName": "Raichika the Destroyer",
"status": "portable",
"sourceRollCount": 13,
"executableOutcomeCount": 125,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss1-102195",
"dungeonId": "ystra-labyrinth-boss1",
"sourceNpcId": 102195,
"bossName": "Specter Guardian Creator",
"status": "portable",
"sourceRollCount": 8,
"executableOutcomeCount": 84,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss2-102196",
"dungeonId": "ystra-labyrinth-boss2",
"sourceNpcId": 102196,
"bossName": "Rune Guardian Leader",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 99,
"blockedOutcomeCount": 0,
"blockedReasons": []
},
{
"bossKey": "rw-ystra-labyrinth-boss3-102197",
"dungeonId": "ystra-labyrinth-boss3",
"sourceNpcId": 102197,
"bossName": "Raichika the Destroyer",
"status": "portable",
"sourceRollCount": 10,
"executableOutcomeCount": 107,
"blockedOutcomeCount": 0,
"blockedReasons": []
}
]
}
@@ -0,0 +1,72 @@
# RuneWaker loot import report
Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.
- Boss templates audited: 56
- Bosses with source rolls: 47
- Fully portable bosses: 34
- Partially portable bosses: 13
- Bosses with no authored drop slots: 9
- Executable item outcomes: 8741
- Blocked outcomes: 27
Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.
| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |
| --- | --- | --- | ---: | ---: | ---: | --- |
| barren-caves | Anglo | partial | 13 | 76 | 3 | Treasure table has no positive, resolvable outcomes. |
| cave-water-dragon | Lytfir | no-source-drops | 0 | 0 | 0 | — |
| cave-water-dragon | Template 104161 | no-source-drops | 0 | 0 | 0 | — |
| demon-stronghold | Naos | portable | 10 | 228 | 0 | — |
| forsaken-abbey | Necromage | portable | 12 | 117 | 0 | — |
| forsaken-abbey | Demon Witch Ancalon | partial | 13 | 130 | 2 | Treasure table has no positive, resolvable outcomes. |
| forsaken-abbey | Flowing Chaos | portable | 11 | 127 | 0 | — |
| forsaken-abbey | Cruel Eater | partial | 12 | 131 | 2 | Treasure table has no positive, resolvable outcomes. |
| forsaken-abbey | Hollow Shell | portable | 11 | 129 | 0 | — |
| hall-of-survivors | Guldamor | portable | 10 | 228 | 0 | — |
| hall-of-survivors | Andriol | portable | 10 | 227 | 0 | — |
| hall-of-survivors | Zygro | portable | 10 | 229 | 0 | — |
| hall-of-survivors | Andaphelmor | partial | 14 | 232 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
| hall-of-survivors | Mantarick | no-source-drops | 0 | 0 | 0 | — |
| hall-of-survivors | Glamo | portable | 10 | 226 | 0 | — |
| hall-of-survivors | Divine Punishment Pursuer | no-source-drops | 0 | 0 | 0 | — |
| hall-of-survivors | Prison Pursuer | no-source-drops | 0 | 0 | 0 | — |
| hall-of-survivors | Horror Seeker | no-source-drops | 0 | 0 | 0 | — |
| hoto | Jiasha | portable | 10 | 219 | 0 | — |
| hoto | Medusa | partial | 13 | 222 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
| hoto | Teeth of the Reef | portable | 10 | 217 | 0 | — |
| hoto | Bloodthirsty Claw | portable | 10 | 218 | 0 | — |
| hoto | Geba | portable | 10 | 220 | 0 | — |
| ice-dwarf-kingdom | Pangkor | portable | 9 | 210 | 0 | — |
| ice-dwarf-kingdom | Thynos | portable | 10 | 211 | 0 | — |
| ice-dwarf-kingdom | Icebound Guardian | portable | 10 | 188 | 0 | — |
| ice-dwarf-kingdom | Crasset Hairyfoot | portable | 10 | 181 | 0 | — |
| ice-dwarf-kingdom | Mole King | portable | 8 | 208 | 0 | — |
| kalins-shrine | Goddess of Art's Disciple | partial | 13 | 318 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Yusalien | partial | 14 | 326 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Locatha | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Ensia | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
| kalins-shrine | Iron Rune Warrior | partial | 11 | 323 | 2 | Treasure table has no positive, resolvable outcomes. |
| kawaks-tomb | Linobia | no-source-drops | 0 | 0 | 0 | — |
| kulech-bones-nest | Template 107306 | no-source-drops | 0 | 0 | 0 | — |
| mystic-altar | Cursed lron Golem | portable | 10 | 128 | 0 | — |
| mystic-altar | Razeela | portable | 10 | 142 | 0 | — |
| mystic-altar | Dorlos | portable | 11 | 124 | 0 | — |
| paspers-shrine | Blackhorn Hafiz | portable | 11 | 128 | 0 | — |
| paspers-shrine | Blackhorn Afalen | portable | 10 | 109 | 0 | — |
| paspers-shrine | Blackhorn Bareth | portable | 10 | 103 | 0 | — |
| paspers-shrine | Blackhorn Nisorn | portable | 10 | 105 | 0 | — |
| paspers-shrine | Blackhorn Dorglas | portable | 13 | 112 | 0 | — |
| raksha-temple | Shadoj | portable | 7 | 71 | 0 | — |
| sand-vortex | Sandlord Sesphiroth | portable | 9 | 101 | 0 | — |
| sardo-castle | Undead Guard | portable | 7 | 91 | 0 | — |
| the-origin | Holy Tree Falynum | no-source-drops | 0 | 0 | 0 | — |
| the-origin | Life Leecher | portable | 12 | 279 | 0 | — |
| treasure-trove | Boulder Sidaar | partial | 10 | 313 | 2 | Treasure table has no positive, resolvable outcomes. |
| treasure-trove | Lookout Luke | partial | 11 | 315 | 2 | Treasure table has no positive, resolvable outcomes. |
| treasure-trove | Snow Blake | partial | 13 | 322 | 2 | Treasure table has no positive, resolvable outcomes. |
| ystra-labyrinth-boss1 | Rune Guardian Leader | portable | 12 | 92 | 0 | — |
| ystra-labyrinth-boss1 | Raichika the Destroyer | portable | 13 | 125 | 0 | — |
| ystra-labyrinth-boss1 | Specter Guardian Creator | portable | 8 | 84 | 0 | — |
| ystra-labyrinth-boss2 | Rune Guardian Leader | portable | 10 | 99 | 0 | — |
| ystra-labyrinth-boss3 | Raichika the Destroyer | portable | 10 | 107 | 0 | — |
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,9 @@ against every raw Recast surface component and against the retained runtime surf
The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed
source-authored order. It does not create links or infer doors/teleports.
Unreachable heuristic legs analyzed: **19** across **8** dungeons.
Unreachable currently ordered legs analyzed: **19** across **8** dungeons.
Source-authored fixed orders implemented by the audited packages: **0**.
Authored off-mesh links added by this review: **0**.
| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |
| --- | --- | --- | --- | --- | --- |
@@ -20,9 +22,9 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
| kalins-shrine | Yusalien -> Locatha | separate-recast-surface-components | 2/0m | 12/0m | 245.55m -> 122.731m |
| kalins-shrine | Locatha -> Goddess of Art's Disciple | separate-recast-surface-components | 12/0m | 3/0m | 122.731m -> 12.741m |
| kalins-shrine | Goddess of Art's Disciple -> Ensia | separate-recast-surface-components | 3/0m | 0/0m | 12.741m -> 0m |
| raksha-temple | entrance -> Shadoj | missing-primary-environment-geometry-source-support-pad | 41/0m | 165/0m | 0.203m -> 200.079m |
| the-origin | entrance -> Life Leecher | missing-primary-environment-geometry-source-support-pad | 106/0m | 302/0m | 466.405m -> 234.143m |
| the-origin | Life Leecher -> Holy Tree Falynum | missing-primary-environment-geometry-source-support-pad | 302/0m | 305/0m | 234.143m -> 361.263m |
| raksha-temple | entrance -> Shadoj | objective-or-entrance-outside-all-navigation | 6069/0m | 2982/7.159m | 578.214m -> 699.329m |
| the-origin | entrance -> Life Leecher | separate-recast-surface-components | 4276/0m | 2868/1.099m | 456.333m -> 258.631m |
| the-origin | Life Leecher -> Holy Tree Falynum | separate-recast-surface-components | 2868/1.099m | 4857/5.632m | 258.631m -> 559.406m |
| treasure-trove | entrance -> Lookout Luke | missing-primary-environment-geometry-source-support-pad | 487/0m | 854/0m | 0.184m -> 519.5m |
| treasure-trove | Lookout Luke -> Snow Blake | missing-primary-environment-geometry-source-support-pad | 854/0m | 490/0m | 519.5m -> 525.287m |
| treasure-trove | Snow Blake -> Boulder Sidaar | missing-primary-environment-geometry-source-support-pad | 490/0m | 489/0m | 525.287m -> 565.311m |
@@ -35,8 +37,8 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- hoto: 260 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 5777 welded nodes discarded by largest-surface retention.
- ice-dwarf-kingdom: 599 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 6237 welded nodes discarded by largest-surface retention.
- kalins-shrine: 93 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 4858 welded nodes discarded by largest-surface retention.
- raksha-temple: 167 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1262 welded nodes discarded by largest-surface retention.
- the-origin: 306 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1678 welded nodes discarded by largest-surface retention.
- raksha-temple: 13137 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 180838 welded nodes discarded by largest-surface retention.
- the-origin: 6736 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 97103 welded nodes discarded by largest-surface retention.
- treasure-trove: 1025 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 16502 welded nodes discarded by largest-surface retention.
- ystra-labyrinth-boss1: 36 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 901 welded nodes discarded by largest-surface retention.
@@ -46,7 +48,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: gated-wave-and-door-review.
- Safe automatic link: no.
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.
- Remaining requirement: encounter-gate-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:21
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:42
@@ -55,7 +62,11 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: source-order-required-before-linking.
- Safe automatic link: no.
- Objective order: source-objective-review-required.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.
- Remaining requirement: source-objective-review.
- Remaining requirement: navigation-geometry-review.
- Evidence: scripts/runewaker-pipeline/build-population.mjs:634
- Evidence: scripts/runewaker-pipeline/recipes/hoto-population.json
@@ -63,7 +74,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: door-gated-room-review.
- Safe automatic link: no.
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.
- Remaining requirement: encounter-gate-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:36
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:46
@@ -73,30 +89,48 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: teleport-room-mechanic.
- Safe automatic link: no.
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.
- Objective order: source-randomized-not-linear.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.
- Remaining requirement: nonlinear-objectives.
- Remaining requirement: encounter-teleport-state.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354
### raksha-temple
- Disposition: missing-modular-environment-assembly.
- Safe automatic link: no.
- Objective order: not-applicable-single-objective.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.
- Remaining requirement: modular-environment-assembly.
- Evidence: scripts/runewaker-pipeline/recipes/raksha-temple.json:16
### the-origin
- Disposition: missing-modular-environment-assembly.
- Safe automatic link: no.
- Objective order: source-objective-review-required.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.
- Remaining requirement: modular-environment-assembly.
- Remaining requirement: source-objective-review.
- Evidence: scripts/runewaker-pipeline/recipes/the-origin.json:16
### treasure-trove
- Disposition: missing-modular-environment-plus-bridge-door-mechanics.
- Safe automatic link: no.
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.
- Remaining requirement: modular-environment-assembly.
- Remaining requirement: encounter-gate-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: scripts/runewaker-pipeline/recipes/treasure-trove.json:16
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:38
@@ -106,9 +140,29 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
- Disposition: separate-authored-room-placements.
- Safe automatic link: no.
- Objective order: independent-room-order-unresolved.
- Current package order: healerMan-distance-derived (non-authoritative).
- Finding: The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.
- Remaining requirement: encounter-teleport-state.
- Remaining requirement: source-objective-review.
- Remaining requirement: dynamic-navigation-transition.
- Evidence: scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1423
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1425
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1426
## Resolution boundary
No off-mesh link was emitted. Every failed leg is either controlled by encounter state,
depends on environment geometry that is not yet in the runtime package, represents
separate authored rooms, or still lacks a complete source-authored boss sequence.
A static link would let companions bypass a door, bridge, wave, or teleport mechanic.
The structured requirement counts below are deterministic and identify the remaining work.
- dynamic-navigation-transition: 5 dungeon(s).
- encounter-gate-state: 3 dungeon(s).
- encounter-teleport-state: 2 dungeon(s).
- modular-environment-assembly: 3 dungeon(s).
- navigation-geometry-review: 1 dungeon(s).
- nonlinear-objectives: 1 dungeon(s).
- source-objective-review: 6 dungeon(s).
@@ -29,6 +29,8 @@ const reviewedFindings = {
"cave-water-dragon": {
disposition: "gated-wave-and-door-review",
safeAutomaticLink: false,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
finding: "Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.",
evidence: [
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
@@ -39,6 +41,8 @@ const reviewedFindings = {
hoto: {
disposition: "source-order-required-before-linking",
safeAutomaticLink: false,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["source-objective-review", "navigation-geometry-review"],
finding: "The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.",
evidence: [
"scripts/runewaker-pipeline/build-population.mjs:634",
@@ -48,6 +52,8 @@ const reviewedFindings = {
"ice-dwarf-kingdom": {
disposition: "door-gated-room-review",
safeAutomaticLink: false,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
finding: "Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.",
evidence: [
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
@@ -59,28 +65,41 @@ const reviewedFindings = {
"kalins-shrine": {
disposition: "teleport-room-mechanic",
safeAutomaticLink: false,
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.",
objectiveOrderStatus: "source-randomized-not-linear",
requirements: ["nonlinear-objectives", "encounter-teleport-state", "dynamic-navigation-transition"],
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.",
evidence: [
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713",
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298",
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363",
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354",
],
},
"raksha-temple": {
disposition: "missing-modular-environment-assembly",
safeAutomaticLink: false,
objectiveOrderStatus: "not-applicable-single-objective",
requirements: ["modular-environment-assembly"],
finding: "The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.",
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
},
"the-origin": {
disposition: "missing-modular-environment-assembly",
safeAutomaticLink: false,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["modular-environment-assembly", "source-objective-review"],
finding: "Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.",
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
},
"treasure-trove": {
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
safeAutomaticLink: false,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: [
"modular-environment-assembly",
"encounter-gate-state",
"source-objective-review",
"dynamic-navigation-transition",
],
finding: "Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.",
evidence: [
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
@@ -92,6 +111,8 @@ const reviewedFindings = {
"ystra-labyrinth-boss1": {
disposition: "separate-authored-room-placements",
safeAutomaticLink: false,
objectiveOrderStatus: "independent-room-order-unresolved",
requirements: ["encounter-teleport-state", "source-objective-review", "dynamic-navigation-transition"],
finding: "The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.",
evidence: [
"scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16",
@@ -418,7 +439,9 @@ function renderMarkdown(report) {
"The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed",
"source-authored order. It does not create links or infer doors/teleports.",
"",
`Unreachable heuristic legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
`Unreachable currently ordered legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
`Source-authored fixed orders implemented by the audited packages: **${report.resolutionSummary.sourceAuthoredFixedOrders}**.`,
`Authored off-mesh links added by this review: **${report.resolutionSummary.authoredOffMeshLinksAdded}**.`,
"",
"| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |",
"| --- | --- | --- | --- | --- | --- |",
@@ -442,9 +465,21 @@ function renderMarkdown(report) {
const finding = dungeon.reviewedFinding;
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
`- Safe automatic link: ${finding.safeAutomaticLink ? "yes" : "no"}.`,
`- Objective order: ${finding.objectiveOrderStatus}.`,
`- Current package order: ${dungeon.objectiveOrdering.kind} (${dungeon.objectiveOrdering.authoritative ? "authoritative" : "non-authoritative"}).`,
`- Finding: ${finding.finding}`,
...finding.requirements.map((entry) => `- Remaining requirement: ${entry}.`),
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
}
lines.push("## Resolution boundary", "",
"No off-mesh link was emitted. Every failed leg is either controlled by encounter state,",
"depends on environment geometry that is not yet in the runtime package, represents",
"separate authored rooms, or still lacks a complete source-authored boss sequence.",
"A static link would let companions bypass a door, bridge, wave, or teleport mechanic.",
"The structured requirement counts below are deterministic and identify the remaining work.", "");
for (const [requirement, count] of Object.entries(report.resolutionSummary.requirementDungeonCounts)) {
lines.push(`- ${requirement}: ${count} dungeon(s).`);
}
lines.push("");
return lines.join("\n");
}
@@ -474,6 +509,11 @@ for (const recipeName of recipeNames) {
constantCase(recipe.dungeonId) + "_BOSSES",
" as const satisfies readonly DungeonBossObjective[];",
);
const populationProvenance = generatedJson(
source,
constantCase(recipe.dungeonId) + "_POPULATION_PROVENANCE",
" as const;",
);
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
const retainedTriangleComponents = triangleComponents(graph);
const raw = analyzeRawComponents(positions);
@@ -519,9 +559,12 @@ for (const recipeName of recipeNames) {
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
disposition: "manual-review-required",
safeAutomaticLink: false,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["source-objective-review"],
finding: "No reviewed source-backed navigation disposition is recorded.",
evidence: [],
},
objectiveOrdering: populationProvenance.objectiveOrder,
rawComponents: raw.components.map((component) => ({
id: component.id,
vertexCount: component.vertices.length,
@@ -532,15 +575,26 @@ for (const recipeName of recipeNames) {
});
}
const requirementDungeonCounts = {};
for (const dungeon of dungeons) {
for (const requirement of new Set(dungeon.reviewedFinding.requirements)) {
requirementDungeonCounts[requirement] = (requirementDungeonCounts[requirement] ?? 0) + 1;
}
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
schemaVersion: 2,
generator: "scripts/runewaker-pipeline/analyze-navigation-gaps.mjs",
methodology: {
weldTolerance,
liveStartSnapDistance: 3,
liveObjectiveSnapDistance: 6,
mutatesAssets: false,
},
resolutionSummary: {
sourceAuthoredFixedOrders: dungeons.filter((dungeon) => dungeon.objectiveOrdering.authoritative).length,
authoredOffMeshLinksAdded: 0,
requirementDungeonCounts: Object.fromEntries(Object.entries(requirementDungeonCounts).sort()),
},
unreachableLegs: dungeons.flatMap((dungeon) => dungeon.legs).filter((leg) => !leg.reachable).length,
dungeons,
};
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
const result = await generateEncounterImplementationReport();
console.log(JSON.stringify({
validationStatus: result.report.validationStatus,
completionStatus: result.report.completionStatus,
reportJson: result.reportJson,
reportMarkdown: result.reportMarkdown,
summary: result.report.summary,
issues: result.report.issues,
}, null, 2));
if (result.report.issues.length) process.exitCode = 1;
@@ -0,0 +1,181 @@
#!/usr/bin/env node
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
import { sourceRootFor } from "./lib/recipe.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
const reportJsonFile = path.join(
root,
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.json",
);
const reportMarkdownFile = path.join(
root,
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md",
);
const projectPath = (file) => path.relative(root, file).split(path.sep).join("/");
async function exists(file) {
try {
await stat(file);
return true;
} catch {
return false;
}
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.mode, "|", dungeon.sourceDescriptors, "|",
dungeon.uniqueResources, "|", dungeon.staticPlacements, "|",
dungeon.dynamicPlacements, "|", dungeon.dynamicReviewCandidates, "|",
dungeon.unsupportedPlacements, "|", dungeon.missingSourceResources, "|",
].join(" "));
return [
"# RuneWaker WDB environment assembly audit",
"",
"Structural validation: **" + report.validationStatus + "**",
"Source-behavior review: **" + report.reviewStatus + "**",
"",
"This deterministic audit reads the checked-in WDB reports and environment recipes. It does",
"not convert or replace shipping GLBs. The builder now supports every valid ROS placement",
"resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale",
"transform relative to the reviewed primary placement.",
"",
"## Coverage",
"",
"- " + report.summary.dungeons + " dungeon WDB packages and "
+ report.summary.sourceDescriptors + " source descriptors.",
"- " + report.summary.uniqueDungeonResources
+ " per-dungeon unique ROS resources (resources reused across dungeons share cache keys).",
"- " + report.summary.staticPlacements + " static placements and "
+ report.summary.dynamicPlacements + " explicitly reviewed dynamic placements.",
"- " + report.summary.dynamicReviewCandidates
+ " filename-based dynamic candidates remain static and review-required; names never prove behavior.",
"- " + report.summary.unsupportedPlacements + " unsupported placement records and "
+ report.summary.missingSourceResources + " missing source ROS resources.",
"",
"| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...rows,
"",
"## Runtime-facing output",
"",
"A full build writes `wdb-placement-manifest.json` beside the environment package. Each",
"placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local",
"Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.",
"Static placements are assembled into the optimized visual and navigation collision. Reviewed",
"dynamic placements are omitted from static collision and packaged as separately addressable GLBs.",
"",
"## Unsupported or review-required behavior",
"",
...report.unsupportedResourceTypes.map((item) => "- " + item),
"- The primary-only top-down preview and editable `.blend` do not include the final merged WDB",
" assembly; the generated GLB and placement manifest are authoritative.",
"- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a",
" reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.",
"",
"## Validation issues",
"",
...(report.issues.length ? report.issues.map((item) => "- " + item) : ["- None."]),
"",
].join("\n");
}
export async function buildEnvironmentAssemblyAudit() {
const recipeNames = (await readdir(recipeDirectory))
.filter((name) => name.endsWith(".json")
&& !name.endsWith("-population.json")
&& name !== "runewaker-player-avatars.json")
.sort();
const dungeons = [];
const issues = [];
for (const recipeName of recipeNames) {
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeName), "utf8"));
const wdbReportFile = path.join(
root,
"src/assets/game/dungeons",
recipe.slug,
"wdb-report.json",
);
if (!await exists(wdbReportFile)) {
issues.push(recipe.slug + ": checked-in WDB report is missing.");
continue;
}
try {
const wdbReport = JSON.parse(await readFile(wdbReportFile, "utf8"));
const plan = buildWdbPlacementPlan(wdbReport, recipe);
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
const missingResources = [];
for (const resource of plan.resources) {
const file = path.join(resourceRoot, ...resource.normalizedResource.split("\\"));
if (!await exists(file)) missingResources.push(resource.resource);
}
dungeons.push({
id: recipe.slug,
mode: recipe.source.proceduralTerrainFallback
? "primary-only-procedural-fallback"
: recipe.source.wdbAssembly?.mode ?? "all-ros",
recipe: projectPath(path.join(recipeDirectory, recipeName)),
wdbReport: projectPath(wdbReportFile),
sourceDescriptors: plan.sourceDescriptorCount,
uniqueResources: plan.resources.length,
staticPlacements: plan.staticPlacements.length,
dynamicPlacements: plan.dynamicPlacements.length,
dynamicReviewCandidates: plan.reviewRequiredPlacements.length,
unsupportedPlacements: plan.unsupportedPlacements.length,
missingSourceResources: missingResources.length,
missingResources,
unsupported: plan.unsupportedPlacements,
});
} catch (error) {
issues.push(recipe.slug + ": " + error.message);
}
}
dungeons.sort((left, right) => left.id.localeCompare(right.id));
const sum = (field) => dungeons.reduce((total, dungeon) => total + dungeon[field], 0);
const unsupportedResourceTypes = [
"WDB placement resources other than ROS are recorded but not converted.",
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.",
];
return {
schemaVersion: 1,
generatedBy: "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
deterministic: true,
validationStatus: issues.length ? "blocked" : "green",
reviewStatus: sum("dynamicReviewCandidates") || sum("unsupportedPlacements")
|| sum("missingSourceResources") ? "review-required" : "green",
summary: {
dungeons: dungeons.length,
sourceDescriptors: sum("sourceDescriptors"),
uniqueDungeonResources: sum("uniqueResources"),
staticPlacements: sum("staticPlacements"),
dynamicPlacements: sum("dynamicPlacements"),
dynamicReviewCandidates: sum("dynamicReviewCandidates"),
unsupportedPlacements: sum("unsupportedPlacements"),
missingSourceResources: sum("missingSourceResources"),
},
unsupportedResourceTypes,
issues,
dungeons,
};
}
const report = await buildEnvironmentAssemblyAudit();
await Promise.all([
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
]);
console.log(JSON.stringify({
validationStatus: report.validationStatus,
reviewStatus: report.reviewStatus,
reportJson: projectPath(reportJsonFile),
reportMarkdown: projectPath(reportMarkdownFile),
summary: report.summary,
issues: report.issues,
}, null, 2));
if (report.issues.length) process.exitCode = 1;
@@ -7,6 +7,7 @@ import { BufferAttribute, BufferGeometry, Matrix4, Vector3 } from "three";
import {
buildPartyNavigationGraph,
} from "../../src/game/partyNavigation.ts";
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -156,6 +157,19 @@ function validateAttack(dungeonId, entityId, attack, issues) {
}
}
function formulaMatchesCatalog(attackFormula, catalogFormula) {
if (!catalogFormula) return attackFormula === undefined;
if (!attackFormula || typeof attackFormula !== "object") return false;
const expected = {
...catalogFormula,
// NPC assignments carry their source spell level. Script-derived boss
// references use the documented zero sentinel because no assignment level
// exists in the packaged row.
spellLevel: attackFormula.spellLevel,
};
return JSON.stringify(attackFormula) === JSON.stringify(expected);
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
@@ -171,7 +185,7 @@ function renderMarkdown(report) {
return [
"# RuneWaker gameplay content audit",
"",
"Structural validation: **" + report.validationStatus + "** ",
"Structural validation: **" + report.validationStatus + "**",
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
"",
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
@@ -200,6 +214,10 @@ function renderMarkdown(report) {
"- Boss signaling includes " + summary.telegraphedBossMechanics
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
+ " visible health-triggered mechanics.",
"- The exhaustive encounter implementation ledger tracks "
+ report.encounterImplementationLedger.deferredSourceRows + " deferred source rows and "
+ report.encounterImplementationLedger.uniqueMechanicsEvidence
+ " unique mechanics evidence records without inflating overlapping regex labels.",
"",
"## Authentic source data currently used",
"",
@@ -215,10 +233,12 @@ function renderMarkdown(report) {
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
"- " + summary.sourceBackedDamageFamilyAttacks
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
"- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,",
" source power fields, and the exact native formula shape/provenance.",
"- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,",
" DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.",
"- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.",
"- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.",
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
" attack, cast, wound, and death families.",
"",
@@ -530,11 +550,16 @@ for (const recipeName of recipeNames) {
+ executableSpellId + ".");
} else {
counts.sourceBackedDamageFamilyAttacks += 1;
if (attack.spellId && [
const runtimeMappingDrift = !catalogAuthored && [
"name", "school", "animation", "cooldownMs", "damageMultiplier",
].some((field) => attack[field] !== (
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
))) {
));
const formulaDrift = !formulaMatchesCatalog(
attack.runewakerFormula,
catalogSpell.runtime.formula,
);
if (attack.spellId && (runtimeMappingDrift || formulaDrift)) {
issue(issues, dungeonId, entityId + " spell " + executableSpellId
+ " drifted from the portable source-backed runtime mapping.");
}
@@ -749,8 +774,8 @@ summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ??
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
reviewNotes.push(summary.sourceGapBossMechanics
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
+ "formula/stat scaling has not been reconstructed.");
reviewNotes.push("Source formula structure is preserved, but live damage uses a labeled HealerMan "
+ "baseline adapter because the required native combat-stat inputs are not packaged.");
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
dungeon.objectiveRouteEvidence
.filter((route) => route.orderAuthoritative && !route.reachable)
@@ -784,6 +809,12 @@ if (summary.derivedObjectiveRoutes) {
+ summary.unreachableDerivedObjectiveRoutes + " unreachable heuristic legs remain navigation/order review evidence.");
}
const encounterImplementation = await generateEncounterImplementationReport();
if (encounterImplementation.report.issues.length) {
issues.push("Encounter implementation ledger is blocked: "
+ encounterImplementation.report.issues.join("; "));
}
const report = {
schemaVersion: 1,
validationStatus: issues.length ? "blocked" : "green",
@@ -797,11 +828,11 @@ const report = {
],
genericRuntimeSystems: [
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"combat/death state", "loot policy", "objective ordering", "procedural animation",
"combat/death state", "loot item stats/slots", "objective ordering", "procedural animation",
],
deferredMechanics: [
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
"encounter resets", "source loot",
"encounter resets", "blocked source-loot predicates", "source item stat/slot mapping",
],
blockerGroups: {
navigation: {
@@ -829,6 +860,14 @@ const report = {
mappingPolicy: "Only positively classified direct-HP damage families execute; "
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
},
encounterImplementationLedger: {
json: encounterImplementation.reportJson,
markdown: encounterImplementation.reportMarkdown,
validationStatus: encounterImplementation.report.validationStatus,
completionStatus: encounterImplementation.report.completionStatus,
deferredSourceRows: encounterImplementation.report.summary.deferredSourceRows,
uniqueMechanicsEvidence: encounterImplementation.report.summary.uniqueMechanicsEvidence,
},
resolvedRuntimeObservations,
documentedExceptions,
issues,
@@ -0,0 +1,262 @@
import { createHash } from "node:crypto";
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const shippingRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
const resourceCacheRoot = path.join(workRoot, "resource-cache");
const requestedSlugs = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
const fix = process.argv.includes("--fix");
const alphaClassifications = new Map();
let resourceTextureIndex;
async function exists(file) {
try {
await stat(file);
return true;
} catch {
return false;
}
}
function parseGlb(buffer, file) {
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
throw new Error(`${file} is not a GLB file.`);
}
const chunks = [];
let json;
let offset = 12;
while (offset + 8 <= buffer.length) {
const chunkLength = buffer.readUInt32LE(offset);
const chunkType = buffer.readUInt32LE(offset + 4);
const chunkStart = offset + 8;
const chunkEnd = chunkStart + chunkLength;
if (chunkEnd > buffer.length) break;
const data = buffer.subarray(chunkStart, chunkEnd);
chunks.push({ type: chunkType, data });
if (chunkType === 0x4e4f534a) {
json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
}
offset = chunkEnd;
}
if (!json) throw new Error(`${file} does not contain a JSON chunk.`);
return { json, chunks };
}
function serializeGlb(source, json, chunks) {
const encodedJson = Buffer.from(JSON.stringify(json));
const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20);
encodedJson.copy(paddedJson);
const updatedChunks = chunks.map((chunk) => (
chunk.type === 0x4e4f534a ? { ...chunk, data: paddedJson } : chunk
));
const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0);
const output = Buffer.alloc(totalLength);
source.copy(output, 0, 0, 12);
output.writeUInt32LE(totalLength, 8);
let offset = 12;
for (const chunk of updatedChunks) {
output.writeUInt32LE(chunk.data.length, offset);
output.writeUInt32LE(chunk.type, offset + 4);
chunk.data.copy(output, offset + 8);
offset += 8 + chunk.data.length;
}
return output;
}
async function classifyAlpha(file) {
if (alphaClassifications.has(file)) return alphaClassifications.get(file);
const metadata = await sharp(file).metadata();
if (!metadata.hasAlpha) {
alphaClassifications.set(file, "opaque");
return "opaque";
}
const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
let hasTransparentPixel = false;
for (let offset = 3; offset < data.length; offset += info.channels) {
const alpha = data[offset];
if (alpha === 255) continue;
hasTransparentPixel = true;
if (alpha > 0) {
alphaClassifications.set(file, "blend");
return "blend";
}
}
const role = hasTransparentPixel ? "cutout" : "opaque";
alphaClassifications.set(file, role);
return role;
}
function sourceImageIndex(texture) {
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
}
async function indexResourceTextures() {
if (resourceTextureIndex) return resourceTextureIndex;
resourceTextureIndex = new Map();
if (!(await exists(resourceCacheRoot))) return resourceTextureIndex;
const cacheDirectories = (await readdir(resourceCacheRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.sort((left, right) => left.name.localeCompare(right.name));
for (const cacheDirectory of cacheDirectories) {
const textureDirectory = path.join(resourceCacheRoot, cacheDirectory.name, "prepared", "textures");
if (!(await exists(textureDirectory))) continue;
for (const texture of (await readdir(textureDirectory)).filter((file) => file.endsWith(".png")).sort()) {
if (!resourceTextureIndex.has(texture)) {
resourceTextureIndex.set(texture, path.join(textureDirectory, texture));
}
}
}
return resourceTextureIndex;
}
async function resolveSourceTexture(localDirectory, imageName) {
const fileName = `${imageName}.png`;
const localFile = path.join(localDirectory, fileName);
if (await exists(localFile)) return localFile;
return (await indexResourceTextures()).get(fileName);
}
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
async function refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer) {
visualArtifact.size = visualBuffer.length;
visualArtifact.sha256 = sha256(visualBuffer);
await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
const optimizationFile = path.join(path.dirname(metadataFile), "optimization-report.json");
if (!(await exists(optimizationFile))) return;
const optimization = JSON.parse(await readFile(optimizationFile, "utf8"));
if (optimization.optimized) {
optimization.optimized.size = visualBuffer.length;
if (Object.hasOwn(optimization.optimized, "checksum")) {
optimization.optimized.checksum = sha256(visualBuffer);
}
}
await writeFile(optimizationFile, `${JSON.stringify(optimization, null, 2)}\n`, "utf8");
}
async function auditDungeon(recipe) {
const slug = recipe.slug;
const metadataFile = path.join(shippingRoot, slug, "import-metadata.json");
const preparedTextureDirectory = path.join(workRoot, slug, "prepared", "textures");
if (!(await exists(metadataFile))) {
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, metadataFile)}`] };
}
if (!(await exists(preparedTextureDirectory))) {
return {
slug,
status: "incomplete",
problems: [`Missing ${path.relative(projectRoot, preparedTextureDirectory)}`],
};
}
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
const visualArtifact = (metadata.assets ?? metadata.artifacts ?? [])
.find((artifact) => artifact.role === "visual");
const visualFile = path.join(shippingRoot, slug, visualArtifact?.fileName ?? `${slug}-visual.glb`);
if (!(await exists(visualFile))) {
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, visualFile)}`] };
}
const sourceGlb = await readFile(visualFile);
const { json: gltf, chunks } = parseGlb(sourceGlb, visualFile);
const materialResults = [];
const missingTextures = [];
for (const [materialIndex, material] of (gltf.materials ?? []).entries()) {
const textureIndex = material.pbrMetallicRoughness?.baseColorTexture?.index;
const texture = gltf.textures?.[textureIndex];
const image = gltf.images?.[sourceImageIndex(texture)];
if (!image?.name) continue;
const sourceFile = await resolveSourceTexture(preparedTextureDirectory, image.name);
if (!sourceFile) {
missingTextures.push(`${image.name}.png`);
continue;
}
const expectedRole = await classifyAlpha(sourceFile);
const expectedAlphaMode = expectedRole === "blend" ? "BLEND" : expectedRole === "cutout" ? "MASK" : "OPAQUE";
const actualAlphaMode = material.alphaMode ?? "OPAQUE";
const actualRole = material.extras?.semanticRole ?? "missing";
materialResults.push({
materialIndex,
material: material.name ?? `material-${materialIndex}`,
texture: image.name,
expectedRole,
expectedAlphaMode,
actualRole,
actualAlphaMode,
matches: expectedAlphaMode === actualAlphaMode && expectedRole === actualRole,
});
if (fix && (expectedAlphaMode !== actualAlphaMode || expectedRole !== actualRole)) {
if (expectedAlphaMode === "OPAQUE") delete material.alphaMode;
else material.alphaMode = expectedAlphaMode;
if (expectedAlphaMode === "MASK") material.alphaCutoff = 0.5;
else delete material.alphaCutoff;
material.extras = { ...(material.extras ?? {}), semanticRole: expectedRole };
}
}
const mismatches = materialResults.filter((result) => !result.matches);
const alphaMaterials = materialResults.filter((result) => result.expectedRole !== "opaque");
const problems = [
...mismatches.map((result) =>
`${result.texture}: expected ${result.expectedAlphaMode}/${result.expectedRole}, got ${result.actualAlphaMode}/${result.actualRole}`,
),
...[...new Set(missingTextures)].map((file) => `Missing prepared source texture ${file}`),
];
if (fix && mismatches.length > 0 && missingTextures.length === 0) {
const visualBuffer = serializeGlb(sourceGlb, gltf, chunks);
await writeFile(visualFile, visualBuffer);
if (visualArtifact) {
await refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer);
}
}
return {
slug,
status: problems.length === 0 ? "green" : fix && missingTextures.length === 0 ? "repaired" : "mismatch",
materials: materialResults.length,
alphaMaterials: alphaMaterials.map(({ material, texture, expectedAlphaMode }) => ({
material,
texture,
alphaMode: expectedAlphaMode,
})),
mismatches,
problems,
};
}
const recipeFiles = (await readdir(recipeDirectory))
.filter((file) => file.endsWith(".json") && !file.endsWith("-population.json"))
.sort();
const recipes = [];
for (const recipeFile of recipeFiles) {
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeFile), "utf8"));
if (!recipe.slug || recipe.slug === "runewaker-player-avatars") continue;
if (requestedSlugs.size > 0 && !requestedSlugs.has(recipe.slug)) continue;
recipes.push(recipe);
}
const results = [];
for (const recipe of recipes) {
results.push(await auditDungeon(recipe));
}
const failures = results.filter((result) => !["green", "repaired"].includes(result.status));
const report = {
schemaVersion: 1,
dungeonCount: results.length,
status: failures.length === 0 ? fix ? "repaired" : "green" : "mismatch",
failureCount: failures.length,
alphaDungeonCount: results.filter((result) => result.alphaMaterials?.length > 0).length,
results,
};
console.log(JSON.stringify(report, null, 2));
if (failures.length > 0) process.exitCode = 1;
@@ -6,6 +6,7 @@ Expected invocation:
"""
from __future__ import annotations
from array import array
import json
import math
import os
@@ -111,6 +112,69 @@ def material_image_key(material: bpy.types.Material) -> str:
return f"material:{material.name.casefold()}"
def material_image(material: bpy.types.Material) -> bpy.types.Image | None:
if not material.use_nodes or material.node_tree is None:
return None
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image is not None:
return node.image
return None
def image_alpha_role(image: bpy.types.Image | None) -> str:
"""Classify source alpha without turning fully opaque RGBA textures translucent."""
if image is None or image.channels < 4:
return "opaque"
pixels = array("f", [0.0]) * len(image.pixels)
image.pixels.foreach_get(pixels)
alpha_values = pixels[3::image.channels]
if min(alpha_values, default=1.0) >= 0.999:
return "opaque"
if any(0.001 < alpha < 0.999 for alpha in alpha_values):
return "blend"
return "cutout"
def preserve_material_alpha(material: bpy.types.Material) -> str:
image = material_image(material)
role = image_alpha_role(image)
if role == "opaque" or material.node_tree is None:
return role
image_node = next(
node
for node in material.node_tree.nodes
if node.type == "TEX_IMAGE" and node.image == image
)
principled = next(
(node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED"),
None,
)
if principled is None:
return role
alpha_output = image_node.outputs.get("Alpha")
alpha_input = principled.inputs.get("Alpha")
if alpha_output is None or alpha_input is None:
return role
if role == "blend":
if not alpha_input.is_linked:
material.node_tree.links.new(alpha_output, alpha_input)
material.blend_method = "BLEND"
if hasattr(material, "surface_render_method"):
material.surface_render_method = "BLENDED"
else:
alpha_clip = material.node_tree.nodes.new("ShaderNodeMath")
alpha_clip.name = "RuneWaker Alpha Clip"
alpha_clip.operation = "GREATER_THAN"
alpha_clip.inputs[1].default_value = 0.5
material.node_tree.links.new(alpha_output, alpha_clip.inputs[0])
material.node_tree.links.new(alpha_clip.outputs[0], alpha_input)
material.blend_method = "CLIP"
material.alpha_threshold = 0.5
return role
def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
canonical: dict[str, bpy.types.Material] = {}
replacements = 0
@@ -126,7 +190,7 @@ def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
replacements += 1
else:
canonical[key] = material
material["semanticRole"] = "opaque"
material["semanticRole"] = preserve_material_alpha(material)
material["sourceFormat"] = "RuneWaker DDS"
return {"unique": len(canonical), "replacements": replacements}
@@ -14,6 +14,11 @@ import {
projectRoot,
sourceRootFor,
} from "./lib/recipe.mjs";
import {
assembleInstancedGlb,
buildWdbPlacementPlan,
normalizeWdbResourcePath,
} from "./lib/environment-assembly.mjs";
const argv = process.argv.slice(2);
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
@@ -23,6 +28,7 @@ const workDirectory = path.join(projectRoot, "runewaker-export-work", slug);
const rawDirectory = path.join(workDirectory, "raw");
const preparedDirectory = path.join(workDirectory, "prepared");
const convertedDirectory = path.join(workDirectory, "converted");
const resourceCacheDirectory = path.join(projectRoot, "runewaker-export-work", "resource-cache");
const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug);
const sourceRoot = sourceRootFor(recipe);
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
@@ -45,6 +51,10 @@ const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker
const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
const ktx2Compressor = path.join(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs");
const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
const fullWdbAssembly = !recipe.source.proceduralTerrainFallback
&& recipe.source.wdbAssembly?.mode !== "primary-only"
&& !argv.includes("--primary-only");
const collisionChunkTriangleTarget = 90_000;
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
@@ -67,6 +77,15 @@ async function requirePath(file, guidance) {
catch { throw new Error(guidance + "\nMissing: " + file); }
}
async function exists(file) {
try {
await stat(file);
return true;
} catch {
return false;
}
}
async function sha256(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
@@ -148,7 +167,7 @@ function sourcePointToPrimaryLocal(sourcePosition, placement) {
async function appendSourceAnchorSupport() {
const support = recipe.source.sourceAnchorSupport;
if (!support) return;
if (!support || fullWdbAssembly) return;
const { placement } = await primaryPlacement();
const half = Number(support.halfExtentSourceUnits ?? 60);
const thickness = Number(support.thicknessSourceUnits ?? 3);
@@ -185,6 +204,133 @@ async function appendSourceAnchorSupport() {
]);
}
function resourceSourceFile(resource) {
return path.join(resourceRoot, ...normalizeWdbResourcePath(resource).split("\\"));
}
async function prepareCachedResourceTextures(raw, prepared) {
const sourceTextures = path.join(raw, "textures");
const preparedTextures = path.join(prepared, "textures");
await mkdir(preparedTextures, { recursive: true });
const ddsFiles = await exists(sourceTextures)
? (await readdir(sourceTextures))
.filter((file) => file.toLowerCase().endsWith(".dds"))
.sort()
.map((file) => path.join(sourceTextures, file))
: [];
if (ddsFiles.length) {
await run(
texconv,
["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles],
"convert " + ddsFiles.length + " cached DDS textures",
);
}
await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj"));
await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json"));
const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8");
await writeFile(
path.join(prepared, "scene.mtl"),
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
"utf8",
);
}
async function cachedResourceAsset(resourceRecord) {
const sourceFile = resourceSourceFile(resourceRecord.resource);
if (!await exists(sourceFile)) {
throw new Error("source ROS is missing: " + projectPath(sourceFile));
}
const sourceSha256 = await sha256(sourceFile);
const converterSha256 = await sha256(blenderScript);
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
const raw = path.join(cacheRoot, "raw");
const prepared = path.join(cacheRoot, "prepared");
const converted = path.join(cacheRoot, "converted");
const manifestFile = path.join(cacheRoot, "cache-manifest.json");
if (!argv.includes("--force-resource-cache") && await exists(manifestFile)) {
const cached = JSON.parse(await readFile(manifestFile, "utf8"));
const outputs = [cached.visual?.file, ...(cached.collision ?? []).map((entry) => entry.file)]
.filter(Boolean)
.map((file) => path.join(cacheRoot, file));
if (cached.source?.resource === resourceRecord.normalizedResource
&& cached.source?.sha256 === sourceSha256
&& cached.toolchain?.converterSha256 === converterSha256
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
return {
...cached,
cacheHit: true,
visualFile: path.join(cacheRoot, cached.visual.file),
collision: cached.collision.map((entry) => ({
...entry,
absoluteFile: path.join(cacheRoot, entry.file),
})),
};
}
}
await Promise.all([
mkdir(raw, { recursive: true }),
mkdir(prepared, { recursive: true }),
mkdir(converted, { recursive: true }),
]);
await run(
nativeExporter,
[resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw],
"export cached WDB resource " + resourceRecord.resource,
);
await prepareCachedResourceTextures(raw, prepared);
await run(
blender,
[
"--background", "--factory-startup", "--python", blenderScript, "--",
path.join(prepared, "scene.obj"),
converted,
resourceRecord.cacheKey,
String(recipe.coordinateSystem.metersPerSourceUnit),
],
"convert cached WDB resource " + resourceRecord.resource,
);
const conversion = JSON.parse(await readFile(path.join(converted, "conversion-report.json"), "utf8"));
const manifest = {
schemaVersion: 1,
source: {
resource: resourceRecord.normalizedResource,
file: projectPath(sourceFile),
sha256: sourceSha256,
},
coordinateSystem: {
nativeBridge: recipe.coordinateSystem.nativeBridge,
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
},
toolchain: {
converterSha256,
},
visual: {
file: path.relative(
cacheRoot,
path.join(converted, resourceRecord.cacheKey + "-visual.glb"),
).split(path.sep).join("/"),
triangles: conversion.visual.triangles,
meshObjects: conversion.visual.meshObjects,
bounds: conversion.visual.bounds,
},
collision: conversion.collision.map((entry) => ({
...entry,
file: path.relative(cacheRoot, path.join(converted, entry.file)).split(path.sep).join("/"),
})),
};
await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
return {
...manifest,
cacheHit: false,
visualFile: path.join(cacheRoot, manifest.visual.file),
collision: manifest.collision.map((entry) => ({
...entry,
absoluteFile: path.join(cacheRoot, entry.file),
})),
};
}
async function prepareTextures() {
const sourceTextures = path.join(rawDirectory, "textures");
const preparedTextures = path.join(preparedDirectory, "textures");
@@ -252,8 +398,208 @@ function cloneNodeTree(document, node) {
return clone;
}
function groupCollisionRecords(records) {
const groups = [];
let current = [];
let triangles = 0;
for (const record of records) {
if (current.length && triangles + record.triangles > collisionChunkTriangleTarget) {
groups.push({ records: current, triangles });
current = [];
triangles = 0;
}
current.push(record);
triangles += record.triangles;
}
if (current.length) groups.push({ records: current, triangles });
return groups;
}
async function assembleAllWdbPlacements() {
const wdbReport = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8"));
const plan = buildWdbPlacementPlan(wdbReport, recipe);
const primaryResource = normalizeWdbResourcePath(plan.primaryPlacement.resource);
const primaryConversion = JSON.parse(
await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"),
);
const assets = new Map();
assets.set(primaryResource, {
cacheHit: true,
cacheKind: "primary-conversion",
visualFile: path.join(convertedDirectory, slug + "-visual.glb"),
visual: primaryConversion.visual,
collision: primaryConversion.collision.map((entry) => ({
...entry,
absoluteFile: path.join(convertedDirectory, entry.file),
})),
});
const failures = [];
for (const resource of plan.resources) {
if (resource.normalizedResource === primaryResource) continue;
try {
assets.set(resource.normalizedResource, await cachedResourceAsset(resource));
} catch (error) {
failures.push({
resource: resource.resource,
normalizedResource: resource.normalizedResource,
placementCount: resource.placementCount,
reason: error.message,
});
}
}
const unavailable = new Set(failures.map((failure) => failure.normalizedResource));
for (const resource of plan.resources) {
const asset = assets.get(resource.normalizedResource);
resource.assetStatus = asset ? "available" : "unsupported";
resource.cacheHit = asset?.cacheHit ?? false;
resource.cacheKind = asset?.cacheKind ?? "normalized-path-cache-with-source-hash-validation";
if (!asset) resource.blocker = failures.find((entry) => (
entry.normalizedResource === resource.normalizedResource
))?.reason;
}
for (const placement of [...plan.staticPlacements, ...plan.dynamicPlacements]) {
placement.assetStatus = unavailable.has(placement.normalizedResource) ? "unsupported" : "available";
placement.assetId = "wdb-resource:" + placement.resourceCacheKey;
}
const staticPlacements = plan.staticPlacements.filter((placement) => (
placement.assetStatus === "available"
));
if (!staticPlacements.length) throw new Error("WDB assembly produced no available static placements.");
const visualRecords = staticPlacements.map((placement) => ({
placement,
assetFile: assets.get(placement.normalizedResource).visualFile,
}));
const assembledVisualFile = path.join(convertedDirectory, slug + "-visual.wdb-assembled.glb");
const visualAssembly = await assembleInstancedGlb(visualRecords, assembledVisualFile);
await copyFile(assembledVisualFile, path.join(convertedDirectory, slug + "-visual.glb"));
const collisionRecords = staticPlacements.flatMap((placement) => (
assets.get(placement.normalizedResource).collision.map((collision) => ({
placement,
assetFile: collision.absoluteFile,
triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0),
}))
)).sort((left, right) => (
left.placement.id.localeCompare(right.placement.id)
|| left.assetFile.localeCompare(right.assetFile)
));
const collision = [];
for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) {
const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb";
await assembleInstancedGlb(group.records, path.join(convertedDirectory, file));
collision.push({
file,
triangles: group.triangles,
sourceTriangleEstimate: group.triangles,
placementCount: group.records.length,
assembledFromWdb: true,
});
}
const dynamicDirectory = path.join(convertedDirectory, "dynamic");
const dynamicAssets = [];
const dynamicResources = plan.resources.filter((resource) => (
resource.dynamicPlacementCount > 0 && resource.assetStatus === "available"
));
if (dynamicResources.length) await mkdir(dynamicDirectory, { recursive: true });
for (const resource of dynamicResources) {
const asset = assets.get(resource.normalizedResource);
const visualName = resource.cacheKey + "-visual.glb";
await copyFile(asset.visualFile, path.join(dynamicDirectory, visualName));
dynamicAssets.push({
role: "dynamic-visual",
file: "dynamic/" + visualName,
assetId: "wdb-resource:" + resource.cacheKey,
resource: resource.resource,
});
for (const [index, sourceCollision] of asset.collision.entries()) {
const collisionName = resource.cacheKey + "-collision-" + String(index).padStart(3, "0") + ".glb";
await copyFile(sourceCollision.absoluteFile, path.join(dynamicDirectory, collisionName));
dynamicAssets.push({
role: "dynamic-collision",
file: "dynamic/" + collisionName,
assetId: "wdb-resource:" + resource.cacheKey,
resource: resource.resource,
triangles: Number(sourceCollision.triangles ?? 0),
});
}
}
plan.resourceFailures = failures;
plan.buildSummary = {
staticPlacements: staticPlacements.length,
dynamicPlacements: plan.dynamicPlacements.filter((placement) => (
placement.assetStatus === "available"
)).length,
unavailablePlacements: [...plan.staticPlacements, ...plan.dynamicPlacements]
.filter((placement) => placement.assetStatus === "unsupported").length,
uniqueResources: plan.resources.length,
availableResources: assets.size,
unavailableResources: failures.length,
resourceCacheHits: [...assets.values()].filter((asset) => (
asset.cacheHit && asset.cacheKind !== "primary-conversion"
)).length,
};
const placementManifestFile = path.join(convertedDirectory, "wdb-placement-manifest.json");
await writeFile(placementManifestFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
const visualTriangles = staticPlacements.reduce((total, placement) => (
total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0)
), 0);
const visualMeshObjects = staticPlacements.reduce((total, placement) => (
total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0)
), 0);
const conversion = {
schemaVersion: 2,
dungeonId: slug,
status: failures.length || plan.reviewRequiredPlacements.length
? "review-required"
: "source-packaged",
environmentMode: "runewaker-wdb-assembly",
source: {
wdb: recipe.source.wdb,
primaryModel: recipe.source.model,
descriptorCount: plan.sourceDescriptorCount,
placementManifest: "wdb-placement-manifest.json",
},
coordinateTransform: plan.coordinateTransform,
visual: {
file: slug + "-visual.glb",
meshObjects: visualMeshObjects,
triangles: visualTriangles,
sourceTriangles: visualTriangles,
bounds: visualAssembly.bounds,
assembledWdbPlacements: staticPlacements.length,
uniqueResources: new Set(staticPlacements.map((placement) => placement.normalizedResource)).size,
},
collision,
collisionPolicy: {
kind: "wdb-static-placement-trimesh",
maximumTrianglesPerChunk: 100_000,
workingChunkTarget: collisionChunkTriangleTarget,
excludesReviewedDynamicPlacements: true,
includesUnreviewedDynamicCandidates: true,
},
dynamicAssets,
preview: slug + "-preview.png",
editableSource: slug + ".source.blend",
wdbAssembly: plan.buildSummary,
warnings: [
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence.",
...failures.map((failure) => failure.resource + ": " + failure.reason),
],
};
await writeFile(
path.join(convertedDirectory, "conversion-report.json"),
JSON.stringify(conversion, null, 2) + "\n",
"utf8",
);
}
async function assembleRepeatedPlacements() {
const translations = assemblyTranslations();
const translations = fullWdbAssembly ? [] : assemblyTranslations();
if (!translations.length) return;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
@@ -385,7 +731,7 @@ async function bakeNavigation() {
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
const support = recipe.source.sourceAnchorSupport;
if (!support) return;
if (!support || fullWdbAssembly) return;
const { placement } = await primaryPlacement();
const half = Number(support.halfExtentSourceUnits ?? 60);
const meters = recipe.coordinateSystem.metersPerSourceUnit;
@@ -556,13 +902,29 @@ async function stageShippingPackage() {
const files = [
{ role: "visual", source: path.join(convertedDirectory, slug + "-visual.optimized.glb"), target: path.join(shippingDirectory, slug + "-visual.glb") },
...conversion.collision.map((chunk) => ({ role: "collision", source: path.join(convertedDirectory, chunk.file), target: path.join(shippingDirectory, chunk.file) })),
...(conversion.dynamicAssets ?? []).map((asset) => ({
role: asset.role,
source: path.join(convertedDirectory, asset.file),
target: path.join(shippingDirectory, asset.file),
assetId: asset.assetId,
resource: asset.resource,
})),
{ role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") },
];
for (const entry of files) await copyFile(entry.source, entry.target);
for (const entry of files) {
await mkdir(path.dirname(entry.target), { recursive: true });
await copyFile(entry.source, entry.target);
}
for (const report of ["conversion-report.json", "optimization-report.json", "recast-report.json", slug + "-preview.png"]) {
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
}
await copyFile(path.join(rawDirectory, "wdb-report.json"), path.join(shippingDirectory, "wdb-report.json"));
if (fullWdbAssembly) {
await copyFile(
path.join(convertedDirectory, "wdb-placement-manifest.json"),
path.join(shippingDirectory, "wdb-placement-manifest.json"),
);
}
const server = await sourceMetadata();
const { report, placement } = await primaryPlacement();
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
@@ -604,12 +966,21 @@ async function stageShippingPackage() {
} : {}),
server,
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
...(fullWdbAssembly ? {
wdbAssembly: {
mode: "all-ros",
placementManifest: "wdb-placement-manifest.json",
...conversion.wdbAssembly,
},
} : {}),
},
coordinateSystem: recipe.coordinateSystem,
entrance,
assets: await Promise.all(files.map(async (entry) => ({
role: entry.role,
fileName: path.basename(entry.target),
relativeFile: path.relative(shippingDirectory, entry.target).split(path.sep).join("/"),
...(entry.assetId ? { assetId: entry.assetId, sourceResource: entry.resource } : {}),
size: (await stat(entry.target)).size,
sha256: await sha256(entry.target),
}))),
@@ -634,7 +1005,8 @@ await buildNativeExporterIfNeeded();
await exportSource();
await prepareTextures();
await convertEnvironment();
await assembleRepeatedPlacements();
if (fullWdbAssembly) await assembleAllWdbPlacements();
else await assembleRepeatedPlacements();
await optimizeVisual();
await bakeNavigation();
await stageShippingPackage();
@@ -460,6 +460,12 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
damageMultiplier: spell.runtime.damageMultiplier,
...(spell.runtime.formula ? {
runewakerFormula: {
...spell.runtime.formula,
spellLevel: Number(sourceSpell.level ?? 0),
},
} : {}),
}];
});
const recipeAttacks = Array.isArray(template.attacks)
@@ -471,7 +477,34 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
const mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
const sourceSpellLevels = new Map(
(sourceSpells ?? []).map((sourceSpell) => [Number(sourceSpell.id), Number(sourceSpell.level ?? 0)]),
);
for (const spellId of authored?.source?.spellIds ?? []) {
if (!sourceSpellLevels.has(Number(spellId))) {
// Script-derived spell evidence has no NPC assignment level in the packaged row.
// Zero is the catalog's explicit unassigned-level sentinel and is not used by
// the HealerMan baseline adapter.
sourceSpellLevels.set(Number(spellId), 0);
}
}
const withSourceFormula = mechanics.map((attack) => {
const spellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
const spell = spellCatalog.get(spellId);
if (!sourceSpellLevels.has(spellId)
|| spell?.mappingStatus !== "damage-family"
|| !spell.runtime?.formula) {
return attack;
}
return {
...attack,
runewakerFormula: {
...spell.runtime.formula,
spellLevel: sourceSpellLevels.get(spellId),
},
};
});
const unique = [...new Map(withSourceFormula.map((attack) => [attack.id, attack])).values()];
return [basicAttack, ...unique];
}
@@ -706,7 +739,7 @@ async function generatePopulation() {
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
sourceSpellCatalog: projectPath(spellCatalogFile),
sourceSpellCatalogSha256: await sha256(spellCatalogFile),
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
};
const reportFile = resolveProject(recipe.files.report);
await mkdir(path.dirname(reportFile), { recursive: true });
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import {
FIDELITY_LEVELS,
IMPLEMENTATION_STATUSES,
buildEncounterImplementationReport,
} from "./lib/encounter-implementation-report.mjs";
test("encounter ledger exhaustively classifies portable RuneWaker evidence", async () => {
const report = await buildEncounterImplementationReport();
assert.equal(report.validationStatus, "green", report.issues.join("\n"));
assert.equal(report.summary.dungeons, 30);
assert.equal(report.summary.deferredSourceRows, 4_113);
assert.equal(report.summary.uniqueMechanicsEvidence, 1_524);
assert.equal(report.summary.luaEvidenceSignals, 329);
assert.equal(report.summary.templateEvidenceSignals, 1_195);
const inventory = report.dungeons.flatMap((dungeon) => [
...dungeon.deferredSourceRows,
...dungeon.mechanicsEvidence,
]);
assert.equal(new Set(inventory.map((item) => item.id)).size, inventory.length);
for (const item of inventory) {
assert.ok(IMPLEMENTATION_STATUSES.includes(item.status), item.id);
assert.ok(FIDELITY_LEVELS.includes(item.fidelity), item.id);
assert.ok(item.blocker, item.id);
assert.ok(item.nextWork, item.id);
assert.ok(item.clearingTest, item.id);
}
for (const row of report.dungeons.flatMap((dungeon) => dungeon.deferredSourceRows)) {
assert.ok(row.sourceIds.spawnId > 0, row.id);
assert.ok(row.sourceIds.templateId > 0, row.id);
assert.ok(row.source.snapshot.path.endsWith(".json"), row.id);
assert.match(row.source.snapshot.sha256, /^[a-f0-9]{64}$/, row.id);
assert.match(row.source.snapshot.contentSha256, /^[a-f0-9]{64}$/, row.id);
}
});
test("regex labels are memberships rather than inflated mechanic records", async () => {
const report = await buildEncounterImplementationReport();
const evidence = report.dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
const multiCategory = evidence.find((item) => item.categories.length > 1);
assert.ok(multiCategory, "expected at least one source line with overlapping review labels");
assert.equal(evidence.filter((item) => item.id === multiCategory.id).length, 1);
assert.equal(
report.summary.evidenceCategoryMembershipCount,
evidence.reduce((total, item) => total + item.categories.length, 0),
);
assert.notEqual(report.summary.evidenceCategoryMembershipCount, evidence.length);
});
test("source gaps, fallbacks, zero-boss packages, and empty variants stay explicit", async () => {
const report = await buildEncounterImplementationReport();
assert.deepEqual(
report.specialCases.fallbackBosses.map((boss) => boss.entityId),
["rw-fa-100070", "rw-fa-100561", "rw-fa-100562", "rw-fa-100563"],
);
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
boss.status === "implemented" && boss.fidelity === "healer-man-authored"
)));
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
boss.mechanicIds.length > 0
&& boss.telegraphedMechanicIds.length === boss.mechanicIds.length
)));
assert.deepEqual(
report.specialCases.nonEmptyZeroBossPackages.map((entry) => entry.dungeonId),
[
"aeternal-circle",
"dungeon-of-dalanis",
"grafu-castle",
"kal-turok-hive",
"lair-demon-dragon",
"tomb-seven-heroes",
"warnorken-arena",
"zurhidon-stronghold",
],
);
assert.deepEqual(
report.specialCases.intentionalEmptyPackages.map((entry) => entry.dungeonId),
["demon-stronghold-125", "zurhidon-stronghold-124"],
);
assert.equal(report.summary.deferredRowStatuses["deferred-source-gap"], 4_113);
});
test("the report has no timestamps and is byte-deterministic in memory", async () => {
const first = await buildEncounterImplementationReport();
const second = await buildEncounterImplementationReport();
assert.equal(JSON.stringify(first), JSON.stringify(second));
assert.equal(Object.hasOwn(first, "generatedAt"), false);
});
test("checked-in JSON is the current deterministic report", async () => {
const built = await buildEncounterImplementationReport();
const checkedIn = JSON.parse(await readFile(
new URL("./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json", import.meta.url),
"utf8",
));
assert.deepEqual(checkedIn, built);
});
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { Matrix4, Vector3 } from "three";
import {
assembleInstancedGlb,
buildWdbPlacementPlan,
normalizeWdbResourcePath,
placementMatrixInPrimaryThree,
resourceCacheKey,
transformSourcePointToPrimaryThree,
} from "./lib/environment-assembly.mjs";
const identityPlacement = (overrides = {}) => ({
guid: "PRIMARY",
resource: "model\\dungeon\\fixture\\room.ros",
scale: [1, 1, 1],
rotation: [0, 0, 0, 1],
translation: [10, 20, 30],
localBounds: { center: [0, 0, 0], radius: 1 },
worldBounds: { center: [10, 20, 30], radius: 1 },
...overrides,
});
const fixtureRecipe = (source = {}) => ({
slug: "fixture",
coordinateSystem: {
source: "left-handed Y-up",
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
metersPerSourceUnit: 0.1,
},
source: {
model: "model/dungeon/fixture/room.ros",
primaryPlacementTranslation: [10, 20, 30],
...source,
},
});
test("normalizes resource paths and makes case-insensitive stable cache keys", () => {
assert.equal(
normalizeWdbResourcePath("/Model/Dungeon/Fixture/Room.ROS"),
"model\\dungeon\\fixture\\room.ros",
);
assert.equal(
resourceCacheKey("Model/Dungeon/Fixture/Room.ROS"),
resourceCacheKey("model\\dungeon\\fixture\\room.ros"),
);
});
test("converts full placement translation, quaternion, and nonuniform scale", () => {
const primary = identityPlacement();
const placed = identityPlacement({
guid: "PLACED",
resource: "model\\dungeon\\fixture\\bridge.ros",
translation: [20, 22, 40],
rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2],
scale: [2, 3, 4],
});
const matrix = placementMatrixInPrimaryThree(placed, primary, 0.1);
const sourcePoint = [3, 4, 5];
const viaMatrix = new Vector3(sourcePoint[0] * 0.1, sourcePoint[1] * 0.1, -sourcePoint[2] * 0.1)
.applyMatrix4(new Matrix4().fromArray(matrix.toArray()))
.toArray();
const direct = transformSourcePointToPrimaryThree(sourcePoint, placed, primary, 0.1);
assert.deepEqual(viaMatrix.map((value) => Number(value.toFixed(9))), direct.map((value) => Number(value.toFixed(9))));
assert.notDeepEqual(matrix.toArray(), new Matrix4().identity().toArray());
});
test("builds deterministic static, dynamic, review, and unsupported placement groups", () => {
const primary = identityPlacement();
const door = identityPlacement({
guid: "DOOR",
resource: "model\\dungeon\\fixture\\boss_door.ros",
translation: [15, 20, 30],
});
const hintedBridge = identityPlacement({
guid: "BRIDGE",
resource: "model\\dungeon\\fixture\\drawbridge.ros",
translation: [20, 20, 30],
});
const unsupported = identityPlacement({
guid: "FX",
resource: "effect\\fixture\\mist.eft",
});
const recipe = fixtureRecipe({
wdbAssembly: {
mode: "all-ros",
dynamicPlacements: [{
id: "boss-door",
guid: "DOOR",
kind: "door",
initialState: "closed",
collision: "stateful",
evidence: ["fixture.lua:10"],
}],
},
});
const first = buildWdbPlacementPlan({
sourceWdb: "fixture.wdb",
descriptors: [unsupported, hintedBridge, door, primary],
}, recipe);
const second = buildWdbPlacementPlan({
sourceWdb: "fixture.wdb",
descriptors: [unsupported, hintedBridge, door, primary],
}, recipe);
assert.deepEqual(first, second);
assert.equal(first.staticPlacements.length, 2);
assert.equal(first.dynamicPlacements.length, 1);
assert.equal(first.dynamicPlacements[0].dynamic.kind, "door");
assert.equal(first.reviewRequiredPlacements.length, 1);
assert.equal(first.reviewRequiredPlacements[0].guid, "BRIDGE");
assert.equal(first.unsupportedPlacements.length, 1);
});
test("assembles repeated cached GLBs as transformed resource instances", async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), "healer-man-wdb-assembly-"));
try {
const sourceFile = path.join(temporary, "source.glb");
const outputFile = path.join(temporary, "assembled.glb");
const source = new Document();
const buffer = source.createBuffer("fixture-buffer");
const position = source.createAccessor("fixture-position", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const primitive = source.createPrimitive().setAttribute("POSITION", position);
const mesh = source.createMesh("fixture-mesh").addPrimitive(primitive);
source.createScene("fixture-scene").addChild(source.createNode("fixture-node").setMesh(mesh));
const io = new NodeIO();
await io.write(sourceFile, source);
const makePlacement = (id, x) => ({
id,
guid: id.toUpperCase(),
resource: "model/dungeon/fixture/room.ros",
resourceCacheKey: resourceCacheKey("model/dungeon/fixture/room.ros"),
transform: {
primaryLocalThreeMatrix: new Matrix4().makeTranslation(x, 0, 0).toArray(),
},
});
const result = await assembleInstancedGlb([
{ assetFile: sourceFile, placement: makePlacement("first", 0) },
{ assetFile: sourceFile, placement: makePlacement("second", 5) },
], outputFile);
const assembled = await io.read(outputFile);
const scene = assembled.getRoot().getDefaultScene() ?? assembled.getRoot().listScenes()[0];
assert.deepEqual(scene.listChildren().map((node) => node.getName()), ["first", "second"]);
assert.deepEqual(result.bounds, { min: [0, 0, 0], max: [6, 1, 0] });
assert.equal(assembled.getRoot().listMeshes().length, 1, "repeated placements share cached mesh data");
} finally {
await rm(temporary, { recursive: true, force: true });
}
});
test("rejects dynamic rules that would remove the primary coordinate anchor", () => {
const primary = identityPlacement();
assert.throws(() => buildWdbPlacementPlan({ descriptors: [primary] }, fixtureRecipe({
wdbAssembly: {
dynamicPlacements: [{
id: "invalid-primary-door",
guid: "PRIMARY",
kind: "door",
evidence: ["fixture.lua:1"],
}],
},
})), /primary coordinate-anchor placement cannot be dynamic/);
});
test("current modular WDB reports account for every descriptor without guessed dynamics", async () => {
for (const slug of ["raksha-temple", "the-origin", "treasure-trove"]) {
const [recipe, report] = await Promise.all([
readFile(new URL("./recipes/" + slug + ".json", import.meta.url), "utf8").then(JSON.parse),
readFile(new URL("../../src/assets/game/dungeons/" + slug + "/wdb-report.json", import.meta.url), "utf8").then(JSON.parse),
]);
const plan = buildWdbPlacementPlan(report, recipe);
assert.equal(
plan.staticPlacements.length + plan.dynamicPlacements.length + plan.unsupportedPlacements.length,
report.descriptorCount,
slug,
);
assert.equal(plan.dynamicPlacements.length, 0, slug + " must not infer stateful behavior from names");
assert.ok(plan.resources.length > 1, slug + " should expose arbitrary resource assembly");
assert.equal(plan.primaryPlacement.resource.toLowerCase(), recipe.source.model.toLowerCase());
}
});
@@ -0,0 +1,401 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const DATA_START = 140;
const COUNT_OFFSET = 132;
const NPC_RECORD_SIZE = 2224;
const NPC_OBJECT_TYPE = 1;
const NPC_DROP_START = 0xf4;
const NPC_DROP_COUNT = 15;
const TREASURE_RECORD_SIZE = 1516;
const TREASURE_OBJECT_TYPE = 20;
const TREASURE_NEED_LEVEL_DELTA = 0xac;
const TREASURE_ITEM_COUNT = 0xb0;
const TREASURE_ITEMS_START = 0xb4;
const TREASURE_MAX_ITEMS = 100;
const TREASURE_LUA_START = 0x5ac;
const TREASURE_LUA_LENGTH = 64;
const RATE_DENOMINATOR = 100_000;
const MAX_TREASURE_DEPTH = 12;
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const recipeNames = (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
const recipes = await Promise.all(recipeNames.map(async (name) => (
readJson(path.join(recipesDirectory, name))
)));
if (!recipes.length) throw new Error("No RuneWaker population recipes were found.");
const sourceRoot = sourceRootFor(recipes[0]);
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
const databaseNames = [
"npcobject.db",
"treasureobject.db",
"itemobject.db",
"armorobject.db",
"weaponobject.db",
"cardobject.db",
"runeobject.db",
"recipeobject.db",
"keyitemobject.db",
"string_enus.db",
];
const buffers = new Map(await Promise.all(databaseNames.map(async (name) => (
[name, await readFile(path.join(dataRoot, name))]
))));
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function validateFixedCatalog(buffer, recordSize, objectType, label) {
const count = int32(buffer, COUNT_OFFSET);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
throw new Error(label + " has an incompatible record count or size.");
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(buffer, DATA_START + index * recordSize + 4) !== objectType) {
throw new Error(label + " object-type sentinel drifted.");
}
}
return count;
}
function detectRecordSize(buffer, label) {
const count = int32(buffer, COUNT_OFFSET);
const objectType = int32(buffer, DATA_START + 4);
for (let recordSize = 100; recordSize <= 20_000; recordSize += 4) {
if (DATA_START + recordSize * count > buffer.length) continue;
const sample = [1, 2, 3, Math.floor(count / 2), count - 1];
if (sample.every((index) => (
int32(buffer, DATA_START + index * recordSize + 4) === objectType
))) return { count, objectType, recordSize };
}
throw new Error(label + " fixed-record layout could not be detected safely.");
}
function indexCatalog(buffer, count, recordSize) {
const offsets = new Map();
for (let index = 0; index < count; index += 1) {
const offset = DATA_START + index * recordSize;
const id = int32(buffer, offset);
if (id > 0) offsets.set(id, offset);
}
return offsets;
}
const npc = buffers.get("npcobject.db");
const treasure = buffers.get("treasureobject.db");
const strings = buffers.get("string_enus.db");
const npcCount = validateFixedCatalog(npc, NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
const treasureCount = validateFixedCatalog(
treasure, TREASURE_RECORD_SIZE, TREASURE_OBJECT_TYPE, "TreasureObjectDB",
);
const npcOffsets = indexCatalog(npc, npcCount, NPC_RECORD_SIZE);
const treasureOffsets = indexCatalog(treasure, treasureCount, TREASURE_RECORD_SIZE);
const leafObjects = new Map();
const leafCatalogs = [];
for (const name of databaseNames.filter((candidate) => ![
"npcobject.db", "treasureobject.db", "string_enus.db",
].includes(candidate))) {
const buffer = buffers.get(name);
const layout = detectRecordSize(buffer, name);
const offsets = indexCatalog(buffer, layout.count, layout.recordSize);
leafCatalogs.push({ name, ...layout, count: offsets.size });
for (const id of offsets.keys()) {
if (!leafObjects.has(id)) leafObjects.set(id, { catalog: name, objectType: layout.objectType });
}
}
const stringCache = new Map();
function stringValue(key) {
if (stringCache.has(key)) return stringCache.get(key);
const marker = Buffer.concat([Buffer.from(key, "ascii"), Buffer.from([0])]);
const keyPosition = strings.indexOf(marker);
if (keyPosition < 0) {
stringCache.set(key, null);
return null;
}
const start = keyPosition + marker.length;
const end = Math.min(strings.length - 4, start + 512);
for (let offset = start; offset < end; offset += 1) {
const length = strings.readInt32LE(offset);
if (length < 1 || length > 4001 || offset + 4 + length > strings.length) continue;
const value = strings.subarray(offset + 4, offset + 3 + length);
if (strings[offset + 3 + length] !== 0 || value.includes(0)) continue;
try {
const result = new TextDecoder("utf-8", { fatal: true }).decode(value).trim() || null;
stringCache.set(key, result);
return result;
} catch {
// Metadata can resemble a string length; continue to the next byte.
}
}
stringCache.set(key, null);
return null;
}
function fixedString(buffer, offset, length) {
const end = buffer.indexOf(0, offset);
const boundedEnd = end < 0 || end > offset + length ? offset + length : end;
return buffer.subarray(offset, boundedEnd).toString("utf8").trim();
}
function cleanProbability(value) {
return Number(Math.max(0, Math.min(1, value)).toFixed(12));
}
function blockedOutcome(objectId, chance, quantity, sourcePath, reason, constraints = {}) {
return {
objectId,
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Object " + objectId,
sourceCatalog: null,
runtimeStatus: "blocked",
blockedReason: reason,
conditionalChance: cleanProbability(chance),
quantity,
sourcePath,
...constraints,
};
}
function expandObject(objectId, chance, quantity, sourcePath, stack = [], constraints = {}) {
if (chance <= 0 || quantity <= 0) return [];
const leaf = leafObjects.get(objectId);
if (leaf) {
return [{
objectId,
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Item " + objectId,
sourceCatalog: leaf.catalog,
sourceObjectType: leaf.objectType,
runtimeStatus: "executable-generic-item",
blockedReason: null,
conditionalChance: cleanProbability(chance),
quantity,
sourcePath,
...constraints,
}];
}
const offset = treasureOffsets.get(objectId);
if (offset === undefined) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Object is absent from the bounded item and treasure source catalogs.", constraints,
)];
}
if (stack.includes(objectId) || stack.length >= MAX_TREASURE_DEPTH) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure recursion is cyclic or exceeds the audited depth limit.", constraints,
)];
}
const luaCheck = fixedString(treasure, offset + TREASURE_LUA_START, TREASURE_LUA_LENGTH);
if (luaCheck) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure table requires unavailable Lua predicate " + luaCheck + ".",
{ ...constraints, luaCheck },
)];
}
const needLevelDelta = int32(treasure, offset + TREASURE_NEED_LEVEL_DELTA);
const nextConstraints = {
...constraints,
minimumLevelDelta: Math.max(constraints.minimumLevelDelta ?? -2_147_483_648, needLevelDelta),
};
const itemCount = int32(treasure, offset + TREASURE_ITEM_COUNT);
if (itemCount < 0 || itemCount > TREASURE_MAX_ITEMS) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure item count is outside the preserved 0..100 layout.", nextConstraints,
)];
}
const outcomes = [];
for (let index = 0; index < itemCount; index += 1) {
const itemOffset = offset + TREASURE_ITEMS_START + index * 12;
const childId = int32(treasure, itemOffset);
const childWeight = int32(treasure, itemOffset + 4);
const childQuantity = int32(treasure, itemOffset + 8);
if (childId <= 0 || childWeight <= 0 || childQuantity <= 0) continue;
outcomes.push(...expandObject(
childId,
chance * Math.min(RATE_DENOMINATOR, childWeight) / RATE_DENOMINATOR,
quantity * childQuantity,
[...sourcePath, objectId],
[...stack, objectId],
nextConstraints,
));
}
if (outcomes.length) return outcomes;
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure table has no positive, resolvable outcomes.", nextConstraints,
)];
}
const bosses = [];
for (const recipe of recipes) {
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId;
for (const template of recipe.templates.filter((candidate) => candidate.classification === "boss")) {
const bossKey = runtimePrefix + "-" + template.id;
const npcOffset = npcOffsets.get(template.id);
if (npcOffset === undefined) {
bosses.push({
bossKey,
dungeonId: recipe.dungeonId,
sourceNpcId: template.id,
bossName: template.name,
sourceLevel: Number(template.level ?? 1),
status: "blocked",
blockedReasons: ["NPCObjectDB record is missing."],
rolls: [],
});
continue;
}
const rolls = [];
for (let slot = 0; slot < NPC_DROP_COUNT; slot += 1) {
const dropOffset = npcOffset + NPC_DROP_START + slot * 8;
const sourceObjectId = int32(npc, dropOffset);
const sourceRate = int32(npc, dropOffset + 4);
if (sourceObjectId <= 0 || sourceRate <= 0) continue;
const rootChance = cleanProbability(Math.min(RATE_DENOMINATOR, sourceRate) / RATE_DENOMINATOR);
const outcomes = expandObject(sourceObjectId, 1, 1, []);
rolls.push({
sourceSlot: slot + 1,
sourceObjectId,
sourceRatePer100000: sourceRate,
chancePerKill: rootChance,
outcomes,
});
}
const executable = rolls.flatMap((roll) => roll.outcomes)
.filter((outcome) => outcome.runtimeStatus === "executable-generic-item");
const blocked = rolls.flatMap((roll) => roll.outcomes)
.filter((outcome) => outcome.runtimeStatus === "blocked");
bosses.push({
bossKey,
dungeonId: recipe.dungeonId,
sourceNpcId: template.id,
bossName: template.name,
sourceLevel: Number(template.level ?? 1),
status: !rolls.length ? "no-source-drops" : blocked.length ? "partial" : "portable",
blockedReasons: [...new Set(blocked.map((outcome) => outcome.blockedReason))],
rolls,
executableOutcomeCount: executable.length,
blockedOutcomeCount: blocked.length,
});
}
}
bosses.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|| left.sourceNpcId - right.sourceNpcId);
const sourceFiles = databaseNames.map((name) => ({
file: projectPath(path.join(dataRoot, name)),
sha256: sha256(buffers.get(name)),
}));
const catalog = {
schemaVersion: 1,
status: "portable-with-explicit-fallbacks",
generatedFrom: {
sourceFiles,
npcDropAlgorithm: {
file: "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
firstRelevantLine: 4707,
rateDenominator: RATE_DENOMINATOR,
},
treasureAlgorithm: {
file: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
firstRelevantLine: 3033,
rateDenominator: RATE_DENOMINATOR,
},
layout: "RuneWaker v0.20 fixed records; read-only export",
},
runtimePolicy: {
rollsAreIndependent: true,
treasureOutcomesAreMutuallyExclusive: true,
unknownObjectsExecute: false,
luaPredicatesExecute: false,
sourceItemStatsInvented: false,
importedItemsUseGenericNonEquipableInventoryRepresentation: true,
},
leafCatalogs,
bossCount: bosses.length,
bossWithSourceRollsCount: bosses.filter((boss) => boss.rolls.length).length,
portableBossCount: bosses.filter((boss) => boss.status === "portable").length,
partialBossCount: bosses.filter((boss) => boss.status === "partial").length,
noSourceDropsBossCount: bosses.filter((boss) => boss.status === "no-source-drops").length,
blockedBossCount: bosses.filter((boss) => boss.status === "blocked").length,
executableOutcomeCount: bosses.reduce((total, boss) => total + (boss.executableOutcomeCount ?? 0), 0),
blockedOutcomeCount: bosses.reduce((total, boss) => total + (boss.blockedOutcomeCount ?? 0), 0),
bosses,
};
const runtimeFile = path.join(projectRoot, "src", "game", "generated", "runewakerLootCatalog.json");
const publicFile = path.join(projectRoot, "public", "data", "runewaker-loot-catalog.json");
const reportFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.json");
const reportMarkdownFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.md");
await Promise.all([
mkdir(path.dirname(runtimeFile), { recursive: true }),
mkdir(path.dirname(publicFile), { recursive: true }),
]);
const serialized = JSON.stringify(catalog, null, 2) + "\n";
const reportSummary = {
...Object.fromEntries(Object.entries(catalog).filter(([key]) => key !== "bosses")),
bosses: bosses.map((boss) => ({
bossKey: boss.bossKey,
dungeonId: boss.dungeonId,
sourceNpcId: boss.sourceNpcId,
bossName: boss.bossName,
status: boss.status,
sourceRollCount: boss.rolls.length,
executableOutcomeCount: boss.executableOutcomeCount ?? 0,
blockedOutcomeCount: boss.blockedOutcomeCount ?? 0,
blockedReasons: boss.blockedReasons,
})),
};
const markdown = [
"# RuneWaker loot import report",
"",
"Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.",
"",
`- Boss templates audited: ${catalog.bossCount}`,
`- Bosses with source rolls: ${catalog.bossWithSourceRollsCount}`,
`- Fully portable bosses: ${catalog.portableBossCount}`,
`- Partially portable bosses: ${catalog.partialBossCount}`,
`- Bosses with no authored drop slots: ${catalog.noSourceDropsBossCount}`,
`- Executable item outcomes: ${catalog.executableOutcomeCount}`,
`- Blocked outcomes: ${catalog.blockedOutcomeCount}`,
"",
"Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.",
"",
"| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |",
"| --- | --- | --- | ---: | ---: | ---: | --- |",
...reportSummary.bosses.map((boss) => (
`| ${boss.dungeonId} | ${boss.bossName.replaceAll("|", "\\|")} | ${boss.status} | ${boss.sourceRollCount} | ${boss.executableOutcomeCount} | ${boss.blockedOutcomeCount} | ${(boss.blockedReasons.join("; ") || "—").replaceAll("|", "\\|")} |`
)),
"",
].join("\n");
await Promise.all([
writeFile(runtimeFile, serialized, "utf8"),
writeFile(publicFile, serialized, "utf8"),
writeFile(reportFile, JSON.stringify(reportSummary, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, markdown, "utf8"),
]);
console.log("[runewaker-loot] exported " + catalog.bossCount + " bosses, "
+ catalog.executableOutcomeCount + " executable outcomes, and "
+ catalog.blockedOutcomeCount + " blocked outcomes.");
console.log("[runewaker-loot] " + projectPath(runtimeFile));
@@ -147,6 +147,35 @@ function schoolFor(component) {
?? "arcane";
}
function formulaFor(spellId, component) {
const magnitude = Math.abs(component.damagePower);
const powerKind = component.damagePower < 0
? magnitude <= 4 ? "source-ratio" : "source-points"
: component.dotBase < 0
? "dot-only"
: "unresolved";
return {
schemaVersion: 1,
spellId,
componentId: component.id,
spellLevel: 0,
calculationPath: component.effectType === 1 ? "physical" : "magic",
powerKind,
damagePower: component.damagePower,
fixedValue: component.fixedValue,
dotBase: component.dotBase,
runtimeBaselineMultiplier: powerKind === "source-ratio"
? Math.max(0.2, Math.min(4, magnitude))
: powerKind === "unresolved" ? 0 : 0.85,
provenance: {
sourceFile: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
physicalFunctionLine: 3366,
magicFunctionLine: 3651,
magicObjectDatabase: "Resource/data/magicobject.db",
},
};
}
const assignmentsBySpell = new Map();
for (const recipe of recipes) {
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
@@ -270,7 +299,8 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
? sourceCooldownSeconds * 1000
: 6500)),
damageMultiplier: 0.85,
damageMultiplier: formulaFor(spellId, primaryDamage).runtimeBaselineMultiplier,
formula: formulaFor(spellId, primaryDamage),
},
} : {}),
components,
@@ -298,6 +328,8 @@ const report = {
runtimeSqlDependency: false,
unknownEffectsExecute: false,
coefficientsAreSourceExact: false,
unavailableNativeInputsDefaulted: false,
pointValuedDamageUsesLabeledScaledAdapter: true,
},
spells,
};
@@ -0,0 +1,702 @@
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
const reportJsonFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
);
const reportMarkdownFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
);
export const IMPLEMENTATION_STATUSES = Object.freeze([
"implemented",
"verified",
"deferred-source-gap",
"deferred-asset-gap",
"deferred-runtime-gap",
"excluded-non-gameplay",
"intentional-empty",
]);
export const FIDELITY_LEVELS = Object.freeze([
"source-exact",
"source-backed-adapted",
"healer-man-authored",
]);
const EXPECTED_FALLBACK_BOSSES = Object.freeze([
"rw-fa-100070",
"rw-fa-100561",
"rw-fa-100562",
"rw-fa-100563",
]);
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
"aeternal-circle",
"dungeon-of-dalanis",
"grafu-castle",
"kal-turok-hive",
"lair-demon-dragon",
"tomb-seven-heroes",
"warnorken-arena",
"zurhidon-stronghold",
]);
const EXPECTED_INTENTIONAL_EMPTY_PACKAGES = Object.freeze([
"demon-stronghold-125",
"zurhidon-stronghold-124",
]);
const SIGNAL_PATTERNS = Object.freeze([
["summon", /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i],
["door", /door|gate|seal|barrier|open.*object|close.*object/i],
["timer", /timer|delay|sleep|wait|schedule/i],
["movement", /moveto|move_to|patrol|waypoint|autoplot|path/i],
["spell", /cast|magic|spell|skill/i],
["death", /ondead|on_dead|dead|death|onkill|on_kill/i],
["phase", /phase|stage|state|wave/i],
["interaction", /click|useobject|talk|speak|interact|trigger/i],
["loot", /loot|treasure|drop|reward|chest/i],
]);
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
const projectPath = (file) => path.relative(projectRoot, file).split(path.sep).join("/");
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const sha256File = async (file) => sha256(await readFile(file));
const compareText = (left, right) => String(left).localeCompare(String(right));
const sameMembers = (left, right) => (
left.length === right.length && left.every((value, index) => value === right[index])
);
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
function generatedJson(source, name, suffix) {
const marker = "export const " + name + " = ";
const start = source.indexOf(marker);
if (start < 0) throw new Error("Missing generated constant " + name + ".");
const valueStart = start + marker.length;
const valueEnd = source.indexOf(suffix, valueStart);
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
return JSON.parse(source.slice(valueStart, valueEnd));
}
function runtimeTemplateId(entityId) {
const match = String(entityId).match(/-(\d+)$/);
return match ? Number(match[1]) : null;
}
function categoriesFor(value) {
return SIGNAL_PATTERNS
.filter(([, pattern]) => pattern.test(String(value ?? "")))
.map(([kind]) => kind);
}
function statusCounts(items) {
return Object.fromEntries(IMPLEMENTATION_STATUSES.map((status) => [
status,
items.filter((item) => item.status === status).length,
]));
}
function fidelityCounts(items) {
return Object.fromEntries(FIDELITY_LEVELS.map((fidelity) => [
fidelity,
items.filter((item) => item.fidelity === fidelity).length,
]));
}
function sourceFile(pathName, fileSha256, contentSha256 = null) {
return {
path: pathName,
sha256: fileSha256,
...(contentSha256 ? { contentSha256 } : {}),
};
}
function deferredRowRecord({ dungeonId, zoneId, row, snapshotPath, snapshotFileSha256, snapshot }) {
const evidenceText = [
row.name,
row.sourceName,
row.modelPath,
row.source?.roleName,
row.source?.autoPlot,
row.source?.plotClassName,
...Object.values(row.source?.templateScripts ?? {}),
].filter(Boolean).join(" ");
return {
id: "row:" + dungeonId + ":" + row.spawnId,
kind: "deferred-source-row",
dungeonId,
zoneId,
sourceIds: {
spawnId: row.spawnId,
templateId: row.templateId,
roomId: Number(row.source?.roomId ?? 0),
},
name: row.name,
sourceName: row.sourceName,
sourceClassification: row.classification,
sourcePosition: row.sourcePosition,
direction: row.direction,
modelPath: row.modelPath ?? null,
heuristicCategories: categoriesFor(evidenceText),
status: "deferred-source-gap",
fidelity: "source-exact",
source: {
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
},
blocker: "The source row preserves identity and placement, but no reviewed evidence proves its gameplay behavior.",
nextWork: "Identify the authoritative trigger, state transition, reset/persistence rules, and asset requirements; then add an explicit source-spawn mapping.",
clearingTest: "Add a deterministic fixture proving that source spawn " + row.spawnId
+ " executes the reviewed behavior and resets idempotently.",
};
}
function runtimeSpellMatches(entities, templateId, spellId) {
return Object.entries(entities).flatMap(([entityId, entity]) => {
if (runtimeTemplateId(entityId) !== templateId) return [];
return (entity.combat?.attacks ?? [])
.filter((attack) => Number(attack.spellId ?? attack.source?.spellId ?? 0) === spellId)
.map((attack) => ({
entityId,
attackId: attack.id,
telegraph: attack.telegraph ?? null,
delivery: attack.delivery,
}));
});
}
function baseEvidenceRecord({
id,
evidenceKind,
dungeonId,
zoneId,
templateId = null,
categories,
value,
source,
}) {
return {
id,
kind: "mechanics-evidence",
evidenceKind,
dungeonId,
zoneId,
sourceIds: templateId == null ? {} : { templateId },
categories: [...new Set(categories)].sort(compareText),
value,
status: "deferred-source-gap",
fidelity: "source-exact",
source,
blocker: "This is source evidence, but it does not prove a complete executable mechanic by itself.",
nextWork: "Review the surrounding source to establish trigger, target, timing, state effects, reset, persistence, and multiplayer behavior.",
clearingTest: "Add a focused encounter fixture tied to this stable evidence ID and verify its full state transition and reset behavior.",
};
}
function templateEvidenceRecords({
dungeonId,
zoneId,
mechanics,
mechanicsPath,
mechanicsFileSha256,
snapshot,
snapshotPath,
snapshotFileSha256,
entities,
}) {
const records = [];
const commonSource = {
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
};
for (const candidate of mechanics.templateCandidates ?? []) {
const templateId = Number(candidate.templateId);
for (const value of candidate.autoPlots ?? []) {
records.push(baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":autoplot:" + sha256(value).slice(0, 12),
evidenceKind: "auto-plot",
dungeonId,
zoneId,
templateId,
categories: categoriesFor(value),
value,
source: commonSource,
}));
}
for (const value of candidate.plotClassNames ?? []) {
records.push(baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":plot-class:" + sha256(value).slice(0, 12),
evidenceKind: "plot-class",
dungeonId,
zoneId,
templateId,
categories: categoriesFor(value),
value,
source: commonSource,
}));
}
for (const script of candidate.templateScripts ?? []) {
const value = script.event + ":" + script.script;
records.push(baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":script:"
+ script.event + ":" + sha256(script.script).slice(0, 12),
evidenceKind: "template-script",
dungeonId,
zoneId,
templateId,
categories: categoriesFor(value),
value: { event: script.event, script: script.script },
source: commonSource,
}));
}
for (const spell of candidate.spells ?? []) {
const spellId = Number(spell.id);
const matches = runtimeSpellMatches(entities, templateId, spellId);
const record = baseEvidenceRecord({
id: "signal:" + dungeonId + ":template:" + templateId + ":spell:" + spellId,
evidenceKind: "source-spell",
dungeonId,
zoneId,
templateId,
categories: ["spell"],
value: { spellId, level: Number(spell.level ?? 0) },
source: commonSource,
});
if (matches.length) {
record.status = "verified";
record.fidelity = "source-backed-adapted";
record.runtimeMappings = matches;
record.blocker = "The original RuneWaker coefficient and native stat inputs are not available in the portable package.";
record.nextWork = "Retain the exact spell identity and revisit numerical fidelity only if authoritative formula inputs are recovered.";
record.clearingTest = "npm run runewaker:audit:gameplay verifies the source spell ID and runtime attack remain aligned.";
}
records.push(record);
}
}
return records;
}
function luaEvidenceRecords({ dungeonId, zoneId, mechanics, mechanicsPath, mechanicsFileSha256 }) {
const luaHashByFile = new Map(
(mechanics.luaSources ?? []).map((source) => [source.file, source.sha256]),
);
return (mechanics.luaSignals ?? []).map((signal) => baseEvidenceRecord({
id: "signal:" + dungeonId + ":lua:" + signal.file + ":" + signal.line,
evidenceKind: "lua-line",
dungeonId,
zoneId,
categories: signal.kinds ?? [],
value: { line: signal.line, text: signal.text },
source: {
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
lua: sourceFile(signal.file, luaHashByFile.get(signal.file) ?? null),
},
}));
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.deferredSourceRows.length, "|",
dungeon.mechanicsEvidence.length, "|", dungeon.evidenceStatusCounts.verified, "|",
dungeon.evidenceStatusCounts["deferred-source-gap"], "|", dungeon.bossObjectives, "|",
dungeon.packageAssessment.status, "|",
].join(" "));
const categoryRows = Object.entries(report.summary.evidenceCategoryMemberships)
.map(([kind, count]) => "| " + kind + " | " + count + " |");
const blockerRows = Object.entries(report.blockerGroups)
.map(([key, value]) => "| " + key + " | " + value.items + " | " + value.nextWork + " |");
return [
"# RuneWaker encounter implementation and fidelity ledger",
"",
"Structural validation: **" + report.validationStatus + "**",
"Content completion: **" + report.completionStatus + "**",
"",
"This report is deterministic and offline. Its JSON companion inventories every deferred",
"source spawn row and every direct mechanics evidence record. A regex category membership",
"is a review label, not a unique mechanic; one evidence record may belong to several categories.",
"",
"## Coverage",
"",
"- " + report.summary.dungeons + " dungeon packages.",
"- " + report.summary.deferredSourceRows + " deferred source rows with stable spawn IDs.",
"- " + report.summary.uniqueMechanicsEvidence + " unique mechanics evidence records: "
+ report.summary.luaEvidenceSignals + " Lua lines and "
+ report.summary.templateEvidenceSignals + " template references.",
"- " + report.summary.evidenceCategoryMembershipCount
+ " overlapping category memberships; this number is intentionally not reported as mechanics.",
"- " + report.summary.verifiedEvidence + " evidence records have an exact executable source-spell mapping.",
"- " + report.summary.sourceGapEvidence + " evidence records remain source-review gaps.",
"",
"## Status definitions",
"",
"- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.",
"- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.",
"- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.",
"- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.",
"- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.",
"- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.",
"- `intentional-empty`: the authoritative packaged snapshot contains no population rows.",
"",
"## Per-dungeon inventory",
"",
"| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
...rows,
"",
"## Four explicit fallback bosses",
"",
"| Boss | Runtime ID | Status | Fidelity | Blocker |",
"| --- | --- | --- | --- | --- |",
...report.specialCases.fallbackBosses.map((boss) => "| " + boss.name + " | `" + boss.entityId
+ "` | " + boss.status + " | " + boss.fidelity + " | " + boss.blocker + " |"),
"",
"These attacks are intentionally playable and telegraphed HealerMan behavior. They are not",
"presented as original RuneWaker mechanics until authoritative source behavior is recovered.",
"",
"## Non-empty packages without confirmed boss objectives",
"",
...report.specialCases.nonEmptyZeroBossPackages.map((entry) => "- `" + entry.dungeonId + "`: "
+ entry.sourceRows + " rows; " + entry.blocker + " Next: " + entry.nextWork),
"",
"## Intentional empty variants",
"",
...report.specialCases.intentionalEmptyPackages.map((entry) => "- `" + entry.dungeonId + "` (Zone "
+ entry.zoneId + "): " + entry.blocker),
"",
"## Evidence category memberships",
"",
"| Category | Evidence records carrying the label |",
"| --- | ---: |",
...categoryRows,
"",
"## Remaining work ledger",
"",
"| Blocker group | Items | Clearing work |",
"| --- | ---: | --- |",
...blockerRows,
"",
"The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source",
"files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.",
"",
"## Validation issues",
"",
...(report.issues.length ? report.issues.map((entry) => "- " + entry) : ["- None."]),
"",
].join("\n");
}
function validateReport(report) {
const issues = [];
const inventory = report.dungeons.flatMap((dungeon) => [
...dungeon.deferredSourceRows,
...dungeon.mechanicsEvidence,
]);
const ids = new Set();
for (const item of inventory) {
if (ids.has(item.id)) issues.push("Duplicate inventory ID: " + item.id + ".");
ids.add(item.id);
if (!IMPLEMENTATION_STATUSES.includes(item.status)) {
issues.push(item.id + " has unsupported status " + item.status + ".");
}
if (!FIDELITY_LEVELS.includes(item.fidelity)) {
issues.push(item.id + " has unsupported fidelity " + item.fidelity + ".");
}
if (!item.blocker || !item.nextWork || !item.clearingTest) {
issues.push(item.id + " does not document blocker, next work, and clearing test.");
}
}
const fallbackIds = report.specialCases.fallbackBosses.map((boss) => boss.entityId).sort(compareText);
if (!sameMembers(fallbackIds, [...EXPECTED_FALLBACK_BOSSES])) {
issues.push("Expected exactly the four documented Forsaken Abbey fallback bosses; found "
+ fallbackIds.join(", ") + ".");
}
const zeroBossIds = report.specialCases.nonEmptyZeroBossPackages
.map((entry) => entry.dungeonId).sort(compareText);
if (!sameMembers(zeroBossIds, [...EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES])) {
issues.push("Non-empty zero-boss package set drifted: " + zeroBossIds.join(", ") + ".");
}
const emptyIds = report.specialCases.intentionalEmptyPackages
.map((entry) => entry.dungeonId).sort(compareText);
if (!sameMembers(emptyIds, [...EXPECTED_INTENTIONAL_EMPTY_PACKAGES])) {
issues.push("Intentional-empty package set drifted: " + emptyIds.join(", ") + ".");
}
return issues;
}
export async function buildEncounterImplementationReport() {
const recipeNames = (await readdir(recipeDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort(compareText);
const bossMechanicsPath = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
const [bossMechanics, bossMechanicsFileSha256] = await Promise.all([
readJson(bossMechanicsPath),
sha256File(bossMechanicsPath),
]);
const dungeons = [];
for (const recipeName of recipeNames) {
const recipeFile = path.join(recipeDirectory, recipeName);
const recipe = await readJson(recipeFile);
const dungeonId = recipe.dungeonId;
const zoneId = recipe.zoneId;
const snapshotFile = path.resolve(projectRoot, recipe.files.snapshot);
const mechanicsFile = path.resolve(
projectRoot,
recipe.files.mechanicsReport
?? "src/assets/game/dungeons/" + dungeonId + "/mechanics-audit.json",
);
const generatedSourceFile = path.resolve(projectRoot, recipe.files.generatedSource);
const [
recipeFileSha256,
snapshot,
snapshotFileSha256,
mechanics,
mechanicsFileSha256,
generatedSource,
generatedSourceSha256,
] = await Promise.all([
sha256File(recipeFile),
readJson(snapshotFile),
sha256File(snapshotFile),
readJson(mechanicsFile),
sha256File(mechanicsFile),
readFile(generatedSourceFile, "utf8"),
sha256File(generatedSourceFile),
]);
const prefix = constantCase(dungeonId);
const entities = generatedJson(
generatedSource,
prefix + "_ENTITIES",
" as const satisfies PopulationDefinitionMap;",
);
const bosses = generatedJson(
generatedSource,
prefix + "_BOSSES",
" as const satisfies readonly DungeonBossObjective[];",
);
const snapshotPath = projectPath(snapshotFile);
const mechanicsPath = projectPath(mechanicsFile);
const deferredSourceRows = (snapshot.rows ?? [])
.filter((row) => String(row.classification).startsWith("deferred"))
.map((row) => deferredRowRecord({
dungeonId,
zoneId,
row,
snapshotPath,
snapshotFileSha256,
snapshot,
}))
.sort((left, right) => left.sourceIds.spawnId - right.sourceIds.spawnId);
const mechanicsEvidence = [
...luaEvidenceRecords({
dungeonId,
zoneId,
mechanics,
mechanicsPath,
mechanicsFileSha256,
}),
...templateEvidenceRecords({
dungeonId,
zoneId,
mechanics,
mechanicsPath,
mechanicsFileSha256,
snapshot,
snapshotPath,
snapshotFileSha256,
entities,
}),
].sort((left, right) => compareText(left.id, right.id));
const packageStatus = snapshot.rows.length === 0
? "intentional-empty"
: bosses.length === 0
? "deferred-source-gap"
: "verified";
dungeons.push({
id: dungeonId,
zoneId,
sourceRows: snapshot.rows.length,
bossObjectives: bosses.length,
sourceArtifacts: {
recipe: sourceFile(projectPath(recipeFile), recipeFileSha256),
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
generatedRuntime: sourceFile(projectPath(generatedSourceFile), generatedSourceSha256),
upstreamHashes: snapshot.sourceHashes ?? {},
},
packageAssessment: {
status: packageStatus,
fidelity: "source-exact",
blocker: packageStatus === "intentional-empty"
? "The authoritative snapshot contains zero population rows; no combat population is fabricated."
: packageStatus === "deferred-source-gap"
? "The package is non-empty, but its preserved source classification confirms no boss objectives."
: "No package-level population blocker is recorded.",
nextWork: packageStatus === "deferred-source-gap"
? "Recover authoritative boss membership and encounter order before creating boss objectives."
: "Re-run the ledger whenever portable source content changes.",
clearingTest: packageStatus === "deferred-source-gap"
? "Add a source-backed boss-membership fixture and verify the generated objective list."
: "npm run runewaker:audit:gameplay",
},
deferredRowStatusCounts: statusCounts(deferredSourceRows),
evidenceStatusCounts: statusCounts(mechanicsEvidence),
evidenceFidelityCounts: fidelityCounts(mechanicsEvidence),
deferredSourceRows,
mechanicsEvidence,
});
}
dungeons.sort((left, right) => compareText(left.id, right.id));
const allRows = dungeons.flatMap((dungeon) => dungeon.deferredSourceRows);
const allEvidence = dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
const fallbackBosses = Object.entries(bossMechanics.bosses ?? {})
.filter(([, boss]) => boss.source?.kind === "healer-man-source-gap")
.map(([entityId, boss]) => {
const dungeon = dungeons.find((entry) => entry.id === boss.source.dungeonId);
return {
entityId,
name: boss.name,
dungeonId: boss.source.dungeonId,
templateId: Number(boss.source.templateId),
mechanicIds: (boss.mechanics ?? []).map((mechanic) => mechanic.id).sort(compareText),
telegraphedMechanicIds: (boss.mechanics ?? [])
.filter((mechanic) => mechanic.telegraph)
.map((mechanic) => mechanic.id)
.sort(compareText),
status: "implemented",
fidelity: "healer-man-authored",
source: {
bossMechanicsCatalog: sourceFile(projectPath(bossMechanicsPath), bossMechanicsFileSha256),
snapshot: dungeon?.sourceArtifacts.snapshot ?? null,
roots: boss.source.roots ?? [],
functions: boss.source.functions ?? [],
spellIds: boss.source.spellIds ?? [],
},
blocker: "Preserved source evidence does not expose an executable original combat kit.",
nextWork: "Replace the fallback only after authoritative spell timing, targeting, and effects are recovered; preserve modular IDs and telegraphs.",
clearingTest: "Add source-backed mechanic fixtures and assert the catalog source kind changes from healer-man-source-gap to runewaker.",
};
})
.sort((left, right) => compareText(left.entityId, right.entityId));
const nonEmptyZeroBossPackages = dungeons
.filter((dungeon) => dungeon.sourceRows > 0 && dungeon.bossObjectives === 0)
.map((dungeon) => ({
dungeonId: dungeon.id,
zoneId: dungeon.zoneId,
sourceRows: dungeon.sourceRows,
status: "deferred-source-gap",
fidelity: "source-exact",
source: dungeon.sourceArtifacts.snapshot,
blocker: "The portable source package contains population rows but no source-confirmed boss membership.",
nextWork: "Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.",
clearingTest: "Verify at least one source-backed boss objective, or add reviewed evidence that this package intentionally has none.",
}));
const intentionalEmptyPackages = dungeons
.filter((dungeon) => dungeon.sourceRows === 0)
.map((dungeon) => ({
dungeonId: dungeon.id,
zoneId: dungeon.zoneId,
sourceRows: 0,
status: "intentional-empty",
fidelity: "source-exact",
source: dungeon.sourceArtifacts.snapshot,
blocker: "The authoritative packaged snapshot contains no active population rows.",
nextWork: "No population work is scheduled unless a different authoritative source snapshot is supplied.",
clearingTest: "Keep the zero-row snapshot hash and intentional-empty classification stable in this audit.",
}));
const categoryMemberships = {};
for (const evidence of allEvidence) {
for (const category of evidence.categories) {
categoryMemberships[category] = (categoryMemberships[category] ?? 0) + 1;
}
}
const luaEvidenceSignals = allEvidence.filter((item) => item.evidenceKind === "lua-line").length;
const templateEvidenceSignals = allEvidence.length - luaEvidenceSignals;
const report = {
schemaVersion: 1,
validationStatus: "green",
completionStatus: allRows.length || allEvidence.some((item) => item.status.startsWith("deferred"))
? "review-required"
: "complete",
scope: "All configured offline RuneWaker dungeon packages",
generatedBy: "scripts/runewaker-pipeline/audit-encounter-implementation.mjs",
deterministic: true,
policy: {
statuses: IMPLEMENTATION_STATUSES,
fidelities: FIDELITY_LEVELS,
evidenceRule: "Direct source rows and source references are inventoried once. Regex category memberships may overlap and are never counted as unique mechanics.",
failClosedRule: "When static evidence cannot prove complete behavior, the item remains deferred-source-gap rather than receiving a guessed implementation status.",
sourceExactRule: "source-exact describes preserved evidence identity and provenance, not an assertion that deferred gameplay behavior is known.",
},
summary: {
dungeons: dungeons.length,
deferredSourceRows: allRows.length,
uniqueMechanicsEvidence: allEvidence.length,
luaEvidenceSignals,
templateEvidenceSignals,
evidenceCategoryMembershipCount: Object.values(categoryMemberships)
.reduce((total, count) => total + count, 0),
evidenceCategoryMemberships: Object.fromEntries(
Object.entries(categoryMemberships).sort(([left], [right]) => compareText(left, right)),
),
deferredRowStatuses: statusCounts(allRows),
evidenceStatuses: statusCounts(allEvidence),
evidenceFidelities: fidelityCounts(allEvidence),
verifiedEvidence: allEvidence.filter((item) => item.status === "verified").length,
sourceGapEvidence: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
fallbackBosses: fallbackBosses.length,
nonEmptyZeroBossPackages: nonEmptyZeroBossPackages.length,
intentionalEmptyPackages: intentionalEmptyPackages.length,
},
blockerGroups: {
deferredSourceRows: {
items: allRows.length,
nextWork: "Review source semantics and add explicit source-spawn encounter mappings.",
},
unresolvedMechanicsEvidence: {
items: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
nextWork: "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior.",
},
adaptedSpellFormula: {
items: allEvidence.filter((item) => item.status === "verified"
&& item.fidelity === "source-backed-adapted").length,
nextWork: "Recover native formula inputs before claiming coefficient-level fidelity.",
},
sourceGapFallbackBosses: {
items: fallbackBosses.length,
nextWork: "Recover authoritative combat kits and replace only with source-backed modular mechanics.",
},
nonEmptyZeroBossPackages: {
items: nonEmptyZeroBossPackages.length,
nextWork: "Recover authoritative boss membership and objective order.",
},
},
specialCases: {
fallbackBosses,
nonEmptyZeroBossPackages,
intentionalEmptyPackages,
},
issues: [],
dungeons,
};
report.issues = validateReport(report);
report.validationStatus = report.issues.length ? "blocked" : "green";
return report;
}
export async function generateEncounterImplementationReport() {
const report = await buildEncounterImplementationReport();
await Promise.all([
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
]);
return {
report,
reportJson: projectPath(reportJsonFile),
reportMarkdown: projectPath(reportMarkdownFile),
};
}
@@ -0,0 +1,383 @@
import { createHash } from "node:crypto";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds, mergeDocuments, unpartition } from "@gltf-transform/functions";
import { Matrix4, Quaternion, Vector3 } from "three";
const SUPPORTED_RESOURCE_EXTENSIONS = new Set([".ros"]);
const DYNAMIC_HINT_PATTERN = /(?:^|[\\/_-])(door|gate|bridge|drawbridge|lift|elevator|switch|lever|chest|treasure|portal)(?:[\\/_.-]|$)/i;
const DYNAMIC_KINDS = new Set([
"door",
"gate",
"bridge",
"lift",
"switch",
"chest",
"portal",
"other",
]);
export function normalizeWdbResourcePath(value) {
return String(value ?? "")
.replaceAll("/", "\\")
.replace(/^\\+/, "")
.toLowerCase();
}
export function resourceCacheKey(value) {
return createHash("sha256")
.update(normalizeWdbResourcePath(value))
.digest("hex")
.slice(0, 24);
}
function finiteTuple(value, length, name) {
if (!Array.isArray(value) || value.length !== length
|| value.some((entry) => !Number.isFinite(Number(entry)))) {
throw new Error(name + " must contain exactly " + length + " finite numbers.");
}
return value.map(Number);
}
export function descriptorSourceMatrix(descriptor) {
const translation = finiteTuple(descriptor.translation, 3, "descriptor.translation");
const rotation = finiteTuple(descriptor.rotation, 4, "descriptor.rotation");
const scale = finiteTuple(descriptor.scale, 3, "descriptor.scale");
const quaternion = new Quaternion(...rotation);
if (quaternion.lengthSq() < 1e-12) throw new Error("descriptor.rotation cannot be zero.");
quaternion.normalize();
return new Matrix4().compose(
new Vector3(...translation),
quaternion,
new Vector3(...scale),
);
}
export function placementMatrixInPrimaryThree(descriptor, primary, metersPerSourceUnit) {
const meters = Number(metersPerSourceUnit);
if (!Number.isFinite(meters) || meters <= 0) {
throw new Error("metersPerSourceUnit must be a positive finite number.");
}
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
const threeToSource = sourceToThree.clone().invert();
return sourceToThree
.clone()
.multiply(descriptorSourceMatrix(primary).invert())
.multiply(descriptorSourceMatrix(descriptor))
.multiply(threeToSource);
}
export function transformSourcePointToPrimaryThree(
sourcePoint,
descriptor,
primary,
metersPerSourceUnit,
) {
const point = new Vector3(...finiteTuple(sourcePoint, 3, "sourcePoint"));
point.applyMatrix4(descriptorSourceMatrix(descriptor));
point.applyMatrix4(descriptorSourceMatrix(primary).invert());
const meters = Number(metersPerSourceUnit);
return [point.x * meters, point.y * meters, -point.z * meters];
}
function cloneNodeTree(document, node) {
const clone = document.createNode(node.getName())
.setMatrix(node.getMatrix())
.setExtras({ ...node.getExtras() })
.setMesh(node.getMesh())
.setCamera(node.getCamera())
.setSkin(node.getSkin())
.setWeights([...node.getWeights()]);
for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child));
return clone;
}
export async function assembleInstancedGlb(records, outputFile) {
if (!records.length) throw new Error("Cannot assemble an empty WDB GLB.");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const target = new Document();
const scene = target.createScene("WDB assembly");
const recordsByAsset = new Map();
for (const record of records) {
const rows = recordsByAsset.get(record.assetFile) ?? [];
rows.push(record);
recordsByAsset.set(record.assetFile, rows);
}
for (const assetFile of [...recordsByAsset.keys()].sort()) {
const source = await io.read(assetFile);
const sourceScene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
if (!sourceScene) throw new Error("Cached WDB resource has no scene: " + assetFile);
const mapping = mergeDocuments(target, source);
const mergedScene = mapping.get(sourceScene);
if (!mergedScene) throw new Error("Could not merge cached WDB resource: " + assetFile);
const roots = mergedScene.listChildren();
if (!roots.length) throw new Error("Cached WDB resource has no scene roots: " + assetFile);
for (const [index, record] of recordsByAsset.get(assetFile).entries()) {
const placement = target.createNode(record.placement.id)
.setMatrix(record.placement.transform.primaryLocalThreeMatrix)
.setExtras({
wdbPlacementId: record.placement.id,
wdbGuid: record.placement.guid,
sourceResource: record.placement.resource,
resourceCacheKey: record.placement.resourceCacheKey,
});
const nodes = index === 0
? roots
: roots.map((rootNode) => cloneNodeTree(target, rootNode));
for (const rootNode of nodes) placement.addChild(rootNode);
scene.addChild(placement);
}
mergedScene.dispose();
}
await target.transform(unpartition());
const bounds = getBounds(scene);
await io.write(outputFile, target);
return {
bounds: {
min: bounds.min.map((value) => Number(value.toFixed(6))),
max: bounds.max.map((value) => Number(value.toFixed(6))),
},
};
}
function matrixArray(matrix) {
return matrix.toArray().map((value) => Number(value.toFixed(10)));
}
function selectPrimaryDescriptor(report, recipe) {
const target = normalizeWdbResourcePath(recipe.source.model);
const matches = (report.descriptors ?? []).filter((descriptor) => (
normalizeWdbResourcePath(descriptor.resource) === target
));
if (!matches.length) {
throw new Error("Expected at least one WDB placement for " + recipe.source.model + ".");
}
if (matches.length === 1) return matches[0];
const expected = recipe.source.primaryPlacementTranslation;
if (!Array.isArray(expected) || expected.length !== 3) {
throw new Error("Multiple primary-resource placements require source.primaryPlacementTranslation.");
}
const match = matches.find((descriptor) => descriptor.translation.every((value, index) => (
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
)));
if (!match) throw new Error("Configured primary WDB placement was not found.");
return match;
}
function reviewedDynamicRules(recipe) {
const rules = recipe.source.wdbAssembly?.dynamicPlacements ?? [];
const ids = new Set();
return rules.map((rule, index) => {
if (!rule || typeof rule !== "object") {
throw new Error("source.wdbAssembly.dynamicPlacements[" + index + "] must be an object.");
}
if (!String(rule.id ?? "").trim()) throw new Error("Dynamic placement rule requires a stable id.");
if (ids.has(rule.id)) throw new Error("Duplicate dynamic placement id " + rule.id + ".");
ids.add(rule.id);
if (!DYNAMIC_KINDS.has(rule.kind)) {
throw new Error("Dynamic placement " + rule.id + " has unsupported kind " + rule.kind + ".");
}
if (!rule.guid && !rule.resource) {
throw new Error("Dynamic placement " + rule.id + " requires guid or resource evidence.");
}
if (!Array.isArray(rule.evidence) || !rule.evidence.length) {
throw new Error("Dynamic placement " + rule.id + " requires reviewed evidence.");
}
return {
...rule,
normalizedResource: rule.resource ? normalizeWdbResourcePath(rule.resource) : null,
};
});
}
function matchingDynamicRule(descriptor, rules) {
const resource = normalizeWdbResourcePath(descriptor.resource);
return rules.find((rule) => (
(rule.guid && String(rule.guid).toLowerCase() === String(descriptor.guid).toLowerCase())
|| (rule.normalizedResource && rule.normalizedResource === resource)
));
}
function placementRecord(descriptor, index, primary, recipe, classification, dynamicRule = null) {
const resource = normalizeWdbResourcePath(descriptor.resource);
const id = classification === "dynamic"
? dynamicRule.id + ":" + String(descriptor.guid ?? index)
: "wdb:" + String(descriptor.guid ?? index);
return {
id,
guid: descriptor.guid ?? null,
resource: String(descriptor.resource).replaceAll("\\", "/"),
normalizedResource: resource,
resourceCacheKey: resourceCacheKey(resource),
classification,
transform: {
source: {
translation: descriptor.translation.map(Number),
rotation: descriptor.rotation.map(Number),
scale: descriptor.scale.map(Number),
},
primaryLocalThreeMatrix: matrixArray(placementMatrixInPrimaryThree(
descriptor,
primary,
recipe.coordinateSystem.metersPerSourceUnit,
)),
},
sourceBounds: {
local: descriptor.localBounds ?? null,
world: descriptor.worldBounds ?? null,
},
sourceFlags: {
zoneFlags: Number(descriptor.zoneFlags ?? 0),
detailClass: Number(descriptor.detailClass ?? 0),
zoneType: Number(descriptor.zoneType ?? 0),
},
...(dynamicRule ? {
dynamic: {
definitionId: dynamicRule.id,
kind: dynamicRule.kind,
initialState: dynamicRule.initialState ?? "default",
collision: dynamicRule.collision ?? "stateful",
evidence: dynamicRule.evidence,
},
} : {}),
};
}
export function buildWdbPlacementPlan(report, recipe) {
if (!report || !Array.isArray(report.descriptors)) {
throw new Error("WDB report must contain descriptors.");
}
const primary = selectPrimaryDescriptor(report, recipe);
const dynamicRules = reviewedDynamicRules(recipe);
const matchedDynamicRuleIds = new Set();
const staticPlacements = [];
const dynamicPlacements = [];
const reviewRequiredPlacements = [];
const unsupportedPlacements = [];
const resourceMap = new Map();
for (const [index, descriptor] of report.descriptors.entries()) {
const resource = normalizeWdbResourcePath(descriptor.resource);
const extension = path.extname(resource);
if (!SUPPORTED_RESOURCE_EXTENSIONS.has(extension)) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: extension
? "unsupported WDB placement resource extension " + extension
: "WDB placement resource has no extension",
});
continue;
}
let matrix;
try {
matrix = placementMatrixInPrimaryThree(
descriptor,
primary,
recipe.coordinateSystem.metersPerSourceUnit,
);
} catch (error) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: "invalid WDB transform: " + error.message,
});
continue;
}
if (matrix.elements.some((value) => !Number.isFinite(value))) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: "WDB transform produced a non-finite matrix",
});
continue;
}
const dynamicRule = matchingDynamicRule(descriptor, dynamicRules);
if (descriptor === primary && dynamicRule) {
throw new Error("The primary coordinate-anchor placement cannot be dynamic ("
+ dynamicRule.id + ").");
}
const classification = dynamicRule ? "dynamic" : "static";
const placement = placementRecord(
descriptor,
index,
primary,
recipe,
classification,
dynamicRule,
);
if (dynamicRule) {
matchedDynamicRuleIds.add(dynamicRule.id);
dynamicPlacements.push(placement);
} else {
staticPlacements.push(placement);
if (DYNAMIC_HINT_PATTERN.test(resource)) {
reviewRequiredPlacements.push({
placementId: placement.id,
guid: placement.guid,
resource: placement.resource,
reason: "Resource name resembles a movable or interactive object, but no reviewed dynamic-placement rule proves its behavior.",
});
}
}
if (!resourceMap.has(resource)) {
resourceMap.set(resource, {
resource: placement.resource,
normalizedResource: resource,
cacheKey: placement.resourceCacheKey,
placementCount: 0,
staticPlacementCount: 0,
dynamicPlacementCount: 0,
});
}
const resourceRecord = resourceMap.get(resource);
resourceRecord.placementCount += 1;
resourceRecord[classification + "PlacementCount"] += 1;
}
const unmatchedDynamicRules = dynamicRules
.filter((rule) => !matchedDynamicRuleIds.has(rule.id))
.map((rule) => ({
id: rule.id,
guid: rule.guid ?? null,
resource: rule.resource ?? null,
reason: "Reviewed dynamic-placement selector matched no WDB descriptor.",
}));
if (unmatchedDynamicRules.length) {
throw new Error(unmatchedDynamicRules.map((rule) => rule.reason + " " + rule.id).join("\n"));
}
return {
schemaVersion: 1,
dungeonId: recipe.slug,
sourceWdb: report.sourceWdb,
sourceDescriptorCount: report.descriptors.length,
primaryPlacement: {
guid: primary.guid ?? null,
resource: String(primary.resource).replaceAll("\\", "/"),
translation: primary.translation.map(Number),
rotation: primary.rotation.map(Number),
scale: primary.scale.map(Number),
},
coordinateTransform: {
source: recipe.coordinateSystem.source,
nativeBridge: recipe.coordinateSystem.nativeBridge,
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
formula: "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)",
},
resources: [...resourceMap.values()].sort((left, right) => (
left.normalizedResource.localeCompare(right.normalizedResource)
)),
staticPlacements: staticPlacements.sort((left, right) => left.id.localeCompare(right.id)),
dynamicPlacements: dynamicPlacements.sort((left, right) => left.id.localeCompare(right.id)),
reviewRequiredPlacements: reviewRequiredPlacements
.sort((left, right) => left.placementId.localeCompare(right.placementId)),
unsupportedPlacements: unsupportedPlacements.sort((left, right) => left.id.localeCompare(right.id)),
unsupportedResourceTypes: [
"WDB placement resources other than ROS",
"particle, light, sound, camera, and script-only components not represented as ROS mesh descriptors",
"animated or stateful behavior without a reviewed source.wdbAssembly.dynamicPlacements rule",
],
};
}
@@ -694,15 +694,15 @@
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"Collision initially includes decorative primary-ROS geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
"The full WDB static assembly replaces the former source-anchor support pads; entrance and combat anchors still require live collision/navigation review."
]
}
@@ -1059,20 +1059,22 @@
"agentRadius": 0.36,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"tileSize": 64,
"tileSize": 512,
"partition": "monotone",
"forceTiled": true,
"retainTileIntermediates": false
},
"review": {
"environment": "authoritative-wdb-largest-model-selected",
"collision": "primary-ros-plus-source-anchor-support-review-required",
"collision": "full-wdb-static-ros-provisional-review-required",
"entrance": "source-script",
"bossAnchors": "source-population",
"wdbPlacements": "unique-primary-model"
"wdbPlacements": "all-static-ros-source-transform"
},
"exceptions": [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
"Collision initially includes decorative primary-ROS geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
"The full static WDB collision requires a tiled monotone Recast build because the solo watershed contour triangulator rejects this source geometry."
]
}
Loaded 100 of 560 files, more files were not shown because too many files have changed in this diff. Show more