notso-glb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ """WASM runtime for gltfpack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .wasi import WasiExit
9
+ from .wasi import WasiFilesystem
10
+
11
+
12
+ def _get_wasm_path() -> Path:
13
+ """Get path to bundled gltfpack.wasm."""
14
+ return Path(__file__).parent / "gltfpack.wasm"
15
+
16
+
17
+ class GltfpackWasm(WasiFilesystem):
18
+ """WASM-based gltfpack runner using wasmtime."""
19
+
20
+ def _get_export(self, name: str) -> Any:
21
+ """Get a named export from the WASM instance."""
22
+ exports: Any = self._instance.exports(self._store) # type: ignore[union-attr]
23
+ return exports[name]
24
+
25
+ def _upload_argv(self, argv: list[str]) -> int:
26
+ """Upload argument vector to WASM memory."""
27
+ encoded_args = [arg.encode("utf-8") for arg in argv]
28
+ buf_size = len(argv) * 4
29
+ for arg in encoded_args:
30
+ buf_size += len(arg) + 1
31
+
32
+ malloc = self._get_export("malloc")
33
+ buf: int = malloc(self._store, buf_size)
34
+ argp = buf + len(argv) * 4
35
+
36
+ self._refresh_memory()
37
+ assert self._memory_array is not None
38
+
39
+ for i, arg in enumerate(encoded_args):
40
+ self._set_u32(buf + i * 4, argp)
41
+ # Copy string bytes
42
+ for j, b in enumerate(arg):
43
+ self._memory_array[argp + j] = b
44
+ self._set_u8(argp + len(arg), 0)
45
+ argp += len(arg) + 1
46
+
47
+ return buf
48
+
49
+ def _initialize(self) -> None:
50
+ """Initialize WASM instance with wasmtime."""
51
+ if self._instance is not None:
52
+ return
53
+
54
+ from wasmtime import Engine
55
+ from wasmtime import Func
56
+ from wasmtime import FuncType
57
+ from wasmtime import Linker
58
+ from wasmtime import Module
59
+ from wasmtime import Store
60
+ from wasmtime import ValType
61
+
62
+ engine = Engine()
63
+ self._store = Store(engine)
64
+ wasm_bytes = _get_wasm_path().read_bytes()
65
+ module = Module(engine, wasm_bytes)
66
+
67
+ linker = Linker(engine)
68
+
69
+ # Define WASI functions
70
+ linker.define(
71
+ self._store,
72
+ "wasi_snapshot_preview1",
73
+ "proc_exit",
74
+ Func(self._store, FuncType([ValType.i32()], []), self.wasi_proc_exit),
75
+ )
76
+ linker.define(
77
+ self._store,
78
+ "wasi_snapshot_preview1",
79
+ "fd_close",
80
+ Func(
81
+ self._store,
82
+ FuncType([ValType.i32()], [ValType.i32()]),
83
+ self.wasi_fd_close,
84
+ ),
85
+ )
86
+ linker.define(
87
+ self._store,
88
+ "wasi_snapshot_preview1",
89
+ "fd_fdstat_get",
90
+ Func(
91
+ self._store,
92
+ FuncType([ValType.i32(), ValType.i32()], [ValType.i32()]),
93
+ self.wasi_fd_fdstat_get,
94
+ ),
95
+ )
96
+ linker.define(
97
+ self._store,
98
+ "wasi_snapshot_preview1",
99
+ "path_open32",
100
+ Func(
101
+ self._store,
102
+ FuncType(
103
+ [ValType.i32()] * 9,
104
+ [ValType.i32()],
105
+ ),
106
+ self.wasi_path_open32,
107
+ ),
108
+ )
109
+ linker.define(
110
+ self._store,
111
+ "wasi_snapshot_preview1",
112
+ "path_filestat_get",
113
+ Func(
114
+ self._store,
115
+ FuncType([ValType.i32()] * 5, [ValType.i32()]),
116
+ self.wasi_path_filestat_get,
117
+ ),
118
+ )
119
+ linker.define(
120
+ self._store,
121
+ "wasi_snapshot_preview1",
122
+ "fd_prestat_get",
123
+ Func(
124
+ self._store,
125
+ FuncType([ValType.i32(), ValType.i32()], [ValType.i32()]),
126
+ self.wasi_fd_prestat_get,
127
+ ),
128
+ )
129
+ linker.define(
130
+ self._store,
131
+ "wasi_snapshot_preview1",
132
+ "fd_prestat_dir_name",
133
+ Func(
134
+ self._store,
135
+ FuncType([ValType.i32()] * 3, [ValType.i32()]),
136
+ self.wasi_fd_prestat_dir_name,
137
+ ),
138
+ )
139
+ linker.define(
140
+ self._store,
141
+ "wasi_snapshot_preview1",
142
+ "path_remove_directory",
143
+ Func(
144
+ self._store,
145
+ FuncType([ValType.i32()] * 3, [ValType.i32()]),
146
+ self.wasi_path_remove_directory,
147
+ ),
148
+ )
149
+ linker.define(
150
+ self._store,
151
+ "wasi_snapshot_preview1",
152
+ "fd_fdstat_set_flags",
153
+ Func(
154
+ self._store,
155
+ FuncType([ValType.i32(), ValType.i32()], [ValType.i32()]),
156
+ self.wasi_fd_fdstat_set_flags,
157
+ ),
158
+ )
159
+ linker.define(
160
+ self._store,
161
+ "wasi_snapshot_preview1",
162
+ "fd_seek32",
163
+ Func(
164
+ self._store,
165
+ FuncType([ValType.i32()] * 4, [ValType.i32()]),
166
+ self.wasi_fd_seek32,
167
+ ),
168
+ )
169
+ linker.define(
170
+ self._store,
171
+ "wasi_snapshot_preview1",
172
+ "fd_read",
173
+ Func(
174
+ self._store,
175
+ FuncType([ValType.i32()] * 4, [ValType.i32()]),
176
+ self.wasi_fd_read,
177
+ ),
178
+ )
179
+ linker.define(
180
+ self._store,
181
+ "wasi_snapshot_preview1",
182
+ "fd_write",
183
+ Func(
184
+ self._store,
185
+ FuncType([ValType.i32()] * 4, [ValType.i32()]),
186
+ self.wasi_fd_write,
187
+ ),
188
+ )
189
+
190
+ self._instance = linker.instantiate(self._store, module)
191
+
192
+ # Call constructors
193
+ exports: Any = self._instance.exports(self._store)
194
+ ctors = exports.get("__wasm_call_ctors")
195
+ if ctors:
196
+ ctors(self._store)
197
+
198
+ def pack(
199
+ self,
200
+ input_data: bytes,
201
+ input_name: str = "input.glb",
202
+ output_name: str = "output.glb",
203
+ args: list[str] | None = None,
204
+ ) -> tuple[bool, bytes, str]:
205
+ """
206
+ Run gltfpack on input data.
207
+
208
+ Args:
209
+ input_data: Input GLB/glTF bytes
210
+ input_name: Virtual input filename
211
+ output_name: Virtual output filename
212
+ args: Additional gltfpack arguments
213
+
214
+ Returns:
215
+ Tuple of (success, output_bytes, log_message)
216
+ """
217
+ self._initialize()
218
+ self._init_fds()
219
+
220
+ self._fs_interface = {input_name: input_data}
221
+
222
+ argv = ["gltfpack", "-i", input_name, "-o", output_name]
223
+ if args:
224
+ argv.extend(args)
225
+
226
+ buf = self._upload_argv(argv)
227
+
228
+ pack_fn = self._get_export("pack")
229
+ try:
230
+ result: int = pack_fn(self._store, len(argv), buf)
231
+ except WasiExit as e:
232
+ # WASI proc_exit was called; treat non-zero as failure
233
+ result = e.exit_code
234
+
235
+ free_fn = self._get_export("free")
236
+ free_fn(self._store, buf)
237
+
238
+ log = self._output_buffer.decode("utf-8", errors="replace")
239
+
240
+ if result != 0:
241
+ return False, b"", log
242
+
243
+ output_data = self._fs_interface.get(output_name, b"")
244
+ return True, output_data, log
notso_glb/wasm/wasi.py ADDED
@@ -0,0 +1,347 @@
1
+ """WASI filesystem implementation for gltfpack WASM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from typing import TYPE_CHECKING
7
+ from typing import Any
8
+
9
+ from .constants import WASI_EBADF
10
+ from .constants import WASI_EFAULT
11
+ from .constants import WASI_EINVAL
12
+ from .constants import WASI_EIO
13
+ from .constants import WASI_ENOSYS
14
+
15
+
16
+ class WasiExit(Exception):
17
+ """Exception raised when WASI proc_exit is called."""
18
+
19
+ def __init__(self, exit_code: int) -> None:
20
+ self.exit_code = exit_code
21
+ super().__init__(f"WASI process exited with code {exit_code}")
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from wasmtime import Instance, Memory, Store
26
+
27
+
28
+ class WasiFilesystem:
29
+ """WASI filesystem implementation for in-memory file operations."""
30
+
31
+ def __init__(self) -> None:
32
+ self._store: Store | None = None
33
+ self._instance: Instance | None = None
34
+ self._fs_interface: dict[str, bytes] | None = None
35
+ self._output_buffer: bytearray = bytearray()
36
+ self._fds: dict[int, dict[str, Any]] = {}
37
+ self._memory_array: ctypes.Array | None = None
38
+
39
+ def _init_fds(self) -> None:
40
+ """Initialize file descriptors."""
41
+ self._output_buffer = bytearray()
42
+ self._fds = {
43
+ 1: {"type": "output"}, # stdout
44
+ 2: {"type": "output"}, # stderr
45
+ 3: {"mount": "/", "path": "/"},
46
+ 4: {"mount": "/gltfpack-$pwd", "path": ""},
47
+ }
48
+
49
+ def _next_fd(self) -> int:
50
+ """Get next available file descriptor."""
51
+ fd = 5
52
+ while fd in self._fds:
53
+ fd += 1
54
+ return fd
55
+
56
+ # Memory access methods
57
+
58
+ def _get_memory(self) -> Memory:
59
+ """Get WASM memory export."""
60
+ if self._instance is None or self._store is None:
61
+ raise RuntimeError("[ERROR] WASI runtime is not initialized")
62
+ from wasmtime import Memory
63
+
64
+ exports: Any = self._instance.exports(self._store)
65
+ memory = exports["memory"]
66
+ assert isinstance(memory, Memory)
67
+ return memory
68
+
69
+ def _refresh_memory(self) -> None:
70
+ """Refresh memory array reference (needed after memory growth)."""
71
+ memory = self._get_memory()
72
+ ptr = memory.data_ptr(self._store) # type: ignore[arg-type]
73
+ size = memory.data_len(self._store) # type: ignore[arg-type]
74
+ self._memory_array = (ctypes.c_ubyte * size).from_address(
75
+ ctypes.addressof(ptr.contents)
76
+ )
77
+
78
+ def _check_bounds(self, func_name: str, offset: int, length: int) -> int:
79
+ """Validate memory access bounds, return memory length."""
80
+ self._refresh_memory()
81
+ assert self._memory_array is not None
82
+ mem_len = len(self._memory_array)
83
+ if offset < 0:
84
+ raise ValueError(f"{func_name}: negative offset {offset} (length={length})")
85
+ if offset + length > mem_len:
86
+ raise ValueError(
87
+ f"{func_name}: out of bounds offset={offset} length={length} "
88
+ f"exceeds memory size {mem_len}"
89
+ )
90
+ return mem_len
91
+
92
+ def _get_string(self, offset: int, length: int) -> str:
93
+ """Read string from WASM memory."""
94
+ self._check_bounds("_get_string", offset, length)
95
+ assert self._memory_array is not None
96
+ return bytes(self._memory_array[offset : offset + length]).decode("utf-8")
97
+
98
+ def _set_u8(self, offset: int, value: int) -> None:
99
+ """Write uint8 to WASM memory."""
100
+ self._check_bounds("_set_u8", offset, 1)
101
+ assert self._memory_array is not None
102
+ self._memory_array[offset] = value & 0xFF
103
+
104
+ def _set_u32(self, offset: int, value: int) -> None:
105
+ """Write uint32 (little-endian) to WASM memory."""
106
+ self._check_bounds("_set_u32", offset, 4)
107
+ assert self._memory_array is not None
108
+ val_bytes = value.to_bytes(4, "little")
109
+ for i, b in enumerate(val_bytes):
110
+ self._memory_array[offset + i] = b
111
+
112
+ def _get_u32(self, offset: int) -> int:
113
+ """Read uint32 (little-endian) from WASM memory."""
114
+ self._check_bounds("_get_u32", offset, 4)
115
+ assert self._memory_array is not None
116
+ return int.from_bytes(bytes(self._memory_array[offset : offset + 4]), "little")
117
+
118
+ # WASI syscall implementations
119
+
120
+ def wasi_proc_exit(self, rval: int) -> None:
121
+ """WASI proc_exit syscall."""
122
+ raise WasiExit(rval)
123
+
124
+ def wasi_fd_close(self, fd: int) -> int:
125
+ """WASI fd_close syscall."""
126
+ if fd not in self._fds:
127
+ return WASI_EBADF
128
+ try:
129
+ fd_info = self._fds[fd]
130
+ if "close_data" in fd_info and self._fs_interface is not None:
131
+ name = fd_info.get("name", "")
132
+ data = fd_info["data"][: fd_info["size"]]
133
+ self._fs_interface[name] = bytes(data)
134
+ del self._fds[fd]
135
+ return 0
136
+ except (KeyError, TypeError, IndexError):
137
+ if fd in self._fds:
138
+ del self._fds[fd]
139
+ return WASI_EIO
140
+
141
+ def wasi_fd_fdstat_get(self, fd: int, stat: int) -> int:
142
+ """WASI fd_fdstat_get syscall."""
143
+ if fd not in self._fds:
144
+ return WASI_EBADF
145
+ # Validate stat buffer can hold fdstat struct (24 bytes)
146
+ self._check_bounds("wasi_fd_fdstat_get", stat, 24)
147
+ fd_info = self._fds[fd]
148
+ # Determine filetype: 2=char device, 3=directory, 4=regular file
149
+ if fd_info.get("type") == "output":
150
+ filetype = 2 # character device (stdout/stderr)
151
+ elif "path" in fd_info:
152
+ filetype = 3 # directory
153
+ else:
154
+ filetype = 4 # regular file
155
+ self._set_u8(stat + 0, filetype)
156
+ self._set_u32(stat + 2, 0)
157
+ self._set_u32(stat + 8, 0)
158
+ self._set_u32(stat + 12, 0)
159
+ self._set_u32(stat + 16, 0)
160
+ self._set_u32(stat + 20, 0)
161
+ return 0
162
+
163
+ def wasi_path_open32(
164
+ self,
165
+ parent_fd: int,
166
+ dirflags: int,
167
+ path: int,
168
+ path_len: int,
169
+ oflags: int,
170
+ fs_rights_base: int,
171
+ fs_rights_inheriting: int,
172
+ fdflags: int,
173
+ opened_fd: int,
174
+ ) -> int:
175
+ """WASI path_open syscall (32-bit variant)."""
176
+ if parent_fd not in self._fds or "path" not in self._fds[parent_fd]:
177
+ return WASI_EBADF
178
+
179
+ file_path = self._fds[parent_fd]["path"] + self._get_string(path, path_len)
180
+
181
+ file_info: dict[str, Any] = {
182
+ "name": file_path,
183
+ "position": 0,
184
+ }
185
+
186
+ if oflags & 1: # O_CREAT
187
+ file_info["data"] = bytearray(4096)
188
+ file_info["size"] = 0
189
+ file_info["close_data"] = True
190
+ else:
191
+ if self._fs_interface is None or file_path not in self._fs_interface:
192
+ return WASI_EIO
193
+ file_info["data"] = bytearray(self._fs_interface[file_path])
194
+ file_info["size"] = len(file_info["data"])
195
+
196
+ fd = self._next_fd()
197
+ self._fds[fd] = file_info
198
+ self._set_u32(opened_fd, fd)
199
+ return 0
200
+
201
+ def wasi_path_filestat_get(
202
+ self, parent_fd: int, flags: int, path: int, path_len: int, buf: int
203
+ ) -> int:
204
+ """WASI path_filestat_get syscall."""
205
+ if parent_fd not in self._fds or "path" not in self._fds[parent_fd]:
206
+ return WASI_EBADF
207
+
208
+ name = self._get_string(path, path_len)
209
+ for i in range(64):
210
+ self._set_u8(buf + i, 0)
211
+
212
+ filetype = 3 if name == "." else 4
213
+ self._set_u8(buf + 16, filetype)
214
+ return 0
215
+
216
+ def wasi_fd_prestat_get(self, fd: int, buf: int) -> int:
217
+ """WASI fd_prestat_get syscall."""
218
+ if fd not in self._fds or "path" not in self._fds[fd]:
219
+ return WASI_EBADF
220
+
221
+ mount = self._fds[fd].get("mount", "").encode("utf-8")
222
+ self._set_u8(buf, 0)
223
+ self._set_u32(buf + 4, len(mount))
224
+ return 0
225
+
226
+ def wasi_fd_prestat_dir_name(self, fd: int, path: int, path_len: int) -> int:
227
+ """WASI fd_prestat_dir_name syscall."""
228
+ if fd not in self._fds or "path" not in self._fds[fd]:
229
+ return WASI_EBADF
230
+
231
+ mount = self._fds[fd].get("mount", "").encode("utf-8")
232
+ if path_len != len(mount):
233
+ return WASI_EINVAL
234
+
235
+ self._refresh_memory()
236
+ assert self._memory_array is not None
237
+
238
+ # Bounds check: ensure destination range is within memory
239
+ if path < 0 or path + path_len > len(self._memory_array):
240
+ return WASI_EFAULT
241
+
242
+ for i, b in enumerate(mount):
243
+ self._memory_array[path + i] = b
244
+ return 0
245
+
246
+ def wasi_path_remove_directory(
247
+ self, parent_fd: int, path: int, path_len: int
248
+ ) -> int:
249
+ """WASI path_remove_directory syscall."""
250
+ return WASI_EINVAL
251
+
252
+ def wasi_fd_fdstat_set_flags(self, fd: int, flags: int) -> int:
253
+ """WASI fd_fdstat_set_flags syscall."""
254
+ return WASI_ENOSYS
255
+
256
+ def wasi_fd_seek32(self, fd: int, offset: int, whence: int, newoffset: int) -> int:
257
+ """WASI fd_seek syscall (32-bit variant)."""
258
+ if fd not in self._fds:
259
+ return WASI_EBADF
260
+
261
+ fd_info = self._fds[fd]
262
+ size = fd_info.get("size", 0)
263
+
264
+ if whence == 0: # SEEK_SET
265
+ new_pos = offset
266
+ elif whence == 1: # SEEK_CUR
267
+ new_pos = fd_info.get("position", 0) + offset
268
+ elif whence == 2: # SEEK_END
269
+ new_pos = size + offset
270
+ else:
271
+ return WASI_EINVAL
272
+
273
+ # Validate position is within valid range [0, size]
274
+ if new_pos < 0 or new_pos > size:
275
+ return WASI_EINVAL
276
+
277
+ fd_info["position"] = new_pos
278
+ self._set_u32(newoffset, new_pos)
279
+ return 0
280
+
281
+ def wasi_fd_read(self, fd: int, iovs: int, iovs_len: int, nread: int) -> int:
282
+ """WASI fd_read syscall."""
283
+ if fd not in self._fds:
284
+ return WASI_EBADF
285
+
286
+ fd_info = self._fds[fd]
287
+ total_read = 0
288
+
289
+ for i in range(iovs_len):
290
+ buf = self._get_u32(iovs + 8 * i)
291
+ buf_len = self._get_u32(iovs + 8 * i + 4)
292
+
293
+ pos = fd_info.get("position", 0)
294
+ size = fd_info.get("size", 0)
295
+ data = fd_info.get("data", b"")
296
+
297
+ read_len = min(size - pos, buf_len)
298
+ if read_len > 0:
299
+ self._check_bounds("wasi_fd_read", buf, read_len)
300
+ assert self._memory_array is not None
301
+ for j in range(read_len):
302
+ self._memory_array[buf + j] = data[pos + j]
303
+
304
+ fd_info["position"] = pos + read_len
305
+ total_read += read_len
306
+
307
+ self._set_u32(nread, total_read)
308
+ return 0
309
+
310
+ def wasi_fd_write(self, fd: int, iovs: int, iovs_len: int, nwritten: int) -> int:
311
+ """WASI fd_write syscall."""
312
+ if fd not in self._fds:
313
+ return WASI_EBADF
314
+
315
+ fd_info = self._fds[fd]
316
+ total_written = 0
317
+
318
+ for i in range(iovs_len):
319
+ buf = self._get_u32(iovs + 8 * i)
320
+ buf_len = self._get_u32(iovs + 8 * i + 4)
321
+
322
+ if buf_len > 0:
323
+ self._check_bounds("wasi_fd_write", buf, buf_len)
324
+ assert self._memory_array is not None
325
+ write_data = bytes(self._memory_array[buf : buf + buf_len])
326
+
327
+ if fd_info.get("type") == "output":
328
+ self._output_buffer.extend(write_data)
329
+ else:
330
+ pos = fd_info.get("position", 0)
331
+ data = fd_info.get("data", bytearray())
332
+
333
+ if pos + buf_len > len(data):
334
+ new_len = max(len(data) * 2, pos + buf_len)
335
+ new_data = bytearray(new_len)
336
+ new_data[: len(data)] = data
337
+ fd_info["data"] = new_data
338
+ data = fd_info["data"]
339
+
340
+ data[pos : pos + buf_len] = write_data
341
+ fd_info["position"] = pos + buf_len
342
+ fd_info["size"] = max(fd_info.get("size", 0), pos + buf_len)
343
+
344
+ total_written += buf_len
345
+
346
+ self._set_u32(nwritten, total_written)
347
+ return 0