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

+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
from `romPlayerCatalog.generated.json` and `magiccollectobject.db`, effect model
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
explicit sound ID from `imageobject.db`. Each cue is tagged with its source
cast phase so a target impact does not replay a caster sound.
parallel `sound/fx` tree. Verified ROS audio events supply cues when their
embedded path differs from the mirrored model path. Each cue is tagged with
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
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
to `docs/ROM_SPELL_MEDIA_MAPPING.md`. It distinguishes source ROS references,
packaged audio, and browser-playable original visual graphs. The WebGL sprite
renderer plays Priest Urgent Heal, Heal, and Group Heal casts, Knight Charge
cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The exact
renderer plays Priest Urgent Heal, Heal, and Group Heal casts; Urgent Heal's
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
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`
@@ -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
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
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"],
];
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) {
return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])]
@@ -27,10 +31,15 @@ function skillMedia(skill) {
const ids = idsForSkill(skill);
const mapped = ids.map((id) => entries.get(id)).filter(Boolean);
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 ?? [])
.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())
.filter((asset) => asset.effectGraphUrl).length;
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.",
"",
`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 |",
"| --- | --- | ---: | ---: | ---: | ---: |",
@@ -85,12 +94,12 @@ const lines = [
"## Decoded particle archive",
"",
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.",
"",
"## 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`;
if (process.argv.includes("--write")) {
+109 -7
View File
@@ -534,6 +534,38 @@ export function runewakerSoundPathsForModel(modelPath, resourceRoot) {
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) {
const count = buffer.readInt32LE(132);
const recordSize = 1032;
@@ -548,27 +580,82 @@ function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
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) {
const archivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
if (!fs.existsSync(archivePath)) return;
const archive = JSON.parse(fs.readFileSync(archivePath, "utf8"));
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]));
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 asset = entry?.phases?.[phase]?.find((candidate) => (
candidate.model.toLowerCase() === 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));
if (!fs.existsSync(graphPath)) continue;
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.effectGraphUrl = result.graphUrl;
if (partialVisual) asset.visualCompleteness = "partial";
if (scale) asset.scale = scale;
if (loopCastSound) for (const sound of entry.sounds ?? []) {
if (sound.phase === "cast-start"
@@ -582,15 +669,29 @@ function attachVerifiedRuneWakerParticles(entries) {
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
const resourceRoot = path.dirname(dataRoot);
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);
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 images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages);
const requestedImages = [...new Set([
...spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean),
...[...targetEffects.values()].map((effect) => effect.imageId),
])];
const images = parseRunewakerImageRecords(imageObject, requestedImages);
const entries = [];
for (const spellId of spellIds) {
const imageId = imageBySpellId.get(spellId);
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 sounds = [];
for (const sourcePath of paths) {
@@ -628,6 +729,7 @@ function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
});
}
attachVerifiedRuneWakerEventSounds(entries);
attachVerifiedRuneWakerParticles(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"));
});
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", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const expected = new Set([
"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",
"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",
"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",
@@ -372,7 +412,14 @@ test("only browser-verified RuneWaker particle phases use original graphs", () =
assert.equal(asset.effectKind, "runewaker-particles");
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"));
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);
}
}
@@ -92,6 +92,17 @@ function sourceFile(resourcePath) {
const textureCache = new Map();
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 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) {
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.eventParameter0 === 1 // Loop during cast.
&& controller.eventParameter1 === 0
&& controller.eventString?.replaceAll("\\", "/").toLowerCase() === HOLY_CAST_SOUND
&& normalizedEventString(controller) === HOLY_CAST_SOUND
&& controller.startTime === 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) {
const normalized = resourcePath.replaceAll("\\", "/").toLowerCase();
if (textureCache.has(normalized)) return textureCache.get(normalized);
@@ -132,11 +253,63 @@ function packageTexture(resourcePath, scratch) {
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 results = [];
try {
for (const model of selectedModels) {
const result = { model, status: "failed", graphUrl: null, rendererReady: false,
partialRendererReady: false,
spriteControllers: 0, textures: 0, unsupported: [], errors: [] };
results.push(result);
try {
@@ -151,8 +324,37 @@ try {
throw new Error(`Unexpected native particle report schema for ${model}`);
}
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) {
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;
result.spriteControllers++;
if (!controller.texture) {
@@ -168,7 +370,9 @@ try {
}
}
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.status = result.errors.length || result.unsupported.length ? "partial" : "decoded";
const unsupportedControllers = decoded.motions.flatMap((motion) => motion.controllers
@@ -176,14 +380,26 @@ try {
.map((controller) => ({ motion, controller })));
// The holy casting graph's sole non-particle controller is a 3D WAV
// 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 }) => (
visualOnlyHolyCastTrigger(model, motion, controller)
|| visualOnlyRisingTideTrigger(model, motion, controller)
))
&& globalAlphaControllers.every(({ motion, controller }) => (
verifiedGlobalAlpha(model, motion, controller)
))
&& result.unsupported.every((type) => type === "CRuController_EventTrigger");
result.rendererReady = verifiedRuneWakerParticleModels.has(model)
&& 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 = {
schemaVersion: 1,
kind: "runewaker-ros-particle-archive",
@@ -192,6 +408,11 @@ try {
sourceSha256,
coordinateTransform: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
rendererReady: result.rendererReady,
...(result.partialRendererReady ? {
partialRendererReady: true,
visualFidelity: "partial-original",
omittedVisuals: urgentOmittedVisuals,
} : {}),
shader: {
name: "ruSHADER_PARTICLE_SPRITEEX",
sourceImplementation: "Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp",
@@ -212,6 +433,14 @@ try {
},
},
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,
sprites: decoded.sprites,
unsupported: [...new Set(result.unsupported)],
@@ -18,7 +18,7 @@ function assetFile(url) {
}
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.failedModels, 0);
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.sourceModel, result.model);
assert.equal(graph.rendererReady, result.rendererReady);
assert.equal(Boolean(graph.partialRendererReady), result.partialRendererReady);
assert.equal(result.rendererReady, verifiedRuneWakerParticleModels.has(result.model)
&& !result.partialRendererReady
&& result.errors.length === 0
&& result.unsupported.every((type) => type === "CRuController_EventTrigger"));
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);
}
}
assert.equal(spriteControllers, 510);
assert.ok(textureReferences >= 103);
assert.equal(spriteControllers, 515);
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 = [
{ 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: 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: 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" },
@@ -21,6 +21,9 @@
#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_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_Visualizer_Sprite.h"
#include "Rune Engine/Rune/Scene/Terrain/RuWorld_Base.h"
@@ -1507,6 +1510,20 @@ namespace
{ WriteFxRealTrack(out, **track); return; }
if(CRuAnimTrack<CRuColor4> **track = boost::get<CRuAnimTrack<CRuColor4> *>(&variant))
{ 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 << "[";
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 isEmitter = base->GetType().IsSubClassOf(CRuController_Particle_Emitter::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 << ",";
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())
<< "\",\"anchor\":\"" << JsonEscape(node->GetAnchorTarget() ? node->GetAnchorTarget() : "") << "\"";
if(isEventTrigger)
@@ -1580,6 +1602,37 @@ namespace
<< ",\"eventDuration\":" << FiniteFxValue(trigger->GetEventDuration())
<< ",\"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)
{
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 << ",\"prerunTime\":" << FiniteFxValue(emitter->GetEmitterPrerunTime());
}
if(isSprite || isEmitter)
if(isSprite || isEmitter || isEntityVisualizer || isDecal || isGlobalColor)
{
IRuEntity_KeyableController *keyable = static_cast<IRuEntity_KeyableController *>(base);
out << ",\"tracks\":[";