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

+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;
}