diff --git a/src/common/units/direction.ts b/src/common/units/direction.ts new file mode 100644 index 0000000..4405fe2 --- /dev/null +++ b/src/common/units/direction.ts @@ -0,0 +1,142 @@ +/** + * Diablo II 1.13c unit facing in the engine's 64-direction space ("dir64"). + * + * dir64 0 faces screen south (world +X+Y) and the index grows clockwise on screen. The + * engine quantises the eight compass vectors to: S (+1,+1) = 0, SW (0,+1) = 7, + * W (−1,+1) = 15, NW (−1,0) = 23, N (−1,−1) = 32, NE (0,−1) = 40, E (+1,−1) = 47, + * SE (+1,0) = 56. + * + * Every unit facing in the client is a dir64 value. It becomes an animation (COF) + * direction only when a clip is drawn, through {@link dir64ToClipDirection}. + */ + +import { dir64ToCof } from './character.ts' + +/** dir64 values are integers in `0..DIR64_COUNT - 1`. */ +export const DIR64_COUNT = 64 + +/** World sub-tile → 16.16 fixed point, the engine's precise unit coordinates. */ +const FP16_ONE = 65536 + +/** + * Angle column of the D2Common.dll 1.13c path direction table at VA **0x6FDDD470** + * (128 × `{ int32 x, int32 y, int32 angle }`), indexed by `trunc(127 · minor / major)`. + * The values step at indices 13, 26, 39, 53, 68, 85 and 105. + */ +const PATH_DIRECTION_ANGLE: readonly number[] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, +] + +/** Throws unless `value` is a dir64 (an integer in `0..63`). */ +export function assertDir64(value: number, what: string): void { + if (!Number.isInteger(value) || value < 0 || value >= DIR64_COUNT) { + throw new RangeError(`${what} must be an integer dir64 in 0..63, got ${String(value)}`) + } +} + +function toFp16(value: number, what: string): number { + if (!Number.isFinite(value)) { + throw new RangeError(`${what} must be a finite sub-tile coordinate, got ${String(value)}`) + } + return Math.round(value * FP16_ONE) +} + +/** + * The dir64 facing from a start point toward a target point, both in world sub-tiles. + * + * Exact port of D2Common.dll 1.13c **0x6FD5CF40** (D2MOO `PATH_GetDirectionVector`, + * `Step.cpp`), which runs on 16.16 fixed-point coordinates: + * 1. take `|dx|`, `|dy|`; if `|dx| > |dy|` swap them, so `minor <= major`; + * 2. `a = ANGLE[trunc(127 · minor / major)]` (table at 0x6FDDD470); + * 3. if swapped, `a = (−1 − a) & 15`; if `start.y > target.y`, `a = (−1 − a) & 31`; + * if `start.x <= target.x`, `a = (−1 − a) & 63`; + * 4. the result is `(a + 8) & 63`. + * + * The engine computes `127 · minor` in 32 bits, which wraps once `minor` reaches about + * 258 sub-tiles. It never asks for a facing across a segment that long, so the port uses + * exact arithmetic instead of reproducing the wrap. + * + * For identical points the raw routine would return 7 (tangent 0, both "start <= target" + * branches), but the engine never calls it that way: it only computes a facing when a unit + * starts a segment toward a path point, and path points never include the unit's own + * position. So when the points coincide (to 1/65536 of a sub-tile) this returns + * `fallbackDir64` — callers pass the unit's current facing, i.e. no new segment and no + * facing change. + * + * @throws RangeError when a coordinate is not finite or `fallbackDir64` is not a dir64. + */ +export function dir64FromPoints( + startX: number, + startY: number, + targetX: number, + targetY: number, + fallbackDir64: number, +): number { + assertDir64(fallbackDir64, 'fallbackDir64') + const sx = toFp16(startX, 'startX') + const sy = toFp16(startY, 'startY') + const tx = toFp16(targetX, 'targetX') + const ty = toFp16(targetY, 'targetY') + + const absDx = Math.abs(tx - sx) + const absDy = Math.abs(ty - sy) + if (absDx === 0 && absDy === 0) return fallbackDir64 + + const swapped = absDx > absDy + const minor = swapped ? absDy : absDx + const major = swapped ? absDx : absDy + // Integer division without floating-point rounding: every operand stays below 2^53. + const scaled = 127 * minor + const tangent = (scaled - (scaled % major)) / major + const tableAngle = PATH_DIRECTION_ANGLE[tangent] + if (tableAngle === undefined) { + throw new RangeError(`direction tangent index ${String(tangent)} outside 0..127`) + } + + let angle = tableAngle + if (swapped) angle = (-1 - angle) & 0x0f + if (sy > ty) angle = (-1 - angle) & 0x1f + if (sx <= tx) angle = (-1 - angle) & 0x3f + return (angle + 8) & 0x3f +} + +/** + * The animation (COF) direction to draw for a dir64 facing. + * + * Port of D2CMP.dll 1.13c ordinal **#10020** (0x6FE21FB0), which reads row + * `bsf(directions) + 1` of the int32 table at 0x6FE2F4D8 and asserts that `dir64 < 64` and + * that `directions` is a power of two no greater than 64: + * - 1 direction: always 0 (row 0x6FE2F5D8 is all zeros); + * - 2 directions: dir64 16..47 → 1, otherwise 0 (row 0x6FE2F6D8); + * - 4 / 8 / 16 / 32 / 64 directions: rows 0x6FE2F7D8 / 8D8 / 9D8 / AD8 / BD8, which equal + * {@link dir64ToCof}'s tables. + * + * @throws RangeError when `dir64` is not a dir64 or `directions` is not one the engine + * supports (instead of `dir64ToCof`'s silent 0). + */ +export function dir64ToClipDirection(dir64: number, directions: number): number { + assertDir64(dir64, 'dir64') + switch (directions) { + case 1: + return 0 + case 2: + return dir64 >= 16 && dir64 < 48 ? 1 : 0 + case 4: + case 8: + case 16: + case 32: + case 64: + return dir64ToCof(dir64, directions) + default: + throw new RangeError( + `animation direction count must be 1, 2, 4, 8, 16, 32 or 64, got ${String(directions)}`, + ) + } +} diff --git a/tests/unit-direction.test.ts b/tests/unit-direction.test.ts new file mode 100644 index 0000000..178d566 --- /dev/null +++ b/tests/unit-direction.test.ts @@ -0,0 +1,263 @@ +/** + * dir64 unit facing (`src/common/units/direction.ts`) against Diablo II 1.13c. + * + * - `dir64FromPoints` ports D2Common.dll 0x6FD5CF40 (path direction from two points). + * - `dir64ToClipDirection` ports D2CMP.dll #10020 (dir64 → animation direction). + * + * The binary-parity block reads the 1.13c DLLs from `samples/d2` and is skipped without them. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + DIR64_COUNT, + assertDir64, + dir64FromPoints, + dir64ToClipDirection, +} from '../src/common/units/direction.ts' + +const D2COMMON_DLL = 'samples/d2/D2Common.dll' +const D2CMP_DLL = 'samples/d2/D2CMP.dll' +const hasDlls = existsSync(D2COMMON_DLL) && existsSync(D2CMP_DLL) + +/** Every animation direction count D2CMP #10020 accepts. */ +const CLIP_DIRECTION_COUNTS = [1, 2, 4, 8, 16, 32, 64] as const + +/** + * The 16 screen headings (0 = screen south, clockwise) as world angles: screen south is world + * (+1, +1), i.e. 45°, and each screen step is 22.5° of world angle. + */ +function headingVector(k: number): readonly [number, number] { + const radians = ((45 + k * 22.5) * Math.PI) / 180 + return [Math.cos(radians), Math.sin(radians)] +} + +/** dir64 for the 16 screen headings, as the engine quantises them. */ +const HEADING_DIR64 = [0, 4, 7, 11, 15, 20, 23, 27, 32, 36, 40, 43, 47, 52, 56, 59] as const + +/** The world angle (atan2 degrees) an animation direction faces: dir64 0 is world 45°. */ +function clipDegrees(direction: number, directions: number): number { + return 45 + (direction * 360) / directions +} + +function angularDistance(a: number, b: number): number { + return Math.abs(((((a - b) % 360) + 540) % 360) - 180) +} + +/** + * The largest shift of a path direction table boundary from the uniform 5.625° grid: + * the table steps at tangent indices 13, 26, 39, 53, 68, 85 and 105, and + * atan(26 / 127) − 11.25° ≈ 0.32° is the worst of them. + */ +const TABLE_BOUNDARY_SLACK_DEGREES = 0.5 + +/** Reads `count` little-endian int32 values at virtual address `va` of a PE32 image. */ +function readPeInt32s(path: string, va: number, count: number): number[] { + const bytes = readFileSync(path) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const peOffset = view.getUint32(0x3c, true) + if (view.getUint32(peOffset, true) !== 0x00004550) throw new Error(`${path} is not a PE image`) + const sectionCount = view.getUint16(peOffset + 6, true) + const optionalHeaderSize = view.getUint16(peOffset + 20, true) + const optionalHeader = peOffset + 24 + const imageBase = view.getUint32(optionalHeader + 28, true) + const rva = va - imageBase + for (let i = 0; i < sectionCount; i++) { + const section = optionalHeader + optionalHeaderSize + i * 40 + const virtualSize = view.getUint32(section + 8, true) + const virtualAddress = view.getUint32(section + 12, true) + const rawSize = view.getUint32(section + 16, true) + const rawOffset = view.getUint32(section + 20, true) + const backed = Math.min(virtualSize, rawSize) + if (rva >= virtualAddress && rva + count * 4 <= virtualAddress + backed) { + const start = rawOffset + (rva - virtualAddress) + return Array.from({ length: count }, (_, k) => view.getInt32(start + k * 4, true)) + } + } + throw new Error(`${path}: VA 0x${va.toString(16)} is not backed by file data`) +} + +describe('dir64FromPoints (D2Common 1.13c 0x6FD5CF40)', () => { + it('quantises the eight compass vectors to the engine dir64 values', () => { + const compass: ReadonlyArray = [ + ['S', 1, 1, 0], + ['SW', 0, 1, 7], + ['W', -1, 1, 15], + ['NW', -1, 0, 23], + ['N', -1, -1, 32], + ['NE', 0, -1, 40], + ['E', 1, -1, 47], + ['SE', 1, 0, 56], + ] + for (const [name, dx, dy, expected] of compass) { + expect(dir64FromPoints(0, 0, dx, dy, 0), name).toBe(expected) + } + }) + + it('quantises the 16 screen headings to the engine dir64 values', () => { + const actual = Array.from({ length: 16 }, (_, k) => { + const [dx, dy] = headingVector(k) + return dir64FromPoints(0, 0, dx, dy, 0) + }) + expect(actual).toEqual([...HEADING_DIR64]) + }) + + it('depends only on the segment direction, not on its length or origin', () => { + const origins: ReadonlyArray = [ + [0, 0], + [5000.5, 4999.25], + [123, 65000], + ] + for (let dx = -12; dx <= 12; dx++) { + for (let dy = -12; dy <= 12; dy++) { + if (dx === 0 && dy === 0) continue + const expected = dir64FromPoints(0, 0, dx, dy, 0) + for (const scale of [2, 3, 17]) { + for (const [ox, oy] of origins) { + expect(dir64FromPoints(ox, oy, ox + scale * dx, oy + scale * dy, 63)).toBe(expected) + } + } + } + } + }) + + it('stays within one dir64 step of the true heading and reaches all 64 values', () => { + const reached = new Set() + let worst = 0 + for (let dx = -40; dx <= 40; dx++) { + for (let dy = -40; dy <= 40; dy++) { + if (dx === 0 && dy === 0) continue + const dir64 = dir64FromPoints(0, 0, dx, dy, 0) + reached.add(dir64) + const trueDegrees = (Math.atan2(dy, dx) * 180) / Math.PI + worst = Math.max(worst, angularDistance(clipDegrees(dir64, DIR64_COUNT), trueDegrees)) + } + } + expect(reached.size).toBe(DIR64_COUNT) + expect(worst).toBeLessThan(360 / DIR64_COUNT + TABLE_BOUNDARY_SLACK_DEGREES) + }) + + it('returns the fallback facing when the points coincide in 16.16 fixed point', () => { + expect(dir64FromPoints(10, 20, 10, 20, 37)).toBe(37) + expect(dir64FromPoints(10, 20, 10 + 1e-6, 20 - 1e-6, 5)).toBe(5) + expect(dir64FromPoints(10, 20, 10 + 1 / 65536, 20, 5)).toBe(56) + }) + + it('throws on non-finite coordinates and invalid fallback facings', () => { + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(() => dir64FromPoints(bad, 0, 1, 1, 0)).toThrow(RangeError) + expect(() => dir64FromPoints(0, bad, 1, 1, 0)).toThrow(RangeError) + expect(() => dir64FromPoints(0, 0, bad, 1, 0)).toThrow(RangeError) + expect(() => dir64FromPoints(0, 0, 1, bad, 0)).toThrow(RangeError) + } + for (const bad of [64, -1, 1.5, Number.NaN]) { + expect(() => dir64FromPoints(0, 0, 1, 1, bad)).toThrow(RangeError) + expect(() => assertDir64(bad, 'facing')).toThrow(RangeError) + } + }) +}) + +describe('dir64ToClipDirection (D2CMP 1.13c #10020)', () => { + it('draws screen heading k with direction k of a 16-direction clip and k / 2 of an 8-direction clip', () => { + for (let k = 0; k < 16; k++) { + expect(dir64ToClipDirection(HEADING_DIR64[k]!, 16), `heading ${k}`).toBe(k) + if (k % 2 === 0) expect(dir64ToClipDirection(HEADING_DIR64[k]!, 8), `heading ${k}`).toBe(k / 2) + } + }) + + it('keeps the drawn direction within half a clip sector of the true heading', () => { + const worst = new Map() + for (let dx = -40; dx <= 40; dx++) { + for (let dy = -40; dy <= 40; dy++) { + if (dx === 0 && dy === 0) continue + const dir64 = dir64FromPoints(0, 0, dx, dy, 0) + const trueDegrees = (Math.atan2(dy, dx) * 180) / Math.PI + for (const directions of [4, 8, 16]) { + const drawn = clipDegrees(dir64ToClipDirection(dir64, directions), directions) + const error = angularDistance(drawn, trueDegrees) + worst.set(directions, Math.max(worst.get(directions) ?? 0, error)) + } + } + } + for (const directions of [4, 8, 16]) { + expect(worst.get(directions), `${directions} directions`).toBeLessThan( + 180 / directions + TABLE_BOUNDARY_SLACK_DEGREES, + ) + } + }) + + it('maps 1- and 2-direction clips like the engine rows', () => { + for (let dir64 = 0; dir64 < DIR64_COUNT; dir64++) { + expect(dir64ToClipDirection(dir64, 1)).toBe(0) + expect(dir64ToClipDirection(dir64, 2)).toBe(dir64 >= 16 && dir64 < 48 ? 1 : 0) + expect(dir64ToClipDirection(dir64, 64)).toBe(dir64) + } + }) + + it('throws for direction counts the engine does not support and for invalid facings', () => { + for (const directions of [0, 3, 5, 12, 128, -8, 1.5, Number.NaN]) { + expect(() => dir64ToClipDirection(0, directions)).toThrow(RangeError) + } + for (const dir64 of [64, -1, 1.5, Number.NaN]) { + expect(() => dir64ToClipDirection(dir64, 16)).toThrow(RangeError) + } + }) +}) + +describe.skipIf(!hasDlls)('binary parity with the 1.13c DLLs (samples/d2)', () => { + it('follows the angle column of the D2Common path direction table at 0x6FDDD470', () => { + // 128 entries of { int32 x, int32 y, int32 angle }, indexed by trunc(127 · minor / major). + const table = readPeInt32s(D2COMMON_DLL, 0x6fddd470, 128 * 3) + for (let index = 0; index < 128; index++) { + const angle = table[index * 3 + 2]! + // Vector (index, 127) reads entry `index`; |dx| <= |dy| and start <= target on both axes, + // so the routine returns (7 − angle). Its mirror (127, index) swaps the axes: 56 + angle. + expect(dir64FromPoints(0, 0, index, 127, 0), `entry ${index}`).toBe(7 - angle) + if (index < 127) expect(dir64FromPoints(0, 0, 127, index, 0), `entry ${index}`).toBe(56 + angle) + } + }) + + it('equals the D2CMP direction rows at 0x6FE2F4D8 + (bsf(directions) + 1) · 256', () => { + for (const directions of CLIP_DIRECTION_COUNTS) { + const row = readPeInt32s(D2CMP_DLL, 0x6fe2f4d8 + (Math.log2(directions) + 1) * 256, DIR64_COUNT) + const ported = Array.from({ length: DIR64_COUNT }, (_, dir64) => + dir64ToClipDirection(dir64, directions), + ) + expect(ported, `${directions} directions`).toEqual(row) + } + }) +}) + +const ASSET_DIRS = ['public/assets/characters', 'public/assets/entities'] as const +const hasBakedArt = ASSET_DIRS.every(dir => existsSync(dir)) + +describe.skipIf(!hasBakedArt)('baked unit art direction counts', () => { + it('only uses direction counts dir64ToClipDirection supports', () => { + const supported = new Set(CLIP_DIRECTION_COUNTS) + const unsupported: string[] = [] + let checked = 0 + const visit = (value: unknown, file: string): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item, file) + return + } + if (value === null || typeof value !== 'object') return + for (const [key, child] of Object.entries(value)) { + if (key === 'directions' && typeof child === 'number') { + checked++ + if (!supported.has(child)) unsupported.push(`${file}: directions ${String(child)}`) + } else { + visit(child, file) + } + } + } + for (const dir of ASSET_DIRS) { + for (const name of readdirSync(dir)) { + if (!name.endsWith('.json')) continue + visit(JSON.parse(readFileSync(`${dir}/${name}`, 'utf8')) as unknown, `${dir}/${name}`) + } + } + expect(checked).toBeGreaterThan(0) + expect(unsupported).toEqual([]) + }) +})