diff --git a/objc/build_darwin_test.go b/objc/build_darwin_test.go new file mode 100644 index 00000000..662634ff --- /dev/null +++ b/objc/build_darwin_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package objc_test + +import ( + "errors" + "fmt" + "os/exec" + "runtime" + "strings" + "testing" +) + +// buildSharedLib compiles the Objective-C sources into a dynamic library at libFile. +// The library is built for GOARCH rather than the compiler's default, because +// macOS arm64 can run amd64 tests through Rosetta. +func buildSharedLib(tb testing.TB, libFile string, sources ...string) error { + tb.Helper() + + out, err := exec.Command("go", "env", "CC").Output() + if err != nil { + return fmt.Errorf("go env CC error: %w", err) + } + compiler := strings.TrimSpace(string(out)) + if compiler == "" { + return errors.New("compiler not found") + } + + var arch string + switch runtime.GOARCH { + case "arm64": + arch = "arm64" + case "amd64": + arch = "x86_64" + default: + return fmt.Errorf("unknown macOS architecture %s", runtime.GOARCH) + } + + args := []string{"-dynamiclib", "-Wall", "-Werror", "-arch", arch, "-framework", "Foundation", "-o", libFile} + cmd := exec.Command(compiler, append(args, sources...)...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("compile lib: %w\n%q\n%s", err, cmd, string(out)) + } + return nil +} diff --git a/objc/objc_block_darwin.go b/objc/objc_block_darwin.go index 224cd5e1..e409f890 100644 --- a/objc/objc_block_darwin.go +++ b/objc/objc_block_darwin.go @@ -4,6 +4,7 @@ package objc import ( + "errors" "fmt" "reflect" "structs" @@ -217,9 +218,93 @@ func (b Block) Copy() Block { return _Block_copy(b) } +// callForeign calls a block that was not created by [NewBlock] (for example one +// handed to us by Objective-C) through the Blocks ABI: the block is passed as the +// first argument followed by args. The signature is derived from the dynamic +// types of args and from resultType (nil for no result), and is checked against +// the block's own signature when it has one. +// See https://clang.llvm.org/docs/Block-ABI-Apple.html. +func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value, error) { + if b == 0 { + return nil, errors.New("objc: cannot invoke a nil block") + } + invoke := (*(**blockLayout)(unsafe.Pointer(&b))).invoke + if invoke == 0 { + return nil, errors.New("objc: block has no invoke function") + } + + in := make([]reflect.Type, len(args)+1) + layouts := make([]abiLayout, len(args)) + reflectedArgs := make([]reflect.Value, len(args)+1) + in[0] = reflect.TypeFor[Block]() + reflectedArgs[0] = reflect.ValueOf(b) + for i, arg := range args { + if arg == nil { + return nil, fmt.Errorf("objc: argument %d to a block is nil; pass a typed value such as objc.ID(0)", i) + } + reflectedArgs[i+1] = reflect.ValueOf(arg) + in[i+1] = reflectedArgs[i+1].Type() + var err error + if layouts[i], err = goLayout(in[i+1]); err != nil { + return nil, err + } + } + var resultLayout abiLayout + var out []reflect.Type + if resultType != nil { + var err error + if resultLayout, err = goLayout(resultType); err != nil { + return nil, err + } + out = []reflect.Type{resultType} + } + + if sig, ok := b.signature(); ok { + types, err := splitSignature(sig) + // types is the result, then the block itself, then the parameters. + if err != nil || len(types) < 2 { + return nil, fmt.Errorf("objc: malformed block signature %q", sig) + } + if len(types)-2 != len(args) { + return nil, fmt.Errorf("objc: block callback expects %d arguments, got %d", len(types)-2, len(args)) + } + want, err := encodingLayout(types[0]) + switch { + case resultType == nil && (err != nil || types[0][0] == '{'): + // The caller has to provide the result buffer for a struct returned in memory. + return nil, fmt.Errorf("objc: block returns %s; use InvokeBlock to receive it", types[0]) + case resultType == nil: + // the result, if any, is discarded. + case err != nil: + return nil, fmt.Errorf("objc: block result %s: %w", types[0], err) + case !want.matches(resultLayout): + return nil, fmt.Errorf("objc: block returns %s, which does not match %s", types[0], resultType) + } + for i, got := range layouts { + want, err := encodingLayout(types[i+2]) + if err != nil { + return nil, fmt.Errorf("objc: block argument %d %s: %w", i, types[i+2], err) + } + if !want.matches(got) { + return nil, fmt.Errorf("objc: block argument %d is %s, which does not match %s", i, types[i+2], in[i+1]) + } + } + } + + fn := reflect.New(reflect.FuncOf(in, out, false)) + purego.RegisterFunc(fn.Interface(), invoke) + return fn.Elem().Call(reflectedArgs), nil +} + // Invoke calls the implementation of a block. func (b Block) Invoke(args ...any) { fn := theBlocksCache.Functions.Load(b) + if !fn.IsValid() { + if _, err := b.callForeign(nil, args); err != nil { + panic(err) + } + return + } reflectedArgs := make([]reflect.Value, len(args)+1) reflectedArgs[0] = reflect.ValueOf(b) @@ -251,6 +336,17 @@ func NewBlock(fn any) Block { // InvokeBlock is a convenience method for calling the implementation of a block. // The block implementation must return 1 value. func InvokeBlock[T any](block Block, args ...any) (result T, err error) { + if !theBlocksCache.Functions.Load(block).IsValid() { + // not one of ours. The block may live on the caller's stack, so it must not be + // copied: the copy would be a different pointer and is unnecessary for a synchronous call. + out, err := block.callForeign(reflect.TypeFor[T](), args) + if err != nil { + return result, err + } + result, _ = reflect.TypeAssert[T](out[0]) + return result, nil + } + block = block.Copy() defer block.Release() diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 0f4fd923..d7039603 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -5,6 +5,7 @@ package objc_test import ( "fmt" + "path/filepath" "structs" "testing" @@ -154,3 +155,207 @@ func TestBlockCopyAndBlockRelease(t *testing.T) { t.Fatalf("refCount: %d != 1", refCount) } } + +// loadBlockFixture compiles testdata/block.m, which creates blocks in Objective-C. +func loadBlockFixture(t *testing.T) uintptr { + t.Helper() + library := filepath.Join(t.TempDir(), "block.dylib") + if err := buildSharedLib(t, library, filepath.Join("testdata", "block.m")); err != nil { + t.Fatal(err) + } + lib, err := purego.Dlopen(library, purego.RTLD_GLOBAL|purego.RTLD_NOW) + if err != nil { + t.Fatal(err) + } + return lib +} + +func TestInvokeForeignBlock(t *testing.T) { + lib := loadBlockFixture(t) + + check := func(name string, block objc.Block) { + t.Helper() + block.Invoke(int64(20), 3.5) // the result is discarded; this must not panic + // the block returns base + i + int64(f), where base is 100. + got, err := objc.InvokeBlock[int64](block, int64(20), 3.5) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if got != 123 { + t.Errorf("%s: InvokeBlock = %d, want 123", name, got) + } + } + + t.Run("heap", func(t *testing.T) { + var heapBlock func(base int64) objc.Block + purego.RegisterLibFunc(&heapBlock, lib, "purego_heap_block") + block := heapBlock(100) + defer block.Release() + check("InvokeBlock", block) + }) + + t.Run("stack", func(t *testing.T) { + var withStackBlock func(base int64, cb uintptr) + purego.RegisterLibFunc(&withStackBlock, lib, "purego_with_stack_block") + called := false + cb := purego.NewCallback(func(block objc.Block) { + called = true + check("InvokeBlock", block) + }) + withStackBlock(100, cb) + if !called { + t.Fatal("callback was not called") + } + }) +} + +func TestInvokeForeignBlockMismatch(t *testing.T) { + lib := loadBlockFixture(t) + var heapBlock func(base int64) objc.Block + purego.RegisterLibFunc(&heapBlock, lib, "purego_heap_block") + block := heapBlock(100) + defer block.Release() + + if _, err := objc.InvokeBlock[int64](block, int64(20)); err == nil { + t.Error("missing argument: expected an error") + } + if _, err := objc.InvokeBlock[int64](block, int64(20), int64(3)); err == nil { + t.Error("integer for a double argument: expected an error") + } + if _, err := objc.InvokeBlock[float64](block, int64(20), 3.5); err == nil { + t.Error("wrong result type: expected an error") + } + if _, err := objc.InvokeBlock[any](block, int64(20), 3.5); err == nil { + t.Error("unsupported result type: expected an error") + } + if _, err := objc.InvokeBlock[int32](block, int64(20), 3.5); err == nil { + t.Error("smaller result type: expected an error") + } + if _, err := objc.InvokeBlock[int64](block, 20, 3.5); err != nil { + t.Errorf("int for an int64_t argument: %v", err) + } + if _, err := objc.InvokeBlock[int64](block, int32(20), 3.5); err == nil { + t.Error("int32 for an int64_t argument: expected an error") + } + if _, err := objc.InvokeBlock[int64](block, nil, 3.5); err == nil { + t.Error("nil argument: expected an error") + } + if got, err := objc.InvokeBlock[int64](block, int64(20), 3.5); err != nil || got != 123 { + t.Errorf("InvokeBlock = %d, %v; want 123, nil", got, err) + } +} + +func TestInvokeForeignBlockStruct(t *testing.T) { + lib := loadBlockFixture(t) + var bigBlock func() objc.Block + purego.RegisterLibFunc(&bigBlock, lib, "purego_big_block") + block := bigBlock() + defer block.Release() + + type big struct { + _ structs.HostLayout + a, b, c, d int64 + } + got, err := objc.InvokeBlock[big](block, int64(1)) + if err != nil { + t.Fatal(err) + } + if want := (big{a: 1, b: 2, c: 3, d: 4}); got != want { + t.Errorf("got %+v, want %+v", got, want) + } + + type small struct { + _ structs.HostLayout + a, b int64 + } + if _, err := objc.InvokeBlock[small](block, int64(1)); err == nil { + t.Error("smaller struct: expected an error") + } + type reordered struct { + _ structs.HostLayout + a, b, c int64 + d float64 + } + if _, err := objc.InvokeBlock[reordered](block, int64(1)); err == nil { + t.Error("struct with a different field: expected an error") + } + + // Invoke cannot receive the result, so it must refuse rather than crash. + defer func() { + if recover() == nil { + t.Error("Invoke on a block returning a struct: expected a panic") + } + }() + block.Invoke(int64(1)) +} + +func TestInvokeForeignBlockStructPadding(t *testing.T) { + lib := loadBlockFixture(t) + var boolFloatBlock func() objc.Block + purego.RegisterLibFunc(&boolFloatBlock, lib, "purego_boolfloat_block") + block := boolFloatBlock() + defer block.Release() + + // the padding is written out, as the RegisterFunc documentation asks for. + type boolFloat struct { + _ structs.HostLayout + b bool + _ [3]byte + f float32 + } + got, err := objc.InvokeBlock[boolFloat](block, float32(1.5)) + if err != nil { + t.Fatal(err) + } + if !got.b || got.f != 3 { + t.Errorf("got {%v %v}, want {true 3}", got.b, got.f) + } +} + +func TestInvokeForeignBlockFuncArgument(t *testing.T) { + lib := loadBlockFixture(t) + var fnptrBlock func() objc.Block + purego.RegisterLibFunc(&fnptrBlock, lib, "purego_fnptr_block") + block := fnptrBlock() + defer block.Release() + + // a func argument would consume a callback on every call; it must be refused. + if _, err := objc.InvokeBlock[objc.ID](block, func() {}); err == nil { + t.Error("expected an error for a func argument") + } + + func() { + defer func() { + if recover() == nil { + t.Error("Invoke with a func argument: expected a panic") + } + }() + block.Invoke(func() {}) + }() + + called := 0 + cb := purego.NewCallback(func() { called++ }) + for range 5000 { + block.Invoke(cb) + } + if called != 5000 { + t.Errorf("called = %d, want 5000", called) + } +} + +func TestInvokeForeignBlockBlockArgument(t *testing.T) { + lib := loadBlockFixture(t) + var blockArgBlock func() objc.Block + purego.RegisterLibFunc(&blockArgBlock, lib, "purego_blockarg_block") + block := blockArgBlock() + defer block.Release() + + var got int64 + handler := objc.NewBlock(func(_ objc.Block, x int64) { got = x }) + defer handler.Release() + + block.Invoke(handler, int64(41)) + if got != 42 { + t.Errorf("got %d, want 42", got) + } +} diff --git a/objc/objc_block_signature_darwin.go b/objc/objc_block_signature_darwin.go new file mode 100644 index 00000000..925ad331 --- /dev/null +++ b/objc/objc_block_signature_darwin.go @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package objc + +import ( + "errors" + "fmt" + "reflect" + "slices" + stdstrings "strings" + "unsafe" + + "github.com/ebitengine/purego/internal/strings" +) + +// encQualifiers are the method type qualifiers (const, in, inout, out, bycopy, byref, oneway) +// that may prefix a type encoding. They do not affect the calling convention. +const encQualifiers = "rnNoORV" + +// signature returns the type encoding of a block, or false if the block does not export one. +func (b Block) signature() (string, bool) { + layout := *(**blockLayout)(unsafe.Pointer(&b)) + if layout.flags&blockHasSignature == 0 { + return "", false + } + // The descriptor is { reserved, size, [copy, dispose,] [signature] } where the + // helpers are only present with blockHasCopyDispose. + offset := 2 * unsafe.Sizeof(uintptr(0)) + if layout.flags&blockHasCopyDispose != 0 { + offset += 2 * unsafe.Sizeof(uintptr(0)) + } + sig := strings.GoString(*(*uintptr)(unsafe.Add(unsafe.Pointer(layout.descriptor), offset))) + return sig, sig != "" +} + +// splitSignature splits a method or block type encoding, such as "v24@?0q8d16", +// into the result type followed by the argument types, without qualifiers or frame offsets. +func splitSignature(sig string) ([]string, error) { + var types []string + for sig != "" { + sig = stdstrings.TrimLeft(sig, encQualifiers) + n, err := encodingLen(sig) + if err != nil { + return nil, err + } + types = append(types, sig[:n]) + sig = stdstrings.TrimLeft(sig[n:], "0123456789") + } + return types, nil +} + +// encodingLen returns the length of the single type encoding at the start of s. +func encodingLen(s string) (int, error) { + if s == "" { + return 0, errors.New("missing type encoding") + } + switch s[0] { + case '{', '(', '[': + return bracketLen(s) + case '^': + rest := stdstrings.TrimLeft(s[1:], encQualifiers) + n, err := encodingLen(rest) + return len(s) - len(rest) + n, err + case 'b': + return 1 + len(s[1:]) - len(stdstrings.TrimLeft(s[1:], "0123456789")), nil + case '@': + switch { + case len(s) > 1 && s[1] == '"': + // an object with its class name: @"NSString" + n, err := quotedLen(s[1:]) + return 1 + n, err + case len(s) > 2 && s[1] == '?' && s[2] == '<': + // a block with its signature: @? + n, err := bracketLen(s[2:]) + return 2 + n, err + case len(s) > 1 && s[1] == '?': + return 2, nil + } + } + return 1, nil +} + +// bracketLen returns the length of the bracketed encoding at the start of s, +// such as {name=type...}, (name=type...), [count type] or . +func bracketLen(s string) (int, error) { + depth := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '{', '(', '[', '<': + depth++ + case '}', ')', ']', '>': + if depth--; depth == 0 { + return i + 1, nil + } + case '"': + // field and class names may contain brackets, as in @"". + n, err := quotedLen(s[i:]) + if err != nil { + return 0, err + } + i += n - 1 + } + } + return 0, fmt.Errorf("unterminated type encoding %q", s) +} + +// quotedLen returns the length of the quoted name at the start of s, including the quotes. +func quotedLen(s string) (int, error) { + if end := stdstrings.IndexByte(s[1:], '"'); end >= 0 { + return end + 2, nil + } + return 0, fmt.Errorf("unterminated name in type encoding %q", s) +} + +// abiScalar is one scalar member of a type, as the calling convention sees it. +type abiScalar struct { + offset uintptr + // kind is the size in bytes of an integer or pointer ('1', '2', '4' or '8'), + // or 'f' or 'd' for floating point. + kind byte +} + +// size returns the size of the scalar in bytes. +func (s abiScalar) size() uintptr { + switch s.kind { + case 'f': + return 4 + case 'd': + return 8 + } + return uintptr(s.kind - '0') +} + +// isFloat reports whether the scalar is passed in a floating point register. +func (s abiScalar) isFloat() bool { + return s.kind == 'f' || s.kind == 'd' +} + +// abiLayout is the layout of a type with nested structs and arrays flattened into scalars, +// so that a type encoding can be compared with a Go type. A void result has no size. +type abiLayout struct { + size, align uintptr + scalars []abiScalar + + // blank and blankScalars describe the blank (_) fields of a Go struct. + // They are padding as far as the Go type is concerned, so the type encoding + // may have nothing there, or a member that the Go type does not name. + blank []abiScalar + blankRegions [][2]uintptr // offset and size +} + +// append adds the members of l at offset. +func (a *abiLayout) append(l abiLayout, offset uintptr) { + for _, s := range l.scalars { + a.scalars = append(a.scalars, abiScalar{offset + s.offset, s.kind}) + } + for _, s := range l.blank { + a.blank = append(a.blank, abiScalar{offset + s.offset, s.kind}) + } + for _, r := range l.blankRegions { + a.blankRegions = append(a.blankRegions, [2]uintptr{offset + r[0], r[1]}) + } +} + +func scalarLayout(kind byte) abiLayout { + s := abiScalar{kind: kind} + return abiLayout{size: s.size(), align: s.size(), scalars: []abiScalar{s}} +} + +func alignUp(n, align uintptr) uintptr { + return (n + align - 1) / align * align +} + +// encodingLayout returns the layout of a type encoding, with members at their natural alignment. +func encodingLayout(enc string) (abiLayout, error) { + enc = stdstrings.TrimLeft(enc, encQualifiers) + if enc == "" { + return abiLayout{}, errors.New("missing type encoding") + } + switch enc[0] { + case 'v': + return abiLayout{}, nil + case 'c', 'C', 'B': + return scalarLayout('1'), nil + case 's', 'S': + return scalarLayout('2'), nil + case 'i', 'I', 'l', 'L': // long is encoded as a 32-bit quantity + return scalarLayout('4'), nil + case 'q', 'Q', '^', '*', '@', '#', ':': + return scalarLayout('8'), nil + case 'f', 'd': + return scalarLayout(enc[0]), nil + case '[': + digits := len(enc) - 1 - len(stdstrings.TrimLeft(enc[1:], "0123456789")) + var count uintptr + for _, c := range enc[1 : 1+digits] { + count = count*10 + uintptr(c-'0') + } + elem, err := encodingLayout(enc[1+digits : len(enc)-1]) + if err != nil { + return abiLayout{}, err + } + layout := abiLayout{size: count * elem.size, align: elem.align} + for i := range count { + layout.append(elem, i*elem.size) + } + return layout, nil + case '{': + _, fields, ok := stdstrings.Cut(enc[1:len(enc)-1], "=") + if !ok { + return abiLayout{}, fmt.Errorf("struct %s has no fields", enc) + } + layout := abiLayout{align: 1} + for fields != "" { + if fields[0] == '"' { + // a field name + n, err := quotedLen(fields) + if err != nil { + return abiLayout{}, err + } + fields = fields[n:] + continue + } + n, err := encodingLen(fields) + if err != nil { + return abiLayout{}, err + } + field, err := encodingLayout(fields[:n]) + if err != nil { + return abiLayout{}, err + } + fields = fields[n:] + if field.size == 0 { + continue + } + layout.size = alignUp(layout.size, field.align) + layout.append(field, layout.size) + layout.size += field.size + layout.align = max(layout.align, field.align) + } + layout.size = alignUp(layout.size, layout.align) + return layout, nil + } + return abiLayout{}, fmt.Errorf("unsupported type encoding %s", enc) +} + +// goLayout is encodingLayout for a Go type. +func goLayout(typ reflect.Type) (abiLayout, error) { + switch typ.Kind() { + case reflect.Bool, reflect.Int8, reflect.Uint8: + return scalarLayout('1'), nil + case reflect.Int16, reflect.Uint16: + return scalarLayout('2'), nil + case reflect.Int32, reflect.Uint32: + return scalarLayout('4'), nil + case reflect.Int, reflect.Int64, reflect.Uint, reflect.Uint64, reflect.Uintptr, + reflect.Pointer, reflect.UnsafePointer, reflect.String: + return scalarLayout('8'), nil + case reflect.Float32: + return scalarLayout('f'), nil + case reflect.Float64: + return scalarLayout('d'), nil + case reflect.Array: + elem, err := goLayout(typ.Elem()) + if err != nil { + return abiLayout{}, err + } + layout := abiLayout{size: typ.Size(), align: uintptr(typ.Align())} + for i := range uintptr(typ.Len()) { + layout.append(elem, i*elem.size) + } + return layout, nil + case reflect.Struct: + layout := abiLayout{size: typ.Size(), align: uintptr(typ.Align())} + for i := range typ.NumField() { + f := typ.Field(i) + if f.Type.Size() == 0 { + // structs.HostLayout and other zero-sized fields have no counterpart in C. + continue + } + field, err := goLayout(f.Type) + if err != nil { + return abiLayout{}, err + } + if f.Name == "_" { + // everything in a blank field is blank. + field.blank = append(field.blank, field.scalars...) + field.scalars = nil + field.blankRegions = [][2]uintptr{{0, field.size}} + } + layout.append(field, f.Offset) + } + return layout, nil + case reflect.Func: + // RegisterFunc would create a callback for every call, and callbacks are never freed. + return abiLayout{}, fmt.Errorf("objc: a %s argument to a block is not supported; create the callback once with purego.NewCallback and pass the uintptr", typ) + } + return abiLayout{}, fmt.Errorf("objc: unsupported block argument or result type %s", typ) +} + +// matches reports whether a value of the Go type laid out as g can be passed as the C type laid out as c. +// +// Every member must be at the same offset with the same kind on both sides, except for +// the blank (_) fields of the Go type. Callers use those for padding, which has no +// counterpart in a type encoding, and for members they have no use for. A blank field +// may therefore cover any integer or pointer members, or none. Floating point members +// decide which registers a struct is passed in, so they must match even when blank. +func (c abiLayout) matches(g abiLayout) bool { + if c.size != g.size { + return false + } + for _, s := range c.scalars { + if slices.Contains(g.scalars, s) { + continue + } + if s.isFloat() { + if !slices.Contains(g.blank, s) { + return false + } + continue + } + if !slices.ContainsFunc(g.blankRegions, func(r [2]uintptr) bool { + return s.offset >= r[0] && s.offset+s.size() <= r[0]+r[1] + }) { + return false + } + } + for _, s := range g.scalars { + if !slices.Contains(c.scalars, s) { + return false + } + } + for _, s := range g.blank { + if s.isFloat() && !slices.Contains(c.scalars, s) { + return false + } + } + return true +} diff --git a/objc/objc_block_signature_darwin_test.go b/objc/objc_block_signature_darwin_test.go new file mode 100644 index 00000000..393d7ba5 --- /dev/null +++ b/objc/objc_block_signature_darwin_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package objc + +import ( + "reflect" + "slices" + "structs" + "testing" +) + +func TestSplitSignature(t *testing.T) { + tests := []struct { + sig string + want []string + }{ + {"v24@?0q8d16", []string{"v", "@?", "q", "d"}}, + {"q28@?0i8c12s16q20", []string{"q", "@?", "i", "c", "s", "q"}}, + {`v32@?0@"NSString"8@"NSError"16q24`, []string{"v", "@?", `@"NSString"`, `@"NSError"`, "q"}}, + {"v24@?0@?8q16", []string{"v", "@?", "@?", "q"}}, + {"v16@?0@?>8", []string{"v", "@?", "@?>"}}, + {`v16@?0@""8`, []string{"v", "@?", `@""`}}, + {"{Big=qqqq}16@?0q8", []string{"{Big=qqqq}", "@?", "q"}}, + {"v16@?0^{S=[2{T=i}]}8", []string{"v", "@?", "^{S=[2{T=i}]}"}}, + {"v24@?0r*8Vv16", []string{"v", "@?", "*", "v"}}, + {"v16@?0^r^v8", []string{"v", "@?", "^r^v"}}, + {`{P="x"d"y"d}8@?0`, []string{`{P="x"d"y"d}`, "@?"}}, + } + for _, tt := range tests { + got, err := splitSignature(tt.sig) + if err != nil { + t.Errorf("splitSignature(%q): %v", tt.sig, err) + continue + } + if !slices.Equal(got, tt.want) { + t.Errorf("splitSignature(%q) = %q, want %q", tt.sig, got, tt.want) + } + } + + for _, sig := range []string{"v8@?0{S=i", `v8@?0@"NSString`, "v8@?0@?", 8, 8, []abiScalar{{0, '8'}}}, + {`@"NSString"`, 8, 8, []abiScalar{{0, '8'}}}, + {"r*", 8, 8, []abiScalar{{0, '8'}}}, + {"f", 4, 4, []abiScalar{{0, 'f'}}}, + {"{Big=qqqq}", 32, 8, []abiScalar{{0, '8'}, {8, '8'}, {16, '8'}, {24, '8'}}}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", 32, 8, []abiScalar{{0, 'd'}, {8, 'd'}, {16, 'd'}, {24, 'd'}}}, + {"{BoolFloat=Bf}", 8, 4, []abiScalar{{0, '1'}, {4, 'f'}}}, + {`{P="x"d"y"i}`, 16, 8, []abiScalar{{0, 'd'}, {8, '4'}}}, + {"{S=c[3s]q}", 16, 8, []abiScalar{{0, '1'}, {2, '2'}, {4, '2'}, {6, '2'}, {8, '8'}}}, + {"{S=[2{T=cd}]c}", 40, 8, []abiScalar{{0, '1'}, {8, 'd'}, {16, '1'}, {24, 'd'}, {32, '1'}}}, + } + for _, tt := range tests { + got, err := encodingLayout(tt.enc) + if err != nil { + t.Errorf("encodingLayout(%q): %v", tt.enc, err) + continue + } + if got.size != tt.size || got.align != tt.align || !slices.Equal(got.scalars, tt.scalars) { + t.Errorf("encodingLayout(%q) = size %d, align %d, %v; want size %d, align %d, %v", + tt.enc, got.size, got.align, got.scalars, tt.size, tt.align, tt.scalars) + } + } + + for _, enc := range []string{"(U=id)", "{S=b3}", "D", "{Opaque}"} { + if got, err := encodingLayout(enc); err == nil { + t.Errorf("encodingLayout(%q) = %v, want an error", enc, got) + } + } +} + +func TestGoLayoutUnsupported(t *testing.T) { + for _, typ := range []reflect.Type{reflect.TypeFor[func()](), reflect.TypeFor[any](), reflect.TypeFor[[]int](), reflect.TypeFor[map[int]int]()} { + if got, err := goLayout(typ); err == nil { + t.Errorf("goLayout(%v) = %v, want an error", typ, got) + } + } +} + +func TestLayoutMatches(t *testing.T) { + type point struct { + _ structs.HostLayout + X, Y float64 + } + type rect struct { + _ structs.HostLayout + Origin point + Size point + } + type mixed struct { + _ structs.HostLayout + A [3]float32 + B bool + C *int + } + // padding written out, as the RegisterFunc documentation asks for. + type boolFloat struct { + _ structs.HostLayout + B bool + _ [3]byte + F float32 + } + type boolFloatImplicit struct { + _ structs.HostLayout + B bool + F float32 + } + // a blank field standing in for a member the caller does not need. + type skipInt struct { + _ structs.HostLayout + A int32 + _ int32 + C int64 + } + type skipInts struct { + _ structs.HostLayout + _ [2]int32 + C int64 + } + type skipFloat struct { + _ structs.HostLayout + A float32 + _ float32 + } + type intOverFloat struct { + _ structs.HostLayout + A float32 + _ int32 + } + type floatOverInt struct { + _ structs.HostLayout + A int32 + _ float32 + } + type tailPadding struct { + _ structs.HostLayout + A int64 + B int8 + _ [7]byte + } + type namedPadding struct { + _ structs.HostLayout + B bool + Pad [3]byte + F float32 + } + + tests := []struct { + enc string + typ reflect.Type + want bool + }{ + {"B", reflect.TypeFor[bool](), true}, + {"c", reflect.TypeFor[bool](), true}, + {"i", reflect.TypeFor[int32](), true}, + {"i", reflect.TypeFor[int](), false}, + {"q", reflect.TypeFor[int](), true}, + {"q", reflect.TypeFor[int32](), false}, + {"q", reflect.TypeFor[float64](), false}, + {"d", reflect.TypeFor[float64](), true}, + {"d", reflect.TypeFor[float32](), false}, + {"@", reflect.TypeFor[ID](), true}, + {"@?", reflect.TypeFor[Block](), true}, + {"v", reflect.TypeFor[int](), false}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", reflect.TypeFor[rect](), true}, + {"{CGPoint=dd}", reflect.TypeFor[rect](), false}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", reflect.TypeFor[point](), false}, + {"{S=[3f]B^i}", reflect.TypeFor[mixed](), true}, + {"{S=[3f]i^i}", reflect.TypeFor[mixed](), false}, + {"{BoolFloat=Bf}", reflect.TypeFor[boolFloat](), true}, + {"{BoolFloat=Bf}", reflect.TypeFor[boolFloatImplicit](), true}, + {"{S=Bi}", reflect.TypeFor[boolFloat](), false}, + {"{S=iiq}", reflect.TypeFor[skipInt](), true}, + {"{S=issq}", reflect.TypeFor[skipInt](), true}, + {"{S=ifq}", reflect.TypeFor[skipInt](), false}, + {"{S=qq}", reflect.TypeFor[skipInt](), false}, + {"{S=iiq}", reflect.TypeFor[skipInts](), true}, + {"{S=qq}", reflect.TypeFor[skipInts](), true}, + {"{S=ff}", reflect.TypeFor[skipFloat](), true}, + {"{S=fi}", reflect.TypeFor[skipFloat](), false}, + {"{S=f}", reflect.TypeFor[skipFloat](), false}, + {"{S=ff}", reflect.TypeFor[intOverFloat](), false}, + {"{S=fi}", reflect.TypeFor[intOverFloat](), true}, + {"{S=ii}", reflect.TypeFor[floatOverInt](), false}, + {"{S=qc}", reflect.TypeFor[tailPadding](), true}, + {"{BoolFloat=Bf}", reflect.TypeFor[namedPadding](), false}, + } + for _, tt := range tests { + c, err := encodingLayout(tt.enc) + if err != nil { + t.Errorf("encodingLayout(%q): %v", tt.enc, err) + continue + } + g, err := goLayout(tt.typ) + if err != nil { + t.Errorf("goLayout(%v): %v", tt.typ, err) + continue + } + if got := c.matches(g); got != tt.want { + t.Errorf("%q matches %v = %v, want %v", tt.enc, tt.typ, got, tt.want) + } + } +} diff --git a/objc/super_darwin_test.go b/objc/super_darwin_test.go index 10d8b308..82b76a68 100644 --- a/objc/super_darwin_test.go +++ b/objc/super_darwin_test.go @@ -5,9 +5,7 @@ package objc_test import ( "fmt" - "os/exec" "path/filepath" - "runtime" "structs" "sync/atomic" "testing" @@ -97,14 +95,9 @@ func TestSendSuperDispatch(t *testing.T) { } func TestSendSuperStruct(t *testing.T) { - arch := "arm64" - if runtime.GOARCH == "amd64" { - arch = "x86_64" - } library := filepath.Join(t.TempDir(), "super.dylib") - cmd := exec.Command("clang", "-dynamiclib", "-arch", arch, "-framework", "Foundation", "-o", library, "testdata/super.m") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("compile super fixture: %v\n%s", err, out) + if err := buildSharedLib(t, library, filepath.Join("testdata", "super.m")); err != nil { + t.Fatal(err) } // Objective-C retains the registered classes and their implementations. if _, err := purego.Dlopen(library, purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil { diff --git a/objc/testdata/block.m b/objc/testdata/block.m new file mode 100644 index 00000000..faf8813b --- /dev/null +++ b/objc/testdata/block.m @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +#import +#include + +typedef int64_t (^PuregoBlock)(int64_t, double); + +// purego_heap_block returns a heap block that adds base, i, and f. +void *purego_heap_block(int64_t base) { + PuregoBlock b = ^int64_t(int64_t i, double f) { + return base + i + (int64_t)f; + }; + return Block_copy(b); +} + +// purego_with_stack_block calls cb with a block that is still on the stack. +// The block is only valid until cb returns. +void purego_with_stack_block(int64_t base, void (*cb)(void *block)) { + PuregoBlock b = ^int64_t(int64_t i, double f) { + return base + i + (int64_t)f; + }; + cb((void *)b); +} + +typedef struct { + int64_t a, b, c, d; +} Big; + +// purego_big_block returns a block that returns a struct in memory. +void *purego_big_block(void) { + Big (^b)(int64_t) = ^Big(int64_t x) { + Big r = {x, x + 1, x + 2, x + 3}; + return r; + }; + return Block_copy(b); +} + +// purego_fnptr_block returns a block that calls a function pointer. +void *purego_fnptr_block(void) { + void (^b)(void (*)(void)) = ^(void (*f)(void)) { + f(); + }; + return Block_copy(b); +} + +// purego_blockarg_block returns a block that calls the block it is given with x + 1. +void *purego_blockarg_block(void) { + void (^b)(void (^)(int64_t), int64_t) = ^(void (^h)(int64_t), int64_t x) { + h(x + 1); + }; + return Block_copy(b); +} + +typedef struct { + bool b; + float f; +} BoolFloat; + +// purego_boolfloat_block returns a block that returns a struct with padding between its members. +void *purego_boolfloat_block(void) { + BoolFloat (^b)(float) = ^BoolFloat(float f) { + BoolFloat r = {true, f * 2}; + return r; + }; + return Block_copy(b); +}