565 lines
19 KiB
JavaScript
565 lines
19 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFile,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Vector3 } from "three";
|
|
import { MeshBVH } from "three-mesh-bvh";
|
|
|
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(scriptDirectory, "../..");
|
|
const recipeFile = path.join(scriptDirectory, "recipes", "forsaken-abbey-population.json");
|
|
const recipe = JSON.parse(await readFile(recipeFile, "utf8"));
|
|
const resolveProject = (value) => path.resolve(projectRoot, value);
|
|
const sourceRoot = path.resolve(
|
|
process.env[recipe.source.rootEnvironmentVariable]
|
|
?? path.join(projectRoot, recipe.source.relativeDefault),
|
|
);
|
|
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work", "forsaken-abbey-population");
|
|
const actorOutput = resolveProject(recipe.files.actorOutput);
|
|
const nativeExporter = path.join(scriptDirectory, "native", "bin", "runewaker_model_exporter.exe");
|
|
const blender = path.resolve(
|
|
process.env.BLENDER_BIN
|
|
?? path.join(
|
|
process.env.USERPROFILE ?? "",
|
|
"blender-portable",
|
|
"blender-5.0.1-windows-x64",
|
|
"blender.exe",
|
|
),
|
|
);
|
|
const texconv = path.resolve(
|
|
process.env.TEXCONV_BIN
|
|
?? path.join(
|
|
process.env.LOCALAPPDATA ?? "",
|
|
"Microsoft",
|
|
"WinGet",
|
|
"Packages",
|
|
"Microsoft.DirectXTex.Texconv_Microsoft.Winget.Source_8wekyb3d8bbwe",
|
|
"texconv.exe",
|
|
),
|
|
);
|
|
const blenderScript = path.join(scriptDirectory, "blender", "convert-runewaker-actor.py");
|
|
const gltfTransform = path.join(
|
|
projectRoot,
|
|
"node_modules",
|
|
"@gltf-transform",
|
|
"cli",
|
|
"bin",
|
|
"cli.js",
|
|
);
|
|
const khronosValidator = path.join(
|
|
projectRoot,
|
|
"scripts",
|
|
"manastorm-assets",
|
|
"validate-package-glbs.cjs",
|
|
);
|
|
|
|
function run(executable, argumentsList, label) {
|
|
return new Promise((resolve, reject) => {
|
|
console.log("\n[runewaker-population] " + label);
|
|
const child = spawn(executable, argumentsList, {
|
|
cwd: projectRoot,
|
|
stdio: "inherit",
|
|
windowsHide: true,
|
|
});
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(label + " exited with code " + code + "."));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function requireFile(file, guidance) {
|
|
try {
|
|
const info = await stat(file);
|
|
if (!info.isFile()) throw new Error();
|
|
} catch {
|
|
throw new Error(guidance + "\nMissing: " + file);
|
|
}
|
|
}
|
|
|
|
async function sha256(file) {
|
|
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
}
|
|
|
|
function stableHash(value) {
|
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
}
|
|
|
|
function projectPath(file) {
|
|
return path.relative(projectRoot, file).split(path.sep).join("/");
|
|
}
|
|
|
|
async function prepareActor(actor) {
|
|
const actorRoot = path.join(workRoot, "actors", actor.id);
|
|
const raw = path.join(actorRoot, "raw");
|
|
const prepared = path.join(actorRoot, "prepared");
|
|
const converted = path.join(actorRoot, "converted");
|
|
await rm(actorRoot, { recursive: true, force: true });
|
|
await mkdir(raw, { recursive: true });
|
|
await mkdir(path.join(prepared, "textures"), { recursive: true });
|
|
await mkdir(converted, { recursive: true });
|
|
|
|
await run(
|
|
nativeExporter,
|
|
[resourceRoot, path.normalize(actor.sourceModel), raw],
|
|
"export " + actor.id + " from its preserved ROS",
|
|
);
|
|
const ddsFiles = (await readdir(path.join(raw, "textures")))
|
|
.filter((file) => file.toLowerCase().endsWith(".dds"))
|
|
.sort()
|
|
.map((file) => path.join(raw, "textures", file));
|
|
if (!ddsFiles.length) throw new Error(actor.id + " exported without DDS textures.");
|
|
await run(
|
|
texconv,
|
|
["-ft", "PNG", "-o", path.join(prepared, "textures"), "-y", "--permissive", ...ddsFiles],
|
|
"decode " + actor.id + " textures",
|
|
);
|
|
await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj"));
|
|
await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json"));
|
|
const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8");
|
|
await writeFile(
|
|
path.join(prepared, "scene.mtl"),
|
|
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
|
|
"utf8",
|
|
);
|
|
|
|
const rawGlb = path.join(converted, actor.id + ".raw.glb");
|
|
const reportFile = path.join(converted, actor.id + ".report.json");
|
|
await run(
|
|
blender,
|
|
[
|
|
"--background",
|
|
"--factory-startup",
|
|
"--python",
|
|
blenderScript,
|
|
"--",
|
|
path.join(prepared, "scene.obj"),
|
|
rawGlb,
|
|
reportFile,
|
|
actor.id,
|
|
String(recipe.coordinateSystem.metersPerSourceUnit),
|
|
],
|
|
"convert " + actor.id + " to GLB",
|
|
);
|
|
const optimized = path.join(converted, actor.id + ".glb");
|
|
await run(
|
|
process.execPath,
|
|
[
|
|
gltfTransform,
|
|
"optimize",
|
|
rawGlb,
|
|
optimized,
|
|
"--compress",
|
|
"meshopt",
|
|
"--meshopt-level",
|
|
"high",
|
|
"--texture-size",
|
|
"1024",
|
|
],
|
|
"meshopt-compress " + actor.id,
|
|
);
|
|
|
|
await mkdir(actorOutput, { recursive: true });
|
|
const shipping = path.join(actorOutput, actor.id + ".glb");
|
|
await copyFile(optimized, shipping);
|
|
const conversion = JSON.parse(await readFile(reportFile, "utf8"));
|
|
const sourceModel = path.join(resourceRoot, actor.sourceModel);
|
|
return {
|
|
id: actor.id,
|
|
sourceModel: actor.sourceModel,
|
|
sourceModelSha256: await sha256(sourceModel),
|
|
fileName: actor.id + ".glb",
|
|
url: "/assets/creatures/forsaken-abbey/" + actor.id + ".glb",
|
|
checksum: await sha256(shipping),
|
|
size: (await stat(shipping)).size,
|
|
triangles: conversion.triangles,
|
|
bounds: conversion.bounds,
|
|
rotationY: actor.runtimeRotationY,
|
|
...conversion.runtime,
|
|
animations: [],
|
|
};
|
|
}
|
|
|
|
async function buildActors() {
|
|
await Promise.all([
|
|
requireFile(nativeExporter, "Build the preserved RuneWaker exporter first."),
|
|
requireFile(blender, "Set BLENDER_BIN to Blender 5."),
|
|
requireFile(texconv, "Set TEXCONV_BIN to Microsoft texconv."),
|
|
]);
|
|
const assets = [];
|
|
for (const actor of recipe.actors) assets.push(await prepareActor(actor));
|
|
await run(
|
|
process.execPath,
|
|
[khronosValidator, actorOutput],
|
|
"validate all Forsaken Abbey creature GLBs with Khronos",
|
|
);
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
dungeonId: recipe.dungeonId,
|
|
status: "green",
|
|
animationPolicy: "authentic static ROS models with HealerMan procedural movement",
|
|
assets,
|
|
};
|
|
await writeFile(
|
|
resolveProject(recipe.files.actorManifest),
|
|
JSON.stringify(manifest, null, 2) + "\n",
|
|
"utf8",
|
|
);
|
|
return manifest;
|
|
}
|
|
|
|
async function navigationProjector() {
|
|
const navigationFile = resolveProject(recipe.files.navigation);
|
|
await requireFile(navigationFile, "Build the Forsaken Abbey environment navigation first.");
|
|
const document = await new NodeIO().read(navigationFile);
|
|
const positions = [];
|
|
const vertex = new Vector3();
|
|
for (const node of document.getRoot().listNodes()) {
|
|
const mesh = node.getMesh();
|
|
if (!mesh) continue;
|
|
const world = new Matrix4().fromArray(node.getWorldMatrix());
|
|
for (const primitive of mesh.listPrimitives()) {
|
|
const source = primitive.getAttribute("POSITION")?.getArray();
|
|
if (!source) continue;
|
|
const sourceIndices = primitive.getIndices()?.getArray();
|
|
const indices = sourceIndices
|
|
? Array.from(sourceIndices)
|
|
: Array.from({ length: source.length / 3 }, (_, index) => index);
|
|
for (const index of indices) {
|
|
vertex.fromArray(source, index * 3).applyMatrix4(world);
|
|
positions.push(vertex.x, vertex.y, vertex.z);
|
|
}
|
|
}
|
|
}
|
|
if (!positions.length) throw new Error("Forsaken Abbey navigation contains no triangles.");
|
|
const geometry = new BufferGeometry();
|
|
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
|
|
const bvh = new MeshBVH(geometry);
|
|
return {
|
|
project(point) {
|
|
const hit = bvh.closestPointToPoint(new Vector3(...point));
|
|
if (!hit) throw new Error("Could not project a Forsaken Abbey spawn to navigation.");
|
|
return {
|
|
position: hit.point.toArray().map((value) => Number(value.toFixed(6))),
|
|
distance: Number(hit.distance.toFixed(6)),
|
|
};
|
|
},
|
|
dispose() {
|
|
geometry.dispose();
|
|
},
|
|
};
|
|
}
|
|
|
|
function sourceToLocal(sourcePosition, placement) {
|
|
const point = new Vector3(...sourcePosition);
|
|
point.sub(new Vector3(...placement.translation));
|
|
point.applyQuaternion(new Quaternion(...placement.rotation).invert());
|
|
const scale = placement.scale;
|
|
point.set(point.x / scale[0], point.y / scale[1], point.z / scale[2]);
|
|
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
|
return [
|
|
point.x * meters,
|
|
point.y * meters,
|
|
-point.z * meters,
|
|
];
|
|
}
|
|
|
|
function yawFromDirection(direction) {
|
|
const turn = recipe.coordinateSystem.directionUnitsPerTurn;
|
|
return Number((((direction % turn) + turn) % turn * Math.PI * 2 / turn).toFixed(9));
|
|
}
|
|
|
|
function distanceSquared(a, b) {
|
|
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
|
|
}
|
|
|
|
function attackFor(template) {
|
|
if (template.id === 100074 || template.id === 100070) {
|
|
return {
|
|
id: "rw-fa-shadow-bolt",
|
|
name: "Shadow Bolt",
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
range: 18,
|
|
cooldownMs: 4200,
|
|
damageMultiplier: 1.08,
|
|
};
|
|
}
|
|
return {
|
|
id: "rw-fa-basic-attack",
|
|
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: template.archetype === "ooze" ? "nature" : "physical",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
cooldownMs: 1900,
|
|
damageMultiplier: 1,
|
|
};
|
|
}
|
|
|
|
function entityFor(template, actor) {
|
|
const boss = template.classification === "boss";
|
|
return {
|
|
id: "rw-fa-" + template.id,
|
|
name: template.name,
|
|
kind: boss ? "boss" : "mob",
|
|
...(boss ? { title: template.title } : {}),
|
|
hasLoot: false,
|
|
combat: {
|
|
level: template.level,
|
|
healthMultiplier: boss ? 7.5 : 1,
|
|
damageMultiplier: boss ? 1.3 : 1,
|
|
moveSpeed: boss ? 2.25 : 2.15,
|
|
leashRange: boss ? 32 : 24,
|
|
attacks: [attackFor(template)],
|
|
},
|
|
visual: {
|
|
primaryColor: template.primaryColor,
|
|
accentColor: template.accentColor,
|
|
scale: 1,
|
|
archetype: template.archetype,
|
|
model: {
|
|
url: actor.url,
|
|
rotationY: actor.rotationY,
|
|
groundOffset: actor.groundOffset,
|
|
labelHeight: actor.labelHeight,
|
|
markerRadius: actor.markerRadius,
|
|
animationMode: actor.animations.length ? "native" : "procedural",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateSnapshot(snapshot, templates) {
|
|
if (snapshot.schemaVersion !== 1 || snapshot.dungeonId !== recipe.dungeonId) {
|
|
throw new Error("Forsaken Abbey snapshot schema or dungeon id is invalid.");
|
|
}
|
|
if (snapshot.zoneId !== recipe.zoneId || snapshot.rows.length !== recipe.expectedActiveRows) {
|
|
throw new Error(
|
|
"Expected " + recipe.expectedActiveRows + " active Zone " + recipe.zoneId
|
|
+ " rows, found " + snapshot.rows.length + ".",
|
|
);
|
|
}
|
|
const ids = new Set();
|
|
for (const row of snapshot.rows) {
|
|
if (ids.has(row.spawnId)) throw new Error("Duplicate RuneWaker spawn id " + row.spawnId + ".");
|
|
ids.add(row.spawnId);
|
|
const template = templates.get(row.templateId);
|
|
if (!template) throw new Error("Unmapped RuneWaker template " + row.templateId + ".");
|
|
if (row.classification !== template.classification) {
|
|
throw new Error("Classification drift for RuneWaker template " + row.templateId + ".");
|
|
}
|
|
if (row.modelPath !== (template.actor
|
|
? recipe.actors.find((actor) => actor.id === template.actor)?.sourceModel
|
|
: null)) {
|
|
throw new Error("Model-path drift for RuneWaker template " + row.templateId + ".");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function generatePopulation() {
|
|
const snapshotFile = resolveProject(recipe.files.snapshot);
|
|
const manifestFile = resolveProject(recipe.files.actorManifest);
|
|
await Promise.all([
|
|
requireFile(
|
|
snapshotFile,
|
|
"Run node scripts/runewaker-pipeline/refresh-forsaken-population.mjs once to create the portable snapshot.",
|
|
),
|
|
requireFile(
|
|
manifestFile,
|
|
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
|
|
),
|
|
]);
|
|
const [snapshot, manifest, environment] = await Promise.all([
|
|
readFile(snapshotFile, "utf8").then(JSON.parse),
|
|
readFile(manifestFile, "utf8").then(JSON.parse),
|
|
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
|
]);
|
|
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
|
|
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
|
|
validateSnapshot(snapshot, templates);
|
|
for (const actor of recipe.actors) {
|
|
if (!actors.has(actor.id)) throw new Error("Actor manifest is missing " + actor.id + ".");
|
|
}
|
|
|
|
const placement = environment.source?.wdb?.primaryPlacement;
|
|
if (!placement) throw new Error("Forsaken Abbey environment metadata has no WDB anchor.");
|
|
const projector = await navigationProjector();
|
|
const entities = {};
|
|
const staticSpawns = [];
|
|
const records = [];
|
|
const rawBosses = [];
|
|
const countsByTemplate = {};
|
|
try {
|
|
for (const row of snapshot.rows) {
|
|
const template = templates.get(row.templateId);
|
|
const local = sourceToLocal(row.sourcePosition, placement);
|
|
const projected = projector.project(local);
|
|
if (
|
|
projected.distance > recipe.coordinateSystem.maximumProjectionDistance
|
|
&& template.classification !== "deferred-object"
|
|
) {
|
|
throw new Error(
|
|
"Spawn " + row.spawnId + " is " + projected.distance
|
|
+ "m from navigation (maximum "
|
|
+ recipe.coordinateSystem.maximumProjectionDistance + "m).",
|
|
);
|
|
}
|
|
const record = {
|
|
sourceSpawnId: row.spawnId,
|
|
sourceTemplateId: row.templateId,
|
|
name: row.name,
|
|
classification: row.classification,
|
|
sourcePosition: row.sourcePosition,
|
|
sourceDirection: row.direction,
|
|
localPosition: local.map((value) => Number(value.toFixed(6))),
|
|
projectedPosition: projected.position,
|
|
projectionDistance: projected.distance,
|
|
modelPath: row.modelPath,
|
|
};
|
|
records.push(record);
|
|
countsByTemplate[row.templateId] = (countsByTemplate[row.templateId] ?? 0) + 1;
|
|
if (template.classification === "deferred-object") continue;
|
|
|
|
const entityId = "rw-fa-" + row.templateId;
|
|
if (!entities[entityId]) {
|
|
entities[entityId] = entityFor(template, actors.get(template.actor));
|
|
}
|
|
const spawn = {
|
|
id: "rw-fa-spawn-" + row.spawnId,
|
|
entityId,
|
|
position: projected.position,
|
|
yaw: yawFromDirection(row.direction),
|
|
spawnMask: 1,
|
|
};
|
|
staticSpawns.push(spawn);
|
|
if (template.classification === "boss") {
|
|
rawBosses.push({
|
|
id: spawn.id,
|
|
templateId: template.id,
|
|
name: template.name,
|
|
position: spawn.position,
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
projector.dispose();
|
|
}
|
|
|
|
if (rawBosses.length !== recipe.expectedBossSpawns) {
|
|
throw new Error(
|
|
"Expected " + recipe.expectedBossSpawns + " boss spawns, found " + rawBosses.length + ".",
|
|
);
|
|
}
|
|
const objectiveOrder = [...rawBosses]
|
|
.sort((a, b) => (
|
|
distanceSquared(a.position, environment.entrance.footPosition)
|
|
- distanceSquared(b.position, environment.entrance.footPosition)
|
|
|| a.id.localeCompare(b.id)
|
|
));
|
|
let keeperIndex = 0;
|
|
const bosses = objectiveOrder.map((boss) => ({
|
|
id: boss.id,
|
|
name: boss.templateId === 100070
|
|
? "Necromage Keeper " + (++keeperIndex)
|
|
: boss.name,
|
|
position: boss.position,
|
|
}));
|
|
const excluded = records.filter((record) => record.classification === "deferred-object");
|
|
const projectionDistances = records.map((record) => record.projectionDistance);
|
|
const maximumProjectionDistance = Math.max(...projectionDistances);
|
|
const combatProjectionDistances = records
|
|
.filter((record) => record.classification !== "deferred-object")
|
|
.map((record) => record.projectionDistance);
|
|
const maximumCombatProjectionDistance = Math.max(...combatProjectionDistances);
|
|
const deferredOffNavigationCount = excluded.filter(
|
|
(record) => record.projectionDistance > recipe.coordinateSystem.maximumProjectionDistance,
|
|
).length;
|
|
const provenance = {
|
|
schemaVersion: 1,
|
|
dungeonId: recipe.dungeonId,
|
|
zoneId: recipe.zoneId,
|
|
sourceRowCount: snapshot.rows.length,
|
|
importedSpawnCount: staticSpawns.length,
|
|
excludedRowCount: excluded.length,
|
|
bossSpawnCount: bosses.length,
|
|
sourceSnapshot: projectPath(snapshotFile),
|
|
sourceContentSha256: snapshot.contentSha256,
|
|
sourceHashes: snapshot.sourceHashes,
|
|
coordinateTransform: "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
|
|
navmeshProjection: {
|
|
maximumAllowedDistance: recipe.coordinateSystem.maximumProjectionDistance,
|
|
maximumObservedDistance: Number(maximumProjectionDistance.toFixed(6)),
|
|
maximumCombatDistance: Number(maximumCombatProjectionDistance.toFixed(6)),
|
|
deferredRowsBeyondCombatLimit: deferredOffNavigationCount,
|
|
},
|
|
runeWakerIdsAreNamespaced: true,
|
|
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
|
};
|
|
const report = {
|
|
...provenance,
|
|
status: "green",
|
|
recipe: projectPath(recipeFile),
|
|
actorManifest: projectPath(manifestFile),
|
|
entityCount: Object.keys(entities).length,
|
|
countsByTemplate,
|
|
bosses,
|
|
excluded,
|
|
records,
|
|
deferred: recipe.deferred,
|
|
};
|
|
await mkdir(path.dirname(resolveProject(recipe.files.report)), { recursive: true });
|
|
await writeFile(
|
|
resolveProject(recipe.files.report),
|
|
JSON.stringify(report, null, 2) + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
const generated = [
|
|
"/* This file is generated by scripts/runewaker-pipeline/build-forsaken-population.mjs. */",
|
|
'import type { DungeonBossObjective } from "../dungeonTypes";',
|
|
'import type { PopulationDefinitionMap, StaticMobSpawnDefinition } from "../mobPopulation";',
|
|
"",
|
|
"export const FORSAKEN_ABBEY_ENTITIES = " + JSON.stringify(entities, null, 2)
|
|
+ " as const satisfies PopulationDefinitionMap;",
|
|
"",
|
|
"export const FORSAKEN_ABBEY_STATIC_SPAWNS = " + JSON.stringify(staticSpawns, null, 2)
|
|
+ " as const satisfies readonly StaticMobSpawnDefinition[];",
|
|
"",
|
|
"export const FORSAKEN_ABBEY_BOSSES = " + JSON.stringify(bosses, null, 2)
|
|
+ " as const satisfies readonly DungeonBossObjective[];",
|
|
"",
|
|
"export const FORSAKEN_ABBEY_OBJECTIVE_ORDER = FORSAKEN_ABBEY_BOSSES;",
|
|
"",
|
|
"export const FORSAKEN_ABBEY_POPULATION_PROVENANCE = "
|
|
+ JSON.stringify(provenance, null, 2) + " as const;",
|
|
"",
|
|
].join("\n");
|
|
const generatedFile = resolveProject(recipe.files.generatedSource);
|
|
await mkdir(path.dirname(generatedFile), { recursive: true });
|
|
await writeFile(generatedFile, generated, "utf8");
|
|
console.log(
|
|
"\n[runewaker-population] generated " + staticSpawns.length + " combat spawns, "
|
|
+ bosses.length + " objectives, and " + excluded.length + " deferred object.",
|
|
);
|
|
}
|
|
|
|
const actorsOnly = process.argv.includes("--actors-only");
|
|
if (actorsOnly || process.argv.includes("--actors")) await buildActors();
|
|
if (!actorsOnly) await generatePopulation();
|