Update Healer Man server

This commit is contained in:
phenom committed 2026-09-24 18:15:45 -04:00
1 parent 71246e5b75
commit a868401ebc
40 files changed
+1522 -161

No files matched your search

+35
View File
@@ -3860,3 +3860,38 @@ Vite's existing large-chunk advisory remains informational.
unrelated dependency deprecation warning. Audio loop and release/cancel unrelated dependency deprecation warning. Audio loop and release/cancel
behavior passed focused unit tests, but the browser pass did not assess sound behavior passed focused unit tests, but the browser pass did not assess sound
by listening. The generated map is `docs/ROM_SPELL_MEDIA_MAPPING.md`. by listening. The generated map is `docs/ROM_SPELL_MEDIA_MAPPING.md`.
## RoM Priest target and Rising Tide media (2026-09-24)
- Traced Urgent Heal's MagicObject component 501543 to ImageObject 573048 and
its target `act_benedictory_heal_t_down.ros`. Its `explode` motion requests a
one-shot 3D `act_benedictory_heal_t_down.mp3` at the healed target. The
browser graph renders two original sprite layers, the emitted 72-triangle
rune cylinder, and the animated gold ground decal with source color and
alpha tracks. It remains marked partial because the decal is placed on a
level ground plane rather than projected onto nearby terrain triangles.
- Traced Rising Tide's component 620408 to ImageObject 572062 and target
`act_riptide_t_freeze_hit.ros`. Its five-sprite target graph and the
`act_icecone01_c_hand.ros` cast and `act_icecone01_f.ros` projectile graphs
are attached to their respective phases. ROS direct-audio events identify
`act_icecone01_f.ros.mp3` on projectile release and
`act_riptide_t_freeze_hit.mp3` on target impact. Impact audio is positioned
at the target; release audio remains at the caster.
- The focused combat audio suite passed 12 tests, including distinct release
and target panning. The manifest test checked all three new MP3 paths against
the source ROS EventTrigger ID 43 payloads and packaged files.
- In the in-app RuneWaker preview, Urgent Heal's impact showed gold stars, a
green rune cylinder, and a gold target-ground ring at the frozen 250 ms frame.
Rising Tide's cast showed a blue ring, its release a bright blue flash, and
its target impact a blue and magenta burst. All four graphs loaded without
browser errors or warnings. Audio mapping was source and test verified; this
browser pass did not assess the sound by listening.
- Live Priest combat in Wailing Caverns confirmed Urgent Heal's original gold
rune circle and sparkles around the selected party target Veyra. In Boss
Testing against Verdan the Everliving, Rising Tide showed blue source
particles around the Priest during the cast, a blue projectile on release,
and a bright blue and magenta burst across Verdan's visible lower body at
arrival. The impact presentation now follows the projectile's 180–550 ms
travel time; large-target attachment uses visual bounds so the burst remains
in view. The browser reported no original-media or WebGL errors. Audio
timing and target panning are source and test verified, not listening tested.
+6 -6
View File
@@ -2,11 +2,11 @@
Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event. Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.
The effect column records original client ROS/RAS references and their cast phase. 6 castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. The sound column lists client cues that are packaged and played on their mapped phase. A dash means the source record or matching sound file did not provide that media. The effect column records original client ROS/RAS references and their cast phase. 7 castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. A `partial original` phase renders verified source layers and declares its remaining fidelity limit. The sound column lists packaged client cues on their mapped phase. A dash means the source record or matching sound file did not provide that media.
| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect | | Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |
| --- | --- | ---: | ---: | ---: | ---: | | --- | --- | ---: | ---: | ---: | ---: |
| Priest | Base | 25 | 19 | 18 | 3 | | Priest | Base | 25 | 19 | 18 | 4 |
| Priest | Accepted elite pair | 6 | 6 | 4 | 0 | | Priest | Accepted elite pair | 6 | 6 | 4 | 0 |
| Knight | Base | 19 | 17 | 14 | 2 | | Knight | Base | 19 | 17 | 14 | 2 |
| Knight | Accepted elite pair | 6 | 6 | 3 | 0 | | Knight | Accepted elite pair | 6 | 6 | 3 | 0 |
@@ -40,8 +40,8 @@ The effect column records original client ROS/RAS references and their cast phas
| Cleanse | 491175 | impact: act_dispersion_t_freeze_hit_e.ros | impact: act_dispersion_t_freeze_hit_e.mp3 | | Cleanse | 491175 | impact: act_dispersion_t_freeze_hit_e.ros | impact: act_dispersion_t_freeze_hit_e.mp3 |
| Advanced Resurrection | 491176 | cast-start: act_casting_c_hand.ros<br>impact: act_anabiosis_t_down.ros | cast-start: act_casting_c_hand.wav<br>impact: act_anabiosis_t_down.mp3 | | Advanced Resurrection | 491176 | cast-start: act_casting_c_hand.ros<br>impact: act_anabiosis_t_down.ros | cast-start: act_casting_c_hand.wav<br>impact: act_anabiosis_t_down.mp3 |
| Supreme Resurrection | 491177 | cast-start: act_casting_c_hand.ros<br>impact: act_anabiosis_t_down.ros | cast-start: act_casting_c_hand.wav<br>impact: act_anabiosis_t_down.mp3 | | Supreme Resurrection | 491177 | cast-start: act_casting_c_hand.ros<br>impact: act_anabiosis_t_down.ros | cast-start: act_casting_c_hand.wav<br>impact: act_anabiosis_t_down.mp3 |
| Urgent Heal | 491147 | cast-start: act_casting_c_hand.ros | cast-start: act_casting_c_hand.wav | | Urgent Heal | 491147 | cast-start: act_casting_c_hand.ros<br>impact: act_benedictory_heal_t_down.ros (partial original) | cast-start: act_casting_c_hand.wav<br>impact: act_benedictory_heal_t_down.mp3 |
| Rising Tide | 490256 | cast-start: act_icecone01_c_hand.ros<br>release: act_icecone01_f.ros | cast-start: act_icecone01_c_hand.wav | | Rising Tide | 490256 | cast-start: act_icecone01_c_hand.ros<br>release: act_icecone01_f.ros<br>impact: act_riptide_t_freeze_hit.ros | cast-start: act_icecone01_c_hand.wav<br>release: act_icecone01_f.ros.mp3<br>impact: act_riptide_t_freeze_hit.mp3 |
| Regenerate | 490269 | cast-start: act_casting_c_hand.ros<br>cast-start: act_holy_hand_wave_c_hand_e.ros | cast-start: act_casting_c_hand.wav | | Regenerate | 490269 | cast-start: act_casting_c_hand.ros<br>cast-start: act_holy_hand_wave_c_hand_e.ros | cast-start: act_casting_c_hand.wav |
| Holy Aura | 490300 | cast-start: act_casting_c_hand.ros<br>cast-start: act_flash_candle_c_down_e.ros | cast-start: act_casting_c_hand.wav | | Holy Aura | 490300 | cast-start: act_casting_c_hand.ros<br>cast-start: act_flash_candle_c_down_e.ros | cast-start: act_casting_c_hand.wav |
| Magic Barrier | 491166 | — | — | | Magic Barrier | 491166 | — | — |
@@ -199,8 +199,8 @@ The effect column records original client ROS/RAS references and their cast phas
## Decoded particle archive ## Decoded particle archive
The particle archive covers 123 referenced ROS models, with 7 fully decoded models and 116 partial models. 4 models are renderer-ready, including the Priest holy cast whose remaining EventTrigger is an audio callback. It exports 510 sprite controllers and 103 distinct PNG textures. See `public/assets/game/combat-effects/original/runewaker/manifest.json` for per-model details. The particle archive covers 124 referenced ROS models, with 7 fully decoded models and 117 partial models. 7 models are renderer-ready and 1 has a browser-playable partial original. The Priest holy cast's remaining EventTrigger is an audio callback; Urgent Heal's target renders its source sprites, emitted rune mesh, and animated ground decal. Its terrain-triangle decal projection remains approximated by a level plane. The archive exports 515 sprite controllers and 106 distinct PNG textures. See `public/assets/game/combat-effects/original/runewaker/manifest.json` for per-model details.
## Next conversion step ## Next conversion step
The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting, Knight Charge cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The Priest holy model's EventTrigger starts a looping 3D WAV and is handled by phase-matched combat audio while its three sprite layers render. Next, verify more spell phases against the source motion and controller semantics before attaching their `effectGraphUrl`. The Direct3D source and packaged ROS data remain the reference for those ports. The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting; Urgent Heal's target impact with its source rune mesh and decal; Rising Tide's cast, projectile release, and target impact; Knight Charge cast and Whirlwind Shield impact; and Mage Electric Bolt projectile. Urgent Heal's holy cast EventTrigger starts a looping 3D WAV, and its target impact triggers the source benedictory heal MP3. Rising Tide retains its packaged cast WAV and now plays its ROS-linked projectile release and target impact MP3s. Impact cues use the target position. Urgent Heal's decal uses a level plane in place of the client's terrain projection. The Direct3D source and packaged ROS data remain the reference for subsequent phases.
@@ -1 +1 @@
{"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6841,"authentic":5569,"fallback":1272,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated {"schemaVersion":1,"generatedAt":null,"sources":[{"kind":"wow335","path":"../wow335a/Data/enUS/patch-enUS.MPQ","sha256":"9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1"},{"kind":"ascension","path":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","sha256":"2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce"},{"kind":"runewaker","path":"../Runewaker/Resource/data","sha256":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9"}],"coverage":{"requested":6841,"authentic":5569,"fallback":1272,"fallbackKeys":["wow335:20647","wow335:2687","wow335:64380","wow335:20187","wow335:25781","wow335:20185","wow335:20186","wow335:5502","wow335:20184","wow335:53651","wow335:31898","wow335:31804","wow335:53733","wow335:53726","wow335:75","wow335:13481","wow335:781","wow335:34026","wow335:34754","wow335:47633","wow335:46584","wow335:26372","wow335:49278","wow335:49279","wow335:44450","wow335:34913","wow335:43043","wow335:43044","wow335:55342","wow335:29886","wow335:58889","wow335:5225","wow335:52610","ascension:705153","ascension:705154","ascension:560456","ascension:800193","ascension:561322","ascension:804138","ascension:805821","ascension:801767","ascension:707583","ascension:705160","ascension:705184","ascension:705185","ascension:804746","ascension:706346","ascension:706347","ascension:705238","ascension:92081","ascension:706354","ascension:92083","ascension:560906","ascension:707207","ascension:706326","ascension:706327","ascension:92082","ascension:705209","ascension:705210","ascension:705215","ascension:707721","ascension:705196","ascension:706804","ascension:706350","ascension:706351","ascension:705206","ascension:561314","ascension:705172","ascension:561321","ascension:705171","ascension:807861","ascension:800943","ascension:561394","ascension:705202","ascension:705241","ascension:705174","ascension:705170","ascension:705169","ascension:706353","ascension:560564","ascension:503408","ascension:503409","ascension:503410","ascension:503411","ascension:503412","ascension:503413","ascension:503414","ascension:705921","ascension:301297","ascension:572870","ascension:92085","ascension:705923","ascension:712396","ascension:802756","ascension:705906","ascension:705888","ascension:705852","ascension:681465","ascension:705889","ascension:705890","ascension:705873","ascension:806283","ascension:705849","ascension:705907","ascension:706368","ascension:705936","ascension:705937","ascension:705861","ascension:705909","ascension:503748","ascension:92084","ascension:705840","ascension:705841","ascension:92086","ascension:705911","ascension:705878","ascension:706538","ascension:300663","ascension:705903","ascension:705904","ascension:501220","ascension:501221","ascension:501222","ascension:501223","ascension:501224","ascension:501225","ascension:501226","ascension:501227","ascension:501228","ascension:705843","ascension:706482","ascension:501162","ascension:705848","ascension:705844","ascension:807296","ascension:707617","ascension:704500","ascension:560822","ascension:706266","ascension:707844","ascension:92089","ascension:681376","ascension:300465","ascension:92087","ascension:520251","ascension:705102","ascension:707842","ascension:520257","ascension:705108","ascension:804613","ascension:704607","ascension:705120","ascension:705151","ascension:92088","ascension:705105","ascension:560573","ascension:704360","ascension:301287","ascension:707513","ascension:704374","ascension:804609","ascension:300468","ascension:705128","ascension:805244","ascension:704613","ascension:524947","ascension:704612","ascension:807430","ascension:705113","ascension:705125","ascension:705133","ascension:705521","ascension:705522","ascension:705523","ascension:705498","ascension:707862","ascension:92091","ascension:680529","ascension:681484","ascension:806195","ascension:802011","ascension:705488","ascension:92094","ascension:705466","ascension:92093","ascension:705493","ascension:705454","ascension:789256","ascension:707230","ascension:706242","ascension:706243","ascension:705528","ascension:681092","ascension:804024","ascension:680253","ascension:705526","ascension:803638","ascension:705534","ascension:800165","ascension:520211","ascension:520212","ascension:520213","ascension:578297","ascension:574319","ascension:574320","ascension:807462","ascension:807887","ascension:705690","ascension:705691","ascension:92097","ascension:705675","ascension:707053","ascension:705720","ascension:705701","ascension:570722","ascension:92098","ascension:705647","ascension:300595","ascension:92096","ascension:705640","ascension:567542","ascension:705693","ascension:705641","ascension:705642","ascension:705656","ascension:705648","ascension:705649","ascension:707652","ascension:705651","ascension:705696","ascension:705697","ascension:705698","ascension:800699","ascension:807233","ascension:705659","ascension:705660","ascension:804040","ascension:705677","ascension:705689","ascension:567533","ascension:705Line truncated
@@ -1 +0,0 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/holy/act_benedictory_heal_t_down.ros","sourceSha256":"1efe9db99e088770f75bbe4f2ab3bc9498ba9eb0aa9d26b4e464a9be1af2f26a","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":false,"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/nor_phy_smashsharp.dds","sourceSha256":"4f715a3db1a4a0586affc7566a15f0643a1b3104b92a0c18349383a80dfe81e1","url":"/assets/game/combat-effects/original/runewaker/textures/4f715a3db1a4a0586affc756.png"},{"sourcePath":"texture/fx/skill/nor_speedupstar.dds","sourceSha256":"94238b7d366ba5bfd1172b7a851eba28566f70a8ac99df22ba16e3c9f038d184","url":"/assets/game/combat-effects/original/runewaker/textures/94238b7d366ba5bfd1172b7a.png"}],"motions":[{"name":"explode","id":1,"controllers":[{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"frame/holy_line","particlePath":"","emitterPath":"","frameType":1,"pathFrameType":0,"particleSystemRadius":5,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,5,0]}],[{"time":0,"value":[5,15,5]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":80},{"time":0.600000024,"value":0}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":0}],[{"time":0,"value":1}],[{"time":0,"value":1}],[{"time":0,"value":8}],[{"time":0,"value":16}],[{"time":0,"value":2}],[{"time":0,"value":4}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"frame/holy_line","texture":"texture\\fx\\skill\\nor_phy_smashsharp.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":2,"billboardAxis":3,"orientToTravel":true,"randomRotation":false,"tileSizePixels":[32,256],"frameRate":8,"frameCount":8,"quadScale":[1,1,0],"quadOffset":[0,0,0],"tracks":[[{"time":0,"value":[1,0.474509805,0.0117647061,1]},{"time":0.633333325,"value":[1,0.752941191,0.0117647061,1]}],[{"time":0,"value":1},{"time":0.633333325,"value":0.800000012}],[{"time":0,"value":0.100000001},{"time":0.300000012,"value":1.5},{"time":0.633333325,"value":0.5}],[{"time":0,"value":1}]],"textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/4f715a3db1a4a0586affc756.png"},{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"frame/star","particlePath":"","emitterPath":"","frameType":1,"pathFrameType":0,"particleSystemRadius":5,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,5,0]}],[{"time":0,"value":[4,15,4]}],[{"time":0,"value":5}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":50},{"time":0.633333325,"value":0}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":0.5}],[{"time":0,"value":1}],[{"time":0,"value":1.5}],[{"time":0,"value":3}],[{"time":0,"value":5}],[{"time":0,"value":2}],[{"time":0,"value":3}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"frame/star","texture":"texture\\fx\\skill\\nor_speedupstar.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":1,"billboardAxis":2,"orientToTravel":false,"raLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/ice/act_icecone01_c_hand.ros","sourceSha256":"fe979d958d6cedd5c5e6791c5165b2d47bac86375594477442c68daab505850d","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":true,"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/nor_ice_conering.dds","sourceSha256":"9d2e0d12c1c9f805d43ce752adcf8be464b071e570c3cbfe4a22d889da0d824f","url":"/assets/game/combat-effects/original/runewaker/textures/9d2e0d12c1c9f805d43ce752.png"},{"sourcePath":"texture/fx/skill/nor_ashpartical_snow.dds","sourceSha256":"51f2b18ce64e77b98c7299cc65f1577c9039d2682baa2fcc735991f3a3b045b8","url":"/assets/game/combat-effects/original/runewaker/textures/51f2b18ce64e77b98c7299cc.png"},{"sourcePath":"texture/fx/skill/nor_phy_smashdark.dds","sourceSha256":"a3dc99fa23017f03954e2fda7b0ab448cc70ea813c4466a7a871f4073fe0c1d3","url":"/assets/game/combat-effects/original/runewaker/textures/a3dc99fa23017f03954e2fda.png"},{"sourcePath":"texture/fx/skill/omni_light.dds","sourceSha256":"9398b7ea5b72f9b125efc831119f8cc0619749a10e8524ad7aa7e6931392bc6e","url":"/assets/game/combat-effects/original/runewaker/textures/9398b7ea5b72f9b125efc831.png"}],"motions":[{"name":"idle","id":1,"controllers":[{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"bb59dbd1_4692ad21_cbdc5198_edfe889/ad50ccba_41f7a5da_a3c055a0_d83e8ce","particlePath":"","emitterPath":"","frameType":0,"pathFrameType":1,"particleSystemRadius":20,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":1,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":0}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":60}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":3.1400001}],[{"time":0,"value":0.100000001}],[{"time":0,"value":2}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":1.64999998}],[{"time":0,"value":1.79999995}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"bb59dbd1_4692ad21_cbdc5198_edfe889/ad50ccba_41f7a5da_a3c055a0_d83e8ce","texture":"texture\\fx\\skill\\nor_ice_conering.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":1,"billboardAxis":2,"orientToTravel":true,"randomRotation":false,"tileSizePixels":[256,256],"frameRate":1,"frameCount":1,"quadScale":[1,1,0],"quadOffset":[0,0,0],"tracks":[[{"time":0,"value":[0.184313729,0.447058827,0.800000012,1]},{"time":0.300000012,"value":[0.0392156877,0.00392156886,0.607843161,1]}],[{"time":0,"value":0.200000003},{"time":0.300000012,"value":0}],[{"time":0,"value":6},{"time":0.300000012,"value":9}],[{"time":0,"value":3}]],"textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/9d2e0d12c1c9f805d43ce752.png"},{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"bb59dbd1_4692ad21_cbdc5198_edfe889/b6f1b26d_4b47c096_c8da7881_f81748af","particlePath":"","emitterPath":"","frameType":0,"pathFrameType":1,"particleSystemRadius":20,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":1,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[8,8,8]}],[{"time":0,"value":7}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":40}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":3.1400001}],[{"time":0,"value":0.5}],[{"time":0,"value":0.800000012}],[{"time":0,"value":10}],[{"time":0,"value":0}],[{"time":0Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/ice/act_icecone01_f.ros","sourceSha256":"613087503b6a2fe1b223e8e82f7797efcbd4d27034955654b6be4e5b3d283955","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":true,"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/nor_phy_smashdark.dds","sourceSha256":"a3dc99fa23017f03954e2fda7b0ab448cc70ea813c4466a7a871f4073fe0c1d3","url":"/assets/game/combat-effects/original/runewaker/textures/a3dc99fa23017f03954e2fda.png"},{"sourcePath":"texture/fx/skill/nor_icecone_crystal.dds","sourceSha256":"b7dcdd74409fb8797e52656a4f37673deca438032ca2eecdb50f842660dccd7b","url":"/assets/game/combat-effects/original/runewaker/textures/b7dcdd74409fb8797e52656a.png"},{"sourcePath":"texture/fx/skill/nor_ashpartical_snow.dds","sourceSha256":"51f2b18ce64e77b98c7299cc65f1577c9039d2682baa2fcc735991f3a3b045b8","url":"/assets/game/combat-effects/original/runewaker/textures/51f2b18ce64e77b98c7299cc.png"}],"motions":[{"name":"idle","id":1,"controllers":[{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"46d13dfd_42ec21db_21c856b3_3a9e94f3/c75f9089_4e354dea_692934a4_271e0d8c","particlePath":"","emitterPath":"","frameType":0,"pathFrameType":1,"particleSystemRadius":30,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[1,1,1]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":30}],[{"time":0,"value":[0,0,-1]}],[{"time":0,"value":0.200000003}],[{"time":0,"value":1}],[{"time":0,"value":1.5}],[{"time":0,"value":0}],[{"time":0,"value":8}],[{"time":0,"value":1}],[{"time":0,"value":2}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"46d13dfd_42ec21db_21c856b3_3a9e94f3/c75f9089_4e354dea_692934a4_271e0d8c","texture":"texture\\fx\\skill\\nor_phy_smashdark.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":1,"billboardAxis":2,"orientToTravel":false,"randomRotation":true,"tileSizePixels":[128,128],"frameRate":1,"frameCount":1,"quadScale":[1,1,0],"quadOffset":[0,0,0],"tracks":[[{"time":0,"value":[0.200000003,0.392156869,0.749019623,1]},{"time":0.5,"value":[0.313725501,0.200000003,0.768627465,1]}],[{"time":0,"value":0},{"time":0.0666666701,"value":0.5},{"time":0.5,"value":0}],[{"time":0,"value":8},{"time":0.5,"value":6}],[{"time":0,"value":60}]],"textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/a3dc99fa23017f03954e2fda.png"},{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"46d13dfd_42ec21db_21c856b3_3a9e94f3/82294ce0_46134ac5_c2e1fd82_ef41c674","particlePath":"","emitterPath":"","frameType":1,"pathFrameType":1,"particleSystemRadius":30,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[1,1,1]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":5}],[{"time":0,"value":[0,0,-1]}],[{"time":0,"value":0.5}],[{"time":0,"value":0.5}],[{"time":0,"value":1}],[{"time":0,"value":8}],[{"time":0,"value":15}],[{"time":0,"value":0.5}],[{"time":0,"value":2}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time"Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/holy/act_benedictory_heal_t_down.ros","sourceSha256":"1efe9db99e088770f75bbe4f2ab3bc9498ba9eb0aa9d26b4e464a9be1af2f26a","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":false,"partialRendererReady":true,"visualFidelity":"partial-original","omittedVisuals":["CRuEntity_Decal:terrain-projection"],"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/rune.dds","sourceSha256":"77850679b67e7a86a45fd290bf1f2658942bd641ef8987766fb241e63c3cd34a","url":"/assets/game/combat-effects/original/runewaker/textures/77850679b67e7a86a45fd290.png"},{"sourcePath":"texture/fx/skill/nor_phy_smashsharp.dds","sourceSha256":"4f715a3db1a4a0586affc7566a15f0643a1b3104b92a0c18349383a80dfe81e1","url":"/assets/game/combat-effects/original/runewaker/textures/4f715a3db1a4a0586affc756.png"},{"sourcePath":"texture/fx/skill/nor_speedupstar.dds","sourceSha256":"94238b7d366ba5bfd1172b7a851eba28566f70a8ac99df22ba16e3c9f038d184","url":"/assets/game/combat-effects/original/runewaker/textures/94238b7d366ba5bfd1172b7a.png"},{"sourcePath":"texture/fx/skill/kan_rune_circle.dds","sourceSha256":"4e6b6ae54598b182d245413a75fb47390e87de8dc32b5a6c78bf67adebe9f145","url":"/assets/game/combat-effects/original/runewaker/textures/4e6b6ae54598b182d245413a.png"}],"runeMesh":{"sourceModel":"model/fx/skill/holy/_polygon/rune_cylinder.ros","sourceSha256":"22fab52e583617550181fec6fd8844b3e20e5b293faba9198c4feb828b5a59d3","sourceTexture":"texture/fx/skill/rune.dds","textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/77850679b67e7a86a45fd290.png","positions":[7.89487362,1.21848297,-3.52884868e-8,7.87259483,-1.21848285,-1.07392617e-9,7.39781952,-1.21848297,2.69258356,7.41875458,1.21848273,2.70020342,7.89487362,1.21848297,-3.52884868e-8,7.39781952,-1.21848297,2.69258356,7.41875458,1.21848273,2.70020342,7.39781952,-1.21848297,2.69258356,6.03075886,-1.21848309,5.0604043,6.04782581,1.21848273,5.07472467,7.41875458,1.21848273,2.70020342,6.03075886,-1.21848309,5.0604043,6.04782581,1.21848273,5.07472467,6.03075886,-1.21848309,5.0604043,3.93629909,-1.21848321,6.81786585,3.94743824,1.21848273,6.83715963,6.04782581,1.21848273,5.07472467,3.93629909,-1.21848321,6.81786585,3.94743824,1.21848273,6.83715963,3.93629909,-1.21848321,6.81786585,1.36706364,-1.21848321,7.75299215,1.37093234,1.21848273,7.77493143,3.94743824,1.21848273,6.83715963,1.36706364,-1.21848321,7.75299215,1.37093234,1.21848273,7.77493143,1.36706364,-1.21848321,7.75299215,-1.36705995,-1.21848321,7.75299263,-1.37092853,1.21848273,7.77493238,1.37093234,1.21848273,7.77493143,-1.36705995,-1.21848321,7.75299263,-1.37092853,1.21848273,7.77493238,-1.36705995,-1.21848321,7.75299263,-3.93629599,-1.21848321,6.81786823,-3.9474349,1.21848273,6.83716202,-1.37092853,1.21848273,7.77493238,-3.93629599,-1.21848321,6.81786823,-3.9474349,1.21848273,6.83716202,-3.93629599,-1.21848321,6.81786823,-6.03075695,-1.21848309,5.06040764,-6.04782295,1.21848273,5.07472801,-3.9474349,1.21848273,6.83716202,-6.03075695,-1.21848309,5.06040764,-6.04782295,1.21848273,5.07472801,-6.03075695,-1.21848309,5.06040764,-7.39781857,-1.21848297,2.69258738,-7.41875362,1.21848273,2.70020723,-6.04782295,1.21848273,5.07472801,-7.39781857,-1.21848297,2.69258738,-7.41875362,1.21848273,2.70020723,-7.39781857,-1.21848297,2.69258738,-7.87259483,-1.21848285,0.00000139103497,-7.89487362,1.21848297,0.00000136076005,-7.41875362,1.21848273,2.70020723,-7.87259483,-1.21848285,0.00000139103497,-7.89487362,1.21848297,0.00000136076005,-7.87259483,-1.21848285,0.00000139103497,-7.39781952,-1.21848273,-2.69258475,-7.41875458,1.21848297,-2.70020437,-7.89487362,1.21848297,0.00000136076005,-7.39781952,-1.21848273,-2.69258475,-7.41875458,1.21848297,-2.70020437,-7.39781952,-1.21848273,-2.69258475,-6.03075886,-1.21848261,-5.06040573,-6.04782486,1.21848297,-5.07472563,-7.41875458,1.21848297,-2.70020437,-6.03075886,-1.21848261,-5.06040573,-6.04782486,1.21848297,-5.07472563,-6.03075886,-1.21848261,-5.06040573,-3.93629837,-1.21848261,-6.8178668,-3.94743752,1.21848321,-6.83716059,-6.04782486,1.21848297,-5.07472563,-3.93629837,-1.21848261,-6.8178668,-3.94743752,1.21848321Line truncated
@@ -1 +0,0 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/ice/act_icecone01_f.ros","sourceSha256":"613087503b6a2fe1b223e8e82f7797efcbd4d27034955654b6be4e5b3d283955","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":false,"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/nor_phy_smashdark.dds","sourceSha256":"a3dc99fa23017f03954e2fda7b0ab448cc70ea813c4466a7a871f4073fe0c1d3","url":"/assets/game/combat-effects/original/runewaker/textures/a3dc99fa23017f03954e2fda.png"},{"sourcePath":"texture/fx/skill/nor_icecone_crystal.dds","sourceSha256":"b7dcdd74409fb8797e52656a4f37673deca438032ca2eecdb50f842660dccd7b","url":"/assets/game/combat-effects/original/runewaker/textures/b7dcdd74409fb8797e52656a.png"},{"sourcePath":"texture/fx/skill/nor_ashpartical_snow.dds","sourceSha256":"51f2b18ce64e77b98c7299cc65f1577c9039d2682baa2fcc735991f3a3b045b8","url":"/assets/game/combat-effects/original/runewaker/textures/51f2b18ce64e77b98c7299cc.png"}],"motions":[{"name":"idle","id":1,"controllers":[{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"46d13dfd_42ec21db_21c856b3_3a9e94f3/c75f9089_4e354dea_692934a4_271e0d8c","particlePath":"","emitterPath":"","frameType":0,"pathFrameType":1,"particleSystemRadius":30,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[1,1,1]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":30}],[{"time":0,"value":[0,0,-1]}],[{"time":0,"value":0.200000003}],[{"time":0,"value":1}],[{"time":0,"value":1.5}],[{"time":0,"value":0}],[{"time":0,"value":8}],[{"time":0,"value":1}],[{"time":0,"value":2}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"46d13dfd_42ec21db_21c856b3_3a9e94f3/c75f9089_4e354dea_692934a4_271e0d8c","texture":"texture\\fx\\skill\\nor_phy_smashdark.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":1,"billboardAxis":2,"orientToTravel":false,"randomRotation":true,"tileSizePixels":[128,128],"frameRate":1,"frameCount":1,"quadScale":[1,1,0],"quadOffset":[0,0,0],"tracks":[[{"time":0,"value":[0.200000003,0.392156869,0.749019623,1]},{"time":0.5,"value":[0.313725501,0.200000003,0.768627465,1]}],[{"time":0,"value":0},{"time":0.0666666701,"value":0.5},{"time":0.5,"value":0}],[{"time":0,"value":8},{"time":0.5,"value":6}],[{"time":0,"value":60}]],"textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/a3dc99fa23017f03954e2fda.png"},{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"46d13dfd_42ec21db_21c856b3_3a9e94f3/82294ce0_46134ac5_c2e1fd82_ef41c674","particlePath":"","emitterPath":"","frameType":1,"pathFrameType":1,"particleSystemRadius":30,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[1,1,1]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":5}],[{"time":0,"value":[0,0,-1]}],[{"time":0,"value":0.5}],[{"time":0,"value":0.5}],[{"time":0,"value":1}],[{"time":0,"value":8}],[{"time":0,"value":15}],[{"time":0,"value":0.5}],[{"time":0,"value":2}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"timeLine truncated
@@ -1 +0,0 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/ice/act_icecone01_c_hand.ros","sourceSha256":"fe979d958d6cedd5c5e6791c5165b2d47bac86375594477442c68daab505850d","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":false,"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/nor_ice_conering.dds","sourceSha256":"9d2e0d12c1c9f805d43ce752adcf8be464b071e570c3cbfe4a22d889da0d824f","url":"/assets/game/combat-effects/original/runewaker/textures/9d2e0d12c1c9f805d43ce752.png"},{"sourcePath":"texture/fx/skill/nor_ashpartical_snow.dds","sourceSha256":"51f2b18ce64e77b98c7299cc65f1577c9039d2682baa2fcc735991f3a3b045b8","url":"/assets/game/combat-effects/original/runewaker/textures/51f2b18ce64e77b98c7299cc.png"},{"sourcePath":"texture/fx/skill/nor_phy_smashdark.dds","sourceSha256":"a3dc99fa23017f03954e2fda7b0ab448cc70ea813c4466a7a871f4073fe0c1d3","url":"/assets/game/combat-effects/original/runewaker/textures/a3dc99fa23017f03954e2fda.png"},{"sourcePath":"texture/fx/skill/omni_light.dds","sourceSha256":"9398b7ea5b72f9b125efc831119f8cc0619749a10e8524ad7aa7e6931392bc6e","url":"/assets/game/combat-effects/original/runewaker/textures/9398b7ea5b72f9b125efc831.png"}],"motions":[{"name":"idle","id":1,"controllers":[{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"bb59dbd1_4692ad21_cbdc5198_edfe889/ad50ccba_41f7a5da_a3c055a0_d83e8ce","particlePath":"","emitterPath":"","frameType":0,"pathFrameType":1,"particleSystemRadius":20,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":1,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":0}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":60}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":3.1400001}],[{"time":0,"value":0.100000001}],[{"time":0,"value":2}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":1.64999998}],[{"time":0,"value":1.79999995}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"bb59dbd1_4692ad21_cbdc5198_edfe889/ad50ccba_41f7a5da_a3c055a0_d83e8ce","texture":"texture\\fx\\skill\\nor_ice_conering.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":1,"billboardAxis":2,"orientToTravel":true,"randomRotation":false,"tileSizePixels":[256,256],"frameRate":1,"frameCount":1,"quadScale":[1,1,0],"quadOffset":[0,0,0],"tracks":[[{"time":0,"value":[0.184313729,0.447058827,0.800000012,1]},{"time":0.300000012,"value":[0.0392156877,0.00392156886,0.607843161,1]}],[{"time":0,"value":0.200000003},{"time":0.300000012,"value":0}],[{"time":0,"value":6},{"time":0.300000012,"value":9}],[{"time":0,"value":3}]],"textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/9d2e0d12c1c9f805d43ce752.png"},{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"bb59dbd1_4692ad21_cbdc5198_edfe889/b6f1b26d_4b47c096_c8da7881_f81748af","particlePath":"","emitterPath":"","frameType":0,"pathFrameType":1,"particleSystemRadius":20,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":1,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[8,8,8]}],[{"time":0,"value":7}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":40}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":3.1400001}],[{"time":0,"value":0.5}],[{"time":0,"value":0.800000012}],[{"time":0,"value":10}],[{"time":0,"value":0}],[{"time":Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"kind":"runewaker-ros-particle-archive","source":"runewaker","sourceModel":"model/fx/skill/ice/act_riptide_t_freeze_hit.ros","sourceSha256":"2430ee30dddf6465007963fb22d5b52c7b212a51ae219e7dfc0c7894f9a28beb","coordinateTransform":"three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)","rendererReady":true,"shader":{"name":"ruSHADER_PARTICLE_SPRITEEX","sourceImplementation":"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp","sourceSha256":"fd40fe72aa310b7b5fe7cffa3d0afe6552e9dfcbdc8a7cc361c697a686317502","vertexSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl","pixelSourceUrl":"/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl","target":"Direct3D 9 source; WebGL sprite port for verified models","twoSided":true,"fog":false,"premultipliedOutput":true,"blendModes":{"0":"source-alpha / one-minus-source-alpha","1":"source-alpha / one","2":"zero / one-minus-source-alpha","3":"one / one-minus-source-alpha","4":"one / one","5":"one / zero"}},"textures":[{"sourcePath":"texture/fx/skill/nor_ice_icebuffwave.dds","sourceSha256":"50c646fc7b1f5f3da13a39b1936d07ab3ada3dd27fe86e5529bc0ca12783a7ab","url":"/assets/game/combat-effects/original/runewaker/textures/50c646fc7b1f5f3da13a39b1.png"},{"sourcePath":"texture/fx/skill/nor_ashpartical_snow.dds","sourceSha256":"51f2b18ce64e77b98c7299cc65f1577c9039d2682baa2fcc735991f3a3b045b8","url":"/assets/game/combat-effects/original/runewaker/textures/51f2b18ce64e77b98c7299cc.png"},{"sourcePath":"texture/fx/skill/nor_phy_line.dds","sourceSha256":"7f87e16f9b839ee1b74444ee89de398ee4e477e5109a8c6df0f2334a188365c8","url":"/assets/game/combat-effects/original/runewaker/textures/7f87e16f9b839ee1b74444ee.png"},{"sourcePath":"texture/fx/environment/nor_spiderdarkwave.dds","sourceSha256":"b97c5c77fd98393fa0554884d2a0de5f5144e085967c5a55f1c63eedf1f2e154","url":"/assets/game/combat-effects/original/runewaker/textures/b97c5c77fd98393fa0554884.png"},{"sourcePath":"texture/fx/skill/kan_ice_riptide.dds","sourceSha256":"f4a11fa1ae15a2c72f9a01d301410084fd2b83fb967a931b85a03e6a8592ddeb","url":"/assets/game/combat-effects/original/runewaker/textures/f4a11fa1ae15a2c72f9a01d3.png"}],"motions":[{"name":"explode","id":1,"controllers":[{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"act_/blue_wave","particlePath":"","emitterPath":"","frameType":1,"pathFrameType":0,"particleSystemRadius":10,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":5}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[3,3,3]}],[{"time":0,"value":3}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":200},{"time":0.166666672,"value":0}],[{"time":0,"value":[0,1,0]}],[{"time":0,"value":0.200000003}],[{"time":0,"value":0.300000012}],[{"time":0,"value":0.800000012}],[{"time":0,"value":5}],[{"time":0,"value":8}],[{"time":0,"value":0.300000012}],[{"time":0,"value":1.20000005}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}]]},{"type":"sprite","sourceType":"CRuController_Particle_Visualizer_Sprite","anchor":"act_/blue_wave","texture":"texture\\fx\\skill\\nor_ice_icebuffwave.dds","blendMode":1,"shader":"ruSHADER_PARTICLE_SPRITEEX","allowWorldRotation":false,"orientation":3,"billboardType":1,"billboardAxis":2,"orientToTravel":true,"randomRotation":false,"tileSizePixels":[128,64],"frameRate":16,"frameCount":16,"quadScale":[1,1,0],"quadOffset":[0,0,0],"tracks":[[{"time":0,"value":[0.619607866,1,0.839215696,1]},{"time":0.633333325,"value":[0.00784313772,0.694117665,1,1]}],[{"time":0,"value":1},{"time":0.533333361,"value":1},{"time":0.633333325,"value":0}],[{"time":0,"value":5},{"time":0.633333325,"value":10}],[{"time":0,"value":0}]],"textureAssetUrl":"/assets/game/combat-effects/original/runewaker/textures/50c646fc7b1f5f3da13a39b1.png"},{"type":"emitter","sourceType":"CRuController_Particle_Emitter","anchor":"act_/spot","particlePath":"","emitterPath":"","frameType":1,"pathFrameType":0,"particleSystemRadius":10,"pathRandomMirror":false,"pathRandomMirrorPlane":1,"pathRotationAxis":[0,1,0],"pathRotationDeviation":0,"pathLoopDelay":-1,"emitterPathFrameType":1,"emitterPathLoopDelay":-1,"emitterOrientation":6,"prerunTime":0,"tracks":[[{"time":0,"value":200}],[{"time":0,"value":1}],[{"time":0,"value":[0,0,0]}],[{"time":0,"value":[3,3,3]}],[{"time":0,"value":20}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":0}],[{"time":0,"value":800},{"time":0.0666666701,"value":0}],[{"time":0,"valLine truncated
@@ -2,10 +2,10 @@
"schemaVersion": 1, "schemaVersion": 1,
"kind": "runewaker-ros-particle-archive-index", "kind": "runewaker-ros-particle-archive-index",
"scope": "primary-five-base-and-accepted-elite-pairs", "scope": "primary-five-base-and-accepted-elite-pairs",
"requestedModels": 123, "requestedModels": 124,
"processedModels": 123, "processedModels": 124,
"decodedModels": 7, "decodedModels": 7,
"partialModels": 116, "partialModels": 117,
"failedModels": 0, "failedModels": 0,
"results": [ "results": [
{ {
@@ -13,6 +13,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e090fa17cd49978789f86bed.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e090fa17cd49978789f86bed.json",
"rendererReady": true, "rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 2, "textures": 2,
"unsupported": [], "unsupported": [],
@@ -23,6 +24,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/be7720da98e84828a0b1827f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/be7720da98e84828a0b1827f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 5, "spriteControllers": 5,
"textures": 5, "textures": 5,
"unsupported": [], "unsupported": [],
@@ -33,6 +35,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5667b51ea910c1de4dacaf8a.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5667b51ea910c1de4dacaf8a.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 12, "spriteControllers": 12,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -45,6 +48,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/75f3f97797399a0026c01826.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/75f3f97797399a0026c01826.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -58,6 +62,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/df83b93fb75960770e4b3ce3.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/df83b93fb75960770e4b3ce3.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -74,6 +79,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d554b42ef95e9fb1fe0c46eb.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d554b42ef95e9fb1fe0c46eb.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -86,6 +92,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/234804e24d630cd4415098b5.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/234804e24d630cd4415098b5.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -98,6 +105,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/08ba946c69fa4ac89a060721.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/08ba946c69fa4ac89a060721.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -127,6 +135,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/98d77f21ff7601057a4b54f4.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/98d77f21ff7601057a4b54f4.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [], "unsupported": [],
@@ -137,6 +146,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d642cb85ed13f0e7546e7d3c.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d642cb85ed13f0e7546e7d3c.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -149,6 +159,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/19ca1b3e4494cc63cb5d3ffb.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/19ca1b3e4494cc63cb5d3ffb.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -162,6 +173,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/964170a45dd885e8df9b3a5a.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/964170a45dd885e8df9b3a5a.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -179,6 +191,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e0a8cd28646454303b835809.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e0a8cd28646454303b835809.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -192,6 +205,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a968041acc9bfde2fecf6266.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a968041acc9bfde2fecf6266.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -204,6 +218,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/311db29ae86c62a0d2eeefde.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/311db29ae86c62a0d2eeefde.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -218,6 +233,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c65961e29ce5f8c285277dc5.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c65961e29ce5f8c285277dc5.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -231,6 +247,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/bf45f9834ef19e5316be6c30.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/bf45f9834ef19e5316be6c30.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -248,6 +265,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a5fec4274fa4fbf4668db02a.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a5fec4274fa4fbf4668db02a.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [], "unsupported": [],
@@ -258,6 +276,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/220d99f74771f4321740fbb9.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/220d99f74771f4321740fbb9.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -271,6 +290,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c5b1129be66e9e390625390e.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c5b1129be66e9e390625390e.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -286,6 +306,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6908daf7bf70c351132272b1.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6908daf7bf70c351132272b1.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -300,6 +321,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/0807b166970705e58f968d45.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/0807b166970705e58f968d45.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -315,6 +337,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2ffc388ef09ff800215ebd0e.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2ffc388ef09ff800215ebd0e.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 12, "spriteControllers": 12,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -340,6 +363,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d9448a6cf58b272d6d9cb9f4.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d9448a6cf58b272d6d9cb9f4.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -352,6 +376,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e20230ec22dd7b39a99302b1.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e20230ec22dd7b39a99302b1.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -365,6 +390,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f62e735344d47fb11fb7c2b7.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f62e735344d47fb11fb7c2b7.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 12, "spriteControllers": 12,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -392,6 +418,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d1e9c74e62571a6bd143de26.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d1e9c74e62571a6bd143de26.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -407,6 +434,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e82f8aaaefb40c56a0de1ea1.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e82f8aaaefb40c56a0de1ea1.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 8, "spriteControllers": 8,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -419,6 +447,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/457e148f32619f5247ab8126.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/457e148f32619f5247ab8126.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -438,6 +467,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/1c4f95bffc77e44e5fe75645.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/1c4f95bffc77e44e5fe75645.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -451,6 +481,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/222d1e6e4fb9ec37b02429cc.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/222d1e6e4fb9ec37b02429cc.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -466,6 +497,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3df82cc72cd0f66f7b519837.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3df82cc72cd0f66f7b519837.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -484,6 +516,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6f9db1c88b41781a99090beb.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6f9db1c88b41781a99090beb.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 8, "spriteControllers": 8,
"textures": 6, "textures": 6,
"unsupported": [ "unsupported": [
@@ -495,17 +528,13 @@
{ {
"model": "model/fx/skill/holy/act_benedictory_heal_t_down.ros", "model": "model/fx/skill/holy/act_benedictory_heal_t_down.ros",
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/25d68c4251779ce20dcde306.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/8dd9bcacd0f5feb65bd52d12.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": true,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 4,
"unsupported": [ "unsupported": [
"CRuController_Particle_Visualizer_Entity", "CRuController_EventTrigger"
"CRuController_Decal",
"CRuController_GlobalAlpha",
"CRuController_GlobalColor",
"CRuController_EventTrigger",
"CRuEntity_Particle_Visualizer_Entity"
], ],
"errors": [] "errors": []
}, },
@@ -514,6 +543,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/071cd6773fd37ff2e7515339.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/071cd6773fd37ff2e7515339.json",
"rendererReady": true, "rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -526,6 +556,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/10af3c42129f27ce6263a939.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/10af3c42129f27ce6263a939.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -540,6 +571,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a361cf0ed24660b34cfc3ac8.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a361cf0ed24660b34cfc3ac8.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -563,6 +595,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4641d64b13f793b2ec59e643.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4641d64b13f793b2ec59e643.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 6, "textures": 6,
"unsupported": [ "unsupported": [
@@ -575,6 +608,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6aeae5be261a170e0ab0370e.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6aeae5be261a170e0ab0370e.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -592,6 +626,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ca746c2b3912631c36fab531.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ca746c2b3912631c36fab531.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -607,6 +642,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/14e269d87e267f206977be8d.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/14e269d87e267f206977be8d.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -619,6 +655,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4457e8ae70e688822f82391d.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4457e8ae70e688822f82391d.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -635,6 +672,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/1c668eabb9743489beeaa540.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/1c668eabb9743489beeaa540.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 5, "spriteControllers": 5,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -647,6 +685,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/9ee131c9295b43c6c4ca86cb.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/9ee131c9295b43c6c4ca86cb.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -660,6 +699,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/00f57b27bcae002a2038340f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/00f57b27bcae002a2038340f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -672,6 +712,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3791aabbd98daa86506bf171.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3791aabbd98daa86506bf171.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -693,6 +734,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f28d5759608237d617592a88.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f28d5759608237d617592a88.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 4, "spriteControllers": 4,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -705,6 +747,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b7d3389e6a44b45b25f2c2eb.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b7d3389e6a44b45b25f2c2eb.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -717,6 +760,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/af5802c9c9076a1cd04d5b70.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/af5802c9c9076a1cd04d5b70.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -740,6 +784,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a37c01cb17215879fde73a63.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a37c01cb17215879fde73a63.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -757,6 +802,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c44a59e78c845ef412c20ec9.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c44a59e78c845ef412c20ec9.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -772,6 +818,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5227647ab51e90334d89a027.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5227647ab51e90334d89a027.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [], "unsupported": [],
@@ -784,6 +831,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ac064aee3422b89ca306d025.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ac064aee3422b89ca306d025.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -804,6 +852,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/cc20b6b82565466c677ea89b.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/cc20b6b82565466c677ea89b.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -818,6 +867,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/7fd85f9680e249402c7565f1.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/7fd85f9680e249402c7565f1.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -832,6 +882,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a3db8cd9d570b85474e770bc.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a3db8cd9d570b85474e770bc.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -844,6 +895,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6388a9b5f8641e2886b2a7e4.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6388a9b5f8641e2886b2a7e4.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -860,6 +912,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/853a0f487435665db20ec94f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/853a0f487435665db20ec94f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -878,6 +931,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/17d8fd9116f380b960f325c7.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/17d8fd9116f380b960f325c7.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -895,6 +949,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e14c2bde166ae71d8d83c8ff.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e14c2bde166ae71d8d83c8ff.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 6, "textures": 6,
"unsupported": [ "unsupported": [
@@ -907,6 +962,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ac821669047622c5b864f2a1.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ac821669047622c5b864f2a1.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 12, "spriteControllers": 12,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -923,6 +979,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/397cc94366a4c51a0eac9cde.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/397cc94366a4c51a0eac9cde.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -938,6 +995,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a6e6b78af72cb1da00922227.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a6e6b78af72cb1da00922227.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -952,6 +1010,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f1171bd6cd1c8dab4c858cd3.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f1171bd6cd1c8dab4c858cd3.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -974,25 +1033,24 @@
{ {
"model": "model/fx/skill/ice/act_icecone01_c_hand.ros", "model": "model/fx/skill/ice/act_icecone01_c_hand.ros",
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d34ce38236573a68c02be790.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4fcf73ba81bbc515a4030544.json",
"rendererReady": false, "rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 12, "spriteControllers": 12,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
"CRuController_EventTrigger", "CRuController_EventTrigger",
"CRuController_GlobalAlpha",
"CRuController_EventTrigger", "CRuController_EventTrigger",
"CRuController_GlobalAlpha", "CRuController_EventTrigger"
"CRuController_EventTrigger",
"CRuController_GlobalAlpha"
], ],
"errors": [] "errors": []
}, },
{ {
"model": "model/fx/skill/ice/act_icecone01_f.ros", "model": "model/fx/skill/ice/act_icecone01_f.ros",
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/ae8ce52b6c50e63ed754cf05.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6dd522645757b1e3694ffe21.json",
"rendererReady": false, "rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1003,11 +1061,26 @@
], ],
"errors": [] "errors": []
}, },
{
"model": "model/fx/skill/ice/act_riptide_t_freeze_hit.ros",
"status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f8fbeadb894fc795581b10dc.json",
"rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 5,
"textures": 5,
"unsupported": [
"CRuController_EventTrigger",
"CRuController_EventTrigger"
],
"errors": []
},
{ {
"model": "model/fx/skill/ice/act_watershieldbuff_c_down_i.ros", "model": "model/fx/skill/ice/act_watershieldbuff_c_down_i.ros",
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/87fb3880b296a0a845cb685d.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/87fb3880b296a0a845cb685d.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1020,6 +1093,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/649470027a4d64510456992f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/649470027a4d64510456992f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1033,6 +1107,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/200c43e790e87a46151f84a5.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/200c43e790e87a46151f84a5.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1050,6 +1125,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b58e1b6ac19262c9d9dd5f1e.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b58e1b6ac19262c9d9dd5f1e.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 4, "spriteControllers": 4,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1062,6 +1138,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/29d3ca5b6a88ee337f042a71.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/29d3ca5b6a88ee337f042a71.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 5, "spriteControllers": 5,
"textures": 5, "textures": 5,
"unsupported": [ "unsupported": [
@@ -1082,6 +1159,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/1e08eb44ba367623a88dd177.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/1e08eb44ba367623a88dd177.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -1110,6 +1188,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5638bf5b5482728086cfc7c3.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5638bf5b5482728086cfc7c3.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1122,6 +1201,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6fb78e867275d92f3dd38847.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/6fb78e867275d92f3dd38847.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 5, "spriteControllers": 5,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -1134,6 +1214,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c63eae7a48188be0c35ce7e1.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c63eae7a48188be0c35ce7e1.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -1155,6 +1236,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c8e4797a43a2450f46d6a129.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c8e4797a43a2450f46d6a129.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 7, "spriteControllers": 7,
"textures": 7, "textures": 7,
"unsupported": [ "unsupported": [
@@ -1167,6 +1249,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4f4d90292dc1f65f7f258bf5.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4f4d90292dc1f65f7f258bf5.json",
"rendererReady": true, "rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [], "unsupported": [],
@@ -1177,6 +1260,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/291291de3f5aefd9f77c73e5.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/291291de3f5aefd9f77c73e5.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1189,6 +1273,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/95df9f350c80e9b29097aa09.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/95df9f350c80e9b29097aa09.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1201,6 +1286,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c7a0f4a1c0f45c3bf5a5b61a.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c7a0f4a1c0f45c3bf5a5b61a.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1228,6 +1314,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4ab81c20c7d74958eb0fbf7f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4ab81c20c7d74958eb0fbf7f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1240,6 +1327,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/aaf151fa3ab8bdcf7ecc2e16.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/aaf151fa3ab8bdcf7ecc2e16.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -1252,6 +1340,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2ee082809dc47463907a939d.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2ee082809dc47463907a939d.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -1269,6 +1358,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/153a0df93c81f185faf0324f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/153a0df93c81f185faf0324f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1298,6 +1388,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2c3d9de6125356d81bfae69c.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2c3d9de6125356d81bfae69c.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1310,6 +1401,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/bd5dda56795f3cec82845fe2.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/bd5dda56795f3cec82845fe2.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 4, "spriteControllers": 4,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1324,6 +1416,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4041e8f84f7358ee8cc1f4d2.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/4041e8f84f7358ee8cc1f4d2.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 4, "spriteControllers": 4,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -1336,6 +1429,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d582e3355c5286158bc630e4.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/d582e3355c5286158bc630e4.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1348,6 +1442,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/97f42badd33077485f0cb98b.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/97f42badd33077485f0cb98b.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1362,6 +1457,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f2bf53cac27ef98cc1c3d838.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/f2bf53cac27ef98cc1c3d838.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1376,6 +1472,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/7b7dd5fc47d1526fcedf2754.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/7b7dd5fc47d1526fcedf2754.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1398,6 +1495,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/66079f115ab86eaa5fbb25f3.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/66079f115ab86eaa5fbb25f3.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1413,6 +1511,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c948963bdd612885ba84aac2.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c948963bdd612885ba84aac2.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1427,6 +1526,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/921d0f3ecbe91bb50ff64891.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/921d0f3ecbe91bb50ff64891.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -1441,6 +1541,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2b6d12cdd3a57031e5d80a2a.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/2b6d12cdd3a57031e5d80a2a.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1455,6 +1556,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/579ccbc92277d9d328a1b7d0.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/579ccbc92277d9d328a1b7d0.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -1471,6 +1573,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/550823913cd3bf13c2528a50.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/550823913cd3bf13c2528a50.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1483,6 +1586,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c28603666865b9072cfc39d4.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/c28603666865b9072cfc39d4.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 5, "spriteControllers": 5,
"textures": 5, "textures": 5,
"unsupported": [ "unsupported": [
@@ -1498,6 +1602,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/df1153babc2a1dc8613c3a27.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/df1153babc2a1dc8613c3a27.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1517,6 +1622,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/89588a8fe7751e72fa3ec75b.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/89588a8fe7751e72fa3ec75b.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 7, "spriteControllers": 7,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -1532,6 +1638,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/72d97a3126effcaefb7441b8.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/72d97a3126effcaefb7441b8.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1559,6 +1666,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/0804e6ee485816e00a3e1791.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/0804e6ee485816e00a3e1791.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1571,6 +1679,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/74e3fbb84c9c01bb1834f4bb.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/74e3fbb84c9c01bb1834f4bb.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1583,6 +1692,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b4f7d91d19f4fdebc0fe4cab.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b4f7d91d19f4fdebc0fe4cab.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -1601,6 +1711,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/89d7847fa450541be3862291.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/89d7847fa450541be3862291.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1617,6 +1728,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3b965f7767599e23d4ebb063.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3b965f7767599e23d4ebb063.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 7, "spriteControllers": 7,
"textures": 4, "textures": 4,
"unsupported": [ "unsupported": [
@@ -1634,6 +1746,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3654b65dcf248481abbc0c2f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/3654b65dcf248481abbc0c2f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -1647,6 +1760,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/03ca38ff0cf39c2242af78b5.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/03ca38ff0cf39c2242af78b5.json",
"rendererReady": true, "rendererReady": true,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [], "unsupported": [],
@@ -1657,6 +1771,7 @@
"status": "decoded", "status": "decoded",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/cc95ace4dd031e20d10d0c5e.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/cc95ace4dd031e20d10d0c5e.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [], "unsupported": [],
@@ -1667,6 +1782,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/849ccde11d4b5dce1f7655f9.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/849ccde11d4b5dce1f7655f9.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 9, "spriteControllers": 9,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1681,6 +1797,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/9c2532ca3f7f0a1960479749.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/9c2532ca3f7f0a1960479749.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1693,6 +1810,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/877aae7a3cdac75c62e43d25.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/877aae7a3cdac75c62e43d25.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -1716,6 +1834,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b7622027e0c79d8dae26dbb9.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b7622027e0c79d8dae26dbb9.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1728,6 +1847,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/0dadf58f017714709a5d78a9.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/0dadf58f017714709a5d78a9.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 2, "spriteControllers": 2,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1740,6 +1860,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/71355ca795b31a2cce23759f.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/71355ca795b31a2cce23759f.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1753,6 +1874,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5b4f7a78e35eb553af907493.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/5b4f7a78e35eb553af907493.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 0, "spriteControllers": 0,
"textures": 0, "textures": 0,
"unsupported": [ "unsupported": [
@@ -1768,6 +1890,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b34256bda32c5cef54b3769b.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/b34256bda32c5cef54b3769b.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 6, "spriteControllers": 6,
"textures": 2, "textures": 2,
"unsupported": [ "unsupported": [
@@ -1783,6 +1906,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/9358501ce9313ad42b5999b3.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/9358501ce9313ad42b5999b3.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1795,6 +1919,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/beea16afdaa6f5d9fcb8e867.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/beea16afdaa6f5d9fcb8e867.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
@@ -1822,6 +1947,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a565d331ff4c490ee376a023.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a565d331ff4c490ee376a023.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 3, "spriteControllers": 3,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1835,6 +1961,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a4f4974cefc640fd0f893884.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/a4f4974cefc640fd0f893884.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 8, "spriteControllers": 8,
"textures": 5, "textures": 5,
"unsupported": [ "unsupported": [
@@ -1847,6 +1974,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/de99a8f0ba46257cc6038cc9.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/de99a8f0ba46257cc6038cc9.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 15, "spriteControllers": 15,
"textures": 3, "textures": 3,
"unsupported": [ "unsupported": [
@@ -1866,6 +1994,7 @@
"status": "partial", "status": "partial",
"graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e91dbe72d224789b0512aaf4.json", "graphUrl": "/assets/game/combat-effects/original/runewaker/graphs/e91dbe72d224789b0512aaf4.json",
"rendererReady": false, "rendererReady": false,
"partialRendererReady": false,
"spriteControllers": 1, "spriteControllers": 1,
"textures": 1, "textures": 1,
"unsupported": [ "unsupported": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+30 -5
View File
@@ -54,9 +54,9 @@ retain the verified media-pack download requirement.
their sound cues without rebuilding WoW effects. It reads spell-to-image links their sound cues without rebuilding WoW effects. It reads spell-to-image links
from `romPlayerCatalog.generated.json` and `magiccollectobject.db`, effect model from `romPlayerCatalog.generated.json` and `magiccollectobject.db`, effect model
paths from `imageobject.db`, and exact same-name cues under the client's paths from `imageobject.db`, and exact same-name cues under the client's
parallel `sound/fx` tree. The sound association is a path match, not an parallel `sound/fx` tree. Verified ROS audio events supply cues when their
explicit sound ID from `imageobject.db`. Each cue is tagged with its source embedded path differs from the mirrored model path. Each cue is tagged with
cast phase so a target impact does not replay a caster sound. its source cast phase so a target impact does not replay a caster sound.
`npm run effects:rom:particles` packages a decoded ROS particle archive for the `npm run effects:rom:particles` packages a decoded ROS particle archive for the
five primary classes and their accepted elite pairs. Build the native exporter five primary classes and their accepted elite pairs. Build the native exporter
@@ -75,8 +75,9 @@ tile sizes are pixels, not atlas row and column counts.
`npm run effects:rom:audit` writes the first-five class and accepted-pair map `npm run effects:rom:audit` writes the first-five class and accepted-pair map
to `docs/ROM_SPELL_MEDIA_MAPPING.md`. It distinguishes source ROS references, to `docs/ROM_SPELL_MEDIA_MAPPING.md`. It distinguishes source ROS references,
packaged audio, and browser-playable original visual graphs. The WebGL sprite packaged audio, and browser-playable original visual graphs. The WebGL sprite
renderer plays Priest Urgent Heal, Heal, and Group Heal casts, Knight Charge renderer plays Priest Urgent Heal, Heal, and Group Heal casts; Urgent Heal's
cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The exact target impact; Rising Tide cast, release, and impact; Knight Charge cast and
Whirlwind Shield impact; and Mage Electric Bolt projectile. The exact
spell, phase, and model allowlist lives in `rom-particle-readiness.mjs`. Other spell, phase, and model allowlist lives in `rom-particle-readiness.mjs`. Other
ROS phases remain in the archive until their controller families and motion ROS phases remain in the archive until their controller families and motion
sequence are checked in the browser. Open `scripts/combat-effects/rom-preview.html` sequence are checked in the browser. Open `scripts/combat-effects/rom-preview.html`
@@ -94,6 +95,30 @@ parameters. Its packaged WAV loops only during the matching Priest cast and
stops on release or cancellation. Other unsupported event triggers still stops on release or cancellation. Other unsupported event triggers still
block a graph from the ready allowlist. block a graph from the ready allowlist.
Urgent Heal's target effect is linked through MagicCollect spell `491147` to
MagicObject component `501543`, whose ImageObject `573048` supplies
`ExtendACT=model/fx/skill/holy/act_benedictory_heal_t_down.ros` at `p_down`.
The client creates the component's `ExtendACT` on the target after the heal
resolves. Its `explode` motion contains two original sprite layers (holy line
and gold stars), a nested `rune_cylinder.ros` particle entity with source rune
texture, a `kan_rune_circle.dds` ground decal, and a one-shot direct 3D audio
event for `sound/fx/skill/holy/act_benedictory_heal_t_down.mp3`. The browser
plays the two sprite layers and source rune mesh emission, and animates the
decal radius, rotation, color, and alpha. The decal lies on a level plane;
the RuneWaker client projects it onto collision triangles. The graph remains
`partial-original` for that terrain projection difference while replacing
the generated target filler.
Rising Tide spell `490256` uses ImageObject `572061` for
`act_icecone01_c_hand.ros` at cast start and `act_icecone01_f.ros` on release.
MagicObject component `620408` links ImageObject `572062`, whose target
`ExtendACT` is `act_riptide_t_freeze_hit.ros` at `freeze_hit`. The source
audio events name the projectile cue
`sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3` and target cue
`sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3`; the cast hand has its
mirrored WAV. The verified models are attached to their respective combat
presentation phases after their controller support and source motion checks.
## Rendering ## Rendering
The `wow-m2-model` format composes source particles, animated/skinned geometry, The `wow-m2-model` format composes source particles, animated/skinned geometry,
+14 -5
View File
@@ -17,6 +17,10 @@ const cohorts = [
["Mage", "rom-mage", "rom-warlock"], ["Mage", "rom-mage", "rom-warlock"],
]; ];
const phases = ["cast-start", "release", "impact", "tick", "aura-start", "aura-end"]; const phases = ["cast-start", "release", "impact", "tick", "aura-start", "aura-end"];
const phaseOrder = (phase) => {
const index = phases.indexOf(phase);
return index < 0 ? phases.length : index;
};
function idsForSkill(skill) { function idsForSkill(skill) {
return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])] return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])]
@@ -27,10 +31,15 @@ function skillMedia(skill) {
const ids = idsForSkill(skill); const ids = idsForSkill(skill);
const mapped = ids.map((id) => entries.get(id)).filter(Boolean); const mapped = ids.map((id) => entries.get(id)).filter(Boolean);
const models = [...new Set(mapped.flatMap((entry) => phases.flatMap((phase) => const models = [...new Set(mapped.flatMap((entry) => phases.flatMap((phase) =>
(entry.phases?.[phase] ?? []).map((asset) => `${phase}: ${path.posix.basename(asset.model)}`))))]; (entry.phases?.[phase] ?? []).map((asset) => (
`${phase}: ${path.posix.basename(asset.model)}`
+ (asset.visualCompleteness === "partial" ? " (partial original)" : "")
)))))];
const sounds = [...new Set(mapped.flatMap((entry) => (entry.sounds ?? []) const sounds = [...new Set(mapped.flatMap((entry) => (entry.sounds ?? [])
.filter((sound) => sound.url) .filter((sound) => sound.url)
.map((sound) => `${sound.phase ?? "any"}: ${path.posix.basename(sound.sourcePath)}`)))]; .map((sound) => `${sound.phase ?? "any"}: ${path.posix.basename(sound.sourcePath)}`)))].sort((left, right) => (
phaseOrder(left.split(":", 1)[0]) - phaseOrder(right.split(":", 1)[0])
));
const playableVisuals = mapped.flatMap((entry) => Object.values(entry.phases ?? {}).flat()) const playableVisuals = mapped.flatMap((entry) => Object.values(entry.phases ?? {}).flat())
.filter((asset) => asset.effectGraphUrl).length; .filter((asset) => asset.effectGraphUrl).length;
for (const entry of mapped) { for (const entry of mapped) {
@@ -75,7 +84,7 @@ const lines = [
"", "",
"Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.", "Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.",
"", "",
`The effect column records original client ROS/RAS references and their cast phase. ${summary.reduce((total, row) => total + row.playableVisuals, 0)} castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. The sound column lists client cues that are packaged and played on their mapped phase. A dash means the source record or matching sound file did not provide that media.`, `The effect column records original client ROS/RAS references and their cast phase. ${summary.reduce((total, row) => total + row.playableVisuals, 0)} castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. A \`partial original\` phase renders verified source layers and declares its remaining fidelity limit. The sound column lists packaged client cues on their mapped phase. A dash means the source record or matching sound file did not provide that media.`,
"", "",
"| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |", "| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |",
"| --- | --- | ---: | ---: | ---: | ---: |", "| --- | --- | ---: | ---: | ---: | ---: |",
@@ -85,12 +94,12 @@ const lines = [
"## Decoded particle archive", "## Decoded particle archive",
"", "",
particleArchive particleArchive
? `The particle archive covers ${particleArchive.processedModels} referenced ROS models, with ${particleArchive.decodedModels} fully decoded models and ${particleArchive.partialModels} partial models. ${particleArchive.results.filter((result) => result.rendererReady).length} models are renderer-ready, including the Priest holy cast whose remaining EventTrigger is an audio callback. It exports ${particleArchive.results.reduce((sum, result) => sum + result.spriteControllers, 0)} sprite controllers and ${new Set(particleArchive.results.flatMap((result) => result.graphUrl ? JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")).textures.map((texture) => texture.url) : [])).size} distinct PNG textures. See \`public/assets/game/combat-effects/original/runewaker/manifest.json\` for per-model details.` ? `The particle archive covers ${particleArchive.processedModels} referenced ROS models, with ${particleArchive.decodedModels} fully decoded models and ${particleArchive.partialModels} partial models. ${particleArchive.results.filter((result) => result.rendererReady).length} models are renderer-ready and ${particleArchive.results.filter((result) => result.partialRendererReady).length} has a browser-playable partial original. The Priest holy cast's remaining EventTrigger is an audio callback; Urgent Heal's target renders its source sprites, emitted rune mesh, and animated ground decal. Its terrain-triangle decal projection remains approximated by a level plane. The archive exports ${particleArchive.results.reduce((sum, result) => sum + result.spriteControllers, 0)} sprite controllers and ${new Set(particleArchive.results.flatMap((result) => result.graphUrl ? JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")).textures.map((texture) => texture.url) : [])).size} distinct PNG textures. See \`public/assets/game/combat-effects/original/runewaker/manifest.json\` for per-model details.`
: "The RuneWaker particle archive has not been packaged.", : "The RuneWaker particle archive has not been packaged.",
"", "",
"## Next conversion step", "## Next conversion step",
"", "",
"The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting, Knight Charge cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The Priest holy model's EventTrigger starts a looping 3D WAV and is handled by phase-matched combat audio while its three sprite layers render. Next, verify more spell phases against the source motion and controller semantics before attaching their `effectGraphUrl`. The Direct3D source and packaged ROS data remain the reference for those ports.", "The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting; Urgent Heal's target impact with its source rune mesh and decal; Rising Tide's cast, projectile release, and target impact; Knight Charge cast and Whirlwind Shield impact; and Mage Electric Bolt projectile. Urgent Heal's holy cast EventTrigger starts a looping 3D WAV, and its target impact triggers the source benedictory heal MP3. Rising Tide retains its packaged cast WAV and now plays its ROS-linked projectile release and target impact MP3s. Impact cues use the target position. Urgent Heal's decal uses a level plane in place of the client's terrain projection. The Direct3D source and packaged ROS data remain the reference for subsequent phases.",
]; ];
const markdown = `${lines.join("\n").trimEnd()}\n`; const markdown = `${lines.join("\n").trimEnd()}\n`;
if (process.argv.includes("--write")) { if (process.argv.includes("--write")) {
+109 -7
View File
@@ -534,6 +534,38 @@ export function runewakerSoundPathsForModel(modelPath, resourceRoot) {
return []; return [];
} }
// These one-shot cues are direct 3D audio events (ID 43) in the named ROS
// motions. The client places the impact models through the skill's MagicObject
// component image, while the projectile model comes from ImageObject.DB.
// The projectile's embedded cue differs from the parallel sound/fx filename.
const verifiedRuneWakerEventSounds = [
{
spellId: 491147, phase: "impact",
model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros",
sourcePath: "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3",
},
{
spellId: 490256, phase: "release",
model: "model/fx/skill/ice/act_icecone01_f.ros",
sourcePath: "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3",
},
{
spellId: 490256, phase: "impact",
model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros",
sourcePath: "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3",
},
];
function attachVerifiedRuneWakerEventSounds(entries) {
const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
for (const { spellId, phase, model, sourcePath } of verifiedRuneWakerEventSounds) {
const entry = bySpellId.get(spellId);
if (!entry?.phases?.[phase]?.some((asset) => asset.model?.toLowerCase() === model)) continue;
if (entry.sounds?.some((sound) => sound.phase === phase && sound.sourcePath?.toLowerCase() === sourcePath)) continue;
(entry.sounds ??= []).push({ sourcePath, phase, weight: 1, volume: 1 });
}
}
function runewakerImageIdsFromMagicCollect(buffer, spellIds) { function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
const count = buffer.readInt32LE(132); const count = buffer.readInt32LE(132);
const recordSize = 1032; const recordSize = 1032;
@@ -548,27 +580,82 @@ function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
return result; return result;
} }
// The cast's MagicCollect ImageID omits the target-side ExtendACT. These two
// component links are verified against MagicObject.ImageID and ImageObject.DB
// before the target model is added to the combat presentation manifest.
const verifiedRuneWakerTargetEffects = [
{ spellId: 491147, componentId: 501543, imageId: 573048,
model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", point: "p_down" },
{ spellId: 490256, componentId: 620408, imageId: 572062,
model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros", point: "freeze_hit" },
];
function verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, spellIds) {
const wanted = verifiedRuneWakerTargetEffects.filter((effect) => spellIds.includes(effect.spellId));
if (!wanted.length) return new Map();
const collectOffsets = new Map();
for (let offset = 140; offset + 1032 <= magicCollect.length; offset += 1032) {
const spellId = magicCollect.readInt32LE(offset);
if (wanted.some((effect) => effect.spellId === spellId)) collectOffsets.set(spellId, offset);
}
const componentImages = new Map();
for (let offset = 140; offset + 992 <= magicObject.length; offset += 992) {
const componentId = magicObject.readInt32LE(offset);
if (wanted.some((effect) => effect.componentId === componentId)) {
componentImages.set(componentId, magicObject.readInt32LE(offset + 0x20));
}
}
const componentImageFields = new Map();
for (let offset = 140; offset + 3712 <= imageObject.length; offset += 3712) {
const imageId = imageObject.readInt32LE(offset);
if (!wanted.some((effect) => effect.imageId === imageId)) continue;
const textField = (fieldOffset, length) => imageObject.subarray(
offset + fieldOffset, offset + fieldOffset + length,
).toString("latin1").split("\0", 1)[0].replaceAll("\\", "/").toLowerCase();
componentImageFields.set(imageId, {
extendAct: textField(2132, 128),
extendPoint: textField(2260, 32),
});
}
for (const effect of wanted) {
const collectOffset = collectOffsets.get(effect.spellId);
const componentIds = collectOffset === undefined ? [] : Array.from(
{ length: (0x2c0 - 0x180) / 4 + 1 }, (_, index) =>
magicCollect.readInt32LE(collectOffset + 0x180 + index * 4),
);
if (!componentIds.includes(effect.componentId)
|| componentImages.get(effect.componentId) !== effect.imageId
|| componentImageFields.get(effect.imageId)?.extendAct !== effect.model
|| componentImageFields.get(effect.imageId)?.extendPoint !== effect.point) {
throw new Error(`RuneWaker target media source link changed for spell ${effect.spellId}`);
}
}
return new Map(wanted.map((effect) => [effect.spellId, effect]));
}
function attachVerifiedRuneWakerParticles(entries) { function attachVerifiedRuneWakerParticles(entries) {
const archivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json"); const archivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
if (!fs.existsSync(archivePath)) return; if (!fs.existsSync(archivePath)) return;
const archive = JSON.parse(fs.readFileSync(archivePath, "utf8")); const archive = JSON.parse(fs.readFileSync(archivePath, "utf8"));
const decoded = new Map((archive.results ?? []) const decoded = new Map((archive.results ?? [])
.filter((result) => result.rendererReady && result.graphUrl) .filter((result) => (result.rendererReady || result.partialRendererReady) && result.graphUrl)
.map((result) => [result.model.toLowerCase(), result])); .map((result) => [result.model.toLowerCase(), result]));
const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry])); const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
for (const { spellId, phase, model, scale, loopCastSound } of verifiedRuneWakerParticlePhases) { for (const { spellId, phase, model, scale, loopCastSound, partialVisual } of verifiedRuneWakerParticlePhases) {
const entry = bySpellId.get(spellId); const entry = bySpellId.get(spellId);
const asset = entry?.phases?.[phase]?.find((candidate) => ( const asset = entry?.phases?.[phase]?.find((candidate) => (
candidate.model.toLowerCase() === model candidate.model.toLowerCase() === model
)); ));
const result = decoded.get(model); const result = decoded.get(model);
if (!asset || !result) continue; if (!asset || !result || (partialVisual ? !result.partialRendererReady : !result.rendererReady)) continue;
const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1)); const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1));
if (!fs.existsSync(graphPath)) continue; if (!fs.existsSync(graphPath)) continue;
const graph = JSON.parse(fs.readFileSync(graphPath, "utf8")); const graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
if (graph.sourceModel !== model || !graph.rendererReady) continue; if (graph.sourceModel !== model
|| (partialVisual ? !graph.partialRendererReady : !graph.rendererReady)) continue;
asset.effectKind = "runewaker-particles"; asset.effectKind = "runewaker-particles";
asset.effectGraphUrl = result.graphUrl; asset.effectGraphUrl = result.graphUrl;
if (partialVisual) asset.visualCompleteness = "partial";
if (scale) asset.scale = scale; if (scale) asset.scale = scale;
if (loopCastSound) for (const sound of entry.sounds ?? []) { if (loopCastSound) for (const sound of entry.sounds ?? []) {
if (sound.phase === "cast-start" if (sound.phase === "cast-start"
@@ -582,15 +669,29 @@ function attachVerifiedRuneWakerParticles(entries) {
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) { function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
const resourceRoot = path.dirname(dataRoot); const resourceRoot = path.dirname(dataRoot);
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db")); const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
const magicObject = fs.readFileSync(path.join(dataRoot, "magicobject.db"));
const imageObject = fs.readFileSync(path.join(dataRoot, "imageobject.db"));
const targetEffects = verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, spellIds);
const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds); const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds);
for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId); for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId);
const requestedImages = [...new Set(spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean))]; const requestedImages = [...new Set([
const images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages); ...spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean),
...[...targetEffects.values()].map((effect) => effect.imageId),
])];
const images = parseRunewakerImageRecords(imageObject, requestedImages);
const entries = []; const entries = [];
for (const spellId of spellIds) { for (const spellId of spellIds) {
const imageId = imageBySpellId.get(spellId); const imageId = imageBySpellId.get(spellId);
if (!imageId) continue; if (!imageId) continue;
const paths = images.get(imageId) ?? []; const targetEffect = targetEffects.get(spellId);
const targetImagePaths = targetEffect ? images.get(targetEffect.imageId) ?? [] : [];
const targetModel = targetEffect && targetImagePaths.find((candidate) => (
candidate.toLowerCase() === targetEffect.model
));
if (targetEffect && !targetModel) {
throw new Error(`RuneWaker target ImageObject changed for spell ${spellId}`);
}
const paths = [...(images.get(imageId) ?? []), ...(targetModel ? [targetModel] : [])];
const phases = {}; const phases = {};
const sounds = []; const sounds = [];
for (const sourcePath of paths) { for (const sourcePath of paths) {
@@ -628,6 +729,7 @@ function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}), ...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
}); });
} }
attachVerifiedRuneWakerEventSounds(entries);
attachVerifiedRuneWakerParticles(entries); attachVerifiedRuneWakerParticles(entries);
return entries; return entries;
} }
@@ -353,12 +353,52 @@ test("primary-five RuneWaker spells retain source phases and packaged sound cues
assert.ok(entry(490495).sounds.some((sound) => sound.sourcePath === "sound/fx/skill/holy/act_godshield_c_down.mp3")); assert.ok(entry(490495).sounds.some((sound) => sound.sourcePath === "sound/fx/skill/holy/act_godshield_c_down.mp3"));
}); });
test("Priest target and projectile sounds follow their verified ROS event phases", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const archive = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json"), "utf8"));
for (const { spellId, phase, motion, model, sourcePath } of [
{
spellId: 491147, phase: "impact", motion: "explode",
model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros",
sourcePath: "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3",
},
{
spellId: 490256, phase: "release", motion: "appear",
model: "model/fx/skill/ice/act_icecone01_f.ros",
sourcePath: "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3",
},
{
spellId: 490256, phase: "impact", motion: "explode",
model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros",
sourcePath: "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3",
},
]) {
const entry = manifest.entries.find((candidate) => candidate.key === `runewaker:${spellId}`);
assert.ok(entry?.phases?.[phase]?.some((asset) => asset.model === model));
const cues = entry.sounds.filter((sound) => sound.phase === phase && sound.sourcePath === sourcePath);
assert.equal(cues.length, 1);
assert.ok(cues[0].url?.startsWith("/assets/game/combat-effects/audio/"));
assert.ok(fs.existsSync(path.join(projectRoot, "public", cues[0].url.slice(1))));
const result = archive.results.find((candidate) => candidate.model === model);
assert.ok(result?.graphUrl);
const graph = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8"));
assert.ok(graph.motions.some((candidate) => candidate.name === motion
&& candidate.controllers.some((controller) => controller.sourceType === "CRuController_EventTrigger"
&& controller.eventID === 43 && controller.eventParameter0 === 0
&& controller.eventString?.replaceAll("\\", "/").toLowerCase() === sourcePath)));
}
});
test("only browser-verified RuneWaker particle phases use original graphs", () => { test("only browser-verified RuneWaker particle phases use original graphs", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8")); const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const expected = new Set([ const expected = new Set([
"491147:cast-start:model/fx/skill/holy/act_casting_c_hand.ros", "491147:cast-start:model/fx/skill/holy/act_casting_c_hand.ros",
"491147:impact:model/fx/skill/holy/act_benedictory_heal_t_down.ros",
"490261:cast-start:model/fx/skill/holy/act_casting_c_hand.ros", "490261:cast-start:model/fx/skill/holy/act_casting_c_hand.ros",
"490281:cast-start:model/fx/skill/holy/act_casting_c_hand.ros", "490281:cast-start:model/fx/skill/holy/act_casting_c_hand.ros",
"490256:cast-start:model/fx/skill/ice/act_icecone01_c_hand.ros",
"490256:release:model/fx/skill/ice/act_icecone01_f.ros",
"490256:impact:model/fx/skill/ice/act_riptide_t_freeze_hit.ros",
"490150:cast-start:model/fx/skill/physical/act_charge02_c_down.ros", "490150:cast-start:model/fx/skill/physical/act_charge02_c_down.ros",
"490175:impact:model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros", "490175:impact:model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros",
"491168:release:model/fx/skill/wind/act_electric_ball_f_i.ros", "491168:release:model/fx/skill/wind/act_electric_ball_f_i.ros",
@@ -372,7 +412,14 @@ test("only browser-verified RuneWaker particle phases use original graphs", () =
assert.equal(asset.effectKind, "runewaker-particles"); assert.equal(asset.effectKind, "runewaker-particles");
if (entry.spellId === 490175) assert.equal(asset.scale, 2.5); if (entry.spellId === 490175) assert.equal(asset.scale, 2.5);
const graph = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", asset.effectGraphUrl.slice(1)), "utf8")); const graph = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", asset.effectGraphUrl.slice(1)), "utf8"));
assert.equal(graph.rendererReady, true); if (key === "491147:impact:model/fx/skill/holy/act_benedictory_heal_t_down.ros") {
assert.equal(asset.visualCompleteness, "partial");
assert.equal(graph.partialRendererReady, true);
assert.equal(graph.rendererReady, false);
} else {
assert.equal(asset.visualCompleteness, undefined);
assert.equal(graph.rendererReady, true);
}
assert.equal(graph.sourceModel, asset.model); assert.equal(graph.sourceModel, asset.model);
} }
} }
@@ -92,6 +92,17 @@ function sourceFile(resourcePath) {
const textureCache = new Map(); const textureCache = new Map();
const HOLY_CAST_MODEL = "model/fx/skill/holy/act_casting_c_hand.ros"; const HOLY_CAST_MODEL = "model/fx/skill/holy/act_casting_c_hand.ros";
const HOLY_CAST_SOUND = "sound/fx/skill/holy/act_casting_c_hand.wav"; const HOLY_CAST_SOUND = "sound/fx/skill/holy/act_casting_c_hand.wav";
const ICE_CAST_MODEL = "model/fx/skill/ice/act_icecone01_c_hand.ros";
const ICE_RELEASE_MODEL = "model/fx/skill/ice/act_icecone01_f.ros";
const RIPTIDE_IMPACT_MODEL = "model/fx/skill/ice/act_riptide_t_freeze_hit.ros";
const URGENT_IMPACT_MODEL = "model/fx/skill/holy/act_benedictory_heal_t_down.ros";
const URGENT_RUNE_MODEL = "model/fx/skill/holy/_polygon/rune_cylinder.ros";
const URGENT_RUNE_TEXTURE = "texture/fx/skill/rune.dds";
const URGENT_DECAL_TEXTURE = "texture/fx/skill/kan_rune_circle.dds";
function normalizedEventString(controller) {
return controller.eventString?.replaceAll("\\", "/").toLowerCase();
}
function visualOnlyHolyCastTrigger(model, motion, controller) { function visualOnlyHolyCastTrigger(model, motion, controller) {
return model === HOLY_CAST_MODEL && motion.name === "appear" return model === HOLY_CAST_MODEL && motion.name === "appear"
@@ -100,11 +111,121 @@ function visualOnlyHolyCastTrigger(model, motion, controller) {
&& controller.eventID === 43 // ruENTITYEVENT_AUDIO_3D_DIRECT && controller.eventID === 43 // ruENTITYEVENT_AUDIO_3D_DIRECT
&& controller.eventParameter0 === 1 // Loop during cast. && controller.eventParameter0 === 1 // Loop during cast.
&& controller.eventParameter1 === 0 && controller.eventParameter1 === 0
&& controller.eventString?.replaceAll("\\", "/").toLowerCase() === HOLY_CAST_SOUND && normalizedEventString(controller) === HOLY_CAST_SOUND
&& controller.startTime === 0 && controller.startTime === 0
&& Number.isFinite(controller.eventDuration) && controller.eventDuration > 0; && Number.isFinite(controller.eventDuration) && controller.eventDuration > 0;
} }
function visualOnlyRisingTideTrigger(model, motion, controller) {
if (controller.type !== "unsupported" || controller.sourceType !== "CRuController_EventTrigger"
|| controller.startTime !== 0 || controller.eventParameter1 !== 0) return false;
const eventString = normalizedEventString(controller);
if (model === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(motion.name)) {
return controller.eventID === 43 && controller.eventParameter0 === 1
&& eventString === "sound/fx/skill/ice/act_cone01_c_hand.mp3"
&& Number.isFinite(controller.eventDuration) && controller.eventDuration > 0;
}
if (model === ICE_RELEASE_MODEL && ["idle", "appear", "disappear"].includes(motion.name)) {
return controller.eventID === 44 && controller.eventParameter0 === 1
&& eventString === "missile" && controller.eventDuration === 1
|| motion.name === "appear" && controller.eventID === 43
&& controller.eventParameter0 === 0 && controller.eventDuration === 0
&& eventString === "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3";
}
if (model === RIPTIDE_IMPACT_MODEL && motion.name === "explode") {
// The source HIT callback is handled by combat; it contributes no visual geometry.
return controller.eventID === 1 && controller.eventParameter0 === 0
&& controller.eventDuration === 0 && eventString === ""
|| controller.eventID === 43 && controller.eventParameter0 === 0
&& controller.eventDuration === 0
&& eventString === "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3";
}
return false;
}
function verifiedGlobalAlpha(model, motion, controller) {
if (controller.type !== "global-alpha" || controller.sourceType !== "CRuController_GlobalAlpha"
|| controller.startTime !== 0 || !Number.isFinite(controller.duration)
|| !Array.isArray(controller.alphaTrack)
|| !controller.alphaTrack.every((key) => Number.isFinite(key.time)
&& Number.isFinite(key.value) && key.value >= 0 && key.value <= 1)) return false;
if (model === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(motion.name)) {
return motion.name === "appear"
? controller.duration > 0 && controller.alphaTrack.length === 2
&& controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 0
&& controller.alphaTrack[1].value === 1
: controller.duration === 0 && controller.alphaTrack.length === 1
&& controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 1;
}
if (model === URGENT_IMPACT_MODEL && motion.name === "explode") {
return controller.anchor === "frame/decal" && controller.duration > 0
&& controller.alphaTrack.length === 4
&& controller.alphaTrack[0].value === 0 && controller.alphaTrack[1].value === 1
&& controller.alphaTrack[2].value === 1 && controller.alphaTrack[3].value === 0;
}
return model === RIPTIDE_IMPACT_MODEL && motion.name === "explode"
&& controller.duration === 0 && controller.alphaTrack.length === 1
&& controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 1;
}
const expectedSpecialControllerCounts = new Map([
[HOLY_CAST_MODEL, [1, 0]],
[ICE_CAST_MODEL, [3, 3]],
[ICE_RELEASE_MODEL, [4, 0]],
[RIPTIDE_IMPACT_MODEL, [2, 1]],
]);
// The source projects onto nearby world triangles. The browser draws this
// same animated texture on a target-ground plane until terrain projection is ported.
const urgentOmittedVisuals = ["CRuEntity_Decal:terrain-projection"];
function verifiedUrgentPartial(model, decoded, result, unsupportedControllers, globalAlphaControllers, runeMesh) {
if (model !== URGENT_IMPACT_MODEL || decoded.motions.length !== 1
|| decoded.motions[0].name !== "explode" || result.spriteControllers !== 2
|| globalAlphaControllers.length !== 1 || unsupportedControllers.length !== 1
|| decoded.unsupported.length !== 1
|| decoded.unsupported[0] !== "CRuEntity_Particle_Visualizer_Entity"
|| runeMesh?.sourceModel !== URGENT_RUNE_MODEL
|| runeMesh.sourceTexture !== URGENT_RUNE_TEXTURE
|| runeMesh.positions.length !== 72 * 9
|| runeMesh.uvs.length !== 72 * 6) return false;
const controllers = decoded.motions[0].controllers;
const sprites = controllers.filter((controller) => controller.type === "sprite");
const emitters = controllers.filter((controller) => controller.type === "emitter");
const entity = controllers.find((controller) => controller.type === "entity-visualizer");
const decal = controllers.find((controller) => controller.type === "decal");
const color = controllers.find((controller) => controller.type === "global-color");
const expectedSprites = new Map([
["frame/holy_line", "texture/fx/skill/nor_phy_smashsharp.dds"],
["frame/star", "texture/fx/skill/nor_speedupstar.dds"],
]);
if (sprites.length !== 2 || emitters.length !== 3
|| !sprites.every((sprite) => expectedSprites.get(sprite.anchor)
=== sprite.texture?.replaceAll("\\", "/").toLowerCase())
|| !["frame/holy_line", "frame/star", "frame/rune"].every((anchor) =>
emitters.some((emitter) => emitter.anchor === anchor))) return false;
const trigger = unsupportedControllers[0].controller;
if (trigger.sourceType !== "CRuController_EventTrigger" || trigger.anchor !== "frame"
|| trigger.eventID !== 43 || trigger.eventParameter0 !== 0 || trigger.eventParameter1 !== 0
|| trigger.startTime !== 0 || trigger.eventDuration !== 0
|| normalizedEventString(trigger) !== "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3") return false;
if (entity?.anchor !== "frame/rune" || entity.sourceType !== "CRuController_Particle_Visualizer_Entity"
|| entity.entityName?.replaceAll("\\", "/").toLowerCase() !== URGENT_RUNE_MODEL
|| entity.orientToTravel !== false || entity.tracks?.length !== 4
|| !entity.tracks.every((track) => Array.isArray(track) && track.length > 0)
|| decal?.anchor !== "frame/decal" || decal.sourceType !== "CRuController_Decal"
|| decal.decalTexture?.replaceAll("\\", "/").toLowerCase() !== URGENT_DECAL_TEXTURE
|| !decal.textureAssetUrl || decal.billboardType !== 0 || decal.tracks?.length !== 3
|| decal.tracks[1]?.length !== 2 || decal.tracks[2]?.length !== 3
|| color?.anchor !== "frame/decal" || color.sourceType !== "CRuController_GlobalColor"
|| color.tracks?.length !== 1 || color.tracks[0]?.length !== 2) return false;
const alpha = globalAlphaControllers[0].controller;
const unsupportedTypes = new Set(result.unsupported);
return alpha.anchor === "frame/decal" && alpha.sourceType === "CRuController_GlobalAlpha"
&& alpha.alphaTrack?.length === 4 && alpha.startTime === 0
&& unsupportedTypes.size === 1 && unsupportedTypes.has("CRuController_EventTrigger");
}
function packageTexture(resourcePath, scratch) { function packageTexture(resourcePath, scratch) {
const normalized = resourcePath.replaceAll("\\", "/").toLowerCase(); const normalized = resourcePath.replaceAll("\\", "/").toLowerCase();
if (textureCache.has(normalized)) return textureCache.get(normalized); if (textureCache.has(normalized)) return textureCache.get(normalized);
@@ -132,11 +253,63 @@ function packageTexture(resourcePath, scratch) {
return asset; return asset;
} }
function packageUrgentRuneMesh(scratch) {
const directory = path.join(scratch, "urgent-rune-mesh");
fs.mkdirSync(directory, { recursive: true });
execFileSync(exporter, [resourceRoot, URGENT_RUNE_MODEL, directory], {
cwd: root, stdio: "pipe", maxBuffer: 8 * 1024 * 1024,
});
const report = JSON.parse(fs.readFileSync(path.join(directory, "export-report.json"), "utf8"));
const material = report.materials?.[0];
if (report.stats?.meshes !== 1 || report.stats?.triangles !== 72
|| report.stats?.missingTextures !== 0
|| material?.resolvedTexture?.replaceAll("\\", "/").toLowerCase() !== URGENT_RUNE_TEXTURE) {
throw new Error("Urgent Heal rune cylinder source mesh changed");
}
const vertices = [];
const textureCoordinates = [];
const positions = [];
const uvs = [];
for (const line of fs.readFileSync(path.join(directory, "scene.obj"), "utf8").split(/\r?\n/)) {
if (line.startsWith("v ")) vertices.push(line.trim().split(/\s+/).slice(1).map(Number));
else if (line.startsWith("vt ")) textureCoordinates.push(line.trim().split(/\s+/).slice(1).map(Number));
else if (line.startsWith("f ")) {
const corners = line.trim().split(/\s+/).slice(1);
if (corners.length !== 3) throw new Error("Urgent rune mesh has a non-triangle face");
for (const corner of corners) {
const [vertexIndex, uvIndex] = corner.split("/").map(Number);
const vertex = vertices[vertexIndex - 1];
const uv = textureCoordinates[uvIndex - 1];
if (vertex?.length !== 3 || uv?.length !== 2
|| ![...vertex, ...uv].every(Number.isFinite)) {
throw new Error("Urgent rune mesh has invalid vertex data");
}
positions.push(...vertex);
uvs.push(...uv);
}
}
}
if (positions.length !== 72 * 9 || uvs.length !== 72 * 6) {
throw new Error("Urgent rune mesh triangle count changed");
}
const texture = packageTexture(material.resolvedTexture, scratch);
return {
sourceModel: URGENT_RUNE_MODEL,
sourceSha256: sha256(fs.readFileSync(sourceFile(URGENT_RUNE_MODEL))),
sourceTexture: URGENT_RUNE_TEXTURE,
textureAssetUrl: texture.url,
positions,
uvs,
texture,
};
}
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "healer-rom-fx-")); const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "healer-rom-fx-"));
const results = []; const results = [];
try { try {
for (const model of selectedModels) { for (const model of selectedModels) {
const result = { model, status: "failed", graphUrl: null, rendererReady: false, const result = { model, status: "failed", graphUrl: null, rendererReady: false,
partialRendererReady: false,
spriteControllers: 0, textures: 0, unsupported: [], errors: [] }; spriteControllers: 0, textures: 0, unsupported: [], errors: [] };
results.push(result); results.push(result);
try { try {
@@ -151,8 +324,37 @@ try {
throw new Error(`Unexpected native particle report schema for ${model}`); throw new Error(`Unexpected native particle report schema for ${model}`);
} }
const textures = new Map(); const textures = new Map();
const runeMesh = model === URGENT_IMPACT_MODEL ? packageUrgentRuneMesh(scratch) : null;
if (runeMesh) textures.set(runeMesh.texture.sourcePath.toLowerCase(), runeMesh.texture);
for (const motion of decoded.motions) for (const controller of motion.controllers) { for (const motion of decoded.motions) for (const controller of motion.controllers) {
if (controller.type === "unsupported") result.unsupported.push(controller.sourceType); if (model !== URGENT_IMPACT_MODEL
&& ["entity-visualizer", "decal", "global-color"].includes(controller.type)) {
controller.type = "unsupported";
delete controller.tracks;
delete controller.textureAssetUrl;
}
if (model !== URGENT_IMPACT_MODEL && controller.type === "unsupported") {
delete controller.entityName;
delete controller.decalTexture;
delete controller.orientToTravel;
delete controller.billboardType;
}
if (controller.type === "global-alpha"
&& ![ICE_CAST_MODEL, RIPTIDE_IMPACT_MODEL, URGENT_IMPACT_MODEL].includes(model)) {
controller.type = "unsupported";
delete controller.startTime;
delete controller.duration;
delete controller.alphaTrack;
}
if (controller.type === "unsupported"
|| controller.type === "global-alpha" && !verifiedGlobalAlpha(model, motion, controller)) {
result.unsupported.push(controller.sourceType);
}
if (model === URGENT_IMPACT_MODEL && controller.type === "decal") {
const texture = packageTexture(controller.decalTexture, scratch);
controller.textureAssetUrl = texture.url;
textures.set(texture.sourcePath.toLowerCase(), texture);
}
if (controller.type !== "sprite") continue; if (controller.type !== "sprite") continue;
result.spriteControllers++; result.spriteControllers++;
if (!controller.texture) { if (!controller.texture) {
@@ -168,7 +370,9 @@ try {
} }
} }
if (result.spriteControllers === 0) result.errors.push("No sprite controllers decoded"); if (result.spriteControllers === 0) result.errors.push("No sprite controllers decoded");
result.unsupported.push(...decoded.unsupported); result.unsupported.push(...decoded.unsupported.filter((type) => !(
model === URGENT_IMPACT_MODEL && type === "CRuEntity_Particle_Visualizer_Entity" && runeMesh
)));
result.textures = textures.size; result.textures = textures.size;
result.status = result.errors.length || result.unsupported.length ? "partial" : "decoded"; result.status = result.errors.length || result.unsupported.length ? "partial" : "decoded";
const unsupportedControllers = decoded.motions.flatMap((motion) => motion.controllers const unsupportedControllers = decoded.motions.flatMap((motion) => motion.controllers
@@ -176,14 +380,26 @@ try {
.map((controller) => ({ motion, controller }))); .map((controller) => ({ motion, controller })));
// The holy casting graph's sole non-particle controller is a 3D WAV // The holy casting graph's sole non-particle controller is a 3D WAV
// callback. Its sprite layers are complete; audio is packaged separately. // callback. Its sprite layers are complete; audio is packaged separately.
const audioOnlyTrigger = unsupportedControllers.length === 1 const globalAlphaControllers = decoded.motions.flatMap((motion) => motion.controllers
.filter((controller) => controller.type === "global-alpha")
.map((controller) => ({ motion, controller })));
const [expectedTriggers, expectedAlpha] = expectedSpecialControllerCounts.get(model) ?? [0, 0];
const visualOnlyControllers = unsupportedControllers.length === expectedTriggers
&& globalAlphaControllers.length === expectedAlpha
&& unsupportedControllers.every(({ motion, controller }) => ( && unsupportedControllers.every(({ motion, controller }) => (
visualOnlyHolyCastTrigger(model, motion, controller) visualOnlyHolyCastTrigger(model, motion, controller)
|| visualOnlyRisingTideTrigger(model, motion, controller)
))
&& globalAlphaControllers.every(({ motion, controller }) => (
verifiedGlobalAlpha(model, motion, controller)
)) ))
&& result.unsupported.every((type) => type === "CRuController_EventTrigger"); && result.unsupported.every((type) => type === "CRuController_EventTrigger");
result.rendererReady = verifiedRuneWakerParticleModels.has(model) result.rendererReady = verifiedRuneWakerParticleModels.has(model)
&& result.errors.length === 0 && result.errors.length === 0
&& (result.unsupported.length === 0 || audioOnlyTrigger); && visualOnlyControllers;
result.partialRendererReady = verifiedRuneWakerParticleModels.has(model)
&& result.errors.length === 0
&& verifiedUrgentPartial(model, decoded, result, unsupportedControllers, globalAlphaControllers, runeMesh);
const graph = { const graph = {
schemaVersion: 1, schemaVersion: 1,
kind: "runewaker-ros-particle-archive", kind: "runewaker-ros-particle-archive",
@@ -192,6 +408,11 @@ try {
sourceSha256, sourceSha256,
coordinateTransform: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)", coordinateTransform: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
rendererReady: result.rendererReady, rendererReady: result.rendererReady,
...(result.partialRendererReady ? {
partialRendererReady: true,
visualFidelity: "partial-original",
omittedVisuals: urgentOmittedVisuals,
} : {}),
shader: { shader: {
name: "ruSHADER_PARTICLE_SPRITEEX", name: "ruSHADER_PARTICLE_SPRITEEX",
sourceImplementation: "Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp", sourceImplementation: "Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp",
@@ -212,6 +433,14 @@ try {
}, },
}, },
textures: [...textures.values()], textures: [...textures.values()],
...(runeMesh ? { runeMesh: {
sourceModel: runeMesh.sourceModel,
sourceSha256: runeMesh.sourceSha256,
sourceTexture: runeMesh.sourceTexture,
textureAssetUrl: runeMesh.textureAssetUrl,
positions: runeMesh.positions,
uvs: runeMesh.uvs,
} } : {}),
motions: decoded.motions, motions: decoded.motions,
sprites: decoded.sprites, sprites: decoded.sprites,
unsupported: [...new Set(result.unsupported)], unsupported: [...new Set(result.unsupported)],
@@ -18,7 +18,7 @@ function assetFile(url) {
} }
test("primary-five RuneWaker particle archive resolves its decoded graphs and textures", () => { test("primary-five RuneWaker particle archive resolves its decoded graphs and textures", () => {
assert.equal(index.requestedModels, 123); assert.equal(index.requestedModels, 124);
assert.equal(index.processedModels, index.requestedModels); assert.equal(index.processedModels, index.requestedModels);
assert.equal(index.failedModels, 0); assert.equal(index.failedModels, 0);
assert.equal(index.results.length, index.requestedModels); assert.equal(index.results.length, index.requestedModels);
@@ -30,7 +30,9 @@ test("primary-five RuneWaker particle archive resolves its decoded graphs and te
assert.equal(graph.kind, "runewaker-ros-particle-archive"); assert.equal(graph.kind, "runewaker-ros-particle-archive");
assert.equal(graph.sourceModel, result.model); assert.equal(graph.sourceModel, result.model);
assert.equal(graph.rendererReady, result.rendererReady); assert.equal(graph.rendererReady, result.rendererReady);
assert.equal(Boolean(graph.partialRendererReady), result.partialRendererReady);
assert.equal(result.rendererReady, verifiedRuneWakerParticleModels.has(result.model) assert.equal(result.rendererReady, verifiedRuneWakerParticleModels.has(result.model)
&& !result.partialRendererReady
&& result.errors.length === 0 && result.errors.length === 0
&& result.unsupported.every((type) => type === "CRuController_EventTrigger")); && result.unsupported.every((type) => type === "CRuController_EventTrigger"));
assert.equal(graph.shader.name, "ruSHADER_PARTICLE_SPRITEEX"); assert.equal(graph.shader.name, "ruSHADER_PARTICLE_SPRITEEX");
@@ -56,6 +58,37 @@ test("primary-five RuneWaker particle archive resolves its decoded graphs and te
if (controller.type === "emitter") assert.equal(controller.tracks.length, 27); if (controller.type === "emitter") assert.equal(controller.tracks.length, 27);
} }
} }
assert.equal(spriteControllers, 510); assert.equal(spriteControllers, 515);
assert.ok(textureReferences >= 103); assert.ok(textureReferences >= 104);
});
test("Rising Tide ships three original renderable phases and Urgent Heal declares its partial target layers", () => {
for (const model of [
"model/fx/skill/ice/act_icecone01_c_hand.ros",
"model/fx/skill/ice/act_icecone01_f.ros",
"model/fx/skill/ice/act_riptide_t_freeze_hit.ros",
]) {
const result = index.results.find((entry) => entry.model === model);
assert.equal(result?.rendererReady, true, model);
assert.equal(result?.partialRendererReady, false, model);
}
const urgent = index.results.find((entry) =>
entry.model === "model/fx/skill/holy/act_benedictory_heal_t_down.ros");
assert.equal(urgent?.rendererReady, false);
assert.equal(urgent?.partialRendererReady, true);
assert.equal(urgent?.spriteControllers, 2);
const graph = JSON.parse(fs.readFileSync(assetFile(urgent.graphUrl), "utf8"));
assert.equal(graph.visualFidelity, "partial-original");
assert.deepEqual(graph.omittedVisuals, ["CRuEntity_Decal:terrain-projection"]);
assert.equal(graph.runeMesh.sourceModel, "model/fx/skill/holy/_polygon/rune_cylinder.ros");
assert.equal(graph.runeMesh.positions.length, 72 * 9);
assert.equal(graph.runeMesh.uvs.length, 72 * 6);
assert.ok(graph.textures.some((texture) => texture.url === graph.runeMesh.textureAssetUrl));
const impactControllers = graph.motions.find((motion) => motion.name === "explode").controllers;
assert.ok(impactControllers.some((controller) => controller.type === "entity-visualizer"));
const decal = impactControllers.find((controller) => controller.type === "decal");
assert.ok(decal);
assert.ok(graph.textures.some((texture) => texture.url === decal.textureAssetUrl));
assert.ok(impactControllers.some((controller) => controller.type === "global-color"));
assert.ok(impactControllers.some((controller) => controller.type === "global-alpha"));
}); });
@@ -1,7 +1,11 @@
export const verifiedRuneWakerParticlePhases = [ export const verifiedRuneWakerParticlePhases = [
{ spellId: 491147, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true }, { spellId: 491147, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true },
{ spellId: 491147, phase: "impact", model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", partialVisual: true },
{ spellId: 490261, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true }, { spellId: 490261, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true },
{ spellId: 490281, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true }, { spellId: 490281, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true },
{ spellId: 490256, phase: "cast-start", model: "model/fx/skill/ice/act_icecone01_c_hand.ros" },
{ spellId: 490256, phase: "release", model: "model/fx/skill/ice/act_icecone01_f.ros" },
{ spellId: 490256, phase: "impact", model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros" },
{ spellId: 490150, phase: "cast-start", model: "model/fx/skill/physical/act_charge02_c_down.ros" }, { spellId: 490150, phase: "cast-start", model: "model/fx/skill/physical/act_charge02_c_down.ros" },
{ spellId: 490175, phase: "impact", model: "model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros", scale: 2.5 }, { spellId: 490175, phase: "impact", model: "model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros", scale: 2.5 },
{ spellId: 491168, phase: "release", model: "model/fx/skill/wind/act_electric_ball_f_i.ros" }, { spellId: 491168, phase: "release", model: "model/fx/skill/wind/act_electric_ball_f_i.ros" },
@@ -21,6 +21,9 @@
#include "Rune Engine/Rune/Scene/Base/RuHierarchy_GR2.h" #include "Rune Engine/Rune/Scene/Base/RuHierarchy_GR2.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_Hierarchy.h" #include "Rune Engine/Rune/Scene/Controller/RuController_Hierarchy.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_EventTrigger.h" #include "Rune Engine/Rune/Scene/Controller/RuController_EventTrigger.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_GlobalAlpha.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_Decal.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_Particle_Visualizer_Entity.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_Particle_Emitter.h" #include "Rune Engine/Rune/Scene/Controller/RuController_Particle_Emitter.h"
#include "Rune Engine/Rune/Scene/Controller/RuController_Particle_Visualizer_Sprite.h" #include "Rune Engine/Rune/Scene/Controller/RuController_Particle_Visualizer_Sprite.h"
#include "Rune Engine/Rune/Scene/Terrain/RuWorld_Base.h" #include "Rune Engine/Rune/Scene/Terrain/RuWorld_Base.h"
@@ -1507,6 +1510,20 @@ namespace
{ WriteFxRealTrack(out, **track); return; } { WriteFxRealTrack(out, **track); return; }
if(CRuAnimTrack<CRuColor4> **track = boost::get<CRuAnimTrack<CRuColor4> *>(&variant)) if(CRuAnimTrack<CRuColor4> **track = boost::get<CRuAnimTrack<CRuColor4> *>(&variant))
{ WriteFxColorTrack(out, **track); return; } { WriteFxColorTrack(out, **track); return; }
if(CRuAnimTrack<char *> **track = boost::get<CRuAnimTrack<char *> *>(&variant))
{
out << "[";
for(INT32 key = 0; key < (*track)->GetNumKeys(); ++key)
{
REAL time = 0; char *value = NULL;
(*track)->GetKeyTimeByIndex(key, &time); (*track)->GetKeyByIndex(key, &value);
if(key) out << ",";
out << "{\"time\":" << FiniteFxValue(time) << ",\"value\":\""
<< JsonEscape(value ? value : "") << "\"}";
}
out << "]";
return;
}
out << "["; out << "[";
if(CRuAnimTrack<INT32> **track = boost::get<CRuAnimTrack<INT32> *>(&variant)) if(CRuAnimTrack<INT32> **track = boost::get<CRuAnimTrack<INT32> *>(&variant))
{ {
@@ -1565,9 +1582,14 @@ namespace
const bool isSprite = base->GetType().IsSubClassOf(CRuController_Particle_Visualizer_Sprite::Type()); const bool isSprite = base->GetType().IsSubClassOf(CRuController_Particle_Visualizer_Sprite::Type());
const bool isEmitter = base->GetType().IsSubClassOf(CRuController_Particle_Emitter::Type()); const bool isEmitter = base->GetType().IsSubClassOf(CRuController_Particle_Emitter::Type());
const bool isEventTrigger = base->GetType().IsSubClassOf(CRuController_EventTrigger::Type()); const bool isEventTrigger = base->GetType().IsSubClassOf(CRuController_EventTrigger::Type());
const bool isGlobalAlpha = base->GetType().IsSubClassOf(CRuController_GlobalAlpha::Type());
const bool isEntityVisualizer = base->GetType().IsSubClassOf(CRuController_Particle_Visualizer_Entity::Type());
const bool isDecal = base->GetType().IsSubClassOf(CRuController_Decal::Type());
const bool isGlobalColor = base->GetType().IsSubClassOf(CRuController_GlobalColor::Type());
if(wroteController) out << ","; if(wroteController) out << ",";
wroteController = true; wroteController = true;
out << "{\"type\":\"" << (isSprite ? "sprite" : isEmitter ? "emitter" : "unsupported") out << "{\"type\":\"" << (isSprite ? "sprite" : isEmitter ? "emitter" : isGlobalAlpha ? "global-alpha"
: isEntityVisualizer ? "entity-visualizer" : isDecal ? "decal" : isGlobalColor ? "global-color" : "unsupported")
<< "\",\"sourceType\":\"" << JsonEscape(base->GetType().GetTypeName()) << "\",\"sourceType\":\"" << JsonEscape(base->GetType().GetTypeName())
<< "\",\"anchor\":\"" << JsonEscape(node->GetAnchorTarget() ? node->GetAnchorTarget() : "") << "\""; << "\",\"anchor\":\"" << JsonEscape(node->GetAnchorTarget() ? node->GetAnchorTarget() : "") << "\"";
if(isEventTrigger) if(isEventTrigger)
@@ -1580,6 +1602,37 @@ namespace
<< ",\"eventDuration\":" << FiniteFxValue(trigger->GetEventDuration()) << ",\"eventDuration\":" << FiniteFxValue(trigger->GetEventDuration())
<< ",\"startTime\":" << FiniteFxValue(trigger->GetStartTime()); << ",\"startTime\":" << FiniteFxValue(trigger->GetStartTime());
} }
if(isGlobalAlpha)
{
CRuController_GlobalAlpha *alpha = static_cast<CRuController_GlobalAlpha *>(base);
out << ",\"startTime\":" << FiniteFxValue(alpha->GetStartTime())
<< ",\"duration\":" << FiniteFxValue(alpha->GetDuration())
<< ",\"alphaTrack\":";
WriteFxControllerTrack(out, alpha, 1);
}
if(isEntityVisualizer)
{
CRuController_Particle_Visualizer_Entity *visualizer = static_cast<CRuController_Particle_Visualizer_Entity *>(base);
out << ",\"entityName\":\"" << JsonEscape(visualizer->GetEntityName()) << "\""
<< ",\"orientToTravel\":" << (visualizer->GetOrientToDirectionOfTravel() ? "true" : "false");
}
if(isDecal)
{
CRuController_Decal *decal = static_cast<CRuController_Decal *>(base);
RuAnimTrackVariant textureVariant;
const char *textureName = "";
if(decal->GetTrackByIndex(0, textureVariant))
{
if(CRuAnimTrack<char *> **track = boost::get<CRuAnimTrack<char *> *>(&textureVariant))
{
char *firstKey = NULL;
if((*track)->GetNumKeys() > 0 && (*track)->GetKeyByIndex(0, &firstKey) && firstKey)
textureName = firstKey;
}
}
out << ",\"decalTexture\":\"" << JsonEscape(textureName) << "\""
<< ",\"billboardType\":" << static_cast<INT32>(decal->GetBillboardType());
}
if(isSprite) if(isSprite)
{ {
CRuController_Particle_Visualizer_Sprite *sprite = static_cast<CRuController_Particle_Visualizer_Sprite *>(base); CRuController_Particle_Visualizer_Sprite *sprite = static_cast<CRuController_Particle_Visualizer_Sprite *>(base);
@@ -1616,7 +1669,7 @@ namespace
out << ",\"emitterOrientation\":" << static_cast<INT32>(emitter->GetEmitterOrientation()); out << ",\"emitterOrientation\":" << static_cast<INT32>(emitter->GetEmitterOrientation());
out << ",\"prerunTime\":" << FiniteFxValue(emitter->GetEmitterPrerunTime()); out << ",\"prerunTime\":" << FiniteFxValue(emitter->GetEmitterPrerunTime());
} }
if(isSprite || isEmitter) if(isSprite || isEmitter || isEntityVisualizer || isDecal || isGlobalColor)
{ {
IRuEntity_KeyableController *keyable = static_cast<IRuEntity_KeyableController *>(base); IRuEntity_KeyableController *keyable = static_cast<IRuEntity_KeyableController *>(base);
out << ",\"tracks\":["; out << ",\"tracks\":[";
+8 -3
View File
@@ -42,8 +42,10 @@ function Preview() {
useEffect(() => { useEffect(() => {
void fetch("/assets/game/combat-effects/original/runewaker/manifest.json") void fetch("/assets/game/combat-effects/original/runewaker/manifest.json")
.then((response) => response.json()) .then((response) => response.json())
.then((index: { results: { model: string; graphUrl: string; status: string; rendererReady?: boolean }[] }) => { .then((index: { results: { model: string; graphUrl: string; status: string;
const available = index.results.filter((entry) => entry.status === "decoded" || entry.rendererReady) rendererReady?: boolean; partialRendererReady?: boolean }[] }) => {
const available = index.results.filter((entry) =>
entry.status === "decoded" || entry.rendererReady || entry.partialRendererReady)
.map(({ model, graphUrl }) => ({ model, graphUrl })); .map(({ model, graphUrl }) => ({ model, graphUrl }));
setChoices(available); setChoices(available);
setSelected(available.find((entry) => entry.model.includes("electric_ball_t_freeze_hit"))?.graphUrl setSelected(available.find((entry) => entry.model.includes("electric_ball_t_freeze_hit"))?.graphUrl
@@ -83,8 +85,11 @@ function Preview() {
<label><input type="checkbox" checked={freezeAt} onChange={(event) => { setFreezeAt(event.target.checked); setReplay(replay + 1); }} />Freeze at 250 ms</label> <label><input type="checkbox" checked={freezeAt} onChange={(event) => { setFreezeAt(event.target.checked); setReplay(replay + 1); }} />Freeze at 250 ms</label>
</div> </div>
<div role="status" style={{ padding: "0 20px 8px" }}>{error || (loaded <div role="status" style={{ padding: "0 20px 8px" }}>{error || (loaded
? `Ready · ${runeLayers(loaded.graph, phase).length} sprite layers · ${loaded.graph.textures.length} textures` ? `${loaded.graph.partialRendererReady ? "Partial original" : "Ready"} · ${runeLayers(loaded.graph, phase).length} sprite layers · ${loaded.graph.textures.length} textures`
: "Loading RuneWaker media…")}</div> : "Loading RuneWaker media…")}</div>
{loaded?.graph.partialRendererReady && <div style={{ padding: "0 20px 8px" }}>
Fidelity limit: {loaded.graph.omittedVisuals?.join(", ")}
</div>}
<div style={{ height: "calc(100vh - 100px)" }}> <div style={{ height: "calc(100vh - 100px)" }}>
<Canvas camera={{ position: [3, 2.5, 5], fov: 45 }}> <Canvas camera={{ position: [3, 2.5, 5], fov: 45 }}>
<color attach="background" args={["#0b121b"]} /> <color attach="background" args={["#0b121b"]} />
+56
View File
@@ -208,6 +208,62 @@ describe("combat audio output policy", () => {
audio.resetCombatAudio(); audio.resetCombatAudio();
}); });
it("positions release audio at the caster and impact audio at the target", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
const panners: Array<{
positionX: { value: number };
positionY: { value: number };
positionZ: { value: number };
}> = [];
const audioContext = {
currentTime: 0, state: "running", destination: {},
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: {
setTargetAtTime: vi.fn(), value: 1,
} }),
createPanner: () => {
const panner = {
connect: vi.fn(), disconnect: vi.fn(),
positionX: { value: 0 }, positionY: { value: 0 }, positionZ: { value: 0 },
panningModel: "HRTF", distanceModel: "inverse",
refDistance: 0, maxDistance: 0, rolloffFactor: 0,
};
panners.push(panner);
return panner;
},
createBufferSource: () => ({
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
buffer: null, loop: false, onended: null,
}),
decodeAudioData: vi.fn(async () => ({ duration: 0.2 })),
resume: vi.fn(async () => undefined),
};
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
combatMedia.ready = true;
combatMedia.entry.mockReturnValue({
key: "runewaker:490256", source: "runewaker", spellId: 490256,
sounds: [
{ url: "/assets/game/combat-effects/audio/rising-release.mp3", phase: "release" },
{ url: "/assets/game/combat-effects/audio/rising-impact.mp3", phase: "impact" },
],
});
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3])));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
const release = {
...fixtureEvent, source: "runewaker" as const, sourceSpellId: 490256,
phase: "release" as const, origin: [1, 2, 3] as const,
targetPosition: [9, 8, 7] as const,
};
audio.playCombatAudio(release);
audio.playCombatAudio({ ...release, id: 2, phase: "impact" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(panners.map((panner) => [
panner.positionX.value, panner.positionY.value, panner.positionZ.value,
])).toEqual([[1, 2, 3], [9, 8, 7]]);
audio.resetCombatAudio();
});
it("loops only a flagged cast sound and stops the matching actor and ability", async () => { it("loops only a flagged cast sound and stops the matching actor and ability", async () => {
vi.resetModules(); vi.resetModules();
const audio = await import("./combatAudio"); const audio = await import("./combatAudio");
+6 -3
View File
@@ -128,15 +128,18 @@ function positionPanner(
event: CombatPresentationEvent, event: CombatPresentationEvent,
): PannerNode | StereoPannerNode { ): PannerNode | StereoPannerNode {
if (typeof audioContext.createPanner === "function") { if (typeof audioContext.createPanner === "function") {
const position = event.phase === "impact"
? event.targetPosition ?? event.origin
: event.origin;
const panner = audioContext.createPanner(); const panner = audioContext.createPanner();
panner.panningModel = "HRTF"; panner.panningModel = "HRTF";
panner.distanceModel = "inverse"; panner.distanceModel = "inverse";
panner.refDistance = event.actorGroup === "player" ? 8 : 5; panner.refDistance = event.actorGroup === "player" ? 8 : 5;
panner.maxDistance = 55; panner.maxDistance = 55;
panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8; panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8;
panner.positionX.value = event.origin[0]; panner.positionX.value = position[0];
panner.positionY.value = event.origin[1]; panner.positionY.value = position[1];
panner.positionZ.value = event.origin[2]; panner.positionZ.value = position[2];
return panner; return panner;
} }
return audioContext.createStereoPanner(); return audioContext.createStereoPanner();
+2
View File
@@ -178,6 +178,8 @@ export interface CombatEffectPhaseAsset {
/** Browser-ready graph generated from the original client asset. */ /** Browser-ready graph generated from the original client asset. */
readonly effectGraphUrl?: string; readonly effectGraphUrl?: string;
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "wow-m2-model" | "runewaker-particles"; readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "wow-m2-model" | "runewaker-particles";
/** A source graph with verified sprite layers but omitted unsupported visual controllers. */
readonly visualCompleteness?: "partial";
readonly originalSourceSha256?: string; readonly originalSourceSha256?: string;
} }
+50 -30
View File
@@ -34,6 +34,7 @@ import {
updateCombatEffectQueue, updateCombatEffectQueue,
originalInstantCastEvent, originalInstantCastEvent,
originalInstantTerminalEvent, originalInstantTerminalEvent,
risingTideArrivalDelayMs,
type ActiveCombatEffect, type ActiveCombatEffect,
} from "./combatEffectRuntime"; } from "./combatEffectRuntime";
import { OriginalCombatEffect } from "./OriginalCombatEffect"; import { OriginalCombatEffect } from "./OriginalCombatEffect";
@@ -296,37 +297,56 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
activeRef.current = active; activeRef.current = active;
}, [active]); }, [active]);
useEffect(() => subscribeCombatPresentation((message) => { useEffect(() => {
if (message.kind === "reset") { const pendingArrivals = new Set<ReturnType<typeof setTimeout>>();
setEffects([]); const present = (event: CombatPresentationEvent) => {
resetCombatAudio(); if (activeRef.current) playCombatAudio(event);
return; setEffects((current) => {
} const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
const event = message.event; ? current.filter((effect) => !(
if (activeRef.current) playCombatAudio(event); effect.event.phase === "cast-start"
setEffects((current) => { && effect.event.sourceActorId === event.sourceActorId
const withoutCast = event.phase === "release" || event.phase === "cast-cancel" && effect.event.abilityId === event.abilityId
? current.filter((effect) => !( ))
effect.event.phase === "cast-start" : current;
&& effect.event.sourceActorId === event.sourceActorId const presentationSettings = settingsRef.current;
&& effect.event.abilityId === event.abilityId const quality = qualityRef.current;
)) if (
: current; event.phase === "cast-cancel"
const presentationSettings = settingsRef.current; || !activeRef.current
const quality = qualityRef.current; || quality === "off"
if ( || !presentationSettings.vfxEnabled[event.actorGroup]
event.phase === "cast-cancel" ) return withoutCast;
|| !activeRef.current const withInstantSourceCast = effectiveCombatVfxMode(presentationSettings) === "original"
|| quality === "off" && originalInstantCastEvent(event)
|| !presentationSettings.vfxEnabled[event.actorGroup] ? addOriginalInstantCastEffect(withoutCast, event, quality)
) return withoutCast; : withoutCast;
const withInstantSourceCast = effectiveCombatVfxMode(presentationSettings) === "original" return updateCombatEffectQueue(withInstantSourceCast, event, quality);
&& originalInstantCastEvent(event) });
? addOriginalInstantCastEffect(withoutCast, event, quality) };
: withoutCast; const unsubscribe = subscribeCombatPresentation((message) => {
return updateCombatEffectQueue(withInstantSourceCast, event, quality); if (message.kind === "reset") {
for (const timer of pendingArrivals) clearTimeout(timer);
pendingArrivals.clear();
setEffects([]);
resetCombatAudio();
return;
}
const event = message.event;
const arrivalDelay = risingTideArrivalDelayMs(event);
if (arrivalDelay > 0) {
const timer = setTimeout(() => {
pendingArrivals.delete(timer);
present(event);
}, arrivalDelay);
pendingArrivals.add(timer);
} else present(event);
}); });
}), []); return () => {
unsubscribe();
for (const timer of pendingArrivals) clearTimeout(timer);
};
}, []);
useEffect(() => () => resetCombatAudio(), []); useEffect(() => () => resetCombatAudio(), []);
+3
View File
@@ -44,6 +44,7 @@ import {
updateMobRuntimePosition, updateMobRuntimePosition,
} from "../game/mobRuntimeRegistry"; } from "../game/mobRuntimeRegistry";
import { combatHasLineOfSight } from "../game/combatSpatial"; import { combatHasLineOfSight } from "../game/combatSpatial";
import { registerCombatEffectTargetVisualSize } from "./combatEffectAnchorRegistry";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset"; import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling"; import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
import { dungeonEnemyScaling } from "../game/dungeonBossScaling"; import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
@@ -909,6 +910,8 @@ function ProxyMob({
const proceduralModelAnimation = model?.animationMode === "procedural"; const proceduralModelAnimation = model?.animationMode === "procedural";
const labelHeight = model?.labelHeight ?? (boss ? 3.25 : 2.65) * scale; const labelHeight = model?.labelHeight ?? (boss ? 3.25 : 2.65) * scale;
const markerRadius = model?.markerRadius ?? 0.9 * scale; const markerRadius = model?.markerRadius ?? 0.9 * scale;
useEffect(() => registerCombatEffectTargetVisualSize(instanceId,
{ height: labelHeight, radius: markerRadius }), [instanceId, labelHeight, markerRadius]);
const dead = mob?.dead ?? false; const dead = mob?.dead ?? false;
const lootable = Boolean(dead && mob?.hasLoot && !mob.looted); const lootable = Boolean(dead && mob?.hasLoot && !mob.looted);
const attackRevision = mob?.attackRevision ?? 0; const attackRevision = mob?.attackRevision ?? 0;
+13 -11
View File
@@ -49,8 +49,10 @@ import {
parseOriginalCombatMeshGraph, parseOriginalCombatMeshGraph,
type OriginalCombatMeshGraph, type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime"; } from "./originalCombatMeshRuntime";
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry"; import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import { shouldUseGeneratedOriginalFallback } from "./combatEffectRuntime"; import {
projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback,
} from "./combatEffectRuntime";
import { OriginalWowModel } from "./OriginalWowModel"; import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry"; import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
import { RunewakerParticleEffect } from "./RunewakerParticleEffect"; import { RunewakerParticleEffect } from "./RunewakerParticleEffect";
@@ -722,13 +724,7 @@ function LoadedOriginalCombatEffect({
const projectile = event.phase === "release" const projectile = event.phase === "release"
&& event.delivery === "projectile" && event.delivery === "projectile"
&& event.targetPosition !== undefined; && event.targetPosition !== undefined;
const projectileDurationMs = projectile && event.targetPosition const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0;
? Math.max(180, Math.min(550, Math.hypot(
event.targetPosition[0] - event.origin[0],
event.targetPosition[1] - event.origin[1],
event.targetPosition[2] - event.origin[2],
) / 28 * 1_000))
: 0;
const anchor = event.phase === "cast-start" || projectile const anchor = event.phase === "cast-start" || projectile
? event.origin ? event.origin
: event.targetPosition ?? event.origin; : event.targetPosition ?? event.origin;
@@ -796,11 +792,17 @@ function LoadedOriginalCombatEffect({
<group ref={groupRef} position={anchor}> <group ref={groupRef} position={anchor}>
{loaded.map((item, loadedIndex) => { {loaded.map((item, loadedIndex) => {
const graph = item.graph; const graph = item.graph;
const scale = effectScale(item.asset); const risingTideTarget = event.phase === "impact" && event.sourceSpellId === 490256
&& item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
: null;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1);
return ( return (
<group <group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`} key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
position={[0, effectPlacementHeight(item.asset, graph, event), 0]} position={risingTideTarget
? [risingTideTarget.offsetX, risingTideTarget.height, risingTideTarget.offsetZ]
: [0, effectPlacementHeight(item.asset, graph, event), 0]}
scale={scale} scale={scale}
> >
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures} {graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
+237 -41
View File
@@ -1,14 +1,18 @@
import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react"; import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { import {
AddEquation, BufferAttribute, CustomBlending, DoubleSide, DynamicDrawUsage, AddEquation, AdditiveBlending, BufferAttribute, BufferGeometry, ClampToEdgeWrapping,
CustomBlending, DoubleSide, DynamicDrawUsage,
InstancedBufferAttribute, InstancedBufferGeometry, OneFactor, OneMinusSrcAlphaFactor, InstancedBufferAttribute, InstancedBufferGeometry, OneFactor, OneMinusSrcAlphaFactor,
ShaderMaterial, Vector2, ZeroFactor, type Texture, Mesh, MeshBasicMaterial, PlaneGeometry, RepeatWrapping, ShaderMaterial, Vector2, ZeroFactor,
type Texture,
} from "three"; } from "three";
import type { CombatPresentationPhase, CombatVfxQuality } from "../game/combatPresentation"; import type { CombatPresentationPhase, CombatVfxQuality } from "../game/combatPresentation";
import { import {
runeEmissionsBetween, runeLayers, runeSample, type RuneParticleLayer, type RuneVector3, runeEmissionsBetween, runeGlobalAlpha, runeLayers, runeMotion, runeSample,
type RunewakerParticleGraph, type RuneDecalController, type RuneEmitterController, type RuneEntityVisualizerController,
type RuneGlobalAlphaController, type RuneGlobalColorController, type RuneParticleLayer,
type RuneVector3, type RunewakerParticleGraph,
} from "./runewakerParticleRuntime"; } from "./runewakerParticleRuntime";
interface Particle { interface Particle {
@@ -32,6 +36,7 @@ const vertexShader = /* glsl */ `
varying vec4 vColor; varying vec4 vColor;
varying float vFrame; varying float vFrame;
uniform vec2 quadOffset; uniform vec2 quadOffset;
uniform float billboardType;
void main() { void main() {
vec4 viewPosition = modelViewMatrix * vec4(instancePosition, 1.0); vec4 viewPosition = modelViewMatrix * vec4(instancePosition, 1.0);
@@ -39,7 +44,16 @@ const vertexShader = /* glsl */ `
float s = sin(instanceRotation); float s = sin(instanceRotation);
float c = cos(instanceRotation); float c = cos(instanceRotation);
vec2 rotated = vec2(local.x * c - local.y * s, local.x * s + local.y * c); vec2 rotated = vec2(local.x * c - local.y * s, local.x * s + local.y * c);
viewPosition.xy += rotated * length(modelViewMatrix[0].xyz); if (billboardType == 2.0) {
vec3 worldPosition = (modelMatrix * vec4(instancePosition, 1.0)).xyz;
vec3 toCamera = normalize(cameraPosition - worldPosition);
vec3 right = cross(vec3(0.0, 1.0, 0.0), toCamera);
right = length(right) < 0.001 ? vec3(1.0, 0.0, 0.0) : normalize(right);
vec3 worldOffset = right * rotated.x + vec3(0.0, 1.0, 0.0) * rotated.y;
viewPosition.xyz += (viewMatrix * vec4(worldOffset, 0.0)).xyz * length(modelMatrix[0].xyz);
} else {
viewPosition.xy += rotated * length(modelViewMatrix[0].xyz);
}
gl_Position = projectionMatrix * viewPosition; gl_Position = projectionMatrix * viewPosition;
vUv = uv; vUv = uv;
vColor = instanceColor; vColor = instanceColor;
@@ -78,68 +92,74 @@ function random(seed: number): number {
return ((value ^ value >>> 14) >>> 0) / 4_294_967_296; return ((value ^ value >>> 14) >>> 0) / 4_294_967_296;
} }
function vectorAt(layer: RuneParticleLayer, track: number, time: number, fallback: RuneVector3): RuneVector3 { function vectorAt(emitter: RuneEmitterController, track: number, time: number, fallback: RuneVector3): RuneVector3 {
return runeSample(layer.emitter.tracks[track] as { time: number; value: RuneVector3 }[], time, fallback); return runeSample(emitter.tracks[track] as { time: number; value: RuneVector3 }[], time, fallback);
} }
function scalarAt(layer: RuneParticleLayer, track: number, time: number, fallback = 0): number { function scalarAt(emitter: RuneEmitterController, track: number, time: number, fallback = 0): number {
return runeSample(layer.emitter.tracks[track] as { time: number; value: number }[], time, fallback); return runeSample(emitter.tracks[track] as { time: number; value: number }[], time, fallback);
} }
function spawn(layer: RuneParticleLayer, time: number, seed: number): Particle { function spawn(emitter: RuneEmitterController, position: RuneVector3,
randomRotation: boolean, time: number, seed: number): Particle {
const randomAt = (index: number) => random(seed + index * 1619); const randomAt = (index: number) => random(seed + index * 1619);
const kind = Math.round(scalarAt(layer, 1, time)); const kind = Math.round(scalarAt(emitter, 1, time));
const emitterPosition = vectorAt(layer, 2, time, [0, 0, 0]); const emitterPosition = vectorAt(emitter, 2, time, [0, 0, 0]);
const extents = vectorAt(layer, 3, time, [0, 0, 0]); const extents = vectorAt(emitter, 3, time, [0, 0, 0]);
const radius = scalarAt(layer, 4, time); const radius = scalarAt(emitter, 4, time);
let offset: RuneVector3 = [0, 0, 0]; let offset: RuneVector3 = [0, 0, 0];
let radialDirection: RuneVector3 | null = null;
if (kind === 1) { if (kind === 1) {
offset = [(randomAt(1) * 2 - 1) * extents[0], offset = [(randomAt(1) * 2 - 1) * extents[0],
(randomAt(2) * 2 - 1) * extents[1], (randomAt(3) * 2 - 1) * extents[2]]; (randomAt(2) * 2 - 1) * extents[1], (randomAt(3) * 2 - 1) * extents[2]];
} else if (kind === 4) { } else if (kind === 4 || kind === 5) {
const angle = randomAt(1) * Math.PI * 2; const angle = randomAt(1) * Math.PI * 2;
const distance = randomAt(2) * radius; const distance = kind === 5 ? radius : randomAt(2) * radius;
const orientation = layer.emitter.emitterOrientation; const orientation = emitter.emitterOrientation;
offset = orientation === 1 || orientation === 4 const radial = orientation === 1 || orientation === 4
? [0, Math.sin(angle) * distance, Math.cos(angle) * distance] ? [0, Math.sin(angle), Math.cos(angle)] as RuneVector3
: orientation === 2 || orientation === 5 : orientation === 2 || orientation === 5
? [Math.sin(angle) * distance, 0, Math.cos(angle) * distance] ? [Math.sin(angle), 0, Math.cos(angle)] as RuneVector3
: [Math.sin(angle) * distance, Math.cos(angle) * distance, 0]; : [Math.sin(angle), Math.cos(angle), 0] as RuneVector3;
offset = [radial[0] * distance, radial[1] * distance, radial[2] * distance];
radialDirection = radial;
} }
const direction = vectorAt(layer, 10, time, [0, 1, 0]); const direction = radialDirection ?? vectorAt(emitter, 10, time, [0, 1, 0]);
const deviation = Math.min(Math.PI, Math.abs(scalarAt(layer, 11, time))); const deviation = radialDirection ? 0 : Math.min(Math.PI, Math.abs(scalarAt(emitter, 11, time)));
const spread = Math.tan(deviation * 0.5); const spread = Math.tan(deviation * 0.5);
const dx = direction[0] + (randomAt(4) * 2 - 1) * spread; const dx = direction[0] + (randomAt(4) * 2 - 1) * spread;
const dy = direction[1] + (randomAt(5) * 2 - 1) * spread; const dy = direction[1] + (randomAt(5) * 2 - 1) * spread;
const dz = direction[2] + (randomAt(6) * 2 - 1) * spread; const dz = direction[2] + (randomAt(6) * 2 - 1) * spread;
const magnitude = Math.max(0.00001, Math.hypot(dx, dy, dz)); const magnitude = Math.max(0.00001, Math.hypot(dx, dy, dz));
const speed = scalarAt(layer, 14, time) + randomAt(7) * ( const speed = scalarAt(emitter, 14, time) + randomAt(7) * (
scalarAt(layer, 15, time) - scalarAt(layer, 14, time)); scalarAt(emitter, 15, time) - scalarAt(emitter, 14, time));
const force0 = vectorAt(layer, 20, time, [0, 0, 0]); const force0 = vectorAt(emitter, 20, time, [0, 0, 0]);
const force1 = vectorAt(layer, 21, time, [0, 0, 0]); const force1 = vectorAt(emitter, 21, time, [0, 0, 0]);
const minLife = scalarAt(layer, 12, time, 0.5); const minLife = scalarAt(emitter, 12, time, 0.5);
const maxLife = scalarAt(layer, 13, time, 0.5); const maxLife = scalarAt(emitter, 13, time, 0.5);
const minScale = scalarAt(layer, 16, time, 1); const minScale = scalarAt(emitter, 16, time, 1);
const maxScale = scalarAt(layer, 17, time, 1); const maxScale = scalarAt(emitter, 17, time, 1);
const minOrientation = vectorAt(layer, 18, time, [0, 0, 0]); const minOrientation = vectorAt(emitter, 18, time, [0, 0, 0]);
const maxOrientation = vectorAt(layer, 19, time, [0, 0, 0]); const maxOrientation = vectorAt(emitter, 19, time, [0, 0, 0]);
return { return {
born: time, born: time,
life: Math.max(0.02, minLife + randomAt(8) * (maxLife - minLife)), life: Math.max(0.02, minLife + randomAt(8) * (maxLife - minLife)),
origin: [layer.position[0] + emitterPosition[0] + offset[0], origin: [position[0] + emitterPosition[0] + offset[0],
layer.position[1] + emitterPosition[1] + offset[1], position[1] + emitterPosition[1] + offset[1],
-layer.position[2] - emitterPosition[2] - offset[2]], -position[2] - emitterPosition[2] - offset[2]],
velocity: [dx / magnitude * speed, dy / magnitude * speed, -dz / magnitude * speed], velocity: [dx / magnitude * speed, dy / magnitude * speed, -dz / magnitude * speed],
acceleration: [force0[0] + force1[0], force0[1] + force1[1], -force0[2] - force1[2]], acceleration: [force0[0] + force1[0], force0[1] + force1[1], -force0[2] - force1[2]],
scale: minScale + randomAt(9) * (maxScale - minScale), scale: minScale + randomAt(9) * (maxScale - minScale),
orientation: (minOrientation[0] + randomAt(10) * (maxOrientation[0] - minOrientation[0])) * Math.PI / 180, orientation: (minOrientation[0] + randomAt(10) * (maxOrientation[0] - minOrientation[0])) * Math.PI / 180,
rotationSign: layer.sprite.randomRotation && randomAt(11) < 0.5 ? -1 : 1, rotationSign: randomRotation && randomAt(11) < 0.5 ? -1 : 1,
}; };
} }
const RuneEmitter = memo(function RuneEmitter({ const RuneEmitter = memo(function RuneEmitter({
layer, texture, budget, active, eventId, timelineRef, graph, phase, layer, texture, budget, active, eventId, timelineRef,
}: { }: {
readonly graph: RunewakerParticleGraph;
readonly phase: CombatPresentationPhase;
readonly layer: RuneParticleLayer; readonly layer: RuneParticleLayer;
readonly texture: Texture; readonly texture: Texture;
readonly budget: number; readonly budget: number;
@@ -181,6 +201,7 @@ const RuneEmitter = memo(function RuneEmitter({
atlasColumns: { value: Math.max(1, Math.floor(width / tileWidth)) }, atlasColumns: { value: Math.max(1, Math.floor(width / tileWidth)) },
frameCount: { value: Math.max(1, layer.sprite.frameCount) }, frameCount: { value: Math.max(1, layer.sprite.frameCount) },
quadOffset: { value: new Vector2(layer.sprite.quadOffset[0], layer.sprite.quadOffset[1]) }, quadOffset: { value: new Vector2(layer.sprite.quadOffset[0], layer.sprite.quadOffset[1]) },
billboardType: { value: layer.sprite.billboardType },
}, },
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
@@ -198,6 +219,7 @@ const RuneEmitter = memo(function RuneEmitter({
useFrame(() => { useFrame(() => {
if (!active || budget === 0) return; if (!active || budget === 0) return;
const time = timelineRef.current.elapsedMs / 1_000; const time = timelineRef.current.elapsedMs / 1_000;
const globalAlpha = runeGlobalAlpha(graph, phase, time);
const current = system.current; const current = system.current;
if (time < current.lastTime) { if (time < current.lastTime) {
current.particles.fill(null); current.debt = 0; current.births = 0; current.particles.fill(null); current.debt = 0; current.births = 0;
@@ -214,7 +236,8 @@ const RuneEmitter = memo(function RuneEmitter({
if (particle && time - particle.born >= particle.life) current.particles[index] = null; if (particle && time - particle.born >= particle.life) current.particles[index] = null;
if (!current.particles[index] && births > 0) { if (!current.particles[index] && births > 0) {
const seed = eventId * 1009 + current.births * 7919 + index * 97; const seed = eventId * 1009 + current.births * 7919 + index * 97;
current.particles[index] = spawn(layer, time, seed); current.particles[index] = spawn(layer.emitter, layer.position,
layer.sprite.randomRotation, time, seed);
current.births++; births--; current.births++; births--;
} }
} }
@@ -237,7 +260,8 @@ const RuneEmitter = memo(function RuneEmitter({
particle.origin[0] + particle.velocity[0] * age + particle.acceleration[0] * ageSquared, particle.origin[0] + particle.velocity[0] * age + particle.acceleration[0] * ageSquared,
particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared, particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared,
particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared); particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared);
colors.setXYZW(index, color[0], color[1], color[2], Math.max(0, Math.min(1, alpha * color[3]))); colors.setXYZW(index, color[0], color[1], color[2],
Math.max(0, Math.min(1, alpha * color[3] * globalAlpha)));
sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]), sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]),
Math.max(0.001, scale * layer.sprite.quadScale[1])); Math.max(0.001, scale * layer.sprite.quadScale[1]));
rotations.setX(index, layer.sprite.orientToTravel rotations.setX(index, layer.sprite.orientToTravel
@@ -251,6 +275,151 @@ const RuneEmitter = memo(function RuneEmitter({
return <mesh name="original-runewaker-particle-layer" geometry={geometry} material={material} frustumCulled={false} />; return <mesh name="original-runewaker-particle-layer" geometry={geometry} material={material} frustumCulled={false} />;
}); });
const RuneEntityEmitter = memo(function RuneEntityEmitter({
graph, emitter, visualizer, texture, budget, active, eventId, timelineRef,
}: {
readonly graph: RunewakerParticleGraph;
readonly emitter: RuneEmitterController;
readonly visualizer: RuneEntityVisualizerController;
readonly texture: Texture;
readonly budget: number;
readonly active: boolean;
readonly eventId: number;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
const pool = useMemo(() => {
const mesh = graph.runeMesh;
if (!mesh) throw new Error("Urgent Heal rune geometry is missing.");
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(mesh.positions), 3));
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(mesh.uvs), 2));
const tiledTexture = texture.clone();
tiledTexture.wrapS = RepeatWrapping;
tiledTexture.wrapT = ClampToEdgeWrapping;
tiledTexture.needsUpdate = true;
const meshes = Array.from({ length: budget }, (_, index) => {
const material = new MeshBasicMaterial({
name: `runewaker-urgent-rune-${index}`,
map: tiledTexture,
transparent: true,
blending: AdditiveBlending,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
});
const instance = new Mesh(geometry, material);
instance.name = "original-runewaker-rune-cylinder";
instance.frustumCulled = false;
instance.visible = false;
return instance;
});
return { geometry, tiledTexture, meshes };
}, [budget, graph.runeMesh, texture]);
useEffect(() => () => {
pool.geometry.dispose();
pool.tiledTexture.dispose();
pool.meshes.forEach((mesh) => mesh.material.dispose());
}, [pool]);
useFrame(() => {
if (!active) {
pool.meshes.forEach((mesh) => { mesh.visible = false; });
return;
}
const time = timelineRef.current.elapsedMs / 1_000;
const current = system.current;
if (time < current.lastTime) {
current.particles.fill(null); current.debt = 0; current.births = 0;
}
const previousTime = current.lastTime;
current.lastTime = time;
current.debt = Math.min(budget, current.debt + runeEmissionsBetween(
emitter.tracks[9] as { time: number; value: number }[], previousTime, time,
));
let births = Math.min(budget, Math.floor(current.debt));
current.debt -= births;
for (let index = 0; index < budget; index++) {
const particle = current.particles[index];
if (particle && time - particle.born >= particle.life) current.particles[index] = null;
if (!current.particles[index] && births > 0) {
const seed = eventId * 1009 + current.births * 7919 + index * 97;
current.particles[index] = spawn(emitter, [0, 0, 0], false, time, seed);
current.births++; births--;
}
const next = current.particles[index];
const mesh = pool.meshes[index];
if (!next) { mesh.visible = false; continue; }
const age = Math.max(0, time - next.born);
const lifetime = Math.min(1, age / next.life);
const color = runeSample(visualizer.tracks[1], lifetime, [1, 1, 1, 1]);
const alpha = runeSample(visualizer.tracks[2], lifetime, 1);
const scale = runeSample(visualizer.tracks[3], lifetime, 1) * next.scale;
const ageSquared = age * age * 0.5;
mesh.position.set(
next.origin[0] + next.velocity[0] * age + next.acceleration[0] * ageSquared,
next.origin[1] + next.velocity[1] * age + next.acceleration[1] * ageSquared,
next.origin[2] + next.velocity[2] * age + next.acceleration[2] * ageSquared,
);
mesh.scale.setScalar(Math.max(0.001, scale));
mesh.rotation.y = next.orientation;
mesh.material.color.setRGB(color[0], color[1], color[2]);
mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3]));
mesh.visible = scale > 0.001 && mesh.material.opacity > 0.002;
}
});
return <group name="original-runewaker-rune-entities">
{pool.meshes.map((mesh, index) => <primitive key={index} object={mesh} />)}
</group>;
});
const RuneGroundDecal = memo(function RuneGroundDecal({
decal, alpha, color, texture, active, timelineRef,
}: {
readonly decal: RuneDecalController;
readonly alpha: RuneGlobalAlphaController;
readonly color: RuneGlobalColorController;
readonly texture: Texture;
readonly active: boolean;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const meshRef = useRef<Mesh<PlaneGeometry, MeshBasicMaterial>>(null);
const geometry = useMemo(() => new PlaneGeometry(2, 2), []);
const material = useMemo(() => new MeshBasicMaterial({
name: "runewaker-urgent-ground-decal",
map: texture,
transparent: true,
blending: AdditiveBlending,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
}), [texture]);
useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]);
useFrame(() => {
const mesh = meshRef.current;
if (!mesh) return;
if (!active) { mesh.visible = false; return; }
const time = timelineRef.current.elapsedMs / 1_000;
const radius = runeSample(decal.tracks[2], time, 0);
const rotation = runeSample(decal.tracks[1], time, 0);
const opacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0);
const tint = runeSample(color.tracks[0], time, [1, 1, 1, 1]);
material.color.setRGB(tint[0], tint[1], tint[2]);
material.opacity = Math.max(0, Math.min(1, opacity * tint[3]));
mesh.scale.set(radius, radius, 1);
mesh.rotation.z = rotation;
mesh.visible = time <= alpha.startTime + alpha.duration && material.opacity > 0.002;
});
// The client projects this decal onto terrain triangles. The preview uses a level ground plane.
return <group name="original-runewaker-ground-decal" position={[0, 0.1, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh ref={meshRef} geometry={geometry} material={material} visible={false} frustumCulled={false} />
</group>;
});
export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
graph, textures, phase, quality, active, eventId, playbackRef, graph, textures, phase, quality, active, eventId, playbackRef,
}: { }: {
@@ -263,6 +432,22 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) { }) {
const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]); const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]);
const motion = useMemo(() => runeMotion(graph, phase), [graph, phase]);
const runeEntity = motion?.controllers.find(
(controller): controller is RuneEntityVisualizerController => controller.type === "entity-visualizer",
);
const runeEmitter = motion?.controllers.find(
(controller): controller is RuneEmitterController => controller.type === "emitter" && controller.anchor === runeEntity?.anchor,
);
const decal = motion?.controllers.find(
(controller): controller is RuneDecalController => controller.type === "decal",
);
const decalAlpha = motion?.controllers.find(
(controller): controller is RuneGlobalAlphaController => controller.type === "global-alpha" && controller.anchor === decal?.anchor,
);
const decalColor = motion?.controllers.find(
(controller): controller is RuneGlobalColorController => controller.type === "global-color" && controller.anchor === decal?.anchor,
);
const limit = quality === "high" ? 256 : 96; const limit = quality === "high" ? 256 : 96;
return <group name="original-runewaker-particles" scale={0.1}> return <group name="original-runewaker-particles" scale={0.1}>
{layers.map((layer, index) => { {layers.map((layer, index) => {
@@ -270,9 +455,20 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
if (!texture) return null; if (!texture) return null;
const sourceMaximum = Number(layer.emitter.tracks[0][0]?.value ?? 0); const sourceMaximum = Number(layer.emitter.tracks[0][0]?.value ?? 0);
const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length)))); const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length))));
return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} layer={layer} texture={texture} return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} graph={graph} phase={phase}
layer={layer} texture={texture}
budget={budget} active={active} eventId={eventId + index * 271} budget={budget} active={active} eventId={eventId + index * 271}
timelineRef={playbackRef} />; timelineRef={playbackRef} />;
})} })}
{graph.runeMesh && runeEntity && runeEmitter && textures.get(graph.runeMesh.textureAssetUrl)
? <RuneEntityEmitter graph={graph} emitter={runeEmitter} visualizer={runeEntity}
texture={textures.get(graph.runeMesh.textureAssetUrl)!}
budget={quality === "high" ? 12 : 6} active={active} eventId={eventId + 617}
timelineRef={playbackRef} />
: null}
{decal && decalAlpha && decalColor && textures.get(decal.textureAssetUrl)
? <RuneGroundDecal decal={decal} alpha={decalAlpha} color={decalColor}
texture={textures.get(decal.textureAssetUrl)!} active={active} timelineRef={playbackRef} />
: null}
</group>; </group>;
}); });
@@ -4,6 +4,8 @@ import {
clearCombatEffectAnchorRegistry, clearCombatEffectAnchorRegistry,
registerCombatEffectAnchor, registerCombatEffectAnchor,
updateCombatEffectAnchorPosition, updateCombatEffectAnchorPosition,
registerCombatEffectTargetVisualSize,
combatEffectTargetVisualSize,
} from "./combatEffectAnchorRegistry"; } from "./combatEffectAnchorRegistry";
afterEach(() => clearCombatEffectAnchorRegistry()); afterEach(() => clearCombatEffectAnchorRegistry());
@@ -49,4 +51,14 @@ describe("combat-effect visual anchors", () => {
expect(updateCombatEffectAnchorPosition("missing", effect, new Vector3())).toBe(false); expect(updateCombatEffectAnchorPosition("missing", effect, new Vector3())).toBe(false);
expect(effect.position.toArray()).toEqual([1, 2, 3]); expect(effect.position.toArray()).toEqual([1, 2, 3]);
}); });
it("tracks creature display bounds without losing a replacement registration", () => {
const unregisterOld = registerCombatEffectTargetVisualSize("verdan", { height: 10, radius: 3 });
const current = { height: 15.85, radius: 5.3 };
const unregisterCurrent = registerCombatEffectTargetVisualSize("verdan", current);
unregisterOld();
expect(combatEffectTargetVisualSize("verdan")).toBe(current);
unregisterCurrent();
expect(combatEffectTargetVisualSize("verdan")).toBeNull();
});
}); });
+20
View File
@@ -1,6 +1,25 @@
import { Vector3, type Object3D } from "three"; import { Vector3, type Object3D } from "three";
const anchors = new Map<string, Object3D>(); const anchors = new Map<string, Object3D>();
export interface CombatEffectTargetVisualSize {
readonly height: number;
readonly radius: number;
}
const targetVisualSizes = new Map<string, CombatEffectTargetVisualSize>();
/** Model display bounds used to place target-attached spell effects on large creatures. */
export function registerCombatEffectTargetVisualSize(
actorId: string, size: CombatEffectTargetVisualSize,
): () => void {
targetVisualSizes.set(actorId, size);
return () => {
if (targetVisualSizes.get(actorId) === size) targetVisualSizes.delete(actorId);
};
}
export function combatEffectTargetVisualSize(actorId: string | undefined): CombatEffectTargetVisualSize | null {
return actorId ? targetVisualSizes.get(actorId) ?? null : null;
}
export function registerCombatEffectAnchor(actorId: string, object: Object3D): () => void { export function registerCombatEffectAnchor(actorId: string, object: Object3D): () => void {
anchors.set(actorId, object); anchors.set(actorId, object);
@@ -30,4 +49,5 @@ export function updateCombatEffectAnchorPosition(
export function clearCombatEffectAnchorRegistry(): void { export function clearCombatEffectAnchorRegistry(): void {
anchors.clear(); anchors.clear();
targetVisualSizes.clear();
} }
+32
View File
@@ -7,6 +7,9 @@ import {
advanceCombatEffectFrame, advanceCombatEffectFrame,
originalInstantCastEvent, originalInstantCastEvent,
originalInstantTerminalEvent, originalInstantTerminalEvent,
projectileTravelDurationMs,
risingTideArrivalDelayMs,
risingTideImpactPlacement,
shouldUseGeneratedOriginalFallback, shouldUseGeneratedOriginalFallback,
updateCombatEffectQueue, updateCombatEffectQueue,
type ActiveCombatEffect, type ActiveCombatEffect,
@@ -46,6 +49,35 @@ function enqueue(
} }
describe("combat-effect runtime budget", () => { describe("combat-effect runtime budget", () => {
it("delays only the Rising Tide target arrival by the projectile's travel time", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
const impact = { ...release, id: 2, phase: "impact" as const };
expect(projectileTravelDurationMs(release)).toBe(500);
expect(risingTideArrivalDelayMs(release)).toBe(0);
expect(risingTideArrivalDelayMs(impact)).toBe(500);
expect(risingTideArrivalDelayMs({ ...impact, sourceSpellId: 491147 })).toBe(0);
expect(risingTideArrivalDelayMs({ ...impact, delivery: "area" })).toBe(0);
expect(projectileTravelDurationMs({ ...release, targetPosition: [100, 0, 0] })).toBe(550);
expect(projectileTravelDurationMs({ ...release, targetPosition: [1, 0, 0] })).toBe(180);
});
it("places Rising Tide impact on a giant target's caster-facing body surface", () => {
const cast = event(1, { origin: [0, 0, 0], targetPosition: [10, 0, 0] });
const verdan = risingTideImpactPlacement(cast, { height: 15.85, radius: 5.3 });
expect(verdan.height).toBe(2.4);
expect(verdan.offsetX).toBeCloseTo(-5.05);
expect(verdan.offsetZ).toBe(0);
expect(verdan.scale).toBe(5);
const humanoid = risingTideImpactPlacement(cast, { height: 2.65, radius: 0.9 });
expect(humanoid.height).toBeCloseTo(1.1925);
expect(humanoid.offsetX).toBeCloseTo(-0.65);
expect(humanoid.scale).toBe(1);
expect(risingTideImpactPlacement(cast, null)).toEqual({
offsetX: 0, height: 1.05, offsetZ: 0, scale: 1,
});
});
it("expires effects by wall time while bounding motion after a hitch", () => { it("expires effects by wall time while bounding motion after a hitch", () => {
let frame = advanceCombatEffectFrame(0, 1 / 60, 420); let frame = advanceCombatEffectFrame(0, 1 / 60, 420);
frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420); frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420);
+50
View File
@@ -2,6 +2,7 @@ import type {
CombatPresentationEvent, CombatPresentationEvent,
CombatVfxQuality, CombatVfxQuality,
} from "../game/combatPresentation"; } from "../game/combatPresentation";
import type { CombatEffectTargetVisualSize } from "./combatEffectAnchorRegistry";
export interface ActiveCombatEffect { export interface ActiveCombatEffect {
readonly key: string; readonly key: string;
@@ -15,6 +16,55 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
high: 32, high: 32,
}; };
/** Keep source projectile travel and its arrival cue on the same presentation clock. */
export function projectileTravelDurationMs(
event: Pick<CombatPresentationEvent, "origin" | "targetPosition" | "delivery">,
): number {
if (event.delivery !== "projectile" || !event.targetPosition) return 0;
const distance = Math.hypot(
event.targetPosition[0] - event.origin[0],
event.targetPosition[1] - event.origin[1],
event.targetPosition[2] - event.origin[2],
);
return Number.isFinite(distance) ? Math.max(180, Math.min(550, distance / 28 * 1_000)) : 0;
}
/** RuneWaker's Rising Tide impact model and sound are target arrival events. */
export function risingTideArrivalDelayMs(
event: Pick<CombatPresentationEvent,
"phase" | "source" | "sourceSpellId" | "origin" | "targetPosition" | "delivery">,
): number {
return event.phase === "impact" && event.source === "runewaker" && event.sourceSpellId === 490256
? projectileTravelDurationMs(event) : 0;
}
/** Approximate RuneWaker's target `freeze_hit` attachment from displayed creature bounds. */
export function risingTideImpactPlacement(
event: Pick<CombatPresentationEvent, "origin" | "targetPosition">,
size: CombatEffectTargetVisualSize | null,
): { readonly offsetX: number; readonly height: number; readonly offsetZ: number; readonly scale: number } {
if (!size || !event.targetPosition || !Number.isFinite(size.height)
|| !Number.isFinite(size.radius)) {
return { offsetX: 0, height: 1.05, offsetZ: 0, scale: 1 };
}
const radius = Math.max(0.6, Math.min(6, size.radius));
// Giant bosses can extend beyond the camera's top edge. Keep the hit on the
// visible lower body even when their model label is many metres above ground.
const height = Math.max(1.05, Math.min(2.4, size.height * 0.45));
const frontDistance = Math.max(0, radius - 0.25);
const towardCasterX = event.origin[0] - event.targetPosition[0];
const towardCasterZ = event.origin[2] - event.targetPosition[2];
const horizontalLength = Math.hypot(towardCasterX, towardCasterZ);
const directionX = horizontalLength > 0.001 ? towardCasterX / horizontalLength : 0;
const directionZ = horizontalLength > 0.001 ? towardCasterZ / horizontalLength : 0;
return {
offsetX: directionX * frontDistance,
height,
offsetZ: directionZ * frontDistance,
scale: Math.max(1, Math.min(5, radius / 0.9)),
};
}
export interface CombatEffectFrameState { export interface CombatEffectFrameState {
readonly elapsedMs: number; readonly elapsedMs: number;
readonly motionDeltaSeconds: number; readonly motionDeltaSeconds: number;
+44 -1
View File
@@ -2,7 +2,8 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
parseRunewakerParticleGraph, runeDurationMs, runeEmissionsBetween, runeLayers, runeMotion, runeSample, parseRunewakerParticleGraph, runeDurationMs, runeEmissionsBetween, runeGlobalAlpha,
runeLayers, runeMotion, runeSample,
} from "./runewakerParticleRuntime"; } from "./runewakerParticleRuntime";
const archiveRoot = path.join(process.cwd(), "public/assets/game/combat-effects/original/runewaker"); const archiveRoot = path.join(process.cwd(), "public/assets/game/combat-effects/original/runewaker");
@@ -46,6 +47,48 @@ describe("RuneWaker sprite particle runtime", () => {
expect(() => parseRunewakerParticleGraph(graphFor("earth/act_casting_c_hand.ros"))).toThrow(); expect(() => parseRunewakerParticleGraph(graphFor("earth/act_casting_c_hand.ros"))).toThrow();
}); });
it("renders the source Rising Tide cast, projectile, and target hit layers", () => {
const cast = parseRunewakerParticleGraph(graphFor("ice/act_icecone01_c_hand.ros"));
const release = parseRunewakerParticleGraph(graphFor("ice/act_icecone01_f.ros"));
const impact = parseRunewakerParticleGraph(graphFor("ice/act_riptide_t_freeze_hit.ros"));
expect(runeLayers(cast, "cast-start")).toHaveLength(4);
expect(runeLayers(release, "release")).toHaveLength(3);
expect(runeMotion(impact, "impact")?.name).toBe("explode");
expect(runeLayers(impact, "impact")).toHaveLength(5);
expect(runeGlobalAlpha(cast, "cast-start", 0.3)).toBe(1);
expect(runeGlobalAlpha(impact, "impact", 0.1)).toBe(1);
const altered = graphFor("ice/act_riptide_t_freeze_hit.ros");
altered.motions[0].controllers.find((controller: { eventID: number }) => controller.eventID === 1).eventID = 25;
expect(() => parseRunewakerParticleGraph(altered)).toThrow();
});
it("accepts Urgent Heal's original sprites, rune entity, and level-ground decal as partial", () => {
const graph = parseRunewakerParticleGraph(graphFor("holy/act_benedictory_heal_t_down.ros"));
expect(graph.rendererReady).toBe(false);
expect(graph.partialRendererReady).toBe(true);
expect(graph.visualFidelity).toBe("partial-original");
expect(graph.omittedVisuals).toEqual(["CRuEntity_Decal:terrain-projection"]);
expect(runeLayers(graph, "impact")).toHaveLength(2);
expect(graph.runeMesh?.positions).toHaveLength(72 * 9);
expect(graph.runeMesh?.uvs).toHaveLength(72 * 6);
expect(graph.motions[0].controllers.filter((controller) =>
controller.type === "entity-visualizer" || controller.type === "decal"
|| controller.type === "global-color")).toHaveLength(3);
expect(runeDurationMs(graph, "impact")).toBeGreaterThan(1_630);
expect(runeGlobalAlpha(graph, "impact", 0)).toBe(1);
const altered = graphFor("holy/act_benedictory_heal_t_down.ros");
altered.motions[0].controllers.find((controller: { sourceType: string }) =>
controller.sourceType === "CRuController_Particle_Visualizer_Entity").entityName = "model/other.ros";
expect(() => parseRunewakerParticleGraph(altered)).toThrow();
const missingMesh = graphFor("holy/act_benedictory_heal_t_down.ros");
delete missingMesh.runeMesh;
expect(() => parseRunewakerParticleGraph(missingMesh)).toThrow();
const wrongDecal = graphFor("holy/act_benedictory_heal_t_down.ros");
wrongDecal.motions[0].controllers.find((controller: { type: string }) =>
controller.type === "decal").decalTexture = "texture/other.dds";
expect(() => parseRunewakerParticleGraph(wrongDecal)).toThrow();
});
it("keeps a 33 ms source burst when the first browser frame is slower", () => { it("keeps a 33 ms source burst when the first browser frame is slower", () => {
const graph = parseRunewakerParticleGraph(graphFor("act_shieldstorm_t_freeze_hit_e.ros")); const graph = parseRunewakerParticleGraph(graphFor("act_shieldstorm_t_freeze_hit_e.ros"));
const rate = runeLayers(graph, "impact")[0].emitter.tracks[9] as { time: number; value: number }[]; const rate = runeLayers(graph, "impact")[0].emitter.tracks[9] as { time: number; value: number }[];
+265 -15
View File
@@ -11,7 +11,7 @@ export interface RuneSpriteController {
readonly textureAssetUrl: string; readonly textureAssetUrl: string;
readonly shader: "ruSHADER_PARTICLE_SPRITEEX"; readonly shader: "ruSHADER_PARTICLE_SPRITEEX";
readonly blendMode: 0 | 1 | 2; readonly blendMode: 0 | 1 | 2;
readonly billboardType: 1; readonly billboardType: 1 | 2;
readonly tileSizePixels: readonly [number, number]; readonly tileSizePixels: readonly [number, number];
readonly frameRate: number; readonly frameRate: number;
readonly frameCount: number; readonly frameCount: number;
@@ -36,16 +36,61 @@ export interface RuneEmitterController {
readonly tracks: readonly RuneTrack<number | RuneVector3>[]; readonly tracks: readonly RuneTrack<number | RuneVector3>[];
} }
export interface RuneAudioTriggerController { export interface RuneEventTriggerController {
readonly type: "unsupported"; readonly type: "unsupported";
readonly sourceType: "CRuController_EventTrigger"; readonly sourceType: "CRuController_EventTrigger";
readonly anchor: string; readonly anchor: string;
readonly eventID: 43; readonly eventID: number;
readonly eventParameter0: 1; readonly eventParameter0: number;
readonly eventParameter1: 0; readonly eventParameter1: number;
readonly eventString: string; readonly eventString: string;
readonly eventDuration: number; readonly eventDuration: number;
readonly startTime: 0; readonly startTime: number;
}
export interface RuneGlobalAlphaController {
readonly type: "global-alpha";
readonly sourceType: "CRuController_GlobalAlpha";
readonly anchor: string;
readonly startTime: number;
readonly duration: number;
readonly alphaTrack: RuneTrack<number>;
}
export interface RuneEntityVisualizerController {
readonly type: "entity-visualizer";
readonly sourceType: "CRuController_Particle_Visualizer_Entity";
readonly anchor: "frame/rune";
readonly entityName: string;
readonly orientToTravel: false;
readonly tracks: readonly [RuneTrack<string>, RuneTrack<readonly [number, number, number, number]>,
RuneTrack<number>, RuneTrack<number>];
}
export interface RuneDecalController {
readonly type: "decal";
readonly sourceType: "CRuController_Decal";
readonly anchor: "frame/decal";
readonly decalTexture: string;
readonly textureAssetUrl: string;
readonly billboardType: 0;
readonly tracks: readonly [RuneTrack<string>, RuneTrack<number>, RuneTrack<number>];
}
export interface RuneGlobalColorController {
readonly type: "global-color";
readonly sourceType: "CRuController_GlobalColor";
readonly anchor: "frame/decal";
readonly tracks: readonly [RuneTrack<readonly [number, number, number, number]>];
}
export interface RuneMeshGeometry {
readonly sourceModel: string;
readonly sourceSha256: string;
readonly sourceTexture: string;
readonly textureAssetUrl: string;
readonly positions: readonly number[];
readonly uvs: readonly number[];
} }
export interface RuneParticleLayer { export interface RuneParticleLayer {
@@ -61,10 +106,16 @@ export interface RunewakerParticleGraph {
readonly sourceModel: string; readonly sourceModel: string;
readonly sourceSha256: string; readonly sourceSha256: string;
readonly rendererReady: boolean; readonly rendererReady: boolean;
readonly partialRendererReady?: boolean;
readonly visualFidelity?: "partial-original";
readonly omittedVisuals?: readonly string[];
readonly runeMesh?: RuneMeshGeometry;
readonly textures: readonly { readonly url: string; readonly sourcePath: string }[]; readonly textures: readonly { readonly url: string; readonly sourcePath: string }[];
readonly motions: readonly { readonly motions: readonly {
readonly name: string; readonly name: string;
readonly controllers: readonly (RuneSpriteController | RuneEmitterController | RuneAudioTriggerController)[]; readonly controllers: readonly (RuneSpriteController | RuneEmitterController
| RuneEventTriggerController | RuneGlobalAlphaController
| RuneEntityVisualizerController | RuneDecalController | RuneGlobalColorController)[];
}[]; }[];
readonly sprites: readonly { readonly name: string; readonly position: RuneVector3 }[]; readonly sprites: readonly { readonly name: string; readonly position: RuneVector3 }[];
readonly unsupported: readonly string[]; readonly unsupported: readonly string[];
@@ -105,6 +156,163 @@ function isVisualOnlyHolyCastTrigger(
&& finite(controller.eventDuration) && controller.eventDuration > 0; && finite(controller.eventDuration) && controller.eventDuration > 0;
} }
const ICE_CAST_MODEL = "model/fx/skill/ice/act_icecone01_c_hand.ros";
const ICE_RELEASE_MODEL = "model/fx/skill/ice/act_icecone01_f.ros";
const RIPTIDE_IMPACT_MODEL = "model/fx/skill/ice/act_riptide_t_freeze_hit.ros";
const URGENT_IMPACT_MODEL = "model/fx/skill/holy/act_benedictory_heal_t_down.ros";
const expectedSpecialControllerCounts = new Map([
["model/fx/skill/holy/act_casting_c_hand.ros", [1, 0]],
[ICE_CAST_MODEL, [3, 3]],
[ICE_RELEASE_MODEL, [4, 0]],
[RIPTIDE_IMPACT_MODEL, [2, 1]],
[URGENT_IMPACT_MODEL, [1, 1]],
]);
const urgentOmittedVisuals = [
"CRuEntity_Decal:terrain-projection",
];
const urgentUnsupportedTypes = new Set([
"CRuController_EventTrigger",
]);
function isVerifiedUrgentPartial(graph: Record<string, unknown>): boolean {
const mesh = graph.runeMesh && typeof graph.runeMesh === "object"
? graph.runeMesh as Record<string, unknown> : null;
return graph.sourceModel === URGENT_IMPACT_MODEL
&& graph.rendererReady === false && graph.partialRendererReady === true
&& graph.visualFidelity === "partial-original"
&& Array.isArray(graph.omittedVisuals)
&& graph.omittedVisuals.length === urgentOmittedVisuals.length
&& graph.omittedVisuals.every((visual, index) => visual === urgentOmittedVisuals[index])
&& Array.isArray(graph.unsupported)
&& graph.unsupported.length === urgentUnsupportedTypes.size
&& graph.unsupported.every((type) => urgentUnsupportedTypes.has(type))
&& mesh?.sourceModel === "model/fx/skill/holy/_polygon/rune_cylinder.ros"
&& mesh.sourceTexture === "texture/fx/skill/rune.dds"
&& typeof mesh.sourceSha256 === "string" && /^[a-f0-9]{64}$/.test(mesh.sourceSha256)
&& typeof mesh.textureAssetUrl === "string"
&& Array.isArray(mesh.positions) && mesh.positions.length === 72 * 9 && mesh.positions.every(finite)
&& Array.isArray(mesh.uvs) && mesh.uvs.length === 72 * 6 && mesh.uvs.every(finite);
}
function isUrgentPartialController(
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
): boolean {
if (!isVerifiedUrgentPartial(graph) || motion.name !== "explode") return false;
return controller.sourceType === "CRuController_EventTrigger" && controller.anchor === "frame"
&& controller.eventID === 43 && controller.eventParameter0 === 0
&& controller.eventParameter1 === 0 && controller.startTime === 0
&& controller.eventDuration === 0
&& normalizedEventString(controller) === "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3";
}
function stringKeys(value: unknown): value is RuneTrack<string> {
return Array.isArray(value) && value.every((key) => {
const sample = record(key);
return finite(sample.time) && typeof sample.value === "string";
});
}
function isUrgentEntityController(graph: Record<string, unknown>, controller: Record<string, unknown>): boolean {
if (!isVerifiedUrgentPartial(graph)) return false;
const tracks = controller.tracks;
return controller.sourceType === "CRuController_Particle_Visualizer_Entity"
&& controller.anchor === "frame/rune" && controller.orientToTravel === false
&& typeof controller.entityName === "string"
&& controller.entityName.replaceAll("\\", "/").toLowerCase()
=== "model/fx/skill/holy/_polygon/rune_cylinder.ros"
&& Array.isArray(tracks) && tracks.length === 4
&& stringKeys(tracks[0]) && tracks[0].length === 2
&& tracks[0][0].value === "idle"
&& keys(tracks[1]) && tracks[1].length === 2
&& tracks[1].every((key) => Array.isArray(key.value) && key.value.length === 4)
&& keys(tracks[2]) && tracks[2].length === 3
&& keys(tracks[3]) && tracks[3].length === 3;
}
function isUrgentDecalController(graph: Record<string, unknown>, controller: Record<string, unknown>): boolean {
if (!isVerifiedUrgentPartial(graph)) return false;
const tracks = controller.tracks;
return controller.sourceType === "CRuController_Decal"
&& controller.anchor === "frame/decal" && controller.billboardType === 0
&& typeof controller.decalTexture === "string"
&& controller.decalTexture.replaceAll("\\", "/").toLowerCase()
=== "texture/fx/skill/kan_rune_circle.dds"
&& typeof controller.textureAssetUrl === "string"
&& Array.isArray(tracks) && tracks.length === 3
&& stringKeys(tracks[0]) && tracks[0].length === 1
&& tracks[0][0].value.replaceAll("\\", "/").toLowerCase()
=== "texture/fx/skill/kan_rune_circle.dds"
&& keys(tracks[1]) && tracks[1].length === 2
&& keys(tracks[2]) && tracks[2].length === 3;
}
function isUrgentGlobalColor(graph: Record<string, unknown>, controller: Record<string, unknown>): boolean {
if (!isVerifiedUrgentPartial(graph)) return false;
const tracks = controller.tracks;
return controller.sourceType === "CRuController_GlobalColor"
&& controller.anchor === "frame/decal" && Array.isArray(tracks)
&& tracks.length === 1 && keys(tracks[0]) && tracks[0].length === 2
&& tracks[0].every((key) => Array.isArray(key.value) && key.value.length === 4);
}
function normalizedEventString(controller: Record<string, unknown>) {
return typeof controller.eventString === "string"
? controller.eventString.replaceAll("\\", "/").toLowerCase() : null;
}
function isVisualOnlyRisingTideTrigger(
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
): boolean {
if (graph.rendererReady !== true || controller.sourceType !== "CRuController_EventTrigger"
|| controller.startTime !== 0 || controller.eventParameter1 !== 0) return false;
const eventString = normalizedEventString(controller);
if (graph.sourceModel === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(String(motion.name))) {
return controller.eventID === 43 && controller.eventParameter0 === 1
&& eventString === "sound/fx/skill/ice/act_cone01_c_hand.mp3"
&& finite(controller.eventDuration) && controller.eventDuration > 0;
}
if (graph.sourceModel === ICE_RELEASE_MODEL && ["idle", "appear", "disappear"].includes(String(motion.name))) {
return controller.eventID === 44 && controller.eventParameter0 === 1
&& eventString === "missile" && controller.eventDuration === 1
|| motion.name === "appear" && controller.eventID === 43
&& controller.eventParameter0 === 0 && controller.eventDuration === 0
&& eventString === "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3";
}
if (graph.sourceModel === RIPTIDE_IMPACT_MODEL && motion.name === "explode") {
return controller.eventID === 1 && controller.eventParameter0 === 0
&& controller.eventDuration === 0 && eventString === ""
|| controller.eventID === 43 && controller.eventParameter0 === 0
&& controller.eventDuration === 0
&& eventString === "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3";
}
return false;
}
function isVerifiedGlobalAlpha(
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
): boolean {
if (graph.rendererReady !== true && !isVerifiedUrgentPartial(graph)) return false;
if (controller.sourceType !== "CRuController_GlobalAlpha"
|| controller.startTime !== 0 || !finite(controller.duration) || !keys(controller.alphaTrack)
|| !controller.alphaTrack.every((key) => finite(key.value) && key.value >= 0 && key.value <= 1)) return false;
const track = controller.alphaTrack;
if (graph.sourceModel === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(String(motion.name))) {
return motion.name === "appear"
? controller.duration > 0 && track.length === 2
&& track[0].time === 0 && track[0].value === 0 && track[1].value === 1
: controller.duration === 0 && track.length === 1
&& track[0].time === 0 && track[0].value === 1;
}
if (isVerifiedUrgentPartial(graph) && motion.name === "explode") {
return controller.anchor === "frame/decal" && controller.duration > 0
&& track.length === 4 && track[0].time === 0 && track[0].value === 0
&& track[1].value === 1 && track[2].value === 1 && track[3].value === 0;
}
return graph.sourceModel === RIPTIDE_IMPACT_MODEL && motion.name === "explode"
&& controller.duration === 0 && track.length === 1
&& track[0].time === 0 && track[0].value === 1;
}
/** Parse only the source controller slice implemented by the browser renderer. */ /** Parse only the source controller slice implemented by the browser renderer. */
export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGraph { export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGraph {
const graph = record(value); const graph = record(value);
@@ -114,12 +322,20 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
|| !Array.isArray(graph.sprites) || !Array.isArray(graph.unsupported) || !Array.isArray(graph.sprites) || !Array.isArray(graph.unsupported)
|| !Array.isArray(graph.missing)) throw new TypeError("Invalid RuneWaker particle graph header."); || !Array.isArray(graph.missing)) throw new TypeError("Invalid RuneWaker particle graph header.");
if (graph.missing.length) throw new TypeError("RuneWaker graph has missing source layers."); if (graph.missing.length) throw new TypeError("RuneWaker graph has missing source layers.");
if (graph.unsupported.some((type) => type !== "CRuController_EventTrigger")) { const urgentPartial = isVerifiedUrgentPartial(graph);
if (!urgentPartial && graph.unsupported.some((type) => type !== "CRuController_EventTrigger")) {
throw new TypeError("RuneWaker graph has unsupported source layers."); throw new TypeError("RuneWaker graph has unsupported source layers.");
} }
const textureUrls = new Set(graph.textures.map((texture) => record(texture).url)); const textureUrls = new Set(graph.textures.map((texture) => record(texture).url));
if (urgentPartial && !textureUrls.has((graph.runeMesh as RuneMeshGeometry).textureAssetUrl)) {
throw new TypeError("Urgent Heal rune mesh texture is not packaged.");
}
let layerCount = 0; let layerCount = 0;
let audioTriggerCount = 0; let visualOnlyTriggerCount = 0;
let globalAlphaCount = 0;
let entityCount = 0;
let decalCount = 0;
let colorCount = 0;
for (const rawMotion of graph.motions) { for (const rawMotion of graph.motions) {
const motion = record(rawMotion); const motion = record(rawMotion);
if (typeof motion.name !== "string" || !Array.isArray(motion.controllers)) { if (typeof motion.name !== "string" || !Array.isArray(motion.controllers)) {
@@ -133,13 +349,13 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
if (typeof controller.anchor !== "string" || controller.particlePath !== "" if (typeof controller.anchor !== "string" || controller.particlePath !== ""
|| controller.emitterPath !== "" || !Array.isArray(controller.tracks) || controller.emitterPath !== "" || !Array.isArray(controller.tracks)
|| controller.tracks.length !== 27 || !controller.tracks.every(keys) || controller.tracks.length !== 27 || !controller.tracks.every(keys)
|| ![0, 1, 4].includes(Number((controller.tracks[1] as RuneTrack<number>)[0]?.value))) { || ![0, 1, 4, 5].includes(Number((controller.tracks[1] as RuneTrack<number>)[0]?.value))) {
throw new TypeError("Unsupported RuneWaker emitter controller."); throw new TypeError("Unsupported RuneWaker emitter controller.");
} }
} else if (controller.type === "sprite") { } else if (controller.type === "sprite") {
if (typeof controller.anchor !== "string" || !emitters.has(controller.anchor) if (typeof controller.anchor !== "string" || !emitters.has(controller.anchor)
|| controller.shader !== "ruSHADER_PARTICLE_SPRITEEX" || controller.shader !== "ruSHADER_PARTICLE_SPRITEEX"
|| ![0, 1, 2].includes(Number(controller.blendMode)) || controller.billboardType !== 1 || ![0, 1, 2].includes(Number(controller.blendMode)) || ![1, 2].includes(Number(controller.billboardType))
|| typeof controller.textureAssetUrl !== "string" || !textureUrls.has(controller.textureAssetUrl) || typeof controller.textureAssetUrl !== "string" || !textureUrls.has(controller.textureAssetUrl)
|| !Array.isArray(controller.tileSizePixels) || !controller.tileSizePixels.every(finite) || !Array.isArray(controller.tileSizePixels) || !controller.tileSizePixels.every(finite)
|| !Array.isArray(controller.tracks) || controller.tracks.length !== 4 || !Array.isArray(controller.tracks) || controller.tracks.length !== 4
@@ -149,12 +365,31 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
} }
layerCount++; layerCount++;
} else if (controller.type === "unsupported" && isVisualOnlyHolyCastTrigger(graph, motion, controller)) { } else if (controller.type === "unsupported" && isVisualOnlyHolyCastTrigger(graph, motion, controller)) {
audioTriggerCount++; visualOnlyTriggerCount++;
} else if (controller.type === "unsupported" && isVisualOnlyRisingTideTrigger(graph, motion, controller)) {
visualOnlyTriggerCount++;
} else if (controller.type === "unsupported" && isUrgentPartialController(graph, motion, controller)) {
visualOnlyTriggerCount++;
} else if (controller.type === "entity-visualizer" && isUrgentEntityController(graph, controller)) {
if (!emitters.has(controller.anchor)) throw new TypeError("Urgent rune entity has no emitter.");
entityCount++;
} else if (controller.type === "decal" && isUrgentDecalController(graph, controller)) {
if (!textureUrls.has(controller.textureAssetUrl)) throw new TypeError("Urgent decal texture is not packaged.");
decalCount++;
} else if (controller.type === "global-color" && isUrgentGlobalColor(graph, controller)) {
colorCount++;
} else if (controller.type === "global-alpha" && isVerifiedGlobalAlpha(graph, motion, controller)) {
globalAlphaCount++;
} else throw new TypeError(`Unsupported RuneWaker controller ${String(controller.sourceType)}.`); } else throw new TypeError(`Unsupported RuneWaker controller ${String(controller.sourceType)}.`);
} }
} }
if (audioTriggerCount !== graph.unsupported.length || audioTriggerCount > 1) { const [expectedTriggers, expectedAlpha] = expectedSpecialControllerCounts.get(graph.sourceModel) ?? [0, 0];
throw new TypeError("RuneWaker graph has unverified event triggers."); if (visualOnlyTriggerCount !== expectedTriggers || globalAlphaCount !== expectedAlpha
|| entityCount !== (urgentPartial ? 1 : 0)
|| decalCount !== (urgentPartial ? 1 : 0)
|| colorCount !== (urgentPartial ? 1 : 0)
|| graph.unsupported.length !== (urgentPartial ? urgentUnsupportedTypes.size : expectedTriggers > 0 ? 1 : 0)) {
throw new TypeError("RuneWaker graph has unverified event or alpha controllers.");
} }
if (!layerCount) throw new TypeError("RuneWaker graph has no renderable sprites."); if (!layerCount) throw new TypeError("RuneWaker graph has no renderable sprites.");
return graph as unknown as RunewakerParticleGraph; return graph as unknown as RunewakerParticleGraph;
@@ -216,6 +451,17 @@ export function runeLayers(graph: RunewakerParticleGraph, phase: CombatPresentat
}); });
} }
export function runeGlobalAlpha(graph: RunewakerParticleGraph, phase: CombatPresentationPhase, time: number): number {
// Urgent Heal's alpha track belongs to its ground decal, not its sprite layers.
if (graph.partialRendererReady) return 1;
const controller = runeMotion(graph, phase)?.controllers.find(
(candidate): candidate is RuneGlobalAlphaController => candidate.type === "global-alpha",
);
if (!controller) return 1;
return Math.max(0, Math.min(1, runeSample(controller.alphaTrack,
Math.max(0, time - controller.startTime), 1)));
}
export function runeDurationMs(graph: RunewakerParticleGraph, phase: CombatPresentationPhase): number { export function runeDurationMs(graph: RunewakerParticleGraph, phase: CombatPresentationPhase): number {
const layers = runeLayers(graph, phase); const layers = runeLayers(graph, phase);
const lastEmission = layers.reduce((maximum, layer) => Math.max(maximum, const lastEmission = layers.reduce((maximum, layer) => Math.max(maximum,
@@ -223,5 +469,9 @@ export function runeDurationMs(graph: RunewakerParticleGraph, phase: CombatPrese
const maxLife = layers.reduce((maximum, layer) => Math.max(maximum, const maxLife = layers.reduce((maximum, layer) => Math.max(maximum,
...layer.emitter.tracks[12].map((sample) => Number(sample.value)), ...layer.emitter.tracks[12].map((sample) => Number(sample.value)),
...layer.emitter.tracks[13].map((sample) => Number(sample.value))), 0); ...layer.emitter.tracks[13].map((sample) => Number(sample.value))), 0);
return Math.max(400, Math.min(6_000, (lastEmission + maxLife + 0.2) * 1_000)); const decal = runeMotion(graph, phase)?.controllers.find(
(controller): controller is RuneDecalController => controller.type === "decal",
);
const decalEnd = decal ? Math.max(...decal.tracks[2].map((sample) => sample.time)) : 0;
return Math.max(400, Math.min(6_000, Math.max(lastEmission + maxLife + 0.2, decalEnd) * 1_000));
} }