Skip to content

Staining Template parsing#36

Closed
AzurIce wants to merge 1 commit intoredstrate:mainfrom
AzurIce:main
Closed

Staining Template parsing#36
AzurIce wants to merge 1 commit intoredstrate:mainfrom
AzurIce:main

Conversation

@AzurIce
Copy link
Contributor

@AzurIce AzurIce commented Feb 13, 2026

closes: #35


Disclaimer: This PR — including the implementation, investigation, and most of this description — was primarily
produced with the assistance of Claude Opus 4.6 (Anthropic's AI model). I have not done an exhaustive deep-dive
into every edge case of the format — I'm submitting this as a draft PR for reference to see if the findings and
implementation are helpful. I'm also actively using this parser in my own toy project
(tomestone, an FFXIV model viewer), which should help surface any remaining
issues over time.

Summary

Rewrites the STM parser (src/stm.rs) to correctly parse stainingtemplate.stm files, including old format
(Endwalker, u16 keys, 128 dyes) and new format (Dawntrail, u32 keys, 254 dyes). The previous implementation had
structural issues that prevented correct data extraction.

Investigation Resources

STM Binary Format Summary

The findings documented below may serve as a reference for the format specification.

Header (8 bytes)

Offset Size Type Description
0x00 2 u16 Magic (0x534D, "MS" in little-endian)
0x02 2 u16 Version (0x0101 for new format)
0x04 2 u16 Entry count (e.g. 43)
0x06 2 u16 Unknown

Old vs New Format Detection

Check bytes at [0x0A] and [0x0B] (3rd and 4th bytes of the first key entry at offset 0x08):

  • If either is non-zeroOld format: u16 keys/offsets, num_dyes = 128
  • If both are zeroNew format: u32 keys/offsets, num_dyes = 254

Current game files use the new format.

Key/Offset Tables

Immediately after the header (at offset 0x08):

Section Old Format New Format
Keys u16 × N u32 × N
Offsets u16 × N u32 × N
  • data_base = 8 + key_size × 2 × entry_count
  • Each entry's absolute offset: data_base + offsets[i] × 2

Entry Structure

Each entry begins with 5 × u16 cumulative end offsets (in half-word units):

Offset Field Description
+0x00 ends[0] Diffuse sub-table end
+0x02 ends[1] Specular sub-table end
+0x04 ends[2] Emissive sub-table end
+0x06 ends[3] Gloss sub-table end
+0x08 ends[4] Specular Power sub-table end
+0x0A ... Sub-table data begins

Sub-table byte ranges are derived from cumulative differences:

  • Diffuse: [0, ends[0]×2) — element type: Half3 (3 × f16 = 6 bytes)
  • Specular: [ends[0]×2, ends[1]×2) — Half3
  • Emissive: [ends[1]×2, ends[2]×2) — Half3
  • Gloss: [ends[2]×2, ends[3]×2) — Half1 (1 × f16 = 2 bytes)
  • Specular Power: [ends[3]×2, ends[4]×2) — Half1

Sub-table Encoding Modes

Each sub-table uses one of three encoding modes, determined by array_size = sub_size / sizeof(T):

1. Singleton (array_size == 1):
A single value, replicated for all num_dyes entries.

2. OneToOne (array_size >= num_dyes):
Direct storage of num_dyes values, one per dye index.

3. Indexed (1 < array_size < num_dyes):
Compressed storage using a palette and index table:

[palette: T × P]              — P color/value entries
[marker: u8 × 1]              — separator byte (0xFF), skipped
[indices: u8 × (num_dyes - 1)] — 1-based palette indices

Where P = (sub_size - num_dyes) / sizeof(T).

Index interpretation:

  • 0 or 255 → default value (zero)
  • Otherwise → palette[index - 1]
  • Last dye entry is always forced to default

Dawntrail Template ID Mapping

Dawntrail materials use template_id >= 1000 in their ColorDyeTable (e.g. 1200, 1500).
These map to legacy STM keys by subtracting 1000: stm_key = template_id - 1000.

Changes

  • Complete rewrite of StainingTemplate::from_existing() with correct binary parsing
  • New read_array<T>() generic method implementing all three sub-table encoding modes
  • New StmEntry struct with per-channel accessor methods
  • New DyePack struct and get_dye_pack() convenience method with Dawntrail ID mapping
  • Helper methods read_half3_array() and read_half1_array() for type conversion

Testing

  • Verified against actual stainingtemplate.stm from game data: 43 entries parsed, each with 254 dye values
  • Template 100 diffuse shows 125 unique colors (confirming indexed encoding works)
  • Dawntrail template mapping verified (template_id 1200 → STM key 200)
  • Binary layout cross-validated with ImHex pattern visualization
  • Existing test_invalid fuzz test passes

The STM parser had multiple incorrect assumptions leading to wrong dye
color lookups:

- Header is 4 × u16 (magic, version, entry_count, unknown), not u32 + i32
- New format uses u32 keys/offsets with 254 dyes, not u16/128
- Format detection via bytes [0x0A..0x0B] (TexTools heuristic)
- Three sub-table encoding modes: Singleton, OneToOne, Indexed
- Indexed mode uses 1-based indices with 0xFF marker byte
- Expose entries as HashMap<u16, StmEntry> with typed accessors
- Add Dawntrail template ID mapping (>= 1000 → subtract 1000)

Based on TexTools xivModdingFramework STM.cs reference implementation.
@redstrate
Copy link
Owner

Thanks for the investigation! Unfortunately I don't want to accept LLM contributions, but that is 100% not your fault - I did not disclose this anywhere in the project. That is amended to the contributing guide now, sorry about that.

Apart from personal reasons, this project is copy-left but alas any generated output - as of right now - is pretty much uncopyrightable. Eventually I'm sure me or someone else will step up to figure out the new STM format though :)

@redstrate redstrate closed this Feb 13, 2026
@AzurIce
Copy link
Contributor Author

AzurIce commented Feb 14, 2026

Thanks for the investigation! Unfortunately I don't want to accept LLM contributions, but that is 100% not your fault - I did not disclose this anywhere in the project. That is amended to the contributing guide now, sorry about that.

Apart from personal reasons, this project is copy-left but alas any generated output - as of right now - is pretty much uncopyrightable. Eventually I'm sure me or someone else will step up to figure out the new STM format though :)

That's fair, I fully understand that! So made it a draft and disclaimed that. Hope some findings here maybe helpful!

Thank you anyway!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement StainingTemplate parsing

2 participants