bedrockskin

package module
v0.2.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: Unlicense Imports: 21 Imported by: 0

README

bedrock-skin

Render Minecraft Bedrock skins to PNG and GIF, in pure Go. 3D bodies, heads and avatars, capes, slim and wide arms, custom geometry, persona skins, armor, elytra and held tools, animations from Blockbench files, and a detector for invisible skins.

A skin rendered full body, front on The same skin from an angle The skin's head as an avatar The skin walking The skin dancing

Texture in, image.Image out. No GPU, no headless browser, no external process — a small software rasterizer of its own, written for skins - the matrix maths and clipping come from fauxgl. It reads skins the way a Bedrock (MCPE) client sends them, so it drops straight into a proxy, a server plugin, a Discord bot or a website.

go get github.com/THEBOSS9345/bedrock-skin-go
import bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"

The package is bedrockskin. The Rust version, bedrock-skin, renders the same images, pixel for pixel.

Quick start

package main

import (
	"image/png"
	"os"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

func main() {
	f, err := os.Open("skin.png")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	tex, err := png.Decode(f)
	if err != nil {
		panic(err)
	}

	img, err := bedrockskin.Render(bedrockskin.Options{Texture: tex})
	if err != nil {
		panic(err)
	}

	out, err := os.Create("body.png")
	if err != nil {
		panic(err)
	}
	defer out.Close()
	png.Encode(out, img)
}

That renders the full body of a standard humanoid, straight on, at 512×512.

Or work in bytes

If you already hold encoded bytes and want encoded bytes back, skip the decode and encode:

out, err := bedrockskin.RenderBytes(bedrockskin.BytesOptions{
	Texture:  textureBytes,  // encoded PNG or JPEG
	Geometry: geometryBytes, // raw geometry.json; nil or "null" is fine
	View:     bedrockskin.ViewAvatar,
	Size:     128,
})

Both paths run the same renderer and produce identical output — use whichever fits. Options render themselves too, if you prefer:

img, err := bedrockskin.Options{Texture: tex}.Render()     // image.Image
raw, err := bedrockskin.Options{Texture: tex}.RenderPNG()  // []byte

Skins coming off the wire arrive as raw RGBA rather than an encoded image; TextureFromRGBA wraps those without copying.

Geometry is optional, and that matters

Passing no geometry is not a shortcut — for most real skins it is the correct input.

A Bedrock client sends no mesh at all for a skin that uses one of the built-in models. Its login packet carries the literal JSON null in SkinGeometryData, and names the model only in the skin's resource patch:

{ "geometry": { "default": "geometry.humanoid.custom" } }

Both ends already have the model, so it never travels the wire. Geometry only shows up for skins with a genuinely custom mesh. If you are proxying real players, you will very often have nothing to pass — so Options.Geometry: nil falls back to the vanilla humanoid bundle, which is what the client itself would draw.

When a skin does carry geometry, parse and pass it:

geos, err := bedrockskin.ParseGeometry(raw)
if err != nil {
	return err
}

img, err := bedrockskin.Render(bedrockskin.Options{
	Texture:    tex,
	Geometry:   geos,
	Identifier: "geometry.humanoid.customSlim",
	View:       bedrockskin.ViewAvatar,
	Angle:      bedrockskin.AngleIso,
	Size:       256,
})

ParseGeometry accepts both of Bedrock's on-the-wire formats — the modern minecraft:geometry array and the pre-1.12 flat top-level-key form — and detects which is which.

To tell "this skin has no custom mesh" apart from "this upload is broken" before parsing, use IsEmpty. It reports true for empty input and for that literal null (with or without a trailing newline, which real captures show both of).

Picking wide vs slim

The bundled default carries three entries: geometry.humanoid.custom (wide arms), geometry.humanoid.customSlim (slim arms), and geometry.cape. Set Identifier to choose; omit it and the wide body wins.

Take that identifier from the skin's resource patch, not from the login packet's ArmSize field. Real captures show the two disagreeing — a skin reporting ArmSize: "wide" whose patch names customSlim. The patch is authoritative.

Options

Field Meaning
Texture Decoded skin image. The only required field.
Geometry From ParseGeometry. Nil uses DefaultGeometry().
Identifier Which entry to render. Empty picks the one with the most cubes.
Cape Cape texture. Drawn from the geometry's own cape entry, falling back to the bundled geometry.cape, so it works for custom-mesh skins too. Skipped for head and avatar views.
View ViewBody, ViewChest, ViewHead, ViewAvatar. Zero means ViewBody.
Angle AngleFront or AngleIso. Zero means the view's own default.
Parts Explicit bone names, e.g. []string{"head", "leftArm"}. Overrides View.
Camera Explicit yaw/pitch/FOV/margin. Overrides Angle.
Size Output edge length. Zero means 512. Always square.
Armor Armor and elytra worn over the skin, one texture per piece. ArmorSet(layer1, layer2) for a full set.
RightHand, LeftHand An item held in each hand, placed as the game places it, with an optional Adjust to move, turn or resize it.
Scale The figure's size in the image (Model) and per-bone scales (Parts).
HideSkin Draws the equipment alone, without the skin; Texture may then be nil.

Bone scoping is ancestry-based, so naming head also pulls in whatever is parented under it — a hat, hair, ears, a party hat. Custom-geometry skins work with no special-casing and no hardcoded bone list.

Skins straight from a packet

A proxy or bot holds a skin the way the client sent it: raw RGBA, null geometry for a built-in model, a resource patch naming the model, and - for a persona skin - animation images carrying its face. WireSkin takes those fields as they are and gives back ready Options, the right model picked and the face attached:

opts, err := bedrockskin.WireSkin{
	SkinData: s.SkinData, SkinWidth: int(s.SkinImageWidth), SkinHeight: int(s.SkinImageHeight),
	CapeData: s.CapeData, CapeWidth: int(s.CapeImageWidth), CapeHeight: int(s.CapeImageHeight),
	Geometry: s.SkinGeometry, ResourcePatch: s.SkinResourcePatch,
	Animations: animations, // []WireAnimation{{Type, Data, Width, Height}} from s.Animations
}.Options()
opts.View = bedrockskin.ViewAvatar
err = opts.WritePNG(w) // straight to an http.ResponseWriter

WireSkin.Skin() gives the invisibility detector the same fields. Options.WritePNG and WriteGIF write to any io.Writer without holding the encoded bytes first.

Parsing request and packet fields

ParseView, ParseAngle and ParseParts turn request parameters into options, and the first two reject names they don't recognise rather than silently falling back — so a request for avatr is a 400, not a full-body render.

ParseResourcePatch reads the login packet's SkinResourcePatch and returns the geometry identifier to pass as Identifier. Use it rather than ArmSize: real captures show ArmSize reporting wide for a skin whose patch names customSlim.

Every error Render returns is bad caller input and has a sentinel — ErrNoTexture, ErrNoGeometry, ErrNoMatchingParts, ErrEmptyView — so errors.Is classifies them without matching message text.

Armor, elytra and held items

Dress the skin in armor or an elytra and put an item in either hand. The textures come from a resource pack, as the game lays them out:

img, err := bedrockskin.Render(bedrockskin.Options{
	Texture:   tex,
	Armor:     bedrockskin.ArmorSet(diamond1, diamond2), // diamond_1.png, diamond_2.png
	RightHand: bedrockskin.Held{Item: diamondSword},     // diamond_sword.png
	LeftHand:  bedrockskin.Held{Item: bread, Flat: true},
})

Pieces mix freely (Armor{Helmet: gold1, Boots: iron1}), and equipment moves with every animation. Items sit where the game puts them; for one that does not suit, Held.Adjust moves, turns or resizes it. Options.Scale resizes the whole figure or any part of it.

Any of it renders on its own too: Options.HideSkin draws the equipment without the skin - the elytra alone, a helmet with View: ViewHead - and RenderItem draws an item by itself. See docs/equipment.md.

Animation

Skins move too: Minecraft's own player motions built in (MotionWalk, MotionIdle, MotionWave, MotionSneak), or any animation from a Bedrock animation file - what Blockbench exports - with keyframes, smooth interpolation and Molang expressions. RenderGIF makes a looping GIF; RenderFrames returns the frames; Options.Pose renders one pose as a still.

anims, _ := bedrockskin.ParseAnimations(blockbenchExport)
gifBytes, err := bedrockskin.RenderGIF(bedrockskin.AnimationOptions{
	Options:   bedrockskin.Options{Texture: tex, Size: 256},
	Animation: anims["animation.player.wave"], // or bedrockskin.MotionWalk
})

33 example animations come bundled — dances, emotes, a backflip, fighting moves — as ExampleAnimations() and as files in examples/animations. Not every Minecraft animation plays on every model: an animation moves bones by name, so one made for a mob with wings or a tail does nothing on a player, and ones driven by the game's state (where a mob looks, what it holds) hold still. MissingBones tells you. See docs/animation.md.

A viewer that rotates the model while it plays does not want the whole GIF at once: PrepareFrames builds the frames and their shared camera without drawing, then Frames.Draw rasterizes one frame at the viewer's own camera as it moves. See docs/animation.md.

Reading geometry files

ParseGeometryTree reads a whole geometry file and picks any value out of it by path, the way bones are picked by name:

tree, _ := bedrockskin.ParseGeometryTree(raw)
pivot, _ := tree.Get("geometry.humanoid.custom/bones/rightArm/pivot") // [-5, 22, 0]
sizes := tree.Select("*/bones/*/cubes/*/size")                       // every cube's size

See docs/geometry-format.md.

Persona skins

Persona (character creator) skins are built from poly meshes instead of cubes, and render in 3D like any other model. Their parts are spread over several geometry entries, and the head is textured by the skin's face animation rather than the skin image - pass the animation images to draw it:

img, err := bedrockskin.Render(bedrockskin.Options{
	Texture:  tex,
	Geometry: geos,
	Animated: []bedrockskin.AnimatedTexture{{Type: bedrockskin.AnimatedFace, Texture: face}},
})

Without them the body renders and the head view returns ErrEmptyView. See docs/geometry-format.md.

Detecting invisible or partly-invisible skins

The same inputs also feed an invisibility detector - for blocking the "invisible player" hack. Bundle a texture with its geometry and ask the high-level Skin type:

skin := bedrockskin.NewSkin(tex, geoBytes) // geoBytes may be nil

switch rep := skin.Report(); rep.Verdict {
case bedrockskin.VerdictInvisible:
	// nothing renders, or only a stray limb
case bedrockskin.VerdictSuspicious:
	// some standard parts missing, but not all - a soft signal
case bedrockskin.VerdictOK:
	// renders normally
}

Report() gives a structured answer: a single Verdict (ok, suspicious, invisible, or unknown for a report never filled in), how many of the six standard parts render, and a per-part breakdown with opaque ratios - JSON-tagged and ready to marshal for an API. Anything derivable is a method rather than a field, so the JSON cannot contradict itself. skin.OK(), skin.IsInvisible(), skin.IsSuspicious() and skin.InvisibleParts() are the one-liner forms.

Key behaviour:

  • Strict when geometry is provided - the detector cross-references geometry cube UVs against the real texture alpha, so a transparent region can't pass just because geometry maps there, and bones too small to see are caught.
  • Lenient without geometry - the standard vanilla humanoid UV layout is assumed.
  • Persona skins are measured too - their poly meshes are checked where their UVs point, and parts drawn only from an animation image the detector never sees (the head) are trusted. Unreadable geometry is checked against the texture instead, so it can't be used to switch the detector off.
  • A cape never masks an invisible body.
  • Thresholds are yours to set - NewSkinWithOptions takes SkinOptions; the zero value is what NewSkin uses.

See docs/README.md and docs/api-reference.md for the full API and a worked recipe in docs/recipes.md.

Untrusted input

The library enforces no limits of its own, because what counts as too large is policy, not physics. If you accept arbitrary uploads:

  • Bound the geometry document with Complexity, which returns total bones and cubes across every entry, before rendering.
  • Bound image dimensions with ImageDimensions before a full decode; it reads only the header. A few-KB PNG can declare enormous dimensions and force a huge allocation.
  • Bound concurrency. Each render is single-threaded CPU work, so throughput comes from running several at once — but cap that, to bound memory in flight and fail fast under a spike.

Try it

go run github.com/THEBOSS9345/bedrock-skin-go/cmd/bedrock-skin@latest skin.png avatar.png avatar iso 256
go run github.com/THEBOSS9345/bedrock-skin-go/cmd/bedrock-skin@latest skin.png dance.gif dance

The arguments are a view (body, chest, head, avatar), a motion (walk, idle, wave, sneak) or an example animation (dance, backflip, ...), then an angle and a size; -geometry and -cape add a model and a cape.

Documentation

The docs/ directory goes well beyond this README:

skin-data.md What a Bedrock client actually sends over the wire
geometry-format.md The geometry.json format, both versions
rendering-pipeline.md How a bone tree becomes pixels, stage by stage
views-and-cameras.md Bone scoping, framing and camera math
api-reference.md Every exported symbol
recipes.md Worked examples
design-decisions.md Why the library works the way it does

Contributing

Pull requests welcome.

Using AI to write your contribution is completely fine — no objection here at all. One ask: point it at docs/ before it writes anything. That directory exists precisely so a newcomer, human or model, can understand what this project is and how it fits together without guessing.

docs/rendering-pipeline.md and docs/design-decisions.md are the two that matter most. A lot of this code looks arbitrary until you know what it prevents — the texture coordinate flip, the missing Viewport call, alpha testing instead of blending, selecting geometry by cube count. Each of those is a real bug that was already fixed once, and each is easy to "clean up" straight back into existence. The docs explain the failure mode for every one.

AGENTS.md carries the same instructions in the form coding agents pick up automatically.

Practical notes:

  • Run gofmt -l ., go vet ./... and go test -race ./... before opening a PR. CI runs all three. -race is worth the trouble here — it is what caught the reason rasterization is single-threaded. It needs cgo and a C compiler, so on Windows set CGO_ENABLED=1 and put a gcc on PATH.
  • Comments in the code stay brief and point into docs/. Put long explanations in the docs rather than expanding the comments back out.
  • If you change rendering behaviour, say what you verified it against. Much of this was established from real captured traffic, not from documentation, and "it looks right" has been wrong before.

Notes

The bundled default_geometry.json is the vanilla humanoid model, captured from a real Bedrock client rather than hand-authored, so it matches what the game draws. It is Mojang's model data, included here for interoperability.

The pictures at the top are rendered by this library from testdata/bench-skin, the scrubbed skin the benchmarks use.

License

The Unlicense — public domain.

Documentation

Overview

Package bedrockskin renders Minecraft Bedrock skins to images.

It takes a skin texture and, optionally, the skin's geometry.json, and rasterizes them into a square PNG-ready image.Image — no GPU, no headless browser, no external process.

Rendering

Render is the entry point. Only a texture is required:

tex, _, err := image.Decode(f)
if err != nil {
	return err
}
img, err := bedrockskin.Render(bedrockskin.Options{Texture: tex})

That renders the full body of a standard humanoid, straight on, at 512x512. Options selects a different framing, angle, size, bone subset or explicit camera.

Callers holding encoded bytes can skip the decode and encode entirely with RenderBytes, which takes BytesOptions and returns PNG bytes. Both paths run the same renderer and produce identical output. Options and BytesOptions also render themselves, via Render, RenderPNG and BytesOptions.RenderPNG.

Skins taken off the wire arrive as raw RGBA rather than an encoded image; TextureFromRGBA wraps those without copying.

Geometry is optional

Leaving Options.Geometry nil is not a shortcut — it is the correct input for most real skins. A Bedrock client sends no mesh at all for a skin that uses one of the built-in models: its login packet carries the literal JSON "null" in SkinGeometryData and names the model only in the skin's resource patch. Geometry travels the wire only for skins with a genuinely custom mesh. So a caller relaying a real skin very often has nothing to pass, and DefaultGeometry stands in with the model the client itself would use.

When a skin does carry geometry, parse it with ParseGeometry, which accepts both of Bedrock's on-the-wire formats — the modern "minecraft:geometry" array and the pre-1.12 flat top-level-key form — and pass the result:

geos, err := bedrockskin.ParseGeometry(raw)
if err != nil {
	return err
}
img, err := bedrockskin.Render(bedrockskin.Options{
	Texture:    tex,
	Geometry:   geos,
	Identifier: "geometry.humanoid.customSlim",
	View:       bedrockskin.ViewAvatar,
	Angle:      bedrockskin.AngleIso,
	Size:       256,
})

Use IsEmpty to tell "the skin has no custom mesh" apart from "the upload was broken" before parsing: it reports true for empty input and for the literal "null" a client actually sends.

Custom models and persona skins

Skins with extra bones — ears, tails, wings, party hats — render as a real 3D mesh with no special-casing; bone scoping is ancestry-based, so a custom bone parented under "head" is included by ViewHead automatically.

Persona (character creator) skins are poly meshes rather than cubes, spread over several geometry entries; their head is textured by the face animation, which Options.Animated carries. See docs/geometry-format.md#persona-skins.

Untrusted input

The library enforces no size or complexity limits of its own, since what counts as too large is policy. It does supply the two measurements to set those limits from: Complexity bounds a geometry document before rendering, and ImageDimensions reads a texture's dimensions from its header before decoding — a small PNG can declare enormous dimensions and force a huge allocation.

Malformed input is survivable rather than fatal. Cubes whose size or origin arrays are too short are skipped rather than indexed, and every parser is fuzzed; see fuzz_test.go.

Reading a login packet

A skin arrives as several fields that each need decoding. ParseResourcePatch reads the one that names the model — it is authoritative for wide-vs-slim, where the packet's ArmSize field is not — and ParseView/ParseAngle turn request parameters into the corresponding options, rejecting names they do not recognise instead of quietly rendering something else.

Detecting invisible skins

Separately from rendering, Skin answers whether a skin is invisible or only half-visible, cross-referencing geometry against the texture's alpha. See NewSkin, or NewSkinWithOptions to judge by your own thresholds.

Further reading

The docs directory covers the subject in depth, for anyone extending the library or debugging a render:

docs/skin-data.md           what a Bedrock client actually sends
docs/geometry-format.md     the geometry.json format, both versions
docs/rendering-pipeline.md  how a bone tree becomes pixels
docs/views-and-cameras.md   bone scoping, framing and camera math
docs/api-reference.md       every exported symbol
docs/recipes.md             worked examples
docs/design-decisions.md    why the library works the way it does

Comments in this package stay brief and point into those files rather than repeating them.

Example

A skin file in, a 3D head icon out. With no geometry the default model is drawn, which is right for most skins.

package main

import (
	"os"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

func main() {
	raw, err := os.ReadFile("skin.png")
	if err != nil {
		return // a skin file of your own
	}
	tex, err := bedrockskin.DecodeImage(raw)
	if err != nil {
		panic(err)
	}
	f, err := os.Create("avatar.png")
	if err != nil {
		panic(err)
	}
	defer f.Close()
	opts := bedrockskin.Options{Texture: tex, View: bedrockskin.ViewAvatar, Angle: bedrockskin.AngleIso, Size: 256}
	if err := opts.WritePNG(f); err != nil {
		panic(err)
	}
}

Index

Examples

Constants

View Source
const (
	// DefaultMinVisibleAlpha is the minimum alpha value for a pixel to count
	// as visible, matching the renderer's alpha-test threshold.
	DefaultMinVisibleAlpha = 0.5 / 255.0

	// DefaultMinVisibleFraction is the default minimum fraction of
	// non-transparent pixels a body part must have to count as VISIBLE. A
	// part that is more than half transparent is effectively invisible to a
	// viewer, so the default requires a majority of a part's sampled pixels
	// to be opaque. Override it per Skin with SkinOptions.
	DefaultMinVisibleFraction = 0.5

	// DefaultMinGeometrySize is the size a geometry bone must reach to be
	// considered non-tiny: the largest axis of the box enclosing its cubes,
	// so a bone qualifies by being big enough in any one dimension. A flat
	// plane is visible; a bone that is small on every axis is not.
	DefaultMinGeometrySize = 0.5

	// DefaultMinVisibleParts is the default minimum number of standard body
	// parts that must be meaningfully visible for the skin to pass. This
	// catches half-invisible skins where a player hides one or more body
	// parts while keeping the rest opaque. Override it per Skin with
	// SkinOptions.
	DefaultMinVisibleParts = 4
)
View Source
const DefaultSize = 512

DefaultSize is the output edge length used when Options.Size is zero.

Variables

View Source
var (
	// ErrNoTexture is returned by Render when Options.Texture is nil.
	ErrNoTexture = errors.New("bedrockskin: texture is required")

	// ErrNoGeometry means Options.Geometry held no entry that could be
	// rendered. Leaving Geometry nil is not this error - it selects
	// DefaultGeometry.
	ErrNoGeometry = errors.New("bedrockskin: geometry has no usable entries")

	// ErrNoMatchingParts means no bone in the geometry matched Options.Parts,
	// usually a misspelled bone name.
	ErrNoMatchingParts = errors.New("bedrockskin: no bones matched the requested parts")

	// ErrEmptyView means the chosen View scoped to bones that have no cubes -
	// for example ViewHead on geometry with no head bone.
	ErrEmptyView = errors.New("bedrockskin: nothing to render for this view")

	// ErrUnknownView is returned by ParseView for an unrecognised name.
	ErrUnknownView = errors.New("bedrockskin: unknown view")

	// ErrUnknownAngle is returned by ParseAngle for an unrecognised name.
	ErrUnknownAngle = errors.New("bedrockskin: unknown angle")
)

Errors returned by Render and the option parsers. Every one describes bad caller input rather than an internal failure, so a service can map the whole set to a 4xx with errors.Is and without matching on message text.

View Source
var ErrNoAnimation = errors.New("bedrockskin: no animation to render")

ErrNoAnimation is returned by RenderFrames and RenderGIF without an Animation.

View Source
var ErrNoAnimations = errors.New("bedrockskin: no animations in the file")

ErrNoAnimations is returned by ParseAnimations for a file with no animations in it.

View Source
var ErrNoGeometryModels = errors.New("bedrockskin: no geometry models in the file")

ErrNoGeometryModels is returned by ParseGeometryTree for valid JSON that holds no geometry.

View Source
var ErrUnknownMotion = errors.New("bedrockskin: unknown motion")

ErrUnknownMotion is returned by ParseMotion for an unrecognised name.

Functions

func Complexity

func Complexity(geos []Geometry) (bones, cubes int)

Complexity reports total bones and cubes across every entry in geos, which is roughly what mesh-building costs. It sums the whole document, not just the entry that will be rendered, because SelectGeometry's fallback and FindCape both walk all of it.

The library enforces no limit itself - what counts as too large is policy. See docs/recipes.md#handling-untrusted-uploads.

func DecodeImage

func DecodeImage(data []byte) (image.Image, error)

DecodeImage decodes PNG or JPEG bytes into an image.

The result is always an *image.NRGBA: straight-alpha RGBA, whatever kind of PNG it was.

It applies no size limit. Decoding is where a malicious image does its damage: a few-KB file can declare enormous dimensions and force a huge allocation. Callers handling untrusted input should call ImageDimensions first, which reads only the header.

func EncodePNG

func EncodePNG(img image.Image) ([]byte, error)

EncodePNG encodes an image as PNG bytes.

func ExampleAnimations

func ExampleAnimations() map[string]*Animation

ExampleAnimations returns the bundled example animations by name, e.g. "animation.player.dance" - emotes, moves and fighting, made for the player model. The map is new on each call; the animations are shared and must not be changed.

Example

One of the 33 bundled animations.

package main

import (
	"fmt"
	"image"
	"image/color"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

// skinTexture stands in for a decoded skin file: 64x64, every pixel opaque.
func skinTexture() *image.NRGBA {
	img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
	for y := 0; y < 64; y++ {
		for x := 0; x < 64; x++ {
			img.Set(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
		}
	}
	return img
}

func main() {
	dance := bedrockskin.ExampleAnimations()["animation.player.dance"]
	frames, err := bedrockskin.RenderFrames(bedrockskin.AnimationOptions{
		Options:   bedrockskin.Options{Texture: skinTexture(), Size: 48},
		Animation: dance,
		FPS:       5,
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(len(frames) > 1)
}
Output:
true

func ImageDimensions

func ImageDimensions(data []byte) (width, height int, err error)

ImageDimensions reports an encoded image's pixel dimensions by reading only its header, without decoding the pixels.

This is the check DecodeImage's documentation asks callers handling untrusted uploads to make first, and the reason it matters: decoding is where a malicious image does its damage. A few-KB PNG can declare enormous dimensions and force a multi-gigabyte allocation the moment it is decoded. Bounding the header first costs nothing.

w, h, err := bedrockskin.ImageDimensions(data)
if err != nil {
	return err
}
if w > 512 || h > 512 {
	return errors.New("skin texture too large")
}
tex, err := bedrockskin.DecodeImage(data)

It is to a texture what Complexity is to geometry: the measurement, with the ceiling left to the caller. See docs/recipes.md#handling-untrusted-uploads.

func IsEmpty

func IsEmpty(raw []byte) bool

IsEmpty reports whether raw carries no geometry at all: either nothing, or the literal JSON null a Bedrock client sends for a skin whose model is built into the client. Both mean "no mesh supplied", not "broken upload" - use it to tell those apart before calling ParseGeometry.

See docs/skin-data.md#most-skins-send-no-geometry-at-all.

func IsSkinInvisible

func IsSkinInvisible(texture image.Image) bool

IsSkinInvisible reports whether the skin is invisible — every body part defined by the geometry (or the standard UV layout) has transparent pixels.

func IsSkinTiny

func IsSkinTiny(geomData []byte) bool

IsSkinTiny reports whether the geometry defines body parts too small to see.

func ParseAnimations

func ParseAnimations(raw []byte) (map[string]*Animation, error)

ParseAnimations reads a Bedrock animation file, returning its animations by name. Every Molang expression in it is compiled, so a syntax error is reported here, naming the animation, bone and channel it is in; names an expression reads that this library does not model are simply 0 when it runs.

func ParseParts

func ParseParts(raw string) []string

ParseParts splits a comma-separated bone list into trimmed, non-empty names. Blank input returns nil, which Options.Parts reads as "everything".

func Render

func Render(opts Options) (image.Image, error)

Render rasterizes a skin into a square image.

The zero-ish case is the common one: with only a Texture set, this renders the full body of a standard humanoid, straight on, at 512x512.

img, err := bedrockskin.Render(bedrockskin.Options{Texture: tex})

Persona skins render in 3D from their poly meshes; pass their animation images as Animated to get the head. Geometry whose bones draw nothing falls back to a flat crop of the texture (see Render2D) rather than failing.

Example
package main

import (
	"fmt"
	"image"
	"image/color"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

// skinTexture stands in for a decoded skin file: 64x64, every pixel opaque.
func skinTexture() *image.NRGBA {
	img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
	for y := 0; y < 64; y++ {
		for x := 0; x < 64; x++ {
			img.Set(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
		}
	}
	return img
}

func main() {
	img, err := bedrockskin.Render(bedrockskin.Options{
		Texture: skinTexture(),
		View:    bedrockskin.ViewHead,
		Size:    128,
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(img.Bounds().Dx(), "x", img.Bounds().Dy())
}
Output:
128 x 128

func Render2D

func Render2D(texture image.Image, view View, size int) image.Image

Render2D composites a flat front-view "paper doll" by cropping the standard vanilla box-UV regions straight out of the texture. It needs no geometry at all, which is why Render falls back to it for geometry that draws nothing: bones with neither cubes nor a poly mesh.

Coordinates are relative to a 64-wide texture and scaled proportionally for other widths. See docs/rendering-pipeline.md#the-2d-fallback.

func RenderBytes

func RenderBytes(opts BytesOptions) ([]byte, error)

RenderBytes renders from encoded bytes and returns encoded PNG bytes.

out, err := bedrockskin.RenderBytes(bedrockskin.BytesOptions{
	Texture: textureBytes,
	View:    bedrockskin.ViewAvatar,
})

It is exactly Render with decoding and PNG encoding folded in, so it behaves identically for geometry defaults, persona fallback and identifier selection.

Note that this decodes whatever it is given. A service accepting untrusted uploads should bound image dimensions first — see docs/recipes.md#handling-untrusted-uploads.

func RenderFrames

func RenderFrames(opts AnimationOptions) ([]image.Image, error)

RenderFrames renders the motion frame by frame. Every frame shares one camera, fitted around the motion's whole sweep, so the model moves within a still frame rather than the frame chasing it. For a viewer that draws frames as its own camera moves, PrepareFrames keeps the same scenes and camera and draws them one at a time.

func RenderFramesPNG added in v0.2.0

func RenderFramesPNG(opts AnimationBytesOptions) ([][]byte, error)

RenderFramesPNG renders an animation from encoded bytes and returns every frame as PNG bytes, in order: RenderFrames with decoding and encoding folded in, for callers that build their own animation format.

func RenderGIF

func RenderGIF(opts AnimationOptions) ([]byte, error)

RenderGIF renders the motion as a looping animated GIF. GIF holds 256 colours a frame, so the frames share a palette of the colours they use most (exact for most skins, which use fewer), and transparency is on or off per pixel, as the renderer's alpha test already makes it.

func RenderGIFBytes added in v0.2.0

func RenderGIFBytes(opts AnimationBytesOptions) ([]byte, error)

RenderGIFBytes renders an animation from encoded bytes and returns GIF bytes: RenderGIF with decoding folded in.

gif, err := bedrockskin.RenderGIFBytes(bedrockskin.AnimationBytesOptions{
	BytesOptions: bedrockskin.BytesOptions{Texture: textureBytes, Size: 256},
	Animation:    bedrockskin.MotionWalk,
})

func RenderItem added in v0.2.3

func RenderItem(opts ItemOptions) (image.Image, error)

RenderItem renders an item on its own: the sprite extruded one texel deep, as the game draws a held item, centred and framed by the camera.

func RenderItemBytes added in v0.2.3

func RenderItemBytes(opts ItemBytesOptions) ([]byte, error)

RenderItemBytes renders an item on its own from encoded bytes and returns PNG bytes: RenderItem with decoding and encoding folded in.

func RenderItemFrames added in v0.2.3

func RenderItemFrames(opts ItemAnimationOptions) ([]image.Image, error)

RenderItemFrames renders the spinning item frame by frame. Every frame shares one camera, fitted around the whole turn, so the item turns in a still frame.

func RenderItemGIF added in v0.2.3

func RenderItemGIF(opts ItemAnimationOptions) ([]byte, error)

RenderItemGIF renders the spinning item as a looping animated GIF, as RenderGIF encodes one.

func RenderItemGIFBytes added in v0.2.3

func RenderItemGIFBytes(opts ItemAnimationBytesOptions) ([]byte, error)

RenderItemGIFBytes spins an item on its own from encoded bytes and returns GIF bytes: RenderItemGIF with decoding folded in.

func TextureFromRGBA

func TextureFromRGBA(pix []byte, width, height int) (image.Image, error)

TextureFromRGBA wraps raw non-premultiplied RGBA pixel data as an image, which is the form Bedrock actually sends a skin in: SkinData decodes to width*height*4 bytes with no header, and the dimensions arrive separately in SkinImageWidth and SkinImageHeight.

raw, err := base64.StdEncoding.DecodeString(data.SkinData)
if err != nil {
	return err
}
tex, err := bedrockskin.TextureFromRGBA(raw, data.SkinImageWidth, data.SkinImageHeight)

The byte slice backs the image directly rather than being copied, so it must not be modified afterwards. A length that disagrees with the declared dimensions is an error rather than a silently garbled image.

See docs/skin-data.md#the-texture-is-not-an-image-file.

func WriteGIF added in v0.2.1

func WriteGIF(w io.Writer, opts AnimationOptions) error

WriteGIF renders the animation and writes the GIF to w - an HTTP response, a file - without holding the encoded bytes first. It writes the same bytes RenderGIF returns.

Example

A looping GIF, written as it is encoded - to a file here, to an HTTP response just the same.

package main

import (
	"bytes"
	"fmt"
	"image"
	"image/color"
	"image/gif"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

// skinTexture stands in for a decoded skin file: 64x64, every pixel opaque.
func skinTexture() *image.NRGBA {
	img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
	for y := 0; y < 64; y++ {
		for x := 0; x < 64; x++ {
			img.Set(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
		}
	}
	return img
}

func main() {
	var out bytes.Buffer
	err := bedrockskin.WriteGIF(&out, bedrockskin.AnimationOptions{
		Options:   bedrockskin.Options{Texture: skinTexture(), Size: 64},
		Animation: bedrockskin.MotionWalk,
		FPS:       10,
	})
	if err != nil {
		panic(err)
	}
	g, err := gif.DecodeAll(&out)
	if err != nil {
		panic(err)
	}
	fmt.Println(len(g.Image), "frames")
}
Output:
10 frames

Types

type Angle

type Angle string

Angle selects one of the two named camera presets. Options.Camera bypasses these entirely and takes explicit yaw/pitch instead.

const (
	AngleFront Angle = "front"
	AngleIso   Angle = "iso"
)

func ParseAngle

func ParseAngle(raw string) (Angle, error)

ParseAngle resolves an angle name to an Angle, the same way ParseView resolves a view. Blank input returns the zero Angle, which Options reads as "the default for the chosen view". An unrecognised name wraps ErrUnknownAngle.

type AnimatedTexture

type AnimatedTexture struct {
	Type    AnimatedType
	Texture image.Image
}

AnimatedTexture is one skin animation's image: its frames stacked top to bottom, as the client sends it.

type AnimatedTextureBytes added in v0.2.0

type AnimatedTextureBytes struct {
	Type    AnimatedType
	Texture []byte // an encoded PNG or JPEG
}

AnimatedTextureBytes is AnimatedTexture with the image encoded.

type AnimatedType

type AnimatedType int

AnimatedType is the kind of a skin animation, numbered as the Bedrock protocol numbers them.

const (
	AnimatedFace    AnimatedType = 1 // the face: eyes that blink
	AnimatedBody32  AnimatedType = 2 // a 32x32 animated body part
	AnimatedBody128 AnimatedType = 3 // a 128x128 animated body part
)

type Animation

type Animation struct {
	// Name is its key in the file, e.g. "animation.player.wave".
	Name string
	// Loop is whether it starts over at the end; HoldOnLastFrame keeps the
	// last pose instead (as does not looping, once it has finished).
	Loop            bool
	HoldOnLastFrame bool
	// Length is how long it runs, in seconds: animation_length, or the last
	// keyframe's time when the file leaves it out.
	Length float64
	// contains filtered or unexported fields
}

Animation is one animation from a Bedrock animation file - the format Blockbench exports and Minecraft's own resource packs use. See docs/animation.md#animation-files.

func (*Animation) Bones

func (a *Animation) Bones() []string

Bones lists the bones the animation moves, as the file names them.

func (*Animation) Duration

func (a *Animation) Duration() float64

Duration is how long one loop lasts: Length, or a second for an animation with no keyframes (all expressions), which has no natural end.

func (*Animation) MissingBones

func (a *Animation) MissingBones(g Geometry) []string

MissingBones lists the bones the animation moves that g does not have, so those parts of it will do nothing on that model - typically an animation made for another entity (a wing, a tail). Names match case-insensitively, as in game. Empty means every part of it applies.

func (*Animation) Pose

func (a *Animation) Pose(t float64) Pose

Pose is the animation's pose t seconds in. Its clock is t, or what anim_time_update makes of it, looped or held at the end as the file says.

type AnimationBytesOptions added in v0.2.0

type AnimationBytesOptions struct {
	BytesOptions

	Animation Animator // required; ErrNoAnimation without one
	FPS       int
	Frames    int
	Workers   int
}

AnimationBytesOptions is AnimationOptions with encoded bytes in place of images: BytesOptions plus the animation fields, which behave as their AnimationOptions counterparts.

func (AnimationBytesOptions) RenderFramesPNG added in v0.2.0

func (o AnimationBytesOptions) RenderFramesPNG() ([][]byte, error)

RenderFramesPNG is the method form of RenderFramesPNG.

func (AnimationBytesOptions) RenderGIF added in v0.2.0

func (o AnimationBytesOptions) RenderGIF() ([]byte, error)

RenderGIF is the method form of RenderGIFBytes.

type AnimationOptions

type AnimationOptions struct {
	Options

	// Animation is what moves the model: a built-in Motion, or an Animation
	// from ParseAnimations. Required.
	Animation Animator
	// FPS is frames per second; zero means 20.
	FPS int
	// Frames is how many frames to render; zero means one loop.
	Frames int
	// Workers is how many frames are rasterized at once; zero means
	// GOMAXPROCS, one means one at a time. Frames are independent, so the
	// images are the same either way; a server already rendering on every
	// core may want 1. See docs/design-decisions.md#why-animation-frames-render-in-parallel.
	Workers int
}

AnimationOptions configures RenderFrames and RenderGIF: every field of Options (its Pose is ignored), plus the animation and its timing.

func (AnimationOptions) WriteGIF added in v0.2.1

func (o AnimationOptions) WriteGIF(w io.Writer) error

WriteGIF is the method form of WriteGIF.

type Animator

type Animator interface {
	// Duration is the length of one loop, in seconds.
	Duration() float64
	// Pose is the pose t seconds in.
	Pose(t float64) Pose
}

Animator is anything that poses a model over time: a built-in Motion, or an Animation loaded from a Bedrock animation file.

type Armor added in v0.2.3

type Armor struct {
	Helmet     image.Image
	Chestplate image.Image
	Leggings   image.Image
	Boots      image.Image
	// Elytra is the elytra's texture (textures/models/armor/elytra.png),
	// worn on the back. It takes the chestplate's slot, as in game: with
	// both set, only the elytra is worn. See docs/equipment.md#elytra.
	Elytra image.Image
}

Armor is the armor a skin wears: one texture per piece, as a resource pack lays them out. The helmet, chestplate and boots take the set's first layer (e.g. textures/models/armor/diamond_1.png), the leggings its second (diamond_2.png). A nil piece is not worn, so pieces from different sets mix freely. See docs/equipment.md.

func ArmorSet added in v0.2.3

func ArmorSet(layer1, layer2 image.Image) Armor

ArmorSet is a full set of one material: layer1 for the helmet, chestplate and boots, layer2 for the leggings.

type ArmorBytes added in v0.2.3

type ArmorBytes struct {
	Helmet     []byte
	Chestplate []byte
	Leggings   []byte
	Boots      []byte
	Elytra     []byte
}

ArmorBytes is Armor with each piece's texture encoded as PNG or JPEG; a nil piece is not worn.

func ArmorSetBytes added in v0.2.3

func ArmorSetBytes(layer1, layer2 []byte) ArmorBytes

ArmorSetBytes is ArmorSet for encoded textures.

type Bone

type Bone struct {
	Name     string    `json:"name"`
	Parent   string    `json:"parent,omitempty"`
	Pivot    []float64 `json:"pivot,omitempty"`
	Rotation []float64 `json:"rotation,omitempty"`
	Inflate  float64   `json:"inflate,omitempty"`
	Mirror   bool      `json:"mirror,omitempty"`
	Cubes    []Cube    `json:"cubes,omitempty"`

	// The rest of the schema, kept so a whole file can be read. Poly
	// meshes are drawn (read one with Mesh); texture meshes are parsed but
	// not drawn. See docs/geometry-format.md#everything-else-in-a-bone.
	BindPoseRotation []float64          `json:"bind_pose_rotation,omitempty"`
	Locators         map[string]Locator `json:"locators,omitempty"`
	PolyMesh         json.RawMessage    `json:"poly_mesh,omitempty"`
	TextureMeshes    json.RawMessage    `json:"texture_meshes,omitempty"`
}

Bone and Cube mirror Bedrock's geometry.json schema. UV stays raw JSON because a cube's "uv" field is either a [u,v] pair or a per-face object, resolved in mesh.go. See docs/geometry-format.md.

func (Bone) Mesh

func (b Bone) Mesh() (PolyMesh, bool)

Mesh returns the bone's poly mesh, and false when it has none or it does not read.

type BonePose

type BonePose struct {
	Rotation [3]float64
	Position [3]float64
	Scale    [3]float64
	Scaled   bool
}

BonePose moves one bone from where its geometry puts it: Rotation is added to the bone's own rotation (degrees, the geometry's convention), Position to its offset from its parent (model units), and, when Scaled, Scale multiplies it (about its pivot, carrying its children; 0 hides it).

type BytesOptions

type BytesOptions struct {
	// Texture is an encoded PNG or JPEG. Required.
	//
	// Bedrock sends skins as raw RGBA rather than an encoded image; use
	// TextureFromRGBA for those and the Options/Render path instead.
	Texture []byte

	// Geometry is a raw geometry.json. Nil, empty, or the literal "null" a
	// Bedrock client sends for a built-in model all fall back to
	// DefaultGeometry, so a skin's field can be passed straight through.
	Geometry []byte

	// Cape is an encoded PNG or JPEG cape texture, or nil.
	Cape []byte

	// Animated holds a persona skin's animation images, encoded; see
	// Options.Animated.
	Animated []AnimatedTextureBytes
	// Armor is Options.Armor with each piece's texture encoded.
	Armor ArmorBytes
	// RightHand and LeftHand are Options' hands with the item encoded.
	RightHand HeldBytes
	LeftHand  HeldBytes

	Identifier string
	View       View
	Angle      Angle
	Parts      []string
	Camera     *Camera
	Size       int
	// Scale and HideSkin are Options' fields. With HideSkin, Texture may be
	// empty.
	Scale    Scale
	HideSkin bool
}

BytesOptions is Options with encoded byte slices in place of decoded images, for callers who already hold file or wire bytes and want PNG bytes back. It is the same render with the decode and encode steps folded in.

Every field behaves exactly as its Options counterpart; see Options for what each one means.

func (BytesOptions) RenderPNG

func (o BytesOptions) RenderPNG() ([]byte, error)

RenderPNG renders these options and returns encoded PNG bytes. It is the method form of RenderBytes.

type Camera

type Camera struct {
	// Yaw rotates the camera around the vertical axis, in degrees. 0 is
	// straight-on front; positive values swing the camera around to bring
	// more of the subject's left side into view.
	Yaw float64
	// Pitch raises the camera, in degrees. 0 is level with the subject's
	// centre; positive values look down and reveal top-facing surfaces.
	Pitch float64
	// FOV is the field of view in degrees. Zero means 35. Small values
	// (~15-20) read as flat and zoomed in; large ones (~50+) add perspective
	// distortion, which suits dramatic close framing.
	FOV float64
	// Margin is how much room to leave around the subject. Zero means 1.5.
	// 1.0 frames as tightly as possible without clipping; larger values pull
	// the camera back.
	Margin float64
}

Camera positions the view explicitly, instead of letting View and Angle pick a framing. Set Options.Camera only when one of the presets won't do — the presets already handle the common cases.

type Cube

type Cube struct {
	Origin  []float64       `json:"origin"`
	Size    []float64       `json:"size"`
	UV      json.RawMessage `json:"uv"`
	Inflate *float64        `json:"inflate,omitempty"`
	Mirror  bool            `json:"mirror,omitempty"`
	// Rotation turns the cube about Pivot, in degrees; Pivot is in model
	// space and defaults to the cube's centre. See
	// docs/geometry-format.md#rotation.
	Rotation []float64 `json:"rotation,omitempty"`
	Pivot    []float64 `json:"pivot,omitempty"`
}

func (Cube) BoxUV

func (c Cube) BoxUV() (u, v float64, ok bool)

BoxUV returns the cube's texture origin when its uv is the box form, [u, v], which lays all six faces out from that corner.

func (Cube) FaceUVs

func (c Cube) FaceUVs() map[string]FaceUV

FaceUVs returns each face's texture area, by face name (north, east, south, west, up, down), when the cube's uv is the per-face form; nil for the box form. A face it leaves out is not drawn.

type FaceUV

type FaceUV struct {
	UV               []float64 `json:"uv"`
	UVSize           []float64 `json:"uv_size,omitempty"`
	UVRotation       float64   `json:"uv_rotation,omitempty"`
	MaterialInstance string    `json:"material_instance,omitempty"`
}

FaceUV is one face's texture area in a cube's per-face uv form.

type Frames added in v0.2.4

type Frames struct {
	// contains filtered or unexported fields
}

Frames is an animation prepared once: its per-frame scenes and the bounding box they share, kept so a viewer can draw frames one at a time as its camera moves. RenderFrames builds the same thing and discards it after drawing every frame; Frames keeps it, so one frame costs one rasterization and every frame of an angle is framed by one camera - root and whole-body motion stay on screen instead of the camera chasing each pose. See docs/animation.md.

func PrepareFrames added in v0.2.4

func PrepareFrames(opts AnimationOptions) (*Frames, error)

PrepareFrames builds every frame of an animation, and the one camera they share, without rasterizing anything. It is RenderFrames split in two; draw the result with Frames.Draw.

func (*Frames) Draw added in v0.2.4

func (f *Frames) Draw(i, size int, cam *Camera) image.Image

Draw rasterizes frame i at size, using the camera the frames were prepared with unless cam is set, when it refits the shared framing to cam. Refitting is what turns a draw into an orbit: every frame at one camera still shares a single framing. i wraps into range. A persona skin's flat crop is returned as prepared, whatever size was asked for.

func (*Frames) Len added in v0.2.4

func (f *Frames) Len() int

Len is how many frames the animation has.

type Geometry

type Geometry struct {
	Identifier    string
	TextureWidth  float64
	TextureHeight float64
	Bones         []Bone

	// The visible bounds: the box, in blocks, the game uses to decide the
	// model is on screen. Zero when the file leaves them out.
	VisibleBoundsWidth  float64
	VisibleBoundsHeight float64
	VisibleBoundsOffset []float64
}

Geometry is one normalized model - a body, a cape - regardless of which of Bedrock's two wire formats it came from. See ParseGeometry.

func DefaultGeometry

func DefaultGeometry() []Geometry

DefaultGeometry returns the standard vanilla humanoid geometry: the wide ("geometry.humanoid.custom") and slim ("geometry.humanoid.customSlim") body variants, plus "geometry.cape". Set Options.Identifier to choose between them; with no identifier the wide variant wins.

This is the right model for most real skins, not merely a fallback: a Bedrock client sends no mesh at all for a skin using a built-in model. See docs/skin-data.md#most-skins-send-no-geometry-at-all.

The returned slice is shared across calls and must not be modified.

func FindCape

func FindCape(geos []Geometry) (Geometry, bool)

FindCape returns the entry holding a bone literally named "cape" that has a cube. Capes always live in their own entry, never merged into the body.

func ParseGeometry

func ParseGeometry(raw []byte) ([]Geometry, error)

ParseGeometry parses raw into normalized entries, detecting whichever of Bedrock's two formats it is - bone and cube fields are identical between them, only the wrapper differs.

Valid JSON carrying no geometry, including the literal "null" a client sends for a built-in model, returns zero entries and no error. An error means genuinely malformed input.

Entry order is stable for the same input: modern keeps document order, legacy sorts by identifier.

func SelectGeometry

func SelectGeometry(geos []Geometry, identifier string) (Geometry, bool)

SelectGeometry picks the entry matching identifier, falling back to the one with the most cubes when identifier is empty or matches nothing.

The fallback is deliberately not geos[0]: bundles commonly list the sparse cape entry first, so taking the first would select it and leave every head-scoped view empty. See docs/design-decisions.md#why-select-by-cube-count.

func (*Geometry) BoneByName

func (g *Geometry) BoneByName(name string) (Bone, bool)

BoneByName returns the bone with the given name, and whether it exists.

func (*Geometry) Children

func (g *Geometry) Children(name string) []Bone

Children returns the bones whose parent is the named bone, in file order.

func (*Geometry) HasMesh

func (g *Geometry) HasMesh() bool

HasMesh reports whether any bone draws something: a cube or a poly mesh.

func (*Geometry) Locator

func (g *Geometry) Locator(name string) (Locator, Bone, bool)

Locator finds a locator by name on any bone, returning it and the bone it is on.

func (*Geometry) TotalCubes

func (g *Geometry) TotalCubes() int

TotalCubes is the number of cubes across every bone in the entry. A persona skin has none - its mesh is poly meshes; HasMesh counts both.

type GeometrySizeResult

type GeometrySizeResult struct {
	Pass       bool
	Violations []GeometryViolation
}

GeometrySizeResult holds the result of ValidateGeometrySize.

func ValidateGeometrySize

func ValidateGeometrySize(geomData []byte, minSize float64) GeometrySizeResult

ValidateGeometrySize checks whether the geometry defines body parts that are large enough to be visible. Every bone with cubes is checked: the largest axis of the box enclosing its cubes, inflate included, must meet minSize. Bones with no cubes are ignored (they produce no rendered geometry).

Violations are ordered by bone name.

type GeometryTree

type GeometryTree struct {
	// FormatVersion is the file's format_version, e.g. "1.12.0".
	FormatVersion string
	// contains filtered or unexported fields
}

GeometryTree is a whole geometry file, every field kept - including ones this library has no type for - so any value in it can be picked out by a path. ParseGeometry gives the typed models the renderer uses; a tree is for reading a file. See docs/geometry-format.md#picking-values-out-of-a-file.

tree, err := bedrockskin.ParseGeometryTree(raw)
pivot, ok := tree.Get("geometry.humanoid.custom/bones/rightArm/pivot")
for _, v := range tree.Select("*/bones/*/cubes/*/size") { ... }

Both of Bedrock's wire formats read into the same shape, the modern one: each model is an object with a "description" (identifier, texture_width, texture_height, visible_bounds_*) and "bones", so one path works on either.

func ParseGeometryTree

func ParseGeometryTree(raw []byte) (*GeometryTree, error)

ParseGeometryTree reads a geometry file of either format into a tree.

func (*GeometryTree) Geometries

func (t *GeometryTree) Geometries() ([]Geometry, error)

Geometries returns the typed models: the same as ParseGeometry on the file.

func (*GeometryTree) Get

func (t *GeometryTree) Get(path string) (GeometryValue, bool)

Get returns the first value the path picks out, and whether there was one.

func (*GeometryTree) Identifiers

func (t *GeometryTree) Identifiers() []string

Identifiers lists the models' identifiers, in the same order as ParseGeometry.

func (*GeometryTree) Select

func (t *GeometryTree) Select(path string) []GeometryValue

Select returns every value the path picks out, in file order; none when nothing matches.

A path is segments separated by "/". The first picks the model by identifier, the rest walk into it:

  • an object's field by name: "description", "bones", "locators", "pivot"
  • an array element by index, from 0 ("-1" is the last), or - for a list of named things, such as bones - by its name: "bones/rightArm"
  • "*" for every model, field or element at that level

Names match exactly, else case-insensitively, as the game matches bones. A legacy model named "geometry.a:geometry.b" (one inheriting from another) is also picked by "geometry.a". An empty path returns every model.

type GeometryValue

type GeometryValue struct {
	Path  string
	Value any
}

GeometryValue is one value a path picked out: its canonical path, with bones and other named entries by name, and the value itself - a map[string]any, []any, float64, string, bool or nil, as encoding/json reads JSON.

func (GeometryValue) Decode

func (v GeometryValue) Decode(dst any) error

Decode stores the value in dst as encoding/json would, so a bone decodes into a Bone, a cube into a Cube, a locator into a Locator.

func (GeometryValue) Float

func (v GeometryValue) Float() (float64, bool)

Float returns the value as a number.

func (GeometryValue) Floats

func (v GeometryValue) Floats() ([]float64, bool)

Floats returns the value as a list of numbers - a pivot, an origin, a size.

func (GeometryValue) JSON

func (v GeometryValue) JSON() []byte

JSON returns the value as JSON.

func (GeometryValue) Text

func (v GeometryValue) Text() (string, bool)

Text returns the value as a string - a name, a parent, an identifier.

type GeometryViolation

type GeometryViolation struct {
	Bone    string
	Size    float64
	Minimum float64
}

GeometryViolation records a bone whose world-space size is below the minimum threshold.

type Held added in v0.2.3

type Held struct {
	// Item is the item's sprite, e.g. textures/items/diamond_sword.png,
	// drawn extruded and placed where the game places it, with the arm
	// held forward. Nil holds nothing; nor does geometry without the arm.
	Item image.Image
	// Flat holds it as the game holds an item that is not a tool or
	// weapon - food, materials. False holds it upright, as a sword.
	Flat bool
	// Adjust moves the item from where the game puts it, for an item that
	// placement does not suit.
	Adjust ItemAdjust
}

Held is an item held in one hand. The zero value holds nothing. See docs/equipment.md#held-items.

type HeldBytes added in v0.2.3

type HeldBytes struct {
	Item   []byte
	Flat   bool
	Adjust ItemAdjust
}

HeldBytes is Held with the item's sprite an encoded PNG or JPEG; nil holds nothing.

type ItemAdjust added in v0.2.3

type ItemAdjust struct {
	// Offset moves the item, in model units along the model's axes, as a
	// bone's position moves a bone.
	Offset [3]float64
	// Rotation turns the item about its grip, in degrees, as a bone's
	// rotation turns a bone: a positive X tips its top forward.
	Rotation [3]float64
	// Scale resizes the item about its grip. Zero means 1.
	Scale float64
}

ItemAdjust moves a held item from the game's placement, about its grip and in the hand's frame, so it follows the arm. The zero value moves nothing. See docs/equipment.md#adjusting-an-item.

type ItemAnimationBytesOptions added in v0.2.3

type ItemAnimationBytesOptions struct {
	ItemBytesOptions

	Duration float64
	FPS      int
	Frames   int
}

ItemAnimationBytesOptions is ItemAnimationOptions with the item's sprite encoded.

type ItemAnimationOptions added in v0.2.3

type ItemAnimationOptions struct {
	ItemOptions

	// Duration is how long one turn takes, in seconds. Zero means 3.
	Duration float64
	// FPS is frames per second; zero means 20.
	FPS int
	// Frames is how many frames to render; zero means one turn.
	Frames int
}

ItemAnimationOptions spins an item on its own: one full turn about its upright axis each loop, as a dropped item turns. See docs/equipment.md#an-item-on-its-own.

type ItemBytesOptions added in v0.2.3

type ItemBytesOptions struct {
	Item   []byte
	Angle  Angle
	Camera *Camera
	Size   int
	Adjust ItemAdjust
}

ItemBytesOptions is ItemOptions with the item's sprite an encoded PNG or JPEG.

type ItemOptions added in v0.2.3

type ItemOptions struct {
	// Item is the item's sprite, e.g. textures/items/diamond_sword.png.
	Item image.Image
	// Angle picks the camera preset: AngleFront faces the sprite, as an
	// inventory icon; AngleIso turns it to show its depth. Zero means
	// AngleFront. Ignored when Camera is set.
	Angle Angle
	// Camera overrides Angle with an explicit position.
	Camera *Camera
	// Size is the output edge length; the image is square. Zero means
	// DefaultSize.
	Size int
	// Adjust turns and resizes the item about its centre. The camera frames
	// the item whatever its size or offset, so Scale and Offset change only
	// its shape against other parts, not how big it looks.
	Adjust ItemAdjust
}

ItemOptions describes a render of one item on its own, extruded as a held item is. Only Item is required. See docs/equipment.md#an-item-on-its-own.

type Locator

type Locator struct {
	Offset               []float64 `json:"offset"`
	Rotation             []float64 `json:"rotation,omitempty"`
	IgnoreInheritedScale bool      `json:"ignore_inherited_scale,omitempty"`
}

Locator is a named point on a bone - where an item is held, a lead ties, particles start. A file writes one as just an offset, [x, y, z], or as an object with an offset and a rotation; both read into this.

func (*Locator) UnmarshalJSON

func (l *Locator) UnmarshalJSON(raw []byte) error

type Motion

type Motion string

Motion is one of the built-in player animations: Minecraft's own movements, recreated as poses over time. They move bones by their standard names (head, body, rightArm, leftArm, rightLeg, leftLeg), so they work on any model that uses them, custom ones included; whatever is parented under a bone - a sleeve, a hat, a backpack - moves with it. See docs/animation.md.

const (
	MotionWalk  Motion = "walk"  // arms and legs swing, each leg opposite its arm
	MotionIdle  Motion = "idle"  // the gentle breathing sway of an idle player
	MotionWave  Motion = "wave"  // the right arm raised, waving
	MotionSneak Motion = "sneak" // leaning forward, creeping
)

The built-in motions.

func Motions

func Motions() []Motion

Motions returns every built-in motion.

func ParseMotion

func ParseMotion(s string) (Motion, error)

ParseMotion returns the Motion named s.

func (Motion) Duration

func (m Motion) Duration() float64

Duration is how long one loop of the motion lasts, in seconds.

func (Motion) Pose

func (m Motion) Pose(t float64) Pose

Pose is the motion's pose t seconds in; it loops every Duration.

Rotations follow the geometry's convention, in which a positive X turn tips a bone's top toward the front: a limb hanging from its shoulder or hip then swings backward, so swinging it forward is a negative X. Raising an arm out to its side is a positive Z for the right arm, negative for the left.

type Options

type Options struct {
	// Texture is the decoded skin image. Required. Bedrock skins are
	// normally 64x64 or 128x128, but any size works.
	Texture image.Image

	// Geometry is the skin's model, as returned by ParseGeometry. Leave it
	// nil for a skin that uses a built-in model — DefaultGeometry stands in,
	// which is what a Bedrock client would render. See DefaultGeometry for
	// why this is the common case rather than a convenience.
	Geometry []Geometry

	// Identifier picks which entry of Geometry to render when it holds more
	// than one, which is usual: a bundle typically carries a cape entry plus
	// both arm variants. It matches the identifier named in the skin's
	// resource patch, e.g. "geometry.humanoid.customSlim".
	//
	// The resource patch is the authoritative wide-vs-slim selector. The
	// login packet's ArmSize field is not: real captures show the two
	// disagreeing, with ArmSize reporting "wide" for a skin whose patch
	// names customSlim.
	//
	// Empty, or naming an entry that isn't present, falls back to the entry
	// with the most cubes. See SelectGeometry.
	Identifier string

	// Cape is an equipped cape texture, or nil.
	//
	// The cape mesh comes from a "cape" bone in Geometry, or from the
	// built-in geometry.cape when Geometry has none — which is the usual
	// case, since capes live in their own entry and never travel merged into
	// a custom skin's body. Not drawn for ViewHead or ViewAvatar, which do
	// not show one.
	Cape image.Image

	// View selects the framing: body, chest, head or avatar. Zero means
	// ViewBody. Ignored when Parts is set.
	View View

	// Angle picks the camera preset for View: straight-on or the angled
	// 3-quarter "head icon" look. Zero means the default for the chosen
	// View, which is AngleIso for ViewHead and AngleFront elsewhere.
	// Ignored when Camera is set.
	Angle Angle

	// Parts names exactly which bones to render, e.g.
	// []string{"head", "leftArm"}. Each name pulls in everything parented
	// under it, so "head" also brings a hat or hair bone. Empty means use
	// View instead.
	Parts []string

	// Camera overrides the View/Angle framing with an explicit position.
	Camera *Camera

	// Size is the output edge length in pixels; the image is always square.
	// Zero means DefaultSize.
	Size int

	// Pose moves bones from where the geometry puts them, e.g. a frame of a
	// Motion (see Motion.Pose). Nil is the rest pose.
	Pose Pose

	// Animated holds the extra textures a persona skin's animations carry -
	// the face, and animated body parts. Each draws the geometry entry made
	// for it alongside the main one; a persona skin's head lives only in its
	// face entry. See docs/geometry-format.md#persona-skins.
	Animated []AnimatedTexture

	// Armor is the armor worn over the skin. The zero value wears none.
	// See docs/equipment.md.
	Armor Armor
	// RightHand and LeftHand are the items held in each hand. The zero
	// value holds nothing. See docs/equipment.md#held-items.
	RightHand Held
	LeftHand  Held
	// Scale resizes the figure or any bone. The zero value changes
	// nothing. See docs/equipment.md#scale.
	Scale Scale
	// HideSkin draws the equipment alone - armor, elytra, held items and
	// cape - posed and framed as it would be on the skin, so any piece can
	// be rendered by itself; Parts and View pick which. Texture may then be
	// nil. ErrEmptyView or ErrNoMatchingParts means no equipment was left
	// to draw. See docs/equipment.md#equipment-on-its-own.
	HideSkin bool
}

Options describes one render. Only Texture is required.

func (Options) Render

func (o Options) Render() (image.Image, error)

Render renders these options, as the package-level Render function does. It is the method form, for callers who prefer to build options and render them in one expression.

func (Options) RenderPNG

func (o Options) RenderPNG() ([]byte, error)

RenderPNG renders these options and encodes the result as PNG bytes, for callers holding decoded images who nonetheless want bytes back.

func (Options) WritePNG added in v0.2.1

func (o Options) WritePNG(w io.Writer) error

WritePNG renders these options and writes the PNG to w - an HTTP response, a file - without holding the encoded bytes first. It writes the same bytes RenderPNG returns.

type PartReport

type PartReport struct {
	// Name is the body part or overlay/geometry bone name (head, hat, cape, ...).
	Name string `json:"name"`
	// Visibility is why this part counts as rendering or not. Visible reports
	// the same thing as a bool when the distinction does not matter.
	Visibility PartVisibility `json:"visibility"`
	// OpaqueRatio is the share of the part's sampled pixels that are opaque,
	// from 0 to 1.
	OpaqueRatio float64 `json:"opaque_ratio"`
	// Pixels is how many texture pixels were sampled for this part, and
	// Transparent how many of those were see-through.
	Pixels      int `json:"pixels"`
	Transparent int `json:"transparent_pixels"`
	// FromGeometry is true when this part was resolved from real geometry
	// cube UVs rather than the standard-layout fallback.
	FromGeometry bool `json:"from_geometry"`
}

PartReport describes the visibility of one body part after analysis.

func (PartReport) Visible

func (p PartReport) Visible() bool

Visible reports whether the part renders. It is derived from Visibility rather than stored, so the two can never disagree.

type PartVisibility

type PartVisibility int

PartVisibility is the visibility classification of a single body part.

const (
	// PartVisible means the part has enough opaque pixels to render normally.
	PartVisible PartVisibility = iota
	// PartInvisible means the part has no usable opaque pixels.
	PartInvisible
	// PartSuspicious means the part is partially visible but below the
	// minimum visible fraction.
	PartSuspicious
	// PartTiny means the geometry defines the part below the minimum size.
	PartTiny
)

func (PartVisibility) MarshalJSON

func (p PartVisibility) MarshalJSON() ([]byte, error)

MarshalJSON encodes the level as its lowercase name rather than its integer value, so a SkinReport handed straight to json.Marshal reads as "visibility":"invisible" instead of "visibility":1.

func (PartVisibility) String

func (p PartVisibility) String() string

String returns a stable lowercase name for the visibility level.

func (*PartVisibility) UnmarshalJSON

func (p *PartVisibility) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts the names MarshalJSON produces, and the bare integers an older client may still send.

type PolyMesh

type PolyMesh struct {
	// NormalizedUVs means UVs are 0..1 across the texture; otherwise they
	// are texture pixels against the entry's declared texture size.
	NormalizedUVs bool        `json:"normalized_uvs"`
	Positions     [][]float64 `json:"positions"`
	Normals       [][]float64 `json:"normals"`
	UVs           [][]float64 `json:"uvs"`
	// Polys is a list of polygons, each a list of [position, normal, uv]
	// index triples, or the string "tri_list" / "quad_list" for vertices
	// taken in order. Polygons resolves them.
	Polys json.RawMessage `json:"polys"`
}

PolyMesh is a bone's free-form mesh: the shape Bedrock sends for persona (character creator) skins instead of cubes. Positions are in model space, like a cube's origin. See docs/geometry-format.md#poly-meshes.

func (PolyMesh) Polygons added in v0.2.1

func (m PolyMesh) Polygons() [][]PolyVertex

Polygons resolves every polygon to its corners, "tri_list" and "quad_list" included. A polygon with fewer than three corners, or one whose position or UV index points outside the mesh's lists, is skipped - the renderer and the detector skip it too. Normals are not drawn, so a missing one leaves the corner's normal zero rather than dropping the polygon.

Example

Reading a persona skin's poly mesh: every polygon, each corner looked up.

package main

import (
	"fmt"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

func main() {
	geos, err := bedrockskin.ParseGeometry([]byte(`{"minecraft:geometry":[{"description":{"identifier":"geometry.persona_x"},
		"bones":[{"name":"body","poly_mesh":{"normalized_uvs":true,
			"positions":[[-4,12,-2],[4,12,-2],[4,24,-2],[-4,24,-2]],
			"normals":[[0,0,-1]],
			"uvs":[[0.25,0.5],[0.375,0.5],[0.375,0.6875],[0.25,0.6875]],
			"polys":[[[0,0,0],[1,0,1],[2,0,2],[3,0,3]]]}}]}]}`))
	if err != nil {
		panic(err)
	}
	mesh, _ := geos[0].Bones[0].Mesh()
	for _, poly := range mesh.Polygons() {
		fmt.Println(len(poly), "corners, first at", poly[0].Position, "facing", poly[0].Normal)
	}
}
Output:
4 corners, first at [-4 12 -2] facing [0 0 -1]

type PolyVertex added in v0.2.1

type PolyVertex struct {
	Position [3]float64
	Normal   [3]float64 // zero when the corner names no usable normal
	UV       [2]float64
}

PolyVertex is one corner of a polygon, its indices looked up: a position in model space, a normal, and a texture coordinate - 0..1 with V counting up when the mesh's NormalizedUVs is set, else texture pixels.

type Pose

type Pose map[string]BonePose

Pose is a BonePose for each bone it moves, by name. Bones not in it stay at rest. A name matches a bone exactly, or failing that case-insensitively, as animation files are matched in game.

type ResourcePatch

type ResourcePatch struct {
	// Default is the identifier of the body geometry, e.g.
	// "geometry.humanoid.customSlim". Pass it as Options.Identifier.
	Default string
	// Cape is the cape geometry identifier when the patch names one, and
	// empty otherwise. Most patches name only Default.
	Cape string
}

ResourcePatch is a skin's decoded SkinResourcePatch: the mapping from render slot to geometry identifier that a Bedrock client sends alongside the texture. See docs/skin-data.md#the-resource-patch-is-the-authoritative-model-selector.

func ParseResourcePatch

func ParseResourcePatch(raw []byte) (ResourcePatch, error)

ParseResourcePatch decodes a skin's resource patch and returns the geometry identifiers it names.

patch, err := bedrockskin.ParseResourcePatch(raw)
if err != nil {
	return err
}
img, err := bedrockskin.Render(bedrockskin.Options{
	Texture:    tex,
	Geometry:   geos,
	Identifier: patch.Default,
})

The patch is the authoritative wide-vs-slim selector, which is why this exists rather than leaving callers to hand-roll the struct: the login packet's ArmSize field disagrees with it on real captures, reporting "wide" for a skin whose patch names customSlim.

Empty input, or the literal "null", returns a zero ResourcePatch and no error - the same "nothing was sent" case IsEmpty covers for geometry. A patch that parses but names no default is not an error either; check Default against "" if you need one, and fall back to SelectGeometry's cube-count heuristic when it is absent.

type Scale added in v0.2.3

type Scale struct {
	// Model is the figure's size in the image: 2 draws it twice as large,
	// cropping what no longer fits; 0.5 half as large. Zero means 1. The
	// camera frames the model whatever its size, so only this changes how
	// big it looks.
	Model float64
	// Parts scales bones by name, ignoring case, each about its own pivot
	// and carrying everything parented under it: the armor on it, and an
	// arm's held item. 0 hides a bone.
	Parts map[string]float64
}

Scale resizes the figure or any of its bones. The zero value changes nothing. A held item has its own scale, in ItemAdjust. See docs/equipment.md#scale.

type Skin

type Skin struct {
	// contains filtered or unexported fields
}

Skin bundles a decoded texture with its optional geometry so callers can question body-part visibility in one place.

A Skin must not be copied after first use, and must be used through the pointer NewSkin returns.

func NewSkin

func NewSkin(texture image.Image, geometry []byte) *Skin

NewSkin builds a Skin for analysis with the default thresholds. geometry is raw geometry.json or nil.

Example

Blocking the invisible-skin trick: a fully transparent skin is caught.

package main

import (
	"fmt"
	"image"
	"image/color"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

// skinTexture stands in for a decoded skin file: 64x64, every pixel opaque.
func skinTexture() *image.NRGBA {
	img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
	for y := 0; y < 64; y++ {
		for x := 0; x < 64; x++ {
			img.Set(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
		}
	}
	return img
}

func main() {
	clear := image.NewNRGBA(image.Rect(0, 0, 64, 64))
	fmt.Println(bedrockskin.NewSkin(clear, nil).IsInvisible())
	fmt.Println(bedrockskin.NewSkin(skinTexture(), nil).IsInvisible())
}
Output:
true
false

func NewSkinWithOptions

func NewSkinWithOptions(texture image.Image, geometry []byte, opts SkinOptions) *Skin

NewSkinWithOptions builds a Skin that judges by opts rather than the defaults. It is otherwise identical to NewSkin, caching and all.

func (*Skin) InvisibleParts

func (s *Skin) InvisibleParts() []string

InvisibleParts returns the names of the invisible body parts.

func (*Skin) IsInvisible

func (s *Skin) IsInvisible() bool

IsInvisible reports whether the whole skin is effectively invisible.

func (*Skin) IsSuspicious

func (s *Skin) IsSuspicious() bool

IsSuspicious reports whether the skin is half-invisible (not fully invisible, but several body parts are missing).

func (*Skin) OK

func (s *Skin) OK() bool

OK reports whether the skin is acceptable, the single question most callers have. Equivalent to Report().OK().

func (*Skin) Parts

func (s *Skin) Parts() []PartReport

Parts returns the per-part breakdown, equivalent to Report().Parts.

func (*Skin) Report

func (s *Skin) Report() SkinReport

Report runs the full visibility analysis and returns the breakdown.

The result is computed once and reused, so asking several questions of one Skin costs a single pass over the texture. It is safe to call concurrently from multiple goroutines, which a service sharing one Skin across requests will do. Each call returns its own copy of the slices, so a caller is free to sort or filter them without disturbing the cached report.

type SkinOptions

type SkinOptions struct {
	// MinVisibleFraction is the share of a part's sampled pixels that must be
	// opaque for it to count as visible. Zero means DefaultMinVisibleFraction.
	MinVisibleFraction float64

	// MinGeometrySize is the world-space size a bone must reach to avoid
	// being judged too small to see. Zero means DefaultMinGeometrySize. It
	// applies only when geometry is supplied.
	MinGeometrySize float64

	// MinVisibleParts is how many of the six standard body parts must be
	// visible before the skin stops being suspicious. Zero means
	// DefaultMinVisibleParts.
	MinVisibleParts int
}

SkinOptions tunes the thresholds one Skin judges by. A zero field takes its Default* value, so the zero SkinOptions is exactly what NewSkin uses.

The knobs exist because what counts as unacceptable is policy, the same reasoning that keeps size and complexity limits out of the library. A lobby that only ever shows head icons might care solely about the head; a strict server might demand every part be near-opaque.

type SkinPartResult

type SkinPartResult struct {
	Name        string
	Visible     bool
	Fraction    float64
	Pixels      int
	Transparent int
	FromGeo     bool // true when the part came from geometry, not the fallback layout
	Tiny        bool // true when the geometry defines the part below the minimum size
}

SkinPartResult holds the visibility result for one body part. When geometry is provided, results reflect the actual cube UV regions resolved from the geometry. Without geometry, the standard vanilla UV layout is assumed.

type SkinReport

type SkinReport struct {
	// Verdict is the overall judgement.
	Verdict Verdict `json:"verdict"`
	// VisibleParts is how many of the standard body parts render, out of
	// TotalParts. Overlay layers fold into the part they cover and
	// accessories are ignored, so an opaque cape cannot mask an invisible
	// body.
	VisibleParts int `json:"visible_parts"`
	TotalParts   int `json:"total_parts"`
	// Parts is the per-part breakdown: the standard body parts first, in a
	// fixed order, then every other bone sorted by name.
	Parts []PartReport `json:"parts"`
}

SkinReport is the result of analyzing a skin for invisible or suspicious body parts. It is safe to marshal straight to JSON from an API.

Everything derivable is a method rather than a field, so there is exactly one place each fact is stated and no way for the JSON to contradict itself.

func (SkinReport) InvisibleParts

func (r SkinReport) InvisibleParts() []string

InvisibleParts returns the names of the standard body parts that do not render, in the order they appear in Parts. It is derived from Parts on each call rather than stored.

func (SkinReport) OK

func (r SkinReport) OK() bool

OK reports whether the skin is acceptable. It is false for a zero SkinReport, so a report that was never filled in does not read as a pass.

type SkinVisibilityResult

type SkinVisibilityResult struct {
	IsInvisible    bool
	Pass           bool
	Suspicious     bool
	Parts          []SkinPartResult
	VisibleParts   int
	InvisibleParts int
}

SkinVisibilityResult holds the result of ValidateSkinVisibility.

IsInvisible is true when no body part is visible. Suspicious flags a half-invisible skin: at least one part is visible, but fewer of the standard body parts than the configured minimum, DefaultMinVisibleParts by default.

func ValidateSkinInvisibility

func ValidateSkinInvisibility(texture image.Image, geomData []byte) SkinVisibilityResult

ValidateSkinInvisibility is the main entry point. It cross-references the geometry against the texture: for every bone the geometry defines, the corresponding UV regions are checked in the actual texture pixels. A skin is invisible if every rendered body part is either transparent in the texture or defined by geometry too small to see.

geomData is the raw geometry.json bytes. Pass nil for standard skins (most real skins send no geometry — the default geometry is assumed).

func ValidateSkinVisibility

func ValidateSkinVisibility(texture image.Image, geomData []byte, minVisibleFraction float64) SkinVisibilityResult

ValidateSkinVisibility checks whether a skin texture has visible body parts. When geomData is provided (raw geometry.json), every bone with cubes is examined: its UV rectangles are resolved from the geometry and the actual texture pixels are checked for alpha. This cross-references geometry against the texture so a player cannot hide behind geometry that maps to transparent regions.

Without geometry (nil geomData), the standard vanilla humanoid UV layout is used as the fallback — correct for most real skins.

Persona geometry - geometry that parses into bones, none of which carry cubes - has no usable box UVs to cross-reference and comes from Minecraft's curated skin market, so it is always treated as visible and never flagged suspicious. Geometry that fails to parse is not that: it falls through to the texture-only check, exactly as nil geomData does.

A skin is invisible if every body part has fewer than minVisibleFraction non-transparent pixels at its rendered UV regions.

type Verdict

type Verdict int

Verdict is the overall judgement on a skin.

It is one value with a fixed set of states rather than the three independent booleans this used to carry (Pass, IsInvisible, IsSuspicious), which could express five combinations that mean nothing - invisible and suspicious at once, or passing while invisible.

const (
	// VerdictUnknown is the zero value: no analysis has been run. It exists
	// so an uninitialized SkinReport does not claim a skin is fine. OK
	// reports false for it, so the zero value fails closed.
	VerdictUnknown Verdict = iota
	// VerdictOK means the skin renders normally.
	VerdictOK
	// VerdictSuspicious means some standard body parts do not render, but
	// enough do that the skin is not simply invisible. A soft signal: worth
	// logging or reviewing, not necessarily worth rejecting.
	VerdictSuspicious
	// VerdictInvisible means nothing renders, or only a stray limb does.
	VerdictInvisible
)

func (Verdict) MarshalJSON

func (v Verdict) MarshalJSON() ([]byte, error)

MarshalJSON encodes the verdict as its lowercase name.

func (Verdict) String

func (v Verdict) String() string

String returns a stable lowercase name for the verdict.

func (*Verdict) UnmarshalJSON

func (v *Verdict) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts the names MarshalJSON produces.

type View

type View string

View selects which part of the model to render. Bone inclusion is ancestry-based rather than a fixed list, so a custom skin's extra bones (ears, tails, wings, party hats) are picked up automatically as long as they are parented somewhere under a standard anchor.

See docs/views-and-cameras.md.

const (
	ViewBody   View = "body"   // full figure
	ViewChest  View = "chest"  // waist-up / bust, arms included
	ViewHead   View = "head"   // head only (+ descendants: hat, custom ear/horn/helmet bones, etc.)
	ViewAvatar View = "avatar" // square head icon, closer-framed than ViewHead
)

func ParseView

func ParseView(raw string) (View, error)

ParseView resolves a view name, as it would arrive in a query string or a config file, to a View. Matching ignores case and surrounding space, and blank input returns ViewBody.

An unrecognised name is an error wrapping ErrUnknownView rather than a silent fallback. Options.View is a bare string type that accepts anything, so a request for "avatr" would otherwise render a full body and look like the service ignoring its caller.

type WireAnimation added in v0.2.1

type WireAnimation struct {
	Type          AnimatedType
	Data          []byte
	Width, Height int
}

WireAnimation is one entry of a skin's animation list: its image as raw RGBA, and its type as the protocol numbers it.

type WireSkin added in v0.2.1

type WireSkin struct {
	// SkinData is the skin's pixels, raw non-premultiplied RGBA,
	// SkinWidth*SkinHeight*4 bytes. Required.
	SkinData              []byte
	SkinWidth, SkinHeight int

	// CapeData is the cape's pixels in the same form; empty for no cape.
	CapeData              []byte
	CapeWidth, CapeHeight int

	// Geometry is SkinGeometryData. Empty or the literal "null" - what a
	// client sends for a built-in model - draws the default model.
	Geometry []byte

	// ResourcePatch is SkinResourcePatch: it names which entry of Geometry
	// the skin uses, wide or slim among them.
	ResourcePatch []byte

	// Animations is the skin's animation list. A persona skin's face, and
	// some of its body, are textured by these.
	Animations []WireAnimation
}

WireSkin is a skin as a Bedrock client sends it - in the login packet, a PlayerList entry or a PlayerSkin packet: images as raw RGBA with their sizes alongside, and the model as JSON. The field names follow the protocol's, so a proxy or bot can fill one straight from the packet.

opts, err := bedrockskin.WireSkin{
	SkinData: s.SkinData, SkinWidth: int(s.SkinImageWidth), SkinHeight: int(s.SkinImageHeight),
	Geometry: s.SkinGeometry, ResourcePatch: s.SkinResourcePatch,
}.Options()

See docs/skin-data.md for what each field holds.

func (WireSkin) Options added in v0.2.1

func (w WireSkin) Options() (Options, error)

Options turns the wire fields into render Options: the images wrapped without copying, the geometry parsed, the entry the resource patch names selected, and the animation images attached so persona heads draw. Set View, Size and the rest on the result.

A resource patch that does not parse is not an error - the patch only picks an entry, and without it the entry with the most cubes is used, as for an empty patch. Malformed geometry and images whose data does not match their size are errors. An animation of a type the renderer does not draw is kept and ignored.

Example

A skin straight from a Bedrock packet: raw RGBA, "null" geometry for a built-in model, and the resource patch naming the slim arms.

package main

import (
	"fmt"
	"image"
	"image/color"

	bedrockskin "github.com/THEBOSS9345/bedrock-skin-go"
)

// skinTexture stands in for a decoded skin file: 64x64, every pixel opaque.
func skinTexture() *image.NRGBA {
	img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
	for y := 0; y < 64; y++ {
		for x := 0; x < 64; x++ {
			img.Set(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
		}
	}
	return img
}

func main() {
	pix := skinTexture().Pix
	opts, err := bedrockskin.WireSkin{
		SkinData:      pix,
		SkinWidth:     64,
		SkinHeight:    64,
		Geometry:      []byte("null"),
		ResourcePatch: []byte(`{"geometry":{"default":"geometry.humanoid.customSlim"}}`),
	}.Options()
	if err != nil {
		panic(err)
	}
	opts.View = bedrockskin.ViewChest
	opts.Size = 96
	img, err := opts.Render()
	if err != nil {
		panic(err)
	}
	fmt.Println(opts.Identifier, img.Bounds().Dx())
}
Output:
geometry.humanoid.customSlim 96

func (WireSkin) Skin added in v0.2.1

func (w WireSkin) Skin() (*Skin, error)

Skin is the invisibility detector's view of the same wire fields: the skin texture and its geometry. It does not need the cape or the animations.

Directories

Path Synopsis
cmd
bedrock-skin command
Command bedrock-skin renders a skin file from the command line, to try the library before writing any code:
Command bedrock-skin renders a skin file from the command line, to try the library before writing any code:

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址