From 0918f5797a3df3da77015d3d6d6a2a39786687b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Peltier?= Date: Mon, 7 Oct 2024 16:31:45 +0200 Subject: [PATCH] Add node graph extraction --- CHANGELOG.md | 3 + goldsrc/node.go | 188 ++++++++++++++++++++++++++++++++++++++++++++++++ goldutil.1 | 36 ++++++++++ main.go | 82 +++++++++++++++++++++ qmap/entity.go | 30 ++++++++ 5 files changed, 339 insertions(+) create mode 100644 goldsrc/node.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 63657fc..215dc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# v1.2.0 +- Add graph node extraction. + # v1.1.1 - Replace help command with a single manpage. diff --git a/goldsrc/node.go b/goldsrc/node.go new file mode 100644 index 0000000..ab1978d --- /dev/null +++ b/goldsrc/node.go @@ -0,0 +1,188 @@ +package goldsrc + +import ( + "encoding/binary" + "fmt" + "io" +) + +// Original retail version. +const NodeGraphVersion = 16 + +type NodeFormat int + +const ( + NodeFormatValve NodeFormat = iota + NodeFormatDecay +) + +type Graph struct { + _ [3]int32 // 3 qbool + _ [3]uint32 // 3 pointers + + NumNodes int32 + NumLinks int32 + + _ [8364]byte +} + +type Vec3f struct { + X, Y, Z float32 +} + +func (vec Vec3f) String() string { // .map-compatible + return fmt.Sprintf("%f %f %f", vec.X, vec.Y, vec.Z) +} + +type Node interface { + Position(original bool) Vec3f // false to get position after being dropped. + ClassName() string +} + +type ValveNode struct { + Origin Vec3f + OriginPeek Vec3f + + _ [3]byte + + NodeInfo int32 + + _ [57]byte +} + +type DecayNode struct { + ValveNode + _ [8]byte +} + +func (node ValveNode) Position(original bool) Vec3f { + if original { + return node.Origin + } + + return node.OriginPeek +} + +const ( + NodeTypeLand int32 = 1 << 0 + NodeTypeAir int32 = 1 << 1 + NodeTypeWater int32 = 1 << 2 +) + +func (node ValveNode) ClassName() string { + // Order matters. + switch { + case node.NodeInfo == 256: + // HACK: No idea where this 256 comes from. + return "info_node" + case (node.NodeInfo & NodeTypeLand) != 0, node.NodeInfo == 0: + return "info_node" + case (node.NodeInfo & NodeTypeWater) != 0: + return "info_node_water" + case (node.NodeInfo & NodeTypeAir) != 0: + return "info_node_air" + default: + return fmt.Sprintf("info_node_unknown_%d", node.NodeInfo) + } +} + +const ( + LinkTypeSmallHull = iota + LinkTypeHumanHull + LinkTypeLargeHull + LinkTypeFlyHull + LinkTypeDisabledHull + + LinkTypeBitMax = 4 +) + +func LinkTypeName(id int) string { + switch id { + case LinkTypeSmallHull: + return "small" + case LinkTypeHumanHull: + return "human" + case LinkTypeLargeHull: + return "large" + case LinkTypeFlyHull: + return "fly" + case LinkTypeDisabledHull: + return "disabled" + default: + return "unknown" + } +} + +type Link struct { + SrcNode int32 + DstNode int32 + _ [8]byte + LinkInfo int32 + _ [4]byte +} + +func ReadNodes(r io.Reader, format NodeFormat) ([]Node, []Link, error) { + var version int32 + if err := binary.Read(r, binary.LittleEndian, &version); err != nil { + return nil, nil, fmt.Errorf("unable to parse version: %w", err) + } + if version != NodeGraphVersion { + return nil, nil, fmt.Errorf("unsupported node graph version: %d", version) + } + + var graph Graph + if err := binary.Read(r, binary.LittleEndian, &graph); err != nil { + return nil, nil, fmt.Errorf("unable to read CGraph: %w", err) + } + + var nodes = make([]Node, 0, graph.NumNodes) + for i := int32(0); i < graph.NumNodes; i++ { + node, err := readNode(r, format) + if err != nil { + return nil, nil, fmt.Errorf("unable to read node #%d: %w", i, err) + } + + nodes = append(nodes, node) + } + + var links = make([]Link, 0, graph.NumLinks) + for i := int32(0); i < graph.NumLinks; i++ { + var link Link + if err := binary.Read(r, binary.LittleEndian, &link); err != nil { + return nil, nil, fmt.Errorf("unable to read CLink: %w", err) + } + + links = append(links, link) + } + + return nodes, links, nil +} + +func readNode(r io.Reader, format NodeFormat) (Node, error) { + switch format { + case NodeFormatValve: + var node ValveNode + var err = binary.Read(r, binary.LittleEndian, &node) + return node, err + case NodeFormatDecay: + var node DecayNode + var err = binary.Read(r, binary.LittleEndian, &node) + return node, err + default: + return nil, fmt.Errorf("unknown node format: %d", format) + } +} + +func assertSizeof(typ any, expected int) { + var actual = binary.Size(typ) + if actual != expected { + panic(fmt.Errorf("invalid size for %T: got %d expected %d", typ, actual, expected)) + } +} + +func init() { + assertSizeof(Graph{}, 8396) + assertSizeof(ValveNode{}, 88) + assertSizeof(DecayNode{}, 96) + assertSizeof(Link{}, 24) +} diff --git a/goldutil.1 b/goldutil.1 index 98bc216..8f38c9e 100644 --- a/goldutil.1 +++ b/goldutil.1 @@ -17,6 +17,10 @@ map export|graph .I FILE .br .B goldutil +nod export +.I FILE +.br +.B goldutil spr create|extract|info .I FILE .br @@ -88,6 +92,38 @@ written to stdout. .RE .\" }}} .\" {{{ +.SH NOD MANIPULATION +.B goldutil +nod export +[-\-input-format \fIFORMAT\fR] +.I FILE +.RS 4 +Extract node positions from a .nod graph into a .map populated with +corresponding info_node entities. +.br +Links between nodes are represented using target/targetname, nodes are +duplicated to allow showing all links, TrenchBroom layers are used to separate +links by hull type. The resulting .map file is not for engine consumption, only +for TrenchBroom-assisted archaeology. +.IP "-\-input-format \fIFORMAT\fR" 4 +Parse the .nod file using a different node graph format instead of using the +PC release format. +\fIFORMAT\fR can be any one of: +.RS 8 +\(bu +\fIvalve\fR\ Standard Half-Life node graph (default). +.br +\(bu +\fIdecay\fR\ PlayStation 2 release of Half-Life: Decay. +.RE +.IP -\-original-positions 4 +Use the node positions as they were set in the original .map instead of their +position after being dropped to the ground during graph generation. +.RE +.\" +.PP +.\" }}} +.\" {{{ .SH SPR MANIPULATION .B goldutil spr create diff --git a/main.go b/main.go index f690899..4aaa612 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "path/filepath" "runtime" "sort" + "strconv" "strings" "github.com/urfave/cli/v2" @@ -45,6 +46,24 @@ func newApp() *cli.App { Name: "help", Action: doHelp, }, + { + Name: "nod", + Subcommands: []*cli.Command{ + { + Name: "export", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "original-positions", + }, + &cli.StringFlag{ + Name: "input-format", + Value: "valve", + }, + }, + Action: doNodExport, + }, + }, + }, { Name: "map", Subcommands: []*cli.Command{ @@ -466,3 +485,66 @@ func doHelp(cCtx *cli.Context) error { return nil } + +func doNodExport(cCtx *cli.Context) error { + format, ok := map[string]goldsrc.NodeFormat{ + "valve": goldsrc.NodeFormatValve, + "decay": goldsrc.NodeFormatDecay, + }[cCtx.String("input-format")] + if !ok { + return errors.New("unrecognize .nod format") + } + + f, err := os.Open(cCtx.Args().Get(0)) + if err != nil { + return fmt.Errorf("unable to open file for reading: %w", err) + } + defer f.Close() + + nodes, links, err := goldsrc.ReadNodes(f, format) + if err != nil { + return fmt.Errorf("unable to read nodes: %w", err) + } + + var ( + original = cCtx.Bool("original-positions") + entities = make([]qmap.Entity, 0, len(nodes)+len(links)) + ) + + for i, v := range nodes { + entities = append(entities, qmap.NewEntity(map[string]string{ + qmap.KClass: v.ClassName(), + qmap.KOrigin: v.Position(original).String(), + qmap.KName: fmt.Sprintf("node#%d", i), + })) + } + + for linkTypeBitID := 0; linkTypeBitID <= goldsrc.LinkTypeBitMax; linkTypeBitID++ { + entities = append(entities, qmap.NewEntity(map[string]string{ + qmap.KClass: "func_group", + "_tb_type": "_tb_layer", + "_tb_name": fmt.Sprintf("hull#%d links (%s)", linkTypeBitID, goldsrc.LinkTypeName(linkTypeBitID)), + "_tb_id": strconv.Itoa(linkTypeBitID + 1), + "_tb_layer_sort_index": strconv.Itoa(linkTypeBitID + 1), + })) + + for _, v := range links { + if (v.LinkInfo & (1 << linkTypeBitID)) == 0 { + continue + } + + src := entities[v.SrcNode] + src.SetProperty("target", fmt.Sprintf("node#%d", v.DstNode)) + src.SetProperty("_tb_layer", strconv.Itoa(linkTypeBitID+1)) + entities = append(entities, src) + } + } + + var out qmap.QMap + for _, v := range entities { + out.AddEntity(v) + } + fmt.Println(out.String()) + + return nil +} diff --git a/qmap/entity.go b/qmap/entity.go index ef61e43..34a5f28 100644 --- a/qmap/entity.go +++ b/qmap/entity.go @@ -30,6 +30,20 @@ type Entity struct { keyLookup map[string]string } +func NewEntity(props map[string]string) Entity { + var ent Entity + for k, v := range props { + ent.addProperty(Property{ + key: k, + value: v, + }) + } + + ent.finalize() + + return ent +} + type Property struct { line int key, value string @@ -83,6 +97,22 @@ func (e *Entity) addProperty(p Property) { e.props = append(e.props, p) } +func (e *Entity) SetProperty(k, v string) { + e.keyLookup[k] = v + + for i := range e.props { + if e.props[i].key == k { + e.props[i].value = v + return + } + } + + e.addProperty(Property{ + key: k, + value: v, + }) +} + func (e *Entity) RemoveProperty(key string) { var filtered = make([]Property, 0, len(e.props)) for _, v := range e.props {