555 lines
19 KiB
JavaScript
555 lines
19 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { parseWdbcBuffer } from "./importer.mjs";
|
|
|
|
const TABLE_NAMES = Object.freeze([
|
|
"Manastorm.dbc",
|
|
"ManastormMessages.dbc",
|
|
"ManastormModifiers.dbc",
|
|
"ManastormPlayerGroupModifiers.dbc",
|
|
]);
|
|
|
|
function sha256(buffer) {
|
|
return createHash("sha256").update(buffer).digest("hex");
|
|
}
|
|
|
|
function relativePath(projectRoot, filePath) {
|
|
return path.relative(projectRoot, filePath).replaceAll("\\", "/");
|
|
}
|
|
|
|
function cleanText(value) {
|
|
return value
|
|
.replace(/\|c[0-9a-f]{8}/gi, "")
|
|
.replace(/\|r/gi, "")
|
|
.replace(/[\r\n]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function iconBasename(value) {
|
|
return value.replace(/^.*[\\/]/, "").replace(/\.(?:blp|tga)$/i, "").trim();
|
|
}
|
|
|
|
function readTable(filePath) {
|
|
const buffer = fs.readFileSync(filePath);
|
|
const table = parseWdbcBuffer(buffer, path.basename(filePath));
|
|
return {
|
|
table,
|
|
provenance: {
|
|
path: filePath,
|
|
sha256: sha256(buffer),
|
|
byteLength: buffer.length,
|
|
dbc: table.header,
|
|
},
|
|
};
|
|
}
|
|
|
|
function readLuaQuotedBytes(buffer, start) {
|
|
const bytes = [];
|
|
const simpleEscapes = new Map([
|
|
[97, 7],
|
|
[98, 8],
|
|
[102, 12],
|
|
[110, 10],
|
|
[114, 13],
|
|
[116, 9],
|
|
[118, 11],
|
|
[34, 34],
|
|
[39, 39],
|
|
[92, 92],
|
|
]);
|
|
|
|
for (let cursor = start; cursor < buffer.length; cursor += 1) {
|
|
const byte = buffer[cursor];
|
|
if (byte === 34) {
|
|
return { value: Buffer.from(bytes), end: cursor + 1 };
|
|
}
|
|
if (byte !== 92) {
|
|
bytes.push(byte);
|
|
continue;
|
|
}
|
|
|
|
cursor += 1;
|
|
if (cursor >= buffer.length) throw new Error("Unterminated Lua escape sequence.");
|
|
const escaped = buffer[cursor];
|
|
if (escaped >= 48 && escaped <= 57) {
|
|
let value = escaped - 48;
|
|
let digits = 1;
|
|
while (
|
|
digits < 3
|
|
&& cursor + 1 < buffer.length
|
|
&& buffer[cursor + 1] >= 48
|
|
&& buffer[cursor + 1] <= 57
|
|
) {
|
|
cursor += 1;
|
|
value = value * 10 + buffer[cursor] - 48;
|
|
digits += 1;
|
|
}
|
|
if (value > 255) throw new Error(`Lua byte escape ${value} is out of range.`);
|
|
bytes.push(value);
|
|
} else if (escaped === 10) {
|
|
bytes.push(10);
|
|
} else if (escaped === 13) {
|
|
if (buffer[cursor + 1] === 10) cursor += 1;
|
|
bytes.push(10);
|
|
} else if (simpleEscapes.has(escaped)) {
|
|
bytes.push(simpleEscapes.get(escaped));
|
|
} else {
|
|
throw new Error(`Unsupported Lua escape byte ${escaped}.`);
|
|
}
|
|
}
|
|
throw new Error("Unterminated Lua quoted string.");
|
|
}
|
|
|
|
function lzwDictionaryKey(low, high) {
|
|
return low | (high << 8);
|
|
}
|
|
|
|
export function decompressLuaLzw(input) {
|
|
if (!Buffer.isBuffer(input) || input.length < 1) {
|
|
throw new Error("Expected a non-empty LZW Buffer.");
|
|
}
|
|
if (input[0] === 117) return input.subarray(1);
|
|
if (input[0] !== 99 || input.length < 3 || (input.length - 1) % 2 !== 0) {
|
|
throw new Error("Invalid lualzw payload.");
|
|
}
|
|
|
|
const codes = input.subarray(1);
|
|
const dictionary = new Map();
|
|
let dictionaryLow = 1;
|
|
let dictionaryHigh = 2;
|
|
const lookup = (offset) => (
|
|
codes[offset + 1] === 1
|
|
? Buffer.from([codes[offset]])
|
|
: dictionary.get(lzwDictionaryKey(codes[offset], codes[offset + 1]))
|
|
);
|
|
|
|
let previous = lookup(0);
|
|
if (!previous) throw new Error("Invalid first lualzw code.");
|
|
const chunks = [previous];
|
|
for (let offset = 2; offset < codes.length; offset += 2) {
|
|
let decoded = lookup(offset);
|
|
if (!decoded) decoded = Buffer.concat([previous, previous.subarray(0, 1)]);
|
|
chunks.push(decoded);
|
|
|
|
if (dictionaryLow >= 256) {
|
|
dictionaryLow = 1;
|
|
dictionaryHigh += 1;
|
|
if (dictionaryHigh >= 256) {
|
|
dictionary.clear();
|
|
dictionaryHigh = 2;
|
|
}
|
|
}
|
|
dictionary.set(
|
|
lzwDictionaryKey(dictionaryLow, dictionaryHigh),
|
|
Buffer.concat([previous, decoded.subarray(0, 1)]),
|
|
);
|
|
dictionaryLow += 1;
|
|
previous = decoded;
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
export function extractAioAddon(cacheBuffer, addonName) {
|
|
const marker = Buffer.from(`["${addonName}"]`);
|
|
const markerOffset = cacheBuffer.indexOf(marker);
|
|
if (markerOffset < 0) throw new Error(`AIO cache does not contain ${addonName}.`);
|
|
|
|
const codeMarker = Buffer.from('["code"] = "');
|
|
const codeMarkerOffset = cacheBuffer.indexOf(codeMarker, markerOffset);
|
|
if (codeMarkerOffset < 0) throw new Error(`AIO cache entry ${addonName} has no code string.`);
|
|
const prefix = cacheBuffer
|
|
.subarray(markerOffset, codeMarkerOffset)
|
|
.toString("latin1");
|
|
const crcMatch = prefix.match(/\["crc"\]\s*=\s*(\d+)/);
|
|
const encoded = readLuaQuotedBytes(cacheBuffer, codeMarkerOffset + codeMarker.length).value;
|
|
|
|
let source;
|
|
let encoding;
|
|
if (encoded[0] === 67) {
|
|
source = decompressLuaLzw(encoded.subarray(1));
|
|
encoding = "AIO_Compressed+lualzw";
|
|
} else if (encoded[0] === 85) {
|
|
source = encoded.subarray(1);
|
|
encoding = "AIO_Uncompressed";
|
|
} else {
|
|
throw new Error(`Unknown AIO code prefix ${encoded[0]}.`);
|
|
}
|
|
|
|
return {
|
|
addonName,
|
|
crc: crcMatch ? Number(crcMatch[1]) : null,
|
|
encoding,
|
|
encodedByteLength: encoded.length,
|
|
decodedByteLength: source.length,
|
|
decodedSha256: sha256(source),
|
|
source: source.toString("utf8"),
|
|
};
|
|
}
|
|
|
|
function inspectAddonContract(addon) {
|
|
const handlers = [...addon.source.matchAll(
|
|
/function\s+MyHandlers\.(\w+)\s*\(([^)]*)\)/g,
|
|
)].map((match) => ({
|
|
name: match[1],
|
|
parameters: match[2].split(",").map((value) => value.trim()).filter(Boolean),
|
|
}));
|
|
return {
|
|
addonName: addon.addonName,
|
|
crc: addon.crc,
|
|
encoding: addon.encoding,
|
|
encodedByteLength: addon.encodedByteLength,
|
|
decodedByteLength: addon.decodedByteLength,
|
|
decodedSha256: addon.decodedSha256,
|
|
handlerNamespace: addon.source.match(/AIO\.AddHandlers\("([^"]+)"/)?.[1] ?? null,
|
|
handlers,
|
|
displaySemantics: {
|
|
level: addon.source.includes('levelText:SetText("Wave " .. level)')
|
|
? 'The level argument is rendered verbatim as "Wave <level>".'
|
|
: null,
|
|
boss: addon.source.includes("bossText:SetText(bossName)")
|
|
? "The bossName argument is rendered verbatim."
|
|
: null,
|
|
elapsedTime: addon.source.includes("timerValue = timerValue + elapsed")
|
|
? "While isRunning is true, the client increments timeSecs every frame."
|
|
: null,
|
|
countdown: addon.source.includes('"Next wave in " .. seconds .. "s"')
|
|
? 'Non-negative seconds render as "Next wave in <seconds>s"; negative values hide the countdown.'
|
|
: null,
|
|
safetyBubble: addon.source.includes('bubbleText:SetText(string.format("Safety Bubble: %.1f yd"')
|
|
? {
|
|
label: "Safety Bubble: <distanceRemaining> yd",
|
|
defaultRadiusYards: 9,
|
|
dangerThresholdYards: 1.5,
|
|
warningThresholdYards: 3,
|
|
}
|
|
: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseMessages(table) {
|
|
return Array.from({ length: table.header.recordCount }, (_, recordIndex) => ({
|
|
sourceRecordId: table.uint(recordIndex, 0),
|
|
modeId: table.uint(recordIndex, 1),
|
|
progressionValue: table.uint(recordIndex, 2),
|
|
groupId: table.uint(recordIndex, 3),
|
|
icon: iconBasename(cleanText(table.string(table.uint(recordIndex, 4)))),
|
|
title: table.localizedString(recordIndex, 5),
|
|
description: table.localizedString(recordIndex, 22),
|
|
}));
|
|
}
|
|
|
|
function normalizedAffixName(value) {
|
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
}
|
|
|
|
function parseAffixDefinitions(spellTable, affixUnlocks) {
|
|
const unlocksByName = new Map();
|
|
for (const unlock of affixUnlocks) {
|
|
const key = normalizedAffixName(unlock.name);
|
|
const group = unlocksByName.get(key) ?? [];
|
|
group.push(unlock);
|
|
unlocksByName.set(key, group);
|
|
}
|
|
|
|
const spellsByName = new Map();
|
|
for (let recordIndex = 0; recordIndex < spellTable.header.recordCount; recordIndex += 1) {
|
|
const spellId = spellTable.uint(recordIndex, 0);
|
|
if (spellId < 90_000 || spellId >= 100_000) continue;
|
|
const name = cleanText(spellTable.localizedString(recordIndex, 136));
|
|
const descriptions = [
|
|
spellTable.localizedString(recordIndex, 187),
|
|
spellTable.localizedString(recordIndex, 170),
|
|
].map(cleanText).filter(Boolean);
|
|
const description = descriptions.find((value) => !/^UPDATE YOUR CLIENT!?$/i.test(value))
|
|
?? descriptions[0]
|
|
?? "";
|
|
if (!name || !description) continue;
|
|
const key = normalizedAffixName(name);
|
|
if (!unlocksByName.has(key)) continue;
|
|
const candidates = spellsByName.get(key) ?? [];
|
|
candidates.push({ spellId, name, description });
|
|
spellsByName.set(key, candidates);
|
|
}
|
|
|
|
return [...unlocksByName.entries()].map(([key, unlocks]) => {
|
|
const spell = (spellsByName.get(key) ?? []).sort(
|
|
(left, right) => left.spellId - right.spellId,
|
|
)[0];
|
|
if (!spell) {
|
|
throw new Error(`No authoritative Spell.dbc definition resolved for affix ${unlocks[0].name}.`);
|
|
}
|
|
const modes = [...new Set(unlocks.map((unlock) => unlock.modeId))].sort(
|
|
(left, right) => left - right,
|
|
);
|
|
const modeZero = unlocks.find((unlock) => unlock.modeId === 0) ?? unlocks[0];
|
|
return {
|
|
id: `manastorm-affix-${key}`,
|
|
name: modeZero.name,
|
|
trigger: modeZero.trigger,
|
|
unlockLevel: modeZero.progressionValue,
|
|
modeIds: modes,
|
|
iconReference: modeZero.icon,
|
|
spellId: spell.spellId,
|
|
description: spell.description.replace(/^.*?(?=Wild Magic spawns)/, ""),
|
|
sourceRecordIds: unlocks.map((unlock) => unlock.sourceRecordId).sort(
|
|
(left, right) => left - right,
|
|
),
|
|
};
|
|
}).sort((left, right) => left.unlockLevel - right.unlockLevel || left.id.localeCompare(right.id));
|
|
}
|
|
|
|
function classifyMessages(messages) {
|
|
const affixUnlocks = [];
|
|
const equipmentUnlocks = [];
|
|
const portalMentions = [];
|
|
const nextRunUnlocks = [];
|
|
const defeatedUnlocks = [];
|
|
for (const message of messages) {
|
|
const affix = message.title.match(/^(On Death|Passive) Affix:\s*(.+?)\s+Unlocked!?$/i);
|
|
if (affix) {
|
|
affixUnlocks.push({
|
|
...message,
|
|
trigger: affix[1].toLowerCase() === "on death" ? "on-death" : "passive",
|
|
name: affix[2],
|
|
});
|
|
}
|
|
if (/^Unlocked .+ Items!$/i.test(message.title)) equipmentUnlocks.push(message);
|
|
if (/\bportal\b/i.test(`${message.title} ${message.description}`)) {
|
|
portalMentions.push(message);
|
|
}
|
|
if (/in your next Manastorm/i.test(message.description)) nextRunUnlocks.push(message);
|
|
if (/\bDefeated!?$/i.test(message.title)) defeatedUnlocks.push(message);
|
|
}
|
|
return {
|
|
affixUnlocks,
|
|
equipmentUnlocks,
|
|
portalMentions,
|
|
nextRunUnlocks,
|
|
defeatedUnlocks,
|
|
};
|
|
}
|
|
|
|
function walkFiles(root) {
|
|
if (!fs.existsSync(root)) return [];
|
|
const files = [];
|
|
const visit = (directory) => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) visit(entryPath);
|
|
else if (entry.isFile()) files.push(entryPath);
|
|
}
|
|
};
|
|
visit(root);
|
|
return files.sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function generateManastormForensics(options = {}) {
|
|
const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
|
|
const extractedRoot = path.resolve(
|
|
options.extractedRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor"),
|
|
);
|
|
const realmDbcRoot = path.resolve(
|
|
options.realmDbcRoot
|
|
?? path.join(extractedRoot, "client-current", "area-52", "patch-D", "DBFilesClient"),
|
|
);
|
|
const legacyDbcRoot = path.resolve(
|
|
options.legacyDbcRoot ?? path.join(extractedRoot, "patch-M", "DBFilesClient"),
|
|
);
|
|
const aioCachePath = path.resolve(
|
|
options.aioCachePath
|
|
?? path.join(
|
|
projectRoot,
|
|
"..",
|
|
"wow335a",
|
|
"WTF",
|
|
"Account",
|
|
"PHENOM",
|
|
"SavedVariables",
|
|
"AIO_Client.lua",
|
|
),
|
|
);
|
|
const audioRoot = path.resolve(
|
|
options.audioRoot ?? path.join(extractedRoot, "patch-O", "sound", "Manastorm"),
|
|
);
|
|
|
|
const currentTables = new Map();
|
|
const tableComparisons = TABLE_NAMES.map((name) => {
|
|
const current = readTable(path.join(realmDbcRoot, name));
|
|
const legacy = readTable(path.join(legacyDbcRoot, name));
|
|
currentTables.set(name, current.table);
|
|
return {
|
|
table: name,
|
|
effectiveRealmOverride: {
|
|
...current.provenance,
|
|
path: relativePath(projectRoot, current.provenance.path),
|
|
},
|
|
legacyExtract: {
|
|
...legacy.provenance,
|
|
path: relativePath(projectRoot, legacy.provenance.path),
|
|
},
|
|
changed: current.provenance.sha256 !== legacy.provenance.sha256,
|
|
deltas: {
|
|
recordCount: current.table.header.recordCount - legacy.table.header.recordCount,
|
|
fieldCount: current.table.header.fieldCount - legacy.table.header.fieldCount,
|
|
recordSize: current.table.header.recordSize - legacy.table.header.recordSize,
|
|
stringBlockSize:
|
|
current.table.header.stringBlockSize - legacy.table.header.stringBlockSize,
|
|
},
|
|
};
|
|
});
|
|
|
|
const cacheBuffer = fs.readFileSync(aioCachePath);
|
|
const addon = extractAioAddon(cacheBuffer, "manastorms_client.lua");
|
|
const messages = parseMessages(currentTables.get("ManastormMessages.dbc"));
|
|
const classifiedMessages = classifyMessages(messages);
|
|
const spell = readTable(path.join(realmDbcRoot, "Spell.dbc"));
|
|
const affixDefinitions = parseAffixDefinitions(spell.table, classifiedMessages.affixUnlocks);
|
|
const audioFiles = walkFiles(audioRoot);
|
|
const portalAudio = audioFiles
|
|
.filter((filePath) => /portal/i.test(path.basename(filePath)))
|
|
.map((filePath) => relativePath(projectRoot, filePath));
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
reportId: "ascension-manastorm-client-forensics",
|
|
sourceBoundary: {
|
|
effectiveClientLayer:
|
|
"The Area-52 patch-D extract is the realm override and is treated as the effective DBC source.",
|
|
legacyComparison:
|
|
"The older extracted patch-M tables are used only to identify structural/data changes.",
|
|
aioCache:
|
|
"The decoded AIO cache is authoritative for the client handler contract, not server-side cadence or encounter rules.",
|
|
},
|
|
sources: {
|
|
aioCache: {
|
|
path: relativePath(projectRoot, aioCachePath),
|
|
sha256: sha256(cacheBuffer),
|
|
byteLength: cacheBuffer.length,
|
|
},
|
|
audioRoot: relativePath(projectRoot, audioRoot),
|
|
spellDbc: {
|
|
...spell.provenance,
|
|
path: relativePath(projectRoot, spell.provenance.path),
|
|
},
|
|
},
|
|
tableComparisons,
|
|
aioContract: inspectAddonContract(addon),
|
|
messages: {
|
|
total: messages.length,
|
|
counts: {
|
|
affixUnlocks: classifiedMessages.affixUnlocks.length,
|
|
uniqueAffixes: new Set(
|
|
classifiedMessages.affixUnlocks.map((message) => message.name.toLowerCase()),
|
|
).size,
|
|
equipmentUnlocks: classifiedMessages.equipmentUnlocks.length,
|
|
portalMentions: classifiedMessages.portalMentions.length,
|
|
nextRunUnlocks: classifiedMessages.nextRunUnlocks.length,
|
|
defeatedUnlocks: classifiedMessages.defeatedUnlocks.length,
|
|
},
|
|
affixUnlocks: classifiedMessages.affixUnlocks,
|
|
affixDefinitions,
|
|
equipmentUnlocks: classifiedMessages.equipmentUnlocks,
|
|
portalMentions: classifiedMessages.portalMentions,
|
|
},
|
|
audioEvidence: {
|
|
fileCount: audioFiles.length,
|
|
portalCueCount: portalAudio.length,
|
|
portalCues: portalAudio,
|
|
},
|
|
evidenceAssessment: {
|
|
authoritative: [
|
|
"Manastorm.dbc supplies map, mode, encounter, and five scalar fields for 1,025 effective realm rows.",
|
|
"ManastormMessages.dbc supplies localized progression/unlock text, including affix names and equipment-tier unlock messages.",
|
|
"manastorms_client.lua supplies three client handlers: UpdateOverlay, UpdateCountdown, and UpdateBubble.",
|
|
"Portal-named audio cues establish that portal-ready and portal-coming-up announcements exist.",
|
|
],
|
|
inferred: [
|
|
"The paired mode 0 and mode 2 affix messages appear to describe the same unlock catalog in two modes.",
|
|
"ProgressionValue is an unlock trigger/index because it accompanies unlock messages; its server-side evaluation is not present client-side.",
|
|
"The equipment messages identify reward tiers, but not concrete item IDs or loot tables.",
|
|
],
|
|
notFound: [
|
|
"No authoritative x/y/z encounter placement records were found.",
|
|
"No encounter-to-creature-template relation was found; encounter IDs are not treated as creature IDs.",
|
|
"No encounter spell loadouts or spell-casting cadence were found.",
|
|
"No portal destination, spawn cadence, or checkpoint rule was found.",
|
|
"No concrete reward item IDs, currency IDs, cache IDs, or loot tables were found.",
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function renderManastormForensicsMarkdown(report) {
|
|
const lines = [
|
|
"# Manastorm client forensic report",
|
|
"",
|
|
"This report separates data directly present in the installed/extracted client from interpretation.",
|
|
"",
|
|
"## Effective DBC tables",
|
|
"",
|
|
"| Table | Effective rows/fields | Legacy rows/fields | Changed |",
|
|
"| --- | ---: | ---: | --- |",
|
|
...report.tableComparisons.map((entry) => (
|
|
`| ${entry.table} | ${entry.effectiveRealmOverride.dbc.recordCount}/${entry.effectiveRealmOverride.dbc.fieldCount}`
|
|
+ ` | ${entry.legacyExtract.dbc.recordCount}/${entry.legacyExtract.dbc.fieldCount}`
|
|
+ ` | ${entry.changed ? "yes" : "no"} |`
|
|
)),
|
|
"",
|
|
"## Cached client contract",
|
|
"",
|
|
`AIO namespace: \`${report.aioContract.handlerNamespace}\`.`,
|
|
"",
|
|
"| Handler | Parameters |",
|
|
"| --- | --- |",
|
|
...report.aioContract.handlers.map((handler) => (
|
|
`| ${handler.name} | ${handler.parameters.join(", ")} |`
|
|
)),
|
|
"",
|
|
'The addon renders `level` as "Wave <level>", renders `bossName` verbatim, increments '
|
|
+ "`timeSecs` while `isRunning` is true, displays a next-wave countdown, and displays "
|
|
+ "a safety-bubble distance meter (9 yd default; warning at 3 yd; danger at 1.5 yd).",
|
|
"",
|
|
"## Affix unlock definitions",
|
|
"",
|
|
"| Affix | Trigger | Progression value | Modes | Icon |",
|
|
"| --- | --- | ---: | --- | --- |",
|
|
...report.messages.affixDefinitions
|
|
.map((affix) => (
|
|
`| ${affix.name} | ${affix.trigger} | ${affix.unlockLevel}`
|
|
+ ` | ${affix.modeIds.join(", ")} | ${affix.iconReference} (spell ${affix.spellId}) |`
|
|
)),
|
|
"",
|
|
"## Reward-tier evidence",
|
|
"",
|
|
`${report.messages.counts.equipmentUnlocks} localized messages unlock named equipment tiers. `
|
|
+ "They contain no concrete item IDs or loot-table bindings.",
|
|
"",
|
|
"| Message | Progression value | Group |",
|
|
"| --- | ---: | ---: |",
|
|
...report.messages.equipmentUnlocks
|
|
.sort((left, right) => left.progressionValue - right.progressionValue)
|
|
.map((message) => (
|
|
`| ${message.title} | ${message.progressionValue} | ${message.groupId} |`
|
|
)),
|
|
"",
|
|
"## Evidence boundary",
|
|
"",
|
|
"Authoritative:",
|
|
"",
|
|
...report.evidenceAssessment.authoritative.map((value) => `- ${value}`),
|
|
"",
|
|
"Inference:",
|
|
"",
|
|
...report.evidenceAssessment.inferred.map((value) => `- ${value}`),
|
|
"",
|
|
"Not found in the searched client sources:",
|
|
"",
|
|
...report.evidenceAssessment.notFound.map((value) => `- ${value}`),
|
|
"",
|
|
];
|
|
return lines.join("\n");
|
|
}
|