diff --git a/src/netproto/d2gs/c2s/position.ts b/src/netproto/d2gs/c2s/position.ts new file mode 100644 index 0000000..10c4b70 --- /dev/null +++ b/src/netproto/d2gs/c2s/position.ts @@ -0,0 +1,29 @@ +/** + * D2GS C->S Player Position Resync Packet Builder (0x5F, 1.13c). + */ + +import { ByteWriter } from '../../core/byte-writer.ts' +import { validateC2sPacket } from '../framing-c2s.ts' + +function assertU16(value: number, what: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + throw new RangeError(`0x5F UpdatePlayerPos ${what} must be a u16, got ${String(value)}`) + } +} + +/** + * 0x5F UpdatePlayerPos (5B): `u8 0x5F | u16 x | u16 y`, the client's own sub-tile. + * + * D2Client 0x6FAC49E0 builds it when the position gate 0x6FADA1B0 finds the local player out + * of tolerance; D2Game handles it at 0x6FC88530 (D2MOO `PlrMsg.cpp` + * `D2GAME_PACKETCALLBACK_Rcv0x5F_UpdatePlayerPos`), answering with `0x15` when it disagrees. + */ +export function encodeC2sUpdatePlayerPos(x: number, y: number): Uint8Array { + assertU16(x, 'x') + assertU16(y, 'y') + const w = new ByteWriter(5) + w.u8(0x5f) + w.u16LE(x) + w.u16LE(y) + return validateC2sPacket(w.toUint8Array()) +} diff --git a/src/netproto/d2gs/registry.ts b/src/netproto/d2gs/registry.ts index 3713656..d9cfc27 100644 --- a/src/netproto/d2gs/registry.ts +++ b/src/netproto/d2gs/registry.ts @@ -61,6 +61,7 @@ import { encodeC2sSkillOnUnit, } from './c2s/skills.ts' import { encodeC2sTrade } from './c2s/trade.ts' +import { encodeC2sUpdatePlayerPos } from './c2s/position.ts' import { D2GS_S2C_PACKET_SIZES, resolveS2cPacketSize } from './framing-s2c.ts' import { decode0x26ChatMessage, decode0x5AEventMessage } from './s2c/chat.ts' import { @@ -436,6 +437,8 @@ export function encodeD2gsClientCommand( return [encodeC2sMoveToUnit(false, { unitType: cmd.unitKind, id: cmd.unitId })] case 'RunToTarget': return [encodeC2sMoveToUnit(true, { unitType: cmd.unitKind, id: cmd.unitId })] + case 'UpdatePlayerPos': + return [encodeC2sUpdatePlayerPos(cmd.x, cmd.y)] case 'SkillAt': return [encodeC2sSkillAt(cmd.hand, cmd.at, cmd.shift)] case 'CastLeftSkillOnLocation': diff --git a/src/netproto/domain/client-command.ts b/src/netproto/domain/client-command.ts index 3c306a0..8125b86 100644 --- a/src/netproto/domain/client-command.ts +++ b/src/netproto/domain/client-command.ts @@ -11,6 +11,7 @@ export type ClientCommand = | { readonly type: 'MoveToUnit'; readonly run: boolean; readonly target: UnitRef } | { readonly type: 'WalkToTarget'; readonly unitKind: UnitType; readonly unitId: number } | { readonly type: 'RunToTarget'; readonly unitKind: UnitType; readonly unitId: number } + | { readonly type: 'UpdatePlayerPos'; readonly x: number; readonly y: number } | { readonly type: 'SkillAt'; readonly hand: Hand; readonly at: Pt; readonly shift?: boolean | undefined } | { readonly type: 'CastLeftSkillOnLocation'; readonly x: number; readonly y: number } | { readonly type: 'CastRightSkillOnLocation'; readonly x: number; readonly y: number } diff --git a/tests/netproto/c2s-update-player-pos.test.ts b/tests/netproto/c2s-update-player-pos.test.ts new file mode 100644 index 0000000..4b2460e --- /dev/null +++ b/tests/netproto/c2s-update-player-pos.test.ts @@ -0,0 +1,27 @@ +/** + * C2S 0x5F UpdatePlayerPos (D2 1.13c): `u8 0x5F | u16 x | u16 y`, sent by D2Client 0x6FAC49E0 when + * the position gate 0x6FADA1B0 finds the local player out of tolerance. + */ + +import { describe, expect, it } from 'vitest' +import { encodeC2sUpdatePlayerPos } from '../../src/netproto/d2gs/c2s/position.ts' +import { encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts' + +describe('C2S 0x5F UpdatePlayerPos', () => { + it('encodes the sub-tile as two little-endian u16 values', () => { + expect([...encodeC2sUpdatePlayerPos(0x1234, 0xbeef)]).toEqual([0x5f, 0x34, 0x12, 0xef, 0xbe]) + expect([...encodeC2sUpdatePlayerPos(0, 0xffff)]).toEqual([0x5f, 0x00, 0x00, 0xff, 0xff]) + }) + + it('is what the UpdatePlayerPos client command sends', () => { + const packets = encodeD2gsClientCommand({ type: 'UpdatePlayerPos', x: 5000, y: 4999 }) + expect(packets.map(p => [...p])).toEqual([[0x5f, 0x88, 0x13, 0x87, 0x13]]) + }) + + it('rejects coordinates that are not u16', () => { + for (const bad of [-1, 0x10000, 1.5, Number.NaN]) { + expect(() => encodeC2sUpdatePlayerPos(bad, 100)).toThrow(RangeError) + expect(() => encodeC2sUpdatePlayerPos(100, bad)).toThrow(RangeError) + } + }) +})