Skip to content

Commit 9f8e1e8

Browse files
hyperpolymathclaude
andcommitted
feat(stage4): TEA bridge Wasm generator + tea-bridge subcommand
Implements Stage 4a/4b of the AffineScript typed-wasm dogfood plan: the TEA bridge Wasm generator and CLI integration. lib/tea_bridge.ml: - Generates a valid Wasm 1.0 module for the TitleScreen TEA state machine - 7 exported functions with clean i32 ABI: init, update, get_screen_w/h, get_bgm_playing, get_selected, set_screen - TitleModel stored in linear memory at offset 64 (screen_w, screen_h, bgm_playing, selected_tag) - Branchless update: selected_tag = msg + 1 (NewGame=0→1 ... Credits=3→4) - affinescript.ownership custom section: update msg param marked Linear (kind byte 1) for typed-wasm Level 10 verification - affinescript.tea_layout custom section: compact field descriptor bin/main.ml: - tea-bridge subcommand: affinescript tea-bridge [-o out.wasm] - No source file required; generates bridge directly from TEA ABI spec lib/dune: - Add tea_bridge to modules list test/test_e2e.ml: - 6 new E2E TEA Bridge tests: structure, export names, custom sections, Wasm magic/version, update msg Linear annotation, tea_layout present - 95 tests total, all passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7dea3ab commit 9f8e1e8

4 files changed

Lines changed: 487 additions & 1 deletion

File tree

bin/main.ml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,27 @@ let eval_file face json path =
333333
`Error (false, "Parse error")
334334
end
335335

336+
(** Generate the AffineScript TEA bridge Wasm module.
337+
338+
Emits a validated Wasm 1.0 binary that implements the TitleScreen
339+
TEA state machine with clean i32 exports for AffineTEA.js.
340+
341+
No source file is needed — the module is generated directly from the
342+
TEA ABI specification. Write to a .wasm file, copy to IDApTIK's
343+
public/assets/wasm/ directory, and load via AffineTEA.load(). *)
344+
let tea_bridge_cmd_fn output =
345+
let m = Affinescript.Tea_bridge.generate () in
346+
Affinescript.Wasm_encode.write_module_to_file output m;
347+
Format.printf "TEA bridge written to %s@." output;
348+
Format.printf " affinescript_init() — initialise TitleModel@.";
349+
Format.printf " affinescript_update(msg: i32) — 0=NewGame 1=LoadGame 2=Settings 3=Credits@.";
350+
Format.printf " affinescript_get_selected() -> i32 — 0=none 1=new_game 2=load_game 3=settings 4=credits@.";
351+
Format.printf " affinescript_get_screen_w/h() -> i32 — current screen dimensions@.";
352+
Format.printf " affinescript_set_screen(w: i32, h: i32) — handle resize events@.";
353+
Format.printf " memory — exported linear memory (model at offset 64)@.";
354+
Format.printf "Custom sections: affinescript.ownership, affinescript.tea_layout@.";
355+
`Ok ()
356+
336357
(** Start the REPL *)
337358
let repl_cmd_fn () =
338359
(* TODO: Re-enable when REPL module is restored *)
@@ -626,6 +647,11 @@ let eval_cmd =
626647
let info = Cmd.info "eval" ~doc in
627648
Cmd.v info Term.(ret (const eval_file $ face_arg $ json_arg $ path_arg))
628649

650+
let tea_bridge_cmd =
651+
let doc = "Generate the AffineScript TEA bridge Wasm module for IDApTIK" in
652+
let info = Cmd.info "tea-bridge" ~doc in
653+
Cmd.v info Term.(ret (const tea_bridge_cmd_fn $ output_arg))
654+
629655
let repl_cmd =
630656
let doc = "Start the interactive REPL" in
631657
let info = Cmd.info "repl" ~doc in
@@ -773,6 +799,7 @@ let default_cmd =
773799
Cmd.group info ~default [
774800
lex_cmd; parse_cmd; check_cmd; eval_cmd; repl_cmd; compile_cmd;
775801
fmt_cmd; lint_cmd;
802+
tea_bridge_cmd;
776803
hover_cmd; goto_def_cmd;
777804
preview_python_cmd; preview_js_cmd; preview_pseudocode_cmd
778805
]

lib/dune

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
(name affinescript)
33
(public_name affinescript)
44
(modes byte native)
5-
(modules ast borrow codegen codegen_gc desugar_traits effect error error_collector error_formatter face formatter interp js_face julia_codegen json_output lexer linter module_loader opt parse_driver parse parser parser_errors pseudocode_face python_face quantity resolve span symbol token trait typecheck types unify value wasm wasm_encode wasm_gc wasm_gc_encode wasi_runtime)
5+
(modules ast borrow codegen codegen_gc desugar_traits effect error error_collector error_formatter face formatter interp js_face julia_codegen json_output lexer linter module_loader opt parse_driver parse parser parser_errors pseudocode_face python_face quantity resolve span symbol tea_bridge token trait typecheck types unify value wasm wasm_encode wasm_gc wasm_gc_encode wasi_runtime)
66
(libraries str unix sedlex fmt menhirLib yojson)
77
(preprocess
88
(pps ppx_deriving.show ppx_deriving.eq ppx_deriving.ord sedlex.ppx)))

lib/tea_bridge.ml

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
(* SPDX-License-Identifier: PMPL-1.0-or-later *)
2+
(* SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell (hyperpolymath) *)
3+
4+
(** TEA Bridge Wasm Generator.
5+
6+
Generates a valid WebAssembly 1.0 module that implements the
7+
AffineScript TEA runtime ABI for the TitleScreen.
8+
9+
The bridge module stores TitleModel in linear memory at a fixed
10+
layout, and exports clean i32 functions that JS can call to drive
11+
a PixiJS scene without needing a full AffineScript → Wasm compiler.
12+
13+
{2 Memory layout}
14+
15+
Model state is stored starting at byte offset 64 (after the WASM
16+
binary header region) with the following field layout:
17+
18+
{v
19+
Offset Type Field Default
20+
+0 i32 screen_w 1280
21+
+4 i32 screen_h 720
22+
+8 i32 bgm_playing 0
23+
+12 i32 selected_tag 0
24+
v}
25+
26+
{2 selected_tag encoding}
27+
28+
{v
29+
0 = none
30+
1 = new_game
31+
2 = load_game
32+
3 = settings
33+
4 = credits
34+
v}
35+
36+
{2 Msg tag encoding (input to affinescript_update)}
37+
38+
{v
39+
0 = NewGame
40+
1 = LoadGame
41+
2 = Settings
42+
3 = Credits
43+
v}
44+
45+
The update function is branchless: [selected_tag := msg + 1],
46+
which naturally maps {NewGame=0 → 1, …, Credits=3 → 4}.
47+
48+
{2 Exported functions}
49+
50+
{ul
51+
{li [affinescript_init()]}
52+
{li [affinescript_update(msg: i32)]}
53+
{li [affinescript_get_screen_w() -> i32]}
54+
{li [affinescript_get_screen_h() -> i32]}
55+
{li [affinescript_get_bgm_playing() -> i32]}
56+
{li [affinescript_get_selected() -> i32]}
57+
{li [affinescript_set_screen(w: i32, h: i32)]}
58+
{li [memory]}
59+
}
60+
61+
{2 Ownership annotations}
62+
63+
The [affinescript.ownership] custom section marks [update]'s [msg]
64+
parameter as Linear (kind byte 1) — consumed exactly once per TEA
65+
update cycle — encoding the AffineScript linearity invariant for
66+
typed-wasm Level 10 verification.
67+
68+
A companion [affinescript.tea_layout] custom section encodes the
69+
model field layout for tooling.
70+
*)
71+
72+
open Wasm
73+
74+
(** Base address of the TitleModel in linear memory. *)
75+
let model_base = 64
76+
77+
(** Field offsets relative to [model_base]. *)
78+
let off_screen_w = 0
79+
let off_screen_h = 4
80+
let off_bgm_playing = 8
81+
let off_selected = 12
82+
83+
(** [load_field off] — Wasm instructions that load an i32 from
84+
[(model_base + off)], leaving the value on the stack. *)
85+
let load_field off : instr list = [
86+
I32Const (Int32.of_int (model_base + off));
87+
I32Load (2, 0);
88+
]
89+
90+
(** [store_const off v] — Wasm instructions that store constant [v]
91+
to [(model_base + off)]. *)
92+
let store_const off v : instr list = [
93+
I32Const (Int32.of_int (model_base + off));
94+
I32Const (Int32.of_int v);
95+
I32Store (2, 0);
96+
]
97+
98+
(* -------------------------------------------------------------------------
99+
Type section
100+
-------------------------------------------------------------------------
101+
Index Signature Used by
102+
0 () -> () fn_init
103+
1 (i32) -> () fn_update
104+
2 () -> i32 fn_get_screen_w/_h/_bgm/_selected
105+
3 (i32, i32) -> () fn_set_screen
106+
------------------------------------------------------------------------- *)
107+
108+
let types : func_type list = [
109+
{ ft_params = []; ft_results = [] };
110+
{ ft_params = [I32]; ft_results = [] };
111+
{ ft_params = []; ft_results = [I32] };
112+
{ ft_params = [I32; I32]; ft_results = [] };
113+
]
114+
115+
(* -------------------------------------------------------------------------
116+
Function bodies
117+
------------------------------------------------------------------------- *)
118+
119+
(** fn 0: affinescript_init() — write default TitleModel to memory. *)
120+
let fn_init : func = {
121+
f_type = 0;
122+
f_locals = [];
123+
f_body =
124+
store_const off_screen_w 1280 @
125+
store_const off_screen_h 720 @
126+
store_const off_bgm_playing 0 @
127+
store_const off_selected 0;
128+
}
129+
130+
(** fn 1: affinescript_update(msg: i32) — branchless update:
131+
[selected_tag := msg + 1]. msg is Linear (consumed exactly once). *)
132+
let fn_update : func = {
133+
f_type = 1;
134+
f_locals = [];
135+
f_body = [
136+
(* address of selected_tag *)
137+
I32Const (Int32.of_int (model_base + off_selected));
138+
(* compute msg + 1: maps NewGame=0→1, LoadGame=1→2, Settings=2→3, Credits=3→4 *)
139+
LocalGet 0;
140+
I32Const 1l;
141+
I32Add;
142+
I32Store (2, 0);
143+
];
144+
}
145+
146+
(** fn 2: affinescript_get_screen_w() -> i32 *)
147+
let fn_get_screen_w : func = {
148+
f_type = 2;
149+
f_locals = [];
150+
f_body = load_field off_screen_w;
151+
}
152+
153+
(** fn 3: affinescript_get_screen_h() -> i32 *)
154+
let fn_get_screen_h : func = {
155+
f_type = 2;
156+
f_locals = [];
157+
f_body = load_field off_screen_h;
158+
}
159+
160+
(** fn 4: affinescript_get_bgm_playing() -> i32 *)
161+
let fn_get_bgm_playing : func = {
162+
f_type = 2;
163+
f_locals = [];
164+
f_body = load_field off_bgm_playing;
165+
}
166+
167+
(** fn 5: affinescript_get_selected() -> i32 *)
168+
let fn_get_selected : func = {
169+
f_type = 2;
170+
f_locals = [];
171+
f_body = load_field off_selected;
172+
}
173+
174+
(** fn 6: affinescript_set_screen(w: i32, h: i32) — store new dimensions.
175+
Handles PixiJS resize events by updating the model. *)
176+
let fn_set_screen : func = {
177+
f_type = 3;
178+
f_locals = [];
179+
f_body = [
180+
I32Const (Int32.of_int (model_base + off_screen_w));
181+
LocalGet 0;
182+
I32Store (2, 0);
183+
I32Const (Int32.of_int (model_base + off_screen_h));
184+
LocalGet 1;
185+
I32Store (2, 0);
186+
];
187+
}
188+
189+
(* -------------------------------------------------------------------------
190+
Custom sections
191+
------------------------------------------------------------------------- *)
192+
193+
(** Build the [affinescript.ownership] custom section payload.
194+
195+
Encoding (all little-endian):
196+
{v
197+
u32 entry_count
198+
per entry:
199+
u32 func_index
200+
u8 param_count
201+
u8* param_kind (0=Unrestricted 1=Linear 2=SharedBorrow 3=ExclBorrow)
202+
u8 return_kind
203+
v}
204+
205+
Only [fn_update] (index 1) carries a Linear param for msg. *)
206+
let build_ownership_section () : bytes =
207+
let buf = Buffer.create 64 in
208+
let u32 n =
209+
Buffer.add_char buf (Char.chr (n land 0xff));
210+
Buffer.add_char buf (Char.chr ((n lsr 8) land 0xff));
211+
Buffer.add_char buf (Char.chr ((n lsr 16) land 0xff));
212+
Buffer.add_char buf (Char.chr ((n lsr 24) land 0xff))
213+
in
214+
let u8 n = Buffer.add_char buf (Char.chr (n land 0xff)) in
215+
u32 7; (* 7 annotated functions *)
216+
(* fn 0 init: () → (), no params, Unrestricted return *)
217+
u32 0; u8 0; u8 0;
218+
(* fn 1 update: (msg: Linear) → (), return Unrestricted *)
219+
u32 1; u8 1; u8 1 (* Linear=1 *); u8 0;
220+
(* fn 2-5 getters: () → i32, Unrestricted *)
221+
u32 2; u8 0; u8 0;
222+
u32 3; u8 0; u8 0;
223+
u32 4; u8 0; u8 0;
224+
u32 5; u8 0; u8 0;
225+
(* fn 6 set_screen: (i32, i32) → (), both Unrestricted *)
226+
u32 6; u8 2; u8 0; u8 0; u8 0;
227+
Buffer.to_bytes buf
228+
229+
(** Build the [affinescript.tea_layout] custom section.
230+
231+
Compact binary descriptor for the TitleModel memory layout:
232+
{v
233+
u8 version = 1
234+
u8 base_addr = 64
235+
u8 field_count = 4
236+
per field: u8 name_len, name_bytes, u8 offset, u8 type_tag (0x49=i32)
237+
v} *)
238+
let build_tea_layout_section () : bytes =
239+
let buf = Buffer.create 64 in
240+
let u8 n = Buffer.add_char buf (Char.chr (n land 0xff)) in
241+
let field name off =
242+
u8 (String.length name);
243+
Buffer.add_string buf name;
244+
u8 off;
245+
u8 0x49 (* i32 type tag *)
246+
in
247+
u8 1; (* version 1 *)
248+
u8 model_base; (* base = 64 *)
249+
u8 4; (* 4 fields *)
250+
field "screen_w" off_screen_w;
251+
field "screen_h" off_screen_h;
252+
field "bgm_playing" off_bgm_playing;
253+
field "selected" off_selected;
254+
Buffer.to_bytes buf
255+
256+
(* -------------------------------------------------------------------------
257+
Module assembly
258+
------------------------------------------------------------------------- *)
259+
260+
(** Generate the complete TEA bridge Wasm module for the TitleScreen.
261+
262+
The resulting module is suitable for use with AffineTEA.js in IDApTIK.
263+
Write it with [Wasm_encode.write_module_to_file]. *)
264+
let generate () : wasm_module = {
265+
types;
266+
funcs = [
267+
fn_init;
268+
fn_update;
269+
fn_get_screen_w;
270+
fn_get_screen_h;
271+
fn_get_bgm_playing;
272+
fn_get_selected;
273+
fn_set_screen;
274+
];
275+
tables = [];
276+
mems = [{ mem_type = { lim_min = 1; lim_max = None } }];
277+
globals = [];
278+
imports = [];
279+
elems = [];
280+
datas = [];
281+
start = None;
282+
exports = [
283+
{ e_name = "affinescript_init"; e_desc = ExportFunc 0 };
284+
{ e_name = "affinescript_update"; e_desc = ExportFunc 1 };
285+
{ e_name = "affinescript_get_screen_w"; e_desc = ExportFunc 2 };
286+
{ e_name = "affinescript_get_screen_h"; e_desc = ExportFunc 3 };
287+
{ e_name = "affinescript_get_bgm_playing"; e_desc = ExportFunc 4 };
288+
{ e_name = "affinescript_get_selected"; e_desc = ExportFunc 5 };
289+
{ e_name = "affinescript_set_screen"; e_desc = ExportFunc 6 };
290+
{ e_name = "memory"; e_desc = ExportMemory 0 };
291+
];
292+
custom_sections = [
293+
("affinescript.ownership", build_ownership_section ());
294+
("affinescript.tea_layout", build_tea_layout_section ());
295+
];
296+
}

0 commit comments

Comments
 (0)