diff --git a/subcommands/skins/skin.go b/subcommands/skins/skin.go new file mode 100644 index 0000000..e0ae7de --- /dev/null +++ b/subcommands/skins/skin.go @@ -0,0 +1,149 @@ +package skins + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "image" + "image/png" + "os" + + "github.com/bedrock-tool/bedrocktool/locale" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +type Skin struct { + *protocol.Skin +} + +type SkinGeometry struct { + Texturewidth int `json:"texturewidth"` + Textureheight int `json:"textureheight"` + VisibleBoundsWidth float64 `json:"visible_bounds_width"` + VisibleBoundsHeight float64 `json:"visible_bounds_height"` + VisibleBoundsOffset []float64 `json:"visible_bounds_offset"` + Bones []any `json:"bones"` +} + +func (skin *Skin) Hash() uuid.UUID { + h := sha256.New() + h.Write(skin.SkinData) + h.Write(skin.SkinGeometry) + h.Write(skin.CapeData) + h.Write([]byte(skin.SkinID)) + return uuid.NewSHA1(uuid.NameSpaceURL, h.Sum(nil)) +} + +func (skin *Skin) getGeometry() (*SkinGeometry, string, error) { + if !skin.HaveGeometry() { + return nil, "", errors.New("no geometry") + } + + var data map[string]any + if err := json.Unmarshal(skin.SkinGeometry, &data); err != nil { + return nil, "", err + } + + arr, ok := data["minecraft:geometry"].([]any) + if !ok { + return nil, "", errors.New("invalid geometry") + } + geom, ok := arr[0].(map[string]any) + if !ok { + return nil, "", errors.New("invalid geometry") + } + + desc, ok := geom["description"].(map[string]any) + if !ok { + return nil, "", errors.New("invalid geometry") + } + + visibleOffset, _ := desc["visible_bounds_offset"].([]float64) + + return &SkinGeometry{ + Texturewidth: int(desc["texture_width"].(float64)), + Textureheight: int(desc["texture_height"].(float64)), + VisibleBoundsWidth: desc["visible_bounds_width"].(float64), + VisibleBoundsHeight: desc["visible_bounds_height"].(float64), + VisibleBoundsOffset: visibleOffset, + Bones: geom["bones"].([]any), + }, desc["identifier"].(string), nil +} + +// WriteCape writes the cape as a png at output_path +func (skin *Skin) WriteCapePng(output_path string) error { + f, err := os.Create(output_path) + if err != nil { + return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Cape", "Path": output_path, "Err": err})) + } + defer f.Close() + cape_tex := image.NewRGBA(image.Rect(0, 0, int(skin.CapeImageWidth), int(skin.CapeImageHeight))) + cape_tex.Pix = skin.CapeData + + if err := png.Encode(f, cape_tex); err != nil { + return fmt.Errorf(locale.Loc("failed_write", locale.Strmap{"Part": "Cape", "Err": err})) + } + return nil +} + +// WriteTexture writes the main texture for this skin to a file +func (skin *Skin) writeSkinTexturePng(output_path string) error { + f, err := os.Create(output_path) + if err != nil { + return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Meta", "Path": output_path, "Err": err})) + } + defer f.Close() + skin_tex := image.NewRGBA(image.Rect(0, 0, int(skin.SkinImageWidth), int(skin.SkinImageHeight))) + skin_tex.Pix = skin.SkinData + + if err := png.Encode(f, skin_tex); err != nil { + return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Texture", "Path": output_path, "Err": err})) + } + return nil +} + +func (skin *Skin) writeMetadataJson(output_path string) error { + f, err := os.Create(output_path) + if err != nil { + return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Meta", "Path": output_path, "Err": err})) + } + defer f.Close() + d, err := json.MarshalIndent(SkinMeta{ + skin.SkinID, + skin.PlayFabID, + skin.PremiumSkin, + skin.PersonaSkin, + skin.CapeID, + skin.SkinColour, + skin.ArmSize, + skin.Trusted, + skin.PersonaPieces, + }, "", " ") + if err != nil { + return err + } + f.Write(d) + return nil +} + +func (skin *Skin) HaveGeometry() bool { + return len(skin.SkinGeometry) > 0 +} + +func (skin *Skin) HaveCape() bool { + return len(skin.CapeData) > 0 +} + +func (skin *Skin) HaveAnimations() bool { + return len(skin.Animations) > 0 +} + +func (skin *Skin) HaveTint() bool { + return len(skin.PieceTintColours) > 0 +} + +func (skin *Skin) Complex() bool { + return skin.HaveGeometry() || skin.HaveCape() || skin.HaveAnimations() || skin.HaveTint() +} diff --git a/subcommands/skins/skinpack.go b/subcommands/skins/skinpack.go new file mode 100644 index 0000000..2d3dfef --- /dev/null +++ b/subcommands/skins/skinpack.go @@ -0,0 +1,140 @@ +package skins + +import ( + "encoding/json" + "fmt" + "os" + "path" + + "github.com/bedrock-tool/bedrocktool/utils" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/resource" + "github.com/sirupsen/logrus" +) + +type _skinWithIndex struct { + i int + skin *Skin +} + +func (s _skinWithIndex) Name(name string) string { + return fmt.Sprintf("%s-%d", name, s.i) +} + +type SkinPack struct { + skins map[uuid.UUID]_skinWithIndex + Name string +} + +type skinEntry struct { + LocalizationName string `json:"localization_name"` + Geometry string `json:"geometry"` + Texture string `json:"texture"` + Type string `json:"type"` +} + +func NewSkinPack(name string) *SkinPack { + return &SkinPack{ + skins: make(map[uuid.UUID]_skinWithIndex), + Name: name, + } +} + +func (s *SkinPack) AddSkin(skin *Skin) bool { + sh := skin.Hash() + if _, ok := s.skins[sh]; !ok { + s.skins[sh] = _skinWithIndex{len(s.skins) + 1, skin} + return true + } + return false +} + +func (s *SkinPack) Save(fpath, serverName string) error { + os.MkdirAll(fpath, 0o755) + + var skinsJson []skinEntry + geometryJson := map[string]SkinGeometry{} + + for _, s2 := range s.skins { // write skin texture + skinName := s2.Name(s.Name) + + if err := s2.skin.writeSkinTexturePng(path.Join(fpath, skinName+".png")); err != nil { + return err + } + + if err := s2.skin.writeMetadataJson(path.Join(fpath, skinName+"_metadata.json")); err != nil { + return err + } + + if s2.skin.HaveCape() { + if err := s2.skin.WriteCapePng(path.Join(fpath, skinName+"_cape.png")); err != nil { + return err + } + } + + entry := skinEntry{LocalizationName: skinName, Texture: skinName, Type: "free"} + if s2.skin.ArmSize == "wide" { + entry.Geometry = "minecraft.geometry.steve" + } else { + entry.Geometry = "minecraft.geometry.alex" + } + + if s2.skin.HaveGeometry() { + geometry, geometryName, err := s2.skin.getGeometry() + if err != nil { + logrus.Warnf("failed to decode geometry %s", skinName) + } else { + geometryJson[geometryName] = *geometry + entry.Geometry = geometryName + } + } + skinsJson = append(skinsJson, entry) + } + + if len(geometryJson) > 0 { // geometry.json + f, err := os.Create(path.Join(fpath, "geometry.json")) + if err != nil { + return err + } + if err := json.NewEncoder(f).Encode(geometryJson); err != nil { + return err + } + } + + { // skins.json + f, err := os.Create(path.Join(fpath, "skins.json")) + if err != nil { + return err + } + if err := json.NewEncoder(f).Encode(skinsJson); err != nil { + return err + } + } + + { // manifest.json + manifest := resource.Manifest{ + FormatVersion: 2, + Header: resource.Header{ + Name: s.Name, + Description: serverName + " " + s.Name, + UUID: uuid.NewString(), + Version: [3]int{1, 0, 0}, + MinimumGameVersion: [3]int{1, 17, 0}, + }, + Modules: []resource.Module{ + { + UUID: uuid.NewString(), + Description: s.Name + " Skinpack", + Type: "skin_pack", + Version: [3]int{1, 0, 0}, + }, + }, + } + + if err := utils.WriteManifest(&manifest, fpath); err != nil { + return err + } + } + + return nil +} diff --git a/subcommands/skins/skins-proxy.go b/subcommands/skins/skins-proxy.go index 21b3cdc..7ea0598 100644 --- a/subcommands/skins/skins-proxy.go +++ b/subcommands/skins/skins-proxy.go @@ -40,21 +40,27 @@ func (c *SkinProxyCMD) Execute(ctx context.Context, f *flag.FlagSet, _ ...interf logrus.Error(err) return 1 } - out_path := fmt.Sprintf("skins/%s", hostname) - os.MkdirAll(out_path, 0o755) proxy := utils.NewProxy() + + s := NewSkinsSession(proxy, hostname) + s.OnlyIfHasGeometry = c.only_with_geometry + s.PlayerNameFilter = c.filter + proxy.PacketCB = func(pk packet.Packet, proxy *utils.ProxyContext, toServer bool, _ time.Time) (packet.Packet, error) { if !toServer { - process_packet_skins(proxy.Client, out_path, pk, c.filter, c.only_with_geometry) + s.ProcessPacket(pk) } return pk, nil } if err := proxy.Run(ctx, address); err != nil { logrus.Error(err) - return 1 } + + outPathBase := fmt.Sprintf("skins/%s", hostname) + os.MkdirAll(outPathBase, 0o755) + s.Save(outPathBase) return 0 } diff --git a/subcommands/skins/skins.go b/subcommands/skins/skins.go index 072595b..34a4836 100644 --- a/subcommands/skins/skins.go +++ b/subcommands/skins/skins.go @@ -1,15 +1,9 @@ package skins import ( - "bytes" "context" - "encoding/json" - "errors" "flag" "fmt" - "image" - "image/png" - "io" "os" "path" "strings" @@ -18,18 +12,13 @@ import ( "github.com/bedrock-tool/bedrocktool/locale" "github.com/bedrock-tool/bedrocktool/utils" - "github.com/flytam/filenamify" "github.com/google/subcommands" - "github.com/sandertv/gophertunnel/minecraft" + "github.com/google/uuid" "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" "github.com/sirupsen/logrus" ) -type Skin struct { - protocol.Skin -} - type SkinMeta struct { SkinID string PlayFabID string @@ -42,227 +31,104 @@ type SkinMeta struct { PersonaPieces []protocol.PersonaPiece } -// WriteGeometry writes the geometry json for the skin to output_path -func (skin *Skin) WriteGeometry(output_path string) error { - f, err := os.Create(output_path) - if err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Geometry", "Path": output_path, "Err": err})) - } - defer f.Close() - io.Copy(f, bytes.NewReader(skin.SkinGeometry)) - return nil +type skinsSession struct { + PlayerNameFilter string + OnlyIfHasGeometry bool + ServerName string + Proxy *utils.ProxyContext + + playerSkinPacks map[uuid.UUID]*SkinPack + playerNames map[uuid.UUID]string } -// WriteCape writes the cape as a png at output_path -func (skin *Skin) WriteCape(output_path string) error { - f, err := os.Create(output_path) - if err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Cape", "Path": output_path, "Err": err})) - } - defer f.Close() - cape_tex := image.NewRGBA(image.Rect(0, 0, int(skin.CapeImageWidth), int(skin.CapeImageHeight))) - cape_tex.Pix = skin.CapeData +func NewSkinsSession(proxy *utils.ProxyContext, serverName string) *skinsSession { + return &skinsSession{ + ServerName: serverName, + Proxy: proxy, - if err := png.Encode(f, cape_tex); err != nil { - return fmt.Errorf(locale.Loc("failed_write", locale.Strmap{"Part": "Cape", "Err": err})) + playerSkinPacks: make(map[uuid.UUID]*SkinPack), + playerNames: make(map[uuid.UUID]string), } - return nil } -// WriteAnimations writes skin animations to the folder -func (skin *Skin) WriteAnimations(output_path string) error { - logrus.Warnf("%s has animations (unimplemented)", output_path) - return nil -} - -// WriteTexture writes the main texture for this skin to a file -func (skin *Skin) WriteTexture(output_path string) error { - f, err := os.Create(output_path) - if err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Meta", "Path": output_path, "Err": err})) +func (s *skinsSession) AddPlayerSkin(playerID uuid.UUID, playerName string, skin *Skin) { + p, ok := s.playerSkinPacks[playerID] + if !ok { + creating := fmt.Sprintf("Creating Skinpack for %s", playerName) + s.Proxy.SendPopup(creating) + logrus.Info(creating) + p = NewSkinPack(playerName) + s.playerSkinPacks[playerID] = p } - defer f.Close() - skin_tex := image.NewRGBA(image.Rect(0, 0, int(skin.SkinImageWidth), int(skin.SkinImageHeight))) - skin_tex.Pix = skin.SkinData - - if err := png.Encode(f, skin_tex); err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Texture", "Path": output_path, "Err": err})) - } - return nil -} - -func (skin *Skin) WriteTint(output_path string) error { - f, err := os.Create(output_path) - if err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Tint", "Path": output_path, "Err": err})) - } - defer f.Close() - - err = json.NewEncoder(f).Encode(skin.PieceTintColours) - if err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Tint", "Path": output_path, "Err": err})) - } - return nil -} - -func (skin *Skin) WriteMeta(output_path string) error { - f, err := os.Create(output_path) - if err != nil { - return errors.New(locale.Loc("failed_write", locale.Strmap{"Part": "Meta", "Path": output_path, "Err": err})) - } - defer f.Close() - d, err := json.MarshalIndent(SkinMeta{ - skin.SkinID, - skin.PlayFabID, - skin.PremiumSkin, - skin.PersonaSkin, - skin.CapeID, - skin.SkinColour, - skin.ArmSize, - skin.Trusted, - skin.PersonaPieces, - }, "", " ") - if err != nil { - return err - } - f.Write(d) - return nil -} - -func (skin *Skin) Complex() bool { - have_geometry, have_cape, have_animations, have_tint := len(skin.SkinGeometry) > 0, len(skin.CapeData) > 0, len(skin.Animations) > 0, len(skin.PieceTintColours) > 0 - return have_geometry || have_cape || have_animations || have_tint -} - -// Write writes all data for this skin to a folder -func (skin *Skin) Write(output_path, name string) error { - name, _ = filenamify.FilenamifyV2(name) - skin_dir := path.Join(output_path, name) - - have_geometry, have_cape, have_animations, have_tint := len(skin.SkinGeometry) > 0, len(skin.CapeData) > 0, len(skin.Animations) > 0, len(skin.PieceTintColours) > 0 - os.MkdirAll(skin_dir, 0o755) - if have_geometry { - if err := skin.WriteGeometry(path.Join(skin_dir, "geometry.json")); err != nil { - return err + if p.AddSkin(skin) { + if ok { + added := fmt.Sprintf("Added a skin to %s", playerName) + s.Proxy.SendPopup(added) + logrus.Info(added) } } - if have_cape { - if err := skin.WriteCape(path.Join(skin_dir, "cape.png")); err != nil { - return err - } - } - if have_animations { - if err := skin.WriteAnimations(skin_dir); err != nil { - return err - } - } - if have_tint { - if err := skin.WriteTint(path.Join(skin_dir, "tint.json")); err != nil { - return err - } - } - - if err := skin.WriteMeta(path.Join(skin_dir, "metadata.json")); err != nil { - return err - } - - return skin.WriteTexture(skin_dir + "/skin.png") } -// puts the skin at output_path if the filter matches it -// internally converts the struct so it can use the extra methods -func write_skin(output_path, name string, skin *protocol.Skin) { - logrus.Infof("Writing skin for %s", name) - _skin := &Skin{*skin} - if err := _skin.Write(output_path, name); err != nil { - logrus.Errorf("Error writing skin: %s", err) - } -} - -var ( - skin_players = make(map[string]string) - skin_player_counts = make(map[string]int) -) - -func popup_skin_saved(conn *minecraft.Conn, name string) { - if conn != nil { - (&utils.ProxyContext{Client: conn}).SendPopup(fmt.Sprintf("%s Skin was Saved", name)) - } -} - -func skin_meta_get_skinid(path string) string { - cont, err := os.ReadFile(fmt.Sprintf("%s/metadata.json", path)) - if err != nil { - return "" - } - var meta SkinMeta - if err := json.Unmarshal(cont, &meta); err != nil { - return "" - } - return meta.SkinID -} - -func save_player_skin(conn *minecraft.Conn, out_path, player_name string, skin *protocol.Skin) { - count := skin_player_counts[player_name] - if count > 0 { - meta_id := skin_meta_get_skinid(fmt.Sprintf("%s/%s_%d", out_path, player_name, count-1)) - if meta_id == skin.SkinID { - return // skin same as before - } - } - - skin_player_counts[player_name]++ - count++ - write_skin(out_path, fmt.Sprintf("%s_%d", player_name, count), skin) - popup_skin_saved(conn, player_name) -} - -func process_packet_skins(conn *minecraft.Conn, out_path string, pk packet.Packet, filter string, only_if_geom bool) { - switch _pk := pk.(type) { +func (s *skinsSession) ProcessPacket(pk packet.Packet) { + switch pk := pk.(type) { case *packet.PlayerSkin: - player_name := skin_players[_pk.UUID.String()] - if player_name == "" { - player_name = _pk.UUID.String() + playerName := s.playerNames[pk.UUID] + if playerName == "" { + playerName = pk.UUID.String() } - if !strings.HasPrefix(player_name, filter) { - return - } - if only_if_geom && len(_pk.Skin.SkinGeometry) == 0 { + if !strings.HasPrefix(playerName, s.PlayerNameFilter) { return } - save_player_skin(conn, out_path, player_name, &_pk.Skin) - case *packet.PlayerList: - if _pk.ActionType == 1 { // remove + skin := Skin{&pk.Skin} + if s.OnlyIfHasGeometry && !skin.HaveGeometry() { return } - for _, player := range _pk.Entries { - player_name := utils.CleanupName(player.Username) - if player_name == "" { - player_name = player.UUID.String() + s.AddPlayerSkin(pk.UUID, playerName, &skin) + case *packet.PlayerList: + if pk.ActionType == 1 { // remove + return + } + for _, player := range pk.Entries { + playerName := utils.CleanupName(player.Username) + if playerName == "" { + playerName = player.UUID.String() } - if !strings.HasPrefix(player_name, filter) { + if !strings.HasPrefix(playerName, s.PlayerNameFilter) { return } - if only_if_geom && len(player.Skin.SkinGeometry) == 0 { + s.playerNames[player.UUID] = playerName + + skin := Skin{&player.Skin} + if s.OnlyIfHasGeometry && !skin.HaveGeometry() { return } - skin_players[player.UUID.String()] = player_name - save_player_skin(conn, out_path, player_name, &player.Skin) + s.AddPlayerSkin(player.UUID, playerName, &skin) } } } +func (s *skinsSession) Save(fpath string) error { + logrus.Infof("Saving %d players", len(s.playerSkinPacks)) + for id, sp := range s.playerSkinPacks { + err := sp.Save(path.Join(fpath, s.playerNames[id])) + if err != nil { + logrus.Warn(err) + } + } + return nil +} + type SkinCMD struct { - server_address string - filter string + serverAddress string + filter string } func (*SkinCMD) Name() string { return "skins" } func (*SkinCMD) Synopsis() string { return locale.Loc("skins_synopsis", nil) } func (c *SkinCMD) SetFlags(f *flag.FlagSet) { - f.StringVar(&c.server_address, "address", "", locale.Loc("remote_address", nil)) + f.StringVar(&c.serverAddress, "address", "", locale.Loc("remote_address", nil)) f.StringVar(&c.filter, "filter", "", locale.Loc("name_prefix", nil)) } @@ -271,35 +137,35 @@ func (c *SkinCMD) Usage() string { } func (c *SkinCMD) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - address, hostname, err := utils.ServerInput(ctx, c.server_address) + address, hostname, err := utils.ServerInput(ctx, c.serverAddress) if err != nil { logrus.Error(err) return 1 } - out_path := fmt.Sprintf("skins/%s", hostname) - - p := utils.NewProxy() - p.WithClient = false - p.ConnectCB = func(proxy *utils.ProxyContext) { - logrus.Info(locale.Loc("connected", nil)) + proxy := utils.NewProxy() + proxy.WithClient = false + proxy.ConnectCB = func(proxy *utils.ProxyContext) { logrus.Info(locale.Loc("ctrl_c_to_exit", nil)) - - os.MkdirAll(out_path, 0o755) } - p.PacketCB = func(pk packet.Packet, proxy *utils.ProxyContext, toServer bool, _ time.Time) (packet.Packet, error) { + s := NewSkinsSession(proxy, hostname) + + proxy.PacketCB = func(pk packet.Packet, _ *utils.ProxyContext, toServer bool, _ time.Time) (packet.Packet, error) { if !toServer { - process_packet_skins(nil, out_path, pk, c.filter, false) + s.ProcessPacket(pk) } return pk, nil } - err = p.Run(ctx, address) + err = proxy.Run(ctx, address) if err != nil { logrus.Error(err) } + outPathBase := fmt.Sprintf("skins/%s", hostname) + os.MkdirAll(outPathBase, 0o755) + s.Save(outPathBase) return 0 } diff --git a/utils/behaviourpack/bp.go b/utils/behaviourpack/bp.go index a83220a..69717bd 100644 --- a/utils/behaviourpack/bp.go +++ b/utils/behaviourpack/bp.go @@ -89,16 +89,8 @@ func ns_name_split(identifier string) (ns, name string) { } func (bp *BehaviourPack) Save(fpath string) error { - { // write manifest - w, err := os.Create(path.Join(fpath, "manifest.json")) - if err != nil { - return err - } - e := json.NewEncoder(w) - e.SetIndent("", "\t") - if err = e.Encode(bp.Manifest); err != nil { - return err - } + if err := utils.WriteManifest(bp.Manifest, fpath); err != nil { + return err } _add_thing := func(base, identifier string, thing any) error { diff --git a/utils/utils.go b/utils/utils.go index 6d2ae10..7eaefed 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "net" + "os" "path" "regexp" "strings" @@ -22,6 +23,7 @@ import ( "github.com/sandertv/gophertunnel/minecraft/protocol/login" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "github.com/sandertv/gophertunnel/minecraft/resource" ) var ( @@ -159,3 +161,16 @@ func RandSeededUUID(str string) string { id, _ := uuid.NewRandomFromReader(bytes.NewBuffer(h[:])) return id.String() } + +func WriteManifest(manifest *resource.Manifest, fpath string) error { + w, err := os.Create(path.Join(fpath, "manifest.json")) + if err != nil { + return err + } + e := json.NewEncoder(w) + e.SetIndent("", "\t") + if err = e.Encode(manifest); err != nil { + return err + } + return nil +}