From 0c8a1f1b9cfbf1249a86d45681895462f0434b1f Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Fri, 12 Jun 2026 11:49:07 -0700 Subject: [PATCH 1/2] Flatten anonymous bitfield sub-properties (#408 phase 2) Phase 1 (#1721) surfaces fields nested in anonymous structs/unions as [UnscopedRef] ref accessors, but only the raw bitfield backing field was reachable that way. This forwards the computed bitfield sub-properties (e.g. PSAPI_WORKING_SET_EX_BLOCK's Valid/ShareCount/Win32Protection) as value get/set properties on the declaring struct, so they can be read and written directly as value.Field instead of value.Anonymous.Anonymous.Field. Each forwarded property delegates to the nested property (readonly get => this.Anonymous...Prop; set => ... = value) and inherits its docs via . The projected type is computed from the bitfield width (1 bit => bool, else smallest fitting integer of the backing field's signedness), matching the nested property exactly, so no narrowing occurs even when the backing field is wider (e.g. nuint backing, byte/ushort sub-properties). The raw backing field continues to be surfaced as a ref (phase 1 behavior is unchanged; this is additive). Only fields reached through anonymous holders are forwarded; bitfields under a named holder are not. Gated on C# 11 like the rest of the feature. Adds syntax-shape unit tests (PSAPI_WORKING_SET_EX_BLOCK, including the named-holder negative case and the C# 10 gate) and functional tests proving the forwarded properties alias the same storage, pack without bit overlap (exact backing-field bit pattern asserted), and isolate neighbors. Validated across net8.0, net472, and net10.0. --- .../Generator.Struct.cs | 95 +++++++++++++++++- test/GenerationSandbox.Tests/BasicTests.cs | 99 +++++++++++++++++++ .../GenerationSandbox.Tests/NativeMethods.txt | 1 + .../StructTests.cs | 78 +++++++++++++++ 4 files changed, 271 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs index 80e83d1f..74712676 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs @@ -38,6 +38,25 @@ private static bool IsAnonymousFieldName(string name) return true; } + /// + /// Computes the C# type of a bitfield sub-property given the backing field's primitive type and the bit length. + /// This mirrors the property-type selection used when bitfields are emitted on their declaring struct in + /// , so a forwarded accessor's type matches the nested property it forwards to. + /// + private static TypeSyntax GetBitfieldProjectedType(PrimitiveTypeCode backingTypeCode, byte propLength) + { + bool signed = backingTypeCode is PrimitiveTypeCode.SByte or PrimitiveTypeCode.Int16 or PrimitiveTypeCode.Int32 or PrimitiveTypeCode.Int64 or PrimitiveTypeCode.IntPtr; + return propLength switch + { + 1 => PredefinedType(Token(SyntaxKind.BoolKeyword)), + <= 8 => PredefinedType(Token(signed ? SyntaxKind.SByteKeyword : SyntaxKind.ByteKeyword)), + <= 16 => PredefinedType(Token(signed ? SyntaxKind.ShortKeyword : SyntaxKind.UShortKeyword)), + <= 32 => PredefinedType(Token(signed ? SyntaxKind.IntKeyword : SyntaxKind.UIntKeyword)), + <= 64 => PredefinedType(Token(signed ? SyntaxKind.LongKeyword : SyntaxKind.ULongKeyword)), + _ => throw new NotSupportedException(), + }; + } + private StructDeclarationSyntax DeclareStruct(TypeDefinitionHandle typeDefHandle, Context context) { TypeDefinition typeDef = this.Reader.GetTypeDefinition(typeDefHandle); @@ -761,6 +780,39 @@ private void GatherFlattenedAnonymousAccessors( continue; } + // Forward computed bitfield sub-properties (e.g. PSAPI_WORKING_SET_EX_BLOCK's Valid/ShareCount/...) as value + // get/set properties on the outer struct. They are generated on the nested struct from NativeBitfieldAttribute + // decorations on a backing field; Phase 1 only surfaces the raw backing field as a ref, so without this the + // friendly bitfield members would only be reachable through the Anonymous holder chain. This runs in addition to + // (not instead of) surfacing the backing field below, mirroring the nested struct, which exposes both. + bool fieldObsolete = ancestorObsolete || this.HasObsoleteAttribute(fieldAttributes); + foreach (CustomAttribute bitfieldAttribute in MetadataUtilities.FindAttributes(this.Reader, fieldAttributes, InteropDecorationNamespace, NativeBitfieldAttribute)) + { + if (fieldDef.DecodeSignature(this.SignatureHandleProvider, null) is not PrimitiveTypeHandleInfo bitfieldBackingType) + { + continue; + } + + CustomAttributeValue decodedBitfield = bitfieldAttribute.DecodeValue(CustomAttributeTypeProvider.Instance); + string bitfieldPropName = (string)decodedBitfield.FixedArguments[0].Value!; + byte bitfieldPropLength = (byte)(long)decodedBitfield.FixedArguments[2].Value!; + + // A zero-length bitfield produces no property on the nested struct, so there is nothing to forward. + if (bitfieldPropLength == 0) + { + continue; + } + + // Skip collisions with an existing member or another flattened accessor. + if (reservedNames.Contains(bitfieldPropName) || !surfacedNames.Add(bitfieldPropName)) + { + continue; + } + + TypeSyntax bitfieldPropType = GetBitfieldProjectedType(bitfieldBackingType.PrimitiveTypeCode, bitfieldPropLength); + accessors.Add(this.CreateFlattenedBitfieldAccessor(bitfieldPropName, bitfieldPropType, accessPrefix, crefTypePrefix, fieldObsolete)); + } + // Skip fields whose generated representation is not a plain, ref-returnable field of the decoded type. if (this.FindAssociatedEnum(fieldAttributes) is not null) { @@ -829,8 +881,7 @@ private void GatherFlattenedAnonymousAccessors( // Mark the accessor obsolete when the leaf field (or any holder along its access path) is obsolete, // so its body may legally reference the obsolete member without triggering CS0612. - bool isObsolete = ancestorObsolete || this.HasObsoleteAttribute(fieldAttributes); - accessors.Add(this.CreateFlattenedAnonymousAccessor(fieldName, fieldType, accessPrefix, crefTypePrefix, isObsolete)); + accessors.Add(this.CreateFlattenedAnonymousAccessor(fieldName, fieldType, accessPrefix, crefTypePrefix, fieldObsolete)); this.DeclareUnscopedRefAttributeIfNecessary(); } } @@ -872,6 +923,46 @@ private PropertyDeclarationSyntax CreateFlattenedAnonymousAccessor(string fieldN return accessor.WithLeadingTrivia(inheritDoc); } + /// + /// Creates a value get/set property that forwards to a computed bitfield sub-property reached through anonymous + /// holders (e.g. value.Valid instead of value.Anonymous.Anonymous.Valid), with documentation inherited + /// from the underlying bitfield property. Unlike the scalar accessor this is a by-value property, not a ref: + /// the bitfield property has no addressable backing storage of its own (it reads and writes bits of a shared field). + /// + private PropertyDeclarationSyntax CreateFlattenedBitfieldAccessor(string propName, TypeSyntax propertyType, ExpressionSyntax accessPrefix, NameSyntax crefTypePrefix, bool isObsolete) + { + // { readonly get => .; set => . = value; } + ExpressionSyntax target = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, accessPrefix, IdentifierName(SafeIdentifier(propName))); + AccessorDeclarationSyntax getter = AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .AddModifiers(TokenWithSpace(SyntaxKind.ReadOnlyKeyword)) + .WithExpressionBody(ArrowExpressionClause(target)) + .WithSemicolonToken(SemicolonWithLineFeed); + AccessorDeclarationSyntax setter = AccessorDeclaration(SyntaxKind.SetAccessorDeclaration) + .WithExpressionBody(ArrowExpressionClause(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, target, IdentifierName("value")))) + .WithSemicolonToken(SemicolonWithLineFeed); + + PropertyDeclarationSyntax accessor = PropertyDeclaration(propertyType.WithTrailingTrivia(Space), SafeIdentifier(propName)) + .AddModifiers(TokenWithSpace(this.Visibility)) + .WithAccessorList(AccessorList(getter, setter)); + + if (isObsolete) + { + accessor = accessor.AddAttributeLists(AttributeList(ObsoleteAttributeSyntax)); + } + + // /// + CrefSyntax cref = SyntaxFactory.NameMemberCref(QualifiedName(crefTypePrefix, SafeIdentifierName(propName))); + SyntaxTrivia inheritDoc = Trivia(DocumentationCommentTrivia( + SyntaxKind.SingleLineDocumentationCommentTrivia, + [ + XmlText("/// "), + XmlEmptyElement("inheritdoc", [XmlCrefAttribute(cref)]), + XmlText(XmlTextNewLine("\n", continueXmlDocumentationComment: false)), + ])); + + return accessor.WithLeadingTrivia(inheritDoc); + } + /// /// Resolves the nested type definition referenced by an anonymous holder field, if the field's type is in fact /// a type nested within . diff --git a/test/GenerationSandbox.Tests/BasicTests.cs b/test/GenerationSandbox.Tests/BasicTests.cs index 6dec0ac1..16744a1d 100644 --- a/test/GenerationSandbox.Tests/BasicTests.cs +++ b/test/GenerationSandbox.Tests/BasicTests.cs @@ -542,6 +542,105 @@ public unsafe void FlattenedAnonymousFieldSupportsPointers() Assert.Equal(0xABCDEF01u, si.Anonymous.dwOemId); } + [Fact] + public void FlattenedBitfieldPropertiesAliasNestedBitfields() + { + // PSAPI_WORKING_SET_EX_BLOCK's anonymous union nests an anonymous struct of bitfields packed into one field. + // The flattened forwarding properties read and write the same underlying bits as the nested path. + Windows.Win32.System.ProcessStatus.PSAPI_WORKING_SET_EX_BLOCK block = default; + + // Writing a forwarded multi-bit property writes through to the nested struct's bitfield. + block.ShareCount = 5; + Assert.Equal((byte)5, block.Anonymous.Anonymous.ShareCount); + + // Reading a forwarded property reflects a write made through the nested path. + block.Anonymous.Anonymous.Win32Protection = 11; + Assert.Equal((ushort)11, block.Win32Protection); + + // A 1-bit (bool) bitfield round-trips through the forwarding property. + block.Valid = true; + Assert.True(block.Anonymous.Anonymous.Valid); + + // Independent bitfields packed into the same backing field do not disturb each other. + Assert.Equal((byte)5, block.ShareCount); + Assert.Equal((ushort)11, block.Win32Protection); + } + + [Fact] + public void FlattenedBitfieldPropertiesPackWithoutOverlap() + { + // PSAPI_WORKING_SET_EX_BLOCK packs nine bitfields into one nuint: + // Valid:bit0, ShareCount:bits1-3, Win32Protection:bits4-14, Shared:bit15, + // Node:bits16-21, Locked:bit22, LargePage:bit23, Reserved:bits24-30, Bad:bit31. + // Set every sub-field (through the flattened outer properties) to a distinct in-range value chosen so each + // field's bits form a recognizable pattern, then assert (a) every value round-trips and (b) the raw backing + // field equals exactly the bitwise-OR of the shifted values — which can only hold if no two fields share a bit. + Windows.Win32.System.ProcessStatus.PSAPI_WORKING_SET_EX_BLOCK block = default; + block.Valid = true; // bit 0 + block.ShareCount = 5; // bits 1-3 (0b101, max 7) + block.Win32Protection = 0x6AB; // bits 4-14 (max 2047) + block.Shared = false; // bit 15 + block.Node = 0x2A; // bits 16-21 (max 63) + block.Locked = true; // bit 22 + block.LargePage = false; // bit 23 + block.Reserved = 0x55; // bits 24-30 (max 127) + block.Bad = true; // bit 31 + + // (a) Every value round-trips through the flattened property (no cross-talk between adjacent fields). + Assert.True(block.Valid); + Assert.Equal((byte)5, block.ShareCount); + Assert.Equal((ushort)0x6AB, block.Win32Protection); + Assert.False(block.Shared); + Assert.Equal((byte)0x2A, block.Node); + Assert.True(block.Locked); + Assert.False(block.LargePage); + Assert.Equal((byte)0x55, block.Reserved); + Assert.True(block.Bad); + + // The nested path observes identical values (the flattened properties truly alias the same storage). + Assert.Equal(block.ShareCount, block.Anonymous.Anonymous.ShareCount); + Assert.Equal(block.Win32Protection, block.Anonymous.Anonymous.Win32Protection); + Assert.Equal(block.Node, block.Anonymous.Anonymous.Node); + Assert.Equal(block.Reserved, block.Anonymous.Anonymous.Reserved); + + // (b) The raw backing field holds exactly those bits and no others. + nuint expected = + ((nuint)1 << 0) | // Valid + ((nuint)5 << 1) | // ShareCount + ((nuint)0x6AB << 4) | // Win32Protection + ((nuint)0x2A << 16) | // Node + ((nuint)1 << 22) | // Locked + ((nuint)0x55 << 24) | // Reserved + ((nuint)1 << 31); // Bad + Assert.Equal(expected, block._bitfield); + } + + [Fact] + public void FlattenedBitfieldPropertyWritesAreIsolatedFromNeighbors() + { + // Mutating one flattened bitfield must never disturb its immediate neighbors. ShareCount (bits 1-3) sits + // between Valid (bit 0) and Win32Protection (bits 4-14); drive its full range and confirm the neighbors hold. + Windows.Win32.System.ProcessStatus.PSAPI_WORKING_SET_EX_BLOCK block = default; + block.Valid = true; + block.Win32Protection = 0x7FF; // all 11 bits set + + for (byte value = 0; value <= 7; value++) + { + block.ShareCount = value; + Assert.Equal(value, block.ShareCount); + + // Neighbors on both sides are untouched no matter what ShareCount is set to. + Assert.True(block.Valid); + Assert.Equal((ushort)0x7FF, block.Win32Protection); + } + + // Clearing the high neighbor likewise leaves ShareCount and Valid intact. + block.Win32Protection = 0; + Assert.True(block.Valid); + Assert.Equal((byte)7, block.ShareCount); + Assert.Equal((ushort)0, block.Win32Protection); + } + [Fact] public void FieldWithAssociatedEnum() { diff --git a/test/GenerationSandbox.Tests/NativeMethods.txt b/test/GenerationSandbox.Tests/NativeMethods.txt index a872dab7..d540f0bc 100644 --- a/test/GenerationSandbox.Tests/NativeMethods.txt +++ b/test/GenerationSandbox.Tests/NativeMethods.txt @@ -59,6 +59,7 @@ PAGESET PathParseIconLocation POINTS PROCESS_BASIC_INFORMATION +PSAPI_WORKING_SET_EX_BLOCK PZZSTR PZZWSTR RECT diff --git a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs index 763f6866..1dac4bc5 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs @@ -568,9 +568,87 @@ public void FlattenNestedAnonymousTypes_DoesNotFlattenIntoNestedStructContaining AssertFlattenedAccessor(FindProperty(decimalDecl, "Lo64"), "this.Anonymous2.Lo64"); } + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_ForwardsBitfieldProperties(bool allowMarshaling) + { + // PSAPI_WORKING_SET_EX_BLOCK is a union whose anonymous _Anonymous_e__Struct (reached via Anonymous.Anonymous) + // carries bitfields packed into a single nuint backing field. Phase 1 surfaces only the raw _bitfield as a ref; + // Phase 2 also forwards the computed bitfield sub-properties (Valid, ShareCount, Win32Protection, ...) as value + // get/set properties on the outer struct so they can be read and written directly. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("PSAPI_WORKING_SET_EX_BLOCK"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("PSAPI_WORKING_SET_EX_BLOCK")); + + // A 1-bit field is forwarded as a bool; a multi-bit field as the smallest unsigned integer that fits. + AssertForwardedBitfieldAccessor(FindProperty(structDecl, "Valid"), "this.Anonymous.Anonymous.Valid"); + Assert.Equal(SyntaxKind.BoolKeyword, ((PredefinedTypeSyntax)FindProperty(structDecl, "Valid").Type).Keyword.Kind()); + AssertForwardedBitfieldAccessor(FindProperty(structDecl, "ShareCount"), "this.Anonymous.Anonymous.ShareCount"); + Assert.Equal(SyntaxKind.ByteKeyword, ((PredefinedTypeSyntax)FindProperty(structDecl, "ShareCount").Type).Keyword.Kind()); + AssertForwardedBitfieldAccessor(FindProperty(structDecl, "Win32Protection"), "this.Anonymous.Anonymous.Win32Protection"); + Assert.Equal(SyntaxKind.UShortKeyword, ((PredefinedTypeSyntax)FindProperty(structDecl, "Win32Protection").Type).Keyword.Kind()); + + // The raw backing field is still surfaced as a ref (Phase 1 behavior is unchanged). + AssertFlattenedAccessor(FindProperty(structDecl, "_bitfield"), "this.Anonymous.Anonymous._bitfield"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_DoesNotForwardBitfieldsThroughNamedHolder() + { + // PSAPI_WORKING_SET_EX_BLOCK's union also has a *named* member 'Invalid' (_Invalid_e__Struct) whose bitfields + // include Reserved0/Reserved1. Named holders are surfaced as a single ref to the whole value, never flattened, + // so those bitfields must NOT appear on the outer struct. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("PSAPI_WORKING_SET_EX_BLOCK"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("PSAPI_WORKING_SET_EX_BLOCK")); + + // 'Invalid' is surfaced as a whole-value ref... + AssertFlattenedAccessor(FindProperty(structDecl, "Invalid"), "this.Anonymous.Invalid"); + + // ...but its bitfields (unique names Reserved0/Reserved1) are not hoisted onto the outer struct. + Assert.DoesNotContain( + structDecl.Members.OfType(), + p => p.Identifier.ValueText is "Reserved0" or "Reserved1"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_BitfieldForwardingRequiresCSharp11() + { + // The whole flattening feature (including bitfield forwarding) is gated on C# 11 (for [UnscopedRef]). + this.parseOptions = this.parseOptions.WithLanguageVersion(LanguageVersion.CSharp10); + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("PSAPI_WORKING_SET_EX_BLOCK"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("PSAPI_WORKING_SET_EX_BLOCK")); + Assert.DoesNotContain(structDecl.Members.OfType(), p => p.Identifier.ValueText == "ShareCount"); + } + private static PropertyDeclarationSyntax FindProperty(StructDeclarationSyntax structDecl, string name) => Assert.Single(structDecl.Members.OfType(), p => p.Identifier.ValueText == name); + private static void AssertForwardedBitfieldAccessor(PropertyDeclarationSyntax property, string expectedTarget) + { + // It is a by-value property (not ref-returning) and is not annotated [UnscopedRef]. + Assert.IsNotType(property.Type); + Assert.DoesNotContain(property.AttributeLists.SelectMany(al => al.Attributes), a => a.Name.ToString() == "UnscopedRef"); + + SyntaxList accessors = Assert.IsType(property.AccessorList).Accessors; + + // readonly get => ; + AccessorDeclarationSyntax getter = Assert.Single(accessors, a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); + Assert.Contains(getter.Modifiers, m => m.IsKind(SyntaxKind.ReadOnlyKeyword)); + Assert.Equal(expectedTarget, Assert.IsType(getter.ExpressionBody).Expression.ToString()); + + // set => = value; + AccessorDeclarationSyntax setter = Assert.Single(accessors, a => a.IsKind(SyntaxKind.SetAccessorDeclaration)); + var assignment = Assert.IsType(Assert.IsType(setter.ExpressionBody).Expression); + Assert.Equal(expectedTarget, assignment.Left.ToString()); + Assert.Equal("value", assignment.Right.ToString()); + + // It inherits documentation via . + Assert.Contains( + property.GetLeadingTrivia().Select(t => t.GetStructure()).OfType().SelectMany(d => d.Content.OfType()), + e => e.Name.ToString() == "inheritdoc" && e.Attributes.OfType().Any()); + } + private static void AssertFlattenedAccessor(PropertyDeclarationSyntax property, string expectedRefTarget) { // It returns by ref. From 88f96c4064a1ffbfa04eefb27dd56b01c7d3261a Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Fri, 12 Jun 2026 11:57:22 -0700 Subject: [PATCH 2/2] Fix SA1025 in bitfield test comment alignment The aligned trailing comments in FlattenedBitfieldPropertiesPackWithoutOverlap used multiple consecutive spaces, which fails the CI -warnAsError build (SA1025). Collapse to a single space before each comment. --- test/GenerationSandbox.Tests/BasicTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/GenerationSandbox.Tests/BasicTests.cs b/test/GenerationSandbox.Tests/BasicTests.cs index 16744a1d..c671b769 100644 --- a/test/GenerationSandbox.Tests/BasicTests.cs +++ b/test/GenerationSandbox.Tests/BasicTests.cs @@ -605,13 +605,13 @@ public void FlattenedBitfieldPropertiesPackWithoutOverlap() // (b) The raw backing field holds exactly those bits and no others. nuint expected = - ((nuint)1 << 0) | // Valid - ((nuint)5 << 1) | // ShareCount - ((nuint)0x6AB << 4) | // Win32Protection - ((nuint)0x2A << 16) | // Node - ((nuint)1 << 22) | // Locked - ((nuint)0x55 << 24) | // Reserved - ((nuint)1 << 31); // Bad + ((nuint)1 << 0) | // Valid + ((nuint)5 << 1) | // ShareCount + ((nuint)0x6AB << 4) | // Win32Protection + ((nuint)0x2A << 16) | // Node + ((nuint)1 << 22) | // Locked + ((nuint)0x55 << 24) | // Reserved + ((nuint)1 << 31); // Bad Assert.Equal(expected, block._bitfield); }