detached f2f4202

objc: encode 64-bit integers as q and Q (#499)

# What issue is this addressing?
Closes #493
## What _type_ of issue is this addressing?
bug
## What this PR does | solves
encodeType mapped Go's 64-bit integer kinds to the 32-bit @encode spellings: int to "l", and int64, uint and uint64 all to "L", which additionally gave int64 an unsigned spelling. On darwin, which is LP64 on every architecture Go supports, clang spells 64-bit integers "q" and "Q".

Argument passing is unaffected because purego marshals from the Go types, but consumers that read the declared width back from the runtime metadata - NSInvocation, NSMethodSignature and forwardInvocation: based proxies, KVC autoboxing from ivar encodings - silently truncate values above 2^32.

Check the encoding of each type against clang @encode itself so that the expected encodings cannot drift from the compiler, and assert how encodeFunc assembles a full method signature.
commit
f2f42023699cc525d655935f7f262a57faa3ee33
author
TotallyGamerJet · · 2026-08-20 17:14 UTC
committer
GitHub · · 2026-08-20 17:14 UTC
parent
7d5f4c2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +190 -6
A objc/encoding_darwin_test.go +184 -0
@@ -0,0 +1,184 @@
1+// SPDX-License-Identifier: Apache-2.0
2+// SPDX-FileCopyrightText: 2026 The Ebitengine Authors
3+
4+package objc
5+
6+import (
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "reflect"
12+ "strings"
13+ "testing"
14+ "unsafe"
15+)
16+
17+type encodeTypeTestStruct struct {
18+ A int32
19+ B float64
20+}
21+
22+var encodeTypeTests = []struct {
23+ typ reflect.Type
24+ cType string
25+ want string
26+}{
27+ {reflect.TypeFor[bool](), "_Bool", "B"},
28+ {reflect.TypeFor[int8](), "signed char", "c"},
29+ {reflect.TypeFor[uint8](), "unsigned char", "C"},
30+ {reflect.TypeFor[int16](), "short", "s"},
31+ {reflect.TypeFor[uint16](), "unsigned short", "S"},
32+ {reflect.TypeFor[int32](), "int", "i"},
33+ {reflect.TypeFor[uint32](), "unsigned int", "I"},
34+ {reflect.TypeFor[int64](), "long long", "q"},
35+ {reflect.TypeFor[uint64](), "unsigned long long", "Q"},
36+ {reflect.TypeFor[int](), "long", "q"},
37+ {reflect.TypeFor[uint](), "unsigned long", "Q"},
38+ {reflect.TypeFor[float32](), "float", "f"},
39+ {reflect.TypeFor[float64](), "double", "d"},
40+ {reflect.TypeFor[string](), "char *", "*"},
41+ {reflect.TypeFor[unsafe.Pointer](), "void *", "^v"},
42+ {reflect.TypeFor[*int32](), "int *", "^i"},
43+ {reflect.TypeFor[**int32](), "int **", "^^i"},
44+ {reflect.TypeFor[ID](), "id", "@"},
45+ {reflect.TypeFor[Class](), "Class", "#"},
46+ {reflect.TypeFor[SEL](), "SEL", ":"},
47+ {reflect.TypeFor[encodeTypeTestStruct](), "struct encodeTypeTestStruct", "{encodeTypeTestStruct=id}"},
48+}
49+
50+func TestEncodeType(t *testing.T) {
51+ for _, tt := range encodeTypeTests {
52+ got, err := encodeType(tt.typ, false)
53+ if err != nil {
54+ t.Errorf("encodeType(%v) returned error: %v", tt.typ, err)
55+ continue
56+ }
57+ if got != tt.want {
58+ t.Errorf("encodeType(%v) = %q; want %q (@encode(%s))", tt.typ, got, tt.want, tt.cType)
59+ }
60+ }
61+}
62+
63+// TestEncodeTypeMatchesClang uses the C compiler as an oracle: it compiles a
64+// program that prints @encode for each C type in encodeTypeTests and checks
65+// that the expected encodings are the ones the compiler actually produces.
66+func TestEncodeTypeMatchesClang(t *testing.T) {
67+ out, err := exec.Command("go", "env", "CC").Output()
68+ if err != nil {
69+ t.Fatalf("go env CC: %v", err)
70+ }
71+ compiler := strings.TrimSpace(string(out))
72+ if compiler == "" {
73+ t.Skip("no C compiler to use as an @encode oracle")
74+ }
75+ if _, err := exec.LookPath(compiler); err != nil {
76+ t.Skipf("no C compiler to use as an @encode oracle: %v", err)
77+ }
78+
79+ var src strings.Builder
80+ src.WriteString("#include <stdio.h>\n#include <objc/objc.h>\n")
81+ src.WriteString("struct encodeTypeTestStruct { int a; double b; };\n")
82+ src.WriteString("int main(void) {\n")
83+ for _, tt := range encodeTypeTests {
84+ fmt.Fprintf(&src, "\tprintf(\"%%s\\n\", @encode(%s));\n", tt.cType)
85+ }
86+ src.WriteString("\treturn 0;\n}\n")
87+
88+ dir := t.TempDir()
89+ srcFile := filepath.Join(dir, "encode.m")
90+ if err := os.WriteFile(srcFile, []byte(src.String()), 0o644); err != nil {
91+ t.Fatal(err)
92+ }
93+
94+ // Built for the host architecture rather than GOARCH, unlike the shared
95+ // libraries in the root package's tests: every darwin target Go supports
96+ // is LP64, so these encodings do not vary by architecture, and building
97+ // for the host avoids needing Rosetta to run an amd64 binary on arm64.
98+ exeFile := filepath.Join(dir, "encode")
99+ cmd := exec.Command(compiler, "-Wall", "-Werror", "-o", exeFile, srcFile)
100+ if out, err := cmd.CombinedOutput(); err != nil {
101+ t.Fatalf("compile oracle: %v\n%q\n%s", err, cmd, out)
102+ }
103+
104+ out, err = exec.Command(exeFile).Output()
105+ if err != nil {
106+ t.Fatalf("run oracle: %v", err)
107+ }
108+ lines := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")
109+ if len(lines) != len(encodeTypeTests) {
110+ t.Fatalf("oracle printed %d encodings; want %d", len(lines), len(encodeTypeTests))
111+ }
112+ for i, tt := range encodeTypeTests {
113+ if lines[i] != tt.want {
114+ t.Errorf("@encode(%s) = %q; want %q", tt.cType, lines[i], tt.want)
115+ }
116+ }
117+}
118+
119+// TestEncodeFunc checks how encodeFunc assembles a method signature: the
120+// return type, the implicit self and _cmd parameters, and the argument order.
121+// encodeType is checked per type by TestEncodeType, so the cases here vary the
122+// shape of the signature rather than the types in it. The first case is the
123+// signature from issue #493, which was recorded as v@:LLLLLLLLS - declaring
124+// 64-bit arguments as 32-bit made NSInvocation and other consumers of the type
125+// encoding truncate values above 2^32.
126+func TestEncodeFunc(t *testing.T) {
127+ tests := []struct {
128+ name string
129+ fn any
130+ want string
131+ }{
132+ {
133+ name: "void return, 64-bit args",
134+ fn: func(_ ID, _ SEL, a1, a2, a3, a4, a5, a6, a7, a8 uint64, a9 uint16) {},
135+ want: "v@:QQQQQQQQS",
136+ },
137+ {
138+ name: "value return, mixed integer kinds",
139+ fn: func(_ ID, _ SEL, a int, b int64, c uint, d uint64) int { return 0 },
140+ want: "q@:qqQQ",
141+ },
142+ {
143+ name: "no arguments",
144+ fn: func(_ ID, _ SEL) {},
145+ want: "v@:",
146+ },
147+ {
148+ name: "argument order is preserved",
149+ fn: func(_ ID, _ SEL, a int8, b float64, c ID) Class { return 0 },
150+ want: "#@:cd@",
151+ },
152+ }
153+ for _, tt := range tests {
154+ t.Run(tt.name, func(t *testing.T) {
155+ got, err := encodeFunc(tt.fn)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ if got != tt.want {
160+ t.Errorf("encodeFunc = %q; want %q", got, tt.want)
161+ }
162+ })
163+ }
164+}
165+
166+func TestEncodeFuncErrors(t *testing.T) {
167+ tests := []struct {
168+ name string
169+ fn any
170+ }{
171+ {"not a func", 0},
172+ {"too many return values", func(_ ID, _ SEL) (int, int) { return 0, 0 }},
173+ {"missing self and _cmd", func() {}},
174+ {"missing _cmd", func(_ ID) {}},
175+ {"unencodable argument", func(_ ID, _ SEL, c chan int) {}},
176+ }
177+ for _, tt := range tests {
178+ t.Run(tt.name, func(t *testing.T) {
179+ if got, err := encodeFunc(tt.fn); err == nil {
180+ t.Errorf("encodeFunc = %q; want an error", got)
181+ }
182+ })
183+ }
184+}
@@ -0,0 +1,184 @@
1+// SPDX-License-Identifier: Apache-2.0
2+// SPDX-FileCopyrightText: 2026 The Ebitengine Authors
3+
4+package objc
5+
6+import (
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "reflect"
12+ "strings"
13+ "testing"
14+ "unsafe"
15+)
16+
17+type encodeTypeTestStruct struct {
18+ A int32
19+ B float64
20+}
21+
22+var encodeTypeTests = []struct {
23+ typ reflect.Type
24+ cType string
25+ want string
26+}{
27+ {reflect.TypeFor[bool](), "_Bool", "B"},
28+ {reflect.TypeFor[int8](), "signed char", "c"},
29+ {reflect.TypeFor[uint8](), "unsigned char", "C"},
30+ {reflect.TypeFor[int16](), "short", "s"},
31+ {reflect.TypeFor[uint16](), "unsigned short", "S"},
32+ {reflect.TypeFor[int32](), "int", "i"},
33+ {reflect.TypeFor[uint32](), "unsigned int", "I"},
34+ {reflect.TypeFor[int64](), "long long", "q"},
35+ {reflect.TypeFor[uint64](), "unsigned long long", "Q"},
36+ {reflect.TypeFor[int](), "long", "q"},
37+ {reflect.TypeFor[uint](), "unsigned long", "Q"},
38+ {reflect.TypeFor[float32](), "float", "f"},
39+ {reflect.TypeFor[float64](), "double", "d"},
40+ {reflect.TypeFor[string](), "char *", "*"},
41+ {reflect.TypeFor[unsafe.Pointer](), "void *", "^v"},
42+ {reflect.TypeFor[*int32](), "int *", "^i"},
43+ {reflect.TypeFor[**int32](), "int **", "^^i"},
44+ {reflect.TypeFor[ID](), "id", "@"},
45+ {reflect.TypeFor[Class](), "Class", "#"},
46+ {reflect.TypeFor[SEL](), "SEL", ":"},
47+ {reflect.TypeFor[encodeTypeTestStruct](), "struct encodeTypeTestStruct", "{encodeTypeTestStruct=id}"},
48+}
49+
50+func TestEncodeType(t *testing.T) {
51+ for _, tt := range encodeTypeTests {
52+ got, err := encodeType(tt.typ, false)
53+ if err != nil {
54+ t.Errorf("encodeType(%v) returned error: %v", tt.typ, err)
55+ continue
56+ }
57+ if got != tt.want {
58+ t.Errorf("encodeType(%v) = %q; want %q (@encode(%s))", tt.typ, got, tt.want, tt.cType)
59+ }
60+ }
61+}
62+
63+// TestEncodeTypeMatchesClang uses the C compiler as an oracle: it compiles a
64+// program that prints @encode for each C type in encodeTypeTests and checks
65+// that the expected encodings are the ones the compiler actually produces.
66+func TestEncodeTypeMatchesClang(t *testing.T) {
67+ out, err := exec.Command("go", "env", "CC").Output()
68+ if err != nil {
69+ t.Fatalf("go env CC: %v", err)
70+ }
71+ compiler := strings.TrimSpace(string(out))
72+ if compiler == "" {
73+ t.Skip("no C compiler to use as an @encode oracle")
74+ }
75+ if _, err := exec.LookPath(compiler); err != nil {
76+ t.Skipf("no C compiler to use as an @encode oracle: %v", err)
77+ }
78+
79+ var src strings.Builder
80+ src.WriteString("#include <stdio.h>\n#include <objc/objc.h>\n")
81+ src.WriteString("struct encodeTypeTestStruct { int a; double b; };\n")
82+ src.WriteString("int main(void) {\n")
83+ for _, tt := range encodeTypeTests {
84+ fmt.Fprintf(&src, "\tprintf(\"%%s\\n\", @encode(%s));\n", tt.cType)
85+ }
86+ src.WriteString("\treturn 0;\n}\n")
87+
88+ dir := t.TempDir()
89+ srcFile := filepath.Join(dir, "encode.m")
90+ if err := os.WriteFile(srcFile, []byte(src.String()), 0o644); err != nil {
91+ t.Fatal(err)
92+ }
93+
94+ // Built for the host architecture rather than GOARCH, unlike the shared
95+ // libraries in the root package's tests: every darwin target Go supports
96+ // is LP64, so these encodings do not vary by architecture, and building
97+ // for the host avoids needing Rosetta to run an amd64 binary on arm64.
98+ exeFile := filepath.Join(dir, "encode")
99+ cmd := exec.Command(compiler, "-Wall", "-Werror", "-o", exeFile, srcFile)
100+ if out, err := cmd.CombinedOutput(); err != nil {
101+ t.Fatalf("compile oracle: %v\n%q\n%s", err, cmd, out)
102+ }
103+
104+ out, err = exec.Command(exeFile).Output()
105+ if err != nil {
106+ t.Fatalf("run oracle: %v", err)
107+ }
108+ lines := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")
109+ if len(lines) != len(encodeTypeTests) {
110+ t.Fatalf("oracle printed %d encodings; want %d", len(lines), len(encodeTypeTests))
111+ }
112+ for i, tt := range encodeTypeTests {
113+ if lines[i] != tt.want {
114+ t.Errorf("@encode(%s) = %q; want %q", tt.cType, lines[i], tt.want)
115+ }
116+ }
117+}
118+
119+// TestEncodeFunc checks how encodeFunc assembles a method signature: the
120+// return type, the implicit self and _cmd parameters, and the argument order.
121+// encodeType is checked per type by TestEncodeType, so the cases here vary the
122+// shape of the signature rather than the types in it. The first case is the
123+// signature from issue #493, which was recorded as v@:LLLLLLLLS - declaring
124+// 64-bit arguments as 32-bit made NSInvocation and other consumers of the type
125+// encoding truncate values above 2^32.
126+func TestEncodeFunc(t *testing.T) {
127+ tests := []struct {
128+ name string
129+ fn any
130+ want string
131+ }{
132+ {
133+ name: "void return, 64-bit args",
134+ fn: func(_ ID, _ SEL, a1, a2, a3, a4, a5, a6, a7, a8 uint64, a9 uint16) {},
135+ want: "v@:QQQQQQQQS",
136+ },
137+ {
138+ name: "value return, mixed integer kinds",
139+ fn: func(_ ID, _ SEL, a int, b int64, c uint, d uint64) int { return 0 },
140+ want: "q@:qqQQ",
141+ },
142+ {
143+ name: "no arguments",
144+ fn: func(_ ID, _ SEL) {},
145+ want: "v@:",
146+ },
147+ {
148+ name: "argument order is preserved",
149+ fn: func(_ ID, _ SEL, a int8, b float64, c ID) Class { return 0 },
150+ want: "#@:cd@",
151+ },
152+ }
153+ for _, tt := range tests {
154+ t.Run(tt.name, func(t *testing.T) {
155+ got, err := encodeFunc(tt.fn)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ if got != tt.want {
160+ t.Errorf("encodeFunc = %q; want %q", got, tt.want)
161+ }
162+ })
163+ }
164+}
165+
166+func TestEncodeFuncErrors(t *testing.T) {
167+ tests := []struct {
168+ name string
169+ fn any
170+ }{
171+ {"not a func", 0},
172+ {"too many return values", func(_ ID, _ SEL) (int, int) { return 0, 0 }},
173+ {"missing self and _cmd", func() {}},
174+ {"missing _cmd", func(_ ID) {}},
175+ {"unencodable argument", func(_ ID, _ SEL, c chan int) {}},
176+ }
177+ for _, tt := range tests {
178+ t.Run(tt.name, func(t *testing.T) {
179+ if got, err := encodeFunc(tt.fn); err == nil {
180+ t.Errorf("encodeFunc = %q; want an error", got)
181+ }
182+ })
183+ }
184+}
M objc/objc_runtime_darwin.go +6 -6
@@ -402,8 +402,8 @@ const (
402402 encUShort = "S"
403403 encInt = "i"
404404 encUInt = "I"
405- encLong = "l"
406- encULong = "L"
405+ encLongLong = "q"
406+ encULongLong = "Q"
407407 encFloat = "f"
408408 encDouble = "d"
409409 encBool = "B"
@@ -432,7 +432,7 @@ func encodeType(typ reflect.Type, insidePtr bool) (string, error) {
432432 case reflect.Bool:
433433 return encBool, nil
434434 case reflect.Int:
435- return encLong, nil
435+ return encLongLong, nil
436436 case reflect.Int8:
437437 return encChar, nil
438438 case reflect.Int16:
@@ -440,9 +440,9 @@ func encodeType(typ reflect.Type, insidePtr bool) (string, error) {
440440 case reflect.Int32:
441441 return encInt, nil
442442 case reflect.Int64:
443- return encULong, nil
443+ return encLongLong, nil
444444 case reflect.Uint:
445- return encULong, nil
445+ return encULongLong, nil
446446 case reflect.Uint8:
447447 return encUChar, nil
448448 case reflect.Uint16:
@@ -450,7 +450,7 @@ func encodeType(typ reflect.Type, insidePtr bool) (string, error) {
450450 case reflect.Uint32:
451451 return encUInt, nil
452452 case reflect.Uint64:
453- return encULong, nil
453+ return encULongLong, nil
454454 case reflect.Uintptr:
455455 return encPtr, nil
456456 case reflect.Float32:
@@ -402,8 +402,8 @@ const (
402 encUShort = "S"402 encUShort = "S"
403 encInt = "i"403 encInt = "i"
404 encUInt = "I"404 encUInt = "I"
405- encLong = "l"405+ encLongLong = "q"
406- encULong = "L"406+ encULongLong = "Q"
407 encFloat = "f"407 encFloat = "f"
408 encDouble = "d"408 encDouble = "d"
409 encBool = "B"409 encBool = "B"
@@ -432,7 +432,7 @@ func encodeType(typ reflect.Type, insidePtr bool) (string, error) {
432 case reflect.Bool:432 case reflect.Bool:
433 return encBool, nil433 return encBool, nil
434 case reflect.Int:434 case reflect.Int:
435- return encLong, nil435+ return encLongLong, nil
436 case reflect.Int8:436 case reflect.Int8:
437 return encChar, nil437 return encChar, nil
438 case reflect.Int16:438 case reflect.Int16:
@@ -440,9 +440,9 @@ func encodeType(typ reflect.Type, insidePtr bool) (string, error) {
440 case reflect.Int32:440 case reflect.Int32:
441 return encInt, nil441 return encInt, nil
442 case reflect.Int64:442 case reflect.Int64:
443- return encULong, nil443+ return encLongLong, nil
444 case reflect.Uint:444 case reflect.Uint:
445- return encULong, nil445+ return encULongLong, nil
446 case reflect.Uint8:446 case reflect.Uint8:
447 return encUChar, nil447 return encUChar, nil
448 case reflect.Uint16:448 case reflect.Uint16:
@@ -450,7 +450,7 @@ func encodeType(typ reflect.Type, insidePtr bool) (string, error) {
450 case reflect.Uint32:450 case reflect.Uint32:
451 return encUInt, nil451 return encUInt, nil
452 case reflect.Uint64:452 case reflect.Uint64:
453- return encULong, nil453+ return encULongLong, nil
454 case reflect.Uintptr:454 case reflect.Uintptr:
455 return encPtr, nil455 return encPtr, nil
456 case reflect.Float32:456 case reflect.Float32:

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 102

↑↓ moveenter openesc close