feat(common): add 1.13c dir64 direction helpers

Port D2Common 0x6FD5CF40 (dir64 from two points via the 0x6FDDD470
octant table) and D2CMP #10020 (dir64 -> clip direction rows at
0x6FE2F4D8) as dir64FromPoints / dir64ToClipDirection, with binary
parity tests against the shipped 1.13c DLLs and a guard that every baked
character/entity sheet uses a direction count the D2CMP table supports.
This commit is contained in:
troytt 2026-10-02 05:13:40 +00:00
parent 6a010670cd
commit 531273c989
2 changed files with 405 additions and 0 deletions

View File

@ -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)}`,
)
}
}

View File

@ -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<readonly [string, number, number, number]> = [
['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<readonly [number, number]> = [
[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<number>()
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<number, number>()
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<number>(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([])
})
})