This section describes how bones influence the data of skinned meshes. Skinned position/normal data in the mesh section gets updated at runtime based on this section.
Only submesh 0 is ever skinned. Every SK1/SK2/SKAcc destination is an offset into submesh 0's position array, and all 172 player-folder models with SKN have their only skinned (CompCount 6) buffer in submesh 0 (survey, 2026-09-16). Any other submesh can follow the skeleton only rigidly, through the one bone that owns it (see GEO ID ownership rules). For a mesh on a single bone, that looks the same as SK1 skinning at weight 1.0.
| Offset | Type | Description |
|---|---|---|
| 0x0 | uint16 | Number of SK1 headers |
| 0x2 | uint16 | Number of SK2 headers |
| 0x4 | uint16 | Number of SKAcc headers |
| 0x6 | uint8 | Quantize info (This seems to only work correctly when the high nibble is 0) |
| 0x7 | byte | Padding |
| 0x8 | uint32 | Pointer to SK1 structures (relative to header) |
| 0xc | uint32 | Pointer to SK2 structures (relative to header) |
| 0x10 | uint32 | Pointer to SKAcc structures (relative to header) |
| 0x14 | uint32 | Start of the memory region to clear before processing SK entries, as a byte offset from the start of submesh 0's raw position data array (same convention as gplVertexArr / gplDestArr — position-data-relative, not GPL-section-relative and not SKN-section-relative). Empirically verified against all 325 skinned models (324 exact): the cleared region covers exactly the vertex slots written by SKAcc (accumulation) entries that are not written by any SK1/SK2 (direct-write) entry. memClrPtr = min(accumulation-only slot offsets), or 0 when no such slots exist. Slot offsets: SK1/SK2 write to gplVertexArr + vertexOffset + k × stride; SKAcc writes to gplDestArr + destIndex[k] × stride. |
| 0x18 | uint32 | Size of memory to clear in the mesh data: normally align32(max(accumulation-only slot offsets) + stride - memClrPtr). Vanilla Birdo's High model (chunk 35 file 0) clears 0x80 bytes from 0x5BC8 where that formula gives 0x60, and the game runs it. It is the only exception among the 202 vanilla High/Low blocks of directories 18-118 and their 294 equipment blocks. BlockValidator therefore only warns about a wider range from the same start; a range that touches SK1/SK2 write lines, starts elsewhere, or is narrower still fails (2026-10-15). |
| 0x1c | uint32 | Pointer to array of uint16 vertex indices to 'flush' (exact runtime purpose unknown). Observed: roughly one index per 32-byte cache line of the position buffer that SKAcc entries accumulate into, grouped by destination region, excluding lines fully inside the memClr range. Vanilla arrays follow no single reproducible formula (26/178 exact with the best candidate). Dolphin ignores CPU cache effects, so a wrong flush array may be invisible there. |
| 0x20 | uint32 | Number of indices to flush |
The memory clearing part is weird. The SKAcc headers work by adding (accumulating) the influences from multiple bones in-place in the position data, so without clearing the memory the correct value for a skinned position will be added to whatever position was used last frame. Slots written by SK1/SK2 are plain overwrites and need no clearing — the cleared region covers only the accumulation-only slots.
The vertex stride — bytes per vertex in the skinned position buffer — is component_size(quantize_info) * 6, where component_size returns 4 for float formats (high nibble 4, 7, or 0xa) and 2 otherwise. memClrSize = align32_up(max(accumulation-only slot) + stride − memClrPtr) — the slot span rounded up to a 32-byte multiple (0 when there are no accumulation-only slots). Verified on 324 of 325 skinned models; the single outlier (model at 0x8808B60) clears one extra cache line.
The simplest case. This signifies a group of vertex positions that are only influenced by a single bone.
| Offset | Type | Description |
|---|---|---|
| 0x0 | Matrix | An 0x30 byte placeholder for a matrix that gets populated at runtime based on the SK1's bone's animation. I believe this is a 4x3 matrix, with the last column assumed to be (0, 0, 0, 1). |
| 0x30 | uint32 | Source position/normal array pointer (relative to header) |
| 0x34 | uint32 | Destination position/normal pointer (byte offset from the start of submesh 0's raw position data array; not relative to this section) |
| 0x38 | uint16 | Bone index (which bone influences these vertices) |
| 0x3a | uint16 | Vertex count. At least 3; see Runtime limits. |
| 0x3c | uint8 | Position/normal offset: byte offset of the first vertex inside the destination's first 32-byte cache line. The destination pointer (0x34) is always on a cache-line boundary, and destination + offset is always on a vertex boundary. Observed values for stride 12 are 0, 4 and 8 only (361 skinned models, 12,364 SK1/SK2 entries). See Destination layout rules. |
| 0x3d | byte[3] | Padding |
The way this works is pretty weird.
First, read the quantized data from (source_position_normal_array_pointer + position_normal_offset). This holds a series of alternating positions and normals, one of each per vertex, so will be (3 + 3) * vertex_count items long. Skinned meshes store six values per position, an x, y, z for the position followed by an x, y, z for the normal. This might mean that the normal section in a skinned mesh's data is completely ignored?
After being transformed, the position/normal data from the source is inserted into the position data of the first mesh at (destination_position_normal_pointer + position_normal_offset) relative to the start of the raw quantized position data. Effectively, there are vertex_cnt sequential vertex positions influenced by this SK1's bone with an assumed weight of 255, starting at index (destination_position_normal_array_pointer + position_normal_offset) / (quantized_data_size(mesh_data.meshes[0].positions.quantization) * 6)
Very similar to SK1, but there are two bones influencing each vertex with different weights.
| Offset | Type | Description |
|---|---|---|
| 0x0 | Matrix | Two 0x30-byte matrix placeholders (one per bone), totalling 0x60 bytes |
| 0x60 | uint32 | Source position/normal array pointer (relative to header) |
| 0x64 | uint32 | Weight array pointer (relative to header) |
| 0x68 | uint32 | Destination position/normal pointer (byte offset from the start of submesh 0's raw position data array; not relative to this section) |
| 0x6c | uint16 | Bone index 1 |
| 0x6e | uint16 | Bone index 2 |
| 0x70 | uint16 | Vertex count. At least 3; see Runtime limits. |
| 0x72 | uint8 | Position/normal offset |
| 0x73 | byte | Padding |
Process this similar to SK1 entries. The weight array (without the position/normal offset) has vertex_count * 2 uint8 entries. Bone 1 influences vertex n with weights[n * 2], and bone 2 with weights[n * 2 + 1]. Vanilla data establishes a 256-unit logical divisor: 89,381 observed SK2 pairs sum to 256, while lower SK2 sums may receive additional SKAcc contributions.
An SK2 weight pair does not have to sum to 256. When a vertex also receives SKAcc contributions, its 256-unit budget is split across the SK2 pair and the SKAcc weights (Luigi: pairs summing to 77, 115 or 205 with the rest supplied by SKAcc). Validators must not enforce a per-pair sum.
These entries are used to supplement SK2s for vertices that have more than two bone influences.
| Offset | Type | Description |
|---|---|---|
| 0x0 | Matrix | An 0x30 byte placeholder for a matrix |
| 0x30 | uint32 | Source position/normal array pointer (relative to header) |
| 0x34 | uint32 | Destination position/normal index array pointer (relative to header) |
| 0x38 | uint32 | Destination position/normal pointer (byte offset from the start of submesh 0's raw position data array; not relative to this section) |
| 0x3c | uint32 | Weight array pointer (relative to header) |
| 0x40 | uint16 | Bone index (which bone influences these vertices) |
| 0x42 | uint16 | Vertex count |
Some vertices are written only by SKAcc entries, with no SK1/SK2 entry at all. These accumulation-only vertices are the ones the memClr range zeroes each frame. Luigi's fingertip vertices, for example, get 192/64 splits from two different SKAcc bones. Such a vertex must stay accumulation-only when its bones are reassigned (see Destination layout rules).
This entry type has one bone index and one weight per vertex. Its gimmick is that it has an array of destination indexes stored as uint16s, rather than a single pointer in the position data to place the entries into sequentially. Unlike SK1 and SK2, SKAcc has no position/normal offset field; the offset is effectively zero. Each vertex position's index is destination_position_normal_array_pointer / (quantized_data_size(mesh_data.meshes[0].positions.quantization) * 6) + destination_position_normal_index_array[n]
Some SKAcc entries intentionally list the same destination vertex index more than once within a single entry's index array, each occurrence carrying a different weight. This has been observed in the low-poly version of a character model for the finger-joint vertices, where a single bone accumulates into the same destination vertex twice per entry.
In all observed cases the duplicate pairs have:
255 for the first occurrence and 1 for the second (i.e. ~100 % and ~0.4 %).The purpose appears to be a lightweight twist/multi-stage deformer: the game engine accumulates the same bone's contribution twice into the destination vertex with two distinct weight scalings. All 936 duplicate groups measured across the skinned models are exactly 255 + 1, giving (weight_0 + weight_1) / 256 = 1.0.
Implication for tools: The weight array is indexed by source-array position, not by destination vertex. Two source entries that share a destination index are independent records with independent weights. Any tool that maps weights by (bone, destination_vertex) key — such as a vertex-group system that stores a single weight per bone per vertex — will silently discard the first weight when it encounters the second, corrupting the skinning on round-trip. The original per-source-entry weight data must be preserved verbatim for all source slots whose destination index appears more than once in the same entry.
These rules describe where entries write inside submesh 0's position buffer. Each held with zero exceptions across all 361 skinned models exported from the game (12,364 SK1/SK2 entries). A 2026-09-16 scan checked them against the exported .sluggie files.
gplVertexArr is a multiple of 32, and the first vertex is at gplVertexArr + vertexOffset with vertexOffset < stride.gplDestArr is 0.Consequence for editing: moving vertices between bones without reordering them only works while every vertex keeps its entry type. Examples: SK1 stays SK1, a two-bone SK2 vertex stays SK2, and an accumulation-only vertex stays accumulation-only. Otherwise part of an entry changes type in the middle of a cache line and would share that line with its neighbour. Luigi examples: reassigning bone 28 to bone 63 lays out in place, but reassigning bone 5 to bone 17 turns part of SK2(5,17) into SK1(17) mid-line and needs vertex reordering.
The game skins SK1/SK2 entries through the CPU's locked cache: it copies an entry's source into a locked-cache buffer, transforms it there, and copies the result back to the position buffer. That gives each entry hard size limits and a write-back that is wider than its vertices. The rules below come from the Sluggers Characters Beta patcher team (their check_models.py in _Sluggers Characters Beta External Tool/). They found them from Dolphin crashes of their own custom character builds. A scan of the 180 distinct skinned model blocks in the input dt_na.dat (10,246 SK1/SK2 entries, 2026-10-01) found no vanilla exception to any of them.
FUN_80523e0c) sets the count register to vertexCount - 1 and loops with bdnz. A 1-vertex entry therefore wraps to 232 iterations and reads past the locked cache. Their Dolphin log: Invalid read from 0xe0040000, PC 0x80523ec4. The vanilla minimum is exactly 3 for both SK1 and SK2. Counts 0 and 2 have not been tested in-game; they are refused because vanilla never uses them.vertexOffset + vertexCount × stride may be at most 8180 bytes for SK1 and 4088 bytes for SK2. These are the 8 KB and 4 KB locked-cache buffers, and they are also the exact vanilla maxima. Larger groups must be split into several entries, as vanilla does (one bone or pair can own several entries).FUN_80597bec) moves (bytes + 31) >> 5 lines, so an entry writes up to gplVertexArr + align32(vertexOffset + vertexCount × stride), not only to its last vertex. No GPL structure may start inside that range. That includes array headers, submesh layouts and display-state tables, not just the arrays the scratch-space rule (see Rebuilding considerations) already covers. A header there is overwritten every frame. Their example is Pom Pom: Invalid read from 0x00000000, PC 0x8052ec90 in FUN_8052e99c.memClr is not part of the write-back rule. Vanilla chunk 2 file 0 (memClr 0x9624/0x140, 3,228 vertices at stride 12) clears 0x14 bytes past the end of its position array into submesh 0's colour-array header, and the game runs fine. So the structure check uses only the SK1/SK2 write-back. The existing scratch-window check (arrays against SK1/SK2, SKAcc and memClr ends) is unchanged.
Where the tools enforce them:
binfmt.skn_direct_entry_problem holds the count and size limits (SKN_MIN_DIRECT_VERTICES, SKN_MAX_SOURCE_BYTES). It is shared by every path.BlockValidator fails a block that breaks the count, size or write-back rule.Hammerspace/SkinnedRebuild.py) pads an entry under 3 vertices by repeating its first vertex and splits an entry over the size limit at a vertex that starts a cache line. (Until 2026-10-09 a same-count membership edit was laid out around the donor entries instead and refused these cases.)HammerspaceMain._allowed_skin_bones is the donor's SkinData bone set plus the added bones of BoneHierarchyEdited. _validate_skin_bones refuses a SkinDataEdited outside it, _validate_skinned_rebuild a SkinnedRebuild outside it, and Rule 10 of _validate_bone_hierarchy_edited any SKN record naming a bone that is neither a donor nor an added bone. The Blender add-on moves weights on refused bones to the nearest allowed ancestor on export; these checks are the patch-time backstop.BlockValidator fails a block whose SKN names a bone id at or past the ACT bone count, and reports the SKN's bone ids as skn_bone_ids.LodPartnerGuard and SlotTarget treat a Low model's skin bones like its drawn bones: the High model must have them (LOD pairs).patch_skn_inplace.skn_edit_problems) refuses, before writing anything, a VertexCntEdited that breaks the count or size limits, and an SK1 BoneIndexEdited that names a bone this model does not skin to.The SKN section is laid out as a contiguous block with the following regions in order:
numSK1 × 0x40 bytes, immediately after header at offset 0x24numSK2 × 0x74 bytes, immediately after SK1 arraynumSKAcc × 0x44 bytes, immediately after SK2 arrayVAR_DATA_OFF + gplVertexArr (gaps included), followed by flush, weight, and SKAcc arrays, each individually 32-byte alignedThe struct arrays are packed contiguously with no gaps between them. Between the end of the SKAcc struct array and the start of the variable data, zero-filled padding is inserted so that the first variable data array begins on a 32-byte boundary.
SK1/SK2 source arrays are not packed back to back. They mirror the skinned position buffer:
srcArrPtr = VAR_DATA_OFF + gplVertexArr (VAR_DATA_OFF = align32(end of SKAcc struct array))
This held for every SK1/SK2 entry in all 361 skinned models, with zero exceptions. The vertex-offset prefix bytes and the unused gap lines between entries are therefore mirrored as well. The source data is effectively a copy of the position buffer laid out with the same offsets.
The runtime depends on this beyond the per-entry pointers. Facial poses on skinned vertices are written to the mirrored address (VAR_DATA_OFF + vertex_index × stride), not through the entry's own source pointer (see facial poses). Two in-game failures traced back to this:
SK2(50,51), the neck strip, holds 12 facial-pose target vertices. Facial poses were written 8 bytes out of step into neighbouring records, and the back of the head and neck stretched down to the floor. Mirroring the sources fixed it in-game.No other section (GPL, ACT, facial) stores pointers into SKN, so the runtime appears to derive the mirror base from the struct-array sizes. Keep VAR_DATA_OFF consistent with the counts in the header.
For unchanged skinning, preserve every exported SKN-relative array pointer and zero-filled gap (array_absolute_ptr - SKNOffset). This reproduces the donor SKN byte for byte.
For rebuilt skinning, the vanilla order is:
The first array after the mirrored region starts on the next 32-byte boundary after the last SK1/SK2 source (305/361 models). The other 56 models leave one extra 32-byte line, and all of them have accumulation-only slots, but no rule was identified. The builder uses no extra line; these arrays are reached only through their own pointers. Every array starts on a 32-byte boundary. Signatures seen: flush, SK2 weights, SKAcc (190 models); SK2 weights only (154); SK2 weights, SKAcc (17).
Entry order: vanilla SK1 and SK2 entries are in ascending destination order. The order of SKAcc entries is not sorted by bone (Luigi starts 62, 27, 18, 20, …). A Luigi build that re-sorted SKAcc entries by bone, with unchanged flush bytes, rendered correctly in-game, so SKAcc order does not appear to matter.
All variable data pointers in this section must be 32-byte aligned relative to the model block. The SKN section start is itself aligned (see the overview), so SKN-relative alignment is the same thing. Across the vanilla survey, 100% of arrays comply:
| Array | Count | mod 32 values |
|---|---|---|
| Source (bind-pose) | 14,436 | {0} |
| Weight | 9,703 | {0} |
| Dest-index | 3,323 | {0} |
| Flush index | 173 | {0} |
The CPU deformer appears to walk these arrays in cache-line units; the exact instruction-level reason is inferred, not confirmed. Compute the variable data start as:
VAR_DATA_OFF = (end_of_SKAcc_array + 31) & ~31
Then insert VAR_DATA_OFF - end_of_SKAcc_array zero bytes between the struct arrays and the variable data blob. The builder pads every array to a 32-byte multiple (ModelFormat.pad_array), so the next array starts aligned.
Alignment alone is not enough. SK1/SK2 sources must also be at their exact mirrored address (see Source mirror rule). The Peach and Luigi failures above both passed every alignment, bounds and count check.
memClrPtr needs no alignment. In 165 non-zero vanilla ranges, memClrSize is always a multiple of 32, but memClrPtr is not always 32-aligned (observed mod 32 values include 0, 4 and 8). The per-frame clear probably uses a plain store loop rather than whole-cache-line zeroing. The original survey reported 12 and 20 as well in one place; this has not been re-checked.
SK1 and SK2 entries have a position_normal_offset field (1 byte). The source array pointer points to the start of the allocated buffer, but actual vertex data begins at srcArrPtr + position_normal_offset. The prefix bytes (between srcArrPtr and the first vertex) must exist and should be zero-filled.
When a tool exports edited bind-pose data without the prefix (starting directly at the first vertex), the builder must prepend position_normal_offset zero bytes to the data before writing it into the source array slot. The total source array length for SK1/SK2 is:
source_array_length = position_normal_offset + vertex_count × vertex_stride
where vertex_stride = component_size(quantize_info) × 6.
SKAcc entries have no position_normal_offset field; their source arrays start directly at the first vertex.
Two different pointer conventions are used within the SKN section:
| Field | Reference base | Description |
|---|---|---|
| srcArrPtr, weightArrPtr, destIdxArrPtr, flushIndPtr | SKN section start | Byte offset from the beginning of the SKN header (offset 0x0 of this section) |
| gplVertexArr (SK1, SK2) | Submesh 0 position data | Byte offset from the start of submesh 0's raw position data array in the GPL section. Used as the destination write address. |
| gplDestArr (SKAcc) | Submesh 0 position data | Same as gplVertexArr — byte offset from submesh 0's position array start. Combined with the dest-index array to compute per-vertex write positions. |
| memClrPtr (header +0x14) | Submesh 0 position data | Position-data-relative (empirically verified — same base as gplVertexArr / gplDestArr). Points to the first accumulation-only vertex slot to zero each frame. Equal to min(SKAcc slots not covered by SK1/SK2 writes), 0 when none exist. |
BlockValidator rejects violations of either. SK1/SK2 entries are ordered by destination. SKAcc entry order was re-sorted by bone without visible effect (Luigi).SkinnedRebuild, and the patcher lays the whole skin out canonically (see Canonical layout). The earlier same-count layout that kept the donor's entries (SkinDataEdited.MembershipEdited) is gone; such files are refused with a re-export request. Weight-value changes on an unchanged bone set still patch the donor's weight bytes in place.max(stored_array_length, max_write_end) bytes, where an SK1/SK2 entry's write end is its aligned write-back end (gplVertexArr + align32(vertexOffset + vertexCount × stride), see Runtime limits) and an SKAcc or memClr write ends at its last slot; packing the next data array (e.g. vertex colors) directly after the position data gets it overwritten by the per-frame memClr/SKAcc writes, which manifests as corrupted colors/partial transparency in-game.When the skinned body is rebuilt (topology, Object Mode transform, surface or bone-set changes; see Rebuilding the skinned body), the patcher writes a whole new SKN with the layout of the Sluggers Characters Beta tool, whose 27 shipped characters run in game (Hammerspace/SkinnedRebuild.py). Confirmed in Dolphin (2026-10-09) with vertices added and with vertices removed.
(vertex, bone, weight) triples. Zero weights are removed and each vertex is quantized to 256 by the largest-remainder rule. The allowed bones are the ones the donor's skin uses at this LOD plus the bones the export appends (SkinnedRebuildExport.allowed_skin_bones). A weight on any other bone moves to that bone's nearest allowed ancestor (the external tool's to_allowed), with a warning; a weight with no such ancestor is dropped, and a used vertex left with no weight stops the export. An added bone the body weights is exported with Skinned set (GEO ID FFFF).vertexOffset is the offset of the first whole vertex in that line.A weight change that keeps every vertex's bone set (only the values change) still patches the donor's weight bytes in place.