Release Healer Man 0.1.4

This commit is contained in:
phenom
2026-08-18 12:06:04 -04:00
parent 5bb170039a
commit f4c9cf356c
63 changed files with 2694 additions and 81 deletions
@@ -0,0 +1,562 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
export function parseDbc(buffer, name = "DBC") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
const recordsEnd = 20 + recordCount * recordSize;
if (recordSize < fieldCount * 4 || recordsEnd + stringSize > buffer.length) {
throw new Error(`${name} has an unsupported or truncated schema.`);
}
const stringAt = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = recordsEnd + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const row = (index) => {
const base = 20 + index * recordSize;
return {
uint: (field) => field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0,
int: (field) => field < fieldCount ? buffer.readInt32LE(base + field * 4) : 0,
float: (field) => field < fieldCount ? buffer.readFloatLE(base + field * 4) : 0,
string: (field) => stringAt(field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0),
};
};
return { name, buffer, recordCount, fieldCount, recordSize, stringSize, row };
}
function decodeM2TrackHeader(buffer, offset) {
if (offset < 0 || offset + 20 > buffer.length) return null;
return {
interpolation: buffer.readUInt16LE(offset),
globalSequence: buffer.readInt16LE(offset + 2),
timeCount: buffer.readUInt32LE(offset + 4),
timeOffset: buffer.readUInt32LE(offset + 8),
keyCount: buffer.readUInt32LE(offset + 12),
keyOffset: buffer.readUInt32LE(offset + 16),
};
}
/** Decode the stable WotLK 3.x particle/ribbon fields used by the browser fallback converter. */
export function decodeWotlkM2Effects(buffer, name = "M2") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const magic = buffer.toString("ascii", 0, 4);
if ((magic !== "MD20" && magic !== "MD21") || buffer.length < 304) {
throw new Error(`${name} is not a supported WotLK M2.`);
}
const ribbonCount = buffer.readUInt32LE(288);
const ribbonOffset = buffer.readUInt32LE(292);
const particleCount = buffer.readUInt32LE(296);
const particleOffset = buffer.readUInt32LE(300);
const particleRecordSize = 476;
const ribbonRecordSize = 176;
if (particleCount && particleOffset + particleCount * particleRecordSize > buffer.length) {
throw new Error(`${name} has truncated particle records.`);
}
if (ribbonCount && ribbonOffset + ribbonCount * ribbonRecordSize > buffer.length) {
throw new Error(`${name} has truncated ribbon records.`);
}
const trackOffsets = {
emissionSpeed: 56,
speedVariation: 76,
verticalRange: 96,
horizontalRange: 116,
gravity: 136,
lifespan: 156,
emissionRate: 180,
emissionAreaLength: 200,
emissionAreaWidth: 220,
zSource: 240,
color: 260,
alpha: 280,
scale: 300,
};
const particles = Array.from({ length: particleCount }, (_, index) => {
const offset = particleOffset + index * particleRecordSize;
return {
id: buffer.readUInt32LE(offset),
flags: buffer.readUInt32LE(offset + 4),
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
parentBone: buffer.readUInt16LE(offset + 20),
textureIndex: buffer.readUInt16LE(offset + 22),
blendMode: buffer.readUInt8(offset + 40),
emitterType: buffer.readUInt8(offset + 41),
particleType: buffer.readUInt8(offset + 44),
headOrTail: buffer.readUInt8(offset + 45),
textureRows: buffer.readUInt16LE(offset + 48),
textureColumns: buffer.readUInt16LE(offset + 50),
tracks: Object.fromEntries(Object.entries(trackOffsets)
.map(([track, relativeOffset]) => [track, decodeM2TrackHeader(buffer, offset + relativeOffset)])),
};
});
const ribbons = Array.from({ length: ribbonCount }, (_, index) => {
const offset = ribbonOffset + index * ribbonRecordSize;
return {
id: buffer.readUInt32LE(offset),
parentBone: buffer.readUInt32LE(offset + 4),
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
textureCount: buffer.readUInt32LE(offset + 20),
textureOffset: buffer.readUInt32LE(offset + 24),
materialCount: buffer.readUInt32LE(offset + 28),
materialOffset: buffer.readUInt32LE(offset + 32),
colorTrack: decodeM2TrackHeader(buffer, offset + 36),
};
});
return { magic, particles, ribbons };
}
function tableById(table, decode) {
const result = new Map();
for (let index = 0; index < table.recordCount; index += 1) {
const row = table.row(index);
result.set(row.uint(0), decode(row));
}
return result;
}
function collectAbilitySpellIds() {
const requested = new Map();
const add = (source, id) => {
if (!Number.isInteger(id) || id <= 0) return;
requested.set(`${source}:${id}`, { source, spellId: id });
};
const abilityCatalog = fs.readFileSync(path.join(projectRoot, "src", "game", "abilityCatalog.ts"), "utf8");
for (const match of abilityCatalog.matchAll(/dbcSpellId:\s*(\d+)/g)) add("wow335", Number(match[1]));
const wow = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json"), "utf8"));
for (const chains of Object.values(wow.classes ?? {})) {
for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId));
}
const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8"));
for (const abilities of Object.values(coa.abilitiesByClass ?? {})) {
for (const ability of abilities) {
add("ascension", Number(ability.dbcSpellId));
for (const rank of ability.ranks ?? []) add("ascension", Number(rank.spellId));
}
}
const rom = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"), "utf8"));
const runewakerImageBySpellId = new Map();
for (const skill of rom.skills ?? []) {
add("runewaker", Number(skill.sourceSkillId));
if (Number(skill.source?.imageId) > 0) runewakerImageBySpellId.set(Number(skill.sourceSkillId), Number(skill.source.imageId));
for (const rank of skill.ranks ?? []) {
add("runewaker", Number(rank.sourceSkillId));
const imageId = Number(rank.source?.imageId ?? skill.source?.imageId);
if (imageId > 0) runewakerImageBySpellId.set(Number(rank.sourceSkillId), imageId);
}
}
const generatedRoot = path.join(projectRoot, "src", "game", "generated");
for (const name of fs.readdirSync(generatedRoot).filter((entry) => entry.endsWith("Population.generated.ts"))) {
const source = fs.readFileSync(path.join(generatedRoot, name), "utf8");
for (const match of source.matchAll(/"spellId":\s*(\d+)/g)) {
const spellId = Number(match[1]);
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "ascension", spellId);
}
}
return { requested: [...requested.values()], runewakerImageBySpellId };
}
function soundEntry(table, id) {
if (!id) return [];
for (let index = 0; index < table.recordCount; index += 1) {
const row = table.row(index);
if (row.uint(0) !== id) continue;
const directory = row.string(23).replaceAll("\\", "/").replace(/\/$/, "");
const volume = Number(row.float(24).toFixed(4));
const minimumDistance = Number(row.float(26).toFixed(3));
const maximumDistance = Number(row.float(27).toFixed(3));
return Array.from({ length: 10 }, (_, fileIndex) => {
const file = row.string(3 + fileIndex).replaceAll("\\", "/");
if (!file) return null;
return {
sourcePath: directory ? `${directory}/${file}` : file,
weight: Math.max(1, row.uint(13 + fileIndex)),
volume: volume > 0 ? volume : 1,
...(minimumDistance > 0 ? { minimumDistance } : {}),
...(maximumDistance > 0 ? { maximumDistance } : {}),
};
}).filter(Boolean);
}
return [];
}
function buildWowEntries(source, requestedIds, tables) {
const spell = parseDbc(tables.Spell, `${source} Spell.dbc`);
const visual = tableById(parseDbc(tables.SpellVisual, `${source} SpellVisual.dbc`), (row) => ({
precast: row.uint(1), cast: row.uint(2), impact: row.uint(3), state: row.uint(4), stateDone: row.uint(5),
channel: row.uint(6), missileModel: row.uint(8), missileSound: row.uint(11), animationSound: row.uint(12),
casterImpact: row.uint(14), targetImpact: row.uint(15), instantArea: row.uint(23), impactArea: row.uint(24), persistentArea: row.uint(25),
}));
const kitsTable = parseDbc(tables.SpellVisualKit, `${source} SpellVisualKit.dbc`);
const kits = tableById(kitsTable, (row) => ({
effects: Array.from({ length: 13 }, (_, offset) => row.uint(3 + offset)).filter(Boolean),
soundId: row.uint(16),
}));
const effectNames = tableById(parseDbc(tables.SpellVisualEffectName, `${source} SpellVisualEffectName.dbc`), (row) => ({
name: row.string(1),
model: row.string(2).replaceAll("\\", "/"),
scale: row.float(4) || 1,
}));
const attachments = new Map();
const attachTable = parseDbc(tables.SpellVisualKitModelAttach, `${source} SpellVisualKitModelAttach.dbc`);
for (let index = 0; index < attachTable.recordCount; index += 1) {
const row = attachTable.row(index);
const records = attachments.get(row.uint(1)) ?? [];
records.push({ effectId: row.uint(2), attachment: `attachment:${row.uint(3)}` });
attachments.set(row.uint(1), records);
}
const soundTable = parseDbc(tables.SoundEntries, `${source} SoundEntries.dbc`);
const kitAssets = (kitId) => {
const kit = kits.get(kitId);
if (!kit) return [];
const attached = new Map((attachments.get(kitId) ?? []).map((entry) => [entry.effectId, entry.attachment]));
return [...new Set([...kit.effects, ...(attachments.get(kitId) ?? []).map((entry) => entry.effectId)])]
.map((effectId) => {
const effect = effectNames.get(effectId);
if (!effect?.model) return null;
return {
model: effect.model,
...(attached.get(effectId) ? { attachment: attached.get(effectId) } : {}),
...(effect.scale !== 1 ? { scale: Number(effect.scale.toFixed(4)) } : {}),
};
}).filter(Boolean);
};
const soundsForKit = (kitId) => soundEntry(soundTable, kits.get(kitId)?.soundId ?? 0);
const spellRows = new Map();
const shift = spell.fieldCount >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + shift : field;
const wanted = new Set(requestedIds);
for (let index = 0; index < spell.recordCount; index += 1) {
const row = spell.row(index);
const id = row.uint(0);
if (wanted.has(id)) spellRows.set(id, [row.uint(shifted(131)), row.uint(shifted(132))].filter(Boolean));
}
const entries = [];
for (const spellId of requestedIds) {
const visualIds = spellRows.get(spellId) ?? [];
const phases = {};
const sounds = [];
const addAssets = (phase, assets) => {
const usable = assets.filter(Boolean);
if (usable.length) phases[phase] = [...(phases[phase] ?? []), ...usable];
};
for (const visualId of visualIds) {
const row = visual.get(visualId);
if (!row) continue;
for (const kitId of [row.precast, row.cast]) addAssets("cast-start", kitAssets(kitId));
if (row.missileModel) {
const missile = effectNames.get(row.missileModel);
if (missile?.model) addAssets("release", [{ model: missile.model, scale: missile.scale }]);
}
for (const kitId of [row.impact, row.casterImpact, row.targetImpact]) addAssets("impact", kitAssets(kitId));
addAssets("aura-start", kitAssets(row.state));
addAssets("aura-end", kitAssets(row.stateDone));
addAssets("tick", kitAssets(row.channel));
for (const kitId of [row.instantArea, row.impactArea, row.persistentArea]) addAssets("impact", kitAssets(kitId));
for (const kitId of [row.precast, row.cast, row.impact, row.state, row.channel, row.instantArea, row.impactArea, row.persistentArea]) {
sounds.push(...soundsForKit(kitId));
}
sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound));
}
for (const [phase, assets] of Object.entries(phases)) {
phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()];
}
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()];
if (!visualIds.length && !uniqueSounds.length) continue;
entries.push({
key: `${source}:${spellId}`,
source,
spellId,
visualIds,
...(Object.keys(phases).length ? { phases } : {}),
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
});
}
return entries;
}
function readNullSeparatedPaths(record) {
const values = [];
let start = 0;
for (let offset = 0; offset <= record.length; offset += 1) {
if (offset < record.length && record[offset] !== 0) continue;
if (offset - start >= 4) {
const value = record.subarray(start, offset).toString("latin1").trim();
if (/^[\x20-\x7e]+$/.test(value) && /[\\/]/.test(value)) values.push(value.replaceAll("\\", "/"));
}
start = offset + 1;
}
return values;
}
export function parseRunewakerImageRecords(buffer, requestedImageIds) {
const count = buffer.readInt32LE(132);
const recordSize = 3712;
const dataStart = 140;
if (count <= 0 || dataStart + count * recordSize > buffer.length) throw new Error("ImageObject.DB has an incompatible layout.");
const wanted = new Set(requestedImageIds);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const offset = dataStart + index * recordSize;
const id = buffer.readInt32LE(offset);
if (!wanted.has(id)) continue;
result.set(id, readNullSeparatedPaths(buffer.subarray(offset, offset + recordSize)));
}
return result;
}
function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
const count = buffer.readInt32LE(132);
const recordSize = 1032;
const dataStart = 140;
const wanted = new Set(spellIds);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const offset = dataStart + index * recordSize;
const spellId = buffer.readInt32LE(offset);
if (wanted.has(spellId)) result.set(spellId, buffer.readInt32LE(offset + 32));
}
return result;
}
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
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))];
const images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages);
const entries = [];
for (const spellId of spellIds) {
const imageId = imageBySpellId.get(spellId);
if (!imageId) continue;
const paths = images.get(imageId) ?? [];
const phases = {};
const sounds = [];
for (const sourcePath of paths) {
if (/\.(?:wav|ogg|mp3)$/i.test(sourcePath)) {
sounds.push({ sourcePath, weight: 1, volume: 1 });
continue;
}
if (!/\.(?:ros|ras)$/i.test(sourcePath)) continue;
const lower = sourcePath.toLowerCase();
const phase = /(?:^|_)t(?:_|\.)|explode|impact|hit/.test(lower)
? "impact"
: /(?:^|_)f(?:_|\.)|fly|missile/.test(lower)
? "release"
: /buff|aura|extend/.test(lower)
? "aura-start"
: "cast-start";
(phases[phase] ??= []).push({ model: sourcePath });
}
if (!Object.keys(phases).length && !sounds.length) continue;
entries.push({
key: `runewaker:${spellId}`,
source: "runewaker",
spellId,
imageId,
phases,
...(sounds.length ? { sounds } : {}),
});
}
return entries;
}
function extractWowTables(archivePath) {
const archive = MpqArchive.open(archivePath);
try {
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
return Object.fromEntries([
"Spell", "SpellVisual", "SpellVisualKit", "SpellVisualEffectName", "SpellVisualKitModelAttach", "SoundEntries",
].map((name) => [name, extract(name)]));
} finally {
archive.close();
}
}
function extractedAscensionTables(spellPath, supportRoot, baseSoundEntries) {
const read = (name) => fs.readFileSync(path.join(supportRoot, `${name}.dbc`));
return {
Spell: fs.readFileSync(spellPath),
SpellVisual: read("SpellVisual"),
SpellVisualKit: read("SpellVisualKit"),
SpellVisualEffectName: read("SpellVisualEffectName"),
SpellVisualKitModelAttach: read("SpellVisualKitModelAttach"),
SoundEntries: fs.existsSync(path.join(supportRoot, "SoundEntries.dbc")) ? read("SoundEntries") : baseSoundEntries,
};
}
function openedWowAudioArchives(dataRoot) {
const names = [
"patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "Patch-B.MPQ", "patch-A.MPQ",
"lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ",
];
return names
.map((name) => path.join(dataRoot, name))
.filter((archivePath) => fs.existsSync(archivePath) && fs.statSync(archivePath).isFile())
.map((archivePath) => ({ archivePath, archive: MpqArchive.open(archivePath) }));
}
function packageAudioAssets(entries, options) {
const outputRoot = path.resolve(options.audioOutput
?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "audio"));
const publicPrefix = "/assets/game/combat-effects/audio";
const wowDataRoot = path.resolve(options.wowDataRoot
?? path.join(projectRoot, "..", "wow335a", "Data"));
const runewakerResourceRoot = path.resolve(options.runewakerResourceRoot
?? path.join(projectRoot, "..", "Runewaker", "Resource"));
const archives = openedWowAudioArchives(wowDataRoot);
const sourceCache = new Map();
const missing = [];
const packagedFiles = new Set();
fs.mkdirSync(outputRoot, { recursive: true });
const extractSource = (sourcePath, source) => {
const cacheKey = `${source}:${sourcePath.toLowerCase()}`;
if (sourceCache.has(cacheKey)) return sourceCache.get(cacheKey);
let buffer = null;
if (source === "runewaker") {
const candidate = path.join(runewakerResourceRoot, ...sourcePath.split("/"));
if (fs.existsSync(candidate)) buffer = fs.readFileSync(candidate);
} else {
const archiveName = sourcePath.replaceAll("/", "\\");
for (const { archive } of archives) {
try {
buffer = archive.extractFile(archiveName);
if (buffer?.length) break;
} catch {
// The source archive stack is sparse by design; continue to the base archives.
}
}
}
sourceCache.set(cacheKey, buffer);
return buffer;
};
try {
for (const entry of entries) {
for (const sound of entry.sounds ?? []) {
const buffer = extractSource(sound.sourcePath, entry.source);
if (!buffer?.length) {
missing.push(`${entry.source}:${sound.sourcePath}`);
continue;
}
const extension = path.extname(sound.sourcePath).toLowerCase() || ".wav";
const fileName = `${sha256(buffer).slice(0, 20)}${extension}`;
const outputPath = path.join(outputRoot, fileName);
if (!fs.existsSync(outputPath)) fs.writeFileSync(outputPath, buffer);
packagedFiles.add(fileName);
sound.url = `${publicPrefix}/${fileName}`;
}
}
} finally {
for (const { archive } of archives) archive.close();
}
return {
referenced: sourceCache.size,
packaged: packagedFiles.size,
missing: [...new Set(missing)].sort(),
};
}
export function generateCombatEffectManifest(options = {}) {
const wowArchive = path.resolve(options.wowArchive ?? path.join(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ"));
const ascensionRoot = path.resolve(options.ascensionRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current"));
const ascensionSpell = path.resolve(options.ascensionSpell ?? path.join(ascensionRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"));
const ascensionSupport = path.resolve(options.ascensionSupport ?? path.join(ascensionRoot, "patch-S", "DBFilesClient"));
const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data"));
const { requested, runewakerImageBySpellId } = collectAbilitySpellIds();
const requestedBySource = requested.reduce((groups, entry) => {
(groups[entry.source] ??= []).push(entry);
return groups;
}, {});
const wowTables = extractWowTables(wowArchive);
const wowEntries = buildWowEntries("wow335", (requestedBySource.wow335 ?? []).map((entry) => entry.spellId), wowTables);
const ascensionTables = extractedAscensionTables(ascensionSpell, ascensionSupport, wowTables.SoundEntries);
const ascensionEntries = buildWowEntries("ascension", (requestedBySource.ascension ?? []).map((entry) => entry.spellId), ascensionTables);
const runewakerEntries = buildRunewakerEntries(
(requestedBySource.runewaker ?? []).map((entry) => entry.spellId),
runewakerImageBySpellId,
runewakerData,
);
const entries = [...wowEntries, ...ascensionEntries, ...runewakerEntries]
.sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId);
const audio = options.packageAudio
? packageAudioAssets(entries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) })
: { referenced: new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`))).size, packaged: 0, missing: [] };
const mapped = new Set(entries.map((entry) => entry.key));
const fallbackKeys = requested.map((entry) => `${entry.source}:${entry.spellId}`).filter((key) => !mapped.has(key));
return {
schemaVersion: 1,
generatedAt: null,
sources: [
{ kind: "wow335", path: path.relative(projectRoot, wowArchive).replaceAll("\\", "/"), sha256: sha256(wowTables.Spell) },
{ kind: "ascension", path: path.relative(projectRoot, ascensionSpell).replaceAll("\\", "/"), sha256: sha256(ascensionTables.Spell) },
{ kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) },
],
coverage: {
requested: requested.length,
authentic: entries.length,
fallback: fallbackKeys.length,
fallbackKeys,
audio,
unsupportedConstructs: [
"WotLK event-driven child emitters use source-styled pooled fallbacks",
"RuneWaker native shader programs outside decoded blend/billboard descriptors use source-styled pooled fallbacks",
],
},
entries,
};
}
function parseArguments(arguments_) {
const options = {};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--audit") options.audit = true;
else if (argument === "--package-audio") options.packageAudio = true;
else if (argument === "--output") options.output = arguments_[++index];
else if (argument === "--wow-archive") options.wowArchive = arguments_[++index];
else if (argument === "--ascension-root") options.ascensionRoot = arguments_[++index];
else if (argument === "--runewaker-data") options.runewakerData = arguments_[++index];
else throw new Error(`Unknown argument: ${argument}`);
}
return options;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArguments(process.argv.slice(2));
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
const manifest = generateCombatEffectManifest(options);
if (options.audit && manifest.coverage.audio.missing.length) {
throw new Error(`Combat effects audit failed: ${manifest.coverage.audio.missing.length} referenced audio files could not be packaged.`);
}
if (!options.audit) {
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(manifest)}\n`);
}
console.log(`Combat effects: ${manifest.coverage.authentic}/${manifest.coverage.requested} source mappings; ${manifest.coverage.fallback} intentional fallbacks.`);
if (!options.audit) console.log(`Wrote ${output}`);
}
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decodeWotlkM2Effects, parseDbc, parseRunewakerImageRecords } from "./generate-manifest.mjs";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
function tinyDbc() {
const strings = Buffer.from("\0hello\0", "utf8");
const buffer = Buffer.alloc(20 + 8 + strings.length);
buffer.write("WDBC", 0, "ascii");
buffer.writeUInt32LE(1, 4);
buffer.writeUInt32LE(2, 8);
buffer.writeUInt32LE(8, 12);
buffer.writeUInt32LE(strings.length, 16);
buffer.writeUInt32LE(42, 20);
buffer.writeUInt32LE(1, 24);
strings.copy(buffer, 28);
return buffer;
}
test("parses numeric and string DBC fields", () => {
const table = parseDbc(tinyDbc(), "fixture");
assert.equal(table.row(0).uint(0), 42);
assert.equal(table.row(0).string(1), "hello");
});
test("classifies paths from fixed RuneWaker ImageObject records", () => {
const buffer = Buffer.alloc(140 + 3712);
buffer.writeInt32LE(1, 132);
buffer.writeInt32LE(573727, 140);
Buffer.from("model\\fx\\skill\\fire\\act_fireball01_f_i.ros\0", "ascii").copy(buffer, 140 + 300);
const records = parseRunewakerImageRecords(buffer, [573727]);
assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]);
});
test("decodes supported WotLK M2 particle and ribbon records", () => {
const particleOffset = 304;
const buffer = Buffer.alloc(particleOffset + 476);
buffer.write("MD20", 0, "ascii");
buffer.writeUInt32LE(1, 296);
buffer.writeUInt32LE(particleOffset, 300);
buffer.writeUInt32LE(17, particleOffset);
buffer.writeUInt16LE(4, particleOffset + 20);
buffer.writeUInt16LE(9, particleOffset + 22);
buffer.writeUInt8(2, particleOffset + 40);
buffer.writeUInt8(1, particleOffset + 41);
buffer.writeUInt16LE(4, particleOffset + 48);
buffer.writeUInt16LE(8, particleOffset + 50);
buffer.writeUInt16LE(1, particleOffset + 56);
buffer.writeUInt32LE(3, particleOffset + 56 + 12);
const decoded = decodeWotlkM2Effects(buffer, "fixture");
assert.equal(decoded.particles[0].id, 17);
assert.equal(decoded.particles[0].textureIndex, 9);
assert.equal(decoded.particles[0].blendMode, 2);
assert.equal(decoded.particles[0].textureColumns, 8);
assert.equal(decoded.particles[0].tracks.emissionSpeed.interpolation, 1);
assert.equal(decoded.particles[0].tracks.emissionSpeed.keyCount, 3);
});
test("generated coverage packages every referenced source sound", () => {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8"));
assert.equal(manifest.coverage.requested, manifest.coverage.authentic + manifest.coverage.fallback);
assert.deepEqual(manifest.coverage.audio.missing, []);
const sounds = manifest.entries.flatMap((entry) => entry.sounds ?? []);
assert.ok(sounds.length > 0);
for (const sound of sounds) {
assert.ok(sound.url?.startsWith("/assets/game/combat-effects/audio/"));
assert.ok(fs.existsSync(path.join(root, "public", sound.url)));
}
});
+63 -15
View File
@@ -8,7 +8,8 @@ param(
[int]$VersionCode,
[string]$ReleaseNotes = 'Healer Man Android update.',
[string]$GiteaBaseUrl = 'https://git.whoagland.com',
[string]$GiteaBaseUrl = 'http://192.168.1.180:30008',
[string]$GiteaPublicBaseUrl = 'https://git.whoagland.com',
[string]$Owner = 'phenom',
[string]$Repository = 'healer-man'
)
@@ -67,7 +68,7 @@ try {
[IO.File]::WriteAllText($checksumPath, "$checksum $apkName`n", [Text.UTF8Encoding]::new($false))
$apiRoot = $GiteaBaseUrl.TrimEnd('/') + '/api/v1'
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
$headers = @{ Authorization = "token $($env:GITEA_TOKEN.Trim())" }
$tag = "v$VersionName"
$releaseBody = @{
tag_name = $tag
@@ -77,26 +78,73 @@ try {
draft = $false
prerelease = $false
} | ConvertTo-Json
$release = Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases" `
-Headers $headers `
-ContentType 'application/json' `
-Body $releaseBody
$encodedTag = [Uri]::EscapeDataString($tag)
try {
$release = Invoke-RestMethod `
-Method Get `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/tags/$encodedTag" `
-Headers $headers
Write-Host "Resuming existing Gitea release $tag."
} catch {
$statusCode = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
if ($statusCode -ne 404) { throw }
$release = Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases" `
-Headers $headers `
-ContentType 'application/json' `
-Body $releaseBody
}
foreach ($attachment in @($apkPath, $checksumPath)) {
$attachmentName = Split-Path -Leaf $attachment
$attachmentSize = (Get-Item -LiteralPath $attachment).Length
$encodedName = [Uri]::EscapeDataString($attachmentName)
Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$encodedName" `
-Headers $headers `
-ContentType 'application/octet-stream' `
-InFile $attachment | Out-Null
$release = Invoke-RestMethod `
-Method Get `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)" `
-Headers $headers
$existingAsset = @($release.assets | Where-Object { $_.name -eq $attachmentName }) |
Select-Object -First 1
if ($existingAsset) {
if ([long]$existingAsset.size -ne $attachmentSize) {
throw "Release asset $attachmentName exists with the wrong size ($($existingAsset.size), expected $attachmentSize)."
}
Write-Host "Release asset $attachmentName already exists with the expected size; skipping upload."
continue
}
$uploaded = $false
for ($attempt = 1; $attempt -le 3 -and -not $uploaded; $attempt++) {
try {
Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$encodedName" `
-Headers $headers `
-ContentType 'application/octet-stream' `
-InFile $attachment | Out-Null
$uploaded = $true
} catch {
$refreshedRelease = Invoke-RestMethod `
-Method Get `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)" `
-Headers $headers
$uploadedAsset = @($refreshedRelease.assets | Where-Object {
$_.name -eq $attachmentName -and [long]$_.size -eq $attachmentSize
}) | Select-Object -First 1
if ($uploadedAsset) {
$uploaded = $true
continue
}
if ($attempt -eq 3) { throw }
Write-Warning "Upload attempt $attempt for $attachmentName failed; retrying through the LAN API."
Start-Sleep -Seconds ([math]::Pow(2, $attempt))
}
}
}
Write-Host "Published $tag with $apkName and its SHA-256 checksum."
Write-Host "$GiteaBaseUrl/$Owner/$Repository/releases/tag/$tag"
Write-Host "$($GiteaPublicBaseUrl.TrimEnd('/'))/$Owner/$Repository/releases/tag/$tag"
} finally {
Pop-Location
}
+2 -2
View File
@@ -319,7 +319,7 @@ def read_saved_signing_password() -> str:
def read_saved_gitea_token() -> str:
return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token")
return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token").strip()
def android_toolchain_status() -> tuple[bool, str]:
@@ -843,7 +843,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
"ANDROID_KEY_ALIAS": self.alias_var.get().strip(),
"ANDROID_KEYSTORE_PASSWORD": self.keystore_password_var.get(),
"ANDROID_KEY_PASSWORD": self.key_password_var.get(),
"GITEA_TOKEN": self.token_var.get(),
"GITEA_TOKEN": self.token_var.get().strip(),
}
for key, value in list(fields.items()):
if not value: