Update Healer Man server
This commit is contained in:
1 parent
a868401ebc
commit
48a678dcc5
295 files changed
+7644
-467
No files matched your search
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user