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

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