diff --git a/Taskfile.yml b/Taskfile.yml index bf53218b..59aff3b0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -55,6 +55,22 @@ tasks: - task: build-parser - task: build-editor + package-extension: + desc: Package prebuilt extension commands into a .sdmmext archive + dir: tools/sdmmpack + requires: + vars: [EXTENSION_DIR, EXTENSION_OUTPUT] + cmds: + - go run . -source "{{.EXTENSION_DIR}}" -manifest "{{.EXTENSION_DIR}}/extension.json" -output "{{.EXTENSION_OUTPUT}}" + + build-extension: + desc: Build every command declared by an extension manifest and package it + dir: tools/sdmmpack + requires: + vars: [EXTENSION_DIR, EXTENSION_OUTPUT] + cmds: + - go run . -build -source "{{.EXTENSION_DIR}}" -manifest "{{.EXTENSION_DIR}}/extension.json" -output "{{.EXTENSION_OUTPUT}}" + run: deps: - build-parser diff --git a/extensionapi/extensionapi.go b/extensionapi/extensionapi.go new file mode 100644 index 00000000..cd280010 --- /dev/null +++ b/extensionapi/extensionapi.go @@ -0,0 +1,148 @@ +// Package extensionapi defines the RPC contract between StrongDMM and Go extensions. +package extensionapi + +import ( + "net/rpc" + + "github.com/hashicorp/go-plugin" +) + +const ( + ProtocolVersion = 1 + PluginName = "strongdmm.extension" + StageMap = "core.map" + StageSelection = "core.selection" + StageOverlays = "core.overlays" + + CapabilityRender = "render" + CapabilityContextMenu = "context-menu" +) + +type Color struct{ R, G, B float32 } +type AtomDefinition struct { + ID uint32 + Path string + Vars map[string]string +} +type Tile struct { + X, Y, Z int + Atoms []uint32 +} +type Map struct { + Width, Height, Levels int + Definitions []AtomDefinition + Tiles []Tile +} +type Vertex struct{ X, Y, R, G, B, A float32 } +type BilinearQuad struct { + X1, Y1, X2, Y2 float32 + SouthWest, SouthEast, NorthWest, NorthEast Color +} +type Mesh struct { + Vertices []Vertex + Indices []uint32 + Quads []BilinearQuad +} +type RenderCommand struct { + ID string + Kind string + Mesh Mesh +} +type RenderPass struct { + ID string + After, Before []string + Blend string + Commands []RenderCommand +} +type RenderPatch struct { + PassID string + Upsert []RenderCommand + Remove []string +} +type RenderUpdate struct { + Replace []RenderPass + Patches []RenderPatch + RemovePasses []string +} +type MapUpdate struct { + Width, Height, Levels int + Definitions []AtomDefinition + Tiles []Tile +} +type ContextTurf struct { + X, Y, Z int + Atoms []AtomDefinition +} +type ContextMenuRequest struct{ Turf ContextTurf } +type ContextMenuAction struct { + ID, Label string + Enabled bool +} +type ContextMenuResponse struct{ Actions []ContextMenuAction } +type ActionRequest struct { + ID string + Turf ContextTurf +} + +// Request is a versioned extension operation. Extensions ignore operations +// outside the capabilities they declare in their package manifest. +type Request struct { + Version int + Type string + MapID string + Revision uint64 + Settings map[string]bool + Map *Map + Update *MapUpdate + Render *RenderUpdate + Context *ContextMenuRequest + Action *ActionRequest + ContextMenu *ContextMenuResponse + Reason string +} + +// Response carries optional output for a request. Render and context-menu +// operations use their corresponding fields; other operations may return ack. +type Response = Request + +// Extension handles versioned requests for its declared capabilities. +type Extension interface { + Handle(Request) (Response, error) +} + +// HandshakeConfig prevents StrongDMM from connecting to an unrelated process. +var HandshakeConfig = plugin.HandshakeConfig{ + ProtocolVersion: ProtocolVersion, + MagicCookieKey: "STRONGDMM_EXTENSION", + MagicCookieValue: "strongdmm-extension-v1", +} + +// Plugin adapts an Extension to HashiCorp go-plugin's net/rpc transport. +type Plugin struct { + plugin.NetRPCUnsupportedPlugin + Impl Extension +} + +func (p *Plugin) Server(*plugin.MuxBroker) (interface{}, error) { + return &rpcServer{Impl: p.Impl}, nil +} + +func (p *Plugin) Client(_ *plugin.MuxBroker, client *rpc.Client) (interface{}, error) { + return &rpcClient{client: client}, nil +} + +type rpcClient struct{ client *rpc.Client } + +func (c *rpcClient) Handle(request Request) (Response, error) { + var response Response + err := c.client.Call("Plugin.Handle", request, &response) + return response, err +} + +type rpcServer struct{ Impl Extension } + +func (s *rpcServer) Handle(request Request, response *Response) error { + result, err := s.Impl.Handle(request) + *response = result + return err +} diff --git a/extensionapi/go.mod b/extensionapi/go.mod new file mode 100644 index 00000000..a5c0bd23 --- /dev/null +++ b/extensionapi/go.mod @@ -0,0 +1,22 @@ +module github.com/SpaiR/StrongDMM-extension-api + +go 1.24.0 + +require github.com/hashicorp/go-plugin v1.6.3 + +require ( + github.com/fatih/color v1.7.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/go-hclog v0.14.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.4 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.0.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/extensionapi/go.sum b/extensionapi/go.sum new file mode 100644 index 00000000..f4b9ac76 --- /dev/null +++ b/extensionapi/go.sum @@ -0,0 +1,54 @@ +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 00000000..c3d8ee33 --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,47 @@ +# StrongDMM extensions + +Extensions are Go executables via go-plugin to extend the editor in different ways. + +```json +{ + "id": "example.extension", + "name": "Example Extension", + "apiVersion": 1, + "capabilities": ["render", "context-menu"], + "commands": { + "windows-amd64": "bin/windows-amd64/example.exe" + } +} +``` + +Implement `extensionapi.Extension` and call `plugin.Serve` with +`extensionapi.HandshakeConfig` and an `extensionapi.Plugin`. The API exposes +versioned `Request` values and request-shaped responses. Rendering receives map +open/update/close operations, revisions, settings, and render-pass output. The +declared capabilities determine which operations an extension receives: +`render` receives map lifecycle operations and `context-menu` receives requests +for dynamic actions at a turf. Standard output is reserved for go-plugin's +handshake and transport; write extension logs to standard error. + +Context-menu requests provide the clicked turf and its atom definitions. Return +actions with stable extension-local IDs; StrongDMM returns the selected ID in a +context action request. Extensions can ignore request types outside their +declared capabilities. + +`RenderPass` IDs are globally unique. Passes can be placed relative to +`core.map`, `core.selection`, and `core.overlays`; invalid dependencies and +cycles are logged. `mesh` commands support RGBA vertex colors; `bilinear` +commands are intentionally opaque and support normal, multiply, or additive +blending. + +Build and package an extension with StrongDMM's generic Task: + +```text +task --taskfile path/to/StrongDMM/Taskfile.yml build-extension EXTENSION_DIR=path/to/extension EXTENSION_OUTPUT=path/to/extension/dist/extension.sdmmext +``` + +The task cross-builds every command declared in `extension.json` and packages +the resulting `.sdmmext` archive. + +Extension API versions must match the host. Build and package extensions only +from trusted source. diff --git a/extensions/example/extension.json b/extensions/example/extension.json new file mode 100644 index 00000000..a2b8c0f0 --- /dev/null +++ b/extensions/example/extension.json @@ -0,0 +1,14 @@ +{ + "id": "example.extension", + "name": "Example Extension", + "apiVersion": 1, + "capabilities": ["render"], + "commands": { + "windows-amd64": "bin/windows-amd64/example-extension.exe", + "linux-amd64": "bin/linux-amd64/example-extension", + "darwin-arm64": "bin/darwin-arm64/example-extension" + }, + "actions": [ + {"id": "enabled", "menu": "Extensions", "label": "Enable Example Extension", "kind": "toggle-enabled"} + ] +} diff --git a/go.mod b/go.mod index f8b2e69a..4b479b4d 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module sdmm go 1.24.0 require ( + github.com/SpaiR/StrongDMM-extension-api v0.0.0 github.com/SpaiR/imgui-go v1.12.1-0.20220214190844-a0bad21e1c5d github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a github.com/go-gl/mathgl v1.2.0 + github.com/hashicorp/go-hclog v0.14.1 + github.com/hashicorp/go-plugin v1.6.3 github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f github.com/mazznoer/csscolorparser v0.1.6 github.com/minio/selfupdate v0.6.0 @@ -17,17 +20,28 @@ require ( golang.design/x/clipboard v0.7.1 ) +replace github.com/SpaiR/StrongDMM-extension-api => ./extensionapi + require ( aead.dev/minisign v0.2.0 // indirect github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.7.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect golang.org/x/image v0.28.0 // indirect golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.36.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6fc0b259..4b7b459c 100644 --- a/go.sum +++ b/go.sum @@ -5,10 +5,14 @@ github.com/SpaiR/imgui-go v1.12.1-0.20220214190844-a0bad21e1c5d h1:QajPUffv+BBqN github.com/SpaiR/imgui-go v1.12.1-0.20220214190844-a0bad21e1c5d/go.mod h1:bzOQqDbRE58xRB4EGTlUrIhCTWxNvU7YtJckrFf+HpM= github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= @@ -16,10 +20,27 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2 github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f h1:B0OD7nYl2FPQEVrw8g2uyc1lGEzNbvrKh7fspGZcbvY= github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f/go.mod h1:aEt7p9Rvh67BYApmZwNDPpgircTO2kgdmDUoF/1QmwA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -27,6 +48,8 @@ github.com/mazznoer/csscolorparser v0.1.6 h1:uK6p5zBA8HaQZJSInHgHVmkVBodUAy+6snS github.com/mazznoer/csscolorparser v0.1.6/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -38,6 +61,7 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:s github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -61,8 +85,12 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -77,8 +105,19 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/action.go b/internal/app/action.go index 0a917b74..42918d91 100644 --- a/internal/app/action.go +++ b/internal/app/action.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "sdmm/internal/app/extensions" "sdmm/internal/app/prefs" "sdmm/internal/app/render" "sdmm/internal/app/ui/cpwsarea/wsmap" @@ -236,6 +237,16 @@ func (a *app) Prefs() prefs.Prefs { return a.preferencesConfig().Prefs } +// Extensions returns the manager for installed extensions. +func (a *app) Extensions() *extensions.Manager { return a.extensions } + +func (a *app) ExtensionMenuActions() []extensions.MenuAction { + if a.extensions == nil { + return nil + } + return a.extensions.MenuActions() +} + // FocusApplicationWindow explicitly moves an OS focus to the current application window. func (a *app) FocusApplicationWindow() { a.masterWindow.Handle().Focus() diff --git a/internal/app/action_user.go b/internal/app/action_user.go index e9d717db..7748d6c2 100644 --- a/internal/app/action_user.go +++ b/internal/app/action_user.go @@ -333,6 +333,16 @@ func (a *app) DoMirrorCanvasCamera() { log.Print("do mirror canvas camera:", pmap.MirrorCanvasCamera) } +// DoExtensionMenuAction executes a extensions menu action (defined by the extension) +func (a *app) DoExtensionMenuAction(id string) { + if a.extensions == nil { + return + } + a.extensions.ExecuteMenuAction(id) + a.layout.WsArea.RefreshExtensions() + log.Print("do extension action:", id) +} + // DoSelfUpdate starts the process of a self update. func (a *app) DoSelfUpdate() { log.Print("do self update") diff --git a/internal/app/app.go b/internal/app/app.go index 4e476071..d4fd8f5b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,6 +10,7 @@ import ( "sdmm/internal/app/command" "sdmm/internal/app/config" + "sdmm/internal/app/extensions" "sdmm/internal/app/render/brush" "sdmm/internal/app/ui/dialog" "sdmm/internal/app/ui/layout" @@ -49,11 +50,13 @@ func Start() { } a := app{ - internalDir: internalDir, - logDir: logDir, - backupDir: filepath.FromSlash(internalDir + "/backup"), - configDir: filepath.FromSlash(internalDir + "/config"), + internalDir: internalDir, + logDir: logDir, + backupDir: filepath.FromSlash(internalDir + "/backup"), + configDir: filepath.FromSlash(internalDir + "/config"), + extensionsDir: filepath.FromSlash(internalDir + "/extensions"), } + _ = os.MkdirAll(a.extensionsDir, os.ModePerm) a.masterWindow = window.New(&a) @@ -73,10 +76,11 @@ func Start() { type app struct { masterWindow *window.Window - internalDir string - logDir string - backupDir string - configDir string + internalDir string + logDir string + backupDir string + configDir string + extensionsDir string tmpShouldClose bool tmpWindowCond imgui.Condition @@ -96,8 +100,9 @@ type app struct { commandStorage *command.Storage clipboard *dmmclip.Clipboard - menu *menu.Menu - layout *layout.Layout + menu *menu.Menu + layout *layout.Layout + extensions *extensions.Manager } func (a *app) initialize() { @@ -106,6 +111,7 @@ func (a *app) initialize() { a.loadConfig() a.loadProjectConfig() + a.extensions = extensions.New(a.projectConfig().ExtensionState, a.extensionsDir) a.loadPreferencesConfig() a.runBackgroundConfigSave() @@ -132,6 +138,9 @@ func (a *app) initialize() { } func (a *app) Process() { + if a.extensions != nil { + a.extensions.Process() + } if a.shortcutsEnabled { shortcut.Process() } @@ -164,6 +173,9 @@ func (a *app) LayoutIniPath() string { } func (a *app) dispose() { + if a.extensions != nil { + a.extensions.Dispose() + } brush.Dispose() a.configSave() a.masterWindow.Dispose() diff --git a/internal/app/config_project.go b/internal/app/config_project.go index 7f071b59..16c23828 100644 --- a/internal/app/config_project.go +++ b/internal/app/config_project.go @@ -4,6 +4,7 @@ import ( "errors" "os" + "sdmm/internal/app/extensions" "sdmm/internal/util/slice" "github.com/rs/zerolog/log" @@ -11,14 +12,15 @@ import ( const ( projectConfigName = "project" - projectConfigVersion = 2 + projectConfigVersion = 3 ) type projectConfig struct { Version uint - Projects []string - Maps []string + Projects []string + Maps []string + ExtensionState *extensions.State } func (projectConfig) Name() string { @@ -44,7 +46,11 @@ func (projectConfig) TryMigrate(cfg map[string]any) (result map[string]any, migr result["Version"] = 2 migrated = true } - + if uint(result["Version"].(float64)) == 2 { + result["ExtensionState"] = map[string]any{} + result["Version"] = 3 + migrated = true + } return result, migrated } @@ -79,10 +85,14 @@ func (cfg *projectConfig) RemoveMap(mapPath string) { func (a *app) loadProjectConfig() { config := &projectConfig{ - Version: projectConfigVersion, + Version: projectConfigVersion, + ExtensionState: &extensions.State{}, } a.ConfigRegister(config) + if config.ExtensionState == nil { + config.ExtensionState = &extensions.State{} + } // Cleanup projects paths var pathsToRemove []string diff --git a/internal/app/extensions/command_other.go b/internal/app/extensions/command_other.go new file mode 100644 index 00000000..448ed05b --- /dev/null +++ b/internal/app/extensions/command_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package extensions + +import "os/exec" + +func extensionCommand(path string) *exec.Cmd { return exec.Command(path) } diff --git a/internal/app/extensions/command_windows.go b/internal/app/extensions/command_windows.go new file mode 100644 index 00000000..f5f6baa9 --- /dev/null +++ b/internal/app/extensions/command_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package extensions + +import ( + "os/exec" + "syscall" +) + +func extensionCommand(path string) *exec.Cmd { + command := exec.Command(path) + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return command +} diff --git a/internal/app/extensions/manager.go b/internal/app/extensions/manager.go new file mode 100644 index 00000000..4f5292ca --- /dev/null +++ b/internal/app/extensions/manager.go @@ -0,0 +1,615 @@ +package extensions + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + api "github.com/SpaiR/StrongDMM-extension-api" + "github.com/hashicorp/go-plugin" + "sdmm/internal/app/render" + "sdmm/internal/dmapi/dmmap" + "sdmm/internal/dmapi/dmvars" + "sdmm/internal/util" + + "github.com/rs/zerolog/log" +) + +type State struct{ Extensions map[string]*ExtensionState } +type ExtensionState struct { + Disabled bool + Preview bool + Settings map[string]bool +} +type MenuAction struct { + ID, Menu, Label string + Enabled, Selected bool +} +type ContextMenuAction struct { + ID, Label string + Enabled bool +} + +type Manager struct { + state *State + clients []*client + documents map[*dmmap.Dmm]*document +} + +type document struct { + id string + revision uint64 + opened bool + definitions map[string]uint32 + atoms map[uint32]api.AtomDefinition + nextDefinition uint32 + renderers map[*render.Render]struct{} + outputs map[*client]map[string]api.RenderPass + applied map[*client]uint64 + newDefinitions []api.AtomDefinition +} + +func New(state *State, extensionsDir string) *Manager { + if state.Extensions == nil { + state.Extensions = make(map[string]*ExtensionState) + } + m := &Manager{state: state, clients: discover(extensionsDir), documents: make(map[*dmmap.Dmm]*document)} + for _, client := range m.clients { + m.stateFor(client) + client.startWorker() + } + return m +} + +func (m *Manager) stateFor(client *client) *ExtensionState { + if state := m.state.Extensions[client.manifest.ID]; state != nil { + return state + } + state := &ExtensionState{Preview: true} + m.state.Extensions[client.manifest.ID] = state + return state +} +func (m *Manager) enabled(client *client) bool { return !m.stateFor(client).Disabled } +func (m *Manager) preview(client *client) bool { return m.stateFor(client).Preview } +func (m *Manager) settings(client *client) map[string]bool { + settings := make(map[string]bool) + for id, value := range m.stateFor(client).Settings { + settings[id] = value + } + return settings +} + +func (m *Manager) MenuActions() []MenuAction { + actions := make([]MenuAction, 0) + for _, client := range m.clients { + for _, action := range client.manifest.Actions { + selected, enabled := false, true + switch action.Kind { + case "toggle-enabled": + selected = m.enabled(client) + case "toggle-preview": + selected, enabled = m.preview(client), m.enabled(client) + case "toggle": + selected, enabled = m.stateFor(client).Settings[action.ID], m.enabled(client) + default: + continue + } + actions = append(actions, MenuAction{ID: client.manifest.ID + ":" + action.ID, Menu: action.Menu, Label: action.Label, Enabled: enabled, Selected: selected}) + } + } + return actions +} + +func (m *Manager) ExecuteMenuAction(id string) { + for _, client := range m.clients { + for _, action := range client.manifest.Actions { + if id != client.manifest.ID+":"+action.ID { + continue + } + state := m.stateFor(client) + switch action.Kind { + case "toggle-enabled": + state.Disabled = !state.Disabled + case "toggle-preview": + state.Preview = !state.Preview + case "toggle": + if state.Settings == nil { + state.Settings = make(map[string]bool) + } + state.Settings[action.ID] = !state.Settings[action.ID] + } + m.refreshClient(client) + return + } + } +} + +// ContextMenuActions returns dynamic actions contributed for one turf. +func (m *Manager) ContextMenuActions(dmm *dmmap.Dmm, point util.Point) []ContextMenuAction { + doc := m.document(dmm) + turf := m.contextTurf(dmm.GetTile(point)) + actions := make([]ContextMenuAction, 0) + for _, client := range m.clients { + if !m.enabled(client) || !client.hasCapability(api.CapabilityContextMenu) { + continue + } + response, err := client.extension.Handle(api.Request{Version: api.ProtocolVersion, Type: "context.menu", MapID: doc.id, Revision: doc.revision, Context: &api.ContextMenuRequest{Turf: turf}}) + if err != nil || response.ContextMenu == nil { + if err != nil { + log.Warn().Err(err).Str("extension", client.manifest.ID).Msg("extension context menu request failed") + } + continue + } + for _, action := range response.ContextMenu.Actions { + if action.ID == "" || action.Label == "" { + continue + } + actions = append(actions, ContextMenuAction{ID: client.manifest.ID + ":" + action.ID, Label: action.Label, Enabled: action.Enabled}) + } + } + return actions +} + +// ExecuteContextMenuAction notifies the extension that supplied an action. +func (m *Manager) ExecuteContextMenuAction(dmm *dmmap.Dmm, point util.Point, id string) { + doc := m.document(dmm) + for _, client := range m.clients { + prefix := client.manifest.ID + ":" + if !m.enabled(client) || !client.hasCapability(api.CapabilityContextMenu) || len(id) <= len(prefix) || id[:len(prefix)] != prefix { + continue + } + response, err := client.extension.Handle(api.Request{Version: api.ProtocolVersion, Type: "context.action", MapID: doc.id, Revision: doc.revision, Action: &api.ActionRequest{ID: id[len(prefix):], Turf: m.contextTurf(dmm.GetTile(point))}}) + if err != nil { + log.Warn().Err(err).Str("extension", client.manifest.ID).Msg("extension context action failed") + return + } + m.apply(client, workerResult{message: api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render}}) + return + } +} + +func (m *Manager) ConfigureRender(renderer *render.Render, dmm *dmmap.Dmm) { + doc := m.document(dmm) + doc.renderers[renderer] = struct{}{} + renderer.SetExtensionPasses(m.extensionPasses(doc)) + if !doc.opened { + m.queueOpen(doc, dmm) + } +} + +func (m *Manager) RefreshRender(renderer *render.Render, dmm *dmmap.Dmm) { + m.ConfigureRender(renderer, dmm) + m.queueOpen(m.document(dmm), dmm) +} + +// ReleaseRender disconnects a canvas from its map's extension output. The +// extension receives map.close only after the last canvas using the map closes. +func (m *Manager) ReleaseRender(renderer *render.Render, dmm *dmmap.Dmm) { + doc := m.documents[dmm] + if doc == nil { + return + } + delete(doc.renderers, renderer) + if len(doc.renderers) != 0 { + return + } + m.closeDocument(dmm, doc) +} + +// ReplaceRender swaps a canvas renderer without interrupting the extension's +// map session, for example while recreating a canvas after a resize. +func (m *Manager) ReplaceRender(previous, next *render.Render, dmm *dmmap.Dmm) { + doc := m.documents[dmm] + if doc == nil { + m.ConfigureRender(next, dmm) + return + } + delete(doc.renderers, previous) + doc.renderers[next] = struct{}{} + next.SetExtensionPasses(m.extensionPasses(doc)) +} + +func (m *Manager) NotifyChanged(dmm *dmmap.Dmm, points []util.Point) { + doc := m.document(dmm) + doc.revision++ + update := m.update(doc, dmm, points) + for _, client := range m.clients { + if m.enabled(client) && m.preview(client) && client.usesMap() { + client.queue(work{message: api.Request{Version: api.ProtocolVersion, Type: "map.update", MapID: doc.id, Revision: doc.revision, Update: &update}}) + } + } +} + +func (m *Manager) NotifyResize(dmm *dmmap.Dmm) { + doc := m.document(dmm) + doc.revision++ + m.queueOpen(doc, dmm) +} + +func (m *Manager) Process() { + for _, client := range m.clients { + for { + select { + case result := <-client.results: + m.apply(client, result) + default: + goto next + } + } + next: + } +} + +func (m *Manager) closeDocument(dmm *dmmap.Dmm, doc *document) { + for _, client := range m.clients { + client.queue(work{message: api.Request{Version: api.ProtocolVersion, Type: "map.close", MapID: doc.id, Revision: doc.revision}}) + } + delete(m.documents, dmm) +} +func (m *Manager) Dispose() { + for _, client := range m.clients { + client.close() + } +} + +func (m *Manager) document(dmm *dmmap.Dmm) *document { + if doc := m.documents[dmm]; doc != nil { + return doc + } + doc := &document{id: fmt.Sprintf("map-%p", dmm), definitions: make(map[string]uint32), atoms: make(map[uint32]api.AtomDefinition), nextDefinition: 1, renderers: make(map[*render.Render]struct{}), outputs: make(map[*client]map[string]api.RenderPass), applied: make(map[*client]uint64)} + m.documents[dmm] = doc + return doc +} + +func (m *Manager) queueOpen(doc *document, dmm *dmmap.Dmm) { + if doc.revision == 0 { + doc.revision = 1 + } + doc.opened = true + mapData := m.mapData(doc, dmm, nil) + for _, client := range m.clients { + if m.enabled(client) && m.preview(client) && client.usesMap() { + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Map: &mapData}}) + } + } +} + +func (m *Manager) refreshClient(client *client) { + for dmm, doc := range m.documents { + if !m.enabled(client) || !m.preview(client) || !client.usesMap() { + delete(doc.outputs, client) + m.render(doc) + continue + } + mapData := m.mapData(doc, dmm, nil) + client.queue(work{immediate: true, message: api.Request{Version: api.ProtocolVersion, Type: "map.open", MapID: doc.id, Revision: doc.revision, Settings: m.settings(client), Map: &mapData}}) + } +} + +func (m *Manager) apply(client *client, result workerResult) { + var doc *document + for _, candidate := range m.documents { + if candidate.id == result.message.MapID { + doc = candidate + break + } + } + if doc == nil { + return + } + if result.err != nil { + log.Error().Err(result.err).Str("extension", client.manifest.ID).Msg("extension request failed") + return + } + message := result.message + if message.Type == "resync.request" { + for dmm, candidate := range m.documents { + if candidate == doc { + m.queueOpen(doc, dmm) + return + } + } + } + if message.Type != "render.update" || message.Render == nil || message.Revision < doc.applied[client] { + return + } + passes := doc.outputs[client] + if passes == nil { + passes = make(map[string]api.RenderPass) + doc.outputs[client] = passes + } + if message.Render.Replace != nil { + passes = make(map[string]api.RenderPass) + for _, pass := range message.Render.Replace { + passes[pass.ID] = pass + } + doc.outputs[client] = passes + } + for _, passID := range message.Render.RemovePasses { + delete(passes, passID) + } + for _, patch := range message.Render.Patches { + pass, ok := passes[patch.PassID] + if !ok { + continue + } + commands := make(map[string]api.RenderCommand) + for _, command := range pass.Commands { + commands[command.ID] = command + } + for _, id := range patch.Remove { + delete(commands, id) + } + for _, command := range patch.Upsert { + commands[command.ID] = command + } + pass.Commands = pass.Commands[:0] + for _, command := range commands { + pass.Commands = append(pass.Commands, command) + } + sort.Slice(pass.Commands, func(i, j int) bool { return pass.Commands[i].ID < pass.Commands[j].ID }) + passes[pass.ID] = pass + } + doc.applied[client] = message.Revision + m.render(doc) +} + +func (m *Manager) render(doc *document) { + passes := m.extensionPasses(doc) + for renderer := range doc.renderers { + renderer.SetExtensionPasses(passes) + } +} + +// extensionPasses produces a stable global pass list. Client discovery is +// already sorted by extension ID; sorting each client's pass IDs keeps output +// stable even though they are stored in a map. +func (m *Manager) extensionPasses(doc *document) []api.RenderPass { + passes := make([]api.RenderPass, 0) + seen := make(map[string]struct{}) + for _, client := range m.clients { + clientPasses := doc.outputs[client] + ids := make([]string, 0, len(clientPasses)) + for id := range clientPasses { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + pass := clientPasses[id] + if _, duplicate := seen[pass.ID]; duplicate { + log.Warn().Str("extension", client.manifest.ID).Str("pass", pass.ID).Msg("ignoring duplicate extension render pass") + continue + } + seen[pass.ID] = struct{}{} + passes = append(passes, pass) + } + } + return passes +} + +func (m *Manager) mapData(doc *document, dmm *dmmap.Dmm, points []util.Point) api.Map { + mapData := api.Map{Width: dmm.MaxX, Height: dmm.MaxY, Levels: dmm.MaxZ} + full := points == nil + doc.newDefinitions = nil + if points == nil { + points = make([]util.Point, 0, len(dmm.Tiles)) + for _, tile := range dmm.Tiles { + points = append(points, tile.Coord) + } + } + for _, point := range points { + mapData.Tiles = append(mapData.Tiles, m.tile(doc, dmm.GetTile(point))) + } + if full { + for _, atom := range doc.atoms { + mapData.Definitions = append(mapData.Definitions, atom) + } + sort.Slice(mapData.Definitions, func(i, j int) bool { return mapData.Definitions[i].ID < mapData.Definitions[j].ID }) + } else { + mapData.Definitions = doc.newDefinitions + } + return mapData +} +func (m *Manager) update(doc *document, dmm *dmmap.Dmm, points []util.Point) api.MapUpdate { + mapData := m.mapData(doc, dmm, points) + return api.MapUpdate{Width: mapData.Width, Height: mapData.Height, Levels: mapData.Levels, Definitions: mapData.Definitions, Tiles: mapData.Tiles} +} +func (m *Manager) tile(doc *document, tile *dmmap.Tile) api.Tile { + out := api.Tile{X: tile.Coord.X, Y: tile.Coord.Y, Z: tile.Coord.Z} + for _, instance := range tile.Instances() { + vars := make(map[string]string) + prefabVars := instance.Prefab().Vars() + keys := resolvedVariableNames(prefabVars) + sort.Strings(keys) + for _, key := range keys { + if value, ok := prefabVars.Value(key); ok { + vars[key] = value + } + } + key := instance.Prefab().Path() + for _, name := range keys { + key += "\x00" + name + "=" + vars[name] + } + id := doc.definitions[key] + if id == 0 { + id = doc.nextDefinition + doc.nextDefinition++ + doc.definitions[key] = id + atom := api.AtomDefinition{ID: id, Path: instance.Prefab().Path(), Vars: vars} + doc.atoms[id] = atom + doc.newDefinitions = append(doc.newDefinitions, atom) + out.Atoms = append(out.Atoms, id) + continue + } + out.Atoms = append(out.Atoms, id) + } + return out +} +func (m *Manager) contextTurf(tile *dmmap.Tile) api.ContextTurf { + turf := api.ContextTurf{X: tile.Coord.X, Y: tile.Coord.Y, Z: tile.Coord.Z} + for _, instance := range tile.Instances() { + vars := make(map[string]string) + prefabVars := instance.Prefab().Vars() + for _, key := range resolvedVariableNames(prefabVars) { + if value, ok := prefabVars.Value(key); ok { + vars[key] = value + } + } + turf.Atoms = append(turf.Atoms, api.AtomDefinition{Path: instance.Prefab().Path(), Vars: vars}) + } + return turf +} + +func resolvedVariableNames(vars *dmvars.Variables) []string { + names := make(map[string]struct{}) + for current := vars; current != nil; current = current.Parent() { + for _, name := range current.Iterate() { + names[name] = struct{}{} + } + } + keys := make([]string, 0, len(names)) + for name := range names { + keys = append(keys, name) + } + return keys +} + +type manifest struct { + ID string `json:"id"` + Name string `json:"name"` + APIVersion int `json:"apiVersion"` + Commands map[string]string `json:"commands"` + Capabilities []string `json:"capabilities"` + Actions []manifestAction `json:"actions"` +} +type manifestAction struct{ ID, Menu, Label, Kind string } +type work struct { + message api.Request + immediate bool +} +type workerResult struct { + message api.Request + err error +} +type client struct { + manifest manifest + process *plugin.Client + extension api.Extension + jobs chan work + results chan workerResult + stop chan struct{} +} + +func (c *client) hasCapability(capability string) bool { + for _, candidate := range c.manifest.Capabilities { + if candidate == capability { + return true + } + } + return false +} +func (c *client) usesMap() bool { + return c.hasCapability(api.CapabilityRender) || c.hasCapability(api.CapabilityContextMenu) +} + +func (c *client) startWorker() { + c.jobs = make(chan work, 128) + c.results = make(chan workerResult, 128) + c.stop = make(chan struct{}) + go c.run() +} +func (c *client) queue(work work) { + select { + case c.jobs <- work: + default: + log.Warn().Str("extension", c.manifest.ID).Msg("extension update queue full") + } +} +func (c *client) close() { + select { + case <-c.stop: + default: + close(c.stop) + } + if c.process != nil { + c.process.Kill() + } +} +func (c *client) run() { + pending := make(map[string]work) + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + for { + select { + case <-c.stop: + return + case work := <-c.jobs: + if work.immediate || work.message.Type == "map.close" { + c.execute(work) + continue + } + pending[work.message.MapID] = merge(pending[work.message.MapID], work) + timer.Reset(50 * time.Millisecond) + case <-timer.C: + for id, work := range pending { + delete(pending, id) + c.execute(work) + } + } + } +} +func merge(previous, next work) work { + if previous.message.Type == "" { + return next + } + if previous.message.Type != "map.update" || next.message.Type != "map.update" { + return next + } + tiles := make(map[string]api.Tile) + for _, tile := range previous.message.Update.Tiles { + tiles[fmt.Sprintf("%d/%d/%d", tile.X, tile.Y, tile.Z)] = tile + } + for _, tile := range next.message.Update.Tiles { + tiles[fmt.Sprintf("%d/%d/%d", tile.X, tile.Y, tile.Z)] = tile + } + next.message.Update.Tiles = next.message.Update.Tiles[:0] + for _, tile := range tiles { + next.message.Update.Tiles = append(next.message.Update.Tiles, tile) + } + next.message.Update.Definitions = append(previous.message.Update.Definitions, next.message.Update.Definitions...) + return next +} +func (c *client) execute(work work) { + message, err := c.request(work.message) + if err != nil { + c.results <- workerResult{message: work.message, err: err} + return + } + c.results <- workerResult{message: message} +} +func (c *client) request(message api.Request) (api.Request, error) { + response, err := c.extension.Handle(message) + return api.Request{Version: response.Version, Type: response.Type, MapID: response.MapID, Revision: response.Revision, Render: response.Render, Reason: response.Reason}, err +} + +func discover(root string) []*client { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + clients := make([]*client, 0) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".sdmmext" { + continue + } + if client := newPackageClient(filepath.Join(root, entry.Name()), filepath.Join(root, ".cache")); client != nil { + clients = append(clients, client) + } + } + sort.Slice(clients, func(i, j int) bool { return clients[i].manifest.ID < clients[j].manifest.ID }) + return clients +} diff --git a/internal/app/extensions/package.go b/internal/app/extensions/package.go new file mode 100644 index 00000000..aa2d60d9 --- /dev/null +++ b/internal/app/extensions/package.go @@ -0,0 +1,147 @@ +package extensions + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + api "github.com/SpaiR/StrongDMM-extension-api" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" + "github.com/rs/zerolog/log" +) + +// newPackageClient opens a .sdmmext ZIP package, extracts it to a content- +// addressed cache directory, and starts its command for this platform. +func newPackageClient(packagePath, cacheRoot string) *client { + hash, err := fileHash(packagePath) + if err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("unable to read extension package") + return nil + } + dir := filepath.Join(cacheRoot, hash) + manifestPath := filepath.Join(dir, "extension.json") + if _, err = os.Stat(manifestPath); err != nil { + if err = extractPackage(packagePath, dir); err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("unable to extract extension package") + return nil + } + } + data, err := os.ReadFile(manifestPath) + if err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("unable to read extension manifest") + return nil + } + var manifest manifest + if json.Unmarshal(data, &manifest) != nil || manifest.ID == "" || manifest.APIVersion != api.ProtocolVersion { + log.Warn().Str("package", packagePath).Msg("extension manifest is invalid or incompatible") + return nil + } + commandPath := manifest.Commands[runtime.GOOS+"-"+runtime.GOARCH] + if commandPath == "" || filepath.IsAbs(commandPath) || filepath.Dir(commandPath) == "." { + log.Warn().Str("package", packagePath).Msg("extension has no command for this platform") + return nil + } + binary := filepath.Join(dir, commandPath) + if relative, err := filepath.Rel(dir, binary); err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + log.Warn().Str("package", packagePath).Msg("extension command path escapes its package") + return nil + } + if _, err = os.Stat(binary); err != nil { + log.Warn().Err(err).Str("package", packagePath).Msg("extension command is missing") + return nil + } + process := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: api.HandshakeConfig, + Plugins: plugin.PluginSet{api.PluginName: &api.Plugin{}}, + Cmd: extensionCommand(binary), + AllowedProtocols: []plugin.Protocol{plugin.ProtocolNetRPC}, + Logger: hclog.New(&hclog.LoggerOptions{Name: manifest.ID, Output: os.Stderr}), + }) + rpcClient, err := process.Client() + if err != nil { + log.Warn().Err(err).Str("extension", manifest.ID).Msg("unable to start extension") + process.Kill() + return nil + } + raw, err := rpcClient.Dispense(api.PluginName) + if err != nil { + log.Warn().Err(err).Str("extension", manifest.ID).Msg("extension did not provide its service") + process.Kill() + return nil + } + extension, ok := raw.(api.Extension) + if !ok { + log.Warn().Str("extension", manifest.ID).Msg("extension returned an incompatible service") + process.Kill() + return nil + } + log.Info().Str("extension", manifest.ID).Str("package", packagePath).Msg("extension loaded") + return &client{manifest: manifest, process: process, extension: extension} +} + +func fileHash(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func extractPackage(packagePath, destination string) error { + archive, err := zip.OpenReader(packagePath) + if err != nil { + return err + } + defer archive.Close() + for _, entry := range archive.File { + entryPath := filepath.FromSlash(strings.ReplaceAll(entry.Name, "\\", "/")) + if filepath.IsAbs(entryPath) { + continue + } + path := filepath.Join(destination, entryPath) + relative, relErr := filepath.Rel(destination, path) + if relErr != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + continue + } + if entry.FileInfo().IsDir() { + if err = os.MkdirAll(path, os.ModePerm); err != nil { + return err + } + continue + } + if err = os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + input, err := entry.Open() + if err != nil { + return err + } + output, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, entry.Mode()) + if err != nil { + input.Close() + return err + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + input.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} diff --git a/internal/app/render/brush/batch.go b/internal/app/render/brush/batch.go index e2ae974c..02380aab 100644 --- a/internal/app/render/brush/batch.go +++ b/internal/app/render/brush/batch.go @@ -21,6 +21,9 @@ type Batching struct { offset int } +// Vertex is the internal representation of generic untextured mesh data. +type Vertex struct{ X, Y, R, G, B, A float32 } + func (b *Batching) flush() { if b.len != 0 && len(b.indices) > 0 { b.calls = append(b.calls, batchCall{ diff --git a/internal/app/render/brush/bilinear.go b/internal/app/render/brush/bilinear.go new file mode 100644 index 00000000..06f65674 --- /dev/null +++ b/internal/app/render/brush/bilinear.go @@ -0,0 +1,47 @@ +package brush + +import ( + "sdmm/internal/platform" + + "github.com/go-gl/gl/v3.3-core/gl" +) + +type Color struct{ R, G, B float32 } + +type BilinearQuad struct { + X1, Y1, X2, Y2 float32 + SouthWest, SouthEast, NorthWest, NorthEast Color +} + +func BilinearQuads(quads []BilinearQuad, w, h, x, y, z float32) { + if len(quads) == 0 { + return + } + data := make([]float32, 0, len(quads)*4*16) + indices := make([]uint32, 0, len(quads)*6) + for quadIndex, quad := range quads { + base := uint32(quadIndex * 4) + for _, vertex := range [][4]float32{{quad.X1, quad.Y1, 0, 0}, {quad.X2, quad.Y1, 1, 0}, {quad.X1, quad.Y2, 0, 1}, {quad.X2, quad.Y2, 1, 1}} { + data = append(data, vertex[0], vertex[1], + quad.SouthWest.R, quad.SouthWest.G, quad.SouthWest.B, + quad.SouthEast.R, quad.SouthEast.G, quad.SouthEast.B, + quad.NorthWest.R, quad.NorthWest.G, quad.NorthWest.B, + quad.NorthEast.R, quad.NorthEast.G, quad.NorthEast.B, + vertex[2], vertex[3]) + } + indices = append(indices, base, base+1, base+2, base+1, base+3, base+2) + } + gl.UseProgram(bilinearProgram) + gl.BindVertexArray(bilinearVAO) + mtx := transformationMatrix(w, h, x, y, z) + gl.UniformMatrix4fv(bilinearTransform, 1, false, &mtx[0]) + gl.BindBuffer(gl.ARRAY_BUFFER, bilinearVBO) + gl.BufferData(gl.ARRAY_BUFFER, len(data)*platform.FloatSize, gl.Ptr(data), gl.STREAM_DRAW) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, bilinearEBO) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(indices)*4, gl.Ptr(indices), gl.STREAM_DRAW) + gl.DrawElements(gl.TRIANGLES, int32(len(indices)), gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, 0) + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + gl.UseProgram(0) +} diff --git a/internal/app/render/brush/brush.go b/internal/app/render/brush/brush.go index 85e1865c..019c1b34 100644 --- a/internal/app/render/brush/brush.go +++ b/internal/app/render/brush/brush.go @@ -12,7 +12,8 @@ var ( attrsList attributesList - program uint32 + program uint32 + bilinearProgram uint32 vao uint32 vbo uint32 @@ -20,6 +21,10 @@ var ( uniformLocationTransform int32 uniformLocationHasTexture int32 + bilinearTransform int32 + bilinearVAO uint32 + bilinearVBO uint32 + bilinearEBO uint32 ) func TryInit() { @@ -50,6 +55,7 @@ func TryInit() { initShader(vertexShader(), fragmentShader()) initBuffers() initAttributes() + initBilinear() log.Print("initialized") } @@ -60,9 +66,36 @@ func Dispose() { gl.DeleteVertexArrays(1, &vao) gl.DeleteBuffers(1, &vbo) gl.DeleteBuffers(1, &ebo) + gl.DeleteVertexArrays(1, &bilinearVAO) + gl.DeleteBuffers(1, &bilinearVBO) + gl.DeleteBuffers(1, &bilinearEBO) log.Print("disposed") } +func initBilinear() { + var err error + if bilinearProgram, err = platform.NewShaderProgram(bilinearVertexShader(), bilinearFragmentShader()); err != nil { + log.Fatal().Msgf("unable to create bilinear shader: %v", err) + } + bilinearTransform = gl.GetUniformLocation(bilinearProgram, gl.Str("Transform\x00")) + gl.GenVertexArrays(1, &bilinearVAO) + gl.GenBuffers(1, &bilinearVBO) + gl.GenBuffers(1, &bilinearEBO) + gl.BindVertexArray(bilinearVAO) + gl.BindBuffer(gl.ARRAY_BUFFER, bilinearVBO) + const stride = 16 * platform.FloatSize + for index, offset := range []int32{0, 2, 5, 8, 11, 14} { + size := int32(3) + if index == 0 || index == 5 { + size = 2 + } + gl.EnableVertexAttribArray(uint32(index)) + gl.VertexAttribPointerWithOffset(uint32(index), size, gl.FLOAT, false, stride, uintptr(offset*platform.FloatSize)) + } + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) +} + func initShader(vertex, fragment string) { log.Print("initializing shader...") var err error diff --git a/internal/app/render/brush/shader.go b/internal/app/render/brush/shader.go index 48f112f8..169cea07 100644 --- a/internal/app/render/brush/shader.go +++ b/internal/app/render/brush/shader.go @@ -42,3 +42,46 @@ void main() { } ` + "\x00" } + +func bilinearVertexShader() string { + return ` +#version 330 core +uniform mat4 Transform; +layout (location = 0) in vec2 in_pos; +layout (location = 1) in vec3 in_south_west; +layout (location = 2) in vec3 in_south_east; +layout (location = 3) in vec3 in_north_west; +layout (location = 4) in vec3 in_north_east; +layout (location = 5) in vec2 in_uv; +flat out vec3 south_west; +flat out vec3 south_east; +flat out vec3 north_west; +flat out vec3 north_east; +out vec2 uv; +void main() { + south_west = in_south_west; + south_east = in_south_east; + north_west = in_north_west; + north_east = in_north_east; + uv = in_uv; + gl_Position = Transform * vec4(in_pos, 1, 1); +} +` + "\x00" +} + +func bilinearFragmentShader() string { + return ` +#version 330 core +flat in vec3 south_west; +flat in vec3 south_east; +flat in vec3 north_west; +flat in vec3 north_east; +in vec2 uv; +out vec4 outputColor; +void main() { + vec3 south = mix(south_west, south_east, uv.x); + vec3 north = mix(north_west, north_east, uv.x); + outputColor = vec4(mix(south, north, uv.y), 1.0); +} +` + "\x00" +} diff --git a/internal/app/render/brush/shape.go b/internal/app/render/brush/shape.go index 1a05be55..1548976f 100644 --- a/internal/app/render/brush/shape.go +++ b/internal/app/render/brush/shape.go @@ -50,6 +50,29 @@ func RectFilledV(x1, y1, x2, y2, r, g, b, a float32) { RectTexturedV(x1, y1, x2, y2, r, g, b, a, s.Texture(), s.U1, s.V1, s.U2, s.V2) } +// ColoredTriangles appends a vertex-coloured triangle mesh. +func ColoredTriangles(vertices []Vertex, indices []uint32) { + if len(vertices) == 0 || len(indices) == 0 { + return + } + s := dmicon.WhiteRect() + if batching.mode != mtRect || batching.texture != s.Texture() { + batching.flush() + } + batching.texture, batching.mode = s.Texture(), mtRect + for _, vertex := range vertices { + batching.data = append(batching.data, vertex.X, vertex.Y, vertex.R, vertex.G, vertex.B, vertex.A, s.U1, s.V1) + } + for _, index := range indices { + if index >= uint32(len(vertices)) { + continue + } + batching.indices = append(batching.indices, batching.idx+index) + batching.len++ + } + batching.idx += uint32(len(vertices)) +} + func Rect(x1, y1, x2, y2 float32, col util.Color) { RectV(x1, y1, x2, y2, col.R(), col.G(), col.B(), col.A()) } diff --git a/internal/app/render/bucket.go b/internal/app/render/bucket.go index 63553890..1759fc2d 100644 --- a/internal/app/render/bucket.go +++ b/internal/app/render/bucket.go @@ -19,21 +19,41 @@ type unitProcessor interface { func (r *Render) batchBucketUnits(viewBounds util.Bounds) { if MultiZRendering && r.Camera.Level > 1 { for level := 1; level < r.Camera.Level; level++ { - r.batchLevel(level, viewBounds, false) // Draw everything below. + r.batchLevel(level, viewBounds) // Draw everything below. } // Draw a "shadow" overlay to visually separate different levels. brush.RectFilled(viewBounds.X1, viewBounds.Y1, viewBounds.X2, viewBounds.Y2, multiZShadow) } - r.batchLevel(r.Camera.Level, viewBounds, true) // Draw currently visible level. + r.batchLevel(r.Camera.Level, viewBounds) // Draw currently visible level. +} - if r.overlay != nil { - r.overlay.FlushUnits() +// batchUnitHighlights batches the active level's selected-unit highlights. +func (r *Render) batchUnitHighlights(viewBounds util.Bounds) { + if r.overlay == nil { + return + } + visibleLevel := r.bucket.Level(r.Camera.Level) + if visibleLevel == nil { + return } + for _, layer := range visibleLevel.Layers { + for _, chunk := range visibleLevel.ChunksByLayers[layer] { + if !chunk.ViewBounds.ContainsV(viewBounds) { + continue + } + for _, u := range chunk.UnitsByLayers[layer] { + if u.ViewBounds().ContainsV(viewBounds) { + r.batchUnitHighlight(u) + } + } + } + } + r.overlay.FlushUnits() } -func (r *Render) batchLevel(level int, viewBounds util.Bounds, withUnitHighlight bool) { +func (r *Render) batchLevel(level int, viewBounds util.Bounds) { visibleLevel := r.bucket.Level(level) if visibleLevel == nil { return @@ -66,9 +86,6 @@ func (r *Render) batchLevel(level int, viewBounds util.Bounds, withUnitHighlight u.Sprite().U1, u.Sprite().V1, u.Sprite().U2, u.Sprite().V2, ) - if withUnitHighlight { - r.batchUnitHighlight(u) - } } } } diff --git a/internal/app/render/extension_passes.go b/internal/app/render/extension_passes.go new file mode 100644 index 00000000..8621d3ef --- /dev/null +++ b/internal/app/render/extension_passes.go @@ -0,0 +1,144 @@ +package render + +import ( + "sort" + + api "github.com/SpaiR/StrongDMM-extension-api" + "sdmm/internal/app/render/brush" + + "github.com/rs/zerolog/log" +) + +const ( + coreMap = api.StageMap + coreSelection = api.StageSelection + coreOverlays = api.StageOverlays +) + +type graphNode struct { + id string + pass *api.RenderPass +} + +// SetExtensionPasses compiles extension pass declarations into the same graph +// as StrongDMM's core render stages. +func (r *Render) SetExtensionPasses(passes []api.RenderPass) { + r.renderGraph = compileRenderGraph(passes) +} + +func compileRenderGraph(passes []api.RenderPass) []graphNode { + nodes := []graphNode{{id: coreMap}, {id: coreSelection}, {id: coreOverlays}} + byID := map[string]int{coreMap: 0, coreSelection: 1, coreOverlays: 2} + passes = append([]api.RenderPass(nil), passes...) + sort.SliceStable(passes, func(i, j int) bool { return passes[i].ID < passes[j].ID }) + for i := range passes { + if passes[i].ID == "" { + log.Warn().Msg("ignoring extension render pass without an ID") + continue + } + if _, exists := byID[passes[i].ID]; exists { + log.Warn().Str("pass", passes[i].ID).Msg("ignoring duplicate extension render pass") + continue + } + byID[passes[i].ID] = len(nodes) + nodes = append(nodes, graphNode{id: passes[i].ID, pass: &passes[i]}) + } + deps := make([]int, len(nodes)) + forward := make([][]int, len(nodes)) + addEdge := func(from, to int) { + for _, next := range forward[from] { + if next == to { + return + } + } + forward[from] = append(forward[from], to) + deps[to]++ + } + addEdge(byID[coreMap], byID[coreSelection]) + addEdge(byID[coreSelection], byID[coreOverlays]) + for i, node := range nodes { + if node.pass == nil { + continue + } + pass := node.pass + validConstraint := false + for _, id := range pass.After { + if before, ok := byID[id]; ok { + addEdge(before, i) + validConstraint = true + } else { + log.Warn().Str("pass", pass.ID).Str("after", id).Msg("unknown extension render pass dependency") + } + } + for _, id := range pass.Before { + if after, ok := byID[id]; ok { + addEdge(i, after) + validConstraint = true + } else { + log.Warn().Str("pass", pass.ID).Str("before", id).Msg("unknown extension render pass dependency") + } + } + if !validConstraint { + addEdge(byID[coreMap], i) + addEdge(i, byID[coreSelection]) + } + } + ordered := make([]graphNode, 0, len(nodes)) + for len(ordered) < len(nodes) { + found := -1 + for i := range nodes { + if deps[i] == 0 { + found = i + break + } + } + if found < 0 { + log.Warn().Msg("extension render pass graph contains a cycle; using fallback order") + return fallbackRenderGraph(nodes) + } + deps[found] = -1 + ordered = append(ordered, nodes[found]) + for _, next := range forward[found] { + deps[next]-- + } + } + return ordered +} + +func fallbackRenderGraph(nodes []graphNode) []graphNode { + ordered := []graphNode{nodes[0]} + for _, node := range nodes[3:] { + ordered = append(ordered, node) + } + return append(ordered, nodes[1], nodes[2]) +} + +func (r *Render) drawExtensionPass(pass api.RenderPass, width, height float32) { + switch pass.Blend { + case "multiply": + r.setMultiplyBlend() + case "add": + r.setAddBlend() + default: + r.setNormalBlend() + } + for _, command := range pass.Commands { + switch command.Kind { + case "mesh": + vertices := make([]brush.Vertex, 0, len(command.Mesh.Vertices)) + for _, v := range command.Mesh.Vertices { + vertices = append(vertices, brush.Vertex{X: v.X, Y: v.Y, R: v.R, G: v.G, B: v.B, A: v.A}) + } + brush.ColoredTriangles(vertices, command.Mesh.Indices) + case "bilinear": + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + quads := make([]brush.BilinearQuad, 0, len(command.Mesh.Quads)) + for _, quad := range command.Mesh.Quads { + quads = append(quads, brush.BilinearQuad{X1: quad.X1, Y1: quad.Y1, X2: quad.X2, Y2: quad.Y2, SouthWest: brush.Color(quad.SouthWest), SouthEast: brush.Color(quad.SouthEast), NorthWest: brush.Color(quad.NorthWest), NorthEast: brush.Color(quad.NorthEast)}) + } + brush.BilinearQuads(quads, width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + } + } + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + r.setNormalBlend() +} diff --git a/internal/app/render/render.go b/internal/app/render/render.go index ab17d590..e71fa5fc 100644 --- a/internal/app/render/render.go +++ b/internal/app/render/render.go @@ -16,13 +16,15 @@ type Render struct { overlay overlay unitProcessor unitProcessor + renderGraph []graphNode } func New() *Render { brush.TryInit() return &Render{ - Camera: newCamera(), - bucket: bucket.New(), + Camera: newCamera(), + bucket: bucket.New(), + renderGraph: compileRenderGraph(nil), } } @@ -65,12 +67,31 @@ func (r *Render) prepare() { gl.ActiveTexture(gl.TEXTURE0) } +func (r *Render) setNormalBlend() { gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) } +func (r *Render) setMultiplyBlend() { gl.BlendFunc(gl.DST_COLOR, gl.ZERO) } +func (r *Render) setAddBlend() { gl.BlendFunc(gl.SRC_ALPHA, gl.ONE) } + func (r *Render) draw(width, height float32) { - r.batchBucketUnits(r.viewportBounds(width, height)) - //r.batchChunksVisuals() - r.batchOverlayAreasBorders() - r.batchOverlayAreas() - brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + view := r.viewportBounds(width, height) + for _, node := range r.renderGraph { + switch node.id { + case coreMap: + r.batchBucketUnits(view) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + case coreSelection: + r.batchUnitHighlights(view) + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + case coreOverlays: + //r.batchChunksVisuals() + r.batchOverlayAreasBorders() + r.batchOverlayAreas() + brush.Draw(width, height, r.Camera.ShiftX, r.Camera.ShiftY, r.Camera.Scale) + default: + if node.pass != nil { + r.drawExtensionPass(*node.pass, width, height) + } + } + } } // Clean OpenGL state after rendering. diff --git a/internal/app/ui/cpwsarea/wsarea.go b/internal/app/ui/cpwsarea/wsarea.go index 84456aad..f47f98c1 100644 --- a/internal/app/ui/cpwsarea/wsarea.go +++ b/internal/app/ui/cpwsarea/wsarea.go @@ -117,6 +117,15 @@ func (w *WsArea) OpenMap(dmm *dmmap.Dmm, ws *workspace.Workspace) bool { return true } +// RefreshExtensions asks every open map pane to rebuild its extension layers. +func (w *WsArea) RefreshExtensions() { + for _, ws := range w.workspaces { + if mapWs, ok := ws.Content().(*wsmap.WsMap); ok { + mapWs.Map().RefreshExtensions() + } + } +} + func (w *WsArea) Close() { if w.activeWs != nil { w.closeWorkspaceGently(w.activeWs) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go b/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go index c7b45e16..30191be1 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/editor/commit.go @@ -29,6 +29,7 @@ func (e *Editor) onMapSizeChange(maxZ int) { } e.pMap.Snapshot().Sync() // Do a full snapshots sync. e.pMap.OnMapSizeChange() + e.pMap.ResetExtensions() e.updateAreasZones() } @@ -75,5 +76,6 @@ func (e *Editor) commitChanges(commitMsg string) { func (e *Editor) updateBucket(activeLevel int, tilesToUpdate []util.Point) { window.RunLater(func() { e.pMap.Canvas().Render().UpdateBucketV(e.dmm, activeLevel, tilesToUpdate) + e.pMap.NotifyExtensions(tilesToUpdate) }) } diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go b/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go index ca87389c..1966329f 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/editor/editor.go @@ -86,6 +86,8 @@ type attachedMap interface { PushAreaHover(bounds util.Bounds, fillColor, borderColor util.Color) OnMapSizeChange() + NotifyExtensions([]util.Point) + ResetExtensions() } func New(app app, attachedMap attachedMap, dmm *dmmap.Dmm) *Editor { diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go b/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go index 4798b1a4..34052350 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/pmap.go @@ -2,6 +2,7 @@ package pmap import ( "sdmm/internal/app/command" + "sdmm/internal/app/extensions" "sdmm/internal/app/prefs" "sdmm/internal/app/render" "sdmm/internal/app/ui/cpwsarea/wsmap/pmap/canvas" @@ -19,6 +20,7 @@ import ( "sdmm/internal/dmapi/dmmclip" "sdmm/internal/dmapi/dmmsnap" "sdmm/internal/imguiext/style" + "sdmm/internal/util" "github.com/SpaiR/imgui-go" "github.com/rs/zerolog/log" @@ -30,6 +32,7 @@ type App interface { psettings.App Prefs() prefs.Prefs + Extensions() *extensions.Manager LoadedEnvironment() *dmenv.Dme @@ -187,6 +190,9 @@ func New(app App, dmm *dmmap.Dmm) *PaneMap { p.canvas.Render().SetOverlay(p.canvasOverlay) p.canvas.Render().SetUnitProcessor(p) + if manager := app.Extensions(); manager != nil { + manager.ConfigureRender(p.canvas.Render(), dmm) + } p.canvas.Render().UpdateBucket(p.dmm, p.activeLevel) p.mouseChangeCbId = app.AddMouseChangeCallback(p.mouseChangeCallback) @@ -238,6 +244,9 @@ func (p *PaneMap) Process() { } func (p *PaneMap) Dispose() { + if manager := p.app.Extensions(); manager != nil { + manager.ReleaseRender(p.canvas.Render(), p.dmm) + } if p == lastActivePane { lastActivePane = nil } @@ -252,6 +261,25 @@ func (p *PaneMap) Dispose() { log.Print("disposed") } +// RefreshExtensions updates this map after extension settings or map contents change +func (p *PaneMap) RefreshExtensions() { + if manager := p.app.Extensions(); manager != nil { + manager.RefreshRender(p.canvas.Render(), p.dmm) + } +} + +func (p *PaneMap) NotifyExtensions(points []util.Point) { + if manager := p.app.Extensions(); manager != nil { + manager.NotifyChanged(p.dmm, points) + } +} + +func (p *PaneMap) ResetExtensions() { + if manager := p.app.Extensions(); manager != nil { + manager.NotifyResize(p.dmm) + } +} + func (p *PaneMap) prepareTools() { log.Print("preparing tools:", p.dmm.Name) tools.SetEditor(p.editor) @@ -331,11 +359,15 @@ func (p *PaneMap) syncActivePane() { // Fully reloads a canvas for the current pane. Does a full re-initialization of the renderer. // Needed when changing global parts of the map, like the map size etc. func (p *PaneMap) reloadCanvas() { - oldCamera := p.canvas.Render().Camera // To keep current camera position + previousRenderer := p.canvas.Render() + oldCamera := previousRenderer.Camera // To keep current camera position p.canvas = canvas.New() p.canvas.Render().Camera = oldCamera p.canvas.Render().SetOverlay(p.canvasOverlay) p.canvas.Render().SetUnitProcessor(p) + if manager := p.app.Extensions(); manager != nil { + manager.ReplaceRender(previousRenderer, p.canvas.Render(), p.dmm) + } p.canvas.Render().UpdateBucket(p.dmm, p.activeLevel) p.canvasState.SetMaxX(p.dmm.MaxX) p.canvasState.SetMaxY(p.dmm.MaxY) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go index 41c4d955..16ef9dfa 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/psettings.go @@ -2,6 +2,7 @@ package psettings import ( "sdmm/internal/app/config" + "sdmm/internal/app/extensions" "sdmm/internal/app/window" "sdmm/internal/dmapi/dm" "sdmm/internal/dmapi/dmmap" @@ -11,6 +12,7 @@ import ( type App interface { PathsFilter() *dm.PathsFilter + Extensions() *extensions.Manager ConfigRegister(config.Config) } diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go index 158d96a7..aa2ded06 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/psettings/screenshot.go @@ -98,10 +98,16 @@ func (p *Panel) createScreenshot() { c.Render().Camera.Level = p.editor.ActiveLevel() c.Render().Camera.Translate(boundX, boundY) c.Render().SetUnitProcessor(p) + if manager := p.app.Extensions(); manager != nil { + manager.ConfigureRender(c.Render(), p.editor.Dmm()) + } for level := 1; level <= p.editor.ActiveLevel(); level++ { c.Render().UpdateBucket(p.editor.Dmm(), level) // Prepare for render all available levels } c.Process(imgui.Vec2{X: float32(width), Y: float32(height)}) + if manager := p.app.Extensions(); manager != nil { + manager.ReleaseRender(c.Render(), p.editor.Dmm()) + } c.Dispose() var pixels = c.ReadPixels() diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go index 68481c3d..f2419db8 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/process.go @@ -62,6 +62,7 @@ func (t *TileMenu) showControls() { Icon(icon.Eraser). Shortcut("Delete"), w.Separator(), + w.Custom(t.showExtensionActions), w.Custom(func() { for idx, instance := range t.tile.Instances().Sorted() { t.showInstance(instance, idx) @@ -70,6 +71,23 @@ func (t *TileMenu) showControls() { }.Build() } +func (t *TileMenu) showExtensionActions() { + if len(t.extensionActions) == 0 { + return + } + manager := t.app.Extensions() + if manager == nil { + return + } + imgui.Separator() + for _, action := range t.extensionActions { + action := action + w.MenuItem(action.Label+"##extension_"+action.ID, func() { + manager.ExecuteContextMenuAction(t.editor.Dmm(), t.tile.Coord, action.ID) + }).IconEmpty().Enabled(action.Enabled).Build() + } +} + func (t *TileMenu) showInstance(i *dmminstance.Instance, idx int) { p := i.Prefab() s := getSprite(p) diff --git a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go index f38bf0f2..70c14a23 100644 --- a/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go +++ b/internal/app/ui/cpwsarea/wsmap/pmap/tilemenu/tilemenu.go @@ -2,6 +2,7 @@ package tilemenu import ( "sdmm/internal/app/command" + "sdmm/internal/app/extensions" "sdmm/internal/app/prefs" "sdmm/internal/app/ui/cpwsarea/wsmap/pmap/pquickedit" "sdmm/internal/app/ui/shortcut" @@ -39,6 +40,7 @@ type App interface { Prefs() prefs.Prefs LoadedEnvironment() *dmenv.Dme + Extensions() *extensions.Manager } type editor interface { @@ -64,7 +66,8 @@ type TileMenu struct { opened bool - tile *dmmap.Tile + tile *dmmap.Tile + extensionActions []extensions.ContextMenuAction pQuickEdit *pquickedit.Panel } @@ -83,6 +86,9 @@ func (t *TileMenu) Dispose() { func (t *TileMenu) Open(coord util.Point) { if t.editor.Dmm().HasTile(coord) { t.tile = t.editor.Dmm().GetTile(coord) + if manager := t.app.Extensions(); manager != nil { + t.extensionActions = manager.ContextMenuActions(t.editor.Dmm(), coord) + } t.opened = true imgui.OpenPopup("tileMenu") } @@ -91,4 +97,5 @@ func (t *TileMenu) Open(coord util.Point) { func (t *TileMenu) close() { t.opened = false t.tile = nil + t.extensionActions = nil } diff --git a/internal/app/ui/menu/menu.go b/internal/app/ui/menu/menu.go index 102130fe..2ad04869 100644 --- a/internal/app/ui/menu/menu.go +++ b/internal/app/ui/menu/menu.go @@ -2,6 +2,7 @@ package menu import ( "sdmm/internal/app/command" + "sdmm/internal/app/extensions" "sdmm/internal/app/ui/shortcut" "sdmm/internal/dmapi/dm" "sdmm/internal/dmapi/dmenv" @@ -46,6 +47,7 @@ type app interface { DoAreaBorders() DoMultiZRendering() DoMirrorCanvasCamera() + DoExtensionMenuAction(id string) // Window DoResetLayout() @@ -80,6 +82,7 @@ type app interface { AreaBordersRendering() bool MultiZRendering() bool MirrorCanvasCamera() bool + ExtensionMenuActions() []extensions.MenuAction } type upStatus int @@ -108,7 +111,46 @@ func New(app app) *Menu { return m } +func (m *Menu) viewMenu() w.Layout { + items := w.Layout{ + w.MenuItem("Show Area", m.doToggleArea).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isAreaToggled()).Shortcut(platform.KeyModName(), "1"), + w.MenuItem("Show Turf", m.doToggleTurf).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isTurfToggled()).Shortcut(platform.KeyModName(), "2"), + w.MenuItem("Show Object", m.doToggleObject).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isObjectToggled()).Shortcut(platform.KeyModName(), "3"), + w.MenuItem("Show Mob", m.doToggleMob).IconEmpty().Enabled(m.app.HasLoadedEnvironment()).Selected(m.isMobToggled()).Shortcut(platform.KeyModName(), "4"), + w.MenuItem("Show All", m.doShowAll).IconEmpty().Enabled(m.app.HasLoadedEnvironment()), + w.Separator(), + w.MenuItem("Area Borders", m.app.DoAreaBorders).IconEmpty().Selected(m.app.AreaBordersRendering()), + w.MenuItem("Multi-Z Rendering", m.app.DoMultiZRendering).IconEmpty().Selected(m.app.MultiZRendering()).Shortcut(platform.KeyModName(), "0"), + w.MenuItem("Mirror Canvas Camera", m.app.DoMirrorCanvasCamera).IconEmpty().Selected(m.app.MirrorCanvasCamera()), + } + for _, action := range m.app.ExtensionMenuActions() { + if action.Menu != "View" { + continue + } + action := action + items = append(items, w.MenuItem(action.Label, func() { m.app.DoExtensionMenuAction(action.ID) }).IconEmpty().Enabled(action.Enabled).Selected(action.Selected)) + } + return items +} + +func (m *Menu) extensionsMenu() w.Layout { + items := w.Layout{} + for _, action := range m.app.ExtensionMenuActions() { + if action.Menu != "Extensions" { + continue + } + action := action + items = append(items, w.MenuItem(action.Label, func() { m.app.DoExtensionMenuAction(action.ID) }).IconEmpty().Enabled(action.Enabled).Selected(action.Selected)) + } + if len(items) == 0 { + items = append(items, w.MenuItem("No extensions found", func() {}).IconEmpty().Enabled(false)) + } + return items +} + func (m *Menu) Process() { + viewMenu := m.viewMenu() + extensionsMenu := m.extensionsMenu() w.MainMenuBar(w.Layout{ w.Menu("File", w.Layout{ w.MenuItem("New Workspace", m.app.DoNewWorkspace). @@ -200,42 +242,9 @@ func (m *Menu) Process() { Shortcut(platform.KeyModName(), "G"), }), - w.Menu("View", w.Layout{ - w.MenuItem("Show Area", m.doToggleArea). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isAreaToggled()). - Shortcut(platform.KeyModName(), "1"), - w.MenuItem("Show Turf", m.doToggleTurf). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isTurfToggled()). - Shortcut(platform.KeyModName(), "2"), - w.MenuItem("Show Object", m.doToggleObject). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isObjectToggled()). - Shortcut(platform.KeyModName(), "3"), - w.MenuItem("Show Mob", m.doToggleMob). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()). - Selected(m.isMobToggled()). - Shortcut(platform.KeyModName(), "4"), - w.MenuItem("Show All", m.doShowAll). - IconEmpty(). - Enabled(m.app.HasLoadedEnvironment()), - w.Separator(), - w.MenuItem("Area Borders", m.app.DoAreaBorders). - IconEmpty(). - Selected(m.app.AreaBordersRendering()), - w.MenuItem("Multi-Z Rendering", m.app.DoMultiZRendering). - IconEmpty(). - Selected(m.app.MultiZRendering()). - Shortcut(platform.KeyModName(), "0"), - w.MenuItem("Mirror Canvas Camera", m.app.DoMirrorCanvasCamera). - IconEmpty(). - Selected(m.app.MirrorCanvasCamera()), - }), + w.Menu("View", viewMenu), + + w.Menu("Extensions", extensionsMenu), w.Menu("Window", w.Layout{ w.MenuItem("Reset Layout", m.app.DoResetLayout).Shortcut("F5"). diff --git a/tools/sdmmpack/go.mod b/tools/sdmmpack/go.mod new file mode 100644 index 00000000..a8e3683c --- /dev/null +++ b/tools/sdmmpack/go.mod @@ -0,0 +1,3 @@ +module github.com/SpaiR/StrongDMM/tools/sdmmpack + +go 1.24.0 diff --git a/tools/sdmmpack/main.go b/tools/sdmmpack/main.go new file mode 100644 index 00000000..3839486a --- /dev/null +++ b/tools/sdmmpack/main.go @@ -0,0 +1,120 @@ +// sdmmpack packages prebuilt extension commands into a .sdmmext archive. +package main + +import ( + "archive/zip" + "encoding/json" + "flag" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type manifest struct { + Commands map[string]string `json:"commands"` +} + +func main() { + source := flag.String("source", ".", "extension source directory") + manifestPath := flag.String("manifest", "extension.json", "extension manifest") + output := flag.String("output", "extension.sdmmext", "output package") + build := flag.Bool("build", false, "build commands declared by the manifest before packaging") + flag.Parse() + data, err := os.ReadFile(*manifestPath) + if err != nil { + panic(err) + } + var extension manifest + if err = json.Unmarshal(data, &extension); err != nil { + panic(err) + } + if *build { + for platform, command := range extension.Commands { + goos, goarch, ok := strings.Cut(platform, "-") + if !ok || goos == "" || goarch == "" { + panic("invalid platform " + platform) + } + if command == "" || filepath.IsAbs(command) || filepath.Dir(command) == "." { + panic("invalid command for " + platform) + } + path := filepath.Join(*source, command) + if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil { + panic(err) + } + buildCommand := exec.Command("go", "build", "-o", path, ".") + buildCommand.Dir = *source + buildCommand.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch) + buildCommand.Stdout = os.Stdout + buildCommand.Stderr = os.Stderr + if err = buildCommand.Run(); err != nil { + panic(err) + } + } + } + stage, err := os.MkdirTemp("", "sdmmpack-") + if err != nil { + panic(err) + } + defer os.RemoveAll(stage) + for platform, command := range extension.Commands { + if command == "" || filepath.IsAbs(command) || filepath.Dir(command) == "." { + panic("invalid command for " + platform) + } + destination := filepath.Join(stage, command) + if err = os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + panic(err) + } + copyFile(destination, filepath.Join(*source, command)) + } + copyFile(filepath.Join(stage, "extension.json"), *manifestPath) + if err = os.MkdirAll(filepath.Dir(*output), 0755); err != nil { + panic(err) + } + archive, err := os.Create(*output) + if err != nil { + panic(err) + } + defer archive.Close() + zipper := zip.NewWriter(archive) + err = filepath.Walk(stage, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil || info.IsDir() { + return walkErr + } + relative, _ := filepath.Rel(stage, path) + entry, err := zipper.Create(filepath.ToSlash(relative)) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(entry, file) + return err + }) + if err != nil { + panic(err) + } + if err = zipper.Close(); err != nil { + panic(err) + } +} + +func copyFile(destination, source string) { + input, err := os.Open(source) + if err != nil { + panic(err) + } + defer input.Close() + output, err := os.Create(destination) + if err != nil { + panic(err) + } + defer output.Close() + if _, err = io.Copy(output, input); err != nil { + panic(err) + } +}