Release Healer Man 0.1.40

This commit is contained in:
phenom committed 2026-09-13 13:21:27 -04:00
1 parent ed0d2fea3d
commit 197a64117c
215 files changed
+5131 -181

No files matched your search

@@ -0,0 +1,159 @@
#!/usr/bin/env node
// Read campaign metadata without booting the full combat runtime.
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { gunzipSync } from "node:zlib";
import { decodeBlp2, encodePng } from "../lib/blp.mjs";
import { stitchMap } from "../lib/wow-map-image.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const require = createRequire(import.meta.url);
const compressedSource = fs.readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.compressed.ts"), "utf8");
const compressed = compressedSource.match(/export default "([A-Za-z0-9+/=]+)"/)?.[1];
if (!compressed) throw new Error("Missing compressed campaign metadata");
const campaign = JSON.parse(gunzipSync(Buffer.from(compressed, "base64")).toString("utf8"));
const authoredWailing = fs.readFileSync(path.join(root, "src/game/dungeons/wailingCaverns.ts"), "utf8");
if (!authoredWailing.includes('fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)"')) throw new Error("Wailing Caverns basis changed; review the map importer");
const definitions = campaign.definitions.map((definition) => definition.id === "wailing-caverns"
? { ...definition, assets: { visual: [{}] }, provenance: { ...definition.provenance, coordinateTransform: "three(x,y,z)=(-wow.x,wow.z,wow.y)" } }
: definition);
const { MpqArchive } = require("stormlib-js");
const source = path.resolve(process.argv[2] ?? path.join(root, "../downloads/patch-enUS-M.MPQ"));
const client = path.resolve(process.env.WOW_CLIENT_ROOT ?? path.join(root, "../wow335a"));
const work = path.resolve(process.env.WOW_DUNGEON_WORK_ROOT ?? path.join(root, "../HealerMan-Storage/pipeline-work/dungeons"));
const archivePaths = [source, ...[
"patch-enUS-3.MPQ", "patch-enUS-2.MPQ", "patch-enUS.MPQ",
"lichking-locale-enUS.MPQ", "expansion-locale-enUS.MPQ", "locale-enUS.MPQ",
].map((name) => path.join(client, "Data/enUS", name))];
const archives = archivePaths.filter(fs.existsSync).map((file) => ({ file, archive: MpqArchive.open(file, { noListfile: true, noAttributes: true }) }));
const sourcesUsed = new Set();
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
function extract(name) {
for (const entry of archives) {
if (!entry.archive.hasFile(name)) continue;
sourcesUsed.add(entry.file);
return entry.archive.extractFile(name);
}
return null;
}
function dbc(name, fields) {
const b = extract(`DBFilesClient/${name}.dbc`);
if (!b || b.toString("ascii", 0, 4) !== "WDBC" || b.readUInt32LE(8) !== fields) throw new Error(`Invalid ${name} DBC`);
const count = b.readUInt32LE(4), size = b.readUInt32LE(12), stringStart = 20 + count * size;
if (size !== fields * 4 || stringStart + b.readUInt32LE(16) > b.length) throw new Error(`Truncated ${name} DBC`);
return Array.from({ length: count }, (_, i) => {
const start = 20 + i * size;
return {
int: (f) => b.readInt32LE(start + f * 4),
float: (f) => b.readFloatLE(start + f * 4),
string: (f) => {
const offset = b.readUInt32LE(start + f * 4);
const end = b.indexOf(0, stringStart + offset);
return b.toString("utf8", stringStart + offset, end);
},
};
});
}
function readJson(file) { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; }
const add = (a, b) => a.map((v, i) => v + b[i]);
const subtract = (a, b) => a.map((v, i) => v - b[i]);
function rotate(point, degrees) {
const angle = degrees * Math.PI / 180, c = Math.cos(angle), s = Math.sin(angle);
return [point[0] * c - point[2] * s, point[1], point[0] * s + point[2] * c];
}
/** Inverse of the reviewed campaign mapper, including WMO yaw and anchor offsets. */
function runtimeToWow(definition, recipe, draft) {
const authoredWailing = definition.id === "wailing-caverns";
const adt = definition.environmentMode === "adt-hybrid";
const flip = recipe.globalWmoSourceBasis === "flip-x";
const adtBasis = recipe.adtSourceBasis ?? (recipe.clientBuild === "3.3.5a-Epoch" ? "z-y-negative-x" : "negative-z-y-negative-x");
const basisDraft = (p) => adt ? [adtBasis === "z-y-negative-x" ? p[2] : -p[2], p[1], -p[0]] : [flip ? -p[0] : p[0], p[1], p[2]];
const basisWow = (p) => authoredWailing ? [-p[0], p[2], p[1]] : basisDraft([-p[0], p[2], p[1]]);
const alignment = definition.provenance.coordinateAlignment ?? "source";
const yaw = !authoredWailing && !adt && alignment === "source-transform" ? recipe.coordinateTransform?.yawDegrees ?? 0 : 0;
let offset = [0, 0, 0];
if (!authoredWailing && alignment === "source-transform" && !adt) {
if (!recipe.coordinateTransform) throw new Error(`${definition.id}: missing source transform`);
offset = rotate(basisDraft(recipe.coordinateTransform.origin).map((v) => -v), yaw);
} else if (!authoredWailing && alignment === "player-anchor") {
if (!draft?.entrance) throw new Error(`${definition.id}: missing draft entrance for anchor alignment`);
offset = subtract(definition.entrance.footPosition, basisDraft(draft.entrance));
}
const axes = [[1, 0, 0], [0, 1, 0], [0, 0, 1]].map((p) => rotate(basisWow(p), yaw));
const inverse = axes.map((axis) => [...axis, -axis.reduce((sum, value, i) => sum + value * offset[i], 0)]);
// Verify against independent imported spawn positions, not the dungeon bounds.
const pairs = (draft?.spawns ?? []).flatMap((spawn) => {
const runtime = definition.staticSpawns.find((candidate) => candidate.id === spawn.id);
return runtime ? [{ runtime: runtime.position, wow: [-spawn.position[0], spawn.position[2], spawn.position[1]] }] : [];
});
const checkedSpawns = pairs.length;
// Event-driven arenas can have no static actors; verify their source entrance instead.
const checkedEntrances = !pairs.length && draft?.entrance ? 1 : 0;
if (checkedEntrances) pairs.push({ runtime: definition.entrance.footPosition, wow: [-draft.entrance[0], draft.entrance[2], draft.entrance[1]] });
if (!pairs.length) throw new Error(`${definition.id}: no source positions available to verify alignment`);
const error = Math.max(0, ...pairs.map(({ runtime, wow }) => Math.hypot(...inverse.map((axis, i) => axis[3] + axis.slice(0, 3).reduce((sum, v, j) => sum + v * runtime[j], 0) - wow[i]))));
if (error > (authoredWailing ? 0.25 : 0.15)) throw new Error(`${definition.id}: map alignment failed (${error.toFixed(3)} yards)`);
return { matrix: inverse.flat().map((v) => Number(v.toFixed(10))), checkedSpawns, checkedEntrances, maxError: error };
}
try {
const areas = dbc("WorldMapArea", 11).map((r) => ({
id: r.int(0), mapId: r.int(1), name: r.string(3),
bounds: [r.float(7), r.float(6), r.float(5), r.float(4)],
}));
const floors = dbc("DungeonMap", 8).map((r) => ({
dbcId: r.int(0), mapId: r.int(1), level: r.int(2),
// DungeonMap: MinX, MaxX, MinY, MaxY (world X is north, Y is west).
bounds: [r.float(5), r.float(6), r.float(3), r.float(4)],
}));
const strings = extract("Interface/FrameXML/GlobalStrings.lua")?.toString("utf8") ?? "";
const names = Object.fromEntries([...strings.matchAll(/DUNGEON_FLOOR_(\w+)\s*=\s*"([^"]+)"/g)].map((m) => [m[1].toLowerCase(), m[2]]));
const catalog = { schemaVersion: 1, width: 1002, height: 668, dungeons: {} };
const report = { source: path.basename(source), sourceSha256: sha256(fs.readFileSync(source)), imported: [], skipped: [], coordinateSources: ["WorldMapArea.dbc", "DungeonMap.dbc"], layoutSource: "https://github.com/wowgaming/3.3.5-interface-files/blob/main/WorldMapFrame.xml" };
for (const definition of definitions.filter((d) => d.expansion !== "runewaker")) {
console.log(`Importing ${definition.id}`);
const area = areas.find((a) => a.mapId === definition.mapId);
if (!area) { report.skipped.push({ id: definition.id, reason: "No matching WorldMapArea record" }); continue; }
const recipe = readJson(path.join(root, "dungeon-pipeline/recipes", `${definition.id}.json`)) ?? {};
const draft = readJson(path.join(work, definition.id, "runtime-draft.json"));
const candidates = floors.filter((floor) => floor.mapId === definition.mapId)
.filter((floor, index, entries) => entries.findIndex((entry) => entry.level === floor.level) === index);
if (area.bounds[1] > area.bounds[0] && area.bounds[3] > area.bounds[2]) candidates.unshift({ level: 0, bounds: area.bounds });
const importedFloors = [];
for (const floor of candidates) {
if (!floor.bounds.every(Number.isFinite) || floor.bounds[1] <= floor.bounds[0] || floor.bounds[3] <= floor.bounds[2]) continue;
const stem = floor.level ? `${area.name}${floor.level}_` : area.name;
const tilePaths = Array.from({ length: 12 }, (_, i) => `Interface/WorldMap/${area.name}/${stem}${i + 1}.blp`);
const buffers = tilePaths.map(extract);
if (buffers.some((b) => !b)) continue;
const png = encodePng(stitchMap(buffers.map((b, i) => decodeBlp2(b, tilePaths[i]))));
const logicalPath = `/assets/game/dungeons/${definition.id}/maps/floor-${floor.level}.png`;
const output = path.join(root, "public", logicalPath);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, png);
importedFloors.push({ level: floor.level, name: names[`${area.name}${floor.level}`.toLowerCase()] ?? (floor.level ? `Floor ${floor.level}` : "Overview"), bounds: floor.bounds, image: logicalPath, checksum: sha256(png) });
}
if (!importedFloors.length) { report.skipped.push({ id: definition.id, reason: "No complete twelve-tile map in supplied/client archives" }); continue; }
let alignment;
try { alignment = runtimeToWow(definition, recipe, draft); }
catch (error) { report.skipped.push({ id: definition.id, reason: error.message }); continue; }
catalog.dungeons[definition.id] = {
mapId: definition.mapId, coordinateTransform: definition.provenance.coordinateTransform,
visualChecksum: definition.assets.visual[0]?.checksum, runtimeToWow: alignment.matrix,
floors: importedFloors,
};
report.imported.push({ id: definition.id, floors: importedFloors.length, checkedSpawns: alignment.checkedSpawns, checkedEntrances: alignment.checkedEntrances, maxAlignmentError: alignment.maxError });
}
report.archives = [...sourcesUsed].map((file) => path.basename(file));
fs.writeFileSync(path.join(root, "src/game/generated/wowDungeonMaps.generated.json"), `${JSON.stringify(catalog, null, 2)}\n`);
fs.writeFileSync(path.join(root, "dungeon-pipeline/WOW_DUNGEON_MAP_REPORT.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(`Imported ${report.imported.length} dungeon maps / ${report.imported.reduce((sum, entry) => sum + entry.floors, 0)} floors; skipped ${report.skipped.length}`);
for (const skip of report.skipped) console.log(` ${skip.id}: ${skip.reason}`);
} finally {
for (const { archive } of archives) archive.close();
}
+212
View File
@@ -0,0 +1,212 @@
import zlib from "node:zlib";
// BLP2/PNG codec adapted from the established spell-icon export pipeline.
function expand565(value) {
const red = (value >>> 11) & 0x1f;
const green = (value >>> 5) & 0x3f;
const blue = value & 0x1f;
return [
Math.round(red * 255 / 31),
Math.round(green * 255 / 63),
Math.round(blue * 255 / 31),
255,
];
}
function mix(first, second, firstWeight, secondWeight, divisor) {
return [
Math.round((first[0] * firstWeight + second[0] * secondWeight) / divisor),
Math.round((first[1] * firstWeight + second[1] * secondWeight) / divisor),
Math.round((first[2] * firstWeight + second[2] * secondWeight) / divisor),
255,
];
}
function decodeColorBlock(buffer, offset, forceFourColors) {
const firstValue = buffer.readUInt16LE(offset);
const secondValue = buffer.readUInt16LE(offset + 2);
const first = expand565(firstValue);
const second = expand565(secondValue);
const colors = [first, second];
if (forceFourColors || firstValue > secondValue) {
colors.push(mix(first, second, 2, 1, 3), mix(first, second, 1, 2, 3));
} else {
colors.push(mix(first, second, 1, 1, 2), [0, 0, 0, 0]);
}
return { colors, indices: buffer.readUInt32LE(offset + 4) };
}
function decodeDxt3Alpha(buffer, offset) {
const alpha = new Uint8Array(16);
for (let pixel = 0; pixel < 16; pixel += 1) {
const nibble = (buffer[offset + Math.floor(pixel / 2)] >>> ((pixel % 2) * 4)) & 0xf;
alpha[pixel] = nibble * 17;
}
return alpha;
}
function decodeDxt5Alpha(buffer, offset) {
const first = buffer[offset];
const second = buffer[offset + 1];
const table = new Uint8Array(8);
table[0] = first;
table[1] = second;
if (first > second) {
for (let index = 1; index <= 6; index += 1) {
table[index + 1] = Math.round(((7 - index) * first + index * second) / 7);
}
} else {
for (let index = 1; index <= 4; index += 1) {
table[index + 1] = Math.round(((5 - index) * first + index * second) / 5);
}
table[6] = 0;
table[7] = 255;
}
let bits = 0n;
for (let byte = 0; byte < 6; byte += 1) bits |= BigInt(buffer[offset + 2 + byte]) << BigInt(byte * 8);
const alpha = new Uint8Array(16);
for (let pixel = 0; pixel < 16; pixel += 1) {
alpha[pixel] = table[Number((bits >> BigInt(pixel * 3)) & 7n)];
}
return alpha;
}
function decodePalettedBlp2(buffer, sourcePath, alphaDepth, width, height, mipOffset, mipSize) {
const pixelCount = width * height;
const alphaBytes = alphaDepth === 0
? 0
: alphaDepth === 1
? Math.ceil(pixelCount / 8)
: alphaDepth === 4
? Math.ceil(pixelCount / 2)
: alphaDepth === 8
? pixelCount
: -1;
if (alphaBytes < 0) throw new Error(`${sourcePath} uses unsupported paletted alpha depth ${alphaDepth}`);
if (mipSize < pixelCount + alphaBytes || mipOffset + pixelCount + alphaBytes > buffer.length) {
throw new Error(`${sourcePath} has a truncated paletted base mip`);
}
const paletteOffset = 148;
if (paletteOffset + 256 * 4 > buffer.length) throw new Error(`${sourcePath} has a truncated palette`);
const alphaOffset = mipOffset + pixelCount;
const pixels = Buffer.alloc(pixelCount * 4);
for (let pixel = 0; pixel < pixelCount; pixel += 1) {
const paletteEntry = paletteOffset + buffer[mipOffset + pixel] * 4;
const output = pixel * 4;
pixels[output] = buffer[paletteEntry + 2];
pixels[output + 1] = buffer[paletteEntry + 1];
pixels[output + 2] = buffer[paletteEntry];
pixels[output + 3] = alphaDepth === 0
? 255
: alphaDepth === 1
? ((buffer[alphaOffset + Math.floor(pixel / 8)] >>> (pixel % 8)) & 1) * 255
: alphaDepth === 4
? ((buffer[alphaOffset + Math.floor(pixel / 2)] >>> ((pixel % 2) * 4)) & 0xf) * 17
: buffer[alphaOffset + pixel];
}
return { width, height, pixels };
}
export function decodeBlp2(buffer, sourcePath) {
if (buffer.toString("ascii", 0, 4) !== "BLP2") throw new Error(`${sourcePath} is not a BLP2 file`);
const type = buffer.readUInt32LE(4);
const encoding = buffer[8];
const alphaDepth = buffer[9];
const alphaEncoding = buffer[10];
const width = buffer.readUInt32LE(12);
const height = buffer.readUInt32LE(16);
const mipOffset = buffer.readUInt32LE(20);
const mipSize = buffer.readUInt32LE(84);
if (type !== 1) throw new Error(`${sourcePath} uses unsupported BLP2 type ${type}`);
if (!width || !height || !mipOffset || !mipSize) throw new Error(`${sourcePath} has no usable base mip`);
if (encoding === 1) {
return decodePalettedBlp2(buffer, sourcePath, alphaDepth, width, height, mipOffset, mipSize);
}
if (encoding !== 2) throw new Error(`${sourcePath} uses unsupported BLP2 encoding ${encoding}`);
const dxtMode = alphaDepth === 0 || alphaEncoding === 0
? "dxt1"
: alphaEncoding === 1
? "dxt3"
: alphaEncoding === 7
? "dxt5"
: null;
if (!dxtMode) throw new Error(`${sourcePath} uses unsupported alpha encoding ${alphaEncoding}`);
const bytesPerBlock = dxtMode === "dxt1" ? 8 : 16;
const blockColumns = Math.ceil(width / 4);
const blockRows = Math.ceil(height / 4);
if (blockColumns * blockRows * bytesPerBlock > mipSize) throw new Error(`${sourcePath} has a truncated base mip`);
const pixels = Buffer.alloc(width * height * 4);
for (let blockY = 0; blockY < blockRows; blockY += 1) {
for (let blockX = 0; blockX < blockColumns; blockX += 1) {
const blockOffset = mipOffset + (blockY * blockColumns + blockX) * bytesPerBlock;
const alpha = dxtMode === "dxt3"
? decodeDxt3Alpha(buffer, blockOffset)
: dxtMode === "dxt5"
? decodeDxt5Alpha(buffer, blockOffset)
: null;
const colorOffset = blockOffset + (dxtMode === "dxt1" ? 0 : 8);
const { colors, indices } = decodeColorBlock(buffer, colorOffset, dxtMode !== "dxt1");
for (let localY = 0; localY < 4; localY += 1) {
for (let localX = 0; localX < 4; localX += 1) {
const x = blockX * 4 + localX;
const y = blockY * 4 + localY;
if (x >= width || y >= height) continue;
const blockPixel = localY * 4 + localX;
const color = colors[(indices >>> (blockPixel * 2)) & 3];
const output = (y * width + x) * 4;
pixels[output] = color[0];
pixels[output + 1] = color[1];
pixels[output + 2] = color[2];
pixels[output + 3] = alpha?.[blockPixel] ?? color[3];
}
}
}
}
return { width, height, pixels };
}
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
crcTable[index] = value >>> 0;
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuffer = Buffer.from(type, "ascii");
const output = Buffer.alloc(data.length + 12);
output.writeUInt32BE(data.length, 0);
typeBuffer.copy(output, 4);
data.copy(output, 8);
output.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), data.length + 8);
return output;
}
export function encodePng({ width, height, pixels }) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const scanlines = Buffer.alloc(height * (width * 4 + 1));
for (let y = 0; y < height; y += 1) pixels.copy(scanlines, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", zlib.deflateSync(scanlines, { level: 9 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
+12
View File
@@ -0,0 +1,12 @@
/** WoW's twelve row-major 256px tiles are cropped to the client map's 1002x668 rectangle. */
export function stitchMap(tiles) {
if (tiles.length !== 12) throw new Error("A map requires all twelve tiles");
const width = 1002, height = 668, pixels = Buffer.alloc(width * height * 4);
tiles.forEach((tile, index) => {
if (tile.width !== 256 || tile.height !== 256) throw new Error("Unexpected map tile dimensions");
const x = (index % 4) * 256, y = Math.floor(index / 4) * 256;
const copyWidth = Math.min(256, width - x), copyHeight = Math.min(256, height - y);
for (let row = 0; row < copyHeight; row += 1) tile.pixels.copy(pixels, ((y + row) * width + x) * 4, row * 256 * 4, (row * 256 + copyWidth) * 4);
});
return { width, height, pixels };
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decodeBlp2, encodePng } from "./blp.mjs";
import { stitchMap } from "./wow-map-image.mjs";
test("map tiles are row-major and cropped to the original client's 1002x668 area", () => {
const tiles = Array.from({ length: 12 }, (_, i) => ({ width: 256, height: 256, pixels: Buffer.alloc(256 * 256 * 4, i) }));
const image = stitchMap(tiles);
assert.deepEqual([image.width, image.height], [1002, 668]);
const pixel = (x, y) => image.pixels[(y * image.width + x) * 4];
assert.equal(pixel(0, 0), 0);
assert.equal(pixel(256, 0), 1);
assert.equal(pixel(1001, 0), 3);
assert.equal(pixel(0, 256), 4);
assert.equal(pixel(0, 667), 8);
assert.equal(pixel(1001, 667), 11);
const png = encodePng(image);
assert.equal(png.toString("ascii", 1, 4), "PNG");
assert.equal(png.readUInt32BE(16), 1002);
assert.equal(png.readUInt32BE(20), 668);
});
test("partial or unexpected-size tile sets cannot become a misleading map", () => {
assert.throws(() => stitchMap([]), /twelve/);
assert.throws(() => stitchMap(Array.from({ length: 12 }, () => ({ width: 128, height: 256 }))), /dimensions/);
});
test("BLP2 DXT1 map tiles decode RGB565 color correctly", () => {
const b = Buffer.alloc(156);
b.write("BLP2"); b.writeUInt32LE(1, 4); b[8] = 2;
b.writeUInt32LE(4, 12); b.writeUInt32LE(4, 16);
b.writeUInt32LE(148, 20); b.writeUInt32LE(8, 84);
b.writeUInt16LE(0xf800, 148);
const decoded = decodeBlp2(b, "red fixture");
assert.deepEqual([...decoded.pixels.subarray(0, 4)], [255, 0, 0, 255]);
assert.equal(decoded.pixels.length, 64);
});