forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
a868401ebc
commit
48a678dcc5
295 files changed
+7644
-467
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user