Skip to content

feat(image): read and write Valve textures (VTF) for Garry's Mod and … - #1483

Open
ltsammy wants to merge 2 commits into
KnifeLemon:mainfrom
ltsammy:feat/vtf
Open

ltsammy wants to merge 2 commits into
KnifeLemon:mainfrom
ltsammy:feat/vtf

Conversation

@ltsammy

@ltsammy ltsammy commented Nov 13, 2026 •

Copy link
Copy Markdown

What does this change?

Adds VTF (Valve Texture Format), the texture format of the Source engine: Garry's Mod, Counter-Strike: Source, Team Fortress 2 and Half-Life 2 mods. Modders currently need VTFEdit (Windows only, and it needs VTFLib) to turn a PNG into a texture. With this PR Filee does it with a drag.

Filee reads and writes VTF itself, so it needs no new package and no native DLL. It is wired into the image engine the same way as PDN.

Closes #1478

Type of change

  • Bug fix
  • New feature or improvement
  • New format or engine
  • Translation
  • Documentation
  • Build, CI or dependencies

How was it tested?

  • dotnet format --verify-no-changes (CI on the fork)
  • dotnet test (CI on the fork: Windows x64, Windows ARM64, Linux portable packages, all green)
  • Tried it in the app (what you converted or clicked, and on which OS):

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files (none added; the format name comes from FormatRegistry)
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none added)
  • The PR does one thing (unrelated changes go in a separate PR)

Details

Writing (picture → VTF)

  • Version 7.2, VTFEdit's default, which every Source branch loads.
  • Each side is rounded to the nearest power of two, at most 4096 px. Source draws other sizes wrong.
  • DXT1 for an opaque picture. DXT5 for one with transparency, with the eight-bit-alpha flag.
  • Every mipmap down to 1 × 1. Each one is made from the full-size picture, so the blur doesn't build up from one level to the next.
  • A DXT1 thumbnail of at most 16 × 16, and the reflectivity value (the average colour in linear light) that Source uses for bounced light.
  • ImageMagick resizes and compresses the DXT blocks: its DDS writer without mipmaps, with the 128-byte header cut off. The block size is checked.
  • Several frames (an animated GIF, the pages of a TIFF) become one animated texture. For this, vtf joins MultiFrame.

Reading (VTF → picture)

  • Versions 7.0 to 7.5. From 7.3 on, the image data is found through the resource list.
  • DXT1, DXT1 with one-bit alpha, DXT3 and DXT5, decoded here. Also every uncompressed format VTFLib knows, including the blue-screen formats, 565/5551/4444, and 16-bit and half-float RGBA. The only exception is P8, which Source never shipped.
  • Reads the largest mipmap of every frame, the first face of a cube map and the first slice of a volume texture.
  • Before any pixel is read, a file is refused when a side is over 16,384 px, its frames together claim more than PaintNet.MaxPixels, it claims more mipmaps than its sides allow, or it holds less image data than its header claims. Sizes are counted in 64 bits. The writer keeps to the same budget (16 frames at 4096 × 4096), so Filee reads back every texture it writes.

Where it goes

  • FormatRegistry: vtf, Image category, writable.
  • FormatFields: graded specialist, Games field only. Following the admission rule, it is a target only where the Games field is on.
  • Settings schema 90: .vtf learns the images donut, and custom claims stay put. (89 went to .krc on main; the number may need renumbering again if another change takes 90 first.)
  • ImageEncoder / ImageReader / ImageJoiner / MagickImageConverter: wired in like PDN (Packages, MaxSide, Write, WriteImage, edges, CanWrite).
  • README and docs/ENGINES.md describe the format.

The .vmt material that points to a texture is not written. Its $basetexture path depends on where the texture ends up under materials/, which Filee can't know.

Tests

  • VtfFormatTests (Core): detection, images donut, grade and field, migration from schema 89.
  • VtfTests (Engines):
    • PNG → DXT1 with power-of-two sides and every mipmap, with the exact file length checked.
    • Transparent PNG → DXT5 with its alpha.
    • Animated GIF → animated texture.
    • Round trip back to PNG.
    • BGRA VTFs of version 7.2 and 7.5 → PNG, pixel for pixel.
    • Frame layout, DXT1, three-colour DXT1 and DXT5 block decoding.
    • Size rules and refused files: an oversized header, a truncated file, more mipmaps than the sides allow.
  • FormatFieldsTests: updated pinned lists and the specialist count (123 → 124).

The layout was also checked against an independent reader, srctools' VTF.read, during development.

@ltsammy
ltsammy requested a review from KnifeLemon as a code owner November 13, 2026 17:59
@github-actions github-actions Bot added the feature New features label Nov 13, 2026
@KnifeLemon KnifeLemon mentioned this pull request Nov 13, 2026
1 task done

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ltsammy, this is a clean addition. It follows the PDN wiring closely and reads and writes VTF with no new dependency. I checked the header offsets (the 7.2 depth field, the 7.3+ resource list), the cube-map face count, the mipmap → frame → face → slice layout and the DXT decoding against VTFLib's layout, and they line up. The writer's order (thumbnail, then the smallest mipmap first, every frame per mipmap) matches too.

Two things before it can go in:

  1. Size limits in Vtf.cs. Filee opens files from anywhere, so the reader needs a tighter budget and 64-bit size math:

    • MaxPixels allows about 1.07 billion pixels and there's no limit per side. A DXT1 file of roughly 512 MB makes Filee allocate 4 GB of RGBA, plus the same again inside ImageMagick. Our other readers stay at or under 256 million pixels (PaintNet.MaxPixels).
    • DataSize and new byte[width * height * 4] multiply in int. A 100-byte header claiming 65535 × 16384 RGBA8888 passes the budget check. DataSize then comes out negative, slips past the start + imageSize > data.Length check, and fails in AsSpan with an ArgumentOutOfRangeException instead of a clear "too large" message.

    Suggested fix:

    • Return long from DataSize.
    • Cap each side (16384 is plenty) and the total at PaintNet.MaxPixels.
    • Compare the expected data length with data.Length as long.
    • A test with an oversized header and one with truncated data would cover it.
  2. CI hasn't run on this repository yet. It's waiting for approval on our side. We'll start it, and look again once it's green on all platforms.

Small things, take them or leave them:

  • mipmaps comes from a byte. Shifts of 32 or more wrap in C# (Width >> 33 is Width >> 1), so rejecting a count above 1 + log2(max(width, height)) keeps the offset math honest.
  • VtfWriter.Write caps frames only at ushort.MaxValue, so it can write a texture the reader then refuses (e.g. 65 frames at 4096²). The same budget in the writer would keep them in step.
  • Please add Closes #1478 to the description.

Thanks for the careful write-up and the srctools cross-check!

…the Source engine

Pictures become a version 7.2 VTF as VTFEdit writes it by default: sides
rounded to the nearest power of two, every mipmap, DXT1 when opaque and DXT5
with transparency, a DXT1 thumbnail and the reflectivity. An animation becomes
an animated texture. VTF 7.0 to 7.5 is read (DXT1/3/5 and the uncompressed
formats), so textures convert back to PNG and the other pictures.

VTF belongs to the Games field; .vtf files learn the images donut (schema 90).
Review of KnifeLemon#1483: a side is at most 16,384 px and every frame together at most
PaintNet.MaxPixels, sizes are counted in 64 bits, a file holding less image
data than its header claims is refused before anything is decoded, and so is
one claiming more mipmaps than its sides allow. The writer keeps to the same
budget, so every texture Filee writes can be read back.
@ltsammy

ltsammy commented Nov 13, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review! All points are addressed in 66c7dfc, on top of a rebase onto current main:

  • Budget: a side is at most 16,384 px (Vtf.MaxSide), and every frame together at most PaintNet.MaxPixels. Both are checked in ReadInfo before anything is allocated.
  • 64-bit sizes: DataSize returns long, and the RGBA buffer is sized in long. ReadInfo now adds up all image data (every mipmap, frame, face and slice) and compares it with data.Length as long, so a truncated file is refused up front with a "cut off" message instead of failing in AsSpan.
  • Mipmaps: a count above 1 + log2(max(width, height)) is refused.
  • Writer: VtfWriter.Write uses the same budget (Vtf.FitsBudget), so it can no longer write a texture the reader refuses. That means at most 16 frames at 4096².
  • Tests:
    • your 100-byte header claiming 65535 × 16384
    • a header within the side limit but over the budget
    • a 4096² header without its data
    • a truncated 4 × 4 DXT1 that fails the job with a clear error
    • mipmap counts of 3 at 2 × 2 and 34 at 64 × 64
    • the shared budget
  • Schema: main took 89 for .krc in the meantime, so the VTF migration is now 90, and VtfFormatTests migrates from 89.
  • Description: added Closes #1478.

CI on my fork is green on Windows x64, Windows ARM64 and the Linux packages. The macOS arm64 job there never got a runner, which is unrelated to the change.

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

Labels

feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Format]: .vtf

2 participants