diff --git a/cmd/a2k/print.go b/cmd/a2k/print.go index 844bbfe..6faf103 100644 --- a/cmd/a2k/print.go +++ b/cmd/a2k/print.go @@ -141,6 +141,8 @@ func fields(e game.Event) string { fmt.Fprintf(&b, "%.1f", x) case time.Time: b.WriteString(x.Local().Format("15:04:05.000")) + case []byte: + fmt.Fprintf(&b, "%x", x) case string: if strings.ContainsAny(x, " \"=") { x = fmt.Sprintf("%q", x) diff --git a/cmd/a2k/stats.go b/cmd/a2k/stats.go index 0b53335..2dc026b 100644 --- a/cmd/a2k/stats.go +++ b/cmd/a2k/stats.go @@ -177,11 +177,13 @@ func writeTable(w io.Writer, a *a2kit.Analysis, marks []time.Time, window time.D } // note says what is known of a type. -// The lock counts it, and it comes whether the player acts or not. +// The lock counts it, and it comes whether the player acts or not. A type that is guessed is read, but the lock does not count it. func note(op wire.Opcode, background bool) string { var notes []string if op.Known() { notes = append(notes, "known") + } else if op.Guessed() { + notes = append(notes, "guessed") } if background { notes = append(notes, "background") diff --git a/game/combat.go b/game/combat.go index 2306aba..9322901 100644 --- a/game/combat.go +++ b/game/combat.go @@ -1,6 +1,9 @@ package game // Hit is the damage one skill deals one target, from 04 38. +// +// Damage is 0 on a hit that deals none, which is a form of its own and never otherwise 0. Skills +// 4001 and 4002 of the player send it with the player for the target as well, and others do. type Hit struct { Actor, Target Entity Skill Skill @@ -11,11 +14,17 @@ type Hit struct { Mods, Direction byte // with some hits, else 0; their bits are not read } -// Cast opens a skill use, from 02 38. Its position is the target's. +// Cast opens a skill use, from 02 38. Its position is the target's, and the zero value on the short +// form, which also comes with another entity as the target. +// +// For skills 4001 and 4002 Pos is not a position: its X and Y are a vector of length 100 and Z is 0. +// Angle is a direction in degrees, as far as its range can tell. type Cast struct { Actor, Target Entity Skill Skill + Angle float32 Pos + Duration uint32 // a guess: milliseconds, 500 to 10000, and 0 on the short form } // CastEnd closes the skill use a Cast opened, from 06 38. diff --git a/game/guess.go b/game/guess.go new file mode 100644 index 0000000..3dba737 --- /dev/null +++ b/game/guess.go @@ -0,0 +1,155 @@ +package game + +import "time" + +// What is in this file is read from one recording and named by a guess. A name, a field or a +// layout here may change when another recording says otherwise. Each doc comment says what the +// guess rests on. + +// Ref names an entity and nothing more. +type Ref struct{ Entity Entity } + +// Tagged is a message that starts with an entity, followed by the bytes a2k does not read yet. +type Tagged struct { + Entity Entity + Data []byte +} + +// Signal is a message of one to four bytes and no entity, which holds a small number, 0 so far. +type Signal struct{ Code uint32 } + +// Target is the entity a mob has picked to attack, from 35 38, with Target 0 when it lets go. +// +// Guess: it follows a mob's first Cast on the player by a few tenths of a second, and ends with +// a 0 when the fight is over. +type Target struct{ Entity, Target Entity } + +// Combo is a chain of the player's skills, from 18 38: the skill that was used, and the one that +// can follow it. From is 0 when the message, 19 38, names only the next skill. +// +// Guess: the three skills 9865 to 9867 and the next three 9868 to 9870 follow each other in the +// order a basic attack chain would, one of these each time the player casts one. +type Combo struct{ From, To Skill } + +// SkillUse is the player's own Cast, again, from 01 38. +// +// Guess: it comes with each of the player's own Cast, with its skill. Info is 0 on most, and +// 0x1813 on a few that came late. +type SkillUse struct { + Skill Skill + Info uint16 +} + +// Windup announces a Cast before it comes, from 09 38, as a mob's skill is about to land. +// +// Guess: it comes 500 to 600 ms before the Cast of the same skill from the same mob, and carries +// a number of the same size in Duration, in milliseconds. +type Windup struct { + Actor, Target Entity + Skill Skill + Angle float32 + Pos + Duration uint32 +} + +// Engage names the mob that is about to pick the player as its Target, from 34 38. The entity is 0 on some. +// +// Guess: it comes right before Target, with the same mob in it. +type Engage struct{ Entity Entity } + +// Threat is the other end of a fight with a mob, from 31 38: the mob, and a number that varies. +// +// Guess: it comes with the mob's Target letting go, and its number, a u16, is not understood. +type Threat struct { + Entity Entity + Value uint16 +} + +// Attributes is an entity's whole table of attributes, from 49 36 for the player's own, which +// has no entity, and 4A 36 for another's. +// +// Guess: the ids are the game's attribute ids and a value goes with each. Ids 0x5d and 0x59 +// hold 1500 and 2500 on a new character, and 0x60 and 0x61 hold 100000, as Stats' 1 and 3 do. +type Attributes struct { + Entity Entity + Values []Attribute +} + +// Attribute is one of Attributes' ids and its value. +type Attribute struct { + ID uint16 + Value int32 +} + +// Height is an entity's height, from 46 37. +// +// Guess: the value is the Z of the entity's position, to the unit, in every sample. +type Height struct { + Entity Entity + Z float32 +} + +// Effect is something put on an entity, from 2A 38, and the change of one from 2B 38, a buff or +// a debuff. +// +// Guess: Instance counts up with each one, an effect's ID repeats, and what Value holds changes +// with it, 5000 or 300 or 200, which looks like its strength or its length. Time is when, and +// Source is who. The kinds seen are 01, 11 and 13: 02 adds a Skill and 10 adds a position. +type Effect struct { + Entity Entity + Kind byte + Instance uint32 + ID uint32 + Value uint32 // 0xFFFFFFFF with the permanent ones + Time time.Time + Source Entity + Skill Skill + Refresh bool // 2B 38 + Pos +} + +// Area is a skill that lands somewhere, from 03 38, and from 0E 38 with a second position. +// +// Guess: a mob's skill, with the player for its target, comes right after the Cast of a mob. +// The layout of what follows the position is not understood, and is not read. +type Area struct { + Actor, Target Entity + Skill Skill + Instance uint32 + Pos + Pos2 Pos // 0E 38 only +} + +// Cutscene starts a scripted scene for the player, from 21 36, at a position, and with the scene's name. +// +// Guess: its names are Cutscene_L_A_QM_1001 and Cutscene_L_A_Seq_1278, and 15 8D names the same one a moment later. +type Cutscene struct { + Kind uint32 // 1 to 5 + ID uint32 // 100001 mostly + Name string // a name comes with some of them + Pos +} + +// Script is a named trigger of the game's scripts, from 15 8D: a cutscene, a talk, or a step of the tutorial. +// +// Guess: its names are Cutscene_L_A_Seq_1273, Talk_L_Starter_InstanceLayer_1_08 and Tutorial_Basic_05. +type Script struct { + Flag, Kind byte // Kind is 09 for a cutscene, 13 for a talk, 2E for a tutorial + Name string +} + +func (Ref) event() {} +func (Tagged) event() {} +func (Signal) event() {} +func (Target) event() {} +func (Combo) event() {} +func (SkillUse) event() {} +func (Windup) event() {} +func (Engage) event() {} +func (Threat) event() {} +func (Attributes) event() {} +func (Height) event() {} +func (Effect) event() {} +func (Area) event() {} +func (Cutscene) event() {} +func (Script) event() {} diff --git a/game/lobby.go b/game/lobby.go index 6ba7f93..7fc5243 100644 --- a/game/lobby.go +++ b/game/lobby.go @@ -76,10 +76,16 @@ func (d Redirect) Addr() (netip.AddrPort, bool) { return netip.AddrPortFrom(ip, d.Port), err == nil } +// Join is the server the client picked in the lobby, sent before the Redirect to it. +type Join struct { + Server uint16 // the ID in Servers, the same as the Redirect's +} + // Account is who logged in to the lobby. type Account struct { ID string // the account's number Server uint16 // likely the one joined last, the ID in Servers + Faction byte // the side it plays, 1 for Light (Elyos) and 2 for Dark (Asmodians), as in Server.Faction Session [2]string // two GUIDs, new at every login (unsure which one is which, or what they are used for) } @@ -98,7 +104,7 @@ type Character struct { Level uint32 // the character's level Exp uint32 // @TODO: not sure what this is, but it's sent for every character Code uint32 // 5 to 44 so far, not read - Flags byte // 1, and 2 on one placeholder, not read + Flags byte // likely its side, 1 for Light (Elyos) and 2 for Dark (Asmodians) as in Server.Faction, not read Mask byte // its two bits of the byte after every fourth character, not read Time time.Time // likely when it was last played } @@ -109,5 +115,6 @@ func (c Character) String() string { return fmt.Sprintf("%d:%s/%d", c.Server, c. func (Servers) event() {} func (LobbyPing) event() {} func (Redirect) event() {} +func (Join) event() {} func (Account) event() {} func (Characters) event() {} diff --git a/game/opcode.go b/game/opcode.go index 6dda168..f73a8ea 100644 --- a/game/opcode.go +++ b/game/opcode.go @@ -15,7 +15,7 @@ var opcodes = map[wire.Opcode]op{ // 36: the login, the world, and its clocks 0x3600: {"Tick", tick}, 0x3603: {"Ping", ping}, - 0x3611: {"Handshake", nil}, // a login's first frame + 0x3611: {"Handshake", nil}, // a login's first frame: 00 00, a u32 60000, a varint 256 and that many random bytes, then 12 more (8 zeros, f9 ff ff ff) 0x3615: {"Login", nil}, // the login time, that time plus 8 h, and the account 0x361A: {"NameCheck", nameCheck}, 0x3623: {"Zone", zone}, @@ -23,21 +23,26 @@ var opcodes = map[wire.Opcode]op{ 0x3641: {"Spawn", spawn}, 0x3642: {"Death", death}, 0x3645: {"Player", player}, + 0x3634: {"Spawn", spawnStatic}, // 39: the lobby, which lists the servers and hands the client to the one picked - 0x3901: {"LobbyHandshake", nil}, // the lobby's first frame + 0x3901: {"LobbyHandshake", nil}, // the lobby's first frame: 00 00, a u16 60, a varint 256 and that many random bytes, then 10 zeros 0x3903: {"LobbyPing", lobbyPing}, 0x3906: {"Account", account}, 0x3909: {"Servers", servers}, 0x390B: {"Characters", characters}, - 0x390D: {"Join", nil}, // the server picked + 0x390D: {"Join", join}, // the server picked 0x390F: {"Redirect", redirect}, // 37: movement 0x371A: {"Move", moveA}, 0x371B: {"Move", moveB}, 0x371C: {"Move", moveB}, - 0x371D: {"Move", nil}, // 9–12 bytes, a sibling + 0x371D: {"Turn", turn}, // a lighter update than Move, with no position + 0x3728: {"Heading", headingWithPos}, // flag 04, with a position + 0x3729: {"Heading", headingWithPos}, // flag 02, with a position + 0x372A: {"Heading", headingOnly}, // no position + 0x372F: {"Move", moveB}, // with more fields after the position, not understood // 38: skills 0x3802: {"Cast", cast}, @@ -45,12 +50,198 @@ var opcodes = map[wire.Opcode]op{ 0x3805: {"DoT", nil}, 0x3806: {"CastEnd", castEnd}, + 0x8D00: {"Stats", stats}, 0x8D04: {"Owner", owner}, 0x8D2F: {"Notice", nil}, // text to the players, the same in each of its slots 0x921B: {"HP", nil}, 0x9702: {"Party", nil}, } +// guesses are opcodes named, and read, on the strength of one recording. Each is a guess until +// another confirms it, see guess.go, and each comment says what the guess is. +var guesses = map[wire.Opcode]op{ + // 36: the world + 0x3618: {"Signal", signal}, + 0x3621: {"Cutscene", cutscene}, + 0x3631: {"Signal", signal}, + 0x3635: {"ObjectState", tagged}, // of the objects that never move: two small numbers, and 00 + 0x3637: {"InteractAck", leadEntity}, // the last of five messages that name one object after the player touches it + 0x3638: {"Interact", tagged}, // an object, the player, and 01 + 0x363A: {"InteractEnd", tagged}, // an object, the player, and eight zeros + 0x363B: {"InteractDone", ref}, // an object + 0x3646: {"Ready", tagged}, // the player's entity and 00, as a zone is entered + 0x3647: {"State", tagged}, // an entity and two bytes that change + 0x3649: {"Attributes", attributes}, + 0x364A: {"Attributes", attributesOf}, + 0x364B: {"Status", tagged}, // a mob, 01, an id, and a value that is 3000 or -2500 + 0x364E: {"Waypoint", tagged}, // an entity, two floats under 500, and 00 or 01 + + // 37: movement + 0x371E: {"Move", moveFlagged}, + 0x3721: {"Land", ref}, // players only + 0x3722: {"Stop", ref}, // the player and others + 0x3723: {"Move", moveFlagged}, + 0x3724: {"Move", moveFlagged}, + 0x372B: {"Move", moveFlagged}, + 0x372E: {"Move", moveLead}, + 0x373C: {"Move", moveFlagged}, + 0x3741: {"Rest", tagged}, // the player's entity and a u16 + 0x3742: {"Jump", ref}, // the player only + 0x3746: {"Height", height}, + + // 38: skills + 0x3801: {"SkillUse", skillUse}, + 0x3803: {"Area", area}, + 0x3809: {"Windup", windup}, + 0x380C: {"Casting", tagged}, // a mob, 00 00, a skill and 00 00 + 0x380E: {"Area", area}, + 0x3818: {"Combo", combo}, + 0x3819: {"Combo", comboNext}, + 0x382A: {"Effect", effectAdded}, + 0x382B: {"Effect", effectChanged}, + 0x382C: {"Charge", tagged}, // an entity, 01 00, and two bytes that count up with the player's casts + 0x3831: {"Threat", threat}, + 0x3834: {"Engage", engage}, + 0x3835: {"Target", target}, + 0x383B: {"ActionBegin", signal}, // 0.1 s before 3D 38, when the player starts a skill of a chain + 0x383D: {"ActionEnd", signal}, + + // 8D + 0x8D03: {"Notify", tagged}, // an entity and four zeros + 0x8D15: {"Script", script}, + 0x8D21: {"Notify", tagged}, // an entity, 00, 01 + 0x8D41: {"Signal", signal}, + 0x8D43: {"Signal", signal}, + 0x8D52: {"Notify", ref}, // a mob + 0x8D6D: {"InteractOpen", ref}, + 0x8D91: {"Notify", tagged}, // a player, 02 00 00 00 + 0x8D93: {"Notify", tagged}, // an object, 01 00 00 00 + + // seen but not read: the names are guesses from what is in them + 0x8D53: {"Season", nil}, // names like Season_Main_Live + 0xE349: {"Mail", nil}, // a gift mail's title and text + 0x5600: {"Skills", nil}, // the player's skill ids, each twice, and a flag + 0x5684: {"Item", nil}, // one item: an id, a count, and the time it came + 0x5683: {"Items", nil}, // 39691 bytes at login, a table that holds Item's ids + 0x5611: {"Bag", nil}, // 819 bytes at login + 0x922E: {"MapEvent", nil}, // an id, and a name like World_L_Starter_Layer_1_1_MapEvent + 0x922D: {"MapEventState", nil}, // the same id, and two bytes that change + 0x9231: {"MapEventState", nil}, // another event's id, and a count + 0xFFA5: {"Time", nil}, // a zero u64, and the time as the other messages carry it + 0xFFAC: {"Time", nil}, + 0xFFA9: {"Time", nil}, + 0x9508: {"MobState", nil}, // a mob, 20001 or 20003, and 02 + + // seen once or twice, mostly as a zone loads: named from the family and what is in them + 0xE324: {"Quest", nil}, // 24 E3 + 0xE335: {"QuestScene", nil}, // 35 E3 + 0xE343: {"QuestList", nil}, // 43 E3 + 0xE32A: {"QuestList", nil}, // 2A E3 + 0xE33A: {"QuestMarker", nil}, // 3A E3 + 0xE347: {"QuestState", nil}, // 47 E3 + 0xE002: {"QuestState", nil}, // 02 E0 + 0xE004: {"QuestState", nil}, // 04 E0 + 0xE200: {"Settings", nil}, // 00 E2 + 0xE215: {"Settings", nil}, // 15 E2 + 0xE223: {"Settings", nil}, // 23 E2 + 0xE224: {"Settings", nil}, // 24 E2 + 0xE22E: {"Settings", nil}, // 2E E2 + 0xE241: {"Settings", nil}, // 41 E2 + 0xE243: {"Settings", nil}, // 43 E2 + 0xE248: {"Settings", nil}, // 48 E2 + 0xE250: {"Settings", nil}, // 50 E2 + 0xE251: {"Settings", nil}, // 51 E2 + 0xE256: {"Settings", nil}, // 56 E2 + 0xE258: {"Settings", nil}, // 58 E2 + 0xE25A: {"Settings", nil}, // 5A E2 + 0xE25C: {"Settings", nil}, // 5C E2 + 0xE262: {"Settings", nil}, // 62 E2 + 0xE263: {"Settings", nil}, // 63 E2 + 0xE274: {"Settings", nil}, // 74 E2 + 0xE28C: {"Settings", nil}, // 8C E2 + 0xE28D: {"Settings", nil}, // 8D E2 + 0xE213: {"Settings", nil}, // 13 E2 + 0x4001: {"SceneState", nil}, // 01 40 + 0x4002: {"SceneState", nil}, // 02 40 + 0x400A: {"SceneState", nil}, // 0A 40 + 0x6000: {"SceneState", nil}, // 00 60 + 0x6100: {"SceneData", nil}, // 00 61 + 0x6101: {"SceneData", nil}, // 01 61 + 0x610B: {"SceneData", nil}, // 0B 61 + 0x4301: {"Achievement", nil}, // 01 43 + 0x5100: {"Currency", nil}, // 00 51 + 0x3656: {"Currency", nil}, // 56 36 + 0x3657: {"Currency", nil}, // 57 36 + 0x561B: {"ItemUpdate", nil}, // 1B 56 + 0x561C: {"ItemUpdate", nil}, // 1C 56 + 0x5648: {"ItemUpdate", nil}, // 48 56 + 0x5613: {"ItemUpdate", nil}, // 13 56 + 0x561D: {"ItemCount", nil}, // 1D 56 + 0x8A1D: {"Chat", nil}, // 1D 8A + 0x9000: {"Social", nil}, // 00 90 + 0x9330: {"Social", nil}, // 30 93 + 0x9331: {"Social", nil}, // 31 93 + 0x361C: {"Social", nil}, // 1C 36 + 0x3620: {"Social", nil}, // 20 36 + 0x3626: {"Social", nil}, // 26 36 + 0x9501: {"Emotes", nil}, // 01 95 + 0x8D10: {"Instance", nil}, // 10 8D + 0x8D2C: {"Instance", nil}, // 2C 8D + 0x8D11: {"Instance", nil}, // 11 8D + 0x8D09: {"Instance", nil}, // 09 8D + 0x8D7E: {"Catalog", nil}, // 7E 8D + 0x8D8C: {"Catalog", nil}, // 8C 8D + 0x922F: {"MapEventState", nil}, // 2F 92 + 0x9232: {"MapEventList", nil}, // 32 92 + 0x372D: {"Move", nil}, // 2D 37 + 0x3725: {"Move", nil}, // 25 37 + 0x3732: {"Move", nil}, // 32 37 + 0x3720: {"Move", nil}, // 20 37 + 0x3730: {"Move", nil}, // 30 37 + 0x3734: {"Move", nil}, // 34 37 + 0x3900: {"SkillSlots", nil}, // 00 39 + 0xE33B: {"Signal", nil}, // 3B E3 + 0xE33D: {"Signal", nil}, // 3D E3 + 0xE33E: {"Signal", nil}, // 3E E3 + 0xE342: {"Signal", nil}, // 42 E3 + 0xE226: {"Signal", nil}, // 26 E2 + 0xE237: {"Signal", nil}, // 37 E2 + 0xE257: {"Signal", nil}, // 57 E2 + 0x8D58: {"Signal", nil}, // 58 8D + 0x8D37: {"Signal", nil}, // 37 8D + 0x8AAF: {"Signal", nil}, // AF 8A + 0x5633: {"Signal", nil}, // 33 56 + 0x564B: {"Signal", nil}, // 4B 56 + 0x5672: {"Signal", nil}, // 72 56 + 0x56B6: {"Signal", nil}, // B6 56 + 0x56AD: {"Signal", nil}, // AD 56 + 0x5679: {"Signal", nil}, // 79 56 + 0x5682: {"Signal", nil}, // 82 56 + 0x56A8: {"Signal", nil}, // A8 56 + 0x570F: {"Signal", nil}, // 0F 57 + 0x572D: {"Signal", nil}, // 2D 57 + 0x6103: {"Signal", nil}, // 03 61 + 0x6108: {"Signal", nil}, // 08 61 + 0x6109: {"Signal", nil}, // 09 61 + 0x363E: {"Signal", nil}, // 3E 36 + 0x8D44: {"Notify", nil}, // 44 8D + 0x8D4B: {"Notify", nil}, // 4B 8D + 0x8D57: {"Notify", nil}, // 57 8D + 0x8D5B: {"Notify", nil}, // 5B 8D + 0x8D4E: {"Notify", nil}, // 4E 8D + 0x8D01: {"Notify", nil}, // 01 8D + 0x8D0F: {"Notify", nil}, // 0F 8D +} + +func init() { + for o, g := range guesses { + if _, ok := opcodes[o]; ok { + panic("game: " + o.String() + " is both known and guessed") + } + opcodes[o] = g + } +} + // Name is what the table calls o, or "" for an opcode it has not met. func Name(o wire.Opcode) string { return opcodes[o].name } diff --git a/game/opcode_test.go b/game/opcode_test.go index 5f56dab..aac2be8 100644 --- a/game/opcode_test.go +++ b/game/opcode_test.go @@ -2,11 +2,17 @@ package game import "testing" -// KnownOnly keeps only known opcodes, so one that game reads and wire does not know never reaches Parse. +// KnownOnly keeps only known and guessed opcodes, so one that game reads and wire does not list never reaches Parse. func TestReadsKnown(t *testing.T) { for op, o := range opcodes { - if o.read != nil && !op.Known() { + _, guess := guesses[op] + switch { + case o.read != nil && !op.Known() && !op.Guessed(): t.Errorf("%v %s is read, but wire does not know it", op, o.name) + case guess && op.Known() && op.Guessed(): + t.Errorf("%v %s is both known and guessed in wire", op, o.name) + case !guess && op.Guessed(): + t.Errorf("%v %s is guessed in wire, and not in game", op, o.name) } } } diff --git a/game/parse.go b/game/parse.go index 2bc27cb..b1efedb 100644 --- a/game/parse.go +++ b/game/parse.go @@ -2,7 +2,9 @@ package game import ( "bytes" + "encoding/binary" "fmt" + "math/bits" "strings" "time" @@ -35,8 +37,9 @@ func Parse(f wire.Frame) (Event, error) { // hit parses a hit event from a frame func hit(r *reader) Event { var ( - h = Hit{Target: r.entity()} - sw = r.varint() + h = Hit{Target: r.entity()} + sw = r.varint() + noDamage bool ) r.varint() // 0 so far h.Actor = r.entity() @@ -44,6 +47,8 @@ func hit(r *reader) Event { r.skip(1) // the actor's hit count h.Type = r.small() switch sw & 0xF { + case 0: + noDamage = true // a hit that deals none: Damage stays 0, and its varint is not sent case 4: case 6: h.Mods = r.u8() @@ -55,7 +60,9 @@ func hit(r *reader) Event { r.skip(4) // varies with the skill, not a float seq := r.u32() h.Scalar = r.varint() - h.Damage = r.varint() + if !noDamage { + h.Damage = r.varint() + } if sw&0x20 != 0 { n := r.varint() if n > uint32(r.left()) { @@ -74,14 +81,31 @@ func hit(r *reader) Event { return h } -// spawn parses a spawn event from a frame +// spawn parses a spawn event from a frame. +// +// A mask of two bytes comes before the flag since the game's patch of October 2026, and four bytes +// came before it. The NPC's id is at most three bytes, so its fourth is 00 in the layout that is +// right, and the one that is wrong finds a byte there that is not. func spawn(r *reader) Event { - s := Spawn{Entity: r.entity(), Mask: r.u32()} + s := Spawn{Entity: r.entity()} + if p := r.peek(9); p != nil && p[6] == 0 && p[8] != 0 { + s.Mask = uint32(r.u16()) + } else { + s.Mask = r.u32() + } if r.u8()&1 != 0 { return nil // a name follows; no fixture } s.NPC = r.npc() - r.skip(2) + r.skip(2) // 00 02 so far, 08 02 twice + s.Pos = r.pos() + return s +} + +// spawnStatic parses 34 36, the spawn of an entity that never moved or died in the recording, from a frame +func spawnStatic(r *reader) Event { + s := Spawn{Entity: r.entity(), Mask: uint32(r.u16())} + s.NPC = r.npc() s.Pos = r.pos() return s } @@ -128,11 +152,16 @@ func cast(r *reader) Event { c.Skill = r.skill() r.skip(2) c.Target = r.entity() - if r.left() < 16 { - return nil // the short self-cast form, without a position + c.Angle = r.f32() + if r.left() >= 12 { + c.Pos = r.pos() } - r.skip(4) - c.Pos = r.pos() + r.varint() // 10000 so far + if r.u8() != 1 { + r.bad = true + } + c.Duration = r.varint() + r.end() return c } @@ -177,6 +206,85 @@ func ping(r *reader) Event { return p } +// turn parses a lighter movement update, with no position, from a frame. +// +// The flag's low four bits each say that one more byte follows it, which of them it is not +// understood. Then two u16 headings, and a trailing 01 unless the flag's bit 0 is set. +func turn(r *reader) Event { + t := Turn{Entity: r.entity()} + flag := r.u8() + r.skip(bits.OnesCount8(flag & 0x0F)) + t.Heading = r.u16() + t.Heading2 = r.u16() + if flag&1 == 0 && r.u8() != 1 { + r.bad = true // 01 so far + } + r.end() + return t +} + +// headingWithPos parses 28 37 and 29 37's entity, position and heading from a frame +func headingWithPos(r *reader) Event { + h := Heading{Entity: r.entity()} + r.skip(1) // 04 on 28 37, 02 on 29 37, likely redundant with the opcode + h.Pos = r.pos() + return heading(r, h) +} + +// headingOnly parses 2A 37's entity and heading from a frame, with no position +func headingOnly(r *reader) Event { + h := Heading{Entity: r.entity()} + if n := r.left() - 15; n >= 0 { + r.skip(n) // not understood; its length varies + } else { + r.bad = true + } + return heading(r, h) +} + +// heading reads the 15 bytes 28 37, 29 37 and 2A 37 end with: two headings, a speed, a changing +// u16 not understood, and a trailing 01. +func heading(r *reader, h Heading) Event { + h.Heading1 = r.f32() + h.Heading2 = r.f32() + h.Speed = r.f32() + r.skip(2) // changes often, not understood + if r.u8() != 1 { + r.bad = true // 01 so far + } + r.end() + return h +} + +// stats parses an entity's attributes from a frame: a mask whose bit 0 says a group of u32 values +// follows, and bit 1 a group of u64 ones, each a count and then an id and a value that many times. +func stats(r *reader) Event { + s := Stats{Entity: r.entity()} + mask := r.u8() + if mask == 0 || mask&^3 != 0 { + r.bad = true + return nil + } + for bit, width := range [...]int{4, 8} { + if mask>>bit&1 == 0 { + continue + } + n := int(r.u8()) + if n*(1+width) > r.left() { + r.bad = true + return nil + } + for range n { + id, v := r.u8(), r.take(width) + var buf [8]byte + copy(buf[:], v) + s.Values = append(s.Values, Stat{ID: id, Value: binary.LittleEndian.Uint64(buf[:])}) + } + } + r.end() + return s +} + // zone parses a zone event from a frame func zone(r *reader) Event { if r.varint() != 0 { @@ -250,6 +358,19 @@ func redirect(r *reader) Event { return d } +// join parses the server the client picked in the lobby from a frame +func join(r *reader) Event { + if r.u16() != 0 || r.u8() != 0 { + r.bad = true // 00 00 00 so far + } + j := Join{Server: r.u16()} + if j.Server == 0 { + r.bad = true + } + r.end() + return j +} + // account parses who logged in to the lobby from a frame func account(r *reader) Event { if r.u16() != 0 || r.u8() != 0 { @@ -267,12 +388,14 @@ func account(r *reader) Event { } a.ID, a.Session[1] = id, session a.Server = r.u16() - r.skip(5) // 01, then a u32 0 so far - if r.u16() != a.Server { + a.Faction = r.u8() // 01 on a Light server, 02 on a Dark one so far + + // What follows is nine bytes that hold the Server again. In older captures four zeros come before it, and 03 09 03 after, + // in the one from 10 October 2026 six zeros come before it, and a bare 03 after. + t := r.take(9) + if t != nil && binary.LittleEndian.Uint16(t[4:]) != a.Server && binary.LittleEndian.Uint16(t[6:]) != a.Server { r.bad = true } - - r.skip(3) // 03 09 03 so far r.end() return a @@ -337,3 +460,246 @@ func lobbyPing(r *reader) Event { // unixMilli converts a Unix milliseconds timestamp to a time.Time in UTC. func unixMilli(ms int64) time.Time { return time.UnixMilli(ms).UTC() } + +// ref parses a message of an entity and nothing else from a frame +func ref(r *reader) Event { + e := Ref{r.entity()} + r.end() + return e +} + +// tagged parses an entity and the bytes after it from a frame +func tagged(r *reader) Event { + t := Tagged{Entity: r.entity()} + t.Data = append([]byte(nil), r.take(r.left())...) + return t +} + +// signal parses a message of one to four bytes from a frame +func signal(r *reader) Event { + var s Signal + if n := r.left(); n >= 1 && n <= 4 { + for i, b := range r.take(n) { + s.Code |= uint32(b) << (8 * i) + } + } else { + r.bad = true + } + return s +} + +// target parses a mob's change of target from a frame +func target(r *reader) Event { + t := Target{Entity: r.entity()} + if r.u8() != 0 { + r.bad = true + } + t.Target = r.entity() + r.end() + return t +} + +// combo parses a skill chain, 18 38, from a frame +func combo(r *reader) Event { + c := Combo{From: r.skill()} + if r.u8() != 1 { + r.bad = true + } + c.To = r.skill() + r.end() + return c +} + +// comboNext parses 19 38, the next skill of a chain, from a frame +func comboNext(r *reader) Event { + if r.u8() != 1 { + r.bad = true + } + c := Combo{To: r.skill()} + r.end() + return c +} + +// skillUse parses the player's skill use from a frame +func skillUse(r *reader) Event { + if r.u8() != 0 { + r.bad = true + } + s := SkillUse{Info: r.u16()} + s.Skill = r.skill() + r.end() + return s +} + +// windup parses the notice of a cast to come from a frame +func windup(r *reader) Event { + w := Windup{Actor: r.entity()} + if r.u8() != 0 { + r.bad = true + } + w.Skill = r.skill() + r.skip(2) // as Cast: a counter and 02 + w.Target = r.entity() + w.Angle = r.f32() + w.Pos = r.pos() + w.Duration = r.varint() + if r.u8() != 1 { + r.bad = true + } + r.end() + return w +} + +// engage parses a mob about to take the player as its target from a frame +func engage(r *reader) Event { + if r.u16() != 0 || r.u8() != 0 { + r.bad = true + } + e := Engage{r.entity()} + r.end() + return e +} + +// threat parses 31 38 from a frame +func threat(r *reader) Event { + if r.u16() != 0 { + r.bad = true + } + t := Threat{Entity: r.entity()} + t.Value = r.u16() + r.end() + return t +} + +// attributes parses the player's attribute table, 49 36, with no entity, from a frame +func attributes(r *reader) Event { + if r.u16() != 0 { + r.bad = true + } + return attributeTable(r, Attributes{}) +} + +// attributesOf parses an entity's attribute table, 4A 36, from a frame +func attributesOf(r *reader) Event { return attributeTable(r, Attributes{Entity: r.entity()}) } + +func attributeTable(r *reader, a Attributes) Event { + n := int(r.u8()) + if n*6+8 != r.left() { + r.bad = true + return nil + } + a.Values = make([]Attribute, n) + for i := range a.Values { + a.Values[i] = Attribute{ID: r.u16(), Value: int32(r.u32())} + } + r.skip(8) // zeros so far + r.end() + return a +} + +// height parses an entity's height from a frame +func height(r *reader) Event { + h := Height{Entity: r.entity()} + h.Z = r.f32() + r.end() + return h +} + +// effectAdded parses an effect put on an entity, 2A 38, from a frame +func effectAdded(r *reader) Event { return effect(r, false) } + +// effectChanged parses an effect's change, 2B 38, from a frame +func effectChanged(r *reader) Event { return effect(r, true) } + +func effect(r *reader, refresh bool) Event { + e := Effect{Entity: r.entity(), Refresh: refresh} + if !refresh && r.u8() != 1 { + r.bad = true + } + e.Kind = r.u8() + if e.Kind&^0x13 != 0 { + r.bad = true // a kind no recording has + } + e.Instance = r.varint() + e.ID = r.u32() + e.Value = r.u32() + r.skip(4) // 0, or FFFFFFFF with the permanent ones + e.Time = unixMilli(int64(r.u64())) + e.Source = r.entity() + r.skip(1) // 01 + if e.Kind&2 != 0 { + e.Skill = r.skill() + } + r.skip(1) // 00, or 02 on 2B 38 + if e.Kind&0x10 != 0 { + e.Pos = r.pos() + } + r.end() + return e +} + +// area parses a skill that lands, 03 38 and 0E 38, from a frame +func area(r *reader) Event { + a := Area{Actor: r.entity()} + flag := r.u8() // 00 on 03 38, 02 on 0E 38 + a.Target = r.entity() + a.Skill = r.skill() + r.skip(1) // a counter + a.Instance = r.u32() + a.Pos = r.pos() + if flag == 2 { + r.skip(7) + a.Pos2 = r.pos() + r.end() + } + return a +} + +// cutscene parses the start of a scene from a frame +func cutscene(r *reader) Event { + c := Cutscene{Kind: r.u32(), ID: r.u32()} + r.skip(8) // a u32 that changes, and a u32 of zeros so far + c.Pos = r.pos() + // A name, when there is one, is the last thing there: its length, its bytes, and 00. + for rest := r.p[min(r.i, len(r.p)):]; len(rest) > 2; { + n := int(rest[0]) + if n > 0 && len(rest) == n+2 && rest[len(rest)-1] == 0 { + c.Name = string(rest[1 : n+1]) + break + } + rest = rest[1:] + } + r.i = len(r.p) + return c +} + +// script parses a named trigger from a frame +func script(r *reader) Event { + s := Script{Flag: r.u8(), Kind: r.u8()} + s.Name = r.name() + return s +} + +// moveFlagged parses a move whose position follows a flag byte from a frame +func moveFlagged(r *reader) Event { + m := Move{Entity: r.entity()} + r.skip(1) + m.Pos = r.pos() + return m +} + +// moveLead parses a move whose position follows an id, and 00 01, from a frame +func moveLead(r *reader) Event { + m := Move{Entity: r.entity()} + r.skip(6) + m.Pos = r.pos() + return m +} + +// leadEntity parses 37 36, whose entity follows three bytes of zeros, from a frame +func leadEntity(r *reader) Event { + if r.u16() != 0 || r.u8() != 0 { + r.bad = true + } + return tagged(r) +} diff --git a/game/parse_test.go b/game/parse_test.go index 95edc6b..ebd3578 100644 --- a/game/parse_test.go +++ b/game/parse_test.go @@ -225,6 +225,229 @@ func TestParseCharactersShapes(t *testing.T) { } } +// Both tails of an account hold its server twice: the first form until some patch, the second since it, as in the capture of 10 October 2026. +func TestParseAccountTails(t *testing.T) { + head := "00 00 00 01 41 01 00 00 03 31 3a 42 " + for _, tt := range []struct { + name, tail string + server uint16 + faction byte + }{ + {"four zeros then 03 09 03", "17 05 01 00 00 00 00 17 05 03 09 03", 1303, 1}, + {"six zeros then 03", "06 09 02 00 00 00 00 00 00 06 09 03", 2310, 2}, + {"the server 0903, which the first form holds as its constant", "03 09 02 00 00 00 00 00 00 03 09 03", 2307, 2}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x3906, Payload: unhex(t, head+tt.tail)}) + want := game.Account{ID: "1", Server: tt.server, Faction: tt.faction, Session: [2]string{"A", "B"}} + if err != nil || !reflect.DeepEqual(e, want) { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, want) + } + } +} + +// The last group of four holds fewer, and its mask byte carries bits for those alone. +func TestParseServersGroups(t *testing.T) { + payload := unhex(t, "00 00 06"+ + " 0e 09 02 0e 09 00 00 03 44 5f 31 55 05 13 50 01 00"+ + " 0f 09 02 0f 09 00 00 03 44 5f 32 01 10 50 01 00"+ + " 10 09 02 10 09 00 00 03 44 5f 33 01 0e 50 01 00"+ + " 11 09 02 11 09 00 00 03 44 5f 34 01 0d 50 01 00"+ + " 12 09 02 12 09 00 00 03 44 5f 35 05 01 0f 50 01 00"+ + " 13 09 02 13 09 00 00 03 44 5f 36 01 0e 50 01 00"+ + " 01 01") + e, err := game.Parse(wire.Frame{Opcode: 0x3909, Payload: payload}) + if err != nil { + t.Fatal(err) + } + got := e.(game.Servers).List + if len(got) != 6 { + t.Fatalf("got %d servers", len(got)) + } + for i, s := range got { + if s.Mask != 1 || s.ID != uint16(2318+i) || s.Faction != 2 { + t.Errorf("server %d: %+v", i, s) + } + } + if s := fmt.Sprint(got); s != "[2318:D_1 19% new restricted 2319:D_2 16% new 2320:D_3 14% new 2321:D_4 13% new 2322:D_5 15% new 2323:D_6 14% new]" { + t.Errorf("printed %s", s) + } +} + +// The flag's low four bits each add a byte before the headings, and bit 0 set leaves out the +// trailing 01. The two headings are the same value, or a few units apart. +func TestParseTurn(t *testing.T) { + for _, tt := range []struct { + name, payload string + want game.Turn + }{ + {"flag 27", "c2 14 27 03 b1 1a c4 72 c4 72", game.Turn{Entity: 2626, Heading: 0x72c4, Heading2: 0x72c4}}, + {"flag 2f, with four bytes", "d0 e9 03 2f 03 35 09 03 ec 06 ec 06", game.Turn{Entity: 62672, Heading: 0x06ec, Heading2: 0x06ec}}, + {"flag 22, with a trailer", "e4 a9 02 22 0c 73 02 73 02 01", game.Turn{Entity: 38116, Heading: 0x0273, Heading2: 0x0273}}, + {"flag 26, with a trailer and two bytes", "c0 bf 02 26 02 11 ed 3a ed 3a 01", game.Turn{Entity: 40896, Heading: 0x3aed, Heading2: 0x3aed}}, + {"headings that differ", "90 67 0f 01 1a 2e ff 5b 29 6d 29", game.Turn{Entity: 13200, Heading: 0x295b, Heading2: 0x296d}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x371D, Payload: unhex(t, tt.payload)}) + if err != nil || e != tt.want { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// Since October 2026 the mask before a spawn's NPC is two bytes, and four before it. +func TestParseSpawnMask(t *testing.T) { + for _, tt := range []struct { + name, payload string + want game.Spawn + }{ + {"mask of two bytes", "d0 e9 03 0c 20 00 f8 3a 29 00 00 02 80 2f b9 c7 00 24 55 47 00 f8 b8 46", + game.Spawn{Entity: 62672, NPC: 2702072, Mask: 0x200c, Pos: game.Pos{X: -94815, Y: 54564, Z: 23676}}}, + {"mask of four bytes", "a8 81 02 05 20 00 00 00 4a 0f 20 00 00 02 49 f3 d2 c6 e5 d0 00 c7 00 dc 8b 46", + game.Spawn{Entity: 32936, NPC: 2101066, Mask: 0x2005, Pos: game.Pos{X: -27001.643, Y: -32976.895, Z: 17902}}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x3641, Payload: unhex(t, tt.payload)}) + if err != nil || e != tt.want { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// 34 36 is a spawn too, with no flag before the NPC. +func TestParseSpawnStatic(t *testing.T) { + payload := unhex(t, "bb b4 02 01 00 e1 c8 10 00 80 8f 8b c7 00 8b 7b 47 00 ca bb 46") + want := game.Spawn{Entity: 39483, NPC: 1100001, Mask: 1, Pos: game.Pos{X: -71455, Y: 64395, Z: 24037}} + if e, err := game.Parse(wire.Frame{Opcode: 0x3634, Payload: payload}); err != nil || e != want { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } +} + +// A hit whose switch nibble is 0 sends no damage. +func TestParseHitNoDamage(t *testing.T) { + payload := unhex(t, "ad 28 00 00 ad 28 a2 0f 00 00 01 02 ef 1a 06 00 01 00 00 00 90 4e 01 00") + want := game.Hit{Actor: 5165, Target: 5165, Skill: 4002, Type: 2, Scalar: 10000} + if e, err := game.Parse(wire.Frame{Opcode: 0x3804, Payload: payload}); err != nil || !reflect.DeepEqual(e, want) { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } +} + +// The short form of a cast has its angle and no position, whoever its target is. +func TestParseCastShort(t *testing.T) { + for _, tt := range []struct { + payload string + want game.Cast + }{ + {"ad 28 00 92 3e c2 00 09 00 ad 28 08 b8 ab 43 90 4e 01 00", game.Cast{Actor: 5165, Target: 5165, Skill: 12730002, Angle: 343.437744140625}}, + {"ad 28 00 91 3e c2 00 0b 00 a0 f6 03 a1 8a 2c 43 90 4e 01 00", game.Cast{Actor: 5165, Target: 64288, Skill: 12730001, Angle: 172.54151916503906}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x3802, Payload: unhex(t, tt.payload)}) + if err != nil || e != tt.want { + t.Errorf("got %+v, %v; want %+v", e, err, tt.want) + } + } +} + +// 2F 37 is a move with more after the position. +func TestParseMove2F(t *testing.T) { + payload := unhex(t, "c8 dd 02 02 ba 9e 8b c7 3e 5d 97 47 00 8e be 46 00 00 00 00 00 00 00 00 00 00 00 00 00 85 3f 47 01") + e, err := game.Parse(wire.Frame{Opcode: 0x372F, Payload: payload}) + m, ok := e.(game.Move) + if err != nil || !ok || m.Entity != 44744 || m.X > -71000 || m.X < -72000 || m.Y < 77000 { + t.Errorf("got %+v, %v", e, err) + } +} + +// Stats' two groups, each with its count, and the mask that says which are there. +func TestParseStats(t *testing.T) { + for _, tt := range []struct { + name, payload string + want game.Stats + }{ + {"u32 only", "ad 28 01 01 03 90 5f 01 00", game.Stats{Entity: 5165, Values: []game.Stat{{ID: 3, Value: 90000}}}}, + {"u64 only", "fb d4 02 02 01 00 73 00 00 00 00 00 00 00", game.Stats{Entity: 43643, Values: []game.Stat{{ID: 0, Value: 115}}}}, + {"both", "ad 28 03 02 01 9d 03 00 00 03 a4 32 01 00 01 00 77 00 00 00 00 00 00 00", + game.Stats{Entity: 5165, Values: []game.Stat{{ID: 1, Value: 925}, {ID: 3, Value: 78500}, {ID: 0, Value: 119}}}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x8D00, Payload: unhex(t, tt.payload)}) + if err != nil || !reflect.DeepEqual(e, tt.want) { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// The guessed opcodes, read from frames of a capture of 10 October 2026. +func TestParseGuesses(t *testing.T) { + pos := game.Pos{X: -100938.4765625, Y: 54664.25, Z: 24089} + for _, tt := range []struct { + name string + op wire.Opcode + hex string + want game.Event + }{ + {"target", 0x3835, "d0 e9 03 00 ad 28", game.Target{Entity: 62672, Target: 5165}}, + {"target let go", 0x3835, "a0 f6 03 00 00", game.Target{Entity: 64288}}, + {"combo", 0x3818, "89 26 00 00 01 8a 26 00 00", game.Combo{From: 9865, To: 9866}}, + {"combo next", 0x3819, "01 8a 26 00 00", game.Combo{To: 9866}}, + {"skill use", 0x3801, "00 00 00 a1 0f 00 00", game.SkillUse{Skill: 4001}}, + {"skill use with info", 0x3801, "00 13 18 8c 26 00 00", game.SkillUse{Skill: 9868, Info: 0x1813}}, + {"engage", 0x3834, "00 00 00 d0 e9 03", game.Engage{Entity: 62672}}, + {"threat", 0x3831, "00 00 fb d4 02 7f 3e", game.Threat{Entity: 43643, Value: 0x3e7f}}, + {"height", 0x3746, "ad 28 00 32 bc 46", game.Height{Entity: 5165, Z: 24089}}, + {"ref", 0x363B, "9a ed 01", game.Ref{Entity: 30362}}, + {"signal", 0x383B, "00 00", game.Signal{}}, + {"tagged", 0x3638, "9a ed 01 ad 28 01", game.Tagged{Entity: 30362, Data: []byte{0xad, 0x28, 0x01}}}, + {"windup", 0x3809, "a0 f6 03 00 44 ae 12 00 01 02 ad 28 83 80 2e c3 53 48 b6 c7 86 85 55 47 f9 fc b8 46 f4 03 01", + game.Windup{Actor: 64288, Target: 5165, Skill: 1224260, Angle: -174.5019989013672, Duration: 500, + Pos: game.Pos{X: -93328.6484375, Y: 54661.5234375, Z: 23678.486328125}}}, + {"script", 0x8D15, "00 09 14 43 75 74 73 63 65 6e 65 5f 4c 5f 41 5f 51 4d 5f 31 30 30 31 00 d8 5f 01 00 00 00 00 00 01 00 00 00 00", + game.Script{Kind: 9, Name: "Cutscene_L_A_QM_1001"}}, + {"effect", 0x382A, "ad 28 01 11 05 12 27 00 00 88 13 00 00 00 00 00 00 96 8a 81 26 a1 01 00 00 ad 28 01 00 3d 25 c5 c7 40 88 55 47 00 32 bc 46", + game.Effect{Entity: 5165, Kind: 0x11, Instance: 5, ID: 10002, Value: 5000, Source: 5165, Pos: pos, + Time: time.UnixMilli(1791647386262).UTC()}}, + {"effect changed", 0x382B, "ad 28 11 05 12 27 00 00 7d 2d 02 00 00 00 00 00 ee a4 83 26 a1 01 00 00 ad 28 01 02 3d 25 c5 c7 40 88 55 47 00 32 bc 46", + game.Effect{Entity: 5165, Kind: 0x11, Instance: 5, ID: 10002, Value: 142717, Source: 5165, Pos: pos, Refresh: true, + Time: time.UnixMilli(0x1a12683a4ee).UTC()}}, + {"move 3C 37", 0x373C, "ad 28 00 3d 25 c5 c7 40 88 55 47 00 32 bc 46 00 00 b4 43 00 00 05 00", game.Move{Entity: 5165, Pos: pos}}, + } { + e, err := game.Parse(wire.Frame{Opcode: tt.op, Payload: unhex(t, tt.hex)}) + if err != nil || !reflect.DeepEqual(e, tt.want) { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// 4A 36 is a count of ids and values, and eight bytes after. +func TestParseAttributes(t *testing.T) { + payload := unhex(t, "d8 18 03 1f 00 05 00 00 00 59 00 c4 09 00 00 48 f4 ff ff ff ff 00 00 00 00 00 00 00 00") + want := game.Attributes{Entity: 3160, Values: []game.Attribute{{ID: 0x1f, Value: 5}, {ID: 0x59, Value: 2500}, {ID: 0xf448, Value: -1}}} + if e, err := game.Parse(wire.Frame{Opcode: 0x364A, Payload: payload}); err != nil || !reflect.DeepEqual(e, want) { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } + // 49 36 is the player's, and has no entity + self := unhex(t, "00 00 01 1f 00 05 00 00 00 00 00 00 00 00 00 00 00") + if e, err := game.Parse(wire.Frame{Opcode: 0x3649, Payload: self}); err != nil || !reflect.DeepEqual(e, game.Attributes{Values: []game.Attribute{{ID: 0x1f, Value: 5}}}) { + t.Errorf("got %+v, %v", e, err) + } + if _, err := game.Parse(wire.Frame{Opcode: 0x364A, Payload: unhex(t, "d8 18 03 1f 00 05 00 00 00 00 00 00 00 00 00 00 00")}); !errors.Is(err, game.ErrLayout) { + t.Errorf("a count of three with one value: %v", err) + } +} + +// The scene's name is the last thing in 21 36, when it has one. +func TestParseCutscene(t *testing.T) { + payload := unhex(t, "01 00 00 00 a1 86 01 00 6d 39 d2 00 00 00 00 00 3d 25 c5 c7 40 88 55 47 00 32 bc 46 64 25 bb b5 00 00 00 00 00 00 00 00 00 00 00 00 14 43 75 74 73 63 65 6e 65 5f 4c 5f 41 5f 51 4d 5f 31 30 30 31 00") + e, err := game.Parse(wire.Frame{Opcode: 0x3621, Payload: payload}) + c, ok := e.(game.Cutscene) + if err != nil || !ok || c.Name != "Cutscene_L_A_QM_1001" || c.ID != 100001 || c.Kind != 1 || c.Pos != (game.Pos{X: -100938.4765625, Y: 54664.25, Z: 24089}) { + t.Errorf("got %+v, %v", e, err) + } +} + +func TestParseJoin(t *testing.T) { + e, err := game.Parse(wire.Frame{Opcode: 0x390D, Payload: unhex(t, "00 00 00 06 09")}) + if want := (game.Join{Server: 2310}); err != nil || e != want { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } +} + func TestRedirectAddr(t *testing.T) { for _, tt := range []struct { payload string @@ -262,12 +485,24 @@ func TestParseRejects(t *testing.T) { {"hit with switch nibble 6 cut in its three bytes", 0x3804, "c7 7c 06 00 f5 a3 02 e0 26 a8 00 00 02 00 00", game.ErrLayout}, {"hit with switch nibble 5", 0x3804, "c7 7c 05 00 f5 a3 02 e0 26 a8 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 90 4e 20 01 00", game.ErrUnread}, {"hit whose type does not fit a byte", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 ac 02 4b ed 9f 41 01 00 00 00 90 4e 24 01 00", game.ErrLayout}, + {"turn cut short", 0x371D, "64 02 11 34 12 34 12", game.ErrLayout}, + {"turn whose trailer is not 01", 0x371D, "64 02 11 34 12 34 12 02", game.ErrLayout}, + {"turn with a byte too many", 0x371D, "64 02 11 34 12 34 12 01 00", game.ErrLayout}, + {"turn with a trailer its flag leaves out", 0x371D, "64 03 11 22 34 12 34 12 01", game.ErrLayout}, + {"turn with fewer bytes than its flag adds", 0x371D, "64 07 11 22 34 12 34 12", game.ErrLayout}, + {"stats with no group", 0x8D00, "ad 28 00", game.ErrLayout}, + {"stats with a group its mask does not have", 0x8D00, "ad 28 04 01 03 90 5f 01 00", game.ErrLayout}, + {"stats claiming more values than bytes", 0x8D00, "ad 28 01 05 03 90 5f 01 00", game.ErrLayout}, + {"stats with a byte too many", 0x8D00, "ad 28 01 01 03 90 5f 01 00 00", game.ErrLayout}, + {"heading too short for its tail", 0x372A, "64 12 00 00 80 42 00 00 a0 42 00 00 c0 42 11", game.ErrLayout}, + {"heading whose trailer is not 01", 0x3728, "64 04 00 00 80 42 00 00 a0 42 00 00 c0 42 00 00 90 42 00 00 a8 42 00 00 70 41 11 22 02", game.ErrLayout}, + {"heading with a byte too many", 0x372A, "64 12 00 00 80 42 00 00 a0 42 00 00 c0 42 11 22 01 00", game.ErrLayout}, {"hit whose trailer is not its sequence number", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 02 00", game.ErrLayout}, {"hit with bytes after the trailer", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 01 00 ff", game.ErrLayout}, {"hit claiming more extra hits than bytes", 0x3804, "c7 7c 24 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 7f 01 00", game.ErrLayout}, {"death with a byte too many", 0x3642, "f5 a3 02 00 03 00", game.ErrLayout}, {"death whose middle varint is not zero", 0x3642, "f5 a3 02 01 03", game.ErrLayout}, - {"cast without a position", 0x3802, "e4 72 00 d8 62 d6 00 0b 00 e4 72 6d d9 90 43 90 4e 01 00", game.ErrUnread}, + {"cast of the short form with a byte too many", 0x3802, "e4 72 00 d8 62 d6 00 0b 00 e4 72 6d d9 90 43 90 4e 01 00 00", game.ErrLayout}, {"cast end with a byte too many", 0x3806, "c7 7c e0 26 a8 00 01 0c 00", game.ErrLayout}, {"move 1B 37 whose flag adds a byte it does not have", 0x371B, "e4 72 05 03 2b d5 bc c6 01 6f 24 c7 6a 42 8c", game.ErrLayout}, {"spawn with a name", 0x3641, "a8 81 02 05 20 00 00 01 4a 0f 20 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00", game.ErrUnread}, @@ -286,6 +521,13 @@ func TestParseRejects(t *testing.T) { {"characters cut short in a character", 0x390B, "00 00 01 15 05 01 41 03 00 00 00", game.ErrLayout}, {"characters without its trailer", 0x390B, "00 00 01 15 05 01 41 03 00 00 00 00 00 00 00 00 00 00 00 06 00 00 00 01 81 e0 d7 f8 a0 01 00 00 00 00 00 00 00 00 00 00 00", game.ErrLayout}, {"account whose Server is not repeated", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 17 05 01 00 00 00 00 18 05 03 09 03", game.ErrLayout}, + {"account of the newer form whose Server is not repeated", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 06 09 02 00 00 00 00 00 00 07 09 03", game.ErrLayout}, + {"account of the newer form cut before its 03", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 06 09 02 00 00 00 00 00 00 06 09", game.ErrLayout}, + {"account with a byte too many", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 06 09 02 00 00 00 00 00 00 06 09 03 00", game.ErrLayout}, + {"join cut short", 0x390D, "00 00 00 06", game.ErrLayout}, + {"join whose first bytes are not 0", 0x390D, "00 00 01 06 09", game.ErrLayout}, + {"join of server 0", 0x390D, "00 00 00 00 00", game.ErrLayout}, + {"join with a byte too many", 0x390D, "00 00 00 06 09 00", game.ErrLayout}, {"account whose ID has no colon", 0x3906, "00 00 00 01 41 01 00 00 01 42 17 05 01 00 00 00 00 17 05 03 09 03", game.ErrLayout}, {"characters whose trailer is not 01 01", 0x390B, "00 00 00 01 02", game.ErrLayout}, {"characters on server 0", 0x390B, "00 00 01 00 00 01 41 03 00 00 00 00 00 00 00 00 00 00 00 06 00 00 00 01 81 e0 d7 f8 a0 01 00 00 00 00 00 00 00 00 00 00 00 01 01", game.ErrLayout}, diff --git a/game/testdata/account-v2.bin b/game/testdata/account-v2.bin new file mode 100644 index 0000000..d32c82c Binary files /dev/null and b/game/testdata/account-v2.bin differ diff --git a/game/testdata/account-v2.expect.txt b/game/testdata/account-v2.expect.txt new file mode 100644 index 0000000..914e5ea --- /dev/null +++ b/game/testdata/account-v2.expect.txt @@ -0,0 +1 @@ +06 39 game.Account {ID:111111 Server:2310 Faction:2 Session:[12345678-9ABC-DEF0-1234-56789ABCDEF0 12345678-9ABC-DEF0-1234-56789ABCDEF0]} diff --git a/game/testdata/account.expect.txt b/game/testdata/account.expect.txt index 7f3f12e..71c201e 100644 --- a/game/testdata/account.expect.txt +++ b/game/testdata/account.expect.txt @@ -1 +1 @@ -06 39 game.Account {ID:111111 Server:1303 Session:[12345678-9ABC-DEF0-1234-56789ABCDEF0 12345678-9ABC-DEF0-1234-56789ABCDEF0]} +06 39 game.Account {ID:111111 Server:1303 Faction:1 Session:[12345678-9ABC-DEF0-1234-56789ABCDEF0 12345678-9ABC-DEF0-1234-56789ABCDEF0]} diff --git a/game/testdata/cast.bin b/game/testdata/cast.bin index 31df40a..4951bbe 100644 Binary files a/game/testdata/cast.bin and b/game/testdata/cast.bin differ diff --git a/game/testdata/cast.expect.txt b/game/testdata/cast.expect.txt index 430c86f..1978773 100644 --- a/game/testdata/cast.expect.txt +++ b/game/testdata/cast.expect.txt @@ -1,7 +1,9 @@ -02 38 game: not read -02 38 game.Cast {Actor:15943 Target:15943 Skill:11030000 Pos:{X:-25784.588 Y:-33925.99 Z:18093.855}} -02 38 game.Cast {Actor:15943 Target:37365 Skill:11020000 Pos:{X:-25712.445 Y:-34181.098 Z:18067.809}} -02 38 game.Cast {Actor:37365 Target:15943 Skill:1231220 Pos:{X:-25830.9 Y:-33949.13 Z:18086}} -02 38 game.Cast {Actor:33204 Target:15943 Skill:1230790 Pos:{X:-25844.92 Y:-35503.35 Z:17897.758}} +02 38 game.Cast {Actor:14692 Target:14692 Skill:14050008 Angle:289.69864 Pos:{X:0 Y:0 Z:0} Duration:0} +02 38 game.Cast {Actor:15943 Target:15943 Skill:11030000 Angle:-82.24831 Pos:{X:-25784.588 Y:-33925.99 Z:18093.855} Duration:0} +02 38 game.Cast {Actor:15943 Target:37365 Skill:11020000 Angle:-65.654495 Pos:{X:-25712.445 Y:-34181.098 Z:18067.809} Duration:0} +02 38 game.Cast {Actor:37365 Target:15943 Skill:1231220 Angle:117.95723 Pos:{X:-25830.9 Y:-33949.13 Z:18086} Duration:1000} +02 38 game.Cast {Actor:33204 Target:15943 Skill:1230790 Angle:62.40274 Pos:{X:-25844.92 Y:-35503.35 Z:17897.758} Duration:20000} 06 38 game.CastEnd {Actor:15943 Skill:11020000} 06 38 game.CastEnd {Actor:37365 Skill:1231220} +02 38 game.Cast {Actor:5165 Target:5165 Skill:12730002 Angle:343.43774 Pos:{X:0 Y:0 Z:0} Duration:0} +02 38 game.Cast {Actor:5165 Target:64288 Skill:12730001 Angle:172.54152 Pos:{X:0 Y:0 Z:0} Duration:0} diff --git a/game/testdata/heading-pos.bin b/game/testdata/heading-pos.bin new file mode 100644 index 0000000..97c94f7 Binary files /dev/null and b/game/testdata/heading-pos.bin differ diff --git a/game/testdata/heading-pos.expect.txt b/game/testdata/heading-pos.expect.txt new file mode 100644 index 0000000..7fe5513 --- /dev/null +++ b/game/testdata/heading-pos.expect.txt @@ -0,0 +1 @@ +28 37 game.Heading {Entity:100 Pos:{X:64 Y:80 Z:96} Heading1:72 Heading2:84 Speed:15} diff --git a/game/testdata/heading.bin b/game/testdata/heading.bin new file mode 100644 index 0000000..02b0ba5 Binary files /dev/null and b/game/testdata/heading.bin differ diff --git a/game/testdata/heading.expect.txt b/game/testdata/heading.expect.txt new file mode 100644 index 0000000..4d9a1cd --- /dev/null +++ b/game/testdata/heading.expect.txt @@ -0,0 +1 @@ +2A 37 game.Heading {Entity:100 Pos:{X:0 Y:0 Z:0} Heading1:64 Heading2:80 Speed:96} diff --git a/game/testdata/hit.bin b/game/testdata/hit.bin index 53b899f..f2401b8 100644 Binary files a/game/testdata/hit.bin and b/game/testdata/hit.bin differ diff --git a/game/testdata/hit.expect.txt b/game/testdata/hit.expect.txt index 7c8a984..f53ae92 100644 --- a/game/testdata/hit.expect.txt +++ b/game/testdata/hit.expect.txt @@ -7,3 +7,6 @@ 04 38 game.Hit {Actor:15943 Target:18777 Skill:11010000 Damage:16 Extra:[1] Type:2 Scalar:10000 Mods:2 Direction:0} 04 38 game.Hit {Actor:15943 Target:19980 Skill:11190000 Damage:26 Extra:[2 2 2] Type:3 Scalar:10000 Mods:2 Direction:0} 04 38 game.Hit {Actor:15943 Target:31355 Skill:11190000 Damage:36 Extra:[3] Type:2 Scalar:10000 Mods:0 Direction:0} +04 38 game.Hit {Actor:5165 Target:5165 Skill:4002 Damage:0 Extra:[] Type:2 Scalar:10000 Mods:0 Direction:0} +04 38 game.Hit {Actor:5165 Target:46100 Skill:9868 Damage:0 Extra:[] Type:2 Scalar:10000 Mods:0 Direction:0} +04 38 game.Hit {Actor:28476 Target:28476 Skill:1900710 Damage:0 Extra:[] Type:0 Scalar:10000 Mods:0 Direction:0} diff --git a/game/testdata/join.bin b/game/testdata/join.bin new file mode 100644 index 0000000..cfc585c Binary files /dev/null and b/game/testdata/join.bin differ diff --git a/game/testdata/join.expect.txt b/game/testdata/join.expect.txt new file mode 100644 index 0000000..e51076a --- /dev/null +++ b/game/testdata/join.expect.txt @@ -0,0 +1 @@ +0D 39 game.Join {Server:2310} diff --git a/game/testdata/move.bin b/game/testdata/move.bin index 2043285..aaece68 100644 Binary files a/game/testdata/move.bin and b/game/testdata/move.bin differ diff --git a/game/testdata/move.expect.txt b/game/testdata/move.expect.txt index 74a8a28..0639f0e 100644 --- a/game/testdata/move.expect.txt +++ b/game/testdata/move.expect.txt @@ -6,3 +6,5 @@ 1C 37 game.Move {Entity:39705 Pos:{X:-25664.053 Y:-33089.8 Z:18074}} 1C 37 game.Move {Entity:14692 Pos:{X:-23952.59 Y:-42236.453 Z:17935.75}} 1C 37 game.Move {Entity:27239 Pos:{X:-24092.316 Y:-42056.7 Z:17949}} +2F 37 game.Move {Entity:44744 Pos:{X:-71485.45 Y:77498.484 Z:24391}} +2F 37 game.Move {Entity:44744 Pos:{X:-71499.67 Y:76292.56 Z:24421}} diff --git a/game/testdata/spawn.bin b/game/testdata/spawn.bin index afe9a06..7fc54b0 100644 Binary files a/game/testdata/spawn.bin and b/game/testdata/spawn.bin differ diff --git a/game/testdata/spawn.expect.txt b/game/testdata/spawn.expect.txt index 1d73792..99c039d 100644 --- a/game/testdata/spawn.expect.txt +++ b/game/testdata/spawn.expect.txt @@ -2,3 +2,8 @@ 41 36 game.Spawn {Entity:19143 NPC:2101066 Mask:8196 Pos:{X:-25067.506 Y:-31636.988 Z:18143}} 41 36 game.Spawn {Entity:37365 NPC:2101066 Mask:8197 Pos:{X:-25659.373 Y:-33702.46 Z:18119}} 41 36 game.Spawn {Entity:25199 NPC:2290017 Mask:12292 Pos:{X:-23251.486 Y:-42852.617 Z:17884}} +41 36 game.Spawn {Entity:64288 NPC:2701091 Mask:12300 Pos:{X:-92385.24 Y:54738.82 Z:23679}} +41 36 game.Spawn {Entity:62672 NPC:2702072 Mask:8204 Pos:{X:-94815 Y:54564 Z:23676}} +41 36 game.Spawn {Entity:54379 NPC:2701693 Mask:12301 Pos:{X:-71380.51 Y:65140.023 Z:24014}} +34 36 game.Spawn {Entity:39483 NPC:1100001 Mask:1 Pos:{X:-71455 Y:64395 Z:24037}} +34 36 game.Spawn {Entity:54428 NPC:1400219 Mask:4 Pos:{X:-75521.055 Y:77907.34 Z:24515}} diff --git a/game/testdata/stats.bin b/game/testdata/stats.bin new file mode 100644 index 0000000..3d5da55 Binary files /dev/null and b/game/testdata/stats.bin differ diff --git a/game/testdata/stats.expect.txt b/game/testdata/stats.expect.txt new file mode 100644 index 0000000..8ecc777 --- /dev/null +++ b/game/testdata/stats.expect.txt @@ -0,0 +1,5 @@ +00 8D game.Stats {Entity:5165 Values:[{ID:1 Value:1500} {ID:8 Value:1500} {ID:10 Value:100000} {ID:12 Value:100000} {ID:0 Value:119} {ID:7 Value:119}]} +00 8D game.Stats {Entity:5165 Values:[{ID:5 Value:30000} {ID:6 Value:0}]} +00 8D game.Stats {Entity:5165 Values:[{ID:5 Value:50000}]} +00 8D game.Stats {Entity:43643 Values:[{ID:0 Value:115}]} +00 8D game.Stats {Entity:5165 Values:[{ID:0 Value:117}]} diff --git a/game/testdata/turn.bin b/game/testdata/turn.bin new file mode 100644 index 0000000..a50880a --- /dev/null +++ b/game/testdata/turn.bin @@ -0,0 +1 @@ +7Ðé'5 ìì7Ðé/5 ìì7æô#5\ÿ\ÿ7ó„$ïX¿X¿7ó„-§ü—À—À7ó„%Ë—À—À7À¿&í:í:7¡Wõ[ÁÆ7¡W ç[ÁÀ7Â+°éˆˆ \ No newline at end of file diff --git a/game/testdata/turn.expect.txt b/game/testdata/turn.expect.txt new file mode 100644 index 0000000..49ccea8 --- /dev/null +++ b/game/testdata/turn.expect.txt @@ -0,0 +1,10 @@ +1D 37 game.Turn {Entity:62672 Heading:1772 Heading2:1772} +1D 37 game.Turn {Entity:62672 Heading:1772 Heading2:1772} +1D 37 game.Turn {Entity:64102 Heading:65372 Heading2:65372} +1D 37 game.Turn {Entity:49779 Heading:48984 Heading2:48984} +1D 37 game.Turn {Entity:49779 Heading:49303 Heading2:49303} +1D 37 game.Turn {Entity:49779 Heading:49303 Heading2:49303} +1D 37 game.Turn {Entity:40896 Heading:15085 Heading2:15085} +1D 37 game.Turn {Entity:11169 Heading:49499 Heading2:50713} +1D 37 game.Turn {Entity:11169 Heading:49499 Heading2:49173} +1D 37 game.Turn {Entity:2626 Heading:32648 Heading2:32648} diff --git a/game/world.go b/game/world.go index 921e4f4..4569ff9 100644 --- a/game/world.go +++ b/game/world.go @@ -1,6 +1,7 @@ package game -// Spawn announces a mob or summon entering view +// Spawn announces a mob or summon entering view, from 41 36. 34 36 announces the entities that +// stood still the whole recording, which a2k has seen no Move or Death of. type Spawn struct { Entity Entity NPC NPC @@ -23,6 +24,45 @@ type Move struct { Pos } +// Turn is a lighter update than Move, with no position. +// +// Its two u16 are the same value on most updates, and a few units apart on the rest, which fits a +// current and a target direction, but which is which is not confirmed, nor whether either is a +// heading at all. +type Turn struct { + Entity Entity + Heading uint16 + Heading2 uint16 +} + +// Stats is the attributes of an entity that changed, from 00 8D, as their ids and values in the order sent. +// +// The ids are not named, but they pair up: ids 0 to 6 hold a value, and id n+7 holds its maximum. +// Id 1 reads like hit points, 1500 at most on the player and 1605 on one mob; it falls 120 at +// a time while damage over time ticks, and 3 is a gauge from 0 to 100000. +type Stats struct { + Entity Entity + Values []Stat +} + +// Stat is one of an entity's attributes: an id and its value. +type Stat struct { + ID byte + Value uint64 +} + +// Heading is an entity's current and turning direction, with its speed. +// +// 28 37 and 29 37 also carry its position; 2A 37 doesn't, and Pos is the zero value then. What +// tells 28 37 apart from 29 37, what precedes 2A 37's fields when there's no position, and which +// of Heading1 and Heading2 is the current direction and which, if either, is a target, are not +// understood. Speed is zero at rest in every sample seen so far, which fits that name. +type Heading struct { + Entity Entity + Pos // the zero value when this update carries none + Heading1, Heading2, Speed float32 +} + // Zone places the client's character in the world after a zone change type Zone struct{ Pos } @@ -36,5 +76,8 @@ type NameCheck struct { func (Spawn) event() {} func (Player) event() {} func (Move) event() {} +func (Turn) event() {} +func (Stats) event() {} +func (Heading) event() {} func (Zone) event() {} func (NameCheck) event() {} diff --git a/trace_test.go b/trace_test.go index 2138962..55c4c4a 100644 --- a/trace_test.go +++ b/trace_test.go @@ -70,14 +70,14 @@ func tracks(evs []traced, pid int) []string { func TestFight(t *testing.T) { evs := traceOf(t, logOf(t, Config{}, - "0 02 38 server x3wA4CaoAAAA9aMCAAAAAAAAAAAAAAAAAAAAAA==", // 15943 casts Keen Strike - "5 04 38 server x3wEAPWjAuAmqAAAAkvtn0EBAAAAkE4kAQA=", // 37365 hits 15943 - "12 05 38 server x3wC9aMCAOAmqAAk", // unread - "40 04 38 server x3wEAPWjAuAmqAAAAks=", // a hit cut short - "100 02 38 server x3wA4CaoAAAA9aMCAAAAAAAAAAAAAAAAAAAAAA==", // cast again before the first ended - "470 06 38 server x3zgJqgAAQw=", // the cast ends - "480 02 38 server x3wA4CaoAAAA9aMCAAAAAAAAAAAAAAAAAAAAAA==", // and never ends - "510 42 36 server x3wAAw==", // 15943 dies + "0 02 38 server x3wA4CaoAAAA9aMCAAAAAAAAAAAAAAAAAAAAAJBOAQA=", // 15943 casts Keen Strike + "5 04 38 server x3wEAPWjAuAmqAAAAkvtn0EBAAAAkE4kAQA=", // 37365 hits 15943 + "12 05 38 server x3wC9aMCAOAmqAAk", // unread + "40 04 38 server x3wEAPWjAuAmqAAAAks=", // a hit cut short + "100 02 38 server x3wA4CaoAAAA9aMCAAAAAAAAAAAAAAAAAAAAAJBOAQA=", // cast again before the first ended + "470 06 38 server x3zgJqgAAQw=", // the cast ends + "480 02 38 server x3wA4CaoAAAA9aMCAAAAAAAAAAAAAAAAAAAAAJBOAQA=", // and never ends + "510 42 36 server x3wAAw==", // 15943 dies )) type span struct { @@ -153,7 +153,7 @@ func TestCapture(t *testing.T) { if want := []int64{0, 0, -1000, -1000}; !slices.Equal(ts, want) { t.Errorf("frames at %vµs, want %v: after the first frame, to the microsecond", ts, want) } - if want := []string{"33 36 Self", "04 38 Hit", "05 38 DoT", "2A 38"}; !slices.Equal(tracks(evs, byOpcode), want) { + if want := []string{"33 36 Self", "04 38 Hit", "05 38 DoT", "2A 38 Effect"}; !slices.Equal(tracks(evs, byOpcode), want) { t.Errorf("opcode tracks %q, want %q", tracks(evs, byOpcode), want) } } diff --git a/wire/decoder.go b/wire/decoder.go index ca7346c..07ad5ad 100644 --- a/wire/decoder.go +++ b/wire/decoder.go @@ -188,7 +188,7 @@ func (d *Decoder) emit(st *stream, body []byte, flags Flags) { if flags&FromClient != 0 && !d.config.EmitClient { return } - if d.config.KnownOnly && !op.Known() { + if d.config.KnownOnly && !op.listed() { return } f := Frame{ diff --git a/wire/framer.go b/wire/framer.go index 1032b1a..440f46e 100644 --- a/wire/framer.go +++ b/wire/framer.go @@ -221,7 +221,7 @@ func (f *framer) plausible(b []byte, size int) bool { return size > minBundle && (len(b) < 3 || b[2] == 0xFF) // a flagged bundle } if f.knownOnly { - return opcode(b).Known() + return opcode(b).listed() } return inFamily(b[1]) } diff --git a/wire/opcode.go b/wire/opcode.go index 675fa96..439c91f 100644 --- a/wire/opcode.go +++ b/wire/opcode.go @@ -48,14 +48,38 @@ var knownBits = func() (bits [1 << 10]uint64) { // known lists the opcodes the lock counts and KnownOnly keeps var known = [...]Opcode{ 0x3804, 0x3805, 0x3802, 0x3806, 0x3822, 0x382A, 0x382B, 0x382C, 0x3835, 0x3847, - 0x3603, 0x361A, 0x3623, 0x3633, 0x3640, 0x3641, 0x3642, 0x3645, 0x3646, 0x3647, 0x3649, 0x364A, + 0x3603, 0x361A, 0x3623, 0x3633, 0x3640, 0x3641, 0x3634, 0x3642, 0x3645, 0x3646, 0x3647, 0x3649, 0x364A, 0x3600, // Server tick - 0x371A, 0x371B, 0x371C, + 0x371A, 0x371B, 0x371C, 0x371D, 0x3728, 0x3729, 0x372A, 0x372F, 0x8D00, 0x8D04, 0x8D21, 0x921B, 0x9200, 0x920D, 0x3901, 0x3903, 0x3906, 0x3909, 0x390B, 0x390D, 0x390F, // Lobby opcodes } +// guessed lists the opcodes package game reads on a guess. A decoder that keeps only known opcodes +// keeps them too, but the lock does not count them: a guess more in the list would make noise +// look like the game's frames sooner. +var guessed = [...]Opcode{ + 0x3618, 0x3621, 0x3631, 0x3635, 0x3637, 0x3638, 0x363A, 0x363B, 0x364B, 0x364E, + 0x371E, 0x3721, 0x3722, 0x3723, 0x3724, 0x372B, 0x372E, 0x373C, 0x3741, 0x3742, 0x3746, + 0x3801, 0x3803, 0x3809, 0x380C, 0x380E, 0x3818, 0x3819, 0x3831, 0x3834, 0x383B, 0x383D, + 0x8D03, 0x8D15, 0x8D41, 0x8D43, 0x8D52, 0x8D6D, 0x8D91, 0x8D93, +} + +// Guessed reports whether o is one package game reads on a guess, and the lock does not count. +func (o Opcode) Guessed() bool { return guessedBits[o>>6]&(1<<(o&63)) != 0 } + +// guessedBits is guessed as a bitmap, one bit an opcode. +var guessedBits = func() (bits [1 << 10]uint64) { + for _, o := range guessed { + bits[o>>6] |= 1 << (o & 63) + } + return bits +}() + +// listed reports whether o is known, or guessed: what KnownOnly keeps. +func (o Opcode) listed() bool { return o.Known() || o.Guessed() } + // inFamily reports whether b, an opcode's high byte, is one of the families. Unless KnownOnly // is set, an opcode in a family is plausible. Every known opcode is in one. func inFamily(b byte) bool {