Update Healer Man server

This commit is contained in:
phenom committed 2026-09-25 18:46:09 -04:00
1 parent a868401ebc
commit 48a678dcc5
295 files changed
+7644 -467

No files matched your search

+32 -15
View File
@@ -52,11 +52,15 @@ retain the verified media-pack download requirement.
`npm run effects:rom` refreshes only RuneWaker manifest entries and packages
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. 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.
from `romPlayerCatalog.generated.json` and `magiccollectobject.db`. It follows
each direct MagicCollect component through `magicobject.db` to ImageObject
`ExtendACT` target models, alongside the spell's own ImageObject paths. Exact
same-name cues under the client's parallel `sound/fx` tree and zero-offset
direct ROS audio events supply phase-tagged cues. Delayed and fade-out events
stay in the batch report until their timeline has a matching runtime event.
The combat audio player layers distinct RuneWaker cues on the same phase,
including separate ROS appear and idle sounds; WoW sources retain weighted
single-cue selection.
`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
@@ -74,26 +78,39 @@ 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; 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`
packaged audio, and browser-playable original visual graphs. The packager
enables any graph whose sprite, emitter, texture, subtree alpha, and nonvisual
event callback data meet the browser renderer's validated schema. Event IDs that spawn an
effect or control projectile flight remain blocked. Urgent Heal's target rune
and decal are an explicit partial-original exception; `rom-particle-readiness.mjs`
holds hand-reviewed scale and fidelity overrides. Other ROS phases retain
generated visual fallbacks until their controller families are implemented.
Open `scripts/combat-effects/rom-preview.html`
with the Vite dev server to inspect the decoded sprite models. The emitter
integrates source rate keys over each rendered frame so short bursts survive
slower frames. Whirlwind Shield uses a 2.5 presentation scale after live
dungeon review.
`npm run effects:rom:plan` creates `docs/ROM_MEDIA_BATCH_PLAN.md` and its
machine-readable JSON companion from the current primary-five manifest and ROS
archive. It groups models by unsupported controller family, lists fully decoded
graphs awaiting visual review, and flags embedded one-shot audio paths absent
from the manifest as candidates for timeline support. Run it after the audio
and particle package steps. The generator attaches source direct audio events
at the matching phase boundary when their motion and zero-offset timing are
known; remaining candidates require a different timeline or phase mapping.
`npm run effects:rom:batch` runs the manifest/audio pass, particle package,
manifest attachment pass, media audit, and batch plan in that order. Build the
native exporter before running it when its C++ source changes.
The Priest's holy `act_casting_c_hand.ros` has three renderable sprite layers
and one `CRuController_EventTrigger` in its `appear` motion. The RuneWaker
source identifies event 43 as looping direct 3D audio for
`sound/fx/skill/holy/act_casting_c_hand.wav`, not another visual layer. The
packager marks this exact model renderer-ready after validating that event's
packager marks the model renderer-ready after validating the event's
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.
stops on release or cancellation. The same source event rule applies to the
five class cohort when IDs 1, 41, 43, 44, 50, or 61 carry valid data.
Urgent Heal's target effect is linked through MagicCollect spell `491147` to
MagicObject component `501543`, whose ImageObject `573048` supplies
+106 -59
View File
@@ -580,9 +580,7 @@ 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.
// Keep these source links as regression guards for the two hand-reviewed spells.
const verifiedRuneWakerTargetEffects = [
{ spellId: 491147, componentId: 501543, imageId: 573048,
model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", point: "p_down" },
@@ -590,25 +588,31 @@ const verifiedRuneWakerTargetEffects = [
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();
function runewakerTargetModels(magicCollect, magicObject, imageObject, spellIds) {
const wanted = new Set(spellIds);
const componentsBySpell = new Map();
const componentIds = new Set();
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);
if (!wanted.has(spellId)) continue;
const components = new Set();
for (let field = 0x180; field <= 0x2c0; field += 4) {
const componentId = magicCollect.readInt32LE(offset + field);
if (componentId > 0) components.add(componentId);
}
componentsBySpell.set(spellId, components);
for (const componentId of components) componentIds.add(componentId);
}
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));
}
if (componentIds.has(componentId)) componentImages.set(componentId, magicObject.readInt32LE(offset + 0x20));
}
const imageIds = new Set([...componentImages.values()].filter((id) => id > 0));
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;
if (!imageIds.has(imageId)) continue;
const textField = (fieldOffset, length) => imageObject.subarray(
offset + fieldOffset, offset + fieldOffset + length,
).toString("latin1").split("\0", 1)[0].replaceAll("\\", "/").toLowerCase();
@@ -617,50 +621,94 @@ function verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, s
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}`);
const result = new Map();
for (const [spellId, components] of componentsBySpell) {
const effects = [];
const seen = new Set();
for (const componentId of components) {
const imageId = componentImages.get(componentId);
const fields = componentImageFields.get(imageId);
const model = fields?.extendAct;
if (!model || !/^model\/fx\/[\w./ -]+\.(?:ros|ras)$/.test(model) || model.includes("..")) continue;
const key = `${model}:${fields.extendPoint}`;
if (seen.has(key)) continue;
seen.add(key);
effects.push({ spellId, componentId, imageId, model, point: fields.extendPoint });
}
if (effects.length) result.set(spellId, effects);
}
return new Map(wanted.map((effect) => [effect.spellId, effect]));
for (const expected of verifiedRuneWakerTargetEffects) {
if (!wanted.has(expected.spellId)) continue;
if (!result.get(expected.spellId)?.some((effect) => Object.entries(expected).every(
([key, value]) => effect[key] === value,
))) throw new Error(`RuneWaker target media source link changed for spell ${expected.spellId}`);
}
return result;
}
function attachVerifiedRuneWakerParticles(entries) {
function attachPackagedRuneWakerMedia(entries, resourceRoot) {
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.partialRendererReady) && result.graphUrl)
.filter((result) => 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, 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 || (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
|| (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"
&& sound.sourcePath === "sound/fx/skill/holy/act_casting_c_hand.wav") {
sound.loopDuringCast = true;
const overrides = new Map(verifiedRuneWakerParticlePhases.map((effect) => (
[`${effect.spellId}:${effect.phase}:${effect.model}`, effect]
)));
const graphs = new Map();
for (const entry of entries) for (const [phase, assets] of Object.entries(entry.phases ?? {})) {
for (const asset of assets) {
const model = asset.model.toLowerCase();
const result = decoded.get(model);
if (!result) continue;
let graph = graphs.get(model);
if (!graph) {
const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1));
if (!fs.existsSync(graphPath)) continue;
graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
if (graph.sourceModel !== model) continue;
graphs.set(model, graph);
}
const override = overrides.get(`${entry.spellId}:${phase}:${model}`);
if (result.rendererReady && graph.rendererReady || override?.partialVisual
&& result.partialRendererReady && graph.partialRendererReady) {
asset.effectKind = "runewaker-particles";
asset.effectGraphUrl = result.graphUrl;
if (override?.partialVisual) asset.visualCompleteness = "partial";
if (override?.scale) asset.scale = override.scale;
}
// The client starts the appear motion before idle, while target hits use
// explode. Only zero-offset direct audio callbacks can play on a combat
// phase boundary with the current manifest audio schema.
const preferredMotion = phase === "impact" ? "explode" : "idle";
const selectedMotion = graph.motions?.find((motion) => motion.name === preferredMotion)
?? graph.motions?.find((motion) => motion.name === "appear")
?? graph.motions?.[0];
const eventMotions = new Set(phase === "impact" && graph.motions?.some((motion) => motion.name === "explode")
? ["explode"] : ["appear", "idle", selectedMotion?.name]);
const eventSounds = (graph.motions ?? []).filter((motion) => eventMotions.has(motion.name))
.flatMap((motion) => (motion.controllers ?? []).filter((controller) => (
controller.sourceType === "CRuController_EventTrigger"
&& [41, 43].includes(controller.eventID)
&& controller.startTime === 0
&& [0, 1].includes(controller.eventParameter0)
)).map((controller) => ({
sourcePath: controller.eventString?.replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase(),
loopDuringCast: controller.eventParameter0 === 1 && phase === "cast-start",
}))).filter((sound) => sound.sourcePath
&& /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(sound.sourcePath)
&& !sound.sourcePath.includes("..")
&& fs.existsSync(path.join(resourceRoot, ...sound.sourcePath.split("/"))));
if (eventSounds.length) {
const mirrored = runewakerSoundPathsForModel(model, resourceRoot);
entry.sounds = (entry.sounds ?? []).filter((sound) => sound.phase !== phase
|| !mirrored.includes(sound.sourcePath));
for (const sound of eventSounds) {
if (entry.sounds?.some((candidate) => candidate.phase === phase
&& candidate.sourcePath.toLowerCase() === sound.sourcePath)) continue;
(entry.sounds ??= []).push({ ...sound, phase, weight: 1, volume: 1 });
}
}
}
}
@@ -671,27 +719,26 @@ function buildRunewakerEntries(spellIds, imageBySpellId, 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 targetEffects = runewakerTargetModels(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),
...[...targetEffects.values()].map((effect) => effect.imageId),
...[...targetEffects.values()].flatMap((effects) => effects.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 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 targetModels = (targetEffects.get(spellId) ?? []).map((effect) => {
const model = (images.get(effect.imageId) ?? []).find((candidate) => (
candidate.toLowerCase() === effect.model
));
if (!model) throw new Error(`RuneWaker target ImageObject changed for spell ${spellId}`);
return model;
});
const paths = [...(images.get(imageId) ?? []), ...targetModels];
const phases = {};
const sounds = [];
for (const sourcePath of paths) {
@@ -730,7 +777,7 @@ function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
});
}
attachVerifiedRuneWakerEventSounds(entries);
attachVerifiedRuneWakerParticles(entries);
attachPackagedRuneWakerMedia(entries, resourceRoot);
return entries;
}
@@ -389,9 +389,9 @@ test("Priest target and projectile sounds follow their verified ROS event phases
}
});
test("only browser-verified RuneWaker particle phases use original graphs", () => {
test("source-linked primary-five phases attach only browser-ready RuneWaker graphs", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const expected = new Set([
const required = 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",
@@ -402,6 +402,10 @@ test("only browser-verified RuneWaker particle phases use original graphs", () =
"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",
"490149:impact:model/fx/skill/holy/act_sanink_c_hit_point_e.ros",
"490450:impact:model/fx/skill/physical/act_snipe_t_freeze_hit.ros",
"490337:impact:model/fx/skill/physical/act_sneak_attack_t_freeze_hit.ros",
"491169:impact:model/fx/skill/wind/act_electric_explode_t_freeze_hit_e.ros",
]);
const actual = new Set();
for (const entry of manifest.entries.filter((candidate) => candidate.source === "runewaker")) {
@@ -423,7 +427,8 @@ test("only browser-verified RuneWaker particle phases use original graphs", () =
assert.equal(graph.sourceModel, asset.model);
}
}
assert.deepEqual(actual, expected);
for (const key of required) assert.ok(actual.has(key), key);
assert.ok(actual.size > required.size);
for (const spellId of [491147, 490261, 490281]) {
const entry = manifest.entries.find((candidate) => candidate.key === `runewaker:${spellId}`);
assert.ok(entry.sounds.some((sound) => sound.phase === "cast-start"
@@ -101,7 +101,29 @@ 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();
return controller.eventString?.replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
}
// RuneWaker's event manager routes these callbacks outside the particle
// renderer. IDs 25 (effect trigger) and 33 (projectile flight) affect visuals
// and intentionally remain blocked until their behavior is implemented.
function visualOnlySourceTrigger(controller) {
if (controller.type !== "unsupported" || controller.sourceType !== "CRuController_EventTrigger"
|| typeof controller.anchor !== "string" || !Number.isFinite(controller.startTime)
|| controller.startTime < 0 || !Number.isFinite(controller.eventDuration)
|| controller.eventDuration < 0 || !Number.isInteger(controller.eventParameter0)
|| !Number.isInteger(controller.eventParameter1)) return false;
const eventString = normalizedEventString(controller);
if ([1, 50, 61].includes(controller.eventID)) {
return controller.eventParameter0 === 0 && controller.eventParameter1 === 0 && eventString === "";
}
if ([41, 43].includes(controller.eventID)) {
return [0, 1].includes(controller.eventParameter0)
&& /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(eventString)
&& !eventString.includes("..");
}
return controller.eventID === 44 && controller.eventParameter0 === 1
&& controller.eventParameter1 === 0 && eventString === "missile";
}
function visualOnlyHolyCastTrigger(model, motion, controller) {
@@ -163,9 +185,15 @@ function verifiedGlobalAlpha(model, motion, controller) {
&& 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;
if (model === RIPTIDE_IMPACT_MODEL && motion.name === "explode") {
return controller.duration === 0 && controller.alphaTrack.length === 1
&& controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 1;
}
const alphas = motion.controllers.filter((candidate) => candidate.type === "global-alpha");
const sprites = motion.controllers.filter((candidate) => candidate.type === "sprite");
return alphas.length === 1 && sprites.length > 0
&& sprites.every((sprite) => controller.anchor === ""
|| sprite.anchor.startsWith(`${controller.anchor}/`));
}
const expectedSpecialControllerCounts = new Map([
@@ -339,8 +367,7 @@ try {
delete controller.orientToTravel;
delete controller.billboardType;
}
if (controller.type === "global-alpha"
&& ![ICE_CAST_MODEL, RIPTIDE_IMPACT_MODEL, URGENT_IMPACT_MODEL].includes(model)) {
if (controller.type === "global-alpha" && !verifiedGlobalAlpha(model, motion, controller)) {
controller.type = "unsupported";
delete controller.startTime;
delete controller.duration;
@@ -370,6 +397,18 @@ try {
}
}
if (result.spriteControllers === 0) result.errors.push("No sprite controllers decoded");
for (const motion of decoded.motions) for (const controller of motion.controllers) {
if (controller.type === "emitter" && (controller.particlePath !== ""
|| controller.emitterPath !== "" || controller.tracks?.length !== 27
|| ![0, 1, 4, 5].includes(controller.tracks?.[1]?.[0]?.value))) {
result.unsupported.push("RuneWaker browser emitter geometry mode");
}
if (controller.type === "sprite" && (controller.shader !== "ruSHADER_PARTICLE_SPRITEEX"
|| ![0, 1, 2].includes(controller.blendMode)
|| ![1, 2].includes(controller.billboardType) || controller.tracks?.length !== 4)) {
result.unsupported.push("RuneWaker browser sprite mode");
}
}
result.unsupported.push(...decoded.unsupported.filter((type) => !(
model === URGENT_IMPACT_MODEL && type === "CRuEntity_Particle_Visualizer_Entity" && runeMesh
)));
@@ -384,19 +423,20 @@ try {
.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
const visualOnlyControllers = (expectedTriggers === 0 || unsupportedControllers.length === expectedTriggers)
&& (expectedAlpha === 0 || globalAlphaControllers.length === expectedAlpha)
&& unsupportedControllers.every(({ motion, controller }) => (
visualOnlyHolyCastTrigger(model, motion, controller)
|| visualOnlyRisingTideTrigger(model, motion, controller)
|| visualOnlySourceTrigger(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
&& visualOnlyControllers;
result.rendererReady = result.spriteControllers > 0
&& result.errors.length === 0 && decoded.unsupported.length === 0
&& !runeMesh && visualOnlyControllers;
result.partialRendererReady = verifiedRuneWakerParticleModels.has(model)
&& result.errors.length === 0
&& verifiedUrgentPartial(model, decoded, result, unsupportedControllers, globalAlphaControllers, runeMesh);
@@ -18,7 +18,7 @@ function assetFile(url) {
}
test("primary-five RuneWaker particle archive resolves its decoded graphs and textures", () => {
assert.equal(index.requestedModels, 124);
assert.equal(index.requestedModels, 170);
assert.equal(index.processedModels, index.requestedModels);
assert.equal(index.failedModels, 0);
assert.equal(index.results.length, index.requestedModels);
@@ -31,10 +31,15 @@ test("primary-five RuneWaker particle archive resolves its decoded graphs and te
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"));
if (result.rendererReady) {
assert.equal(result.partialRendererReady, false);
assert.equal(result.errors.length, 0);
assert.ok(result.spriteControllers > 0);
assert.ok(result.unsupported.every((type) => type === "CRuController_EventTrigger"));
}
if (verifiedRuneWakerParticleModels.has(result.model) && !result.partialRendererReady) {
assert.equal(result.rendererReady, true, result.model);
}
assert.equal(graph.shader.name, "ruSHADER_PARTICLE_SPRITEEX");
for (const [stage, signature] of [["vertex", "VS_OUTPUT main"], ["pixel", "PS_OUTPUT main"]]) {
const shader = fs.readFileSync(assetFile(graph.shader[`${stage}SourceUrl`]), "utf8");
@@ -58,8 +63,8 @@ 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, 515);
assert.ok(textureReferences >= 104);
assert.equal(spriteControllers, 668);
assert.ok(textureReferences >= 139);
});
test("Rising Tide ships three original renderable phases and Urgent Heal declares its partial target layers", () => {
+181
View File
@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(projectRoot, "public");
const archiveRoot = path.join(publicRoot, "assets/game/combat-effects/original/runewaker");
const manifest = JSON.parse(fs.readFileSync(path.join(publicRoot, "assets/game/combat-effects/manifest.json"), "utf8"));
const archive = JSON.parse(fs.readFileSync(path.join(archiveRoot, "manifest.json"), "utf8"));
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/romPlayerCatalog.generated.json"), "utf8"));
const write = process.argv.includes("--write");
if (process.argv.some((argument, index) => index > 1 && argument !== "--write")) {
throw new Error("Usage: node scripts/combat-effects/plan-rom-media.mjs [--write]");
}
const cohorts = [
["rom-priest", null], ["rom-priest", "rom-knight"],
["rom-knight", null], ["rom-knight", "rom-scout"],
["rom-scout", null], ["rom-scout", "rom-rogue"],
["rom-rogue", null], ["rom-rogue", "rom-warrior"],
["rom-mage", null], ["rom-mage", "rom-warlock"],
];
const spellIds = new Set(catalog.skills.filter((skill) => !skill.passive && cohorts.some(
([primary, secondary]) => skill.classId === primary && (skill.secondaryClassId ?? null) === secondary,
)).flatMap((skill) => [skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)]));
const entries = manifest.entries.filter((entry) => entry.source === "runewaker" && spellIds.has(entry.spellId));
const entriesBySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
const usages = new Map();
for (const entry of entries) for (const [phase, assets] of Object.entries(entry.phases ?? {})) {
for (const asset of assets) {
if (!asset.model) continue;
const key = asset.model.toLowerCase();
const list = usages.get(key) ?? [];
list.push({ spellId: entry.spellId, phase });
usages.set(key, list);
}
}
function graphFor(result) {
if (!result.graphUrl) return null;
const graphPath = path.resolve(publicRoot, result.graphUrl.replace(/^\/+/, ""));
const graphsRoot = path.join(archiveRoot, "graphs") + path.sep;
if (!graphPath.toLowerCase().startsWith(graphsRoot.toLowerCase())) {
throw new Error(`RuneWaker graph path escapes archive: ${result.graphUrl}`);
}
return JSON.parse(fs.readFileSync(graphPath, "utf8"));
}
function eventSoundPath(value) {
const normalized = String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
return /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(normalized) ? normalized : null;
}
const results = archive.results.map((result) => {
const model = result.model.toLowerCase();
const graph = graphFor(result);
const references = [...new Map((usages.get(model) ?? []).map((usage) =>
[`${usage.spellId}:${usage.phase}`, usage])).values()];
const blockers = [...new Set(result.unsupported ?? [])].sort();
const errors = [...new Set(result.errors ?? [])];
const state = result.rendererReady ? "ready"
: result.partialRendererReady ? "partial-original"
: result.status === "decoded" && errors.length === 0 ? "clean-unverified"
: result.status === "failed" ? "failed" : "blocked";
const directAudioEvents = [...new Map((graph?.motions ?? []).flatMap((motion) =>
(motion.controllers ?? []).filter((controller) => (
controller.sourceType === "CRuController_EventTrigger"
&& controller.eventID === 43 && eventSoundPath(controller.eventString)
)).map((controller) => {
const sourcePath = eventSoundPath(controller.eventString);
return [`${motion.name}:${sourcePath}:${controller.eventParameter0}:${controller.eventParameter1}`,
{ motion: motion.name, sourcePath, loop: controller.eventParameter0 === 1,
parameter1: controller.eventParameter1 }];
}))).values()];
const missingEventCues = directAudioEvents.flatMap((event) => {
if (event.loop || event.parameter1 !== 0) return [];
const missing = references.filter((reference) => {
const entry = entriesBySpellId.get(reference.spellId);
return !entry?.sounds?.some((sound) => sound.phase === reference.phase
&& sound.sourcePath?.toLowerCase() === event.sourcePath && sound.url);
});
return missing.length ? [{ ...event, references: missing }] : [];
});
return {
model: result.model,
state,
graphUrl: result.graphUrl,
spriteControllers: result.spriteControllers,
textures: result.textures,
blockers,
errors,
references,
directAudioEvents,
missingEventCues,
};
});
const stateCounts = Object.fromEntries([
"ready", "partial-original", "clean-unverified", "blocked", "failed",
].map((state) => [state, results.filter((result) => result.state === state).length]));
const blockerFamilies = [...new Set(results.flatMap((result) => result.blockers))].map((controller) => {
const models = results.filter((result) => result.blockers.includes(controller));
return { controller, models: models.length,
spellIds: new Set(models.flatMap((model) => model.references.map((reference) => reference.spellId))).size };
}).sort((left, right) => right.models - left.models || left.controller.localeCompare(right.controller));
const signatureGroups = [...new Set(results.filter((result) => result.state === "blocked")
.map((result) => result.blockers.join(" + ") || "decode errors"))].map((signature) => {
const models = results.filter((result) => result.state === "blocked"
&& (result.blockers.join(" + ") || "decode errors") === signature);
return { signature, models: models.length,
spellIds: new Set(models.flatMap((model) => model.references.map((reference) => reference.spellId))).size,
example: models[0]?.model };
}).sort((left, right) => right.models - left.models || left.signature.localeCompare(right.signature));
const summary = {
models: results.length,
...stateCounts,
cohortSpellEntries: entries.length,
entriesWithPackagedAudio: entries.filter((entry) => entry.sounds?.some((sound) => sound.url)).length,
eventCueCandidates: results.reduce((sum, result) => sum + result.missingEventCues.length, 0),
};
const report = { schemaVersion: 1, scope: archive.scope, summary, blockerFamilies, signatureGroups, results };
const clean = results.filter((result) => result.state === "clean-unverified");
const eventOnly = results.filter((result) => result.state === "blocked"
&& result.spriteControllers > 0 && result.blockers.length === 1
&& result.blockers[0] === "CRuController_EventTrigger");
const countRefs = (result) => result.references.length;
const priority = (left, right) => countRefs(right) - countRefs(left) || left.model.localeCompare(right.model);
const row = (result) => `| ${result.model} | ${result.references.length} | ${result.spriteControllers} | ${result.blockers.join(", ") || "—"} |`;
const lines = [
"# RuneWaker media batch plan",
"",
"Generated by `npm run effects:rom:plan` from the current primary-five spell manifest and decoded ROS archive. It groups remaining controller and timing work. The manifest generator already attaches source direct audio events at supported phase boundaries.",
"",
`The archive has ${summary.models} unique models: ${summary.ready} ready, ${summary["partial-original"]} partial original, ${summary["clean-unverified"]} fully decoded but unverified, ${summary.blocked} blocked, and ${summary.failed} failed. ${summary.entriesWithPackagedAudio} of ${summary.cohortSpellEntries} source spell entries have at least one packaged audio cue.`,
"",
"## Shared controller blockers",
"",
"Counts overlap when a model has several controller families. A family should be ported and verified once against representative models before its other users are considered for promotion.",
"",
"| Controller family | Models | Source spell IDs using those models |",
"| --- | ---: | ---: |",
...blockerFamilies.map((family) => `| ${family.controller} | ${family.models} | ${family.spellIds} |`),
"",
"## Fully decoded models awaiting visual review",
"",
"| Source model | Spell-phase references | Sprite layers | Blockers |",
"| --- | ---: | ---: | --- |",
...clean.sort(priority).map(row),
"",
"## Event-only models still blocked",
"",
"These models have sprite data but include an event callback outside the validated visual-only set. Effect-spawning and projectile-flight callbacks require browser behavior before their visuals can be promoted.",
"",
"| Source model | Spell-phase references | Sprite layers | Blockers |",
"| --- | ---: | ---: | --- |",
...eventOnly.sort(priority).slice(0, 20).map(row),
"",
`There are ${eventOnly.length} event-only models. The archive also exposes ${summary.eventCueCandidates} model/phase audio-event candidates missing from the current packaged manifest. They need delayed timing or a later lifecycle phase; an ROS motion name alone is insufficient. Full per-model evidence is in \`docs/ROM_MEDIA_BATCH_PLAN.json\`.`,
"",
"## Largest blocker combinations",
"",
"| Controller signature | Models | Source spell IDs | Example model |",
"| --- | ---: | ---: | --- |",
...signatureGroups.slice(0, 15).map((group) =>
`| ${group.signature.replaceAll("|", "\\|")} | ${group.models} | ${group.spellIds} | ${group.example} |`),
"",
"## Batch workflow",
"",
"1. Run `npm run effects:rom:batch` to refresh source links, package audio and ROS graphs, attach verified phases, and rebuild this plan.",
"2. Review the remaining event timing candidates and largest shared blocker families above.",
"3. Port one controller family in the exporter/WebGL runtime and add a parser check that every promoted graph is renderable.",
"4. Regenerate the manifest and media audit, replay representative graphs in the RuneWaker preview, then spot-check distinct motion and attachment patterns in live combat.",
];
if (write) {
const docs = path.join(projectRoot, "docs");
fs.writeFileSync(path.join(docs, "ROM_MEDIA_BATCH_PLAN.json"), `${JSON.stringify(report, null, 2)}\n`);
fs.writeFileSync(path.join(docs, "ROM_MEDIA_BATCH_PLAN.md"), `${lines.join("\n").trimEnd()}\n`);
}
console.log(JSON.stringify(summary));